Compare commits
156
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9873ac5e87 | ||
|
|
b0b78c82ab | ||
|
|
dd93ffe683 | ||
|
|
ef014e0d9d | ||
|
|
32f8e19bf1 | ||
|
|
26b2cde699 | ||
|
|
5ab3be1fdd | ||
|
|
6b528c34c8 | ||
|
|
2138626519 | ||
|
|
37df208706 | ||
|
|
ec4e0b9f29 | ||
|
|
a3bae1555a | ||
|
|
15e9c486f9 | ||
|
|
eadc82535d | ||
|
|
62f74888f3 | ||
|
|
79719b801e | ||
|
|
07dacbcb28 | ||
|
|
d364fe506a | ||
|
|
860b550180 | ||
|
|
707e4888ad | ||
|
|
b0a8bd83bc | ||
|
|
574d37908a | ||
|
|
62512d30f9 | ||
|
|
be15531ab3 | ||
|
|
1dd5133202 | ||
|
|
51b34a98b5 | ||
|
|
1755185f50 | ||
|
|
b3d3048100 | ||
|
|
a38af5bb13 | ||
|
|
0a781bda54 | ||
|
|
450be5852f | ||
|
|
3c35ab67cd | ||
|
|
cbf992d6df | ||
|
|
7aec1a9713 | ||
|
|
01bc355bed | ||
|
|
1b746b46c0 | ||
|
|
ceb313b1a7 | ||
|
|
7cfa4d269a | ||
|
|
576ab05681 | ||
|
|
132a17b946 | ||
|
|
75dfaa0dcd | ||
|
|
e83dd07553 | ||
|
|
c589bfa647 | ||
|
|
17ad153821 | ||
|
|
e4312ab5d1 | ||
|
|
7f4fad7b62 | ||
|
|
9b5cd5b2b9 | ||
|
|
94e787ae80 | ||
|
|
7b46204454 | ||
|
|
9f3db8f281 | ||
|
|
6cb8688587 | ||
|
|
0e5b5beb9c | ||
|
|
8d635bb7ed | ||
|
|
0f974be7a1 | ||
|
|
2b1ce61e72 | ||
|
|
6efc340bd8 | ||
|
|
c435e6ae4e | ||
|
|
dfb8838ecc | ||
|
|
7f08771dd0 | ||
|
|
afd88714ec | ||
|
|
bb21825c16 | ||
|
|
c3e4dfa479 | ||
|
|
33f8f669ad | ||
|
|
3ebe3c58c8 | ||
|
|
0225e9f971 | ||
|
|
4f066b560e | ||
|
|
13f58a5fdc | ||
|
|
36be8fedad | ||
|
|
b9d2541b32 | ||
|
|
261f8315de | ||
|
|
ffe4849c27 | ||
|
|
19af50c344 | ||
|
|
012830de4a | ||
|
|
8b0fcd7050 | ||
|
|
57e4892140 | ||
|
|
95fff1262b | ||
|
|
0dd9cdcba9 | ||
|
|
5b233fb0ab | ||
|
|
3325be0622 | ||
|
|
9af5c5be63 | ||
|
|
0e46bf8e0d | ||
|
|
b93313d935 | ||
|
|
c1dfb01f53 | ||
|
|
3f999b312d | ||
|
|
50abe5dece | ||
|
|
58c224a081 | ||
|
|
f718df833e | ||
|
|
f068acf390 | ||
|
|
769d912eef | ||
|
|
5aa0cda252 | ||
|
|
552da3aec2 | ||
|
|
ed5285b58a | ||
|
|
0d35d1b6c3 | ||
|
|
27c2ada4ec | ||
|
|
6be5b4c45d | ||
|
|
fbf3d47b2a | ||
|
|
82c4d7e0a2 | ||
|
|
867a6a97cf | ||
|
|
a3ee72b34d | ||
|
|
a08031b901 | ||
|
|
665481a6d8 | ||
|
|
30b344b776 | ||
|
|
e104249707 | ||
|
|
9b6d9ce007 | ||
|
|
69da8066a6 | ||
|
|
edc9993403 | ||
|
|
8f14f3190e | ||
|
|
71a3c63182 | ||
|
|
d10678763f | ||
|
|
2beace89f4 | ||
|
|
e5d0d7db93 | ||
|
|
35a42078ce | ||
|
|
e8e28d19fb | ||
|
|
163e40c73f | ||
|
|
d46cfff079 | ||
|
|
275a7a550d | ||
|
|
5430fe0417 | ||
|
|
f314e2e271 | ||
|
|
a4fb55d164 | ||
|
|
295c8f0623 | ||
|
|
4b749a1ee0 | ||
|
|
679e61057b | ||
|
|
7f7b8f0a64 | ||
|
|
7e94c5dfd7 | ||
|
|
003810114b | ||
|
|
37c5849103 | ||
|
|
55521b8e11 | ||
|
|
e4f181e4fe | ||
|
|
bba989c0af | ||
|
|
d5e0ba04b7 | ||
|
|
22ec53c070 | ||
|
|
183b6a1acf | ||
|
|
49c8c95966 | ||
|
|
c7c264497e | ||
|
|
436f69c272 | ||
|
|
dc72a8ccaf | ||
|
|
499c969233 | ||
|
|
3b44300216 | ||
|
|
8bb7b5d30a | ||
|
|
4c3becf33e | ||
|
|
a42fc6453a | ||
|
|
dad06304f1 | ||
|
|
03b80310f6 | ||
|
|
8d015fa962 | ||
|
|
f639ef2fbb | ||
|
|
ea8e6d7c1b | ||
|
|
ba1c067dce | ||
|
|
5e96fda88f | ||
|
|
076cd0fcf9 | ||
|
|
44e97861d2 | ||
|
|
93e6d02ce4 | ||
|
|
60cb2b80cd | ||
|
|
61d14d1b45 | ||
|
|
a83c583a28 | ||
|
|
485ba7a380 | ||
|
|
3e4039efb0 |
@@ -33,3 +33,5 @@ bin-release/
|
||||
/server/.spool
|
||||
/server/.claude
|
||||
/.spool
|
||||
TUICallKit-Vue3/.env
|
||||
/.codegraph
|
||||
|
||||
Vendored
+1
-3
@@ -1,5 +1,3 @@
|
||||
{
|
||||
"kiroAgent.configureMCP": "Disabled"
|
||||
|
||||
|
||||
"kiroAgent.configureMCP": "Enabled"
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ lerna-debug.log*
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
unpackage/dist
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
|
||||
+13
-2
@@ -15,11 +15,22 @@ const globalData = {
|
||||
let avatarUrl = ref(""); // 声明为响应式变量
|
||||
uni.CallManager = new CallManager();
|
||||
onLaunch(() => {
|
||||
// iOS 静音键模式下 InnerAudioContext 默认不发声,
|
||||
// 必须在 onLaunch 全局调用此 API,单实例 obeyMuteSwitch 在 iOS 不可靠
|
||||
// #ifdef MP-WEIXIN
|
||||
try {
|
||||
uni.setInnerAudioOption({
|
||||
obeyMuteSwitch: false,
|
||||
mixWithOther: true,
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('setInnerAudioOption failed:', e)
|
||||
}
|
||||
// #endif
|
||||
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: function (loginRes) {
|
||||
console.log('xx');
|
||||
console.log(loginRes.code);
|
||||
if( uni.getStorageSync('token')){
|
||||
userinfo(loginRes.code);
|
||||
}else{
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
## 简介
|
||||
本 demo 演示了如何在 uni-app 项目中集成 [TUICallKit](https://www.npmjs.com/package/@trtc/calls-uikit-wx-uniapp) 音视频通话组件。
|
||||
|
||||
## 两种开发方式
|
||||
|
||||
本工程同时支持 **HBuilderX** 和 **命令行 (vite)** 两套编译链路,二选一即可。
|
||||
|
||||
### 方式 A:命令行 (vite)
|
||||
|
||||
```bash
|
||||
npm install # 安装依赖
|
||||
npm run dev:mp-weixin # watch 模式,产物 dist/dev/mp-weixin
|
||||
npm run build:mp-weixin # 生产构建,产物 dist/build/mp-weixin
|
||||
```
|
||||
|
||||
构建完成后用微信开发者工具打开对应 `dist/.../mp-weixin` 目录即可。
|
||||
|
||||
支持平台:`mp-weixin` / `h5` / `app`(脚本里有 dev/build 两组)。
|
||||
|
||||
### 方式 B:HBuilderX
|
||||
|
||||
HBuilderX 3.4+ 会自动识别 `vite.config.ts` 并切换到 vite 编译模式。
|
||||
|
||||
1. 用 HBuilderX 打开工程根目录
|
||||
2. 菜单「运行」→「运行到小程序模拟器」→「微信开发者工具」
|
||||
3. 产物会输出到 `unpackage/dist/dev/mp-weixin`
|
||||
|
||||
|
||||
## 环境准备
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<view class="tab-dock">
|
||||
<view class="tab-dock__inner">
|
||||
<view class="tab-dock__item" @click="goHome">
|
||||
<image
|
||||
class="tab-dock__icon"
|
||||
:src="active === 0 ? '/static/user/home_no.png' : '/static/user/home.png'"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="tab-dock__label" :style="{ color: active === 0 ? activeColor : color }">首页</text>
|
||||
</view>
|
||||
|
||||
<view class="tab-dock__item tab-dock__item--center" @click="goWeekly">
|
||||
<view class="tab-dock__fab" :class="{ 'tab-dock__fab--active': active === 1 }">
|
||||
<text class="tab-dock__fab-text">糖</text>
|
||||
</view>
|
||||
<text class="tab-dock__label tab-dock__label--center" :style="{ color: active === 1 ? activeColor : color }">血糖</text>
|
||||
</view>
|
||||
|
||||
<view class="tab-dock__item" @click="goUser">
|
||||
<image
|
||||
class="tab-dock__icon"
|
||||
:src="active === 2 ? '/static/user/user_no.png' : '/static/user/user.png'"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="tab-dock__label" :style="{ color: active === 2 ? activeColor : color }">我的</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="tab-dock__safe" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
/** 0 首页 1 血糖 2 我的 */
|
||||
active: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
})
|
||||
|
||||
const color = '#8a8a8a'
|
||||
const activeColor = '#204e2b'
|
||||
|
||||
function goHome() {
|
||||
if (props.active === 0) return
|
||||
uni.redirectTo({ url: '/pages/index/index' })
|
||||
}
|
||||
|
||||
function goUser() {
|
||||
if (props.active === 2) return
|
||||
uni.redirectTo({ url: '/pages/user/user' })
|
||||
}
|
||||
|
||||
function goWeekly() {
|
||||
if (props.active === 1) return
|
||||
uni.redirectTo({ url: '/tongji/pages/weekly' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tab-dock {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 999;
|
||||
background: #ffffff;
|
||||
border-top: 1rpx solid rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 -6rpx 20rpx rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.tab-dock__inner {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
height: 100rpx;
|
||||
}
|
||||
|
||||
.tab-dock__item {
|
||||
flex: 1;
|
||||
height: 100rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tab-dock__item--center {
|
||||
justify-content: flex-end;
|
||||
padding-bottom: 6rpx;
|
||||
}
|
||||
|
||||
.tab-dock__icon {
|
||||
width: 46rpx;
|
||||
height: 46rpx;
|
||||
}
|
||||
|
||||
.tab-dock__label {
|
||||
margin-top: 6rpx;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.tab-dock__label--center {
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.tab-dock__fab {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
margin-top: -48rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(180deg, #2d7340 0%, #204e2b 100%);
|
||||
border: 6rpx solid #ffffff;
|
||||
box-shadow: 0 10rpx 24rpx rgba(32, 78, 43, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tab-dock__fab--active {
|
||||
transform: scale(1.04);
|
||||
box-shadow: 0 12rpx 28rpx rgba(32, 78, 43, 0.42);
|
||||
}
|
||||
|
||||
.tab-dock__fab-text {
|
||||
color: #ffffff;
|
||||
font-size: 38rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tab-dock__safe {
|
||||
height: env(safe-area-inset-bottom);
|
||||
background: #ffffff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,737 @@
|
||||
<template>
|
||||
<!-- 页面内嵌模块(如 weekly 统计页主内容区) -->
|
||||
<view
|
||||
v-if="visible && props.inline"
|
||||
class="dev-entry-inline"
|
||||
:class="{ 'dev-entry-inline--weekly': props.tone === 'weekly' }"
|
||||
>
|
||||
<view class="inline-hero">
|
||||
<view class="inline-hero-icon" aria-hidden="true">
|
||||
<view class="inline-hero-wave" />
|
||||
</view>
|
||||
<view class="inline-hero-text">
|
||||
<view class="inline-hero-title-row">
|
||||
<text class="inline-title">居家耗糖训练</text>
|
||||
<text v-if="props.tone === 'weekly'" class="inline-pill">体验</text>
|
||||
</view>
|
||||
<text class="inline-sub">低强度动起来,帮助身体消耗糖分</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view
|
||||
scroll-x
|
||||
class="inline-scroll"
|
||||
:show-scrollbar="false"
|
||||
:enable-flex="true"
|
||||
>
|
||||
<view class="inline-scroll-track">
|
||||
<view
|
||||
v-for="item in inlineItems"
|
||||
:key="item.url"
|
||||
class="inline-card"
|
||||
@click="goto(item.url)"
|
||||
>
|
||||
<view v-if="item.featured" class="inline-card-badge">
|
||||
<text>推荐</text>
|
||||
</view>
|
||||
<view class="menu-icon" :class="item.iconClass">
|
||||
<view v-if="item.icon === 'dumbbell'" class="dumbbell-shape">
|
||||
<view class="dumbbell-plate dumbbell-plate-left" />
|
||||
<view class="dumbbell-bar" />
|
||||
<view class="dumbbell-plate dumbbell-plate-right" />
|
||||
</view>
|
||||
<view v-else-if="item.icon === 'pedal'" class="foot-pedal-shape">
|
||||
<view class="pedal-bar pedal-bar-left" />
|
||||
<view class="pedal-bar pedal-bar-right" />
|
||||
</view>
|
||||
<view v-else-if="item.icon === 'pilates'" class="pilates-ring-shape" />
|
||||
<view v-else-if="item.icon === 'grip'" class="grip-ring-shape" />
|
||||
<view v-else-if="item.icon === 'metro'" class="menu-icon--metro-inner">
|
||||
<view class="metro-bar metro-bar-1" />
|
||||
<view class="metro-bar metro-bar-2" />
|
||||
<view class="metro-bar metro-bar-3" />
|
||||
</view>
|
||||
</view>
|
||||
<text class="inline-card-title">{{ item.title }}</text>
|
||||
<text class="inline-card-sub">{{ item.sub }}</text>
|
||||
<text class="inline-card-go" aria-hidden="true">进入</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<text v-if="props.tone === 'weekly'" class="inline-scroll-hint">左右滑动,选择适合您的训练</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="visible" class="dev-entry-wrap" :style="{ bottom: props.bottom + 'rpx' }">
|
||||
<!-- 展开后的菜单卡片(从下往上动画) -->
|
||||
<view v-if="expanded" class="menu-list" @click.stop>
|
||||
<view class="menu-item" @click="goto('/training/pages/dumbbell')">
|
||||
<view class="menu-icon menu-icon--dumbbell">
|
||||
<view class="dumbbell-shape">
|
||||
<view class="dumbbell-plate dumbbell-plate-left" />
|
||||
<view class="dumbbell-bar" />
|
||||
<view class="dumbbell-plate dumbbell-plate-right" />
|
||||
</view>
|
||||
</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/foot-pedal')">
|
||||
<view class="menu-icon menu-icon--pedal">
|
||||
<view class="foot-pedal-shape">
|
||||
<view class="pedal-bar pedal-bar-left" />
|
||||
<view class="pedal-bar pedal-bar-right" />
|
||||
</view>
|
||||
</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/pilates-ring')">
|
||||
<view class="menu-icon menu-icon--pilates">
|
||||
<view class="pilates-ring-shape" />
|
||||
</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/grip-ring')">
|
||||
<view class="menu-icon menu-icon--grip">
|
||||
<view class="grip-ring-shape" />
|
||||
</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 { computed, ref } from 'vue'
|
||||
|
||||
type TrainingItem = {
|
||||
url: string
|
||||
title: string
|
||||
sub: string
|
||||
icon: 'dumbbell' | 'pedal' | 'pilates' | 'grip' | 'metro'
|
||||
iconClass: string
|
||||
featured?: boolean
|
||||
}
|
||||
|
||||
const trainingItems: TrainingItem[] = [
|
||||
{ url: '/training/pages/dumbbell', title: '哑铃', sub: '力量塑形', icon: 'dumbbell', iconClass: 'menu-icon--dumbbell' },
|
||||
{ url: '/training/pages/foot-pedal', title: '脚蹬器', sub: '下肢有氧', icon: 'pedal', iconClass: 'menu-icon--pedal' },
|
||||
{ url: '/training/pages/pilates-ring', title: '瑜伽环', sub: '核心塑形', icon: 'pilates', iconClass: 'menu-icon--pilates' },
|
||||
{ url: '/training/pages/grip-ring', title: '握力环', sub: '握力训练', icon: 'grip', iconClass: 'menu-icon--grip' },
|
||||
{ url: '/training/pages/metronome', title: '耗糖节拍器', sub: '健走配速', icon: 'metro', iconClass: 'menu-icon--metro', featured: true },
|
||||
]
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 悬浮按钮距底部 rpx(仅非 inline) */
|
||||
bottom?: number
|
||||
/** 内嵌到页面主内容区,非 fixed 悬浮 */
|
||||
inline?: boolean
|
||||
/** weekly 页:与统计页 Vital Mint 视觉一致 */
|
||||
tone?: 'default' | 'weekly'
|
||||
}>(),
|
||||
{ bottom: 200, inline: false, tone: 'default' },
|
||||
)
|
||||
|
||||
/** weekly 将健走节拍器置前并标推荐 */
|
||||
const inlineItems = computed(() => {
|
||||
if (props.tone !== 'weekly') return trainingItems
|
||||
const metro = trainingItems.find((i) => i.icon === 'metro')
|
||||
const rest = trainingItems.filter((i) => i.icon !== 'metro')
|
||||
return metro ? [metro, ...rest] : trainingItems
|
||||
})
|
||||
|
||||
const isDevMode = (): boolean => {
|
||||
/* HBuilderX 工程:发行(release)模式下 NODE_ENV === 'production' */
|
||||
try {
|
||||
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);
|
||||
}
|
||||
&--dumbbell {
|
||||
background: linear-gradient(140deg, #93c5fd, #3b82f6);
|
||||
box-shadow: 0 2rpx 8rpx rgba(59, 130, 246, 0.36);
|
||||
}
|
||||
&--pedal {
|
||||
background: linear-gradient(140deg, #fdba74, #f97316);
|
||||
box-shadow: 0 2rpx 8rpx rgba(249, 115, 22, 0.36);
|
||||
}
|
||||
&--pilates {
|
||||
background: linear-gradient(140deg, #c4b5fd, #8b5cf6);
|
||||
box-shadow: 0 2rpx 8rpx rgba(139, 92, 246, 0.36);
|
||||
}
|
||||
&--grip {
|
||||
background: linear-gradient(140deg, #5eead4, #14b8a6);
|
||||
box-shadow: 0 2rpx 8rpx rgba(20, 184, 166, 0.36);
|
||||
}
|
||||
&--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 */
|
||||
.dumbbell-shape {
|
||||
width: 40rpx;
|
||||
height: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dumbbell-plate {
|
||||
width: 10rpx;
|
||||
height: 18rpx;
|
||||
background: #fff;
|
||||
border-radius: 3rpx;
|
||||
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.dumbbell-bar {
|
||||
width: 16rpx;
|
||||
height: 5rpx;
|
||||
background: #fff;
|
||||
border-radius: 2rpx;
|
||||
margin: 0 2rpx;
|
||||
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
/* 脚蹬器小 icon: 左右踏板 */
|
||||
.foot-pedal-shape {
|
||||
width: 40rpx;
|
||||
height: 28rpx;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.pedal-bar {
|
||||
width: 14rpx;
|
||||
background: #fff;
|
||||
border-radius: 4rpx 4rpx 2rpx 2rpx;
|
||||
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.pedal-bar-left {
|
||||
height: 18rpx;
|
||||
transform: rotate(-12deg);
|
||||
}
|
||||
|
||||
.pedal-bar-right {
|
||||
height: 22rpx;
|
||||
transform: rotate(12deg);
|
||||
}
|
||||
|
||||
/* 瑜伽环小 icon: 双弧阻力环 */
|
||||
.pilates-ring-shape {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
border-radius: 50%;
|
||||
border: 5rpx solid #fff;
|
||||
position: relative;
|
||||
box-shadow:
|
||||
0 1rpx 2rpx rgba(0, 0, 0, 0.18),
|
||||
inset 0 1rpx 2rpx rgba(255, 255, 255, 0.6);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 18rpx;
|
||||
height: 18rpx;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
border: 3rpx solid rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
/* 握力环小 icon: 白色描边的环(甜甜圈) */
|
||||
.grip-ring-shape {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
border-radius: 50%;
|
||||
border: 7rpx solid #fff;
|
||||
box-shadow:
|
||||
0 1rpx 2rpx rgba(0, 0, 0, 0.18),
|
||||
inset 0 1rpx 2rpx rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
/* 节拍器小 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;
|
||||
}
|
||||
|
||||
.menu-icon--metro-inner {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 4rpx;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 内嵌模块(weekly 等)
|
||||
* ============================================================ */
|
||||
.dev-entry-inline {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dev-entry-inline--weekly {
|
||||
padding: 32rpx 28rpx 24rpx;
|
||||
border-radius: 48rpx;
|
||||
background: var(--surface-container-lowest, #fff);
|
||||
border: 1rpx solid rgba(0, 108, 73, 0.12);
|
||||
box-shadow:
|
||||
0 24rpx 60rpx -20rpx rgba(0, 108, 73, 0.08),
|
||||
0 8rpx 20rpx -8rpx rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.inline-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.inline-hero-icon {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
border-radius: 24rpx;
|
||||
background: rgba(0, 108, 73, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.inline-hero-wave {
|
||||
width: 52rpx;
|
||||
height: 52rpx;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23006c49' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'><polyline points='2,12 7,12 9.5,7 12,17 14.5,9 16.5,12 22,12'/></svg>");
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.inline-hero-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.inline-hero-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.inline-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.inline-pill {
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
color: #006c49;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(0, 108, 73, 0.1);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.inline-sub {
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
color: #475569;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.inline-scroll {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.inline-scroll-track {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
gap: 16rpx;
|
||||
padding: 4rpx 2rpx 8rpx;
|
||||
}
|
||||
|
||||
.inline-card {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 212rpx;
|
||||
min-height: 220rpx;
|
||||
padding: 20rpx 18rpx 18rpx;
|
||||
border-radius: 28rpx;
|
||||
background: #f4f7f5;
|
||||
border: 1rpx solid rgba(0, 108, 73, 0.1);
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.2s ease, background 0.2s ease;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
background: #e8f3ee;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
margin-bottom: 14rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.inline-card-badge {
|
||||
position: absolute;
|
||||
top: 12rpx;
|
||||
right: 12rpx;
|
||||
padding: 4rpx 10rpx;
|
||||
border-radius: 8rpx;
|
||||
background: #006c49;
|
||||
|
||||
text {
|
||||
font-size: 18rpx;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
.inline-card-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
line-height: 1.25;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inline-card-sub {
|
||||
margin-top: 6rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
line-height: 1.35;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inline-card-go {
|
||||
margin-top: auto;
|
||||
padding-top: 12rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
color: #006c49;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.inline-scroll-hint {
|
||||
display: block;
|
||||
margin-top: 14rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.inline-card:active {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+11
-5
@@ -1,5 +1,11 @@
|
||||
import App from './App'
|
||||
var baseUrl ='https://admin.zhenyangtang.com.cn/';
|
||||
var baseUrl ='https://css.zhenyangtang.com.cn/';
|
||||
|
||||
function joinApiUrl(base, path) {
|
||||
const b = String(base || '').replace(/\/+$/, '')
|
||||
const p = String(path || '').replace(/^\/+/, '')
|
||||
return p ? `${b}/${p}` : b
|
||||
}
|
||||
// #ifndef VUE3
|
||||
import Vue from 'vue'
|
||||
import './uni.promisify.adaptor'
|
||||
@@ -20,13 +26,13 @@ Vue.prototype.apiUrl =function apiurl(promise, Loading = true){
|
||||
if (promise.data) {
|
||||
data = promise.data
|
||||
}
|
||||
let url=Vue.prototype.$url+promise.url
|
||||
let url = joinApiUrl(Vue.prototype.$url, promise.url)
|
||||
|
||||
return new Promise(function(resolve, reject) {
|
||||
uni.request({
|
||||
url: url,
|
||||
data: data,
|
||||
timeout: 15000,
|
||||
timeout: promise.timeout != null ? promise.timeout : 15000,
|
||||
method: promise.method ? promise.method : 'POST',
|
||||
header: header,
|
||||
sslVerify: false,
|
||||
@@ -73,7 +79,7 @@ function apiUrl(promise, Loading = true) {
|
||||
|
||||
// 处理请求数据和URL
|
||||
const data = promise.data || {}
|
||||
const url = baseUrl + (promise.url || '')
|
||||
const url = joinApiUrl(baseUrl, promise.url || '')
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// 可选:根据Loading参数显示加载中
|
||||
@@ -84,7 +90,7 @@ function apiUrl(promise, Loading = true) {
|
||||
uni.request({
|
||||
url: url,
|
||||
data: data,
|
||||
timeout: 15000,
|
||||
timeout: promise.timeout != null ? promise.timeout : 15000,
|
||||
method: promise.method || 'POST', // 简化三元表达式
|
||||
header: header,
|
||||
sslVerify: false,
|
||||
|
||||
@@ -57,7 +57,15 @@
|
||||
"optimization" : {
|
||||
"subPackages" : true
|
||||
},
|
||||
"usingComponents" : true
|
||||
"usingComponents" : true,
|
||||
"requiredBackgroundModes" : ["audio"],
|
||||
|
||||
"plugins" : {
|
||||
"WechatSI" : {
|
||||
"version" : "0.3.5",
|
||||
"provider" : "wx069ba97219f66d99"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mp-alipay" : {
|
||||
"usingComponents" : true
|
||||
|
||||
Generated
+10471
-1784
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,55 @@
|
||||
{
|
||||
"name": "tuicallkit-vue3",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"description": "TUICallKit Vue3 uni-app 工程(兼容 HBuilderX & vite CLI)",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"dev:mp-weixin": "UNI_INPUT_DIR=\"$PWD\" uni -p mp-weixin",
|
||||
"dev:h5": "UNI_INPUT_DIR=\"$PWD\" uni",
|
||||
"dev:app": "UNI_INPUT_DIR=\"$PWD\" uni -p app",
|
||||
"build:mp-weixin": "UNI_INPUT_DIR=\"$PWD\" uni build -p mp-weixin",
|
||||
"build:h5": "UNI_INPUT_DIR=\"$PWD\" uni build",
|
||||
"build:app": "UNI_INPUT_DIR=\"$PWD\" uni build -p app"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@dcloudio/uni-app": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-app-harmony": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-app-plus": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-components": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-h5": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-mp-alipay": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-mp-baidu": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-mp-jd": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-mp-kuaishou": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-mp-lark": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-mp-qq": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-mp-toutiao": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-mp-weixin": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-mp-xhs": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-quickapp-webview": "3.0.0-4060620250520001",
|
||||
"@trtc/calls-uikit-wx-uniapp": "^4.1.0",
|
||||
"@uni-ku/bundle-optimizer": "^1.3.16",
|
||||
"tuikit-atomicx-uniapp-wx-standard": "^1.1.9"
|
||||
"tuikit-atomicx-uniapp-wx-standard": "^1.1.9",
|
||||
"vue": "^3.4.21",
|
||||
"vue-i18n": "^9.1.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dcloudio/types": "^3.4.8",
|
||||
"@dcloudio/uni-automator": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-cli-shared": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-stacktracey": "3.0.0-4060620250520001",
|
||||
"@dcloudio/vite-plugin-uni": "3.0.0-4060620250520001",
|
||||
"@types/node": "^20.11.0",
|
||||
"@vue/runtime-core": "^3.4.21",
|
||||
"less": "^4.6.4",
|
||||
"sass": "^1.77.8",
|
||||
"typescript": "^5.4.0",
|
||||
"vite": "^5.2.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
|
||||
+114
-19
@@ -3,7 +3,9 @@
|
||||
{
|
||||
"path": "pages/index/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "甄养堂"
|
||||
"navigationBarTitleText": "甄养堂",
|
||||
"backgroundColor": "#F8F8F8",
|
||||
"backgroundColorTop": "#F8F8F8"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -43,7 +45,9 @@
|
||||
{
|
||||
"path": "pages/user/user",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的"
|
||||
"navigationBarTitleText": "我的",
|
||||
"backgroundColor": "#e8eaed",
|
||||
"backgroundColorTop": "#e8eaed"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -105,25 +109,116 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "training",
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/grip-ring",
|
||||
"style": {
|
||||
"navigationBarTitleText": "握力环训练",
|
||||
"navigationBarBackgroundColor": "#f8fafc",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#f8fafc"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/pilates-ring",
|
||||
"style": {
|
||||
"navigationBarTitleText": "瑜伽环训练",
|
||||
"navigationBarBackgroundColor": "#f8fafc",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#f8fafc"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/foot-pedal",
|
||||
"style": {
|
||||
"navigationBarTitleText": "脚蹬器训练",
|
||||
"navigationBarBackgroundColor": "#f8fafc",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#f8fafc"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/dumbbell",
|
||||
"style": {
|
||||
"navigationBarTitleText": "哑铃训练",
|
||||
"navigationBarBackgroundColor": "#f8fafc",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#f8fafc"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/metronome",
|
||||
"style": {
|
||||
"navigationBarTitleText": "耗糖节拍器",
|
||||
"navigationBarBackgroundColor": "#f8fafc",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#f8fafc"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "tongji",
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "血糖记录",
|
||||
"navigationBarBackgroundColor": "#204e2b",
|
||||
"navigationBarTextStyle": "white",
|
||||
"backgroundColor": "#faf9f5",
|
||||
"enablePullDownRefresh": true,
|
||||
"mp-weixin": {
|
||||
"usingPlugins": {
|
||||
"WechatSI": {
|
||||
"version": "0.3.5",
|
||||
"provider": "wx069ba97219f66d99"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/weekly",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "血糖管理",
|
||||
"backgroundColor": "#f4fbf4",
|
||||
"enablePullDownRefresh": true,
|
||||
"mp-weixin": {
|
||||
"usingPlugins": {
|
||||
"WechatSI": {
|
||||
"version": "0.3.5",
|
||||
"provider": "wx069ba97219f66d99"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/more",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "日常护理",
|
||||
"backgroundColor": "#f4fbf4",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/game",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "糖分突袭",
|
||||
"backgroundColor": "#c7d2fe",
|
||||
"disableScroll": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"tabBar": {
|
||||
"color": "#8a8a8a",
|
||||
"selectedColor": "#204e2b",
|
||||
"borderStyle": "black",
|
||||
"backgroundColor": "#ffffff",
|
||||
"list": [{
|
||||
"pagePath": "pages/index/index",
|
||||
"iconPath": "/static/user/home.png",
|
||||
"selectedIconPath": "/static/user/home_no.png",
|
||||
"text": "首页"
|
||||
},{
|
||||
"pagePath": "pages/user/user",
|
||||
"iconPath": "/static/user/user.png",
|
||||
"selectedIconPath": "/static/user/user_no.png",
|
||||
"text": "我的"
|
||||
}]
|
||||
},
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "甄养堂互联网医院",
|
||||
|
||||
@@ -53,6 +53,18 @@
|
||||
<text class="time-value">{{ card.create_time || '-' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 快捷入口 -->
|
||||
<view class="card-actions">
|
||||
<view class="card-action-btn primary" @click.stop="viewDailyRecord(card)">
|
||||
<text class="card-action-icon">📈</text>
|
||||
<text class="card-action-text">日常记录</text>
|
||||
</view>
|
||||
<view class="card-action-btn" @click.stop="viewCardDetail(card)">
|
||||
<text class="card-action-icon">📝</text>
|
||||
<text class="card-action-text">查看 / 编辑</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
@@ -138,6 +150,16 @@ const viewCardDetail = (card) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 查看日常记录(血糖血压 / 饮食 / 运动 / 跟踪备注 + 波浪图)
|
||||
const viewDailyRecord = (card) => {
|
||||
const url = `/tongji/pages/index?diagnosis_id=${card.id}` +
|
||||
`&patient_id=${card.patient_id || ''}` +
|
||||
`&patient_name=${encodeURIComponent(card.patient_name || '')}` +
|
||||
`&age=${card.age || ''}` +
|
||||
`&gender=${card.gender || ''}`
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
|
||||
// 新建就诊卡(patient_id 创建后自动生成,仅需登录)
|
||||
const createCard = () => {
|
||||
const token = uni.getStorageSync('token')
|
||||
@@ -310,6 +332,54 @@ const formatDate = (timestamp) => {
|
||||
border-top: 4rpx solid #e8eaed;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
margin-top: 28rpx;
|
||||
padding-top: 28rpx;
|
||||
border-top: 4rpx solid #e8eaed;
|
||||
}
|
||||
|
||||
.card-action-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12rpx;
|
||||
padding: 24rpx 12rpx;
|
||||
background: #f6faff;
|
||||
border-radius: 20rpx;
|
||||
border: 2rpx solid #d6e6fb;
|
||||
min-height: 80rpx;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background: linear-gradient(135deg, #1890ff, #0ea5a4);
|
||||
border-color: transparent;
|
||||
|
||||
.card-action-icon,
|
||||
.card-action-text {
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-action-icon {
|
||||
font-size: 36rpx;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.card-action-text {
|
||||
font-size: 30rpx;
|
||||
color: #1890ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.time-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -778,6 +778,7 @@ const showDatePicker = ref(false)
|
||||
const diagnosisId = ref(null)
|
||||
const patientId = ref(null)
|
||||
const isAddMode = ref(false) // 新建模式
|
||||
const returnUrl = ref('') // 保存成功后跳回(如日常记录页)
|
||||
|
||||
// 表单数据
|
||||
const formData = ref({
|
||||
@@ -1023,6 +1024,13 @@ const loadCardDetail = async () => {
|
||||
const options = currentPage.options || {}
|
||||
diagnosisId.value = options.id
|
||||
isAddMode.value = options.add === '1' || options.add === 1
|
||||
if (options.returnUrl) {
|
||||
try {
|
||||
returnUrl.value = decodeURIComponent(String(options.returnUrl))
|
||||
} catch (e) {
|
||||
returnUrl.value = String(options.returnUrl)
|
||||
}
|
||||
}
|
||||
|
||||
const userData = uni.getStorageSync('userData')
|
||||
patientId.value = userData?.diagnosis?.patient_id
|
||||
@@ -1302,7 +1310,12 @@ const submitForm = async () => {
|
||||
}
|
||||
uni.showToast({ title: isAddMode.value ? '创建成功' : '保存成功', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
uni.navigateBack()
|
||||
const back = returnUrl.value
|
||||
if (back && back.startsWith('/')) {
|
||||
uni.redirectTo({ url: back })
|
||||
} else {
|
||||
uni.navigateBack()
|
||||
}
|
||||
}, 1500)
|
||||
} else {
|
||||
uni.showToast({ title: res.msg || '保存失败', icon: 'none' })
|
||||
|
||||
@@ -118,13 +118,15 @@
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
<TabBarDock :active="0" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getCurrentInstance } from 'vue'
|
||||
import { onShow, onShareAppMessage } from '@dcloudio/uni-app'
|
||||
import { onShareAppMessage } from '@dcloudio/uni-app'
|
||||
import TabBarDock from '@/components/app-tab-bar/tab-bar-dock.vue'
|
||||
const { proxy } = getCurrentInstance()
|
||||
|
||||
const doctors = ref([])
|
||||
@@ -263,7 +265,8 @@ $outline: #727970;
|
||||
.container {
|
||||
background: $background;
|
||||
min-height: 100vh;
|
||||
padding: 24rpx 32rpx 120rpx;
|
||||
padding: 24rpx 32rpx;
|
||||
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
|
||||
font-family: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="mine-page">
|
||||
<view class="mine-page">
|
||||
<!-- 顶部用户信息卡片 -->
|
||||
<div class="user-card" @click="navigateTo('/pages/user/userinfo')">
|
||||
<div class="user-header">
|
||||
@@ -88,13 +88,20 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DEV 开发悬浮入口(生产环境自动隐藏) -->
|
||||
<dev-training-entry :bottom="360" />
|
||||
<TabBarDock :active="2" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { onShow, onShareAppMessage } from '@dcloudio/uni-app'
|
||||
import DevTrainingEntry from '@/components/dev-training-entry/index.vue'
|
||||
import TabBarDock from '@/components/app-tab-bar/tab-bar-dock.vue'
|
||||
export default {
|
||||
name: 'profileB',
|
||||
components: { DevTrainingEntry, TabBarDock },
|
||||
data() {
|
||||
return {
|
||||
userInfo: {
|
||||
@@ -165,6 +172,7 @@ export default {
|
||||
min-height: 100vh;
|
||||
background: #e8eaed;
|
||||
padding: 40rpx;
|
||||
padding-bottom: calc(40rpx + 200rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.user-card {
|
||||
@@ -415,4 +423,4 @@ export default {
|
||||
.bg-cyan { background: #13c2c2; }
|
||||
.bg-pink { background: #eb2f96; }
|
||||
.bg-gray { background: #666666; }
|
||||
</style>
|
||||
</style>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<view v-if="show" class="celebrate-root" @touchmove.stop.prevent>
|
||||
<view
|
||||
v-for="p in particles"
|
||||
:key="p.id"
|
||||
class="celebrate-particle"
|
||||
:style="p.style"
|
||||
/>
|
||||
<view v-if="title" class="celebrate-card" :class="{ pop: cardPop }">
|
||||
<view class="celebrate-card-glow" />
|
||||
<TongjiIcon name="sparkles" size="lg" color="#204E2B" />
|
||||
<text class="celebrate-card-title">{{ title }}</text>
|
||||
<text v-if="subtitle" class="celebrate-card-sub">{{ subtitle }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import TongjiIcon from './TongjiIcon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
title: { type: String, default: '' },
|
||||
subtitle: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const particles = ref([])
|
||||
const cardPop = ref(false)
|
||||
|
||||
const COLORS = ['#204E2B', '#386641', '#AFE2B3', '#FBBF24', '#727970', '#DC2626']
|
||||
|
||||
function buildParticles() {
|
||||
const list = []
|
||||
for (let i = 0; i < 18; i++) {
|
||||
const left = 8 + Math.random() * 84
|
||||
const delay = Math.random() * 0.35
|
||||
const hue = COLORS[i % COLORS.length]
|
||||
const size = 10 + Math.floor(Math.random() * 14)
|
||||
list.push({
|
||||
id: `${Date.now()}_${i}`,
|
||||
style: {
|
||||
left: `${left}%`,
|
||||
width: `${size}rpx`,
|
||||
height: `${size}rpx`,
|
||||
background: hue,
|
||||
animationDelay: `${delay}s`
|
||||
}
|
||||
})
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(v) => {
|
||||
if (v) {
|
||||
particles.value = buildParticles()
|
||||
cardPop.value = false
|
||||
setTimeout(() => {
|
||||
cardPop.value = true
|
||||
}, 30)
|
||||
} else {
|
||||
particles.value = []
|
||||
cardPop.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.celebrate-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
.celebrate-particle {
|
||||
position: absolute;
|
||||
top: -20rpx;
|
||||
border-radius: 4rpx;
|
||||
opacity: 0.9;
|
||||
animation: celebrate-fall 1.6s ease-in forwards;
|
||||
}
|
||||
@keyframes celebrate-fall {
|
||||
0% {
|
||||
transform: translateY(0) rotate(0deg) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(110vh) rotate(540deg) scale(0.4);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.celebrate-card {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 38%;
|
||||
transform: translate(-50%, -50%) scale(0.82);
|
||||
width: 78%;
|
||||
max-width: 560rpx;
|
||||
padding: 36rpx 32rpx 32rpx;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border-radius: 28rpx;
|
||||
box-shadow: 0 20rpx 60rpx rgba(8, 145, 178, 0.22);
|
||||
border: 2rpx solid rgba(8, 145, 178, 0.12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease;
|
||||
&.pop {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
.celebrate-card-glow {
|
||||
position: absolute;
|
||||
inset: -20rpx;
|
||||
border-radius: 36rpx;
|
||||
background: radial-gradient(circle, rgba(34, 211, 238, 0.2), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.celebrate-card-title {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 16rpx;
|
||||
font-size: 36rpx;
|
||||
font-weight: 800;
|
||||
color: #1b1c1a;
|
||||
}
|
||||
.celebrate-card-sub {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 10rpx;
|
||||
font-size: 26rpx;
|
||||
color: #475569;
|
||||
line-height: 1.45;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<view class="food-tile-icon" :class="[`food-tile-icon--${size}`]">
|
||||
<text class="food-tile-icon__emoji">{{ fallbackIcon }}</text>
|
||||
<image
|
||||
v-if="src && !imageFailed"
|
||||
class="food-tile-icon__img"
|
||||
:src="src"
|
||||
:mode="size === 'tile' ? 'aspectFill' : 'aspectFit'"
|
||||
@error="onImageError"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { getFoodImgUrl } from '../data/gameFoodImg.js'
|
||||
|
||||
const props = defineProps({
|
||||
food: { type: [Object, String], default: null },
|
||||
size: { type: String, default: 'tile' }
|
||||
})
|
||||
|
||||
const imageFailed = ref(false)
|
||||
|
||||
const fallbackIcon = computed(() => {
|
||||
if (typeof props.food === 'object' && props.food?.icon) return props.food.icon
|
||||
return '🍽'
|
||||
})
|
||||
|
||||
const src = computed(() => {
|
||||
if (typeof props.food === 'object' && props.food?.img) return props.food.img
|
||||
if (typeof props.food === 'object' && props.food?.icon) return getFoodImgUrl(props.food.icon)
|
||||
return ''
|
||||
})
|
||||
|
||||
watch(
|
||||
() => src.value,
|
||||
() => {
|
||||
imageFailed.value = false
|
||||
}
|
||||
)
|
||||
|
||||
function onImageError() {
|
||||
imageFailed.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.food-tile-icon {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.food-tile-icon--tile {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: calc(var(--tile-emoji-size, 48px) * 0.6);
|
||||
}
|
||||
|
||||
.food-tile-icon--tile .food-tile-icon__emoji,
|
||||
.food-tile-icon--tile .food-tile-icon__img {
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 58%;
|
||||
height: 58%;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.food-tile-icon--goal {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.food-tile-icon--tooltip {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.food-tile-icon__emoji {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: inherit;
|
||||
line-height: 1;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.food-tile-icon--goal .food-tile-icon__emoji,
|
||||
.food-tile-icon--tooltip .food-tile-icon__emoji {
|
||||
position: static;
|
||||
transform: none;
|
||||
font-size: 34rpx;
|
||||
}
|
||||
|
||||
.food-tile-icon__img {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 2;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<view
|
||||
class="sugar-tree-graphic"
|
||||
:class="[`lv-${clampedLevel}`, `tier-${visualTier}`, `size-${size}`, { watering: watering, 'is-max': clampedLevel >= MAX_LEVEL }]"
|
||||
>
|
||||
<view class="stg-glow" />
|
||||
<view v-if="clampedLevel >= 7" class="stg-aura" />
|
||||
<image v-if="!treeUseFallback" class="stg-image" :src="treeImageSrc" mode="aspectFit" @error="treeUseFallback = true" />
|
||||
<text v-else class="stg-emoji">{{ treeEmoji }}</text>
|
||||
<view v-if="clampedLevel >= 7" class="stg-sparkle stg-sparkle-a" />
|
||||
<view v-if="clampedLevel >= 8" class="stg-sparkle stg-sparkle-b" />
|
||||
<view v-if="clampedLevel >= MAX_LEVEL" class="stg-sparkle stg-sparkle-c" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { svgToDataUrl } from '../utils/svgDataUrl.js'
|
||||
import { TREE_MAX_LEVEL, TREE_LEVELS } from '../utils/treeLevels.js'
|
||||
|
||||
const MAX_LEVEL = TREE_MAX_LEVEL
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
const treeUseFallback = ref(true)
|
||||
// #endif
|
||||
// #ifndef MP-WEIXIN
|
||||
const treeUseFallback = ref(false)
|
||||
// #endif
|
||||
|
||||
const props = defineProps({
|
||||
level: { type: Number, default: 0 },
|
||||
size: { type: String, default: 'md' },
|
||||
watering: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const clampedLevel = computed(() => Math.min(MAX_LEVEL, Math.max(0, Number(props.level) || 0)))
|
||||
const visualTier = computed(() => {
|
||||
const lv = clampedLevel.value
|
||||
if (lv <= 0) return 0
|
||||
if (lv <= 2) return 1
|
||||
if (lv <= 4) return 2
|
||||
if (lv <= 6) return 3
|
||||
if (lv <= 8) return 4
|
||||
return 5
|
||||
})
|
||||
const treeEmoji = computed(() => (TREE_LEVELS[clampedLevel.value] || TREE_LEVELS[0]).emoji)
|
||||
|
||||
function buildTreeSvg(level) {
|
||||
const pot = `
|
||||
<ellipse cx="24" cy="50" rx="15" ry="3" fill="#204e2b" opacity="0.12"/>
|
||||
<path d="M11 46h26c1.2 0 2 1 2 2.2v3.8c0 1-.8 1.8-1.8 1.8H10.8c-1 0-1.8-.8-1.8-1.8v-3.8c0-1.2.8-2.2 2-2.2z" fill="#E7E5E4"/>
|
||||
<path d="M12.5 46h23c.8 0 1.5.7 1.5 1.5v2.2c0 .6-.5 1.1-1.1 1.1H12.1c-.6 0-1.1-.5-1.1-1.1v-2.2c0-.8.7-1.5 1.5-1.5z" fill="#D6D3D1"/>
|
||||
<ellipse cx="24" cy="46.5" rx="9" ry="1.6" fill="#A8A29E" opacity="0.35"/>
|
||||
`
|
||||
const soil = `<ellipse cx="24" cy="44.5" rx="8" ry="2.2" fill="#386641" opacity="0.18"/>`
|
||||
|
||||
if (level <= 0) {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 56" fill="none">
|
||||
${pot}${soil}
|
||||
<circle cx="24" cy="41.5" r="2.2" fill="#a8a29e" opacity="0.7"/>
|
||||
<path d="M24 41.5v3.5" stroke="#78716c" stroke-width="1" stroke-linecap="round"/>
|
||||
</svg>`
|
||||
}
|
||||
|
||||
const trunkH = level >= 6 ? 16 : level >= 3 ? 14 : 10
|
||||
const trunkY = 46 - trunkH
|
||||
const trunk = `<rect x="22.2" y="${trunkY}" width="3.6" height="${trunkH}" rx="1.8" fill="#78716C"/>
|
||||
<rect x="22.6" y="${trunkY + 1}" width="2.8" height="${trunkH - 1}" rx="1.4" fill="#A8A29E" opacity="0.35"/>`
|
||||
|
||||
const leaf = (cx, cy, r, fill, opacity = 1) =>
|
||||
`<circle cx="${cx}" cy="${cy}" r="${r}" fill="${fill}" opacity="${opacity}"/>
|
||||
<circle cx="${cx - r * 0.25}" cy="${cy - r * 0.2}" r="${r * 0.35}" fill="#eef6ef" opacity="0.55"/>`
|
||||
|
||||
const bloom =
|
||||
level >= 7
|
||||
? leaf(17, 18, 3, '#fda4af', 0.95) +
|
||||
leaf(31, 17, 2.8, '#f9a8d4', 0.9) +
|
||||
leaf(24, 13, 3.2, '#fb7185', 0.95) +
|
||||
`<circle cx="24" cy="12" r="1.3" fill="#fef3c7"/>`
|
||||
: ''
|
||||
const crown =
|
||||
level >= 9
|
||||
? leaf(12, 22, 6, '#34d399', 0.9) +
|
||||
leaf(36, 22, 6, '#34d399', 0.9) +
|
||||
leaf(24, 10, 7, '#386641', 0.95) +
|
||||
bloom
|
||||
: bloom
|
||||
|
||||
let canopy = ''
|
||||
if (level === 1) {
|
||||
canopy = leaf(24, 38, 4, '#afe2b3') + `<path d="M24 38v-5" stroke="#386641" stroke-width="1.2" stroke-linecap="round"/>`
|
||||
} else if (level === 2) {
|
||||
canopy = leaf(24, 32, 5.5, '#34d399') + leaf(20, 34, 3.5, '#6ee7b7', 0.9)
|
||||
} else if (level <= 4) {
|
||||
canopy =
|
||||
leaf(24, 28, 7, '#34d399') +
|
||||
leaf(17, 30, 5, '#6ee7b7', 0.9) +
|
||||
leaf(31, 30, 5, '#6ee7b7', 0.9)
|
||||
} else if (level <= 6) {
|
||||
canopy =
|
||||
leaf(24, 24, 9, '#22c55e') +
|
||||
leaf(14, 26, 7, '#4ade80', 0.92) +
|
||||
leaf(34, 26, 7, '#4ade80', 0.92) +
|
||||
leaf(24, 16, 6, '#386641', 0.88)
|
||||
} else {
|
||||
canopy =
|
||||
leaf(24, 20, 10, '#204e2b') +
|
||||
leaf(13, 24, 8, '#34d399', 0.95) +
|
||||
leaf(35, 24, 8, '#34d399', 0.95) +
|
||||
crown
|
||||
}
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 56" fill="none">${pot}${soil}${trunk}${canopy}</svg>`
|
||||
}
|
||||
|
||||
const treeImageSrc = computed(() => svgToDataUrl(buildTreeSvg(clampedLevel.value)))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sugar-tree-graphic {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.size-sm { width: 64rpx; height: 72rpx; }
|
||||
.size-md { width: 80rpx; height: 88rpx; }
|
||||
.size-lg { width: 112rpx; height: 124rpx; }
|
||||
|
||||
.stg-glow {
|
||||
position: absolute;
|
||||
inset: 6%;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(32, 78, 43, 0.22) 0%, transparent 68%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.lv-0 .stg-glow { background: radial-gradient(circle, rgba(148, 163, 184, 0.25) 0%, transparent 70%); }
|
||||
.tier-4 .stg-glow,
|
||||
.tier-5 .stg-glow,
|
||||
.is-max .stg-glow {
|
||||
background: radial-gradient(circle, rgba(251, 191, 36, 0.3) 0%, rgba(32, 78, 43, 0.15) 55%, transparent 72%);
|
||||
}
|
||||
.stg-aura {
|
||||
position: absolute;
|
||||
inset: -8%;
|
||||
border-radius: 50%;
|
||||
border: 2rpx solid rgba(253, 224, 71, 0.35);
|
||||
animation: stg-aura-pulse 2.4s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes stg-aura-pulse {
|
||||
0%, 100% { transform: scale(0.92); opacity: 0.5; }
|
||||
50% { transform: scale(1.05); opacity: 1; }
|
||||
}
|
||||
.stg-image {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: transform 0.35s ease;
|
||||
}
|
||||
.sugar-tree-graphic.watering .stg-image,
|
||||
.sugar-tree-graphic.watering .stg-emoji {
|
||||
animation: stg-water-bounce 0.65s ease;
|
||||
}
|
||||
.stg-emoji {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
line-height: 1;
|
||||
font-size: 72rpx;
|
||||
}
|
||||
.size-sm .stg-emoji { font-size: 48rpx; }
|
||||
.size-md .stg-emoji { font-size: 60rpx; }
|
||||
.size-lg .stg-emoji { font-size: 88rpx; }
|
||||
@keyframes stg-water-bounce {
|
||||
0%, 100% { transform: scale(1); }
|
||||
35% { transform: scale(1.1) translateY(-6rpx); }
|
||||
60% { transform: scale(0.96) translateY(2rpx); }
|
||||
}
|
||||
.stg-sparkle {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
width: 8rpx;
|
||||
height: 8rpx;
|
||||
border-radius: 50%;
|
||||
background: #fde68a;
|
||||
box-shadow: 0 0 6rpx rgba(253, 224, 71, 0.8);
|
||||
pointer-events: none;
|
||||
animation: stg-sparkle-twinkle 1.8s ease-in-out infinite;
|
||||
}
|
||||
.stg-sparkle-a { top: 4%; right: 16%; }
|
||||
.stg-sparkle-b { top: 12%; left: 10%; width: 6rpx; height: 6rpx; animation-delay: 0.4s; }
|
||||
.stg-sparkle-c { top: 22%; right: 28%; width: 10rpx; height: 10rpx; animation-delay: 0.8s; }
|
||||
@keyframes stg-sparkle-twinkle {
|
||||
0%, 100% { opacity: 0.4; transform: scale(0.8); }
|
||||
50% { opacity: 1; transform: scale(1.2); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<view class="tj-icon-wrap" :class="[`tj-icon-wrap--${size}`]">
|
||||
<image
|
||||
v-if="!useFallback"
|
||||
class="tj-icon"
|
||||
:class="[`tj-icon--${name}`, `tj-icon--${size}`]"
|
||||
:src="iconSrc"
|
||||
mode="aspectFit"
|
||||
@error="onImageError"
|
||||
/>
|
||||
<!-- <text
|
||||
v-else
|
||||
class="tj-icon-fallback"
|
||||
:class="[`tj-icon-fallback--${size}`]"
|
||||
:style="{ color }"
|
||||
>{{ fallbackGlyph }}</text> -->
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { svgToDataUrl } from '../utils/svgDataUrl.js'
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, required: true },
|
||||
size: { type: String, default: 'md' },
|
||||
color: { type: String, default: '#204E2B' }
|
||||
})
|
||||
|
||||
const useFallback = ref(false)
|
||||
|
||||
/** Lucide 风格描边路径 */
|
||||
const ICON_PATHS = {
|
||||
view: '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>',
|
||||
ticket: '<path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z"/><path d="M13 5v2"/><path d="M13 17v2"/><path d="M13 11v2"/>',
|
||||
calendar: '<rect width="18" height="18" x="3" y="4" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
|
||||
flame: '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
|
||||
'check-circle': '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="m9 11 3 3L22 4"/>',
|
||||
glucose: '<path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>',
|
||||
heart: '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>',
|
||||
activity: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
|
||||
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
user: '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
|
||||
person: '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
|
||||
send: '<path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/>',
|
||||
'alert-triangle': '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
|
||||
minus: '<path d="M5 12h14"/>',
|
||||
droplet: '<path d="M12 22a7 7 0 0 0 7-7c0-2-1-3.5-2.5-5.5C15 7 12 2 12 2S9 7 7.5 9.5 5 13 5 15a7 7 0 0 0 7 7z"/>',
|
||||
sparkles: '<path d="m12 3-1.9 5.8L4 12l5.8 1.9L12 21l1.9-5.8L20 12l-5.8-1.9L12 3Z"/><path d="M5 3v4M19 17v4M3 5h4M17 19h4"/>',
|
||||
trophy: '<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6M18 9h1.5a2.5 2.5 0 0 0 0-5H18M4 22h16M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20 7 22M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20 17 22M18 2H6v7a6 6 0 0 0 12 0V2Z"/>',
|
||||
share: '<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8M16 6l-4-4-4 4M12 2v13"/>',
|
||||
plus: '<path d="M5 12h14M12 5v14"/>',
|
||||
refresh: '<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8M3 3v5h5M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16M21 21v-5h-5"/>',
|
||||
volume: '<path d="M11 5 6 9H2v6h4l5 4V5zM15.54 8.46a5 5 0 0 1 0 7.07M19.07 4.93a10 10 0 0 1 0 14.14"/>',
|
||||
pause: '<rect width="4" height="16" x="14" y="4" rx="1"/><rect width="4" height="16" x="6" y="4" rx="1"/>',
|
||||
play: '<polygon points="6 3 20 12 6 21 6 3"/>',
|
||||
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/>',
|
||||
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41-1.41M17.66 6.34l1.41-1.41M6.34 4.93l1.41 1.41"/>',
|
||||
moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
|
||||
sunset: '<path d="M12 10V2M18.364 5.636l-2.12 2.12M5.636 18.364l2.12-2.12M22 18h-3M5 18H2M18.364 18.364l-2.12-2.12M5.636 5.636l2.12 2.12M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z"/>',
|
||||
mic: '<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/>',
|
||||
camera: '<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/>',
|
||||
bulb: '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1.3.5 2.6 1.5 3.5.8.8 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>',
|
||||
leaf: '<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z"/><path d="M2 21c0-3 1.85-5.36 5.08-6"/>',
|
||||
utensils: '<path d="M3 2v7c0 1.1.9 2 2 2a2 2 0 0 0 2-2V2"/><path d="M7 2v20"/><path d="M21 15V2a5 5 0 0 0-3 4.5v6a2 2 0 0 0 2 2h1Z"/><path d="M18 15v7"/>',
|
||||
egg: '<path d="M12 22c4.97 0 8-3.27 8-7.31C20 9.65 16.42 2 12 2S4 9.65 4 14.69C4 18.73 7.03 22 12 22Z"/>',
|
||||
home: '<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
|
||||
'plus-circle': '<circle cx="12" cy="12" r="10"/><path d="M8 12h8M12 8v8"/>',
|
||||
'chevron-right': '<path d="m9 18 6-6-6-6"/>',
|
||||
'chevron-left': '<path d="m15 18-6-6 6-6"/>',
|
||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||
settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
|
||||
syringe: '<path d="m18 2 4 4"/><path d="m17 7 3-3"/><path d="M19 9 8.7 19.3c-1 1-2.5 1-3.4 0l-.6-.6c-1-1-1-2.5 0-3.4L15 5"/><path d="m9 11 4 4"/><path d="m5 19-3 3"/><path d="m14 4 6 6"/>',
|
||||
zap: '<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/>'
|
||||
}
|
||||
|
||||
/** 图片加载失败时的 emoji 回退 */
|
||||
const ICON_FALLBACK = {
|
||||
view: '👁',
|
||||
ticket: '🎫',
|
||||
calendar: '📅',
|
||||
flame: '🔥',
|
||||
'check-circle': '✓',
|
||||
glucose: '💧',
|
||||
heart: '❤',
|
||||
activity: '🏃',
|
||||
users: '👥',
|
||||
user: '👤',
|
||||
person: '👤',
|
||||
send: '➤',
|
||||
'alert-triangle': '⚠',
|
||||
minus: '—',
|
||||
droplet: '💧',
|
||||
sparkles: '✨',
|
||||
trophy: '🏆',
|
||||
share: '↗',
|
||||
plus: '+',
|
||||
refresh: '↻',
|
||||
volume: '🔊',
|
||||
pause: '⏸',
|
||||
play: '▶',
|
||||
info: '!',
|
||||
sun: '☀',
|
||||
moon: '🌙',
|
||||
sunset: '☀',
|
||||
mic: '🎤',
|
||||
camera: '📷',
|
||||
bulb: '💡',
|
||||
leaf: '🥬',
|
||||
utensils: '🍴',
|
||||
egg: '🍳',
|
||||
home: '🏠',
|
||||
'plus-circle': '⊕',
|
||||
'chevron-right': '›',
|
||||
'chevron-left': '‹',
|
||||
check: '✓',
|
||||
settings: '⚙',
|
||||
syringe: '💉',
|
||||
zap: '⚡'
|
||||
}
|
||||
|
||||
const strokeColor = computed(() => {
|
||||
const c = String(props.color || '#204E2B').trim()
|
||||
return /^#[0-9A-Fa-f]{3,8}$/.test(c) ? c : '#204E2B'
|
||||
})
|
||||
|
||||
const iconSrc = computed(() => {
|
||||
const path = ICON_PATHS[props.name] || ICON_PATHS.view
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${strokeColor.value}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`
|
||||
return svgToDataUrl(svg)
|
||||
})
|
||||
|
||||
const fallbackGlyph = computed(() => ICON_FALLBACK[props.name] || ICON_FALLBACK.view)
|
||||
|
||||
function onImageError() {
|
||||
useFallback.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tj-icon-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tj-icon-wrap--sm {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
}
|
||||
.tj-icon-wrap--md {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
}
|
||||
.tj-icon-wrap--lg {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
}
|
||||
.tj-icon-wrap--xl {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
}
|
||||
.tj-icon {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
/* Lucide send 路径重心偏右上,微调使圆形按钮内视觉居中 */
|
||||
.tj-icon--send {
|
||||
transform: translate(-10%, 10%);
|
||||
}
|
||||
.tj-icon-fallback {
|
||||
display: block;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
.tj-icon-fallback--sm {
|
||||
font-size: 24rpx;
|
||||
}
|
||||
.tj-icon-fallback--md {
|
||||
font-size: 30rpx;
|
||||
}
|
||||
.tj-icon-fallback--lg {
|
||||
font-size: 36rpx;
|
||||
}
|
||||
.tj-icon-fallback--xl {
|
||||
font-size: 42rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,203 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { requestAiStream, parsePartialBloodCarePlainText } from '../utils/aiStreamRequest.js'
|
||||
|
||||
const emptyBloodCarePlan = () => ({
|
||||
summary: '',
|
||||
blood_advice: '',
|
||||
tcm_plan: '',
|
||||
watch_points: [],
|
||||
next_steps: '',
|
||||
control_level: '',
|
||||
control_label: '',
|
||||
disclaimer: '',
|
||||
analysis: [],
|
||||
source: '',
|
||||
date: ''
|
||||
})
|
||||
|
||||
/** AI 血糖照护建议(累计 7 天有记录后) */
|
||||
export function useBloodCareAi(proxy, { diagnosisId, chartUseMockData, showUserToast, formatUserMessage }) {
|
||||
const bloodCarePlan = ref(emptyBloodCarePlan())
|
||||
const bloodCareLoading = ref(false)
|
||||
const bloodCareStreaming = ref(false)
|
||||
const bloodCareStreamField = ref('')
|
||||
let careStreamBuffer = ''
|
||||
/** 用户点击后才展开照护建议卡片 */
|
||||
const bloodCarePanelOpen = ref(false)
|
||||
|
||||
const bloodCareEligible = computed(() => {
|
||||
return !!diagnosisId.value && !chartUseMockData.value
|
||||
})
|
||||
|
||||
const hasBloodCareContent = computed(() => {
|
||||
const p = bloodCarePlan.value
|
||||
return !!(p.summary || p.blood_advice || p.tcm_plan)
|
||||
})
|
||||
|
||||
const bloodCareAnalysis = computed(() => {
|
||||
const list = bloodCarePlan.value?.analysis
|
||||
return Array.isArray(list) ? list : []
|
||||
})
|
||||
|
||||
const bloodCareControlLabel = computed(() => {
|
||||
const label = String(bloodCarePlan.value?.control_label || '').trim()
|
||||
return label
|
||||
})
|
||||
|
||||
const bloodCareDisclaimer = computed(() => {
|
||||
return String(bloodCarePlan.value?.disclaimer || '').trim()
|
||||
})
|
||||
|
||||
function detectCareStreamField(text) {
|
||||
const tail = String(text || '').match(/(?:^|\n)(总评|血糖|中医|留意|复诊)[::]\s*([^\n]*)$/)
|
||||
if (!tail) return 'summary'
|
||||
const map = { '总评': 'summary', '血糖': 'blood_advice', '中医': 'tcm_plan', '留意': 'watch_points', '复诊': 'next_steps' }
|
||||
return map[tail[1]] || 'summary'
|
||||
}
|
||||
|
||||
function mergePartialCareFromStream() {
|
||||
const partial = parsePartialBloodCarePlainText(careStreamBuffer)
|
||||
const next = { ...bloodCarePlan.value }
|
||||
for (const key of ['summary', 'blood_advice', 'tcm_plan', 'next_steps']) {
|
||||
if (partial[key] != null) next[key] = partial[key]
|
||||
}
|
||||
if (Array.isArray(partial.watch_points)) next.watch_points = partial.watch_points
|
||||
bloodCarePlan.value = next
|
||||
bloodCareStreamField.value = detectCareStreamField(careStreamBuffer)
|
||||
}
|
||||
|
||||
function applyBloodCarePayload(payload) {
|
||||
if (!payload) return
|
||||
const prevAnalysis = bloodCarePlan.value.analysis
|
||||
bloodCarePlan.value = {
|
||||
...emptyBloodCarePlan(),
|
||||
...payload,
|
||||
watch_points: Array.isArray(payload.watch_points) ? payload.watch_points : [],
|
||||
analysis: Array.isArray(payload.analysis) && payload.analysis.length
|
||||
? payload.analysis
|
||||
: (Array.isArray(prevAnalysis) ? prevAnalysis : [])
|
||||
}
|
||||
}
|
||||
|
||||
function onBloodCareStreamEvent(event, payload) {
|
||||
if (event === 'analysis') {
|
||||
if (Array.isArray(payload?.analysis)) {
|
||||
bloodCarePlan.value = { ...bloodCarePlan.value, analysis: payload.analysis }
|
||||
}
|
||||
} else if (event === 'delta') {
|
||||
bloodCareStreaming.value = true
|
||||
if (payload.buffer != null) {
|
||||
careStreamBuffer = payload.buffer
|
||||
} else {
|
||||
careStreamBuffer += payload.text || ''
|
||||
}
|
||||
mergePartialCareFromStream()
|
||||
} else if (event === 'done') {
|
||||
applyBloodCarePayload(payload)
|
||||
careStreamBuffer = ''
|
||||
bloodCareStreamField.value = ''
|
||||
} else if (event === 'error') {
|
||||
showUserToast(formatUserMessage(payload?.message, '照护建议加载失败'))
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBloodCareAiFallback(refresh) {
|
||||
const res = await proxy.apiUrl({
|
||||
url: '/api/tcm/dailyBloodCareAiRecommend',
|
||||
method: 'GET',
|
||||
timeout: 35000,
|
||||
data: {
|
||||
diagnosis_id: diagnosisId.value,
|
||||
refresh: refresh ? 1 : 0,
|
||||
...(refresh ? { _t: Date.now() } : {})
|
||||
}
|
||||
}, false)
|
||||
if (res?.code === 1 && res.data) {
|
||||
applyBloodCarePayload(res.data)
|
||||
} else if (res?.msg) {
|
||||
showUserToast(res.msg)
|
||||
}
|
||||
}
|
||||
|
||||
function closeBloodCarePanel() {
|
||||
bloodCarePanelOpen.value = false
|
||||
}
|
||||
|
||||
/** 趋势图 AI 按钮:未展开则打开并生成;已展开则收起 */
|
||||
async function toggleBloodCareFromChart() {
|
||||
if (!bloodCareEligible.value || bloodCareLoading.value) return
|
||||
if (bloodCarePanelOpen.value) {
|
||||
closeBloodCarePanel()
|
||||
return
|
||||
}
|
||||
bloodCarePanelOpen.value = true
|
||||
if (!hasBloodCareContent.value) {
|
||||
await fetchBloodCareAi(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBloodCareAi(refresh = false) {
|
||||
if (!bloodCareEligible.value || bloodCareLoading.value) return
|
||||
bloodCareLoading.value = true
|
||||
bloodCareStreaming.value = false
|
||||
careStreamBuffer = ''
|
||||
bloodCareStreamField.value = ''
|
||||
if (refresh) {
|
||||
applyBloodCarePayload(emptyBloodCarePlan())
|
||||
}
|
||||
try {
|
||||
await requestAiStream({
|
||||
baseUrl: proxy.$url,
|
||||
url: '/api/tcm/dailyBloodCareAiRecommendStream',
|
||||
method: 'GET',
|
||||
data: {
|
||||
diagnosis_id: diagnosisId.value,
|
||||
refresh: refresh ? 1 : 0,
|
||||
...(refresh ? { _t: Date.now() } : {})
|
||||
},
|
||||
onEvent: onBloodCareStreamEvent,
|
||||
fallback: () => fetchBloodCareAiFallback(refresh)
|
||||
})
|
||||
} catch (e) {
|
||||
try {
|
||||
await fetchBloodCareAiFallback(refresh)
|
||||
} catch (e2) {
|
||||
showUserToast('照护建议加载失败')
|
||||
}
|
||||
} finally {
|
||||
bloodCareLoading.value = false
|
||||
bloodCareStreaming.value = false
|
||||
bloodCareStreamField.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function refreshBloodCareAi() {
|
||||
if (bloodCareLoading.value) return
|
||||
fetchBloodCareAi(true)
|
||||
}
|
||||
|
||||
function resetBloodCarePlan() {
|
||||
applyBloodCarePayload(emptyBloodCarePlan())
|
||||
careStreamBuffer = ''
|
||||
bloodCareStreamField.value = ''
|
||||
bloodCarePanelOpen.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
bloodCarePlan,
|
||||
bloodCareLoading,
|
||||
bloodCareStreaming,
|
||||
bloodCareStreamField,
|
||||
bloodCareEligible,
|
||||
bloodCarePanelOpen,
|
||||
hasBloodCareContent,
|
||||
bloodCareAnalysis,
|
||||
bloodCareControlLabel,
|
||||
bloodCareDisclaimer,
|
||||
fetchBloodCareAi,
|
||||
toggleBloodCareFromChart,
|
||||
closeBloodCarePanel,
|
||||
refreshBloodCareAi,
|
||||
resetBloodCarePlan
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { requestAiStream, parsePartialDietPlainText } from '../utils/aiStreamRequest.js'
|
||||
|
||||
const DIET_AI_PREFILL_KEY = 'tongji_diet_ai_prefill'
|
||||
|
||||
const emptyDietAiRecommend = () => ({
|
||||
breakfast: '',
|
||||
drinks: '',
|
||||
lunch: '',
|
||||
dinner: '',
|
||||
tips: '',
|
||||
avoid: [],
|
||||
analysis: [],
|
||||
exercise: null,
|
||||
disclaimer: '',
|
||||
rules_summary: '',
|
||||
source: '',
|
||||
date: ''
|
||||
})
|
||||
|
||||
/** AI 饮食助手(今日推荐 + 能不能吃) */
|
||||
export function useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage }) {
|
||||
const dietAiRecommend = ref(emptyDietAiRecommend())
|
||||
const dietAiLoading = ref(false)
|
||||
const dietAiStreaming = ref(false)
|
||||
const dietAiReplacing = ref(false)
|
||||
const dietAiStreamField = ref('')
|
||||
let dietStreamBuffer = ''
|
||||
const dietAiAskText = ref('')
|
||||
const dietAiAsking = ref(false)
|
||||
const dietAiAskResult = ref({ advice: '', level: '', level_label: '', portion: '', food: '' })
|
||||
|
||||
const hasDietAiMealContent = computed(() => {
|
||||
const r = dietAiRecommend.value
|
||||
return !!(r.breakfast || r.drinks || r.lunch || r.dinner || r.tips)
|
||||
})
|
||||
|
||||
function detectDietStreamField(text) {
|
||||
const tail = String(text || '').match(/(?:^|\n)(早餐|喝的|午餐|晚餐|提示|少碰)[::]\s*([^\n]*)$/)
|
||||
if (!tail) return 'breakfast'
|
||||
const map = { '早餐': 'breakfast', '喝的': 'drinks', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'tips' }
|
||||
return map[tail[1]] || 'breakfast'
|
||||
}
|
||||
|
||||
function mergePartialDietFromStream() {
|
||||
const partial = parsePartialDietPlainText(dietStreamBuffer)
|
||||
const next = { ...dietAiRecommend.value }
|
||||
for (const key of ['breakfast', 'drinks', 'lunch', 'dinner', 'tips']) {
|
||||
if (partial[key] != null) next[key] = partial[key]
|
||||
}
|
||||
if (Array.isArray(partial.avoid)) next.avoid = partial.avoid
|
||||
dietAiRecommend.value = next
|
||||
dietAiStreamField.value = detectDietStreamField(dietStreamBuffer)
|
||||
}
|
||||
|
||||
function applyDietRecommendPayload(payload) {
|
||||
if (!payload) return
|
||||
const prevAnalysis = dietAiRecommend.value.analysis
|
||||
const prevExercise = dietAiRecommend.value.exercise
|
||||
dietAiRecommend.value = {
|
||||
...emptyDietAiRecommend(),
|
||||
...payload,
|
||||
avoid: Array.isArray(payload.avoid) ? payload.avoid : [],
|
||||
analysis: Array.isArray(payload.analysis) && payload.analysis.length
|
||||
? payload.analysis
|
||||
: (Array.isArray(prevAnalysis) ? prevAnalysis : []),
|
||||
exercise: (payload.exercise && typeof payload.exercise === 'object')
|
||||
? payload.exercise
|
||||
: (prevExercise || null)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDietDonePayload(payload) {
|
||||
if (!payload) return payload
|
||||
if (payload.source !== 'rule' || !dietStreamBuffer) return payload
|
||||
|
||||
const streamed = parsePartialDietPlainText(dietStreamBuffer)
|
||||
if (!streamed.breakfast) return payload
|
||||
|
||||
return {
|
||||
...emptyDietAiRecommend(),
|
||||
...payload,
|
||||
breakfast: streamed.breakfast,
|
||||
drinks: streamed.drinks || payload.drinks || '',
|
||||
lunch: streamed.lunch || payload.lunch || '',
|
||||
dinner: streamed.dinner || payload.dinner || '',
|
||||
tips: streamed.tips || payload.tips || '',
|
||||
avoid: (Array.isArray(streamed.avoid) && streamed.avoid.length) ? streamed.avoid : (payload.avoid || []),
|
||||
source: 'ai',
|
||||
disclaimer: 'AI 建议供参考,仍请结合医嘱与血糖监测。'
|
||||
}
|
||||
}
|
||||
|
||||
function onDietAiStreamEvent(event, payload) {
|
||||
if (event === 'analysis') {
|
||||
if (Array.isArray(payload?.analysis)) {
|
||||
dietAiRecommend.value = { ...dietAiRecommend.value, analysis: payload.analysis }
|
||||
}
|
||||
} else if (event === 'exercise') {
|
||||
if (payload?.exercise && typeof payload.exercise === 'object') {
|
||||
dietAiRecommend.value = { ...dietAiRecommend.value, exercise: payload.exercise }
|
||||
}
|
||||
} else if (event === 'delta') {
|
||||
dietAiStreaming.value = true
|
||||
if (payload.buffer != null) {
|
||||
dietStreamBuffer = payload.buffer
|
||||
} else {
|
||||
dietStreamBuffer += payload.text || ''
|
||||
}
|
||||
mergePartialDietFromStream()
|
||||
} else if (event === 'done') {
|
||||
applyDietRecommendPayload(resolveDietDonePayload(payload))
|
||||
dietStreamBuffer = ''
|
||||
dietAiStreamField.value = ''
|
||||
} else if (event === 'error') {
|
||||
showUserToast(formatUserMessage(payload?.message, '饮食建议加载失败'))
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDietAiRecommendFallback(refresh) {
|
||||
const res = await proxy.apiUrl({
|
||||
url: '/api/tcm/dailyDietAiRecommend',
|
||||
method: 'GET',
|
||||
timeout: 35000,
|
||||
data: {
|
||||
diagnosis_id: diagnosisId.value,
|
||||
refresh: refresh ? 1 : 0,
|
||||
...(refresh ? { _t: Date.now() } : {})
|
||||
}
|
||||
}, false)
|
||||
if (res?.code === 1 && res.data) {
|
||||
applyDietRecommendPayload(res.data)
|
||||
} else if (res?.msg) {
|
||||
showUserToast(res.msg)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDietAiRecommend(refresh = false) {
|
||||
if (!diagnosisId.value || dietAiLoading.value) return
|
||||
dietAiLoading.value = true
|
||||
dietAiStreaming.value = false
|
||||
dietAiReplacing.value = !!refresh
|
||||
dietStreamBuffer = ''
|
||||
dietAiStreamField.value = ''
|
||||
if (refresh) {
|
||||
applyDietRecommendPayload(emptyDietAiRecommend())
|
||||
}
|
||||
try {
|
||||
await requestAiStream({
|
||||
baseUrl: proxy.$url,
|
||||
url: '/api/tcm/dailyDietAiRecommendStream',
|
||||
method: 'GET',
|
||||
data: {
|
||||
diagnosis_id: diagnosisId.value,
|
||||
refresh: refresh ? 1 : 0,
|
||||
...(refresh ? { _t: Date.now() } : {})
|
||||
},
|
||||
onEvent: onDietAiStreamEvent,
|
||||
fallback: () => fetchDietAiRecommendFallback(refresh)
|
||||
})
|
||||
} catch (e) {
|
||||
try {
|
||||
await fetchDietAiRecommendFallback(refresh)
|
||||
} catch (e2) {
|
||||
showUserToast('饮食建议加载失败')
|
||||
}
|
||||
} finally {
|
||||
dietAiLoading.value = false
|
||||
dietAiStreaming.value = false
|
||||
dietAiReplacing.value = false
|
||||
dietAiStreamField.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function refreshDietAiRecommend() {
|
||||
if (dietAiLoading.value) return
|
||||
fetchDietAiRecommend(true)
|
||||
}
|
||||
|
||||
async function askDietAiFoodFallback(q) {
|
||||
const res = await proxy.apiUrl({
|
||||
url: '/api/tcm/dailyDietAiAsk',
|
||||
method: 'POST',
|
||||
timeout: 35000,
|
||||
data: {
|
||||
diagnosis_id: diagnosisId.value,
|
||||
question: q
|
||||
}
|
||||
}, false)
|
||||
if (res?.code === 1 && res.data) {
|
||||
dietAiAskResult.value = {
|
||||
advice: res.data.advice || '',
|
||||
level: res.data.level || '',
|
||||
level_label: res.data.level_label || '',
|
||||
portion: res.data.portion || '',
|
||||
food: res.data.food || q
|
||||
}
|
||||
} else {
|
||||
showUserToast(formatUserMessage(res?.msg, '咨询失败'))
|
||||
}
|
||||
}
|
||||
|
||||
async function askDietAiFood() {
|
||||
const q = String(dietAiAskText.value || '').trim()
|
||||
if (!q) {
|
||||
showUserToast('请输入想咨询的食物')
|
||||
return
|
||||
}
|
||||
if (!diagnosisId.value) return
|
||||
dietAiAsking.value = true
|
||||
dietAiAskResult.value = { advice: '', level: '', level_label: '', portion: '', food: '' }
|
||||
try {
|
||||
await requestAiStream({
|
||||
baseUrl: proxy.$url,
|
||||
url: '/api/tcm/dailyDietAiAskStream',
|
||||
method: 'POST',
|
||||
data: {
|
||||
diagnosis_id: diagnosisId.value,
|
||||
question: q
|
||||
},
|
||||
onEvent: (event, payload) => {
|
||||
if (event === 'start' && !dietAiAskResult.value.advice) {
|
||||
dietAiAskResult.value = { ...dietAiAskResult.value, advice: payload.message || '正在想…' }
|
||||
} else if (event === 'delta') {
|
||||
dietAiAskResult.value = {
|
||||
...dietAiAskResult.value,
|
||||
advice: payload.advice || dietAiAskResult.value.advice
|
||||
}
|
||||
} else if (event === 'done') {
|
||||
dietAiAskResult.value = {
|
||||
advice: payload.advice || '',
|
||||
level: payload.level || '',
|
||||
level_label: payload.level_label || '',
|
||||
portion: payload.portion || '',
|
||||
food: payload.food || q
|
||||
}
|
||||
} else if (event === 'error') {
|
||||
showUserToast(formatUserMessage(payload?.message, '咨询失败'))
|
||||
}
|
||||
},
|
||||
fallback: () => askDietAiFoodFallback(q)
|
||||
})
|
||||
} catch (e) {
|
||||
try {
|
||||
await askDietAiFoodFallback(q)
|
||||
} catch (e2) {
|
||||
showUserToast('网络异常,请稍后再试')
|
||||
}
|
||||
} finally {
|
||||
dietAiAsking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onDietAiAskInput(e) {
|
||||
dietAiAskText.value = e?.detail?.value ?? ''
|
||||
}
|
||||
|
||||
function saveDietPrefillToStorage() {
|
||||
const r = dietAiRecommend.value
|
||||
if (!r.breakfast) return
|
||||
try {
|
||||
uni.setStorageSync(DIET_AI_PREFILL_KEY, {
|
||||
breakfast: r.breakfast,
|
||||
drinks: r.drinks || '',
|
||||
lunch: r.lunch,
|
||||
dinner: r.dinner,
|
||||
tips: r.tips || ''
|
||||
})
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function goMoreDietForm(withPrefill = false) {
|
||||
if (withPrefill) saveDietPrefillToStorage()
|
||||
uni.navigateTo({ url: '/tongji/pages/more?openDiet=1' })
|
||||
}
|
||||
|
||||
async function applyDietAiToForm() {
|
||||
if (!dietAiRecommend.value.breakfast) {
|
||||
await fetchDietAiRecommend(false)
|
||||
}
|
||||
goMoreDietForm(true)
|
||||
}
|
||||
|
||||
function openDietForm() {
|
||||
goMoreDietForm(false)
|
||||
}
|
||||
|
||||
return {
|
||||
dietAiRecommend,
|
||||
dietAiLoading,
|
||||
dietAiStreaming,
|
||||
dietAiReplacing,
|
||||
dietAiStreamField,
|
||||
dietAiAskText,
|
||||
dietAiAsking,
|
||||
dietAiAskResult,
|
||||
hasDietAiMealContent,
|
||||
fetchDietAiRecommend,
|
||||
refreshDietAiRecommend,
|
||||
askDietAiFood,
|
||||
onDietAiAskInput,
|
||||
applyDietAiToForm,
|
||||
openDietForm
|
||||
}
|
||||
}
|
||||
|
||||
/** more 页打开饮食表单时读取并清除预填 */
|
||||
export function consumeDietAiPrefill() {
|
||||
try {
|
||||
const data = uni.getStorageSync(DIET_AI_PREFILL_KEY)
|
||||
uni.removeStorageSync(DIET_AI_PREFILL_KEY)
|
||||
if (data && typeof data === 'object' && data.breakfast) return data
|
||||
} catch (e) {}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* 糖分突袭 · 视觉特效状态(飘字、粒子、连消横幅、震屏、闪屏)
|
||||
* 特效风格参考「开心消消乐」:糖果色星屑、弹性扩散光环、大消除闪屏。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
|
||||
let fxSeq = 0
|
||||
function nextFxId(prefix) {
|
||||
fxSeq += 1
|
||||
return `${prefix}-${fxSeq}-${Date.now()}`
|
||||
}
|
||||
|
||||
// 开心消消乐式糖果色板(彩虹爆裂用)
|
||||
const CANDY_COLORS = ['#FF5E9C', '#FFC93C', '#5BD16A', '#4D9DFF', '#FF8A3D', '#B57BFF', '#FF6F91', '#2FE6C8']
|
||||
// 星屑字形
|
||||
const STAR_GLYPHS = ['✦', '✧', '★', '✨']
|
||||
|
||||
export function useGameFx() {
|
||||
const floatingTexts = ref([])
|
||||
const burstParticles = ref([])
|
||||
const ripples = ref([])
|
||||
const comboBanner = ref({ show: false, text: '', tier: 1 })
|
||||
const screenShaking = ref(false)
|
||||
const flash = ref({ active: false, color: '#ffffff', opacity: 0 })
|
||||
|
||||
let comboBannerTimer = null
|
||||
let shakeTimer = null
|
||||
let flashTimer = null
|
||||
let flashFadeTimer = null
|
||||
|
||||
function spawnFloatingText(payload) {
|
||||
const id = nextFxId('ft')
|
||||
floatingTexts.value.push({ id, ...payload })
|
||||
const duration = payload.danger ? 1500 : payload.isCombo ? 1400 : 1200
|
||||
setTimeout(() => {
|
||||
floatingTexts.value = floatingTexts.value.filter((f) => f.id !== id)
|
||||
}, duration)
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* 糖果爆裂粒子(星屑 + 圆点混合,上抛后受重力下落,带旋转与缓动)。
|
||||
* @param {number} cx,cy 爆裂中心(屏幕坐标)
|
||||
* @param {string} color 基础颜色
|
||||
* @param {number} count 粒子数量
|
||||
* @param {object} opts { star, rainbow, gravity, sizeMin, sizeMax, distMin, distMax, life }
|
||||
*/
|
||||
function spawnBurst(cx, cy, color, count = 8, opts = {}) {
|
||||
const {
|
||||
star = true,
|
||||
rainbow = false,
|
||||
gravity = 64,
|
||||
sizeMin = 8,
|
||||
sizeMax = 16,
|
||||
distMin = 36,
|
||||
distMax = 110,
|
||||
life = 850
|
||||
} = opts
|
||||
const dur = (life / 1000).toFixed(2)
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const id = nextFxId('p')
|
||||
const isStar = star && Math.random() < 0.65
|
||||
const pSize = Math.random() * (sizeMax - sizeMin) + sizeMin
|
||||
const angle = Math.random() * Math.PI * 2
|
||||
const distance = Math.random() * (distMax - distMin) + distMin
|
||||
const tx = Math.cos(angle) * distance
|
||||
// 先上抛、再叠加重力下落,形成喷泉抛物线
|
||||
const ty = Math.sin(angle) * distance - distance * 0.35 + gravity
|
||||
const half = pSize / 2
|
||||
const pColor = rainbow
|
||||
? CANDY_COLORS[Math.floor(Math.random() * CANDY_COLORS.length)]
|
||||
: color
|
||||
const rot = Math.random() * 720 - 360
|
||||
const glyph = isStar ? STAR_GLYPHS[Math.floor(Math.random() * STAR_GLYPHS.length)] : ''
|
||||
|
||||
const particle = {
|
||||
id,
|
||||
left: cx - half,
|
||||
top: cy - half,
|
||||
size: pSize,
|
||||
color: pColor,
|
||||
char: glyph,
|
||||
opacity: 1,
|
||||
transform: 'scale(0.4) rotate(0deg)',
|
||||
transition: 'none'
|
||||
}
|
||||
burstParticles.value.push(particle)
|
||||
|
||||
setTimeout(() => {
|
||||
particle.transition =
|
||||
`left ${dur}s cubic-bezier(0.16,0.7,0.3,1), top ${dur}s cubic-bezier(0.3,0.1,0.4,1), opacity ${dur}s ease-in, transform ${dur}s ease-out`
|
||||
particle.left = cx - half + tx
|
||||
particle.top = cy - half + ty
|
||||
particle.opacity = 0
|
||||
particle.transform = `scale(${isStar ? 1.15 : 0.5}) rotate(${rot}deg)`
|
||||
}, 16)
|
||||
|
||||
setTimeout(() => {
|
||||
burstParticles.value = burstParticles.value.filter((p) => p.id !== id)
|
||||
}, life + 80)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹性扩散发光光环(开心消消乐式的扩散光圈)。
|
||||
* @param {object} opts { thickness, life }
|
||||
*/
|
||||
function spawnRipple(cx, cy, color, tier = 1, opts = {}) {
|
||||
const id = nextFxId('rp')
|
||||
const size = 36 + tier * 18
|
||||
const { thickness = Math.max(3, tier + 2), life = 600 } = opts
|
||||
ripples.value.push({
|
||||
id,
|
||||
left: cx,
|
||||
top: cy,
|
||||
size,
|
||||
color,
|
||||
thickness,
|
||||
opacity: 0.95
|
||||
})
|
||||
setTimeout(() => {
|
||||
ripples.value = ripples.value.filter((r) => r.id !== id)
|
||||
}, life)
|
||||
}
|
||||
|
||||
/**
|
||||
* 全屏闪光(大消除瞬间的爆闪),先点亮再淡出。
|
||||
*/
|
||||
function triggerFlash(color = '#ffffff', intensity = 0.5, life = 280) {
|
||||
if (flashFadeTimer) clearTimeout(flashFadeTimer)
|
||||
if (flashTimer) clearTimeout(flashTimer)
|
||||
flash.value = { active: true, color, opacity: intensity }
|
||||
flashFadeTimer = setTimeout(() => {
|
||||
flash.value = { ...flash.value, opacity: 0 }
|
||||
}, 24)
|
||||
flashTimer = setTimeout(() => {
|
||||
flash.value = { active: false, color, opacity: 0 }
|
||||
}, life)
|
||||
}
|
||||
|
||||
function showComboBanner(chain, points) {
|
||||
if (chain <= 1) return
|
||||
const tier = chain >= 4 ? 3 : chain >= 3 ? 2 : 1
|
||||
comboBanner.value = {
|
||||
show: true,
|
||||
text: `连消 ×${chain} +${points}`,
|
||||
tier
|
||||
}
|
||||
if (comboBannerTimer) clearTimeout(comboBannerTimer)
|
||||
comboBannerTimer = setTimeout(() => {
|
||||
comboBanner.value = { ...comboBanner.value, show: false }
|
||||
}, 1100)
|
||||
}
|
||||
|
||||
function triggerShake(intensity = 1) {
|
||||
screenShaking.value = true
|
||||
if (shakeTimer) clearTimeout(shakeTimer)
|
||||
shakeTimer = setTimeout(() => {
|
||||
screenShaking.value = false
|
||||
}, intensity >= 2 ? 420 : 280)
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
floatingTexts.value = []
|
||||
burstParticles.value = []
|
||||
ripples.value = []
|
||||
comboBanner.value = { show: false, text: '', tier: 1 }
|
||||
screenShaking.value = false
|
||||
flash.value = { active: false, color: '#ffffff', opacity: 0 }
|
||||
}
|
||||
|
||||
return {
|
||||
floatingTexts,
|
||||
burstParticles,
|
||||
ripples,
|
||||
comboBanner,
|
||||
screenShaking,
|
||||
flash,
|
||||
spawnFloatingText,
|
||||
spawnBurst,
|
||||
spawnRipple,
|
||||
showComboBanner,
|
||||
triggerShake,
|
||||
triggerFlash,
|
||||
clearAll
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 游戏进度持久化:关卡、星级、连胜、每日打卡
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { calcLevelStars } from '../data/gameLevels.js'
|
||||
|
||||
const STORAGE_KEY = 'tongji_game_progress_v1'
|
||||
|
||||
const DEFAULT = {
|
||||
currentLevel: 1,
|
||||
maxUnlocked: 1,
|
||||
stars: {},
|
||||
winStreak: 0,
|
||||
dailyStreak: 0,
|
||||
lastPlayDate: '',
|
||||
totalWins: 0,
|
||||
bestCombo: 0,
|
||||
totalStars: 0
|
||||
}
|
||||
|
||||
function todayStr() {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function sumStars(starsMap) {
|
||||
return Object.values(starsMap || {}).reduce((s, n) => s + (Number(n) || 0), 0)
|
||||
}
|
||||
|
||||
function loadRaw() {
|
||||
try {
|
||||
const raw = uni.getStorageSync(STORAGE_KEY)
|
||||
if (raw && typeof raw === 'object') return { ...DEFAULT, ...raw }
|
||||
} catch (_) {}
|
||||
return { ...DEFAULT }
|
||||
}
|
||||
|
||||
function saveRaw(data) {
|
||||
try {
|
||||
uni.setStorageSync(STORAGE_KEY, data)
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
export function useGameProgress() {
|
||||
const progress = ref(loadRaw())
|
||||
|
||||
function persist() {
|
||||
progress.value.totalStars = sumStars(progress.value.stars)
|
||||
saveRaw(progress.value)
|
||||
}
|
||||
|
||||
/** 进入游戏时更新每日打卡 */
|
||||
function touchDailyPlay() {
|
||||
const today = todayStr()
|
||||
const last = progress.value.lastPlayDate
|
||||
if (last === today) return progress.value.dailyStreak
|
||||
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
const yStr = `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, '0')}-${String(yesterday.getDate()).padStart(2, '0')}`
|
||||
|
||||
if (last === yStr) {
|
||||
progress.value.dailyStreak = (progress.value.dailyStreak || 0) + 1
|
||||
} else {
|
||||
progress.value.dailyStreak = 1
|
||||
}
|
||||
progress.value.lastPlayDate = today
|
||||
persist()
|
||||
return progress.value.dailyStreak
|
||||
}
|
||||
|
||||
function getStars(levelId) {
|
||||
return progress.value.stars[String(levelId)] || 0
|
||||
}
|
||||
|
||||
function recordWin({ levelId, movesLeft, glucoseLevel, score, levelConfig, maxCombo }) {
|
||||
const stars = calcLevelStars({ movesLeft, glucoseLevel, score, levelConfig })
|
||||
const key = String(levelId)
|
||||
const prev = progress.value.stars[key] || 0
|
||||
if (stars > prev) progress.value.stars[key] = stars
|
||||
|
||||
progress.value.winStreak = (progress.value.winStreak || 0) + 1
|
||||
progress.value.totalWins = (progress.value.totalWins || 0) + 1
|
||||
if (maxCombo > (progress.value.bestCombo || 0)) progress.value.bestCombo = maxCombo
|
||||
|
||||
const next = levelId + 1
|
||||
if (next > progress.value.maxUnlocked) progress.value.maxUnlocked = next
|
||||
progress.value.currentLevel = next
|
||||
persist()
|
||||
return { stars, newBest: stars > prev }
|
||||
}
|
||||
|
||||
function recordLoss() {
|
||||
progress.value.winStreak = 0
|
||||
persist()
|
||||
}
|
||||
|
||||
function setCurrentLevel(levelId) {
|
||||
progress.value.currentLevel = Math.max(1, Math.min(progress.value.maxUnlocked, levelId))
|
||||
persist()
|
||||
}
|
||||
|
||||
return {
|
||||
progress,
|
||||
touchDailyPlay,
|
||||
getStars,
|
||||
recordWin,
|
||||
recordLoss,
|
||||
setCurrentLevel,
|
||||
persist
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* 糖分突袭 · 游戏音效(InnerAudioContext 池化,H5/小程序通用)
|
||||
* 每种操作独立音轨 + playbackRate 变调,避免“全是同一个声”
|
||||
*/
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'tongji_game_sfx_enabled'
|
||||
const COS = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file'
|
||||
|
||||
/** 9 条基础素材(已上传 COS,小程序域名白名单内) */
|
||||
const SRC = {
|
||||
tap: `${COS}/20260526/20260526105128557ef8669.mp3`,
|
||||
swoosh: `${COS}/20260527/202605271539371ebe13672.mp3`,
|
||||
pop: `${COS}/20260526/202605261051282a0a94508.mp3`,
|
||||
buzz: `${COS}/20260527/202605271539376ff628472.mp3`,
|
||||
chime: `${COS}/20260528/202605280937077bad76716.mp3`,
|
||||
fanfare: `${COS}/20260528/20260528093707b021f5794.mp3`,
|
||||
blast: `${COS}/20260528/20260528093707ebd4d5733.mp3`,
|
||||
sparkle: `${COS}/20260528/20260528093707016f07427.mp3`,
|
||||
stinger: `${COS}/20260528/20260528093709c669f4659.mp3`
|
||||
}
|
||||
|
||||
/**
|
||||
* 音效表:src / volume / rate(playbackRate) / pool
|
||||
* rate 不同可在同一素材上听出明显差异
|
||||
*/
|
||||
const SFX = {
|
||||
// —— 交互 ——
|
||||
select: { src: SRC.tap, volume: 0.32, rate: 1.05, pool: 3 },
|
||||
deselect: { src: SRC.tap, volume: 0.22, rate: 0.82, pool: 2 },
|
||||
hint: { src: SRC.tap, volume: 0.26, rate: 1.28, pool: 2 },
|
||||
|
||||
swap: { src: SRC.swoosh, volume: 0.38, rate: 1.0, pool: 3 },
|
||||
swapFail: { src: SRC.buzz, volume: 0.36, rate: 1.18, pool: 2 },
|
||||
|
||||
drop: { src: SRC.pop, volume: 0.3, rate: 0.88, pool: 4 },
|
||||
dropLight: { src: SRC.pop, volume: 0.22, rate: 1.22, pool: 2 },
|
||||
|
||||
// —— 消除(按连数)——
|
||||
match3: { src: SRC.chime, volume: 0.48, rate: 1.0, pool: 4 },
|
||||
match4: { src: SRC.fanfare, volume: 0.54, rate: 1.06, pool: 3 },
|
||||
match5: { src: SRC.blast, volume: 0.62, rate: 1.0, pool: 3 },
|
||||
|
||||
// —— 消除(按 GI 主色)——
|
||||
matchLowGi: { src: SRC.chime, volume: 0.42, rate: 0.86, pool: 2 },
|
||||
matchMidGi: { src: SRC.chime, volume: 0.46, rate: 1.02, pool: 2 },
|
||||
matchHighGi: { src: SRC.buzz, volume: 0.44, rate: 0.92, pool: 2 },
|
||||
|
||||
// —— 连消 ——
|
||||
combo2: { src: SRC.fanfare, volume: 0.56, rate: 1.12, pool: 2 },
|
||||
combo3: { src: SRC.blast, volume: 0.64, rate: 1.08, pool: 2 },
|
||||
comboMega: { src: SRC.blast, volume: 0.72, rate: 1.22, pool: 2 },
|
||||
|
||||
// —— 道具 ——
|
||||
insulin: { src: SRC.sparkle, volume: 0.5, rate: 1.15, pool: 2 },
|
||||
fiber: { src: SRC.swoosh, volume: 0.48, rate: 1.32, pool: 2 },
|
||||
meal: { src: SRC.stinger, volume: 0.42, rate: 1.35, pool: 1 },
|
||||
reshuffle: { src: SRC.swoosh, volume: 0.46, rate: 0.72, pool: 2 },
|
||||
|
||||
// —— 反馈 ——
|
||||
score: { src: SRC.tap, volume: 0.28, rate: 1.38, pool: 2 },
|
||||
goalTick: { src: SRC.chime, volume: 0.36, rate: 1.45, pool: 2 },
|
||||
meterWarn: { src: SRC.buzz, volume: 0.5, rate: 1.0, pool: 2 },
|
||||
meterDanger: { src: SRC.buzz, volume: 0.58, rate: 0.78, pool: 2 },
|
||||
meterStable: { src: SRC.pop, volume: 0.24, rate: 1.05, pool: 1 },
|
||||
|
||||
win: { src: SRC.fanfare, volume: 0.66, rate: 1.0, pool: 1 },
|
||||
winStinger: { src: SRC.stinger, volume: 0.38, rate: 1.5, pool: 1 },
|
||||
lose: { src: SRC.buzz, volume: 0.52, rate: 0.68, pool: 1 },
|
||||
|
||||
// 兼容旧名
|
||||
match: { src: SRC.chime, volume: 0.48, rate: 1.0, pool: 4 },
|
||||
combo: { src: SRC.fanfare, volume: 0.56, rate: 1.1, pool: 3 },
|
||||
heal: { src: SRC.pop, volume: 0.44, rate: 1.18, pool: 2 },
|
||||
boost: { src: SRC.sparkle, volume: 0.52, rate: 1.0, pool: 2 },
|
||||
invalid: { src: SRC.buzz, volume: 0.4, rate: 1.15, pool: 2 },
|
||||
danger: { src: SRC.blast, volume: 0.58, rate: 0.95, pool: 2 }
|
||||
}
|
||||
|
||||
class SfxPool {
|
||||
constructor(src, size, volume, rate = 1) {
|
||||
this.src = src
|
||||
this.size = size
|
||||
this.volume = volume
|
||||
this.rate = rate
|
||||
this.list = []
|
||||
this.cursor = 0
|
||||
this.warmedUp = false
|
||||
}
|
||||
|
||||
create() {
|
||||
for (let i = 0; i < this.size; i++) {
|
||||
const ctx = uni.createInnerAudioContext()
|
||||
ctx.src = this.src
|
||||
ctx.obeyMuteSwitch = false
|
||||
ctx.autoplay = false
|
||||
ctx.volume = this.volume
|
||||
try {
|
||||
ctx.playbackRate = this.rate
|
||||
} catch (_) {}
|
||||
this.list.push(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
warmUp() {
|
||||
if (this.warmedUp) return
|
||||
if (!this.list.length) this.create()
|
||||
this.list.forEach((ctx) => {
|
||||
try {
|
||||
ctx.volume = 0
|
||||
ctx.play()
|
||||
setTimeout(() => {
|
||||
try {
|
||||
ctx.stop()
|
||||
ctx.volume = this.volume
|
||||
} catch (_) {}
|
||||
}, 60)
|
||||
} catch (_) {}
|
||||
})
|
||||
this.warmedUp = true
|
||||
}
|
||||
|
||||
play(scale = 1, rateMul = 1) {
|
||||
if (!this.list.length) this.create()
|
||||
const ctx = this.list[this.cursor % this.list.length]
|
||||
this.cursor += 1
|
||||
const vol = Math.min(1, this.volume * scale)
|
||||
const rate = Math.min(2, Math.max(0.5, this.rate * rateMul))
|
||||
try {
|
||||
ctx.volume = vol
|
||||
try {
|
||||
ctx.playbackRate = rate
|
||||
} catch (_) {}
|
||||
ctx.stop()
|
||||
ctx.seek(0)
|
||||
ctx.play()
|
||||
} catch (_) {
|
||||
try {
|
||||
ctx.play()
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.list.forEach((ctx) => {
|
||||
try {
|
||||
ctx.stop()
|
||||
ctx.destroy()
|
||||
} catch (_) {}
|
||||
})
|
||||
this.list = []
|
||||
this.warmedUp = false
|
||||
}
|
||||
}
|
||||
|
||||
function dominantGi(tiles = []) {
|
||||
const cnt = { low: 0, mid: 0, high: 0 }
|
||||
tiles.forEach((t) => {
|
||||
const g = t?.type?.gi
|
||||
if (g && cnt[g] !== undefined) cnt[g] += 1
|
||||
})
|
||||
let best = 'mid'
|
||||
let max = 0
|
||||
Object.entries(cnt).forEach(([k, v]) => {
|
||||
if (v > max) {
|
||||
max = v
|
||||
best = k
|
||||
}
|
||||
})
|
||||
return best
|
||||
}
|
||||
|
||||
export function useGameSfx() {
|
||||
const enabled = ref(true)
|
||||
const pools = {}
|
||||
|
||||
try {
|
||||
const stored = uni.getStorageSync(STORAGE_KEY)
|
||||
if (stored === false || stored === '0' || stored === 0) {
|
||||
enabled.value = false
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
function ensureAudioOption() {
|
||||
// #ifdef MP-WEIXIN
|
||||
try {
|
||||
uni.setInnerAudioOption({
|
||||
obeyMuteSwitch: false,
|
||||
mixWithOther: true
|
||||
})
|
||||
} catch (_) {}
|
||||
// #endif
|
||||
}
|
||||
|
||||
function getPool(name) {
|
||||
const cfg = SFX[name]
|
||||
if (!cfg) return null
|
||||
if (!pools[name]) {
|
||||
pools[name] = new SfxPool(cfg.src, cfg.pool, cfg.volume, cfg.rate ?? 1)
|
||||
}
|
||||
return pools[name]
|
||||
}
|
||||
|
||||
function warmUp() {
|
||||
if (!enabled.value) return
|
||||
ensureAudioOption()
|
||||
Object.keys(SFX).forEach((name) => {
|
||||
getPool(name)?.warmUp()
|
||||
})
|
||||
}
|
||||
|
||||
function play(name, scale = 1, rateMul = 1) {
|
||||
if (!enabled.value) return
|
||||
getPool(name)?.play(scale, rateMul)
|
||||
}
|
||||
|
||||
function playLayer(primary, secondary, delayMs = 70, secScale = 0.6) {
|
||||
play(primary)
|
||||
if (secondary) {
|
||||
setTimeout(() => play(secondary, secScale), delayMs)
|
||||
}
|
||||
}
|
||||
|
||||
function playMatch({ matchCount = 3, chain = 0, tiles = [] } = {}) {
|
||||
if (!enabled.value) return
|
||||
const giTrack = (() => {
|
||||
const gi = dominantGi(tiles)
|
||||
if (gi === 'low') return 'matchLowGi'
|
||||
if (gi === 'high') return 'matchHighGi'
|
||||
return 'matchMidGi'
|
||||
})()
|
||||
|
||||
if (chain >= 3) {
|
||||
playLayer('comboMega', giTrack, 90, 0.55)
|
||||
return
|
||||
}
|
||||
if (chain === 2) {
|
||||
playLayer('combo3', giTrack, 80, 0.5)
|
||||
return
|
||||
}
|
||||
if (chain === 1) {
|
||||
playLayer('combo2', giTrack, 70, 0.45)
|
||||
return
|
||||
}
|
||||
|
||||
if (matchCount >= 5) {
|
||||
playLayer('match5', 'matchHighGi', 85, 0.5)
|
||||
} else if (matchCount >= 4) {
|
||||
playLayer('match4', giTrack, 65, 0.48)
|
||||
} else {
|
||||
play(giTrack, 1, 1)
|
||||
setTimeout(() => play('match3', 0.85), 40)
|
||||
}
|
||||
}
|
||||
|
||||
function playDrop(count = 1) {
|
||||
if (!enabled.value || count <= 0) return
|
||||
const name = count >= 4 ? 'drop' : 'dropLight'
|
||||
play(name, Math.min(1.2, 0.85 + count * 0.04), count >= 6 ? 0.9 : 1)
|
||||
}
|
||||
|
||||
function playMeter(delta, level) {
|
||||
if (!enabled.value) return
|
||||
if (level > 70) play('meterDanger', 1 + (level - 70) * 0.02)
|
||||
else if (level < 30) play('meterWarn', 0.9, 0.95)
|
||||
else if (delta > 8) play('meterWarn', 0.75)
|
||||
else if (delta < -5) play('meterStable', 1.1)
|
||||
}
|
||||
|
||||
function toggleEnabled() {
|
||||
enabled.value = !enabled.value
|
||||
try {
|
||||
uni.setStorageSync(STORAGE_KEY, enabled.value ? '1' : '0')
|
||||
} catch (_) {}
|
||||
if (enabled.value) {
|
||||
warmUp()
|
||||
play('select')
|
||||
}
|
||||
}
|
||||
|
||||
function destroyAll() {
|
||||
Object.keys(pools).forEach((key) => {
|
||||
pools[key]?.destroy()
|
||||
delete pools[key]
|
||||
})
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
destroyAll()
|
||||
})
|
||||
|
||||
return {
|
||||
enabled,
|
||||
play,
|
||||
playLayer,
|
||||
playMatch,
|
||||
playDrop,
|
||||
playMeter,
|
||||
warmUp,
|
||||
toggleEnabled,
|
||||
destroyAll
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
|
||||
const MIN_HOLD_MS = 280
|
||||
|
||||
/**
|
||||
* 语音转文字(长按说话):微信小程序 WechatSI;H5 Web Speech API。
|
||||
*
|
||||
* @param {{ onResult?: (text: string) => void, showToast?: (msg: string) => void }} options
|
||||
*/
|
||||
export function useSpeechToText({ onResult, showToast } = {}) {
|
||||
const sttListening = ref(false)
|
||||
const sttHolding = ref(false)
|
||||
const sttSupported = ref(false)
|
||||
|
||||
let wxRecordManager = null
|
||||
let h5Recognition = null
|
||||
let holdStartTs = 0
|
||||
let holdSessionId = 0
|
||||
let startRequested = false
|
||||
let recordAuthorized = null
|
||||
|
||||
function notify(msg) {
|
||||
if (!msg) return
|
||||
if (typeof showToast === 'function') {
|
||||
showToast(msg)
|
||||
return
|
||||
}
|
||||
uni.showToast({ title: msg, icon: 'none', duration: 2000 })
|
||||
}
|
||||
|
||||
function emitResult(text) {
|
||||
const t = String(text || '').trim()
|
||||
const heldMs = Date.now() - holdStartTs
|
||||
if (!t) {
|
||||
if (heldMs < MIN_HOLD_MS) return
|
||||
notify('没听清,请再试一次')
|
||||
return
|
||||
}
|
||||
if (typeof onResult === 'function') {
|
||||
onResult(t)
|
||||
}
|
||||
}
|
||||
|
||||
function stopListening() {
|
||||
// #ifdef MP-WEIXIN
|
||||
if (wxRecordManager && (sttListening.value || startRequested)) {
|
||||
try {
|
||||
wxRecordManager.stop()
|
||||
} catch (e) {}
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifdef H5
|
||||
if (h5Recognition && sttListening.value) {
|
||||
try {
|
||||
h5Recognition.stop()
|
||||
} catch (e) {}
|
||||
}
|
||||
// #endif
|
||||
|
||||
startRequested = false
|
||||
sttListening.value = false
|
||||
}
|
||||
|
||||
function ensureRecordAuth(onGranted) {
|
||||
// #ifdef MP-WEIXIN
|
||||
if (recordAuthorized === true) {
|
||||
onGranted()
|
||||
return
|
||||
}
|
||||
uni.authorize({
|
||||
scope: 'scope.record',
|
||||
success() {
|
||||
recordAuthorized = true
|
||||
onGranted()
|
||||
},
|
||||
fail() {
|
||||
recordAuthorized = false
|
||||
sttHolding.value = false
|
||||
uni.showModal({
|
||||
title: '需要麦克风权限',
|
||||
content: '请在设置中允许使用麦克风,以便长按说话',
|
||||
confirmText: '去设置',
|
||||
success(res) {
|
||||
if (res.confirm) {
|
||||
uni.openSetting({})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
// #ifndef MP-WEIXIN
|
||||
onGranted()
|
||||
// #endif
|
||||
}
|
||||
|
||||
function startWechatRecord(session) {
|
||||
// #ifdef MP-WEIXIN
|
||||
if (!wxRecordManager || session !== holdSessionId || !sttHolding.value) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
startRequested = true
|
||||
wxRecordManager.start({
|
||||
duration: 60000,
|
||||
lang: 'zh_CN'
|
||||
})
|
||||
} catch (e) {
|
||||
startRequested = false
|
||||
sttHolding.value = false
|
||||
notify('无法开始录音')
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
|
||||
function startH5Record() {
|
||||
// #ifdef H5
|
||||
if (!h5Recognition) {
|
||||
notify('当前浏览器不支持语音输入')
|
||||
sttHolding.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
startRequested = true
|
||||
h5Recognition.start()
|
||||
sttListening.value = true
|
||||
} catch (e) {
|
||||
startRequested = false
|
||||
sttHolding.value = false
|
||||
notify('无法开始语音识别,请检查麦克风权限')
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
|
||||
/** 长按开始:手指按下 */
|
||||
function beginHoldSpeech() {
|
||||
if (!sttSupported.value) {
|
||||
notify('当前环境不支持语音输入')
|
||||
return
|
||||
}
|
||||
if (sttHolding.value) return
|
||||
|
||||
const session = ++holdSessionId
|
||||
holdStartTs = Date.now()
|
||||
sttHolding.value = true
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
ensureRecordAuth(() => startWechatRecord(session))
|
||||
// #endif
|
||||
// #ifdef H5
|
||||
startH5Record()
|
||||
// #endif
|
||||
// #ifndef MP-WEIXIN
|
||||
// #ifndef H5
|
||||
sttHolding.value = false
|
||||
notify('当前端暂不支持语音输入')
|
||||
// #endif
|
||||
// #endif
|
||||
}
|
||||
|
||||
/** 松手结束:手指抬起 */
|
||||
function endHoldSpeech() {
|
||||
if (!sttHolding.value && !sttListening.value && !startRequested) {
|
||||
return
|
||||
}
|
||||
sttHolding.value = false
|
||||
if (sttListening.value || startRequested) {
|
||||
stopListening()
|
||||
return
|
||||
}
|
||||
holdSessionId += 1
|
||||
}
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
const plugin = requirePlugin('WechatSI')
|
||||
wxRecordManager = plugin.getRecordRecognitionManager()
|
||||
sttSupported.value = !!wxRecordManager
|
||||
|
||||
wxRecordManager.onStart = () => {
|
||||
if (!sttHolding.value) {
|
||||
try {
|
||||
wxRecordManager.stop()
|
||||
} catch (e) {}
|
||||
return
|
||||
}
|
||||
startRequested = false
|
||||
sttListening.value = true
|
||||
}
|
||||
wxRecordManager.onStop = (res) => {
|
||||
sttListening.value = false
|
||||
startRequested = false
|
||||
emitResult(res?.result)
|
||||
}
|
||||
wxRecordManager.onError = (res) => {
|
||||
sttListening.value = false
|
||||
startRequested = false
|
||||
sttHolding.value = false
|
||||
notify(res?.msg || '语音识别失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('WechatSI record recognition not available', e)
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifdef H5
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition
|
||||
if (SR) {
|
||||
h5Recognition = new SR()
|
||||
h5Recognition.lang = 'zh-CN'
|
||||
h5Recognition.interimResults = false
|
||||
h5Recognition.continuous = false
|
||||
h5Recognition.maxAlternatives = 1
|
||||
sttSupported.value = true
|
||||
|
||||
h5Recognition.onresult = (event) => {
|
||||
const item = event.results?.[0]?.[0]
|
||||
emitResult(item?.transcript || '')
|
||||
}
|
||||
h5Recognition.onend = () => {
|
||||
sttListening.value = false
|
||||
startRequested = false
|
||||
sttHolding.value = false
|
||||
}
|
||||
h5Recognition.onerror = (event) => {
|
||||
sttListening.value = false
|
||||
startRequested = false
|
||||
sttHolding.value = false
|
||||
if (event?.error === 'not-allowed') {
|
||||
notify('请允许浏览器使用麦克风')
|
||||
} else if (event?.error !== 'aborted') {
|
||||
notify('语音识别失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('H5 SpeechRecognition not available', e)
|
||||
}
|
||||
// #endif
|
||||
|
||||
onUnmounted(() => {
|
||||
sttHolding.value = false
|
||||
holdSessionId += 1
|
||||
stopListening()
|
||||
})
|
||||
|
||||
return {
|
||||
sttListening,
|
||||
sttHolding,
|
||||
sttSupported,
|
||||
beginHoldSpeech,
|
||||
endHoldSpeech,
|
||||
stopSpeechToText: endHoldSpeech
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { ref } from 'vue'
|
||||
import { formatUserMessage } from '../utils/tongjiHelpers.js'
|
||||
|
||||
/** 登录 / 就诊卡门禁(index 与 more 共用) */
|
||||
export function useTongjiAuth(proxy) {
|
||||
const authChecking = ref(false)
|
||||
let gateRedirected = false
|
||||
let authSessionPromise = null
|
||||
|
||||
function hasAuthToken() {
|
||||
return !!String(uni.getStorageSync('token') || '').trim()
|
||||
}
|
||||
|
||||
function clearAuthStorage() {
|
||||
uni.removeStorageSync('token')
|
||||
authSessionPromise = null
|
||||
}
|
||||
|
||||
function wxLoginGetCode() {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: (res) => {
|
||||
if (res && res.code) resolve(res.code)
|
||||
else reject(new Error('微信登录未返回 code'))
|
||||
},
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function mnpLoginWithCode(code) {
|
||||
const res = await proxy.apiUrl({
|
||||
url: '/api/login/mnpLogin',
|
||||
method: 'POST',
|
||||
data: { code }
|
||||
}, false)
|
||||
if (res && res.code === 1 && res.data && res.data.token) {
|
||||
uni.setStorageSync('token', res.data.token)
|
||||
uni.setStorageSync('userData', res.data)
|
||||
return res.data
|
||||
}
|
||||
clearAuthStorage()
|
||||
throw new Error(formatUserMessage(res?.msg, '登录失败'))
|
||||
}
|
||||
|
||||
async function doWxLogin() {
|
||||
const code = await wxLoginGetCode()
|
||||
await mnpLoginWithCode(code)
|
||||
return hasAuthToken()
|
||||
}
|
||||
|
||||
async function verifyOrLogin() {
|
||||
if (!hasAuthToken()) {
|
||||
return doWxLogin()
|
||||
}
|
||||
try {
|
||||
const res = await proxy.apiUrl({ url: '/api/user/info', method: 'POST' }, false)
|
||||
if (res && res.code === 1 && res.data) {
|
||||
uni.setStorageSync('userData', res.data)
|
||||
return true
|
||||
}
|
||||
} catch (e) {
|
||||
/* 网络异常走重新登录 */
|
||||
}
|
||||
clearAuthStorage()
|
||||
try {
|
||||
return await doWxLogin()
|
||||
} catch (e2) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureLoggedIn() {
|
||||
if (authSessionPromise) {
|
||||
return authSessionPromise
|
||||
}
|
||||
authSessionPromise = verifyOrLogin()
|
||||
.then((ok) => {
|
||||
if (!ok) authSessionPromise = null
|
||||
return !!ok
|
||||
})
|
||||
.catch(() => {
|
||||
authSessionPromise = null
|
||||
return false
|
||||
})
|
||||
return authSessionPromise
|
||||
}
|
||||
|
||||
function redirectToCardEntry(returnPath) {
|
||||
if (gateRedirected) return
|
||||
gateRedirected = true
|
||||
const returnUrl = encodeURIComponent(returnPath || '/tongji/pages/index')
|
||||
uni.redirectTo({
|
||||
url: `/pages/Card/edit_card?add=1&returnUrl=${returnUrl}`
|
||||
})
|
||||
}
|
||||
|
||||
function resetGateRedirect() {
|
||||
gateRedirected = false
|
||||
}
|
||||
|
||||
function isGateRedirected() {
|
||||
return gateRedirected
|
||||
}
|
||||
|
||||
return {
|
||||
authChecking,
|
||||
hasAuthToken,
|
||||
ensureLoggedIn,
|
||||
redirectToCardEntry,
|
||||
resetGateRedirect,
|
||||
isGateRedirected
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
/**
|
||||
* 棋盘食材 · 仿真插画 SVG(渐变 + 高光 + 阴影,开心消消乐式 2.5D)
|
||||
*/
|
||||
import { svgToDataUrl } from '../utils/svgDataUrl.js'
|
||||
|
||||
function gid(key, suffix) {
|
||||
return `f-${String(key).replace(/[^a-z0-9]/gi, '')}-${suffix}`
|
||||
}
|
||||
|
||||
function wrap(key, body) {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">${body}</svg>`
|
||||
}
|
||||
|
||||
function shadow(key) {
|
||||
const id = gid(key, 'sh')
|
||||
return `<filter id="${id}"><feDropShadow dx="0" dy="2.5" stdDeviation="2.2" flood-color="#1a1a2e" flood-opacity="0.28"/></filter>`
|
||||
}
|
||||
|
||||
function shine(x, y, rx, ry) {
|
||||
return `<ellipse cx="${x}" cy="${y}" rx="${rx}" ry="${ry}" fill="#fff" opacity="0.38"/>`
|
||||
}
|
||||
|
||||
/** 圆形水果 */
|
||||
function artRound(key, base, dark, light, extra = '') {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs>
|
||||
<radialGradient id="${g}" cx="36%" cy="30%" r="68%">
|
||||
<stop offset="0%" stop-color="${light}"/>
|
||||
<stop offset="55%" stop-color="${base}"/>
|
||||
<stop offset="100%" stop-color="${dark}"/>
|
||||
</radialGradient>
|
||||
${shadow(key)}
|
||||
</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="36" rx="21" ry="23" fill="url(#${g})"/>
|
||||
${shine(24, 28, 7, 5)}
|
||||
<path d="M32 13 Q35 9 37 15" stroke="#5d4037" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<ellipse cx="38" cy="15" rx="5.5" ry="3" fill="#66bb6a" transform="rotate(28 38 15)"/>
|
||||
${extra}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 叶菜 */
|
||||
function artLeafy(key, light, mid, dark) {
|
||||
const g1 = gid(key, 'g1')
|
||||
const g2 = gid(key, 'g2')
|
||||
return wrap(key, `
|
||||
<defs>
|
||||
<linearGradient id="${g1}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${mid}"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="${g2}" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="${mid}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>
|
||||
${shadow(key)}
|
||||
</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M32 8 C18 18 14 34 20 48 C24 54 32 56 32 56 C32 56 40 54 44 48 C50 34 46 18 32 8Z" fill="url(#${g1})"/>
|
||||
<path d="M32 14 C26 22 24 32 28 42 C30 46 32 48 32 48 C32 48 34 46 36 42 C40 32 38 22 32 14Z" fill="url(#${g2})" opacity="0.85"/>
|
||||
${shine(26, 24, 6, 8)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 西兰花 */
|
||||
function artBroccoli(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs>
|
||||
<radialGradient id="${g}" cx="50%" cy="40%" r="55%">
|
||||
<stop offset="0%" stop-color="#7cb342"/><stop offset="100%" stop-color="#33691e"/>
|
||||
</radialGradient>
|
||||
${shadow(key)}
|
||||
</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<rect x="28" y="38" width="8" height="16" rx="3" fill="#558b2f"/>
|
||||
<circle cx="22" cy="28" r="9" fill="url(#${g})"/><circle cx="32" cy="22" r="10" fill="url(#${g})"/>
|
||||
<circle cx="42" cy="28" r="9" fill="url(#${g})"/><circle cx="32" cy="32" r="8" fill="#689f38"/>
|
||||
${shine(28, 20, 5, 4)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 长条(黄瓜/香蕉/玉米) */
|
||||
function artLong(key, base, dark, light, type = 'cucumber') {
|
||||
const g = gid(key, 'g')
|
||||
if (type === 'banana') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M18 46 Q14 30 28 14 Q38 8 46 16 Q52 24 44 38 Q36 52 22 50 Q16 48 18 46Z" fill="url(#${g})"/>
|
||||
<path d="M24 42 Q20 32 30 20" stroke="${dark}" stroke-width="1.2" fill="none" opacity="0.5"/>
|
||||
${shine(30, 26, 5, 3)}
|
||||
</g>`)
|
||||
}
|
||||
if (type === 'corn') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${base}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="34" rx="14" ry="22" fill="url(#${g})"/>
|
||||
<g fill="${dark}" opacity="0.55">${Array.from({ length: 5 }, (_, r) =>
|
||||
Array.from({ length: 4 }, (_, c) =>
|
||||
`<circle cx="${22 + c * 5}" cy="${20 + r * 5}" r="1.2"/>`
|
||||
).join('')
|
||||
).join('')}</g>
|
||||
<path d="M32 10 L34 18 M32 10 L30 18" stroke="#558b2f" stroke-width="2" stroke-linecap="round"/>
|
||||
${shine(26, 26, 5, 8)}
|
||||
</g>`)
|
||||
}
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="32" rx="10" ry="24" fill="url(#${g})" transform="rotate(-12 32 32)"/>
|
||||
<ellipse cx="28" cy="22" rx="3" ry="5" fill="#fff" opacity="0.25" transform="rotate(-12 28 22)"/>
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 茄子 */
|
||||
function artEggplant(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="20%" y1="0%" x2="80%" y2="100%">
|
||||
<stop offset="0%" stop-color="#9575cd"/><stop offset="100%" stop-color="#4527a0"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="38" rx="13" ry="20" fill="url(#${g})"/>
|
||||
<path d="M32 18 Q34 12 32 8 Q30 12 32 18" fill="#558b2f"/>
|
||||
${shine(26, 32, 5, 7)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 番茄 */
|
||||
function artTomato(key) {
|
||||
return artRound(key, '#e53935', '#b71c1c', '#ef5350',
|
||||
'<path d="M24 16 Q32 12 40 16" stroke="#2e7d32" stroke-width="2.5" fill="none" stroke-linecap="round"/>')
|
||||
}
|
||||
|
||||
/** 甜椒 */
|
||||
function artPepper(key, color, dark) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="30%" y1="0%" x2="70%" y2="100%">
|
||||
<stop offset="0%" stop-color="${color}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M32 10 Q26 14 24 24 Q22 38 28 48 Q32 54 36 48 Q42 38 40 24 Q38 14 32 10Z" fill="url(#${g})"/>
|
||||
<path d="M30 12 Q32 8 34 12" stroke="#33691e" stroke-width="2" fill="none"/>
|
||||
${shine(28, 26, 5, 6)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 洋葱 */
|
||||
function artOnion(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="45%" cy="35%" r="60%">
|
||||
<stop offset="0%" stop-color="#ffe0b2"/><stop offset="100%" stop-color="#bc8f5a"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="36" rx="18" ry="20" fill="url(#${g})"/>
|
||||
<path d="M32 16 Q28 22 32 28 Q36 22 32 16" fill="#d7ccc8"/>
|
||||
${shine(24, 30, 6, 5)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 葡萄串 */
|
||||
function artGrapes(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="65%">
|
||||
<stop offset="0%" stop-color="#ba68c8"/><stop offset="100%" stop-color="#6a1b9a"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M32 10 L32 18" stroke="#558b2f" stroke-width="2"/>
|
||||
<ellipse cx="32" cy="22" rx="4" ry="4.5" fill="url(#${g})"/>
|
||||
<ellipse cx="26" cy="28" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="38" cy="28" rx="4" ry="4.5" fill="url(#${g})"/>
|
||||
<ellipse cx="22" cy="34" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="32" cy="34" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="42" cy="34" rx="4" ry="4.5" fill="url(#${g})"/>
|
||||
<ellipse cx="28" cy="40" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="36" cy="40" rx="4" ry="4.5" fill="url(#${g})"/>
|
||||
${shine(28, 24, 3, 2)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 樱桃 */
|
||||
function artCherry(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="35%" cy="30%" r="70%">
|
||||
<stop offset="0%" stop-color="#f06292"/><stop offset="100%" stop-color="#880e4f"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M32 12 Q24 18 22 26 M32 12 Q40 18 42 26" stroke="#558b2f" stroke-width="2" fill="none"/>
|
||||
<circle cx="22" cy="32" r="9" fill="url(#${g})"/><circle cx="42" cy="32" r="9" fill="url(#${g})"/>
|
||||
${shine(19, 29, 3, 2)}${shine(39, 29, 3, 2)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 草莓 */
|
||||
function artStrawberry(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="50%" y1="0%" x2="50%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ef5350"/><stop offset="100%" stop-color="#c62828"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M32 14 Q18 28 22 46 Q26 54 32 56 Q38 54 42 46 Q46 28 32 14Z" fill="url(#${g})"/>
|
||||
<path d="M22 16 Q32 10 42 16 Q32 20 22 16Z" fill="#43a047"/>
|
||||
${shine(26, 28, 4, 6)}
|
||||
<g fill="#ffeb3b" opacity="0.85">${[28, 34, 38, 30, 36].map((x, i) =>
|
||||
`<circle cx="${x}" cy="${32 + (i % 3) * 5}" r="1.1"/>`
|
||||
).join('')}</g>
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 豆类 */
|
||||
function artBeans(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#d7ccc8"/><stop offset="100%" stop-color="#8d6e63"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="24" cy="32" rx="9" ry="12" fill="url(#${g})" transform="rotate(-20 24 32)"/>
|
||||
<ellipse cx="36" cy="30" rx="9" ry="12" fill="url(#${g})" transform="rotate(15 36 30)"/>
|
||||
<ellipse cx="32" cy="42" rx="9" ry="12" fill="url(#${g})" transform="rotate(-5 32 42)"/>
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 饮品杯 */
|
||||
function artCup(key, liquid, cup = '#eceff1', type = 'glass') {
|
||||
const g = gid(key, 'g')
|
||||
if (type === 'bottle') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="${liquid}"/><stop offset="100%" stop-color="${cup}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<rect x="24" y="14" width="16" height="6" rx="2" fill="#b0bec5"/>
|
||||
<path d="M22 20 L24 52 Q32 56 40 52 L42 20Z" fill="url(#${g})"/>
|
||||
${shine(28, 30, 4, 10)}
|
||||
</g>`)
|
||||
}
|
||||
if (type === 'wine') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="100%" x2="0%" y2="0%">
|
||||
<stop offset="0%" stop-color="#4a0e16"/><stop offset="100%" stop-color="${liquid}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M28 18 L26 38 Q32 46 38 38 L36 18Z" fill="url(#${g})"/>
|
||||
<rect x="30" y="38" width="4" height="12" fill="#cfd8dc"/>
|
||||
<ellipse cx="32" cy="52" rx="8" ry="2" fill="#cfd8dc"/>
|
||||
</g>`)
|
||||
}
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="${liquid}"/><stop offset="100%" stop-color="${cup}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M18 24 L20 48 Q32 54 44 48 L46 24Z" fill="#eceff1" stroke="#b0bec5" stroke-width="1"/>
|
||||
<path d="M20 28 L22 46 Q32 50 42 46 L44 28Z" fill="url(#${g})"/>
|
||||
${shine(26, 32, 4, 8)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 蘑菇 */
|
||||
function artMushroom(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="40%" cy="35%" r="65%">
|
||||
<stop offset="0%" stop-color="#a1887f"/><stop offset="100%" stop-color="#5d4037"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<rect x="28" y="34" width="8" height="16" rx="3" fill="#efebe9"/>
|
||||
<ellipse cx="32" cy="30" rx="18" ry="14" fill="url(#${g})"/>
|
||||
<ellipse cx="24" cy="26" rx="3" ry="2" fill="#efebe9" opacity="0.7"/>
|
||||
<ellipse cx="36" cy="24" rx="2.5" ry="1.8" fill="#efebe9" opacity="0.7"/>
|
||||
${shine(24, 24, 6, 4)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 芹菜 */
|
||||
function artCelery(key) {
|
||||
return artLeafy(key, '#aed581', '#7cb342', '#558b2f')
|
||||
}
|
||||
|
||||
/** 四季豆 */
|
||||
function artGreenBean(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#9ccc65"/><stop offset="100%" stop-color="#33691e"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="26" cy="32" rx="5" ry="18" fill="url(#${g})" transform="rotate(-15 26 32)"/>
|
||||
<ellipse cx="38" cy="34" rx="5" ry="18" fill="url(#${g})" transform="rotate(12 38 34)"/>
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 饭碗 */
|
||||
function artRiceBowl(key, rice, bowl = '#e0e0e0', dark = '#bdbdbd') {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="${rice}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M12 36 Q32 58 52 36 L48 48 Q32 56 16 48Z" fill="${bowl}"/>
|
||||
<ellipse cx="32" cy="36" rx="20" ry="8" fill="url(#${g})"/>
|
||||
${shine(24, 34, 6, 3)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 薯类 */
|
||||
function artRoot(key, base, dark, light) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="40%" cy="35%" r="65%">
|
||||
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="36" rx="16" ry="20" fill="url(#${g})"/>
|
||||
<path d="M22 28 Q32 22 42 28" stroke="${base}" stroke-width="1.5" fill="none" opacity="0.4"/>
|
||||
${shine(24, 30, 5, 6)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 肉类 */
|
||||
function artMeat(key, base, dark, type = 'steak') {
|
||||
const g = gid(key, 'g')
|
||||
if (type === 'chicken') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="30" cy="38" rx="14" ry="16" fill="url(#${g})"/>
|
||||
<ellipse cx="38" cy="28" rx="8" ry="10" fill="url(#${g})"/>
|
||||
<circle cx="42" cy="24" r="3" fill="#efebe9"/>
|
||||
${shine(26, 32, 5, 6)}
|
||||
</g>`)
|
||||
}
|
||||
if (type === 'fish') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="34" rx="20" ry="12" fill="url(#${g})"/>
|
||||
<path d="M52 34 L60 28 L60 40Z" fill="${dark}"/>
|
||||
<circle cx="22" cy="32" r="2.5" fill="#fff"/>
|
||||
${shine(28, 30, 6, 4)}
|
||||
</g>`)
|
||||
}
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M16 40 Q20 22 36 20 Q50 20 48 38 Q46 50 32 52 Q18 52 16 40Z" fill="url(#${g})"/>
|
||||
<ellipse cx="28" cy="32" rx="4" ry="3" fill="#efebe9" opacity="0.5"/>
|
||||
${shine(26, 28, 6, 4)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 虾/蟹 */
|
||||
function artSeafood(key, type = 'shrimp') {
|
||||
const g = gid(key, 'g')
|
||||
if (type === 'crab') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ff7043"/><stop offset="100%" stop-color="#bf360c"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="36" rx="14" ry="12" fill="url(#${g})"/>
|
||||
<circle cx="28" cy="32" r="2" fill="#fff"/><circle cx="36" cy="32" r="2" fill="#fff"/>
|
||||
<path d="M18 30 L10 24 M18 38 L10 44 M46 30 L54 24 M46 38 L54 44" stroke="#bf360c" stroke-width="2.5" stroke-linecap="round"/>
|
||||
</g>`)
|
||||
}
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ff8a65"/><stop offset="100%" stop-color="#e64a19"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M16 40 Q28 16 48 28 Q42 36 38 44 Q28 52 16 40Z" fill="url(#${g})"/>
|
||||
${shine(30, 28, 5, 4)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 菠萝 */
|
||||
function artPineapple(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffca28"/><stop offset="100%" stop-color="#f57f17"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="38" rx="16" ry="18" fill="url(#${g})"/>
|
||||
<g stroke="#e65100" stroke-width="1" opacity="0.45">${[-8, 0, 8].map(o =>
|
||||
`<line x1="${24 + o}" y1="24" x2="${24 + o}" y2="52"/>`
|
||||
).join('')}</g>
|
||||
<path d="M24 18 L32 8 L40 18" fill="#43a047"/>
|
||||
${shine(26, 32, 5, 6)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 奇异果 */
|
||||
function artKiwi(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#dce775"/><stop offset="60%" stop-color="#9ccc65"/><stop offset="100%" stop-color="#558b2f"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<circle cx="32" cy="34" r="18" fill="#8d6e63"/>
|
||||
<circle cx="32" cy="34" r="14" fill="url(#${g})"/>
|
||||
<circle cx="32" cy="34" r="4" fill="#fff" opacity="0.5"/>
|
||||
${shine(26, 28, 4, 3)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 蜜瓜 */
|
||||
function artMelon(key) {
|
||||
return artRound(key, '#aed581', '#689f38', '#c5e1a5', '')
|
||||
}
|
||||
|
||||
/** 蜂蜜 */
|
||||
function artHoney(key) {
|
||||
return artCup(key, '#ffb300', '#ff8f00', 'bottle')
|
||||
}
|
||||
|
||||
/** 面包类 */
|
||||
function artBread(key, base, dark, type = 'loaf') {
|
||||
const g = gid(key, 'g')
|
||||
if (type === 'donut') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<circle cx="32" cy="34" r="18" fill="url(#${g})"/>
|
||||
<circle cx="32" cy="34" r="7" fill="#fff5f5"/>
|
||||
<path d="M20 28 Q32 18 44 28" stroke="#f48fb1" stroke-width="4" fill="none" stroke-linecap="round"/>
|
||||
${shine(24, 28, 5, 4)}
|
||||
</g>`)
|
||||
}
|
||||
if (type === 'mantou') {
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="70%">
|
||||
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="38" rx="16" ry="14" fill="url(#${g})"/>
|
||||
<path d="M18 34 Q32 26 46 34" stroke="${dark}" stroke-width="1" fill="none" opacity="0.3"/>
|
||||
${shine(24, 32, 6, 4)}
|
||||
</g>`)
|
||||
}
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<rect x="14" y="22" width="36" height="28" rx="8" fill="url(#${g})"/>
|
||||
${shine(22, 28, 8, 5)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 面条 */
|
||||
function artNoodle(key, soup = '#d7ccc8') {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="${soup}"/><stop offset="100%" stop-color="#a1887f"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="42" rx="20" ry="10" fill="#eceff1"/>
|
||||
<ellipse cx="32" cy="38" rx="18" ry="8" fill="url(#${g})"/>
|
||||
<path d="M20 36 Q26 32 32 36 Q38 40 44 36" stroke="#ffe082" stroke-width="2.5" fill="none" stroke-linecap="round"/>
|
||||
<path d="M22 40 Q30 44 38 40" stroke="#ffe082" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 南瓜 */
|
||||
function artPumpkin(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="40%" cy="35%" r="65%">
|
||||
<stop offset="0%" stop-color="#ffb74d"/><stop offset="100%" stop-color="#e65100"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="24" cy="36" rx="10" ry="16" fill="url(#${g})"/>
|
||||
<ellipse cx="32" cy="34" rx="11" ry="18" fill="url(#${g})"/>
|
||||
<ellipse cx="40" cy="36" rx="10" ry="16" fill="url(#${g})"/>
|
||||
<path d="M32 16 Q34 10 32 8" stroke="#558b2f" stroke-width="2.5" fill="none"/>
|
||||
${shine(26, 28, 5, 6)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 薯条 */
|
||||
function artFries(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffe082"/><stop offset="100%" stop-color="#f9a825"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M18 48 L22 24 L46 24 L50 48Z" fill="#ef5350"/>
|
||||
${[26, 32, 38, 44].map(x => `<rect x="${x}" y="18" width="4" height="22" rx="2" fill="url(#${g})"/>`).join('')}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 西瓜 */
|
||||
function artWatermelon(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ef5350"/><stop offset="100%" stop-color="#c62828"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M12 40 Q32 8 52 40 Q32 56 12 40Z" fill="#2e7d32"/>
|
||||
<path d="M16 40 Q32 14 48 40 Q32 52 16 40Z" fill="url(#${g})"/>
|
||||
${[24, 32, 40].map(x => `<circle cx="${x}" cy="36" r="1.2" fill="#212121"/>`).join('')}
|
||||
${shine(24, 30, 4, 3)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 糖果 */
|
||||
function artCandy(key, base, dark, type = 'lollipop') {
|
||||
const g = gid(key, 'g')
|
||||
if (type === 'wrap') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<rect x="20" y="22" width="24" height="20" rx="4" fill="url(#${g})"/>
|
||||
<path d="M20 26 L16 30 L20 34 M44 26 L48 30 L44 34" stroke="${dark}" stroke-width="2" fill="none"/>
|
||||
</g>`)
|
||||
}
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="35%" cy="30%" r="70%">
|
||||
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<circle cx="32" cy="28" r="14" fill="url(#${g})"/>
|
||||
<rect x="30" y="40" width="4" height="14" rx="2" fill="#eceff1"/>
|
||||
${shine(26, 24, 4, 3)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 糯米团 */
|
||||
function artDango(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="70%">
|
||||
<stop offset="0%" stop-color="#f8bbd0"/><stop offset="100%" stop-color="#ec407a"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<circle cx="22" cy="36" r="8" fill="#fff"/>
|
||||
<circle cx="32" cy="32" r="8" fill="url(#${g})"/>
|
||||
<circle cx="42" cy="36" r="8" fill="#fff"/>
|
||||
<rect x="14" y="44" width="36" height="3" rx="1.5" fill="#8d6e63"/>
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 爆米花 */
|
||||
function artPopcorn(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="70%">
|
||||
<stop offset="0%" stop-color="#fff9c4"/><stop offset="100%" stop-color="#fff176"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M16 48 L20 36 L48 36 L52 48Z" fill="#ef5350"/>
|
||||
<circle cx="24" cy="30" r="7" fill="url(#${g})"/><circle cx="34" cy="24" r="8" fill="url(#${g})"/>
|
||||
<circle cx="44" cy="30" r="7" fill="url(#${g})"/><circle cx="32" cy="32" r="6" fill="url(#${g})"/>
|
||||
</g>`)
|
||||
}
|
||||
|
||||
const FOOD_ART = {
|
||||
lettuce: () => artLeafy('lettuce', '#c5e1a5', '#81c784', '#388e3c'),
|
||||
broccoli: () => artBroccoli('broccoli'),
|
||||
apple: () => artRound('apple', '#e53935', '#b71c1c', '#ef5350'),
|
||||
cucumber: () => artLong('cucumber', '#66bb6a', '#2e7d32', '#a5d6a7'),
|
||||
eggplant: () => artEggplant('eggplant'),
|
||||
tomato: () => artTomato('tomato'),
|
||||
pepper: () => artPepper('pepper', '#43a047', '#1b5e20'),
|
||||
onion: () => artOnion('onion'),
|
||||
pear: () => artRound('pear', '#c0ca33', '#827717', '#dce775', ''),
|
||||
orange: () => artRound('orange', '#fb8c00', '#e65100', '#ffb74d'),
|
||||
peach: () => artRound('peach', '#ff8a65', '#e64a19', '#ffab91'),
|
||||
cherry: () => artCherry('cherry'),
|
||||
grape: () => artGrapes('grape'),
|
||||
strawberry: () => artStrawberry('strawberry'),
|
||||
soybean: () => artBeans('soybean'),
|
||||
milk: () => artCup('milk', '#eceff1', '#b0bec5'),
|
||||
tea: () => artCup('tea', '#a1887f', '#6d4c41'),
|
||||
mushroom: () => artMushroom('mushroom'),
|
||||
celery: () => artCelery('celery'),
|
||||
greenBean: () => artGreenBean('greenBean'),
|
||||
brownRice: () => artRiceBowl('brownRice', '#bcaaa4', '#8d6e63', '#6d4c41'),
|
||||
sweetPotato: () => artRoot('sweetPotato', '#c75b39', '#8d2600', '#e57373'),
|
||||
taro: () => artRoot('taro', '#b39ddb', '#5e35b1', '#d1c4e9'),
|
||||
fish: () => artMeat('fish', '#4fc3f7', '#0277bd', 'fish'),
|
||||
chicken: () => artMeat('chicken', '#ffb74d', '#e65100', 'chicken'),
|
||||
beef: () => artMeat('beef', '#c0504d', '#7f0000', 'steak'),
|
||||
leanMeat: () => artMeat('leanMeat', '#b5654d', '#8d4000', 'steak'),
|
||||
shrimp: () => artSeafood('shrimp', 'shrimp'),
|
||||
crab: () => artSeafood('crab', 'crab'),
|
||||
banana: () => artLong('banana', '#fdd835', '#f9a825', '#fff176', 'banana'),
|
||||
mango: () => artRound('mango', '#ffb300', '#ff6f00', '#ffca28'),
|
||||
pineapple: () => artPineapple('pineapple'),
|
||||
kiwi: () => artKiwi('kiwi'),
|
||||
melon: () => artMelon('melon'),
|
||||
honey: () => artHoney('honey'),
|
||||
wine: () => artCup('wine', '#9b2c3b', '#4a0e16', 'wine'),
|
||||
beer: () => artCup('beer', '#ffb300', '#ff8f00'),
|
||||
coffee: () => artCup('coffee', '#6f4e37', '#3e2723'),
|
||||
corn: () => artLong('corn', '#f9c513', '#f57f17', '#fff176', 'corn'),
|
||||
udon: () => artNoodle('udon', '#d9b88f'),
|
||||
whiteRice: () => artRiceBowl('whiteRice', '#f5f5f5', '#eeeeee', '#bdbdbd'),
|
||||
whiteBread: () => artBread('whiteBread', '#d9a85c', '#a1887f', 'loaf'),
|
||||
mantou: () => artBread('mantou', '#ece0c8', '#bcaaa4', 'mantou'),
|
||||
youtiao: () => artBread('youtiao', '#d4943f', '#a1660a', 'loaf'),
|
||||
donut: () => artBread('donut', '#e87fb0', '#ad1457', 'donut'),
|
||||
popcorn: () => artPopcorn('popcorn'),
|
||||
ramen: () => artNoodle('ramen', '#e0a96d'),
|
||||
pumpkin: () => artPumpkin('pumpkin'),
|
||||
bakedPotato: () => artFries('bakedPotato'),
|
||||
watermelon: () => artWatermelon('watermelon'),
|
||||
sugar: () => artCandy('sugar', '#ff79b0', '#c2185b', 'lollipop'),
|
||||
maltose: () => artCandy('maltose', '#ffa726', '#e65100', 'wrap'),
|
||||
soda: () => artCup('soda', '#c62828', '#880e0e', 'bottle'),
|
||||
orangeJuice: () => artCup('orangeJuice', '#ff9f1c', '#e65100', 'bottle'),
|
||||
stickyRice: () => artDango('stickyRice')
|
||||
}
|
||||
|
||||
const imgCache = {}
|
||||
|
||||
/** 获取食材插画 data URL(带缓存) */
|
||||
export function getFoodImg(key) {
|
||||
if (imgCache[key]) return imgCache[key]
|
||||
const builder = FOOD_ART[key]
|
||||
if (!builder) return ''
|
||||
imgCache[key] = svgToDataUrl(builder())
|
||||
return imgCache[key]
|
||||
}
|
||||
|
||||
export function warmUpFoodImgs(keys) {
|
||||
keys.forEach((k) => getFoodImg(k))
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 食材图标 URL(Twemoji PNG,小程序 / H5 通用)
|
||||
* 微信小程序 <image> 不支持 SVG data URL,必须用 https PNG
|
||||
*/
|
||||
const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72'
|
||||
|
||||
/** emoji → Twemoji PNG 地址 */
|
||||
export function emojiToTwemojiUrl(emoji) {
|
||||
if (!emoji) return ''
|
||||
const parts = []
|
||||
for (let i = 0; i < emoji.length;) {
|
||||
const cp = emoji.codePointAt(i)
|
||||
if (!cp) break
|
||||
parts.push(cp.toString(16))
|
||||
i += cp > 0xffff ? 2 : 1
|
||||
}
|
||||
return `${TWEMOJI_BASE}/${parts.join('-')}.png`
|
||||
}
|
||||
|
||||
const imgCache = {}
|
||||
|
||||
export function getFoodImgUrl(emoji) {
|
||||
if (!emoji) return ''
|
||||
if (imgCache[emoji]) return imgCache[emoji]
|
||||
const url = emojiToTwemojiUrl(emoji)
|
||||
imgCache[emoji] = url
|
||||
return url
|
||||
}
|
||||
|
||||
/** 预加载本局食材图标(小程序提前拉取,减少首屏空白) */
|
||||
export function warmUpFoodImgs(foods) {
|
||||
const list = Array.isArray(foods) ? foods : []
|
||||
list.forEach((f) => {
|
||||
const icon = typeof f === 'string' ? f : f?.icon
|
||||
const url = getFoodImgUrl(icon)
|
||||
if (!url) return
|
||||
// #ifdef MP-WEIXIN
|
||||
try {
|
||||
uni.getImageInfo({ src: url, fail: () => {} })
|
||||
} catch (_) {}
|
||||
// #endif
|
||||
// #ifdef H5
|
||||
try {
|
||||
const img = new Image()
|
||||
img.src = url
|
||||
} catch (_) {}
|
||||
// #endif
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* 全量食材库:key 用于消除匹配,img 为 Twemoji PNG(小程序可用)
|
||||
*/
|
||||
import { getFoodImgUrl } from './gameFoodImg.js'
|
||||
|
||||
export const GI_LABEL = { low: '低血糖', mid: '中血糖', high: '高血糖' }
|
||||
|
||||
/** 若实际食用时的升糖幅度(教育提示用,正值=升糖) */
|
||||
export function getEatVal(food) {
|
||||
if (food.eatVal != null) return food.eatVal
|
||||
if (food.gi === 'high') return Math.abs(food.val)
|
||||
if (food.gi === 'mid') return Math.max(1, food.val)
|
||||
return Math.max(1, Math.abs(food.val))
|
||||
}
|
||||
|
||||
/** 从餐盘消除该食物后的血糖变化(负值=控糖有益) */
|
||||
export function getClearVal(food) {
|
||||
if (food.clearVal != null) return food.clearVal
|
||||
const eat = getEatVal(food)
|
||||
if (food.gi === 'high') return -Math.max(8, Math.round(eat * 0.5))
|
||||
if (food.gi === 'mid') return -1
|
||||
return 0
|
||||
}
|
||||
|
||||
/** 点击食材时的升糖说明文案 */
|
||||
export function formatEatLabel(food) {
|
||||
if (food.gi === 'low') return '升糖低'
|
||||
return `若食用 +${getEatVal(food)}`
|
||||
}
|
||||
|
||||
/** 含糖等级简称:低糖 / 中糖 / 高糖 */
|
||||
export const GI_SHORT = { low: '低糖', mid: '中糖', high: '高糖' }
|
||||
|
||||
/** 含糖等级对应的提示主色(绿 / 橙 / 红) */
|
||||
export const GI_COLOR = { low: '#16a34a', mid: '#ea580c', high: '#dc2626' }
|
||||
|
||||
/** 部分代表食物的专属科普文案(一句话,便于老人记忆) */
|
||||
const FOOD_TIP_OVERRIDE = {
|
||||
whiteRice: '精制主食,升糖快,建议小份',
|
||||
stickyRice: '糯米黏软,升糖很快,要少吃',
|
||||
whiteBread: '精制面食,升糖快',
|
||||
mantou: '白面馒头,升糖快,配菜一起吃更稳',
|
||||
youtiao: '油炸又高糖,少吃为好',
|
||||
sugar: '纯糖,升糖最快,尽量不吃',
|
||||
maltose: '糖分很高,升糖快',
|
||||
soda: '含糖饮料,升糖很快,不建议喝',
|
||||
orangeJuice: '果汁含糖高,吃整果更好',
|
||||
watermelon: '很甜,升糖快,一次别吃太多',
|
||||
donut: '又甜又油,升糖高',
|
||||
banana: '偏甜水果,适量吃',
|
||||
sweetPotato: '粗粮但有糖,可代替部分主食',
|
||||
corn: '粗粮主食,适量吃',
|
||||
honey: '本质是糖,别当健康品多吃',
|
||||
broccoli: '新鲜蔬菜,升糖很慢,可多吃',
|
||||
cucumber: '清爽蔬菜,几乎不升糖',
|
||||
lettuce: '绿叶菜,升糖很低,可多吃',
|
||||
celery: '高纤维蔬菜,升糖低',
|
||||
apple: '带皮整果,升糖较慢',
|
||||
milk: '低糖,含蛋白,适量喝好',
|
||||
fish: '优质蛋白,升糖低',
|
||||
beef: '蛋白为主,升糖低'
|
||||
}
|
||||
|
||||
/** 含糖等级通用科普文案 */
|
||||
const GI_TIP_DEFAULT = {
|
||||
low: '升糖慢,相对放心,可常吃',
|
||||
mid: '升糖中等,注意分量',
|
||||
high: '含糖高、升糖快,要少吃'
|
||||
}
|
||||
|
||||
/** 点击食物时展示的一句话科普(认知教育用) */
|
||||
export function getFoodTip(food) {
|
||||
if (!food) return ''
|
||||
if (food.tip) return food.tip
|
||||
if (FOOD_TIP_OVERRIDE[food.key]) return FOOD_TIP_OVERRIDE[food.key]
|
||||
return GI_TIP_DEFAULT[food.gi] || ''
|
||||
}
|
||||
|
||||
const RAW_FOODS = [
|
||||
// 低 GI
|
||||
{ key: 'lettuce', icon: '🥬', name: '生菜', gi: 'low', val: -3, color: '#8BC34A' },
|
||||
{ key: 'broccoli', icon: '🥦', name: '西兰花', gi: 'low', val: -3, color: '#4E8A3A' },
|
||||
{ key: 'apple', icon: '🍎', name: '苹果', gi: 'low', val: -2, color: '#E53935' },
|
||||
{ key: 'cucumber', icon: '🥒', name: '黄瓜', gi: 'low', val: -4, color: '#66A82F' },
|
||||
{ key: 'eggplant', icon: '🍆', name: '茄子', gi: 'low', val: -3, color: '#7E57C2' },
|
||||
{ key: 'tomato', icon: '🍅', name: '番茄', gi: 'low', val: -2, color: '#EF5350' },
|
||||
{ key: 'pepper', icon: '🫑', name: '青椒', gi: 'low', val: -3, color: '#43A047' },
|
||||
{ key: 'onion', icon: '🧅', name: '洋葱', gi: 'low', val: -2, color: '#CDA06B' },
|
||||
{ key: 'pear', icon: '🍐', name: '雪梨', gi: 'low', val: -2, color: '#C0CA33' },
|
||||
{ key: 'orange', icon: '🍊', name: '橙子', gi: 'low', val: -2, color: '#FB8C00' },
|
||||
{ key: 'peach', icon: '🍑', name: '桃子', gi: 'low', val: -2, color: '#FF8A65' },
|
||||
{ key: 'cherry', icon: '🍒', name: '樱桃', gi: 'low', val: -2, color: '#C2185B' },
|
||||
{ key: 'grape', icon: '🍇', name: '葡萄', gi: 'low', val: -1, color: '#8E24AA' },
|
||||
{ key: 'strawberry', icon: '🍓', name: '草莓', gi: 'low', val: -2, color: '#EC407A' },
|
||||
{ key: 'soybean', icon: '🫘', name: '黄豆', gi: 'low', val: -3, color: '#C9A063' },
|
||||
{ key: 'milk', icon: '🥛', name: '牛奶', gi: 'low', val: -2, color: '#B0BEC5' },
|
||||
{ key: 'tea', icon: '🍵', name: '红茶', gi: 'low', val: -1, color: '#A1573B' },
|
||||
{ key: 'mushroom', icon: '🍄', name: '香菇', gi: 'low', val: -3, color: '#8D6E63' },
|
||||
{ key: 'celery', icon: '🌿', name: '芹菜', gi: 'low', val: -4, color: '#7CB342' },
|
||||
{ key: 'greenBean', icon: '🫛', name: '四季豆', gi: 'low', val: -3, color: '#689F38' },
|
||||
|
||||
// 中 GI
|
||||
{ key: 'brownRice', icon: '🍙', name: '糙米饭', gi: 'mid', val: 4, color: '#C9A26B' },
|
||||
{ key: 'sweetPotato', icon: '🍠', name: '番薯', gi: 'mid', val: 5, color: '#C75B39' },
|
||||
{ key: 'taro', icon: '🥔', name: '芋头', gi: 'mid', val: 5, color: '#B39DDB' },
|
||||
{ key: 'fish', icon: '🐟', name: '鱼肉', gi: 'mid', val: 2, color: '#4FA3C7' },
|
||||
{ key: 'chicken', icon: '🍗', name: '鸡肉', gi: 'mid', val: 3, color: '#D6A15A' },
|
||||
{ key: 'beef', icon: '🥩', name: '牛肉', gi: 'mid', val: 3, color: '#C0504D' },
|
||||
{ key: 'leanMeat', icon: '🍖', name: '瘦肉', gi: 'mid', val: 3, color: '#B5654D' },
|
||||
{ key: 'shrimp', icon: '🦐', name: '虾仁', gi: 'mid', val: 2, color: '#FF7043' },
|
||||
{ key: 'crab', icon: '🦀', name: '螃蟹', gi: 'mid', val: 2, color: '#F4623A' },
|
||||
{ key: 'banana', icon: '🍌', name: '香蕉', gi: 'mid', val: 6, color: '#FDD835' },
|
||||
{ key: 'mango', icon: '🥭', name: '芒果', gi: 'mid', val: 6, color: '#FFB300' },
|
||||
{ key: 'pineapple', icon: '🍍', name: '菠萝', gi: 'mid', val: 6, color: '#FBC02D' },
|
||||
{ key: 'kiwi', icon: '🥝', name: '奇异果', gi: 'mid', val: 4, color: '#9CCC65' },
|
||||
{ key: 'melon', icon: '🍈', name: '哈密瓜', gi: 'mid', val: 6, color: '#AED581' },
|
||||
{ key: 'honey', icon: '🍯', name: '蜂蜜', gi: 'mid', val: 6, color: '#F9A825' },
|
||||
{ key: 'wine', icon: '🍷', name: '红酒', gi: 'mid', val: 4, color: '#9B2C3B' },
|
||||
{ key: 'beer', icon: '🍺', name: '啤酒', gi: 'mid', val: 5, color: '#E0A83E' },
|
||||
{ key: 'coffee', icon: '☕', name: '咖啡', gi: 'mid', val: 2, color: '#6F4E37' },
|
||||
{ key: 'corn', icon: '🌽', name: '玉米', gi: 'mid', val: 5, color: '#F9C513' },
|
||||
{ key: 'udon', icon: '🍲', name: '乌冬面', gi: 'mid', val: 5, color: '#D9B88F' },
|
||||
|
||||
// 高 GI
|
||||
{ key: 'whiteRice', icon: '🍚', name: '白米饭', gi: 'high', val: 18, color: '#E0E0E0' },
|
||||
{ key: 'whiteBread', icon: '🍞', name: '白面包', gi: 'high', val: 16, color: '#D9A85C' },
|
||||
{ key: 'mantou', icon: '🥯', name: '馒头', gi: 'high', val: 17, color: '#ECE0C8' },
|
||||
{ key: 'youtiao', icon: '🥖', name: '油条', gi: 'high', val: 20, color: '#D4943F' },
|
||||
{ key: 'donut', icon: '🍩', name: '甜甜圈', gi: 'high', val: 22, color: '#E87FB0' },
|
||||
{ key: 'popcorn', icon: '🍿', name: '爆米花', gi: 'high', val: 18, color: '#F5E6B3' },
|
||||
{ key: 'ramen', icon: '🍜', name: '拉面', gi: 'high', val: 17, color: '#E0A96D' },
|
||||
{ key: 'pumpkin', icon: '🎃', name: '南瓜', gi: 'high', val: 15, color: '#EF6C00' },
|
||||
{ key: 'bakedPotato', icon: '🍟', name: '焗薯', gi: 'high', val: 20, color: '#F2C14E' },
|
||||
{ key: 'watermelon', icon: '🍉', name: '西瓜', gi: 'high', val: 16, color: '#F0506A' },
|
||||
{ key: 'sugar', icon: '🍭', name: '砂糖', gi: 'high', val: 25, color: '#FF79B0' },
|
||||
{ key: 'maltose', icon: '🍬', name: '麦芽糖', gi: 'high', val: 24, color: '#FFA726' },
|
||||
{ key: 'soda', icon: '🥤', name: '汽水', gi: 'high', val: 25, color: '#C62828' },
|
||||
{ key: 'orangeJuice', icon: '🧃', name: '柳橙汁', gi: 'high', val: 22, color: '#FF9F1C' },
|
||||
{ key: 'stickyRice', icon: '🍡', name: '糯米饭', gi: 'high', val: 19, color: '#F2A7B8' }
|
||||
]
|
||||
|
||||
export const FOOD_LIBRARY = RAW_FOODS.map((f, i) => {
|
||||
const eatVal = getEatVal(f)
|
||||
const clearVal = getClearVal({ ...f, eatVal })
|
||||
return {
|
||||
id: i + 1,
|
||||
...f,
|
||||
eatVal,
|
||||
clearVal,
|
||||
giLabel: GI_LABEL[f.gi],
|
||||
get img() {
|
||||
return getFoodImgUrl(f.icon)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/** 按 key 查找食材(目标栏等用) */
|
||||
export function getFoodByKey(key) {
|
||||
return FOOD_LIBRARY.find((f) => f.key === key) || null
|
||||
}
|
||||
|
||||
/** 为棋盘 tile 使用的 plain 对象(img 预计算,避免 getter 在响应式里反复触发) */
|
||||
export function cloneFoodType(food) {
|
||||
return {
|
||||
id: food.id,
|
||||
key: food.key,
|
||||
icon: food.icon,
|
||||
name: food.name,
|
||||
gi: food.gi,
|
||||
giLabel: food.giLabel,
|
||||
val: food.val,
|
||||
color: food.color,
|
||||
img: getFoodImgUrl(food.icon)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 关卡配置:面向 45+ 的认知练习,节奏放慢、无失败惩罚。
|
||||
* 每组目标只比上一组略增,步数充裕(仅展示,不会因耗尽判负)。
|
||||
*/
|
||||
export function getLevelConfig(levelId) {
|
||||
const lv = Math.max(1, Math.min(999, Number(levelId) || 1))
|
||||
const tier = Math.floor((lv - 1) / 5)
|
||||
|
||||
return {
|
||||
id: lv,
|
||||
// 步数充裕:仅作展示,认全目标即可通关
|
||||
moves: Math.max(40, 60 - tier * 2),
|
||||
// 认识目标平缓增长,避免给老人压力
|
||||
goalTargets: [6 + tier, 8 + tier],
|
||||
scoreTarget: 2000 + lv * 300,
|
||||
startGlucose: 50,
|
||||
boosters: {
|
||||
insulin: 3,
|
||||
fiber: 1,
|
||||
meal: 5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 根据本局表现计算 1~3 星:完成即得星,步数越省星越多(不依赖血糖) */
|
||||
export function calcLevelStars({ movesLeft, levelConfig }) {
|
||||
const totalMoves = levelConfig?.moves || 1
|
||||
const ratio = totalMoves > 0 ? movesLeft / totalMoves : 0
|
||||
let stars = 1
|
||||
if (ratio >= 0.3) stars = 2
|
||||
if (ratio >= 0.55) stars = 3
|
||||
return stars
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# VitalMint Health (Stitch)
|
||||
|
||||
Source: Google Stitch MCP · project `Modern WeChat UI Redesign`
|
||||
Screen HTML: `design-system/stitch-weekly.html`
|
||||
|
||||
## Colors
|
||||
|
||||
- background / surface: `#f4fbf4`
|
||||
- primary: `#006c49`
|
||||
- primary-container: `#10b981`
|
||||
- tertiary-container (餐后): `#fc7c78`
|
||||
- on-surface: `#161d19`
|
||||
- error (high glucose): `#ba1a1a`
|
||||
- warning: `#ea580c`
|
||||
|
||||
## Layout (weekly.vue)
|
||||
|
||||
1. TopAppBar — 头像 + 问候 + 朗读按钮
|
||||
2. 录入今日血糖 — 全宽圆角主按钮
|
||||
3. 今日血糖 — 双列卡片 + 查看更多
|
||||
4. 血糖趋势 — 单卡片内含统计、图表、7/30 切换
|
||||
5. AI 饮食 — 渐变边框白底卡片
|
||||
6. 能不能吃 — 独立卡片
|
||||
|
||||
## Styles
|
||||
|
||||
- `styles/stitch-vitalmint-theme.scss` — 设计 token
|
||||
- `styles/weekly-stitch.scss` — 页面组件样式 (vm-*)
|
||||
@@ -0,0 +1,398 @@
|
||||
<!DOCTYPE html><html lang="zh-CN" style=""><head>
|
||||
<meta charset="utf-8">
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||
<title>血糖管理</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"on-error-container": "#93000a",
|
||||
"on-secondary-fixed-variant": "#3e4943",
|
||||
"on-background": "#161d19",
|
||||
"error-container": "#ffdad6",
|
||||
"surface-container-highest": "#dde4dd",
|
||||
"surface-container-high": "#e3eae3",
|
||||
"primary-fixed-dim": "#4edea3",
|
||||
"on-secondary-container": "#5b6760",
|
||||
"on-primary": "#ffffff",
|
||||
"inverse-surface": "#2b322d",
|
||||
"tertiary-fixed-dim": "#ffb3af",
|
||||
"primary": "#006c49",
|
||||
"surface-container-lowest": "#ffffff",
|
||||
"on-primary-fixed-variant": "#005236",
|
||||
"on-surface-variant": "#3c4a42",
|
||||
"surface-container": "#e8f0e9",
|
||||
"tertiary-container": "#fc7c78",
|
||||
"on-tertiary": "#ffffff",
|
||||
"on-tertiary-container": "#711419",
|
||||
"surface-container-low": "#eef6ee",
|
||||
"on-secondary-fixed": "#131e19",
|
||||
"inverse-primary": "#4edea3",
|
||||
"surface-tint": "#006c49",
|
||||
"secondary-fixed-dim": "#bdcac1",
|
||||
"on-primary-container": "#00422b",
|
||||
"on-secondary": "#ffffff",
|
||||
"secondary-container": "#d9e6dd",
|
||||
"tertiary": "#a43a3a",
|
||||
"on-error": "#ffffff",
|
||||
"secondary-fixed": "#d9e6dd",
|
||||
"background": "#f4fbf4",
|
||||
"tertiary-fixed": "#ffdad7",
|
||||
"outline": "#6c7a71",
|
||||
"primary-fixed": "#6ffbbe",
|
||||
"on-tertiary-fixed": "#410005",
|
||||
"outline-variant": "#bbcabf",
|
||||
"primary-container": "#10b981",
|
||||
"on-tertiary-fixed-variant": "#842225",
|
||||
"surface-variant": "#dde4dd",
|
||||
"on-primary-fixed": "#002113",
|
||||
"on-surface": "#161d19",
|
||||
"surface-bright": "#f4fbf4",
|
||||
"inverse-on-surface": "#ebf3eb",
|
||||
"surface": "#f4fbf4",
|
||||
"surface-dim": "#d4dcd5",
|
||||
"secondary": "#55615a",
|
||||
"error": "#ba1a1a"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.25rem",
|
||||
"lg": "0.5rem",
|
||||
"xl": "0.75rem",
|
||||
"full": "9999px",
|
||||
"2xl": "1.5rem",
|
||||
"3xl": "2rem"
|
||||
},
|
||||
"spacing": {
|
||||
"container-margin": "20px",
|
||||
"stack-gap": "16px",
|
||||
"section-margin": "32px",
|
||||
"inline-gap": "12px",
|
||||
"card-padding": "20px"
|
||||
},
|
||||
"fontFamily": {
|
||||
"body-lg": [
|
||||
"Manrope"
|
||||
],
|
||||
"headline-lg": [
|
||||
"Manrope"
|
||||
],
|
||||
"headline-md": [
|
||||
"Manrope"
|
||||
],
|
||||
"display-lg": [
|
||||
"Manrope"
|
||||
],
|
||||
"headline-lg-mobile": [
|
||||
"Manrope"
|
||||
],
|
||||
"label-md": [
|
||||
"Manrope"
|
||||
],
|
||||
"body-md": [
|
||||
"Manrope"
|
||||
]
|
||||
},
|
||||
"fontSize": {
|
||||
"body-lg": [
|
||||
"16px",
|
||||
{
|
||||
"lineHeight": "24px",
|
||||
"fontWeight": "500"
|
||||
}
|
||||
],
|
||||
"headline-lg": [
|
||||
"24px",
|
||||
{
|
||||
"lineHeight": "32px",
|
||||
"letterSpacing": "-0.01em",
|
||||
"fontWeight": "700"
|
||||
}
|
||||
],
|
||||
"headline-md": [
|
||||
"20px",
|
||||
{
|
||||
"lineHeight": "28px",
|
||||
"fontWeight": "700"
|
||||
}
|
||||
],
|
||||
"display-lg": [
|
||||
"32px",
|
||||
{
|
||||
"lineHeight": "40px",
|
||||
"letterSpacing": "-0.02em",
|
||||
"fontWeight": "800"
|
||||
}
|
||||
],
|
||||
"headline-lg-mobile": [
|
||||
"22px",
|
||||
{
|
||||
"lineHeight": "28px",
|
||||
"fontWeight": "700"
|
||||
}
|
||||
],
|
||||
"label-md": [
|
||||
"12px",
|
||||
{
|
||||
"lineHeight": "16px",
|
||||
"letterSpacing": "0.02em",
|
||||
"fontWeight": "600"
|
||||
}
|
||||
],
|
||||
"body-md": [
|
||||
"14px",
|
||||
{
|
||||
"lineHeight": "20px",
|
||||
"fontWeight": "400"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
.icon-fill {
|
||||
font-variation-settings: 'FILL' 1;
|
||||
}
|
||||
|
||||
.ambient-shadow {
|
||||
box-shadow: 0 12px 30px -10px rgba(0, 108, 73, 0.08), 0 4px 10px -4px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.btn-press:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-background text-on-surface font-body-lg antialiased pb-24 md:pb-0 min-h-screen">
|
||||
<!-- Desktop Sidebar Shell (Hidden on Mobile) -->
|
||||
<div class="hidden md:flex fixed left-0 top-0 h-full w-64 bg-surface-container-lowest border-r border-surface-dim z-50 flex-col">
|
||||
<div class="p-6">
|
||||
<h1 class="text-headline-lg font-headline-lg text-primary">VitalMint</h1>
|
||||
</div>
|
||||
<nav class="flex-1 px-4 flex flex-col gap-2 mt-4">
|
||||
<a class="flex items-center gap-3 px-4 py-3 rounded-xl bg-primary-container text-on-primary-container font-bold" href="#">
|
||||
<span class="material-symbols-outlined icon-fill">home</span>
|
||||
<span class="">首页</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-3 rounded-xl text-on-surface-variant hover:bg-surface-container-high transition-colors" href="#">
|
||||
<span class="material-symbols-outlined">add_circle</span>
|
||||
<span class="">记录</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-3 rounded-xl text-on-surface-variant hover:bg-surface-container-high transition-colors" href="#">
|
||||
<span class="material-symbols-outlined">analytics</span>
|
||||
<span class="">动态</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-3 rounded-xl text-on-surface-variant hover:bg-surface-container-high transition-colors" href="#">
|
||||
<span class="material-symbols-outlined">person</span>
|
||||
<span class="">我的</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
<!-- Main Content Area -->
|
||||
<main class="md:ml-64 w-full max-w-[1200px] mx-auto min-h-screen flex flex-col md:flex-row">
|
||||
<!-- Left/Main Column: Dashboard -->
|
||||
<div class="flex-1 w-full">
|
||||
<!-- TopAppBar -->
|
||||
<header class="bg-surface dark:bg-surface-dim docked full-width top-0 flex justify-between items-center px-container-margin py-4 w-full sticky z-40">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-primary-container flex items-center justify-center overflow-hidden border-2 border-surface-container-lowest">
|
||||
<span class="material-symbols-outlined text-primary">person</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-headline-lg-mobile font-headline-lg-mobile text-on-surface dark:text-on-background">下午好,用户16295733</h1>
|
||||
<p class="text-label-md font-label-md text-on-surface-variant opacity-80">今日血糖已记录</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="w-10 h-10 rounded-full flex items-center justify-center hover:bg-surface-container dark:hover:bg-surface-container-high transition-colors text-primary dark:text-primary-fixed-dim">
|
||||
<span class="material-symbols-outlined" data-icon="volume_up">volume_up</span>
|
||||
</button>
|
||||
</header>
|
||||
<!-- Content Canvas -->
|
||||
<div class="px-container-margin pb-section-margin flex flex-col gap-6 mt-2">
|
||||
<!-- 1. Primary Action (Prominent) -->
|
||||
<section>
|
||||
<button class="w-full bg-primary text-on-primary h-14 rounded-2xl flex items-center justify-center gap-2 text-body-lg font-bold btn-press shadow-[0_8px_16px_rgba(0,108,73,0.2)] transition-all">
|
||||
<span class="material-symbols-outlined icon-fill">add</span>
|
||||
录入今日血糖
|
||||
</button>
|
||||
</section>
|
||||
<!-- 2. 血糖趋势 (Trends) - Moved to top -->
|
||||
<section class="bg-surface-container-lowest rounded-3xl p-5 ambient-shadow border border-surface-container-highest/30">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 class="text-headline-md font-headline-md text-on-surface">血糖趋势</h2>
|
||||
<p class="text-label-md font-label-md text-on-surface-variant">最近 7 天 · mmol/L</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-label-md font-label-md text-on-surface-variant">
|
||||
<div class="flex items-center gap-1"><div class="w-2 h-2 rounded-full bg-primary"></div>空腹</div>
|
||||
<div class="flex items-center gap-1"><div class="w-2 h-2 rounded-full bg-tertiary"></div>餐后</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Stats Row -->
|
||||
<div class="flex justify-between items-center bg-surface-container-low rounded-xl p-4 mb-6">
|
||||
<div class="flex flex-col items-center flex-1 border-r border-surface-container-highest">
|
||||
<span class="text-headline-md font-headline-md text-tertiary">1</span>
|
||||
<span class="text-label-md font-label-md text-on-surface-variant mt-1">天偏高</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center flex-1 border-r border-surface-container-highest">
|
||||
<span class="text-headline-md font-headline-md text-primary">1</span>
|
||||
<span class="text-label-md font-label-md text-on-surface-variant mt-1">天正常</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center flex-1">
|
||||
<span class="text-headline-md font-headline-md text-on-surface">2</span>
|
||||
<span class="text-label-md font-label-md text-on-surface-variant mt-1">天有记录</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Faux Chart Area -->
|
||||
<div class="h-[180px] w-full relative flex items-end pt-4 pb-6 border-b border-surface-container-highest border-dashed">
|
||||
<!-- Y Axis -->
|
||||
<div class="absolute left-0 top-0 bottom-6 flex flex-col justify-between text-[10px] text-on-surface-variant">
|
||||
<span class="">23.0</span>
|
||||
<span class="">18.0</span>
|
||||
<span class="">13.0</span>
|
||||
<span class="">8.0</span>
|
||||
<span class="">3.0</span>
|
||||
</div>
|
||||
<!-- Target Line -->
|
||||
<div class="absolute left-6 right-0 bottom-12 border-b border-tertiary border-dashed opacity-30"></div>
|
||||
<span class="absolute left-6 bottom-12 text-[10px] text-tertiary -translate-y-full mb-1">空腹阈 7</span>
|
||||
<!-- Chart Lines -->
|
||||
<div class="absolute inset-0 left-8 overflow-hidden">
|
||||
<div class="absolute bottom-6 right-8 w-1 h-[100px] bg-primary/20 rounded-t-full transform rotate-45 origin-bottom"></div>
|
||||
<div class="absolute bottom-[80px] right-2 w-1 h-[60px] bg-tertiary/20 rounded-t-full transform rotate-12 origin-bottom"></div>
|
||||
<div class="absolute right-[15%] bottom-[15%] w-2 h-2 rounded-full border-[1.5px] border-primary bg-white z-10"></div>
|
||||
<div class="absolute right-[5%] bottom-[75%] w-2 h-2 rounded-full border-[1.5px] border-tertiary bg-white z-10"></div>
|
||||
<!-- Tooltips -->
|
||||
<div class="absolute right-[2%] bottom-[85%] bg-tertiary text-white text-[10px] px-1.5 py-0.5 rounded font-bold">18</div>
|
||||
<div class="absolute right-[2%] bottom-[65%] bg-tertiary text-white text-[10px] px-1.5 py-0.5 rounded font-bold">20</div>
|
||||
</div>
|
||||
<!-- X Axis -->
|
||||
<div class="absolute left-8 right-0 bottom-0 flex justify-between text-[10px] text-on-surface-variant translate-y-full pt-2">
|
||||
<span class="">05-23</span>
|
||||
<span class="">05-25</span>
|
||||
<span class="">05-27</span>
|
||||
<span class="">05-29</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- 3. 最新测量 & 概览 (Combined Section) -->
|
||||
<section class="flex flex-col gap-4">
|
||||
<!-- Latest Reading -->
|
||||
<div class="bg-error-container text-on-error-container rounded-3xl p-6 ambient-shadow relative overflow-hidden flex flex-col items-center text-center justify-center min-h-[160px]">
|
||||
<div class="absolute inset-0 opacity-10 pointer-events-none" style="background-image: radial-gradient(circle at 2px 2px, currentColor 1px, transparent 0); background-size: 16px 16px;"></div>
|
||||
<span class="text-label-md font-label-md uppercase tracking-wider mb-1 opacity-90 z-10">最新测量 · 餐后</span>
|
||||
<div class="flex items-baseline gap-1 z-10">
|
||||
<span class="text-[56px] font-extrabold leading-none tracking-tighter">20.00</span>
|
||||
<span class="text-body-lg font-body-lg opacity-80 font-medium">mmol/L</span>
|
||||
</div>
|
||||
<div class="mt-3 inline-flex items-center gap-1.5 bg-white/30 backdrop-blur-sm px-3 py-1 rounded-full z-10">
|
||||
<span class="material-symbols-outlined text-[16px] icon-fill">warning</span>
|
||||
<span class="text-label-md font-label-md font-bold">偏高,请遵医嘱</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Today's Summary -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="bg-surface-container-lowest rounded-2xl p-4 ambient-shadow flex flex-col gap-2 border border-surface-container-highest/50">
|
||||
<span class="text-label-md font-label-md text-on-surface-variant">空腹</span>
|
||||
<div class="flex items-baseline gap-1 text-tertiary">
|
||||
<span class="text-headline-lg font-headline-lg">18.00</span>
|
||||
</div>
|
||||
<span class="text-[10px] text-tertiary bg-error-container/50 px-2 py-0.5 rounded-sm inline-block w-fit">偏高</span>
|
||||
</div>
|
||||
<div class="bg-surface-container-lowest rounded-2xl p-4 ambient-shadow flex flex-col gap-2 border border-surface-container-highest/50">
|
||||
<span class="text-label-md font-label-md text-on-surface-variant">餐后</span>
|
||||
<div class="flex items-baseline gap-1 text-tertiary">
|
||||
<span class="text-headline-lg font-headline-lg">20.00</span>
|
||||
</div>
|
||||
<span class="text-[10px] text-tertiary bg-error-container/50 px-2 py-0.5 rounded-sm inline-block w-fit">偏高</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- 4. AI 饮食建议 -->
|
||||
<section class="bg-gradient-to-br from-primary/10 to-primary/5 rounded-3xl p-5 border border-primary/10 relative overflow-hidden">
|
||||
<div class="absolute -right-8 -top-8 w-32 h-32 bg-primary-fixed-dim/20 rounded-full blur-2xl"></div>
|
||||
<div class="flex justify-between items-center mb-4 relative z-10">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 rounded-full bg-primary text-white flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-[18px]">temp_preferences_custom</span>
|
||||
</div>
|
||||
<h3 class="text-headline-md font-headline-md text-on-surface">AI 饮食建议</h3>
|
||||
</div>
|
||||
<button class="text-label-md font-label-md text-primary flex items-center gap-1 bg-white/50 px-3 py-1.5 rounded-full hover:bg-white transition-colors">
|
||||
<span class="material-symbols-outlined text-[14px]">refresh</span>
|
||||
换一换
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-body-md font-body-md text-on-surface-variant mb-4 relative z-10">读您近 7 天 · 30 天血糖,定制今日三餐</p>
|
||||
<div class="flex flex-col gap-3 relative z-10">
|
||||
<div class="bg-white/80 backdrop-blur-md rounded-2xl p-4 shadow-sm border border-white">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="material-symbols-outlined text-orange-400">light_mode</span>
|
||||
<span class="font-bold text-on-surface">早餐</span>
|
||||
</div>
|
||||
<p class="text-body-md font-body-md text-on-surface-variant">小米粥(少米多水)加煮鸡蛋一个</p>
|
||||
</div>
|
||||
<div class="bg-white/80 backdrop-blur-md rounded-2xl p-4 shadow-sm border border-white">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="material-symbols-outlined text-primary">eco</span>
|
||||
<span class="font-bold text-on-surface">午餐</span>
|
||||
</div>
|
||||
<p class="text-body-md font-body-md text-on-surface-variant">清蒸鱼块、蒜蓉炒苋菜、二米饭(小米掺大米)</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Spacer for bottom nav on mobile -->
|
||||
<section class="sticky bottom-20 md:bottom-6 z-30 px-container-margin md:px-0 mb-4">
|
||||
<div class="bg-white/90 backdrop-blur-md rounded-full p-2 pl-5 flex items-center gap-3 shadow-[0_8px_32px_rgba(0,108,73,0.15)] border border-primary/10">
|
||||
<div class="w-8 h-8 rounded-full bg-primary/10 text-primary flex items-center justify-center">
|
||||
<span class="material-symbols-outlined icon-fill text-[18px]">sparkles</span>
|
||||
</div>
|
||||
<input type="text" placeholder="输入食物名称或健康疑问,咨询 AI..." class="flex-1 bg-transparent border-none focus:ring-0 text-body-md placeholder:text-on-surface-variant/60 py-2">
|
||||
<button class="bg-primary text-on-primary h-10 w-10 rounded-full flex items-center justify-center btn-press shadow-sm">
|
||||
<span class="material-symbols-outlined">send</span>
|
||||
</button>
|
||||
</div>
|
||||
</section><div class="h-8 md:hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right Column: Secondary Info (Visible on Tablet/Desktop) -->
|
||||
<aside class="hidden lg:flex w-80 flex-col gap-section-margin pt-4 pr-container-margin">
|
||||
<div class="bg-surface-container-lowest rounded-3xl p-6 ambient-shadow">
|
||||
<h3 class="text-headline-md font-headline-md text-on-surface mb-4">运动降糖</h3>
|
||||
<div class="bg-error-container/30 rounded-xl p-4 mb-4">
|
||||
<p class="text-body-md font-body-md text-on-surface">近期血糖偏高,三顿饭后都动一动,最能帮着把糖降下来。</p>
|
||||
</div>
|
||||
<ul class="flex flex-col gap-3 text-body-md text-on-surface-variant">
|
||||
<li class="flex gap-2"><span class="font-bold text-tertiary">1</span> <span class="">早饭后快走 15 分钟</span></li>
|
||||
<li class="flex gap-2"><span class="font-bold text-tertiary">2</span> <span class="">午饭后原地踏步 20-30 分钟</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
<!-- BottomNavBar (Mobile Only) -->
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</body></html>
|
||||
@@ -0,0 +1,596 @@
|
||||
<!DOCTYPE html><html class="light" lang="zh-CN"><head>
|
||||
<meta charset="utf-8">
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||
<title>Daily Care - Health Dashboard</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet">
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"secondary-fixed": "#d9e6dd",
|
||||
"surface-container": "#e8f0e9",
|
||||
"surface-dim": "#d4dcd5",
|
||||
"surface-variant": "#dde4dd",
|
||||
"error-container": "#ffdad6",
|
||||
"inverse-surface": "#2b322d",
|
||||
"secondary": "#55615a",
|
||||
"primary": "#006c49",
|
||||
"on-secondary-fixed": "#131e19",
|
||||
"surface-bright": "#f4fbf4",
|
||||
"on-tertiary-container": "#711419",
|
||||
"on-primary-fixed-variant": "#005236",
|
||||
"outline-variant": "#bbcabf",
|
||||
"primary-container": "#10b981",
|
||||
"surface": "#f4fbf4",
|
||||
"on-tertiary-fixed-variant": "#842225",
|
||||
"tertiary-fixed-dim": "#ffb3af",
|
||||
"tertiary-fixed": "#ffdad7",
|
||||
"error": "#ba1a1a",
|
||||
"surface-container-lowest": "#ffffff",
|
||||
"surface-container-highest": "#dde4dd",
|
||||
"primary-fixed": "#6ffbbe",
|
||||
"on-primary-container": "#00422b",
|
||||
"secondary-fixed-dim": "#bdcac1",
|
||||
"on-tertiary": "#ffffff",
|
||||
"on-primary-fixed": "#002113",
|
||||
"on-error-container": "#93000a",
|
||||
"tertiary-container": "#fc7c78",
|
||||
"on-error": "#ffffff",
|
||||
"inverse-primary": "#4edea3",
|
||||
"inverse-on-surface": "#ebf3eb",
|
||||
"on-secondary-container": "#5b6760",
|
||||
"surface-container-high": "#e3eae3",
|
||||
"surface-container-low": "#eef6ee",
|
||||
"on-secondary-fixed-variant": "#3e4943",
|
||||
"outline": "#6c7a71",
|
||||
"secondary-container": "#d9e6dd",
|
||||
"on-primary": "#ffffff",
|
||||
"on-surface": "#161d19",
|
||||
"on-background": "#161d19",
|
||||
"tertiary": "#a43a3a",
|
||||
"primary-fixed-dim": "#4edea3",
|
||||
"on-secondary": "#ffffff",
|
||||
"background": "#f4fbf4",
|
||||
"on-surface-variant": "#3c4a42",
|
||||
"surface-tint": "#006c49",
|
||||
"on-tertiary-fixed": "#410005"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.25rem",
|
||||
"lg": "0.5rem",
|
||||
"xl": "0.75rem",
|
||||
"2xl": "1rem",
|
||||
"3xl": "1.5rem",
|
||||
"full": "9999px"
|
||||
},
|
||||
"spacing": {
|
||||
"card-padding": "20px",
|
||||
"section-margin": "32px",
|
||||
"inline-gap": "12px",
|
||||
"stack-gap": "16px",
|
||||
"container-margin": "20px"
|
||||
},
|
||||
"fontFamily": {
|
||||
"headline-md": ["Manrope"],
|
||||
"headline-lg-mobile": ["Manrope"],
|
||||
"body-lg": ["Manrope"],
|
||||
"headline-lg": ["Manrope"],
|
||||
"display-lg": ["Manrope"],
|
||||
"body-md": ["Manrope"],
|
||||
"label-md": ["Manrope"]
|
||||
},
|
||||
"fontSize": {
|
||||
"headline-md": ["20px", {"lineHeight": "28px", "fontWeight": "700"}],
|
||||
"headline-lg-mobile": ["22px", {"lineHeight": "28px", "fontWeight": "700"}],
|
||||
"body-lg": ["16px", {"lineHeight": "24px", "fontWeight": "500"}],
|
||||
"headline-lg": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
|
||||
"display-lg": ["32px", {"lineHeight": "40px", "letterSpacing": "-0.02em", "fontWeight": "800"}],
|
||||
"body-md": ["14px", {"lineHeight": "20px", "fontWeight": "400"}],
|
||||
"label-md": ["12px", {"lineHeight": "16px", "letterSpacing": "0.02em", "fontWeight": "600"}]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body { font-family: 'Manrope', sans-serif; -webkit-tap-highlight-color: transparent; }
|
||||
.material-symbols-outlined { font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; display: inline-block; vertical-align: middle; }
|
||||
.ambient-shadow { box-shadow: 0 20px 30px -10px rgba(0, 108, 73, 0.08); }
|
||||
.active-scale:active { transform: scale(0.98); transition: all 0.2s ease; }
|
||||
.hide-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
|
||||
/* Watering Animation Styles */
|
||||
@keyframes fall {
|
||||
0% { transform: translateY(-20px); opacity: 0; }
|
||||
50% { opacity: 1; }
|
||||
100% { transform: translateY(40px); opacity: 0; }
|
||||
}
|
||||
.droplet {
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
height: 10px;
|
||||
background: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: fall 0.6s linear infinite;
|
||||
}
|
||||
@keyframes pulse-growth {
|
||||
0% { transform: scale(1); filter: drop-shadow(0 0 0px rgba(78, 222, 163, 0)); }
|
||||
50% { transform: scale(1.15); filter: drop-shadow(0 0 15px rgba(78, 222, 163, 0.6)); }
|
||||
100% { transform: scale(1); filter: drop-shadow(0 0 0px rgba(78, 222, 163, 0)); }
|
||||
}
|
||||
.growth-pulse {
|
||||
animation: pulse-growth 0.8s ease-out;
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { opacity: 0; transform: scale(0) rotate(0deg); }
|
||||
50% { opacity: 1; transform: scale(1.2) rotate(180deg); }
|
||||
100% { opacity: 0; transform: scale(0) rotate(360deg); }
|
||||
}
|
||||
.shimmer-particle {
|
||||
position: absolute;
|
||||
color: #4edea3;
|
||||
font-size: 14px;
|
||||
pointer-events: none;
|
||||
animation: shimmer 1s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Blood Sugar Decrease Animation */
|
||||
@keyframes float-up-fade {
|
||||
0% { transform: translateY(20px); opacity: 0; }
|
||||
20% { opacity: 1; }
|
||||
80% { opacity: 1; }
|
||||
100% { transform: translateY(-100px); opacity: 0; }
|
||||
}
|
||||
.sugar-decrease-text {
|
||||
position: fixed;
|
||||
color: #10b981;
|
||||
font-weight: 800;
|
||||
font-size: 24px;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
text-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
|
||||
animation: float-up-fade 2s ease-out forwards;
|
||||
}
|
||||
@keyframes fall-arrow {
|
||||
0% { transform: translateY(-50vh) scale(0.5); opacity: 0; }
|
||||
50% { opacity: 0.8; }
|
||||
100% { transform: translateY(110vh) scale(1.2); opacity: 0; }
|
||||
}
|
||||
.sugar-particle {
|
||||
position: fixed;
|
||||
color: #10b981;
|
||||
pointer-events: none;
|
||||
z-index: 90;
|
||||
animation: fall-arrow 1.5s linear forwards;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-background text-on-background min-h-screen pb-24">
|
||||
<!-- Top App Bar -->
|
||||
<header class="bg-primary docked full-width top-0 h-48 flex items-end pb-6 px-5 fixed left-0 right-0 z-40">
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full border-2 border-on-primary overflow-hidden">
|
||||
<img class="w-full h-full object-cover" data-alt="A professional studio portrait" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCXIPbeivladmw0-SmLno2vkwVvsObW2D5HUtodItlBnxdhFF8TvZdQYrTt14QCk3n5VXXIpdk3cuWTJHXJgDXd_clQyjLsb477e5h2313zh76yRVns01Q5pLpM-2_RbheRcUioLvObwJtIPJbkDcbkNA61-LyBnbStRcPoh6YOvNHSHjUUR_nDJpPm18ga87ggphbFWF-pt7lRF027swmyitUeVF7HXQDfcG7Mq7E0pqsbhyA7jKxtzmbN-HLh7GIrhc6LOAUaXiX9">
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-on-primary font-bold text-headline-lg-mobile leading-tight">Daily Care</h1>
|
||||
<p class="text-on-primary/80 text-body-md">吕帅</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="bg-on-primary/10 hover:bg-on-primary/20 text-on-primary rounded-full px-4 py-1.5 flex items-center gap-2 active-scale transition-all">
|
||||
<span class="material-symbols-outlined text-[18px]" style="font-variation-settings: 'FILL' 1;">bloodtype</span>
|
||||
<span class="text-label-md">血糖</span>
|
||||
</button>
|
||||
<button class="bg-on-primary/10 hover:bg-on-primary/20 text-on-primary w-10 h-10 rounded-full flex items-center justify-center active-scale transition-all">
|
||||
<span class="material-symbols-outlined text-[20px]">refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="pt-52 px-container-margin space-y-6">
|
||||
<!-- Toggle Switch -->
|
||||
<div class="bg-surface-container-high rounded-full p-1 flex items-center">
|
||||
<button class="flex-1 bg-primary text-on-primary rounded-full py-2 text-label-md font-bold shadow-md transition-all">最近 7 天</button>
|
||||
<button class="flex-1 text-on-surface-variant py-2 text-label-md font-bold transition-all">最近 30 天</button>
|
||||
</div>
|
||||
<!-- Health Calendar -->
|
||||
<section class="bg-surface-container-lowest rounded-3xl p-card-padding ambient-shadow">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h2 class="text-headline-md font-headline-md text-on-surface">健康日历</h2>
|
||||
<p class="text-body-md text-outline">2026年5月 · 已记录 3 / 7 天</p>
|
||||
</div>
|
||||
<button class="text-primary"><span class="material-symbols-outlined">chevron_right</span></button>
|
||||
</div>
|
||||
<div class="grid grid-cols-7 gap-1 text-center mb-4">
|
||||
<span class="text-label-md text-outline">一</span>
|
||||
<span class="text-label-md text-outline">二</span>
|
||||
<span class="text-label-md text-outline">三</span>
|
||||
<span class="text-label-md text-outline">四</span>
|
||||
<span class="text-label-md text-outline">五</span>
|
||||
<span class="text-label-md text-outline">六</span>
|
||||
<span class="text-label-md text-tertiary">日</span>
|
||||
<span class="py-2 text-body-md text-outline/30">18</span>
|
||||
<span class="py-2 text-body-md text-outline/30">19</span>
|
||||
<span class="py-2 text-body-md text-outline/30">20</span>
|
||||
<span class="py-2 text-body-md text-outline/30">21</span>
|
||||
<span class="py-2 text-body-md text-outline/30">22</span>
|
||||
<div class="py-2 relative"><span class="text-body-md font-bold text-on-surface">23</span></div>
|
||||
<div class="py-2 relative bg-primary-container/10 rounded-lg">
|
||||
<span class="text-body-md font-bold text-primary">24</span>
|
||||
<span class="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 bg-primary rounded-full"></span>
|
||||
</div>
|
||||
<div class="py-2 relative bg-primary-container/10 rounded-lg">
|
||||
<span class="text-body-md font-bold text-primary">25</span>
|
||||
<span class="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 bg-primary rounded-full"></span>
|
||||
</div>
|
||||
<div class="py-2 relative bg-primary-container/10 rounded-lg">
|
||||
<span class="text-body-md font-bold text-primary">26</span>
|
||||
<span class="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 bg-primary rounded-full"></span>
|
||||
</div>
|
||||
<span class="py-2 text-body-md text-on-surface">27</span>
|
||||
<span class="py-2 text-body-md text-on-surface">28</span>
|
||||
<div class="py-2 relative bg-tertiary-container rounded-lg">
|
||||
<span class="text-body-md font-bold text-on-tertiary">29</span>
|
||||
</div>
|
||||
<span class="py-2 text-body-md text-outline/40">30</span>
|
||||
<span class="py-2 text-body-md text-outline/40">31</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between border-t border-outline-variant/30 pt-4 mt-2">
|
||||
<span class="text-body-md text-outline">今日还没有记录</span>
|
||||
<button class="bg-primary text-on-primary px-6 py-3 rounded-2xl text-label-md font-bold active-scale transition-all flex items-center gap-2">去记血糖</button>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Blood Sugar Details Section -->
|
||||
<section class="space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-headline-md font-headline-md text-on-surface">血糖明细</h3>
|
||||
<span class="text-body-md text-outline">共 3 天</span>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="bg-surface-container-lowest rounded-3xl p-card-padding ambient-shadow">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<span class="text-headline-md font-bold">05-26</span>
|
||||
<span class="text-body-md text-outline">周二</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="bg-background/50 border border-outline-variant/20 rounded-2xl p-4 flex justify-between items-center">
|
||||
<span class="text-body-md text-outline">空腹</span>
|
||||
<div class="text-right">
|
||||
<span class="text-headline-lg font-extrabold text-error">10.4</span>
|
||||
<span class="text-label-md text-outline ml-1">mmol/L</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-background/50 border border-outline-variant/20 rounded-2xl p-4 flex justify-between items-center">
|
||||
<span class="text-body-md text-outline">餐后</span>
|
||||
<span class="text-headline-md font-bold text-outline-variant">—— <small class="text-label-md">mmol/L</small></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-surface-container-lowest rounded-3xl p-card-padding ambient-shadow">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<span class="text-headline-md font-bold">05-25</span>
|
||||
<span class="text-body-md text-outline">周一</span>
|
||||
</div>
|
||||
<div class="bg-background/50 border border-outline-variant/20 rounded-2xl p-4 flex justify-between items-center">
|
||||
<span class="text-body-md text-outline">空腹</span>
|
||||
<div class="text-right">
|
||||
<span class="text-headline-lg font-extrabold text-error">10.2</span>
|
||||
<span class="text-label-md text-outline ml-1">mmol/L</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Reward Section: 稳糖乐园 -->
|
||||
<section class="bg-surface-container-lowest rounded-3xl p-card-padding ambient-shadow overflow-hidden relative" id="sugar-paradise">
|
||||
<div class="flex justify-between items-start mb-6">
|
||||
<div>
|
||||
<h3 class="text-headline-md font-headline-md text-on-surface">稳糖乐园</h3>
|
||||
<p class="text-body-md text-outline">用健康打卡浇灌你的稳糖树</p>
|
||||
</div>
|
||||
<div class="bg-primary/10 rounded-xl px-3 py-1 text-center">
|
||||
<span class="block text-headline-lg font-extrabold text-primary" id="total-points">110</span>
|
||||
<span class="text-[10px] text-primary/70 font-bold">稳糖积分</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mini Quick Actions -->
|
||||
<div class="grid grid-cols-4 gap-2 mb-8">
|
||||
<button class="bg-surface-container-low p-3 rounded-2xl flex flex-col items-center gap-1 active-scale transition-all">
|
||||
<span class="material-symbols-outlined text-primary" style="font-variation-settings: 'FILL' 1;">bloodtype</span>
|
||||
<span class="text-label-md text-on-surface">血糖</span>
|
||||
</button>
|
||||
<button class="bg-surface-container-low p-3 rounded-2xl flex flex-col items-center gap-1 active-scale transition-all">
|
||||
<span class="material-symbols-outlined text-primary">monitoring</span>
|
||||
<span class="text-label-md text-on-surface">血压</span>
|
||||
</button>
|
||||
<button class="bg-surface-container-low p-3 rounded-2xl flex flex-col items-center gap-1 active-scale transition-all">
|
||||
<span class="material-symbols-outlined text-primary">restaurant</span>
|
||||
<span class="text-label-md text-on-surface">饮食</span>
|
||||
</button>
|
||||
<button class="bg-surface-container-low p-3 rounded-2xl flex flex-col items-center gap-1 active-scale transition-all">
|
||||
<span class="material-symbols-outlined text-primary">exercise</span>
|
||||
<span class="text-label-md text-on-surface">运动</span>
|
||||
</button>
|
||||
</div>
|
||||
<h4 class="text-label-md font-bold text-on-surface mb-3">今日控糖任务</h4>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full border-2 border-primary flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-[16px] text-primary" style="font-variation-settings: 'wght' 700;">check</span>
|
||||
</div>
|
||||
<span class="text-body-md text-on-surface">测血糖血压</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-label-md text-primary font-bold">+10积分</span>
|
||||
<span class="text-outline text-label-md font-bold italic">已完成</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full border-2 border-outline-variant/30 flex items-center justify-center">
|
||||
<span class="w-2 h-2 rounded-full bg-outline-variant"></span>
|
||||
</div>
|
||||
<span class="text-body-md text-on-surface">记今日饮食</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-label-md text-primary font-bold">+10积分</span>
|
||||
<button class="bg-on-surface text-surface rounded-full px-4 py-1 text-label-md font-bold active-scale transition-all">去记录</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Optimized Level System & Gamification -->
|
||||
<div class="mt-8 bg-gradient-to-br from-primary/5 to-primary/20 rounded-3xl p-6 border border-primary/10 relative overflow-hidden">
|
||||
<!-- Level Indicator Header -->
|
||||
<div class="flex justify-between items-end mb-6">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="bg-primary text-on-primary text-[10px] px-2 py-0.5 rounded-full font-extrabold" id="level-label">Lv.4</span>
|
||||
<span class="text-body-lg font-extrabold text-primary" id="stage-name">枝繁叶茂</span>
|
||||
</div>
|
||||
<p class="text-[11px] text-primary/60">还差 <span class="font-bold">15 XP</span> 升级至 Lv.4</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-label-md font-bold text-primary" id="xp-text">5/50 XP</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Progress Bar -->
|
||||
<div class="w-full h-2.5 bg-white/50 rounded-full mb-8 overflow-hidden backdrop-blur-sm border border-white/20">
|
||||
<div class="h-full bg-gradient-to-r from-primary to-primary-container rounded-full transition-all duration-500 shadow-inner" id="xp-progress" style="width: 10%;"></div>
|
||||
</div>
|
||||
<!-- Plant Growth Display -->
|
||||
<div class="flex items-center justify-center py-8 relative min-h-[160px]">
|
||||
<!-- Watering Animation Container -->
|
||||
<div class="absolute top-0 left-0 w-full h-full pointer-events-none z-10" id="rain-container"></div>
|
||||
<!-- Visual Stage Indicator Background -->
|
||||
<div class="absolute inset-0 flex items-center justify-center opacity-10 pointer-events-none">
|
||||
<span class="text-[120px] material-symbols-outlined text-primary" style="font-variation-settings: 'FILL' 1;">eco</span>
|
||||
</div>
|
||||
<!-- Current Plant State -->
|
||||
<div class="relative z-20 transition-all duration-300" id="plant-container">
|
||||
<span class="text-7xl drop-shadow-2xl filter block" id="plant-emoji">🪴</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Watering Action -->
|
||||
<div class="relative z-30">
|
||||
<button class="w-full bg-primary text-on-primary py-4 rounded-2xl text-body-lg font-bold flex items-center justify-center gap-3 shadow-lg active:scale-95 transition-all active:bg-primary-container group" id="water-btn">
|
||||
<span class="material-symbols-outlined text-[24px] transition-transform group-active:-rotate-12 group-hover:scale-110">opacity</span>
|
||||
给小树浇水
|
||||
</button>
|
||||
<p class="text-[11px] text-center text-outline mt-3 italic">浇水可消耗 5 积分,获得 10 XP</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Social Interaction -->
|
||||
<section class="bg-surface-container-lowest rounded-3xl p-card-padding ambient-shadow flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-tertiary-container/20 rounded-2xl flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-tertiary" style="font-variation-settings: 'FILL' 1;">favorite</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-body-md font-bold text-on-surface">邀请家人点赞</h4>
|
||||
<p class="text-[12px] text-outline">分享战报给家人,邀请他们为您点赞鼓励</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-outline">chevron_right</span>
|
||||
</section>
|
||||
<!-- Status Tip -->
|
||||
<div class="flex items-start gap-3 px-2">
|
||||
<span class="material-symbols-outlined text-primary-container text-[20px]">chat_bubble</span>
|
||||
<div>
|
||||
<p class="text-body-md text-on-surface">先完成血糖记录,<span class="text-primary font-bold">今日第一格就亮起来</span>。</p>
|
||||
<button class="text-outline text-label-md hover:text-primary transition-colors">轻触换一句鼓励 ›</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Care List Horizontal -->
|
||||
<div class="flex items-center gap-3 overflow-x-auto hide-scrollbar -mx-container-margin px-container-margin pb-2">
|
||||
<div class="shrink-0 flex items-center gap-2 bg-white px-3 py-2 rounded-xl shadow-sm border border-outline-variant/20">
|
||||
<span class="text-label-md text-outline">就诊卡</span>
|
||||
<span class="text-label-md font-extrabold text-on-surface">10 张</span>
|
||||
</div>
|
||||
<div class="shrink-0 flex items-center gap-2 bg-primary text-on-primary px-3 py-2 rounded-xl shadow-md">
|
||||
<span class="text-label-md font-bold">吕帅</span>
|
||||
<span class="text-[10px] bg-white/20 rounded px-1">23岁</span>
|
||||
</div>
|
||||
<div class="shrink-0 flex items-center gap-2 bg-surface-container-high text-on-surface-variant px-3 py-2 rounded-xl border border-outline-variant/30">
|
||||
<span class="text-label-md font-bold">霍爱玲</span>
|
||||
<span class="text-[10px] text-outline">57岁</span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<!-- Footer Note -->
|
||||
<footer class="mt-8 text-center px-container-margin pb-12">
|
||||
<p class="text-[11px] text-outline-variant/60">数据每日同步 · 阈值仅作参考,请遵医嘱</p>
|
||||
</footer>
|
||||
<!-- Navigation Bar -->
|
||||
|
||||
<script>
|
||||
// State Management
|
||||
let currentXP = 35;
|
||||
let currentLevel = 3;
|
||||
let totalPoints = 120;
|
||||
const maxLevel = 10;
|
||||
const xpPerLevel = 50;
|
||||
|
||||
const stageNames = [
|
||||
"种子眠", "破土而出", "茁壮成长", "枝繁叶茂", "初绽花蕾",
|
||||
"繁花似锦", "硕果初步", "丰收在望", "参天大树", "森林守护"
|
||||
];
|
||||
|
||||
const plantEmojis = [
|
||||
"🫘", "🌱", "🌿", "🪴", "🎍",
|
||||
"🌸", "🍒", "🍎", "🌳", "🌲"
|
||||
];
|
||||
|
||||
// DOM Elements
|
||||
const waterBtn = document.getElementById('water-btn');
|
||||
const rainContainer = document.getElementById('rain-container');
|
||||
const plantContainer = document.getElementById('plant-container');
|
||||
const plantEmoji = document.getElementById('plant-emoji');
|
||||
const xpProgress = document.getElementById('xp-progress');
|
||||
const xpText = document.getElementById('xp-text');
|
||||
const levelLabel = document.getElementById('level-label');
|
||||
const stageNameLabel = document.getElementById('stage-name');
|
||||
const totalPointsLabel = document.getElementById('total-points');
|
||||
|
||||
// Watering Animation Logic
|
||||
function createRain() {
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const droplet = document.createElement('div');
|
||||
droplet.className = 'droplet';
|
||||
droplet.style.left = `${Math.random() * 100}%`;
|
||||
droplet.style.animationDelay = `${Math.random() * 0.4}s`;
|
||||
rainContainer.appendChild(droplet);
|
||||
|
||||
// Cleanup
|
||||
setTimeout(() => droplet.remove(), 600);
|
||||
}
|
||||
}
|
||||
|
||||
function triggerGrowthPulse() {
|
||||
plantContainer.classList.add('growth-pulse');
|
||||
setTimeout(() => {
|
||||
plantContainer.classList.remove('growth-pulse');
|
||||
}, 800);
|
||||
}
|
||||
|
||||
function createSparkles() {
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const sparkle = document.createElement('span');
|
||||
sparkle.className = 'material-symbols-outlined shimmer-particle';
|
||||
sparkle.textContent = 'sparkles';
|
||||
sparkle.style.left = `${40 + (Math.random() - 0.5) * 40}%`;
|
||||
sparkle.style.top = `${40 + (Math.random() - 0.5) * 40}%`;
|
||||
sparkle.style.animationDuration = `${0.6 + Math.random() * 0.4}s`;
|
||||
plantContainer.appendChild(sparkle);
|
||||
|
||||
setTimeout(() => sparkle.remove(), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function triggerSugarDecreaseEffect() {
|
||||
// 1. Floating Text
|
||||
const floatingText = document.createElement('div');
|
||||
floatingText.className = 'sugar-decrease-text';
|
||||
floatingText.textContent = '-0.5 mmol/L';
|
||||
|
||||
// Randomize spawn position near the plant
|
||||
const rect = plantContainer.getBoundingClientRect();
|
||||
floatingText.style.left = `${rect.left + rect.width / 2}px`;
|
||||
floatingText.style.top = `${rect.top}px`;
|
||||
|
||||
document.body.appendChild(floatingText);
|
||||
setTimeout(() => floatingText.remove(), 2000);
|
||||
|
||||
// 2. Full-screen Downward Particles
|
||||
for (let i = 0; i < 20; i++) {
|
||||
setTimeout(() => {
|
||||
const particle = document.createElement('div');
|
||||
particle.className = 'sugar-particle text-primary-container';
|
||||
// Mix arrows and symbols
|
||||
particle.innerHTML = Math.random() > 0.5 ? '<span class="material-symbols-outlined">south</span>' : '▼';
|
||||
particle.style.left = `${Math.random() * 100}vw`;
|
||||
particle.style.fontSize = `${16 + Math.random() * 16}px`;
|
||||
particle.style.opacity = '0';
|
||||
|
||||
document.body.appendChild(particle);
|
||||
setTimeout(() => particle.remove(), 1500);
|
||||
}, i * 50);
|
||||
}
|
||||
}
|
||||
|
||||
function updateDisplay() {
|
||||
const progress = (currentXP / xpPerLevel) * 100;
|
||||
xpProgress.style.width = `${progress}%`;
|
||||
xpText.textContent = `${currentXP}/${xpPerLevel} XP`;
|
||||
levelLabel.textContent = `Lv.${currentLevel}`;
|
||||
stageNameLabel.textContent = stageNames[currentLevel - 1];
|
||||
plantEmoji.textContent = plantEmojis[currentLevel - 1];
|
||||
totalPointsLabel.textContent = totalPoints;
|
||||
}
|
||||
|
||||
// Action Logic
|
||||
waterBtn.addEventListener('click', () => {
|
||||
if (totalPoints < 5) {
|
||||
alert("积分不足,快去完成任务领积分吧!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Lock button briefly
|
||||
waterBtn.disabled = true;
|
||||
waterBtn.classList.add('opacity-50');
|
||||
|
||||
// 1. Consume Points
|
||||
totalPoints -= 5;
|
||||
|
||||
// 2. Trigger Animations
|
||||
createRain();
|
||||
|
||||
setTimeout(() => {
|
||||
triggerGrowthPulse();
|
||||
createSparkles();
|
||||
triggerSugarDecreaseEffect(); // New Effect
|
||||
|
||||
// 3. Update State
|
||||
currentXP += 10;
|
||||
if (currentXP >= xpPerLevel && currentLevel < maxLevel) {
|
||||
currentXP -= xpPerLevel;
|
||||
currentLevel++;
|
||||
}
|
||||
|
||||
updateDisplay();
|
||||
|
||||
// Unlock button
|
||||
waterBtn.disabled = false;
|
||||
waterBtn.classList.remove('opacity-50');
|
||||
}, 600);
|
||||
});
|
||||
|
||||
// Initialize
|
||||
updateDisplay();
|
||||
|
||||
// Simple button micro-interactions for others
|
||||
document.querySelectorAll('button:not(#water-btn)').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
if(!btn.classList.contains('cursor-not-allowed')) {
|
||||
// Interaction logic
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
</body></html>
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"projects/15362588878567114380/screens/259c8c233434481881d0bacddb59fa7f","title":"血糖管理重构版","screenshot":{"name":"projects/15362588878567114380/files/c18dd3e3d7774a16897d78b40073b9f2","downloadUrl":"https://lh3.googleusercontent.com/aida/ADBb0ugpswBosy3eCG_j3kJlxCWpntXQGvRJvMuepINJTcB47Nf4ymWU81mrjex1ICPdAXn-p_JjyUokOtms4zIJpXL49692zt16mIJhEC16nfU9kH5hwAj1s7v3PlAZbUGWjYjPTdfVMaOCveS6Ohuh_IeyRTD6MmdsFZ2-H7Whg6zy4MiZeGLxRw0ckCOrNbTRObtuzk_dlUc49eDGeRagYtuL9xyS6pno5CiwBzSkGp872chdZVn9JOK-2n1P"},"htmlCode":{"name":"projects/15362588878567114380/files/ca56c13fc2d6481d9de60727f7b70f3f","downloadUrl":"https://contribution.usercontent.google.com/download?c=CgthaWRhX2NvZGVmeBJ8Eh1hcHBfY29tcGFuaW9uX2dlbmVyYXRlZF9maWxlcxpbCiVodG1sXzk5NTVmZjZjNTFkODQxMmY4MzIxNWNkYmIwNWI2MTMzEgsSBxCEnv3unRAYAZIBJAoKcHJvamVjdF9pZBIWQhQxNTM2MjU4ODg3ODU2NzExNDM4MA&filename=&opi=89354086","mimeType":"text/html"},"width":"780","height":"4122","deviceType":"MOBILE"}
|
||||
@@ -0,0 +1,351 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="light" lang="zh-CN"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>血糖管理</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"on-tertiary-fixed": "#410005",
|
||||
"surface-bright": "#f4fbf4",
|
||||
"on-primary-container": "#00422b",
|
||||
"surface-container": "#e8f0e9",
|
||||
"surface-container-high": "#e3eae3",
|
||||
"surface-dim": "#d4dcd5",
|
||||
"primary-container": "#10b981",
|
||||
"on-secondary-fixed": "#131e19",
|
||||
"surface": "#f4fbf4",
|
||||
"on-surface": "#161d19",
|
||||
"on-tertiary": "#ffffff",
|
||||
"tertiary-container": "#fc7c78",
|
||||
"secondary": "#55615a",
|
||||
"on-error-container": "#93000a",
|
||||
"on-background": "#161d19",
|
||||
"secondary-fixed-dim": "#bdcac1",
|
||||
"inverse-on-surface": "#ebf3eb",
|
||||
"surface-container-highest": "#dde4dd",
|
||||
"on-error": "#ffffff",
|
||||
"on-surface-variant": "#3c4a42",
|
||||
"secondary-fixed": "#d9e6dd",
|
||||
"inverse-surface": "#2b322d",
|
||||
"on-secondary": "#ffffff",
|
||||
"on-tertiary-fixed-variant": "#842225",
|
||||
"outline-variant": "#bbcabf",
|
||||
"on-primary-fixed-variant": "#005236",
|
||||
"on-secondary-container": "#5b6760",
|
||||
"on-primary": "#ffffff",
|
||||
"primary": "#006c49",
|
||||
"outline": "#6c7a71",
|
||||
"tertiary-fixed": "#ffdad7",
|
||||
"error": "#ba1a1a",
|
||||
"tertiary": "#a43a3a",
|
||||
"surface-tint": "#006c49",
|
||||
"inverse-primary": "#4edea3",
|
||||
"on-tertiary-container": "#711419",
|
||||
"primary-fixed-dim": "#4edea3",
|
||||
"on-secondary-fixed-variant": "#3e4943",
|
||||
"tertiary-fixed-dim": "#ffb3af",
|
||||
"surface-container-low": "#eef6ee",
|
||||
"error-container": "#ffdad6",
|
||||
"on-primary-fixed": "#002113",
|
||||
"background": "#f4fbf4",
|
||||
"surface-container-lowest": "#ffffff",
|
||||
"primary-fixed": "#6ffbbe",
|
||||
"surface-variant": "#dde4dd",
|
||||
"secondary-container": "#d9e6dd"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.25rem",
|
||||
"lg": "0.5rem",
|
||||
"xl": "0.75rem",
|
||||
"full": "9999px",
|
||||
"2xl": "1.5rem"
|
||||
},
|
||||
"spacing": {
|
||||
"section-margin": "32px",
|
||||
"stack-gap": "16px",
|
||||
"card-padding": "20px",
|
||||
"inline-gap": "12px",
|
||||
"container-margin": "20px"
|
||||
},
|
||||
"fontFamily": {
|
||||
"headline-md": ["Manrope"],
|
||||
"label-md": ["Manrope"],
|
||||
"body-lg": ["Manrope"],
|
||||
"headline-lg-mobile": ["Manrope"],
|
||||
"headline-lg": ["Manrope"],
|
||||
"display-lg": ["Manrope"],
|
||||
"body-md": ["Manrope"]
|
||||
},
|
||||
"boxShadow": {
|
||||
'ambient': '0 10px 30px -10px rgba(0, 108, 73, 0.08)',
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
.material-symbols-outlined.fill-icon {
|
||||
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-surface text-on-surface font-body-md antialiased md:max-w-[600px] md:mx-auto pb-24">
|
||||
<!-- TopAppBar -->
|
||||
<header class="flex justify-between items-center px-container-margin py-4 w-full bg-surface top-0 sticky z-40">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-surface-container-high flex items-center justify-center overflow-hidden">
|
||||
<span class="material-symbols-outlined text-outline">person</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-headline-lg-mobile font-headline-lg-mobile text-on-surface">下午好,用户16295733</h1>
|
||||
<p class="text-label-md font-label-md text-on-surface-variant">今日血糖已记录</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="flex flex-col items-center justify-center bg-surface-container-low border border-outline-variant rounded-xl px-3 py-1.5 hover:bg-surface-container transition-colors">
|
||||
<span class="material-symbols-outlined text-primary mb-0.5">volume_up</span>
|
||||
<span class="text-[10px] font-label-md text-on-surface-variant">朗读血糖</span>
|
||||
</button>
|
||||
</header>
|
||||
<main class="px-container-margin flex flex-col gap-section-margin mt-4">
|
||||
<!-- Primary Action -->
|
||||
<section>
|
||||
<button class="w-full bg-primary text-on-primary rounded-full py-4 flex items-center justify-center gap-2 hover:bg-on-primary-fixed-variant transition-colors shadow-ambient active:scale-95 duration-150">
|
||||
<span class="material-symbols-outlined">add</span>
|
||||
<span class="text-body-lg font-body-lg font-bold">录入今日血糖</span>
|
||||
</button>
|
||||
</section>
|
||||
<!-- Current Status Cards -->
|
||||
<section class="flex flex-col gap-3">
|
||||
<h2 class="text-headline-md font-headline-md">今日血糖</h2>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<!-- Fasting -->
|
||||
<div class="bg-surface-container-lowest rounded-2xl p-card-padding shadow-ambient border border-error-container">
|
||||
<div class="text-label-md font-label-md text-on-surface-variant mb-1">空腹</div>
|
||||
<div class="flex items-baseline gap-1 mb-2">
|
||||
<span class="text-display-lg font-display-lg text-error">18.00</span>
|
||||
<span class="text-label-md font-label-md text-error">mmol/L</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-error bg-error-container/30 px-2 py-1 rounded-md w-fit">
|
||||
<span class="material-symbols-outlined text-[14px]">warning</span>
|
||||
<span class="text-[10px] font-label-md">偏高,请遵医嘱</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Post-meal -->
|
||||
<div class="bg-surface-container-lowest rounded-2xl p-card-padding shadow-ambient border border-error-container">
|
||||
<div class="text-label-md font-label-md text-on-surface-variant mb-1">餐后</div>
|
||||
<div class="flex items-baseline gap-1 mb-2">
|
||||
<span class="text-display-lg font-display-lg text-error">20.00</span>
|
||||
<span class="text-label-md font-label-md text-error">mmol/L</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-error bg-error-container/30 px-2 py-1 rounded-md w-fit">
|
||||
<span class="material-symbols-outlined text-[14px]">warning</span>
|
||||
<span class="text-[10px] font-label-md">偏高,请遵医嘱</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="w-full flex items-center justify-between bg-surface-container-lowest rounded-xl p-4 shadow-ambient mt-2">
|
||||
<div>
|
||||
<div class="text-body-md font-body-md font-bold">查看更多</div>
|
||||
<div class="text-label-md font-label-md text-on-surface-variant">健康日历 · 运动 · 家人互动</div>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-outline">chevron_right</span>
|
||||
</button>
|
||||
</section>
|
||||
<!-- Data Overview: Trend -->
|
||||
<section class="bg-surface-container-lowest rounded-2xl p-card-padding shadow-ambient">
|
||||
<div class="flex justify-between items-end mb-4">
|
||||
<div>
|
||||
<h2 class="text-headline-md font-headline-md">血糖趋势</h2>
|
||||
<p class="text-label-md font-label-md text-on-surface-variant">最近 7 天 · mmol/L</p>
|
||||
</div>
|
||||
<div class="flex gap-3 text-label-md font-label-md">
|
||||
<div class="flex items-center gap-1"><div class="w-2 h-2 rounded-full bg-primary"></div>空腹</div>
|
||||
<div class="flex items-center gap-1"><div class="w-2 h-2 rounded-full bg-tertiary-container"></div>餐后</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Summary Stats -->
|
||||
<div class="flex bg-surface-container-low rounded-xl p-3 mb-6 divide-x divide-outline-variant">
|
||||
<div class="flex-1 flex flex-col items-center">
|
||||
<span class="text-headline-md font-headline-md text-error">1</span>
|
||||
<span class="text-label-md font-label-md text-on-surface-variant">天偏高</span>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center">
|
||||
<span class="text-headline-md font-headline-md text-primary">1</span>
|
||||
<span class="text-label-md font-label-md text-on-surface-variant">天正常</span>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center">
|
||||
<span class="text-headline-md font-headline-md text-on-surface">2</span>
|
||||
<span class="text-label-md font-label-md text-on-surface-variant">天有记录</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Chart Area Placeholder -->
|
||||
<div class="relative h-48 w-full mb-6 border-b border-l border-outline-variant">
|
||||
<!-- Y Axis Labels -->
|
||||
<div class="absolute -left-6 top-0 bottom-0 flex flex-col justify-between text-[10px] text-on-surface-variant">
|
||||
<span>23.0</span>
|
||||
<span>18.0</span>
|
||||
<span>13.0</span>
|
||||
<span>8.0</span>
|
||||
<span>3.0</span>
|
||||
</div>
|
||||
<!-- X Axis Labels -->
|
||||
<div class="absolute -bottom-5 left-0 right-0 flex justify-between text-[10px] text-on-surface-variant">
|
||||
<span>05-23</span>
|
||||
<span>05-25</span>
|
||||
<span>05-27</span>
|
||||
<span>05-29</span>
|
||||
</div>
|
||||
<!-- Threshold Line -->
|
||||
<div class="absolute bottom-[25%] w-full border-t border-dashed border-error opacity-50">
|
||||
<span class="absolute -top-4 text-[10px] text-error">空腹阈值 7</span>
|
||||
</div>
|
||||
<!-- Abstract Chart representation -->
|
||||
<svg class="absolute inset-0 h-full w-full" preserveaspectratio="none" viewbox="0 0 100 100">
|
||||
<path d="M 80 80 L 95 30" fill="none" stroke="#006c49" stroke-width="2"></path>
|
||||
<path d="M 85 85 L 95 15" fill="none" stroke="#fc7c78" stroke-width="2"></path>
|
||||
<circle cx="80" cy="80" fill="#ffffff" r="2" stroke="#006c49" stroke-width="1.5"></circle>
|
||||
<circle cx="95" cy="30" fill="#ffffff" r="2" stroke="#006c49" stroke-width="1.5"></circle>
|
||||
<circle cx="85" cy="85" fill="#ffffff" r="2" stroke="#fc7c78" stroke-width="1.5"></circle>
|
||||
<circle cx="95" cy="15" fill="#ffffff" r="2" stroke="#fc7c78" stroke-width="1.5"></circle>
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Time Toggles -->
|
||||
<div class="flex bg-surface-container-low rounded-full p-1 w-full max-w-[240px] mx-auto mt-8">
|
||||
<button class="flex-1 py-2 text-label-md font-label-md bg-primary text-on-primary rounded-full shadow-sm">最近 7 天</button>
|
||||
<button class="flex-1 py-2 text-label-md font-label-md text-on-surface-variant rounded-full">最近 30 天</button>
|
||||
</div>
|
||||
</section>
|
||||
<!-- AI Nutrition Section -->
|
||||
<section class="bg-gradient-to-br from-primary-container/20 to-primary/10 rounded-2xl p-[2px] shadow-ambient overflow-hidden relative">
|
||||
<div class="bg-surface-container-lowest rounded-2xl p-card-padding relative z-10">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-on-primary text-[18px]">auto_awesome</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-body-lg font-body-lg font-bold">AI 饮食建议</h3>
|
||||
<span class="text-[10px] bg-primary/10 text-primary px-2 py-0.5 rounded-full border border-primary/20">智能定制</span>
|
||||
</div>
|
||||
<p class="text-[10px] text-on-surface-variant mt-0.5">读您近 7 天 · 30 天血糖,定制今日三餐</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="flex items-center gap-1 text-label-md font-label-md text-primary bg-primary/10 px-3 py-1.5 rounded-full">
|
||||
<span class="material-symbols-outlined text-[14px]">refresh</span>换一换
|
||||
</button>
|
||||
</div>
|
||||
<!-- Meals List -->
|
||||
<div class="flex flex-col gap-3 mb-4">
|
||||
<div class="text-label-md font-label-md text-primary font-bold flex items-center gap-1">
|
||||
<div class="w-1 h-3 bg-primary rounded-full"></div> 今日三餐
|
||||
</div>
|
||||
<!-- Breakfast -->
|
||||
<div class="bg-surface p-3 rounded-xl border border-surface-container-highest">
|
||||
<div class="flex items-center gap-1.5 mb-1.5">
|
||||
<span class="material-symbols-outlined text-tertiary-container text-[16px] fill-icon">wb_sunny</span>
|
||||
<span class="text-label-md font-label-md font-bold">早餐</span>
|
||||
</div>
|
||||
<p class="text-body-md font-body-md">小米粥(少米多水)加煮鸡蛋一个</p>
|
||||
</div>
|
||||
<!-- Lunch -->
|
||||
<div class="bg-primary/5 p-3 rounded-xl border-l-2 border-primary">
|
||||
<div class="flex items-center gap-1.5 mb-1.5">
|
||||
<span class="material-symbols-outlined text-primary text-[16px] fill-icon">wb_twilight</span>
|
||||
<span class="text-label-md font-label-md font-bold text-primary">午餐</span>
|
||||
</div>
|
||||
<p class="text-body-md font-body-md">清蒸鱼块、蒜蓉炒苋菜、二米饭(小米掺大米)</p>
|
||||
</div>
|
||||
<!-- Dinner -->
|
||||
<div class="bg-surface p-3 rounded-xl border border-surface-container-highest">
|
||||
<div class="flex items-center gap-1.5 mb-1.5">
|
||||
<span class="material-symbols-outlined text-[#7e85cc] text-[16px] fill-icon">dark_mode</span>
|
||||
<span class="text-label-md font-label-md font-bold">晚餐</span>
|
||||
</div>
|
||||
<p class="text-body-md font-body-md">玉米糝粥(稀)配凉拌黄瓜</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tips -->
|
||||
<div class="bg-surface-container-low rounded-xl p-3 mb-4 text-body-md font-body-md text-on-surface-variant flex gap-2">
|
||||
<span class="material-symbols-outlined text-primary text-[16px] mt-0.5">info</span>
|
||||
<p>小贴士:先吃菜和肉再吃主食,每餐七分饱,血糖高就少喝粥汤。</p>
|
||||
</div>
|
||||
<!-- Avoid List -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center gap-1 text-error text-label-md font-label-md mb-2">
|
||||
<span class="material-symbols-outlined text-[14px]">warning</span> 今日尽量少碰
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="bg-error-container/30 text-error px-3 py-1 rounded-full text-[12px] border border-error-container">白馒头</span>
|
||||
<span class="bg-error-container/30 text-error px-3 py-1 rounded-full text-[12px] border border-error-container">油条</span>
|
||||
<span class="bg-error-container/30 text-error px-3 py-1 rounded-full text-[12px] border border-error-container">粘豆包</span>
|
||||
<span class="bg-error-container/30 text-error px-3 py-1 rounded-full text-[12px] border border-error-container">糯米饭</span>
|
||||
<span class="bg-error-container/30 text-error px-3 py-1 rounded-full text-[12px] border border-error-container">西瓜</span>
|
||||
<span class="bg-error-container/30 text-error px-3 py-1 rounded-full text-[12px] border border-error-container">含糖饮料</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3">
|
||||
<button class="flex-1 bg-primary text-on-primary rounded-xl py-3 text-body-md font-body-md font-bold hover:bg-on-primary-fixed-variant transition-colors">填入今日饮食</button>
|
||||
<button class="flex-1 bg-surface-container border border-outline-variant text-on-surface rounded-xl py-3 text-body-md font-body-md font-bold hover:bg-surface-container-high transition-colors">手动记录</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Decorative background elements -->
|
||||
<div class="absolute top-0 right-0 w-32 h-32 bg-primary/10 rounded-full blur-2xl -mr-10 -mt-10 pointer-events-none"></div>
|
||||
<div class="absolute bottom-0 left-0 w-24 h-24 bg-tertiary-container/10 rounded-full blur-xl -ml-10 -mb-10 pointer-events-none"></div>
|
||||
</section>
|
||||
<!-- Ask AI -->
|
||||
<section class="bg-surface-container-lowest rounded-2xl p-4 shadow-ambient border border-surface-container-highest">
|
||||
<h3 class="text-body-md font-body-md font-bold mb-1">能不能吃?</h3>
|
||||
<p class="text-[10px] text-on-surface-variant mb-3">输入食物名称,结合您的档案判断</p>
|
||||
<div class="flex gap-2">
|
||||
<input class="flex-1 bg-surface rounded-xl border border-outline-variant px-3 py-2 text-body-md focus:ring-2 focus:ring-primary focus:border-primary outline-none" placeholder="如:白馒头、地瓜、粘豆包" type="text"/>
|
||||
<button class="bg-primary text-on-primary px-4 py-2 rounded-xl flex items-center gap-1 hover:bg-on-primary-fixed-variant transition-colors">
|
||||
<span class="material-symbols-outlined text-[18px]">auto_awesome</span>
|
||||
<span class="text-body-md font-body-md font-bold">问 AI</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Disclaimer -->
|
||||
<footer class="text-center py-6">
|
||||
<p class="text-[10px] text-outline">数据仅供参考,请遵医嘱</p>
|
||||
</footer>
|
||||
</main>
|
||||
<!-- BottomNavBar -->
|
||||
<nav class="fixed bottom-0 w-full z-50 rounded-t-2xl shadow-[0_-4px_20px_0_rgba(0,0,0,0.05)] bg-surface-container-lowest md:hidden flex justify-around items-center h-16 px-4">
|
||||
<button class="flex flex-col items-center justify-center text-primary font-bold w-16">
|
||||
<span class="material-symbols-outlined fill-icon mb-1">home</span>
|
||||
<span class="text-[10px] font-label-md">首页</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center text-on-surface-variant opacity-60 w-16 hover:bg-surface-container-high transition-all rounded-xl py-1">
|
||||
<span class="material-symbols-outlined mb-1">add_circle</span>
|
||||
<span class="text-[10px] font-label-md">记录</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center text-on-surface-variant opacity-60 w-16 hover:bg-surface-container-high transition-all rounded-xl py-1">
|
||||
<span class="material-symbols-outlined mb-1">analytics</span>
|
||||
<span class="text-[10px] font-label-md">动态</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center text-on-surface-variant opacity-60 w-16 hover:bg-surface-container-high transition-all rounded-xl py-1">
|
||||
<span class="material-symbols-outlined mb-1">person</span>
|
||||
<span class="text-[10px] font-label-md">我的</span>
|
||||
</button>
|
||||
</nav>
|
||||
</body></html>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,993 @@
|
||||
/**
|
||||
|
||||
* 日常记录统一主题 — 与甄养堂小程序 TCM Care 品牌色一致
|
||||
|
||||
* 来源:pages/index/index.vue · tabBar selectedColor #204e2b
|
||||
|
||||
*/
|
||||
|
||||
|
||||
|
||||
.daily-page {
|
||||
|
||||
/* 品牌主色 */
|
||||
|
||||
--primary: #204e2b;
|
||||
|
||||
--primary-dark: #163d22;
|
||||
|
||||
--primary-container: #386641;
|
||||
|
||||
--primary-grad: linear-gradient(160deg, #204e2b 0%, #386641 100%);
|
||||
|
||||
--primary-light: #eef6ef;
|
||||
|
||||
--primary-soft: #cfe8d3;
|
||||
|
||||
--primary-glow: rgba(32, 78, 43, 0.14);
|
||||
|
||||
--on-primary-container: #afe2b3;
|
||||
|
||||
|
||||
|
||||
--success: #386641;
|
||||
|
||||
--success-light: #eef6ef;
|
||||
|
||||
|
||||
|
||||
--danger: #dc2626;
|
||||
|
||||
--danger-light: #fef2f2;
|
||||
|
||||
--danger-glow: rgba(220, 38, 38, 0.1);
|
||||
|
||||
|
||||
|
||||
--warning: #d97706;
|
||||
|
||||
--warning-light: #fff7ed;
|
||||
|
||||
|
||||
|
||||
--text-primary: #1b1c1a;
|
||||
|
||||
--text-secondary: #414941;
|
||||
|
||||
--text-muted: #727970;
|
||||
|
||||
|
||||
|
||||
--surface: #ffffff;
|
||||
|
||||
--surface-muted: #f4f4f0;
|
||||
|
||||
--border: #e3e2df;
|
||||
|
||||
--border-soft: rgba(32, 78, 43, 0.12);
|
||||
|
||||
|
||||
|
||||
--slate-50: #faf9f5;
|
||||
|
||||
--slate-100: #f4f4f0;
|
||||
|
||||
--slate-200: #e9e8e4;
|
||||
|
||||
--slate-300: #e3e2df;
|
||||
|
||||
--slate-400: #727970;
|
||||
|
||||
--slate-600: #414941;
|
||||
|
||||
--slate-900: #1b1c1a;
|
||||
|
||||
|
||||
|
||||
--shadow-premium: 0 8rpx 28rpx rgba(32, 78, 43, 0.06);
|
||||
|
||||
--shadow-sm: 0 4rpx 16rpx rgba(32, 78, 43, 0.05);
|
||||
|
||||
--shadow-glow-primary: 0 8rpx 20rpx rgba(32, 78, 43, 0.18);
|
||||
|
||||
--shadow-glow-danger: 0 8rpx 20rpx rgba(220, 38, 38, 0.12);
|
||||
|
||||
--border-premium: 1rpx solid var(--border-soft);
|
||||
|
||||
|
||||
|
||||
background: var(--slate-50);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Hero:品牌绿渐变 */
|
||||
|
||||
.daily-page .hero-bg {
|
||||
|
||||
background: var(--primary-grad);
|
||||
|
||||
&::after {
|
||||
|
||||
background: radial-gradient(circle at 88% 12%, rgba(255, 255, 255, 0.1) 0%, transparent 55%);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .hero-orb {
|
||||
|
||||
display: none;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .hero-pill.voice.speaking {
|
||||
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
|
||||
border-color: rgba(255, 255, 255, 0.95);
|
||||
|
||||
animation: none;
|
||||
|
||||
box-shadow: 0 0 0 3rpx rgba(255, 255, 255, 0.35);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .hero-status-chip.pending {
|
||||
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
|
||||
border-color: rgba(255, 255, 255, 0.28);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .hero-status-chip.done {
|
||||
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
|
||||
border-color: rgba(255, 255, 255, 0.32);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 指标卡 */
|
||||
|
||||
.daily-page .stat-fasting,
|
||||
|
||||
.daily-page .stat-postprandial,
|
||||
|
||||
.daily-page .stat-other {
|
||||
|
||||
background: var(--surface);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .stat-fasting .stat-card-tag,
|
||||
|
||||
.daily-page .stat-postprandial .stat-card-tag,
|
||||
|
||||
.daily-page .stat-other .stat-card-tag {
|
||||
|
||||
background: var(--primary-light);
|
||||
|
||||
color: var(--primary-dark);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .stat-card-value {
|
||||
|
||||
background: none;
|
||||
|
||||
-webkit-background-clip: unset;
|
||||
|
||||
background-clip: unset;
|
||||
|
||||
color: var(--text-primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .stat-card-value.is-high {
|
||||
|
||||
background: none;
|
||||
|
||||
color: var(--danger);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .stat-card-trend.trend-down {
|
||||
|
||||
background: var(--primary-light);
|
||||
|
||||
.stat-card-trend-arrow,
|
||||
|
||||
.stat-card-trend-delta {
|
||||
|
||||
color: var(--primary-dark);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .more-entry-arrow {
|
||||
|
||||
color: var(--primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .card-chip.active {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
box-shadow: var(--shadow-glow-primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .range-item.active {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
box-shadow: var(--shadow-glow-primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .legend-dot-fasting {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .legend-dot-postprandial {
|
||||
|
||||
background: var(--warning);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .streak-strip {
|
||||
|
||||
background: var(--surface);
|
||||
|
||||
border-color: var(--border-soft);
|
||||
|
||||
&::after {
|
||||
|
||||
background: radial-gradient(circle, rgba(32, 78, 43, 0.08) 0%, transparent 70%);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .streak-num {
|
||||
|
||||
color: var(--primary-dark);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 日历:品牌绿深浅 */
|
||||
|
||||
.daily-page .calendar-cell.level-1 .calendar-cell-mark,
|
||||
|
||||
.daily-page .calendar-legend-cell.level-1 {
|
||||
|
||||
background: var(--primary-soft);
|
||||
|
||||
border-color: #b8dcc0;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .calendar-cell.level-2 .calendar-cell-mark,
|
||||
|
||||
.daily-page .calendar-legend-cell.level-2 {
|
||||
|
||||
background: var(--on-primary-container);
|
||||
|
||||
border-color: #86c992;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .calendar-cell.level-3 .calendar-cell-mark,
|
||||
|
||||
.daily-page .calendar-legend-cell.level-3 {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
border-color: var(--primary-dark);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .day-block-tag.tag-blood,
|
||||
|
||||
.daily-page .day-block-tag.tag-diet,
|
||||
|
||||
.daily-page .day-block-tag.tag-exercise,
|
||||
|
||||
.daily-page .day-block-tag.tag-tracking {
|
||||
|
||||
background: var(--primary-light);
|
||||
|
||||
color: var(--primary-dark);
|
||||
|
||||
border: 1rpx solid var(--border-soft);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .day-block-tag.tag-diet,
|
||||
|
||||
.daily-page .day-block-tag.tag-exercise {
|
||||
|
||||
background: var(--surface-muted);
|
||||
|
||||
color: var(--text-secondary);
|
||||
|
||||
border-color: var(--border);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .pg-points-badge {
|
||||
|
||||
background: var(--primary-light);
|
||||
|
||||
border-color: var(--border-soft);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .pg-points-num {
|
||||
|
||||
color: var(--primary-dark);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .record-quick-chip.pending {
|
||||
|
||||
border-color: var(--border);
|
||||
|
||||
background: var(--surface-muted);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .record-quick-chip.done {
|
||||
|
||||
border-color: rgba(56, 102, 65, 0.35);
|
||||
|
||||
background: var(--success-light);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .task-action-btn.pending {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .water-btn {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .family-like-strip,
|
||||
|
||||
.daily-page .encourage-strip {
|
||||
|
||||
background: var(--surface);
|
||||
|
||||
border: 1rpx solid var(--border);
|
||||
|
||||
box-shadow: var(--shadow-premium);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .card-title,
|
||||
|
||||
.daily-page .hero-title {
|
||||
|
||||
color: inherit;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .report-share-btn,
|
||||
|
||||
.daily-page .report-share-btn::after {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
box-shadow: var(--shadow-glow-primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .input-modal-btn.primary {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .input-field-dot-fasting {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .input-field-dot-postprandial {
|
||||
|
||||
background: var(--primary-container);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .input-field-dot-other {
|
||||
|
||||
background: var(--text-muted);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .voice-switch.on .voice-switch-track {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .heatmap-foot-cta {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ========== 适老极简首页(index) ========== */
|
||||
|
||||
.daily-page .elder-header {
|
||||
|
||||
padding: 48rpx 32rpx 24rpx;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-greet {
|
||||
|
||||
display: block;
|
||||
|
||||
font-size: 44rpx;
|
||||
|
||||
font-weight: 700;
|
||||
|
||||
color: var(--text-primary);
|
||||
|
||||
line-height: 1.35;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-status {
|
||||
|
||||
display: block;
|
||||
|
||||
margin-top: 16rpx;
|
||||
|
||||
font-size: 32rpx;
|
||||
|
||||
font-weight: 600;
|
||||
|
||||
line-height: 1.4;
|
||||
|
||||
|
||||
|
||||
&.is-done {
|
||||
|
||||
color: var(--success);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
&.is-pending {
|
||||
|
||||
color: var(--text-secondary);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-card-scroll {
|
||||
|
||||
margin-top: 24rpx;
|
||||
|
||||
width: 100%;
|
||||
|
||||
white-space: nowrap;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-card-row {
|
||||
|
||||
display: inline-flex;
|
||||
|
||||
flex-wrap: nowrap;
|
||||
|
||||
gap: 16rpx;
|
||||
|
||||
padding: 2rpx 0;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-card-chip {
|
||||
|
||||
flex-shrink: 0;
|
||||
|
||||
padding: 12rpx 28rpx;
|
||||
|
||||
border-radius: 999rpx;
|
||||
|
||||
background: var(--surface);
|
||||
|
||||
border: 2rpx solid var(--border);
|
||||
|
||||
|
||||
|
||||
text {
|
||||
|
||||
font-size: 28rpx;
|
||||
|
||||
color: var(--text-secondary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
&.active {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
border-color: var(--primary);
|
||||
|
||||
|
||||
|
||||
text {
|
||||
|
||||
color: #fff;
|
||||
|
||||
font-weight: 600;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-actions {
|
||||
|
||||
padding: 0 32rpx 24rpx;
|
||||
|
||||
display: flex;
|
||||
|
||||
flex-direction: row;
|
||||
|
||||
align-items: stretch;
|
||||
|
||||
gap: 16rpx;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-btn {
|
||||
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
justify-content: center;
|
||||
|
||||
gap: 12rpx;
|
||||
|
||||
min-height: 112rpx;
|
||||
|
||||
min-width: 0;
|
||||
|
||||
border-radius: 24rpx;
|
||||
|
||||
box-shadow: var(--shadow-sm);
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-btn-text {
|
||||
|
||||
font-size: 36rpx;
|
||||
|
||||
font-weight: 700;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-btn-primary {
|
||||
|
||||
flex: 8;
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
|
||||
|
||||
.elder-btn-text {
|
||||
|
||||
color: #fff;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-btn-voice {
|
||||
|
||||
flex: 2;
|
||||
|
||||
flex-direction: column;
|
||||
|
||||
gap: 8rpx;
|
||||
|
||||
padding: 12rpx 8rpx;
|
||||
|
||||
background: var(--surface);
|
||||
|
||||
border: 2rpx solid var(--border-soft);
|
||||
|
||||
|
||||
|
||||
.elder-btn-text {
|
||||
|
||||
color: var(--primary-dark);
|
||||
|
||||
font-size: 24rpx;
|
||||
|
||||
line-height: 1.25;
|
||||
|
||||
text-align: center;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
&.speaking {
|
||||
|
||||
border-color: var(--primary);
|
||||
|
||||
background: var(--primary-light);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
&.disabled {
|
||||
|
||||
opacity: 0.45;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-today-panel {
|
||||
|
||||
margin: 0 32rpx 24rpx;
|
||||
|
||||
padding: 32rpx;
|
||||
|
||||
background: var(--surface);
|
||||
|
||||
border-radius: 24rpx;
|
||||
|
||||
border: 1rpx solid var(--border);
|
||||
|
||||
box-shadow: var(--shadow-premium);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-panel-title {
|
||||
|
||||
display: block;
|
||||
|
||||
font-size: 34rpx;
|
||||
|
||||
font-weight: 700;
|
||||
|
||||
color: var(--text-primary);
|
||||
|
||||
margin-bottom: 24rpx;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-today-grid {
|
||||
|
||||
display: grid;
|
||||
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
gap: 20rpx;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-metric {
|
||||
|
||||
padding: 24rpx;
|
||||
|
||||
border-radius: 20rpx;
|
||||
|
||||
background: var(--surface-muted);
|
||||
|
||||
border: 2rpx solid var(--border);
|
||||
|
||||
|
||||
|
||||
&.is-high {
|
||||
|
||||
border-color: rgba(220, 38, 38, 0.35);
|
||||
|
||||
background: var(--danger-light);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-metric-label {
|
||||
|
||||
display: block;
|
||||
|
||||
font-size: 28rpx;
|
||||
|
||||
color: var(--text-secondary);
|
||||
|
||||
margin-bottom: 12rpx;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-metric-value-row {
|
||||
|
||||
display: flex;
|
||||
|
||||
align-items: baseline;
|
||||
|
||||
gap: 8rpx;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-metric-value {
|
||||
|
||||
font-size: 56rpx;
|
||||
|
||||
font-weight: 800;
|
||||
|
||||
color: var(--text-primary);
|
||||
|
||||
line-height: 1.1;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-metric.is-high .elder-metric-value {
|
||||
|
||||
color: var(--danger);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-metric-unit {
|
||||
|
||||
font-size: 26rpx;
|
||||
|
||||
color: var(--text-muted);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-metric-flag {
|
||||
|
||||
display: block;
|
||||
|
||||
margin-top: 12rpx;
|
||||
|
||||
font-size: 26rpx;
|
||||
|
||||
color: var(--danger);
|
||||
|
||||
font-weight: 600;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-more-link {
|
||||
|
||||
margin: 0 32rpx 16rpx;
|
||||
|
||||
padding: 28rpx 32rpx;
|
||||
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
justify-content: space-between;
|
||||
|
||||
background: var(--surface);
|
||||
|
||||
border-radius: 24rpx;
|
||||
|
||||
border: 1rpx solid var(--border);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-more-title {
|
||||
|
||||
display: block;
|
||||
|
||||
font-size: 32rpx;
|
||||
|
||||
font-weight: 700;
|
||||
|
||||
color: var(--text-primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-more-sub {
|
||||
|
||||
display: block;
|
||||
|
||||
margin-top: 8rpx;
|
||||
|
||||
font-size: 26rpx;
|
||||
|
||||
color: var(--text-muted);
|
||||
|
||||
line-height: 1.45;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-more-arrow {
|
||||
|
||||
font-size: 48rpx;
|
||||
|
||||
color: var(--primary);
|
||||
|
||||
font-weight: 300;
|
||||
|
||||
padding-left: 16rpx;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .footer-tip {
|
||||
|
||||
text-align: center;
|
||||
|
||||
font-size: 26rpx;
|
||||
|
||||
color: var(--text-muted);
|
||||
|
||||
padding: 16rpx 32rpx 48rpx;
|
||||
|
||||
line-height: 1.6;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-chart-block .range-bar {
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.daily-page .glucose-history-block {
|
||||
|
||||
padding-bottom: 8rpx;
|
||||
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Google Stitch MCP · VitalMint Health
|
||||
* 设计 token 与 stitch-weekly.html tailwind.config 一一对应
|
||||
*/
|
||||
|
||||
.stitch-vitalmint {
|
||||
--primary: #006c49;
|
||||
--on-primary: #ffffff;
|
||||
--on-primary-fixed-variant: #005236;
|
||||
--primary-container: #10b981;
|
||||
--on-primary-container: #00422b;
|
||||
--inverse-primary: #4edea3;
|
||||
|
||||
--surface: #f4fbf4;
|
||||
--on-surface: #161d19;
|
||||
--on-surface-variant: #3c4a42;
|
||||
--surface-container-lowest: #ffffff;
|
||||
--surface-container-low: #eef6ee;
|
||||
--surface-container: #e8f0e9;
|
||||
--surface-container-high: #e3eae3;
|
||||
--surface-container-highest: #dde4dd;
|
||||
--surface-variant: #dde4dd;
|
||||
|
||||
--outline: #6c7a71;
|
||||
--outline-variant: #bbcabf;
|
||||
|
||||
--error: #ba1a1a;
|
||||
--on-error-container: #93000a;
|
||||
--error-container: #ffdad6;
|
||||
--tertiary: #a43a3a;
|
||||
--tertiary-container: #fc7c78;
|
||||
--on-tertiary: #ffffff;
|
||||
--on-tertiary-container: #711419;
|
||||
|
||||
--st-container-margin: 40rpx;
|
||||
--st-section-gap: 64rpx;
|
||||
--st-card-padding: 40rpx;
|
||||
--st-stack-gap: 32rpx;
|
||||
--st-inline-gap: 24rpx;
|
||||
--st-radius-2xl: 48rpx;
|
||||
--st-radius-xl: 24rpx;
|
||||
--st-radius-lg: 16rpx;
|
||||
--st-shadow-ambient: 0 20rpx 60rpx -20rpx rgba(0, 108, 73, 0.08);
|
||||
}
|
||||
|
||||
.weekly-page.stitch-vitalmint {
|
||||
min-height: 100vh;
|
||||
background: var(--surface);
|
||||
color: var(--on-surface);
|
||||
padding-bottom: calc(200rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
/* weekly.vue 录入弹窗 — 沿用 MCP 主色 */
|
||||
|
||||
.weekly-page .input-modal-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
backdrop-filter: blur(12px);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
animation: input-modal-mask-in 0.38s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.weekly-page .input-modal {
|
||||
width: 100%;
|
||||
max-width: 750rpx;
|
||||
background: var(--surface-container-lowest);
|
||||
border-radius: 36rpx 36rpx 0 0;
|
||||
padding: 16rpx 0 calc(20rpx + env(safe-area-inset-bottom));
|
||||
box-shadow: 0 -12rpx 36rpx rgba(15, 23, 42, 0.12);
|
||||
max-height: 88vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: input-modal-rise 0.46s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
@keyframes input-modal-mask-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes input-modal-rise {
|
||||
from { transform: translate3d(0, 100%, 0); opacity: 0.96; }
|
||||
to { transform: translate3d(0, 0, 0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes input-modal-content-in {
|
||||
from { opacity: 0; transform: translate3d(0, 24rpx, 0); }
|
||||
to { opacity: 1; transform: translate3d(0, 0, 0); }
|
||||
}
|
||||
|
||||
.weekly-page .input-modal-grip {
|
||||
width: 80rpx;
|
||||
height: 6rpx;
|
||||
border-radius: 999rpx;
|
||||
background: var(--surface-container-highest);
|
||||
margin: 0 auto 8rpx;
|
||||
}
|
||||
|
||||
.weekly-page .input-modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12rpx 32rpx 16rpx;
|
||||
border-bottom: 1rpx solid var(--surface-container-low);
|
||||
}
|
||||
|
||||
.weekly-page .input-modal-title {
|
||||
font-size: 40rpx;
|
||||
font-weight: 800;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.weekly-page .input-modal-sub {
|
||||
font-size: 26rpx;
|
||||
color: var(--on-surface-variant);
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.weekly-page .input-modal-close {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999rpx;
|
||||
background: var(--surface-container-low);
|
||||
}
|
||||
|
||||
.weekly-page .input-modal-close-icon {
|
||||
font-size: 42rpx;
|
||||
color: var(--outline);
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.weekly-page .phone-gate-body {
|
||||
padding: 32rpx;
|
||||
}
|
||||
|
||||
.weekly-page .phone-gate-error {
|
||||
margin-bottom: 20rpx;
|
||||
padding: 20rpx 22rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #fef2f2;
|
||||
border: 1rpx solid rgba(220, 38, 38, 0.2);
|
||||
box-sizing: border-box;
|
||||
|
||||
text {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: #991b1b;
|
||||
line-height: 1.45;
|
||||
}
|
||||
}
|
||||
|
||||
.weekly-page .phone-gate-tip {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
color: var(--on-surface-variant);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.weekly-page .phone-gate-gender-label {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.weekly-page .phone-gate-gender-row {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.weekly-page .phone-gate-gender-opt {
|
||||
flex: 1;
|
||||
min-height: 88rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--st-radius-xl);
|
||||
background: var(--surface-container-lowest);
|
||||
border: 2rpx solid var(--outline-variant);
|
||||
color: var(--on-surface-variant);
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.weekly-page .phone-gate-gender-opt.active {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.weekly-page .phone-gate-btn {
|
||||
width: 100%;
|
||||
min-height: 96rpx;
|
||||
line-height: 96rpx;
|
||||
border-radius: var(--st-radius-xl);
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.weekly-page .phone-gate-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.weekly-page .input-modal-loading {
|
||||
min-height: 520rpx;
|
||||
padding: 120rpx 0;
|
||||
text-align: center;
|
||||
color: var(--outline);
|
||||
font-size: 30rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.weekly-page .input-modal-body {
|
||||
flex: 1;
|
||||
min-height: 520rpx;
|
||||
padding: 20rpx 32rpx 28rpx;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.weekly-page .input-modal-body-ready {
|
||||
animation: input-modal-content-in 0.34s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.weekly-page .input-modal-tip {
|
||||
background: var(--surface-container-low);
|
||||
border: 1rpx dashed var(--outline-variant);
|
||||
border-radius: 14rpx;
|
||||
padding: 18rpx 20rpx;
|
||||
font-size: 26rpx;
|
||||
color: var(--on-surface-variant);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 22rpx;
|
||||
}
|
||||
|
||||
.weekly-page .input-section-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: var(--on-surface);
|
||||
margin: 18rpx 0 14rpx;
|
||||
}
|
||||
|
||||
.weekly-page .input-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
padding: 14rpx 0;
|
||||
border-bottom: 1rpx solid var(--surface-container-low);
|
||||
min-height: 88rpx;
|
||||
}
|
||||
|
||||
.weekly-page .input-field-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
width: 180rpx;
|
||||
font-size: 30rpx;
|
||||
color: var(--on-surface);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.weekly-page .input-field-dot {
|
||||
width: 14rpx;
|
||||
height: 14rpx;
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
|
||||
.weekly-page .input-field-dot-fasting {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.weekly-page .input-field-dot-postprandial {
|
||||
background: var(--tertiary-container);
|
||||
}
|
||||
|
||||
.weekly-page .input-field-dot-other {
|
||||
background: var(--outline);
|
||||
}
|
||||
|
||||
.weekly-page .input-field-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--surface);
|
||||
border-radius: 16rpx;
|
||||
padding: 18rpx 20rpx;
|
||||
min-height: 76rpx;
|
||||
border: 2rpx solid transparent;
|
||||
}
|
||||
|
||||
.weekly-page .input-field-input input {
|
||||
width: 100%;
|
||||
font-size: 34rpx;
|
||||
color: var(--on-surface);
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.weekly-page .input-placeholder {
|
||||
color: var(--outline);
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.weekly-page .input-field-textarea textarea {
|
||||
width: 100%;
|
||||
min-height: 140rpx;
|
||||
background: var(--surface);
|
||||
border-radius: 14rpx;
|
||||
padding: 20rpx 22rpx;
|
||||
font-size: 30rpx;
|
||||
color: var(--on-surface);
|
||||
border: 2rpx solid var(--outline-variant);
|
||||
}
|
||||
|
||||
.weekly-page .input-modal-actions {
|
||||
display: flex;
|
||||
gap: 18rpx;
|
||||
padding: 20rpx 32rpx 0;
|
||||
border-top: 1rpx solid var(--surface-container-low);
|
||||
}
|
||||
|
||||
.weekly-page .input-modal-btn {
|
||||
flex: 1;
|
||||
height: 100rpx;
|
||||
border-radius: var(--st-radius-xl);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.weekly-page .input-modal-btn.primary {
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
}
|
||||
|
||||
.weekly-page .input-modal-btn.danger {
|
||||
flex: 0 0 180rpx;
|
||||
background: var(--error-container);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.weekly-page .input-modal-btn.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,950 @@
|
||||
/**
|
||||
* weekly.vue — 与 code.html (Stitch Modern WeChat UI) 1:1
|
||||
*/
|
||||
|
||||
.weekly-page {
|
||||
--st-shadow-ambient: 0 24rpx 60rpx -20rpx rgba(0, 108, 73, 0.08), 0 8rpx 20rpx -8rpx rgba(0, 0, 0, 0.03);
|
||||
--st-shadow-cta: 0 16rpx 32rpx rgba(0, 108, 73, 0.2);
|
||||
--st-shadow-float: 0 16rpx 64rpx rgba(0, 108, 73, 0.15);
|
||||
--tab-dock-full-offset: calc(220rpx + env(safe-area-inset-bottom));
|
||||
padding-bottom: var(--tab-dock-full-offset);
|
||||
}
|
||||
|
||||
/* ===== Header ===== */
|
||||
.weekly-page .st-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: var(--st-container-margin);
|
||||
padding-right: var(--st-container-margin);
|
||||
padding-bottom: 32rpx;
|
||||
/* padding-top / padding-right 由 initHeaderSafeArea 动态设置 */
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.weekly-page .st-header-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.weekly-page .st-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--st-inline-gap);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.weekly-page .st-avatar {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-container);
|
||||
border: 4rpx solid var(--surface-container-lowest);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.weekly-page .st-greet {
|
||||
display: block;
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.27;
|
||||
color: var(--on-surface);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.weekly-page .st-status {
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
color: var(--on-surface-variant);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.weekly-page .st-voice-round {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.weekly-page .st-voice-round.disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.weekly-page .st-card-scroll {
|
||||
margin: 0 var(--st-container-margin) 8rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.weekly-page .st-card-row {
|
||||
display: inline-flex;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-card-chip {
|
||||
padding: 12rpx 28rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: var(--on-surface-variant);
|
||||
background: var(--surface-container-lowest);
|
||||
border: 1rpx solid var(--outline-variant);
|
||||
}
|
||||
|
||||
.weekly-page .st-card-chip.active {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: var(--on-primary);
|
||||
}
|
||||
|
||||
/* ===== 居家训练(DEV 内嵌) ===== */
|
||||
.weekly-page .st-training-section {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.weekly-page .st-training-section .dev-entry-inline--weekly {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.weekly-page .st-training-section .inline-scroll {
|
||||
margin-left: -4rpx;
|
||||
margin-right: -4rpx;
|
||||
width: calc(100% + 8rpx);
|
||||
}
|
||||
|
||||
.weekly-page .st-training-section .inline-card:first-child {
|
||||
border-color: rgba(0, 108, 73, 0.22);
|
||||
background: linear-gradient(160deg, rgba(0, 108, 73, 0.08) 0%, #f4f7f5 55%);
|
||||
}
|
||||
|
||||
/* ===== Main ===== */
|
||||
.weekly-page .st-main {
|
||||
padding: 0 var(--st-container-margin) 32rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 48rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-daily-context-error {
|
||||
padding: 24rpx 28rpx;
|
||||
border-radius: 24rpx;
|
||||
background: #fef2f2;
|
||||
border: 1rpx solid rgba(220, 38, 38, 0.22);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.weekly-page .st-daily-context-error-text {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #991b1b;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.weekly-page .st-main-spacer {
|
||||
height: 32rpx;
|
||||
}
|
||||
|
||||
/* CTA — rounded-2xl h-14 */
|
||||
.weekly-page .st-cta {
|
||||
width: 100%;
|
||||
height: 112rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16rpx;
|
||||
border-radius: var(--st-radius-2xl);
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
box-shadow: var(--st-shadow-cta);
|
||||
}
|
||||
|
||||
.weekly-page .st-cta:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* 糖分突袭游戏入口 */
|
||||
.weekly-page .st-game-entry {
|
||||
|
||||
margin-top: 24rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
border-radius: var(--st-radius-2xl);
|
||||
background: var(--surface-container-lowest);
|
||||
border: 1rpx solid rgba(0, 108, 73, 0.14);
|
||||
box-shadow: var(--st-shadow-ambient);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.weekly-page .st-game-entry:active {
|
||||
transform: scale(0.98);
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.weekly-page .st-game-entry-icon {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
border-radius: 24rpx;
|
||||
background: rgba(0, 108, 73, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.weekly-page .st-game-entry-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-game-entry-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: var(--on-surface);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.weekly-page .st-game-entry-sub {
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
color: var(--on-surface-variant, #64748b);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
/* ===== Chart card — rounded-3xl ===== */
|
||||
.weekly-page .st-chart-card {
|
||||
padding: 40rpx 10rpx 40rpx 10rpx;
|
||||
border-radius: 48rpx;
|
||||
background: var(--surface-container-lowest);
|
||||
border: 1rpx solid rgba(221, 228, 221, 0.3);
|
||||
box-shadow: var(--st-shadow-ambient);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12rpx;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-title {
|
||||
display: block;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-sub {
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-more-text {
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-more:active {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-legend {
|
||||
display: flex;
|
||||
gap: var(--st-inline-gap);
|
||||
}
|
||||
|
||||
.weekly-page .st-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
|
||||
.weekly-page .st-legend-item.inactive {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.weekly-page .st-dot {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.weekly-page .st-dot-fasting {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.weekly-page .st-dot-post {
|
||||
background: var(--tertiary);
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-summary {
|
||||
display: flex;
|
||||
padding: 32rpx;
|
||||
margin-bottom: 48rpx;
|
||||
border-radius: var(--st-radius-xl);
|
||||
background: var(--surface-container-low);
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-summary-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
border-right: 1rpx solid var(--surface-container-highest);
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-summary-item:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-summary-num {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-summary-num.is-high {
|
||||
color: var(--tertiary);
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-summary-num.is-good {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-summary-label {
|
||||
margin-top: 8rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-foot {
|
||||
padding: 20rpx 0 20rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-mock-hint {
|
||||
display: block;
|
||||
margin: 0;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: var(--outline);
|
||||
line-height: 1.35;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-area {
|
||||
position: relative;
|
||||
height: 360rpx;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-area:active {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-canvas {
|
||||
width: 100%;
|
||||
height: 360rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 识糖入口嵌在趋势卡内:轻量分隔,避免大块留白 */
|
||||
.weekly-page .st-chart-card .st-game-entry--in-card {
|
||||
margin-top: 12rpx;
|
||||
padding: 16rpx 20rpx;
|
||||
gap: 16rpx;
|
||||
border-radius: 24rpx;
|
||||
background: var(--surface-container-low);
|
||||
border: 1rpx solid rgba(0, 108, 73, 0.08);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-card .st-game-entry--in-card .st-game-entry-icon {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-card .st-game-entry--in-card .st-game-entry-title {
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-card .st-game-entry--in-card .st-game-entry-sub {
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-empty,
|
||||
.weekly-page .st-chart-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 26rpx;
|
||||
color: var(--outline);
|
||||
}
|
||||
|
||||
/* ===== 最新测量 ===== */
|
||||
.weekly-page .st-latest-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-latest-hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 320rpx;
|
||||
padding: 48rpx 40rpx;
|
||||
border-radius: 48rpx;
|
||||
background: var(--surface-container-lowest);
|
||||
box-shadow: var(--st-shadow-ambient);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.weekly-page .st-latest-hero.is-high {
|
||||
background: var(--error-container);
|
||||
color: var(--on-error-container);
|
||||
}
|
||||
|
||||
.weekly-page .st-latest-hero-pattern {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.1;
|
||||
background-image: radial-gradient(circle at 4rpx 4rpx, currentColor 2rpx, transparent 0);
|
||||
background-size: 32rpx 32rpx;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.weekly-page .st-latest-label {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-latest-value-row {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-latest-value {
|
||||
font-size: 112rpx;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.weekly-page .st-latest-hero.is-high .st-latest-value,
|
||||
.weekly-page .st-latest-hero.is-high .st-latest-unit {
|
||||
color: var(--on-error-container);
|
||||
}
|
||||
|
||||
.weekly-page .st-latest-unit {
|
||||
font-size: 32rpx;
|
||||
font-weight: 500;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.weekly-page .st-latest-flag {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 24rpx;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
padding: 8rpx 24rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.weekly-page .st-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-summary-card {
|
||||
padding: 32rpx;
|
||||
border-radius: var(--st-radius-2xl);
|
||||
background: var(--surface-container-lowest);
|
||||
border: 1rpx solid rgba(221, 228, 221, 0.5);
|
||||
box-shadow: var(--st-shadow-ambient);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-summary-label {
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
|
||||
.weekly-page .st-summary-value {
|
||||
font-size: 48rpx;
|
||||
font-weight: 700;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.weekly-page .st-summary-value.is-high {
|
||||
color: var(--tertiary);
|
||||
}
|
||||
|
||||
.weekly-page .st-summary-tag {
|
||||
align-self: flex-start;
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 20rpx;
|
||||
font-weight: 600;
|
||||
color: var(--tertiary);
|
||||
background: rgba(255, 218, 214, 0.5);
|
||||
}
|
||||
|
||||
/* ===== AI 饮食 ===== */
|
||||
.weekly-page .st-ai-section {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 40rpx;
|
||||
border-radius: 48rpx;
|
||||
background: linear-gradient(135deg, rgba(0, 108, 73, 0.1) 0%, rgba(0, 108, 73, 0.05) 100%);
|
||||
border: 1rpx solid rgba(0, 108, 73, 0.1);
|
||||
}
|
||||
|
||||
.weekly-page .st-ai-deco {
|
||||
position: absolute;
|
||||
width: 256rpx;
|
||||
height: 256rpx;
|
||||
top: -64rpx;
|
||||
right: -64rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(78, 222, 163, 0.2);
|
||||
filter: blur(40rpx);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.weekly-page .st-ai-head {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-ai-head-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-ai-icon {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.weekly-page .st-ai-icon.thinking {
|
||||
animation: st-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes st-pulse {
|
||||
50% { opacity: 0.65; }
|
||||
}
|
||||
|
||||
.weekly-page .st-ai-title {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.weekly-page .st-ai-refresh {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
padding: 12rpx 24rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.weekly-page .st-ai-refresh.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.weekly-page .st-ai-sub {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
margin-bottom: 32rpx;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.43;
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
|
||||
.weekly-page .st-meal-list {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-meal-glass {
|
||||
padding: 32rpx;
|
||||
border-radius: var(--st-radius-2xl);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border: 1rpx solid #ffffff;
|
||||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.weekly-page .st-meal-glass--drinks {
|
||||
background: rgba(224, 242, 254, 0.65);
|
||||
border-color: rgba(14, 165, 233, 0.15);
|
||||
}
|
||||
|
||||
.weekly-page .st-meal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-meal-label {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.weekly-page .st-meal-text {
|
||||
font-size: 28rpx;
|
||||
line-height: 1.43;
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
|
||||
.weekly-page .st-skeleton-line {
|
||||
height: 28rpx;
|
||||
margin-bottom: 16rpx;
|
||||
border-radius: 8rpx;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.weekly-page .st-skeleton-hint {
|
||||
font-size: 26rpx;
|
||||
color: var(--outline);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ===== 底部浮动输入 ===== */
|
||||
.weekly-page .st-float-ask {
|
||||
position: fixed;
|
||||
left: var(--st-container-margin);
|
||||
right: var(--st-container-margin);
|
||||
bottom: var(--tab-dock-full-offset);
|
||||
z-index: 210;
|
||||
}
|
||||
|
||||
.weekly-page .st-float-close {
|
||||
position: absolute;
|
||||
top: -18rpx;
|
||||
right: -6rpx;
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.weekly-page .st-float-close-icon {
|
||||
color: #ffffff;
|
||||
font-size: 30rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.weekly-page .st-float-reopen {
|
||||
position: fixed;
|
||||
right: var(--st-container-margin);
|
||||
bottom: var(--tab-dock-full-offset);
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(180deg, #2d7340 0%, #006c49 100%);
|
||||
box-shadow: var(--st-shadow-float);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 210;
|
||||
}
|
||||
|
||||
.weekly-page .st-float-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
padding: 16rpx 16rpx 16rpx 40rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(20rpx);
|
||||
border: 1rpx solid rgba(0, 108, 73, 0.1);
|
||||
box-shadow: var(--st-shadow-float);
|
||||
}
|
||||
|
||||
.weekly-page .st-float-icon {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 108, 73, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.weekly-page .st-float-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 64rpx;
|
||||
font-size: 28rpx;
|
||||
color: var(--on-surface);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.weekly-page .st-float-placeholder {
|
||||
color: rgba(60, 74, 66, 0.6);
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-float-mic {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 108, 73, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.2s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.weekly-page .st-float-mic.listening {
|
||||
background: var(--primary);
|
||||
animation: st-float-mic-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.weekly-page .st-float-mic.disabled {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.weekly-page .st-float-mic:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
.weekly-page .st-float-hold-hint {
|
||||
margin-top: 12rpx;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
color: var(--primary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.weekly-page .st-voice-overlay {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 3000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.32);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.weekly-page .st-voice-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
padding: 56rpx 64rpx;
|
||||
border-radius: 32rpx;
|
||||
background: rgba(20, 24, 22, 0.86);
|
||||
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.weekly-page .st-voice-icon {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(180deg, #2d7340 0%, #006c49 100%);
|
||||
}
|
||||
|
||||
.weekly-page .st-voice-icon.active {
|
||||
animation: st-voice-pulse 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes st-voice-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(0, 108, 73, 0.45); }
|
||||
50% { box-shadow: 0 0 0 20rpx rgba(0, 108, 73, 0); }
|
||||
}
|
||||
|
||||
.weekly-page .st-voice-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.weekly-page .st-voice-sub {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
@keyframes st-float-mic-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(0, 108, 73, 0.35); }
|
||||
50% { box-shadow: 0 0 0 12rpx rgba(0, 108, 73, 0); }
|
||||
}
|
||||
|
||||
.weekly-page .st-float-send {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4rpx 12rpx rgba(0, 108, 73, 0.2);
|
||||
line-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.weekly-page .st-float-send.disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.weekly-page .st-float-send:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.weekly-page .st-float-result {
|
||||
margin-top: 16rpx;
|
||||
padding: 24rpx 32rpx;
|
||||
border-radius: var(--st-radius-xl);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1rpx solid var(--outline-variant);
|
||||
box-shadow: var(--st-shadow-ambient);
|
||||
}
|
||||
|
||||
.weekly-page .st-float-badge {
|
||||
display: inline-block;
|
||||
margin-bottom: 8rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
background: rgba(0, 108, 73, 0.1);
|
||||
}
|
||||
|
||||
.weekly-page .st-float-advice {
|
||||
font-size: 28rpx;
|
||||
line-height: 1.5;
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
|
||||
.weekly-page .ai-stream-cursor {
|
||||
animation: st-blink 0.8s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes st-blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.weekly-page .chart-tip {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
padding: 12rpx 16rpx;
|
||||
border-radius: 12rpx;
|
||||
background: rgba(22, 29, 25, 0.88);
|
||||
color: #fff;
|
||||
font-size: 22rpx;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.weekly-page .chart-tip-date {
|
||||
font-weight: 700;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.weekly-page .chart-tip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/** 拼接 API 根路径与相对路径,避免双斜杠 */
|
||||
export function joinApiUrl(base, path) {
|
||||
const b = String(base || '').replace(/\/+$/, '')
|
||||
const p = String(path || '').replace(/^\/+/, '')
|
||||
return p ? `${b}/${p}` : b
|
||||
}
|
||||
|
||||
function appendQuery(url, data) {
|
||||
if (!data || typeof data !== 'object') return url
|
||||
const keys = Object.keys(data)
|
||||
if (!keys.length) return url
|
||||
const qs = keys
|
||||
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(data[k] ?? '')}`)
|
||||
.join('&')
|
||||
return url + (url.includes('?') ? '&' : '?') + qs
|
||||
}
|
||||
|
||||
function decodeChunkData(data) {
|
||||
if (typeof data === 'string') return data
|
||||
if (!data) return ''
|
||||
if (typeof TextDecoder !== 'undefined' && data instanceof ArrayBuffer) {
|
||||
return new TextDecoder('utf-8').decode(new Uint8Array(data))
|
||||
}
|
||||
if (data instanceof ArrayBuffer) {
|
||||
const bytes = new Uint8Array(data)
|
||||
let str = ''
|
||||
for (let i = 0; i < bytes.length; i++) str += String.fromCharCode(bytes[i])
|
||||
try {
|
||||
return decodeURIComponent(escape(str))
|
||||
} catch (e) {
|
||||
return str
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 从 SSE 文本缓冲中解析并消费完整事件 */
|
||||
export function consumeSseBuffer(buffer, onEvent) {
|
||||
let rest = buffer
|
||||
let idx = rest.indexOf('\n\n')
|
||||
while (idx !== -1) {
|
||||
const block = rest.slice(0, idx)
|
||||
rest = rest.slice(idx + 2)
|
||||
let event = 'message'
|
||||
let dataLine = ''
|
||||
block.split('\n').forEach((line) => {
|
||||
if (line.startsWith('event:')) event = line.slice(6).trim()
|
||||
else if (line.startsWith('data:')) dataLine += line.slice(5).trim()
|
||||
})
|
||||
if (dataLine) {
|
||||
try {
|
||||
onEvent(event, JSON.parse(dataLine))
|
||||
} catch (e) {
|
||||
onEvent(event, dataLine)
|
||||
}
|
||||
}
|
||||
idx = rest.indexOf('\n\n')
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
/** 从流式纯文本中提取三餐(支持未写完的最后一行) */
|
||||
export function parsePartialDietPlainText(text) {
|
||||
const out = {}
|
||||
if (!text) return out
|
||||
|
||||
const lineRe = /^(早餐|喝的|午餐|晚餐|提示|少碰)[::]\s*(.*)$/gm
|
||||
let match
|
||||
while ((match = lineRe.exec(text)) !== null) {
|
||||
const key = { '早餐': 'breakfast', '喝的': 'drinks', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'avoid' }[match[1]]
|
||||
const val = (match[2] || '').trim()
|
||||
if (key === 'avoid') {
|
||||
out.avoid = val.split(/[、,,;;\s]+/).map((s) => s.trim()).filter(Boolean)
|
||||
} else {
|
||||
out[key] = val
|
||||
}
|
||||
}
|
||||
|
||||
const tail = text.match(/(?:^|\n)(早餐|喝的|午餐|晚餐|提示|少碰)[::]\s*([^\n]*)$/)
|
||||
if (tail) {
|
||||
const key = { '早餐': 'breakfast', '喝的': 'drinks', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'avoid' }[tail[1]]
|
||||
const val = (tail[2] || '').trim()
|
||||
if (key === 'avoid') {
|
||||
if (val) out.avoid = val.split(/[、,,;;\s]+/).map((s) => s.trim()).filter(Boolean)
|
||||
} else {
|
||||
out[key] = val
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/** @deprecated 使用 parsePartialDietPlainText */
|
||||
export function parsePartialDietJson(text) {
|
||||
return parsePartialDietPlainText(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE 流式请求(微信小程序 enableChunked;其它端走 fallback)
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.baseUrl
|
||||
* @param {string} opts.url
|
||||
* @param {string} [opts.method]
|
||||
* @param {object} [opts.data]
|
||||
* @param {function(string, object):void} opts.onEvent
|
||||
* @param {function():Promise<object>} [opts.fallback] 不支持流式时的降级请求
|
||||
*/
|
||||
export function requestAiStream(opts) {
|
||||
const {
|
||||
baseUrl,
|
||||
url,
|
||||
method = 'GET',
|
||||
data = {},
|
||||
onEvent,
|
||||
fallback
|
||||
} = opts
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
return new Promise((resolve, reject) => {
|
||||
const token = uni.getStorageSync('token') || ''
|
||||
let sseBuffer = ''
|
||||
let requestUrl = joinApiUrl(baseUrl, url)
|
||||
if (method === 'GET') {
|
||||
requestUrl = appendQuery(requestUrl, data)
|
||||
}
|
||||
|
||||
const task = wx.request({
|
||||
url: requestUrl,
|
||||
method,
|
||||
data: method === 'POST' ? data : {},
|
||||
enableChunked: true,
|
||||
timeout: 60000,
|
||||
header: {
|
||||
token,
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
'Cache-Control': 'no-cache'
|
||||
},
|
||||
success: (res) => {
|
||||
if (res && res.data) {
|
||||
sseBuffer += decodeChunkData(res.data)
|
||||
sseBuffer = consumeSseBuffer(sseBuffer, onEvent)
|
||||
}
|
||||
resolve(res)
|
||||
},
|
||||
fail: (err) => reject(err)
|
||||
})
|
||||
|
||||
if (task && typeof task.onChunkReceived === 'function') {
|
||||
task.onChunkReceived((res) => {
|
||||
sseBuffer += decodeChunkData(res.data)
|
||||
sseBuffer = consumeSseBuffer(sseBuffer, onEvent)
|
||||
})
|
||||
} else if (typeof fallback === 'function') {
|
||||
fallback().then(resolve).catch(reject)
|
||||
} else {
|
||||
reject(new Error('当前环境不支持流式请求'))
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
|
||||
// #ifndef MP-WEIXIN
|
||||
if (typeof fallback === 'function') {
|
||||
return fallback()
|
||||
}
|
||||
return Promise.reject(new Error('当前环境不支持流式请求'))
|
||||
// #endif
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 将 SVG 字符串转为小程序可用的 data URL(base64 在真机上更稳定)
|
||||
*/
|
||||
|
||||
function utf8ToBytes(str) {
|
||||
const encoded = encodeURIComponent(str)
|
||||
const bytes = []
|
||||
for (let i = 0; i < encoded.length; i++) {
|
||||
if (encoded.charCodeAt(i) === 37) {
|
||||
bytes.push(parseInt(encoded.substring(i + 1, i + 3), 16))
|
||||
i += 2
|
||||
} else {
|
||||
bytes.push(encoded.charCodeAt(i))
|
||||
}
|
||||
}
|
||||
return new Uint8Array(bytes)
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes) {
|
||||
if (typeof uni !== 'undefined' && typeof uni.arrayBufferToBase64 === 'function') {
|
||||
return uni.arrayBufferToBase64(bytes.buffer)
|
||||
}
|
||||
if (typeof btoa !== 'undefined') {
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i])
|
||||
}
|
||||
return btoa(binary)
|
||||
}
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
||||
let result = ''
|
||||
for (let i = 0; i < bytes.length; i += 3) {
|
||||
const a = bytes[i]
|
||||
const b = i + 1 < bytes.length ? bytes[i + 1] : 0
|
||||
const c = i + 2 < bytes.length ? bytes[i + 2] : 0
|
||||
result += chars[a >> 2]
|
||||
result += chars[((a & 3) << 4) | (b >> 4)]
|
||||
result += i + 1 < bytes.length ? chars[((b & 15) << 2) | (c >> 6)] : '='
|
||||
result += i + 2 < bytes.length ? chars[c & 63] : '='
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function svgToDataUrl(svg) {
|
||||
const normalized = String(svg || '').trim()
|
||||
if (!normalized) return ''
|
||||
const base64 = bytesToBase64(utf8ToBytes(normalized))
|
||||
return `data:image/svg+xml;base64,${base64}`
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/** 将接口 msg / 异常对象转为可展示的字符串,避免 [object Object] */
|
||||
export function formatUserMessage(msg, fallback = '') {
|
||||
if (msg == null || msg === '') return fallback
|
||||
if (typeof msg === 'string') return msg
|
||||
if (typeof msg === 'number' || typeof msg === 'boolean') return String(msg)
|
||||
if (Array.isArray(msg)) {
|
||||
const parts = msg.map((item) => {
|
||||
if (item == null) return ''
|
||||
if (typeof item === 'string') return item
|
||||
if (typeof item === 'number' || typeof item === 'boolean') return String(item)
|
||||
if (typeof item === 'object') {
|
||||
return item.msg || item.message || item.error || item.title || item.label || ''
|
||||
}
|
||||
return String(item)
|
||||
}).filter(Boolean)
|
||||
return parts.length ? parts.join(';') : fallback
|
||||
}
|
||||
if (typeof msg === 'object') {
|
||||
return msg.msg || msg.message || msg.error || msg.title || msg.label || fallback
|
||||
}
|
||||
return String(msg)
|
||||
}
|
||||
|
||||
export function showUserToast(title, options = {}) {
|
||||
const text = formatUserMessage(title, '')
|
||||
if (!text) return
|
||||
uni.showToast({
|
||||
title: text,
|
||||
icon: options.icon || 'none',
|
||||
duration: options.duration
|
||||
})
|
||||
}
|
||||
|
||||
export function formatDate(date) {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
export function parseDate(str) {
|
||||
if (!str) return new Date()
|
||||
const [y, m, d] = str.split('-').map(Number)
|
||||
return new Date(y, (m || 1) - 1, d || 1)
|
||||
}
|
||||
|
||||
export function toNumber(v) {
|
||||
if (v === null || v === undefined || v === '') return null
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) && n !== 0 ? n : null
|
||||
}
|
||||
|
||||
export function getWeekday(dateStr) {
|
||||
const w = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||
return w[parseDate(dateStr).getDay()]
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/** 控糖树等级配置(前后端算法需保持一致:每级 50 积分,最高 Lv.9) */
|
||||
export const TREE_MAX_LEVEL = 9
|
||||
export const TREE_XP_PER_LEVEL = 50
|
||||
|
||||
export const TREE_LEVELS = [
|
||||
{ level: 0, name: '种子眠', emoji: '🫘', mood: '困困', desc: '稳糖种子在土里打盹' },
|
||||
{ level: 1, name: '破土芽', emoji: '🌱', mood: '探头', desc: '探出第一抹新绿' },
|
||||
{ level: 2, name: '展两叶', emoji: '🌿', mood: '好奇', desc: '两片嫩叶迎风展' },
|
||||
{ level: 3, name: '小树苗', emoji: '🪴', mood: '精神', desc: '身子骨硬朗起来' },
|
||||
{ level: 4, name: '青枝繁', emoji: '🌳', mood: '茁壮', desc: '枝叶渐密,元气足' },
|
||||
{ level: 5, name: '拔节高', emoji: '🌲', mood: '挺拔', desc: '一节一节往上蹿' },
|
||||
{ level: 6, name: '稳糖冠', emoji: '💚', mood: '沉稳', desc: '树冠成形,习惯成自然' },
|
||||
{ level: 7, name: '初绽香', emoji: '🌸', mood: '开心', desc: '枝头冒出第一朵花' },
|
||||
{ level: 8, name: '漫开花', emoji: '🌺', mood: '灿烂', desc: '花开满枝,越记越稳' },
|
||||
{ level: 9, name: '圆满树', emoji: '🏆', mood: '荣耀', desc: '满级大树,习惯大师' }
|
||||
]
|
||||
|
||||
const WHISPERS_BY_LEVEL = {
|
||||
0: ['种子在睡觉,记一笔就醒啦。', '今天浇第一滴水,芽就要冒出来。'],
|
||||
1: ['破土啦!再坚持几天就长高。', '小芽最喜欢规律的记录了。'],
|
||||
2: ['两片叶子为你鼓掌。', '空腹餐后都记全,我会长得更快。'],
|
||||
3: ['我已经是一棵小树啦。', '家人点赞的时候,我也会发光。'],
|
||||
4: ['枝叶越来越密,您真棒。', '连续记录,我会开出更多叶子。'],
|
||||
5: ['拔节中!习惯比完美更重要。', '再浇一点水,我就更高啦。'],
|
||||
6: ['习惯成自然,树冠成形啦。', '您今天的坚持,小树都记得。'],
|
||||
7: ['开花啦!闻到春天的味道了吗?', '稳糖花只开给坚持的人。'],
|
||||
8: ['满树花香,您已是控糖达人。', '明天继续来,花儿会更艳。'],
|
||||
9: ['满级大树陪您一路稳糖。', '圆满不是终点,习惯才是。']
|
||||
}
|
||||
|
||||
export function calcTreeFromPoints(points) {
|
||||
const pts = Math.max(0, Number(points) || 0)
|
||||
const level = Math.min(TREE_MAX_LEVEL, Math.floor(pts / TREE_XP_PER_LEVEL))
|
||||
const xpInLevel = level >= TREE_MAX_LEVEL ? TREE_XP_PER_LEVEL : pts % TREE_XP_PER_LEVEL
|
||||
const progress = level >= TREE_MAX_LEVEL ? 100 : Math.round((xpInLevel / TREE_XP_PER_LEVEL) * 100)
|
||||
const meta = TREE_LEVELS[level] || TREE_LEVELS[0]
|
||||
const nextMeta = level < TREE_MAX_LEVEL ? TREE_LEVELS[level + 1] : null
|
||||
const pointsToNext = level >= TREE_MAX_LEVEL ? 0 : TREE_XP_PER_LEVEL - xpInLevel
|
||||
return {
|
||||
level,
|
||||
progress,
|
||||
name: meta.name,
|
||||
emoji: meta.emoji,
|
||||
mood: meta.mood,
|
||||
desc: meta.desc,
|
||||
xpInLevel,
|
||||
xpNeed: TREE_XP_PER_LEVEL,
|
||||
pointsToNext,
|
||||
nextName: nextMeta?.name || '',
|
||||
isMax: level >= TREE_MAX_LEVEL
|
||||
}
|
||||
}
|
||||
|
||||
export function pickTreeWhisper(level) {
|
||||
const lv = Math.min(TREE_MAX_LEVEL, Math.max(0, Number(level) || 0))
|
||||
const list = WHISPERS_BY_LEVEL[lv] || WHISPERS_BY_LEVEL[0]
|
||||
return list[Math.floor(Math.random() * list.length)]
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
|
||||
export interface MetronomeOptions {
|
||||
initialBpm?: number
|
||||
bpmMin?: number
|
||||
bpmMax?: number
|
||||
clickSrc?: string
|
||||
accentSrc?: string
|
||||
accentEvery?: number
|
||||
poolSize?: number
|
||||
volume?: number // 节拍音音量 0-1
|
||||
silent?: boolean // 静音模式:只驱动节拍回调,不发声(如握力环只需视觉节拍)
|
||||
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
|
||||
private targetVolume = 1
|
||||
|
||||
constructor(
|
||||
private src: string,
|
||||
private size: number,
|
||||
volume = 1,
|
||||
) {
|
||||
this.targetVolume = volume
|
||||
}
|
||||
|
||||
private create() {
|
||||
for (let i = 0; i < this.size; i++) {
|
||||
const ctx = uni.createInnerAudioContext()
|
||||
ctx.src = this.src
|
||||
ctx.obeyMuteSwitch = false
|
||||
ctx.autoplay = false
|
||||
ctx.volume = this.targetVolume
|
||||
this.list.push(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
setVolume(volume: number) {
|
||||
this.targetVolume = Math.max(0, Math.min(1, volume))
|
||||
this.list.forEach((ctx) => {
|
||||
try {
|
||||
ctx.volume = this.targetVolume
|
||||
} catch (_) {}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 预热:短暂播放,让音频文件下载/解码到内存
|
||||
* 真正首次 play 不会再卡冷启动
|
||||
*/
|
||||
warmUp() {
|
||||
if (this.warmedUp) return
|
||||
if (this.list.length === 0) this.create()
|
||||
|
||||
this.list.forEach((ctx) => {
|
||||
try {
|
||||
ctx.volume = 0 // 静音预热,避免进入页面时响声
|
||||
ctx.play()
|
||||
setTimeout(() => {
|
||||
try {
|
||||
ctx.stop()
|
||||
ctx.volume = this.targetVolume // 恢复目标音量
|
||||
} 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 {
|
||||
// 强制停止所有正在播放的音频,避免重叠
|
||||
this.list.forEach(c => {
|
||||
try { c.stop() } catch (_) {}
|
||||
})
|
||||
// iOS 上 seek(0) + play() 不一定能从头播放;stop() + play() 更稳
|
||||
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,
|
||||
volume = 1,
|
||||
silent = false,
|
||||
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 (silent) return
|
||||
if (!clickPool) {
|
||||
clickPool = new AudioPool(clickSrc, poolSize, volume)
|
||||
clickPool.warmUp()
|
||||
}
|
||||
if (accentSrc && !accentPool) {
|
||||
accentPool = new AudioPool(accentSrc, Math.max(2, Math.ceil(poolSize / 2)), volume)
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态更新音频源(用于切换音色)
|
||||
*/
|
||||
const updateAudioSrc = (newClickSrc: string, newAccentSrc?: string) => {
|
||||
const wasPlaying = isPlaying.value
|
||||
if (wasPlaying) stop()
|
||||
|
||||
// 销毁旧的音频池
|
||||
clickPool?.destroy()
|
||||
accentPool?.destroy()
|
||||
clickPool = null
|
||||
accentPool = null
|
||||
|
||||
// 创建新的音频池(静默预热,不播放)
|
||||
clickPool = new AudioPool(newClickSrc, poolSize)
|
||||
if (newAccentSrc) {
|
||||
accentPool = new AudioPool(newAccentSrc, Math.max(2, Math.ceil(poolSize / 2)))
|
||||
}
|
||||
|
||||
// 播放一次预览音效(适中音量)
|
||||
const previewCtx = uni.createInnerAudioContext()
|
||||
previewCtx.src = newClickSrc
|
||||
previewCtx.obeyMuteSwitch = false
|
||||
previewCtx.volume = 0.4
|
||||
try {
|
||||
previewCtx.play()
|
||||
setTimeout(() => {
|
||||
try {
|
||||
previewCtx.destroy?.()
|
||||
} catch (_) {}
|
||||
}, 500)
|
||||
} catch (_) {}
|
||||
|
||||
// 恢复播放状态
|
||||
if (wasPlaying) start()
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动预加载(推荐在页面 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,
|
||||
updateAudioSrc,
|
||||
preload,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
<template>
|
||||
<view class="crush-canvas-container">
|
||||
<canvas
|
||||
type="2d"
|
||||
id="crush"
|
||||
class="crush-canvas"
|
||||
></canvas>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onBeforeUnmount, getCurrentInstance } from 'vue'
|
||||
|
||||
// ============================================================
|
||||
// 类型定义
|
||||
// ============================================================
|
||||
|
||||
type CrushItemType = 'egg' | 'walnut' | 'can' | 'balloon'
|
||||
type ParticleShape = 'circle' | 'square' | 'triangle' | 'rect'
|
||||
|
||||
// 碎片(主体飞溅物)
|
||||
interface Debris {
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
vy: number
|
||||
size: number
|
||||
color: string
|
||||
shape: ParticleShape
|
||||
rotation: number
|
||||
rotationSpeed: number
|
||||
life: number
|
||||
decay: number
|
||||
gravity: number
|
||||
glow: boolean
|
||||
swing: number // 水平摇摆幅度(confetti 彩纸用),0 表示不摇摆
|
||||
age: number
|
||||
}
|
||||
|
||||
// 火花(细小高速亮点,带拖尾)
|
||||
interface Spark {
|
||||
x: number
|
||||
y: number
|
||||
px: number // 上一帧位置(拖尾起点)
|
||||
py: number
|
||||
vx: number
|
||||
vy: number
|
||||
size: number
|
||||
color: string
|
||||
life: number
|
||||
decay: number
|
||||
gravity: number
|
||||
}
|
||||
|
||||
// 闪烁星光(四角星高光)
|
||||
interface Sparkle {
|
||||
x: number
|
||||
y: number
|
||||
size: number
|
||||
rotation: number
|
||||
spin: number
|
||||
color: string
|
||||
life: number
|
||||
decay: number
|
||||
}
|
||||
|
||||
// 中心爆闪
|
||||
interface Flash {
|
||||
x: number
|
||||
y: number
|
||||
radius: number
|
||||
maxRadius: number
|
||||
color: string
|
||||
life: number
|
||||
decay: number
|
||||
}
|
||||
|
||||
interface Shockwave {
|
||||
x: number
|
||||
y: number
|
||||
radius: number
|
||||
maxRadius: number
|
||||
color: string
|
||||
width: number
|
||||
life: number
|
||||
decay: number
|
||||
}
|
||||
|
||||
interface CrushItemConfig {
|
||||
colors: string[] // 碎片主体色
|
||||
sparkColors: string[] // 火花/高光色(偏亮)
|
||||
shapes: ParticleShape[]
|
||||
debrisCount: number
|
||||
sparkCount: number
|
||||
sparkleCount: number
|
||||
speedMin: number
|
||||
speedMax: number
|
||||
sizeMin: number
|
||||
sizeMax: number
|
||||
gravity: number
|
||||
flashColor: string // 中心爆闪核心色
|
||||
shockwaveColor: string
|
||||
confetti?: boolean // balloon: 碎片改为彩纸飘落
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 物品差异化配置
|
||||
// ============================================================
|
||||
|
||||
const CRUSH_ITEMS: Record<CrushItemType, CrushItemConfig> = {
|
||||
egg: {
|
||||
colors: ['#FFEB3B', '#FFF59D', '#FFFFFF', '#FFD54F', '#FFC107'],
|
||||
sparkColors: ['#FFFFFF', '#FFF9C4', '#FFEE58'],
|
||||
shapes: ['circle', 'circle', 'circle'],
|
||||
debrisCount: 20,
|
||||
sparkCount: 16,
|
||||
sparkleCount: 5,
|
||||
speedMin: 3,
|
||||
speedMax: 6,
|
||||
sizeMin: 4,
|
||||
sizeMax: 10,
|
||||
gravity: 0.34,
|
||||
flashColor: '#FFFDE7',
|
||||
shockwaveColor: 'rgba(255, 224, 130, 0.85)',
|
||||
},
|
||||
walnut: {
|
||||
colors: ['#5D4037', '#795548', '#8D6E63', '#A1887F', '#3E2723'],
|
||||
sparkColors: ['#FFCC80', '#FFB74D', '#FFE0B2'],
|
||||
shapes: ['square', 'triangle', 'rect'],
|
||||
debrisCount: 22,
|
||||
sparkCount: 18,
|
||||
sparkleCount: 5,
|
||||
speedMin: 2.8,
|
||||
speedMax: 5,
|
||||
sizeMin: 4,
|
||||
sizeMax: 9,
|
||||
gravity: 0.42,
|
||||
flashColor: '#FFE0B2',
|
||||
shockwaveColor: 'rgba(255, 167, 38, 0.8)',
|
||||
},
|
||||
can: {
|
||||
colors: ['#90A4AE', '#CFD8DC', '#B0BEC5', '#78909C', '#ECEFF1', '#607D8B'],
|
||||
sparkColors: ['#FFFFFF', '#E1F5FE', '#B3E5FC'],
|
||||
shapes: ['rect', 'rect', 'triangle', 'square'],
|
||||
debrisCount: 24,
|
||||
sparkCount: 20,
|
||||
sparkleCount: 6,
|
||||
speedMin: 4,
|
||||
speedMax: 7,
|
||||
sizeMin: 3,
|
||||
sizeMax: 11,
|
||||
gravity: 0.36,
|
||||
flashColor: '#FFFFFF',
|
||||
shockwaveColor: 'rgba(207, 216, 220, 0.9)',
|
||||
},
|
||||
balloon: {
|
||||
colors: ['#FF5252', '#FF4081', '#E040FB', '#7C4DFF', '#536DFE', '#448AFF', '#FFEB3B', '#69F0AE', '#FF6E40'],
|
||||
sparkColors: ['#FFFFFF', '#FF80AB', '#82B1FF', '#FFFF8D'],
|
||||
shapes: ['rect', 'rect', 'square'],
|
||||
debrisCount: 28,
|
||||
sparkCount: 18,
|
||||
sparkleCount: 8,
|
||||
speedMin: 4,
|
||||
speedMax: 7.5,
|
||||
sizeMin: 4,
|
||||
sizeMax: 8,
|
||||
gravity: 0.14,
|
||||
flashColor: '#FCE4EC',
|
||||
shockwaveColor: 'rgba(255, 64, 129, 0.85)',
|
||||
confetti: true,
|
||||
},
|
||||
}
|
||||
|
||||
const PHYSICS = {
|
||||
AIR_RESISTANCE: 0.985,
|
||||
SPARK_RESISTANCE: 0.92,
|
||||
DEBRIS_DECAY: 0.018,
|
||||
SPARK_DECAY: 0.035,
|
||||
SPARKLE_DECAY: 0.045,
|
||||
FLASH_DECAY: 0.11,
|
||||
SHOCKWAVE_DECAY: 0.045,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 状态
|
||||
// ============================================================
|
||||
|
||||
const canvasNode = ref<any>(null)
|
||||
const ctx = ref<any>(null)
|
||||
const canvasWidth = ref<number>(240)
|
||||
const canvasHeight = ref<number>(240)
|
||||
const dpr = ref<number>(1)
|
||||
|
||||
const rafId = ref<number | null>(null)
|
||||
const renderRunning = ref<boolean>(false)
|
||||
|
||||
const debrisList = ref<Debris[]>([])
|
||||
const sparkList = ref<Spark[]>([])
|
||||
const sparkleList = ref<Sparkle[]>([])
|
||||
const flashList = ref<Flash[]>([])
|
||||
const shockwaves = ref<Shockwave[]>([])
|
||||
|
||||
const instance = getCurrentInstance()
|
||||
|
||||
// 由父级 prop 信号驱动触发特效(避免跨组件 ref 调用, 小程序端更稳)
|
||||
const props = defineProps<{
|
||||
crushSignal?: { type: CrushItemType; nonce: number } | null
|
||||
}>()
|
||||
|
||||
watch(
|
||||
() => props.crushSignal,
|
||||
(sig) => {
|
||||
if (sig) triggerCrush(sig.type)
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
initCanvas()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 初始化 Canvas 2D
|
||||
// ============================================================
|
||||
|
||||
function initCanvas() {
|
||||
setTimeout(() => {
|
||||
if (!instance) return
|
||||
|
||||
const query = uni.createSelectorQuery().in(instance)
|
||||
query.select('#crush')
|
||||
.fields({ node: true, size: true } as any)
|
||||
.exec((res: any[]) => {
|
||||
if (!res || !res[0] || !res[0].node) {
|
||||
// 降级:如果获取不到 node(非微信平台),不渲染
|
||||
console.warn('[crush-canvas] canvas 2d not supported on this platform')
|
||||
return
|
||||
}
|
||||
|
||||
const canvas = res[0].node
|
||||
const width = res[0].width
|
||||
const height = res[0].height
|
||||
|
||||
// 获取设备像素比
|
||||
const systemInfo = uni.getSystemInfoSync()
|
||||
const pixelRatio = systemInfo.pixelRatio || 1
|
||||
|
||||
// 设置 canvas 物理像素尺寸(高清)
|
||||
canvas.width = width * pixelRatio
|
||||
canvas.height = height * pixelRatio
|
||||
|
||||
// 获取 2D 上下文
|
||||
const context = canvas.getContext('2d')
|
||||
context.scale(pixelRatio, pixelRatio)
|
||||
|
||||
// 保存状态
|
||||
canvasNode.value = canvas
|
||||
ctx.value = context
|
||||
canvasWidth.value = width
|
||||
canvasHeight.value = height
|
||||
dpr.value = pixelRatio
|
||||
})
|
||||
}, 200)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 触发捏碎特效
|
||||
// ============================================================
|
||||
|
||||
function triggerCrush(itemType: CrushItemType) {
|
||||
if (!ctx.value || !canvasNode.value) return
|
||||
|
||||
const item = CRUSH_ITEMS[itemType]
|
||||
if (!item) return
|
||||
|
||||
const centerX = canvasWidth.value / 2
|
||||
const centerY = canvasHeight.value / 2
|
||||
const maxR = Math.min(canvasWidth.value, canvasHeight.value)
|
||||
|
||||
// 1. 中心爆闪
|
||||
flashList.value.push({
|
||||
x: centerX,
|
||||
y: centerY,
|
||||
radius: maxR * 0.12,
|
||||
maxRadius: maxR * 0.62,
|
||||
color: item.flashColor,
|
||||
life: 1.0,
|
||||
decay: PHYSICS.FLASH_DECAY,
|
||||
})
|
||||
|
||||
// 2. 双层冲击波
|
||||
shockwaves.value.push({
|
||||
x: centerX, y: centerY,
|
||||
radius: 12, maxRadius: maxR * 0.62,
|
||||
color: item.shockwaveColor, width: 5,
|
||||
life: 1.0, decay: PHYSICS.SHOCKWAVE_DECAY,
|
||||
})
|
||||
shockwaves.value.push({
|
||||
x: centerX, y: centerY,
|
||||
radius: 4, maxRadius: maxR * 0.42,
|
||||
color: item.shockwaveColor, width: 3,
|
||||
life: 1.0, decay: PHYSICS.SHOCKWAVE_DECAY * 1.3,
|
||||
})
|
||||
|
||||
// 3. 碎片(主体飞溅)
|
||||
for (let i = 0; i < item.debrisCount; i++) {
|
||||
const angle = (Math.PI * 2 * i) / item.debrisCount + (Math.random() - 0.5) * 0.7
|
||||
const speed = item.speedMin + Math.random() * (item.speedMax - item.speedMin)
|
||||
const size = item.sizeMin + Math.random() * (item.sizeMax - item.sizeMin)
|
||||
const color = item.colors[Math.floor(Math.random() * item.colors.length)]
|
||||
const shape = item.shapes[Math.floor(Math.random() * item.shapes.length)]
|
||||
|
||||
// confetti(彩纸): 初速带强烈向上偏移 + 水平摇摆下落
|
||||
const confetti = !!item.confetti
|
||||
const vy0 = confetti
|
||||
? Math.sin(angle) * speed - 2.5 // 先向上窜
|
||||
: Math.sin(angle) * speed
|
||||
|
||||
debrisList.value.push({
|
||||
x: centerX,
|
||||
y: centerY,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: vy0,
|
||||
size,
|
||||
color,
|
||||
shape,
|
||||
rotation: Math.random() * Math.PI * 2,
|
||||
rotationSpeed: (Math.random() - 0.5) * (confetti ? 0.5 : 0.3),
|
||||
life: 1.0,
|
||||
decay: PHYSICS.DEBRIS_DECAY * (confetti ? 0.7 : 1),
|
||||
gravity: item.gravity,
|
||||
glow: !confetti, // 彩纸不发光,实色碎片发光
|
||||
swing: confetti ? 0.6 + Math.random() * 1.2 : 0,
|
||||
age: Math.random() * Math.PI * 2,
|
||||
})
|
||||
}
|
||||
|
||||
// 4. 火花(细小高速亮点,带拖尾)
|
||||
for (let i = 0; i < item.sparkCount; i++) {
|
||||
const angle = Math.random() * Math.PI * 2
|
||||
const speed = item.speedMax * (0.9 + Math.random() * 0.8)
|
||||
const color = item.sparkColors[Math.floor(Math.random() * item.sparkColors.length)]
|
||||
sparkList.value.push({
|
||||
x: centerX,
|
||||
y: centerY,
|
||||
px: centerX,
|
||||
py: centerY,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
size: 1.5 + Math.random() * 2,
|
||||
color,
|
||||
life: 1.0,
|
||||
decay: PHYSICS.SPARK_DECAY * (0.8 + Math.random() * 0.6),
|
||||
gravity: item.gravity * 0.3,
|
||||
})
|
||||
}
|
||||
|
||||
// 5. 闪烁星光
|
||||
for (let i = 0; i < item.sparkleCount; i++) {
|
||||
const r = maxR * (0.08 + Math.random() * 0.32)
|
||||
const a = Math.random() * Math.PI * 2
|
||||
const color = item.sparkColors[Math.floor(Math.random() * item.sparkColors.length)]
|
||||
sparkleList.value.push({
|
||||
x: centerX + Math.cos(a) * r,
|
||||
y: centerY + Math.sin(a) * r,
|
||||
size: 10 + Math.random() * 16,
|
||||
rotation: Math.random() * Math.PI,
|
||||
spin: (Math.random() - 0.5) * 0.16,
|
||||
color,
|
||||
life: 1.0 + Math.random() * 0.4, // 错峰出现
|
||||
decay: PHYSICS.SPARKLE_DECAY * (0.8 + Math.random() * 0.5),
|
||||
})
|
||||
}
|
||||
|
||||
ensureRenderLoop()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 渲染循环
|
||||
// ============================================================
|
||||
|
||||
function ensureRenderLoop() {
|
||||
if (renderRunning.value) return
|
||||
renderRunning.value = true
|
||||
renderLoop()
|
||||
}
|
||||
|
||||
function renderLoop() {
|
||||
if (!canvasNode.value || !ctx.value) {
|
||||
renderRunning.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const w = canvasWidth.value
|
||||
const h = canvasHeight.value
|
||||
|
||||
// 碎片
|
||||
for (let i = debrisList.value.length - 1; i >= 0; i--) {
|
||||
const p = debrisList.value[i]
|
||||
p.vy += p.gravity
|
||||
p.vx *= PHYSICS.AIR_RESISTANCE
|
||||
p.vy *= PHYSICS.AIR_RESISTANCE
|
||||
p.x += p.vx
|
||||
p.y += p.vy
|
||||
p.age += 0.2
|
||||
if (p.swing > 0) p.x += Math.sin(p.age) * p.swing // 彩纸摇摆
|
||||
p.rotation += p.rotationSpeed
|
||||
p.life -= p.decay
|
||||
if (p.life <= 0 || p.x < -60 || p.x > w + 60 || p.y > h + 60) {
|
||||
debrisList.value.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 火花
|
||||
for (let i = sparkList.value.length - 1; i >= 0; i--) {
|
||||
const s = sparkList.value[i]
|
||||
s.px = s.x
|
||||
s.py = s.y
|
||||
s.vy += s.gravity
|
||||
s.vx *= PHYSICS.SPARK_RESISTANCE
|
||||
s.vy *= PHYSICS.SPARK_RESISTANCE
|
||||
s.x += s.vx
|
||||
s.y += s.vy
|
||||
s.life -= s.decay
|
||||
if (s.life <= 0) sparkList.value.splice(i, 1)
|
||||
}
|
||||
|
||||
// 闪烁星光
|
||||
for (let i = sparkleList.value.length - 1; i >= 0; i--) {
|
||||
const sp = sparkleList.value[i]
|
||||
sp.rotation += sp.spin
|
||||
sp.life -= sp.decay
|
||||
if (sp.life <= 0) sparkleList.value.splice(i, 1)
|
||||
}
|
||||
|
||||
// 中心爆闪
|
||||
for (let i = flashList.value.length - 1; i >= 0; i--) {
|
||||
const f = flashList.value[i]
|
||||
f.radius += (f.maxRadius - f.radius) * 0.35
|
||||
f.life -= f.decay
|
||||
if (f.life <= 0) flashList.value.splice(i, 1)
|
||||
}
|
||||
|
||||
// 冲击波
|
||||
for (let i = shockwaves.value.length - 1; i >= 0; i--) {
|
||||
const sw = shockwaves.value[i]
|
||||
sw.radius += (sw.maxRadius - sw.radius) * 0.16
|
||||
sw.life -= sw.decay
|
||||
if (sw.life <= 0) shockwaves.value.splice(i, 1)
|
||||
}
|
||||
|
||||
draw()
|
||||
|
||||
const alive =
|
||||
debrisList.value.length > 0 ||
|
||||
sparkList.value.length > 0 ||
|
||||
sparkleList.value.length > 0 ||
|
||||
flashList.value.length > 0 ||
|
||||
shockwaves.value.length > 0
|
||||
|
||||
if (alive) {
|
||||
rafId.value = canvasNode.value.requestAnimationFrame(renderLoop) as unknown as number
|
||||
} else {
|
||||
rafId.value = null
|
||||
renderRunning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 绘制
|
||||
// ============================================================
|
||||
|
||||
function draw() {
|
||||
const c = ctx.value
|
||||
const w = canvasWidth.value
|
||||
const h = canvasHeight.value
|
||||
|
||||
c.clearRect(0, 0, w, h)
|
||||
|
||||
// —— 1. 冲击波(底层) ——
|
||||
c.globalCompositeOperation = 'source-over'
|
||||
shockwaves.value.forEach(sw => {
|
||||
c.beginPath()
|
||||
c.arc(sw.x, sw.y, sw.radius, 0, Math.PI * 2)
|
||||
c.strokeStyle = applyAlphaToRgba(sw.color, Math.min(1, sw.life))
|
||||
c.lineWidth = sw.width * sw.life
|
||||
c.stroke()
|
||||
})
|
||||
|
||||
// —— 2. 中心爆闪(叠加发光) ——
|
||||
c.globalCompositeOperation = 'lighter'
|
||||
flashList.value.forEach(f => {
|
||||
const grd = c.createRadialGradient(f.x, f.y, 0, f.x, f.y, f.radius)
|
||||
const a = Math.min(1, f.life)
|
||||
grd.addColorStop(0, hexToRgba(f.color, 0.95 * a))
|
||||
grd.addColorStop(0.4, hexToRgba(f.color, 0.5 * a))
|
||||
grd.addColorStop(1, hexToRgba(f.color, 0))
|
||||
c.fillStyle = grd
|
||||
c.beginPath()
|
||||
c.arc(f.x, f.y, f.radius, 0, Math.PI * 2)
|
||||
c.fill()
|
||||
})
|
||||
|
||||
// —— 3. 碎片(实体,带辉光) ——
|
||||
c.globalCompositeOperation = 'source-over'
|
||||
debrisList.value.forEach(p => {
|
||||
const alpha = Math.min(1, p.life)
|
||||
const currentSize = p.size * (0.65 + p.life * 0.35)
|
||||
|
||||
// 辉光: 同色低透明大圆衬底
|
||||
if (p.glow) {
|
||||
c.fillStyle = hexToRgba(p.color, alpha * 0.22)
|
||||
c.beginPath()
|
||||
c.arc(p.x, p.y, currentSize * 1.9, 0, Math.PI * 2)
|
||||
c.fill()
|
||||
}
|
||||
|
||||
c.fillStyle = hexToRgba(p.color, alpha)
|
||||
drawDebrisShape(c, p, currentSize)
|
||||
})
|
||||
|
||||
// —— 4. 火花(拖尾 + 亮点,叠加发光) ——
|
||||
c.globalCompositeOperation = 'lighter'
|
||||
sparkList.value.forEach(s => {
|
||||
const a = Math.min(1, s.life)
|
||||
// 拖尾线
|
||||
c.strokeStyle = hexToRgba(s.color, a * 0.8)
|
||||
c.lineWidth = s.size * a
|
||||
c.lineCap = 'round'
|
||||
c.beginPath()
|
||||
c.moveTo(s.px, s.py)
|
||||
c.lineTo(s.x, s.y)
|
||||
c.stroke()
|
||||
// 头部亮点
|
||||
c.fillStyle = hexToRgba(s.color, a)
|
||||
c.beginPath()
|
||||
c.arc(s.x, s.y, s.size * a, 0, Math.PI * 2)
|
||||
c.fill()
|
||||
})
|
||||
|
||||
// —— 5. 闪烁星光(四角星,叠加发光) ——
|
||||
sparkleList.value.forEach(sp => {
|
||||
// life 在 1→0, 用 sin 做"先亮后灭"的缩放
|
||||
const t = Math.max(0, Math.min(1, sp.life))
|
||||
const scale = Math.sin(Math.min(1, sp.life) * Math.PI) // 0→1→0
|
||||
if (scale <= 0.02) return
|
||||
const a = t
|
||||
drawStar(c, sp.x, sp.y, sp.size * scale, sp.size * scale * 0.32, sp.rotation, hexToRgba(sp.color, a))
|
||||
})
|
||||
|
||||
c.globalCompositeOperation = 'source-over'
|
||||
}
|
||||
|
||||
function drawDebrisShape(c: any, p: Debris, size: number) {
|
||||
switch (p.shape) {
|
||||
case 'circle':
|
||||
c.beginPath()
|
||||
c.arc(p.x, p.y, size, 0, Math.PI * 2)
|
||||
c.fill()
|
||||
break
|
||||
case 'square':
|
||||
c.save()
|
||||
c.translate(p.x, p.y)
|
||||
c.rotate(p.rotation)
|
||||
c.fillRect(-size, -size, size * 2, size * 2)
|
||||
c.restore()
|
||||
break
|
||||
case 'rect':
|
||||
c.save()
|
||||
c.translate(p.x, p.y)
|
||||
c.rotate(p.rotation)
|
||||
c.fillRect(-size * 1.6, -size * 0.5, size * 3.2, size)
|
||||
c.restore()
|
||||
break
|
||||
case 'triangle':
|
||||
c.save()
|
||||
c.translate(p.x, p.y)
|
||||
c.rotate(p.rotation)
|
||||
c.beginPath()
|
||||
c.moveTo(0, -size)
|
||||
c.lineTo(size, size)
|
||||
c.lineTo(-size, size)
|
||||
c.closePath()
|
||||
c.fill()
|
||||
c.restore()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 四角星(尖锐十字星),用于高光闪烁
|
||||
function drawStar(c: any, cx: number, cy: number, outerR: number, innerR: number, rotation: number, fill: string) {
|
||||
const points = 4
|
||||
c.save()
|
||||
c.translate(cx, cy)
|
||||
c.rotate(rotation)
|
||||
c.beginPath()
|
||||
for (let i = 0; i < points * 2; i++) {
|
||||
const r = i % 2 === 0 ? outerR : innerR
|
||||
const a = (Math.PI * i) / points
|
||||
const x = Math.cos(a) * r
|
||||
const y = Math.sin(a) * r
|
||||
if (i === 0) c.moveTo(x, y)
|
||||
else c.lineTo(x, y)
|
||||
}
|
||||
c.closePath()
|
||||
c.fillStyle = fill
|
||||
c.fill()
|
||||
c.restore()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具函数
|
||||
// ============================================================
|
||||
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
const g = parseInt(hex.slice(3, 5), 16)
|
||||
const b = parseInt(hex.slice(5, 7), 16)
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||||
}
|
||||
|
||||
function applyAlphaToRgba(rgba: string, alphaMultiplier: number): string {
|
||||
const match = rgba.match(/rgba?\(([^)]+)\)/)
|
||||
if (!match) return rgba
|
||||
|
||||
const parts = match[1].split(',').map(s => s.trim())
|
||||
const r = parts[0]
|
||||
const g = parts[1]
|
||||
const b = parts[2]
|
||||
const a = parts[3] ? parseFloat(parts[3]) : 1
|
||||
return `rgba(${r}, ${g}, ${b}, ${a * alphaMultiplier})`
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 清理
|
||||
// ============================================================
|
||||
|
||||
function cleanup() {
|
||||
if (rafId.value !== null && canvasNode.value) {
|
||||
try {
|
||||
canvasNode.value.cancelAnimationFrame(rafId.value)
|
||||
} catch (_) {}
|
||||
rafId.value = null
|
||||
}
|
||||
renderRunning.value = false
|
||||
debrisList.value = []
|
||||
sparkList.value = []
|
||||
sparkleList.value = []
|
||||
flashList.value = []
|
||||
shockwaves.value = []
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.crush-canvas-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.crush-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,383 @@
|
||||
<template>
|
||||
<view class="walker-canvas-container">
|
||||
<canvas
|
||||
canvas-id="walker"
|
||||
id="walker"
|
||||
class="walker-canvas"
|
||||
:style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
|
||||
@touchstart="onTouchStart"
|
||||
@touchmove="onTouchMove"
|
||||
@touchend="onTouchEnd"
|
||||
@touchcancel="onTouchCancel"
|
||||
></canvas>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
isPlaying: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
bpm: {
|
||||
type: Number,
|
||||
default: 110
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
canvasWidth: 320,
|
||||
canvasHeight: 400,
|
||||
ctx: null,
|
||||
timer: null,
|
||||
renderRunning: false,
|
||||
|
||||
// Interaction State (Spring Physics)
|
||||
coreScale: 1,
|
||||
coreTargetScale: 1,
|
||||
coreVelocity: 0,
|
||||
pressed: false,
|
||||
|
||||
// Animation Physics (Time)
|
||||
time: 0,
|
||||
lastFrameTime: 0,
|
||||
|
||||
// Metronome State
|
||||
lastBeatTime: 0,
|
||||
|
||||
// Energy Waves
|
||||
waveAmplitudeMultiplier: 1,
|
||||
|
||||
// Shockwaves
|
||||
shockwaves: [],
|
||||
|
||||
// Rotation for outer ring
|
||||
ringAngle: 0
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.initCanvas();
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.renderRunning = false;
|
||||
},
|
||||
watch: {
|
||||
isPlaying(newVal) {
|
||||
if (newVal) {
|
||||
this.waveAmplitudeMultiplier = 1;
|
||||
this.ensureRenderLoop();
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
initCanvas() {
|
||||
// Delay to ensure Flexbox layout is fully complete before measuring
|
||||
setTimeout(() => {
|
||||
const query = uni.createSelectorQuery().in(this);
|
||||
query.select('.walker-canvas-container').boundingClientRect(data => {
|
||||
if (data && data.width > 0) {
|
||||
this.canvasWidth = data.width;
|
||||
this.canvasHeight = data.height > 100 ? data.height : data.width * 1.2;
|
||||
} else {
|
||||
const sys = uni.getSystemInfoSync();
|
||||
this.canvasWidth = sys.windowWidth - 30; // approximate padding
|
||||
this.canvasHeight = this.canvasWidth * 1.2;
|
||||
}
|
||||
|
||||
if (!this.ctx) {
|
||||
this.ctx = uni.createCanvasContext('walker', this);
|
||||
this.lastFrameTime = Date.now();
|
||||
this.ensureRenderLoop();
|
||||
}
|
||||
}).exec();
|
||||
}, 100);
|
||||
},
|
||||
|
||||
// Expose a method to be called from parent when a beat hits
|
||||
triggerBeat() {
|
||||
const coreRadius = Math.min(this.canvasWidth, this.canvasHeight) * 0.32;
|
||||
// Add a shockwave
|
||||
this.shockwaves.push({
|
||||
radius: coreRadius,
|
||||
maxRadius: coreRadius * 2.2,
|
||||
opacity: 1
|
||||
});
|
||||
|
||||
// Spike the wave amplitude
|
||||
this.waveAmplitudeMultiplier = 2.5;
|
||||
|
||||
// Slight pulse to the core
|
||||
this.coreVelocity -= 0.05;
|
||||
|
||||
this.ensureRenderLoop();
|
||||
},
|
||||
|
||||
onTouchStart(e) {
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
|
||||
// Center
|
||||
const cx = this.canvasWidth / 2;
|
||||
const cy = this.canvasHeight / 2 - 30;
|
||||
const coreRadius = Math.min(this.canvasWidth, this.canvasHeight) * 0.32;
|
||||
|
||||
const dx = touch.x - cx;
|
||||
const dy = touch.y - cy;
|
||||
const dist = Math.sqrt(dx*dx + dy*dy);
|
||||
|
||||
// Hit box slightly larger than the visual circle
|
||||
if (dist <= coreRadius + 40) {
|
||||
this.pressed = true;
|
||||
this.coreTargetScale = 0.9;
|
||||
this.ensureRenderLoop();
|
||||
}
|
||||
},
|
||||
onTouchMove(e) {
|
||||
if (!this.pressed) return;
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
|
||||
const cx = this.canvasWidth / 2;
|
||||
const cy = this.canvasHeight / 2 - 30;
|
||||
const coreRadius = Math.min(this.canvasWidth, this.canvasHeight) * 0.32;
|
||||
|
||||
const dx = touch.x - cx;
|
||||
const dy = touch.y - cy;
|
||||
const dist = Math.sqrt(dx*dx + dy*dy);
|
||||
|
||||
// Slid out of the hit box - cancel the press
|
||||
if (dist > coreRadius + 40) {
|
||||
this.pressed = false;
|
||||
this.coreTargetScale = 1;
|
||||
}
|
||||
},
|
||||
onTouchEnd() {
|
||||
// Unconditionally reset the visual scale
|
||||
this.coreTargetScale = 1;
|
||||
// Only emit toggle when the press has not been cancelled by drag-out
|
||||
if (this.pressed) {
|
||||
this.$emit('toggle-play');
|
||||
}
|
||||
this.pressed = false;
|
||||
},
|
||||
onTouchCancel() {
|
||||
// System interruption (incoming call, notification pull-down, etc.):
|
||||
// reset visuals only — do NOT count this as a user toggle intent.
|
||||
this.coreTargetScale = 1;
|
||||
this.pressed = false;
|
||||
},
|
||||
|
||||
ensureRenderLoop() {
|
||||
if (this.renderRunning) return;
|
||||
this.renderRunning = true;
|
||||
this.renderLoop();
|
||||
},
|
||||
|
||||
renderLoop() {
|
||||
// Use a fixed time delta to prevent rubber-banding/jitter on unstable JS timers
|
||||
const fixedDt = 0.016;
|
||||
|
||||
if (this.isPlaying) {
|
||||
this.time += fixedDt;
|
||||
} else {
|
||||
this.time += fixedDt * 0.2; // slow drift when paused
|
||||
}
|
||||
|
||||
// Spring Physics for Core Scale
|
||||
const tension = 120;
|
||||
const friction = 12;
|
||||
const force = (this.coreTargetScale - this.coreScale) * tension;
|
||||
this.coreVelocity += force * fixedDt;
|
||||
this.coreVelocity *= Math.exp(-friction * fixedDt);
|
||||
this.coreScale += this.coreVelocity * fixedDt;
|
||||
|
||||
// Decay wave amplitude back to 1
|
||||
if (this.waveAmplitudeMultiplier > 1) {
|
||||
this.waveAmplitudeMultiplier -= fixedDt * 3;
|
||||
if (this.waveAmplitudeMultiplier < 1) this.waveAmplitudeMultiplier = 1;
|
||||
} else if (!this.isPlaying && this.waveAmplitudeMultiplier > 0.1) {
|
||||
this.waveAmplitudeMultiplier -= fixedDt * 2;
|
||||
if (this.waveAmplitudeMultiplier < 0.1) this.waveAmplitudeMultiplier = 0.1;
|
||||
}
|
||||
|
||||
this.draw();
|
||||
|
||||
this.ctx.draw(false);
|
||||
|
||||
// Decide whether to continue the render loop. Short-circuit on isPlaying first for perf.
|
||||
const shouldContinue =
|
||||
this.isPlaying ||
|
||||
this.shockwaves.length > 0 ||
|
||||
Math.abs(this.coreScale - this.coreTargetScale) >= 0.001 ||
|
||||
Math.abs(this.coreVelocity) >= 0.001 ||
|
||||
this.waveAmplitudeMultiplier > 0.11;
|
||||
|
||||
if (shouldContinue) {
|
||||
// Polyfill requestAnimationFrame for uni-app
|
||||
this.timer = setTimeout(() => {
|
||||
this.renderLoop();
|
||||
}, 1000 / 60);
|
||||
} else {
|
||||
this.timer = null;
|
||||
this.renderRunning = false;
|
||||
}
|
||||
},
|
||||
|
||||
draw() {
|
||||
const ctx = this.ctx;
|
||||
const W = this.canvasWidth;
|
||||
const H = this.canvasHeight;
|
||||
|
||||
// Clear entire canvas to ensure it is completely transparent and blends with the page background
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
// Dynamic majestic sizing to fill the huge screen area
|
||||
const coreRadius = Math.min(W, H) * 0.32; // Make the central circle MASSIVE
|
||||
const ringRadius = coreRadius + 18;
|
||||
const cx = W / 2;
|
||||
const cy = H / 2 - 30; // Slightly above center
|
||||
|
||||
// 3. Draw Shockwaves (Ripples)
|
||||
for (let i = this.shockwaves.length - 1; i >= 0; i--) {
|
||||
const sw = this.shockwaves[i];
|
||||
sw.radius += (sw.maxRadius - sw.radius) * 0.08;
|
||||
sw.opacity -= 0.03;
|
||||
|
||||
if (sw.opacity <= 0) {
|
||||
this.shockwaves.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, sw.radius, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = `rgba(16, 185, 129, ${sw.opacity * 0.5})`;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 4. Draw Energy Waves (Bottom area)
|
||||
this.drawWaves(ctx, W, H);
|
||||
|
||||
// 5. Draw Interactive BPM Core
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
ctx.scale(this.coreScale, this.coreScale);
|
||||
|
||||
// The Core Button (Emerald Gradient)
|
||||
ctx.setShadow(0, 12, 24, 'rgba(16, 185, 129, 0.3)');
|
||||
|
||||
const coreGrad = ctx.createLinearGradient(-coreRadius, -coreRadius, coreRadius, coreRadius);
|
||||
coreGrad.addColorStop(0, '#34d399'); // Light Mint
|
||||
coreGrad.addColorStop(1, '#047857'); // Deep Emerald
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, coreRadius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = coreGrad;
|
||||
ctx.fill();
|
||||
|
||||
// Clear shadow for internal elements
|
||||
ctx.setShadow(0, 0, 0, 'transparent');
|
||||
|
||||
// Inner Subtle Highlight (Glass edge)
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, coreRadius - 2, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.35)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
// Text - BPM Number (MASSIVE font)
|
||||
const fontSize = Math.floor(coreRadius * 0.7); // Dynamic font size based on radius
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = `bold ${fontSize}px "DIN Condensed", "Inter", sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
// Removed shadowBlur for text to save low-end GPU performance
|
||||
ctx.fillText(this.bpm.toString(), 0, -12);
|
||||
|
||||
// Text - 'BPM' Label
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.85)';
|
||||
ctx.font = '500 16px "Inter", sans-serif';
|
||||
ctx.fillText('BPM', 0, coreRadius * 0.3);
|
||||
|
||||
// Play/Pause Icon
|
||||
ctx.fillStyle = '#ffffff';
|
||||
const iconY = coreRadius * 0.55;
|
||||
if (this.isPlaying) {
|
||||
// Pause Icon (Two vertical bars)
|
||||
ctx.fillRect(-8, iconY - 6, 5, 14);
|
||||
ctx.fillRect(3, iconY - 6, 5, 14);
|
||||
} else {
|
||||
// Play Icon (Triangle)
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-5, iconY - 8);
|
||||
ctx.lineTo(9, iconY);
|
||||
ctx.lineTo(-5, iconY + 8);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
drawWaves(ctx, W, H) {
|
||||
const baseY = H * 0.82;
|
||||
const dynamicAmp = H * 0.08;
|
||||
// Reduced to 2 layers to save GPU/Bridge rendering time and ensure 60fps
|
||||
const waves = [
|
||||
{ color: 'rgba(52, 211, 153, 0.3)', speed: 1.5, freq: 0.012, amp: dynamicAmp * 0.7, offset: 0 },
|
||||
{ color: 'rgba(16, 185, 129, 0.6)', speed: 2.5, freq: 0.015, amp: dynamicAmp, offset: Math.PI }
|
||||
];
|
||||
|
||||
const activeAmp = this.waveAmplitudeMultiplier;
|
||||
const t = this.time;
|
||||
|
||||
waves.forEach(wave => {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, H);
|
||||
ctx.lineTo(0, baseY);
|
||||
|
||||
// Increased step size to drastically reduce JS-to-Native bridge commands
|
||||
const step = 25;
|
||||
for (let x = 0; x <= W; x += step) {
|
||||
// Removed edge envelope taper so waves crash cleanly into the edge of the screen
|
||||
const y = baseY + Math.sin(x * wave.freq + t * wave.speed + wave.offset) * wave.amp * activeAmp;
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
|
||||
// Add one final line to exactly W to ensure flush edge
|
||||
const finalY = baseY + Math.sin(W * wave.freq + t * wave.speed + wave.offset) * wave.amp * activeAmp;
|
||||
ctx.lineTo(W, finalY);
|
||||
|
||||
ctx.lineTo(W, H);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = wave.color;
|
||||
ctx.fill();
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.walker-canvas-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.walker-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent; /* Pure transparent to blend into page */
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,551 @@
|
||||
<template>
|
||||
<view class="page" :style="rootStyle">
|
||||
<!-- ===== 统一运动律动视区 (Canvas 律动舞台) ===== -->
|
||||
<view class="stage-canvas-wrapper">
|
||||
<WalkerCanvas
|
||||
ref="walkerCanvasRef"
|
||||
:bpm="currentBpm"
|
||||
:is-playing="isPlaying"
|
||||
@toggle-play="onCenterTap"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- ===== 音色选择 ===== -->
|
||||
<view class="sound-selector">
|
||||
<view
|
||||
class="sound-option"
|
||||
:class="{ active: currentSound === 'crisp' }"
|
||||
@click="onSoundSelect('crisp')"
|
||||
>
|
||||
<text class="sound-icon">✨</text>
|
||||
<text class="sound-label">清脆</text>
|
||||
</view>
|
||||
<view
|
||||
class="sound-option"
|
||||
:class="{ active: currentSound === 'wood' }"
|
||||
@click="onSoundSelect('wood')"
|
||||
>
|
||||
<text class="sound-icon">🪵</text>
|
||||
<text class="sound-label">木鱼</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ===== 推荐档位(3 选 1) ===== -->
|
||||
<view class="presets">
|
||||
<view
|
||||
v-for="p in LOOP_PRESETS"
|
||||
:key="p.id"
|
||||
class="preset"
|
||||
:class="{ active: currentLoop === p.id }"
|
||||
@click="onPresetTap(p.id)"
|
||||
>
|
||||
<text class="preset-name">{{ p.label }}</text>
|
||||
<text class="preset-bpm">{{ p.bpm }} BPM</text>
|
||||
<text class="preset-desc">{{ p.desc }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ===== 自定义节奏区域(含标题栏+折叠内容) ===== -->
|
||||
<view class="custom-section">
|
||||
<!-- 标题栏(始终显示) -->
|
||||
<view class="custom-header" @click="onToggleCustom">
|
||||
<text class="custom-title">{{ customExpanded ? '✕ 收起自定义' : '⚙ 自定义节奏' }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 折叠内容(仅展开时显示) -->
|
||||
<view v-if="customExpanded" class="custom-content">
|
||||
<!-- 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">{{ currentBpm }}</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>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { onShow, onHide, onUnload } from '@dcloudio/uni-app'
|
||||
import { useMetronome } from '../hooks/useMetronome'
|
||||
import WalkerCanvas from './components/walker-canvas.vue'
|
||||
|
||||
type LoopId = 'slow' | 'normal' | 'brisk'
|
||||
interface LoopPreset {
|
||||
id: LoopId
|
||||
bpm: number
|
||||
label: string
|
||||
desc: string
|
||||
}
|
||||
|
||||
const LOOP_PRESETS: readonly LoopPreset[] = [
|
||||
{ id: 'slow', bpm: 110, label: '慢走', desc: '热身 · 恢复' },
|
||||
{ id: 'normal', bpm: 130, label: '健走', desc: '日常 · 通勤' },
|
||||
{ id: 'brisk', bpm: 150, label: '快走', desc: '提速 · 燃脂' },
|
||||
]
|
||||
|
||||
const customExpanded = ref<boolean>(false)
|
||||
const currentLoop = ref<LoopId | null>('normal')
|
||||
const currentSound = ref<'crisp' | 'wood'>('crisp')
|
||||
const walkerCanvasRef = ref<any>(null)
|
||||
|
||||
/* 节拍器音效配置 */
|
||||
const SOUND_PRESETS = {
|
||||
crisp: {
|
||||
normal: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260527/202605271539371ebe13672.mp3',
|
||||
accent: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260527/202605271539376ff628472.mp3',
|
||||
},
|
||||
wood: {
|
||||
normal: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051282a0a94508.mp3',
|
||||
accent: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051282a0a94508.mp3',
|
||||
},
|
||||
}
|
||||
|
||||
// 统一采用高精度引擎,彻底抛弃卡顿的后台播放引擎
|
||||
const fg = useMetronome({
|
||||
initialBpm: 130,
|
||||
accentEvery: 2,
|
||||
clickSrc: SOUND_PRESETS.crisp.normal,
|
||||
accentSrc: SOUND_PRESETS.crisp.accent,
|
||||
poolSize: 4,
|
||||
// 【完美音画同步核心】
|
||||
onBeat: (index, isAccent) => {
|
||||
if (walkerCanvasRef.value && walkerCanvasRef.value.triggerBeat) {
|
||||
walkerCanvasRef.value.triggerBeat()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const isPlaying = computed(() => fg.isPlaying.value)
|
||||
const currentBpm = computed(() => fg.bpm.value)
|
||||
const customAccent = computed(() => fg.accentEvery.value)
|
||||
const intervalMs = computed(() => 60000 / fg.bpm.value)
|
||||
|
||||
/* ============================================================
|
||||
* 中央圆按钮
|
||||
* ============================================================ */
|
||||
const onCenterTap = () => {
|
||||
if (fg.isPlaying.value) {
|
||||
fg.stop()
|
||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||
} else {
|
||||
fg.start()
|
||||
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 档位卡片
|
||||
* ============================================================ */
|
||||
const onPresetTap = (id: LoopId) => {
|
||||
const preset = LOOP_PRESETS.find(p => p.id === id)
|
||||
if (!preset) return
|
||||
|
||||
currentLoop.value = id
|
||||
fg.setBpm(preset.bpm)
|
||||
if (!fg.isPlaying.value) {
|
||||
fg.start()
|
||||
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 自定义折叠区切换
|
||||
* ============================================================ */
|
||||
const onToggleCustom = () => {
|
||||
customExpanded.value = !customExpanded.value
|
||||
if (customExpanded.value) {
|
||||
fg.preload()
|
||||
} else {
|
||||
// 收起时,如果当前 BPM 刚好匹配某个档位,高亮对应档位卡片
|
||||
const matched = LOOP_PRESETS.find(p => p.bpm === fg.bpm.value)
|
||||
currentLoop.value = matched ? matched.id : null
|
||||
}
|
||||
}
|
||||
|
||||
const onBpmDelta = (delta: number) => {
|
||||
fg.setBpm(fg.bpm.value + delta)
|
||||
currentLoop.value = null // 用户手动微调后取消档位高亮
|
||||
}
|
||||
|
||||
const onAccentSelect = (n: number) => {
|
||||
fg.setAccentEvery(n)
|
||||
}
|
||||
|
||||
const onSoundSelect = (sound: 'crisp' | 'wood') => {
|
||||
currentSound.value = sound
|
||||
const preset = SOUND_PRESETS[sound]
|
||||
fg.updateAudioSrc(preset.normal, preset.accent)
|
||||
}
|
||||
|
||||
const rootStyle = computed(() => ({
|
||||
'--beat-duration': `${intervalMs.value}ms`,
|
||||
}))
|
||||
|
||||
onShow(() => {
|
||||
fg.preload()
|
||||
})
|
||||
|
||||
function cleanupMetronome() {
|
||||
fg.stop()
|
||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||
}
|
||||
|
||||
onHide(cleanupMetronome)
|
||||
onUnload(cleanupMetronome)
|
||||
|
||||
onUnmounted(cleanupMetronome)
|
||||
</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;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 统一运动律动视区 (Canvas)
|
||||
* ============================================================ */
|
||||
.stage-canvas-wrapper {
|
||||
width: 100vw;
|
||||
margin-left: -28rpx;
|
||||
margin-right: -28rpx;
|
||||
flex-shrink: 0; /* 防止被压缩 */
|
||||
height: 600rpx; /* 固定高度,不随内容变化 */
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: 24rpx;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 音色选择器
|
||||
* ============================================================ */
|
||||
.sound-selector {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 14rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.sound-option {
|
||||
flex: 1;
|
||||
background: $card-bg;
|
||||
border: 2rpx solid $card-border;
|
||||
border-radius: $radius-card;
|
||||
padding: 20rpx 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10rpx;
|
||||
box-shadow: $shadow-sm;
|
||||
transition: all 0.18s;
|
||||
|
||||
.sound-icon {
|
||||
font-size: 32rpx;
|
||||
}
|
||||
|
||||
.sound-label {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: $text-2;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
|
||||
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);
|
||||
|
||||
.sound-label {
|
||||
color: $brand-deep;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 三档位推荐
|
||||
* ============================================================ */
|
||||
.presets {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.preset {
|
||||
flex: 1;
|
||||
background: $card-bg;
|
||||
border: 2rpx solid $card-border;
|
||||
border-radius: $radius-card;
|
||||
padding: 26rpx 8rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
box-shadow: $shadow-sm;
|
||||
transition: all 0.18s;
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 自定义节奏区域(标题栏 + 折叠内容)
|
||||
* ============================================================ */
|
||||
.custom-section {
|
||||
width: 100%;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.custom-header {
|
||||
width: 100%;
|
||||
padding: 16rpx 6rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.custom-title {
|
||||
font-size: 22rpx;
|
||||
color: $text-3;
|
||||
letter-spacing: 0.5rpx;
|
||||
|
||||
&:active {
|
||||
color: $text-2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-content {
|
||||
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;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,276 @@
|
||||
# 握力环训练音效采集清单
|
||||
|
||||
> 更新日期: 2026-05-28
|
||||
> 用途: 为握力环训练页面添加完整音效体系
|
||||
|
||||
---
|
||||
|
||||
## 📋 总体规划
|
||||
|
||||
| 类型 | 数量 | 单文件大小 | 总大小预算 | 优先级 |
|
||||
|------|------|-----------|-----------|--------|
|
||||
| 节拍音效 | 1-3 个音色 | 5-10 KB | < 30 KB | P0 |
|
||||
| 捏碎音效 | 4 个 | 20-50 KB | < 200 KB | P0 |
|
||||
| 背景音乐 | 1-2 首 | 200-500 KB | < 1 MB | P1 |
|
||||
| 语音鼓励 | 4-8 句 | 10-30 KB | < 200 KB | P1 |
|
||||
|
||||
**总计预算**: < 1.5 MB(都放 CDN,不影响小程序包体积)
|
||||
|
||||
---
|
||||
|
||||
## 🥁 一、节拍音效 (P0 - 必需)
|
||||
|
||||
### 用途
|
||||
训练时按 BPM 节奏播放,引导用户握紧/松开。
|
||||
|
||||
### 推荐音色 (任选 1-3 个)
|
||||
|
||||
| 音色 | 推荐度 | 描述 | Pixabay 搜索词 |
|
||||
|------|--------|------|---------------|
|
||||
| 木鱼 | ⭐⭐⭐⭐⭐ | 温暖、东方意境,适合养生主题 | `wood block`, `wooden tap`, `muyu` |
|
||||
| 心跳 | ⭐⭐⭐⭐ | 有运动感,代入感强 | `heart beat`, `heartbeat single` |
|
||||
| 轻柔敲击 | ⭐⭐⭐⭐ | 不刺耳,适合长时间训练 | `soft tap`, `gentle knock` |
|
||||
| 竹片声 | ⭐⭐⭐ | 清脆,有节奏感 | `bamboo tap`, `bamboo click` |
|
||||
| 鼓点 | ⭐⭐⭐ | 有力量感,适合"重度"档位 | `kick drum soft`, `tom drum tap` |
|
||||
|
||||
### 技术要求
|
||||
- **格式**: mp3
|
||||
- **时长**: 50-150ms (不能太长,会重叠)
|
||||
- **采样率**: 22050Hz 或 44100Hz
|
||||
- **声道**: 单声道
|
||||
- **大小**: < 10 KB
|
||||
|
||||
### 命名规范
|
||||
```
|
||||
grip-tick-wood.mp3 # 木鱼
|
||||
grip-tick-heart.mp3 # 心跳
|
||||
grip-tick-soft.mp3 # 轻柔
|
||||
grip-tick-bamboo.mp3 # 竹片
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💥 二、捏碎音效 (P0 - 必需)
|
||||
|
||||
### 用途
|
||||
达成奖励里程碑时播放,与粒子特效同步。
|
||||
|
||||
### 音效清单
|
||||
|
||||
| 奖励 | 触发次数 | Pixabay 搜索词 | 时长 | 备注 |
|
||||
|------|---------|---------------|------|------|
|
||||
| 🥚 鸡蛋 | 8-12 | `egg crack`, `eggshell break`, `crack sound` | 0.5-1s | 清脆短促 |
|
||||
| 🥜 核桃 | 15-20 | `nut crack`, `walnut break`, `wood snap` | 0.5-1s | 木质爆裂感 |
|
||||
| 🥫 易拉罐 | 25-30 | `can crush`, `aluminum crush`, `metal crunch` | 1-2s | 金属挤压 |
|
||||
| 🎈 气球 | 40-50 | `balloon pop`, `pop burst`, `balloon explosion` | 0.3-0.5s | 短促爆裂 |
|
||||
|
||||
### 技术要求
|
||||
- **格式**: mp3
|
||||
- **时长**: 0.3-2s
|
||||
- **大小**: < 50 KB
|
||||
- **音量**: 适中(避免突然吓到用户)
|
||||
|
||||
### 命名规范
|
||||
```
|
||||
crush-egg.mp3
|
||||
crush-walnut.mp3
|
||||
crush-can.mp3
|
||||
crush-balloon.mp3
|
||||
```
|
||||
|
||||
### 试听建议
|
||||
下载前一定要试听,优先选择:
|
||||
- ✅ 干净,无杂音
|
||||
- ✅ 中等音量(峰值不爆音)
|
||||
- ✅ 短促有力(不拖沓)
|
||||
- ❌ 避免: 太长、太尖锐、有人声
|
||||
|
||||
---
|
||||
|
||||
## 🎵 三、背景音乐 BGM (P1 - 推荐)
|
||||
|
||||
### 用途
|
||||
训练时持续播放,营造轻松氛围,缓解握力训练的枯燥感。
|
||||
|
||||
### 风格推荐
|
||||
|
||||
| 风格 | 描述 | Pixabay 搜索词 | 适合人群 |
|
||||
|------|------|---------------|---------|
|
||||
| **Lo-Fi** | 慵懒电子,流行选择 | `lofi calm`, `lofi study`, `lofi chill` | 年轻人 |
|
||||
| **冥想轻音乐** | 钢琴 + 自然音,放松 | `meditation`, `calm piano`, `relaxing` | 中老年 |
|
||||
| **轻爵士** | 优雅放松,有质感 | `light jazz`, `cafe jazz`, `bossa nova` | 通用 |
|
||||
| **自然环境音** | 鸟叫/流水/雨声 | `nature ambient`, `forest sounds`, `rain bgm` | 喜欢自然的用户 |
|
||||
| **东方禅意** | 古筝/笛子,养生 | `chinese zen`, `oriental calm`, `guqin` | 养生主题契合 |
|
||||
|
||||
### 推荐场景搭配
|
||||
- **轻度档位 (60 BPM)**: 冥想轻音乐 (节奏舒缓)
|
||||
- **中度档位 (80 BPM)**: 轻爵士 / Lo-Fi (适中)
|
||||
- **重度档位 (100 BPM)**: 轻快电子 / 健身流行
|
||||
|
||||
### 技术要求
|
||||
- **格式**: mp3
|
||||
- **时长**: 30s - 2min (循环播放)
|
||||
- **比特率**: 128 kbps (单声道 64 kbps 也可)
|
||||
- **大小**: < 500 KB
|
||||
- **循环点**: 选择起止点接近的片段,避免明显接缝
|
||||
|
||||
### 命名规范
|
||||
```
|
||||
bgm-grip-light.mp3 # 轻度档位 BGM
|
||||
bgm-grip-medium.mp3 # 中度档位 BGM (可选)
|
||||
bgm-grip-heavy.mp3 # 重度档位 BGM (可选)
|
||||
```
|
||||
|
||||
或简化:
|
||||
```
|
||||
bgm-grip-default.mp3 # 默认 BGM (所有档位通用)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎙️ 四、语音鼓励 (P1 - 推荐)
|
||||
|
||||
### 用途
|
||||
达成奖励时播放鼓励语音,增强成就感。
|
||||
|
||||
### 两种方案
|
||||
|
||||
#### 方案 A: 小米 MiMo TTS 自动生成 (推荐)
|
||||
|
||||
项目已有 `useVoiceCoach.ts` + `generate-voice.mjs` 脚本支持。
|
||||
|
||||
**操作步骤**:
|
||||
1. 申请小米 MiMo API Key: https://platform.xiaomimimo.com/
|
||||
2. 配置环境变量: `export MIMO_API_KEY=sk-xxx`
|
||||
3. 在 `generate-voice.mjs` 添加握力环话术
|
||||
4. 运行脚本自动生成
|
||||
|
||||
**话术清单**:
|
||||
```javascript
|
||||
const gripPrompts = [
|
||||
{ id: 'grip-egg', text: '握力不错!' },
|
||||
{ id: 'grip-walnut', text: '力量惊人!' },
|
||||
{ id: 'grip-can', text: '太强了!' },
|
||||
{ id: 'grip-balloon', text: '完美!继续保持!' },
|
||||
{ id: 'grip-start', text: '开始训练!' },
|
||||
{ id: 'grip-pause', text: '休息一下!' },
|
||||
{ id: 'grip-encourage-1', text: '加油,再来!' },
|
||||
{ id: 'grip-encourage-2', text: '坚持住!' },
|
||||
]
|
||||
```
|
||||
|
||||
**音色推荐**: `茉莉` (清爽利落,有"运动博主"感)
|
||||
|
||||
#### 方案 B: 手工录制
|
||||
|
||||
如果不想用 TTS,可以自己录制(质感更好,但费时):
|
||||
- 工具: 手机录音 / Audacity
|
||||
- 环境: 安静的房间,距离麦克风 15-30cm
|
||||
- 后期: 去噪 + 标准化音量
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 五、采集工具推荐
|
||||
|
||||
### 在线裁剪
|
||||
- [audiotrimmer.com](https://audiotrimmer.com) - 简单的 mp3 裁剪
|
||||
- [mp3cut.net](https://mp3cut.net) - 功能更全
|
||||
|
||||
### 桌面工具(免费)
|
||||
- **Audacity** (Mac/Windows/Linux) - 专业级,可以剪辑、降噪、标准化
|
||||
- **Logic Pro** (Mac, 付费) - 更专业的音频处理
|
||||
|
||||
### 音量标准化
|
||||
统一音量(避免有的太响有的太轻):
|
||||
```bash
|
||||
# 使用 ffmpeg (Mac: brew install ffmpeg)
|
||||
ffmpeg -i input.mp3 -af "loudnorm=I=-16:LRA=11:TP=-1.5" output.mp3
|
||||
```
|
||||
|
||||
或在 Audacity 里: Effect → Normalize (峰值 -3dB)
|
||||
|
||||
---
|
||||
|
||||
## ☁️ 六、上传到 CDN
|
||||
|
||||
### 项目使用的 COS 信息
|
||||
- **域名**: `gz-1349751149.cos.ap-guangzhou.myqcloud.com`
|
||||
- **目录建议**: `uploads/training/grip-ring/`
|
||||
|
||||
### 上传途径
|
||||
1. **腾讯云控制台**: https://console.cloud.tencent.com/cos
|
||||
2. **管理后台** (如果项目有上传入口)
|
||||
3. **找团队 COS 管理员上传**
|
||||
|
||||
### 上传后获得的 URL 格式
|
||||
```
|
||||
https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/training/grip-ring/crush-egg.mp3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 七、最终采集清单 (一次性收齐)
|
||||
|
||||
### 最小可行版本 (MVP) - 4 个文件
|
||||
```
|
||||
☐ grip-tick-wood.mp3 # 节拍音 - 木鱼
|
||||
☐ crush-egg.mp3 # 鸡蛋
|
||||
☐ crush-walnut.mp3 # 核桃
|
||||
☐ crush-can.mp3 # 易拉罐
|
||||
☐ crush-balloon.mp3 # 气球
|
||||
```
|
||||
|
||||
### 完整版本 - 10 个文件
|
||||
```
|
||||
节拍音 (3 选 1 或全部):
|
||||
☐ grip-tick-wood.mp3
|
||||
☐ grip-tick-heart.mp3
|
||||
☐ grip-tick-soft.mp3
|
||||
|
||||
捏碎音效 (必需 4 个):
|
||||
☐ crush-egg.mp3
|
||||
☐ crush-walnut.mp3
|
||||
☐ crush-can.mp3
|
||||
☐ crush-balloon.mp3
|
||||
|
||||
BGM (1-3 首):
|
||||
☐ bgm-grip-default.mp3
|
||||
☐ bgm-grip-meditation.mp3 (可选)
|
||||
☐ bgm-grip-lofi.mp3 (可选)
|
||||
|
||||
语音鼓励 (TTS 自动生成,8 个):
|
||||
☐ grip-egg.mp3
|
||||
☐ grip-walnut.mp3
|
||||
☐ grip-can.mp3
|
||||
☐ grip-balloon.mp3
|
||||
☐ grip-start.mp3
|
||||
☐ grip-pause.mp3
|
||||
☐ grip-encourage-1.mp3
|
||||
☐ grip-encourage-2.mp3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 八、采集完成后
|
||||
|
||||
把所有 URL 整理给我,我会:
|
||||
1. 更新 `grip-ring.vue` 中的 REWARDS 配置 (替换捏碎音效 URL)
|
||||
2. 创建/更新 `useMetronome.ts` 的音色配置 (支持切换节拍音色)
|
||||
3. 在 `grip-ring.vue` 集成 `useTrainingBgm` (添加 BGM)
|
||||
4. 在 `grip-ring.vue` 集成 `useVoiceCoach` (添加语音鼓励)
|
||||
5. 在 UI 上添加音色/BGM 切换选项 (可选)
|
||||
|
||||
预计代码改动: 50-100 行,1-2 小时工作量。
|
||||
|
||||
---
|
||||
|
||||
## 📚 参考链接
|
||||
|
||||
- [Pixabay 音效](https://pixabay.com/sound-effects/) - 免费可商用
|
||||
- [Pixabay 音乐](https://pixabay.com/music/) - 免费可商用
|
||||
- [Freesound](https://freesound.org) - 大量音效,部分需署名
|
||||
- [Mixkit](https://mixkit.co/free-sound-effects/) - 免费音效库
|
||||
- [Bensound](https://bensound.com) - 免费音乐(需署名)
|
||||
- [Free Music Archive](https://freemusicarchive.org) - 免费音乐
|
||||
|
||||
**版权提示**: 务必确认音效/音乐是"免费商用"(Public Domain 或 CC0),避免侵权风险。
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 卡路里计算工具
|
||||
* 基于标准 MET (Metabolic Equivalent of Task) 公式
|
||||
*/
|
||||
|
||||
export interface FoodItem {
|
||||
name: string
|
||||
emoji: string
|
||||
calories: number
|
||||
unit: string
|
||||
}
|
||||
|
||||
export interface FoodComparison {
|
||||
food: FoodItem
|
||||
ratio: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface CalorieCalculationConfig {
|
||||
totalReps: number
|
||||
bpm: number
|
||||
userWeight?: number // 默认 60kg
|
||||
}
|
||||
|
||||
// 食物卡路里数据库
|
||||
export const FOOD_CALORIES: Record<string, FoodItem> = {
|
||||
apple: { name: '苹果', emoji: '🍎', calories: 52, unit: '个(100g)' },
|
||||
egg: { name: '鸡蛋', emoji: '🥚', calories: 70, unit: '个' },
|
||||
chocolate: { name: '巧克力', emoji: '🍫', calories: 54, unit: '块(10g)' },
|
||||
banana: { name: '香蕉', emoji: '🍌', calories: 89, unit: '根' },
|
||||
rice: { name: '米饭', emoji: '🍚', calories: 116, unit: '碗(100g)' },
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算卡路里消耗
|
||||
* 公式:卡路里 = MET × 体重(kg) × 时长(小时)
|
||||
*
|
||||
* @param config 计算配置
|
||||
* @returns 卡路里消耗(kcal,保留一位小数)
|
||||
*/
|
||||
export function calculateCalories(config: CalorieCalculationConfig): number {
|
||||
const MET = 3.5 // 握力环训练的标准 MET 值
|
||||
const weight = config.userWeight || 60 // 默认 60kg
|
||||
|
||||
// 边界情况:无效输入
|
||||
if (config.totalReps <= 0 || config.bpm <= 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const secondsPerRep = 60 / config.bpm
|
||||
const activeTimeHours = (config.totalReps * secondsPerRep) / 3600
|
||||
|
||||
const calories = MET * weight * activeTimeHours
|
||||
return Math.round(calories * 10) / 10 // 保留一位小数
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动匹配最接近的食物对比
|
||||
*
|
||||
* @param burnedCalories 消耗的卡路里
|
||||
* @returns 食物对比信息
|
||||
*/
|
||||
export function getFoodComparison(burnedCalories: number): FoodComparison {
|
||||
const foods = Object.values(FOOD_CALORIES)
|
||||
|
||||
// 边界情况:食物数据库为空
|
||||
if (foods.length === 0) {
|
||||
throw new Error('食物数据库为空')
|
||||
}
|
||||
|
||||
// 边界情况:卡路里为 0 或负数
|
||||
if (burnedCalories <= 0) {
|
||||
return {
|
||||
food: foods[0],
|
||||
ratio: 0,
|
||||
message: `暂无消耗`,
|
||||
}
|
||||
}
|
||||
|
||||
// 找到卡路里最接近的食物
|
||||
const closest = foods.reduce((prev, curr) => {
|
||||
const prevDiff = Math.abs(prev.calories - burnedCalories)
|
||||
const currDiff = Math.abs(curr.calories - burnedCalories)
|
||||
return currDiff < prevDiff ? curr : prev
|
||||
})
|
||||
|
||||
const ratio = burnedCalories / closest.calories
|
||||
const ratioRounded = Math.round(ratio * 100) / 100 // 保留两位小数
|
||||
|
||||
// 格式化显示:ratio < 1 时显示分数形式更直观
|
||||
let displayText: string
|
||||
if (ratioRounded < 1) {
|
||||
displayText = `相当于 ${ratioRounded.toFixed(2)} 个${closest.name} ${closest.emoji}`
|
||||
} else {
|
||||
displayText = `相当于 ${ratioRounded.toFixed(2)} ${closest.unit}${closest.name} ${closest.emoji}`
|
||||
}
|
||||
|
||||
return {
|
||||
food: closest,
|
||||
ratio: ratioRounded,
|
||||
message: displayText,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时长(秒 → 分:秒)
|
||||
*
|
||||
* @param seconds 秒数
|
||||
* @returns 格式化字符串(如 "3:45")
|
||||
*/
|
||||
export function formatDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import uni from '@dcloudio/vite-plugin-uni'
|
||||
|
||||
// uni-app 工程根目录就是源码目录(HBuilderX 兼容)
|
||||
// 编译产物默认输出到 ./dist/<mode>/<platform>
|
||||
export default defineConfig({
|
||||
plugins: [uni()],
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
// 屏蔽 TUICallKit SDK 内部 @import / legacy-js-api 弃用警告
|
||||
// 待 TUICallKit 升级 @use 语法后可移除
|
||||
silenceDeprecations: ['legacy-js-api', 'import'],
|
||||
},
|
||||
},
|
||||
},
|
||||
// dev 热重载卡顿修复:
|
||||
// UNI_INPUT_DIR=$PWD 让工程根=源码目录, chokidar 会监听整个仓库,
|
||||
// 包含 node_modules / dist(编译产物) / unpackage 等巨大目录, 导致每次热重载卡住,
|
||||
// 写 dist 又触发监听抖动。这里收窄监听范围。
|
||||
// 注意: 自定义 ignored 会覆盖 vite 默认值, 故 node_modules/.git 必须一并列出。
|
||||
server: {
|
||||
watch: {
|
||||
ignored: [
|
||||
'**/node_modules/**',
|
||||
'**/.git/**',
|
||||
'**/dist/**',
|
||||
'**/unpackage/**',
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -100,6 +100,7 @@ export function doctorDailyStatsOverview(params: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
channel_code?: string
|
||||
dept_ids?: number[] | string
|
||||
}) {
|
||||
return request.get({ url: '/stats.doctorDailyStats/overview', params })
|
||||
}
|
||||
|
||||
@@ -482,11 +482,16 @@ export function prescriptionOrderRequestCompletion(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/requestCompletion', params })
|
||||
}
|
||||
|
||||
/** 将「已发货/已签收」且支付审核通过的订单结案(3=已完成 或 7-12 业务状态) */
|
||||
/** 将「已发货/已签收」且支付审核通过的订单结案(3=已完成 或 7-12 业务状态;退款请走 refund 或完成弹窗选「退款」) */
|
||||
export function prescriptionOrderComplete(params: { id: number; fulfillment_status: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/complete', params })
|
||||
}
|
||||
|
||||
/** 业务订单退款(须填写原因;refund_amount 可选,不传则退满可退上限) */
|
||||
export function prescriptionOrderRefund(params: { id: number; reason: string; refund_amount?: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/refund', params })
|
||||
}
|
||||
|
||||
/** 甘草药管家:中药处方下单(服务端 CTM_PREVIEW → CTM_SUBMIT_RECIPEL) */
|
||||
export function prescriptionOrderSubmitGancaoRecipel(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/submitGancaoRecipel', params })
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -102,8 +102,8 @@ export default defineComponent({
|
||||
const visible = ref(false)
|
||||
const fileList = ref<any[]>([])
|
||||
|
||||
// 仅 video + direct 时才接管 http-request
|
||||
const useDirect = computed(() => props.direct && props.type === 'video')
|
||||
// 仅 video/voice + direct 时才接管 http-request
|
||||
const useDirect = computed(() => props.direct && ['video', 'voice'].includes(props.type))
|
||||
|
||||
const handleProgress = () => {
|
||||
visible.value = true
|
||||
@@ -151,6 +151,8 @@ export default defineComponent({
|
||||
return '.jpg,.png,.gif,.jpeg,.ico'
|
||||
case 'video':
|
||||
return '.wmv,.avi,.mpg,.mpeg,.3gp,.mov,.mp4,.flv,.rmvb,.mkv'
|
||||
case 'voice':
|
||||
return '.mp3,.wav,.wma,.m4a,.aac,.amr'
|
||||
default:
|
||||
return '*'
|
||||
}
|
||||
@@ -162,7 +164,7 @@ export default defineComponent({
|
||||
try {
|
||||
const data = await uploadVideoDirectToCos({
|
||||
file: options.file,
|
||||
type: 'video',
|
||||
type: props.type as any,
|
||||
cid: Number((options.data as any)?.cid ?? 0),
|
||||
onProgress(info) {
|
||||
// 触发 ElUpload 内部进度(保持与默认上传一致的体验)
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<div class="asset-resource-container">
|
||||
<!-- 搜索区域 -->
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<el-form class="ls-form" :model="searchData" inline>
|
||||
<el-form-item class="w-[280px]" label="标题">
|
||||
<el-input
|
||||
placeholder="请输入标题关键词"
|
||||
v-model="searchData.title"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="上传时间">
|
||||
<el-date-picker
|
||||
v-model="searchData.dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
clearable
|
||||
style="width: 280px;"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 列表区域 -->
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<div class="mb-4">
|
||||
<el-button type="primary" @click="handleAdd">上传资源</el-button>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="queryParams.type" @tab-change="handleTabChange">
|
||||
<el-tab-pane label="图片" name="1"></el-tab-pane>
|
||||
<el-tab-pane label="视频" name="2"></el-tab-pane>
|
||||
<el-tab-pane label="语音" name="3"></el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading">
|
||||
<!-- <el-table-column prop="id" label="ID" width="80" /> -->
|
||||
<el-table-column prop="title" label="标题" min-width="80" />
|
||||
<el-table-column label="预览" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-image v-if="row.type == 1" :src="row.file_url" style="width: 50px; height: 50px;" :preview-src-list="[row.file_url]" preview-teleported />
|
||||
<el-link v-else :href="row.file_url" target="_blank" type="primary">点击预览</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="关联账号" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="!row.users?.length" type="info" size="small">公开资源</el-tag>
|
||||
<span v-else>{{ row.users?.map((u: any) => u.phone).join(', ') }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="create_time" label="上传时间" width="180" />
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page_no"
|
||||
v-model:page-size="queryParams.page_size"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="getList"
|
||||
@current-change="getList"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 新增/编辑资源弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑资源' : '新增分发资源'" width="600px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="formData" :rules="computedRules" label-width="100px">
|
||||
<el-form-item label="资源类型" prop="type" v-if="!isEdit">
|
||||
<el-radio-group v-model="formData.type">
|
||||
<el-radio :label="1">图片</el-radio>
|
||||
<el-radio :label="2">视频</el-radio>
|
||||
<el-radio :label="3">语音</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input v-model="formData.title" placeholder="请输入资源标题" />
|
||||
</el-form-item>
|
||||
<el-form-item label="资源文件" prop="file_url" v-if="!isEdit">
|
||||
<material-picker v-if="formData.type === 1" v-model="formData.file_url" :limit="1" type="image" />
|
||||
<upload v-else-if="formData.type === 2" type="video" direct :multiple="false" :limit="1" :show-progress="true" @success="handleUploadSuccess">
|
||||
<el-button type="primary">上传视频 (OSS直传)</el-button>
|
||||
</upload>
|
||||
<upload v-else-if="formData.type === 3" type="voice" direct :multiple="false" :limit="1" :show-progress="true" @success="handleUploadSuccess">
|
||||
<el-button type="primary">上传语音 (OSS直传)</el-button>
|
||||
</upload>
|
||||
<div v-if="formData.file_url && formData.type !== 1" class="ml-4 text-primary truncate max-w-xs" :title="formData.file_url">
|
||||
已上传: {{ formData.file_url }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="关联账号" prop="user_ids">
|
||||
<el-select v-model="formData.user_ids" multiple filterable placeholder="不选择则为公开资源" clearable style="width: 100%;">
|
||||
<el-option v-for="item in userOptions" :key="item.id" :label="item.phone" :value="item.id" />
|
||||
</el-select>
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px;">不选择账号则资源为公开资源,所有人可见</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitForm" :loading="submitLoading">{{ isEdit ? '保存修改' : '确定发布' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { apiAssetResourceList, apiAssetResourceAdd, apiAssetResourceEdit, apiAssetResourceDelete, apiAssetUserList } from '@/api/asset'
|
||||
import MaterialPicker from '@/components/material/picker.vue'
|
||||
import Upload from '@/components/upload/index.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref([])
|
||||
const total = ref(0)
|
||||
|
||||
const searchData = reactive<any>({
|
||||
title: '',
|
||||
dateRange: []
|
||||
})
|
||||
|
||||
const queryParams = reactive<any>({
|
||||
type: '1',
|
||||
page_no: 1,
|
||||
page_size: 15
|
||||
})
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const formRef = ref()
|
||||
const isEdit = ref(false)
|
||||
const editId = ref(0)
|
||||
|
||||
const userOptions = ref<any[]>([])
|
||||
|
||||
const formData = reactive<any>({
|
||||
type: 1,
|
||||
title: '',
|
||||
file_url: '',
|
||||
cover_url: '',
|
||||
user_ids: []
|
||||
})
|
||||
|
||||
const computedRules = computed(() => {
|
||||
if (isEdit.value) {
|
||||
return {
|
||||
title: [{ required: true, message: '请输入标题', trigger: 'blur' }]
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
|
||||
file_url: [{ required: true, message: '请输入或上传文件获取链接', trigger: 'blur' }]
|
||||
}
|
||||
})
|
||||
|
||||
const buildQueryParams = () => {
|
||||
const params: any = { ...queryParams }
|
||||
if (searchData.title) {
|
||||
params.title = searchData.title
|
||||
}
|
||||
if (searchData.dateRange && searchData.dateRange.length === 2) {
|
||||
params.start_time = searchData.dateRange[0]
|
||||
params.end_time = searchData.dateRange[1]
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await apiAssetResourceList(buildQueryParams())
|
||||
tableData.value = res.lists
|
||||
total.value = res.count
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchUserOptions = async () => {
|
||||
try {
|
||||
const res = await apiAssetUserList({ page_no: 1, page_size: 9999 })
|
||||
userOptions.value = res.lists
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
queryParams.page_no = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
searchData.title = ''
|
||||
searchData.dateRange = []
|
||||
queryParams.page_no = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
const handleTabChange = () => {
|
||||
queryParams.page_no = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
isEdit.value = false
|
||||
editId.value = 0
|
||||
Object.assign(formData, { type: parseInt(queryParams.type), title: '', file_url: '', cover_url: '', user_ids: [] })
|
||||
fetchUserOptions()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row: any) => {
|
||||
isEdit.value = true
|
||||
editId.value = row.id
|
||||
Object.assign(formData, {
|
||||
type: row.type,
|
||||
title: row.title,
|
||||
file_url: row.file_url,
|
||||
cover_url: row.cover_url || '',
|
||||
user_ids: row.users?.map((u: any) => u.id) || []
|
||||
})
|
||||
fetchUserOptions()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleUploadSuccess = (response: any) => {
|
||||
formData.file_url = response?.data?.uri || response?.data?.url || ''
|
||||
ElMessage.success('上传成功')
|
||||
}
|
||||
|
||||
const handleDelete = async (row: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该资源吗?用户将无法再看到。', '提示', { type: 'warning' })
|
||||
await apiAssetResourceDelete({ id: row.id })
|
||||
ElMessage.success('删除成功')
|
||||
getList()
|
||||
} catch (e) {
|
||||
// canceled
|
||||
}
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid: boolean) => {
|
||||
if (valid) {
|
||||
submitLoading.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await apiAssetResourceEdit({
|
||||
id: editId.value,
|
||||
title: formData.title,
|
||||
user_ids: formData.user_ids
|
||||
})
|
||||
ElMessage.success('编辑成功')
|
||||
} else {
|
||||
await apiAssetResourceAdd(formData)
|
||||
ElMessage.success('上传分发成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
getList()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getList()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div class="asset-user-container">
|
||||
<!-- 搜索区域 -->
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<el-form class="ls-form" :model="searchData" inline>
|
||||
<el-form-item class="w-[280px]" label="手机号">
|
||||
<el-input
|
||||
placeholder="请输入手机号"
|
||||
v-model="searchData.phone"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 列表区域 -->
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<div class="mb-4">
|
||||
<el-button type="primary" @click="handleAdd">新增账号</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading">
|
||||
<!-- <el-table-column prop="id" label="ID" width="80" /> -->
|
||||
<el-table-column prop="phone" label="手机号" />
|
||||
<el-table-column prop="create_time" label="创建时间" width="180" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-model="row.status"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
active-text="正常"
|
||||
inactive-text="禁用"
|
||||
inline-prompt
|
||||
@change="handleStatusChange(row)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page_no"
|
||||
v-model:page-size="queryParams.page_size"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="getList"
|
||||
@current-change="getList"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 编辑/新增弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="formData" :rules="rules" label-width="80px">
|
||||
<el-form-item label="手机号" prop="phone">
|
||||
<el-input v-model="formData.phone" placeholder="请输入手机号(作为登录账号)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input v-model="formData.password" placeholder="为空则默认为 123456" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-switch v-model="formData.status" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitForm" :loading="submitLoading">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { apiAssetUserList, apiAssetUserAdd, apiAssetUserEdit, apiAssetUserDelete } from '@/api/asset'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref([])
|
||||
const total = ref(0)
|
||||
|
||||
const searchData = reactive({
|
||||
phone: ''
|
||||
})
|
||||
|
||||
const queryParams = reactive<any>({
|
||||
page_no: 1,
|
||||
page_size: 15
|
||||
})
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('')
|
||||
const submitLoading = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
const formData = reactive({
|
||||
id: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
status: 1
|
||||
})
|
||||
|
||||
const rules = {
|
||||
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { ...queryParams, ...searchData }
|
||||
const res = await apiAssetUserList(params)
|
||||
tableData.value = res.lists
|
||||
total.value = res.count
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
queryParams.page_no = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
searchData.phone = ''
|
||||
queryParams.page_no = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
const handleStatusChange = async (row: any) => {
|
||||
try {
|
||||
await apiAssetUserEdit({ id: row.id, status: row.status })
|
||||
ElMessage.success(row.status === 1 ? '已启用' : '已禁用')
|
||||
} catch (e) {
|
||||
// 失败时回滚状态
|
||||
row.status = row.status === 1 ? 0 : 1
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
dialogTitle.value = '新增账号'
|
||||
Object.assign(formData, { id: '', phone: '', password: '', status: 1 })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row: any) => {
|
||||
dialogTitle.value = '编辑账号'
|
||||
Object.assign(formData, { id: row.id, phone: row.phone, password: '', status: row.status })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async (row: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该账号及其关联资源吗?', '提示', { type: 'warning' })
|
||||
await apiAssetUserDelete({ id: row.id })
|
||||
ElMessage.success('删除成功')
|
||||
getList()
|
||||
} catch (e) {
|
||||
// canceled
|
||||
}
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid: boolean) => {
|
||||
if (valid) {
|
||||
submitLoading.value = true
|
||||
try {
|
||||
if (formData.id) {
|
||||
await apiAssetUserEdit(formData)
|
||||
} else {
|
||||
await apiAssetUserAdd(formData)
|
||||
}
|
||||
ElMessage.success('操作成功')
|
||||
dialogVisible.value = false
|
||||
getList()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getList()
|
||||
})
|
||||
</script>
|
||||
@@ -407,20 +407,35 @@
|
||||
</div>
|
||||
|
||||
<div class="rx-herbs">
|
||||
<div
|
||||
v-for="(h, i) in slipHerbsList"
|
||||
:key="i"
|
||||
class="rx-herb-cell"
|
||||
>
|
||||
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }}克)</span>
|
||||
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}克</span>
|
||||
</div>
|
||||
<template v-if="slipMainHerbs.length">
|
||||
<div class="rx-herb-section-label">主方</div>
|
||||
<div
|
||||
v-for="(h, i) in slipMainHerbs"
|
||||
:key="'main-' + i"
|
||||
class="rx-herb-cell"
|
||||
>
|
||||
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }}克)</span>
|
||||
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}克</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="slipAuxHerbs.length">
|
||||
<div class="rx-herb-section-label rx-herb-section-label--aux">辅方</div>
|
||||
<div
|
||||
v-for="(h, i) in slipAuxHerbs"
|
||||
:key="'aux-' + i"
|
||||
class="rx-herb-cell"
|
||||
>
|
||||
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }}克)</span>
|
||||
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}克</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 服法 / 医嘱 / 备注 / 药房备注 / 出丸 -->
|
||||
<div class="rx-text">
|
||||
<p>服法:{{ rxUsageText }}</p>
|
||||
<p>主方服法:{{ rxUsageText }}</p>
|
||||
<p v-if="rxAuxUsageText">辅方服法:{{ rxAuxUsageText }}</p>
|
||||
<p v-if="rxAdviceText">医嘱:{{ rxAdviceText }}</p>
|
||||
<p v-if="rxRemarkText">备注:{{ rxRemarkText }}</p>
|
||||
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">
|
||||
@@ -649,53 +664,87 @@
|
||||
<el-form-item label="药材配方" prop="herbs" required>
|
||||
<div class="w-full">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<el-button type="primary" size="small" @click="addHerb">
|
||||
添加药材
|
||||
</el-button>
|
||||
<el-button type="success" size="small" @click="openLibraryDialog">
|
||||
<el-button
|
||||
type="success"
|
||||
size="small"
|
||||
v-perms="['cf.prescription/add', 'cf.prescription/edit', 'tcm.prescriptionLibrary/lists']"
|
||||
@click="openLibraryDialog"
|
||||
>
|
||||
从处方库导入
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<el-button type="warning" size="small" plain :icon="EditPen" @click="openPasteRecipeDialog">
|
||||
导入药方
|
||||
</el-button>
|
||||
<span class="text-xs text-gray-500 ml-2 align-middle">
|
||||
粘贴文本解析药名与剂量;须与药品库名称完全一致才录入
|
||||
<span class="text-xs text-gray-500">
|
||||
粘贴文本解析药名与剂量;须与药品库名称完全一致才录入(导入至主方)
|
||||
</span>
|
||||
</div>
|
||||
<el-table :data="editForm.herbs" class="mt-2" border>
|
||||
<el-table-column label="序号" type="index" width="60" />
|
||||
<el-table-column label="药材名称" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<MedicineNameSelect v-model="row.name" class="w-full" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剂量(克)" min-width="120">
|
||||
<template #default="{ row, $index }">
|
||||
<el-input-number
|
||||
v-model="row.dosage"
|
||||
:min="0"
|
||||
:precision="1"
|
||||
:step="0.5"
|
||||
placeholder="剂量"
|
||||
class="w-full"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ $index }">
|
||||
<el-button
|
||||
type="danger"
|
||||
link
|
||||
@click="removeHerb($index)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
</el-table>
|
||||
|
||||
<div class="herb-formula-block mt-3">
|
||||
<div class="herb-formula-block__head">
|
||||
<el-tag type="primary" size="small">主方</el-tag>
|
||||
<el-button type="primary" size="small" @click="addHerb('主方')">添加主方药材</el-button>
|
||||
</div>
|
||||
<el-table :data="mainHerbRows" class="mt-2" border empty-text="暂无主方药材">
|
||||
<el-table-column label="序号" type="index" width="60" />
|
||||
<el-table-column label="药材名称" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<MedicineNameSelect v-model="editForm.herbs[row.index].name" class="w-full" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剂量(克)" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-model="editForm.herbs[row.index].dosage"
|
||||
:min="0"
|
||||
:precision="1"
|
||||
:step="0.5"
|
||||
placeholder="剂量"
|
||||
class="w-full"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" link @click="removeHerb(row.index)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="herb-formula-block mt-4">
|
||||
<div class="herb-formula-block__head">
|
||||
<el-tag type="warning" size="small">辅方</el-tag>
|
||||
<el-button type="primary" size="small" plain @click="addHerb('辅方')">
|
||||
添加辅方药材
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table :data="auxHerbRows" class="mt-2" border empty-text="暂无辅方药材">
|
||||
<el-table-column label="序号" type="index" width="60" />
|
||||
<el-table-column label="药材名称" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<MedicineNameSelect v-model="editForm.herbs[row.index].name" class="w-full" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剂量(克)" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-model="editForm.herbs[row.index].dosage"
|
||||
:min="0"
|
||||
:precision="1"
|
||||
:step="0.5"
|
||||
placeholder="剂量"
|
||||
class="w-full"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" link @click="removeHerb(row.index)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<el-alert
|
||||
v-if="editMode === 'edit'"
|
||||
type="warning"
|
||||
@@ -778,8 +827,10 @@
|
||||
<el-option label="汤剂" value="汤剂" />
|
||||
</el-select>
|
||||
</el-form-item> </el-col>
|
||||
|
||||
<!-- 用量字段 -->
|
||||
</el-row>
|
||||
|
||||
<div class="usage-formula-title usage-formula-title--main">主方用法</div>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="用量" prop="dosage_amount">
|
||||
<!-- 浓缩水丸:1-10g 下拉选择 -->
|
||||
@@ -881,6 +932,97 @@
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<template v-if="auxHerbRows.length > 0">
|
||||
<div class="usage-formula-title usage-formula-title--aux">辅方用法</div>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="用量">
|
||||
<el-select
|
||||
v-if="editForm.prescription_type === '浓缩水丸'"
|
||||
v-model="editForm.aux_usage.dosage_amount"
|
||||
placeholder="请选择用量"
|
||||
class="w-full"
|
||||
>
|
||||
<el-option v-for="n in 10" :key="n" :label="n + 'g'" :value="n" />
|
||||
</el-select>
|
||||
<el-select
|
||||
v-else-if="editForm.prescription_type === '饮片'"
|
||||
v-model="editForm.aux_usage.dosage_amount"
|
||||
placeholder="请选择用量"
|
||||
class="w-full"
|
||||
>
|
||||
<el-option label="50ml" :value="50" />
|
||||
<el-option label="100ml" :value="100" />
|
||||
<el-option label="120ml" :value="120" />
|
||||
<el-option label="150ml" :value="150" />
|
||||
<el-option label="180ml" :value="180" />
|
||||
<el-option label="200ml" :value="200" />
|
||||
<el-option label="250ml" :value="250" />
|
||||
</el-select>
|
||||
<el-input-number
|
||||
v-else
|
||||
v-model="editForm.aux_usage.dosage_amount"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="editForm.prescription_type === '浓缩水丸'" :span="8">
|
||||
<el-form-item label=" " label-width="12px">
|
||||
<el-select
|
||||
v-model="editForm.aux_usage.dosage_bag_count"
|
||||
placeholder="请选择袋数"
|
||||
class="w-[120px]"
|
||||
>
|
||||
<el-option label="1袋" :value="1" />
|
||||
<el-option label="2袋" :value="2" />
|
||||
<el-option label="3袋" :value="3" />
|
||||
<el-option label="4袋" :value="4" />
|
||||
<el-option label="5袋" :value="5" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="editForm.prescription_type === '饮片'" :span="8">
|
||||
<el-form-item label="是否代煎">
|
||||
<el-radio-group v-model="editForm.aux_usage.need_decoction">
|
||||
<el-radio :label="true">代煎</el-radio>
|
||||
<el-radio :label="false">不代煎</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="editForm.prescription_type === '饮片'" :span="8">
|
||||
<el-form-item label="每贴出包数">
|
||||
<el-select v-model="editForm.aux_usage.bags_per_dose" placeholder="请选择" class="w-full">
|
||||
<el-option v-for="n in 9" :key="n" :label="n + '包'" :value="n" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="每天几次">
|
||||
<el-input-number
|
||||
v-model="editForm.aux_usage.times_per_day"
|
||||
:min="1"
|
||||
:max="6"
|
||||
class="!w-full"
|
||||
placeholder="每天服用次数"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="服用天数">
|
||||
<el-input-number
|
||||
v-model="editForm.aux_usage.usage_days"
|
||||
:min="1"
|
||||
:max="365"
|
||||
placeholder="服用天数"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
|
||||
<el-form-item label="用法" prop="usage_instruction">
|
||||
<el-input
|
||||
@@ -1365,16 +1507,17 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 从处方库导入(与诊间 tcm-prescription 一致,按当前处方开方医师 creator_id 筛选) -->
|
||||
<!-- 从处方库导入:当前开方医师的全部模板 + 所有人可见的公共模板 -->
|
||||
<el-dialog
|
||||
v-model="showLibraryDialog"
|
||||
title="从处方库导入"
|
||||
width="800px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<div class="mb-4 flex gap-3">
|
||||
<el-input
|
||||
v-model="librarySearchName"
|
||||
class="flex-1"
|
||||
placeholder="请输入处方名称搜索"
|
||||
clearable
|
||||
@keyup.enter="searchLibrary"
|
||||
@@ -1384,6 +1527,24 @@
|
||||
<el-button :icon="Search" @click="searchLibrary" />
|
||||
</template>
|
||||
</el-input>
|
||||
<el-select
|
||||
v-model="librarySearchFormulaType"
|
||||
placeholder="处方类型"
|
||||
clearable
|
||||
class="w-[140px]"
|
||||
@change="searchLibrary"
|
||||
>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="主方" value="主方" />
|
||||
<el-option label="辅方" value="辅方" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="mb-3 flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<span class="text-sm text-gray-600 shrink-0">导入方式</span>
|
||||
<el-radio-group v-model="libraryImportMode">
|
||||
<el-radio label="replace">覆盖现有药材</el-radio>
|
||||
<el-radio label="append">追加到末尾</el-radio>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
@@ -1394,6 +1555,13 @@
|
||||
@row-click="handleSelectLibraryRow"
|
||||
>
|
||||
<el-table-column label="处方名称" prop="prescription_name" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="处方类型" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.formula_type === '辅方' ? 'warning' : 'primary'" size="small">
|
||||
{{ row.formula_type || '主方' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="药材数量" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.herbs?.length || 0 }}味
|
||||
@@ -1407,6 +1575,13 @@
|
||||
<span v-else class="text-gray-400">暂无药材</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="是否公开" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_public ? 'success' : 'info'" size="small">
|
||||
{{ row.is_public ? '所有人可见' : '仅自己可见' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建人" prop="creator_name" width="100" />
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
@@ -1522,6 +1697,122 @@ import jsPDF from 'jspdf'
|
||||
|
||||
const TcmDiagnosisEditView = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
||||
|
||||
type FormulaType = '主方' | '辅方'
|
||||
type HerbRow = { name: string; dosage: number; formula_type: FormulaType }
|
||||
|
||||
type AuxUsageForm = {
|
||||
dosage_amount?: number
|
||||
dosage_bag_count: number
|
||||
need_decoction: boolean
|
||||
bags_per_dose: number
|
||||
times_per_day: number
|
||||
usage_days: number
|
||||
/** 从处方库导入辅方时写入的模板名称,供药房联展示 */
|
||||
prescription_name?: string
|
||||
}
|
||||
|
||||
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,
|
||||
prescription_name: String(o.prescription_name ?? '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
function buildUsageSegmentText(
|
||||
usage: {
|
||||
prescription_type?: string
|
||||
dosage_amount?: number | null
|
||||
dosage_unit?: string
|
||||
dosage_bag_count?: number
|
||||
times_per_day?: number
|
||||
usage_way?: string
|
||||
usage_time?: string
|
||||
},
|
||||
fallbackWay?: string,
|
||||
fallbackTime?: string
|
||||
): string {
|
||||
const pt = usage.prescription_type || '浓缩水丸'
|
||||
const times = Number(usage.times_per_day) > 0 ? Number(usage.times_per_day) : 3
|
||||
const amount = usage.dosage_amount != null ? Number(usage.dosage_amount) : 10
|
||||
const unit = usage.dosage_unit || (pt === '饮片' ? 'ml' : 'g')
|
||||
const usageWay = usage.usage_way || fallbackWay || '温水送服'
|
||||
const usageTime = usage.usage_time || fallbackTime || ''
|
||||
const seg: string[] = []
|
||||
seg.push(`每天${times}次`)
|
||||
if (pt === '浓缩水丸') {
|
||||
const bags = Number(usage.dosage_bag_count) > 0 ? Number(usage.dosage_bag_count) : 1
|
||||
seg.push(`一次${bags}袋`)
|
||||
seg.push(`每袋${amount}${unit}`)
|
||||
} else {
|
||||
seg.push(`一次${amount}${unit}`)
|
||||
}
|
||||
seg.push(usageWay)
|
||||
if (usageTime) seg.push(usageTime)
|
||||
return seg.join(', ')
|
||||
}
|
||||
|
||||
function normalizeFormulaType(v: unknown): FormulaType {
|
||||
return v === '辅方' ? '辅方' : '主方'
|
||||
}
|
||||
|
||||
function normalizeHerbRow(raw: any): HerbRow {
|
||||
return {
|
||||
name: String(raw?.name ?? '').trim(),
|
||||
dosage: Number(raw?.dosage) || 0,
|
||||
formula_type: normalizeFormulaType(raw?.formula_type)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSlipHerbs(raw: unknown): HerbRow[] {
|
||||
if (!raw) return []
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map((x: any) => normalizeHerbRow(x))
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/** 与 server/config/project.php prescription_audit_roles 保持一致 */
|
||||
const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3]
|
||||
|
||||
@@ -1923,7 +2214,13 @@ const createOrderHerbSummary = computed(() => {
|
||||
const row = createOrderPrescription.value
|
||||
const herbs = normalizeSlipHerbs(row?.herbs)
|
||||
if (!herbs.length) return '暂无药材明细'
|
||||
const s = herbs.map((h) => `${h.name} ${h.dosage}g`).join('、')
|
||||
const main = herbs.filter((h) => normalizeFormulaType(h.formula_type) === '主方')
|
||||
const aux = herbs.filter((h) => normalizeFormulaType(h.formula_type) === '辅方')
|
||||
const fmt = (list: HerbRow[]) => list.map((h) => `${h.name} ${h.dosage}g`).join('、')
|
||||
const parts: string[] = []
|
||||
if (main.length) parts.push(`主方:${fmt(main)}`)
|
||||
if (aux.length) parts.push(`辅方:${fmt(aux)}`)
|
||||
const s = parts.join(';')
|
||||
return s.length > 120 ? `${s.slice(0, 120)}…` : s
|
||||
})
|
||||
|
||||
@@ -2163,9 +2460,27 @@ const editReviveHint = computed(() => {
|
||||
|
||||
const slipHerbsList = computed(() => {
|
||||
const h = slipView.value?.herbs
|
||||
return Array.isArray(h) ? h : []
|
||||
return Array.isArray(h) ? (h as HerbRow[]) : []
|
||||
})
|
||||
|
||||
const slipMainHerbs = computed(() =>
|
||||
slipHerbsList.value.filter((h) => normalizeFormulaType(h.formula_type) === '主方')
|
||||
)
|
||||
const slipAuxHerbs = computed(() =>
|
||||
slipHerbsList.value.filter((h) => normalizeFormulaType(h.formula_type) === '辅方')
|
||||
)
|
||||
|
||||
const mainHerbRows = computed(() =>
|
||||
editForm.herbs
|
||||
.map((herb, index) => ({ herb, index }))
|
||||
.filter(({ herb }) => normalizeFormulaType(herb.formula_type) === '主方')
|
||||
)
|
||||
const auxHerbRows = computed(() =>
|
||||
editForm.herbs
|
||||
.map((herb, index) => ({ herb, index }))
|
||||
.filter(({ herb }) => normalizeFormulaType(herb.formula_type) === '辅方')
|
||||
)
|
||||
|
||||
const slipDietaryText = computed(() => {
|
||||
const d = slipView.value?.dietary_taboo
|
||||
if (Array.isArray(d)) return d.filter(Boolean).join('、')
|
||||
@@ -2251,23 +2566,26 @@ const rxUsageText = computed(() => {
|
||||
const v = slipView.value as any
|
||||
if (!v) return '—'
|
||||
if (v.usage_text) return v.usage_text
|
||||
const times = Number(v.times_per_day) > 0 ? Number(v.times_per_day) : 3
|
||||
const amount = v.dosage_amount != null ? Number(v.dosage_amount) : 10
|
||||
const unit = v.dosage_unit || 'g'
|
||||
const usageWay = v.usage_way || '温水送服'
|
||||
const usageTime = v.usage_time || ''
|
||||
const seg: string[] = []
|
||||
seg.push(`每天${times}次`)
|
||||
if ((v.prescription_type || '浓缩水丸') === '浓缩水丸') {
|
||||
const bags = Number(v.dosage_bag_count) > 0 ? Number(v.dosage_bag_count) : 1
|
||||
seg.push(`一次${bags}袋`)
|
||||
seg.push(`每袋${amount}${unit}`)
|
||||
} else {
|
||||
seg.push(`一次${amount}${unit}`)
|
||||
}
|
||||
seg.push(usageWay)
|
||||
if (usageTime) seg.push(usageTime)
|
||||
return seg.join(', ')
|
||||
return buildUsageSegmentText(v)
|
||||
})
|
||||
|
||||
const rxAuxUsageText = computed(() => {
|
||||
const v = slipView.value as any
|
||||
if (!v || !slipAuxHerbs.value.length) return ''
|
||||
const aux = normalizeAuxUsageForm(v.aux_usage, v.prescription_type || '浓缩水丸')
|
||||
return buildUsageSegmentText(
|
||||
{
|
||||
prescription_type: v.prescription_type,
|
||||
dosage_amount: aux.dosage_amount,
|
||||
dosage_unit: v.dosage_unit,
|
||||
dosage_bag_count: aux.dosage_bag_count,
|
||||
times_per_day: aux.times_per_day,
|
||||
usage_way: v.usage_way,
|
||||
usage_time: v.usage_time
|
||||
},
|
||||
v.usage_way,
|
||||
v.usage_time
|
||||
)
|
||||
})
|
||||
|
||||
const rxAdviceText = computed(() => {
|
||||
@@ -2462,7 +2780,7 @@ const editForm = reactive({
|
||||
pulse: '',
|
||||
pulse_condition: '',
|
||||
clinical_diagnosis: '',
|
||||
herbs: [] as Array<{ name: string; dosage: number }>,
|
||||
herbs: [] as HerbRow[],
|
||||
dose_count: 7,
|
||||
dose_unit: '剂',
|
||||
usage_days: 7,
|
||||
@@ -2488,7 +2806,8 @@ const editForm = reactive({
|
||||
/** 列表带入:业务订单「处方审核」驳回(消费者处方本身可能仍为已通过) */
|
||||
business_prescription_audit_rejected: 0,
|
||||
business_prescription_audit_remark: '',
|
||||
times_per_day: 2
|
||||
times_per_day: 2,
|
||||
aux_usage: defaultAuxUsage('浓缩水丸')
|
||||
})
|
||||
|
||||
/** 编辑页:关联业务订单上的服用天数、医助备注(与处方提示一致) */
|
||||
@@ -2579,6 +2898,7 @@ watch(() => editForm.prescription_type, (newType) => {
|
||||
editForm.need_decoction = false
|
||||
editForm.bags_per_dose = 1
|
||||
}
|
||||
Object.assign(editForm.aux_usage, defaultAuxUsage(newType))
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
@@ -2807,15 +3127,15 @@ function slipAgeText(age: unknown) {
|
||||
return `${age}岁`
|
||||
}
|
||||
|
||||
function normalizeSlipHerbs(raw: unknown): Array<{ name: string; dosage: number }> {
|
||||
if (!raw) return []
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map((x: any) => ({
|
||||
name: String(x?.name ?? '').trim(),
|
||||
dosage: Number(x?.dosage) || 0
|
||||
}))
|
||||
function herbValidationLabel(globalIndex: number): string {
|
||||
const herb = editForm.herbs[globalIndex]
|
||||
if (!herb) return `第${globalIndex + 1}味`
|
||||
const ft = normalizeFormulaType(herb.formula_type)
|
||||
let n = 0
|
||||
for (let i = 0; i <= globalIndex; i++) {
|
||||
if (normalizeFormulaType(editForm.herbs[i]?.formula_type) === ft) n++
|
||||
}
|
||||
return []
|
||||
return `${ft}第${n}味`
|
||||
}
|
||||
|
||||
function listRxOrderWarnings(row: any): string[] {
|
||||
@@ -3038,6 +3358,8 @@ const showLibraryDialog = ref(false)
|
||||
const libraryLoading = ref(false)
|
||||
const libraryList = ref<any[]>([])
|
||||
const librarySearchName = ref('')
|
||||
const librarySearchFormulaType = ref('')
|
||||
const libraryImportMode = ref<'replace' | 'append'>('replace')
|
||||
const libraryPage = ref(1)
|
||||
const libraryPageSize = ref(15)
|
||||
const libraryTotal = ref(0)
|
||||
@@ -3082,8 +3404,8 @@ const loadLibraryList = async () => {
|
||||
page_no: libraryPage.value,
|
||||
page_size: libraryPageSize.value,
|
||||
prescription_name: librarySearchName.value,
|
||||
is_public: '',
|
||||
creator_id: doctorId
|
||||
formula_type: librarySearchFormulaType.value,
|
||||
prescribing_creator_id: doctorId
|
||||
})
|
||||
libraryList.value = res?.lists || []
|
||||
libraryTotal.value = res?.count || 0
|
||||
@@ -3118,18 +3440,37 @@ const handleImportLibrary = (row: any) => {
|
||||
feedback.msgWarning('该处方没有药材信息')
|
||||
return
|
||||
}
|
||||
const imported = JSON.parse(JSON.stringify(row.herbs)) as Array<{ name: string; dosage: number }>
|
||||
editForm.herbs = imported
|
||||
const formulaType = normalizeFormulaType(row.formula_type)
|
||||
const imported = (JSON.parse(JSON.stringify(row.herbs)) as any[]).map((h) => ({
|
||||
...normalizeHerbRow(h),
|
||||
formula_type: formulaType
|
||||
}))
|
||||
if (libraryImportMode.value === 'replace') {
|
||||
const kept = editForm.herbs.filter((h) => normalizeFormulaType(h.formula_type) !== formulaType)
|
||||
editForm.herbs = [...kept, ...imported]
|
||||
} else {
|
||||
editForm.herbs.push(...imported)
|
||||
}
|
||||
if (formulaType === '辅方') {
|
||||
editForm.aux_usage.prescription_name = String(row.prescription_name ?? '').trim()
|
||||
}
|
||||
|
||||
const modeHint = libraryImportMode.value === 'append' ? '(已追加)' : ''
|
||||
if (findDuplicateHerbNamesLocal().length) {
|
||||
feedback.msgWarning('导入的处方中存在重复药名,请合并剂量或删除多余行')
|
||||
feedback.msgWarning(
|
||||
`已导入处方「${row.prescription_name}」${modeHint},共${imported.length}味药材。存在重复药名,请合并剂量或删除多余行`
|
||||
)
|
||||
} else {
|
||||
feedback.msgSuccess(`已导入处方「${row.prescription_name}」${modeHint},共${imported.length}味药材`)
|
||||
}
|
||||
feedback.msgSuccess(`已导入处方「${row.prescription_name}」,共${imported.length}味药材`)
|
||||
showLibraryDialog.value = false
|
||||
}
|
||||
|
||||
watch(showLibraryDialog, (open) => {
|
||||
if (open) {
|
||||
librarySearchName.value = ''
|
||||
librarySearchFormulaType.value = ''
|
||||
libraryImportMode.value = 'replace'
|
||||
libraryPage.value = 1
|
||||
loadLibraryList()
|
||||
}
|
||||
@@ -3261,7 +3602,7 @@ async function handlePasteRecipeImport() {
|
||||
}
|
||||
pasteRecipeImportLoading.value = true
|
||||
try {
|
||||
const resolved: Array<{ name: string; dosage: number }> = []
|
||||
const resolved: HerbRow[] = []
|
||||
const skippedNames: string[] = []
|
||||
for (const row of parsed) {
|
||||
const name = await resolvePasteHerbNameFromLibrary(row.name)
|
||||
@@ -3269,7 +3610,7 @@ async function handlePasteRecipeImport() {
|
||||
skippedNames.push(row.name.trim())
|
||||
continue
|
||||
}
|
||||
resolved.push({ name, dosage: row.dosage })
|
||||
resolved.push({ name, dosage: row.dosage, formula_type: '主方' })
|
||||
}
|
||||
const skippedUnique = [...new Set(skippedNames.filter(Boolean))]
|
||||
if (resolved.length === 0) {
|
||||
@@ -3281,7 +3622,8 @@ async function handlePasteRecipeImport() {
|
||||
return
|
||||
}
|
||||
if (pasteRecipeImportMode.value === 'replace') {
|
||||
editForm.herbs = resolved
|
||||
const auxKept = editForm.herbs.filter((h) => normalizeFormulaType(h.formula_type) === '辅方')
|
||||
editForm.herbs = [...auxKept, ...resolved]
|
||||
} else {
|
||||
editForm.herbs.push(...resolved)
|
||||
}
|
||||
@@ -3306,11 +3648,12 @@ async function handlePasteRecipeImport() {
|
||||
}
|
||||
}
|
||||
|
||||
// 添加药材
|
||||
const addHerb = () => {
|
||||
// 添加药材(主方 / 辅方)
|
||||
const addHerb = (formulaType: FormulaType = '主方') => {
|
||||
editForm.herbs.push({
|
||||
name: '',
|
||||
dosage: 0
|
||||
dosage: 0,
|
||||
formula_type: formulaType
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3360,6 +3703,8 @@ const resetForm = () => {
|
||||
editForm.is_system_auto = 0
|
||||
editForm.business_prescription_audit_rejected = 0
|
||||
editForm.business_prescription_audit_remark = ''
|
||||
editForm.times_per_day = 2
|
||||
Object.assign(editForm.aux_usage, defaultAuxUsage('浓缩水丸'))
|
||||
clearRxLinkedOrderHint()
|
||||
}
|
||||
|
||||
@@ -3458,7 +3803,7 @@ const handleEdit = async (row: any) => {
|
||||
editForm.pulse = src.pulse || ''
|
||||
editForm.pulse_condition = src.pulse_condition || ''
|
||||
editForm.clinical_diagnosis = src.clinical_diagnosis || ''
|
||||
editForm.herbs = src.herbs ? JSON.parse(JSON.stringify(src.herbs)) : []
|
||||
editForm.herbs = src.herbs ? (src.herbs as any[]).map((h) => normalizeHerbRow(h)) : []
|
||||
editForm.dose_count = src.dose_count ?? 7
|
||||
editForm.dose_unit = src.dose_unit || '剂'
|
||||
editForm.usage_days = src.usage_days ?? 7
|
||||
@@ -3483,6 +3828,10 @@ const handleEdit = async (row: any) => {
|
||||
editForm.is_system_auto = Number(src.is_system_auto) === 1 ? 1 : 0
|
||||
editForm.business_prescription_audit_rejected = Number(src.business_prescription_audit_rejected) === 1 ? 1 : 0
|
||||
editForm.business_prescription_audit_remark = String(src.business_prescription_audit_remark || '')
|
||||
Object.assign(
|
||||
editForm.aux_usage,
|
||||
normalizeAuxUsageForm(src.aux_usage, editForm.prescription_type)
|
||||
)
|
||||
|
||||
// 数据加载完成,重新启用watch
|
||||
// 使用 setTimeout 确保所有数据都已经渲染完成
|
||||
@@ -3517,12 +3866,13 @@ const handleSubmit = async () => {
|
||||
|
||||
for (let i = 0; i < editForm.herbs.length; i++) {
|
||||
const herb = editForm.herbs[i]
|
||||
const label = herbValidationLabel(i)
|
||||
if (!herb.name || !herb.name.trim()) {
|
||||
feedback.msgError(`第${i + 1}味药材名称不能为空`)
|
||||
feedback.msgError(`${label}药材名称不能为空`)
|
||||
return
|
||||
}
|
||||
if (!herb.dosage || herb.dosage <= 0) {
|
||||
feedback.msgError(`第${i + 1}味药材剂量必须大于0`)
|
||||
feedback.msgError(`${label}药材剂量必须大于0`)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -3946,6 +4296,38 @@ onMounted(async () => {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.rx-herb-section-label {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #409eff;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.rx-herb-section-label--aux {
|
||||
color: #e6a23c;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.herb-formula-block__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.usage-formula-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin: 8px 0 4px;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.usage-formula-title--aux {
|
||||
color: #e6a23c;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.rx-herb-cell {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 64px;
|
||||
|
||||
@@ -11,6 +11,13 @@
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item class="w-[200px]" label="处方类型">
|
||||
<el-select v-model="formData.formula_type" placeholder="全部" clearable>
|
||||
<el-option label="全部" :value="''" />
|
||||
<el-option label="主方" value="主方" />
|
||||
<el-option label="辅方" value="辅方" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item class="w-[200px]" label="是否公开">
|
||||
<el-select v-model="formData.is_public" placeholder="全部" clearable>
|
||||
<el-option label="全部" :value="''" />
|
||||
@@ -37,6 +44,13 @@
|
||||
<el-table :data="pager.lists" size="large">
|
||||
<el-table-column label="ID" prop="id" min-width="60" />
|
||||
<el-table-column label="处方名称" prop="prescription_name" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="处方类型" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.formula_type === '辅方' ? 'warning' : 'primary'" size="small">
|
||||
{{ row.formula_type || '主方' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="药材数量" min-width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.herbs?.length || 0 }}味
|
||||
@@ -114,6 +128,13 @@
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="处方类型" prop="formula_type">
|
||||
<el-radio-group v-model="editForm.formula_type">
|
||||
<el-radio value="主方">主方</el-radio>
|
||||
<el-radio value="辅方">辅方</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="药材配方" prop="herbs" required>
|
||||
<div class="w-full">
|
||||
@@ -200,6 +221,7 @@ import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
prescription_name: '',
|
||||
formula_type: '',
|
||||
is_public: ''
|
||||
})
|
||||
|
||||
@@ -212,6 +234,7 @@ const formRef = ref<FormInstance>()
|
||||
const editForm = reactive({
|
||||
id: 0,
|
||||
prescription_name: '',
|
||||
formula_type: '主方',
|
||||
herbs: [] as Array<{ name: string; dosage: number }>,
|
||||
is_public: 0
|
||||
})
|
||||
@@ -267,6 +290,7 @@ const removeHerb = (index: number) => {
|
||||
const resetForm = () => {
|
||||
editForm.id = 0
|
||||
editForm.prescription_name = ''
|
||||
editForm.formula_type = '主方'
|
||||
editForm.herbs = []
|
||||
editForm.is_public = 0
|
||||
}
|
||||
@@ -283,6 +307,7 @@ const handleView = (row: any) => {
|
||||
editMode.value = 'view'
|
||||
editForm.id = row.id
|
||||
editForm.prescription_name = row.prescription_name || ''
|
||||
editForm.formula_type = row.formula_type || '主方'
|
||||
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
|
||||
editForm.is_public = row.is_public ?? 0
|
||||
showEdit.value = true
|
||||
@@ -293,6 +318,7 @@ const handleEdit = (row: any) => {
|
||||
editMode.value = 'edit'
|
||||
editForm.id = row.id
|
||||
editForm.prescription_name = row.prescription_name || ''
|
||||
editForm.formula_type = row.formula_type || '主方'
|
||||
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
|
||||
editForm.is_public = row.is_public ?? 0
|
||||
showEdit.value = true
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -234,6 +234,22 @@
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="showAuditAdminFilter" class="w-[200px]" label="下单人">
|
||||
<el-select
|
||||
v-model="queryParams.audit_admin_id"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="全部下单人"
|
||||
class="!w-full"
|
||||
>
|
||||
<el-option
|
||||
v-for="a in auditAdminOptions"
|
||||
:key="a.id"
|
||||
:label="a.name"
|
||||
:value="a.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item class="w-[200px]" label="医生">
|
||||
<el-select
|
||||
v-model="queryParams.doctor_id"
|
||||
@@ -482,6 +498,9 @@
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item v-if="canAddPayOrderRow(row)" command="addPayOrder">补齐支付单</el-dropdown-item>
|
||||
<el-dropdown-item v-if="canCompleteRow(row)" command="complete">完成订单</el-dropdown-item>
|
||||
<el-dropdown-item v-if="canRefundRow(row)" command="refund">
|
||||
<span class="text-red-500">退款</span>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item v-if="canUploadGancaoRow(row)" command="submitGancao">上传药方</el-dropdown-item>
|
||||
<el-dropdown-item v-if="canWithdrawRow(row)" command="withdraw" divided>
|
||||
<span class="text-red-500">撤回订单</span>
|
||||
@@ -596,6 +615,16 @@
|
||||
>
|
||||
完成订单
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canRefundRow(detailData)"
|
||||
v-perms="['tcm.prescriptionOrder/refund']"
|
||||
type="danger"
|
||||
size="small"
|
||||
plain
|
||||
@click="openRefundOrder(detailData)"
|
||||
>
|
||||
退款
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1816,9 +1845,11 @@
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
class="po-h5-dialog"
|
||||
@closed="completeOrderId = 0"
|
||||
@closed="onCompleteOrderDialogClosed"
|
||||
>
|
||||
<p class="text-sm text-gray-600 mb-3">请选择本单结案状态。选择「已完成」时,实付将按已关联的支付单金额汇总。</p>
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
选择「已完成」按关联收款汇总实付;选择「退款」将关联收款标记已退款并清零已付。
|
||||
</p>
|
||||
<el-select
|
||||
v-model="completeFulfillmentStatus"
|
||||
class="w-full"
|
||||
@@ -1831,10 +1862,101 @@
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<template v-if="completeFulfillmentStatus === 10">
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mt-3"
|
||||
title="关联收款将标记已退款,本单已付清零,不可撤销。"
|
||||
/>
|
||||
<p v-if="completeOrderLinkedPays.length" class="text-xs text-amber-800 mt-2">
|
||||
将退款 {{ completeOrderLinkedPays.length }} 笔关联收款,合计 ¥{{ formatMoney(completeOrderRefundTotal) }}
|
||||
</p>
|
||||
<el-form
|
||||
ref="completeRefundFormRef"
|
||||
:model="completeRefundForm"
|
||||
:rules="completeRefundRules"
|
||||
label-width="80px"
|
||||
class="mt-2"
|
||||
>
|
||||
<el-form-item label="退款金额" prop="refund_amount">
|
||||
<el-input-number
|
||||
v-model="completeRefundForm.refund_amount"
|
||||
:min="0.01"
|
||||
:max="completeOrderRefundMax > 0 ? completeOrderRefundMax : undefined"
|
||||
:step="0.01"
|
||||
:precision="2"
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="text-xs text-gray-500 mt-1">可退上限 ¥{{ formatMoney(completeOrderRefundMax) }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="退款原因" prop="reason">
|
||||
<el-input
|
||||
v-model="completeRefundForm.reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
placeholder="必填"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
<template #footer>
|
||||
<el-button @click="completeOrderDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="completeOrderSubmitting" @click="submitCompleteOrder">
|
||||
确定
|
||||
<el-button
|
||||
:type="completeFulfillmentStatus === 10 ? 'danger' : 'primary'"
|
||||
:loading="completeOrderSubmitting"
|
||||
@click="submitCompleteOrder"
|
||||
>
|
||||
{{ completeFulfillmentStatus === 10 ? '确认退款' : '确定' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<!-- 订单退款:须填写原因 -->
|
||||
<el-dialog
|
||||
v-model="refundOrderDialogVisible"
|
||||
title="订单退款"
|
||||
width="92%"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
class="po-h5-dialog"
|
||||
@closed="onRefundOrderDialogClosed"
|
||||
>
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-4 !items-start"
|
||||
:title="refundOrderAlertTitle"
|
||||
/>
|
||||
<el-form ref="refundOrderFormRef" :model="refundOrderForm" :rules="refundOrderRules" label-width="88px">
|
||||
<el-form-item label="退款金额" prop="refund_amount">
|
||||
<el-input-number
|
||||
v-model="refundOrderForm.refund_amount"
|
||||
:min="0.01"
|
||||
:max="refundOrderMaxAmount > 0 ? refundOrderMaxAmount : undefined"
|
||||
:step="0.01"
|
||||
:precision="2"
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="text-xs text-gray-500 mt-1">可退上限 ¥{{ formatMoney(refundOrderMaxAmount) }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="退款原因" prop="reason">
|
||||
<el-input
|
||||
v-model="refundOrderForm.reason"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="请填写退款原因(必填)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="refundOrderDialogVisible = false">取消</el-button>
|
||||
<el-button type="danger" :loading="refundOrderSubmitting" @click="submitRefundOrder">
|
||||
确认退款
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@@ -2075,7 +2197,7 @@
|
||||
|
||||
<!-- A4 处方纸(药房联) -->
|
||||
<div v-if="prescriptionViewData" ref="prescriptionSlipPrintRef" class="rx-paper">
|
||||
<div class="rx-title">处方单</div>
|
||||
<div class="rx-title">{{ SLIP_TITLE }}</div>
|
||||
<div class="rx-notice">
|
||||
<span class="rx-notice-text">
|
||||
服药前请核对姓名、电话、医生等信息以及服法、医嘱等要点
|
||||
@@ -2400,6 +2522,7 @@ import {
|
||||
prescriptionOrderUpdateAmount,
|
||||
prescriptionOrderAddPayOrder,
|
||||
prescriptionOrderComplete,
|
||||
prescriptionOrderRefund,
|
||||
prescriptionOrderRevokeRxAudit,
|
||||
prescriptionOrderRevokePayAudit,
|
||||
prescriptionOrderPatchPrescriptionPatient,
|
||||
@@ -2440,9 +2563,39 @@ function openDiagnosisPatientDetailFromOrder() {
|
||||
|
||||
/** 诊间医助角色(与 DiagnosisLists 等一致) */
|
||||
const TCM_ASSISTANT_ROLE_ID = 2
|
||||
/** 下单角色(与 server/config/project.php prescription_order_placer_role_ids 一致) */
|
||||
const ORDER_PLACER_ROLE_ID = 6
|
||||
/** 拥有以下角色时可按任意审核人筛选(与 prescription_order_placer_exempt_role_ids 一致) */
|
||||
const ORDER_PLACER_EXEMPT_ROLE_IDS = [3, 8]
|
||||
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
|
||||
const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
|
||||
|
||||
/** 「下单」角色:下单人筛选仅允许查本人审核过的订单 */
|
||||
const isOrderPlacerAuditFilterRestricted = computed(() => {
|
||||
const u = userStore.userInfo
|
||||
if (!u || Number(u.root) === 1) return false
|
||||
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
|
||||
if (!ids.includes(ORDER_PLACER_ROLE_ID)) return false
|
||||
return !ORDER_PLACER_EXEMPT_ROLE_IDS.some((rid) => ids.includes(rid))
|
||||
})
|
||||
|
||||
/** 超管 / 经理 / 管理员:可按任意下单人筛选 */
|
||||
const canSearchAnyAuditAdmin = computed(() => {
|
||||
const u = userStore.userInfo
|
||||
if (!u) return false
|
||||
if (Number(u.root) === 1) return true
|
||||
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
|
||||
return ORDER_PLACER_EXEMPT_ROLE_IDS.some((rid) => ids.includes(rid))
|
||||
})
|
||||
|
||||
/** 是否展示「下单人」筛选(下单角色 + 经理/管理员/超管) */
|
||||
const showAuditAdminFilter = computed(
|
||||
() => isOrderPlacerAuditFilterRestricted.value || canSearchAnyAuditAdmin.value
|
||||
)
|
||||
|
||||
/** 下单人下拉(lists extend.audit_admin_options,下单角色用户) */
|
||||
const auditAdminOptions = ref<Array<{ id: number; name: string }>>([])
|
||||
|
||||
/**
|
||||
* 是否展示「处方审核」筛选:纯医助(含角色 2 且不具备处方审核角色)仅能用「支付单审核」筛选
|
||||
*/
|
||||
@@ -2673,7 +2826,9 @@ const queryParams = reactive({
|
||||
prescription_audit_status: '' as number | '',
|
||||
payment_slip_audit_status: '' as number | '',
|
||||
/** 供货方式:甘草(已传甘草药方单号)/ 自营(无甘草单号) */
|
||||
supply_mode: '' as '' | 'gancao' | 'self'
|
||||
supply_mode: '' as '' | 'gancao' | 'self',
|
||||
/** 下单人(关联操作日志 audit_rx_* / audit_pay_*) */
|
||||
audit_admin_id: '' as number | ''
|
||||
})
|
||||
|
||||
const rxAuditTabs = [
|
||||
@@ -2767,6 +2922,11 @@ async function fetchLists(params: Record<string, unknown>) {
|
||||
}
|
||||
if (!p.order_no) delete p.order_no
|
||||
if (!String(p.patient_keyword || '').trim()) delete p.patient_keyword
|
||||
if (p.audit_admin_id === '' || p.audit_admin_id === undefined || p.audit_admin_id === null) {
|
||||
delete p.audit_admin_id
|
||||
} else {
|
||||
p.audit_admin_id = Number(p.audit_admin_id)
|
||||
}
|
||||
if (!String(p.express_keyword || '').trim()) delete p.express_keyword
|
||||
if (!p.express_company || p.express_company === '') delete p.express_company
|
||||
if (!String(p.start_time || '').trim() || !String(p.end_time || '').trim()) {
|
||||
@@ -2789,6 +2949,24 @@ const { pager, getLists, resetPage, resetParams } = usePaging({
|
||||
params: queryParams
|
||||
})
|
||||
|
||||
watch(
|
||||
() => (pager.extend as Record<string, unknown> | undefined)?.audit_admin_options,
|
||||
(raw) => {
|
||||
if (!Array.isArray(raw)) return
|
||||
auditAdminOptions.value = raw
|
||||
.map((r: unknown) => {
|
||||
if (r == null || typeof r !== 'object') return null
|
||||
const row = r as Record<string, unknown>
|
||||
const id = Number(row.id)
|
||||
const name = String(row.name ?? '').trim()
|
||||
if (!Number.isFinite(id) || id <= 0 || name === '') return null
|
||||
return { id, name }
|
||||
})
|
||||
.filter((x): x is { id: number; name: string } => x != null)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
/** 选医助部门后,医助下拉仅显示该部门及子部门成员(与后台含子级一致) */
|
||||
const filteredAssistantOptions = computed(() => {
|
||||
const deptRaw = queryParams.assistant_dept_id
|
||||
@@ -2950,6 +3128,7 @@ function handleReset() {
|
||||
queryParams.prescription_audit_status = ''
|
||||
queryParams.payment_slip_audit_status = ''
|
||||
queryParams.supply_mode = ''
|
||||
queryParams.audit_admin_id = ''
|
||||
resetParams()
|
||||
}
|
||||
|
||||
@@ -3165,6 +3344,11 @@ function canCompleteRow(row: { fulfillment_status?: number; payment_slip_audit_s
|
||||
return (fs === 5 || fs === 6) && Number(row.payment_slip_audit_status) === 1
|
||||
}
|
||||
|
||||
function canRefundRow(row: { fulfillment_status?: number; payment_slip_audit_status?: number }) {
|
||||
const fs = Number(row.fulfillment_status)
|
||||
return (fs === 5 || fs === 6 || fs === 3 || fs === 9) && Number(row.payment_slip_audit_status) === 1
|
||||
}
|
||||
|
||||
function canQuickTrackRow(row: { fulfillment_status?: number }) {
|
||||
return Number(row.fulfillment_status) === 5
|
||||
}
|
||||
@@ -3214,13 +3398,14 @@ const h5ActiveFilterCount = computed(() => {
|
||||
|
||||
// H5: 判断列表卡片「更多」下拉是否需要展示
|
||||
function hasMoreCardActions(row: Record<string, any>) {
|
||||
return canAddPayOrderRow(row) || canCompleteRow(row) || canUploadGancaoRow(row) || canWithdrawRow(row)
|
||||
return canAddPayOrderRow(row) || canCompleteRow(row) || canRefundRow(row) || canUploadGancaoRow(row) || canWithdrawRow(row)
|
||||
}
|
||||
|
||||
// H5: 处理列表卡片「更多」下拉指令
|
||||
function handleCardMoreCommand(cmd: string, row: Record<string, any>) {
|
||||
if (cmd === 'addPayOrder') openAddPayOrder(row as any)
|
||||
else if (cmd === 'complete') confirmComplete(row as any)
|
||||
else if (cmd === 'refund') openRefundOrder(row as any)
|
||||
else if (cmd === 'submitGancao') confirmSubmitGancaoRecipel(row as any)
|
||||
else if (cmd === 'withdraw') confirmWithdraw(row as any)
|
||||
}
|
||||
@@ -3440,6 +3625,40 @@ const logisticsTraceLoading = ref(false)
|
||||
const logisticsTracePayload = ref<Record<string, any> | null>(null)
|
||||
/** 顺丰/快递100:与运单一致的收件手机后四位(可覆盖订单收货手机) */
|
||||
const logisticsTracePhoneTail = ref('')
|
||||
/** 物流轨迹请求序号:同手机多运单或快速切换订单时,丢弃过期的异步响应 */
|
||||
let logisticsTraceRequestSeq = 0
|
||||
|
||||
type LogisticsTraceRequestContext = {
|
||||
requestSeq: number
|
||||
orderId: number
|
||||
trackingNumber: string
|
||||
}
|
||||
|
||||
function bumpLogisticsTraceRequestToken() {
|
||||
logisticsTraceRequestSeq += 1
|
||||
logisticsTracePayload.value = null
|
||||
}
|
||||
|
||||
function isCurrentLogisticsTraceContext(ctx: LogisticsTraceRequestContext): boolean {
|
||||
if (ctx.requestSeq !== logisticsTraceRequestSeq) return false
|
||||
if (Number(detailData.value?.id) !== ctx.orderId) return false
|
||||
return String(detailData.value?.tracking_number || '').trim() === ctx.trackingNumber
|
||||
}
|
||||
|
||||
function parseLogisticsTracePayload(
|
||||
res: unknown,
|
||||
expectedTrackingNumber: string,
|
||||
expectedOrderId: number
|
||||
): Record<string, any> | null {
|
||||
const raw = (res as { data?: unknown })?.data ?? res
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const payload = raw as Record<string, any>
|
||||
const respNum = String(payload.tracking_number || '').trim()
|
||||
if (respNum && respNum !== expectedTrackingNumber) return null
|
||||
const respOrderId = Number(payload.order_id)
|
||||
if (respOrderId > 0 && respOrderId !== expectedOrderId) return null
|
||||
return payload
|
||||
}
|
||||
|
||||
const detailLogs = ref<any[]>([])
|
||||
const updateAmountVisible = ref(false)
|
||||
@@ -3501,7 +3720,8 @@ function logActionText(act: string) {
|
||||
gancao_submit: '甘草下单',
|
||||
patch_rx_patient: '处方患者信息',
|
||||
update_amount: '修改订单金额',
|
||||
complete: '完成订单'
|
||||
complete: '完成订单',
|
||||
refund: '退款'
|
||||
}
|
||||
return m[act] || act
|
||||
}
|
||||
@@ -3534,24 +3754,33 @@ function expressCompanyLabel(v: unknown) {
|
||||
}
|
||||
|
||||
async function fetchLogisticsTrace() {
|
||||
const id = Number(detailData.value?.id)
|
||||
if (!id) return
|
||||
const orderId = Number(detailData.value?.id)
|
||||
const trackingNumber = String(detailData.value?.tracking_number || '').trim()
|
||||
if (!orderId || !trackingNumber) return
|
||||
|
||||
const requestSeq = ++logisticsTraceRequestSeq
|
||||
const ctx: LogisticsTraceRequestContext = { requestSeq, orderId, trackingNumber }
|
||||
|
||||
logisticsTraceLoading.value = true
|
||||
try {
|
||||
const digits = String(logisticsTracePhoneTail.value || '').replace(/\D/g, '')
|
||||
const params: { id: number; express_company?: string; phone_tail?: string } = {
|
||||
id,
|
||||
id: orderId,
|
||||
express_company: detailLogisticsExpress.value
|
||||
}
|
||||
if (digits.length >= 4) {
|
||||
params.phone_tail = digits
|
||||
}
|
||||
const res: any = await prescriptionOrderLogisticsTrace(params)
|
||||
logisticsTracePayload.value = (res?.data ?? res) as Record<string, any>
|
||||
if (!isCurrentLogisticsTraceContext(ctx)) return
|
||||
logisticsTracePayload.value = parseLogisticsTracePayload(res, trackingNumber, orderId)
|
||||
} catch {
|
||||
if (!isCurrentLogisticsTraceContext(ctx)) return
|
||||
logisticsTracePayload.value = null
|
||||
} finally {
|
||||
logisticsTraceLoading.value = false
|
||||
if (isCurrentLogisticsTraceContext(ctx)) {
|
||||
logisticsTraceLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3677,7 +3906,7 @@ async function openDetail(id: number) {
|
||||
// 修复 Bug:显式彻底清空缓存,防止前一次弹窗的数据残留
|
||||
detailData.value = null
|
||||
detailUnlinkedPayOrders.value = []
|
||||
logisticsTracePayload.value = null
|
||||
bumpLogisticsTraceRequestToken()
|
||||
detailLogisticsExpress.value = 'auto'
|
||||
logisticsTracePhoneTail.value = ''
|
||||
detailLogs.value = []
|
||||
@@ -3693,7 +3922,7 @@ async function openDetail(id: number) {
|
||||
const dig = String(d.recipient_phone || '').replace(/\D/g, '')
|
||||
logisticsTracePhoneTail.value = dig.length >= 4 ? dig : ''
|
||||
if (String(d.tracking_number || '').trim()) {
|
||||
fetchLogisticsTrace()
|
||||
void fetchLogisticsTrace()
|
||||
}
|
||||
fetchLogs(id)
|
||||
|
||||
@@ -4447,10 +4676,98 @@ const completeOrderDialogVisible = ref(false)
|
||||
const completeOrderId = ref(0)
|
||||
const completeFulfillmentStatus = ref<number>(3)
|
||||
const completeOrderSubmitting = ref(false)
|
||||
const completeOrderLinkedPays = ref<any[]>([])
|
||||
const completeRefundFormRef = ref<FormInstance>()
|
||||
function calcRefundableFromLinkedPays(list: any[]) {
|
||||
let sum = 0
|
||||
for (const o of list || []) {
|
||||
const st = Number(o?.status)
|
||||
if (st === 2 || st === 5) sum += Number(o?.amount) || 0
|
||||
}
|
||||
return Math.round(sum * 100) / 100
|
||||
}
|
||||
|
||||
const completeRefundForm = reactive({ reason: '', refund_amount: undefined as number | undefined })
|
||||
const completeOrderRefundMax = ref(0)
|
||||
const completeOrderRefundTotal = computed(() => calcRefundableFromLinkedPays(completeOrderLinkedPays.value))
|
||||
const completeRefundRules = computed<FormRules>(() => ({
|
||||
refund_amount: [
|
||||
{ required: true, message: '请输入退款金额', trigger: 'change' },
|
||||
{
|
||||
validator: (_r, v, cb) => {
|
||||
const n = Number(v)
|
||||
const cap = completeOrderRefundMax.value
|
||||
if (!Number.isFinite(n) || n <= 0) return cb(new Error('退款金额须大于 0'))
|
||||
if (cap > 0 && n > cap + 0.009) return cb(new Error(`不能超过 ¥${formatMoney(cap)}`))
|
||||
cb()
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
],
|
||||
reason: [{ required: true, message: '请填写退款原因', trigger: 'blur' }]
|
||||
}))
|
||||
|
||||
function onCompleteOrderDialogClosed() {
|
||||
completeOrderId.value = 0
|
||||
completeOrderLinkedPays.value = []
|
||||
completeRefundForm.reason = ''
|
||||
completeRefundForm.refund_amount = undefined
|
||||
completeOrderRefundMax.value = 0
|
||||
completeRefundFormRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
async function loadCompleteOrderPayContext(orderId: number) {
|
||||
if (!orderId) {
|
||||
completeOrderLinkedPays.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
const r: any = await prescriptionOrderDetail({ id: orderId })
|
||||
const d = r?.data ?? r ?? null
|
||||
completeOrderLinkedPays.value = Array.isArray(d?.linked_pay_orders) ? d.linked_pay_orders : []
|
||||
const linkedMax = calcRefundableFromLinkedPays(completeOrderLinkedPays.value)
|
||||
const paid = Number(d?.paid) || 0
|
||||
completeOrderRefundMax.value = linkedMax > 0 ? linkedMax : paid
|
||||
completeRefundForm.refund_amount = completeOrderRefundMax.value
|
||||
} catch {
|
||||
completeOrderLinkedPays.value = []
|
||||
completeOrderRefundMax.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCompleteOrder() {
|
||||
const id = completeOrderId.value
|
||||
if (!id) return
|
||||
|
||||
if (completeFulfillmentStatus.value === 10) {
|
||||
const valid = await completeRefundFormRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
completeOrderSubmitting.value = true
|
||||
try {
|
||||
await prescriptionOrderRefund({
|
||||
id,
|
||||
reason: completeRefundForm.reason.trim(),
|
||||
refund_amount: Number(completeRefundForm.refund_amount)
|
||||
})
|
||||
feedback.msgSuccess('退款成功')
|
||||
completeOrderDialogVisible.value = false
|
||||
getLists()
|
||||
if (detailVisible.value && detailData.value?.id === id) {
|
||||
try {
|
||||
const r: any = await prescriptionOrderDetail({ id })
|
||||
const nd = r?.data ?? r ?? null
|
||||
if (nd) detailData.value = nd
|
||||
await fetchLogs(id)
|
||||
} catch { /* 静默 */ }
|
||||
}
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
completeOrderSubmitting.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
completeOrderSubmitting.value = true
|
||||
try {
|
||||
await prescriptionOrderComplete({ id, fulfillment_status: completeFulfillmentStatus.value })
|
||||
@@ -4471,6 +4788,93 @@ async function submitCompleteOrder() {
|
||||
}
|
||||
}
|
||||
|
||||
const refundOrderDialogVisible = ref(false)
|
||||
const refundOrderId = ref(0)
|
||||
const refundOrderSubmitting = ref(false)
|
||||
const refundOrderFormRef = ref<FormInstance>()
|
||||
const refundOrderMaxAmount = ref(0)
|
||||
const refundOrderForm = reactive({ reason: '', refund_amount: undefined as number | undefined })
|
||||
const refundOrderAlertTitle = computed(() => {
|
||||
const cap = formatMoney(refundOrderMaxAmount.value)
|
||||
const amt = formatMoney(refundOrderForm.refund_amount)
|
||||
if (Number(refundOrderForm.refund_amount) >= refundOrderMaxAmount.value - 0.009) {
|
||||
return `全额退款 ¥${cap}:关联收款标记已退款,已付清零。`
|
||||
}
|
||||
return `部分退款 ¥${amt}(上限 ¥${cap})`
|
||||
})
|
||||
const refundOrderRules = computed<FormRules>(() => ({
|
||||
refund_amount: [
|
||||
{ required: true, message: '请输入退款金额', trigger: 'change' },
|
||||
{
|
||||
validator: (_r, v, cb) => {
|
||||
const n = Number(v)
|
||||
const cap = refundOrderMaxAmount.value
|
||||
if (!Number.isFinite(n) || n <= 0) return cb(new Error('退款金额须大于 0'))
|
||||
if (cap > 0 && n > cap + 0.009) return cb(new Error(`不能超过 ¥${formatMoney(cap)}`))
|
||||
cb()
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
],
|
||||
reason: [{ required: true, message: '请填写退款原因', trigger: 'blur' }]
|
||||
}))
|
||||
|
||||
function onRefundOrderDialogClosed() {
|
||||
refundOrderId.value = 0
|
||||
refundOrderForm.reason = ''
|
||||
refundOrderForm.refund_amount = undefined
|
||||
refundOrderMaxAmount.value = 0
|
||||
refundOrderFormRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
async function openRefundOrder(row: { id: number }) {
|
||||
refundOrderId.value = row.id
|
||||
refundOrderForm.reason = ''
|
||||
refundOrderMaxAmount.value = 0
|
||||
refundOrderDialogVisible.value = true
|
||||
try {
|
||||
const r: any = await prescriptionOrderDetail({ id: row.id })
|
||||
const d = r?.data ?? r ?? null
|
||||
const linked = Array.isArray(d?.linked_pay_orders) ? d.linked_pay_orders : []
|
||||
const linkedMax = calcRefundableFromLinkedPays(linked)
|
||||
const paid = Number(d?.paid) || 0
|
||||
refundOrderMaxAmount.value = linkedMax > 0 ? linkedMax : paid
|
||||
refundOrderForm.refund_amount = refundOrderMaxAmount.value
|
||||
} catch {
|
||||
refundOrderMaxAmount.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRefundOrder() {
|
||||
const id = refundOrderId.value
|
||||
if (!id) return
|
||||
const valid = await refundOrderFormRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
refundOrderSubmitting.value = true
|
||||
try {
|
||||
await prescriptionOrderRefund({
|
||||
id,
|
||||
reason: refundOrderForm.reason.trim(),
|
||||
refund_amount: Number(refundOrderForm.refund_amount)
|
||||
})
|
||||
feedback.msgSuccess('退款成功')
|
||||
refundOrderDialogVisible.value = false
|
||||
getLists()
|
||||
if (detailVisible.value && detailData.value?.id === id) {
|
||||
try {
|
||||
const r: any = await prescriptionOrderDetail({ id })
|
||||
const nd = r?.data ?? r ?? null
|
||||
if (nd) detailData.value = nd
|
||||
await fetchLogs(id)
|
||||
} catch { /* 静默 */ }
|
||||
}
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
refundOrderSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 补齐支付单(已发货状态)────────────────────────────────────────────
|
||||
const addPayOrderVisible = ref(false)
|
||||
const addPayOrderSaving = ref(false)
|
||||
@@ -4613,7 +5017,10 @@ async function submitAddPayOrder() {
|
||||
function confirmComplete(row: { id: number }) {
|
||||
completeOrderId.value = row.id
|
||||
completeFulfillmentStatus.value = 3
|
||||
completeRefundForm.reason = ''
|
||||
completeOrderLinkedPays.value = []
|
||||
completeOrderDialogVisible.value = true
|
||||
void loadCompleteOrderPayContext(row.id)
|
||||
}
|
||||
|
||||
// ─── 撤回处方审核 ────────────────────────────────────────────
|
||||
@@ -4669,6 +5076,7 @@ async function confirmRevokePayAudit(row: { id: number }) {
|
||||
// ─── 查看处方详情(处方单样式)────────────────────────────────────────────
|
||||
/** 处方笺抬头/页脚(与诊间打印一致) */
|
||||
const SLIP_HOSPITAL_NAME = '成都双流甄养堂互联网医院'
|
||||
const SLIP_TITLE = `${SLIP_HOSPITAL_NAME} 处方笺`
|
||||
const SLIP_COMPANY_LINE = '成都双流甄养堂互联网医院有限公司 联系方式:4001667339'
|
||||
const SLIP_ADDRESS_LINE = '地址:四川省成都市双流区黄甲街道黄龙大道二段280号'
|
||||
|
||||
|
||||
@@ -405,6 +405,18 @@
|
||||
</div>
|
||||
<el-table v-loading="linesLoading" :data="linesRows" border stripe size="small" max-height="440">
|
||||
<el-table-column prop="order_no" label="业务订单号" min-width="128" show-overflow-tooltip />
|
||||
<el-table-column label="患者" min-width="112" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<div class="cs-patient-cell">
|
||||
<span class="cs-patient-cell__name">{{ row.patient_name || '—' }}</span>
|
||||
<span v-if="row.patient_phone || row.patient_gender_text" class="cs-patient-cell__meta cs-muted">
|
||||
<template v-if="row.patient_gender_text">{{ row.patient_gender_text }}</template>
|
||||
<template v-if="row.patient_gender_text && row.patient_phone"> · </template>
|
||||
<template v-if="row.patient_phone">{{ row.patient_phone }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="快递单号" min-width="132" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.tracking_number || '—' }}</template>
|
||||
</el-table-column>
|
||||
@@ -492,6 +504,18 @@
|
||||
</div>
|
||||
<el-table v-loading="leafOrdersLoading" :data="leafOrdersRows" border stripe size="small" max-height="440">
|
||||
<el-table-column prop="order_no" label="业务订单号" min-width="128" show-overflow-tooltip />
|
||||
<el-table-column label="患者" min-width="112" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<div class="cs-patient-cell">
|
||||
<span class="cs-patient-cell__name">{{ row.patient_name || '—' }}</span>
|
||||
<span v-if="row.patient_phone || row.patient_gender_text" class="cs-patient-cell__meta cs-muted">
|
||||
<template v-if="row.patient_gender_text">{{ row.patient_gender_text }}</template>
|
||||
<template v-if="row.patient_gender_text && row.patient_phone"> · </template>
|
||||
<template v-if="row.patient_phone">{{ row.patient_phone }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="快递单号" min-width="132" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.tracking_number || '—' }}</template>
|
||||
</el-table-column>
|
||||
@@ -948,10 +972,8 @@ async function loadLeafOrders(page: number) {
|
||||
page_size: leafOrdersPageSize.value,
|
||||
}
|
||||
if (d.mode === 'dept') {
|
||||
p.dept_ids = String(d.dept_id)
|
||||
if (selectedChannel.value) {
|
||||
p.channel_code = selectedChannel.value
|
||||
}
|
||||
Object.assign(p, buildQueryParams())
|
||||
p.table_row_dept_id = d.dept_id
|
||||
} else {
|
||||
Object.assign(p, buildQueryParams())
|
||||
p.appt_channel_value = d.appt_channel_value
|
||||
@@ -1981,6 +2003,21 @@ onMounted(async () => {
|
||||
|
||||
.cs-muted { color: var(--cs-muted); }
|
||||
|
||||
.cs-patient-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.cs-patient-cell__name {
|
||||
color: var(--cs-ink, #0f172a);
|
||||
}
|
||||
|
||||
.cs-patient-cell__meta {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ============ Element Plus overrides (scoped via deep) ============ */
|
||||
.cs-page {
|
||||
:deep(.el-input__wrapper),
|
||||
|
||||
+305
-48
@@ -127,10 +127,10 @@
|
||||
<span class="yeji-table-tabs-head__title">表格统计</span>
|
||||
<span class="yeji-table-tabs-head__hint">列较多时可横向滚动浏览;「部门 / 排名」列在滚动时保持固定。</span>
|
||||
</div>
|
||||
<el-tabs v-model="yejiDisplayTab" class="yeji-view-tabs">
|
||||
<el-tab-pane label="医生统计" name="doctor" lazy>
|
||||
<el-tabs v-if="hasAnyYejiTableTab" v-model="yejiDisplayTab" class="yeji-view-tabs">
|
||||
<el-tab-pane v-if="canViewDoctorTab" label="医生统计" name="doctor" lazy>
|
||||
<el-card
|
||||
v-if="canViewDoctorDailyStats"
|
||||
v-if="canViewDoctorTab"
|
||||
class="yeji-panel doctor-daily-card doctor-daily-card--nested"
|
||||
shadow="never"
|
||||
>
|
||||
@@ -181,15 +181,50 @@
|
||||
<template #default="{ row }">¥ {{ formatDoctorDailyMoney(row.deal_amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="deal_order_count" label="接诊诊单" min-width="88" align="right" />
|
||||
<el-table-column
|
||||
prop="appointment_total"
|
||||
label="总挂号"
|
||||
min-width="88"
|
||||
align="right"
|
||||
>
|
||||
<template #header>
|
||||
<span>总挂号</span>
|
||||
<el-tooltip
|
||||
effect="dark"
|
||||
placement="top"
|
||||
content="区间内该医生 doctor_appointment 全部状态合计(已预约/已完成/已过号/已取消);选部门时仅计经手医助归属在所选部门子树的挂号(与业绩看板「预约诊单」医助口径一致)"
|
||||
>
|
||||
<el-icon class="doctor-daily-th__hint"><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="appointment_completed" label="挂号完成" min-width="88" align="right" />
|
||||
<el-table-column prop="appointment_missed" label="过号" min-width="72" align="right" />
|
||||
<el-table-column prop="appointment_cancelled" label="取消" min-width="72" align="right" />
|
||||
<el-table-column
|
||||
prop="appointment_conversion_rate"
|
||||
label="挂号率"
|
||||
min-width="92"
|
||||
align="right"
|
||||
>
|
||||
<template #header>
|
||||
<span>挂号率</span>
|
||||
<el-tooltip
|
||||
effect="dark"
|
||||
placement="top"
|
||||
content="挂号率 = 成交单数 / 总挂号数 × 100;总挂号为 0 时显示「—」"
|
||||
>
|
||||
<el-icon class="doctor-daily-th__hint"><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">{{ formatDoctorDailyRate(row.appointment_conversion_rate) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
<el-empty v-else description="当前账号无医生统计权限" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="医助排行榜" name="leaderboard" lazy>
|
||||
<el-tab-pane v-if="canViewLeaderboardTab" label="医助排行榜" name="leaderboard" lazy>
|
||||
<div
|
||||
v-if="leaderboardBlock || leaderboardsLoading"
|
||||
class="leaderboards-wrap"
|
||||
@@ -316,7 +351,7 @@
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="甄养堂互联网医院诊金" name="zyyt" lazy>
|
||||
<el-tab-pane v-if="canViewZyytTab" label="甄养堂互联网医院诊金" name="zyyt" lazy>
|
||||
<!-- ── 业绩表(4 张/N 张);目标看板见「目标看板(卡片)」Tab ── -->
|
||||
<div v-loading="loading" class="tables-wrap">
|
||||
<div v-if="!loading && tables.length === 0" class="empty-tip">
|
||||
@@ -445,12 +480,8 @@
|
||||
<el-tooltip placement="top" effect="dark" :show-after="200">
|
||||
<template #content>
|
||||
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
|
||||
<<<<<<< HEAD
|
||||
<b>业务订单条数</b>(计业绩):订单 <b>create_time</b> 落入区间、<b>fulfillment_status ∉ {4,9,10}</b>(<b>NULL 计入</b>);按<b>订单创建人</b>的人事部门落在该部门子树即计入(表格多行命中时取最深的展示部门)。与<b>合计业绩 / 医助排行榜接诊诊单</b>同口径。选定渠道时本列仍为全量。
|
||||
=======
|
||||
<b>业务订单条数</b>(计业绩):订单 <b>create_time</b> 落入区间、<b>fulfillment_status ∉ {4,9,10}</b>;与列表筛选
|
||||
<b>assistant_dept_id</b> 时一致——<b>创建人</b>人事部门优先,无创建人则诊单 <b>医助</b>;落在该部门子树即计入(表格多行命中时取最深的展示部门)。与侧栏勾选「与表格业绩对齐」时的集合可能略有差异。选定渠道时本列仍为全量。
|
||||
>>>>>>> master
|
||||
</div>
|
||||
</template>
|
||||
<el-icon class="col-info"><InfoFilled /></el-icon>
|
||||
@@ -636,7 +667,7 @@
|
||||
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="目标看板(卡片)" name="zyyt_matrix" lazy>
|
||||
<el-tab-pane v-if="canViewTargetMatrixTab" label="目标看板(卡片)" name="zyyt_matrix" lazy>
|
||||
<div v-loading="targetProgressLoading" class="tp-matrix-wrap">
|
||||
<template v-if="targetProgressCard">
|
||||
<h2 class="tp-matrix__title">{{ targetProgressCard.title }}</h2>
|
||||
@@ -702,18 +733,19 @@
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<el-empty v-else description="当前账号无表格统计 Tab 权限" />
|
||||
</el-card>
|
||||
|
||||
<el-card class="yeji-panel yeji-charts-only-card" shadow="never">
|
||||
<div class="yeji-charts-only-head">统计图</div>
|
||||
<div
|
||||
v-loading="loading || (canViewDoctorDailyStats && doctorDailyLoading) || leaderboardsLoading"
|
||||
v-loading="loading || (canViewDoctorTab && doctorDailyLoading) || (canViewLeaderboardTab && leaderboardsLoading)"
|
||||
class="yeji-charts-stack"
|
||||
>
|
||||
<template v-if="chartsHasAnyData">
|
||||
<section
|
||||
v-if="
|
||||
canViewDoctorDailyStats &&
|
||||
canViewDoctorTab &&
|
||||
(doctorDealBarHasData ||
|
||||
doctorRxStackHasData ||
|
||||
doctorAppointmentPieHasData ||
|
||||
@@ -855,7 +887,7 @@
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-if="leaderboardBlock">
|
||||
<template v-if="canViewLeaderboardTab && leaderboardBlock">
|
||||
<section
|
||||
v-for="lb in leaderboardChartBlocks"
|
||||
:key="'lb-chart-' + lb.dept_id"
|
||||
@@ -898,8 +930,8 @@
|
||||
<el-empty
|
||||
v-else-if="
|
||||
!loading &&
|
||||
(!canViewDoctorDailyStats || !doctorDailyLoading) &&
|
||||
!leaderboardsLoading
|
||||
(!canViewDoctorTab || !doctorDailyLoading) &&
|
||||
(!canViewLeaderboardTab || !leaderboardsLoading)
|
||||
"
|
||||
class="yeji-charts-empty"
|
||||
description="当前筛选下暂无图表数据,请调整条件后查询"
|
||||
@@ -1159,9 +1191,19 @@
|
||||
class="yeji-leadlines-dialog"
|
||||
>
|
||||
<template #header>
|
||||
<div class="yeji-unassigned-dialog__head">
|
||||
<span class="yeji-unassigned-dialog__title">进线数据明细</span>
|
||||
<p class="yeji-unassigned-dialog__sub">{{ leadLinesSubtitle }}</p>
|
||||
<div class="yeji-unassigned-dialog__head yeji-leadlines-dialog__head">
|
||||
<div class="yeji-leadlines-dialog__head-main">
|
||||
<span class="yeji-unassigned-dialog__title">进线数据明细</span>
|
||||
<p class="yeji-unassigned-dialog__sub">{{ leadLinesSubtitle }}</p>
|
||||
</div>
|
||||
<el-button
|
||||
size="small"
|
||||
:loading="leadLinesExporting"
|
||||
:disabled="leadLinesLoading || leadLinesExporting || leadLinesCount <= 0"
|
||||
@click="exportLeadLines"
|
||||
>
|
||||
导出
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<p v-if="leadLinesApiNote" class="yeji-unassigned-dialog__note">{{ leadLinesApiNote }}</p>
|
||||
@@ -1318,7 +1360,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { Search, RefreshRight, InfoFilled } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -1337,7 +1379,14 @@ import {
|
||||
} from '@/api/stats'
|
||||
import { deptPerformanceTargetMonthMatrix } from '@/api/finance'
|
||||
import { prescriptionOrderLists } from '@/api/tcm'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
|
||||
type YejiDisplayTab = 'doctor' | 'leaderboard' | 'zyyt' | 'zyyt_matrix'
|
||||
|
||||
/** 任一权限命中即可(兼容原有接口按钮权限 + 新增 Tab 独立权限) */
|
||||
function hasAnyPermission(perms: string[]): boolean {
|
||||
return perms.some(p => hasPermission([p]))
|
||||
}
|
||||
|
||||
interface DeptOption {
|
||||
id: number
|
||||
@@ -1626,6 +1675,7 @@ const revisitBreakdownMeta = ref<{ start: string; end: string; revisitSlot: numb
|
||||
|
||||
const leadLinesDialogVisible = ref(false)
|
||||
const leadLinesLoading = ref(false)
|
||||
const leadLinesExporting = ref(false)
|
||||
const leadLinesSubtitle = ref('')
|
||||
const leadLinesApiNote = ref('')
|
||||
const leadLinesRows = ref<YejiLeadLineRow[]>([])
|
||||
@@ -1664,14 +1714,60 @@ const appointmentLinesShowChannelColumn = computed(() =>
|
||||
)
|
||||
|
||||
/** 图表 / 数据表切换 */
|
||||
const yejiDisplayTab = ref<'doctor' | 'leaderboard' | 'zyyt' | 'zyyt_matrix'>('zyyt')
|
||||
const yejiDisplayTab = ref<YejiDisplayTab>('zyyt')
|
||||
|
||||
/** 与菜单 stats.doctorDailyStats/overview 一致;无权限则不展示医生统计表/图、不请求接口 */
|
||||
const userStore = useUserStore()
|
||||
const DOCTOR_DAILY_STATS_PERM = 'stats.doctorDailyStats/overview'
|
||||
const canViewDoctorDailyStats = computed(() => {
|
||||
const perms = userStore.perms || []
|
||||
return perms.some(p => p === '*' || p === DOCTOR_DAILY_STATS_PERM)
|
||||
/** Tab 可见性:优先认 Tab 独立权限;未配置时兼容原有接口按钮权限,避免破坏已有角色 */
|
||||
const YEJI_TAB_DOCTOR_PERM = 'stats.yejiStats/tabDoctor'
|
||||
const YEJI_TAB_LEADERBOARD_PERM = 'stats.yejiStats/tabLeaderboard'
|
||||
const YEJI_TAB_ZYYT_PERM = 'stats.yejiStats/tabZyyt'
|
||||
const YEJI_TAB_TARGET_MATRIX_PERM = 'stats.yejiStats/tabTargetMatrix'
|
||||
|
||||
const canViewDoctorTab = computed(() =>
|
||||
hasAnyPermission([YEJI_TAB_DOCTOR_PERM, 'stats.doctorDailyStats/overview'])
|
||||
)
|
||||
const canViewLeaderboardTab = computed(() =>
|
||||
hasAnyPermission([YEJI_TAB_LEADERBOARD_PERM, 'stats.yejiStats/leaderboard'])
|
||||
)
|
||||
const canViewZyytTab = computed(() =>
|
||||
hasAnyPermission([
|
||||
YEJI_TAB_ZYYT_PERM,
|
||||
'stats.yejiStats/multi',
|
||||
'stats.yejiStats/overview',
|
||||
])
|
||||
)
|
||||
const canViewTargetMatrixTab = computed(() =>
|
||||
hasAnyPermission([
|
||||
YEJI_TAB_TARGET_MATRIX_PERM,
|
||||
'stats.yejiStats/targetMatrix',
|
||||
'stats.yejiStats/multi',
|
||||
])
|
||||
)
|
||||
const hasAnyYejiTableTab = computed(
|
||||
() =>
|
||||
canViewDoctorTab.value ||
|
||||
canViewLeaderboardTab.value ||
|
||||
canViewZyytTab.value ||
|
||||
canViewTargetMatrixTab.value
|
||||
)
|
||||
|
||||
function pickInitialYejiTab(): YejiDisplayTab {
|
||||
if (canViewZyytTab.value) return 'zyyt'
|
||||
if (canViewDoctorTab.value) return 'doctor'
|
||||
if (canViewLeaderboardTab.value) return 'leaderboard'
|
||||
if (canViewTargetMatrixTab.value) return 'zyyt_matrix'
|
||||
return 'zyyt'
|
||||
}
|
||||
|
||||
watch(yejiDisplayTab, tab => {
|
||||
if (tab === 'doctor' && !canViewDoctorTab.value) {
|
||||
yejiDisplayTab.value = pickInitialYejiTab()
|
||||
} else if (tab === 'leaderboard' && !canViewLeaderboardTab.value) {
|
||||
yejiDisplayTab.value = pickInitialYejiTab()
|
||||
} else if (tab === 'zyyt' && !canViewZyytTab.value) {
|
||||
yejiDisplayTab.value = pickInitialYejiTab()
|
||||
} else if (tab === 'zyyt_matrix' && !canViewTargetMatrixTab.value) {
|
||||
yejiDisplayTab.value = pickInitialYejiTab()
|
||||
}
|
||||
})
|
||||
|
||||
const leaderboardChartBlocks = computed(() =>
|
||||
@@ -1715,7 +1811,7 @@ const doctorRxStackHasData = computed(() =>
|
||||
)
|
||||
|
||||
const doctorChartsHasData = computed(() => {
|
||||
if (!canViewDoctorDailyStats.value) {
|
||||
if (!canViewDoctorTab.value) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
@@ -1731,12 +1827,14 @@ const chartsHasAnyData = computed(() => {
|
||||
if (doctorChartsHasData.value) {
|
||||
return true
|
||||
}
|
||||
for (const tb of tables.value) {
|
||||
if (yejiDeptChartHasData(tb)) {
|
||||
return true
|
||||
if (canViewZyytTab.value) {
|
||||
for (const tb of tables.value) {
|
||||
if (yejiDeptChartHasData(tb)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (leaderboardChartBlocks.value.length > 0) {
|
||||
if (canViewLeaderboardTab.value && leaderboardChartBlocks.value.length > 0) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -2015,6 +2113,10 @@ function buildDoctorDailyRequestParams(): Record<string, any> {
|
||||
if (selectedChannel.value) {
|
||||
p.channel_code = selectedChannel.value
|
||||
}
|
||||
// 选中父级时由后端 resolvePrimaryDepts 自动展开所有子级,再用 admin_dept 收窄医生集合。
|
||||
if (selectedDeptIds.value.length > 0) {
|
||||
p.dept_ids = selectedDeptIds.value.join(',')
|
||||
}
|
||||
if (customRange.value?.[0] && customRange.value?.[1]) {
|
||||
p.start_date = customRange.value[0]
|
||||
p.end_date = customRange.value[1]
|
||||
@@ -2033,6 +2135,15 @@ function formatDoctorDailyMoney(n: number | string | undefined) {
|
||||
return x.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
/** 挂号率展示:null 或非有限数显示「—」;保留 2 位小数 + % 后缀 */
|
||||
function formatDoctorDailyRate(v: number | string | null | undefined): string {
|
||||
if (v === null || v === undefined || v === '') return '—'
|
||||
const n = Number(v)
|
||||
if (!Number.isFinite(n)) return '—'
|
||||
|
||||
return `${n.toFixed(2)}%`
|
||||
}
|
||||
|
||||
function getDoctorDailySummaries(param: { columns: any[] }) {
|
||||
const t = doctorDailyTotal.value
|
||||
const sums: string[] = []
|
||||
@@ -2055,6 +2166,9 @@ function getDoctorDailySummaries(param: { columns: any[] }) {
|
||||
case 'deal_order_count':
|
||||
sums[index] = String(t.deal_order_count ?? 0)
|
||||
break
|
||||
case 'appointment_total':
|
||||
sums[index] = String(t.appointment_total ?? 0)
|
||||
break
|
||||
case 'appointment_completed':
|
||||
sums[index] = String(t.appointment_completed ?? 0)
|
||||
break
|
||||
@@ -2064,6 +2178,9 @@ function getDoctorDailySummaries(param: { columns: any[] }) {
|
||||
case 'appointment_cancelled':
|
||||
sums[index] = String(t.appointment_cancelled ?? 0)
|
||||
break
|
||||
case 'appointment_conversion_rate':
|
||||
sums[index] = formatDoctorDailyRate(t.appointment_conversion_rate)
|
||||
break
|
||||
default:
|
||||
sums[index] = ''
|
||||
}
|
||||
@@ -2073,6 +2190,12 @@ function getDoctorDailySummaries(param: { columns: any[] }) {
|
||||
}
|
||||
|
||||
async function loadDoctorDailyStats() {
|
||||
if (!canViewDoctorTab.value) {
|
||||
doctorDailyRows.value = []
|
||||
doctorDailyTotal.value = {}
|
||||
doctorDailyRange.value = null
|
||||
return
|
||||
}
|
||||
doctorDailyLoading.value = true
|
||||
try {
|
||||
const res: any = await doctorDailyStatsOverview(buildDoctorDailyRequestParams())
|
||||
@@ -2211,6 +2334,10 @@ function daysInMonth(ym: string): number {
|
||||
}
|
||||
|
||||
async function loadTargetProgress() {
|
||||
if (!canViewTargetMatrixTab.value) {
|
||||
targetProgressCard.value = null
|
||||
return
|
||||
}
|
||||
targetProgressLoading.value = true
|
||||
targetProgressCard.value = null
|
||||
try {
|
||||
@@ -2458,6 +2585,10 @@ async function loadChannelOptions() {
|
||||
}
|
||||
|
||||
async function loadLeaderboard(range: { start: string; end: string }) {
|
||||
if (!canViewLeaderboardTab.value) {
|
||||
leaderboardBlock.value = null
|
||||
return
|
||||
}
|
||||
leaderboardsLoading.value = true
|
||||
try {
|
||||
const p: Record<string, any> = {
|
||||
@@ -2491,6 +2622,18 @@ async function loadLeaderboard(range: { start: string; end: string }) {
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
leaderboardBlock.value = null
|
||||
if (!canViewZyytTab.value) {
|
||||
tables.value = []
|
||||
loading.value = false
|
||||
if (canViewDoctorTab.value) {
|
||||
void loadDoctorDailyStats()
|
||||
} else {
|
||||
doctorDailyRows.value = []
|
||||
doctorDailyTotal.value = {}
|
||||
doctorDailyRange.value = null
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
const baseParams: Record<string, any> = {}
|
||||
if (selectedDeptIds.value.length > 0) {
|
||||
@@ -2542,7 +2685,7 @@ async function loadData() {
|
||||
ElMessage.error(any?.msg || any?.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (canViewDoctorDailyStats.value) {
|
||||
if (canViewDoctorTab.value) {
|
||||
void loadDoctorDailyStats()
|
||||
} else {
|
||||
doctorDailyRows.value = []
|
||||
@@ -2661,6 +2804,9 @@ function buildOrderDrawerListParams(): Record<string, any> | null {
|
||||
if (selectedDeptIds.value.length > 0) {
|
||||
params.dept_ids = selectedDeptIds.value.join(',')
|
||||
}
|
||||
if (f.yejiTableRowDeptIds && f.yejiTableRowDeptIds.length > 0) {
|
||||
params.yeji_table_row_dept_ids = f.yejiTableRowDeptIds.join(',')
|
||||
}
|
||||
} else {
|
||||
params.assistant_id = f.assistantId
|
||||
if (f.erCenterRevisitSlot !== undefined) {
|
||||
@@ -3047,28 +3193,79 @@ function onLeadCountCellClick(ev: MouseEvent, tb: YejiTable, row: YejiRow) {
|
||||
void openLeadLinesDialog(tb, row)
|
||||
}
|
||||
|
||||
const LEAD_LINES_EXPORT_COLUMNS: { key: keyof YejiLeadLineRow; label: string }[] = [
|
||||
{ key: 'event_time_text', label: '进线时间' },
|
||||
{ key: 'reception_admin_name', label: '接待' },
|
||||
{ key: 'external_contact_name', label: '客户' },
|
||||
{ key: 'external_userid', label: '外部联系人ID' },
|
||||
{ key: 'user_id', label: '企微成员ID' },
|
||||
{ key: 'state', label: '渠道参数' },
|
||||
]
|
||||
|
||||
function buildLeadLinesRequestParams(
|
||||
ctx: { tb: YejiTable; row: YejiRow },
|
||||
page: number,
|
||||
pageSize: number
|
||||
): Record<string, string | number> {
|
||||
const { tb, row } = ctx
|
||||
const p: Record<string, string | number> = {
|
||||
start_date: tb.start_date,
|
||||
end_date: tb.end_date,
|
||||
dept_id: row.dept_id,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
}
|
||||
if (selectedDeptIds.value.length > 0) {
|
||||
p.dept_ids = selectedDeptIds.value.join(',')
|
||||
}
|
||||
if (selectedChannel.value) {
|
||||
p.channel_code = selectedChannel.value
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
function escapeLeadLinesCsvCell(value: unknown): string {
|
||||
const s = value == null ? '' : String(value)
|
||||
if (/[",\n\r]/.test(s)) {
|
||||
return `"${s.replace(/"/g, '""')}"`
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
function buildLeadLinesCsv(rows: YejiLeadLineRow[]): string {
|
||||
const header = LEAD_LINES_EXPORT_COLUMNS.map((c) => escapeLeadLinesCsvCell(c.label)).join(',')
|
||||
const body = rows
|
||||
.map((row) => LEAD_LINES_EXPORT_COLUMNS.map((c) => escapeLeadLinesCsvCell(row[c.key])).join(','))
|
||||
.join('\n')
|
||||
return `\uFEFF${header}\n${body}`
|
||||
}
|
||||
|
||||
function downloadLeadLinesCsv(filename: string, content: string) {
|
||||
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function buildLeadLinesExportFilename(ctx: { tb: YejiTable; row: YejiRow }): string {
|
||||
const { tb, row } = ctx
|
||||
const safeDept = (row.dept_name || `dept${row.dept_id}`).replace(/[\\/:*?"<>|]/g, '_')
|
||||
return `进线数据明细_${safeDept}_${tb.start_date}_${tb.end_date}.csv`
|
||||
}
|
||||
|
||||
async function fetchLeadLinesPage() {
|
||||
const ctx = leadLinesContext.value
|
||||
if (!ctx) {
|
||||
return
|
||||
}
|
||||
const { tb, row } = ctx
|
||||
leadLinesLoading.value = true
|
||||
try {
|
||||
const p: Record<string, string | number> = {
|
||||
start_date: tb.start_date,
|
||||
end_date: tb.end_date,
|
||||
dept_id: row.dept_id,
|
||||
page: leadLinesPage.value,
|
||||
page_size: leadLinesPageSize.value,
|
||||
}
|
||||
if (selectedDeptIds.value.length > 0) {
|
||||
p.dept_ids = selectedDeptIds.value.join(',')
|
||||
}
|
||||
if (selectedChannel.value) {
|
||||
p.channel_code = selectedChannel.value
|
||||
}
|
||||
const res: any = await yejiStatsLeadLines(p as any)
|
||||
const res: any = await yejiStatsLeadLines(
|
||||
buildLeadLinesRequestParams(ctx, leadLinesPage.value, leadLinesPageSize.value) as any
|
||||
)
|
||||
leadLinesRows.value = Array.isArray(res?.lists) ? res.lists : []
|
||||
leadLinesCount.value = Number(res?.count ?? 0)
|
||||
leadLinesApiNote.value = typeof res?.note === 'string' ? res.note : ''
|
||||
@@ -3107,6 +3304,40 @@ function onLeadLinesPageSizeChange(size: number) {
|
||||
void fetchLeadLinesPage()
|
||||
}
|
||||
|
||||
async function exportLeadLines() {
|
||||
const ctx = leadLinesContext.value
|
||||
if (!ctx || leadLinesExporting.value) {
|
||||
return
|
||||
}
|
||||
leadLinesExporting.value = true
|
||||
try {
|
||||
const pageSize = 100
|
||||
const firstRes: any = await yejiStatsLeadLines(buildLeadLinesRequestParams(ctx, 1, pageSize) as any)
|
||||
const total = Number(firstRes?.count ?? 0)
|
||||
if (total <= 0) {
|
||||
ElMessage.warning('暂无进线明细可导出')
|
||||
return
|
||||
}
|
||||
const allRows: YejiLeadLineRow[] = Array.isArray(firstRes?.lists) ? [...firstRes.lists] : []
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
for (let page = 2; page <= totalPages; page++) {
|
||||
const res: any = await yejiStatsLeadLines(buildLeadLinesRequestParams(ctx, page, pageSize) as any)
|
||||
const lists = Array.isArray(res?.lists) ? res.lists : []
|
||||
allRows.push(...lists)
|
||||
}
|
||||
downloadLeadLinesCsv(buildLeadLinesExportFilename(ctx), buildLeadLinesCsv(allRows))
|
||||
ElMessage.success(`已导出 ${allRows.length} 条进线明细`)
|
||||
} catch (e: unknown) {
|
||||
if (axios.isCancel(e)) {
|
||||
return
|
||||
}
|
||||
const any = e as any
|
||||
ElMessage.error(any?.msg || any?.message || '导出进线明细失败')
|
||||
} finally {
|
||||
leadLinesExporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function appointmentLinesIndexMethod(index: number) {
|
||||
return (appointmentLinesPage.value - 1) * appointmentLinesPageSize.value + index + 1
|
||||
}
|
||||
@@ -3561,6 +3792,7 @@ function formatLocalYmd(d: Date): string {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
yejiDisplayTab.value = pickInitialYejiTab()
|
||||
await Promise.all([loadDeptOptions(), loadChannelOptions()])
|
||||
loadData()
|
||||
loadTargetProgress()
|
||||
@@ -3922,6 +4154,19 @@ onMounted(async () => {
|
||||
background: var(--yj-accent-soft) !important;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.doctor-daily-th__hint {
|
||||
margin-left: 4px;
|
||||
color: var(--yj-muted);
|
||||
cursor: help;
|
||||
vertical-align: -2px;
|
||||
font-size: 13px;
|
||||
transition: color 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: var(--yj-brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-surface {
|
||||
@@ -4832,6 +5077,18 @@ tbody tr.total-row:hover .yeji-lead-cell--link {
|
||||
color: color-mix(in srgb, var(--yj-brand, #2563eb) 88%, #000);
|
||||
}
|
||||
|
||||
.yeji-leadlines-dialog__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.yeji-leadlines-dialog__head-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.yeji-leadlines-dialog__pager {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
<template>
|
||||
<div class="note-timeline-wrap">
|
||||
<div v-if="!readonly && diagnosisId" class="timeline-actions">
|
||||
<el-button
|
||||
v-if="hasAddDoctorNotePermission"
|
||||
type="primary"
|
||||
plain
|
||||
size="small"
|
||||
@click="showNoteDialog = true"
|
||||
>
|
||||
添加备注
|
||||
</el-button>
|
||||
<material-picker
|
||||
v-model="tongueModel"
|
||||
:limit="99"
|
||||
@@ -95,16 +104,32 @@
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-if="!notes.length && readonly" description="暂无备注" :image-size="48" />
|
||||
|
||||
<el-dialog v-model="showNoteDialog" title="添加备注" width="480px" append-to-body>
|
||||
<el-input
|
||||
v-model="noteContent"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="输入今日备注..."
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="showNoteDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="noteSaving" @click="handleSaveNote">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { CircleClose, Document } from '@element-plus/icons-vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import useAppStore from '@/stores/modules/app'
|
||||
import { addDoctorNote, deleteDoctorNoteImage } from '@/api/patient'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
|
||||
export interface DoctorNoteItem {
|
||||
id: number
|
||||
@@ -122,6 +147,13 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{ refresh: [] }>()
|
||||
|
||||
/** 与接诊台悬浮「备注」按钮一致 */
|
||||
const hasAddDoctorNotePermission = computed(() => hasPermission(['doctor.appointment/addDoctorNote']))
|
||||
|
||||
const showNoteDialog = ref(false)
|
||||
const noteContent = ref('')
|
||||
const noteSaving = ref(false)
|
||||
|
||||
const appStore = useAppStore()
|
||||
const getImageUrl = (url: string) => appStore.getImageUrl(url)
|
||||
|
||||
@@ -201,6 +233,30 @@ watch(() => props.notes, () => {
|
||||
prevReportCount.value = 0
|
||||
})
|
||||
|
||||
const handleSaveNote = async () => {
|
||||
if (!props.diagnosisId) {
|
||||
feedback.msgWarning('当前无诊单,无法添加备注')
|
||||
return
|
||||
}
|
||||
const text = noteContent.value.trim()
|
||||
if (!text) {
|
||||
feedback.msgWarning('请输入备注内容')
|
||||
return
|
||||
}
|
||||
noteSaving.value = true
|
||||
try {
|
||||
await addDoctorNote({ diagnosis_id: props.diagnosisId, content: text })
|
||||
feedback.msgSuccess('备注保存成功')
|
||||
noteContent.value = ''
|
||||
showNoteDialog.value = false
|
||||
emit('refresh')
|
||||
} catch (e: any) {
|
||||
feedback.msgError(e?.message || '保存失败')
|
||||
} finally {
|
||||
noteSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteImage = async (noteId: number, imageType: 'tongue_images' | 'report_files', imagePath: string) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认删除?', '提示', { type: 'warning' })
|
||||
@@ -217,6 +273,7 @@ const handleDeleteImage = async (noteId: number, imageType: 'tongue_images' | 'r
|
||||
.timeline-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
|
||||
@@ -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,503 @@
|
||||
<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: 'total_open_rate', label: '总开口率', type: 'percent' },
|
||||
{ key: 'paid_appointment_rate', label: '付费挂号率', type: 'percent' },
|
||||
{ key: 'open_appointment_rate', label: '开口挂号率', type: 'percent' },
|
||||
{ key: 'interview_rate', label: '面诊率', type: 'percent' },
|
||||
{ key: 'receive_rate', label: '接诊率', type: 'percent' },
|
||||
{ key: 'open_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: 'total_open_rate', label: '总开口率', type: 'percent', minWidth: 100 },
|
||||
{ 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>
|
||||
@@ -509,6 +509,32 @@
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="completeDialogVisible"
|
||||
title="完成问诊"
|
||||
width="420px"
|
||||
append-to-body
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<div class="complete-dialog">
|
||||
<p class="complete-dialog-tip">是否添加医生备注?</p>
|
||||
<el-input
|
||||
v-if="canAddCompleteNote"
|
||||
v-model="completeNoteContent"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="填写完成备注,将追加到医生备注时间轴"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="completeDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="completeSubmitting" @click="submitComplete">确认完成</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -518,7 +544,9 @@ import { defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { appointmentLists, cancelAppointment, completeAppointment, appointmentDetail } from '@/api/doctor'
|
||||
import { getCallSignature, generateMiniProgramQrcode, tcmDiagnosisDetail, prescriptionGetByAppointment } from '@/api/tcm'
|
||||
import { getDictData } from '@/api/app'
|
||||
import { addDoctorNote } from '@/api/patient'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
import {
|
||||
formatDiabetesDiscoveryDisplay,
|
||||
isDiabetesDiscoveryFilled
|
||||
@@ -797,6 +825,12 @@ const ensureDetailDictOptions = async () => {
|
||||
const editRef = ref()
|
||||
const prescriptionRef = ref()
|
||||
const chatDialogRef = ref()
|
||||
const COMPLETE_NOTE_PERMISSION = ['doctor.appointment/addDoctorNote']
|
||||
const completeDialogVisible = ref(false)
|
||||
const completeSubmitting = ref(false)
|
||||
const completeNoteContent = ref('')
|
||||
const completeRow = ref<any>(null)
|
||||
const canAddCompleteNote = computed(() => hasPermission(COMPLETE_NOTE_PERMISSION))
|
||||
|
||||
// 小程序二维码相关
|
||||
const qrcodeDialogVisible = ref(false)
|
||||
@@ -866,8 +900,14 @@ const handleCancel = async (row: any) => {
|
||||
|
||||
// 完成挂号
|
||||
const handleComplete = async (row: any) => {
|
||||
completeRow.value = row
|
||||
completeNoteContent.value = ''
|
||||
if (canAddCompleteNote.value) {
|
||||
completeDialogVisible.value = true
|
||||
return
|
||||
}
|
||||
try {
|
||||
await feedback.confirm('确认患者已就诊完成?')
|
||||
await feedback.confirm('是否添加医生备注?')
|
||||
await completeAppointment({ id: row.id })
|
||||
feedback.msgSuccess('操作成功')
|
||||
loadData()
|
||||
@@ -875,6 +915,37 @@ const handleComplete = async (row: any) => {
|
||||
// 用户取消操作
|
||||
}
|
||||
}
|
||||
const submitComplete = async () => {
|
||||
const row = completeRow.value
|
||||
if (!row?.id || completeSubmitting.value) return
|
||||
completeSubmitting.value = true
|
||||
try {
|
||||
const note = completeNoteContent.value.trim()
|
||||
await completeAppointment({ id: row.id })
|
||||
let noteFailureMsg = ''
|
||||
if (canAddCompleteNote.value && note) {
|
||||
const diagnosisId = row.patient_id || row.diagnosis_id
|
||||
if (diagnosisId) {
|
||||
try {
|
||||
await addDoctorNote({ diagnosis_id: Number(diagnosisId), content: note })
|
||||
} catch (e) {
|
||||
noteFailureMsg = '问诊已完成,但备注未保存'
|
||||
}
|
||||
} else {
|
||||
noteFailureMsg = '问诊已完成,但该预约没有关联诊单,备注未保存'
|
||||
}
|
||||
}
|
||||
noteFailureMsg ? feedback.msgWarning(noteFailureMsg) : feedback.msgSuccess('操作成功')
|
||||
completeDialogVisible.value = false
|
||||
completeNoteContent.value = ''
|
||||
completeRow.value = null
|
||||
loadData()
|
||||
} catch (e: any) {
|
||||
feedback.msgError(e?.msg || e?.message || '操作失败')
|
||||
} finally {
|
||||
completeSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑患者资料
|
||||
const handleEditPatient = (row: any) => {
|
||||
@@ -1330,6 +1401,13 @@ onUnmounted(() => {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.complete-dialog-tip {
|
||||
margin: 0 0 12px;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.qrcode-content {
|
||||
padding: 16px 0;
|
||||
min-height: 200px;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,12 @@
|
||||
{{ assistantLogCellText(row, 'to') }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="继承" width="72" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="Number(row.is_inherit) === 1" type="success" size="small">是</el-tag>
|
||||
<span v-else class="text-gray-400">否</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="快照·业务单创建人" min-width="130" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ relatedPoCreatorCell(row) }}
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
</div>
|
||||
|
||||
<div v-if="canEditDailyRecord" class="daily-matrix__toolbar-right">
|
||||
<span v-if="hasPatientSelfRecord" class="daily-matrix__legend">
|
||||
<span class="daily-matrix__cell-patient">自录</span>
|
||||
<span class="daily-matrix__legend-text">= 患者在小程序自录</span>
|
||||
</span>
|
||||
<el-button type="primary" @click="openQuickAdd('blood')">+血糖</el-button>
|
||||
<el-button @click="openQuickAdd('diet')">+饮食</el-button>
|
||||
<el-button @click="openQuickAdd('exercise')">+运动</el-button>
|
||||
@@ -30,6 +34,10 @@
|
||||
<el-button @click="fetchTracking">刷新</el-button>
|
||||
</div>
|
||||
<div v-else class="daily-matrix__toolbar-right">
|
||||
<span v-if="hasPatientSelfRecord" class="daily-matrix__legend">
|
||||
<span class="daily-matrix__cell-patient">自录</span>
|
||||
<span class="daily-matrix__legend-text">= 患者在小程序自录</span>
|
||||
</span>
|
||||
<el-button @click="fetchTracking">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,7 +65,8 @@
|
||||
:class="{
|
||||
'is-clickable': isCellClickable(row.key, date),
|
||||
'is-empty': !getCell(row.key, date).hasRecord,
|
||||
'is-high': getCell(row.key, date).isHigh
|
||||
'is-high': getCell(row.key, date).isHigh,
|
||||
'is-patient-self': getCell(row.key, date).isPatientSelf && getCell(row.key, date).hasRecord
|
||||
}"
|
||||
@click="handleCellClick(row.key, date)"
|
||||
>
|
||||
@@ -82,6 +91,14 @@
|
||||
<template v-else-if="getCell(row.key, date).value">
|
||||
<span>{{ getCell(row.key, date).value }}</span>
|
||||
<span v-if="getCell(row.key, date).isHigh" class="daily-matrix__cell-up">↑</span>
|
||||
<el-tooltip
|
||||
v-if="getCell(row.key, date).isPatientSelf"
|
||||
content="患者在小程序自录的数据"
|
||||
placement="top"
|
||||
:show-after="120"
|
||||
>
|
||||
<span class="daily-matrix__cell-patient">自录</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</div>
|
||||
@@ -345,6 +362,10 @@ interface BloodRecord {
|
||||
western_medicine?: string
|
||||
insulin?: string
|
||||
remark?: string
|
||||
/** 来源:0-医生录入 1-患者自录(小程序日常记录页) */
|
||||
source?: number
|
||||
/** 合并后该日是否含有「患者自录」记录 */
|
||||
has_patient_self?: boolean
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
@@ -558,6 +579,10 @@ const visibleDates = computed(() => {
|
||||
return dates
|
||||
})
|
||||
|
||||
const hasPatientSelfRecord = computed(() => {
|
||||
return bloodRecords.value.some((r) => Number((r as any).source) === 1)
|
||||
})
|
||||
|
||||
const bloodByDate = computed(() => {
|
||||
const map = new Map<string, BloodRecord>()
|
||||
for (const record of bloodRecords.value) {
|
||||
@@ -570,7 +595,8 @@ const bloodByDate = computed(() => {
|
||||
patient_id: record.patient_id,
|
||||
record_date: date,
|
||||
record_time: record.record_time || '',
|
||||
record_date_ts: record.record_date_ts
|
||||
record_date_ts: record.record_date_ts,
|
||||
has_patient_self: false
|
||||
})
|
||||
}
|
||||
const merged = map.get(date)!
|
||||
@@ -594,6 +620,10 @@ const bloodByDate = computed(() => {
|
||||
;(merged as any)[field] = record[field]
|
||||
}
|
||||
}
|
||||
// 当天有任意 source=1 的记录则标记为患者自录
|
||||
if (Number(record.source) === 1) {
|
||||
merged.has_patient_self = true
|
||||
}
|
||||
}
|
||||
return map
|
||||
})
|
||||
@@ -723,35 +753,40 @@ function parseTrendNumber(value: unknown): number | null {
|
||||
return parsed
|
||||
}
|
||||
|
||||
function getCell(metric: string, date: string): { value: string; isHigh: boolean; hasRecord: boolean } {
|
||||
function getCell(metric: string, date: string): { value: string; isHigh: boolean; hasRecord: boolean; isPatientSelf?: boolean } {
|
||||
const blood = bloodByDate.value.get(date)
|
||||
const diet = dietByDate.value.get(date)
|
||||
const exercise = exerciseByDate.value.get(date)
|
||||
const tracking = trackingByDate.value.get(date)
|
||||
const isPatientSelf = !!blood?.has_patient_self
|
||||
switch (metric) {
|
||||
case 'fasting':
|
||||
return {
|
||||
value: blood?.fasting_blood_sugar != null && blood.fasting_blood_sugar !== '' ? String(blood.fasting_blood_sugar) : '',
|
||||
isHigh: isHighFastingBloodSugar(blood?.fasting_blood_sugar, props.age),
|
||||
hasRecord: !!blood && blood.fasting_blood_sugar != null && blood.fasting_blood_sugar !== ''
|
||||
hasRecord: !!blood && blood.fasting_blood_sugar != null && blood.fasting_blood_sugar !== '',
|
||||
isPatientSelf
|
||||
}
|
||||
case 'postprandial':
|
||||
return {
|
||||
value: blood?.postprandial_blood_sugar != null && blood.postprandial_blood_sugar !== '' ? String(blood.postprandial_blood_sugar) : '',
|
||||
isHigh: isHighPostprandialBloodSugar(blood?.postprandial_blood_sugar, props.age),
|
||||
hasRecord: !!blood && blood.postprandial_blood_sugar != null && blood.postprandial_blood_sugar !== ''
|
||||
hasRecord: !!blood && blood.postprandial_blood_sugar != null && blood.postprandial_blood_sugar !== '',
|
||||
isPatientSelf
|
||||
}
|
||||
case 'other':
|
||||
return {
|
||||
value: blood?.other_blood_sugar != null && blood.other_blood_sugar !== '' ? String(blood.other_blood_sugar) : '',
|
||||
isHigh: isHighOtherBloodSugar(blood?.other_blood_sugar, props.age),
|
||||
hasRecord: !!blood && blood.other_blood_sugar != null && blood.other_blood_sugar !== ''
|
||||
hasRecord: !!blood && blood.other_blood_sugar != null && blood.other_blood_sugar !== '',
|
||||
isPatientSelf
|
||||
}
|
||||
case 'bp':
|
||||
return {
|
||||
value: blood && (blood.systolic_pressure || blood.diastolic_pressure) ? formatBp(blood) : '',
|
||||
isHigh: isHighBloodPressure(blood),
|
||||
hasRecord: !!blood && !!(blood.systolic_pressure || blood.diastolic_pressure)
|
||||
hasRecord: !!blood && !!(blood.systolic_pressure || blood.diastolic_pressure),
|
||||
isPatientSelf
|
||||
}
|
||||
case 'western':
|
||||
return {
|
||||
@@ -1115,6 +1150,12 @@ defineExpose({ refresh: fetchTracking })
|
||||
color: #dc2626;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&.is-patient-self {
|
||||
position: relative;
|
||||
background: linear-gradient(180deg, rgba(139, 92, 246, 0.04) 0%, rgba(139, 92, 246, 0.10) 100%);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&__cell-up {
|
||||
@@ -1122,6 +1163,38 @@ defineExpose({ refresh: fetchTracking })
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&__cell-patient {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
padding: 1px 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #6d28d9;
|
||||
background: #ede9fe;
|
||||
border: 1px solid #ddd6fe;
|
||||
border-radius: 999px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__legend {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-right: 12px;
|
||||
padding: 2px 10px 2px 6px;
|
||||
background: #f8f6ff;
|
||||
border: 1px dashed #ddd6fe;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
&__legend-text {
|
||||
font-size: 12px;
|
||||
color: #6d28d9;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__todo {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
const caseTypeLabel = computed(() => {
|
||||
const d: any = props.diag || {}
|
||||
return d.consultation_type === 'follow_up' ? '复诊' : '初诊'
|
||||
return d.consultation_type === 'follow_up' ? '复诊' : '复诊'
|
||||
})
|
||||
|
||||
const textOf = makeTextOf(() => props.diag || {})
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
预约:{{ apt?.appointment_date }} {{ apt?.appointment_time }} ·
|
||||
{{ formatPeriod(apt?.period) }}
|
||||
</div>
|
||||
<div>医生:{{ apt?.doctor_name || '—' }} 医助:{{ apt?.assistant_name || '—' }}</div>
|
||||
<div>医生:{{ apt?.doctor_name || '—' }} 客服:{{ apt?.assistant_name || '—' }}</div>
|
||||
<div>
|
||||
状态:{{ apt?.status_desc || '—' }} ·
|
||||
{{ apt?.has_prescription ? '已开方' : '未开方' }}
|
||||
|
||||
@@ -272,6 +272,41 @@
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="渠道来源" prop="create_source">
|
||||
<el-select
|
||||
v-model="formData.create_source"
|
||||
placeholder="请选择渠道来源"
|
||||
class="w-full"
|
||||
:popper-options="{ strategy: 'fixed' }"
|
||||
popper-class="high-z-index"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in createSourceOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="统计端就诊卡" prop="show_card">
|
||||
<el-switch
|
||||
v-model="formData.show_card"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
active-text="显示"
|
||||
inactive-text="隐藏"
|
||||
inline-prompt
|
||||
/>
|
||||
<span class="form-tips ml-3">
|
||||
关闭后,该就诊卡将不会出现在小程序统计端
|
||||
</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
@@ -855,9 +890,19 @@ const formData = ref({
|
||||
remark: '',
|
||||
current_medications: '',
|
||||
status: 1,
|
||||
create_source: 'admin',
|
||||
show_card: 1,
|
||||
external_userid: ''
|
||||
})
|
||||
|
||||
/** 就诊卡创建来源(与 zyt_tcm_diagnosis.create_source 一致) */
|
||||
const createSourceOptions = [
|
||||
{ value: '', label: '未设置' },
|
||||
{ value: 'admin', label: '后台建档' },
|
||||
{ value: 'mnp', label: '小程序完整建档' },
|
||||
{ value: 'mnp_daily', label: '小程序手机号快捷建档' }
|
||||
]
|
||||
|
||||
// 保存原始完整数据
|
||||
const originalPhone = ref('')
|
||||
const originalIdCard = ref('')
|
||||
@@ -1127,6 +1172,8 @@ async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
|
||||
data.id_card = maskIdCard(data.id_card || '')
|
||||
|
||||
formData.value = data
|
||||
formData.value.create_source = data.create_source ?? ''
|
||||
formData.value.show_card = Number(data.show_card) === 0 ? 0 : 1
|
||||
const y = data.diabetes_discovery_year
|
||||
formData.value.diabetes_discovery_year = y == null || y === '' ? '' : String(y)
|
||||
}
|
||||
@@ -1215,7 +1262,7 @@ const handleSubmit = async () => {
|
||||
}
|
||||
|
||||
const handleViewCase = (row: any) => {
|
||||
prescriptionRef.value?.openById(row.id)
|
||||
prescriptionRef.value?.openById(row.id, { slipType: 'user', hideCaseRecord: true })
|
||||
}
|
||||
|
||||
// 开方:传入当前诊单数据,包含详细病历(深拷贝避免响应式引用)
|
||||
@@ -1288,6 +1335,8 @@ const handleClose = () => {
|
||||
remark: '',
|
||||
current_medications: '',
|
||||
status: 1,
|
||||
create_source: 'admin',
|
||||
show_card: 1,
|
||||
external_userid: ''
|
||||
}
|
||||
|
||||
|
||||
@@ -456,6 +456,10 @@
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="继承">
|
||||
<el-checkbox v-model="assignForm.is_inherit">继承</el-checkbox>
|
||||
<span class="text-gray-400 text-xs ml-2">勾选后写入指派记录</span>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="assignMode === 'batch'" label="已选择">
|
||||
<div class="text-gray-500">{{ batchAssignIds.length }} 条诊单记录</div>
|
||||
</el-form-item>
|
||||
@@ -1340,12 +1344,14 @@ const assignLoading = ref(false)
|
||||
const assignMode = ref<'batch' | 'single'>('batch')
|
||||
const currentRow = ref<any>(null)
|
||||
const assignForm = ref({
|
||||
assistant_id: null as number | null
|
||||
assistant_id: null as number | null,
|
||||
is_inherit: false
|
||||
})
|
||||
|
||||
function resetAssignDialog() {
|
||||
batchAssignIds.value = []
|
||||
assignForm.value.assistant_id = null
|
||||
assignForm.value.is_inherit = false
|
||||
}
|
||||
|
||||
const handleSelectionChange = (selection: any[]) => {
|
||||
@@ -1372,6 +1378,7 @@ const handleSingleAssign = (row: any) => {
|
||||
batchAssignIds.value = []
|
||||
// 确保类型一致,转换为数字
|
||||
assignForm.value.assistant_id = Number(row.assistant_id) > 0 ? Number(row.assistant_id) : null
|
||||
assignForm.value.is_inherit = false
|
||||
assignDialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -1384,6 +1391,7 @@ const handleBatchAssign = () => {
|
||||
assignMode.value = 'batch'
|
||||
currentRow.value = null
|
||||
assignForm.value.assistant_id = null
|
||||
assignForm.value.is_inherit = false
|
||||
batchAssignIds.value = [...selectedIds.value]
|
||||
assignDialogVisible.value = true
|
||||
}
|
||||
@@ -1400,7 +1408,8 @@ const handleConfirmAssign = async () => {
|
||||
// 单独指派
|
||||
await tcmDiagnosisAssign({
|
||||
id: currentRow.value.id,
|
||||
assistant_id: assignForm.value.assistant_id
|
||||
assistant_id: assignForm.value.assistant_id,
|
||||
is_inherit: assignForm.value.is_inherit ? 1 : 0
|
||||
})
|
||||
} else {
|
||||
// 批量指派:使用打开弹窗时的 id 快照(避免弹窗内操作导致表格勾选被清空后提交为空)
|
||||
@@ -1413,6 +1422,7 @@ const handleConfirmAssign = async () => {
|
||||
await tcmDiagnosisAssign({
|
||||
id,
|
||||
assistant_id: assignForm.value.assistant_id!,
|
||||
is_inherit: assignForm.value.is_inherit ? 1 : 0
|
||||
})
|
||||
}
|
||||
selectedIds.value = []
|
||||
|
||||
@@ -364,6 +364,10 @@
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="继承">
|
||||
<el-checkbox v-model="assignForm.is_inherit">继承</el-checkbox>
|
||||
<span class="text-gray-400 text-xs ml-2">勾选后写入指派记录</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="诊单信息">
|
||||
<div class="dh5-form-tip">
|
||||
<div>患者:{{ currentRow?.patient_name }}</div>
|
||||
@@ -1080,10 +1084,11 @@ const runAction = (cmd: string) => {
|
||||
const assignDialogVisible = ref(false)
|
||||
const assignLoading = ref(false)
|
||||
const currentRow = ref<any>(null)
|
||||
const assignForm = ref({ assistant_id: null as number | null })
|
||||
const assignForm = ref({ assistant_id: null as number | null, is_inherit: false })
|
||||
const handleSingleAssign = (row: any) => {
|
||||
currentRow.value = row
|
||||
assignForm.value.assistant_id = row.assistant_id ? Number(row.assistant_id) : null
|
||||
assignForm.value.is_inherit = false
|
||||
assignDialogVisible.value = true
|
||||
}
|
||||
const handleConfirmAssign = async () => {
|
||||
@@ -1092,7 +1097,8 @@ const handleConfirmAssign = async () => {
|
||||
try {
|
||||
await tcmDiagnosisAssign({
|
||||
id: currentRow.value.id,
|
||||
assistant_id: assignForm.value.assistant_id
|
||||
assistant_id: assignForm.value.assistant_id,
|
||||
is_inherit: assignForm.value.is_inherit ? 1 : 0
|
||||
})
|
||||
assignDialogVisible.value = false
|
||||
getLists()
|
||||
|
||||
@@ -326,6 +326,10 @@
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="继承">
|
||||
<el-checkbox v-model="assignForm.is_inherit">继承</el-checkbox>
|
||||
<span class="text-gray-400 text-xs ml-2">勾选后写入指派记录</span>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="assignMode === 'batch'" label="已选择">
|
||||
<div class="text-gray-500">{{ selectedIds.length }} 条诊单记录</div>
|
||||
</el-form-item>
|
||||
@@ -1078,7 +1082,8 @@ const assignLoading = ref(false)
|
||||
const assignMode = ref<'batch' | 'single'>('batch')
|
||||
const currentRow = ref<any>(null)
|
||||
const assignForm = ref({
|
||||
assistant_id: null as number | null
|
||||
assistant_id: null as number | null,
|
||||
is_inherit: false
|
||||
})
|
||||
|
||||
const handleSelectionChange = (selection: any[]) => {
|
||||
@@ -1091,6 +1096,7 @@ const handleSingleAssign = (row: any) => {
|
||||
currentRow.value = row
|
||||
// 确保类型一致,转换为数字
|
||||
assignForm.value.assistant_id = Number(row.assistant_id) > 0 ? Number(row.assistant_id) : null
|
||||
assignForm.value.is_inherit = false
|
||||
assignDialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -1103,6 +1109,7 @@ const handleBatchAssign = () => {
|
||||
assignMode.value = 'batch'
|
||||
currentRow.value = null
|
||||
assignForm.value.assistant_id = null
|
||||
assignForm.value.is_inherit = false
|
||||
assignDialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -1118,14 +1125,16 @@ const handleConfirmAssign = async () => {
|
||||
// 单独指派
|
||||
await tcmDiagnosisAssign({
|
||||
id: currentRow.value.id,
|
||||
assistant_id: assignForm.value.assistant_id
|
||||
assistant_id: assignForm.value.assistant_id,
|
||||
is_inherit: assignForm.value.is_inherit ? 1 : 0
|
||||
})
|
||||
} else {
|
||||
// 批量指派
|
||||
const promises = selectedIds.value.map(id =>
|
||||
tcmDiagnosisAssign({
|
||||
id,
|
||||
assistant_id: assignForm.value.assistant_id
|
||||
assistant_id: assignForm.value.assistant_id,
|
||||
is_inherit: assignForm.value.is_inherit ? 1 : 0
|
||||
})
|
||||
)
|
||||
await Promise.all(promises)
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
# Design System Master File
|
||||
|
||||
> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`.
|
||||
> If that file exists, its rules **override** this Master file.
|
||||
> If not, strictly follow the rules below.
|
||||
|
||||
---
|
||||
|
||||
**Project:** SugarControl
|
||||
**Generated:** 2026-05-28 14:58:06
|
||||
**Category:** Healthcare App
|
||||
|
||||
---
|
||||
|
||||
## Global Rules
|
||||
|
||||
### Color Palette
|
||||
|
||||
| Role | Hex | CSS Variable |
|
||||
|------|-----|--------------|
|
||||
| Primary | `#0891B2` | `--color-primary` |
|
||||
| Secondary | `#22D3EE` | `--color-secondary` |
|
||||
| CTA/Accent | `#059669` | `--color-cta` |
|
||||
| Background | `#ECFEFF` | `--color-background` |
|
||||
| Text | `#164E63` | `--color-text` |
|
||||
|
||||
**Color Notes:** Calm cyan + health green
|
||||
|
||||
### Typography
|
||||
|
||||
- **Heading Font:** Atkinson Hyperlegible
|
||||
- **Body Font:** Atkinson Hyperlegible
|
||||
- **Mood:** accessible, readable, inclusive, WCAG, dyslexia-friendly, clear
|
||||
- **Google Fonts:** [Atkinson Hyperlegible + Atkinson Hyperlegible](https://fonts.google.com/share?selection.family=Atkinson+Hyperlegible:wght@400;700)
|
||||
|
||||
**CSS Import:**
|
||||
```css
|
||||
@import url('https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:wght@400;700&display=swap');
|
||||
```
|
||||
|
||||
### Spacing Variables
|
||||
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| `--space-xs` | `4px` / `0.25rem` | Tight gaps |
|
||||
| `--space-sm` | `8px` / `0.5rem` | Icon gaps, inline spacing |
|
||||
| `--space-md` | `16px` / `1rem` | Standard padding |
|
||||
| `--space-lg` | `24px` / `1.5rem` | Section padding |
|
||||
| `--space-xl` | `32px` / `2rem` | Large gaps |
|
||||
| `--space-2xl` | `48px` / `3rem` | Section margins |
|
||||
| `--space-3xl` | `64px` / `4rem` | Hero padding |
|
||||
|
||||
### Shadow Depths
|
||||
|
||||
| Level | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift |
|
||||
| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons |
|
||||
| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns |
|
||||
| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards |
|
||||
|
||||
---
|
||||
|
||||
## Component Specs
|
||||
|
||||
### Buttons
|
||||
|
||||
```css
|
||||
/* Primary Button */
|
||||
.btn-primary {
|
||||
background: #059669;
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
transition: all 200ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Secondary Button */
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: #0891B2;
|
||||
border: 2px solid #0891B2;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
transition: all 200ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
```
|
||||
|
||||
### Cards
|
||||
|
||||
```css
|
||||
.card {
|
||||
background: #ECFEFF;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: all 200ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
```
|
||||
|
||||
### Inputs
|
||||
|
||||
```css
|
||||
.input {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #E2E8F0;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
transition: border-color 200ms ease;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: #0891B2;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px #0891B220;
|
||||
}
|
||||
```
|
||||
|
||||
### Modals
|
||||
|
||||
```css
|
||||
.modal-overlay {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
box-shadow: var(--shadow-xl);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Style Guidelines
|
||||
|
||||
**Style:** Accessible & Ethical
|
||||
|
||||
**Keywords:** High contrast, large text (16px+), keyboard navigation, screen reader friendly, WCAG compliant, focus state, semantic
|
||||
|
||||
**Best For:** Government, healthcare, education, inclusive products, large audience, legal compliance, public
|
||||
|
||||
**Key Effects:** Clear focus rings (3-4px), ARIA labels, skip links, responsive design, reduced motion, 44x44px touch targets
|
||||
|
||||
### Page Pattern
|
||||
|
||||
**Pattern Name:** Social Proof-Focused
|
||||
|
||||
- **CTA Placement:** Above fold
|
||||
- **Section Order:** Hero > Features > CTA
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns (Do NOT Use)
|
||||
|
||||
- ❌ Bright neon colors
|
||||
- ❌ Motion-heavy animations
|
||||
- ❌ AI purple/pink gradients
|
||||
|
||||
### Additional Forbidden Patterns
|
||||
|
||||
- ❌ **Emojis as icons** — Use SVG icons (Heroicons, Lucide, Simple Icons)
|
||||
- ❌ **Missing cursor:pointer** — All clickable elements must have cursor:pointer
|
||||
- ❌ **Layout-shifting hovers** — Avoid scale transforms that shift layout
|
||||
- ❌ **Low contrast text** — Maintain 4.5:1 minimum contrast ratio
|
||||
- ❌ **Instant state changes** — Always use transitions (150-300ms)
|
||||
- ❌ **Invisible focus states** — Focus states must be visible for a11y
|
||||
|
||||
---
|
||||
|
||||
## Pre-Delivery Checklist
|
||||
|
||||
Before delivering any UI code, verify:
|
||||
|
||||
- [ ] No emojis used as icons (use SVG instead)
|
||||
- [ ] All icons from consistent icon set (Heroicons/Lucide)
|
||||
- [ ] `cursor-pointer` on all clickable elements
|
||||
- [ ] Hover states with smooth transitions (150-300ms)
|
||||
- [ ] Light mode: text contrast 4.5:1 minimum
|
||||
- [ ] Focus states visible for keyboard navigation
|
||||
- [ ] `prefers-reduced-motion` respected
|
||||
- [ ] Responsive: 375px, 768px, 1024px, 1440px
|
||||
- [ ] No content hidden behind fixed navbars
|
||||
- [ ] No horizontal scroll on mobile
|
||||
@@ -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('删除成功');
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,14 @@ use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\logic\stats\DoctorDailyStatsLogic;
|
||||
|
||||
/**
|
||||
* 医生日统计(系统/手动开方、成交、挂号状态)
|
||||
* 医生日统计(系统/手动开方、成交、挂号状态、总挂号、挂号率)
|
||||
*
|
||||
* - GET stats.doctorDailyStats/overview
|
||||
* ?start_date=&end_date=&channel_code=
|
||||
* 不传/忽略 dept_ids(医生统计不按展示部门检索);未传日期时由后端默认当日。
|
||||
* ?start_date=&end_date=&channel_code=&dept_ids=
|
||||
* dept_ids 可传逗号串或数组;选中父级会自动展开全部子级(与业绩看板部门树同口径)。
|
||||
* 筛选口径:部门 → admin_dept 命中的医助集合 → 该医助经手的挂号(COALESCE(a.assistant_id,u.assistant_id))/
|
||||
* 订单(o.creator_id)/ 处方(tcm_diagnosis.assistant_id),再按医生(rx.creator_id / a.doctor_id)汇总;
|
||||
* 显式选部门时隐藏全 0 医生行。未传日期时由后端默认当日。
|
||||
*/
|
||||
class DoctorDailyStatsController extends BaseAdminController
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user