Compare commits
123
Commits
deesp-ui
...
master-5-24
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57e4892140 | ||
|
|
95fff1262b | ||
|
|
0dd9cdcba9 | ||
|
|
5b233fb0ab | ||
|
|
3325be0622 | ||
|
|
9af5c5be63 | ||
|
|
0e46bf8e0d | ||
|
|
b93313d935 | ||
|
|
c1dfb01f53 | ||
|
|
3f999b312d | ||
|
|
50abe5dece | ||
|
|
58c224a081 | ||
|
|
f718df833e | ||
|
|
f068acf390 | ||
|
|
769d912eef | ||
|
|
5aa0cda252 | ||
|
|
552da3aec2 | ||
|
|
ed5285b58a | ||
|
|
0d35d1b6c3 | ||
|
|
27c2ada4ec | ||
|
|
6be5b4c45d | ||
|
|
fbf3d47b2a | ||
|
|
82c4d7e0a2 | ||
|
|
867a6a97cf | ||
|
|
a3ee72b34d | ||
|
|
a08031b901 | ||
|
|
665481a6d8 | ||
|
|
30b344b776 | ||
|
|
e104249707 | ||
|
|
9b6d9ce007 | ||
|
|
69da8066a6 | ||
|
|
edc9993403 | ||
|
|
8f14f3190e | ||
|
|
71a3c63182 | ||
|
|
d10678763f | ||
|
|
2beace89f4 | ||
|
|
e5d0d7db93 | ||
|
|
35a42078ce | ||
|
|
e8e28d19fb | ||
|
|
163e40c73f | ||
|
|
d46cfff079 | ||
|
|
275a7a550d | ||
|
|
5430fe0417 | ||
|
|
f314e2e271 | ||
|
|
a4fb55d164 | ||
|
|
295c8f0623 | ||
|
|
4b749a1ee0 | ||
|
|
679e61057b | ||
|
|
7f7b8f0a64 | ||
|
|
7e94c5dfd7 | ||
|
|
003810114b | ||
|
|
37c5849103 | ||
|
|
55521b8e11 | ||
|
|
e4f181e4fe | ||
|
|
bba989c0af | ||
|
|
d5e0ba04b7 | ||
|
|
22ec53c070 | ||
|
|
183b6a1acf | ||
|
|
49c8c95966 | ||
|
|
c7c264497e | ||
|
|
436f69c272 | ||
|
|
dc72a8ccaf | ||
|
|
499c969233 | ||
|
|
3b44300216 | ||
|
|
8bb7b5d30a | ||
|
|
4c3becf33e | ||
|
|
a42fc6453a | ||
|
|
dad06304f1 | ||
|
|
03b80310f6 | ||
|
|
8d015fa962 | ||
|
|
f639ef2fbb | ||
|
|
ea8e6d7c1b | ||
|
|
ba1c067dce | ||
|
|
5e96fda88f | ||
|
|
076cd0fcf9 | ||
|
|
44e97861d2 | ||
|
|
93e6d02ce4 | ||
|
|
60cb2b80cd | ||
|
|
752eddb0ba | ||
|
|
61d14d1b45 | ||
|
|
a83c583a28 | ||
|
|
485ba7a380 | ||
|
|
1005859404 | ||
|
|
ea6c73c063 | ||
|
|
3e4039efb0 | ||
|
|
c9ad4ae2ed | ||
|
|
abf8026d45 | ||
|
|
26b442b5da | ||
|
|
1cb00e9fbe | ||
|
|
9208e7eaaa | ||
|
|
a1392f9491 | ||
|
|
2ea0e1d54b | ||
|
|
d96fa6f4a1 | ||
|
|
0fb51e12d1 | ||
|
|
4f9494db0b | ||
|
|
4e87b080a0 | ||
|
|
2f3038aba1 | ||
|
|
131fb7d9c7 | ||
|
|
762a724204 | ||
|
|
8090e0aab3 | ||
|
|
9df6330d41 | ||
|
|
fd63e23eaf | ||
|
|
c1143f4629 | ||
|
|
149fdffd49 | ||
|
|
439d5a3ec9 | ||
|
|
9e24d49210 | ||
|
|
90ac43fc82 | ||
|
|
8b6b709987 | ||
|
|
fb6ca9ce85 | ||
|
|
08290b329e | ||
|
|
1d66f84bd3 | ||
|
|
a2d9c1a03f | ||
|
|
d79db88349 | ||
|
|
9f5fb650af | ||
|
|
15b4339c90 | ||
|
|
5ce331394d | ||
|
|
800c6af7f1 | ||
|
|
876ef0f8a0 | ||
|
|
fdcfa30810 | ||
|
|
4caa98af3a | ||
|
|
bb87e4fb4c | ||
|
|
039105580c | ||
|
|
f47f431bdf |
@@ -33,3 +33,4 @@ bin-release/
|
|||||||
/server/.spool
|
/server/.spool
|
||||||
/server/.claude
|
/server/.claude
|
||||||
/.spool
|
/.spool
|
||||||
|
TUICallKit-Vue3/.env
|
||||||
|
|||||||
@@ -15,6 +15,19 @@ const globalData = {
|
|||||||
let avatarUrl = ref(""); // 声明为响应式变量
|
let avatarUrl = ref(""); // 声明为响应式变量
|
||||||
uni.CallManager = new CallManager();
|
uni.CallManager = new CallManager();
|
||||||
onLaunch(() => {
|
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({
|
uni.login({
|
||||||
provider: 'weixin',
|
provider: 'weixin',
|
||||||
success: function (loginRes) {
|
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'
|
import App from './App'
|
||||||
var baseUrl ='https://admin.zhenyangtang.com.cn/';
|
var baseUrl ='https://css.zhenyangtang.com.cn/';
|
||||||
// #ifndef VUE3
|
// #ifndef VUE3
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
import './uni.promisify.adaptor'
|
import './uni.promisify.adaptor'
|
||||||
|
|||||||
@@ -57,7 +57,14 @@
|
|||||||
"optimization" : {
|
"optimization" : {
|
||||||
"subPackages" : true
|
"subPackages" : true
|
||||||
},
|
},
|
||||||
"usingComponents" : true
|
"usingComponents" : true,
|
||||||
|
|
||||||
|
"plugins" : {
|
||||||
|
"WechatSI" : {
|
||||||
|
"version" : "0.3.5",
|
||||||
|
"provider" : "wx069ba97219f66d99"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"mp-alipay" : {
|
"mp-alipay" : {
|
||||||
"usingComponents" : true
|
"usingComponents" : true
|
||||||
|
|||||||
@@ -105,6 +105,42 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": "#0ea5a4",
|
||||||
|
"navigationBarTextStyle": "white",
|
||||||
|
"backgroundColor": "#f1f5f9",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tabBar": {
|
"tabBar": {
|
||||||
|
|||||||
@@ -53,6 +53,18 @@
|
|||||||
<text class="time-value">{{ card.create_time || '-' }}</text>
|
<text class="time-value">{{ card.create_time || '-' }}</text>
|
||||||
</view>
|
</view>
|
||||||
</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>
|
</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 创建后自动生成,仅需登录)
|
// 新建就诊卡(patient_id 创建后自动生成,仅需登录)
|
||||||
const createCard = () => {
|
const createCard = () => {
|
||||||
const token = uni.getStorageSync('token')
|
const token = uni.getStorageSync('token')
|
||||||
@@ -310,6 +332,54 @@ const formatDate = (timestamp) => {
|
|||||||
border-top: 4rpx solid #e8eaed;
|
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 {
|
.time-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -88,13 +88,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- DEV 开发悬浮入口(生产环境自动隐藏) -->
|
||||||
|
<dev-training-entry />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { onShow, onShareAppMessage } from '@dcloudio/uni-app'
|
import { onShow, onShareAppMessage } from '@dcloudio/uni-app'
|
||||||
|
import DevTrainingEntry from '@/components/dev-training-entry/index.vue'
|
||||||
export default {
|
export default {
|
||||||
name: 'profileB',
|
name: 'profileB',
|
||||||
|
components: { DevTrainingEntry },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
userInfo: {
|
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)
|
||||||
|
})
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,219 @@
|
|||||||
|
import { ref, computed, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
export interface MetronomeOptions {
|
||||||
|
initialBpm?: number
|
||||||
|
bpmMin?: number
|
||||||
|
bpmMax?: number
|
||||||
|
clickSrc?: string
|
||||||
|
accentSrc?: string
|
||||||
|
accentEvery?: number
|
||||||
|
poolSize?: number
|
||||||
|
onBeat?: (beatIndex: number, isAccent: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 音频实例池:每个 click 用一个独立 InnerAudioContext,循环复用。
|
||||||
|
* 解决两个问题:
|
||||||
|
* 1. seek+play 模式在某些设备上首拍冷启动延迟(100-300ms)
|
||||||
|
* 2. BPM 偏快时上一拍音频还没播完,下一拍 play 会被阻塞或丢失
|
||||||
|
*/
|
||||||
|
class AudioPool {
|
||||||
|
private list: UniApp.InnerAudioContext[] = []
|
||||||
|
private cursor = 0
|
||||||
|
private warmedUp = false
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private src: string,
|
||||||
|
private size: number,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private create() {
|
||||||
|
for (let i = 0; i < this.size; i++) {
|
||||||
|
const ctx = uni.createInnerAudioContext()
|
||||||
|
ctx.src = this.src
|
||||||
|
ctx.obeyMuteSwitch = false
|
||||||
|
ctx.autoplay = false
|
||||||
|
this.list.push(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预热:极小音量短暂播放,让音频文件下载/解码到内存
|
||||||
|
* 真正首次 play 不会再卡冷启动
|
||||||
|
* 注意:iOS 上 volume = 0 不一定真正解码,所以用 0.01 极小音量
|
||||||
|
*/
|
||||||
|
warmUp() {
|
||||||
|
if (this.warmedUp) return
|
||||||
|
if (this.list.length === 0) this.create()
|
||||||
|
|
||||||
|
this.list.forEach((ctx) => {
|
||||||
|
try {
|
||||||
|
ctx.volume = 0.01
|
||||||
|
ctx.play()
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
ctx.stop()
|
||||||
|
ctx.volume = 1
|
||||||
|
} catch (_) {}
|
||||||
|
}, 60)
|
||||||
|
} catch (_) {}
|
||||||
|
})
|
||||||
|
this.warmedUp = true
|
||||||
|
}
|
||||||
|
|
||||||
|
play() {
|
||||||
|
if (this.list.length === 0) {
|
||||||
|
this.create()
|
||||||
|
this.warmUp()
|
||||||
|
}
|
||||||
|
const ctx = this.list[this.cursor]
|
||||||
|
this.cursor = (this.cursor + 1) % this.list.length
|
||||||
|
try {
|
||||||
|
// iOS 上 seek(0) + play() 不一定能从头播放;stop() + play() 更稳
|
||||||
|
ctx.stop()
|
||||||
|
ctx.play()
|
||||||
|
} catch (_) {
|
||||||
|
try { ctx.play() } catch (__) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.list.forEach((ctx) => {
|
||||||
|
try {
|
||||||
|
ctx.destroy?.()
|
||||||
|
} catch (_) {}
|
||||||
|
})
|
||||||
|
this.list = []
|
||||||
|
this.warmedUp = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* InnerAudio 兼容本地路径和网络 URL,这里默认走 CDN,跟 BgAudio 保持一致便于维护
|
||||||
|
首次播放会下载 ~3KB,实测 100~300ms 完成,然后小程序会缓存,后续触发零延迟 */
|
||||||
|
const DEFAULT_CLICK_SRC =
|
||||||
|
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/20260526105128557ef8669.mp3'
|
||||||
|
|
||||||
|
export function useMetronome(options: MetronomeOptions = {}) {
|
||||||
|
const {
|
||||||
|
initialBpm = 80,
|
||||||
|
bpmMin = 40,
|
||||||
|
bpmMax = 240,
|
||||||
|
clickSrc = DEFAULT_CLICK_SRC,
|
||||||
|
accentSrc,
|
||||||
|
poolSize = 4,
|
||||||
|
onBeat,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const bpm = ref<number>(initialBpm)
|
||||||
|
const isPlaying = ref<boolean>(false)
|
||||||
|
const beatIndex = ref<number>(0)
|
||||||
|
const isAccent = ref<boolean>(false)
|
||||||
|
const accentEveryRef = ref<number>(options.accentEvery ?? 4)
|
||||||
|
|
||||||
|
const intervalMs = computed(() => 60000 / bpm.value)
|
||||||
|
|
||||||
|
let clickPool: AudioPool | null = null
|
||||||
|
let accentPool: AudioPool | null = null
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let nextTickAt = 0
|
||||||
|
|
||||||
|
const ensureAudio = () => {
|
||||||
|
if (!clickPool) {
|
||||||
|
clickPool = new AudioPool(clickSrc, poolSize)
|
||||||
|
clickPool.warmUp()
|
||||||
|
}
|
||||||
|
if (accentSrc && !accentPool) {
|
||||||
|
accentPool = new AudioPool(accentSrc, Math.max(2, Math.ceil(poolSize / 2)))
|
||||||
|
accentPool.warmUp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const playSound = (accent: boolean) => {
|
||||||
|
if (accent && accentPool) {
|
||||||
|
accentPool.play()
|
||||||
|
} else if (clickPool) {
|
||||||
|
clickPool.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (!isPlaying.value) return
|
||||||
|
|
||||||
|
const every = Math.max(1, accentEveryRef.value)
|
||||||
|
const accent = beatIndex.value % every === 0
|
||||||
|
isAccent.value = accent
|
||||||
|
|
||||||
|
playSound(accent)
|
||||||
|
onBeat?.(beatIndex.value, accent)
|
||||||
|
beatIndex.value++
|
||||||
|
|
||||||
|
nextTickAt += intervalMs.value
|
||||||
|
const nextDelay = Math.max(0, nextTickAt - Date.now())
|
||||||
|
|
||||||
|
timer = setTimeout(tick, nextDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
if (isPlaying.value) return
|
||||||
|
ensureAudio()
|
||||||
|
isPlaying.value = true
|
||||||
|
beatIndex.value = 0
|
||||||
|
nextTickAt = Date.now()
|
||||||
|
tick()
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
isPlaying.value = false
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setBpm = (val: number) => {
|
||||||
|
bpm.value = Math.max(bpmMin, Math.min(bpmMax, Math.round(val)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAccentEvery = (n: number) => {
|
||||||
|
accentEveryRef.value = Math.max(1, Math.min(16, Math.round(n)))
|
||||||
|
beatIndex.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主动预加载(推荐在页面 onShow 时调用,让用户进页面就完成预热)
|
||||||
|
* 同时再次设置 setInnerAudioOption,防 App.vue 全局设置失效(iOS 静音键)
|
||||||
|
*/
|
||||||
|
const preload = () => {
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
try {
|
||||||
|
uni.setInnerAudioOption({
|
||||||
|
obeyMuteSwitch: false,
|
||||||
|
mixWithOther: true,
|
||||||
|
})
|
||||||
|
} catch (_) {}
|
||||||
|
// #endif
|
||||||
|
ensureAudio()
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stop()
|
||||||
|
clickPool?.destroy()
|
||||||
|
accentPool?.destroy()
|
||||||
|
clickPool = null
|
||||||
|
accentPool = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
bpm,
|
||||||
|
isPlaying,
|
||||||
|
beatIndex,
|
||||||
|
isAccent,
|
||||||
|
intervalMs,
|
||||||
|
accentEvery: accentEveryRef,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
setBpm,
|
||||||
|
setAccentEvery,
|
||||||
|
preload,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { ref, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节拍器后台播放专版 - 基于 BackgroundAudioManager
|
||||||
|
*
|
||||||
|
* 解决 InnerAudioContext 在 iOS 微信小程序中无法后台播放的硬限制:
|
||||||
|
* - iOS 真机切到后台/锁屏 InnerAudioContext 必被挂起,requiredBackgroundModes 无效
|
||||||
|
* - BackgroundAudioManager 是微信唯一支持 iOS 真后台/锁屏播放的音频 API
|
||||||
|
*
|
||||||
|
* 取舍:
|
||||||
|
* - BgAudio 是全局单例,一次只能播一个音频,不适合短促 click 高频重复
|
||||||
|
* - 因此提前用 ffmpeg 合成 3 个档位的"完整节拍循环"音轨(80/110/130 BPM × 2 拍)
|
||||||
|
* 每个 mp3 是 5~6 秒无缝循环,设 onEnded 重赋 src 实现永久循环
|
||||||
|
*
|
||||||
|
* 副作用:
|
||||||
|
* - 播放时锁屏/通知栏会显示带封面+暂停按钮的音乐控制条(可被用户从锁屏暂停)
|
||||||
|
* - 必须声明 requiredBackgroundModes:["audio"](已配在 manifest+pages)
|
||||||
|
* - 切档位有 200~500ms 切换延迟,但用户主动操作时可接受
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type LoopId = 'slow' | 'normal' | 'brisk'
|
||||||
|
|
||||||
|
export interface LoopPreset {
|
||||||
|
id: LoopId
|
||||||
|
bpm: number
|
||||||
|
accent: number
|
||||||
|
src: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重要:微信 BackgroundAudioManager.src 只接受 http/https 网络流,不能是包内资源
|
||||||
|
* 所以必须把音频上传到 CDN(腾讯云 COS),并在小程序后台加 downloadFile 合法域名
|
||||||
|
*
|
||||||
|
* 当前线上文件(2026-05-26 上传到 cos.ap-guangzhou):
|
||||||
|
* - loop_80bpm_2.mp3 循环音轨 慢走档
|
||||||
|
* - loop_110bpm_2.mp3 循环音轨 健走档
|
||||||
|
* - loop_130bpm_2.mp3 循环音轨 快走档
|
||||||
|
*
|
||||||
|
* 历史源文件(本地 training/static/audio/*.mp3) 已删除以减小包体积,
|
||||||
|
* 如果以后要重新合成,先从 COS 下回来再用 ffmpeg 处理
|
||||||
|
*/
|
||||||
|
export const LOOP_PRESETS: readonly LoopPreset[] = [
|
||||||
|
{
|
||||||
|
id: 'slow',
|
||||||
|
bpm: 80,
|
||||||
|
accent: 2,
|
||||||
|
src: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/20260526105129dcbc50112.mp3',
|
||||||
|
label: '慢走 80 BPM',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'normal',
|
||||||
|
bpm: 110,
|
||||||
|
accent: 2,
|
||||||
|
src: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051284f5b04928.mp3',
|
||||||
|
label: '健走 110 BPM',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'brisk',
|
||||||
|
bpm: 130,
|
||||||
|
accent: 2,
|
||||||
|
src: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/20260526105128164d77281.mp3',
|
||||||
|
label: '快走 130 BPM',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 锁屏控制条封面
|
||||||
|
* 暂时留空,微信会显示默认音乐图标,等以后有正式品牌图再上传到 COS 后填入这里
|
||||||
|
* 注意:URL 必须是 https,且域名要加进小程序后台 downloadFile 合法域名
|
||||||
|
*/
|
||||||
|
const COVER_URL = ''
|
||||||
|
|
||||||
|
export function useMetronomeBg() {
|
||||||
|
const isPlaying = ref<boolean>(false)
|
||||||
|
const currentLoop = ref<LoopId | null>(null)
|
||||||
|
|
||||||
|
/* @dcloudio/types 没有 BackgroundAudioManager 类型,直接 any */
|
||||||
|
let bgm: any = null
|
||||||
|
/* 当前期望的 src,onEnded 时用它重新赋值实现循环 */
|
||||||
|
let desiredSrc = ''
|
||||||
|
|
||||||
|
/* 懒初始化 BackgroundAudioManager + 绑定事件
|
||||||
|
BgAudio 是全局单例,跨页面共享,只能在第一次需要时初始化 */
|
||||||
|
const ensureBgm = () => {
|
||||||
|
if (bgm) return bgm
|
||||||
|
|
||||||
|
const m = uni.getBackgroundAudioManager()
|
||||||
|
|
||||||
|
/* 必填 metadata,缺一会报错或不显示锁屏控制条 */
|
||||||
|
m.title = '节拍器'
|
||||||
|
m.epname = '甄养堂 · 健走'
|
||||||
|
m.singer = '健走配速'
|
||||||
|
if (COVER_URL) m.coverImgUrl = COVER_URL
|
||||||
|
m.webUrl = ''
|
||||||
|
|
||||||
|
m.onPlay(() => {
|
||||||
|
isPlaying.value = true
|
||||||
|
})
|
||||||
|
m.onPause(() => {
|
||||||
|
/* 用户从锁屏控制条点暂停,同步 UI 状态 */
|
||||||
|
isPlaying.value = false
|
||||||
|
})
|
||||||
|
m.onStop(() => {
|
||||||
|
isPlaying.value = false
|
||||||
|
currentLoop.value = null
|
||||||
|
})
|
||||||
|
|
||||||
|
/* 实现无限循环:每段 mp3 播完时立即重赋 src 再次播放
|
||||||
|
BgAudio 没有原生 loop 属性,只能用这招 */
|
||||||
|
m.onEnded(() => {
|
||||||
|
if (desiredSrc && isPlaying.value) {
|
||||||
|
try {
|
||||||
|
m.src = desiredSrc
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
m.onError((err) => {
|
||||||
|
console.error('[BgAudio] error:', err)
|
||||||
|
isPlaying.value = false
|
||||||
|
uni.showToast({
|
||||||
|
title: '音频播放失败,请重试',
|
||||||
|
icon: 'none',
|
||||||
|
duration: 2000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
bgm = m
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切到指定档位并开始播放
|
||||||
|
* 如果已经在播同一档位 → 切到 pause/play 状态
|
||||||
|
* 如果在播别的档位 → 切换 src(有 200~500ms 延迟)
|
||||||
|
*/
|
||||||
|
const playLoop = (id: LoopId) => {
|
||||||
|
const preset = LOOP_PRESETS.find((p) => p.id === id)
|
||||||
|
if (!preset) return
|
||||||
|
|
||||||
|
const m = ensureBgm()
|
||||||
|
|
||||||
|
/* 同档位再点一下 = 暂停 */
|
||||||
|
if (currentLoop.value === id && isPlaying.value) {
|
||||||
|
m.pause()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 切换档位或从暂停恢复 */
|
||||||
|
currentLoop.value = id
|
||||||
|
desiredSrc = preset.src
|
||||||
|
|
||||||
|
/* 重设 title 让锁屏控制条显示当前档位 */
|
||||||
|
m.title = `节拍器 · ${preset.label}`
|
||||||
|
|
||||||
|
/* 赋值 src 会自动播放(微信 API 设计如此) */
|
||||||
|
m.src = preset.src
|
||||||
|
/* isPlaying 由 onPlay 回调置 true */
|
||||||
|
}
|
||||||
|
|
||||||
|
const pause = () => {
|
||||||
|
if (bgm && isPlaying.value) {
|
||||||
|
bgm.pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resume = () => {
|
||||||
|
if (bgm && !isPlaying.value && desiredSrc) {
|
||||||
|
/* 从暂停态恢复:直接 play 即可 */
|
||||||
|
try {
|
||||||
|
bgm.play()
|
||||||
|
} catch (_) {
|
||||||
|
/* 部分基础库 play() 不可用时,重赋 src */
|
||||||
|
bgm.src = desiredSrc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完全停止 + 清掉锁屏控制条
|
||||||
|
* 注意 BgAudio 是全局单例,stop 会影响所有页面共享的实例
|
||||||
|
*/
|
||||||
|
const stop = () => {
|
||||||
|
if (bgm) {
|
||||||
|
try {
|
||||||
|
bgm.stop()
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
currentLoop.value = null
|
||||||
|
desiredSrc = ''
|
||||||
|
isPlaying.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/* hook 卸载时不主动 stop,因为用户离开节拍器页时
|
||||||
|
仍希望音乐持续(走在路上拿出手机切别的页面应该不停)
|
||||||
|
真正停止的责任在 metronome.vue 的退出按钮里 */
|
||||||
|
onUnmounted(() => {
|
||||||
|
/* 仅解绑回调? BgAudio 是全局单例,我们的回调还在,
|
||||||
|
不解会导致内存中保留无用引用,但回调里都判断了 isPlaying,
|
||||||
|
且新页面再 ensureBgm 时会覆盖回调,可接受 */
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
isPlaying,
|
||||||
|
currentLoop,
|
||||||
|
playLoop,
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
stop,
|
||||||
|
LOOP_PRESETS,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { ref, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const BGM_BASE = '/training/static/bgm'
|
||||||
|
|
||||||
|
export type BgmMode = 'none' | 'training' | 'resting'
|
||||||
|
|
||||||
|
export interface UseTrainingBgmOptions {
|
||||||
|
trainingSrc?: string
|
||||||
|
restingSrc?: string
|
||||||
|
volume?: number
|
||||||
|
fadeMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTrainingBgm(options: UseTrainingBgmOptions = {}) {
|
||||||
|
const {
|
||||||
|
trainingSrc = `${BGM_BASE}/train-light.mp3`,
|
||||||
|
restingSrc = `${BGM_BASE}/rest-meditation.mp3`,
|
||||||
|
volume = 0.6,
|
||||||
|
fadeMs = 600,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const mode = ref<BgmMode>('none')
|
||||||
|
const enabled = ref<boolean>(true)
|
||||||
|
|
||||||
|
let trainCtx: UniApp.InnerAudioContext | null = null
|
||||||
|
let restCtx: UniApp.InnerAudioContext | null = null
|
||||||
|
let fadeTimers: ReturnType<typeof setInterval>[] = []
|
||||||
|
|
||||||
|
const ensureCtx = () => {
|
||||||
|
if (!trainCtx) {
|
||||||
|
trainCtx = uni.createInnerAudioContext()
|
||||||
|
trainCtx.src = trainingSrc
|
||||||
|
trainCtx.loop = true
|
||||||
|
trainCtx.obeyMuteSwitch = false
|
||||||
|
trainCtx.volume = 0
|
||||||
|
}
|
||||||
|
if (!restCtx) {
|
||||||
|
restCtx = uni.createInnerAudioContext()
|
||||||
|
restCtx.src = restingSrc
|
||||||
|
restCtx.loop = true
|
||||||
|
restCtx.obeyMuteSwitch = false
|
||||||
|
restCtx.volume = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fade = (
|
||||||
|
ctx: UniApp.InnerAudioContext,
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
duration = fadeMs,
|
||||||
|
) => {
|
||||||
|
const steps = 16
|
||||||
|
const stepMs = Math.max(16, duration / steps)
|
||||||
|
let i = 0
|
||||||
|
const t = setInterval(() => {
|
||||||
|
i++
|
||||||
|
const v = from + (to - from) * (i / steps)
|
||||||
|
ctx.volume = Math.max(0, Math.min(1, v))
|
||||||
|
if (i >= steps) {
|
||||||
|
clearInterval(t)
|
||||||
|
fadeTimers = fadeTimers.filter((x) => x !== t)
|
||||||
|
}
|
||||||
|
}, stepMs)
|
||||||
|
fadeTimers.push(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
const switchTo = (target: BgmMode) => {
|
||||||
|
if (!enabled.value) return
|
||||||
|
if (mode.value === target) return
|
||||||
|
ensureCtx()
|
||||||
|
|
||||||
|
if (target === 'training' && trainCtx && restCtx) {
|
||||||
|
fade(restCtx, restCtx.volume, 0)
|
||||||
|
setTimeout(() => restCtx?.pause(), fadeMs)
|
||||||
|
trainCtx.play()
|
||||||
|
fade(trainCtx, 0, volume)
|
||||||
|
} else if (target === 'resting' && trainCtx && restCtx) {
|
||||||
|
fade(trainCtx, trainCtx.volume, 0)
|
||||||
|
setTimeout(() => trainCtx?.pause(), fadeMs)
|
||||||
|
restCtx.play()
|
||||||
|
fade(restCtx, 0, volume)
|
||||||
|
} else if (target === 'none') {
|
||||||
|
if (trainCtx) {
|
||||||
|
fade(trainCtx, trainCtx.volume, 0)
|
||||||
|
setTimeout(() => trainCtx?.pause(), fadeMs)
|
||||||
|
}
|
||||||
|
if (restCtx) {
|
||||||
|
fade(restCtx, restCtx.volume, 0)
|
||||||
|
setTimeout(() => restCtx?.pause(), fadeMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mode.value = target
|
||||||
|
}
|
||||||
|
|
||||||
|
const setEnabled = (v: boolean) => {
|
||||||
|
enabled.value = v
|
||||||
|
if (!v) switchTo('none')
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
fadeTimers.forEach((t) => clearInterval(t))
|
||||||
|
fadeTimers = []
|
||||||
|
trainCtx?.destroy?.()
|
||||||
|
restCtx?.destroy?.()
|
||||||
|
trainCtx = null
|
||||||
|
restCtx = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
enabled,
|
||||||
|
switchTo,
|
||||||
|
setEnabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { ref, computed, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
export type SessionPhase = 'idle' | 'training' | 'resting' | 'done'
|
||||||
|
|
||||||
|
export interface TrainingSessionConfig {
|
||||||
|
sets: number
|
||||||
|
reps: number
|
||||||
|
restSec: number
|
||||||
|
beatsPerRep?: number
|
||||||
|
onRepComplete?: (currentRep: number, totalReps: number) => void
|
||||||
|
onSetComplete?: (currentSet: number, totalSets: number) => void
|
||||||
|
onEnterRest?: (restSec: number) => void
|
||||||
|
onExitRest?: () => void
|
||||||
|
onDone?: () => void
|
||||||
|
onCountdownTick?: (remainingSec: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTrainingSession() {
|
||||||
|
const phase = ref<SessionPhase>('idle')
|
||||||
|
const currentSet = ref<number>(0)
|
||||||
|
const currentRep = ref<number>(0)
|
||||||
|
const restRemainingSec = ref<number>(0)
|
||||||
|
const beatCounter = ref<number>(0)
|
||||||
|
|
||||||
|
let config: TrainingSessionConfig | null = null
|
||||||
|
let restTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const totalReps = computed(() => config?.reps ?? 0)
|
||||||
|
const totalSets = computed(() => config?.sets ?? 0)
|
||||||
|
const beatsPerRep = computed(() => config?.beatsPerRep ?? 2)
|
||||||
|
|
||||||
|
const isTraining = computed(() => phase.value === 'training')
|
||||||
|
const isResting = computed(() => phase.value === 'resting')
|
||||||
|
const isDone = computed(() => phase.value === 'done')
|
||||||
|
|
||||||
|
const start = (cfg: TrainingSessionConfig) => {
|
||||||
|
config = cfg
|
||||||
|
phase.value = 'training'
|
||||||
|
currentSet.value = 1
|
||||||
|
currentRep.value = 0
|
||||||
|
beatCounter.value = 0
|
||||||
|
restRemainingSec.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const onBeat = () => {
|
||||||
|
if (phase.value !== 'training' || !config) return
|
||||||
|
|
||||||
|
beatCounter.value++
|
||||||
|
|
||||||
|
if (beatCounter.value % beatsPerRep.value !== 0) return
|
||||||
|
|
||||||
|
currentRep.value++
|
||||||
|
config.onRepComplete?.(currentRep.value, totalReps.value)
|
||||||
|
|
||||||
|
if (currentRep.value >= totalReps.value) {
|
||||||
|
config.onSetComplete?.(currentSet.value, totalSets.value)
|
||||||
|
|
||||||
|
if (currentSet.value >= totalSets.value) {
|
||||||
|
phase.value = 'done'
|
||||||
|
config.onDone?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
enterRest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const enterRest = () => {
|
||||||
|
if (!config) return
|
||||||
|
phase.value = 'resting'
|
||||||
|
restRemainingSec.value = config.restSec
|
||||||
|
config.onEnterRest?.(config.restSec)
|
||||||
|
|
||||||
|
restTimer = setInterval(() => {
|
||||||
|
restRemainingSec.value--
|
||||||
|
config?.onCountdownTick?.(restRemainingSec.value)
|
||||||
|
if (restRemainingSec.value <= 0) {
|
||||||
|
exitRest()
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitRest = () => {
|
||||||
|
if (restTimer) {
|
||||||
|
clearInterval(restTimer)
|
||||||
|
restTimer = null
|
||||||
|
}
|
||||||
|
if (!config) return
|
||||||
|
|
||||||
|
currentSet.value++
|
||||||
|
currentRep.value = 0
|
||||||
|
beatCounter.value = 0
|
||||||
|
phase.value = 'training'
|
||||||
|
config.onExitRest?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
const skipRest = () => {
|
||||||
|
if (phase.value !== 'resting') return
|
||||||
|
exitRest()
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (restTimer) {
|
||||||
|
clearInterval(restTimer)
|
||||||
|
restTimer = null
|
||||||
|
}
|
||||||
|
phase.value = 'idle'
|
||||||
|
currentSet.value = 0
|
||||||
|
currentRep.value = 0
|
||||||
|
beatCounter.value = 0
|
||||||
|
restRemainingSec.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (restTimer) clearInterval(restTimer)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
phase,
|
||||||
|
currentSet,
|
||||||
|
currentRep,
|
||||||
|
restRemainingSec,
|
||||||
|
totalReps,
|
||||||
|
totalSets,
|
||||||
|
isTraining,
|
||||||
|
isResting,
|
||||||
|
isDone,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
skipRest,
|
||||||
|
onBeat,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const VOICE_BASE = '/training/static/voice'
|
||||||
|
|
||||||
|
export type VoicePromptKey =
|
||||||
|
| 'start'
|
||||||
|
| 'ready'
|
||||||
|
| 'rest'
|
||||||
|
| 'next-set'
|
||||||
|
| 'last-rep'
|
||||||
|
| 'keep-it-up'
|
||||||
|
| 'good-job'
|
||||||
|
| 'breathe-in'
|
||||||
|
| 'breathe-out'
|
||||||
|
|
||||||
|
export interface UseVoiceCoachOptions {
|
||||||
|
enabled?: boolean
|
||||||
|
volume?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVoiceCoach(options: UseVoiceCoachOptions = {}) {
|
||||||
|
const { enabled = true, volume = 1 } = options
|
||||||
|
|
||||||
|
let ctx: UniApp.InnerAudioContext | null = null
|
||||||
|
let queue: string[] = []
|
||||||
|
let isPlayingQueue = false
|
||||||
|
|
||||||
|
const ensureCtx = () => {
|
||||||
|
if (ctx) return
|
||||||
|
ctx = uni.createInnerAudioContext()
|
||||||
|
ctx.volume = volume
|
||||||
|
ctx.obeyMuteSwitch = false
|
||||||
|
ctx.onEnded(() => {
|
||||||
|
playNextInQueue()
|
||||||
|
})
|
||||||
|
ctx.onError(() => {
|
||||||
|
playNextInQueue()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const playNextInQueue = () => {
|
||||||
|
if (!ctx || queue.length === 0) {
|
||||||
|
isPlayingQueue = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const nextSrc = queue.shift()!
|
||||||
|
ctx.src = nextSrc
|
||||||
|
ctx.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
const enqueue = (src: string) => {
|
||||||
|
if (!enabled) return
|
||||||
|
ensureCtx()
|
||||||
|
queue.push(src)
|
||||||
|
if (!isPlayingQueue) {
|
||||||
|
isPlayingQueue = true
|
||||||
|
playNextInQueue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const speakNumber = (n: number) => {
|
||||||
|
if (n < 1 || n > 30) return
|
||||||
|
enqueue(`${VOICE_BASE}/numbers/${n}.mp3`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const speakPrompt = (key: VoicePromptKey) => {
|
||||||
|
enqueue(`${VOICE_BASE}/prompts/${key}.mp3`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
queue = []
|
||||||
|
isPlayingQueue = false
|
||||||
|
ctx?.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stop()
|
||||||
|
ctx?.destroy?.()
|
||||||
|
ctx = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
speakNumber,
|
||||||
|
speakPrompt,
|
||||||
|
stop,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
<template>
|
||||||
|
<view class="exercise-anim" :class="`anim-${animationType}`" :style="cssVars">
|
||||||
|
<view class="stage">
|
||||||
|
<!-- 哑铃弯举:摆臂 -->
|
||||||
|
<view v-if="animationType === 'curl'" class="curl-arm">
|
||||||
|
<view class="curl-forearm">
|
||||||
|
<view class="curl-dumbbell">{{ icon }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 哑铃推举:上下移动 -->
|
||||||
|
<view v-else-if="animationType === 'press'" class="press-wrap">
|
||||||
|
<view class="press-dumbbell left">{{ icon }}</view>
|
||||||
|
<view class="press-dumbbell right">{{ icon }}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 哑铃侧平举:双臂张开 -->
|
||||||
|
<view v-else-if="animationType === 'sidefly'" class="sidefly-wrap">
|
||||||
|
<view class="sidefly-arm sidefly-left">
|
||||||
|
<view class="sidefly-dumbbell">{{ icon }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="sidefly-arm sidefly-right">
|
||||||
|
<view class="sidefly-dumbbell">{{ icon }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 握力环:缩放 -->
|
||||||
|
<view v-else-if="animationType === 'grip'" class="grip-ring">
|
||||||
|
<view class="grip-ring-inner">{{ icon }}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 卷腹:身体折叠 -->
|
||||||
|
<view v-else-if="animationType === 'crunch'" class="crunch-wrap">
|
||||||
|
<view class="crunch-body">{{ icon }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { AnimationType } from '../exercises'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
animationType: AnimationType
|
||||||
|
bpm: number
|
||||||
|
beatsPerRep?: number
|
||||||
|
icon?: string
|
||||||
|
isPlaying?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
beatsPerRep: 2,
|
||||||
|
icon: '🏋',
|
||||||
|
isPlaying: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const cssVars = computed(() => {
|
||||||
|
const repDurationMs = (60000 / props.bpm) * props.beatsPerRep
|
||||||
|
return {
|
||||||
|
'--rep-duration': `${repDurationMs}ms`,
|
||||||
|
'--anim-state': props.isPlaying ? 'running' : 'paused',
|
||||||
|
} as Record<string, string>
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.exercise-anim {
|
||||||
|
width: 100%;
|
||||||
|
height: 480rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #f0fdf4, #ecfeff);
|
||||||
|
border-radius: 24rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
width: 320rpx;
|
||||||
|
height: 320rpx;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 弯举 ===== */
|
||||||
|
.curl-arm {
|
||||||
|
width: 60rpx;
|
||||||
|
height: 240rpx;
|
||||||
|
background: #fbbf24;
|
||||||
|
border-radius: 30rpx;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.curl-forearm {
|
||||||
|
width: 60rpx;
|
||||||
|
height: 200rpx;
|
||||||
|
background: #f59e0b;
|
||||||
|
border-radius: 30rpx;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
transform-origin: bottom center;
|
||||||
|
animation: curl-anim var(--rep-duration) ease-in-out infinite;
|
||||||
|
animation-play-state: var(--anim-state);
|
||||||
|
}
|
||||||
|
|
||||||
|
.curl-dumbbell {
|
||||||
|
position: absolute;
|
||||||
|
top: -20rpx;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 56rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes curl-anim {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(-115deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 推举 ===== */
|
||||||
|
.press-wrap {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
}
|
||||||
|
.press-dumbbell {
|
||||||
|
font-size: 88rpx;
|
||||||
|
animation: press-anim var(--rep-duration) ease-in-out infinite;
|
||||||
|
animation-play-state: var(--anim-state);
|
||||||
|
}
|
||||||
|
@keyframes press-anim {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(80rpx);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-80rpx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 侧平举 ===== */
|
||||||
|
.sidefly-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.sidefly-arm {
|
||||||
|
position: absolute;
|
||||||
|
width: 140rpx;
|
||||||
|
height: 24rpx;
|
||||||
|
background: #f59e0b;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
top: 50%;
|
||||||
|
transform-origin: center right;
|
||||||
|
animation: sidefly-left var(--rep-duration) ease-in-out infinite;
|
||||||
|
animation-play-state: var(--anim-state);
|
||||||
|
|
||||||
|
&.sidefly-left {
|
||||||
|
right: 50%;
|
||||||
|
}
|
||||||
|
&.sidefly-right {
|
||||||
|
left: 50%;
|
||||||
|
transform-origin: center left;
|
||||||
|
animation-name: sidefly-right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sidefly-dumbbell {
|
||||||
|
position: absolute;
|
||||||
|
top: -36rpx;
|
||||||
|
font-size: 56rpx;
|
||||||
|
}
|
||||||
|
.sidefly-left .sidefly-dumbbell {
|
||||||
|
left: -20rpx;
|
||||||
|
}
|
||||||
|
.sidefly-right .sidefly-dumbbell {
|
||||||
|
right: -20rpx;
|
||||||
|
}
|
||||||
|
@keyframes sidefly-left {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(80deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes sidefly-right {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(-80deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 握力环 ===== */
|
||||||
|
.grip-ring {
|
||||||
|
width: 240rpx;
|
||||||
|
height: 240rpx;
|
||||||
|
animation: grip-anim var(--rep-duration) ease-in-out infinite;
|
||||||
|
animation-play-state: var(--anim-state);
|
||||||
|
border: 16rpx solid #22c55e;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
}
|
||||||
|
.grip-ring-inner {
|
||||||
|
font-size: 80rpx;
|
||||||
|
}
|
||||||
|
@keyframes grip-anim {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(0.65);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 卷腹 ===== */
|
||||||
|
.crunch-wrap {
|
||||||
|
width: 280rpx;
|
||||||
|
height: 280rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.crunch-body {
|
||||||
|
font-size: 120rpx;
|
||||||
|
animation: crunch-anim var(--rep-duration) ease-in-out infinite;
|
||||||
|
animation-play-state: var(--anim-state);
|
||||||
|
transform-origin: bottom center;
|
||||||
|
}
|
||||||
|
@keyframes crunch-anim {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scaleY(1) translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scaleY(0.6) translateY(-10rpx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
export type AnimationType =
|
||||||
|
| 'curl'
|
||||||
|
| 'press'
|
||||||
|
| 'sidefly'
|
||||||
|
| 'grip'
|
||||||
|
| 'crunch'
|
||||||
|
|
||||||
|
export interface ExercisePreset {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
equipment: '哑铃' | '握力环' | '健身环' | '徒手'
|
||||||
|
icon: string
|
||||||
|
animationType: AnimationType
|
||||||
|
description: string
|
||||||
|
tips: string[]
|
||||||
|
contraindication?: string
|
||||||
|
|
||||||
|
defaultBpm: number
|
||||||
|
bpmMin: number
|
||||||
|
bpmMax: number
|
||||||
|
defaultSets: number
|
||||||
|
defaultReps: number
|
||||||
|
defaultRestSec: number
|
||||||
|
|
||||||
|
beatsPerRep: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EXERCISE_PRESETS: ExercisePreset[] = [
|
||||||
|
{
|
||||||
|
id: 'dumbbell-curl',
|
||||||
|
name: '哑铃弯举',
|
||||||
|
equipment: '哑铃',
|
||||||
|
icon: '🏋',
|
||||||
|
animationType: 'curl',
|
||||||
|
description: '锻炼肱二头肌,注意肘部固定不动',
|
||||||
|
tips: ['肘部贴紧身体', '上举吸气,下落呼气', '动作放慢,控制重量'],
|
||||||
|
contraindication: '肘关节炎症急性期不宜',
|
||||||
|
defaultBpm: 60,
|
||||||
|
bpmMin: 40,
|
||||||
|
bpmMax: 100,
|
||||||
|
defaultSets: 3,
|
||||||
|
defaultReps: 12,
|
||||||
|
defaultRestSec: 60,
|
||||||
|
beatsPerRep: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dumbbell-press',
|
||||||
|
name: '哑铃推举',
|
||||||
|
equipment: '哑铃',
|
||||||
|
icon: '🏋',
|
||||||
|
animationType: 'press',
|
||||||
|
description: '锻炼肩部三角肌,核心保持收紧',
|
||||||
|
tips: ['不要含胸驼背', '推举时呼气', '不要锁死肘关节'],
|
||||||
|
contraindication: '肩袖损伤者请咨询医生',
|
||||||
|
defaultBpm: 60,
|
||||||
|
bpmMin: 40,
|
||||||
|
bpmMax: 90,
|
||||||
|
defaultSets: 3,
|
||||||
|
defaultReps: 10,
|
||||||
|
defaultRestSec: 90,
|
||||||
|
beatsPerRep: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dumbbell-sidefly',
|
||||||
|
name: '哑铃侧平举',
|
||||||
|
equipment: '哑铃',
|
||||||
|
icon: '🏋',
|
||||||
|
animationType: 'sidefly',
|
||||||
|
description: '锻炼三角肌中束,重量宜轻不宜重',
|
||||||
|
tips: ['手肘微屈', '抬至与肩平齐即可', '想象用肩膀发力,不是手臂'],
|
||||||
|
defaultBpm: 60,
|
||||||
|
bpmMin: 40,
|
||||||
|
bpmMax: 80,
|
||||||
|
defaultSets: 3,
|
||||||
|
defaultReps: 12,
|
||||||
|
defaultRestSec: 60,
|
||||||
|
beatsPerRep: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'grip-ring',
|
||||||
|
name: '握力环训练',
|
||||||
|
equipment: '握力环',
|
||||||
|
icon: '🟢',
|
||||||
|
animationType: 'grip',
|
||||||
|
description: '锻炼前臂屈肌群,改善握力',
|
||||||
|
tips: ['完全握紧停顿 1 秒', '完全张开放松', '左右手交替'],
|
||||||
|
defaultBpm: 80,
|
||||||
|
bpmMin: 60,
|
||||||
|
bpmMax: 120,
|
||||||
|
defaultSets: 4,
|
||||||
|
defaultReps: 20,
|
||||||
|
defaultRestSec: 45,
|
||||||
|
beatsPerRep: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'crunch',
|
||||||
|
name: '集中机(仰卧卷腹)',
|
||||||
|
equipment: '健身环',
|
||||||
|
icon: '💪',
|
||||||
|
animationType: 'crunch',
|
||||||
|
description: '锻炼腹直肌,借助健身环增加阻力',
|
||||||
|
tips: ['下背贴地', '用腹部发力,不是脖子', '上抬呼气,下落吸气'],
|
||||||
|
contraindication: '腰椎间盘突出急性期不宜',
|
||||||
|
defaultBpm: 50,
|
||||||
|
bpmMin: 40,
|
||||||
|
bpmMax: 80,
|
||||||
|
defaultSets: 3,
|
||||||
|
defaultReps: 15,
|
||||||
|
defaultRestSec: 60,
|
||||||
|
beatsPerRep: 2,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function getPresetById(id: string): ExercisePreset | undefined {
|
||||||
|
return EXERCISE_PRESETS.find((e) => e.id === id)
|
||||||
|
}
|
||||||
@@ -0,0 +1,610 @@
|
|||||||
|
<template>
|
||||||
|
<view class="training-page">
|
||||||
|
<view class="header">
|
||||||
|
<view class="header-main">
|
||||||
|
<text class="title">练一练</text>
|
||||||
|
<text class="subtitle">器械动作跟练</text>
|
||||||
|
</view>
|
||||||
|
<view class="header-link" @click="goMetronome">
|
||||||
|
🎵 节拍器
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="equipment-tabs">
|
||||||
|
<view
|
||||||
|
v-for="eq in equipmentList"
|
||||||
|
:key="eq"
|
||||||
|
class="equipment-tab"
|
||||||
|
:class="{ active: currentEquipment === eq }"
|
||||||
|
@click="onSwitchEquipment(eq)"
|
||||||
|
>
|
||||||
|
{{ eq }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<scroll-view scroll-x class="exercise-scroll">
|
||||||
|
<view class="exercise-list">
|
||||||
|
<view
|
||||||
|
v-for="ex in filteredExercises"
|
||||||
|
:key="ex.id"
|
||||||
|
class="exercise-card"
|
||||||
|
:class="{ active: selectedId === ex.id }"
|
||||||
|
@click="onSelect(ex.id)"
|
||||||
|
>
|
||||||
|
<text class="exercise-icon">{{ ex.icon }}</text>
|
||||||
|
<text class="exercise-name">{{ ex.name }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<view v-if="selected" class="exercise-detail">
|
||||||
|
<exercise-anim
|
||||||
|
:animation-type="selected.animationType"
|
||||||
|
:bpm="bpm"
|
||||||
|
:beats-per-rep="selected.beatsPerRep"
|
||||||
|
:icon="selected.icon"
|
||||||
|
:is-playing="metronomeIsPlaying"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<view class="beat-indicator">
|
||||||
|
<view
|
||||||
|
v-for="i in 4"
|
||||||
|
:key="i"
|
||||||
|
class="beat-dot"
|
||||||
|
:class="{ active: ((beatIndex - 1) % 4) + 1 === i && metronomeIsPlaying }"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="meta-row">
|
||||||
|
<text class="meta-label">{{ selected.description }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="settings">
|
||||||
|
<view class="setting-row">
|
||||||
|
<text class="setting-label">节奏</text>
|
||||||
|
<slider
|
||||||
|
:value="bpm"
|
||||||
|
:min="selected.bpmMin"
|
||||||
|
:max="selected.bpmMax"
|
||||||
|
:step="2"
|
||||||
|
block-size="24"
|
||||||
|
active-color="#22c55e"
|
||||||
|
@change="onBpmChange"
|
||||||
|
/>
|
||||||
|
<text class="setting-value">{{ bpm }} BPM</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="setting-row inline">
|
||||||
|
<view class="inline-item">
|
||||||
|
<text class="setting-label">组数</text>
|
||||||
|
<view class="stepper">
|
||||||
|
<text class="step-btn" @click="adjust('sets', -1)">-</text>
|
||||||
|
<text class="step-val">{{ sets }}</text>
|
||||||
|
<text class="step-btn" @click="adjust('sets', 1)">+</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="inline-item">
|
||||||
|
<text class="setting-label">次数</text>
|
||||||
|
<view class="stepper">
|
||||||
|
<text class="step-btn" @click="adjust('reps', -1)">-</text>
|
||||||
|
<text class="step-val">{{ reps }}</text>
|
||||||
|
<text class="step-btn" @click="adjust('reps', 1)">+</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="inline-item">
|
||||||
|
<text class="setting-label">休息(秒)</text>
|
||||||
|
<view class="stepper">
|
||||||
|
<text class="step-btn" @click="adjust('rest', -15)">-</text>
|
||||||
|
<text class="step-val">{{ restSec }}</text>
|
||||||
|
<text class="step-btn" @click="adjust('rest', 15)">+</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="!isTraining && !isResting && !isDone" class="action-row">
|
||||||
|
<button class="btn-primary" @click="onStart">开始训练</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="isTraining" class="status-card training">
|
||||||
|
<text class="status-title">训练中</text>
|
||||||
|
<text class="status-line">第 {{ currentSet }} / {{ totalSets }} 组</text>
|
||||||
|
<text class="status-line big">{{ currentRep }} / {{ totalReps }}</text>
|
||||||
|
<view class="action-row">
|
||||||
|
<button class="btn-secondary" @click="onStop">停止</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="isResting" class="status-card resting">
|
||||||
|
<text class="status-title">休息中</text>
|
||||||
|
<text class="status-line big">{{ restRemainingSec }}s</text>
|
||||||
|
<view class="action-row">
|
||||||
|
<button class="btn-secondary" @click="onSkipRest">跳过休息</button>
|
||||||
|
<button class="btn-text" @click="onStop">结束训练</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="isDone" class="status-card done">
|
||||||
|
<text class="status-title">训练完成 🎉</text>
|
||||||
|
<text class="status-line">共 {{ totalSets }} 组 × {{ totalReps }} 次</text>
|
||||||
|
<view class="action-row">
|
||||||
|
<button class="btn-primary" @click="onStart">再来一次</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="tips-card">
|
||||||
|
<text class="tips-title">动作要领</text>
|
||||||
|
<text v-for="(tip, i) in selected.tips" :key="i" class="tips-item">
|
||||||
|
• {{ tip }}
|
||||||
|
</text>
|
||||||
|
<text v-if="selected.contraindication" class="tips-warn">
|
||||||
|
⚠ {{ selected.contraindication }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||||
|
import { onShow, onHide } from '@dcloudio/uni-app'
|
||||||
|
import ExerciseAnim from './components/exercise-anim.vue'
|
||||||
|
import { EXERCISE_PRESETS, getPresetById } from './exercises'
|
||||||
|
import type { ExercisePreset } from './exercises'
|
||||||
|
import { useMetronome } from '../hooks/useMetronome'
|
||||||
|
import { useTrainingSession } from '../hooks/useTrainingSession'
|
||||||
|
import { useVoiceCoach } from '../hooks/useVoiceCoach'
|
||||||
|
import { useTrainingBgm } from '../hooks/useTrainingBgm'
|
||||||
|
|
||||||
|
const equipmentList = ['哑铃', '握力环', '健身环', '徒手'] as const
|
||||||
|
type Equipment = (typeof equipmentList)[number]
|
||||||
|
|
||||||
|
const currentEquipment = ref<Equipment>('哑铃')
|
||||||
|
const selectedId = ref<string>(EXERCISE_PRESETS[0].id)
|
||||||
|
|
||||||
|
const filteredExercises = computed(() =>
|
||||||
|
EXERCISE_PRESETS.filter((e) => e.equipment === currentEquipment.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
const selected = computed<ExercisePreset | undefined>(() =>
|
||||||
|
getPresetById(selectedId.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
const bpm = ref<number>(60)
|
||||||
|
const sets = ref<number>(3)
|
||||||
|
const reps = ref<number>(12)
|
||||||
|
const restSec = ref<number>(60)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
selected,
|
||||||
|
(val) => {
|
||||||
|
if (!val) return
|
||||||
|
bpm.value = val.defaultBpm
|
||||||
|
sets.value = val.defaultSets
|
||||||
|
reps.value = val.defaultReps
|
||||||
|
restSec.value = val.defaultRestSec
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const voice = useVoiceCoach()
|
||||||
|
const bgm = useTrainingBgm()
|
||||||
|
|
||||||
|
const session = useTrainingSession()
|
||||||
|
const {
|
||||||
|
currentSet,
|
||||||
|
currentRep,
|
||||||
|
restRemainingSec,
|
||||||
|
totalReps,
|
||||||
|
totalSets,
|
||||||
|
isTraining,
|
||||||
|
isResting,
|
||||||
|
isDone,
|
||||||
|
} = session
|
||||||
|
|
||||||
|
const metronome = useMetronome({
|
||||||
|
initialBpm: bpm.value,
|
||||||
|
onBeat: () => session.onBeat(),
|
||||||
|
})
|
||||||
|
const { beatIndex, isPlaying: metronomeIsPlaying } = metronome
|
||||||
|
|
||||||
|
watch(bpm, (v) => metronome.setBpm(v))
|
||||||
|
|
||||||
|
const onSwitchEquipment = (eq: Equipment) => {
|
||||||
|
if (isTraining.value || isResting.value) return
|
||||||
|
currentEquipment.value = eq
|
||||||
|
const first = EXERCISE_PRESETS.find((e) => e.equipment === eq)
|
||||||
|
if (first) selectedId.value = first.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSelect = (id: string) => {
|
||||||
|
if (isTraining.value || isResting.value) return
|
||||||
|
selectedId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
const onBpmChange = (e: any) => {
|
||||||
|
const v = e.detail?.value ?? e
|
||||||
|
bpm.value = Number(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
const adjust = (key: 'sets' | 'reps' | 'rest', delta: number) => {
|
||||||
|
if (isTraining.value || isResting.value) return
|
||||||
|
if (key === 'sets') sets.value = Math.max(1, Math.min(10, sets.value + delta))
|
||||||
|
if (key === 'reps') reps.value = Math.max(1, Math.min(50, reps.value + delta))
|
||||||
|
if (key === 'rest') restSec.value = Math.max(0, Math.min(300, restSec.value + delta))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onStart = () => {
|
||||||
|
if (!selected.value) return
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||||
|
|
||||||
|
voice.speakPrompt('start')
|
||||||
|
|
||||||
|
session.start({
|
||||||
|
sets: sets.value,
|
||||||
|
reps: reps.value,
|
||||||
|
restSec: restSec.value,
|
||||||
|
beatsPerRep: selected.value.beatsPerRep,
|
||||||
|
onRepComplete: (rep, total) => {
|
||||||
|
if (rep <= 30) voice.speakNumber(rep)
|
||||||
|
if (rep === total - 1) voice.speakPrompt('last-rep')
|
||||||
|
},
|
||||||
|
onSetComplete: (set, total) => {
|
||||||
|
if (set < total) voice.speakPrompt('keep-it-up')
|
||||||
|
},
|
||||||
|
onEnterRest: () => {
|
||||||
|
metronome.stop()
|
||||||
|
voice.speakPrompt('rest')
|
||||||
|
bgm.switchTo('resting')
|
||||||
|
},
|
||||||
|
onExitRest: () => {
|
||||||
|
voice.speakPrompt('next-set')
|
||||||
|
bgm.switchTo('training')
|
||||||
|
metronome.start()
|
||||||
|
},
|
||||||
|
onDone: () => {
|
||||||
|
metronome.stop()
|
||||||
|
bgm.switchTo('none')
|
||||||
|
voice.speakPrompt('good-job')
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
bgm.switchTo('training')
|
||||||
|
metronome.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onStop = () => {
|
||||||
|
metronome.stop()
|
||||||
|
session.stop()
|
||||||
|
bgm.switchTo('none')
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSkipRest = () => {
|
||||||
|
session.skipRest()
|
||||||
|
}
|
||||||
|
|
||||||
|
const goMetronome = () => {
|
||||||
|
uni.navigateTo({ url: '/training/pages/metronome' })
|
||||||
|
}
|
||||||
|
|
||||||
|
onHide(() => {
|
||||||
|
if (isTraining.value || isResting.value) {
|
||||||
|
onStop()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.training-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f9fafb;
|
||||||
|
padding: 32rpx 24rpx 80rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
|
||||||
|
.header-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 44rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-top: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-link {
|
||||||
|
padding: 12rpx 24rpx;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #4b5563;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
overflow-x: auto;
|
||||||
|
|
||||||
|
.equipment-tab {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 14rpx 32rpx;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #4b5563;
|
||||||
|
border: 2rpx solid transparent;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #22c55e;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #22c55e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.exercise-scroll {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exercise-list {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding-right: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exercise-card {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 160rpx;
|
||||||
|
padding: 20rpx 12rpx;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
text-align: center;
|
||||||
|
border: 2rpx solid transparent;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: #22c55e;
|
||||||
|
background: #f0fdf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exercise-icon {
|
||||||
|
display: block;
|
||||||
|
font-size: 56rpx;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
.exercise-name {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.exercise-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beat-indicator {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding: 8rpx 0;
|
||||||
|
|
||||||
|
.beat-dot {
|
||||||
|
width: 16rpx;
|
||||||
|
height: 16rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #d1d5db;
|
||||||
|
transition: transform 0.15s, background 0.15s;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #22c55e;
|
||||||
|
transform: scale(1.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-row {
|
||||||
|
.meta-label {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #6b7280;
|
||||||
|
text-align: center;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
|
||||||
|
.setting-label {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #4b5563;
|
||||||
|
min-width: 80rpx;
|
||||||
|
}
|
||||||
|
.setting-value {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #22c55e;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 120rpx;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
slider {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.inline {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
|
||||||
|
.step-btn {
|
||||||
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
line-height: 48rpx;
|
||||||
|
text-align: center;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
.step-val {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
min-width: 56rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
button {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 320rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #22c55e;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
line-height: 88rpx;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #fff;
|
||||||
|
color: #22c55e;
|
||||||
|
border: 2rpx solid #22c55e;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
line-height: 76rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text {
|
||||||
|
background: transparent;
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 26rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
line-height: 80rpx;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 32rpx 24rpx;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12rpx;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&.training {
|
||||||
|
background: linear-gradient(135deg, #f0fdf4, #ecfeff);
|
||||||
|
}
|
||||||
|
&.resting {
|
||||||
|
background: linear-gradient(135deg, #fef3c7, #fef9c3);
|
||||||
|
}
|
||||||
|
&.done {
|
||||||
|
background: linear-gradient(135deg, #dbeafe, #ede9fe);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #4b5563;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.status-line {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #6b7280;
|
||||||
|
&.big {
|
||||||
|
font-size: 64rpx;
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8rpx;
|
||||||
|
|
||||||
|
.tips-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
.tips-item {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #4b5563;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.tips-warn {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #ef4444;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,735 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page" :style="rootStyle">
|
||||||
|
<!-- ===== 主舞台:节拍器圆 ===== -->
|
||||||
|
<view class="stage" @click="onCenterTap">
|
||||||
|
<view class="ring r1" :class="{ playing: isPlaying }" />
|
||||||
|
<view class="ring r2" :class="{ playing: isPlaying }" />
|
||||||
|
<view class="core" :class="{ playing: isPlaying }">
|
||||||
|
<text class="bpm-num">{{ currentBpm }}</text>
|
||||||
|
<text class="bpm-label">BPM</text>
|
||||||
|
<view class="ctrl">
|
||||||
|
<view v-if="isPlaying" class="icon-pause">
|
||||||
|
<view class="bar" />
|
||||||
|
<view class="bar" />
|
||||||
|
</view>
|
||||||
|
<view v-else class="icon-play" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- ===== 跑步小人(矩形完整显示,跟白底融合) ===== -->
|
||||||
|
<view class="walker">
|
||||||
|
<view class="walker-box">
|
||||||
|
<video
|
||||||
|
id="runner-video"
|
||||||
|
class="runner-video"
|
||||||
|
:src="RUNNER_VIDEO_URL"
|
||||||
|
:muted="true"
|
||||||
|
:loop="true"
|
||||||
|
:autoplay="true"
|
||||||
|
:show-controls="false"
|
||||||
|
:show-fullscreen-btn="false"
|
||||||
|
:show-play-btn="false"
|
||||||
|
:show-center-play-btn="false"
|
||||||
|
:enable-progress-gesture="false"
|
||||||
|
:show-mute-btn="false"
|
||||||
|
:picture-in-picture-mode="[]"
|
||||||
|
object-fit="contain"
|
||||||
|
@error="onVideoError"
|
||||||
|
@loadedmetadata="onVideoLoaded"
|
||||||
|
/>
|
||||||
|
<!-- #ifdef MP-WEIXIN -->
|
||||||
|
<cover-view class="pip-mask"> </cover-view>
|
||||||
|
<!-- #endif -->
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- ===== 推荐档位(3 选 1) ===== -->
|
||||||
|
<view class="presets">
|
||||||
|
<view
|
||||||
|
v-for="p in LOOP_PRESETS"
|
||||||
|
:key="p.id"
|
||||||
|
class="preset"
|
||||||
|
:class="{ active: mode === 'preset' && currentLoop === p.id }"
|
||||||
|
@click="onPresetTap(p.id)"
|
||||||
|
>
|
||||||
|
<text class="preset-icon">{{ presetIcon(p.id) }}</text>
|
||||||
|
<text class="preset-name">{{ presetName(p.id) }}</text>
|
||||||
|
<text class="preset-bpm">{{ p.bpm }} BPM</text>
|
||||||
|
<text class="preset-desc">{{ presetDesc(p.id) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- ===== 自定义节奏(高级折叠区) ===== -->
|
||||||
|
<view v-if="customExpanded" class="custom-panel">
|
||||||
|
<view class="custom-warn">
|
||||||
|
<text class="warn-dot">●</text>
|
||||||
|
<text class="warn-text">前台模式 · 切后台会暂停</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- BPM 微调 -->
|
||||||
|
<view class="row">
|
||||||
|
<text class="row-label">BPM</text>
|
||||||
|
<view class="bpm-stepper">
|
||||||
|
<view class="step-btn" @click="onBpmDelta(-5)">−5</view>
|
||||||
|
<view class="step-btn" @click="onBpmDelta(-1)">−1</view>
|
||||||
|
<view class="step-val">{{ customBpm }}</view>
|
||||||
|
<view class="step-btn" @click="onBpmDelta(1)">+1</view>
|
||||||
|
<view class="step-btn" @click="onBpmDelta(5)">+5</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 拍号 -->
|
||||||
|
<view class="row">
|
||||||
|
<text class="row-label">拍号</text>
|
||||||
|
<view class="meter-tabs">
|
||||||
|
<view
|
||||||
|
v-for="n in [2, 3, 4]"
|
||||||
|
:key="n"
|
||||||
|
class="meter-tab"
|
||||||
|
:class="{ active: customAccent === n }"
|
||||||
|
@click="onAccentSelect(n)"
|
||||||
|
>{{ n }}/4</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 底部行:自定义入口 + 提示 -->
|
||||||
|
<view class="bottom-row">
|
||||||
|
<text class="bottom-link" @click="onToggleCustom">
|
||||||
|
{{ customExpanded ? '✕ 收起自定义' : '⚙ 自定义节奏' }}
|
||||||
|
</text>
|
||||||
|
<text class="bottom-hint" v-if="mode === 'preset'">🔒 锁屏可继续播放</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { onShow, onHide } from '@dcloudio/uni-app'
|
||||||
|
import { useMetronomeBg, type LoopId } from '../hooks/useMetronomeBg'
|
||||||
|
import { useMetronome } from '../hooks/useMetronome'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跑步动画视频(腾讯云 COS CDN)
|
||||||
|
* 注意:小程序后台需将 cos.ap-guangzhou.myqcloud.com 加入 downloadFile 合法域名,
|
||||||
|
* 否则正式发布版会被拦截(开发版默认不校验)
|
||||||
|
*/
|
||||||
|
const RUNNER_VIDEO_URL =
|
||||||
|
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/video/20260525/20260525164014ef89f8862.mp4'
|
||||||
|
|
||||||
|
/** 视频原速对应的 BPM 基准, playback-rate 范围 [0.5, 2.0] */
|
||||||
|
const VIDEO_BASE_BPM = 124
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 双引擎设计
|
||||||
|
* ------------------------------------------------------------
|
||||||
|
* - mode='preset': 用 BgAudio 播预合成 mp3,支持后台/锁屏(走路场景)
|
||||||
|
* - mode='custom': 用 InnerAudio 实时合成,支持任意 BPM/拍号,但仅前台
|
||||||
|
* - 切模式时停掉对侧引擎,避免双声道叠加
|
||||||
|
* ============================================================ */
|
||||||
|
type Mode = 'preset' | 'custom'
|
||||||
|
const mode = ref<Mode>('preset')
|
||||||
|
const customExpanded = ref<boolean>(false)
|
||||||
|
|
||||||
|
/* click-wood.mp3 (CDN), 木鱼质感的敲击音 */
|
||||||
|
const CLICK_WOOD_URL =
|
||||||
|
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051282a0a94508.mp3'
|
||||||
|
|
||||||
|
const bg = useMetronomeBg()
|
||||||
|
const fg = useMetronome({
|
||||||
|
initialBpm: 100,
|
||||||
|
accentEvery: 2,
|
||||||
|
clickSrc: CLICK_WOOD_URL,
|
||||||
|
accentSrc: CLICK_WOOD_URL,
|
||||||
|
poolSize: 4,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { LOOP_PRESETS } = bg
|
||||||
|
const currentLoop = bg.currentLoop
|
||||||
|
|
||||||
|
const customBpm = computed(() => fg.bpm.value)
|
||||||
|
const customAccent = computed(() => fg.accentEvery.value)
|
||||||
|
|
||||||
|
/* 统一对外暴露的 isPlaying / currentBpm,根据 mode 选择信号源 */
|
||||||
|
const isPlaying = computed(() =>
|
||||||
|
mode.value === 'preset' ? bg.isPlaying.value : fg.isPlaying.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentBpm = computed(() => {
|
||||||
|
if (mode.value === 'preset') {
|
||||||
|
const p = LOOP_PRESETS.find((p) => p.id === currentLoop.value)
|
||||||
|
return p?.bpm ?? 110
|
||||||
|
}
|
||||||
|
return fg.bpm.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const intervalMs = computed(() => 60000 / currentBpm.value)
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 档位元数据
|
||||||
|
* ============================================================ */
|
||||||
|
const presetIcon = (id: LoopId) =>
|
||||||
|
id === 'slow' ? '🚶' : id === 'normal' ? '🏃♀️' : '🏃'
|
||||||
|
const presetName = (id: LoopId) =>
|
||||||
|
id === 'slow' ? '慢走' : id === 'normal' ? '健走' : '快走'
|
||||||
|
const presetDesc = (id: LoopId) =>
|
||||||
|
id === 'slow' ? '热身 · 恢复' : id === 'normal' ? '日常 · 通勤' : '提速 · 燃脂'
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 中央圆按钮:在两个模式下分别工作
|
||||||
|
* ============================================================ */
|
||||||
|
const onCenterTap = () => {
|
||||||
|
if (mode.value === 'preset') {
|
||||||
|
if (!currentLoop.value) {
|
||||||
|
bg.playLoop('normal')
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (bg.isPlaying.value) {
|
||||||
|
bg.pause()
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
|
} else {
|
||||||
|
bg.resume()
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
/* custom 模式 */
|
||||||
|
if (fg.isPlaying.value) {
|
||||||
|
fg.stop()
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
|
} else {
|
||||||
|
fg.start()
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 档位卡片:点了 → 切回 preset 模式 + 停掉 custom
|
||||||
|
* ============================================================ */
|
||||||
|
const onPresetTap = (id: LoopId) => {
|
||||||
|
if (mode.value === 'custom') {
|
||||||
|
fg.stop()
|
||||||
|
mode.value = 'preset'
|
||||||
|
customExpanded.value = false
|
||||||
|
}
|
||||||
|
bg.playLoop(id)
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 自定义折叠区切换
|
||||||
|
* ============================================================ */
|
||||||
|
const onToggleCustom = () => {
|
||||||
|
if (!customExpanded.value) {
|
||||||
|
/* 展开:进入 custom 模式 + 停掉 preset */
|
||||||
|
if (bg.isPlaying.value || currentLoop.value) {
|
||||||
|
bg.stop()
|
||||||
|
}
|
||||||
|
mode.value = 'custom'
|
||||||
|
/* 把当前显示 BPM 带过去当起始值 */
|
||||||
|
const seed =
|
||||||
|
LOOP_PRESETS.find((p) => p.id === (currentLoop.value ?? 'normal'))?.bpm ?? 100
|
||||||
|
fg.setBpm(seed)
|
||||||
|
fg.setAccentEvery(2)
|
||||||
|
/* 立即预热 InnerAudio,避免首拍冷启动延迟 */
|
||||||
|
fg.preload()
|
||||||
|
customExpanded.value = true
|
||||||
|
} else {
|
||||||
|
/* 收起:回到 preset,停掉 fg */
|
||||||
|
fg.stop()
|
||||||
|
mode.value = 'preset'
|
||||||
|
customExpanded.value = false
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onBpmDelta = (delta: number) => {
|
||||||
|
fg.setBpm(fg.bpm.value + delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onAccentSelect = (n: number) => {
|
||||||
|
fg.setAccentEvery(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 视频实例与 BPM 同步
|
||||||
|
* ============================================================ */
|
||||||
|
let videoCtx: UniApp.VideoContext | null = null
|
||||||
|
|
||||||
|
const playbackRate = computed(() => {
|
||||||
|
const rate = currentBpm.value / VIDEO_BASE_BPM
|
||||||
|
return Math.max(0.5, Math.min(2.0, +rate.toFixed(2)))
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
videoCtx = uni.createVideoContext('runner-video')
|
||||||
|
setTimeout(() => videoCtx?.pause(), 50)
|
||||||
|
})
|
||||||
|
|
||||||
|
const onVideoError = (e: any) => {
|
||||||
|
console.error('[runner-video] error:', e?.detail || e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onVideoLoaded = (e: any) => {
|
||||||
|
console.log('[runner-video] metadata loaded:', e?.detail)
|
||||||
|
try {
|
||||||
|
videoCtx?.playbackRate?.(playbackRate.value)
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(isPlaying, (playing) => {
|
||||||
|
if (playing) {
|
||||||
|
videoCtx?.play()
|
||||||
|
} else {
|
||||||
|
videoCtx?.pause()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(playbackRate, (rate) => {
|
||||||
|
try {
|
||||||
|
videoCtx?.playbackRate?.(rate)
|
||||||
|
} catch (_) {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const rootStyle = computed(() => ({
|
||||||
|
'--beat-duration': `${intervalMs.value}ms`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
/* custom 模式回到前台时主动预加载,避免冷启动延迟 */
|
||||||
|
if (mode.value === 'custom') {
|
||||||
|
fg.preload()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* onHide:差异化处理
|
||||||
|
* - preset 模式: 不停,BgAudio 接管后台播放
|
||||||
|
* - custom 模式: 立即停掉,InnerAudio 后台不工作,留着会被 iOS 挂起后状态错乱
|
||||||
|
*/
|
||||||
|
onHide(() => {
|
||||||
|
if (mode.value === 'custom') {
|
||||||
|
fg.stop()
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
/* 页面销毁时全部清理 */
|
||||||
|
if (mode.value === 'preset' && bg.isPlaying.value) bg.stop()
|
||||||
|
if (mode.value === 'custom') fg.stop()
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
/* ============================================================
|
||||||
|
* 设计 token —— 浅色清爽
|
||||||
|
* ============================================================ */
|
||||||
|
$bg-color: #f8fafc;
|
||||||
|
$card-bg: #ffffff;
|
||||||
|
$card-border: rgba(15, 23, 42, 0.06);
|
||||||
|
$text-1: #0f172a;
|
||||||
|
$text-2: #475569;
|
||||||
|
$text-3: #94a3b8;
|
||||||
|
$brand: #10b981;
|
||||||
|
$brand-soft: #d1fae5;
|
||||||
|
$brand-deep: #047857;
|
||||||
|
$warn: #f59e0b;
|
||||||
|
$warn-soft: #fef3c7;
|
||||||
|
$radius-card: 24rpx;
|
||||||
|
$shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 页面容器
|
||||||
|
* ============================================================ */
|
||||||
|
.page {
|
||||||
|
position: relative;
|
||||||
|
min-height: 100vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 30rpx 28rpx 50rpx;
|
||||||
|
background: $bg-color;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
color: $text-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 节拍器主舞台
|
||||||
|
* ============================================================ */
|
||||||
|
.stage {
|
||||||
|
position: relative;
|
||||||
|
width: 480rpx;
|
||||||
|
height: 480rpx;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:active .core {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3rpx solid rgba(16, 185, 129, 0.12);
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&.playing {
|
||||||
|
animation: ring-pulse var(--beat-duration, 750ms) ease-out infinite;
|
||||||
|
}
|
||||||
|
&.r2.playing {
|
||||||
|
animation-delay: calc(var(--beat-duration, 750ms) * -0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes ring-pulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.94);
|
||||||
|
opacity: 0.85;
|
||||||
|
border-color: rgba(16, 185, 129, 0.4);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1.18);
|
||||||
|
opacity: 0;
|
||||||
|
border-color: rgba(16, 185, 129, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.core {
|
||||||
|
position: relative;
|
||||||
|
width: 380rpx;
|
||||||
|
height: 380rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(140deg, #34d399 0%, $brand-deep 100%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 -16rpx 50rpx rgba(0, 0, 0, 0.18),
|
||||||
|
inset 0 16rpx 50rpx rgba(255, 255, 255, 0.18),
|
||||||
|
0 16rpx 40rpx rgba(16, 185, 129, 0.32);
|
||||||
|
transition: transform 0.12s, background 0.18s, box-shadow 0.18s;
|
||||||
|
|
||||||
|
&.playing {
|
||||||
|
animation: core-beat var(--beat-duration, 750ms) ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bpm-num {
|
||||||
|
font-size: 168rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -2rpx;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bpm-label {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
letter-spacing: 6rpx;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl {
|
||||||
|
margin-top: 22rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes core-beat {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 播放/暂停 icon */
|
||||||
|
.icon-play {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 52rpx solid #fff;
|
||||||
|
border-top: 32rpx solid transparent;
|
||||||
|
border-bottom: 32rpx solid transparent;
|
||||||
|
margin-left: 12rpx;
|
||||||
|
filter: drop-shadow(0 2rpx 6rpx rgba(0, 0, 0, 0.18));
|
||||||
|
}
|
||||||
|
.icon-pause {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
width: 16rpx;
|
||||||
|
height: 100%;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 跑步小人
|
||||||
|
* ============================================================ */
|
||||||
|
.walker {
|
||||||
|
width: 100%;
|
||||||
|
height: 240rpx;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.walker-box {
|
||||||
|
position: relative;
|
||||||
|
width: 240rpx;
|
||||||
|
height: 240rpx;
|
||||||
|
background: #e8ecf2;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4rpx 14rpx rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runner-video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #e8ecf2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pip-mask {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 80rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
background-color: #dde2eb;
|
||||||
|
border-bottom-left-radius: 18rpx;
|
||||||
|
border-top-right-radius: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 三档位推荐
|
||||||
|
* ============================================================ */
|
||||||
|
.presets {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset {
|
||||||
|
flex: 1;
|
||||||
|
background: $card-bg;
|
||||||
|
border: 2rpx solid $card-border;
|
||||||
|
border-radius: $radius-card;
|
||||||
|
padding: 18rpx 8rpx 16rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4rpx;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
transition: all 0.18s;
|
||||||
|
|
||||||
|
.preset-icon {
|
||||||
|
font-size: 40rpx;
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 4rpx;
|
||||||
|
}
|
||||||
|
.preset-name {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-1;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
}
|
||||||
|
.preset-bpm {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $text-2;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.preset-desc {
|
||||||
|
font-size: 18rpx;
|
||||||
|
color: $text-3;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
margin-top: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: $brand-soft;
|
||||||
|
border-color: $brand;
|
||||||
|
box-shadow:
|
||||||
|
0 8rpx 20rpx rgba(16, 185, 129, 0.18),
|
||||||
|
inset 0 0 0 2rpx rgba(16, 185, 129, 0.4);
|
||||||
|
|
||||||
|
.preset-name {
|
||||||
|
color: $brand-deep;
|
||||||
|
}
|
||||||
|
.preset-bpm {
|
||||||
|
color: $brand-deep;
|
||||||
|
}
|
||||||
|
.preset-desc {
|
||||||
|
color: $brand;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 自定义节奏面板(仅 customExpanded 时显示)
|
||||||
|
* ============================================================ */
|
||||||
|
.custom-panel {
|
||||||
|
width: 100%;
|
||||||
|
background: $card-bg;
|
||||||
|
border: 2rpx solid rgba(245, 158, 11, 0.25);
|
||||||
|
border-radius: $radius-card;
|
||||||
|
box-shadow: 0 6rpx 18rpx rgba(245, 158, 11, 0.08);
|
||||||
|
padding: 20rpx 24rpx 22rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-warn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
margin-bottom: 4rpx;
|
||||||
|
|
||||||
|
.warn-dot {
|
||||||
|
font-size: 18rpx;
|
||||||
|
color: $warn;
|
||||||
|
}
|
||||||
|
.warn-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #b45309;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12rpx;
|
||||||
|
|
||||||
|
.row-label {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $text-2;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 70rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bpm-stepper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6rpx;
|
||||||
|
flex: 1;
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
.step-btn {
|
||||||
|
min-width: 56rpx;
|
||||||
|
height: 52rpx;
|
||||||
|
line-height: 52rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $text-2;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0 10rpx;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #e2e8f0;
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.step-val {
|
||||||
|
min-width: 84rpx;
|
||||||
|
height: 52rpx;
|
||||||
|
line-height: 52rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: $text-1;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
background: $warn-soft;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8rpx;
|
||||||
|
|
||||||
|
.meter-tab {
|
||||||
|
height: 52rpx;
|
||||||
|
line-height: 52rpx;
|
||||||
|
padding: 0 18rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $text-2;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: $warn;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4rpx 10rpx rgba(245, 158, 11, 0.3);
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 底部行(自定义入口 + 锁屏提示,横向排布)
|
||||||
|
* ============================================================ */
|
||||||
|
.bottom-row {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 6rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-link {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $text-3;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
padding: 8rpx 4rpx;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
color: $text-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-hint {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $text-3;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
# 训练模块静态资源
|
||||||
|
|
||||||
|
本目录用于存放"练一练"功能的所有音频素材。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
training/static/
|
||||||
|
├── audio/ # 当前为空 —— 节拍器音频已全部迁移到 COS CDN
|
||||||
|
├── voice/ # (规划中) TTS 语音教练
|
||||||
|
│ ├── numbers/ 数字报数 1~30
|
||||||
|
│ └── prompts/ 开始/休息/再来 等口令
|
||||||
|
├── bgm/ # (规划中) 训练/休息背景乐
|
||||||
|
└── footprint.svg # 脚印图标(早期方案残留,可保留作为备用素材)
|
||||||
|
```
|
||||||
|
|
||||||
|
> 节拍器音频已完全走 CDN(`gz-1349751149.cos.ap-guangzhou.myqcloud.com`),减少小程序包体积约 210 KB。`useMetronomeBg.ts` / `useMetronome.ts` 中的 URL 即源信息,更换音色只需改 hook 里的常量。
|
||||||
|
|
||||||
|
## 准备步骤
|
||||||
|
|
||||||
|
### 1. 语音素材(小米 MiMo TTS 自动生成)
|
||||||
|
|
||||||
|
需要 Node 18+(用内置 `fetch`)。先准备小米 MiMo API Key:
|
||||||
|
|
||||||
|
- 文档: https://platform.xiaomimimo.com/
|
||||||
|
- API Key 形如 `sk-xxxxxxxx`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export MIMO_API_KEY=sk-your-key-here
|
||||||
|
|
||||||
|
cd uniapp
|
||||||
|
node scripts/generate-voice.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
会自动调用 `mimo-v2.5-tts` 模型生成 39 个 mp3(30 个数字 + 9 个口令)到 `voice/` 目录。
|
||||||
|
|
||||||
|
**可选参数**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 换音色(默认 冰糖;可选:冰糖/茉莉/苏打/白桦/Mia/Chloe/Milo/Dean)
|
||||||
|
node scripts/generate-voice.mjs --voice 茉莉
|
||||||
|
|
||||||
|
# 换格式(默认 mp3,需要跟 hooks/useVoiceCoach.ts 里的 .mp3 后缀对应)
|
||||||
|
node scripts/generate-voice.mjs --format wav
|
||||||
|
|
||||||
|
# 强制重新生成(默认存在则跳过)
|
||||||
|
node scripts/generate-voice.mjs --force
|
||||||
|
```
|
||||||
|
|
||||||
|
**音色推荐**(中文女声更适合健身教练):
|
||||||
|
- `冰糖`:温柔甜美,亲和力强(默认)
|
||||||
|
- `茉莉`:清爽利落,有"运动博主"感
|
||||||
|
- `苏打`:年轻男声,有力量感
|
||||||
|
- `白桦`:成熟男声,沉稳
|
||||||
|
|
||||||
|
### 2. 节拍器 click / 循环音轨
|
||||||
|
|
||||||
|
**全部托管在腾讯云 COS(2026-05-26 上传)**,本地不再保留:
|
||||||
|
|
||||||
|
| 用途 | 文件 | 大小 | 引擎 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 单拍 click(基础) | `click.mp3` | 3.4 KB | InnerAudio |
|
||||||
|
| 单拍 click(木鱼,推荐) | `click-wood.mp3` | 4.6 KB | InnerAudio (custom 模式) |
|
||||||
|
| 循环音轨 慢走 80 BPM | `loop_80bpm_2.mp3` | 71 KB | BgAudio (preset) |
|
||||||
|
| 循环音轨 健走 110 BPM | `loop_110bpm_2.mp3` | 65 KB | BgAudio (preset) |
|
||||||
|
| 循环音轨 快走 130 BPM | `loop_130bpm_2.mp3` | 66 KB | BgAudio (preset) |
|
||||||
|
|
||||||
|
> 微信 `BackgroundAudioManager.src` 只接受 https URL,不支持包内资源,所以必须走 CDN。
|
||||||
|
> COS 域名已加入小程序后台 `downloadFile 合法域名`,跑步视频也走同一域名。
|
||||||
|
|
||||||
|
更换音色:去 [pixabay.com/sound-effects](https://pixabay.com/sound-effects/) 找新素材 → 上传到 COS → 在 `useMetronomeBg.ts` / `useMetronome.ts` 里替换 URL 即可。
|
||||||
|
|
||||||
|
### 3. 背景音乐
|
||||||
|
|
||||||
|
每首 30 秒~2 分钟即可(loop 后听不出接缝)。
|
||||||
|
|
||||||
|
推荐来源(免费可商用):
|
||||||
|
- [pixabay.com/music](https://pixabay.com/music) 搜 "calm" / "meditation" / "lofi"
|
||||||
|
- [freemusicarchive.org](https://freemusicarchive.org)
|
||||||
|
- [bensound.com](https://bensound.com)(含署名)
|
||||||
|
|
||||||
|
文件大小建议 < 1MB(mp3 128kbps 单声道即可)。
|
||||||
|
|
||||||
|
## 在代码里被引用的位置
|
||||||
|
|
||||||
|
- `training/hooks/useMetronome.ts` → `DEFAULT_CLICK_SRC` (CDN: click.mp3)
|
||||||
|
- `training/hooks/useMetronomeBg.ts` → `LOOP_PRESETS[*].src` (CDN: loop_*.mp3)
|
||||||
|
- `training/pages/metronome.vue` → `CLICK_WOOD_URL` (CDN: click-wood.mp3, custom 模式)
|
||||||
|
- `training/hooks/useVoiceCoach.ts` → `voice/numbers/*.mp3`、`voice/prompts/*.mp3` (规划中)
|
||||||
|
- `training/hooks/useTrainingBgm.ts` → `bgm/*.mp3` (规划中)
|
||||||
|
|
||||||
|
如需修改路径,修改对应 hook 文件里的常量即可。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 微信小程序对单个文件大小有 10MB 上限,本目录所有文件加起来建议控制在 5MB 以内。
|
||||||
|
- 小程序整包大小限制(主包 2MB / 总包 20MB),如果资源较大建议放 CDN 而非本地 static。
|
||||||
|
- 把 `useVoiceCoach.ts` 里的 `VOICE_BASE` 改成 CDN URL 即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 后台/锁屏播放设计备忘(未来健身模块用)
|
||||||
|
|
||||||
|
> 节拍器目前用 `InnerAudioContext` + `requiredBackgroundModes:["audio"]` 已能覆盖 5~10 分钟健走场景。
|
||||||
|
> 真要做长时间训练 BGM、锁屏控制条等高级能力,参考下面的 `BackgroundAudioManager` 方案。
|
||||||
|
|
||||||
|
### 引擎选择对照表
|
||||||
|
|
||||||
|
健身模块的音频天然分两类,配两套引擎不冲突:
|
||||||
|
|
||||||
|
| 音频类型 | 时长 | 推荐引擎 | 理由 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 训练/休息 BGM | 30s~2min 循环 | **BackgroundAudioManager** | 长流、需要后台/锁屏不停 |
|
||||||
|
| 语音教练("开始/休息/再来") | 1~3s 单句 | InnerAudioContext | 短促,前台用即可 |
|
||||||
|
| 报数(1, 2, 3...) | 0.5~1s | InnerAudioContext + 池子 | 高频短促 |
|
||||||
|
| 节拍器 click | 50~150ms | InnerAudioContext + 池子 | 极短,BgAudio 不接受 |
|
||||||
|
|
||||||
|
**关键约束:BgAudio 是全局单例,一次只能播 1 个音频**。所以让它专门播 BGM 这类"长流",其他短音用 InnerAudio 配合,互不打架。
|
||||||
|
|
||||||
|
### BackgroundAudioManager 限制清单(踩坑预警)
|
||||||
|
|
||||||
|
1. **全局单例**:整个小程序同一时刻只能播一个,多场景要协调切换
|
||||||
|
2. **音频时长 ≥ 1 秒**:太短的 click 会被微信判定异常忽略
|
||||||
|
3. **必填 metadata**:`title` / `coverImgUrl` / `singer` / `epname` / `webUrl` 缺一会报错
|
||||||
|
4. **切 src 有延迟**:200~500ms 初始化抖动,频繁切换会卡顿
|
||||||
|
5. **必须声明 `requiredBackgroundModes:["audio"]`** 才能后台播放
|
||||||
|
6. **iOS 锁屏豁免**:声明后能锁屏继续播,无 5 分钟时长限制(vs InnerAudio 的 5min)
|
||||||
|
7. **会显示系统控制条**:锁屏/通知栏出现带封面+暂停按钮的控制条
|
||||||
|
|
||||||
|
### 推荐架构(健身模块上线时)
|
||||||
|
|
||||||
|
```
|
||||||
|
training/hooks/
|
||||||
|
├── useTrainingBgm.ts → BackgroundAudioManager (后台/锁屏继续放音乐)
|
||||||
|
├── useVoiceCoach.ts → InnerAudioContext (语音指导,前台用)
|
||||||
|
└── useMetronome.ts → InnerAudioContext (节拍器,池子方案,保持现状)
|
||||||
|
```
|
||||||
|
|
||||||
|
页面 `pages.json` 声明:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"path": "pages/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "练一练",
|
||||||
|
"requiredBackgroundModes": ["audio"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
锁屏会显示 BGM 控制条,老人能直接在锁屏点暂停。训练页面通过 `bgm.onPause()` 监听同步暂停训练,体验连贯。
|
||||||
|
|
||||||
|
### 必备资产清单
|
||||||
|
|
||||||
|
到时候要准备的文件:
|
||||||
|
|
||||||
|
- **BGM 2 首**:`bgm/train-light.mp3`(训练)、`bgm/rest-meditation.mp3`(休息)
|
||||||
|
- 每个 30s~1min,128kbps 单声道,30~80KB
|
||||||
|
- 推荐来源:[pixabay.com/music](https://pixabay.com/music) 搜 "calm" / "meditation" / "lofi"
|
||||||
|
- **锁屏封面**:`audio/cover.jpg` 200×200
|
||||||
|
- 简洁绿色背景 + 训练 emoji 即可
|
||||||
|
|
||||||
|
### BackgroundAudioManager API 速查
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const bgm = uni.getBackgroundAudioManager()
|
||||||
|
|
||||||
|
/* 必填 metadata,缺一会报错 */
|
||||||
|
bgm.title = '健走训练中'
|
||||||
|
bgm.coverImgUrl = '/training/static/audio/cover.jpg'
|
||||||
|
bgm.epname = '甄养堂'
|
||||||
|
bgm.singer = '健走节拍'
|
||||||
|
bgm.webUrl = '' // 必填,空字符串可
|
||||||
|
|
||||||
|
/* src 一旦赋值会自动播放 */
|
||||||
|
bgm.src = '/training/static/audio/bgm/train-light.mp3'
|
||||||
|
|
||||||
|
/* 控制 */
|
||||||
|
bgm.pause() // 暂停 (锁屏控制条仍在)
|
||||||
|
bgm.play() // 继续
|
||||||
|
bgm.stop() // 真停 + 隐藏控制条
|
||||||
|
bgm.seek(30) // 跳到 30s
|
||||||
|
|
||||||
|
/* 事件监听 — 用户从锁屏点暂停时会触发 */
|
||||||
|
bgm.onPause(() => { /* 同步训练 UI 状态 */ })
|
||||||
|
bgm.onPlay(() => {})
|
||||||
|
bgm.onStop(() => {})
|
||||||
|
bgm.onEnded(() => { /* 不 loop 时触发,可手动接下一首 */ })
|
||||||
|
bgm.onError((err) => { console.error(err) })
|
||||||
|
```
|
||||||
|
|
||||||
|
### 切换不同场景音乐的模式
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function switchBgm(scene: 'training' | 'resting' | 'none') {
|
||||||
|
if (scene === 'none') {
|
||||||
|
bgm.stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bgm.title = scene === 'training' ? '健走训练中' : '休息恢复中'
|
||||||
|
bgm.src = scene === 'training'
|
||||||
|
? '/training/static/audio/bgm/train-light.mp3'
|
||||||
|
: '/training/static/audio/bgm/rest-meditation.mp3'
|
||||||
|
/* 注:setSrc 会自动播放,有 200~500ms 切换延迟 */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 节拍器要不要也升级到 BgAudio?
|
||||||
|
|
||||||
|
**目前不需要**。节拍器升级 BgAudio 的代价:
|
||||||
|
- 需要预合成 3 个档位的循环 mp3(80/110/130 BPM × 2 拍)
|
||||||
|
- 必须砍掉右下角微调按钮(预合成 mp3 改不了 BPM)
|
||||||
|
- 切档位有 200~500ms 卡顿
|
||||||
|
|
||||||
|
如果未来发现"健走 30 分钟以上锁屏会停"才考虑改。当前 5~10 分钟场景 InnerAudio + `requiredBackgroundModes` 够用。
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 150">
|
||||||
|
<g fill="#e2e8f0" fill-opacity="0.92">
|
||||||
|
<path d="M 50,30 C 38,30 28,32 24,40 C 19,50 18,60 22,70 C 26,82 26,92 28,102 C 30,122 40,140 50,140 C 60,140 70,122 72,102 C 74,92 74,82 78,70 C 82,60 81,50 76,40 C 72,32 62,30 50,30 Z"/>
|
||||||
|
<ellipse cx="30" cy="20" rx="6.5" ry="8" transform="rotate(-18 30 20)"/>
|
||||||
|
<ellipse cx="43" cy="11" rx="5" ry="6.8" transform="rotate(-6 43 11)"/>
|
||||||
|
<ellipse cx="55" cy="9" rx="4.5" ry="6.2" transform="rotate(4 55 9)"/>
|
||||||
|
<ellipse cx="66" cy="13" rx="4" ry="5.5" transform="rotate(13 66 13)"/>
|
||||||
|
<ellipse cx="76" cy="20" rx="3.5" ry="5" transform="rotate(22 76 20)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 730 B |
@@ -0,0 +1,29 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
// 账号管理 API
|
||||||
|
export function apiAssetUserList(params: any) {
|
||||||
|
return request.get({ url: '/asset.AssetUser/lists', params })
|
||||||
|
}
|
||||||
|
export function apiAssetUserAdd(params: any) {
|
||||||
|
return request.post({ url: '/asset.AssetUser/add', params })
|
||||||
|
}
|
||||||
|
export function apiAssetUserEdit(params: any) {
|
||||||
|
return request.post({ url: '/asset.AssetUser/edit', params })
|
||||||
|
}
|
||||||
|
export function apiAssetUserDelete(params: any) {
|
||||||
|
return request.post({ url: '/asset.AssetUser/delete', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 资源管理 API
|
||||||
|
export function apiAssetResourceList(params: any) {
|
||||||
|
return request.get({ url: '/asset.AssetResource/lists', params })
|
||||||
|
}
|
||||||
|
export function apiAssetResourceAdd(params: any) {
|
||||||
|
return request.post({ url: '/asset.AssetResource/add', params })
|
||||||
|
}
|
||||||
|
export function apiAssetResourceEdit(params: any) {
|
||||||
|
return request.post({ url: '/asset.AssetResource/edit', params })
|
||||||
|
}
|
||||||
|
export function apiAssetResourceDelete(params: any) {
|
||||||
|
return request.post({ url: '/asset.AssetResource/delete', params })
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
export function getSelfInputOverview(params: any) {
|
||||||
|
return request.get({ url: '/stats.self_input/overview', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 自媒体来源下拉(业绩+账户消耗已录入值去重) */
|
||||||
|
export function getSelfInputMediaSourceOptions() {
|
||||||
|
return request.get({ url: '/stats.self_input/mediaSourceOptions' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalYejiLists(params: any) {
|
||||||
|
return request.get({ url: '/stats.personal_yeji/lists', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalYejiAdd(data: any) {
|
||||||
|
return request.post({ url: '/stats.personal_yeji/add', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalYejiEdit(data: any) {
|
||||||
|
return request.post({ url: '/stats.personal_yeji/edit', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalYejiDetail(params: any) {
|
||||||
|
return request.get({ url: '/stats.personal_yeji/detail', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalYejiDelete(params: any) {
|
||||||
|
return request.post({ url: '/stats.personal_yeji/delete', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalAccountCostLists(params: any) {
|
||||||
|
return request.get({ url: '/stats.personal_account_cost/lists', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalAccountCostAdd(data: any) {
|
||||||
|
return request.post({ url: '/stats.personal_account_cost/add', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalAccountCostEdit(data: any) {
|
||||||
|
return request.post({ url: '/stats.personal_account_cost/edit', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalAccountCostDetail(params: any) {
|
||||||
|
return request.get({ url: '/stats.personal_account_cost/detail', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalAccountCostDelete(params: any) {
|
||||||
|
return request.post({ url: '/stats.personal_account_cost/delete', params })
|
||||||
|
}
|
||||||
@@ -179,3 +179,12 @@ export function commissionSettlementConfirmFinalize(params: Record<string, any>)
|
|||||||
export function commissionSettlementConfirmRevoke(params: Record<string, any>) {
|
export function commissionSettlementConfirmRevoke(params: Record<string, any>) {
|
||||||
return request.post({ url: '/stats.commissionSettlement/confirmRevoke', params })
|
return request.post({ url: '/stats.commissionSettlement/confirmRevoke', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 医助个人业绩概览 */
|
||||||
|
export function assistantPerformanceOverview(params: {
|
||||||
|
time_type?: string
|
||||||
|
start_date?: string
|
||||||
|
end_date?: string
|
||||||
|
}) {
|
||||||
|
return request.get({ url: '/stats.assistantPerformance/overview', params })
|
||||||
|
}
|
||||||
|
|||||||
+27
-1
@@ -319,6 +319,16 @@ export function prescriptionEdit(params: any) {
|
|||||||
return request.post({ url: '/tcm.prescription/edit', params })
|
return request.post({ url: '/tcm.prescription/edit', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 仅修正处方笺患者姓名、手机号与性别(不改审核状态),写入业务订单日志表 */
|
||||||
|
export function prescriptionPatchPatient(params: {
|
||||||
|
id: number
|
||||||
|
patient_name: string
|
||||||
|
phone: string
|
||||||
|
gender: number
|
||||||
|
}) {
|
||||||
|
return request.post({ url: '/tcm.prescription/patchPatient', params })
|
||||||
|
}
|
||||||
|
|
||||||
// 删除处方
|
// 删除处方
|
||||||
export function prescriptionDelete(params: { id: number }) {
|
export function prescriptionDelete(params: { id: number }) {
|
||||||
return request.post({ url: '/tcm.prescription/delete', params })
|
return request.post({ url: '/tcm.prescription/delete', params })
|
||||||
@@ -452,15 +462,26 @@ export function prescriptionOrderAddPayOrder(params: {
|
|||||||
order_type: number
|
order_type: number
|
||||||
pay_amount: number
|
pay_amount: number
|
||||||
pay_remark?: string
|
pay_remark?: string
|
||||||
|
completion_request?: number
|
||||||
}) {
|
}) {
|
||||||
return request.post({ url: '/tcm.prescriptionOrder/addPayOrder', params })
|
return request.post({ url: '/tcm.prescriptionOrder/addPayOrder', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 为「已发货」订单关联已有支付单并重置支付审核为待审核 */
|
/** 为「已发货」订单关联已有支付单并重置支付审核为待审核 */
|
||||||
export function prescriptionOrderLinkPayOrder(params: { id: number; pay_order_id: number }) {
|
export function prescriptionOrderLinkPayOrder(params: {
|
||||||
|
id: number
|
||||||
|
pay_order_id?: number
|
||||||
|
pay_order_ids?: number[]
|
||||||
|
completion_request?: number
|
||||||
|
}) {
|
||||||
return request.post({ url: '/tcm.prescriptionOrder/linkPayOrder', params })
|
return request.post({ url: '/tcm.prescriptionOrder/linkPayOrder', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 已发货/已签收:仅提交完单申请(不新增/关联支付单) */
|
||||||
|
export function prescriptionOrderRequestCompletion(params: { id: number }) {
|
||||||
|
return request.post({ url: '/tcm.prescriptionOrder/requestCompletion', params })
|
||||||
|
}
|
||||||
|
|
||||||
/** 将「已发货/已签收」且支付审核通过的订单结案(3=已完成 或 7-12 业务状态) */
|
/** 将「已发货/已签收」且支付审核通过的订单结案(3=已完成 或 7-12 业务状态) */
|
||||||
export function prescriptionOrderComplete(params: { id: number; fulfillment_status: number }) {
|
export function prescriptionOrderComplete(params: { id: number; fulfillment_status: number }) {
|
||||||
return request.post({ url: '/tcm.prescriptionOrder/complete', params })
|
return request.post({ url: '/tcm.prescriptionOrder/complete', params })
|
||||||
@@ -490,6 +511,11 @@ export function prescriptionOrderUpdateAmount(params: { id: number; amount: numb
|
|||||||
return request.post({ url: '/tcm.prescriptionOrder/updateAmount', params })
|
return request.post({ url: '/tcm.prescriptionOrder/updateAmount', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 设置发货类型:gancao 甘草药房 / direct 洛阳药房 */
|
||||||
|
export function prescriptionOrderSetShipMode(params: { id: number; ship_mode: 'gancao' | 'direct' }) {
|
||||||
|
return request.post({ url: '/tcm.prescriptionOrder/setShipMode', params })
|
||||||
|
}
|
||||||
|
|
||||||
// ========== 处方库 ==========
|
// ========== 处方库 ==========
|
||||||
|
|
||||||
// 处方库列表
|
// 处方库列表
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -102,8 +102,8 @@ export default defineComponent({
|
|||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
const fileList = ref<any[]>([])
|
const fileList = ref<any[]>([])
|
||||||
|
|
||||||
// 仅 video + direct 时才接管 http-request
|
// 仅 video/voice + direct 时才接管 http-request
|
||||||
const useDirect = computed(() => props.direct && props.type === 'video')
|
const useDirect = computed(() => props.direct && ['video', 'voice'].includes(props.type))
|
||||||
|
|
||||||
const handleProgress = () => {
|
const handleProgress = () => {
|
||||||
visible.value = true
|
visible.value = true
|
||||||
@@ -151,6 +151,8 @@ export default defineComponent({
|
|||||||
return '.jpg,.png,.gif,.jpeg,.ico'
|
return '.jpg,.png,.gif,.jpeg,.ico'
|
||||||
case 'video':
|
case 'video':
|
||||||
return '.wmv,.avi,.mpg,.mpeg,.3gp,.mov,.mp4,.flv,.rmvb,.mkv'
|
return '.wmv,.avi,.mpg,.mpeg,.3gp,.mov,.mp4,.flv,.rmvb,.mkv'
|
||||||
|
case 'voice':
|
||||||
|
return '.mp3,.wav,.wma,.m4a,.aac,.amr'
|
||||||
default:
|
default:
|
||||||
return '*'
|
return '*'
|
||||||
}
|
}
|
||||||
@@ -162,7 +164,7 @@ export default defineComponent({
|
|||||||
try {
|
try {
|
||||||
const data = await uploadVideoDirectToCos({
|
const data = await uploadVideoDirectToCos({
|
||||||
file: options.file,
|
file: options.file,
|
||||||
type: 'video',
|
type: props.type as any,
|
||||||
cid: Number((options.data as any)?.cid ?? 0),
|
cid: Number((options.data as any)?.cid ?? 0),
|
||||||
onProgress(info) {
|
onProgress(info) {
|
||||||
// 触发 ElUpload 内部进度(保持与默认上传一致的体验)
|
// 触发 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>
|
||||||
@@ -216,6 +216,15 @@
|
|||||||
<el-button type="primary" link @click="handleView(row)" v-perms="['cf.prescription/read']">
|
<el-button type="primary" link @click="handleView(row)" v-perms="['cf.prescription/read']">
|
||||||
查看
|
查看
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="Number(row.void_status) !== 1"
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
v-perms="['tcm.prescription/patchPatient']"
|
||||||
|
@click="openPatchPatientDialog(row)"
|
||||||
|
>
|
||||||
|
改姓名手机
|
||||||
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="!Number(row.has_prescription_order) && Number(row.void_status) !== 1"
|
v-if="!Number(row.has_prescription_order) && Number(row.void_status) !== 1"
|
||||||
type="success"
|
type="success"
|
||||||
@@ -398,20 +407,35 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rx-herbs">
|
<div class="rx-herbs">
|
||||||
<div
|
<template v-if="slipMainHerbs.length">
|
||||||
v-for="(h, i) in slipHerbsList"
|
<div class="rx-herb-section-label">主方</div>
|
||||||
:key="i"
|
<div
|
||||||
class="rx-herb-cell"
|
v-for="(h, i) in slipMainHerbs"
|
||||||
>
|
:key="'main-' + i"
|
||||||
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }}克)</span>
|
class="rx-herb-cell"
|
||||||
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}克</span>
|
>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- 服法 / 医嘱 / 备注 / 药房备注 / 出丸 -->
|
<!-- 服法 / 医嘱 / 备注 / 药房备注 / 出丸 -->
|
||||||
<div class="rx-text">
|
<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="rxAdviceText">医嘱:{{ rxAdviceText }}</p>
|
||||||
<p v-if="rxRemarkText">备注:{{ rxRemarkText }}</p>
|
<p v-if="rxRemarkText">备注:{{ rxRemarkText }}</p>
|
||||||
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">
|
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">
|
||||||
@@ -640,53 +664,87 @@
|
|||||||
<el-form-item label="药材配方" prop="herbs" required>
|
<el-form-item label="药材配方" prop="herbs" required>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<el-button type="primary" size="small" @click="addHerb">
|
<el-button
|
||||||
添加药材
|
type="success"
|
||||||
</el-button>
|
size="small"
|
||||||
<el-button type="success" size="small" @click="openLibraryDialog">
|
v-perms="['cf.prescription/add', 'cf.prescription/edit', 'tcm.prescriptionLibrary/lists']"
|
||||||
|
@click="openLibraryDialog"
|
||||||
|
>
|
||||||
从处方库导入
|
从处方库导入
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
|
||||||
<div class="mt-2">
|
|
||||||
<el-button type="warning" size="small" plain :icon="EditPen" @click="openPasteRecipeDialog">
|
<el-button type="warning" size="small" plain :icon="EditPen" @click="openPasteRecipeDialog">
|
||||||
导入药方
|
导入药方
|
||||||
</el-button>
|
</el-button>
|
||||||
<span class="text-xs text-gray-500 ml-2 align-middle">
|
<span class="text-xs text-gray-500">
|
||||||
粘贴文本解析药名与剂量;须与药品库名称完全一致才录入
|
粘贴文本解析药名与剂量;须与药品库名称完全一致才录入(导入至主方)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="editForm.herbs" class="mt-2" border>
|
|
||||||
<el-table-column label="序号" type="index" width="60" />
|
<div class="herb-formula-block mt-3">
|
||||||
<el-table-column label="药材名称" min-width="220">
|
<div class="herb-formula-block__head">
|
||||||
<template #default="{ row }">
|
<el-tag type="primary" size="small">主方</el-tag>
|
||||||
<MedicineNameSelect v-model="row.name" class="w-full" />
|
<el-button type="primary" size="small" @click="addHerb('主方')">添加主方药材</el-button>
|
||||||
</template>
|
</div>
|
||||||
</el-table-column>
|
<el-table :data="mainHerbRows" class="mt-2" border empty-text="暂无主方药材">
|
||||||
<el-table-column label="剂量(克)" min-width="120">
|
<el-table-column label="序号" type="index" width="60" />
|
||||||
<template #default="{ row, $index }">
|
<el-table-column label="药材名称" min-width="220">
|
||||||
<el-input-number
|
<template #default="{ row }">
|
||||||
v-model="row.dosage"
|
<MedicineNameSelect v-model="editForm.herbs[row.index].name" class="w-full" />
|
||||||
:min="0"
|
</template>
|
||||||
:precision="1"
|
</el-table-column>
|
||||||
:step="0.5"
|
<el-table-column label="剂量(克)" min-width="120">
|
||||||
placeholder="剂量"
|
<template #default="{ row }">
|
||||||
class="w-full"
|
<el-input-number
|
||||||
/>
|
v-model="editForm.herbs[row.index].dosage"
|
||||||
</template>
|
:min="0"
|
||||||
</el-table-column>
|
:precision="1"
|
||||||
<el-table-column label="操作" width="80">
|
:step="0.5"
|
||||||
<template #default="{ $index }">
|
placeholder="剂量"
|
||||||
<el-button
|
class="w-full"
|
||||||
type="danger"
|
/>
|
||||||
link
|
</template>
|
||||||
@click="removeHerb($index)"
|
</el-table-column>
|
||||||
>
|
<el-table-column label="操作" width="80">
|
||||||
删除
|
<template #default="{ row }">
|
||||||
</el-button>
|
<el-button type="danger" link @click="removeHerb(row.index)">删除</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
</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
|
<el-alert
|
||||||
v-if="editMode === 'edit'"
|
v-if="editMode === 'edit'"
|
||||||
type="warning"
|
type="warning"
|
||||||
@@ -769,8 +827,10 @@
|
|||||||
<el-option label="汤剂" value="汤剂" />
|
<el-option label="汤剂" value="汤剂" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item> </el-col>
|
</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-col :span="8">
|
||||||
<el-form-item label="用量" prop="dosage_amount">
|
<el-form-item label="用量" prop="dosage_amount">
|
||||||
<!-- 浓缩水丸:1-10g 下拉选择 -->
|
<!-- 浓缩水丸:1-10g 下拉选择 -->
|
||||||
@@ -872,6 +932,97 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</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-form-item label="用法" prop="usage_instruction">
|
||||||
<el-input
|
<el-input
|
||||||
@@ -980,6 +1131,48 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
|
|
||||||
|
<!-- 修正处方笺姓名 / 手机号(zyt_tcm_prescription,写入订单日志) -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="patchPatientVisible"
|
||||||
|
title="修正姓名、性别与手机号"
|
||||||
|
width="440px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
destroy-on-close
|
||||||
|
@closed="resetPatchPatientForm"
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
ref="patchPatientFormRef"
|
||||||
|
:model="patchPatientForm"
|
||||||
|
:rules="patchPatientRules"
|
||||||
|
label-width="88px"
|
||||||
|
>
|
||||||
|
<el-form-item label="处方编号">
|
||||||
|
<span class="text-gray-700">#{{ patchPatientForm.id || '—' }}</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="患者姓名" prop="patient_name">
|
||||||
|
<el-input v-model="patchPatientForm.patient_name" maxlength="50" show-word-limit placeholder="处方笺展示姓名" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="性别" prop="gender">
|
||||||
|
<el-radio-group v-model="patchPatientForm.gender">
|
||||||
|
<el-radio :label="1">男</el-radio>
|
||||||
|
<el-radio :label="0">女</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="手机号" prop="phone">
|
||||||
|
<el-input v-model="patchPatientForm.phone" maxlength="20" placeholder="处方笺展示手机号" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<p class="text-xs text-gray-500 -mt-2 mb-2">
|
||||||
|
仅更新处方表 patient_name、gender、phone,不改变审核状态;记录写入业务订单操作日志(有关联订单时可在此订单日志中查看)。
|
||||||
|
</p>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="patchPatientVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="patchPatientSubmitLoading" @click="submitPatchPatient">
|
||||||
|
保存
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 从消费者处方创建业务订单(zyt_tcm_prescription_order,与支付单 zyt_order 分离) -->
|
<!-- 从消费者处方创建业务订单(zyt_tcm_prescription_order,与支付单 zyt_order 分离) -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="createOrderVisible"
|
v-model="createOrderVisible"
|
||||||
@@ -1314,16 +1507,17 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 从处方库导入(与诊间 tcm-prescription 一致,按当前处方开方医师 creator_id 筛选) -->
|
<!-- 从处方库导入:当前开方医师的全部模板 + 所有人可见的公共模板 -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="showLibraryDialog"
|
v-model="showLibraryDialog"
|
||||||
title="从处方库导入"
|
title="从处方库导入"
|
||||||
width="800px"
|
width="800px"
|
||||||
:close-on-click-modal="false"
|
:close-on-click-modal="false"
|
||||||
>
|
>
|
||||||
<div class="mb-4">
|
<div class="mb-4 flex gap-3">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="librarySearchName"
|
v-model="librarySearchName"
|
||||||
|
class="flex-1"
|
||||||
placeholder="请输入处方名称搜索"
|
placeholder="请输入处方名称搜索"
|
||||||
clearable
|
clearable
|
||||||
@keyup.enter="searchLibrary"
|
@keyup.enter="searchLibrary"
|
||||||
@@ -1333,6 +1527,24 @@
|
|||||||
<el-button :icon="Search" @click="searchLibrary" />
|
<el-button :icon="Search" @click="searchLibrary" />
|
||||||
</template>
|
</template>
|
||||||
</el-input>
|
</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>
|
</div>
|
||||||
|
|
||||||
<el-table
|
<el-table
|
||||||
@@ -1343,6 +1555,13 @@
|
|||||||
@row-click="handleSelectLibraryRow"
|
@row-click="handleSelectLibraryRow"
|
||||||
>
|
>
|
||||||
<el-table-column label="处方名称" prop="prescription_name" min-width="150" show-overflow-tooltip />
|
<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">
|
<el-table-column label="药材数量" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ row.herbs?.length || 0 }}味
|
{{ row.herbs?.length || 0 }}味
|
||||||
@@ -1356,6 +1575,13 @@
|
|||||||
<span v-else class="text-gray-400">暂无药材</span>
|
<span v-else class="text-gray-400">暂无药材</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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="创建人" prop="creator_name" width="100" />
|
||||||
<el-table-column label="操作" width="100" fixed="right">
|
<el-table-column label="操作" width="100" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -1444,6 +1670,7 @@ import {
|
|||||||
prescriptionLists,
|
prescriptionLists,
|
||||||
prescriptionAdd,
|
prescriptionAdd,
|
||||||
prescriptionEdit,
|
prescriptionEdit,
|
||||||
|
prescriptionPatchPatient,
|
||||||
prescriptionDelete,
|
prescriptionDelete,
|
||||||
prescriptionAudit,
|
prescriptionAudit,
|
||||||
prescriptionDetail,
|
prescriptionDetail,
|
||||||
@@ -1470,6 +1697,119 @@ import jsPDF from 'jspdf'
|
|||||||
|
|
||||||
const TcmDiagnosisEditView = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
const TcmDiagnosisEditView = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
||||||
|
|
||||||
|
type FormulaType = '主方' | '辅方'
|
||||||
|
type 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 保持一致 */
|
/** 与 server/config/project.php prescription_audit_roles 保持一致 */
|
||||||
const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3]
|
const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3]
|
||||||
|
|
||||||
@@ -1531,6 +1871,66 @@ const auditTargetId = ref(0)
|
|||||||
const auditRemark = ref('')
|
const auditRemark = ref('')
|
||||||
const auditLoading = ref(false)
|
const auditLoading = ref(false)
|
||||||
|
|
||||||
|
/** 修正处方笺显示用姓名 / 手机号 */
|
||||||
|
const patchPatientVisible = ref(false)
|
||||||
|
const patchPatientSubmitLoading = ref(false)
|
||||||
|
const patchPatientFormRef = ref<FormInstance>()
|
||||||
|
const patchPatientForm = reactive({
|
||||||
|
id: 0,
|
||||||
|
patient_name: '',
|
||||||
|
gender: 1 as 0 | 1,
|
||||||
|
phone: ''
|
||||||
|
})
|
||||||
|
const patchPatientRules: FormRules = {
|
||||||
|
patient_name: [{ required: true, message: '请输入患者姓名', trigger: 'blur' }],
|
||||||
|
gender: [{ required: true, message: '请选择性别', trigger: 'change' }],
|
||||||
|
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPatchPatientDialog(row: Record<string, unknown>) {
|
||||||
|
patchPatientForm.id = Number(row.id) || 0
|
||||||
|
patchPatientForm.patient_name = String(row.patient_name ?? '').trim()
|
||||||
|
const g = Number(row.gender)
|
||||||
|
patchPatientForm.gender = g === 0 || g === 1 ? (g as 0 | 1) : 1
|
||||||
|
patchPatientForm.phone = String(row.phone ?? '').trim()
|
||||||
|
patchPatientVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPatchPatientForm() {
|
||||||
|
patchPatientForm.id = 0
|
||||||
|
patchPatientForm.patient_name = ''
|
||||||
|
patchPatientForm.gender = 1
|
||||||
|
patchPatientForm.phone = ''
|
||||||
|
patchPatientFormRef.value?.clearValidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPatchPatient() {
|
||||||
|
const form = patchPatientFormRef.value
|
||||||
|
if (!form) return
|
||||||
|
try {
|
||||||
|
await form.validate()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
patchPatientSubmitLoading.value = true
|
||||||
|
try {
|
||||||
|
await prescriptionPatchPatient({
|
||||||
|
id: patchPatientForm.id,
|
||||||
|
patient_name: patchPatientForm.patient_name.trim(),
|
||||||
|
phone: patchPatientForm.phone.trim(),
|
||||||
|
gender: patchPatientForm.gender
|
||||||
|
})
|
||||||
|
feedback.msgSuccess('已保存')
|
||||||
|
patchPatientVisible.value = false
|
||||||
|
getLists()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = e && typeof e === 'object' && 'msg' in e ? String((e as { msg?: string }).msg) : ''
|
||||||
|
feedback.msgError(msg || '保存失败')
|
||||||
|
} finally {
|
||||||
|
patchPatientSubmitLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 从消费者处方创建订单 */
|
/** 从消费者处方创建订单 */
|
||||||
const createOrderVisible = ref(false)
|
const createOrderVisible = ref(false)
|
||||||
/** 创建订单分步向导:0 患者与收货 / 1 服务与支付单 / 2 金额与确认 */
|
/** 创建订单分步向导:0 患者与收货 / 1 服务与支付单 / 2 金额与确认 */
|
||||||
@@ -1811,7 +2211,13 @@ const createOrderHerbSummary = computed(() => {
|
|||||||
const row = createOrderPrescription.value
|
const row = createOrderPrescription.value
|
||||||
const herbs = normalizeSlipHerbs(row?.herbs)
|
const herbs = normalizeSlipHerbs(row?.herbs)
|
||||||
if (!herbs.length) return '暂无药材明细'
|
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
|
return s.length > 120 ? `${s.slice(0, 120)}…` : s
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -2051,9 +2457,27 @@ const editReviveHint = computed(() => {
|
|||||||
|
|
||||||
const slipHerbsList = computed(() => {
|
const slipHerbsList = computed(() => {
|
||||||
const h = slipView.value?.herbs
|
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 slipDietaryText = computed(() => {
|
||||||
const d = slipView.value?.dietary_taboo
|
const d = slipView.value?.dietary_taboo
|
||||||
if (Array.isArray(d)) return d.filter(Boolean).join('、')
|
if (Array.isArray(d)) return d.filter(Boolean).join('、')
|
||||||
@@ -2139,23 +2563,26 @@ const rxUsageText = computed(() => {
|
|||||||
const v = slipView.value as any
|
const v = slipView.value as any
|
||||||
if (!v) return '—'
|
if (!v) return '—'
|
||||||
if (v.usage_text) return v.usage_text
|
if (v.usage_text) return v.usage_text
|
||||||
const times = Number(v.times_per_day) > 0 ? Number(v.times_per_day) : 3
|
return buildUsageSegmentText(v)
|
||||||
const amount = v.dosage_amount != null ? Number(v.dosage_amount) : 10
|
})
|
||||||
const unit = v.dosage_unit || 'g'
|
|
||||||
const usageWay = v.usage_way || '温水送服'
|
const rxAuxUsageText = computed(() => {
|
||||||
const usageTime = v.usage_time || ''
|
const v = slipView.value as any
|
||||||
const seg: string[] = []
|
if (!v || !slipAuxHerbs.value.length) return ''
|
||||||
seg.push(`每天${times}次`)
|
const aux = normalizeAuxUsageForm(v.aux_usage, v.prescription_type || '浓缩水丸')
|
||||||
if ((v.prescription_type || '浓缩水丸') === '浓缩水丸') {
|
return buildUsageSegmentText(
|
||||||
const bags = Number(v.dosage_bag_count) > 0 ? Number(v.dosage_bag_count) : 1
|
{
|
||||||
seg.push(`一次${bags}袋`)
|
prescription_type: v.prescription_type,
|
||||||
seg.push(`每袋${amount}${unit}`)
|
dosage_amount: aux.dosage_amount,
|
||||||
} else {
|
dosage_unit: v.dosage_unit,
|
||||||
seg.push(`一次${amount}${unit}`)
|
dosage_bag_count: aux.dosage_bag_count,
|
||||||
}
|
times_per_day: aux.times_per_day,
|
||||||
seg.push(usageWay)
|
usage_way: v.usage_way,
|
||||||
if (usageTime) seg.push(usageTime)
|
usage_time: v.usage_time
|
||||||
return seg.join(', ')
|
},
|
||||||
|
v.usage_way,
|
||||||
|
v.usage_time
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const rxAdviceText = computed(() => {
|
const rxAdviceText = computed(() => {
|
||||||
@@ -2350,7 +2777,7 @@ const editForm = reactive({
|
|||||||
pulse: '',
|
pulse: '',
|
||||||
pulse_condition: '',
|
pulse_condition: '',
|
||||||
clinical_diagnosis: '',
|
clinical_diagnosis: '',
|
||||||
herbs: [] as Array<{ name: string; dosage: number }>,
|
herbs: [] as HerbRow[],
|
||||||
dose_count: 7,
|
dose_count: 7,
|
||||||
dose_unit: '剂',
|
dose_unit: '剂',
|
||||||
usage_days: 7,
|
usage_days: 7,
|
||||||
@@ -2376,7 +2803,8 @@ const editForm = reactive({
|
|||||||
/** 列表带入:业务订单「处方审核」驳回(消费者处方本身可能仍为已通过) */
|
/** 列表带入:业务订单「处方审核」驳回(消费者处方本身可能仍为已通过) */
|
||||||
business_prescription_audit_rejected: 0,
|
business_prescription_audit_rejected: 0,
|
||||||
business_prescription_audit_remark: '',
|
business_prescription_audit_remark: '',
|
||||||
times_per_day: 2
|
times_per_day: 2,
|
||||||
|
aux_usage: defaultAuxUsage('浓缩水丸')
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 编辑页:关联业务订单上的服用天数、医助备注(与处方提示一致) */
|
/** 编辑页:关联业务订单上的服用天数、医助备注(与处方提示一致) */
|
||||||
@@ -2467,6 +2895,7 @@ watch(() => editForm.prescription_type, (newType) => {
|
|||||||
editForm.need_decoction = false
|
editForm.need_decoction = false
|
||||||
editForm.bags_per_dose = 1
|
editForm.bags_per_dose = 1
|
||||||
}
|
}
|
||||||
|
Object.assign(editForm.aux_usage, defaultAuxUsage(newType))
|
||||||
})
|
})
|
||||||
|
|
||||||
// 表单验证规则
|
// 表单验证规则
|
||||||
@@ -2695,15 +3124,15 @@ function slipAgeText(age: unknown) {
|
|||||||
return `${age}岁`
|
return `${age}岁`
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSlipHerbs(raw: unknown): Array<{ name: string; dosage: number }> {
|
function herbValidationLabel(globalIndex: number): string {
|
||||||
if (!raw) return []
|
const herb = editForm.herbs[globalIndex]
|
||||||
if (Array.isArray(raw)) {
|
if (!herb) return `第${globalIndex + 1}味`
|
||||||
return raw.map((x: any) => ({
|
const ft = normalizeFormulaType(herb.formula_type)
|
||||||
name: String(x?.name ?? '').trim(),
|
let n = 0
|
||||||
dosage: Number(x?.dosage) || 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[] {
|
function listRxOrderWarnings(row: any): string[] {
|
||||||
@@ -2926,6 +3355,8 @@ const showLibraryDialog = ref(false)
|
|||||||
const libraryLoading = ref(false)
|
const libraryLoading = ref(false)
|
||||||
const libraryList = ref<any[]>([])
|
const libraryList = ref<any[]>([])
|
||||||
const librarySearchName = ref('')
|
const librarySearchName = ref('')
|
||||||
|
const librarySearchFormulaType = ref('')
|
||||||
|
const libraryImportMode = ref<'replace' | 'append'>('replace')
|
||||||
const libraryPage = ref(1)
|
const libraryPage = ref(1)
|
||||||
const libraryPageSize = ref(15)
|
const libraryPageSize = ref(15)
|
||||||
const libraryTotal = ref(0)
|
const libraryTotal = ref(0)
|
||||||
@@ -2970,8 +3401,8 @@ const loadLibraryList = async () => {
|
|||||||
page_no: libraryPage.value,
|
page_no: libraryPage.value,
|
||||||
page_size: libraryPageSize.value,
|
page_size: libraryPageSize.value,
|
||||||
prescription_name: librarySearchName.value,
|
prescription_name: librarySearchName.value,
|
||||||
is_public: '',
|
formula_type: librarySearchFormulaType.value,
|
||||||
creator_id: doctorId
|
prescribing_creator_id: doctorId
|
||||||
})
|
})
|
||||||
libraryList.value = res?.lists || []
|
libraryList.value = res?.lists || []
|
||||||
libraryTotal.value = res?.count || 0
|
libraryTotal.value = res?.count || 0
|
||||||
@@ -3006,18 +3437,33 @@ const handleImportLibrary = (row: any) => {
|
|||||||
feedback.msgWarning('该处方没有药材信息')
|
feedback.msgWarning('该处方没有药材信息')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const imported = JSON.parse(JSON.stringify(row.herbs)) as Array<{ name: string; dosage: number }>
|
const formulaType = normalizeFormulaType(row.formula_type)
|
||||||
editForm.herbs = imported
|
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) {
|
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
|
showLibraryDialog.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(showLibraryDialog, (open) => {
|
watch(showLibraryDialog, (open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
librarySearchName.value = ''
|
librarySearchName.value = ''
|
||||||
|
librarySearchFormulaType.value = ''
|
||||||
|
libraryImportMode.value = 'replace'
|
||||||
libraryPage.value = 1
|
libraryPage.value = 1
|
||||||
loadLibraryList()
|
loadLibraryList()
|
||||||
}
|
}
|
||||||
@@ -3149,7 +3595,7 @@ async function handlePasteRecipeImport() {
|
|||||||
}
|
}
|
||||||
pasteRecipeImportLoading.value = true
|
pasteRecipeImportLoading.value = true
|
||||||
try {
|
try {
|
||||||
const resolved: Array<{ name: string; dosage: number }> = []
|
const resolved: HerbRow[] = []
|
||||||
const skippedNames: string[] = []
|
const skippedNames: string[] = []
|
||||||
for (const row of parsed) {
|
for (const row of parsed) {
|
||||||
const name = await resolvePasteHerbNameFromLibrary(row.name)
|
const name = await resolvePasteHerbNameFromLibrary(row.name)
|
||||||
@@ -3157,7 +3603,7 @@ async function handlePasteRecipeImport() {
|
|||||||
skippedNames.push(row.name.trim())
|
skippedNames.push(row.name.trim())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
resolved.push({ name, dosage: row.dosage })
|
resolved.push({ name, dosage: row.dosage, formula_type: '主方' })
|
||||||
}
|
}
|
||||||
const skippedUnique = [...new Set(skippedNames.filter(Boolean))]
|
const skippedUnique = [...new Set(skippedNames.filter(Boolean))]
|
||||||
if (resolved.length === 0) {
|
if (resolved.length === 0) {
|
||||||
@@ -3169,7 +3615,8 @@ async function handlePasteRecipeImport() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (pasteRecipeImportMode.value === 'replace') {
|
if (pasteRecipeImportMode.value === 'replace') {
|
||||||
editForm.herbs = resolved
|
const auxKept = editForm.herbs.filter((h) => normalizeFormulaType(h.formula_type) === '辅方')
|
||||||
|
editForm.herbs = [...auxKept, ...resolved]
|
||||||
} else {
|
} else {
|
||||||
editForm.herbs.push(...resolved)
|
editForm.herbs.push(...resolved)
|
||||||
}
|
}
|
||||||
@@ -3194,11 +3641,12 @@ async function handlePasteRecipeImport() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加药材
|
// 添加药材(主方 / 辅方)
|
||||||
const addHerb = () => {
|
const addHerb = (formulaType: FormulaType = '主方') => {
|
||||||
editForm.herbs.push({
|
editForm.herbs.push({
|
||||||
name: '',
|
name: '',
|
||||||
dosage: 0
|
dosage: 0,
|
||||||
|
formula_type: formulaType
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3248,6 +3696,8 @@ const resetForm = () => {
|
|||||||
editForm.is_system_auto = 0
|
editForm.is_system_auto = 0
|
||||||
editForm.business_prescription_audit_rejected = 0
|
editForm.business_prescription_audit_rejected = 0
|
||||||
editForm.business_prescription_audit_remark = ''
|
editForm.business_prescription_audit_remark = ''
|
||||||
|
editForm.times_per_day = 2
|
||||||
|
Object.assign(editForm.aux_usage, defaultAuxUsage('浓缩水丸'))
|
||||||
clearRxLinkedOrderHint()
|
clearRxLinkedOrderHint()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3346,7 +3796,7 @@ const handleEdit = async (row: any) => {
|
|||||||
editForm.pulse = src.pulse || ''
|
editForm.pulse = src.pulse || ''
|
||||||
editForm.pulse_condition = src.pulse_condition || ''
|
editForm.pulse_condition = src.pulse_condition || ''
|
||||||
editForm.clinical_diagnosis = src.clinical_diagnosis || ''
|
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_count = src.dose_count ?? 7
|
||||||
editForm.dose_unit = src.dose_unit || '剂'
|
editForm.dose_unit = src.dose_unit || '剂'
|
||||||
editForm.usage_days = src.usage_days ?? 7
|
editForm.usage_days = src.usage_days ?? 7
|
||||||
@@ -3371,6 +3821,10 @@ const handleEdit = async (row: any) => {
|
|||||||
editForm.is_system_auto = Number(src.is_system_auto) === 1 ? 1 : 0
|
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_rejected = Number(src.business_prescription_audit_rejected) === 1 ? 1 : 0
|
||||||
editForm.business_prescription_audit_remark = String(src.business_prescription_audit_remark || '')
|
editForm.business_prescription_audit_remark = String(src.business_prescription_audit_remark || '')
|
||||||
|
Object.assign(
|
||||||
|
editForm.aux_usage,
|
||||||
|
normalizeAuxUsageForm(src.aux_usage, editForm.prescription_type)
|
||||||
|
)
|
||||||
|
|
||||||
// 数据加载完成,重新启用watch
|
// 数据加载完成,重新启用watch
|
||||||
// 使用 setTimeout 确保所有数据都已经渲染完成
|
// 使用 setTimeout 确保所有数据都已经渲染完成
|
||||||
@@ -3405,12 +3859,13 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
for (let i = 0; i < editForm.herbs.length; i++) {
|
for (let i = 0; i < editForm.herbs.length; i++) {
|
||||||
const herb = editForm.herbs[i]
|
const herb = editForm.herbs[i]
|
||||||
|
const label = herbValidationLabel(i)
|
||||||
if (!herb.name || !herb.name.trim()) {
|
if (!herb.name || !herb.name.trim()) {
|
||||||
feedback.msgError(`第${i + 1}味药材名称不能为空`)
|
feedback.msgError(`${label}药材名称不能为空`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!herb.dosage || herb.dosage <= 0) {
|
if (!herb.dosage || herb.dosage <= 0) {
|
||||||
feedback.msgError(`第${i + 1}味药材剂量必须大于0`)
|
feedback.msgError(`${label}药材剂量必须大于0`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3834,6 +4289,38 @@ onMounted(async () => {
|
|||||||
box-sizing: border-box;
|
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 {
|
.rx-herb-cell {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 64px;
|
grid-template-columns: 1fr 64px;
|
||||||
|
|||||||
@@ -11,6 +11,13 @@
|
|||||||
@keyup.enter="resetPage"
|
@keyup.enter="resetPage"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</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-form-item class="w-[200px]" label="是否公开">
|
||||||
<el-select v-model="formData.is_public" placeholder="全部" clearable>
|
<el-select v-model="formData.is_public" placeholder="全部" clearable>
|
||||||
<el-option label="全部" :value="''" />
|
<el-option label="全部" :value="''" />
|
||||||
@@ -37,6 +44,13 @@
|
|||||||
<el-table :data="pager.lists" size="large">
|
<el-table :data="pager.lists" size="large">
|
||||||
<el-table-column label="ID" prop="id" min-width="60" />
|
<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="处方名称" 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">
|
<el-table-column label="药材数量" min-width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ row.herbs?.length || 0 }}味
|
{{ row.herbs?.length || 0 }}味
|
||||||
@@ -114,6 +128,13 @@
|
|||||||
show-word-limit
|
show-word-limit
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</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>
|
<el-form-item label="药材配方" prop="herbs" required>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
@@ -200,6 +221,7 @@ import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
|
|||||||
// 表单数据
|
// 表单数据
|
||||||
const formData = reactive({
|
const formData = reactive({
|
||||||
prescription_name: '',
|
prescription_name: '',
|
||||||
|
formula_type: '',
|
||||||
is_public: ''
|
is_public: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -212,6 +234,7 @@ const formRef = ref<FormInstance>()
|
|||||||
const editForm = reactive({
|
const editForm = reactive({
|
||||||
id: 0,
|
id: 0,
|
||||||
prescription_name: '',
|
prescription_name: '',
|
||||||
|
formula_type: '主方',
|
||||||
herbs: [] as Array<{ name: string; dosage: number }>,
|
herbs: [] as Array<{ name: string; dosage: number }>,
|
||||||
is_public: 0
|
is_public: 0
|
||||||
})
|
})
|
||||||
@@ -267,6 +290,7 @@ const removeHerb = (index: number) => {
|
|||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
editForm.id = 0
|
editForm.id = 0
|
||||||
editForm.prescription_name = ''
|
editForm.prescription_name = ''
|
||||||
|
editForm.formula_type = '主方'
|
||||||
editForm.herbs = []
|
editForm.herbs = []
|
||||||
editForm.is_public = 0
|
editForm.is_public = 0
|
||||||
}
|
}
|
||||||
@@ -283,6 +307,7 @@ const handleView = (row: any) => {
|
|||||||
editMode.value = 'view'
|
editMode.value = 'view'
|
||||||
editForm.id = row.id
|
editForm.id = row.id
|
||||||
editForm.prescription_name = row.prescription_name || ''
|
editForm.prescription_name = row.prescription_name || ''
|
||||||
|
editForm.formula_type = row.formula_type || '主方'
|
||||||
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
|
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
|
||||||
editForm.is_public = row.is_public ?? 0
|
editForm.is_public = row.is_public ?? 0
|
||||||
showEdit.value = true
|
showEdit.value = true
|
||||||
@@ -293,6 +318,7 @@ const handleEdit = (row: any) => {
|
|||||||
editMode.value = 'edit'
|
editMode.value = 'edit'
|
||||||
editForm.id = row.id
|
editForm.id = row.id
|
||||||
editForm.prescription_name = row.prescription_name || ''
|
editForm.prescription_name = row.prescription_name || ''
|
||||||
|
editForm.formula_type = row.formula_type || '主方'
|
||||||
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
|
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
|
||||||
editForm.is_public = row.is_public ?? 0
|
editForm.is_public = row.is_public ?? 0
|
||||||
showEdit.value = true
|
showEdit.value = true
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -234,6 +234,22 @@
|
|||||||
@keyup.enter="resetPage"
|
@keyup.enter="resetPage"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</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-form-item class="w-[200px]" label="医生">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="queryParams.doctor_id"
|
v-model="queryParams.doctor_id"
|
||||||
@@ -2440,9 +2456,39 @@ function openDiagnosisPatientDetailFromOrder() {
|
|||||||
|
|
||||||
/** 诊间医助角色(与 DiagnosisLists 等一致) */
|
/** 诊间医助角色(与 DiagnosisLists 等一致) */
|
||||||
const TCM_ASSISTANT_ROLE_ID = 2
|
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 默认一致,可处方审核的角色 */
|
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
|
||||||
const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
|
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 且不具备处方审核角色)仅能用「支付单审核」筛选
|
* 是否展示「处方审核」筛选:纯医助(含角色 2 且不具备处方审核角色)仅能用「支付单审核」筛选
|
||||||
*/
|
*/
|
||||||
@@ -2673,7 +2719,9 @@ const queryParams = reactive({
|
|||||||
prescription_audit_status: '' as number | '',
|
prescription_audit_status: '' as number | '',
|
||||||
payment_slip_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 = [
|
const rxAuditTabs = [
|
||||||
@@ -2767,6 +2815,11 @@ async function fetchLists(params: Record<string, unknown>) {
|
|||||||
}
|
}
|
||||||
if (!p.order_no) delete p.order_no
|
if (!p.order_no) delete p.order_no
|
||||||
if (!String(p.patient_keyword || '').trim()) delete p.patient_keyword
|
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 (!String(p.express_keyword || '').trim()) delete p.express_keyword
|
||||||
if (!p.express_company || p.express_company === '') delete p.express_company
|
if (!p.express_company || p.express_company === '') delete p.express_company
|
||||||
if (!String(p.start_time || '').trim() || !String(p.end_time || '').trim()) {
|
if (!String(p.start_time || '').trim() || !String(p.end_time || '').trim()) {
|
||||||
@@ -2789,6 +2842,24 @@ const { pager, getLists, resetPage, resetParams } = usePaging({
|
|||||||
params: queryParams
|
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 filteredAssistantOptions = computed(() => {
|
||||||
const deptRaw = queryParams.assistant_dept_id
|
const deptRaw = queryParams.assistant_dept_id
|
||||||
@@ -2950,6 +3021,7 @@ function handleReset() {
|
|||||||
queryParams.prescription_audit_status = ''
|
queryParams.prescription_audit_status = ''
|
||||||
queryParams.payment_slip_audit_status = ''
|
queryParams.payment_slip_audit_status = ''
|
||||||
queryParams.supply_mode = ''
|
queryParams.supply_mode = ''
|
||||||
|
queryParams.audit_admin_id = ''
|
||||||
resetParams()
|
resetParams()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3047,14 +3119,48 @@ function formatTime(v: unknown) {
|
|||||||
return String(v)
|
return String(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 与 server/config/project.php order_edit_all_roles 一致 */
|
||||||
|
const ORDER_EDIT_ALL_ROLE_IDS = [0, 3]
|
||||||
|
|
||||||
|
function canBypassCreatorDualAuditEditLock(): boolean {
|
||||||
|
const u = userStore.userInfo
|
||||||
|
if (!u) return false
|
||||||
|
if (Number(u.root) === 1) return true
|
||||||
|
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
|
||||||
|
return ids.some((id) => ORDER_EDIT_ALL_ROLE_IDS.includes(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentUserOrderCreator(row: { creator_id?: number }) {
|
||||||
|
const uid = Number(userStore.userInfo?.id)
|
||||||
|
return uid > 0 && Number(row.creator_id) === uid
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDualAuditPassed(row: {
|
||||||
|
prescription_audit_status?: number
|
||||||
|
payment_slip_audit_status?: number
|
||||||
|
}) {
|
||||||
|
return Number(row.prescription_audit_status) === 1 && Number(row.payment_slip_audit_status) === 1
|
||||||
|
}
|
||||||
|
|
||||||
function canEditRow(row: {
|
function canEditRow(row: {
|
||||||
|
creator_id?: number
|
||||||
fulfillment_status?: number
|
fulfillment_status?: number
|
||||||
|
prescription_audit_status?: number
|
||||||
|
payment_slip_audit_status?: number
|
||||||
gancao_reciperl_order_no?: string | null
|
gancao_reciperl_order_no?: string | null
|
||||||
gancao_submit_time?: number | null
|
gancao_submit_time?: number | null
|
||||||
}) {
|
}) {
|
||||||
const fs = Number(row.fulfillment_status)
|
const fs = Number(row.fulfillment_status)
|
||||||
if (fs === 3 || fs === 4) return false
|
if (fs === 3 || fs === 4) return false
|
||||||
|
|
||||||
|
if (
|
||||||
|
isCurrentUserOrderCreator(row) &&
|
||||||
|
isDualAuditPassed(row) &&
|
||||||
|
!canBypassCreatorDualAuditEditLock()
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const gcNo = String(row.gancao_reciperl_order_no || '').trim()
|
const gcNo = String(row.gancao_reciperl_order_no || '').trim()
|
||||||
const gcTime = Number(row.gancao_submit_time || 0)
|
const gcTime = Number(row.gancao_submit_time || 0)
|
||||||
const gcLocked = gcNo !== '' || gcTime > 0
|
const gcLocked = gcNo !== '' || gcTime > 0
|
||||||
@@ -3406,6 +3512,40 @@ const logisticsTraceLoading = ref(false)
|
|||||||
const logisticsTracePayload = ref<Record<string, any> | null>(null)
|
const logisticsTracePayload = ref<Record<string, any> | null>(null)
|
||||||
/** 顺丰/快递100:与运单一致的收件手机后四位(可覆盖订单收货手机) */
|
/** 顺丰/快递100:与运单一致的收件手机后四位(可覆盖订单收货手机) */
|
||||||
const logisticsTracePhoneTail = ref('')
|
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 detailLogs = ref<any[]>([])
|
||||||
const updateAmountVisible = ref(false)
|
const updateAmountVisible = ref(false)
|
||||||
@@ -3500,24 +3640,33 @@ function expressCompanyLabel(v: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchLogisticsTrace() {
|
async function fetchLogisticsTrace() {
|
||||||
const id = Number(detailData.value?.id)
|
const orderId = Number(detailData.value?.id)
|
||||||
if (!id) return
|
const trackingNumber = String(detailData.value?.tracking_number || '').trim()
|
||||||
|
if (!orderId || !trackingNumber) return
|
||||||
|
|
||||||
|
const requestSeq = ++logisticsTraceRequestSeq
|
||||||
|
const ctx: LogisticsTraceRequestContext = { requestSeq, orderId, trackingNumber }
|
||||||
|
|
||||||
logisticsTraceLoading.value = true
|
logisticsTraceLoading.value = true
|
||||||
try {
|
try {
|
||||||
const digits = String(logisticsTracePhoneTail.value || '').replace(/\D/g, '')
|
const digits = String(logisticsTracePhoneTail.value || '').replace(/\D/g, '')
|
||||||
const params: { id: number; express_company?: string; phone_tail?: string } = {
|
const params: { id: number; express_company?: string; phone_tail?: string } = {
|
||||||
id,
|
id: orderId,
|
||||||
express_company: detailLogisticsExpress.value
|
express_company: detailLogisticsExpress.value
|
||||||
}
|
}
|
||||||
if (digits.length >= 4) {
|
if (digits.length >= 4) {
|
||||||
params.phone_tail = digits
|
params.phone_tail = digits
|
||||||
}
|
}
|
||||||
const res: any = await prescriptionOrderLogisticsTrace(params)
|
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 {
|
} catch {
|
||||||
|
if (!isCurrentLogisticsTraceContext(ctx)) return
|
||||||
logisticsTracePayload.value = null
|
logisticsTracePayload.value = null
|
||||||
} finally {
|
} finally {
|
||||||
logisticsTraceLoading.value = false
|
if (isCurrentLogisticsTraceContext(ctx)) {
|
||||||
|
logisticsTraceLoading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3643,7 +3792,7 @@ async function openDetail(id: number) {
|
|||||||
// 修复 Bug:显式彻底清空缓存,防止前一次弹窗的数据残留
|
// 修复 Bug:显式彻底清空缓存,防止前一次弹窗的数据残留
|
||||||
detailData.value = null
|
detailData.value = null
|
||||||
detailUnlinkedPayOrders.value = []
|
detailUnlinkedPayOrders.value = []
|
||||||
logisticsTracePayload.value = null
|
bumpLogisticsTraceRequestToken()
|
||||||
detailLogisticsExpress.value = 'auto'
|
detailLogisticsExpress.value = 'auto'
|
||||||
logisticsTracePhoneTail.value = ''
|
logisticsTracePhoneTail.value = ''
|
||||||
detailLogs.value = []
|
detailLogs.value = []
|
||||||
@@ -3659,7 +3808,7 @@ async function openDetail(id: number) {
|
|||||||
const dig = String(d.recipient_phone || '').replace(/\D/g, '')
|
const dig = String(d.recipient_phone || '').replace(/\D/g, '')
|
||||||
logisticsTracePhoneTail.value = dig.length >= 4 ? dig : ''
|
logisticsTracePhoneTail.value = dig.length >= 4 ? dig : ''
|
||||||
if (String(d.tracking_number || '').trim()) {
|
if (String(d.tracking_number || '').trim()) {
|
||||||
fetchLogisticsTrace()
|
void fetchLogisticsTrace()
|
||||||
}
|
}
|
||||||
fetchLogs(id)
|
fetchLogs(id)
|
||||||
|
|
||||||
@@ -3889,7 +4038,25 @@ async function goEditOrderNextStep() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openEdit(row: { id: number }) {
|
async function openEdit(row: {
|
||||||
|
id: number
|
||||||
|
creator_id?: number
|
||||||
|
prescription_audit_status?: number
|
||||||
|
payment_slip_audit_status?: number
|
||||||
|
fulfillment_status?: number
|
||||||
|
gancao_reciperl_order_no?: string | null
|
||||||
|
gancao_submit_time?: number | null
|
||||||
|
}) {
|
||||||
|
if (!canEditRow(row)) {
|
||||||
|
if (
|
||||||
|
isCurrentUserOrderCreator(row) &&
|
||||||
|
isDualAuditPassed(row) &&
|
||||||
|
!canBypassCreatorDualAuditEditLock()
|
||||||
|
) {
|
||||||
|
feedback.msgWarning('处方与支付单均已审核通过,创建人不可再编辑')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
editOrderStep.value = 0
|
editOrderStep.value = 0
|
||||||
editOrderPrescription.value = null
|
editOrderPrescription.value = null
|
||||||
editVisible.value = true
|
editVisible.value = true
|
||||||
|
|||||||
+238
-48
@@ -127,10 +127,10 @@
|
|||||||
<span class="yeji-table-tabs-head__title">表格统计</span>
|
<span class="yeji-table-tabs-head__title">表格统计</span>
|
||||||
<span class="yeji-table-tabs-head__hint">列较多时可横向滚动浏览;「部门 / 排名」列在滚动时保持固定。</span>
|
<span class="yeji-table-tabs-head__hint">列较多时可横向滚动浏览;「部门 / 排名」列在滚动时保持固定。</span>
|
||||||
</div>
|
</div>
|
||||||
<el-tabs v-model="yejiDisplayTab" class="yeji-view-tabs">
|
<el-tabs v-if="hasAnyYejiTableTab" v-model="yejiDisplayTab" class="yeji-view-tabs">
|
||||||
<el-tab-pane label="医生统计" name="doctor" lazy>
|
<el-tab-pane v-if="canViewDoctorTab" label="医生统计" name="doctor" lazy>
|
||||||
<el-card
|
<el-card
|
||||||
v-if="canViewDoctorDailyStats"
|
v-if="canViewDoctorTab"
|
||||||
class="yeji-panel doctor-daily-card doctor-daily-card--nested"
|
class="yeji-panel doctor-daily-card doctor-daily-card--nested"
|
||||||
shadow="never"
|
shadow="never"
|
||||||
>
|
>
|
||||||
@@ -189,7 +189,7 @@
|
|||||||
<el-empty v-else description="当前账号无医生统计权限" />
|
<el-empty v-else description="当前账号无医生统计权限" />
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<el-tab-pane label="医助排行榜" name="leaderboard" lazy>
|
<el-tab-pane v-if="canViewLeaderboardTab" label="医助排行榜" name="leaderboard" lazy>
|
||||||
<div
|
<div
|
||||||
v-if="leaderboardBlock || leaderboardsLoading"
|
v-if="leaderboardBlock || leaderboardsLoading"
|
||||||
class="leaderboards-wrap"
|
class="leaderboards-wrap"
|
||||||
@@ -316,7 +316,7 @@
|
|||||||
/>
|
/>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<el-tab-pane label="甄养堂互联网医院诊金" name="zyyt" lazy>
|
<el-tab-pane v-if="canViewZyytTab" label="甄养堂互联网医院诊金" name="zyyt" lazy>
|
||||||
<!-- ── 业绩表(4 张/N 张);目标看板见「目标看板(卡片)」Tab ── -->
|
<!-- ── 业绩表(4 张/N 张);目标看板见「目标看板(卡片)」Tab ── -->
|
||||||
<div v-loading="loading" class="tables-wrap">
|
<div v-loading="loading" class="tables-wrap">
|
||||||
<div v-if="!loading && tables.length === 0" class="empty-tip">
|
<div v-if="!loading && tables.length === 0" class="empty-tip">
|
||||||
@@ -445,12 +445,8 @@
|
|||||||
<el-tooltip placement="top" effect="dark" :show-after="200">
|
<el-tooltip placement="top" effect="dark" :show-after="200">
|
||||||
<template #content>
|
<template #content>
|
||||||
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
|
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
|
||||||
<<<<<<< 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>业务订单条数</b>(计业绩):订单 <b>create_time</b> 落入区间、<b>fulfillment_status ∉ {4,9,10}</b>;与列表筛选
|
||||||
<b>assistant_dept_id</b> 时一致——<b>创建人</b>人事部门优先,无创建人则诊单 <b>医助</b>;落在该部门子树即计入(表格多行命中时取最深的展示部门)。与侧栏勾选「与表格业绩对齐」时的集合可能略有差异。选定渠道时本列仍为全量。
|
<b>assistant_dept_id</b> 时一致——<b>创建人</b>人事部门优先,无创建人则诊单 <b>医助</b>;落在该部门子树即计入(表格多行命中时取最深的展示部门)。与侧栏勾选「与表格业绩对齐」时的集合可能略有差异。选定渠道时本列仍为全量。
|
||||||
>>>>>>> master
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-icon class="col-info"><InfoFilled /></el-icon>
|
<el-icon class="col-info"><InfoFilled /></el-icon>
|
||||||
@@ -636,7 +632,7 @@
|
|||||||
|
|
||||||
</el-tab-pane>
|
</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">
|
<div v-loading="targetProgressLoading" class="tp-matrix-wrap">
|
||||||
<template v-if="targetProgressCard">
|
<template v-if="targetProgressCard">
|
||||||
<h2 class="tp-matrix__title">{{ targetProgressCard.title }}</h2>
|
<h2 class="tp-matrix__title">{{ targetProgressCard.title }}</h2>
|
||||||
@@ -702,18 +698,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
|
<el-empty v-else description="当前账号无表格统计 Tab 权限" />
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card class="yeji-panel yeji-charts-only-card" shadow="never">
|
<el-card class="yeji-panel yeji-charts-only-card" shadow="never">
|
||||||
<div class="yeji-charts-only-head">统计图</div>
|
<div class="yeji-charts-only-head">统计图</div>
|
||||||
<div
|
<div
|
||||||
v-loading="loading || (canViewDoctorDailyStats && doctorDailyLoading) || leaderboardsLoading"
|
v-loading="loading || (canViewDoctorTab && doctorDailyLoading) || (canViewLeaderboardTab && leaderboardsLoading)"
|
||||||
class="yeji-charts-stack"
|
class="yeji-charts-stack"
|
||||||
>
|
>
|
||||||
<template v-if="chartsHasAnyData">
|
<template v-if="chartsHasAnyData">
|
||||||
<section
|
<section
|
||||||
v-if="
|
v-if="
|
||||||
canViewDoctorDailyStats &&
|
canViewDoctorTab &&
|
||||||
(doctorDealBarHasData ||
|
(doctorDealBarHasData ||
|
||||||
doctorRxStackHasData ||
|
doctorRxStackHasData ||
|
||||||
doctorAppointmentPieHasData ||
|
doctorAppointmentPieHasData ||
|
||||||
@@ -855,7 +852,7 @@
|
|||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="leaderboardBlock">
|
<template v-if="canViewLeaderboardTab && leaderboardBlock">
|
||||||
<section
|
<section
|
||||||
v-for="lb in leaderboardChartBlocks"
|
v-for="lb in leaderboardChartBlocks"
|
||||||
:key="'lb-chart-' + lb.dept_id"
|
:key="'lb-chart-' + lb.dept_id"
|
||||||
@@ -898,8 +895,8 @@
|
|||||||
<el-empty
|
<el-empty
|
||||||
v-else-if="
|
v-else-if="
|
||||||
!loading &&
|
!loading &&
|
||||||
(!canViewDoctorDailyStats || !doctorDailyLoading) &&
|
(!canViewDoctorTab || !doctorDailyLoading) &&
|
||||||
!leaderboardsLoading
|
(!canViewLeaderboardTab || !leaderboardsLoading)
|
||||||
"
|
"
|
||||||
class="yeji-charts-empty"
|
class="yeji-charts-empty"
|
||||||
description="当前筛选下暂无图表数据,请调整条件后查询"
|
description="当前筛选下暂无图表数据,请调整条件后查询"
|
||||||
@@ -1159,9 +1156,19 @@
|
|||||||
class="yeji-leadlines-dialog"
|
class="yeji-leadlines-dialog"
|
||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="yeji-unassigned-dialog__head">
|
<div class="yeji-unassigned-dialog__head yeji-leadlines-dialog__head">
|
||||||
<span class="yeji-unassigned-dialog__title">进线数据明细</span>
|
<div class="yeji-leadlines-dialog__head-main">
|
||||||
<p class="yeji-unassigned-dialog__sub">{{ leadLinesSubtitle }}</p>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<p v-if="leadLinesApiNote" class="yeji-unassigned-dialog__note">{{ leadLinesApiNote }}</p>
|
<p v-if="leadLinesApiNote" class="yeji-unassigned-dialog__note">{{ leadLinesApiNote }}</p>
|
||||||
@@ -1318,7 +1325,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { Search, RefreshRight, InfoFilled } from '@element-plus/icons-vue'
|
import { Search, RefreshRight, InfoFilled } from '@element-plus/icons-vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
@@ -1337,7 +1344,14 @@ import {
|
|||||||
} from '@/api/stats'
|
} from '@/api/stats'
|
||||||
import { deptPerformanceTargetMonthMatrix } from '@/api/finance'
|
import { deptPerformanceTargetMonthMatrix } from '@/api/finance'
|
||||||
import { prescriptionOrderLists } from '@/api/tcm'
|
import { prescriptionOrderLists } from '@/api/tcm'
|
||||||
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 {
|
interface DeptOption {
|
||||||
id: number
|
id: number
|
||||||
@@ -1626,6 +1640,7 @@ const revisitBreakdownMeta = ref<{ start: string; end: string; revisitSlot: numb
|
|||||||
|
|
||||||
const leadLinesDialogVisible = ref(false)
|
const leadLinesDialogVisible = ref(false)
|
||||||
const leadLinesLoading = ref(false)
|
const leadLinesLoading = ref(false)
|
||||||
|
const leadLinesExporting = ref(false)
|
||||||
const leadLinesSubtitle = ref('')
|
const leadLinesSubtitle = ref('')
|
||||||
const leadLinesApiNote = ref('')
|
const leadLinesApiNote = ref('')
|
||||||
const leadLinesRows = ref<YejiLeadLineRow[]>([])
|
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 一致;无权限则不展示医生统计表/图、不请求接口 */
|
/** Tab 可见性:优先认 Tab 独立权限;未配置时兼容原有接口按钮权限,避免破坏已有角色 */
|
||||||
const userStore = useUserStore()
|
const YEJI_TAB_DOCTOR_PERM = 'stats.yejiStats/tabDoctor'
|
||||||
const DOCTOR_DAILY_STATS_PERM = 'stats.doctorDailyStats/overview'
|
const YEJI_TAB_LEADERBOARD_PERM = 'stats.yejiStats/tabLeaderboard'
|
||||||
const canViewDoctorDailyStats = computed(() => {
|
const YEJI_TAB_ZYYT_PERM = 'stats.yejiStats/tabZyyt'
|
||||||
const perms = userStore.perms || []
|
const YEJI_TAB_TARGET_MATRIX_PERM = 'stats.yejiStats/tabTargetMatrix'
|
||||||
return perms.some(p => p === '*' || p === DOCTOR_DAILY_STATS_PERM)
|
|
||||||
|
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(() =>
|
const leaderboardChartBlocks = computed(() =>
|
||||||
@@ -1715,7 +1776,7 @@ const doctorRxStackHasData = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const doctorChartsHasData = computed(() => {
|
const doctorChartsHasData = computed(() => {
|
||||||
if (!canViewDoctorDailyStats.value) {
|
if (!canViewDoctorTab.value) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@@ -1731,12 +1792,14 @@ const chartsHasAnyData = computed(() => {
|
|||||||
if (doctorChartsHasData.value) {
|
if (doctorChartsHasData.value) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
for (const tb of tables.value) {
|
if (canViewZyytTab.value) {
|
||||||
if (yejiDeptChartHasData(tb)) {
|
for (const tb of tables.value) {
|
||||||
return true
|
if (yejiDeptChartHasData(tb)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (leaderboardChartBlocks.value.length > 0) {
|
if (canViewLeaderboardTab.value && leaderboardChartBlocks.value.length > 0) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -2073,6 +2136,12 @@ function getDoctorDailySummaries(param: { columns: any[] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadDoctorDailyStats() {
|
async function loadDoctorDailyStats() {
|
||||||
|
if (!canViewDoctorTab.value) {
|
||||||
|
doctorDailyRows.value = []
|
||||||
|
doctorDailyTotal.value = {}
|
||||||
|
doctorDailyRange.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
doctorDailyLoading.value = true
|
doctorDailyLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res: any = await doctorDailyStatsOverview(buildDoctorDailyRequestParams())
|
const res: any = await doctorDailyStatsOverview(buildDoctorDailyRequestParams())
|
||||||
@@ -2211,6 +2280,10 @@ function daysInMonth(ym: string): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadTargetProgress() {
|
async function loadTargetProgress() {
|
||||||
|
if (!canViewTargetMatrixTab.value) {
|
||||||
|
targetProgressCard.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
targetProgressLoading.value = true
|
targetProgressLoading.value = true
|
||||||
targetProgressCard.value = null
|
targetProgressCard.value = null
|
||||||
try {
|
try {
|
||||||
@@ -2458,6 +2531,10 @@ async function loadChannelOptions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadLeaderboard(range: { start: string; end: string }) {
|
async function loadLeaderboard(range: { start: string; end: string }) {
|
||||||
|
if (!canViewLeaderboardTab.value) {
|
||||||
|
leaderboardBlock.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
leaderboardsLoading.value = true
|
leaderboardsLoading.value = true
|
||||||
try {
|
try {
|
||||||
const p: Record<string, any> = {
|
const p: Record<string, any> = {
|
||||||
@@ -2491,6 +2568,18 @@ async function loadLeaderboard(range: { start: string; end: string }) {
|
|||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
leaderboardBlock.value = null
|
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 {
|
try {
|
||||||
const baseParams: Record<string, any> = {}
|
const baseParams: Record<string, any> = {}
|
||||||
if (selectedDeptIds.value.length > 0) {
|
if (selectedDeptIds.value.length > 0) {
|
||||||
@@ -2542,7 +2631,7 @@ async function loadData() {
|
|||||||
ElMessage.error(any?.msg || any?.message || '加载失败')
|
ElMessage.error(any?.msg || any?.message || '加载失败')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
if (canViewDoctorDailyStats.value) {
|
if (canViewDoctorTab.value) {
|
||||||
void loadDoctorDailyStats()
|
void loadDoctorDailyStats()
|
||||||
} else {
|
} else {
|
||||||
doctorDailyRows.value = []
|
doctorDailyRows.value = []
|
||||||
@@ -2661,6 +2750,9 @@ function buildOrderDrawerListParams(): Record<string, any> | null {
|
|||||||
if (selectedDeptIds.value.length > 0) {
|
if (selectedDeptIds.value.length > 0) {
|
||||||
params.dept_ids = selectedDeptIds.value.join(',')
|
params.dept_ids = selectedDeptIds.value.join(',')
|
||||||
}
|
}
|
||||||
|
if (f.yejiTableRowDeptIds && f.yejiTableRowDeptIds.length > 0) {
|
||||||
|
params.yeji_table_row_dept_ids = f.yejiTableRowDeptIds.join(',')
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
params.assistant_id = f.assistantId
|
params.assistant_id = f.assistantId
|
||||||
if (f.erCenterRevisitSlot !== undefined) {
|
if (f.erCenterRevisitSlot !== undefined) {
|
||||||
@@ -3047,28 +3139,79 @@ function onLeadCountCellClick(ev: MouseEvent, tb: YejiTable, row: YejiRow) {
|
|||||||
void openLeadLinesDialog(tb, row)
|
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() {
|
async function fetchLeadLinesPage() {
|
||||||
const ctx = leadLinesContext.value
|
const ctx = leadLinesContext.value
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const { tb, row } = ctx
|
|
||||||
leadLinesLoading.value = true
|
leadLinesLoading.value = true
|
||||||
try {
|
try {
|
||||||
const p: Record<string, string | number> = {
|
const res: any = await yejiStatsLeadLines(
|
||||||
start_date: tb.start_date,
|
buildLeadLinesRequestParams(ctx, leadLinesPage.value, leadLinesPageSize.value) as any
|
||||||
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)
|
|
||||||
leadLinesRows.value = Array.isArray(res?.lists) ? res.lists : []
|
leadLinesRows.value = Array.isArray(res?.lists) ? res.lists : []
|
||||||
leadLinesCount.value = Number(res?.count ?? 0)
|
leadLinesCount.value = Number(res?.count ?? 0)
|
||||||
leadLinesApiNote.value = typeof res?.note === 'string' ? res.note : ''
|
leadLinesApiNote.value = typeof res?.note === 'string' ? res.note : ''
|
||||||
@@ -3107,6 +3250,40 @@ function onLeadLinesPageSizeChange(size: number) {
|
|||||||
void fetchLeadLinesPage()
|
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) {
|
function appointmentLinesIndexMethod(index: number) {
|
||||||
return (appointmentLinesPage.value - 1) * appointmentLinesPageSize.value + index + 1
|
return (appointmentLinesPage.value - 1) * appointmentLinesPageSize.value + index + 1
|
||||||
}
|
}
|
||||||
@@ -3561,6 +3738,7 @@ function formatLocalYmd(d: Date): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
yejiDisplayTab.value = pickInitialYejiTab()
|
||||||
await Promise.all([loadDeptOptions(), loadChannelOptions()])
|
await Promise.all([loadDeptOptions(), loadChannelOptions()])
|
||||||
loadData()
|
loadData()
|
||||||
loadTargetProgress()
|
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);
|
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 {
|
.yeji-leadlines-dialog__pager {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -22,16 +22,25 @@
|
|||||||
<el-select
|
<el-select
|
||||||
v-model="formData.media_channel_code"
|
v-model="formData.media_channel_code"
|
||||||
placeholder="请选择自媒体渠道"
|
placeholder="请选择自媒体渠道"
|
||||||
class="w-full"
|
class="account-cost-channel-select w-full"
|
||||||
filterable
|
filterable
|
||||||
:disabled="mode === 'edit'"
|
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option-group
|
||||||
v-for="item in mediaChannelOptions"
|
v-for="g in mediaChannelGroups"
|
||||||
:key="item.code"
|
:key="g.group_name"
|
||||||
:label="item.name"
|
:label="g.group_name"
|
||||||
:value="item.code"
|
>
|
||||||
/>
|
<el-option
|
||||||
|
v-for="ch in g.channels"
|
||||||
|
:key="ch.channel_code"
|
||||||
|
:label="ch.channel_name"
|
||||||
|
:value="ch.channel_code"
|
||||||
|
>
|
||||||
|
<div class="channel-opt-row">
|
||||||
|
<span class="opt-name">{{ ch.channel_name }}</span>
|
||||||
|
</div>
|
||||||
|
</el-option>
|
||||||
|
</el-option-group>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="部门" prop="dept_id">
|
<el-form-item label="部门" prop="dept_id">
|
||||||
@@ -78,9 +87,9 @@ import type { FormInstance } from 'element-plus'
|
|||||||
import { accountCostAdd, accountCostDetail, accountCostEdit } from '@/api/finance'
|
import { accountCostAdd, accountCostDetail, accountCostEdit } from '@/api/finance'
|
||||||
import Popup from '@/components/popup/index.vue'
|
import Popup from '@/components/popup/index.vue'
|
||||||
|
|
||||||
interface MediaChannelOption {
|
interface MediaChannelGroup {
|
||||||
code: string
|
group_name: string
|
||||||
name: string
|
channels: { channel_code: string; channel_name: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DeptOption {
|
interface DeptOption {
|
||||||
@@ -90,7 +99,7 @@ interface DeptOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
mediaChannelOptions: MediaChannelOption[]
|
mediaChannelGroups: MediaChannelGroup[]
|
||||||
deptOptions: DeptOption[]
|
deptOptions: DeptOption[]
|
||||||
defaultMediaChannelCode: string
|
defaultMediaChannelCode: string
|
||||||
}>()
|
}>()
|
||||||
@@ -197,3 +206,21 @@ defineExpose({
|
|||||||
getDetail,
|
getDetail,
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.account-cost-channel-select {
|
||||||
|
:deep(.channel-opt-row) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
:deep(.opt-name) {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -27,14 +27,25 @@
|
|||||||
placeholder="筛选全部渠道"
|
placeholder="筛选全部渠道"
|
||||||
clearable
|
clearable
|
||||||
filterable
|
filterable
|
||||||
class="w-[220px]"
|
class="account-cost-channel-select w-[220px]"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option label="全部" value="" />
|
||||||
v-for="item in mediaChannelOptions"
|
<el-option-group
|
||||||
:key="item.code"
|
v-for="g in mediaChannelGroups"
|
||||||
:label="item.name"
|
:key="g.group_name"
|
||||||
:value="item.code"
|
:label="g.group_name"
|
||||||
/>
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="ch in g.channels"
|
||||||
|
:key="ch.channel_code"
|
||||||
|
:label="ch.channel_name"
|
||||||
|
:value="ch.channel_code"
|
||||||
|
>
|
||||||
|
<div class="channel-opt-row">
|
||||||
|
<span class="opt-name">{{ ch.channel_name }}</span>
|
||||||
|
</div>
|
||||||
|
</el-option>
|
||||||
|
</el-option-group>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="部门">
|
<el-form-item label="部门">
|
||||||
@@ -78,7 +89,15 @@
|
|||||||
|
|
||||||
<el-table class="mt-4" size="large" v-loading="pager.loading" :data="pager.lists">
|
<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="cost_date" min-width="120" />
|
||||||
<el-table-column label="自媒体渠道" prop="media_channel_name" min-width="120" />
|
<el-table-column label="自媒体渠道" min-width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="getChannelGroupLabel(row.media_channel_code)">
|
||||||
|
<el-tag size="small" class="mr-1">{{ getChannelGroupLabel(row.media_channel_code) }}</el-tag>
|
||||||
|
{{ row.media_channel_name }}
|
||||||
|
</span>
|
||||||
|
<span v-else>{{ row.media_channel_name }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="部门" prop="dept_name" min-width="140" />
|
<el-table-column label="部门" prop="dept_name" min-width="140" />
|
||||||
<el-table-column label="账户消耗" min-width="120">
|
<el-table-column label="账户消耗" min-width="120">
|
||||||
<template #default="{ row }">¥{{ row.amount }}</template>
|
<template #default="{ row }">¥{{ row.amount }}</template>
|
||||||
@@ -117,7 +136,7 @@
|
|||||||
<edit-popup
|
<edit-popup
|
||||||
v-if="showEdit"
|
v-if="showEdit"
|
||||||
ref="editRef"
|
ref="editRef"
|
||||||
:media-channel-options="mediaChannelOptions"
|
:media-channel-groups="mediaChannelGroups"
|
||||||
:dept-options="deptOptions"
|
:dept-options="deptOptions"
|
||||||
:default-media-channel-code="defaultMediaChannelCode"
|
:default-media-channel-code="defaultMediaChannelCode"
|
||||||
@success="getLists"
|
@success="getLists"
|
||||||
@@ -133,6 +152,11 @@ import feedback from '@/utils/feedback'
|
|||||||
|
|
||||||
import EditPopup from './edit.vue'
|
import EditPopup from './edit.vue'
|
||||||
|
|
||||||
|
interface MediaChannelGroup {
|
||||||
|
group_name: string
|
||||||
|
channels: { channel_code: string; channel_name: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
interface MediaChannelOption {
|
interface MediaChannelOption {
|
||||||
code: string
|
code: string
|
||||||
name: string
|
name: string
|
||||||
@@ -160,7 +184,7 @@ const { pager, getLists, resetPage } = usePaging({
|
|||||||
params: queryParams,
|
params: queryParams,
|
||||||
})
|
})
|
||||||
|
|
||||||
const mediaChannelOptions = computed<MediaChannelOption[]>(() => {
|
const mediaChannelOptionsFlat = computed<MediaChannelOption[]>(() => {
|
||||||
const options = pager.extend.media_channel_options
|
const options = pager.extend.media_channel_options
|
||||||
if (!Array.isArray(options)) return []
|
if (!Array.isArray(options)) return []
|
||||||
|
|
||||||
@@ -170,6 +194,26 @@ const mediaChannelOptions = computed<MediaChannelOption[]>(() => {
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const mediaChannelGroups = computed<MediaChannelGroup[]>(() => {
|
||||||
|
const groups = pager.extend.media_channel_groups
|
||||||
|
if (Array.isArray(groups) && groups.length > 0) {
|
||||||
|
return groups as MediaChannelGroup[]
|
||||||
|
}
|
||||||
|
const flat = mediaChannelOptionsFlat.value
|
||||||
|
if (!flat.length) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
group_name: '渠道',
|
||||||
|
channels: flat.map(item => ({
|
||||||
|
channel_code: item.code,
|
||||||
|
channel_name: item.name,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
const deptOptions = computed<DeptOption[]>(() => {
|
const deptOptions = computed<DeptOption[]>(() => {
|
||||||
const options = pager.extend.dept_options
|
const options = pager.extend.dept_options
|
||||||
return Array.isArray(options) ? options : []
|
return Array.isArray(options) ? options : []
|
||||||
@@ -177,6 +221,17 @@ const deptOptions = computed<DeptOption[]>(() => {
|
|||||||
|
|
||||||
const defaultMediaChannelCode = computed(() => String(pager.extend.default_media_channel_code || ''))
|
const defaultMediaChannelCode = computed(() => String(pager.extend.default_media_channel_code || ''))
|
||||||
|
|
||||||
|
const getChannelGroupLabel = (channelCode: string): string => {
|
||||||
|
if (!channelCode) return ''
|
||||||
|
for (const group of mediaChannelGroups.value) {
|
||||||
|
const channel = group.channels.find(ch => ch.channel_code === channelCode)
|
||||||
|
if (channel) {
|
||||||
|
return group.group_name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
const treeProps = {
|
const treeProps = {
|
||||||
value: 'id',
|
value: 'id',
|
||||||
label: 'name',
|
label: 'name',
|
||||||
@@ -213,3 +268,21 @@ const handleReset = () => {
|
|||||||
|
|
||||||
getLists()
|
getLists()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.account-cost-channel-select {
|
||||||
|
:deep(.channel-opt-row) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
:deep(.opt-name) {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
<template>
|
||||||
|
<div class="assistant-performance-page">
|
||||||
|
<el-card class="!border-none" shadow="never">
|
||||||
|
<el-form :inline="true" class="stats-filter-form">
|
||||||
|
<el-form-item label="时间范围">
|
||||||
|
<el-radio-group v-model="queryParams.time_type" @change="handleTimeTypeChange">
|
||||||
|
<el-radio-button label="today">今天</el-radio-button>
|
||||||
|
<el-radio-button label="yesterday">昨天</el-radio-button>
|
||||||
|
<el-radio-button label="week">最近7天</el-radio-button>
|
||||||
|
<el-radio-button label="month">最近30天</el-radio-button>
|
||||||
|
<el-radio-button label="custom">自定义</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item v-if="queryParams.time_type === 'custom'">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="dateRange"
|
||||||
|
type="daterange"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
style="width: 260px"
|
||||||
|
@change="handleCustomDateChange"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="loading" @click="fetchData">查询</el-button>
|
||||||
|
<el-button @click="handleReset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<div class="stats-kpi-grid">
|
||||||
|
<div v-for="card in summaryCards" :key="card.key" class="stats-kpi-card" :class="card.cardClass">
|
||||||
|
<div class="stats-kpi-label">{{ card.label }}</div>
|
||||||
|
<div class="stats-kpi-value" :class="{ 'is-money': card.type === 'money' }">
|
||||||
|
{{ formatValue(card.key, card.type) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card class="!border-none mt-4" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">业绩趋势</span>
|
||||||
|
<span class="card-hint">{{ dateRangeText }} · 履约完成业绩</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<v-charts
|
||||||
|
v-if="chartHasData"
|
||||||
|
class="stats-chart"
|
||||||
|
:option="chartOption"
|
||||||
|
autoresize
|
||||||
|
/>
|
||||||
|
<el-empty v-else description="暂无数据" />
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts" name="assistantPerformancePage">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import vCharts from 'vue-echarts'
|
||||||
|
import { assistantPerformanceOverview } from '@/api/stats'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const dateRange = ref<string[]>([])
|
||||||
|
const queryParams = reactive({
|
||||||
|
time_type: 'month',
|
||||||
|
start_date: '',
|
||||||
|
end_date: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const overview = reactive<Record<string, any>>({
|
||||||
|
date_range: [],
|
||||||
|
summary: {
|
||||||
|
total_amount: 0,
|
||||||
|
total_count: 0
|
||||||
|
},
|
||||||
|
chart: {
|
||||||
|
dates: [],
|
||||||
|
amounts: [],
|
||||||
|
counts: []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const summaryCards = [
|
||||||
|
{ key: 'total_amount', label: '业绩', type: 'money', cardClass: '' },
|
||||||
|
{ key: 'total_count', label: '有效订单数', type: 'count', cardClass: '' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const dateRangeText = computed(() => {
|
||||||
|
if (!overview.date_range?.length) return '未选择'
|
||||||
|
return `${overview.date_range[0]} 至 ${overview.date_range[1]}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartHasData = computed(() => {
|
||||||
|
return overview.chart.dates.length > 0 && overview.chart.amounts.some((v: number) => v > 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartOption = computed(() => ({
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
formatter: (params: any) => {
|
||||||
|
const p = params[0]
|
||||||
|
return `${p.axisValue}<br/>${p.marker}${p.seriesName}: ¥${Number(p.value).toFixed(2)}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: { left: 60, right: 24, top: 36, bottom: 36 },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: overview.chart.dates,
|
||||||
|
axisLabel: {
|
||||||
|
interval: overview.chart.dates.length > 15 ? 'auto' : 0,
|
||||||
|
rotate: overview.chart.dates.length > 10 ? 30 : 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
name: '金额(元)',
|
||||||
|
axisLabel: {
|
||||||
|
formatter: (val: number) => val >= 10000 ? (val / 10000).toFixed(1) + '万' : String(val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '业绩',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: true,
|
||||||
|
symbolSize: 6,
|
||||||
|
lineStyle: { width: 3, color: '#4a78ff' },
|
||||||
|
itemStyle: { color: '#4a78ff' },
|
||||||
|
areaStyle: {
|
||||||
|
color: {
|
||||||
|
type: 'linear',
|
||||||
|
x: 0, y: 0, x2: 0, y2: 1,
|
||||||
|
colorStops: [
|
||||||
|
{ offset: 0, color: 'rgba(74, 120, 255, 0.25)' },
|
||||||
|
{ offset: 1, color: 'rgba(74, 120, 255, 0.02)' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: overview.chart.amounts
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
|
||||||
|
const formatValue = (key: string, type: string) => {
|
||||||
|
const value = overview.summary?.[key] ?? 0
|
||||||
|
if (type === 'money') return `¥${Number(value).toFixed(2)}`
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params: Record<string, any> = { time_type: queryParams.time_type }
|
||||||
|
if (queryParams.time_type === 'custom') {
|
||||||
|
params.start_date = dateRange.value[0] || ''
|
||||||
|
params.end_date = dateRange.value[1] || ''
|
||||||
|
}
|
||||||
|
const res = await assistantPerformanceOverview(params)
|
||||||
|
Object.assign(overview, res || {})
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('获取业绩数据失败:', error)
|
||||||
|
ElMessage.error(error?.msg || '获取业绩数据失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTimeTypeChange = () => {
|
||||||
|
if (queryParams.time_type !== 'custom') {
|
||||||
|
dateRange.value = []
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCustomDateChange = () => {
|
||||||
|
if (dateRange.value?.length === 2) {
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
queryParams.time_type = 'month'
|
||||||
|
dateRange.value = []
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.assistant-performance-page {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-filter-form {
|
||||||
|
:deep(.el-form-item) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-card {
|
||||||
|
background: linear-gradient(145deg, #ffffff, #f5f8ff);
|
||||||
|
border: 1px solid #ebf1ff;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 10px 24px rgba(74, 120, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-card.is-cancelled {
|
||||||
|
background: linear-gradient(145deg, #ffffff, #fff5f5);
|
||||||
|
border-color: #ffe0e0;
|
||||||
|
box-shadow: 0 10px 24px rgba(239, 68, 68, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-label {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-value {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1.1;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-value.is-money {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-card.is-cancelled .stats-kpi-value {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hint {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-chart {
|
||||||
|
height: 360px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<el-select
|
||||||
|
:model-value="modelValue"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:clearable="clearable"
|
||||||
|
:filterable="filterable"
|
||||||
|
:allow-create="allowCreate"
|
||||||
|
:default-first-option="allowCreate"
|
||||||
|
:disabled="disabled"
|
||||||
|
:loading="loading"
|
||||||
|
:class="selectClass"
|
||||||
|
:style="selectStyle"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
@change="emit('change', $event)"
|
||||||
|
@visible-change="onVisibleChange"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in normalizedOptions"
|
||||||
|
:key="item.name"
|
||||||
|
:label="item.name"
|
||||||
|
:value="item.name"
|
||||||
|
/>
|
||||||
|
<!-- 旧记录可能存在不在当前字典内的值:保留显示,避免编辑时反查不到 -->
|
||||||
|
<el-option
|
||||||
|
v-if="modelValue && !hasCurrentValue"
|
||||||
|
:key="`__legacy_${modelValue}`"
|
||||||
|
:label="`${modelValue}(已停用)`"
|
||||||
|
:value="modelValue"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
interface OptionItem {
|
||||||
|
name: string
|
||||||
|
value?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue?: string
|
||||||
|
options: Array<OptionItem | string>
|
||||||
|
loading?: boolean
|
||||||
|
placeholder?: string
|
||||||
|
clearable?: boolean
|
||||||
|
filterable?: boolean
|
||||||
|
allowCreate?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
selectClass?: string
|
||||||
|
selectStyle?: string | Record<string, string>
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
modelValue: '',
|
||||||
|
loading: false,
|
||||||
|
placeholder: '请选择自媒体来源',
|
||||||
|
clearable: true,
|
||||||
|
filterable: true,
|
||||||
|
allowCreate: false,
|
||||||
|
disabled: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
change: [value: string]
|
||||||
|
'visible-change': [visible: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const normalizedOptions = computed<OptionItem[]>(() => {
|
||||||
|
return (props.options || []).map((item) =>
|
||||||
|
typeof item === 'string' ? { name: item } : { name: item.name, value: item.value }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasCurrentValue = computed(() => {
|
||||||
|
return normalizedOptions.value.some((item) => item.name === props.modelValue)
|
||||||
|
})
|
||||||
|
|
||||||
|
const onVisibleChange = (visible: boolean) => {
|
||||||
|
emit('visible-change', visible)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
<template>
|
||||||
|
<div class="personal-account-cost-page">
|
||||||
|
<el-card class="!border-none mb-4" shadow="never">
|
||||||
|
<div class="flex flex-wrap">
|
||||||
|
<div class="w-1/2 md:w-1/3">
|
||||||
|
<div class="leading-10">当前筛选天数</div>
|
||||||
|
<div class="text-4xl">{{ pager.extend.days_count ?? 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-1/2 md:w-1/3">
|
||||||
|
<div class="leading-10">累计账户消耗 (元)</div>
|
||||||
|
<div class="text-4xl">¥{{ pager.extend.total_amount ?? '0.00' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="!border-none" shadow="never">
|
||||||
|
<el-form class="mb-[-16px]" :model="queryParams" inline>
|
||||||
|
<el-form-item label="日期范围">
|
||||||
|
<daterange-picker
|
||||||
|
v-model:startTime="queryParams.start_date"
|
||||||
|
v-model:endTime="queryParams.end_date"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="部门">
|
||||||
|
<el-tree-select
|
||||||
|
v-model="queryParams.dept_id"
|
||||||
|
:data="deptOptions"
|
||||||
|
node-key="id"
|
||||||
|
:props="deptTreeProps"
|
||||||
|
:default-expand-all="true"
|
||||||
|
check-strictly
|
||||||
|
placeholder="全部部门"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
class="w-[220px]"
|
||||||
|
@change="resetPage"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="自媒体来源">
|
||||||
|
<media-source-select
|
||||||
|
v-model="queryParams.media_source"
|
||||||
|
:options="mediaSourceOptions"
|
||||||
|
:loading="mediaSourceLoading"
|
||||||
|
placeholder="全部来源"
|
||||||
|
:allow-create="false"
|
||||||
|
select-class="w-[220px]"
|
||||||
|
@change="resetPage"
|
||||||
|
@visible-change="onMediaSourceDropdownVisible"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item class="w-[280px]" label="备注/录入人">
|
||||||
|
<el-input
|
||||||
|
v-model="queryParams.remark"
|
||||||
|
placeholder="备注 / 创建人"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="resetPage"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="resetPage">查询</el-button>
|
||||||
|
<el-button @click="handleReset">重置</el-button>
|
||||||
|
<el-button v-if="selfInputPath" class="ml-2" @click="goSelfInput">返回统计</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="!border-none mt-4" shadow="never">
|
||||||
|
<div>
|
||||||
|
<el-button v-perms="['stats.personal_account_cost/add']" type="primary" @click="handleAdd">
|
||||||
|
<template #icon>
|
||||||
|
<icon name="el-icon-Plus" />
|
||||||
|
</template>
|
||||||
|
新增
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table class="mt-4" size="large" v-loading="pager.loading" :data="pager.lists">
|
||||||
|
<el-table-column label="日期" prop="cost_date" min-width="120" />
|
||||||
|
<el-table-column label="自媒体来源" prop="media_source" min-width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column label="账户消耗" min-width="120">
|
||||||
|
<template #default="{ row }">¥{{ row.amount }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="备注" prop="remark" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column label="录入人" prop="creator_name" min-width="100" />
|
||||||
|
<el-table-column label="部门" prop="dept_name" min-width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip
|
||||||
|
v-if="row.dept_path"
|
||||||
|
:content="row.dept_path"
|
||||||
|
placement="top"
|
||||||
|
effect="dark"
|
||||||
|
>
|
||||||
|
<span class="dept-cell">{{ row.dept_name || '未分配' }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
<span v-else-if="row.dept_name">{{ row.dept_name }}</span>
|
||||||
|
<span v-else class="text-gray-400">未分配</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="最后修改人" prop="updater_name" min-width="100" />
|
||||||
|
<el-table-column label="更新时间" prop="update_time" min-width="180" />
|
||||||
|
<el-table-column label="操作" width="160" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
v-perms="['stats.personal_account_cost/edit']"
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
@click="handleEdit(row)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-perms="['stats.personal_account_cost/delete']"
|
||||||
|
type="danger"
|
||||||
|
link
|
||||||
|
@click="handleDelete(row.id)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="flex justify-end mt-4">
|
||||||
|
<pagination v-model="pager" @change="getLists" />
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<cost-edit-popup
|
||||||
|
v-if="showEdit"
|
||||||
|
ref="editRef"
|
||||||
|
:media-source-options="mediaSourceOptions"
|
||||||
|
:media-source-loading="mediaSourceLoading"
|
||||||
|
@success="handleCostSuccess"
|
||||||
|
@close="showEdit = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup name="personalAccountCost">
|
||||||
|
import { personalAccountCostDelete, personalAccountCostLists } from '@/api/self_input_stats'
|
||||||
|
import { deptAll } from '@/api/org/department'
|
||||||
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
|
import { getRoutePath } from '@/router'
|
||||||
|
import feedback from '@/utils/feedback'
|
||||||
|
|
||||||
|
import CostEditPopup from './cost-edit.vue'
|
||||||
|
import MediaSourceSelect from './MediaSourceSelect.vue'
|
||||||
|
import { useMediaSourceOptions } from './useMediaSourceOptions'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const editRef = shallowRef<InstanceType<typeof CostEditPopup>>()
|
||||||
|
const showEdit = ref(false)
|
||||||
|
const deptOptions = ref<any[]>([])
|
||||||
|
const {
|
||||||
|
mediaSourceOptions,
|
||||||
|
mediaSourceLoading,
|
||||||
|
loadMediaSourceOptions,
|
||||||
|
refreshMediaSourceOptions,
|
||||||
|
} = useMediaSourceOptions()
|
||||||
|
|
||||||
|
const deptTreeProps = {
|
||||||
|
value: 'id',
|
||||||
|
label: 'name',
|
||||||
|
children: 'children',
|
||||||
|
}
|
||||||
|
|
||||||
|
const selfInputPath = computed(() => getRoutePath('stats.self_input/overview') || '')
|
||||||
|
|
||||||
|
const goSelfInput = () => {
|
||||||
|
if (selfInputPath.value) {
|
||||||
|
router.push(selfInputPath.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryParams = reactive({
|
||||||
|
start_date: '',
|
||||||
|
end_date: '',
|
||||||
|
media_source: '',
|
||||||
|
dept_id: undefined as number | undefined,
|
||||||
|
remark: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const { pager, getLists, resetPage } = usePaging({
|
||||||
|
fetchFun: personalAccountCostLists,
|
||||||
|
params: queryParams,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
queryParams.start_date = ''
|
||||||
|
queryParams.end_date = ''
|
||||||
|
queryParams.media_source = ''
|
||||||
|
queryParams.dept_id = undefined
|
||||||
|
queryParams.remark = ''
|
||||||
|
resetPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMediaSourceDropdownVisible = (visible: boolean) => {
|
||||||
|
if (visible) {
|
||||||
|
refreshMediaSourceOptions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCostSuccess = async () => {
|
||||||
|
await refreshMediaSourceOptions()
|
||||||
|
getLists()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
await refreshMediaSourceOptions()
|
||||||
|
showEdit.value = true
|
||||||
|
await nextTick()
|
||||||
|
editRef.value?.open('add')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = async (row: Record<string, any>) => {
|
||||||
|
showEdit.value = true
|
||||||
|
await nextTick()
|
||||||
|
editRef.value?.open('edit')
|
||||||
|
editRef.value?.setFormData(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
await feedback.confirm('确定删除该账户消耗记录?')
|
||||||
|
await personalAccountCostDelete({ id })
|
||||||
|
getLists()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadDeptOptions = async () => {
|
||||||
|
try {
|
||||||
|
const res = await deptAll({ apply_data_scope: 1 })
|
||||||
|
if (Array.isArray(res)) {
|
||||||
|
deptOptions.value = res
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
deptOptions.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadDeptOptions()
|
||||||
|
loadMediaSourceOptions()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.personal-account-cost-page {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dept-cell {
|
||||||
|
cursor: help;
|
||||||
|
border-bottom: 1px dashed #c0c4cc;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<template>
|
||||||
|
<div class="edit-popup">
|
||||||
|
<popup
|
||||||
|
ref="popupRef"
|
||||||
|
:title="popupTitle"
|
||||||
|
:async="true"
|
||||||
|
width="520px"
|
||||||
|
@confirm="handleSubmit"
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<el-form ref="formRef" :model="formData" label-width="110px" :rules="formRules">
|
||||||
|
<el-form-item label="日期" prop="cost_date">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="formData.cost_date"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
placeholder="请选择日期"
|
||||||
|
:disabled="mode === 'edit'"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="自媒体来源" prop="media_source">
|
||||||
|
<media-source-select
|
||||||
|
v-model="formData.media_source"
|
||||||
|
:options="mediaSourceOptions"
|
||||||
|
:loading="mediaSourceLoading"
|
||||||
|
placeholder="请选择自媒体来源"
|
||||||
|
:allow-create="false"
|
||||||
|
:disabled="mode === 'edit'"
|
||||||
|
select-class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="账户消耗" prop="amount">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formData.amount"
|
||||||
|
:min="0"
|
||||||
|
:step="0.01"
|
||||||
|
:precision="2"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注" prop="remark">
|
||||||
|
<el-input
|
||||||
|
v-model="formData.remark"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 5 }"
|
||||||
|
maxlength="255"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="选填"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</popup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { FormInstance } from 'element-plus'
|
||||||
|
|
||||||
|
import {
|
||||||
|
personalAccountCostAdd,
|
||||||
|
personalAccountCostEdit,
|
||||||
|
} from '@/api/self_input_stats'
|
||||||
|
import Popup from '@/components/popup/index.vue'
|
||||||
|
|
||||||
|
import MediaSourceSelect from './MediaSourceSelect.vue'
|
||||||
|
import type { MediaSourceOption } from './useMediaSourceOptions'
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
mediaSourceOptions?: MediaSourceOption[]
|
||||||
|
mediaSourceLoading?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
mediaSourceOptions: () => [],
|
||||||
|
mediaSourceLoading: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits(['success', 'close'])
|
||||||
|
const formRef = shallowRef<FormInstance>()
|
||||||
|
const popupRef = shallowRef<InstanceType<typeof Popup>>()
|
||||||
|
const mode = ref<'add' | 'edit'>('add')
|
||||||
|
|
||||||
|
const popupTitle = computed(() => (mode.value === 'edit' ? '编辑账户消耗' : '新增账户消耗'))
|
||||||
|
|
||||||
|
const formData = reactive({
|
||||||
|
id: '',
|
||||||
|
cost_date: '',
|
||||||
|
media_source: '',
|
||||||
|
amount: 0,
|
||||||
|
remark: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const formRules = {
|
||||||
|
cost_date: [{ required: true, message: '请选择日期', trigger: ['change'] }],
|
||||||
|
media_source: [{ required: true, message: '请填写自媒体来源', trigger: ['blur'] }],
|
||||||
|
amount: [{ required: true, message: '请输入账户消耗金额', trigger: ['blur', 'change'] }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
formData.id = ''
|
||||||
|
formData.cost_date = ''
|
||||||
|
formData.media_source = ''
|
||||||
|
formData.amount = 0
|
||||||
|
formData.remark = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
await formRef.value?.validate()
|
||||||
|
if (mode.value === 'edit') {
|
||||||
|
await personalAccountCostEdit(formData)
|
||||||
|
} else {
|
||||||
|
await personalAccountCostAdd(formData)
|
||||||
|
}
|
||||||
|
popupRef.value?.close()
|
||||||
|
emit('success')
|
||||||
|
}
|
||||||
|
|
||||||
|
const open = (type: 'add' | 'edit' = 'add') => {
|
||||||
|
mode.value = type
|
||||||
|
resetForm()
|
||||||
|
popupRef.value?.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
const setFormData = (data: Record<string, any>) => {
|
||||||
|
formData.id = data.id ?? ''
|
||||||
|
formData.cost_date = data.cost_date ?? ''
|
||||||
|
formData.media_source = data.media_source ?? ''
|
||||||
|
formData.amount = Number(data.amount ?? 0)
|
||||||
|
formData.remark = data.remark ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
open,
|
||||||
|
setFormData,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,499 @@
|
|||||||
|
<template>
|
||||||
|
<div class="self-input-stats-page">
|
||||||
|
<el-card class="!border-none" shadow="never">
|
||||||
|
<el-form :inline="true" :model="queryParams" class="stats-filter-form">
|
||||||
|
<el-form-item label="时间范围">
|
||||||
|
<el-radio-group v-model="queryParams.time_type" @change="handleTimeTypeChange">
|
||||||
|
<el-radio-button label="today">今天</el-radio-button>
|
||||||
|
<el-radio-button label="yesterday">昨天</el-radio-button>
|
||||||
|
<el-radio-button label="week">最近7天</el-radio-button>
|
||||||
|
<el-radio-button label="month">最近30天</el-radio-button>
|
||||||
|
<el-radio-button label="custom">自定义</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item v-if="queryParams.time_type === 'custom'">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="dateRange"
|
||||||
|
type="daterange"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
style="width: 260px"
|
||||||
|
@change="handleCustomDateChange"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="部门">
|
||||||
|
<el-tree-select
|
||||||
|
v-model="queryParams.dept_id"
|
||||||
|
:data="deptOptions"
|
||||||
|
node-key="id"
|
||||||
|
:props="deptTreeProps"
|
||||||
|
:default-expand-all="true"
|
||||||
|
check-strictly
|
||||||
|
placeholder="全部部门"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
style="width: 220px"
|
||||||
|
@change="handleQuery"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="自媒体来源">
|
||||||
|
<media-source-select
|
||||||
|
v-model="queryParams.media_source"
|
||||||
|
:options="mediaSourceOptions"
|
||||||
|
:loading="mediaSourceLoading"
|
||||||
|
placeholder="全部来源"
|
||||||
|
:allow-create="false"
|
||||||
|
select-style="width: 220px"
|
||||||
|
@change="handleQuery"
|
||||||
|
@visible-change="onMediaSourceDropdownVisible"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="pager.loading" @click="handleQuery">查询</el-button>
|
||||||
|
<el-button @click="handleReset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<div class="stats-kpi-grid">
|
||||||
|
<div v-for="card in visibleSummaryCards" :key="card.key" class="stats-kpi-card">
|
||||||
|
<div class="stats-kpi-label">{{ card.label }}</div>
|
||||||
|
<div class="stats-kpi-value" :class="{ 'is-money': card.type === 'money' }">
|
||||||
|
{{ renderMetric(card.key, overview.summary, card.type) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card class="!border-none mt-4 stats-detail-card" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-header-main">
|
||||||
|
<span class="card-title">业绩明细</span>
|
||||||
|
<span class="card-hint">{{ dateRangeText }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-header-actions">
|
||||||
|
<el-button v-perms="['stats.personal_yeji/add']" type="primary" @click="handleAddYeji">
|
||||||
|
<template #icon>
|
||||||
|
<icon name="el-icon-Plus" />
|
||||||
|
</template>
|
||||||
|
新增业绩
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="accountCostEntryPath"
|
||||||
|
v-perms="['stats.personal_account_cost/lists']"
|
||||||
|
@click="goAccountCostEntry"
|
||||||
|
>
|
||||||
|
账户消耗录入
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
v-loading="pager.loading"
|
||||||
|
:data="pager.lists"
|
||||||
|
border
|
||||||
|
size="large"
|
||||||
|
max-height="640"
|
||||||
|
>
|
||||||
|
<el-table-column label="日期" prop="yeji_date" min-width="110" fixed="left" />
|
||||||
|
<el-table-column label="自媒体来源" prop="media_source" min-width="140" show-overflow-tooltip />
|
||||||
|
<el-table-column label="录入人" prop="creator_name" min-width="100" />
|
||||||
|
<el-table-column label="部门" prop="dept_name" min-width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip
|
||||||
|
v-if="row.dept_path"
|
||||||
|
:content="row.dept_path"
|
||||||
|
placement="top"
|
||||||
|
effect="dark"
|
||||||
|
>
|
||||||
|
<span class="dept-cell">{{ row.dept_name || '未分配' }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
<span v-else-if="row.dept_name">{{ row.dept_name }}</span>
|
||||||
|
<span v-else class="text-gray-400">未分配</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
v-for="col in visibleTableColumns"
|
||||||
|
:key="col.key"
|
||||||
|
:label="col.label"
|
||||||
|
:min-width="col.minWidth || 100"
|
||||||
|
align="right"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ renderMetric(col.key, row, col.type) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="备注" prop="remark" min-width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column label="操作" width="140" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
v-perms="['stats.personal_yeji/edit']"
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
@click="handleEditYeji(row)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-perms="['stats.personal_yeji/delete']"
|
||||||
|
type="danger"
|
||||||
|
link
|
||||||
|
@click="handleDeleteYeji(row.id)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="flex justify-end mt-4">
|
||||||
|
<pagination v-model="pager" @change="fetchOverview" />
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<yeji-edit-popup
|
||||||
|
v-if="showYejiEdit"
|
||||||
|
ref="yejiEditRef"
|
||||||
|
:media-source-options="mediaSourceOptions"
|
||||||
|
:media-source-loading="mediaSourceLoading"
|
||||||
|
@success="handleYejiSuccess"
|
||||||
|
@close="showYejiEdit = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup name="selfInputStats">
|
||||||
|
import {
|
||||||
|
getSelfInputOverview,
|
||||||
|
personalYejiDelete,
|
||||||
|
} from '@/api/self_input_stats'
|
||||||
|
import { deptAll } from '@/api/org/department'
|
||||||
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
|
import { getRoutePath } from '@/router'
|
||||||
|
import feedback from '@/utils/feedback'
|
||||||
|
|
||||||
|
import MediaSourceSelect from './MediaSourceSelect.vue'
|
||||||
|
import YejiEditPopup from './yeji-edit.vue'
|
||||||
|
import { useMediaSourceOptions } from './useMediaSourceOptions'
|
||||||
|
|
||||||
|
type MetricType = 'count' | 'money' | 'percent' | 'ratio'
|
||||||
|
|
||||||
|
interface MetricCard {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
type: MetricType
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricColumn {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
type: MetricType
|
||||||
|
minWidth?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const yejiEditRef = shallowRef<InstanceType<typeof YejiEditPopup>>()
|
||||||
|
const showYejiEdit = ref(false)
|
||||||
|
const dateRange = ref<string[]>([])
|
||||||
|
const deptOptions = ref<any[]>([])
|
||||||
|
const {
|
||||||
|
mediaSourceOptions,
|
||||||
|
mediaSourceLoading,
|
||||||
|
loadMediaSourceOptions,
|
||||||
|
refreshMediaSourceOptions,
|
||||||
|
} = useMediaSourceOptions()
|
||||||
|
|
||||||
|
const deptTreeProps = {
|
||||||
|
value: 'id',
|
||||||
|
label: 'name',
|
||||||
|
children: 'children',
|
||||||
|
}
|
||||||
|
|
||||||
|
const overview = reactive<Record<string, any>>({
|
||||||
|
date_range: [],
|
||||||
|
summary: {},
|
||||||
|
can_view_finance: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const FINANCE_KEYS = new Set(['account_cost', 'cash_cost', 'roi'])
|
||||||
|
const canViewFinance = computed(() => Boolean(overview.can_view_finance))
|
||||||
|
|
||||||
|
const queryParams = reactive({
|
||||||
|
media_source: '',
|
||||||
|
dept_id: undefined as number | undefined,
|
||||||
|
time_type: 'today',
|
||||||
|
start_date: '',
|
||||||
|
end_date: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const accountCostEntryPath = computed(() => getRoutePath('stats.personal_account_cost/lists') || '')
|
||||||
|
|
||||||
|
const goAccountCostEntry = () => {
|
||||||
|
if (accountCostEntryPath.value) {
|
||||||
|
router.push(accountCostEntryPath.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchOverviewList = async (params: Record<string, any>) => {
|
||||||
|
if (queryParams.time_type === 'custom') {
|
||||||
|
params.start_date = dateRange.value[0] || ''
|
||||||
|
params.end_date = dateRange.value[1] || ''
|
||||||
|
}
|
||||||
|
const res = await getSelfInputOverview(params)
|
||||||
|
Object.assign(overview, res?.extend || {})
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadDeptOptions = async () => {
|
||||||
|
try {
|
||||||
|
const res = await deptAll({ apply_data_scope: 1 })
|
||||||
|
if (Array.isArray(res)) {
|
||||||
|
deptOptions.value = res
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
deptOptions.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMediaSourceDropdownVisible = (visible: boolean) => {
|
||||||
|
if (visible) {
|
||||||
|
refreshMediaSourceOptions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleYejiSuccess = async () => {
|
||||||
|
await refreshMediaSourceOptions()
|
||||||
|
await fetchOverview()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadDeptOptions()
|
||||||
|
loadMediaSourceOptions()
|
||||||
|
})
|
||||||
|
|
||||||
|
const { pager, getLists } = usePaging({
|
||||||
|
fetchFun: fetchOverviewList,
|
||||||
|
params: queryParams,
|
||||||
|
firstLoading: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const summaryCards: MetricCard[] = [
|
||||||
|
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
|
||||||
|
{ key: 'total_open_count', label: '总开口', type: 'count' },
|
||||||
|
{ key: 'unreplied_count', label: '未回复', type: 'count' },
|
||||||
|
{ key: 'paid_appointment_count', label: '付费挂号', type: 'count' },
|
||||||
|
{ key: 'free_appointment_count', label: '免费挂号', type: 'count' },
|
||||||
|
{ key: 'interview_count', label: '面诊', type: 'count' },
|
||||||
|
{ key: 'order_amount', label: '订单金额', type: 'money' },
|
||||||
|
{ key: 'account_cost', label: '账户消耗', type: 'money' },
|
||||||
|
{ key: 'paid_appointment_rate', label: '付费挂号率', type: 'percent' },
|
||||||
|
{ key: 'interview_rate', label: '面诊率', type: 'percent' },
|
||||||
|
{ key: 'receive_rate', label: '接诊率', type: 'percent' },
|
||||||
|
{ key: 'interview_receive_rate', label: '面诊接诊率', type: 'percent' },
|
||||||
|
{ key: 'avg_unit_price', label: '平均单价', type: 'money' },
|
||||||
|
{ key: 'cash_cost', label: '现金成本', type: 'money' },
|
||||||
|
{ key: 'roi', label: 'ROI', type: 'ratio' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const tableColumns: MetricColumn[] = [
|
||||||
|
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
|
||||||
|
{ key: 'total_open_count', label: '总开口', type: 'count' },
|
||||||
|
{ key: 'unreplied_count', label: '未回复', type: 'count' },
|
||||||
|
{ key: 'paid_appointment_count', label: '付费挂号', type: 'count' },
|
||||||
|
{ key: 'free_appointment_count', label: '免费挂号', type: 'count' },
|
||||||
|
{ key: 'appointment_total_count', label: '挂号总数', type: 'count' },
|
||||||
|
{ key: 'interview_count', label: '面诊', type: 'count' },
|
||||||
|
{ key: 'completed_order_count', label: '接诊诊单', type: 'count' },
|
||||||
|
{ key: 'order_amount', label: '订单金额', type: 'money', minWidth: 110 },
|
||||||
|
{ key: 'account_cost', label: '账户消耗', type: 'money', minWidth: 110 },
|
||||||
|
{ key: 'paid_appointment_rate', label: '付费挂号率', type: 'percent', minWidth: 110 },
|
||||||
|
{ key: 'open_appointment_rate', label: '开口挂号率', type: 'percent', minWidth: 110 },
|
||||||
|
{ key: 'interview_rate', label: '面诊率', type: 'percent', minWidth: 100 },
|
||||||
|
{ key: 'receive_rate', label: '接诊率', type: 'percent', minWidth: 100 },
|
||||||
|
{ key: 'interview_receive_rate', label: '面诊接诊率', type: 'percent', minWidth: 120 },
|
||||||
|
{ key: 'open_receive_rate', label: '开口接诊率', type: 'percent', minWidth: 110 },
|
||||||
|
{ key: 'avg_unit_price', label: '平均单价', type: 'money', minWidth: 100 },
|
||||||
|
{ key: 'cash_cost', label: '现金成本', type: 'money', minWidth: 100 },
|
||||||
|
{ key: 'roi', label: 'ROI', type: 'ratio', minWidth: 80 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const visibleSummaryCards = computed(() =>
|
||||||
|
canViewFinance.value
|
||||||
|
? summaryCards
|
||||||
|
: summaryCards.filter((card) => !FINANCE_KEYS.has(card.key))
|
||||||
|
)
|
||||||
|
|
||||||
|
const visibleTableColumns = computed(() =>
|
||||||
|
canViewFinance.value
|
||||||
|
? tableColumns
|
||||||
|
: tableColumns.filter((col) => !FINANCE_KEYS.has(col.key))
|
||||||
|
)
|
||||||
|
|
||||||
|
const dateRangeText = computed(() => {
|
||||||
|
if (!overview.date_range?.length) return '未选择'
|
||||||
|
return `${overview.date_range[0]} 至 ${overview.date_range[1]}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchOverview = async () => {
|
||||||
|
try {
|
||||||
|
await getLists()
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('获取自录转化统计失败:', error)
|
||||||
|
ElMessage.error(error?.msg || '获取统计数据失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTimeTypeChange = () => {
|
||||||
|
if (queryParams.time_type !== 'custom') {
|
||||||
|
dateRange.value = []
|
||||||
|
pager.page = 1
|
||||||
|
fetchOverview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCustomDateChange = () => {
|
||||||
|
if (dateRange.value.length === 2) {
|
||||||
|
pager.page = 1
|
||||||
|
fetchOverview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleQuery = () => {
|
||||||
|
pager.page = 1
|
||||||
|
fetchOverview()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
queryParams.media_source = ''
|
||||||
|
queryParams.dept_id = undefined
|
||||||
|
queryParams.time_type = 'today'
|
||||||
|
dateRange.value = []
|
||||||
|
pager.page = 1
|
||||||
|
pager.size = 15
|
||||||
|
fetchOverview()
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderMetric = (key: string, source: Record<string, any>, type = 'count') => {
|
||||||
|
const value = source?.[key] ?? 0
|
||||||
|
if (type === 'money') return `¥${Number(value || 0).toFixed(2)}`
|
||||||
|
if (type === 'percent') return `${Number(value || 0).toFixed(2)}%`
|
||||||
|
if (type === 'ratio') return Number(value || 0).toFixed(2)
|
||||||
|
return String(value ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddYeji = async () => {
|
||||||
|
await refreshMediaSourceOptions()
|
||||||
|
showYejiEdit.value = true
|
||||||
|
await nextTick()
|
||||||
|
yejiEditRef.value?.open('add')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditYeji = async (row: Record<string, any>) => {
|
||||||
|
showYejiEdit.value = true
|
||||||
|
await nextTick()
|
||||||
|
yejiEditRef.value?.open('edit')
|
||||||
|
yejiEditRef.value?.setFormData(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteYeji = async (id: number) => {
|
||||||
|
await feedback.confirm('确定删除该业绩记录?')
|
||||||
|
await personalYejiDelete({ id })
|
||||||
|
fetchOverview()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchOverview()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.self-input-stats-page {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-filter-form {
|
||||||
|
:deep(.el-form-item) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-card {
|
||||||
|
background: linear-gradient(145deg, #ffffff, #f5f8ff);
|
||||||
|
border: 1px solid #ebf1ff;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 18px 16px;
|
||||||
|
box-shadow: 0 10px 24px rgba(74, 120, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dept-cell {
|
||||||
|
cursor: help;
|
||||||
|
border-bottom: 1px dashed #c0c4cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-label {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-value {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1.1;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-value.is-money {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hint {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-detail-card :deep(.el-card__body) {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { getSelfInputMediaSourceOptions } from '@/api/self_input_stats'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自录转化统计:自媒体来源选项(来自字典「推广渠道」channels)。
|
||||||
|
* 业绩页与账户消耗页共用同一接口,下拉打开时刷新,保证两侧字典变更同步。
|
||||||
|
*/
|
||||||
|
export interface MediaSourceOption {
|
||||||
|
name: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMediaSourceOptions() {
|
||||||
|
const mediaSourceOptions = ref<MediaSourceOption[]>([])
|
||||||
|
const mediaSourceLoading = ref(false)
|
||||||
|
|
||||||
|
const normalize = (raw: any): MediaSourceOption[] => {
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
const list: MediaSourceOption[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const item of raw) {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
const name = item.trim()
|
||||||
|
if (name && !seen.has(name)) {
|
||||||
|
seen.add(name)
|
||||||
|
list.push({ name, value: '' })
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (item && typeof item === 'object') {
|
||||||
|
const name = String(item.name ?? '').trim()
|
||||||
|
if (name && !seen.has(name)) {
|
||||||
|
seen.add(name)
|
||||||
|
list.push({ name, value: String(item.value ?? '') })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadMediaSourceOptions = async (force = false) => {
|
||||||
|
if (!force && mediaSourceOptions.value.length > 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mediaSourceLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getSelfInputMediaSourceOptions()
|
||||||
|
mediaSourceOptions.value = normalize(res)
|
||||||
|
} catch {
|
||||||
|
mediaSourceOptions.value = []
|
||||||
|
} finally {
|
||||||
|
mediaSourceLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshMediaSourceOptions = () => loadMediaSourceOptions(true)
|
||||||
|
|
||||||
|
return {
|
||||||
|
mediaSourceOptions,
|
||||||
|
mediaSourceLoading,
|
||||||
|
loadMediaSourceOptions,
|
||||||
|
refreshMediaSourceOptions,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
<template>
|
||||||
|
<div class="edit-popup">
|
||||||
|
<popup
|
||||||
|
ref="popupRef"
|
||||||
|
:title="popupTitle"
|
||||||
|
:async="true"
|
||||||
|
width="640px"
|
||||||
|
@confirm="handleSubmit"
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<el-form ref="formRef" :model="formData" label-width="110px" :rules="formRules">
|
||||||
|
<el-form-item label="业绩日期" prop="yeji_date">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="formData.yeji_date"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
placeholder="请选择日期"
|
||||||
|
:disabled="mode === 'edit'"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="自媒体来源" prop="media_source">
|
||||||
|
<media-source-select
|
||||||
|
v-model="formData.media_source"
|
||||||
|
:options="mediaSourceOptions"
|
||||||
|
:loading="mediaSourceLoading"
|
||||||
|
placeholder="请选择自媒体来源"
|
||||||
|
:allow-create="false"
|
||||||
|
:disabled="mode === 'edit'"
|
||||||
|
select-class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="加粉数" prop="add_fans_count">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formData.add_fans_count"
|
||||||
|
:min="0"
|
||||||
|
:step="1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="总开口" prop="total_open_count">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formData.total_open_count"
|
||||||
|
:min="0"
|
||||||
|
:step="1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="未回复" prop="unreplied_count">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formData.unreplied_count"
|
||||||
|
:min="0"
|
||||||
|
:step="1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="付费挂号" prop="paid_appointment_count">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formData.paid_appointment_count"
|
||||||
|
:min="0"
|
||||||
|
:step="1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="免费挂号" prop="free_appointment_count">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formData.free_appointment_count"
|
||||||
|
:min="0"
|
||||||
|
:step="1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="面诊" prop="interview_count">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formData.interview_count"
|
||||||
|
:min="0"
|
||||||
|
:step="1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="订单金额" prop="order_amount">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formData.order_amount"
|
||||||
|
:min="0"
|
||||||
|
:step="0.01"
|
||||||
|
:precision="2"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="接诊诊单" prop="completed_order_count">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formData.completed_order_count"
|
||||||
|
:min="0"
|
||||||
|
:step="1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-form-item label="备注" prop="remark">
|
||||||
|
<el-input
|
||||||
|
v-model="formData.remark"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 5 }"
|
||||||
|
maxlength="255"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="选填"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</popup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { FormInstance } from 'element-plus'
|
||||||
|
|
||||||
|
import { personalYejiAdd, personalYejiEdit } from '@/api/self_input_stats'
|
||||||
|
import Popup from '@/components/popup/index.vue'
|
||||||
|
|
||||||
|
import MediaSourceSelect from './MediaSourceSelect.vue'
|
||||||
|
import type { MediaSourceOption } from './useMediaSourceOptions'
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
mediaSourceOptions?: MediaSourceOption[]
|
||||||
|
mediaSourceLoading?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
mediaSourceOptions: () => [],
|
||||||
|
mediaSourceLoading: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits(['success', 'close'])
|
||||||
|
const formRef = shallowRef<FormInstance>()
|
||||||
|
const popupRef = shallowRef<InstanceType<typeof Popup>>()
|
||||||
|
const mode = ref<'add' | 'edit'>('add')
|
||||||
|
|
||||||
|
const popupTitle = computed(() => (mode.value === 'edit' ? '编辑业绩' : '新增业绩'))
|
||||||
|
|
||||||
|
const formData = reactive({
|
||||||
|
id: '',
|
||||||
|
yeji_date: '',
|
||||||
|
media_source: '',
|
||||||
|
add_fans_count: 0,
|
||||||
|
total_open_count: 0,
|
||||||
|
unreplied_count: 0,
|
||||||
|
paid_appointment_count: 0,
|
||||||
|
free_appointment_count: 0,
|
||||||
|
interview_count: 0,
|
||||||
|
order_amount: 0,
|
||||||
|
completed_order_count: 0,
|
||||||
|
remark: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const formRules = {
|
||||||
|
yeji_date: [{ required: true, message: '请选择业绩日期', trigger: ['change'] }],
|
||||||
|
media_source: [{ required: true, message: '请填写自媒体来源', trigger: ['blur'] }],
|
||||||
|
add_fans_count: [{ required: true, message: '请填写加粉数', trigger: ['blur', 'change'] }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
formData.id = ''
|
||||||
|
formData.yeji_date = ''
|
||||||
|
formData.media_source = ''
|
||||||
|
formData.add_fans_count = 0
|
||||||
|
formData.total_open_count = 0
|
||||||
|
formData.unreplied_count = 0
|
||||||
|
formData.paid_appointment_count = 0
|
||||||
|
formData.free_appointment_count = 0
|
||||||
|
formData.interview_count = 0
|
||||||
|
formData.order_amount = 0
|
||||||
|
formData.completed_order_count = 0
|
||||||
|
formData.remark = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
await formRef.value?.validate()
|
||||||
|
if (mode.value === 'edit') {
|
||||||
|
await personalYejiEdit(formData)
|
||||||
|
} else {
|
||||||
|
await personalYejiAdd(formData)
|
||||||
|
}
|
||||||
|
popupRef.value?.close()
|
||||||
|
emit('success')
|
||||||
|
}
|
||||||
|
|
||||||
|
const open = (type: 'add' | 'edit' = 'add') => {
|
||||||
|
mode.value = type
|
||||||
|
resetForm()
|
||||||
|
popupRef.value?.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
const setFormData = (data: Record<string, any>) => {
|
||||||
|
formData.id = data.id ?? ''
|
||||||
|
formData.yeji_date = data.yeji_date ?? ''
|
||||||
|
formData.media_source = data.media_source ?? ''
|
||||||
|
formData.add_fans_count = Number(data.add_fans_count ?? 0)
|
||||||
|
formData.total_open_count = Number(data.total_open_count ?? 0)
|
||||||
|
formData.unreplied_count = Number(data.unreplied_count ?? 0)
|
||||||
|
formData.paid_appointment_count = Number(data.paid_appointment_count ?? 0)
|
||||||
|
formData.free_appointment_count = Number(data.free_appointment_count ?? 0)
|
||||||
|
formData.interview_count = Number(data.interview_count ?? 0)
|
||||||
|
formData.order_amount = Number(data.order_amount ?? 0)
|
||||||
|
formData.completed_order_count = Number(data.completed_order_count ?? 0)
|
||||||
|
formData.remark = data.remark ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
open,
|
||||||
|
setFormData,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -23,6 +23,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="canEditDailyRecord" class="daily-matrix__toolbar-right">
|
<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 type="primary" @click="openQuickAdd('blood')">+血糖</el-button>
|
||||||
<el-button @click="openQuickAdd('diet')">+饮食</el-button>
|
<el-button @click="openQuickAdd('diet')">+饮食</el-button>
|
||||||
<el-button @click="openQuickAdd('exercise')">+运动</el-button>
|
<el-button @click="openQuickAdd('exercise')">+运动</el-button>
|
||||||
@@ -30,6 +34,10 @@
|
|||||||
<el-button @click="fetchTracking">刷新</el-button>
|
<el-button @click="fetchTracking">刷新</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="daily-matrix__toolbar-right">
|
<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>
|
<el-button @click="fetchTracking">刷新</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,7 +65,8 @@
|
|||||||
:class="{
|
:class="{
|
||||||
'is-clickable': isCellClickable(row.key, date),
|
'is-clickable': isCellClickable(row.key, date),
|
||||||
'is-empty': !getCell(row.key, date).hasRecord,
|
'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)"
|
@click="handleCellClick(row.key, date)"
|
||||||
>
|
>
|
||||||
@@ -82,6 +91,14 @@
|
|||||||
<template v-else-if="getCell(row.key, date).value">
|
<template v-else-if="getCell(row.key, date).value">
|
||||||
<span>{{ getCell(row.key, date).value }}</span>
|
<span>{{ getCell(row.key, date).value }}</span>
|
||||||
<span v-if="getCell(row.key, date).isHigh" class="daily-matrix__cell-up">↑</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>
|
||||||
<template v-else>—</template>
|
<template v-else>—</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -345,6 +362,10 @@ interface BloodRecord {
|
|||||||
western_medicine?: string
|
western_medicine?: string
|
||||||
insulin?: string
|
insulin?: string
|
||||||
remark?: string
|
remark?: string
|
||||||
|
/** 来源:0-医生录入 1-患者自录(小程序日常记录页) */
|
||||||
|
source?: number
|
||||||
|
/** 合并后该日是否含有「患者自录」记录 */
|
||||||
|
has_patient_self?: boolean
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,6 +579,10 @@ const visibleDates = computed(() => {
|
|||||||
return dates
|
return dates
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const hasPatientSelfRecord = computed(() => {
|
||||||
|
return bloodRecords.value.some((r) => Number((r as any).source) === 1)
|
||||||
|
})
|
||||||
|
|
||||||
const bloodByDate = computed(() => {
|
const bloodByDate = computed(() => {
|
||||||
const map = new Map<string, BloodRecord>()
|
const map = new Map<string, BloodRecord>()
|
||||||
for (const record of bloodRecords.value) {
|
for (const record of bloodRecords.value) {
|
||||||
@@ -570,7 +595,8 @@ const bloodByDate = computed(() => {
|
|||||||
patient_id: record.patient_id,
|
patient_id: record.patient_id,
|
||||||
record_date: date,
|
record_date: date,
|
||||||
record_time: record.record_time || '',
|
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)!
|
const merged = map.get(date)!
|
||||||
@@ -594,6 +620,10 @@ const bloodByDate = computed(() => {
|
|||||||
;(merged as any)[field] = record[field]
|
;(merged as any)[field] = record[field]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 当天有任意 source=1 的记录则标记为患者自录
|
||||||
|
if (Number(record.source) === 1) {
|
||||||
|
merged.has_patient_self = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return map
|
return map
|
||||||
})
|
})
|
||||||
@@ -723,35 +753,40 @@ function parseTrendNumber(value: unknown): number | null {
|
|||||||
return parsed
|
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 blood = bloodByDate.value.get(date)
|
||||||
const diet = dietByDate.value.get(date)
|
const diet = dietByDate.value.get(date)
|
||||||
const exercise = exerciseByDate.value.get(date)
|
const exercise = exerciseByDate.value.get(date)
|
||||||
const tracking = trackingByDate.value.get(date)
|
const tracking = trackingByDate.value.get(date)
|
||||||
|
const isPatientSelf = !!blood?.has_patient_self
|
||||||
switch (metric) {
|
switch (metric) {
|
||||||
case 'fasting':
|
case 'fasting':
|
||||||
return {
|
return {
|
||||||
value: blood?.fasting_blood_sugar != null && blood.fasting_blood_sugar !== '' ? String(blood.fasting_blood_sugar) : '',
|
value: blood?.fasting_blood_sugar != null && blood.fasting_blood_sugar !== '' ? String(blood.fasting_blood_sugar) : '',
|
||||||
isHigh: isHighFastingBloodSugar(blood?.fasting_blood_sugar, props.age),
|
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':
|
case 'postprandial':
|
||||||
return {
|
return {
|
||||||
value: blood?.postprandial_blood_sugar != null && blood.postprandial_blood_sugar !== '' ? String(blood.postprandial_blood_sugar) : '',
|
value: blood?.postprandial_blood_sugar != null && blood.postprandial_blood_sugar !== '' ? String(blood.postprandial_blood_sugar) : '',
|
||||||
isHigh: isHighPostprandialBloodSugar(blood?.postprandial_blood_sugar, props.age),
|
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':
|
case 'other':
|
||||||
return {
|
return {
|
||||||
value: blood?.other_blood_sugar != null && blood.other_blood_sugar !== '' ? String(blood.other_blood_sugar) : '',
|
value: blood?.other_blood_sugar != null && blood.other_blood_sugar !== '' ? String(blood.other_blood_sugar) : '',
|
||||||
isHigh: isHighOtherBloodSugar(blood?.other_blood_sugar, props.age),
|
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':
|
case 'bp':
|
||||||
return {
|
return {
|
||||||
value: blood && (blood.systolic_pressure || blood.diastolic_pressure) ? formatBp(blood) : '',
|
value: blood && (blood.systolic_pressure || blood.diastolic_pressure) ? formatBp(blood) : '',
|
||||||
isHigh: isHighBloodPressure(blood),
|
isHigh: isHighBloodPressure(blood),
|
||||||
hasRecord: !!blood && !!(blood.systolic_pressure || blood.diastolic_pressure)
|
hasRecord: !!blood && !!(blood.systolic_pressure || blood.diastolic_pressure),
|
||||||
|
isPatientSelf
|
||||||
}
|
}
|
||||||
case 'western':
|
case 'western':
|
||||||
return {
|
return {
|
||||||
@@ -1115,6 +1150,12 @@ defineExpose({ refresh: fetchTracking })
|
|||||||
color: #dc2626;
|
color: #dc2626;
|
||||||
font-weight: 700;
|
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 {
|
&__cell-up {
|
||||||
@@ -1122,6 +1163,38 @@ defineExpose({ refresh: fetchTracking })
|
|||||||
font-size: 13px;
|
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 {
|
&__todo {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,7 +131,7 @@
|
|||||||
<el-descriptions-item label="医师">{{ detailPrescription.doctor_name || '—' }}</el-descriptions-item>
|
<el-descriptions-item label="医师">{{ detailPrescription.doctor_name || '—' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="类型">{{ detailPrescription.prescription_type || '—' }}</el-descriptions-item>
|
<el-descriptions-item label="类型">{{ detailPrescription.prescription_type || '—' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="临床诊断" :span="2">{{ detailPrescription.clinical_diagnosis || '—' }}</el-descriptions-item>
|
<el-descriptions-item label="临床诊断" :span="2">{{ detailPrescription.clinical_diagnosis || '—' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="剂数 / 用法" :span="2">
|
<el-descriptions-item label="剂数 / 用法" :span="2" v-if="showPrescriptionAuditFilter">
|
||||||
{{ detailData.dose_count ?? detailPrescription.dose_count ?? '—' }} {{ detailData.dose_unit || '剂' }} ·
|
{{ detailData.dose_count ?? detailPrescription.dose_count ?? '—' }} {{ detailData.dose_unit || '剂' }} ·
|
||||||
{{ detailPrescription.usage_instruction || detailPrescription.usage_method || '—' }}
|
{{ detailPrescription.usage_instruction || detailPrescription.usage_method || '—' }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
@@ -274,6 +274,7 @@
|
|||||||
<el-descriptions-item label="用药疗程">{{ detailData.medication_days ? detailData.medication_days + ' 天' : '—' }}</el-descriptions-item>
|
<el-descriptions-item label="用药疗程">{{ detailData.medication_days ? detailData.medication_days + ' 天' : '—' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="上次医护">{{ detailData.prev_staff || '—' }}</el-descriptions-item>
|
<el-descriptions-item label="上次医护">{{ detailData.prev_staff || '—' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="服务渠道">{{ detailData.service_channel || '—' }}</el-descriptions-item>
|
<el-descriptions-item label="服务渠道">{{ detailData.service_channel || '—' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="服务套餐">{{ formatServicePackage(detailData.service_package) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="费用类别">{{ feeTypeText(detailData.fee_type) }}</el-descriptions-item>
|
<el-descriptions-item label="费用类别">{{ feeTypeText(detailData.fee_type) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="快递单号">{{ detailData.tracking_number || '—' }}</el-descriptions-item>
|
<el-descriptions-item label="快递单号">{{ detailData.tracking_number || '—' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="快递公司">{{ expressCompanyLabel(detailData.express_company) }}</el-descriptions-item>
|
<el-descriptions-item label="快递公司">{{ expressCompanyLabel(detailData.express_company) }}</el-descriptions-item>
|
||||||
@@ -332,7 +333,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, watch, ref } from 'vue'
|
import { computed, watch, ref, onMounted } from 'vue'
|
||||||
import { usePaging } from '@/hooks/usePaging'
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
import {
|
import {
|
||||||
prescriptionOrderLists,
|
prescriptionOrderLists,
|
||||||
@@ -341,6 +342,7 @@ import {
|
|||||||
prescriptionOrderLogisticsTrace,
|
prescriptionOrderLogisticsTrace,
|
||||||
prescriptionOrderPaidPayOrders
|
prescriptionOrderPaidPayOrders
|
||||||
} from '@/api/tcm'
|
} from '@/api/tcm'
|
||||||
|
import { getDictData } from '@/api/app'
|
||||||
import { hasPermission } from '@/utils/perm'
|
import { hasPermission } from '@/utils/perm'
|
||||||
import feedback from '@/utils/feedback'
|
import feedback from '@/utils/feedback'
|
||||||
import useUserStore from '@/stores/modules/user'
|
import useUserStore from '@/stores/modules/user'
|
||||||
@@ -377,6 +379,22 @@ const buildParams = () => {
|
|||||||
queryParams.scene = 'diagnosis_edit'
|
queryParams.scene = 'diagnosis_edit'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 诊间医助角色(与 DiagnosisLists 等一致) */
|
||||||
|
const TCM_ASSISTANT_ROLE_ID = 2
|
||||||
|
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
|
||||||
|
const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否展示「处方审核」相关字段
|
||||||
|
*/
|
||||||
|
const showPrescriptionAuditFilter = computed(() => {
|
||||||
|
const u = userStore.userInfo
|
||||||
|
if (!u || Number(u.root) === 1) return true
|
||||||
|
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
|
||||||
|
if (!ids.includes(TCM_ASSISTANT_ROLE_ID)) return true
|
||||||
|
return ids.some((id) => PRESCRIPTION_AUDIT_ROLE_IDS.includes(id))
|
||||||
|
})
|
||||||
|
|
||||||
// ─── 详情抽屉 ───
|
// ─── 详情抽屉 ───
|
||||||
const detailVisible = ref(false)
|
const detailVisible = ref(false)
|
||||||
const detailLoading = ref(false)
|
const detailLoading = ref(false)
|
||||||
@@ -563,6 +581,39 @@ function feeTypeText(t: number | undefined) {
|
|||||||
return m[Number(t)] ?? '—'
|
return m[Number(t)] ?? '—'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 服务套餐字典选项(与 order_list.vue 数据源一致:dict 类型 server_order)
|
||||||
|
const servicePackageOptions = ref<Array<{ name: string; value: string }>>([])
|
||||||
|
|
||||||
|
async function loadServicePackageOptions() {
|
||||||
|
try {
|
||||||
|
const data: any = await getDictData({ type: 'server_order' })
|
||||||
|
const list = (data?.server_order || []) as Array<{ name: string; value: string; status?: number }>
|
||||||
|
servicePackageOptions.value = list.filter((item) => item.status !== 0)
|
||||||
|
} catch {
|
||||||
|
servicePackageOptions.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatServicePackage(value: unknown): string {
|
||||||
|
if (value === null || value === undefined || value === '') return '—'
|
||||||
|
let packages: string[] = []
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
packages = value.map((v) => String(v)).filter((v) => v !== '')
|
||||||
|
} else if (typeof value === 'string') {
|
||||||
|
packages = value.split(',').map((v) => v.trim()).filter((v) => v !== '')
|
||||||
|
}
|
||||||
|
if (packages.length === 0) return '—'
|
||||||
|
const names = packages.map((val) => {
|
||||||
|
const opt = servicePackageOptions.value.find((o) => o.value === val)
|
||||||
|
return opt ? opt.name : val
|
||||||
|
})
|
||||||
|
return names.join('、')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadServicePackageOptions()
|
||||||
|
})
|
||||||
|
|
||||||
function expressCompanyLabel(v: unknown) {
|
function expressCompanyLabel(v: unknown) {
|
||||||
const s = String(v || '').toLowerCase()
|
const s = String(v || '').toLowerCase()
|
||||||
if (s === 'sf') return '顺丰速运'
|
if (s === 'sf') return '顺丰速运'
|
||||||
|
|||||||
@@ -257,7 +257,7 @@
|
|||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tooltip
|
<el-tooltip
|
||||||
v-if="row.last_blood_record_at"
|
v-if="row.last_blood_record_at"
|
||||||
:content="`最近一次血糖打卡:${row.last_blood_record_at}`"
|
:content="`最近一次健康打卡:${row.last_blood_record_at}`"
|
||||||
placement="top"
|
placement="top"
|
||||||
>
|
>
|
||||||
<span class="unserved-days" :class="unservedDaysClass(row.unserved_days)">
|
<span class="unserved-days" :class="unservedDaysClass(row.unserved_days)">
|
||||||
|
|||||||
@@ -17,14 +17,14 @@
|
|||||||
</span>
|
</span>
|
||||||
<el-tooltip
|
<el-tooltip
|
||||||
v-if="lastBloodAt"
|
v-if="lastBloodAt"
|
||||||
:content="`最近一次血糖打卡:${lastBloodAt}`"
|
:content="`最近一次健康打卡:${lastBloodAt}`"
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
>
|
>
|
||||||
<span :class="['unserved-badge', unservedDaysClass(unservedDays)]">
|
<span :class="['unserved-badge', unservedDaysClass(unservedDays)]">
|
||||||
未服务 {{ unservedDays ?? '—' }} 天
|
未服务 {{ unservedDays ?? '—' }} 天
|
||||||
</span>
|
</span>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
<span v-else class="unserved-badge unserved-muted">从未打卡血糖</span>
|
<span v-else class="unserved-badge unserved-muted">从未打卡</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
@@ -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('删除成功');
|
||||||
|
}
|
||||||
|
}
|
||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user