This commit is contained in:
2026-06-25 18:24:29 +08:00
commit a1190d7d9a
90 changed files with 5459 additions and 0 deletions

View File

@@ -0,0 +1,56 @@
// utils/achievements.js — 成就定义(前端展示用副本)
// 解锁判定的"权威"逻辑在云函数侧(防刷);此处仅供前端渲染成就墙/进度。
// 成就基于「连续 / 累计 / 场景」,不基于「最多 / 最快」docs/05 R10
const ACHIEVEMENTS = [
{
key: 'first_walk',
name: '首遛',
desc: '第一次记录遛狗',
icon: 'flag',
progress: (stats) => Math.min(1, stats.totalWalks / 1),
},
{
key: 'week_streak',
name: '一周不断',
desc: '连续遛狗满 7 天',
icon: 'medal',
progress: (stats) => Math.min(1, stats.currentStreak / 7),
},
{
key: 'month_streak',
name: '满月坚持',
desc: '连续遛狗满 30 天',
icon: 'trophy',
progress: (stats) => Math.min(1, stats.currentStreak / 30),
},
{
key: 'rainy_warrior',
name: '雨天战士',
desc: '雨天也坚持遛狗',
icon: 'rain',
progress: () => 0, // 场景类,由事件触发
},
{
key: 'hundred_km',
name: '百里同行',
desc: '累计遛狗满 100 公里',
icon: 'walk',
progress: (stats) => Math.min(1, stats.totalDistance / 100),
},
{
key: 'early_bird',
name: '早起鸟',
desc: '在 7 点前遛狗 10 次',
icon: 'sun',
progress: (stats) => Math.min(1, (stats.earlyWalks || 0) / 10), // 场景类:累计早遛次数
},
];
const TOTAL = ACHIEVEMENTS.length;
function getDef(key) {
return ACHIEVEMENTS.find((a) => a.key === key);
}
module.exports = { ACHIEVEMENTS, TOTAL, getDef };

View File

@@ -0,0 +1,34 @@
// utils/cloud.js — 云函数调用统一封装
// 用法: const { ok, data } = await call('login', { ... })
/**
* 调用云函数,统一处理 loading 与错误提示。
* @param {string} name 云函数名
* @param {object} data 入参
* @param {object} [opts] { loading: boolean, loadingText: string }
* @returns {Promise<{ok:boolean, data?:any, code?:string, msg?:string}>}
*/
function call(name, data = {}, opts = {}) {
const { loading = false, loadingText = '加载中' } = opts;
if (loading) wx.showLoading({ title: loadingText, mask: true });
return wx.cloud
.callFunction({ name, data })
.then((res) => {
const result = res.result || {};
if (!result.ok) {
wx.showToast({ title: result.msg || '操作失败', icon: 'none' });
}
return result;
})
.catch((err) => {
console.error(`[cloud] ${name} 调用失败`, err);
wx.showToast({ title: '网络异常,请重试', icon: 'none' });
return { ok: false, code: 'NETWORK', msg: '网络异常' };
})
.finally(() => {
if (loading) wx.hideLoading();
});
}
module.exports = { call };

View File

@@ -0,0 +1,6 @@
// utils/config.js — 前端可配置常量
module.exports = {
// 订阅消息模板 ID在「微信公众平台 → 功能 → 订阅消息」创建「遛狗提醒」类模板后填入。
// 留空时,订阅请求与提醒功能自动跳过,不影响其它功能。
SUBSCRIBE_TEMPLATE_ID: '',
};

View File

@@ -0,0 +1,49 @@
// utils/dogStep.js — 体型档位 → 步幅 → 狗步docs/05 R3
// 狗步 = round(distanceMeters / stride)stride 由体型档位决定,体重做 ±15% 校准。
// 与 cloudfunctions/dogManage/index.js 的同名常量保持一致。
const SIZE_LABELS = [
{ key: 'xs', label: '超小型' },
{ key: 's', label: '小型/短腿' },
{ key: 'm', label: '中型' },
{ key: 'l', label: '大型' },
{ key: 'xl', label: '超大型' },
];
const BASE_STRIDE = { xs: 0.20, s: 0.25, m: 0.35, l: 0.45, xl: 0.55 }; // 米/步
const STD_WEIGHT = { xs: 3, s: 7, m: 15, l: 28, xl: 45 }; // 档位标准体重 kg
// 犬种 → 体型档位;未列出的(中华田园犬/串串/其他)返回 null需用户手动选
const BREED_SIZE = {
柯基: 's', 比熊: 'xs', 泰迪: 's', 柴犬: 'm', 金毛: 'l',
拉布拉多: 'l', 哈士奇: 'l', 边境牧羊犬: 'm', 萨摩耶: 'l', 法斗: 's',
};
function sizeOfBreed(breed) {
return BREED_SIZE[breed] || null;
}
// 由体型档位(+可选体重)算步幅
function computeStride(sizeLevel, weight) {
const base = BASE_STRIDE[sizeLevel];
if (!base) return { baseStride: null, stride: null };
let stride = base;
if (weight) {
const std = STD_WEIGHT[sizeLevel];
let f = 1 + (Number(weight) - std) * 0.005;
f = Math.max(0.85, Math.min(1.15, f)); // 封顶 ±15%
stride = +(base * f).toFixed(3);
}
return { baseStride: base, stride };
}
// 距离(米) + 步幅 → 狗步;无步幅返回 null
function dogStepsFromMeters(meters, stride) {
if (!stride || meters == null) return null;
return Math.round(meters / stride);
}
module.exports = {
SIZE_LABELS, BASE_STRIDE, STD_WEIGHT, BREED_SIZE,
sizeOfBreed, computeStride, dogStepsFromMeters,
};

