Compare commits

..
Author SHA1 Message Date
Your Name f48a66b611 更新 2026-08-12 17:18:56 +08:00
Your Name 28cd110dae 更新 2026-08-12 15:22:00 +08:00
Your Name 6d9fe1bbf8 更新 2026-08-12 14:48:57 +08:00
Your Name bc9ad1d3cd 更新 2026-08-12 11:03:28 +08:00
Your Name 09d3fcbf82 更新 2026-08-11 17:45:25 +08:00
Your Name 5711ad946f Merge branch 'app-8-11' 2026-08-11 17:43:31 +08:00
Your Name d005a0bf3c 更新 2026-08-11 17:42:53 +08:00
Your Name 03fe4ddf9d 更新 2026-08-11 17:41:36 +08:00
Your Name 25467b9d91 新增 2026-08-11 17:39:41 +08:00
Your Name cfe4c82c90 更新 2026-08-11 09:12:51 +08:00
Your Name c3ceb0dd0f 更新 2026-08-10 17:46:04 +08:00
Your Name 9add23e019 更新 2026-08-10 17:29:05 +08:00
Your Name 2199887c07 更新
:i# Please enter the commit message for your changes. Lines starting
2026-08-08 18:08:23 +08:00
Your Name 56dbd7f115 新增 2026-08-08 15:46:32 +08:00
Your Name d10f213573 更新 2026-08-08 15:42:45 +08:00
Your Name a968945057 更新 2026-08-07 15:25:09 +08:00
Your Name 971a627288 更新 2026-08-07 15:13:36 +08:00
Your Name c37d5abac4 更新 2026-08-07 15:08:55 +08:00
Your Name ebc463864b 更新 2026-08-07 15:07:17 +08:00
Your Name ab140ab05e Merge branch 'yz-0803' 2026-08-07 15:02:36 +08:00
Your Name 894f52e875 更新 2026-08-07 15:02:20 +08:00
Your Name a133d5d85d 更新 2026-08-07 14:57:33 +08:00
Your Name 2c238d6599 Merge branch 'yz-0803' 2026-08-07 14:54:48 +08:00
Your Name 6ce58bcd85 更新 2026-08-07 14:54:05 +08:00
Your Name 1ca87f8d72 新增 2026-08-07 14:38:25 +08:00
Your Name 5bf6ea01f6 Merge branch 'yz-0803' 2026-08-07 14:33:50 +08:00
Your Name 58a7197d3e 新增功能 2026-08-07 14:32:35 +08:00
Your Name 90be4bf60b 更新 2026-08-07 09:24:50 +08:00
Your Name 1c7b22a72f Merge branch 'yz-0803' 2026-08-07 09:17:18 +08:00
Your Name 16d301f302 更新 2026-08-07 09:16:15 +08:00
Your Name 00ce6d6875 更新 2026-08-06 16:36:37 +08:00
Your Name bdd8e69ec8 Merge branch 'yz-0803' 2026-08-06 16:30:05 +08:00
Your Name 8bbd6f7885 更新 2026-08-06 16:29:08 +08:00
Your Name 245c52075d 更新 2026-08-06 14:52:40 +08:00
Your Name c836084fd3 Merge branch 'yz-0803'
GENG
2026-08-06 14:49:29 +08:00
Your Name a010483bdc 更新 2026-08-06 14:48:14 +08:00
Your Name e20374e5d8 更新 2026-08-06 14:13:54 +08:00
Your Name 037e5ba450 更新 2026-08-06 14:11:52 +08:00
Your Name d5b0ab4709 更新 2026-08-06 12:03:58 +08:00
Your Name 079e50006d 更新 2026-08-06 10:57:35 +08:00
Your Name 2c0b9c5afa 更新 2026-08-05 14:37:29 +08:00
Your Name dd28bba354 更新 2026-08-05 14:14:55 +08:00
Your Name a797743aa4 更新 2026-08-05 14:06:07 +08:00
Your Name abb4aced1c 更新 2026-08-05 11:55:10 +08:00
Your Name 4ee8a8e98b 更新 2026-08-05 11:54:24 +08:00
Your Name 7ddb4882e3 修复bug 2026-08-05 11:15:14 +08:00
Your Name c2b7018a22 更新 2026-08-05 11:08:02 +08:00
Your Name 57a7c415a1 Merge branch 'bug-08/03' 2026-08-03 11:27:55 +08:00
Your Name 582c7cf0c6 更新 2026-08-03 11:27:15 +08:00
Your Name 2425fb60d0 更新 2026-08-03 09:56:58 +08:00
Your Name 22a371a733 更新 2026-08-03 09:51:53 +08:00
long 81d6a38e26 build 2026-07-28 16:29:32 +08:00
long 64d760af1a debug 2026-07-28 16:28:36 +08:00
long 9ba53b6145 debug 2026-07-28 15:15:14 +08:00
long 30cf33c546 build 2026-07-28 11:04:45 +08:00
long e1fe818dce feat: integrate Luoyang pharmacy workflow 2026-07-22 18:00:17 +08:00
long 31312ae975 feat: add zyt medicine bootstrap 2026-07-22 14:50:34 +08:00
20183 changed files with 3887878 additions and 110114 deletions
-2
View File
@@ -22,5 +22,3 @@ unpackage/dist
*.sw?
TUICallKit
# 本工程的通话源码复用仓库内 ../wx/TUICallKit;保留相对软链接以便干净克隆后可直接构建。
!TUICallKit
-1
View File
@@ -1 +0,0 @@
../wx/TUICallKit
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -63,7 +63,7 @@
"requiredBackgroundModes" : [ "audio" ],
"plugins" : {
"WechatSI" : {
"version" : "0.3.9",
"version" : "0.3.5",
"provider" : "wx069ba97219f66d99"
}
}
+243 -243
View File
@@ -1,243 +1,243 @@
{
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "甄养堂",
"backgroundColor": "#F8F8F8",
"backgroundColorTop": "#F8F8F8"
}
},
{
"path": "pages/login/login",
"style": {
"navigationBarTitleText": "视频问诊"
}
},
{
"path": "pages/user/userinfo",
"style": {
"navigationBarTitleText": "个人资料"
}
},
{
"path": "pages/index/video",
"style": {
"navigationBarTitleText": "甄养堂"
}
},
{
"path": "pages/order/monad/monad",
"style": {
"navigationBarTitleText": "诊单确认",
"navigationBarHidden": true,
"navigationStyle": "custom"
}
},
{
"path": "pages/user/user",
"style": {
"navigationBarTitleText": "我的",
"backgroundColor": "#e8eaed",
"backgroundColorTop": "#e8eaed"
}
},
{
"path": "pages/Card/Card",
"style": {
"navigationBarTitleText": "就诊卡"
}
},
{
"path": "pages/Card/edit_card",
"style": {
"navigationBarTitleText": "修改就诊卡"
}
},
{
"path": "pages/Card/contact",
"style": {
"navigationBarTitleText": "服务"
}
},
{
"path": "pages/order/order",
"style": {
"navigationBarTitleText": "付款"
}
}
],
"subPackages": [
{
"root": "TUICallKit/src/Components",
"pages": [
{
"path": "TUICallKit",
"style": {
"navigationBarTitleText": "视频问诊",
"navigationBarHidden": true,
"navigationStyle": "custom"
}
}
]
},
{
"root": "TUIKit",
"pages": [
{
"path": "pages/chat/chat",
"style": {
"navigationBarTitleText": "问诊"
}
}
]
},
{
"root": "doctor",
"pages": [
{
"path": "pages/doctor/doctor",
"style": {
"navigationBarTitleText": "医生信息"
}
},
{
"path": "pages/doctor/list/list",
"style": {
"navigationBarTitleText": "执业医生列表"
}
},
{
"path": "pages/article/article",
"style": {
"navigationBarTitleText": "健康百科"
}
}
]
},
{
"root": "training",
"pages": [
{
"path": "pages/grip-ring",
"style": {
"navigationBarTitleText": "握力环训练",
"navigationBarBackgroundColor": "#f8fafc",
"navigationBarTextStyle": "black",
"backgroundColor": "#f8fafc"
}
},
{
"path": "pages/pilates-ring",
"style": {
"navigationBarTitleText": "瑜伽环训练",
"navigationBarBackgroundColor": "#f8fafc",
"navigationBarTextStyle": "black",
"backgroundColor": "#f8fafc"
}
},
{
"path": "pages/foot-pedal",
"style": {
"navigationBarTitleText": "脚蹬器训练",
"navigationBarBackgroundColor": "#f8fafc",
"navigationBarTextStyle": "black",
"backgroundColor": "#f8fafc"
}
},
{
"path": "pages/dumbbell",
"style": {
"navigationBarTitleText": "哑铃训练",
"navigationBarBackgroundColor": "#f8fafc",
"navigationBarTextStyle": "black",
"backgroundColor": "#f8fafc"
}
},
{
"path": "pages/metronome",
"style": {
"navigationBarTitleText": "耗糖节拍器",
"navigationBarBackgroundColor": "#f8fafc",
"navigationBarTextStyle": "black",
"backgroundColor": "#f8fafc"
}
}
]
},
{
"root": "tongji",
"pages": [
{
"path": "pages/index",
"style": {
"navigationBarTitleText": "血糖记录",
"navigationBarBackgroundColor": "#204e2b",
"navigationBarTextStyle": "white",
"backgroundColor": "#faf9f5",
"enablePullDownRefresh": true,
"mp-weixin": {
"usingPlugins": {
"WechatSI": {
"version": "0.3.9",
"provider": "wx069ba97219f66d99"
}
}
}
}
},
{
"path": "pages/weekly",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "血糖管理",
"backgroundColor": "#f4fbf4",
"enablePullDownRefresh": true,
"mp-weixin": {
"usingPlugins": {
"WechatSI": {
"version": "0.3.9",
"provider": "wx069ba97219f66d99"
}
}
}
}
},
{
"path": "pages/more",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "日常护理",
"backgroundColor": "#f4fbf4",
"enablePullDownRefresh": true,
"mp-weixin": {
"usingPlugins": {
"WechatSI": {
"version": "0.3.9",
"provider": "wx069ba97219f66d99"
}
}
}
}
},
{
"path": "endless-game/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "识糖小课堂",
"backgroundColor": "#eefbf4",
"disableScroll": false,
"enableShareAppMessage": true,
"enableShareTimeline": true
}
}
]
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "甄养堂互联网医院",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F8F8F8"
}
}
{
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "甄养堂",
"backgroundColor": "#F8F8F8",
"backgroundColorTop": "#F8F8F8"
}
},
{
"path": "pages/login/login",
"style": {
"navigationBarTitleText": "视频问诊"
}
},
{
"path": "pages/user/userinfo",
"style": {
"navigationBarTitleText": "个人资料"
}
},
{
"path": "pages/index/video",
"style": {
"navigationBarTitleText": "甄养堂"
}
},
{
"path": "pages/order/monad/monad",
"style": {
"navigationBarTitleText": "诊单确认",
"navigationBarHidden": true,
"navigationStyle": "custom"
}
},
{
"path": "pages/user/user",
"style": {
"navigationBarTitleText": "我的",
"backgroundColor": "#e8eaed",
"backgroundColorTop": "#e8eaed"
}
},
{
"path": "pages/Card/Card",
"style": {
"navigationBarTitleText": "就诊卡"
}
},
{
"path": "pages/Card/edit_card",
"style": {
"navigationBarTitleText": "修改就诊卡"
}
},
{
"path": "pages/Card/contact",
"style": {
"navigationBarTitleText": "服务"
}
},
{
"path": "pages/order/order",
"style": {
"navigationBarTitleText": "付款"
}
}
],
"subPackages": [
{
"root": "TUICallKit/src/Components",
"pages": [
{
"path": "TUICallKit",
"style": {
"navigationBarTitleText": "视频问诊",
"navigationBarHidden": true,
"navigationStyle": "custom"
}
}
]
},
{
"root": "TUIKit",
"pages": [
{
"path": "pages/chat/chat",
"style": {
"navigationBarTitleText": "问诊"
}
}
]
},
{
"root": "doctor",
"pages": [
{
"path": "pages/doctor/doctor",
"style": {
"navigationBarTitleText": "医生信息"
}
},
{
"path": "pages/doctor/list/list",
"style": {
"navigationBarTitleText": "执业医生列表"
}
},
{
"path": "pages/article/article",
"style": {
"navigationBarTitleText": "健康百科"
}
}
]
},
{
"root": "training",
"pages": [
{
"path": "pages/grip-ring",
"style": {
"navigationBarTitleText": "握力环训练",
"navigationBarBackgroundColor": "#f8fafc",
"navigationBarTextStyle": "black",
"backgroundColor": "#f8fafc"
}
},
{
"path": "pages/pilates-ring",
"style": {
"navigationBarTitleText": "瑜伽环训练",
"navigationBarBackgroundColor": "#f8fafc",
"navigationBarTextStyle": "black",
"backgroundColor": "#f8fafc"
}
},
{
"path": "pages/foot-pedal",
"style": {
"navigationBarTitleText": "脚蹬器训练",
"navigationBarBackgroundColor": "#f8fafc",
"navigationBarTextStyle": "black",
"backgroundColor": "#f8fafc"
}
},
{
"path": "pages/dumbbell",
"style": {
"navigationBarTitleText": "哑铃训练",
"navigationBarBackgroundColor": "#f8fafc",
"navigationBarTextStyle": "black",
"backgroundColor": "#f8fafc"
}
},
{
"path": "pages/metronome",
"style": {
"navigationBarTitleText": "耗糖节拍器",
"navigationBarBackgroundColor": "#f8fafc",
"navigationBarTextStyle": "black",
"backgroundColor": "#f8fafc"
}
}
]
},
{
"root": "tongji",
"pages": [
{
"path": "pages/index",
"style": {
"navigationBarTitleText": "血糖记录",
"navigationBarBackgroundColor": "#204e2b",
"navigationBarTextStyle": "white",
"backgroundColor": "#faf9f5",
"enablePullDownRefresh": true,
"mp-weixin": {
"usingPlugins": {
"WechatSI": {
"version": "0.3.5",
"provider": "wx069ba97219f66d99"
}
}
}
}
},
{
"path": "pages/weekly",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "血糖管理",
"backgroundColor": "#f4fbf4",
"enablePullDownRefresh": true,
"mp-weixin": {
"usingPlugins": {
"WechatSI": {
"version": "0.3.5",
"provider": "wx069ba97219f66d99"
}
}
}
}
},
{
"path": "pages/more",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "日常护理",
"backgroundColor": "#f4fbf4",
"enablePullDownRefresh": true,
"mp-weixin": {
"usingPlugins": {
"WechatSI": {
"version": "0.3.5",
"provider": "wx069ba97219f66d99"
}
}
}
}
},
{
"path": "endless-game/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "识糖小课堂",
"backgroundColor": "#eefbf4",
"disableScroll": false,
"enableShareAppMessage": true,
"enableShareTimeline": true
}
}
]
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "甄养堂互联网医院",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F8F8F8"
}
}
+317 -317
View File
@@ -1,317 +1,317 @@
import { ref, computed } from 'vue'
import { requestAiStream, parsePartialDietPlainText } from '../utils/aiStreamRequest.js'
const DIET_AI_PREFILL_KEY = 'tongji_diet_ai_prefill'
const emptyDietAiRecommend = () => ({
breakfast: '',
drinks: '',
lunch: '',
dinner: '',
tips: '',
avoid: [],
analysis: [],
exercise: null,
disclaimer: '',
rules_summary: '',
source: '',
date: ''
})
/** AI 饮食助手(今日推荐 + 能不能吃) */
export function useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage }) {
const dietAiRecommend = ref(emptyDietAiRecommend())
const dietAiLoading = ref(false)
const dietAiStreaming = ref(false)
const dietAiReplacing = ref(false)
const dietAiStreamField = ref('')
let dietStreamBuffer = ''
const dietAiAskText = ref('')
const dietAiAsking = ref(false)
const dietAiAskResult = ref({ advice: '', level: '', level_label: '', portion: '', food: '' })
const hasDietAiMealContent = computed(() => {
const r = dietAiRecommend.value
return !!(r.breakfast || r.drinks || r.lunch || r.dinner || r.tips)
})
function detectDietStreamField(text) {
const tail = String(text || '').match(/(?:^|\n)(早餐|喝的|午餐|晚餐|提示|少碰)[:]\s*([^\n]*)$/)
if (!tail) return 'breakfast'
const map = { '早餐': 'breakfast', '喝的': 'drinks', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'tips' }
return map[tail[1]] || 'breakfast'
}
function mergePartialDietFromStream() {
const partial = parsePartialDietPlainText(dietStreamBuffer)
const next = { ...dietAiRecommend.value }
for (const key of ['breakfast', 'drinks', 'lunch', 'dinner', 'tips']) {
if (partial[key] != null) next[key] = partial[key]
}
if (Array.isArray(partial.avoid)) next.avoid = partial.avoid
dietAiRecommend.value = next
dietAiStreamField.value = detectDietStreamField(dietStreamBuffer)
}
function applyDietRecommendPayload(payload) {
if (!payload) return
const prevAnalysis = dietAiRecommend.value.analysis
const prevExercise = dietAiRecommend.value.exercise
dietAiRecommend.value = {
...emptyDietAiRecommend(),
...payload,
avoid: Array.isArray(payload.avoid) ? payload.avoid : [],
analysis: Array.isArray(payload.analysis) && payload.analysis.length
? payload.analysis
: (Array.isArray(prevAnalysis) ? prevAnalysis : []),
exercise: (payload.exercise && typeof payload.exercise === 'object')
? payload.exercise
: (prevExercise || null)
}
}
function resolveDietDonePayload(payload) {
if (!payload) return payload
if (payload.source !== 'rule' || !dietStreamBuffer) return payload
const streamed = parsePartialDietPlainText(dietStreamBuffer)
if (!streamed.breakfast) return payload
return {
...emptyDietAiRecommend(),
...payload,
breakfast: streamed.breakfast,
drinks: streamed.drinks || payload.drinks || '',
lunch: streamed.lunch || payload.lunch || '',
dinner: streamed.dinner || payload.dinner || '',
tips: streamed.tips || payload.tips || '',
avoid: (Array.isArray(streamed.avoid) && streamed.avoid.length) ? streamed.avoid : (payload.avoid || []),
source: 'ai',
disclaimer: 'AI 建议供参考,仍请结合医嘱与血糖监测。'
}
}
function onDietAiStreamEvent(event, payload) {
if (event === 'analysis') {
if (Array.isArray(payload?.analysis)) {
dietAiRecommend.value = { ...dietAiRecommend.value, analysis: payload.analysis }
}
} else if (event === 'exercise') {
if (payload?.exercise && typeof payload.exercise === 'object') {
dietAiRecommend.value = { ...dietAiRecommend.value, exercise: payload.exercise }
}
} else if (event === 'delta') {
dietAiStreaming.value = true
if (payload.buffer != null) {
dietStreamBuffer = payload.buffer
} else {
dietStreamBuffer += payload.text || ''
}
mergePartialDietFromStream()
} else if (event === 'done') {
applyDietRecommendPayload(resolveDietDonePayload(payload))
dietStreamBuffer = ''
dietAiStreamField.value = ''
} else if (event === 'error') {
showUserToast(formatUserMessage(payload?.message, '饮食建议加载失败'))
}
}
async function fetchDietAiRecommendFallback(refresh) {
const res = await proxy.apiUrl({
url: '/api/tcm/dailyDietAiRecommend',
method: 'GET',
timeout: 35000,
data: {
diagnosis_id: diagnosisId.value,
refresh: refresh ? 1 : 0,
...(refresh ? { _t: Date.now() } : {})
}
}, false)
if (res?.code === 1 && res.data) {
applyDietRecommendPayload(res.data)
} else if (res?.msg) {
showUserToast(res.msg)
}
}
async function fetchDietAiRecommend(refresh = false) {
if (!diagnosisId.value || dietAiLoading.value) return
dietAiLoading.value = true
dietAiStreaming.value = false
dietAiReplacing.value = !!refresh
dietStreamBuffer = ''
dietAiStreamField.value = ''
if (refresh) {
applyDietRecommendPayload(emptyDietAiRecommend())
}
try {
await requestAiStream({
baseUrl: proxy.$url,
url: '/api/tcm/dailyDietAiRecommendStream',
method: 'GET',
data: {
diagnosis_id: diagnosisId.value,
refresh: refresh ? 1 : 0,
...(refresh ? { _t: Date.now() } : {})
},
onEvent: onDietAiStreamEvent,
fallback: () => fetchDietAiRecommendFallback(refresh)
})
} catch (e) {
try {
await fetchDietAiRecommendFallback(refresh)
} catch (e2) {
showUserToast('饮食建议加载失败')
}
} finally {
dietAiLoading.value = false
dietAiStreaming.value = false
dietAiReplacing.value = false
dietAiStreamField.value = ''
}
}
function refreshDietAiRecommend() {
if (dietAiLoading.value) return
fetchDietAiRecommend(true)
}
async function askDietAiFoodFallback(q) {
const res = await proxy.apiUrl({
url: '/api/tcm/dailyDietAiAsk',
method: 'POST',
timeout: 35000,
data: {
diagnosis_id: diagnosisId.value,
question: q
}
}, false)
if (res?.code === 1 && res.data) {
dietAiAskResult.value = {
advice: res.data.advice || '',
level: res.data.level || '',
level_label: res.data.level_label || '',
portion: res.data.portion || '',
food: res.data.food || q
}
} else {
showUserToast(formatUserMessage(res?.msg, '咨询失败'))
}
}
async function askDietAiFood() {
const q = String(dietAiAskText.value || '').trim()
if (!q) {
showUserToast('请输入想咨询的食物')
return
}
if (!diagnosisId.value) return
// 发送后立即清空输入框
dietAiAskText.value = ''
dietAiAsking.value = true
dietAiAskResult.value = { advice: '', level: '', level_label: '', portion: '', food: '' }
try {
await requestAiStream({
baseUrl: proxy.$url,
url: '/api/tcm/dailyDietAiAskStream',
method: 'POST',
data: {
diagnosis_id: diagnosisId.value,
question: q
},
onEvent: (event, payload) => {
if (event === 'start' && !dietAiAskResult.value.advice) {
dietAiAskResult.value = { ...dietAiAskResult.value, advice: payload.message || '正在想…' }
} else if (event === 'delta') {
dietAiAskResult.value = {
...dietAiAskResult.value,
advice: payload.advice || dietAiAskResult.value.advice
}
} else if (event === 'done') {
dietAiAskResult.value = {
advice: payload.advice || '',
level: payload.level || '',
level_label: payload.level_label || '',
portion: payload.portion || '',
food: payload.food || q
}
} else if (event === 'error') {
showUserToast(formatUserMessage(payload?.message, '咨询失败'))
}
},
fallback: () => askDietAiFoodFallback(q)
})
} catch (e) {
try {
await askDietAiFoodFallback(q)
} catch (e2) {
showUserToast('网络异常,请稍后再试')
}
} finally {
dietAiAsking.value = false
}
}
function onDietAiAskInput(e) {
dietAiAskText.value = e?.detail?.value ?? ''
}
function saveDietPrefillToStorage() {
const r = dietAiRecommend.value
if (!r.breakfast) return
try {
uni.setStorageSync(DIET_AI_PREFILL_KEY, {
breakfast: r.breakfast,
drinks: r.drinks || '',
lunch: r.lunch,
dinner: r.dinner,
tips: r.tips || ''
})
} catch (e) {}
}
function goMoreDietForm(withPrefill = false) {
if (withPrefill) saveDietPrefillToStorage()
uni.navigateTo({ url: '/tongji/pages/more?openDiet=1' })
}
async function applyDietAiToForm() {
if (!dietAiRecommend.value.breakfast) {
await fetchDietAiRecommend(false)
}
goMoreDietForm(true)
}
function openDietForm() {
goMoreDietForm(false)
}
return {
dietAiRecommend,
dietAiLoading,
dietAiStreaming,
dietAiReplacing,
dietAiStreamField,
dietAiAskText,
dietAiAsking,
dietAiAskResult,
hasDietAiMealContent,
fetchDietAiRecommend,
refreshDietAiRecommend,
askDietAiFood,
onDietAiAskInput,
applyDietAiToForm,
openDietForm
}
}
/** more 页打开饮食表单时读取并清除预填 */
export function consumeDietAiPrefill() {
try {
const data = uni.getStorageSync(DIET_AI_PREFILL_KEY)
uni.removeStorageSync(DIET_AI_PREFILL_KEY)
if (data && typeof data === 'object' && data.breakfast) return data
} catch (e) {}
return null
}
import { ref, computed } from 'vue'
import { requestAiStream, parsePartialDietPlainText } from '../utils/aiStreamRequest.js'
const DIET_AI_PREFILL_KEY = 'tongji_diet_ai_prefill'
const emptyDietAiRecommend = () => ({
breakfast: '',
drinks: '',
lunch: '',
dinner: '',
tips: '',
avoid: [],
analysis: [],
exercise: null,
disclaimer: '',
rules_summary: '',
source: '',
date: ''
})
/** AI 饮食助手(今日推荐 + 能不能吃) */
export function useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage }) {
const dietAiRecommend = ref(emptyDietAiRecommend())
const dietAiLoading = ref(false)
const dietAiStreaming = ref(false)
const dietAiReplacing = ref(false)
const dietAiStreamField = ref('')
let dietStreamBuffer = ''
const dietAiAskText = ref('')
const dietAiAsking = ref(false)
const dietAiAskResult = ref({ advice: '', level: '', level_label: '', portion: '', food: '' })
const hasDietAiMealContent = computed(() => {
const r = dietAiRecommend.value
return !!(r.breakfast || r.drinks || r.lunch || r.dinner || r.tips)
})
function detectDietStreamField(text) {
const tail = String(text || '').match(/(?:^|\n)(早餐|喝的|午餐|晚餐|提示|少碰)[:]\s*([^\n]*)$/)
if (!tail) return 'breakfast'
const map = { '早餐': 'breakfast', '喝的': 'drinks', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'tips' }
return map[tail[1]] || 'breakfast'
}
function mergePartialDietFromStream() {
const partial = parsePartialDietPlainText(dietStreamBuffer)
const next = { ...dietAiRecommend.value }
for (const key of ['breakfast', 'drinks', 'lunch', 'dinner', 'tips']) {
if (partial[key] != null) next[key] = partial[key]
}
if (Array.isArray(partial.avoid)) next.avoid = partial.avoid
dietAiRecommend.value = next
dietAiStreamField.value = detectDietStreamField(dietStreamBuffer)
}
function applyDietRecommendPayload(payload) {
if (!payload) return
const prevAnalysis = dietAiRecommend.value.analysis
const prevExercise = dietAiRecommend.value.exercise
dietAiRecommend.value = {
...emptyDietAiRecommend(),
...payload,
avoid: Array.isArray(payload.avoid) ? payload.avoid : [],
analysis: Array.isArray(payload.analysis) && payload.analysis.length
? payload.analysis
: (Array.isArray(prevAnalysis) ? prevAnalysis : []),
exercise: (payload.exercise && typeof payload.exercise === 'object')
? payload.exercise
: (prevExercise || null)
}
}
function resolveDietDonePayload(payload) {
if (!payload) return payload
if (payload.source !== 'rule' || !dietStreamBuffer) return payload
const streamed = parsePartialDietPlainText(dietStreamBuffer)
if (!streamed.breakfast) return payload
return {
...emptyDietAiRecommend(),
...payload,
breakfast: streamed.breakfast,
drinks: streamed.drinks || payload.drinks || '',
lunch: streamed.lunch || payload.lunch || '',
dinner: streamed.dinner || payload.dinner || '',
tips: streamed.tips || payload.tips || '',
avoid: (Array.isArray(streamed.avoid) && streamed.avoid.length) ? streamed.avoid : (payload.avoid || []),
source: 'ai',
disclaimer: 'AI 建议供参考,仍请结合医嘱与血糖监测。'
}
}
function onDietAiStreamEvent(event, payload) {
if (event === 'analysis') {
if (Array.isArray(payload?.analysis)) {
dietAiRecommend.value = { ...dietAiRecommend.value, analysis: payload.analysis }
}
} else if (event === 'exercise') {
if (payload?.exercise && typeof payload.exercise === 'object') {
dietAiRecommend.value = { ...dietAiRecommend.value, exercise: payload.exercise }
}
} else if (event === 'delta') {
dietAiStreaming.value = true
if (payload.buffer != null) {
dietStreamBuffer = payload.buffer
} else {
dietStreamBuffer += payload.text || ''
}
mergePartialDietFromStream()
} else if (event === 'done') {
applyDietRecommendPayload(resolveDietDonePayload(payload))
dietStreamBuffer = ''
dietAiStreamField.value = ''
} else if (event === 'error') {
showUserToast(formatUserMessage(payload?.message, '饮食建议加载失败'))
}
}
async function fetchDietAiRecommendFallback(refresh) {
const res = await proxy.apiUrl({
url: '/api/tcm/dailyDietAiRecommend',
method: 'GET',
timeout: 35000,
data: {
diagnosis_id: diagnosisId.value,
refresh: refresh ? 1 : 0,
...(refresh ? { _t: Date.now() } : {})
}
}, false)
if (res?.code === 1 && res.data) {
applyDietRecommendPayload(res.data)
} else if (res?.msg) {
showUserToast(res.msg)
}
}
async function fetchDietAiRecommend(refresh = false) {
if (!diagnosisId.value || dietAiLoading.value) return
dietAiLoading.value = true
dietAiStreaming.value = false
dietAiReplacing.value = !!refresh
dietStreamBuffer = ''
dietAiStreamField.value = ''
if (refresh) {
applyDietRecommendPayload(emptyDietAiRecommend())
}
try {
await requestAiStream({
baseUrl: proxy.$url,
url: '/api/tcm/dailyDietAiRecommendStream',
method: 'GET',
data: {
diagnosis_id: diagnosisId.value,
refresh: refresh ? 1 : 0,
...(refresh ? { _t: Date.now() } : {})
},
onEvent: onDietAiStreamEvent,
fallback: () => fetchDietAiRecommendFallback(refresh)
})
} catch (e) {
try {
await fetchDietAiRecommendFallback(refresh)
} catch (e2) {
showUserToast('饮食建议加载失败')
}
} finally {
dietAiLoading.value = false
dietAiStreaming.value = false
dietAiReplacing.value = false
dietAiStreamField.value = ''
}
}
function refreshDietAiRecommend() {
if (dietAiLoading.value) return
fetchDietAiRecommend(true)
}
async function askDietAiFoodFallback(q) {
const res = await proxy.apiUrl({
url: '/api/tcm/dailyDietAiAsk',
method: 'POST',
timeout: 35000,
data: {
diagnosis_id: diagnosisId.value,
question: q
}
}, false)
if (res?.code === 1 && res.data) {
dietAiAskResult.value = {
advice: res.data.advice || '',
level: res.data.level || '',
level_label: res.data.level_label || '',
portion: res.data.portion || '',
food: res.data.food || q
}
} else {
showUserToast(formatUserMessage(res?.msg, '咨询失败'))
}
}
async function askDietAiFood() {
const q = String(dietAiAskText.value || '').trim()
if (!q) {
showUserToast('请输入想咨询的食物')
return
}
if (!diagnosisId.value) return
// 发送后立即清空输入框
dietAiAskText.value = ''
dietAiAsking.value = true
dietAiAskResult.value = { advice: '', level: '', level_label: '', portion: '', food: '' }
try {
await requestAiStream({
baseUrl: proxy.$url,
url: '/api/tcm/dailyDietAiAskStream',
method: 'POST',
data: {
diagnosis_id: diagnosisId.value,
question: q
},
onEvent: (event, payload) => {
if (event === 'start' && !dietAiAskResult.value.advice) {
dietAiAskResult.value = { ...dietAiAskResult.value, advice: payload.message || '正在想…' }
} else if (event === 'delta') {
dietAiAskResult.value = {
...dietAiAskResult.value,
advice: payload.advice || dietAiAskResult.value.advice
}
} else if (event === 'done') {
dietAiAskResult.value = {
advice: payload.advice || '',
level: payload.level || '',
level_label: payload.level_label || '',
portion: payload.portion || '',
food: payload.food || q
}
} else if (event === 'error') {
showUserToast(formatUserMessage(payload?.message, '咨询失败'))
}
},
fallback: () => askDietAiFoodFallback(q)
})
} catch (e) {
try {
await askDietAiFoodFallback(q)
} catch (e2) {
showUserToast('网络异常,请稍后再试')
}
} finally {
dietAiAsking.value = false
}
}
function onDietAiAskInput(e) {
dietAiAskText.value = e?.detail?.value ?? ''
}
function saveDietPrefillToStorage() {
const r = dietAiRecommend.value
if (!r.breakfast) return
try {
uni.setStorageSync(DIET_AI_PREFILL_KEY, {
breakfast: r.breakfast,
drinks: r.drinks || '',
lunch: r.lunch,
dinner: r.dinner,
tips: r.tips || ''
})
} catch (e) {}
}
function goMoreDietForm(withPrefill = false) {
if (withPrefill) saveDietPrefillToStorage()
uni.navigateTo({ url: '/tongji/pages/more?openDiet=1' })
}
async function applyDietAiToForm() {
if (!dietAiRecommend.value.breakfast) {
await fetchDietAiRecommend(false)
}
goMoreDietForm(true)
}
function openDietForm() {
goMoreDietForm(false)
}
return {
dietAiRecommend,
dietAiLoading,
dietAiStreaming,
dietAiReplacing,
dietAiStreamField,
dietAiAskText,
dietAiAsking,
dietAiAskResult,
hasDietAiMealContent,
fetchDietAiRecommend,
refreshDietAiRecommend,
askDietAiFood,
onDietAiAskInput,
applyDietAiToForm,
openDietForm
}
}
/** more 页打开饮食表单时读取并清除预填 */
export function consumeDietAiPrefill() {
try {
const data = uni.getStorageSync(DIET_AI_PREFILL_KEY)
uni.removeStorageSync(DIET_AI_PREFILL_KEY)
if (data && typeof data === 'object' && data.breakfast) return data
} catch (e) {}
return null
}
@@ -1,115 +1,115 @@
import { ref } from 'vue'
import { formatUserMessage } from '../utils/tongjiHelpers.js'
/** 登录 / 就诊卡门禁(index 与 more 共用) */
export function useTongjiAuth(proxy) {
const authChecking = ref(false)
let gateRedirected = false
let authSessionPromise = null
function hasAuthToken() {
return !!String(uni.getStorageSync('token') || '').trim()
}
function clearAuthStorage() {
uni.removeStorageSync('token')
authSessionPromise = null
}
function wxLoginGetCode() {
return new Promise((resolve, reject) => {
uni.login({
provider: 'weixin',
success: (res) => {
if (res && res.code) resolve(res.code)
else reject(new Error('微信登录未返回 code'))
},
fail: reject
})
})
}
async function mnpLoginWithCode(code) {
const res = await proxy.apiUrl({
url: '/api/login/mnpLogin',
method: 'POST',
data: { code }
}, false)
if (res && res.code === 1 && res.data && res.data.token) {
uni.setStorageSync('token', res.data.token)
uni.setStorageSync('userData', res.data)
return res.data
}
clearAuthStorage()
throw new Error(formatUserMessage(res?.msg, '登录失败'))
}
async function doWxLogin() {
const code = await wxLoginGetCode()
await mnpLoginWithCode(code)
return hasAuthToken()
}
async function verifyOrLogin() {
if (!hasAuthToken()) {
return doWxLogin()
}
try {
const res = await proxy.apiUrl({ url: '/api/user/info', method: 'POST' }, false)
if (res && res.code === 1 && res.data) {
uni.setStorageSync('userData', res.data)
return true
}
} catch (e) {
/* 网络异常走重新登录 */
}
clearAuthStorage()
try {
return await doWxLogin()
} catch (e2) {
return false
}
}
async function ensureLoggedIn() {
if (authSessionPromise) {
return authSessionPromise
}
authSessionPromise = verifyOrLogin()
.then((ok) => {
if (!ok) authSessionPromise = null
return !!ok
})
.catch(() => {
authSessionPromise = null
return false
})
return authSessionPromise
}
function redirectToCardEntry(returnPath) {
if (gateRedirected) return
gateRedirected = true
const returnUrl = encodeURIComponent(returnPath || '/tongji/pages/index')
uni.redirectTo({
url: `/pages/Card/edit_card?add=1&returnUrl=${returnUrl}`
})
}
function resetGateRedirect() {
gateRedirected = false
}
function isGateRedirected() {
return gateRedirected
}
return {
authChecking,
hasAuthToken,
ensureLoggedIn,
redirectToCardEntry,
resetGateRedirect,
isGateRedirected
}
}
import { ref } from 'vue'
import { formatUserMessage } from '../utils/tongjiHelpers.js'
/** 登录 / 就诊卡门禁(index 与 more 共用) */
export function useTongjiAuth(proxy) {
const authChecking = ref(false)
let gateRedirected = false
let authSessionPromise = null
function hasAuthToken() {
return !!String(uni.getStorageSync('token') || '').trim()
}
function clearAuthStorage() {
uni.removeStorageSync('token')
authSessionPromise = null
}
function wxLoginGetCode() {
return new Promise((resolve, reject) => {
uni.login({
provider: 'weixin',
success: (res) => {
if (res && res.code) resolve(res.code)
else reject(new Error('微信登录未返回 code'))
},
fail: reject
})
})
}
async function mnpLoginWithCode(code) {
const res = await proxy.apiUrl({
url: '/api/login/mnpLogin',
method: 'POST',
data: { code }
}, false)
if (res && res.code === 1 && res.data && res.data.token) {
uni.setStorageSync('token', res.data.token)
uni.setStorageSync('userData', res.data)
return res.data
}
clearAuthStorage()
throw new Error(formatUserMessage(res?.msg, '登录失败'))
}
async function doWxLogin() {
const code = await wxLoginGetCode()
await mnpLoginWithCode(code)
return hasAuthToken()
}
async function verifyOrLogin() {
if (!hasAuthToken()) {
return doWxLogin()
}
try {
const res = await proxy.apiUrl({ url: '/api/user/info', method: 'POST' }, false)
if (res && res.code === 1 && res.data) {
uni.setStorageSync('userData', res.data)
return true
}
} catch (e) {
/* 网络异常走重新登录 */
}
clearAuthStorage()
try {
return await doWxLogin()
} catch (e2) {
return false
}
}
async function ensureLoggedIn() {
if (authSessionPromise) {
return authSessionPromise
}
authSessionPromise = verifyOrLogin()
.then((ok) => {
if (!ok) authSessionPromise = null
return !!ok
})
.catch(() => {
authSessionPromise = null
return false
})
return authSessionPromise
}
function redirectToCardEntry(returnPath) {
if (gateRedirected) return
gateRedirected = true
const returnUrl = encodeURIComponent(returnPath || '/tongji/pages/index')
uni.redirectTo({
url: `/pages/Card/edit_card?add=1&returnUrl=${returnUrl}`
})
}
function resetGateRedirect() {
gateRedirected = false
}
function isGateRedirected() {
return gateRedirected
}
return {
authChecking,
hasAuthToken,
ensureLoggedIn,
redirectToCardEntry,
resetGateRedirect,
isGateRedirected
}
}
@@ -3,8 +3,7 @@
## 已接入能力
- 复用主小程序 `token` 与微信小程序登录,不创建第二套游戏账号。
- 按周一日期自动分配最多 7 人的混合同行组,前期不区分男女
- 本周已经进入旧男女组的账号,下次读取榜单时保留成绩并自动迁入混合组。
- 按周一日期、性别自动分配最多 7 人的同行组。
- 首次入组使用数据库分配锁,多个用户同时进入也不会重复分组或超过 7 人。
- 以真实平台昵称、头像、认糖数和本周最高分排序。
- 每局用 `session_key` 上报绝对进度,断网重试不会重复加分。
@@ -34,7 +33,7 @@
## 上线前检查
-不同账号进入,确认按进入顺序加入同一个最多 7 人的混合同行组。
-男女各两个测试账号进入,确认被分入对应性别组。
- 同一局重复提交相同 `session_key`,确认周认糖数不重复增加。
- 断网完成几次消除,再联网打开榜单,确认成绩补传。
- 分享给另一个微信账号,确认能直接进入游戏且链接中没有用户 ID。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

