diff --git a/TUICallKit-Vue3/pages.json b/TUICallKit-Vue3/pages.json
index 11f9cdae..4a6cf44e 100644
--- a/TUICallKit-Vue3/pages.json
+++ b/TUICallKit-Vue3/pages.json
@@ -137,7 +137,15 @@
"navigationBarBackgroundColor": "#0ea5a4",
"navigationBarTextStyle": "white",
"backgroundColor": "#f1f5f9",
- "enablePullDownRefresh": true
+ "enablePullDownRefresh": true,
+ "mp-weixin": {
+ "usingPlugins": {
+ "WechatSI": {
+ "version": "0.3.5",
+ "provider": "wx069ba97219f66d99"
+ }
+ }
+ }
}
}
]
diff --git a/TUICallKit-Vue3/tongji/components/CelebrateBurst.vue b/TUICallKit-Vue3/tongji/components/CelebrateBurst.vue
new file mode 100644
index 00000000..c7aa159e
--- /dev/null
+++ b/TUICallKit-Vue3/tongji/components/CelebrateBurst.vue
@@ -0,0 +1,142 @@
+
+
+
+
+
+
+ {{ title }}
+ {{ subtitle }}
+
+
+
+
+
+
+
diff --git a/TUICallKit-Vue3/tongji/components/SugarTreeGraphic.vue b/TUICallKit-Vue3/tongji/components/SugarTreeGraphic.vue
new file mode 100644
index 00000000..0f17faac
--- /dev/null
+++ b/TUICallKit-Vue3/tongji/components/SugarTreeGraphic.vue
@@ -0,0 +1,202 @@
+
+
+
+
+ {{ treeEmoji }}
+
+
+
+
+
+
+
+
diff --git a/TUICallKit-Vue3/tongji/components/TongjiIcon.vue b/TUICallKit-Vue3/tongji/components/TongjiIcon.vue
new file mode 100644
index 00000000..be6c7899
--- /dev/null
+++ b/TUICallKit-Vue3/tongji/components/TongjiIcon.vue
@@ -0,0 +1,140 @@
+
+
+
+ {{ fallbackGlyph }}
+
+
+
+
+
+
diff --git a/TUICallKit-Vue3/tongji/pages/index.vue b/TUICallKit-Vue3/tongji/pages/index.vue
index a4627447..5841fee4 100644
--- a/TUICallKit-Vue3/tongji/pages/index.vue
+++ b/TUICallKit-Vue3/tongji/pages/index.vue
@@ -1,5 +1,218 @@
-
+
+
+
+
+
+
+
+
+
+ 家人分享
+
+ {{ invitePreview.viewer_notice || '近7日稳糖记录' }}
+ {{ invitePreview.expires_hint || '邀请码仅今日有效' }}
+
+
+ {{ invitePreview.patient_label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 今日邀请码
+ 凭此码今日可查看
+
+
+ {{ invitePreview.invite_code }}
+
+
+
+
+
+
+
+ 点树听一句鼓励
+
+
+ {{ invitePreview.tree_title || '控糖树' }}
+ {{ invitePreview.tree_desc || '' }}
+ {{ invitePreview.week_label || '' }}
+
+
+
+
+
+
+
+ {{ invitePreview.record_days }}
+ 天有记录
+
+
+
+
+
+ {{ invitePreview.complete_days }}
+ 完整一日
+
+
+
+
+
+ {{ invitePreview.streak_days }}
+ 连续天数
+
+
+
+ {{ invitePreview.quote }}
+
+
+
+
+
+
+
+
+
+
+ 近7日血糖记录
+ 空腹 / 餐后 / 血压
+
+
+
+
+ 暂无记录数据
+
+
+
+
+
+
+
+
+
+ {{ day.date_label }}
+ {{ day.weekday }}
+
+
+
+ 空腹
+
+
+ {{ formatInviteSugar(day.fasting) }}
+
+ mmol/L
+
+
+
+ 偏高
+
+
+
+ 餐后
+
+
+ {{ formatInviteSugar(day.postprandial) }}
+
+ mmol/L
+
+
+
+ 偏高
+
+
+
+ 血压
+
+ {{ day.bp_text }}
+ mmHg
+
+
+
+ 偏高
+
+
+
+
+
+ 当日未记录
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 给 Ta 点个赞
+ 今日 {{ invitePreview.like_count || 0 }} 位家人已鼓励
+
+
+
+
+ {{ n }}
+
+
+
+
+ {{ invitePreview.liked_by_me ? '今日已点赞,谢谢您的鼓励' : '送 Ta 一份鼓励' }}
+
+
+
+
+
+
+
+
+
+
+ 无法查看
+ {{ inviteError || '邀请码无效或已过期' }}
+
+
+
+
@@ -29,7 +242,7 @@
-
+
+
录入今日
@@ -168,7 +381,7 @@
-
+
{{ streakDays.days === 0 ? '✨' : '🔥' }}
@@ -198,6 +411,192 @@
+
+
+
+
+ 稳糖乐园
+ 用健康打卡浇灌你的稳糖树
+
+
+ {{ gamifyPoints }}
+ 稳糖积分
+
+
+
+
+
+
+ 今日任务大满贯
+ 小树今天喝饱啦,明天继续加油
+
+
+
+
+
+
+
+ 今日记录
+ 记全三项,再给小树浇水领积分
+
+
+
+
+
+
+ 血糖血压
+ {{ todayChecked ? '已记' : '去记录' }}
+
+
+
+
+
+ 饮食
+ {{ todayHasDiet ? '已记' : '去记录' }}
+
+
+
+
+
+ 运动
+ {{ todayHasExercise ? '已记' : '去记录' }}
+
+
+
+
+
+
+ 今日控糖任务
+
+
+
+
+ ✓
+ ○
+
+ {{ task.name }}
+
+
+ +{{ task.points }}积分
+
+ {{ task.claimed ? '已领' : (task.completed ? '去浇水' : '去记录') }}
+
+
+
+
+
+
+
+
+
+
+
+ +{{ floatReward.text }}
+
+ 点我聊聊天
+
+
+
+ Lv.{{ treeLevel }} {{ treeLevelName }}
+ {{ treeProgress }}/100 XP
+ 已满级
+
+
+
+
+
+
+ {{ gamifyStatusTip }}
+ 先完成上方记录,再浇水领积分
+
+
+
+
+
+ 我的成就
+
+
+
+
+
+
+ {{ b.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+ 分享本周战报
+ 邀请家人查看近7日血糖记录
+
+
+
+
+
+
+
+
+ ❤️
+
+ {{ familyLikeSummary.like_count > 0 ? '家人为您点赞' : '邀请家人点赞' }}
+ {{ familyLikeSummary.summary_line }}
+
+ {{ item.nickname }} · {{ item.time_label }}
+
+
+ ›
+
+
+
+
+ 💬
+
+ {{ encourageLine }}
+ 轻触换一句鼓励 ›
+
+
+
@@ -302,31 +701,31 @@
打开任何弹层时必须 v-if 卸载它,避免穿透到弹层上方。
-->
-
+
图表已暂停显示
-
+
📈
当前时间范围暂无血糖数据
-
- {{ hoverInfo.date }}
-
-
- 空腹 {{ hoverInfo.fasting }}
-
-
-
- 餐后 {{ hoverInfo.postprandial }}
-
-
+
+ {{ hoverInfo.date }}
+
+
+ 空腹 {{ hoverInfo.fasting }}
+
+
+
+ 餐后 {{ hoverInfo.postprandial }}
+
+
@@ -407,7 +806,7 @@
+
+
+
+
+
+
+
+ 记今日饮食
+ {{ todayText }}
+
+ ×
+
+ 加载中…
+
+ 至少填写一餐,完成后可浇水领取积分。
+
+ 早餐
+
+
+
+
+
+ 午餐
+
+
+
+
+
+ 晚餐
+
+
+
+
+
+
+
+
+
+
+ {{ dietForm.submitting ? '提交中…' : '保存饮食' }}
+
+
+
+
+
+
+
+
+
+
+
+ 记今日运动
+ {{ todayText }}
+
+ ×
+
+ 加载中…
+
+ 填写运动类型或时长,完成后可浇水领取积分。
+
+ 运动类型
+
+
+
+
+
+ 时长(分钟)
+
+
+
+
+ 运动强度
+
+
+ {{ opt.label }}
+
+
+
+
+
+ {{ exerciseForm.submitting ? '提交中…' : '保存运动' }}
+
+
+
+
+
+
+
+
+
+ 本周稳糖战报
+ ×
+
+
+
+
+ {{ weeklyReportData.patientLabel }}
+
+ {{ weeklyReportData.weekLabel }}
+
+
+ {{ weeklyReportData.recordDays }}
+ 天有记录
+
+
+ {{ weeklyReportData.completeDays }}
+ 完整一日
+
+
+ {{ weeklyReportData.streakDays }}
+ 连续天数
+
+
+ {{ weeklyReportData.quote }}
+ 习惯参考,不替代医嘱 · 家人打开分享可查看近7日记录
+
+
+
+
+
+
+ 分享给家人
+
+ 关闭
+
+
+
+
diff --git a/TUICallKit-Vue3/tongji/utils/svgDataUrl.js b/TUICallKit-Vue3/tongji/utils/svgDataUrl.js
new file mode 100644
index 00000000..045b6282
--- /dev/null
+++ b/TUICallKit-Vue3/tongji/utils/svgDataUrl.js
@@ -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}`
+}
diff --git a/server/app/api/controller/TcmController.php b/server/app/api/controller/TcmController.php
index bed3e404..28bafdbc 100755
--- a/server/app/api/controller/TcmController.php
+++ b/server/app/api/controller/TcmController.php
@@ -16,9 +16,14 @@ namespace app\api\controller;
use app\adminapi\logic\tcm\DiagnosisLogic;
use app\adminapi\logic\tcm\TrackingNoteLogic;
+use app\api\logic\tcm\DailyGamifyLogic;
+use app\api\logic\tcm\DailyFamilyLikeLogic;
+use app\api\logic\tcm\DailyShareLogic;
use app\adminapi\logic\ConfigLogic;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\BloodRecord;
+use app\common\model\tcm\DietRecord;
+use app\common\model\tcm\ExerciseRecord;
/**
* 中医诊断控制器(供小程序调用)
@@ -31,7 +36,7 @@ class TcmController extends BaseApiController
* @notes 不需要登录的方法
* @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 获取患者签名(供小程序调用)
@@ -350,16 +355,13 @@ class TcmController extends BaseApiController
$startDate = (string) $this->request->get('start_date', '');
$endDate = (string) $this->request->get('end_date', '');
- if ($diagnosisId <= 0) {
- return $this->fail('诊单ID不能为空');
+ $check = $this->ensurePatientOwnsDiagnosis($diagnosisId);
+ if (!$check['ok']) {
+ return $this->fail($check['error']);
}
+ $diagnosis = $check['diagnosis'];
try {
- // 患者端越权校验:诊单必须存在;如传入 patient_id 则须与诊单匹配
- $diagnosis = Diagnosis::where('id', $diagnosisId)->find();
- if (!$diagnosis) {
- return $this->fail('诊单不存在');
- }
if ($patientId > 0 && (int) $diagnosis['patient_id'] !== $patientId) {
return $this->fail('无权查看该诊单的日常记录');
}
@@ -393,15 +395,13 @@ class TcmController extends BaseApiController
$diagnosisId = (int) $this->request->get('diagnosis_id', 0);
$patientId = (int) $this->request->get('patient_id', 0);
- if ($diagnosisId <= 0) {
- return $this->fail('诊单ID不能为空');
+ $check = $this->ensurePatientOwnsDiagnosis($diagnosisId);
+ if (!$check['ok']) {
+ return $this->fail($check['error']);
}
+ $diagnosis = $check['diagnosis'];
try {
- $diagnosis = Diagnosis::where('id', $diagnosisId)->find();
- if (!$diagnosis) {
- return $this->fail('诊单不存在');
- }
if ($patientId > 0 && (int) $diagnosis['patient_id'] !== $patientId) {
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 关联)
* @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 根据订单号获取订单详情(供小程序支付页调用)
* @return \think\response\Json
diff --git a/server/app/api/logic/tcm/DailyFamilyLikeLogic.php b/server/app/api/logic/tcm/DailyFamilyLikeLogic.php
new file mode 100644
index 00000000..9a7ea1ad
--- /dev/null
+++ b/server/app/api/logic/tcm/DailyFamilyLikeLogic.php
@@ -0,0 +1,250 @@
+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);
+ }
+}
diff --git a/server/app/api/logic/tcm/DailyGamifyLogic.php b/server/app/api/logic/tcm/DailyGamifyLogic.php
new file mode 100644
index 00000000..a24877c0
--- /dev/null
+++ b/server/app/api/logic/tcm/DailyGamifyLogic.php
@@ -0,0 +1,373 @@
+ */
+ 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> $taskAwards
+ * @return array
+ */
+ 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 $badges
+ * @param array> $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 : [];
+ }
+}
diff --git a/server/app/api/logic/tcm/DailyShareLogic.php b/server/app/api/logic/tcm/DailyShareLogic.php
new file mode 100644
index 00000000..a056f6ee
--- /dev/null
+++ b/server/app/api/logic/tcm/DailyShareLogic.php
@@ -0,0 +1,373 @@
+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');
+ }
+}
diff --git a/server/app/common/model/tcm/DailyFamilyLike.php b/server/app/common/model/tcm/DailyFamilyLike.php
new file mode 100644
index 00000000..4d96aa14
--- /dev/null
+++ b/server/app/common/model/tcm/DailyFamilyLike.php
@@ -0,0 +1,15 @@
+