
Water Tracker
- 1 installs
- 27 repo stars
- Updated June 18, 2026
- tencentcloudbase/awesome-miniprogram-skills
A WeChat Mini Program skill for logging daily water intake and reviewing recent drinking history.
About
Adds a water-logging capability to a WeChat Mini Program for recording daily intake and viewing recent history. A developer uses it as a simple scenario template for habit-tracking features.
- Records daily water intake by volume
- Displays recent drinking history
Water Tracker by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,983 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tencentcloudbase/awesome-miniprogram-skills --skill water-trackerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 27 |
| Last updated | June 18, 2026 |
| Repository | tencentcloudbase/awesome-miniprogram-skills ↗ |
What it does
A WeChat Mini Program skill for logging daily water intake and reviewing recent drinking history.
Files
喝水记录
记录每日饮水并查看近期喝水情况的能力集合。
触发场景
用户原话举例(路由命中本技能):
- "我刚喝了 250 毫升水,帮我记一下"
- "记录一杯 300ml 的水"
- "今天喝了多少水了"
- "看下我最近几天的喝水情况"
- "帮我回顾一下今天的饮水记录"
不适用范围
- 饮食营养建议、疾病诊断、医疗用水建议 → 不在本技能范围
- 运动、睡眠、体重等健康数据记录 → 不在本技能范围
前置条件
- 需要用户从小程序 AI 场景调用,以便获取当前用户身份。
- 当前 mock 数据源已开启;切回真实接口后需要小程序云开发环境可用。
使用顺序
- 记录喝水时需要先知道本次喝水毫升数。
- 查看记录可直接进行,默认查看近期记录和今日明细。
const {
callWaterTracker,
errorResult,
formatDaily,
normalizeAmount,
successResult,
} = require("../utils/util");
async function addWaterRecord(params = {}) {
console.info("[ai-mode] addWaterRecord 入口, params=", JSON.stringify(params));
try {
const amountMl = normalizeAmount(params.amountMl, "喝水量", 1, 5000);
const note = typeof params.note === "string" ? params.note.slice(0, 80) : "";
console.info(
"[ai-mode] addWaterRecord 请求前 amountMl=",
amountMl,
"note=",
note
);
const rawDaily = await callWaterTracker({
type: "addWater",
amountMl,
note,
});
const daily = formatDaily(rawDaily || {});
const structuredContent = {
date: daily.date,
addedAmountMl: amountMl,
totalMl: daily.totalMl,
goalMl: daily.goalMl,
progressPercent: daily.progressPercent,
remainingMl: daily.remainingMl,
statusText: daily.statusText,
records: daily.records,
};
console.info(
"[ai-mode] addWaterRecord 出口 structuredContent=",
JSON.stringify(structuredContent)
);
return successResult(
`已记录 ${amountMl} ml,今天累计 ${daily.totalMl} ml。${daily.statusText}`,
structuredContent
);
} catch (error) {
console.error("[ai-mode] addWaterRecord 出错:", error.message);
return errorResult(`记录喝水失败: ${error.message}`);
}
}
module.exports = { addWaterRecord };
const {
callWaterTracker,
errorResult,
formatDaily,
formatHistory,
normalizeAmount,
successResult,
} = require("../utils/util");
async function getWaterRecords(params = {}) {
console.info("[ai-mode] getWaterRecords 入口, params=", JSON.stringify(params));
try {
const days =
params.days === undefined
? 7
: normalizeAmount(params.days, "查询天数", 1, 90);
console.info("[ai-mode] getWaterRecords 请求前 days=", days);
const todayPromise = callWaterTracker({
type: "getToday",
});
const historyPromise = callWaterTracker({
type: "listDaily",
days,
});
const rawToday = await todayPromise;
const rawHistory = await historyPromise;
const today = formatDaily(rawToday || {});
const history = formatHistory(rawHistory || []);
const structuredContent = {
today,
days: history,
requestedDays: days,
totalDays: history.length,
};
console.info(
"[ai-mode] getWaterRecords 出口 structuredContent=",
JSON.stringify(structuredContent)
);
return successResult(
`今天已喝 ${today.totalMl} ml,${today.statusText}。已拉取 ${history.length} 天记录。`,
structuredContent
);
} catch (error) {
console.error("[ai-mode] getWaterRecords 出错:", error.message);
return errorResult(`拉取喝水记录失败: ${error.message}`);
}
}
module.exports = { getWaterRecords };
// water-tracker-handler 云函数
const cloud = require('wx-server-sdk')
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
const db = cloud.database()
const DEFAULT_GOAL_ML = 2000
function buildDaily(date, records) {
const totalMl = records.reduce((sum, r) => sum + Number(r.amountMl || 0), 0)
return {
date,
totalMl,
goalMl: DEFAULT_GOAL_ML,
records: records.map(r => ({
amountMl: Number(r.amountMl || 0),
note: r.note || '',
drankAt: r.drankAt || ''
}))
}
}
function getDateRange(days) {
const list = []
for (let i = 0; i < days; i++) {
const d = new Date()
d.setDate(d.getDate() - i)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
list.push(`${y}-${m}-${day}`)
}
return list
}
exports.main = async (event, context) => {
const { action, openid, amountMl, note, date, days } = event
try {
// 添加饮水记录
if (action === 'addWater') {
if (!openid || amountMl === undefined) {
return { code: -1, message: '缺少必要参数' }
}
const today = date || new Date().toISOString().slice(0, 10)
const drankAt = new Date().toISOString()
await db.collection('water_daily').add({
data: { openid, amountMl: Number(amountMl), note: note || '', date: today, drankAt }
})
// 查当天全部记录
const { data: records } = await db.collection('water_daily')
.where({ openid, date: today })
.get()
return { code: 0, data: buildDaily(today, records) }
}
// 查询当天记录
if (action === 'getToday') {
if (!openid || !date) {
return { code: -1, message: '缺少必要参数' }
}
const { data: records } = await db.collection('water_daily')
.where({ openid, date })
.get()
return { code: 0, data: buildDaily(date, records) }
}
// 查询最近 N 天记录
if (action === 'listDaily') {
if (!openid) {
return { code: -1, message: '缺少 openid' }
}
const n = Number(days || 7)
const dateList = getDateRange(n)
const result = []
for (const d of dateList) {
const { data: records } = await db.collection('water_daily')
.where({ openid, date: d })
.get()
result.push(buildDaily(d, records))
}
return { code: 0, data: result }
}
// 查询用户配置
if (action === 'getProfile') {
if (!openid) {
return { code: -1, message: '缺少 openid' }
}
const { data: profiles } = await db.collection('water_profile')
.where({ openid })
.get()
return { code: 0, data: profiles.length > 0 ? profiles[0] : null }
}
// 保存/更新用户配置
if (action === 'saveProfile') {
if (!openid) {
return { code: -1, message: '缺少 openid' }
}
const { goalMl, remindInterval, remindStart, remindEnd } = event
const { data: existing } = await db.collection('water_profile')
.where({ openid })
.get()
const profile = {
openid,
goalMl: Number(goalMl || DEFAULT_GOAL_ML),
remindInterval: remindInterval || 60,
remindStart: remindStart || '08:00',
remindEnd: remindEnd || '22:00'
}
if (existing.length > 0) {
await db.collection('water_profile').doc(existing[0]._id).update({ data: profile })
} else {
await db.collection('water_profile').add({ data: profile })
}
return { code: 0, data: profile }
}
return { code: -1, message: `未知 action: ${action}` }
} catch (err) {
console.error('[water-tracker-handler] error:', err.message)
return { code: -1, message: err.message }
}
}
{
"name": "water-tracker-handler",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"wx-server-sdk": "latest"
}
}
Component({
data: {
addedAmountMl: 0,
date: "",
totalMl: 0,
goalMl: 2000,
progressPercent: 0,
statusText: "",
},
lifetimes: {
created() {
console.info("[ai-mode] add-water-result created");
const { NotificationType } = wx.modelContext;
const modelCtx = wx.modelContext.getContext(this);
const viewCtx = wx.modelContext.getViewContext(this);
try {
const dimensions = viewCtx.getDimensions();
console.info(
`[ai-mode] add-water-result dimensions width=${dimensions.width} minHeight=${dimensions.minHeight} maxHeight=${dimensions.maxHeight}`
);
} catch (e) {
console.info("[ai-mode] add-water-result getDimensions skipped:", e.message);
}
modelCtx.on(NotificationType.Result, (data) => {
const structuredContent =
data.result && data.result.structuredContent;
console.info(
"[ai-mode] add-water-result 收到 Result:",
JSON.stringify(structuredContent)
);
if (!structuredContent) {
return;
}
this.setData({
addedAmountMl: structuredContent.addedAmountMl || 0,
date: structuredContent.date || "",
totalMl: structuredContent.totalMl || 0,
goalMl: structuredContent.goalMl || 2000,
progressPercent: structuredContent.progressPercent || 0,
statusText: structuredContent.statusText || "",
});
console.info(
"[ai-mode] add-water-result setData added=",
structuredContent.addedAmountMl
);
});
viewCtx.on(NotificationType.Overflow, (data) => {
const overflowed = !!(data && data.overflowHeight > 0);
console.info(
`[ai-mode] add-water-result overflow overflowed=${overflowed} data=${JSON.stringify(data)}`
);
});
console.info("[ai-mode] add-water-result overflow monitor=on");
},
},
methods: {
onTapReview() {
const args = {
days: 7,
};
console.info(
`[ai-mode] add-water-result send api/call name=getWaterRecords args=${JSON.stringify(args)}`
);
wx.modelContext.getContext(this).sendFollowUpMessage({
content: [
{
type: "text",
text: "看喝水记录",
},
{
type: "api/call",
data: {
name: "getWaterRecords",
arguments: args,
},
},
],
});
},
},
});
{
"component": true,
"usingComponents": {}
}
<view class="water-card">
<view class="card-header">
<view class="title">已记录 {{addedAmountMl}} ml</view>
<view class="date">{{date}}</view>
</view>
<view class="summary">
<view class="total">{{totalMl}}</view>
<view class="unit">/ {{goalMl}} ml</view>
</view>
<view class="bar">
<view class="bar-fill" style="width: {{progressPercent}}%;"></view>
</view>
<view class="status">{{statusText}} · 今日 {{progressPercent}}%</view>
<view
class="primary-action"
hover-class="primary-action-hover"
bindtap="onTapReview"
>
查看记录
</view>
</view>
/* 样式参考:miniprogram/pages/index/index.wxss
* ratio=4:3;色源:app.json window.backgroundColor #F6F6F6 + 首页主色 #0f82a8 / #1f9fc8 / 主文字 #182326
* 暗黑:源项目无 darkmode,按浅色底降明度得到 #101f24 / #153642
*/
.water-card {
box-sizing: border-box;
width: 100%;
padding: 3.2vw;
border-width: 0.27vw;
border-style: solid;
border-color: #dce6e8;
border-radius: 1.07vw;
background-color: #ffffff;
color: rgba(24, 35, 38, 0.9);
overflow: hidden;
}
.card-header {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
}
.title {
font-size: 4.53vw;
font-weight: 600;
line-height: 1.25;
color: rgba(24, 35, 38, 0.9);
}
.date {
margin-left: 2.13vw;
font-size: 3.2vw;
line-height: 1.4;
color: rgba(24, 35, 38, 0.45);
}
.summary {
display: flex;
flex-direction: row;
align-items: baseline;
margin-top: 4.27vw;
}
.total {
font-size: 9.6vw;
font-weight: 600;
line-height: 1;
color: #0d759b;
}
.unit {
margin-left: 2.13vw;
font-size: 4vw;
line-height: 1.4;
color: rgba(24, 35, 38, 0.45);
}
.bar {
width: 100%;
height: 2.13vw;
margin-top: 4.27vw;
border-radius: 1.07vw;
background-color: #dfe8eb;
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 1.07vw;
background-color: #1f9fc8;
}
.status {
margin-top: 2.13vw;
font-size: 3.2vw;
line-height: 1.4;
color: rgba(24, 35, 38, 0.45);
}
.primary-action {
box-sizing: border-box;
width: 100%;
margin-top: 4.27vw;
height: 10.67vw;
border-radius: 1.07vw;
background-color: #0f82a8;
color: rgba(255, 255, 255, 0.9);
font-size: 4vw;
font-weight: 500;
line-height: 10.67vw;
text-align: center;
}
.primary-action-hover {
opacity: 0.82;
}
@media (prefers-color-scheme: dark) {
.water-card {
border-color: #24505c;
background-color: #101f24;
color: rgba(255, 255, 255, 0.9);
}
.title {
color: rgba(255, 255, 255, 0.9);
}
.date,
.unit,
.status {
color: rgba(255, 255, 255, 0.45);
}
.bar {
background-color: #153642;
}
}
Component({
data: {
daysRequested: 7,
today: {
totalMl: 0,
goalMl: 2000,
progressPercent: 0,
statusText: "",
},
visibleDays: [],
omittedCount: 0,
},
lifetimes: {
created() {
console.info("[ai-mode] water-records created");
const { NotificationType } = wx.modelContext;
const modelCtx = wx.modelContext.getContext(this);
const viewCtx = wx.modelContext.getViewContext(this);
try {
const dimensions = viewCtx.getDimensions();
console.info(
`[ai-mode] water-records dimensions width=${dimensions.width} minHeight=${dimensions.minHeight} maxHeight=${dimensions.maxHeight}`
);
} catch (e) {
console.info("[ai-mode] water-records getDimensions skipped:", e.message);
}
modelCtx.on(NotificationType.Result, (data) => {
const structuredContent =
data.result && data.result.structuredContent;
console.info(
"[ai-mode] water-records 收到 Result:",
JSON.stringify(structuredContent)
);
if (!structuredContent) {
return;
}
const days = structuredContent.days || [];
const visibleDays = days.slice(0, 4);
const daysRequested = structuredContent.requestedDays || 7;
this.setData({
daysRequested,
today: structuredContent.today || this.data.today,
visibleDays,
omittedCount: Math.max(days.length - visibleDays.length, 0),
});
console.info(
`[ai-mode] water-records setData total=${days.length} visible=${visibleDays.length}`
);
});
viewCtx.on(NotificationType.Overflow, (data) => {
const overflowed = !!(data && data.overflowHeight > 0);
console.info(
`[ai-mode] water-records overflow overflowed=${overflowed} data=${JSON.stringify(data)}`
);
});
console.info("[ai-mode] water-records overflow monitor=on");
},
},
methods: {
onTapRefresh() {
const args = {
days: this.data.daysRequested || 7,
};
console.info(
`[ai-mode] water-records send api/call name=getWaterRecords args=${JSON.stringify(args)}`
);
wx.modelContext.getContext(this).sendFollowUpMessage({
content: [
{
type: "text",
text: "刷新记录",
},
{
type: "api/call",
data: {
name: "getWaterRecords",
arguments: args,
},
},
],
});
},
},
});
{
"component": true,
"usingComponents": {}
}
<view class="records-card">
<view class="card-header">
<view>
<view class="title">喝水回顾</view>
<view class="subtitle">最近 {{daysRequested}} 天 · 今日 {{today.totalMl}} / {{today.goalMl}} ml</view>
</view>
<view class="percent">{{today.progressPercent}}%</view>
</view>
<view class="bar">
<view class="bar-fill" style="width: {{today.progressPercent}}%;"></view>
</view>
<view class="today-status">{{today.statusText}}</view>
<view class="day-list">
<view wx:for="{{visibleDays}}" wx:key="date" class="day-row">
<view class="day-date">{{item.dateLabel}}</view>
<view class="day-total">{{item.totalMl}} ml</view>
<view class="day-percent">{{item.progressPercent}}%</view>
</view>
</view>
<view wx:if="{{omittedCount > 0}}" class="omitted">还有 {{omittedCount}} 天未展示</view>
<view
class="primary-action"
hover-class="primary-action-hover"
bindtap="onTapRefresh"
>
刷新记录
</view>
</view>
/* 样式参考:miniprogram/pages/index/index.wxss
* ratio=1:1;色源:app.json window.backgroundColor #F6F6F6 + 首页主色 #0f82a8 / #1f9fc8 / 主文字 #182326
* 暗黑:源项目无 darkmode,按浅色底降明度得到 #101f24 / #153642
*/
.records-card {
box-sizing: border-box;
width: 100%;
padding: 3.2vw;
border-width: 0.27vw;
border-style: solid;
border-color: #dce6e8;
border-radius: 1.07vw;
background-color: #ffffff;
color: rgba(24, 35, 38, 0.9);
overflow: hidden;
}
.card-header {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
}
.title {
font-size: 4.53vw;
font-weight: 600;
line-height: 1.25;
color: rgba(24, 35, 38, 0.9);
}
.subtitle {
margin-top: 1.07vw;
font-size: 3.2vw;
line-height: 1.4;
color: rgba(24, 35, 38, 0.45);
}
.percent {
margin-left: 2.13vw;
font-size: 4.53vw;
font-weight: 600;
line-height: 1.25;
color: #0d759b;
}
.bar {
width: 100%;
height: 2.13vw;
margin-top: 3.2vw;
border-radius: 1.07vw;
background-color: #dfe8eb;
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 1.07vw;
background-color: #1f9fc8;
}
.today-status {
margin-top: 2.13vw;
font-size: 3.2vw;
line-height: 1.4;
color: rgba(24, 35, 38, 0.45);
}
.day-list {
margin-top: 4.27vw;
border-top-width: 0.27vw;
border-top-style: solid;
border-top-color: #edf2f3;
}
.day-row {
display: flex;
flex-direction: row;
align-items: center;
padding-top: 2.13vw;
padding-bottom: 2.13vw;
border-bottom-width: 0.27vw;
border-bottom-style: solid;
border-bottom-color: #edf2f3;
}
.day-date {
flex: 1;
font-size: 4vw;
font-weight: 500;
line-height: 1.4;
color: rgba(24, 35, 38, 0.9);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.day-total {
width: 21.33vw;
font-size: 3.2vw;
line-height: 1.4;
text-align: right;
color: rgba(24, 35, 38, 0.45);
}
.day-percent {
width: 12vw;
font-size: 3.2vw;
line-height: 1.4;
text-align: right;
color: #0d759b;
}
.omitted {
margin-top: 2.13vw;
font-size: 3.2vw;
line-height: 1.4;
color: rgba(24, 35, 38, 0.45);
}
.primary-action {
box-sizing: border-box;
width: 100%;
margin-top: 4.27vw;
height: 10.67vw;
border-radius: 1.07vw;
background-color: #0f82a8;
color: rgba(255, 255, 255, 0.9);
font-size: 4vw;
font-weight: 500;
line-height: 10.67vw;
text-align: center;
}
.primary-action-hover {
opacity: 0.82;
}
@media (prefers-color-scheme: dark) {
.records-card {
border-color: #24505c;
background-color: #101f24;
color: rgba(255, 255, 255, 0.9);
}
.title,
.day-date {
color: rgba(255, 255, 255, 0.9);
}
.subtitle,
.today-status,
.day-total,
.omitted {
color: rgba(255, 255, 255, 0.45);
}
.bar {
background-color: #153642;
}
.day-list {
border-top-color: #24505c;
}
.day-row {
border-bottom-color: #24505c;
}
}
{
"collections": [
{
"name": "water_daily",
"description": "每日饮水记录",
"indexes": [
{
"name": "idx_openid_date",
"field": ["openid", "date"]
}
]
},
{
"name": "water_profile",
"description": "用户饮水配置",
"indexes": [
{
"name": "idx_openid",
"field": "openid"
}
]
}
]
}
const { addWaterRecord } = require("./apis/addWaterRecord");
const { getWaterRecords } = require("./apis/getWaterRecords");
wx.modelContext.registerAPI("addWaterRecord", addWaterRecord);
wx.modelContext.registerAPI("getWaterRecords", getWaterRecords);
{
"apis": [
{
"name": "addWaterRecord",
"description": "记录用户一次喝水量,返回当天累计饮水、目标进度和本次记录结果。",
"_meta": {
"ui": {
"componentPath": "components/add-water-result/index"
}
},
"inputSchema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"amountMl": {
"type": "integer",
"minimum": 1,
"maximum": 5000,
"description": "本次喝水量,单位毫升"
},
"note": {
"type": "string",
"maxLength": 80,
"description": "本次喝水备注,可为空"
}
},
"required": ["amountMl"],
"additionalProperties": false
},
"outputSchema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"date": { "type": "string" },
"addedAmountMl": { "type": "integer" },
"totalMl": { "type": "integer" },
"goalMl": { "type": "integer" },
"progressPercent": { "type": "integer" },
"remainingMl": { "type": "integer" },
"statusText": { "type": "string" },
"records": {
"type": "array",
"items": {
"type": "object",
"properties": {
"amountMl": { "type": "integer" },
"note": { "type": "string" },
"drankAt": { "type": "string" },
"timeText": { "type": "string" }
},
"required": ["amountMl", "note", "drankAt", "timeText"],
"additionalProperties": false
}
}
},
"required": [
"date",
"addedAmountMl",
"totalMl",
"goalMl",
"progressPercent",
"remainingMl",
"statusText",
"records"
],
"additionalProperties": false
}
},
{
"name": "getWaterRecords",
"description": "拉取用户喝水记录,返回今日进度、最近若干天汇总和今日明细。",
"_meta": {
"ui": {
"componentPath": "components/water-records/index"
}
},
"inputSchema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"days": {
"type": "integer",
"minimum": 1,
"maximum": 90,
"default": 7,
"description": "要拉取的最近天数"
}
},
"additionalProperties": false
},
"outputSchema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"today": {
"type": "object",
"properties": {
"date": { "type": "string" },
"totalMl": { "type": "integer" },
"goalMl": { "type": "integer" },
"progressPercent": { "type": "integer" },
"remainingMl": { "type": "integer" },
"statusText": { "type": "string" },
"records": {
"type": "array",
"items": {
"type": "object",
"properties": {
"amountMl": { "type": "integer" },
"note": { "type": "string" },
"drankAt": { "type": "string" },
"timeText": { "type": "string" }
},
"required": ["amountMl", "note", "drankAt", "timeText"],
"additionalProperties": false
}
}
},
"required": [
"date",
"totalMl",
"goalMl",
"progressPercent",
"remainingMl",
"statusText",
"records"
],
"additionalProperties": false
},
"days": {
"type": "array",
"items": {
"type": "object",
"properties": {
"date": { "type": "string" },
"dateLabel": { "type": "string" },
"totalMl": { "type": "integer" },
"goalMl": { "type": "integer" },
"progressPercent": { "type": "integer" },
"remainingMl": { "type": "integer" },
"recordCount": { "type": "integer" }
},
"required": [
"date",
"dateLabel",
"totalMl",
"goalMl",
"progressPercent",
"remainingMl",
"recordCount"
],
"additionalProperties": false
}
},
"requestedDays": { "type": "integer" },
"totalDays": { "type": "integer" }
},
"required": ["today", "days", "requestedDays", "totalDays"],
"additionalProperties": false
}
}
],
"components": [
{
"path": "components/add-water-result/index",
"relatedPage": "/pages/home/home"
},
{
"path": "components/water-records/index",
"relatedPage": "/pages/home/home"
}
]
}
Page({});
{
"navigationBarTitleText": "AI Skill"
}
<view class="placeholder"></view>
.placeholder {
width: 100%;
min-height: 100vh;
background-color: #f6f6f6;
}
const ENV_ID = "lowcode-2gp2855c5ce22e35";
const DEFAULT_GOAL_ML = 2000;
let cloudInited = false;
const mockRecordsByDate = createInitialRecords();
function isPreviewMode() {
return wx.getStorageSync('mp_skills_preview_mode') !== false
}
function ensureCloudInit() {
if (cloudInited) {
return;
}
if (!wx.cloud) {
throw new Error("当前基础库不支持云开发");
}
console.info("[ai-mode] water-tracker ensureCloudInit env=", ENV_ID);
wx.cloud.init({
env: ENV_ID,
traceUser: true,
});
cloudInited = true;
}
function callWaterTracker(data) {
if (isPreviewMode()) {
console.info("[ai-mode] callWaterTracker 预览模式 data=", JSON.stringify(data));
return mockCallWaterTracker(data);
}
ensureCloudInit();
console.info("[ai-mode] callWaterTracker 请求前 data=", JSON.stringify(data));
return new Promise((resolve, reject) => {
wx.cloud.callFunction({
name: "waterTracker",
data,
success(res) {
const result = res.result || {};
console.info(
"[ai-mode] callWaterTracker 请求后 result=",
JSON.stringify(result)
);
if (!result.success) {
reject(new Error(result.errMsg || "云函数调用失败"));
return;
}
resolve(result.data || result.collections || null);
},
fail(error) {
console.error("[ai-mode] callWaterTracker fail:", error);
reject(error);
},
});
});
}
function mockCallWaterTracker(data = {}) {
const type = data.type;
if (type === "init") {
return Promise.resolve(["water_daily", "water_profile"]);
}
if (type === "addWater") {
const amountMl = Number(data.amountMl || 0);
const dateKey = getDateKey();
const records = ensureRecords(dateKey);
records.push({
amountMl,
note: data.note || "",
drankAt: new Date().toISOString(),
});
return Promise.resolve(buildMockDaily(dateKey));
}
if (type === "getToday") {
return Promise.resolve(buildMockDaily(getDateKey()));
}
if (type === "listDaily") {
const days = Number(data.days || 7);
const list = [];
for (let index = 0; index < days; index += 1) {
list.push(buildMockDaily(getDateKey(-index)));
}
return Promise.resolve(list);
}
return Promise.reject(new Error(`未知 mock 类型: ${type}`));
}
function errorResult(message) {
return {
isError: true,
content: [
{
type: "text",
text: message,
},
],
};
}
function successResult(message, structuredContent) {
const result = {
isError: false,
content: [
{
type: "text",
text: message,
},
],
};
if (structuredContent !== undefined) {
result.structuredContent = structuredContent;
}
return result;
}
function normalizeAmount(value, fieldName, min, max) {
const amount = Number(value);
if (!Number.isInteger(amount) || amount < min || amount > max) {
throw new Error(`${fieldName} 需要是 ${min} 到 ${max} 之间的整数`);
}
return amount;
}
function formatDaily(raw = {}) {
const totalMl = Number(raw.totalMl || 0);
const goalMl = Number(raw.goalMl || DEFAULT_GOAL_ML);
const progressPercent = goalMl
? Math.min(Math.round((totalMl / goalMl) * 100), 100)
: 0;
const remainingMl = Math.max(goalMl - totalMl, 0);
return {
date: raw.date || getDateKey(),
totalMl,
goalMl,
progressPercent,
remainingMl,
statusText: remainingMl > 0 ? `还差 ${remainingMl} ml` : "今日已达标",
records: formatRecords(raw.records || []),
};
}
function formatHistory(list = []) {
return list.map((raw) => {
const daily = formatDaily(raw);
return {
date: daily.date,
dateLabel: formatDateLabel(daily.date),
totalMl: daily.totalMl,
goalMl: daily.goalMl,
progressPercent: daily.progressPercent,
remainingMl: daily.remainingMl,
recordCount: daily.records.length,
};
});
}
function formatRecords(records = []) {
return records
.slice()
.reverse()
.map((record) => ({
amountMl: Number(record.amountMl || 0),
note: record.note || "",
drankAt: record.drankAt || "",
timeText: formatTime(record.drankAt),
}));
}
function createInitialRecords() {
const today = getDateKey();
const yesterday = getDateKey(-1);
const twoDaysAgo = getDateKey(-2);
return {
[today]: [
makeMockRecord(300, "早餐后", today, 8, 30),
makeMockRecord(250, "上午", today, 10, 45),
makeMockRecord(400, "午餐", today, 13, 5),
],
[yesterday]: [
makeMockRecord(500, "上午", yesterday, 9, 10),
makeMockRecord(350, "下午", yesterday, 15, 20),
makeMockRecord(300, "晚上", yesterday, 20, 5),
],
[twoDaysAgo]: [
makeMockRecord(250, "出门前", twoDaysAgo, 8, 0),
makeMockRecord(500, "运动后", twoDaysAgo, 18, 40),
],
};
}
function makeMockRecord(amountMl, note, dateKey, hour, minute) {
const parts = dateKey.split("-").map(Number);
const date = new Date(parts[0], parts[1] - 1, parts[2], hour, minute, 0);
return {
amountMl,
note,
drankAt: date.toISOString(),
};
}
function ensureRecords(dateKey) {
if (!mockRecordsByDate[dateKey]) {
mockRecordsByDate[dateKey] = [];
}
return mockRecordsByDate[dateKey];
}
function buildMockDaily(dateKey) {
const records = ensureRecords(dateKey);
const totalMl = records.reduce(
(sum, record) => sum + Number(record.amountMl || 0),
0
);
return {
date: dateKey,
totalMl,
goalMl: DEFAULT_GOAL_ML,
records: records.slice(),
};
}
function formatTime(value) {
if (!value) {
return "";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return "";
}
const hour = String(date.getHours()).padStart(2, "0");
const minute = String(date.getMinutes()).padStart(2, "0");
return `${hour}:${minute}`;
}
function formatDateLabel(dateKey) {
if (dateKey === getDateKey()) {
return "今天";
}
if (dateKey === getDateKey(-1)) {
return "昨天";
}
const parts = dateKey.split("-").map(Number);
const month = parts[1] || 0;
const day = parts[2] || 0;
const date = new Date(parts[0], month - 1, day);
const weekNames = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
return `${month}/${day} ${weekNames[date.getDay()]}`;
}
function getDateKey(offsetDays = 0) {
const date = new Date();
date.setDate(date.getDate() + offsetDays);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
module.exports = {
callWaterTracker,
errorResult,
formatDaily,
formatHistory,
isPreviewMode,
normalizeAmount,
successResult,
};