@@ -1,577 +0,0 @@
#!/usr/bin/env node
// 识糖小课堂棋盘压力测试:复刻生产版的 5x4 棋盘、掉落、洗牌和渐进换组规则。
// 运行:node tongji/endless-game/build/stress-game.mjs --runs 10000
const ROWS = 5
const COLS = 4
const HIGH_CAP = 3
const MAX_CASCADE_WAVES = 3
const DANGER_DROP_CHANCE = 0.1
const COLUMN_GROUP_BIAS = 0.72
const FOOD_GROUPS = [
{ title: '中式早餐', safe: ['egg', 'cucumber', 'corn', 'grainMantou'], danger: 'congee' },
{ title: '家常粥餐', safe: ['celery', 'mushroom', 'chicken', 'taro'], danger: 'centuryCongee' },
{ title: '粗粮早餐', safe: ['broccoli', 'milk', 'brownRice', 'potato'], danger: 'milletCongee' },
{ title: '水果饮品', safe: ['apple', 'strawberry', 'banana', 'mango'], danger: 'orangeJuice' },
{ title: '日常饮品', safe: ['milk', 'tea', 'blackCoffee', 'coffee'], danger: 'soda' },
{ title: '下午茶甜饮', safe: ['milk', 'tea', 'apple', 'strawberry'], danger: 'milkTea' },
{ title: '南方汤粉', safe: ['pepper', 'greenBean', 'shrimp', 'udon'], danger: 'riceNoodleSoup' },
{ title: '北方面食', safe: ['onion', 'celery', 'corn', 'grainMantou'], danger: 'noodleSoup' },
{ title: '家常餐桌', safe: ['tofu', 'spinach', 'carrot', 'fish'], danger: 'redBeanCongee' }
]
const HIGH_KEYS = new Set(FOOD_GROUPS.map(group => group.danger))
const SAFE_KEYS = new Set(FOOD_GROUPS.flatMap(group => group.safe))
function readNumberArg(name, fallback) {
const index = process.argv.indexOf(name)
if (index < 0) return fallback
const value = Number(process.argv[index + 1])
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback
}
const RUNS = readNumberArg('--runs', 10000)
const TURNS = readNumberArg('--turns', 80)
const BASE_SEED = readNumberArg('--seed', 20260722)
function createRng(seed) {
let state = seed >>> 0 || 1
return () => {
state ^= state << 13
state ^= state >>> 17
state ^= state << 5
return (state >>> 0) / 4294967296
}
}
function cloneBoard(source) {
return source.map(tile => tile ? { ...tile } : null)
}
function tile(key) {
return { key }
}
function isAdjacent(a, b) {
const ar = Math.floor(a / COLS)
const ac = a % COLS
const br = Math.floor(b / COLS)
const bc = b % COLS
return Math.abs(ar - br) + Math.abs(ac - bc) === 1
}
function swap(source, a, b) {
const next = cloneBoard(source)
;[next[a], next[b]] = [next[b], next[a]]
return next
}
function shuffle(source, rng) {
const result = cloneBoard(source)
for (let i = result.length - 1; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1))
;[result[i], result[j]] = [result[j], result[i]]
}
return result
}
function findMatches(source) {
const groups = []
for (let row = 0; row < ROWS; row += 1) {
let start = 0
while (start < COLS) {
let end = start + 1
while (end < COLS && source[row * COLS + end]?.key === source[row * COLS + start]?.key) end += 1
if (source[row * COLS + start] && end - start >= 3) {
groups.push({ direction: 'row', indices: Array.from({ length: end - start }, (_, n) => row * COLS + start + n) })
}
start = end
}
}
for (let col = 0; col < COLS; col += 1) {
let start = 0
while (start < ROWS) {
let end = start + 1
while (end < ROWS && source[end * COLS + col]?.key === source[start * COLS + col]?.key) end += 1
if (source[start * COLS + col] && end - start >= 3) {
groups.push({ direction: 'col', indices: Array.from({ length: end - start }, (_, n) => (start + n) * COLS + col) })
}
start = end
}
}
return groups
}
function enumerateMatchingMoves(source) {
const moves = []
for (let i = 0; i < source.length; i += 1) {
const candidates = []
if (i % COLS < COLS - 1) candidates.push(i + 1)
if (i + COLS < source.length) candidates.push(i + COLS)
for (const j of candidates) {
const copy = swap(source, i, j)
const groups = findMatches(copy)
if (!groups.length) continue
const indices = new Set(groups.flatMap(group => group.indices))
const hitsHigh = [...indices].some(index => HIGH_KEYS.has(copy[index]?.key))
moves.push({ from: i, to: j, hitsHigh, groups })
}
}
return moves
}
function safeMoves(source) {
return enumerateMatchingMoves(source).filter(move => !move.hitsHigh)
}
function wouldCreateMatchAt(source, index, key) {
const row = Math.floor(index / COLS)
const col = index % COLS
const keyAt = target => target === index ? key : source[target]?.key
let horizontal = 1
for (let c = col - 1; c >= 0 && keyAt(row * COLS + c) === key; c -= 1) horizontal += 1
for (let c = col + 1; c < COLS && keyAt(row * COLS + c) === key; c += 1) horizontal += 1
if (horizontal >= 3) return true
let vertical = 1
for (let r = row - 1; r >= 0 && keyAt(r * COLS + col) === key; r -= 1) vertical += 1
for (let r = row + 1; r < ROWS && keyAt(r * COLS + col) === key; r += 1) vertical += 1
return vertical >= 3
}
function openingKeys(state) {
const [a, b, c, d] = state.activeSafeKeys
const danger = FOOD_GROUPS[state.themeIndex].danger
return [
a, b, c, d,
b, c, d, a,
c, d, a, danger,
d, a, danger, b,
a, b, c, d
]
}
function forcedPlayableKeys(state) {
const [a, b, c, d] = state.activeSafeKeys
const danger = FOOD_GROUPS[state.themeIndex].danger
return [
d, a, danger, c,
d, c, b, danger,
c, a, b, a,
d, d, a, a,
b, d, c, b
]
}
function buildPlayableBoard(state, keys = openingKeys(state)) {
let candidate = keys.map(tile)
for (let attempt = 0; attempt < 240; attempt += 1) {
if (!findMatches(candidate).length && safeMoves(candidate).length) return candidate
candidate = shuffle(candidate, state.rng)
}
state.metrics.guaranteedFallbacks += 1
return forcedPlayableKeys(state).map(tile)
}
function makeStablePlayableBoard(state, source) {
const tiles = source.filter(Boolean).map(item => ({ ...item }))
if (tiles.length === ROWS * COLS) {
for (let attempt = 0; attempt < 300; attempt += 1) {
const candidate = shuffle(tiles, state.rng)
if (!findMatches(candidate).length && safeMoves(candidate).length) return candidate
}
}
state.metrics.stableFallbacks += 1
return buildPlayableBoard(state)
}
function queueSafeGroupTransition(state, keys) {
const target = [...keys]
const last = state.queuedSafeGroups[state.queuedSafeGroups.length - 1]
if (!last || !last.every((key, index) => key === target[index])) {
state.queuedSafeGroups.push(target)
}
startNextTransition(state)
}
function startNextTransition(state) {
if (state.transition) return false
while (state.queuedSafeGroups.length) {
const target = state.queuedSafeGroups[0]
const slot = target.findIndex((key, index) => state.activeSafeKeys[index] !== key)
if (slot < 0) {
state.queuedSafeGroups.shift()
continue
}
const from = state.activeSafeKeys[slot]
const to = target[slot]
state.activeSafeKeys[slot] = to
state.transition = { from, to, slot, age: 0 }
return true
}
return false
}
function finishTransitionIfReady(state) {
let changed = false
while (state.transition) {
const { from, to } = state.transition
const remaining = state.board.filter(item => item?.key === from).length
if (remaining > 2) break
if (remaining) {
state.board = state.board.map(item => item?.key === from ? { ...item, key: to } : item)
state.metrics.tailConversions += remaining
}
state.transition = null
changed = true
startNextTransition(state)
}
return changed
}
function prepareTransitionForReshuffle(state) {
const activeSet = new Set(state.activeSafeKeys)
const counts = Object.fromEntries(state.activeSafeKeys.map(key => [key, 0]))
state.board.forEach(item => {
if (item && activeSet.has(item.key)) counts[item.key] += 1
})
let imported = 0
const next = state.board.map(item => {
if (!item || item.key === 'camel' || HIGH_KEYS.has(item.key)) return item ? { ...item } : item
if (state.transition && item.key === state.transition.from) {
imported += 1
counts[state.transition.to] += 1
return { ...item, key: state.transition.to }
}
if (activeSet.has(item.key)) return { ...item }
const key = [...state.activeSafeKeys].sort((a, b) => counts[a] - counts[b])[0]
counts[key] += 1
imported += 1
return { ...item, key }
})
state.board = next
state.metrics.reshuffleImports += imported
}
function randomFoodKey(state, snapshot, col) {
const highs = snapshot.filter(item => item && HIGH_KEYS.has(item.key)).length
if (highs < HIGH_CAP && state.rng() < DANGER_DROP_CHANCE) {
return FOOD_GROUPS[state.themeIndex].danger
}
if (state.rng() < COLUMN_GROUP_BIAS) return state.activeSafeKeys[col % state.activeSafeKeys.length]
return state.activeSafeKeys[Math.floor(state.rng() * state.activeSafeKeys.length)]
}
function pickDropKey(state, snapshot, index, col) {
for (let attempt = 0; attempt < 16; attempt += 1) {
const key = randomFoodKey(state, snapshot, col)
if (!wouldCreateMatchAt(snapshot, index, key)) return key
}
const fallback = [...state.activeSafeKeys, FOOD_GROUPS[state.themeIndex].danger]
.find(key => !wouldCreateMatchAt(snapshot, index, key))
return fallback || state.activeSafeKeys[(index + col) % state.activeSafeKeys.length]
}
function collapseAndFill(state) {
const next = Array(ROWS * COLS).fill(null)
for (let col = 0; col < COLS; col += 1) {
const existing = []
for (let row = ROWS - 1; row >= 0; row -= 1) {
const item = state.board[row * COLS + col]
if (item) existing.push(item)
}
existing.forEach((item, offset) => {
next[(ROWS - 1 - offset) * COLS + col] = item
})
}
for (let row = 0; row < ROWS; row += 1) {
for (let col = 0; col < COLS; col += 1) {
const index = row * COLS + col
if (!next[index]) next[index] = tile(pickDropKey(state, next, index, col))
}
}
state.board = next
}
function reshuffle(state) {
prepareTransitionForReshuffle(state)
let candidate = null
for (let attempt = 0; attempt < 240; attempt += 1) {
const shuffled = shuffle(state.board, state.rng)
if (findMatches(shuffled).length || !safeMoves(shuffled).length) continue
candidate = shuffled
break
}
state.board = candidate || buildPlayableBoard(state, forcedPlayableKeys(state))
finishTransitionIfReady(state)
state.metrics.reshuffles += 1
}
function advanceTheme(state) {
const previous = FOOD_GROUPS[state.themeIndex]
state.themeIndex = (state.themeIndex + 1) % FOOD_GROUPS.length
const next = FOOD_GROUPS[state.themeIndex]
state.board = state.board.map(item => item?.key === previous.danger ? { ...item, key: next.danger } : item)
queueSafeGroupTransition(state, next.safe)
state.metrics.themeChanges += 1
}
function resolveMatches(state, initialGroups) {
let groups = initialGroups
let wave = 0
let camelMatch = 0
while (groups.length && wave < MAX_CASCADE_WAVES) {
wave += 1
const remove = new Set(groups.flatMap(group => group.indices))
for (const group of groups) {
if (group.indices.every(index => state.board[index]?.key === 'camel')) {
camelMatch = Math.max(camelMatch, group.indices.length)
}
}
remove.forEach(index => { state.board[index] = null })
collapseAndFill(state)
finishTransitionIfReady(state)
groups = findMatches(state.board)
const autoHigh = groups.some(group => group.indices.some(index => HIGH_KEYS.has(state.board[index]?.key)))
if (autoHigh) {
state.metrics.automaticHighAvoided += 1
state.board = makeStablePlayableBoard(state, state.board)
groups = []
} else if (wave >= MAX_CASCADE_WAVES && groups.length) {
state.metrics.cascadeCaps += 1
state.board = makeStablePlayableBoard(state, state.board)
groups = []
}
}
state.metrics.maxCascade = Math.max(state.metrics.maxCascade, wave)
if (camelMatch >= 3) advanceTheme(state)
}
function useCamelTool(state) {
const danger = FOOD_GROUPS[state.themeIndex].danger
const targets = state.board
.map((item, index) => item?.key === danger ? index : -1)
.filter(index => index >= 0)
if (!targets.length) return false
const index = targets[Math.floor(state.rng() * targets.length)]
state.board[index] = tile('camel')
state.metrics.toolUses += 1
const groups = findMatches(state.board)
if (groups.length) resolveMatches(state, groups)
return true
}
function recordFailure(state, type, detail = '') {
state.metrics.failures[type] = (state.metrics.failures[type] || 0) + 1
if (state.metrics.samples.length < 20) {
state.metrics.samples.push({ run: state.run, turn: state.turn, type, detail, board: state.board.map(item => item?.key || '-') })
}
}
function validateState(state, expectStable = true) {
if (state.board.length !== ROWS * COLS || state.board.some(item => !item?.key)) {
recordFailure(state, 'invalid_board_size')
return false
}
const currentDanger = FOOD_GROUPS[state.themeIndex].danger
const highKeys = state.board.filter(item => HIGH_KEYS.has(item.key)).map(item => item.key)
if (highKeys.some(key => key !== currentDanger)) recordFailure(state, 'wrong_theme_danger', highKeys.join(','))
if (highKeys.length > HIGH_CAP) recordFailure(state, 'danger_cap_exceeded', String(highKeys.length))
const allowed = new Set([...state.activeSafeKeys, 'camel'])
if (state.transition) allowed.add(state.transition.from)
const stale = state.board.filter(item => !HIGH_KEYS.has(item.key) && !allowed.has(item.key)).map(item => item.key)
if (stale.length) recordFailure(state, 'stale_food', stale.join(','))
const normalTypes = new Set(state.board.filter(item => SAFE_KEYS.has(item.key)).map(item => item.key))
if (normalTypes.size > 5) recordFailure(state, 'too_many_normal_types', String(normalTypes.size))
if (new Set(state.activeSafeKeys).size !== 4) recordFailure(state, 'duplicate_active_food')
if (state.transition) {
const remaining = state.board.filter(item => item.key === state.transition.from).length
if (remaining <= 2) recordFailure(state, 'orphan_transition_tail', `${state.transition.from}:${remaining}`)
}
if (expectStable && findMatches(state.board).length) recordFailure(state, 'unresolved_match')
return true
}
function createMetrics() {
return {
runs: RUNS,
turnsPerRun: TURNS,
totalTurns: 0,
validMoves: 0,
reshuffles: 0,
reshuffleImports: 0,
guaranteedFallbacks: 0,
stableFallbacks: 0,
themeChanges: 0,
toolUses: 0,
tailConversions: 0,
automaticHighAvoided: 0,
cascadeCaps: 0,
maxCascade: 0,
unsafeHighMoveProbes: 0,
maxTransitionAge: 0,
maxQueuedThemes: 0,
transitionDrainTurns: 0,
unfinishedTransitions: 0,
failures: {},
samples: []
}
}
function runTargetedTailTests(metrics) {
for (let groupIndex = 0; groupIndex < FOOD_GROUPS.length; groupIndex += 1) {
const nextIndex = (groupIndex + 1) % FOOD_GROUPS.length
const previousGroup = FOOD_GROUPS[groupIndex]
const nextGroup = FOOD_GROUPS[nextIndex]
// 相邻主题允许在相同槽位保留同一种食物。定向测试应选择真正退出下一组的食物,
// 不能固定测试第 0 槽,否则“牛奶→牛奶”会被误报为尾巴未转换。
const slot = previousGroup.safe.findIndex(key => !nextGroup.safe.includes(key))
if (slot < 0) continue
for (const remaining of [0, 1, 2]) {
const state = {
run: -1,
turn: remaining,
rng: createRng(BASE_SEED + groupIndex * 17 + remaining),
metrics,
themeIndex: nextIndex,
activeSafeKeys: [...nextGroup.safe],
transition: {
from: previousGroup.safe[slot],
to: nextGroup.safe[slot],
slot,
age: 0
},
queuedSafeGroups: [[...nextGroup.safe]],
board: []
}
const fill = nextGroup.safe
state.board = Array.from({ length: ROWS * COLS }, (_, index) => tile(fill[index % fill.length]))
for (let index = 0; index < remaining; index += 1) state.board[index] = tile(state.transition.from)
finishTransitionIfReady(state)
if (state.board.some(item => item.key === state.transition?.from || item.key === previousGroup.safe[slot])) {
recordFailure(state, 'targeted_tail_not_converted', `${groupIndex}:${remaining}`)
}
}
}
}
const metrics = createMetrics()
runTargetedTailTests(metrics)
for (let run = 0; run < RUNS; run += 1) {
const state = {
run,
turn: 0,
rng: createRng(BASE_SEED + run * 2654435761),
metrics,
themeIndex: run % FOOD_GROUPS.length,
activeSafeKeys: [...FOOD_GROUPS[run % FOOD_GROUPS.length].safe],
transition: null,
queuedSafeGroups: [],
board: []
}
state.board = buildPlayableBoard(state)
validateState(state)
let toolsUsed = 0
for (let turn = 0; turn < TURNS; turn += 1) {
state.turn = turn
metrics.totalTurns += 1
if (state.transition) {
state.transition.age += 1
metrics.maxTransitionAge = Math.max(metrics.maxTransitionAge, state.transition.age)
}
metrics.maxQueuedThemes = Math.max(metrics.maxQueuedThemes, state.queuedSafeGroups.length)
// 模拟玩家积攒到驼乳后替换高糖,但每局限制5次,避免测试人为填满棋盘。
if (toolsUsed < 5 && turn > 0 && turn % 13 === 0 && useCamelTool(state)) {
toolsUsed += 1
finishTransitionIfReady(state)
if (findMatches(state.board).length) state.board = makeStablePlayableBoard(state, state.board)
}
let moves = safeMoves(state.board)
if (!moves.length) {
reshuffle(state)
moves = safeMoves(state.board)
}
if (!moves.length) {
recordFailure(state, 'dead_board_after_reshuffle')
break
}
// 额外探测“主动三消高糖会结束”的判定,不改变主模拟棋盘。
const unsafe = enumerateMatchingMoves(state.board).find(move => move.hitsHigh)
if (unsafe) {
const probe = swap(state.board, unsafe.from, unsafe.to)
const direct = findMatches(probe)
if (!direct.some(group => group.indices.some(index => HIGH_KEYS.has(probe[index]?.key)))) {
recordFailure(state, 'unsafe_high_probe_missed')
}
metrics.unsafeHighMoveProbes += 1
}
const move = moves[Math.floor(state.rng() * moves.length)]
state.board = swap(state.board, move.from, move.to)
const groups = findMatches(state.board)
if (!groups.length) {
recordFailure(state, 'safe_move_without_match')
break
}
if (groups.some(group => group.indices.some(index => HIGH_KEYS.has(state.board[index]?.key)))) {
recordFailure(state, 'safe_move_hit_high')
break
}
resolveMatches(state, groups)
metrics.validMoves += 1
// 用较快节奏覆盖连续换组和排队逻辑,强度高于正常玩家。
if ((turn + 1) % 16 === 0 && turn + 1 < TURNS) advanceTheme(state)
finishTransitionIfReady(state)
if (findMatches(state.board).length) state.board = makeStablePlayableBoard(state, state.board)
if (!safeMoves(state.board).length) reshuffle(state)
validateState(state)
}
// 停止新增主题后继续正常消除,验证排队中的旧食物能否全部退出,避免队列永久积压。
let drainTurn = 0
while ((state.transition || state.queuedSafeGroups.length) && drainTurn < 80) {
state.turn = TURNS + drainTurn
if (state.transition) {
state.transition.age += 1
metrics.maxTransitionAge = Math.max(metrics.maxTransitionAge, state.transition.age)
}
let moves = safeMoves(state.board)
if (!moves.length) {
reshuffle(state)
moves = safeMoves(state.board)
}
if (!moves.length) {
recordFailure(state, 'dead_board_while_draining')
break
}
const move = moves[Math.floor(state.rng() * moves.length)]
state.board = swap(state.board, move.from, move.to)
resolveMatches(state, findMatches(state.board))
metrics.validMoves += 1
finishTransitionIfReady(state)
if (findMatches(state.board).length) state.board = makeStablePlayableBoard(state, state.board)
if (!safeMoves(state.board).length) reshuffle(state)
validateState(state)
drainTurn += 1
}
metrics.transitionDrainTurns += drainTurn
if (state.transition || state.queuedSafeGroups.length) metrics.unfinishedTransitions += 1
}
const failureCount = Object.values(metrics.failures).reduce((sum, count) => sum + count, 0)
const result = {
ok: failureCount === 0,
...metrics,
failureCount
}
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
if (!result.ok) process.exitCode = 1
@@ -1,32 +0,0 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import type { Plugin } from 'vite'
const FOOD_ICON_FILES = [
'tofu.jpg',
'spinach.jpg',
'carrot.jpg',
'fish.jpg',
'redBeanCongee.jpg',
'milkTea.jpg'
]
/**
* Keep endless-game food icons inside the tongji subpackage.
* Importing them from Vue would make Vite emit them into the main-package assets folder.
*/
export function tongjiFoodAssetsPlugin(): Plugin {
return {
name: 'tongji-food-assets',
apply: 'build',
generateBundle() {
FOOD_ICON_FILES.forEach((fileName) => {
this.emitFile({
type: 'asset',
fileName: `tongji/endless-game/assets/food/${fileName}`,
source: readFileSync(resolve(process.cwd(), 'tongji/endless-game/assets/food', fileName))
})
})
}
}
}
@@ -39,7 +39,6 @@ export function useGamePlatform(proxy, ensureLoggedIn) {
const connected = ref(false)
const loading = ref(false)
const syncStatus = ref('idle')
const lastError = ref('')
const leaderboard = ref({ ...EMPTY_BOARD })
const confirmedSessionLearned = ref(0)
@@ -74,19 +73,17 @@ export function useGamePlatform(proxy, ensureLoggedIn) {
}
connected.value = true
syncStatus.value = 'synced'
lastError.value = ''
}
async function refreshLeaderboard() {
const res = await api({
url: '/api/tcm/gameWeeklyLeaderboard',
method: 'GET',
data: { session_key: sessionKey }
method: 'GET'
})
if (res?.code !== 1 || !res.data) {
throw new Error(res?.msg || '同行榜加载失败')
}
applyLeaderboard(res.data, sessionKey)
applyLeaderboard(res.data)
return res.data
}
@@ -115,10 +112,9 @@ export function useGamePlatform(proxy, ensureLoggedIn) {
await refreshLeaderboard()
if (queuedPayloads.length) flushProgress()
return true
} catch (error) {
} catch (_) {
connected.value = false
syncStatus.value = 'offline'
lastError.value = String(error?.message || '平台连接失败,请点击重试')
return false
} finally {
loading.value = false
@@ -128,21 +124,14 @@ export function useGamePlatform(proxy, ensureLoggedIn) {
return connectPromise
}
function beginSession(existingSessionKey = '') {
function beginSession() {
if (syncTimer) clearTimeout(syncTimer)
const restoredKey = String(existingSessionKey || '').trim()
sessionKey = /^[A-Za-z0-9_-]{16,64}$/.test(restoredKey)
? restoredKey
: createSessionKey()
sessionKey = createSessionKey()
confirmedSessionLearned.value = 0
syncStatus.value = queuedPayloads.length ? 'pending' : (connected.value ? 'synced' : 'offline')
return sessionKey
}
function getSessionKey() {
return sessionKey
}
function queueProgress(learnedCount, score, ended = false) {
const nextPayload = {
session_key: sessionKey,
@@ -202,10 +191,9 @@ export function useGamePlatform(proxy, ensureLoggedIn) {
|| Number(item.ended) > Number(payload.ended)
))
persistPendingPayloads()
} catch (error) {
} catch (_) {
connected.value = false
syncStatus.value = 'offline'
lastError.value = String(error?.message || '成绩暂未保存,联网后会自动重试')
// 队首保留原绝对值,下次连接或打开榜单时安全重试。
persistPendingPayloads()
} finally {
@@ -249,12 +237,10 @@ export function useGamePlatform(proxy, ensureLoggedIn) {
connected,
loading,
syncStatus,
lastError,
leaderboard,
confirmedSessionLearned,
connect,
beginSession,
getSessionKey,
queueProgress,
flushProgress,
syncAndRefresh,
@@ -1,17 +0,0 @@
# 识糖小课堂 3D 食物图标候选稿 v1
- 共 42 个食物图标,文件名与 `FOODS` 中的英文键名一致。
- 单图尺寸:320 × 320JPEG。
- 风格:中式、圆润 3D、暖色奶油背景、大轮廓、适老化高辨识度。
- 糖级、边框和角标不画入图标,由游戏界面控制,方便医生调整分类。
- `milkTea.jpg` 是唯一自带角色表情的高糖小反派候选。
- 驼乳属于产品道具,不计入 42 个普通食物图标,本轮未替换现有产品罐素材。
- 本目录当前仅作设计候选,没有接入正式游戏,也不会进入微信小程序发布包。
目录说明:
- `icons/`:可直接用于游戏的独立图标。
- `sheets/`:11 张分组预览母版,方便整体审美检查。
- `manifest.json`:食物中文名、代码键名与文件路径清单。
医学提示:图标仅表达食物外观,不代表最终糖级结论;糖级和健康说明仍需甄养堂医生审核。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

@@ -1,53 +0,0 @@
{
"version": 1,
"style": "chinese-rounded-3d-elderly-friendly",
"iconSize": {
"width": 320,
"height": 320,
"format": "jpg"
},
"items": [
{ "key": "corn", "name": "玉米", "file": "icons/corn.jpg" },
{ "key": "egg", "name": "鸡蛋", "file": "icons/egg.jpg" },
{ "key": "tomato", "name": "番茄", "file": "icons/tomato.jpg" },
{ "key": "potato", "name": "红薯", "file": "icons/potato.jpg" },
{ "key": "cucumber", "name": "黄瓜", "file": "icons/cucumber.jpg" },
{ "key": "lettuce", "name": "生菜", "file": "icons/lettuce.jpg" },
{ "key": "grainMantou", "name": "杂粮馒头", "file": "icons/grainMantou.jpg" },
{ "key": "wonton", "name": "馄饨", "file": "icons/wonton.jpg" },
{ "key": "celery", "name": "芹菜", "file": "icons/celery.jpg" },
{ "key": "mushroom", "name": "香菇", "file": "icons/mushroom.jpg" },
{ "key": "chicken", "name": "鸡肉", "file": "icons/chicken.jpg" },
{ "key": "taro", "name": "芋头", "file": "icons/taro.jpg" },
{ "key": "broccoli", "name": "西兰花", "file": "icons/broccoli.jpg" },
{ "key": "milk", "name": "牛奶", "file": "icons/milk.jpg" },
{ "key": "brownRice", "name": "糙米饭", "file": "icons/brownRice.jpg" },
{ "key": "apple", "name": "苹果", "file": "icons/apple.jpg" },
{ "key": "strawberry", "name": "草莓", "file": "icons/strawberry.jpg" },
{ "key": "banana", "name": "香蕉", "file": "icons/banana.jpg" },
{ "key": "mango", "name": "芒果", "file": "icons/mango.jpg" },
{ "key": "tea", "name": "红茶", "file": "icons/tea.jpg" },
{ "key": "blackCoffee", "name": "黑咖啡", "file": "icons/blackCoffee.jpg" },
{ "key": "coffee", "name": "咖啡", "file": "icons/coffee.jpg" },
{ "key": "pineapple", "name": "菠萝", "file": "icons/pineapple.jpg" },
{ "key": "pepper", "name": "青椒", "file": "icons/pepper.jpg" },
{ "key": "greenBean", "name": "四季豆", "file": "icons/greenBean.jpg" },
{ "key": "shrimp", "name": "虾仁", "file": "icons/shrimp.jpg" },
{ "key": "udon", "name": "乌冬面", "file": "icons/udon.jpg" },
{ "key": "onion", "name": "洋葱", "file": "icons/onion.jpg" },
{ "key": "congee", "name": "白米粥", "file": "icons/congee.jpg" },
{ "key": "eightCongee", "name": "八宝粥", "file": "icons/eightCongee.jpg" },
{ "key": "centuryCongee", "name": "皮蛋瘦肉粥", "file": "icons/centuryCongee.jpg" },
{ "key": "milletCongee", "name": "小米粥", "file": "icons/milletCongee.jpg" },
{ "key": "orangeJuice", "name": "柳橙汁", "file": "icons/orangeJuice.jpg" },
{ "key": "soda", "name": "含糖汽水", "file": "icons/soda.jpg" },
{ "key": "riceNoodleSoup", "name": "汤粉", "file": "icons/riceNoodleSoup.jpg" },
{ "key": "noodleSoup", "name": "汤面", "file": "icons/noodleSoup.jpg" },
{ "key": "milkTea", "name": "含糖奶茶", "file": "icons/milkTea.jpg" },
{ "key": "tofu", "name": "豆腐", "file": "icons/tofu.jpg" },
{ "key": "spinach", "name": "菠菜", "file": "icons/spinach.jpg" },
{ "key": "carrot", "name": "胡萝卜", "file": "icons/carrot.jpg" },
{ "key": "fish", "name": "鱼", "file": "icons/fish.jpg" },
{ "key": "redBeanCongee", "name": "软烂红豆粥", "file": "icons/redBeanCongee.jpg" }
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

@@ -46,44 +46,7 @@
.eg-nav-actions { display: flex; width: 156rpx; flex-shrink: 0; justify-content: flex-end; gap: 10rpx; }
.eg-nav-btn--small { width: 68rpx; height: 68rpx; border-radius: 22rpx; }
.eg-title-wrap { position: relative; display: flex; min-width: 0; flex-direction: column; align-items: center; }
.eg-title-wrap > .eg-title,
.eg-title-wrap > .eg-subtitle { transition: opacity .08s ease; }
.eg-title-wrap.is-noticing > .eg-title,
.eg-title-wrap.is-noticing > .eg-subtitle { opacity: .16; }
.eg-nav-notice {
position: absolute;
z-index: 1;
top: 50%;
left: 50%;
display: flex;
width: 100%;
min-width: 0;
box-sizing: border-box;
flex-direction: column;
align-items: center;
padding: 5rpx 8rpx;
border: 1rpx solid rgba(21,94,75,.09);
border-radius: 16rpx;
color: rgba(38,86,72,.82);
background: rgba(255,255,255,.58);
box-shadow: 0 4rpx 12rpx rgba(13,64,51,.06);
text-align: center;
transform: translate(-50%, -50%);
animation: egNavNotice 1.05s ease-out both;
pointer-events: none;
}
.eg-nav-notice-title,
.eg-nav-notice-detail {
display: block;
overflow: hidden;
max-width: 100%;
line-height: 1.15;
text-overflow: ellipsis;
white-space: nowrap;
}
.eg-nav-notice-title { font-size: 23rpx; font-weight: 800; }
.eg-nav-notice-detail { margin-top: 2rpx; color: rgba(154,106,42,.78); font-size: 20rpx; font-weight: 700; }
.eg-title-wrap { display: flex; min-width: 0; flex-direction: column; align-items: center; }
.eg-title, .eg-subtitle { overflow: hidden; max-width: 100%; white-space: nowrap; text-overflow: ellipsis; }
.eg-title { color: #155e4b; font-size: 40rpx; font-weight: 800; }
.eg-subtitle { margin-top: 2rpx; color: #648278; font-size: 24rpx; }
@@ -154,20 +117,12 @@
background: rgba(255,255,255,.92);
box-shadow: 0 18rpx 44rpx rgba(29, 78, 59, .12);
}
.eg-board-shell.is-flowing {
border-color: rgba(245, 158, 11, .72);
box-shadow: 0 0 0 5rpx rgba(255, 221, 89, .2), 0 18rpx 46rpx rgba(234, 88, 12, .2);
animation: egFlowShell 1.05s ease-in-out infinite alternate;
}
.eg-board-top { display: flex; align-items: center; justify-content: space-between; margin: 0 4rpx 14rpx; }
.eg-board-bonuses { display: flex; align-items: center; gap: 8rpx; }
.eg-combo { padding: 8rpx 16rpx; border-radius: 999rpx; color: #45685d; background: #e8f3ee; font-size: 25rpx; font-weight: 700; }
.eg-combo.is-hot { color: #fff; background: linear-gradient(135deg, #f59e0b, #ea580c); }
.eg-double-state { padding: 8rpx 14rpx; border: 2rpx solid rgba(255,255,255,.9); border-radius: 999rpx; color: #fff; background: linear-gradient(135deg, #7c3aed, #d946ef); box-shadow: 0 4rpx 12rpx rgba(124,58,237,.24); font-size: 23rpx; font-weight: 900; white-space: nowrap; }
.eg-flow-state { display: flex; align-items: center; gap: 8rpx; padding: 8rpx 14rpx; border: 3rpx solid #fff3a3; border-radius: 999rpx; color: #fff; background: linear-gradient(135deg, #f97316, #dc2626); box-shadow: 0 5rpx 16rpx rgba(220,38,38,.3); font-size: 25rpx; font-weight: 1000; white-space: nowrap; animation: egFlowBadge .62s ease-in-out infinite alternate; }
.eg-flow-state > text:last-child { min-width: 62rpx; color: #fff7a8; text-align: right; }
.eg-flow-state.is-paused { border-color: #d9f6ff; background: linear-gradient(135deg, #1687b4, #3155a6); box-shadow: 0 5rpx 16rpx rgba(34,109,166,.28); animation: none; }
.eg-risk { display: flex; align-items: center; gap: 10rpx; color: #8d512c; font-size: 24rpx; }
.eg-risk-dots { display: flex; gap: 5rpx; }
.eg-risk-dot { width: 12rpx; height: 12rpx; border-radius: 50%; background: #ead8c9; }
@@ -212,15 +167,7 @@
.eg-cell.is-level-high { background: #fff0e4; }
.eg-cell.is-milk { background: #edf7ff; }
.eg-cell.is-high { border-color: #ef7b45; }
.eg-cell.is-villain {
background: radial-gradient(circle at 50% 38%, #fff7e8 0%, #ffe9d8 70%, #ffddc6 100%);
box-shadow: inset 0 -5rpx 0 rgba(154,57,25,.08), 0 6rpx 15rpx rgba(191,72,31,.16);
}
.eg-cell.is-villain .eg-food-image { animation: egVillainPeek 2.2s ease-in-out infinite; }
.eg-cell.is-villain.is-clearing .eg-food-image,
.eg-cell.is-villain.is-dragging .eg-food-image { animation: none; }
.eg-food-image { width: 132rpx; height: 92rpx; margin-top: 7rpx; border-radius: 17rpx; filter: saturate(1.06) contrast(1.03); }
.eg-food-image.is-large-square { width: 136rpx; height: 102rpx; margin-top: 2rpx; filter: saturate(1.1) contrast(1.06); }
.eg-food-name { margin-top: 8rpx; color: #203d34; font-size: 29rpx; font-weight: 900; letter-spacing: 1rpx; line-height: 1.1; }
.eg-sugar-tag { position: absolute; top: 5rpx; right: 5rpx; min-width: 52rpx; padding: 4rpx 10rpx; border: 2rpx solid #fff; border-radius: 999rpx; color: #fff; box-shadow: 0 3rpx 8rpx rgba(25,55,45,.22); font-size: 23rpx; font-weight: 900; line-height: 1.25; text-align: center; }
.eg-sugar-tag.is-low { color: #316a54; border-color: rgba(255,255,255,.82); background: #dcefe6; box-shadow: 0 2rpx 5rpx rgba(32,101,75,.1); }
@@ -390,9 +337,6 @@
.eg-weekly-list { display: flex; flex-direction: column; gap: 7rpx; margin-top: 15rpx; }
.eg-weekly-row { display: flex; min-height: 68rpx; align-items: center; padding: 7rpx 14rpx; border: 2rpx solid transparent; border-radius: 19rpx; color: #345c50; background: rgba(255,255,255,.72); }
.eg-weekly-row.is-me { border-color: #f2bd48; color: #174f3e; background: linear-gradient(90deg, #fff2bb, #fff9e3); box-shadow: 0 5rpx 14rpx rgba(153,102,17,.12); transform: scale(1.015); }
.eg-weekly-row.is-waiting { color: #8a9b94; border-style: dashed; border-color: #dbe8e2; background: rgba(247,251,249,.72); }
.eg-weekly-row.is-waiting .eg-weekly-avatar { color: #6e887e; border-color: #dce9e3; background: #edf4f1; box-shadow: none; }
.eg-weekly-row.is-waiting .eg-weekly-count { color: #9baba4; }
.eg-weekly-place { width: 43rpx; color: #6b7d76; font-size: 27rpx; font-weight: 1000; text-align: center; }
.eg-weekly-row:nth-child(1) .eg-weekly-place { color: #dd7b17; font-size: 31rpx; }
.eg-weekly-avatar { display: flex; width: 52rpx; height: 52rpx; flex: 0 0 52rpx; align-items: center; justify-content: center; margin-left: 6rpx; border: 3rpx solid rgba(255,255,255,.9); border-radius: 50%; color: #fff; background: #5d9b84; box-shadow: 0 3rpx 9rpx rgba(30,78,61,.16); font-size: 24rpx; font-weight: 1000; }
@@ -406,9 +350,6 @@
.eg-weekly-name { flex: 1; margin-left: 13rpx; overflow: hidden; font-size: 27rpx; font-weight: 850; text-overflow: ellipsis; white-space: nowrap; }
.eg-weekly-me { margin-right: 8rpx; padding: 3rpx 9rpx; border-radius: 999rpx; color: #fff; background: #dd6b27; font-size: 18rpx; font-weight: 900; }
.eg-weekly-count { min-width: 70rpx; color: #315b4d; font-size: 29rpx; font-weight: 1000; text-align: right; }
.eg-weekly-connection { display: flex; align-items: center; justify-content: space-between; gap: 12rpx; margin-top: 13rpx; padding: 12rpx 15rpx; border: 2rpx solid #e4d7b2; border-radius: 18rpx; color: #6b6655; background: #fff9e8; font-size: 21rpx; font-weight: 700; }
.eg-weekly-connection > text:first-child { flex: 1; }
.eg-weekly-retry { flex-shrink: 0; padding: 6rpx 12rpx; border-radius: 999rpx; color: #fff; background: #23765c; font-weight: 900; }
.eg-cheer-card { margin-top: 16rpx; padding: 15rpx 17rpx 14rpx; border: 3rpx solid #f0d38d; border-radius: 23rpx; background: linear-gradient(135deg, #fff9d9, #fff1c2); }
.eg-cheer-card.is-family { border-color: #d9c8ef; background: linear-gradient(135deg, #f8f2ff, #efe7ff); }
.eg-cheer-card.is-bright { border-color: #f4c77b; background: linear-gradient(135deg, #fff8dc, #ffeec7); }
@@ -476,13 +417,6 @@
100% { opacity: 0; transform: scale(.32) rotate(7deg); }
}
@keyframes egVillainPeek {
0%, 72%, 100% { transform: translateY(0) rotate(0); filter: saturate(1.08) contrast(1.05); }
80% { transform: translateY(-3rpx) rotate(-1.5deg); filter: saturate(1.15) contrast(1.08); }
88% { transform: translateY(0) rotate(1.5deg); }
95% { transform: translateY(-2rpx) rotate(0); }
}
@keyframes egTaskRewardFlight {
0% { opacity: 0; transform: translate3d(-20rpx, 0, 0) scale(.62) rotate(-8deg); }
16% { opacity: 1; transform: translate3d(0, -24rpx, 0) scale(1.1) rotate(3deg); }
@@ -490,13 +424,6 @@
100% { opacity: 0; transform: translate3d(350rpx, 1205rpx, 0) scale(.3) rotate(2deg); }
}
@keyframes egNavNotice {
0% { opacity: 0; transform: translate(-50%, -42%); }
12% { opacity: .9; transform: translate(-50%, -50%); }
42% { opacity: .82; transform: translate(-50%, -50%); }
100% { opacity: 0; transform: translate(-50%, -58%); }
}
@keyframes egRewardGlow {
from { opacity: .4; transform: scale(.86); }
to { opacity: .9; transform: scale(1.12); }
@@ -626,16 +553,6 @@
100% { opacity: 1; transform: scale(1); }
}
@keyframes egFlowShell {
from { filter: saturate(1); }
to { filter: saturate(1.08); }
}
@keyframes egFlowBadge {
from { transform: scale(.98); filter: brightness(.96); }
to { transform: scale(1.035); filter: brightness(1.12); }
}
@keyframes egStageIn {
0% { opacity: 0; transform: scale(.7) translateY(34rpx); }
18% { opacity: 1; transform: scale(1.06) translateY(0); }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,51 +1,51 @@
/**
* Google Stitch MCP · VitalMint Health
* 设计 token 与 stitch-weekly.html tailwind.config 一一对应
*/
.stitch-vitalmint {
--primary: #006c49;
--on-primary: #ffffff;
--on-primary-fixed-variant: #005236;
--primary-container: #10b981;
--on-primary-container: #00422b;
--inverse-primary: #4edea3;
--surface: #f4fbf4;
--on-surface: #161d19;
--on-surface-variant: #3c4a42;
--surface-container-lowest: #ffffff;
--surface-container-low: #eef6ee;
--surface-container: #e8f0e9;
--surface-container-high: #e3eae3;
--surface-container-highest: #dde4dd;
--surface-variant: #dde4dd;
--outline: #6c7a71;
--outline-variant: #bbcabf;
--error: #ba1a1a;
--on-error-container: #93000a;
--error-container: #ffdad6;
--tertiary: #a43a3a;
--tertiary-container: #fc7c78;
--on-tertiary: #ffffff;
--on-tertiary-container: #711419;
--st-container-margin: 40rpx;
--st-section-gap: 64rpx;
--st-card-padding: 40rpx;
--st-stack-gap: 32rpx;
--st-inline-gap: 24rpx;
--st-radius-2xl: 48rpx;
--st-radius-xl: 24rpx;
--st-radius-lg: 16rpx;
--st-shadow-ambient: 0 20rpx 60rpx -20rpx rgba(0, 108, 73, 0.08);
}
.weekly-page.stitch-vitalmint {
min-height: 100vh;
background: var(--surface);
color: var(--on-surface);
padding-bottom: calc(200rpx + env(safe-area-inset-bottom));
}
/**
* Google Stitch MCP · VitalMint Health
* 设计 token 与 stitch-weekly.html tailwind.config 一一对应
*/
.stitch-vitalmint {
--primary: #006c49;
--on-primary: #ffffff;
--on-primary-fixed-variant: #005236;
--primary-container: #10b981;
--on-primary-container: #00422b;
--inverse-primary: #4edea3;
--surface: #f4fbf4;
--on-surface: #161d19;
--on-surface-variant: #3c4a42;
--surface-container-lowest: #ffffff;
--surface-container-low: #eef6ee;
--surface-container: #e8f0e9;
--surface-container-high: #e3eae3;
--surface-container-highest: #dde4dd;
--surface-variant: #dde4dd;
--outline: #6c7a71;
--outline-variant: #bbcabf;
--error: #ba1a1a;
--on-error-container: #93000a;
--error-container: #ffdad6;
--tertiary: #a43a3a;
--tertiary-container: #fc7c78;
--on-tertiary: #ffffff;
--on-tertiary-container: #711419;
--st-container-margin: 40rpx;
--st-section-gap: 64rpx;
--st-card-padding: 40rpx;
--st-stack-gap: 32rpx;
--st-inline-gap: 24rpx;
--st-radius-2xl: 48rpx;
--st-radius-xl: 24rpx;
--st-radius-lg: 16rpx;
--st-shadow-ambient: 0 20rpx 60rpx -20rpx rgba(0, 108, 73, 0.08);
}
.weekly-page.stitch-vitalmint {
min-height: 100vh;
background: var(--surface);
color: var(--on-surface);
padding-bottom: calc(200rpx + env(safe-area-inset-bottom));
}
File diff suppressed because it is too large Load Diff
+56 -56
View File
@@ -1,56 +1,56 @@
/** 将接口 msg / 异常对象转为可展示的字符串,避免 [object Object] */
export function formatUserMessage(msg, fallback = '') {
if (msg == null || msg === '') return fallback
if (typeof msg === 'string') return msg
if (typeof msg === 'number' || typeof msg === 'boolean') return String(msg)
if (Array.isArray(msg)) {
const parts = msg.map((item) => {
if (item == null) return ''
if (typeof item === 'string') return item
if (typeof item === 'number' || typeof item === 'boolean') return String(item)
if (typeof item === 'object') {
return item.msg || item.message || item.error || item.title || item.label || ''
}
return String(item)
}).filter(Boolean)
return parts.length ? parts.join('') : fallback
}
if (typeof msg === 'object') {
return msg.msg || msg.message || msg.error || msg.title || msg.label || fallback
}
return String(msg)
}
export function showUserToast(title, options = {}) {
const text = formatUserMessage(title, '')
if (!text) return
uni.showToast({
title: text,
icon: options.icon || 'none',
duration: options.duration
})
}
export function formatDate(date) {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
export function parseDate(str) {
if (!str) return new Date()
const [y, m, d] = str.split('-').map(Number)
return new Date(y, (m || 1) - 1, d || 1)
}
export function toNumber(v) {
if (v === null || v === undefined || v === '') return null
const n = Number(v)
return Number.isFinite(n) && n !== 0 ? n : null
}
export function getWeekday(dateStr) {
const w = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
return w[parseDate(dateStr).getDay()]
}
/** 将接口 msg / 异常对象转为可展示的字符串,避免 [object Object] */
export function formatUserMessage(msg, fallback = '') {
if (msg == null || msg === '') return fallback
if (typeof msg === 'string') return msg
if (typeof msg === 'number' || typeof msg === 'boolean') return String(msg)
if (Array.isArray(msg)) {
const parts = msg.map((item) => {
if (item == null) return ''
if (typeof item === 'string') return item
if (typeof item === 'number' || typeof item === 'boolean') return String(item)
if (typeof item === 'object') {
return item.msg || item.message || item.error || item.title || item.label || ''
}
return String(item)
}).filter(Boolean)
return parts.length ? parts.join('') : fallback
}
if (typeof msg === 'object') {
return msg.msg || msg.message || msg.error || msg.title || msg.label || fallback
}
return String(msg)
}
export function showUserToast(title, options = {}) {
const text = formatUserMessage(title, '')
if (!text) return
uni.showToast({
title: text,
icon: options.icon || 'none',
duration: options.duration
})
}
export function formatDate(date) {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
export function parseDate(str) {
if (!str) return new Date()
const [y, m, d] = str.split('-').map(Number)
return new Date(y, (m || 1) - 1, d || 1)
}
export function toNumber(v) {
if (v === null || v === undefined || v === '') return null
const n = Number(v)
return Number.isFinite(n) && n !== 0 ? n : null
}
export function getWeekday(dateStr) {
const w = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
return w[parseDate(dateStr).getDay()]
}
-2
View File
@@ -1,7 +1,6 @@
import { defineConfig } from 'vite'
import uni from '@dcloudio/vite-plugin-uni'
import Optimization from '@uni-ku/bundle-optimizer'
import { tongjiFoodAssetsPlugin } from './tongji/endless-game/build/tongjiFoodAssetsPlugin'
// uni-app 工程根目录就是源码目录(HBuilderX 兼容)
// 编译产物默认输出到 ./dist/<mode>/<platform>
@@ -17,7 +16,6 @@ export default defineConfig({
dts: false,
logger: false,
}),
tongjiFoodAssetsPlugin(),
],
css: {
preprocessorOptions: {
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
unpackage/
.DS_Store
*.log
.idea/
.vscode/
.hbuilderx/
+78
View File
@@ -0,0 +1,78 @@
<script setup>
import { onLaunch } from '@dcloudio/uni-app'
import { getCurrentInstance } from 'vue'
const { proxy } = getCurrentInstance()
onLaunch(() => {
// iOS 静音键模式下 InnerAudioContext 默认不发声
// #ifdef MP-WEIXIN
try {
uni.setInnerAudioOption({
obeyMuteSwitch: false,
mixWithOther: true,
})
} catch (e) {
console.warn('setInnerAudioOption failed:', e)
}
// #endif
uni.login({
provider: 'weixin',
success: function (loginRes) {
if (uni.getStorageSync('token')) {
userinfo(loginRes.code)
} else {
wxcode(loginRes.code)
}
},
})
})
const wxcode = async (code) => {
try {
const res = await proxy.apiUrl(
{
url: '/api/login/mnpLogin',
method: 'POST',
data: {
code: code,
},
},
false
)
if (res && res.data && res.data.token) {
uni.setStorageSync('token', res.data.token)
uni.setStorageSync('userData', res.data)
}
} catch (err) {}
}
const userinfo = async (code) => {
try {
const res = await proxy.apiUrl(
{
url: '/api/user/info',
method: 'POST',
},
false
)
if (res.code == -1) {
wxcode(code)
return
}
if (res && res.data) {
uni.setStorageSync('userData', res.data)
}
} catch (err) {}
}
</script>
<style>
/* 全局基础 */
page {
background-color: #f4fbf4;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
sans-serif;
}
</style>
+33
View File
@@ -0,0 +1,33 @@
# TongjiUniApp
`TUICallKit-Vue3` 抽出的独立 uni-app,保留血糖管理与「我的」相关能力。
## 包含功能
| 模块 | 说明 |
|------|------|
| 血糖管理 | `tongji/pages/weekly`(首页) |
| 日常护理 | `tongji/pages/more` |
| 血糖记录 | `tongji/pages/index` |
| 识糖小课堂 | `tongji/endless-game` |
| 我的 | 个人资料、就诊卡 |
| 居家耗糖训练 | `training` 分包(哑铃/脚蹬/瑜伽环/握力环/节拍器) |
底部导航:血糖 · 我的(已去掉原首页/视频问诊)。
## 开发
```bash
npm install
npm run dev:mp-weixin # 微信小程序 → dist/dev/mp-weixin
npm run build:mp-weixin
npm run dev:h5
```
- API`main.js``https://admin.zhenyangtang.com.cn/`
- 微信语音插件 WechatSI 已配置(`manifest.json` / `pages.json`
- 微信小程序 appid 沿用原工程:`wx79b9a0bfbfe7cbcd`(可在 `manifest.json` 修改)
## 说明
未移植:视频问诊(TUICallKit)、医生站、订单支付等。就诊卡未登录时回跳血糖首页,由 App 静默 `mnpLogin` 完成鉴权。
@@ -0,0 +1,123 @@
<template>
<view class="tab-dock">
<view class="tab-dock__inner">
<view class="tab-dock__item tab-dock__item--center" @click="goWeekly">
<view class="tab-dock__fab" :class="{ 'tab-dock__fab--active': active === 0 }">
<text class="tab-dock__fab-text"></text>
</view>
<text class="tab-dock__label tab-dock__label--center" :style="{ color: active === 0 ? activeColor : color }">血糖</text>
</view>
<view class="tab-dock__item" @click="goUser">
<image
class="tab-dock__icon"
:src="active === 1 ? '/static/user/user_no.png' : '/static/user/user.png'"
mode="aspectFit"
/>
<text class="tab-dock__label" :style="{ color: active === 1 ? activeColor : color }">我的</text>
</view>
</view>
<view class="tab-dock__safe" />
</view>
</template>
<script setup>
const props = defineProps({
/** 0 血糖 1 我的 */
active: {
type: Number,
default: 0
}
})
const color = '#8a8a8a'
const activeColor = '#204e2b'
function goUser() {
if (props.active === 1) return
uni.redirectTo({ url: '/pages/user/user' })
}
function goWeekly() {
if (props.active === 0) return
uni.redirectTo({ url: '/tongji/pages/weekly' })
}
</script>
<style scoped lang="scss">
.tab-dock {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 999;
background: #ffffff;
border-top: 1rpx solid rgba(0, 0, 0, 0.06);
box-shadow: 0 -6rpx 20rpx rgba(0, 0, 0, 0.04);
}
.tab-dock__inner {
display: flex;
align-items: flex-end;
height: 100rpx;
}
.tab-dock__item {
flex: 1;
height: 100rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.tab-dock__item--center {
justify-content: flex-end;
padding-bottom: 6rpx;
}
.tab-dock__icon {
width: 46rpx;
height: 46rpx;
}
.tab-dock__label {
margin-top: 6rpx;
font-size: 22rpx;
line-height: 1.2;
}
.tab-dock__label--center {
margin-top: 8rpx;
}
.tab-dock__fab {
width: 96rpx;
height: 96rpx;
margin-top: -48rpx;
border-radius: 50%;
background: linear-gradient(180deg, #2d7340 0%, #204e2b 100%);
border: 6rpx solid #ffffff;
box-shadow: 0 10rpx 24rpx rgba(32, 78, 43, 0.35);
display: flex;
align-items: center;
justify-content: center;
}
.tab-dock__fab--active {
transform: scale(1.04);
box-shadow: 0 12rpx 28rpx rgba(32, 78, 43, 0.42);
}
.tab-dock__fab-text {
color: #ffffff;
font-size: 38rpx;
font-weight: 700;
line-height: 1;
}
.tab-dock__safe {
height: env(safe-area-inset-bottom);
background: #ffffff;
}
</style>
@@ -0,0 +1,729 @@
<template>
<!-- 页面内嵌模块 weekly 统计页主内容区 -->
<view
v-if="visible && props.inline"
class="dev-entry-inline"
:class="{ 'dev-entry-inline--weekly': props.tone === 'weekly' }"
>
<view class="inline-hero">
<view class="inline-hero-icon" aria-hidden="true">
<view class="inline-hero-wave" />
</view>
<view class="inline-hero-text">
<view class="inline-hero-title-row">
<text class="inline-title">居家耗糖训练</text>
<text v-if="props.tone === 'weekly'" class="inline-pill">体验</text>
</view>
<text class="inline-sub">低强度动起来帮助身体消耗糖分</text>
</view>
</view>
<scroll-view
scroll-x
class="inline-scroll"
:show-scrollbar="false"
:enable-flex="true"
>
<view class="inline-scroll-track">
<view
v-for="item in inlineItems"
:key="item.url"
class="inline-card"
@click="goto(item.url)"
>
<view v-if="item.featured" class="inline-card-badge">
<text>推荐</text>
</view>
<view class="menu-icon" :class="item.iconClass">
<view v-if="item.icon === 'dumbbell'" class="dumbbell-shape">
<view class="dumbbell-plate dumbbell-plate-left" />
<view class="dumbbell-bar" />
<view class="dumbbell-plate dumbbell-plate-right" />
</view>
<view v-else-if="item.icon === 'pedal'" class="foot-pedal-shape">
<view class="pedal-bar pedal-bar-left" />
<view class="pedal-bar pedal-bar-right" />
</view>
<view v-else-if="item.icon === 'pilates'" class="pilates-ring-shape" />
<view v-else-if="item.icon === 'grip'" class="grip-ring-shape" />
<view v-else-if="item.icon === 'metro'" class="menu-icon--metro-inner">
<view class="metro-bar metro-bar-1" />
<view class="metro-bar metro-bar-2" />
<view class="metro-bar metro-bar-3" />
</view>
</view>
<text class="inline-card-title">{{ item.title }}</text>
<text class="inline-card-sub">{{ item.sub }}</text>
<text class="inline-card-go" aria-hidden="true">进入</text>
</view>
</view>
</scroll-view>
<text v-if="props.tone === 'weekly'" class="inline-scroll-hint">左右滑动选择适合您的训练</text>
</view>
<view v-else-if="visible" class="dev-entry-wrap" :style="{ bottom: props.bottom + 'rpx' }">
<!-- 展开后的菜单卡片(从下往上动画) -->
<view v-if="expanded" class="menu-list" @click.stop>
<view class="menu-item" @click="goto('/training/pages/dumbbell')">
<view class="menu-icon menu-icon--dumbbell">
<view class="dumbbell-shape">
<view class="dumbbell-plate dumbbell-plate-left" />
<view class="dumbbell-bar" />
<view class="dumbbell-plate dumbbell-plate-right" />
</view>
</view>
<view class="menu-info">
<text class="menu-title">哑铃</text>
<text class="menu-sub">力量塑形</text>
</view>
<view class="menu-arrow"></view>
</view>
<view class="menu-item" @click="goto('/training/pages/foot-pedal')">
<view class="menu-icon menu-icon--pedal">
<view class="foot-pedal-shape">
<view class="pedal-bar pedal-bar-left" />
<view class="pedal-bar pedal-bar-right" />
</view>
</view>
<view class="menu-info">
<text class="menu-title">脚蹬器</text>
<text class="menu-sub">下肢有氧</text>
</view>
<view class="menu-arrow"></view>
</view>
<view class="menu-item" @click="goto('/training/pages/pilates-ring')">
<view class="menu-icon menu-icon--pilates">
<view class="pilates-ring-shape" />
</view>
<view class="menu-info">
<text class="menu-title">瑜伽环</text>
<text class="menu-sub">核心塑形</text>
</view>
<view class="menu-arrow"></view>
</view>
<view class="menu-item" @click="goto('/training/pages/grip-ring')">
<view class="menu-icon menu-icon--grip">
<view class="grip-ring-shape" />
</view>
<view class="menu-info">
<text class="menu-title">握力环</text>
<text class="menu-sub">握力训练</text>
</view>
<view class="menu-arrow"></view>
</view>
<view class="menu-item" @click="goto('/training/pages/metronome')">
<view class="menu-icon menu-icon--metro">
<view class="metro-bar metro-bar-1" />
<view class="metro-bar metro-bar-2" />
<view class="metro-bar metro-bar-3" />
</view>
<view class="menu-info">
<text class="menu-title">耗糖节拍器</text>
<text class="menu-sub">健走配速</text>
</view>
<view class="menu-arrow"></view>
</view>
</view>
<!-- 主浮动按钮(单字"练" + 呼吸光环) -->
<view class="fab-container">
<view v-if="!expanded" class="fab-pulse" />
<view v-if="!expanded" class="fab-pulse fab-pulse-2" />
<view class="entry-fab" :class="{ expanded }" @click="toggle">
<view v-if="expanded" class="icon-close">
<view class="cross-bar bar-1" />
<view class="cross-bar bar-2" />
</view>
<!-- 心跳波形 icon: 健康主题,SVG 折线 -->
<view v-else class="fab-icon" />
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
type TrainingItem = {
url: string
title: string
sub: string
icon: 'dumbbell' | 'pedal' | 'pilates' | 'grip' | 'metro'
iconClass: string
featured?: boolean
}
const trainingItems: TrainingItem[] = [
{ url: '/training/pages/dumbbell', title: '哑铃', sub: '力量塑形', icon: 'dumbbell', iconClass: 'menu-icon--dumbbell' },
{ url: '/training/pages/foot-pedal', title: '脚蹬器', sub: '下肢有氧', icon: 'pedal', iconClass: 'menu-icon--pedal' },
{ url: '/training/pages/pilates-ring', title: '瑜伽环', sub: '核心塑形', icon: 'pilates', iconClass: 'menu-icon--pilates' },
{ url: '/training/pages/grip-ring', title: '握力环', sub: '握力训练', icon: 'grip', iconClass: 'menu-icon--grip' },
{ url: '/training/pages/metronome', title: '耗糖节拍器', sub: '健走配速', icon: 'metro', iconClass: 'menu-icon--metro', featured: true },
]
const props = withDefaults(
defineProps<{
/** 悬浮按钮距底部 rpx(仅非 inline */
bottom?: number
/** 内嵌到页面主内容区,非 fixed 悬浮 */
inline?: boolean
/** weekly 页:与统计页 Vital Mint 视觉一致 */
tone?: 'default' | 'weekly'
}>(),
{ bottom: 200, inline: false, tone: 'default' },
)
/** weekly 将健走节拍器置前并标推荐 */
const inlineItems = computed(() => {
if (props.tone !== 'weekly') return trainingItems
const metro = trainingItems.find((i) => i.icon === 'metro')
const rest = trainingItems.filter((i) => i.icon !== 'metro')
return metro ? [metro, ...rest] : trainingItems
})
// 居家耗糖训练是正式功能,开发版和发行版都需要展示。
const visible = ref(true)
const expanded = ref(false)
const toggle = () => {
expanded.value = !expanded.value
}
const goto = (url: string) => {
expanded.value = false
uni.navigateTo({ url })
}
</script>
<style lang="scss" scoped>
$brand: #10b981;
$brand-deep: #047857;
$brand-light: #34d399;
.dev-entry-wrap {
position: fixed;
right: 24rpx;
z-index: 999;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 18rpx;
}
/* ============================================================
* 主浮动按钮
* ============================================================ */
.fab-container {
position: relative;
width: 108rpx;
height: 108rpx;
}
.entry-fab {
position: absolute;
inset: 0;
border-radius: 50%;
background: linear-gradient(140deg, $brand-light 0%, $brand-deep 100%);
box-shadow:
0 8rpx 20rpx rgba(16, 185, 129, 0.42),
0 2rpx 6rpx rgba(15, 23, 42, 0.12),
inset 0 -8rpx 16rpx rgba(0, 0, 0, 0.12),
inset 0 8rpx 16rpx rgba(255, 255, 255, 0.18);
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.2s;
&:active {
transform: scale(0.92);
}
&.expanded {
background: linear-gradient(140deg, #94a3b8 0%, #475569 100%);
transform: rotate(135deg);
box-shadow:
0 6rpx 16rpx rgba(15, 23, 42, 0.2),
inset 0 -6rpx 14rpx rgba(0, 0, 0, 0.12),
inset 0 6rpx 14rpx rgba(255, 255, 255, 0.12);
}
.fab-icon {
width: 60rpx;
height: 60rpx;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'><polyline points='2,12 7,12 9.5,7 12,17 14.5,9 16.5,12 22,12'/></svg>");
background-size: contain;
background-repeat: no-repeat;
background-position: center;
filter: drop-shadow(0 2rpx 6rpx rgba(0, 0, 0, 0.22));
animation: fab-icon-beat 1.6s ease-in-out infinite;
}
}
@keyframes fab-icon-beat {
0%, 60%, 100% {
transform: scale(1);
}
20% {
transform: scale(1.12);
}
40% {
transform: scale(1);
}
}
/* 关闭态 X 图标 (CSS 几何) */
.icon-close {
position: relative;
width: 36rpx;
height: 36rpx;
.cross-bar {
position: absolute;
top: 50%;
left: 0;
width: 100%;
height: 5rpx;
background: #fff;
border-radius: 3rpx;
transform-origin: center;
}
.bar-1 {
transform: translateY(-50%) rotate(45deg);
}
.bar-2 {
transform: translateY(-50%) rotate(-45deg);
}
}
/* 呼吸光环(2 圈错相位扩散) */
.fab-pulse {
position: absolute;
inset: 0;
border-radius: 50%;
background: rgba(16, 185, 129, 0.25);
animation: fab-ring 2.4s ease-out infinite;
pointer-events: none;
}
.fab-pulse-2 {
animation-delay: 1.2s;
}
@keyframes fab-ring {
0% {
transform: scale(0.95);
opacity: 0.7;
}
100% {
transform: scale(1.55);
opacity: 0;
}
}
/* ============================================================
* 展开菜单
* ============================================================ */
.menu-list {
display: flex;
flex-direction: column;
gap: 14rpx;
animation: slide-up 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(20rpx) scale(0.92);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.menu-item {
display: flex;
align-items: center;
gap: 16rpx;
background: rgba(255, 255, 255, 0.96);
padding: 14rpx 20rpx 14rpx 14rpx;
border-radius: 999rpx;
box-shadow:
0 8rpx 24rpx rgba(15, 23, 42, 0.1),
0 1rpx 2rpx rgba(15, 23, 42, 0.06);
min-width: 280rpx;
transition: transform 0.15s;
backdrop-filter: blur(12rpx);
&:active {
transform: scale(0.97);
background: #f0fdf4;
}
}
.menu-icon {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 2rpx 8rpx rgba(16, 185, 129, 0.32);
&--train {
background: linear-gradient(140deg, $brand-light, $brand-deep);
}
&--dumbbell {
background: linear-gradient(140deg, #93c5fd, #3b82f6);
box-shadow: 0 2rpx 8rpx rgba(59, 130, 246, 0.36);
}
&--pedal {
background: linear-gradient(140deg, #fdba74, #f97316);
box-shadow: 0 2rpx 8rpx rgba(249, 115, 22, 0.36);
}
&--pilates {
background: linear-gradient(140deg, #c4b5fd, #8b5cf6);
box-shadow: 0 2rpx 8rpx rgba(139, 92, 246, 0.36);
}
&--grip {
background: linear-gradient(140deg, #5eead4, #14b8a6);
box-shadow: 0 2rpx 8rpx rgba(20, 184, 166, 0.36);
}
&--metro {
background: linear-gradient(140deg, #fbbf24, #d97706);
box-shadow: 0 2rpx 8rpx rgba(245, 158, 11, 0.36);
gap: 4rpx;
}
}
.menu-icon-text {
font-size: 32rpx;
line-height: 1;
}
/* 哑铃小 icon */
.dumbbell-shape {
width: 40rpx;
height: 20rpx;
display: flex;
align-items: center;
justify-content: center;
}
.dumbbell-plate {
width: 10rpx;
height: 18rpx;
background: #fff;
border-radius: 3rpx;
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.18);
}
.dumbbell-bar {
width: 16rpx;
height: 5rpx;
background: #fff;
border-radius: 2rpx;
margin: 0 2rpx;
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.18);
}
/* 脚蹬器小 icon: 左右踏板 */
.foot-pedal-shape {
width: 40rpx;
height: 28rpx;
display: flex;
align-items: flex-end;
justify-content: center;
gap: 6rpx;
}
.pedal-bar {
width: 14rpx;
background: #fff;
border-radius: 4rpx 4rpx 2rpx 2rpx;
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.18);
}
.pedal-bar-left {
height: 18rpx;
transform: rotate(-12deg);
}
.pedal-bar-right {
height: 22rpx;
transform: rotate(12deg);
}
/* 瑜伽环小 icon: 双弧阻力环 */
.pilates-ring-shape {
width: 36rpx;
height: 36rpx;
border-radius: 50%;
border: 5rpx solid #fff;
position: relative;
box-shadow:
0 1rpx 2rpx rgba(0, 0, 0, 0.18),
inset 0 1rpx 2rpx rgba(255, 255, 255, 0.6);
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 18rpx;
height: 18rpx;
transform: translate(-50%, -50%);
border-radius: 50%;
border: 3rpx solid rgba(255, 255, 255, 0.85);
}
}
/* 握力环小 icon: 白色描边的环(甜甜圈) */
.grip-ring-shape {
width: 32rpx;
height: 32rpx;
border-radius: 50%;
border: 7rpx solid #fff;
box-shadow:
0 1rpx 2rpx rgba(0, 0, 0, 0.18),
inset 0 1rpx 2rpx rgba(255, 255, 255, 0.6);
}
/* 节拍器小 icon: 3 根高低柱模拟均衡器/节拍 */
.metro-bar {
width: 4rpx;
background: #fff;
border-radius: 2rpx;
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.18);
}
.metro-bar-1 {
height: 16rpx;
}
.metro-bar-2 {
height: 28rpx;
}
.metro-bar-3 {
height: 22rpx;
}
.menu-info {
display: flex;
flex-direction: column;
gap: 2rpx;
flex: 1;
}
.menu-title {
font-size: 28rpx;
color: #0f172a;
font-weight: 700;
letter-spacing: 1rpx;
}
.menu-sub {
font-size: 20rpx;
color: #94a3b8;
letter-spacing: 0.5rpx;
}
.menu-arrow {
font-size: 32rpx;
color: #cbd5e1;
font-weight: 300;
line-height: 1;
margin-left: 4rpx;
}
.menu-icon--metro-inner {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 4rpx;
width: 100%;
height: 100%;
}
/* ============================================================
* 内嵌模块(weekly 等)
* ============================================================ */
.dev-entry-inline {
width: 100%;
box-sizing: border-box;
}
.dev-entry-inline--weekly {
padding: 32rpx 28rpx 24rpx;
border-radius: 48rpx;
background: var(--surface-container-lowest, #fff);
border: 1rpx solid rgba(0, 108, 73, 0.12);
box-shadow:
0 24rpx 60rpx -20rpx rgba(0, 108, 73, 0.08),
0 8rpx 20rpx -8rpx rgba(0, 0, 0, 0.03);
}
.inline-hero {
display: flex;
align-items: center;
gap: 20rpx;
margin-bottom: 24rpx;
}
.inline-hero-icon {
width: 88rpx;
height: 88rpx;
border-radius: 24rpx;
background: rgba(0, 108, 73, 0.1);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.inline-hero-wave {
width: 52rpx;
height: 52rpx;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23006c49' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'><polyline points='2,12 7,12 9.5,7 12,17 14.5,9 16.5,12 22,12'/></svg>");
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
.inline-hero-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.inline-hero-title-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10rpx;
}
.inline-title {
font-size: 32rpx;
font-weight: 700;
color: #0f172a;
line-height: 1.25;
}
.inline-pill {
font-size: 20rpx;
font-weight: 700;
color: #006c49;
padding: 4rpx 12rpx;
border-radius: 999rpx;
background: rgba(0, 108, 73, 0.1);
line-height: 1.2;
}
.inline-sub {
font-size: 26rpx;
font-weight: 500;
color: #475569;
line-height: 1.4;
}
.inline-scroll {
width: 100%;
white-space: nowrap;
}
.inline-scroll-track {
display: inline-flex;
flex-direction: row;
align-items: stretch;
gap: 16rpx;
padding: 4rpx 2rpx 8rpx;
}
.inline-card {
position: relative;
display: inline-flex;
flex-direction: column;
align-items: flex-start;
width: 212rpx;
min-height: 220rpx;
padding: 20rpx 18rpx 18rpx;
border-radius: 28rpx;
background: #f4f7f5;
border: 1rpx solid rgba(0, 108, 73, 0.1);
box-sizing: border-box;
flex-shrink: 0;
transition: transform 0.2s ease, background 0.2s ease;
&:active {
transform: scale(0.98);
background: #e8f3ee;
}
.menu-icon {
width: 72rpx;
height: 72rpx;
margin-bottom: 14rpx;
flex-shrink: 0;
}
}
.inline-card-badge {
position: absolute;
top: 12rpx;
right: 12rpx;
padding: 4rpx 10rpx;
border-radius: 8rpx;
background: #006c49;
text {
font-size: 18rpx;
font-weight: 700;
color: #fff;
line-height: 1.2;
}
}
.inline-card-title {
font-size: 28rpx;
font-weight: 700;
color: #0f172a;
line-height: 1.25;
width: 100%;
}
.inline-card-sub {
margin-top: 6rpx;
font-size: 22rpx;
font-weight: 500;
color: #64748b;
line-height: 1.35;
width: 100%;
}
.inline-card-go {
margin-top: auto;
padding-top: 12rpx;
font-size: 22rpx;
font-weight: 700;
color: #006c49;
line-height: 1.2;
}
.inline-scroll-hint {
display: block;
margin-top: 14rpx;
font-size: 22rpx;
font-weight: 600;
color: #94a3b8;
text-align: center;
line-height: 1.35;
}
@media (prefers-reduced-motion: reduce) {
.inline-card:active {
transform: none;
}
}
</style>
+1
View File
@@ -0,0 +1 @@
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./common/vendor.js");exports.createApp=e.createApp;
+42
View File
@@ -0,0 +1,42 @@
{
"pages": [
"tongji/pages/weekly",
"pages/user/user",
"pages/user/userinfo",
"pages/Card/Card",
"pages/Card/edit_card",
"pages/Card/contact",
"tongji/pages/index",
"tongji/pages/more",
"tongji/endless-game/index"
],
"subPackages": [
{
"root": "training",
"pages": [
"pages/grip-ring",
"pages/pilates-ring",
"pages/foot-pedal",
"pages/dumbbell",
"pages/metronome"
]
}
],
"window": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "甄养堂血糖管理",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F8F8F8"
},
"lazyCodeLoading": "requiredComponents",
"requiredBackgroundModes": [
"audio"
],
"plugins": {
"WechatSI": {
"version": "0.3.5",
"provider": "wx069ba97219f66d99"
}
},
"usingComponents": {}
}
+2
View File
@@ -0,0 +1,2 @@
page{background-color:#f4fbf4;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif}
page::after{position:fixed;content:'';left:-1000px;top:-1000px;-webkit-animation:shadow-preload .1s;-webkit-animation-delay:3s;animation:shadow-preload .1s;animation-delay:3s}@-webkit-keyframes shadow-preload{0%{background-image:url(https://cdn1.dcloud.net.cn/5645394f5230704a4d44456c4a5864344e7a6c694f574577596d5a695a6d553359324a6a5a413d3d/img/shadow-grey.png)}100%{background-image:url(https://cdn1.dcloud.net.cn/5645394f5230704a4d44456c4a5864344e7a6c694f574577596d5a695a6d553359324a6a5a413d3d/img/shadow-grey.png)}}@keyframes shadow-preload{0%{background-image:url(https://cdn1.dcloud.net.cn/5645394f5230704a4d44456c4a5864344e7a6c694f574577596d5a695a6d553359324a6a5a413d3d/img/shadow-grey.png)}100%{background-image:url(https://cdn1.dcloud.net.cn/5645394f5230704a4d44456c4a5864344e7a6c694f574577596d5a695a6d553359324a6a5a413d3d/img/shadow-grey.png)}}page{--status-bar-height:25px;--top-window-height:0px;--window-top:0px;--window-bottom:0px;--window-left:0px;--window-right:0px;--window-magin:0px}[data-c-h="true"]{display: none !important;}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
"use strict";const e=require("../../common/vendor.js"),t="#8a8a8a",c="#204e2b",r={__name:"tab-bar-dock",props:{active:{type:Number,default:0}},setup(r){const a=r;function o(){1!==a.active&&e.index.redirectTo({url:"/pages/user/user"})}function s(){0!==a.active&&e.index.redirectTo({url:"/tongji/pages/weekly"})}return(a,i)=>({a:0===r.active?1:"",b:0===r.active?c:t,c:e.o(s),d:1===r.active?"/static/user/user_no.png":"/static/user/user.png",e:1===r.active?c:t,f:e.o(o)})}},a=e._export_sfc(r,[["__scopeId","data-v-6c5d8930"]]);wx.createComponent(a);
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
@@ -0,0 +1 @@
<view class="tab-dock data-v-6c5d8930"><view class="tab-dock__inner data-v-6c5d8930"><view class="tab-dock__item tab-dock__item--center data-v-6c5d8930" bindtap="{{c}}"><view class="{{['tab-dock__fab', 'data-v-6c5d8930', a && 'tab-dock__fab--active']}}"><text class="tab-dock__fab-text data-v-6c5d8930">糖</text></view><text class="tab-dock__label tab-dock__label--center data-v-6c5d8930" style="{{'color:' + b}}">血糖</text></view><view class="tab-dock__item data-v-6c5d8930" bindtap="{{f}}"><image class="tab-dock__icon data-v-6c5d8930" src="{{d}}" mode="aspectFit"/><text class="tab-dock__label data-v-6c5d8930" style="{{'color:' + e}}">我的</text></view></view><view class="tab-dock__safe data-v-6c5d8930"/></view>
@@ -0,0 +1 @@
.tab-dock.data-v-6c5d8930{position:fixed;left:0;right:0;bottom:0;z-index:999;background:#fff;border-top:1rpx solid rgba(0,0,0,.06);box-shadow:0 -6rpx 20rpx rgba(0,0,0,.04)}.tab-dock__inner.data-v-6c5d8930{display:flex;align-items:flex-end;height:100rpx}.tab-dock__item.data-v-6c5d8930{flex:1;height:100rpx;display:flex;flex-direction:column;align-items:center;justify-content:center}.tab-dock__item--center.data-v-6c5d8930{justify-content:flex-end;padding-bottom:6rpx}.tab-dock__icon.data-v-6c5d8930{width:46rpx;height:46rpx}.tab-dock__label.data-v-6c5d8930{margin-top:6rpx;font-size:22rpx;line-height:1.2}.tab-dock__label--center.data-v-6c5d8930{margin-top:8rpx}.tab-dock__fab.data-v-6c5d8930{width:96rpx;height:96rpx;margin-top:-48rpx;border-radius:50%;background:linear-gradient(180deg,#2d7340,#204e2b);border:6rpx solid #ffffff;box-shadow:0 10rpx 24rpx rgba(32,78,43,.35);display:flex;align-items:center;justify-content:center}.tab-dock__fab--active.data-v-6c5d8930{transform:scale(1.04);box-shadow:0 12rpx 28rpx rgba(32,78,43,.42)}.tab-dock__fab-text.data-v-6c5d8930{color:#fff;font-size:38rpx;font-weight:700;line-height:1}.tab-dock__safe.data-v-6c5d8930{height:env(safe-area-inset-bottom);background:#fff}
@@ -0,0 +1 @@
"use strict";const e=require("../../common/vendor.js"),n=e.defineComponent({__name:"index",props:{bottom:{default:200},inline:{type:Boolean,default:!1},tone:{default:"default"}},setup(n){const i=[{url:"/training/pages/dumbbell",title:"哑铃",sub:"力量塑形",icon:"dumbbell",iconClass:"menu-icon--dumbbell"},{url:"/training/pages/foot-pedal",title:"脚蹬器",sub:"下肢有氧",icon:"pedal",iconClass:"menu-icon--pedal"},{url:"/training/pages/pilates-ring",title:"瑜伽环",sub:"核心塑形",icon:"pilates",iconClass:"menu-icon--pilates"},{url:"/training/pages/grip-ring",title:"握力环",sub:"握力训练",icon:"grip",iconClass:"menu-icon--grip"},{url:"/training/pages/metronome",title:"耗糖节拍器",sub:"健走配速",icon:"metro",iconClass:"menu-icon--metro",featured:!0}],o=n,l=e.computed(()=>{if("weekly"!==o.tone)return i;const e=i.find(e=>"metro"===e.icon),n=i.filter(e=>"metro"!==e.icon);return e?[e,...n]:i}),t=e.ref(!0),a=e.ref(!1),r=()=>{a.value=!a.value},u=n=>{a.value=!1,e.index.navigateTo({url:n})};return(n,i)=>e.e({a:t.value&&o.inline},t.value&&o.inline?e.e({b:"weekly"===o.tone},(o.tone,{}),{c:e.f(l.value,(n,i,o)=>e.e({a:n.featured},(n.featured,{}),{b:"dumbbell"===n.icon},("dumbbell"===n.icon||"pedal"===n.icon||"pilates"===n.icon||"grip"===n.icon||n.icon,{}),{c:"pedal"===n.icon,d:"pilates"===n.icon,e:"grip"===n.icon,f:"metro"===n.icon,g:e.n(n.iconClass),h:e.t(n.title),i:e.t(n.sub),j:n.url,k:e.o(e=>u(n.url),n.url)})),d:"weekly"===o.tone},(o.tone,{}),{e:"weekly"===o.tone?1:""}):t.value?e.e({g:a.value},a.value?{h:e.o(e=>u("/training/pages/dumbbell")),i:e.o(e=>u("/training/pages/foot-pedal")),j:e.o(e=>u("/training/pages/pilates-ring")),k:e.o(e=>u("/training/pages/grip-ring")),l:e.o(e=>u("/training/pages/metronome")),m:e.o(()=>{})}:{},{n:!a.value},(a.value,{}),{o:!a.value},(a.value,{}),{p:a.value},(a.value,{}),{q:a.value?1:"",r:e.o(r),s:o.bottom+"rpx"}):{},{f:t.value})}}),i=e._export_sfc(n,[["__scopeId","data-v-2ae07c93"]]);wx.createComponent(i);
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
"use strict";const e=require("../../common/vendor.js");if(!Array){e.resolveComponent("uni-icons")()}Math;const t={__name:"Card",setup(t){const{proxy:n}=e.getCurrentInstance(),a=e.ref([]),i=e.ref(!1);e.onMounted(()=>{o()}),e.onShow(()=>{o()});const o=async()=>{var t;i.value=!0;try{if(!e.index.getStorageSync("token"))return void e.index.showToast({title:"请先登录",icon:"none"});const i=e.index.getStorageSync("userData"),o=null==(t=null==i?void 0:i.diagnosis)?void 0:t.patient_id;if(!o)return;const d=await n.apiUrl({url:"/api/tcm/getCardList",method:"GET",data:{patient_id:o}});1===d.code?a.value=d.data||[]:e.index.showToast({title:d.msg||"获取失败",icon:"none"})}catch(o){console.error("加载就诊卡列表失败:",o)}finally{i.value=!1}},d=t=>{e.index.navigateTo({url:`/pages/Card/edit_card?id=${t.id}`})},s=()=>{e.index.getStorageSync("token")?e.index.navigateTo({url:"/pages/Card/edit_card?add=1"}):e.index.showToast({title:"请先登录",icon:"none"})},r=e=>1===e?"status-confirmed":"status-pending",c={first_visit:"初诊",follow_up:"复诊",consultation:"会诊"},l={qi_deficiency:"气虚",blood_deficiency:"血虚",yin_deficiency:"阴虚",yang_deficiency:"阳虚",qi_stagnation:"气滞",blood_stasis:"血瘀",phlegm_dampness:"痰湿",damp_heat:"湿热",cold_dampness:"寒湿",wind_cold:"风寒",wind_heat:"风热"},g=e=>l[e]||e||"-",_=e=>{if(!e)return"-";const t=new Date(1e3*e);return`${t.getFullYear()}-${String(t.getMonth()+1).padStart(2,"0")}-${String(t.getDate()).padStart(2,"0")}`};return(t,n)=>e.e({a:e.f(a.value,(t,n,a)=>{return{a:e.t(t.id),b:e.t(t.status_desc||(o=t.status,1===o?"启用":"禁用")),c:e.n(r(t.status)),d:e.t(t.patient_name||"-"),e:e.t(t.gender_desc||"-"),f:e.t(t.age),g:e.t((i=t.diagnosis_type,c[i]||i||"-")),h:e.t(g(t.syndrome_type)),i:e.t(t.diagnosis_date_text||_(t.diagnosis_date)),j:e.t(t.create_time||"-"),k:e.o(n=>(t=>{const n=`/tongji/pages/index?diagnosis_id=${t.id}&patient_id=${t.patient_id||""}&patient_name=${encodeURIComponent(t.patient_name||"")}&age=${t.age||""}&gender=${t.gender||""}`;e.index.navigateTo({url:n})})(t),t.id),l:e.o(e=>d(t),t.id),m:t.id,n:e.o(e=>d(t),t.id)};var i,o}),b:!i.value&&0===a.value.length},i.value||0!==a.value.length?{}:{c:e.p({type:"wallet",size:"80",color:"#1890ff"}),d:e.o(s)},{e:i.value},(i.value,{}))}},n=e._export_sfc(t,[["__scopeId","data-v-341940bb"]]);t.__runtimeHooks=2,wx.createPage(n);
@@ -0,0 +1,6 @@
{
"navigationBarTitleText": "就诊卡",
"usingComponents": {
"uni-icons": "../../uni_modules/uni-icons/components/uni-icons/uni-icons"
}
}

Some files were not shown because too many files have changed in this diff Show More