View File

@@ -0,0 +1,48 @@
// utils/format.js — 时长 / 距离 / 日期格式化
/** 秒 → "MM:SS" 或 "HH:MM:SS" */
function formatDuration(sec) {
sec = Math.max(0, Math.floor(sec || 0));
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
const pad = (n) => String(n).padStart(2, '0');
return h > 0 ? `${pad(h)}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
}
/** 秒 → "12分34秒"(历史列表用) */
function formatDurationCN(sec) {
sec = Math.max(0, Math.floor(sec || 0));
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}${String(s).padStart(2, '0')}`;
}
/** 公里数 → "1.2km"null → "距离未记录" */
function formatDistance(km) {
if (km == null) return '距离未记录';
return `${Number(km).toFixed(1)}km`;
}
/** 时间戳 → 本地 "yyyy-mm-dd" */
function toDateStr(ts, tzOffsetHours = 8) {
const d = new Date(ts + tzOffsetHours * 3600 * 1000);
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
const day = String(d.getUTCDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
/** 时间戳 → "今天 早上 / 昨天 傍晚" 之类的友好时段(简版占位) */
function friendlyTime(ts) {
// TODO: 阶段 2 完整实现(今天/昨天/前天 + 时段)
return new Date(ts).toLocaleString();
}
module.exports = {
formatDuration,
formatDurationCN,
formatDistance,
toDateStr,
friendlyTime,
};

View File

@@ -0,0 +1,38 @@
// utils/media.js — 选图 + 压缩 + 上传云存储,返回 fileID
// 用法const fileID = await chooseAndUpload('avatars')
/**
* 选择一张图片(已压缩)并上传到云存储。
* @param {string} prefix 云存储目录前缀,如 'avatars' / 'walk-photos'
* @returns {Promise<string|null>} fileIDcloud://...)或 null
*/
function chooseAndUpload(prefix = 'uploads') {
return new Promise((resolve) => {
wx.chooseMedia({
count: 1,
mediaType: ['image'],
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (r) => {
const temp = r.tempFiles && r.tempFiles[0] && r.tempFiles[0].tempFilePath;
if (!temp) return resolve(null);
wx.showLoading({ title: '上传中', mask: true });
try {
const up = await wx.cloud.uploadFile({
cloudPath: `${prefix}/${Date.now()}-${Math.floor(Math.random() * 1e6)}.jpg`,
filePath: temp,
});
resolve(up.fileID);
} catch (e) {
wx.showToast({ title: '上传失败', icon: 'none' });
resolve(null);
} finally {
wx.hideLoading();
}
},
fail: () => resolve(null),
});
});
}
module.exports = { chooseAndUpload };

View File

@@ -0,0 +1,43 @@
// utils/store.js — 轻量全局状态 + 订阅(不引第三方状态库)
// 读 app.globalData变更通过 set() 并广播给订阅者。
const app = () => getApp();
const listeners = new Set();
function getState() {
return app().globalData;
}
/**
* 合并更新全局状态并广播。
* @param {object} patch 要合并进 globalData 的字段
*/
function set(patch) {
Object.assign(app().globalData, patch);
listeners.forEach((fn) => fn(app().globalData));
}
/**
* 订阅状态变更,返回取消订阅函数。
* @param {(state:object)=>void} fn
*/
function subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
// ===== 遛狗会话V1 核心运行时状态,需持久化防丢) =====
function setWalkSession(session) {
set({ walkSession: session });
if (session) {
wx.setStorageSync('walkSession', session);
} else {
wx.removeStorageSync('walkSession');
}
}
function getWalkSession() {
return app().globalData.walkSession;
}
module.exports = { getState, set, subscribe, setWalkSession, getWalkSession };