This commit is contained in:
Your Name
2026-05-27 15:50:54 +08:00
parent 8b0fcd7050
commit 19af50c344
16 changed files with 5372 additions and 684 deletions
+9 -1
View File
@@ -137,7 +137,15 @@
"navigationBarBackgroundColor": "#0ea5a4", "navigationBarBackgroundColor": "#0ea5a4",
"navigationBarTextStyle": "white", "navigationBarTextStyle": "white",
"backgroundColor": "#f1f5f9", "backgroundColor": "#f1f5f9",
"enablePullDownRefresh": true "enablePullDownRefresh": true,
"mp-weixin": {
"usingPlugins": {
"WechatSI": {
"version": "0.3.5",
"provider": "wx069ba97219f66d99"
}
}
}
} }
} }
] ]
@@ -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="#0891B2" />
<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 = ['#0891B2', '#22D3EE', '#059669', '#FBBF24', '#F472B6', '#A78BFA']
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: #164e63;
}
.celebrate-card-sub {
position: relative;
z-index: 1;
margin-top: 10rpx;
font-size: 26rpx;
color: #475569;
line-height: 1.45;
}
</style>
@@ -0,0 +1,202 @@
<template>
<view class="sugar-tree-graphic" :class="[`lv-${clampedLevel}`, `size-${size}`, { watering: watering }]">
<view class="stg-glow" />
<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 >= 4" class="stg-sparkle stg-sparkle-a" />
<view v-if="clampedLevel >= 4" class="stg-sparkle stg-sparkle-b" />
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { svgToDataUrl } from '../utils/svgDataUrl.js'
// #ifdef MP-WEIXIN
const treeUseFallback = ref(true)
// #endif
// #ifndef MP-WEIXIN
const treeUseFallback = ref(false)
// #endif
const TREE_EMOJI = ['🌱', '🌿', '🌳', '🌸', '🌺']
const props = defineProps({
level: { type: Number, default: 0 },
size: { type: String, default: 'md' },
watering: { type: Boolean, default: false }
})
const clampedLevel = computed(() => Math.min(4, Math.max(0, Number(props.level) || 0)))
const treeEmoji = computed(() => TREE_EMOJI[clampedLevel.value] || TREE_EMOJI[0])
/** 医疗青绿 + 柔和陶土盆,5 档成长态 */
function buildTreeSvg(level) {
const pot = `
<ellipse cx="24" cy="50" rx="15" ry="3" fill="#0ea5a4" 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="#0d9488" 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" fill="#6ee7b7" opacity="0.55"/>
<path d="M24 41.5v4" stroke="#14b8a6" stroke-width="1.2" stroke-linecap="round" opacity="0.7"/>
</svg>`
}
const trunk = `<rect x="22.2" y="30" width="3.6" height="14" rx="1.8" fill="#78716C"/>
<rect x="22.6" y="30" width="2.8" height="14" 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="#ecfdf5" opacity="0.55"/>`
const blooms =
level >= 4
? `
${leaf(17, 19, 3.2, '#fda4af', 0.95)}
${leaf(30, 17, 3, '#f9a8d4', 0.9)}
${leaf(24, 14, 3.4, '#fb7185', 0.95)}
<circle cx="24" cy="14" r="1.2" fill="#fef3c7"/>
<circle cx="17" cy="19" r="0.9" fill="#fef3c7"/>
<circle cx="30" cy="17" r="0.8" fill="#fef3c7"/>
`
: ''
let canopy = ''
if (level === 1) {
canopy = leaf(24, 26, 7, '#34d399') + leaf(24, 25, 4.5, '#10b981', 0.85)
} else if (level === 2) {
canopy =
leaf(24, 24, 8, '#34d399') +
leaf(17, 27, 5.5, '#6ee7b7', 0.9) +
leaf(31, 27, 5.5, '#6ee7b7', 0.9)
} else if (level === 3) {
canopy =
leaf(24, 21, 10, '#22c55e') +
leaf(15, 25, 7, '#4ade80', 0.92) +
leaf(33, 25, 7, '#4ade80', 0.92) +
leaf(24, 15, 6, '#10b981', 0.88)
} else {
canopy =
leaf(24, 20, 11, '#059669') +
leaf(14, 24, 8, '#34d399', 0.95) +
leaf(34, 24, 8, '#34d399', 0.95) +
leaf(24, 13, 7.5, '#10b981', 0.9) +
blooms
}
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: 96rpx;
height: 108rpx;
}
.stg-glow {
position: absolute;
inset: 8%;
border-radius: 50%;
background: radial-gradient(circle, rgba(14, 165, 164, 0.22) 0%, transparent 68%);
pointer-events: none;
}
.lv-0 .stg-glow {
background: radial-gradient(circle, rgba(148, 163, 184, 0.2) 0%, transparent 70%);
}
.lv-4 .stg-glow {
background: radial-gradient(circle, rgba(251, 191, 36, 0.28) 0%, rgba(14, 165, 164, 0.12) 55%, transparent 72%);
}
.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: 72rpx;
}
@keyframes stg-water-bounce {
0%,
100% {
transform: scale(1);
}
35% {
transform: scale(1.08) translateY(-4rpx);
}
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;
}
.stg-sparkle-a {
top: 6%;
right: 18%;
}
.stg-sparkle-b {
top: 14%;
left: 12%;
width: 6rpx;
height: 6rpx;
opacity: 0.85;
}
</style>
@@ -0,0 +1,140 @@
<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: '#0891B2' }
})
/** 微信小程序 image 对 data:svg 支持不稳定,默认用文字图标保证可见 */
// #ifdef MP-WEIXIN
const useFallback = ref(true)
// #endif
// #ifndef MP-WEIXIN
const useFallback = ref(false)
// #endif
/** Lucide 风格描边路径 */
const ICON_PATHS = {
view: '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>',
ticket: '<path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z"/><path d="M13 5v2"/><path d="M13 17v2"/><path d="M13 11v2"/>',
calendar: '<rect width="18" height="18" x="3" y="4" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
flame: '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
'check-circle': '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="m9 11 3 3L22 4"/>',
glucose: '<path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>',
heart: '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>',
activity: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
'alert-triangle': '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
minus: '<path d="M5 12h14"/>',
droplet: '<path d="M12 22a7 7 0 0 0 7-7c0-2-1-3.5-2.5-5.5C15 7 12 2 12 2S9 7 7.5 9.5 5 13 5 15a7 7 0 0 0 7 7z"/>',
sparkles: '<path d="m12 3-1.9 5.8L4 12l5.8 1.9L12 21l1.9-5.8L20 12l-5.8-1.9L12 3Z"/><path d="M5 3v4M19 17v4M3 5h4M17 19h4"/>',
trophy: '<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6M18 9h1.5a2.5 2.5 0 0 0 0-5H18M4 22h16M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20 7 22M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20 17 22M18 2H6v7a6 6 0 0 0 12 0V2Z"/>',
share: '<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8M16 6l-4-4-4 4M12 2v13"/>'
}
/** 图片加载失败时的 emoji 回退 */
const ICON_FALLBACK = {
view: '👁',
ticket: '🎫',
calendar: '📅',
flame: '🔥',
'check-circle': '✓',
glucose: '💧',
heart: '❤',
activity: '🏃',
users: '👥',
'alert-triangle': '⚠',
minus: '—',
droplet: '💧',
sparkles: '✨',
trophy: '🏆',
share: '↗'
}
const strokeColor = computed(() => {
const c = String(props.color || '#0891B2').trim()
return /^#[0-9A-Fa-f]{3,8}$/.test(c) ? c : '#0891B2'
})
const iconSrc = computed(() => {
const path = ICON_PATHS[props.name] || ICON_PATHS.view
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${strokeColor.value}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`
return svgToDataUrl(svg)
})
const fallbackGlyph = computed(() => ICON_FALLBACK[props.name] || ICON_FALLBACK.view)
function onImageError() {
useFallback.value = true
}
</script>
<style lang="scss" scoped>
.tj-icon-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.tj-icon-wrap--sm {
width: 32rpx;
height: 32rpx;
}
.tj-icon-wrap--md {
width: 40rpx;
height: 40rpx;
}
.tj-icon-wrap--lg {
width: 48rpx;
height: 48rpx;
}
.tj-icon-wrap--xl {
width: 56rpx;
height: 56rpx;
}
.tj-icon {
display: block;
width: 100%;
height: 100%;
}
.tj-icon-fallback {
display: block;
line-height: 1;
font-weight: 700;
text-align: center;
}
.tj-icon-fallback--sm {
font-size: 24rpx;
}
.tj-icon-fallback--md {
font-size: 30rpx;
}
.tj-icon-fallback--lg {
font-size: 36rpx;
}
.tj-icon-fallback--xl {
font-size: 42rpx;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -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}`
}
+358 -14
View File
@@ -16,9 +16,14 @@ namespace app\api\controller;
use app\adminapi\logic\tcm\DiagnosisLogic; use app\adminapi\logic\tcm\DiagnosisLogic;
use app\adminapi\logic\tcm\TrackingNoteLogic; use app\adminapi\logic\tcm\TrackingNoteLogic;
use app\api\logic\tcm\DailyGamifyLogic;
use app\api\logic\tcm\DailyFamilyLikeLogic;
use app\api\logic\tcm\DailyShareLogic;
use app\adminapi\logic\ConfigLogic; use app\adminapi\logic\ConfigLogic;
use app\common\model\tcm\Diagnosis; use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\BloodRecord; use app\common\model\tcm\BloodRecord;
use app\common\model\tcm\DietRecord;
use app\common\model\tcm\ExerciseRecord;
/** /**
* 中医诊断控制器(供小程序调用) * 中医诊断控制器(供小程序调用)
@@ -31,7 +36,7 @@ class TcmController extends BaseApiController
* @notes 不需要登录的方法 * @notes 不需要登录的方法
* @var array * @var array
*/ */
public array $notNeedLogin = ['getPatientSignature', 'diagnosisDetail', 'getDict', 'confirmDiagnosis', 'getCardList', 'getOrderByNo', 'patientHangupVideo', 'dailyTrackingWindow', 'dailyTrackingNotes']; public array $notNeedLogin = ['getPatientSignature', 'diagnosisDetail', 'getDict', 'confirmDiagnosis', 'getCardList', 'getOrderByNo', 'patientHangupVideo', 'dailySharePreview', 'dailyFamilyLike'];
/** /**
* @notes 获取患者签名(供小程序调用) * @notes 获取患者签名(供小程序调用)
@@ -350,16 +355,13 @@ class TcmController extends BaseApiController
$startDate = (string) $this->request->get('start_date', ''); $startDate = (string) $this->request->get('start_date', '');
$endDate = (string) $this->request->get('end_date', ''); $endDate = (string) $this->request->get('end_date', '');
if ($diagnosisId <= 0) { $check = $this->ensurePatientOwnsDiagnosis($diagnosisId);
return $this->fail('诊单ID不能为空'); if (!$check['ok']) {
return $this->fail($check['error']);
} }
$diagnosis = $check['diagnosis'];
try { try {
// 患者端越权校验:诊单必须存在;如传入 patient_id 则须与诊单匹配
$diagnosis = Diagnosis::where('id', $diagnosisId)->find();
if (!$diagnosis) {
return $this->fail('诊单不存在');
}
if ($patientId > 0 && (int) $diagnosis['patient_id'] !== $patientId) { if ($patientId > 0 && (int) $diagnosis['patient_id'] !== $patientId) {
return $this->fail('无权查看该诊单的日常记录'); return $this->fail('无权查看该诊单的日常记录');
} }
@@ -393,15 +395,13 @@ class TcmController extends BaseApiController
$diagnosisId = (int) $this->request->get('diagnosis_id', 0); $diagnosisId = (int) $this->request->get('diagnosis_id', 0);
$patientId = (int) $this->request->get('patient_id', 0); $patientId = (int) $this->request->get('patient_id', 0);
if ($diagnosisId <= 0) { $check = $this->ensurePatientOwnsDiagnosis($diagnosisId);
return $this->fail('诊单ID不能为空'); if (!$check['ok']) {
return $this->fail($check['error']);
} }
$diagnosis = $check['diagnosis'];
try { try {
$diagnosis = Diagnosis::where('id', $diagnosisId)->find();
if (!$diagnosis) {
return $this->fail('诊单不存在');
}
if ($patientId > 0 && (int) $diagnosis['patient_id'] !== $patientId) { if ($patientId > 0 && (int) $diagnosis['patient_id'] !== $patientId) {
return $this->fail('无权查看该诊单的跟踪备注'); return $this->fail('无权查看该诊单的跟踪备注');
} }
@@ -413,6 +413,28 @@ class TcmController extends BaseApiController
} }
} }
/**
* 解析小程序 form-urlencoded 提交的 JSON 字段
*
* @param mixed $raw
* @param string $arrayKey ThinkPHP 数组字段名,如 badges/a
* @return array
*/
private function decodeJsonPostField($raw, string $arrayKey): array
{
if (is_string($raw) && $raw !== '') {
$decoded = json_decode($raw, true);
if (is_array($decoded)) {
return $decoded;
}
}
if (is_array($raw)) {
return $raw;
}
$arr = $this->request->post($arrayKey, []);
return is_array($arr) ? $arr : [];
}
/** /**
* @notes 校验当前登录患者是否拥有指定诊单(通过 diagnosis_view_records 关联) * @notes 校验当前登录患者是否拥有指定诊单(通过 diagnosis_view_records 关联)
* @param int $diagnosisId * @param int $diagnosisId
@@ -630,6 +652,328 @@ class TcmController extends BaseApiController
} }
} }
/**
* @notes 获取稳糖乐园状态(积分、今日任务、可领取积分)
*
* 路由:GET /api/tcm/dailyGetGamify?diagnosis_id=:id
*
* @return \think\response\Json
*/
public function dailyGetGamify()
{
$diagnosisId = (int) $this->request->get('diagnosis_id', 0);
$result = DailyGamifyLogic::getState((int) $this->userId, $diagnosisId);
if ($result === false) {
return $this->fail(DailyGamifyLogic::getError());
}
return $this->data($result);
}
/**
* @notes 给小树浇水(领取今日已完成任务的积分,服务端校验)
*
* 路由:POST /api/tcm/dailyWaterTree
* 必填:diagnosis_id
*
* @return \think\response\Json
*/
public function dailyWaterTree()
{
$diagnosisId = (int) $this->request->post('diagnosis_id', 0);
$result = DailyGamifyLogic::waterTree((int) $this->userId, $diagnosisId);
if ($result === false) {
return $this->fail(DailyGamifyLogic::getError());
}
// 统一走 success,保证 msg 与 data 同时返回,前端可提示且刷新状态
return $this->success($result['message'] ?? '操作成功', $result, 1, 1);
}
/**
* @notes 日常记录-获取今日饮食记录
*
* 路由:GET /api/tcm/dailyTodayDietRecord?diagnosis_id=:id
*/
public function dailyTodayDietRecord()
{
$diagnosisId = (int) $this->request->get('diagnosis_id', 0);
$check = $this->ensurePatientOwnsDiagnosis($diagnosisId);
if (!$check['ok']) {
return $this->fail($check['error']);
}
try {
$todayStart = strtotime(date('Y-m-d 00:00:00'));
$todayEnd = strtotime(date('Y-m-d 23:59:59'));
$record = DietRecord::where('diagnosis_id', $diagnosisId)
->where('record_date', '>=', $todayStart)
->where('record_date', '<=', $todayEnd)
->order('id', 'desc')
->find();
if (!$record) {
return $this->data(['record' => null, 'today' => date('Y-m-d')]);
}
$data = $record->toArray();
$data['record_date'] = date('Y-m-d', (int) $data['record_date']);
return $this->data(['record' => $data, 'today' => date('Y-m-d')]);
} catch (\Exception $e) {
return $this->fail($e->getMessage());
}
}
/**
* @notes 日常记录-保存今日饮食
*
* 路由:POST /api/tcm/dailySaveDietRecord
*/
public function dailySaveDietRecord()
{
$diagnosisId = (int) $this->request->post('diagnosis_id', 0);
$check = $this->ensurePatientOwnsDiagnosis($diagnosisId);
if (!$check['ok']) {
return $this->fail($check['error']);
}
$diagnosis = $check['diagnosis'];
$breakfast = trim((string) $this->request->post('breakfast_foods', ''));
$lunch = trim((string) $this->request->post('lunch_foods', ''));
$dinner = trim((string) $this->request->post('dinner_foods', ''));
$note = trim((string) $this->request->post('note', ''));
if ($breakfast === '' && $lunch === '' && $dinner === '') {
return $this->fail('请至少填写一餐饮食');
}
try {
$todayStart = strtotime(date('Y-m-d 00:00:00'));
$todayEnd = strtotime(date('Y-m-d 23:59:59'));
$existing = DietRecord::where('diagnosis_id', $diagnosisId)
->where('record_date', '>=', $todayStart)
->where('record_date', '<=', $todayEnd)
->order('id', 'desc')
->find();
$payload = [
'diagnosis_id' => $diagnosisId,
'patient_id' => (int) $diagnosis['patient_id'],
'record_date' => $todayStart,
'breakfast_foods' => $breakfast,
'lunch_foods' => $lunch,
'dinner_foods' => $dinner,
'note' => $note,
];
if ($existing) {
$payload['id'] = $existing['id'];
DietRecord::update($payload);
return $this->success('已更新今日饮食', ['id' => $existing['id']]);
}
$created = DietRecord::create($payload);
return $this->success('饮食记录已保存', ['id' => $created->id]);
} catch (\Exception $e) {
return $this->fail($e->getMessage());
}
}
/**
* @notes 日常记录-获取今日运动记录
*
* 路由:GET /api/tcm/dailyTodayExerciseRecord?diagnosis_id=:id
*/
public function dailyTodayExerciseRecord()
{
$diagnosisId = (int) $this->request->get('diagnosis_id', 0);
$check = $this->ensurePatientOwnsDiagnosis($diagnosisId);
if (!$check['ok']) {
return $this->fail($check['error']);
}
try {
$todayStart = strtotime(date('Y-m-d 00:00:00'));
$todayEnd = strtotime(date('Y-m-d 23:59:59'));
$record = ExerciseRecord::where('diagnosis_id', $diagnosisId)
->where('record_date', '>=', $todayStart)
->where('record_date', '<=', $todayEnd)
->order('id', 'desc')
->find();
if (!$record) {
return $this->data(['record' => null, 'today' => date('Y-m-d')]);
}
$data = $record->toArray();
$data['record_date'] = date('Y-m-d', (int) $data['record_date']);
return $this->data(['record' => $data, 'today' => date('Y-m-d')]);
} catch (\Exception $e) {
return $this->fail($e->getMessage());
}
}
/**
* @notes 日常记录-保存今日运动
*
* 路由:POST /api/tcm/dailySaveExerciseRecord
*/
public function dailySaveExerciseRecord()
{
$diagnosisId = (int) $this->request->post('diagnosis_id', 0);
$check = $this->ensurePatientOwnsDiagnosis($diagnosisId);
if (!$check['ok']) {
return $this->fail($check['error']);
}
$diagnosis = $check['diagnosis'];
$exerciseType = trim((string) $this->request->post('exercise_type', ''));
$durationRaw = $this->request->post('duration', '');
$intensity = (int) $this->request->post('intensity', 2);
$note = trim((string) $this->request->post('note', ''));
$duration = null;
if ($durationRaw !== '' && $durationRaw !== null && is_numeric($durationRaw)) {
$d = (int) $durationRaw;
if ($d > 0) {
$duration = $d;
}
}
if ($exerciseType === '' && $duration === null) {
return $this->fail('请填写运动类型或时长');
}
if ($intensity < 1 || $intensity > 3) {
$intensity = 2;
}
try {
$todayStart = strtotime(date('Y-m-d 00:00:00'));
$todayEnd = strtotime(date('Y-m-d 23:59:59'));
$existing = ExerciseRecord::where('diagnosis_id', $diagnosisId)
->where('record_date', '>=', $todayStart)
->where('record_date', '<=', $todayEnd)
->order('id', 'desc')
->find();
$payload = [
'diagnosis_id' => $diagnosisId,
'patient_id' => (int) $diagnosis['patient_id'],
'record_date' => $todayStart,
'exercise_type' => $exerciseType,
'duration' => $duration,
'intensity' => $intensity,
'note' => $note,
];
if ($existing) {
$payload['id'] = $existing['id'];
ExerciseRecord::update($payload);
return $this->success('已更新今日运动', ['id' => $existing['id']]);
}
$created = ExerciseRecord::create($payload);
return $this->success('运动记录已保存', ['id' => $created->id]);
} catch (\Exception $e) {
return $this->fail($e->getMessage());
}
}
/**
* @notes 保存稳糖分 / 勋章 / 任务领奖状态
*
* 路由:POST /api/tcm/dailySaveGamify
* 参数:diagnosis_id, points, badges(数组), task_awards(对象)
*
* @return \think\response\Json
*/
public function dailySaveGamify()
{
$diagnosisId = (int) $this->request->post('diagnosis_id', 0);
$points = (int) $this->request->post('points', 0);
$badges = $this->decodeJsonPostField($this->request->post('badges', ''), 'badges/a');
$taskAwards = $this->decodeJsonPostField($this->request->post('task_awards', ''), 'task_awards/a');
$result = DailyGamifyLogic::saveState((int) $this->userId, $diagnosisId, $points, $badges, $taskAwards);
if ($result === false) {
return $this->fail(DailyGamifyLogic::getError());
}
return $this->data($result);
}
/**
* @notes 生成日常记录分享邀请码(仅当天有效)
*
* 路由:POST /api/tcm/dailyCreateShareInvite
* 必填:diagnosis_id
*
* @return \think\response\Json
*/
public function dailyCreateShareInvite()
{
$diagnosisId = (int) $this->request->post('diagnosis_id', 0);
$result = DailyShareLogic::createInvite((int) $this->userId, $diagnosisId);
if ($result === false) {
return $this->fail(DailyShareLogic::getError());
}
return $this->data($result);
}
/**
* @notes 凭邀请码查看分享战报(无需登录,含近7日血糖/血压记录)
*
* 路由:GET /api/tcm/dailySharePreview?invite_code=XXXXXXXX
*
* @return \think\response\Json
*/
public function dailySharePreview()
{
$inviteCode = (string) $this->request->get('invite_code', '');
$viewerKey = (string) $this->request->get('viewer_key', '');
$result = DailyShareLogic::previewByInviteCode($inviteCode, $viewerKey);
if ($result === false) {
return $this->fail(DailyShareLogic::getError());
}
return $this->data($result);
}
/**
* @notes 家人点赞(邀请观看页,无需登录)
*
* 路由:POST /api/tcm/dailyFamilyLike
* 必填:invite_code, viewer_key
* 可选:nickname(家人/亲友/老伴 等)
*
* @return \think\response\Json
*/
public function dailyFamilyLike()
{
$inviteCode = (string) $this->request->post('invite_code', '');
$viewerKey = (string) $this->request->post('viewer_key', '');
$nickname = (string) $this->request->post('nickname', '');
$result = DailyFamilyLikeLogic::addLike($inviteCode, $viewerKey, $nickname);
if ($result === false) {
return $this->fail(DailyFamilyLikeLogic::getError());
}
return $this->data($result);
}
/**
* @notes 患者查看今日家人点赞汇总
*
* 路由:GET /api/tcm/dailyFamilyLikeSummary?diagnosis_id=
*
* @return \think\response\Json
*/
public function dailyFamilyLikeSummary()
{
$diagnosisId = (int) $this->request->get('diagnosis_id', 0);
$result = DailyFamilyLikeLogic::summaryForPatient((int) $this->userId, $diagnosisId);
if ($result === false) {
return $this->fail(DailyFamilyLikeLogic::getError());
}
return $this->data($result);
}
/** /**
* @notes 根据订单号获取订单详情(供小程序支付页调用) * @notes 根据订单号获取订单详情(供小程序支付页调用)
* @return \think\response\Json * @return \think\response\Json
@@ -0,0 +1,250 @@
<?php
namespace app\api\logic\tcm;
use app\common\model\tcm\DailyFamilyLike;
use app\common\model\tcm\DailyShareInvite;
/**
* 家人点赞(邀请观看页 → 患者日常页展示)
*/
class DailyFamilyLikeLogic
{
protected static string $error = '';
/** @var string[] */
protected static array $praisePool = [
'坚持得很好,为您点赞!',
'每天记录真棒,继续保持!',
'您的自律让人佩服!',
'加油,家人一直支持您!',
'稳糖路上,您并不孤单!',
'好习惯正在养成,真为您高兴!',
];
public static function getError(): string
{
return self::$error;
}
protected static function setError(string $msg): bool
{
self::$error = $msg;
return false;
}
/**
* 校验当日邀请码
*
* @return array{diagnosis_id:int,invite_code:string}|false
*/
protected static function resolveInvite(string $inviteCode): array|false
{
$inviteCode = strtoupper(trim($inviteCode));
if ($inviteCode === '') {
return self::setError('邀请码不能为空') ? false : false;
}
$row = DailyShareInvite::where('invite_code', $inviteCode)->find();
if (!$row) {
return self::setError('邀请码无效或已失效') ? false : false;
}
$today = date('Y-m-d');
if ((string) $row['invite_date'] !== $today) {
return self::setError('邀请码已过期,仅可在分享当天点赞') ? false : false;
}
return [
'diagnosis_id' => (int) $row['diagnosis_id'],
'invite_code' => $inviteCode,
];
}
protected static function normalizeViewerKey(string $viewerKey): string
{
$viewerKey = preg_replace('/[^\w\-]/', '', trim($viewerKey)) ?? '';
if (strlen($viewerKey) < 8) {
return '';
}
return substr($viewerKey, 0, 64);
}
protected static function normalizeNickname(string $nickname): string
{
$nickname = trim($nickname);
if ($nickname === '') {
return '家人';
}
$nickname = mb_substr($nickname, 0, 8, 'UTF-8');
return $nickname !== '' ? $nickname : '家人';
}
public static function countToday(int $diagnosisId, ?string $likeDate = null): int
{
$likeDate = $likeDate ?: date('Y-m-d');
return (int) DailyFamilyLike::where('diagnosis_id', $diagnosisId)
->where('like_date', $likeDate)
->count();
}
public static function likedByViewer(int $diagnosisId, string $viewerKey, ?string $likeDate = null): bool
{
$viewerKey = self::normalizeViewerKey($viewerKey);
if ($viewerKey === '') {
return false;
}
$likeDate = $likeDate ?: date('Y-m-d');
return DailyFamilyLike::where('diagnosis_id', $diagnosisId)
->where('like_date', $likeDate)
->where('viewer_key', $viewerKey)
->find() !== null;
}
/**
* 邀请预览附加点赞信息
*/
public static function metaForInvite(string $inviteCode, string $viewerKey = ''): array
{
$resolved = self::resolveInvite($inviteCode);
if ($resolved === false) {
return [
'like_count' => 0,
'liked_by_me' => false,
];
}
$diagnosisId = $resolved['diagnosis_id'];
$viewerKey = self::normalizeViewerKey($viewerKey);
return [
'like_count' => self::countToday($diagnosisId),
'liked_by_me' => $viewerKey !== '' && self::likedByViewer($diagnosisId, $viewerKey),
];
}
/**
* 家人点赞(无需登录)
*/
public static function addLike(string $inviteCode, string $viewerKey, string $nickname = ''): array|false
{
self::$error = '';
$resolved = self::resolveInvite($inviteCode);
if ($resolved === false) {
return false;
}
$viewerKey = self::normalizeViewerKey($viewerKey);
if ($viewerKey === '') {
return self::setError('设备标识无效,请重试') ? false : false;
}
$diagnosisId = $resolved['diagnosis_id'];
$likeDate = date('Y-m-d');
$nickname = self::normalizeNickname($nickname);
$exists = DailyFamilyLike::where('diagnosis_id', $diagnosisId)
->where('like_date', $likeDate)
->where('viewer_key', $viewerKey)
->find();
if ($exists) {
return [
'already_liked' => true,
'like_count' => self::countToday($diagnosisId, $likeDate),
'praise_message' => '您今天已经点过赞啦,谢谢您的鼓励',
'nickname' => (string) ($exists['nickname'] ?? '家人'),
];
}
$todayCount = self::countToday($diagnosisId, $likeDate);
if ($todayCount >= 50) {
return self::setError('今日点赞已满,明天再来吧') ? false : false;
}
DailyFamilyLike::create([
'diagnosis_id' => $diagnosisId,
'like_date' => $likeDate,
'invite_code' => $resolved['invite_code'],
'viewer_key' => $viewerKey,
'nickname' => $nickname,
'create_time' => time(),
]);
$likeCount = self::countToday($diagnosisId, $likeDate);
$idx = $likeCount > 0 ? ($likeCount - 1) % count(self::$praisePool) : 0;
return [
'already_liked' => false,
'like_count' => $likeCount,
'praise_message' => self::$praisePool[$idx],
'nickname' => $nickname,
];
}
/**
* 患者查看今日家人点赞(需登录且拥有诊单)
*/
public static function summaryForPatient(int $userId, int $diagnosisId): array|false
{
self::$error = '';
if ($userId <= 0) {
return self::setError('请先登录') ? false : false;
}
if ($diagnosisId <= 0) {
return self::setError('诊单ID不能为空') ? false : false;
}
$owned = \think\facade\Db::name('diagnosis_view_records')
->where('user_id', $userId)
->where('diagnosis_id', $diagnosisId)
->where('delete_time', null)
->find();
if (!$owned) {
return self::setError('无权查看该诊单') ? false : false;
}
$likeDate = date('Y-m-d');
$count = self::countToday($diagnosisId, $likeDate);
$rows = DailyFamilyLike::where('diagnosis_id', $diagnosisId)
->where('like_date', $likeDate)
->order('id', 'desc')
->limit(8)
->select()
->toArray();
$recent = [];
foreach ($rows as $r) {
$recent[] = [
'nickname' => (string) ($r['nickname'] ?? '家人'),
'time_label' => self::timeLabel((int) ($r['create_time'] ?? 0)),
];
}
$summaryLine = $count > 0
? "今日有 {$count} 位家人为您点赞,继续加油!"
: '分享战报给家人,邀请他们为您点赞鼓劲';
return [
'like_count' => $count,
'summary_line' => $summaryLine,
'recent' => $recent,
];
}
protected static function timeLabel(int $ts): string
{
if ($ts <= 0) {
return '刚刚';
}
$diff = time() - $ts;
if ($diff < 60) {
return '刚刚';
}
if ($diff < 3600) {
return (int) floor($diff / 60) . '分钟前';
}
return date('H:i', $ts);
}
}
@@ -0,0 +1,373 @@
<?php
namespace app\api\logic\tcm;
use app\common\model\tcm\BloodRecord;
use app\common\model\tcm\DailyGamify;
use app\common\model\tcm\DietRecord;
use app\common\model\tcm\ExerciseRecord;
/**
* 稳糖分 / 勋章 / 浇水领奖
*/
class DailyGamifyLogic
{
protected static string $error = '';
/** @var array<string,array{name:string,points:int}> */
protected static array $taskDefs = [
'blood' => ['name' => '测血糖血压', 'points' => 10],
'diet' => ['name' => '记今日饮食', 'points' => 10],
'exercise' => ['name' => '记今日运动', 'points' => 10],
];
public static function getError(): string
{
return self::$error;
}
protected static function setError(string $msg): bool
{
self::$error = $msg;
return false;
}
protected static function assertOwned(int $userId, int $diagnosisId): bool
{
if ($userId <= 0) {
return self::setError('请先登录') ? false : false;
}
if ($diagnosisId <= 0) {
return self::setError('诊单ID不能为空') ? false : false;
}
$owned = \think\facade\Db::name('diagnosis_view_records')
->where('user_id', $userId)
->where('diagnosis_id', $diagnosisId)
->where('delete_time', null)
->find();
if (!$owned) {
return self::setError('无权操作该诊单') ? false : false;
}
return true;
}
protected static function todayRange(): array
{
return [
strtotime(date('Y-m-d 00:00:00')),
strtotime(date('Y-m-d 23:59:59')),
];
}
protected static function hasValue($v): bool
{
if ($v === null || $v === '' || $v === '0' || $v === 0) {
return false;
}
if (is_string($v)) {
return trim($v) !== '';
}
return is_numeric($v) && (float) $v > 0;
}
/**
* 今日任务是否已完成(依据真实业务记录)
*/
public static function evaluateTaskCompletion(int $diagnosisId): array
{
[$start, $end] = self::todayRange();
$blood = BloodRecord::where('diagnosis_id', $diagnosisId)
->where('source', 1)
->where('record_date', '>=', $start)
->where('record_date', '<=', $end)
->find();
$bloodDone = false;
if ($blood) {
$bloodDone = self::hasValue($blood['fasting_blood_sugar'] ?? null)
|| self::hasValue($blood['postprandial_blood_sugar'] ?? null)
|| self::hasValue($blood['other_blood_sugar'] ?? null)
|| self::hasValue($blood['systolic_pressure'] ?? null)
|| self::hasValue($blood['diastolic_pressure'] ?? null);
}
$diet = DietRecord::where('diagnosis_id', $diagnosisId)
->where('record_date', '>=', $start)
->where('record_date', '<=', $end)
->order('id', 'desc')
->find();
$dietDone = false;
if ($diet) {
$dietDone = self::hasValue($diet['breakfast_foods'] ?? null)
|| self::hasValue($diet['lunch_foods'] ?? null)
|| self::hasValue($diet['dinner_foods'] ?? null);
}
$exercise = ExerciseRecord::where('diagnosis_id', $diagnosisId)
->where('record_date', '>=', $start)
->where('record_date', '<=', $end)
->order('id', 'desc')
->find();
$exerciseDone = false;
if ($exercise) {
$exerciseDone = self::hasValue($exercise['exercise_type'] ?? null)
|| self::hasValue($exercise['duration'] ?? null);
}
return [
'blood' => $bloodDone,
'diet' => $dietDone,
'exercise' => $exerciseDone,
];
}
/**
* @param array<string,array<string,bool>> $taskAwards
* @return array<int,array{id:string,name:string,points:int,completed:bool,claimed:bool}>
*/
public static function buildTodayTasks(int $diagnosisId, array $taskAwards): array
{
$today = date('Y-m-d');
$awards = isset($taskAwards[$today]) && is_array($taskAwards[$today]) ? $taskAwards[$today] : [];
$completion = self::evaluateTaskCompletion($diagnosisId);
$list = [];
foreach (self::$taskDefs as $id => $def) {
$list[] = [
'id' => $id,
'name' => $def['name'],
'points' => $def['points'],
'completed' => !empty($completion[$id]),
'claimed' => !empty($awards[$id]),
];
}
return $list;
}
protected static function treeMeta(int $points): array
{
$level = min(4, (int) floor($points / 100));
$names = ['嫩芽期', '成长雏形', '枝繁叶茂', '含苞待放', '花开稳糖'];
return [
'tree_level' => $level,
'tree_progress' => $level >= 4 ? 100 : ($points % 100),
'tree_level_name' => $names[$level] ?? '嫩芽期',
];
}
/**
* 获取稳糖乐园状态(含今日任务)
*/
public static function getState(int $userId, int $diagnosisId): array|false
{
self::$error = '';
if (!self::assertOwned($userId, $diagnosisId)) {
return false;
}
$row = DailyGamify::where('diagnosis_id', $diagnosisId)
->where('user_id', $userId)
->find();
$points = $row ? (int) $row['points'] : 0;
$badges = $row ? self::decodeJsonArray((string) ($row['badges'] ?? '')) : [];
$taskAwards = $row ? self::decodeJsonObject((string) ($row['task_awards'] ?? '')) : [];
$todayTasks = self::buildTodayTasks($diagnosisId, $taskAwards);
$claimable = 0;
foreach ($todayTasks as $t) {
if ($t['completed'] && !$t['claimed']) {
$claimable += (int) $t['points'];
}
}
return array_merge([
'points' => $points,
'badges' => $badges,
'task_awards' => $taskAwards,
'today_tasks' => $todayTasks,
'claimable_points' => $claimable,
], self::treeMeta($points));
}
/**
* 浇水:领取今日已完成且未领取的任务积分
*/
public static function waterTree(int $userId, int $diagnosisId): array|false
{
self::$error = '';
if (!self::assertOwned($userId, $diagnosisId)) {
return false;
}
$row = DailyGamify::where('diagnosis_id', $diagnosisId)
->where('user_id', $userId)
->find();
$points = $row ? (int) $row['points'] : 0;
$badges = $row ? self::decodeJsonArray((string) ($row['badges'] ?? '')) : [];
$taskAwards = $row ? self::decodeJsonObject((string) ($row['task_awards'] ?? '')) : [];
$today = date('Y-m-d');
$todayTasks = self::buildTodayTasks($diagnosisId, $taskAwards);
$addedPoints = 0;
$claimedIds = [];
$pending = [];
if (!isset($taskAwards[$today]) || !is_array($taskAwards[$today])) {
$taskAwards[$today] = [];
}
foreach ($todayTasks as $task) {
if ($task['completed'] && !$task['claimed']) {
$id = (string) $task['id'];
$taskAwards[$today][$id] = true;
$addedPoints += (int) $task['points'];
$claimedIds[] = $id;
} elseif (!$task['completed']) {
$pending[] = [
'id' => $task['id'],
'name' => $task['name'],
];
}
}
if ($addedPoints <= 0) {
$claimable = 0;
foreach ($todayTasks as $t) {
if ($t['completed'] && !$t['claimed']) {
$claimable += (int) $t['points'];
}
}
$refreshedTasks = self::buildTodayTasks($diagnosisId, $taskAwards);
return [
'added_points' => 0,
'claimed_tasks' => [],
'claimable_points' => $claimable,
'pending_tasks' => $pending,
'points' => $points,
'badges' => $badges,
'task_awards' => $taskAwards,
'today_tasks' => $refreshedTasks,
'message' => $claimable > 0 ? '请先点击浇水领取积分' : (count($pending) ? '请先完成今日任务再浇水' : '今日奖励已全部领取'),
] + self::treeMeta($points);
}
$newPoints = $points + $addedPoints;
$saved = self::saveState($userId, $diagnosisId, $newPoints, $badges, $taskAwards);
if ($saved === false) {
return false;
}
$refreshed = self::buildTodayTasks($diagnosisId, $taskAwards);
return [
'added_points' => $addedPoints,
'claimed_tasks' => $claimedIds,
'claimable_points' => 0,
'pending_tasks' => $pending,
'points' => $newPoints,
'badges' => $badges,
'task_awards' => $taskAwards,
'today_tasks' => $refreshed,
'message' => "浇水成功,获得 {$addedPoints} 稳糖积分",
] + self::treeMeta($newPoints);
}
/**
* @param array<int,string> $badges
* @param array<string,array<string,bool>> $taskAwards
*/
public static function saveState(int $userId, int $diagnosisId, int $points, array $badges, array $taskAwards): array|false
{
self::$error = '';
if (!self::assertOwned($userId, $diagnosisId)) {
return false;
}
$points = max(0, (int) $points);
$badges = array_values(array_unique(array_filter(array_map('strval', $badges))));
if (!is_array($taskAwards)) {
$taskAwards = [];
}
$now = time();
$row = DailyGamify::where('diagnosis_id', $diagnosisId)
->where('user_id', $userId)
->find();
$data = [
'points' => $points,
'badges' => json_encode($badges, JSON_UNESCAPED_UNICODE),
'task_awards' => json_encode($taskAwards, JSON_UNESCAPED_UNICODE),
'update_time' => $now,
];
if ($row) {
DailyGamify::where('id', (int) $row['id'])->update($data);
} else {
$data['diagnosis_id'] = $diagnosisId;
$data['user_id'] = $userId;
$data['create_time'] = $now;
DailyGamify::create($data);
}
return [
'points' => $points,
'badges' => $badges,
'task_awards' => $taskAwards,
];
}
/**
* 本地缓存迁到服务端:取 points/badges/task_awards 的较大合并
*/
public static function mergeFromClient(int $userId, int $diagnosisId, int $points, array $badges, array $taskAwards): array|false
{
$server = self::getState($userId, $diagnosisId);
if ($server === false) {
return false;
}
$mergedPoints = max((int) $server['points'], max(0, $points));
$mergedBadges = array_values(array_unique(array_merge($server['badges'], $badges)));
$mergedAwards = $server['task_awards'];
foreach ($taskAwards as $date => $tasks) {
if (!is_array($tasks)) {
continue;
}
if (!isset($mergedAwards[$date]) || !is_array($mergedAwards[$date])) {
$mergedAwards[$date] = [];
}
foreach ($tasks as $taskId => $flag) {
if ($flag) {
$mergedAwards[$date][(string) $taskId] = true;
}
}
}
return self::saveState($userId, $diagnosisId, $mergedPoints, $mergedBadges, $mergedAwards);
}
protected static function decodeJsonArray(string $json): array
{
if ($json === '') {
return [];
}
$data = json_decode($json, true);
return is_array($data) ? array_values(array_map('strval', $data)) : [];
}
protected static function decodeJsonObject(string $json): array
{
if ($json === '') {
return [];
}
$data = json_decode($json, true);
return is_array($data) ? $data : [];
}
}
@@ -0,0 +1,373 @@
<?php
namespace app\api\logic\tcm;
use app\common\model\tcm\BloodRecord;
use app\common\model\tcm\DailyShareInvite;
use app\common\model\tcm\Diagnosis;
/**
* 日常记录分享邀请码(当日有效、脱敏预览)
*/
class DailyShareLogic
{
protected static string $error = '';
public static function getError(): string
{
return self::$error;
}
protected static function setError(string $msg): bool
{
self::$error = $msg;
return false;
}
/**
* 生成当日邀请码(分享人须已拥有诊单)
*/
public static function createInvite(int $userId, int $diagnosisId): array|false
{
self::$error = '';
if ($userId <= 0) {
return self::setError('请先登录') ? false : false;
}
if ($diagnosisId <= 0) {
return self::setError('诊单ID不能为空') ? false : false;
}
$owned = \think\facade\Db::name('diagnosis_view_records')
->where('user_id', $userId)
->where('diagnosis_id', $diagnosisId)
->where('delete_time', null)
->find();
if (!$owned) {
return self::setError('无权分享该诊单') ? false : false;
}
$diagnosis = Diagnosis::where('id', $diagnosisId)->where('delete_time', null)->find();
if (!$diagnosis) {
return self::setError('诊单不存在') ? false : false;
}
$inviteDate = date('Y-m-d');
$code = self::generateUniqueCode();
DailyShareInvite::create([
'invite_code' => $code,
'diagnosis_id' => $diagnosisId,
'user_id' => $userId,
'invite_date' => $inviteDate,
'create_time' => time(),
]);
return [
'invite_code' => $code,
'invite_date' => $inviteDate,
'expires_hint' => '邀请码仅今日有效,明日将无法查看',
'share_path' => '/tongji/pages/index?from=share&invite_code=' . $code,
];
}
/**
* 凭邀请码查看分享战报(含近 7 日血糖/血压记录,仅当日邀请码有效)
*/
public static function previewByInviteCode(string $inviteCode, string $viewerKey = ''): array|false
{
self::$error = '';
$inviteCode = strtoupper(trim($inviteCode));
if ($inviteCode === '') {
return self::setError('邀请码不能为空') ? false : false;
}
$row = DailyShareInvite::where('invite_code', $inviteCode)->find();
if (!$row) {
return self::setError('邀请码无效或已失效') ? false : false;
}
$today = date('Y-m-d');
if ((string) $row['invite_date'] !== $today) {
return self::setError('邀请码已过期,仅可在分享当天查看') ? false : false;
}
$diagnosisId = (int) $row['diagnosis_id'];
$diagnosis = Diagnosis::where('id', $diagnosisId)->where('delete_time', null)->find();
if (!$diagnosis) {
return self::setError('诊单不存在') ? false : false;
}
$age = (int) ($diagnosis['age'] ?? 0);
$stats = self::buildDesensitizedWeekStats($diagnosisId, $age);
$name = trim((string) ($diagnosis['patient_name'] ?? ''));
$label = self::maskPatientName($name);
$likeMeta = DailyFamilyLikeLogic::metaForInvite($inviteCode, $viewerKey);
return array_merge($stats, $likeMeta, [
'invite_code' => $inviteCode,
'invite_date' => $today,
'expires_hint' => '本邀请码仅今日有效,明日将无法查看',
'viewer_notice' => '您正在查看家人分享的近7日血糖记录',
'patient_label' => $label,
]);
}
protected static function generateUniqueCode(): string
{
for ($i = 0; $i < 8; $i++) {
$code = strtoupper(substr(bin2hex(random_bytes(4)), 0, 8));
if (!DailyShareInvite::where('invite_code', $code)->find()) {
return $code;
}
}
return strtoupper(substr(uniqid('', true), -8));
}
protected static function maskPatientName(string $name): string
{
$name = trim($name);
if ($name === '') {
return '家人';
}
$len = mb_strlen($name, 'UTF-8');
if ($len <= 1) {
return $name . '*';
}
if ($len === 2) {
return mb_substr($name, 0, 1, 'UTF-8') . '*';
}
return mb_substr($name, 0, 1, 'UTF-8') . '*' . mb_substr($name, -1, 1, 'UTF-8');
}
/**
* 近 7 天习惯统计 + 每日血糖/血压记录(供家人分享页展示)
*/
public static function buildDesensitizedWeekStats(int $diagnosisId, int $age = 0): array
{
$today = new \DateTime('today');
$dayKeys = [];
for ($i = 6; $i >= 0; $i--) {
$d = clone $today;
$d->modify("-{$i} days");
$dayKeys[] = $d->format('Y-m-d');
}
$startTs = strtotime($dayKeys[0] . ' 00:00:00');
$endTs = strtotime($dayKeys[6] . ' 23:59:59');
$rows = BloodRecord::where('diagnosis_id', $diagnosisId)
->where('record_date', '>=', $startTs)
->where('record_date', '<=', $endTs)
->field('record_date,fasting_blood_sugar,postprandial_blood_sugar,other_blood_sugar,systolic_pressure,diastolic_pressure')
->select()
->toArray();
$byDate = [];
foreach ($rows as $r) {
$key = date('Y-m-d', (int) $r['record_date']);
if (!isset($byDate[$key])) {
$byDate[$key] = $r;
} else {
foreach (['fasting_blood_sugar', 'postprandial_blood_sugar', 'other_blood_sugar', 'systolic_pressure', 'diastolic_pressure'] as $f) {
if (self::hasValue($r[$f]) && !self::hasValue($byDate[$key][$f])) {
$byDate[$key][$f] = $r[$f];
}
}
}
}
$recordDays = 0;
$completeDays = 0;
foreach ($dayKeys as $key) {
$b = $byDate[$key] ?? null;
if (!$b || !self::dayHasBlood($b)) {
continue;
}
$recordDays++;
if (self::hasValue($b['fasting_blood_sugar']) && self::hasValue($b['postprandial_blood_sugar'])) {
$completeDays++;
}
}
$streakDays = self::calcStreakDays($diagnosisId);
$tree = self::treeMeta($recordDays);
$quote = self::buildQuote($recordDays, $completeDays);
$weekdayLabels = ['日', '一', '二', '三', '四', '五', '六'];
$dailyRecords = [];
foreach ($dayKeys as $key) {
$b = $byDate[$key] ?? null;
$dt = new \DateTime($key);
$w = (int) $dt->format('w');
$has = $b && self::dayHasBlood($b);
$fasting = $has ? self::formatSugarValue($b['fasting_blood_sugar'] ?? null) : null;
$post = $has ? self::formatSugarValue($b['postprandial_blood_sugar'] ?? null) : null;
$other = $has ? self::formatSugarValue($b['other_blood_sugar'] ?? null) : null;
$sys = $has ? self::formatSugarValue($b['systolic_pressure'] ?? null) : null;
$dia = $has ? self::formatSugarValue($b['diastolic_pressure'] ?? null) : null;
$dailyRecords[] = [
'date' => $key,
'date_label' => $dt->format('n') . '/' . $dt->format('j'),
'weekday' => '周' . $weekdayLabels[$w],
'has_record' => $has,
'fasting' => $fasting,
'fasting_high' => self::isHighFasting($fasting, $age),
'postprandial' => $post,
'postprandial_high' => self::isHighPostprandial($post, $age),
'other' => $other,
'other_high' => self::isHighPostprandial($other, $age),
'systolic' => $sys,
'diastolic' => $dia,
'bp_high' => self::isHighBp($sys, $dia),
'bp_text' => self::formatBpText($sys, $dia),
];
}
return [
'week_label' => $today->format('Y') . '年 第' . self::weekOfYear($today) . '周',
'record_days' => $recordDays,
'complete_days' => $completeDays,
'streak_days' => $streakDays,
'quote' => $quote,
'tree_emoji' => $tree['emoji'],
'tree_title' => $tree['title'],
'tree_desc' => $tree['desc'],
'tree_level' => $tree['level'],
'daily_records' => $dailyRecords,
];
}
protected static function formatSugarValue($v): ?float
{
if (!self::hasValue($v)) {
return null;
}
return round((float) $v, 1);
}
protected static function getBloodSugarThresholds(int $age): ?array
{
if ($age <= 0) {
return null;
}
if ($age < 50) {
return ['fasting' => 7.0, 'postprandial' => 9.0];
}
return ['fasting' => 8.0, 'postprandial' => 10.0];
}
protected static function isHighFasting(?float $v, int $age): bool
{
$t = self::getBloodSugarThresholds($age);
return $v !== null && $t !== null && $v >= $t['fasting'];
}
protected static function isHighPostprandial(?float $v, int $age): bool
{
$t = self::getBloodSugarThresholds($age);
return $v !== null && $t !== null && $v >= $t['postprandial'];
}
protected static function isHighBp(?float $systolic, ?float $diastolic): bool
{
return ($systolic !== null && $systolic > 140)
|| ($diastolic !== null && $diastolic > 90);
}
protected static function formatBpText(?float $systolic, ?float $diastolic): string
{
if ($systolic === null && $diastolic === null) {
return '';
}
$s = $systolic !== null ? (string) (int) round($systolic) : '—';
$d = $diastolic !== null ? (string) (int) round($diastolic) : '—';
return $s . '/' . $d;
}
protected static function hasValue($v): bool
{
if ($v === null || $v === '' || $v === '0' || $v === 0) {
return false;
}
return is_numeric($v) && (float) $v > 0;
}
protected static function dayHasBlood(array $b): bool
{
return self::hasValue($b['fasting_blood_sugar'] ?? null)
|| self::hasValue($b['postprandial_blood_sugar'] ?? null)
|| self::hasValue($b['other_blood_sugar'] ?? null)
|| self::hasValue($b['systolic_pressure'] ?? null)
|| self::hasValue($b['diastolic_pressure'] ?? null);
}
protected static function calcStreakDays(int $diagnosisId): int
{
$today = new \DateTime('today');
$count = 0;
for ($i = 0; $i < 90; $i++) {
$d = clone $today;
$d->modify("-{$i} days");
$key = $d->format('Y-m-d');
$start = strtotime($key . ' 00:00:00');
$end = strtotime($key . ' 23:59:59');
$exists = BloodRecord::where('diagnosis_id', $diagnosisId)
->where('record_date', '>=', $start)
->where('record_date', '<=', $end)
->whereRaw('(fasting_blood_sugar > 0 OR postprandial_blood_sugar > 0 OR other_blood_sugar > 0 OR systolic_pressure > 0 OR diastolic_pressure > 0)')
->find();
if ($exists) {
$count++;
continue;
}
if ($i === 0) {
continue;
}
break;
}
return $count;
}
protected static function treeMeta(int $recordDays): array
{
if ($recordDays >= 7) {
return ['level' => 4, 'emoji' => '🌸', 'title' => '控糖树 · 开花啦', 'desc' => '本周每天都记录了'];
}
if ($recordDays >= 5) {
return ['level' => 3, 'emoji' => '🌳', 'title' => '控糖树 · 枝繁叶茂', 'desc' => "本周 {$recordDays}/7 天有记录"];
}
if ($recordDays >= 3) {
return ['level' => 2, 'emoji' => '🌿', 'title' => '控糖树 · 茁壮成长', 'desc' => "本周 {$recordDays}/7 天有记录"];
}
if ($recordDays >= 1) {
return ['level' => 1, 'emoji' => '🌱', 'title' => '控糖树 · 破土发芽', 'desc' => '本周已开始记录'];
}
return ['level' => 0, 'emoji' => '🪴', 'title' => '控糖树 · 等待浇水', 'desc' => '本周暂无记录'];
}
protected static function buildQuote(int $recordDays, int $completeDays): string
{
if ($recordDays >= 7) {
return '本周每天都留下了记录,这份自律值得骄傲!';
}
if ($completeDays >= 5) {
return "本周有 {$completeDays} 天完成了空腹+餐后记录,习惯越来越稳。";
}
if ($recordDays >= 4) {
return "本周已记录 {$recordDays} 天,坚持就是胜利。";
}
if ($recordDays > 0) {
return '好的开始!继续记录会更稳。';
}
return '分享者本周尚未记录,鼓励 Ta 每天记一笔。';
}
protected static function weekOfYear(\DateTime $date): int
{
return (int) $date->format('W');
}
}
@@ -0,0 +1,15 @@
<?php
namespace app\common\model\tcm;
use app\common\model\BaseModel;
/**
* 日常记录家人点赞
*/
class DailyFamilyLike extends BaseModel
{
protected $name = 'tcm_daily_family_like';
protected $autoWriteTimestamp = false;
}
@@ -0,0 +1,15 @@
<?php
namespace app\common\model\tcm;
use app\common\model\BaseModel;
/**
* 日常记录游戏化(稳糖分 / 勋章 / 任务领奖)
*/
class DailyGamify extends BaseModel
{
protected $name = 'tcm_daily_gamify';
protected $autoWriteTimestamp = false;
}
@@ -0,0 +1,15 @@
<?php
namespace app\common\model\tcm;
use app\common\model\BaseModel;
/**
* 日常记录分享邀请码
*/
class DailyShareInvite extends BaseModel
{
protected $name = 'tcm_daily_share_invite';
protected $autoWriteTimestamp = false;
}
@@ -0,0 +1,14 @@
-- 家人点赞(邀请码观看页点赞,按诊单+日期汇总,患者端可见)
CREATE TABLE IF NOT EXISTS `zyt_tcm_daily_family_like` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`diagnosis_id` int unsigned NOT NULL COMMENT '诊单ID',
`like_date` date NOT NULL COMMENT '点赞日期(仅当日有效)',
`invite_code` varchar(16) NOT NULL DEFAULT '' COMMENT '来源邀请码',
`viewer_key` varchar(64) NOT NULL COMMENT '访客设备标识,防重复点赞',
`nickname` varchar(16) NOT NULL DEFAULT '家人' COMMENT '显示称呼',
`create_time` int unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_day_viewer` (`diagnosis_id`, `like_date`, `viewer_key`),
KEY `idx_diagnosis_date` (`diagnosis_id`, `like_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='日常记录家人点赞';
@@ -0,0 +1,15 @@
-- 日常记录游戏化:稳糖分、勋章、每日任务领奖记录(按诊单+用户)
CREATE TABLE IF NOT EXISTS `zyt_tcm_daily_gamify` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`diagnosis_id` int unsigned NOT NULL COMMENT '诊单ID',
`user_id` int unsigned NOT NULL COMMENT '小程序用户ID',
`points` int unsigned NOT NULL DEFAULT 0 COMMENT '稳糖分累计',
`badges` text COMMENT '已解锁勋章ID列表 JSON',
`task_awards` mediumtext COMMENT '每日任务领奖记录 JSON,键为日期',
`create_time` int unsigned NOT NULL DEFAULT 0,
`update_time` int unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_diagnosis_user` (`diagnosis_id`, `user_id`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='日常记录稳糖分与勋章';
@@ -0,0 +1,13 @@
-- 日常记录分享邀请码(仅当天有效,用于家属查看脱敏战报)
CREATE TABLE IF NOT EXISTS `zyt_tcm_daily_share_invite` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`invite_code` varchar(16) NOT NULL COMMENT '邀请码',
`diagnosis_id` int unsigned NOT NULL COMMENT '诊单ID',
`user_id` int unsigned NOT NULL DEFAULT 0 COMMENT '分享人小程序用户ID',
`invite_date` date NOT NULL COMMENT '有效日期,仅当天可查看',
`create_time` int unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_invite_code` (`invite_code`),
KEY `idx_diagnosis_date` (`diagnosis_id`, `invite_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='日常记录分享邀请码';