0.0.0
This commit is contained in:
56
miniprogram/utils/achievements.js
Normal file
56
miniprogram/utils/achievements.js
Normal 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 };
|
||||
34
miniprogram/utils/cloud.js
Normal file
34
miniprogram/utils/cloud.js
Normal 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 };
|
||||
6
miniprogram/utils/config.js
Normal file
6
miniprogram/utils/config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
// utils/config.js — 前端可配置常量
|
||||
module.exports = {
|
||||
// 订阅消息模板 ID:在「微信公众平台 → 功能 → 订阅消息」创建「遛狗提醒」类模板后填入。
|
||||
// 留空时,订阅请求与提醒功能自动跳过,不影响其它功能。
|
||||
SUBSCRIBE_TEMPLATE_ID: '',
|
||||
};
|
||||
49
miniprogram/utils/dogStep.js
Normal file
49
miniprogram/utils/dogStep.js
Normal 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,
|
||||
};
|
||||
48
miniprogram/utils/format.js
Normal file
48
miniprogram/utils/format.js
Normal 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,
|
||||
};
|
||||
38
miniprogram/utils/media.js
Normal file
38
miniprogram/utils/media.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// utils/media.js — 选图 + 压缩 + 上传云存储,返回 fileID
|
||||
// 用法:const fileID = await chooseAndUpload('avatars')
|
||||
|
||||
/**
|
||||
* 选择一张图片(已压缩)并上传到云存储。
|
||||
* @param {string} prefix 云存储目录前缀,如 'avatars' / 'walk-photos'
|
||||
* @returns {Promise<string|null>} fileID(cloud://...)或 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 };
|
||||
43
miniprogram/utils/store.js
Normal file
43
miniprogram/utils/store.js
Normal 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 };
|
||||
Reference in New Issue
Block a user