feat: rebuild pet app and backend around core care flows

This commit is contained in:
2026-10-01 13:11:30 +08:00
parent 51ead8f411
commit bfcf8788a4
321 changed files with 4918 additions and 25199 deletions
+54 -159
View File
@@ -1,167 +1,62 @@
const { request, uploadFile, setToken, setRefreshToken } = require('./request.js');
// 登录成功后统一落盘 access + refresh 两个令牌
function saveTokens(data) {
setToken(data.token);
setRefreshToken(data.refresh_token);
return data;
}
// wx.login 取 code
function wxLogin() {
const { baseURL } = require("./config");
function request(path, method = "GET", data, auth = true) {
return new Promise((resolve, reject) => {
wx.login({
success: (r) => (r.code ? resolve(r.code) : reject(new Error('wx.login 未返回 code'))),
const token = getApp().globalData.token || wx.getStorageSync("pet_token");
wx.request({
url: baseURL + path,
method,
data,
header: token && auth ? { Authorization: "Bearer " + token } : {},
success(res) {
if (res.data && res.data.code === 0) resolve(res.data.data);
else {
if (res.statusCode === 401) {
wx.removeStorageSync("pet_token");
getApp().globalData.token = "";
}
const error = new Error((res.data && res.data.message) || "请求失败");
error.status = res.statusCode;
reject(error);
}
},
fail: reject,
});
});
}
// 登录:优先微信 code2session,失败回退开发态 Mock 登录(后端 dev_login=true)
async function login() {
async function secure(path, method, data) {
await getApp().login();
try {
const code = await wxLogin();
// noAuthRetry:登录接口失败时也会返回 40100,不能再触发「续期→重新登录」,否则会自己套自己
const data = await request({ url: '/api/auth/wechat', method: 'POST', data: { code }, noAuthRetry: true });
return saveTokens(data);
} catch (e) {
console.warn('[api] 微信登录失败,回退 Mock 登录:', e && e.message);
const data = await request({
url: '/api/auth/login',
method: 'POST',
data: { nickname: '开发用户' },
noAuthRetry: true,
});
return saveTokens(data);
return await request(path, method, data);
} catch (error) {
if (error.status === 401) {
await getApp().login();
return request(path, method, data);
}
throw error;
}
}
const api = {
login,
uploadFile,
// 用户
getProfile: () => request({ url: '/api/user/profile' }),
bindPhone: (code) => request({ url: '/api/user/phone', method: 'POST', data: { code } }),
updateProfile: (body) => request({ url: '/api/user/profile', method: 'PUT', data: body }),
// 用户
userSummary: () => request({ url: '/api/user/summary' }),
// 记录类型(后台可配)。分组好的,前端直接渲染
recordTypes: () => request({ url: '/api/record-types' }),
updateDecoration: (body) => request({ url: '/api/user/decoration', method: 'PUT', data: body }),
// 宠物
getPets: () => request({ url: '/api/pets' }),
homeSummary: (id) => request({ url: `/api/pets/${id}/home-summary` }),
getPet: (id) => request({ url: `/api/pets/${id}` }),
// 养护方案:可选列表 + 套用到某个月
careTemplates: (species, stage) =>
request({ url: '/api/care-templates?species=' + species + '&stage=' + encodeURIComponent(stage) }),
applyTemplate: (petId, templateID, monthOffset) =>
request({ url: `/api/pets/${petId}/apply-template`, method: 'POST',
data: { template_id: templateID, month_offset: monthOffset || 0 } }),
// 生命阶段的划分标准。判定和展示用同一份数据 —— 不另写一份说明文案,
// 否则改了阈值忘了改文案,用户看到的依据和实际行为对不上
lifeStages: (species, size) =>
request({ url: `/api/life-stages?species=${species}&size=${size || ''}` }),
petIDCard: (id) => request({ url: `/api/pets/${id}/id-card` }),
petStageDrift: (petId) => request({ url: `/api/pets/${petId}/stage-drift` }),
// 品种(后台可配),按首字母分组
breeds: (species) => request({ url: '/api/breeds?species=' + species }),
createPet: (body) => request({ url: '/api/pets', method: 'POST', data: body }),
updatePet: (id, body) => request({ url: `/api/pets/${id}`, method: 'PUT', data: body }),
deletePet: (id) => request({ url: `/api/pets/${id}`, method: 'DELETE' }),
onboarding: (body) => request({ url: '/api/onboarding', method: 'POST', data: body }),
// 记录
getRecords: (id, { type, page, pageSize } = {}) => {
const qs = [];
if (type) qs.push(`type=${type}`);
if (page) qs.push(`page=${page}`);
if (pageSize) qs.push(`page_size=${pageSize}`);
return request({ url: `/api/pets/${id}/records${qs.length ? '?' + qs.join('&') : ''}` });
},
createRecord: (id, body) => request({ url: `/api/pets/${id}/records`, method: 'POST', data: body }),
deleteRecord: (recordId) => request({ url: `/api/records/${recordId}`, method: 'DELETE' }),
petInsights: (id) => request({ url: `/api/pets/${id}/insights` }),
weightTrend: (id) => request({ url: `/api/pets/${id}/records/weight-trend` }),
// 任务
getTasks: (id, date) => request({ url: `/api/pets/${id}/tasks${date ? `?date=${date}` : ''}` }),
createTask: (id, body) => request({ url: `/api/pets/${id}/tasks`, method: 'POST', data: body }),
updateTask: (taskId, body) => request({ url: `/api/tasks/${taskId}`, method: 'PUT', data: body }),
deleteTask: (taskId) => request({ url: `/api/tasks/${taskId}`, method: 'DELETE' }),
toggleTask: (taskId) => request({ url: `/api/tasks/${taskId}/toggle`, method: 'POST' }),
completeAllTasks: (id) => request({ url: `/api/pets/${id}/tasks/complete-all`, method: 'POST' }),
// 计划
getPlan: (id) => request({ url: `/api/pets/${id}/plan` }),
planCalendar: (id, month) =>
request({ url: `/api/pets/${id}/plan/calendar${month ? `?month=${month}` : ''}` }),
dayPlan: (id, date) => request({ url: `/api/pets/${id}/day-plan?date=${date}` }),
// AI 相关:用户端入口已全部下掉,后端 10 个路由和配额配置都还在。
// 想开回来把这几行解注释、再把入口接上就行,不用重写。
// createAIPlan: (id, input) => request({ url: `/api/pets/${id}/ai-plan`, method: 'POST', data: { input } }),
// applyAIPlan: (planId) => request({ url: `/api/ai-plan/${planId}/apply`, method: 'POST' }),
// 用户自己管理计划节点
planMonthStatus: (petId) => request({ url: `/api/pets/${petId}/plan/month-status` }),
addPlanTask: (petId, body) => request({ url: `/api/pets/${petId}/plan-tasks`, method: 'POST', data: body }),
updatePlanTask: (taskId, body) => request({ url: `/api/plan-tasks/${taskId}`, method: 'PUT', data: body }),
deletePlanTask: (taskId) => request({ url: `/api/plan-tasks/${taskId}`, method: 'DELETE' }),
togglePlanTask: (taskId) => request({ url: `/api/plan-tasks/${taskId}/toggle`, method: 'POST' }),
// 提醒
getReminders: (id) => request({ url: `/api/pets/${id}/reminders` }),
createReminder: (id, body) => request({ url: `/api/pets/${id}/reminders`, method: 'POST', data: body }),
updateReminder: (remId, body) => request({ url: `/api/reminders/${remId}`, method: 'PUT', data: body }),
deleteReminder: (remId) => request({ url: `/api/reminders/${remId}`, method: 'DELETE' }),
// 报告
weeklyReport: (id) => request({ url: `/api/pets/${id}/report/weekly` }),
getBill: (id, period) => request({ url: `/api/pets/${id}/bill?period=${period || 'month'}` }),
healthSummary: (id) => request({ url: `/api/pets/${id}/health-summary` }),
getPoster: (id) => request({ url: `/api/pets/${id}/poster` }),
// 社区
listPosts: (tab, page) =>
request({ url: `/api/posts?tab=${encodeURIComponent(tab || '')}&page=${page || 1}&page_size=10` }),
createPost: (body) => request({ url: '/api/posts', method: 'POST', data: body }),
likePost: (id) => request({ url: `/api/posts/${id}/like`, method: 'POST' }),
userCard: (userId) => request({ url: `/api/users/${userId}/profile` }),
userPosts: (userId, page) => request({ url: `/api/users/${userId}/posts?page=${page || 1}&page_size=10` }),
relations: (userId, kind, page) =>
request({ url: `/api/users/${userId}/relations?kind=${kind}&page=${page || 1}&page_size=20` }),
followUser: (userId) => request({ url: `/api/users/${userId}/follow`, method: 'POST' }),
unfollowUser: (userId) => request({ url: `/api/users/${userId}/follow`, method: 'DELETE' }),
deletePost: (id) => request({ url: `/api/posts/${id}`, method: 'DELETE' }),
deleteComment: (id) => request({ url: `/api/comments/${id}`, method: 'DELETE' }),
listComments: (id) => request({ url: `/api/posts/${id}/comments` }),
createComment: (id, content, images, parentId) =>
request({
url: `/api/posts/${id}/comments`,
method: 'POST',
data: { content, images: images || [], parent_id: parentId || '' },
}),
listReplies: (commentId) => request({ url: `/api/comments/${commentId}/replies` }),
// 文章
listArticles: () => request({ url: '/api/articles' }),
submitFeedback: (body) => request({ url: '/api/feedback', method: 'POST', data: body }),
getArticle: (id) => request({ url: `/api/articles/${id}` }),
// Pro
getPro: () => request({ url: '/api/pro' }),
activatePro: () => request({ url: '/api/pro/activate', method: 'POST' }),
// AI
// aiMessages: (session, limit) =>
// request({ url: `/api/ai/messages?session=${session || ''}&limit=${limit || 30}` }),
// aiChat: (body) => request({ url: '/api/ai/chat', method: 'POST', data: body }),
// assessSymptom: (id, body) =>
// request({ url: `/api/pets/${id}/ai/assess-symptom`, method: 'POST', data: body }),
};
module.exports = api;
function toast(error) {
wx.showToast({ title: error.message || "操作失败", icon: "none" });
}
function upload(path) {
return new Promise((resolve, reject) => {
const token = getApp().globalData.token;
wx.uploadFile({
url: baseURL + "/files",
filePath: path,
name: "file",
header: { Authorization: "Bearer " + token },
success(res) {
try {
const body = JSON.parse(res.data);
if (body.code === 0) resolve(body.data);
else reject(new Error(body.message));
} catch (e) {
reject(e);
}
},
fail: reject,
});
});
}
module.exports = { request, secure, toast, upload };
+3 -6
View File
@@ -1,7 +1,4 @@
// 后端地址。本地联调:微信开发者工具需在「详情 → 本地设置」勾选「不校验合法域名」。
// 上线时改为你的 https 域名,并在微信公众平台配置 request 合法域名。
//const BASE_URL = 'http://192.168.31.4:9090';
const BASE_URL = 'https://pet.sundynix.cn';
const baseURL = 'http://192.168.100.3:9090/api'
//const baseURL = 'https://pet.sundynix.cn'
module.exports = { BASE_URL };
module.exports = { baseURL};
-45
View File
@@ -1,45 +0,0 @@
// 宠物档案里那些「有数据但没露出」的东西。字段后端早就返回了,只是没人用。
// 单独放一个文件而不是塞在 home.js 里:身份卡、宠友主页以后都要用同一套算法。
// 星座。按公历日期分段,边界日取「这一天起算下一个星座」
const ZODIAC = [
[1, 20, '水瓶座'], [2, 19, '双鱼座'], [3, 21, '白羊座'], [4, 20, '金牛座'],
[5, 21, '双子座'], [6, 22, '巨蟹座'], [7, 23, '狮子座'], [8, 23, '处女座'],
[9, 23, '天秤座'], [10, 24, '天蝎座'], [11, 23, '射手座'], [12, 22, '摩羯座'],
];
function zodiac(birthday) {
if (!birthday) return '';
const p = String(birthday).slice(0, 10).split('-');
if (p.length < 3) return '';
const m = +p[1], d = +p[2];
if (!m || !d) return '';
// 12 月 22 日之后到 1 月 19 日都是摩羯
for (let i = 0; i < ZODIAC.length; i++) {
const [zm, zd, name] = ZODIAC[i];
if (m === zm) return d >= zd ? name : ZODIAC[(i + 11) % 12][2];
}
return '';
}
// 一起生活了多少天。优先用到家日期——「我们一起生活的第 N 天」说的是
// 它到你家之后的日子,不是它出生之后的日子。没填到家日就退回建档日。
// 一起生活了多少天,只从「到家日期」算 —— 没填到家日就返回 null(首页显示「-」)。
// 原来会退回建档日兜底,但那是「在这个 App 上建档的日子」,不是「它到你家的日子」,
// 领养多年后才装 App 的会算出个很小的数,反而误导
function daysTogether(pet) {
const from = (pet && pet.arrived_at) || '';
if (!from) return null;
const d = new Date(String(from).slice(0, 10) + 'T00:00:00');
if (isNaN(d.getTime())) return null;
const days = Math.floor((Date.now() - d.getTime()) / 86400000) + 1;
return days > 0 ? days : null;
}
// 性别徽章。后端存的是「男孩 / 女孩 / 不确定」
function genderBadge(gender) {
if (gender === '男孩') return { icon: '♂', tone: 'male' };
if (gender === '女孩') return { icon: '♀', tone: 'female' };
return { icon: '', tone: '' };
}
module.exports = { zodiac, daysTogether, genderBadge };
-78
View File
@@ -1,78 +0,0 @@
// 记录类型缓存。类型从后端来(后台可配),记录页要拿它渲染分组宫格,
// 弹层要拿它判断走专用表单还是通用简易表单。两边必须是同一份数据——
// 各自请求一次的话,后台改完配置两边会短暂不一致。
const api = require('./api.js');
// 四个分组对应的色系。这是纯表现层的东西,不进后端配置:
// 后端存 group key,色值留在前端,改配色不用动数据
const GROUP_TONE = {
daily: 'tone-1',
health: 'tone-2',
care: 'tone-4',
clean: 'tone-3',
};
let groups = null; // [{ key, label, items: [...] }]
let byCode = {};
let loading = null;
function index(gs) {
groups = (gs || []).map((g) => ({
...g,
tone: GROUP_TONE[g.key] || 'tone-1',
items: (g.items || []).map((t) => ({ ...t, tone: GROUP_TONE[g.key] || 'tone-1' })),
}));
byCode = {};
groups.forEach((g) => g.items.forEach((t) => (byCode[t.code] = t)));
return groups;
}
// load 只真正请求一次;并发调用共用同一个 promise
function load() {
if (groups) return Promise.resolve(groups);
if (loading) return loading;
loading = api
.recordTypes()
.then((gs) => {
loading = null;
return index(gs);
})
.catch((e) => {
loading = null;
throw e;
});
return loading;
}
// get 是同步的,给弹层用——弹层打开时不该再等一次网络请求。
// 记录页/首页先 load 过,缓存已经热了;万一没热,返回 undefined,
// 调用方按「专用表单」处理(那 9 种的分支是写死在 wxml 里的,不依赖这份缓存)
function get(code) {
return byCode[code];
}
// isSimple 走通用简易表单的类型。缓存没热时返回 false,
// 宁可打不开也不要用错的表单存下一条字段不全的记录
function isSimple(code) {
const t = byCode[code];
return !!t && t.form === 'simple';
}
function label(code) {
const t = byCode[code];
return (t && t.label) || '';
}
// 后台改了配置之后强制重取
function reset() {
groups = null;
byCode = {};
loading = null;
}
// groups 同步读缓存,给「任务关联记录类型」那个下拉用
function all() {
return groups;
}
module.exports = { load, get, isSimple, label, reset, groups: all, GROUP_TONE };
-167
View File
@@ -1,167 +0,0 @@
const { BASE_URL } = require('./config.js');
const TOKEN_KEY = 'pets_token';
const REFRESH_KEY = 'pets_refresh_token';
function getToken() {
return wx.getStorageSync(TOKEN_KEY) || '';
}
function setToken(t) {
wx.setStorageSync(TOKEN_KEY, t);
}
function getRefreshToken() {
return wx.getStorageSync(REFRESH_KEY) || '';
}
function setRefreshToken(t) {
if (t) wx.setStorageSync(REFRESH_KEY, t);
}
function clearToken() {
wx.removeStorageSync(TOKEN_KEY);
wx.removeStorageSync(REFRESH_KEY);
}
// token 失效时的重新登录钩子,由 store 注册(放在这里避免 request ← api ← store 循环依赖)
let reAuth = null;
function setReAuthHandler(fn) {
reAuth = fn;
}
const TIMEOUT = 15000;
// access token 只有 2 小时,过期后先拿 refresh token 换新的(用户无感),
// 换不到(刷新令牌也过期/被吊销)才退回 wx.login 重新登录。
// 单飞:并发请求同时 401 时只发一次刷新,否则刷新令牌会被并发轮换掉。
let refreshing = null;
function refreshAccessToken() {
if (!refreshing) {
const rt = getRefreshToken();
refreshing = (rt
? request({ url: '/api/auth/refresh', method: 'POST', data: { refresh_token: rt }, noAuthRetry: true })
: Promise.reject(new Error('没有刷新令牌'))
).then((data) => {
setToken(data.token);
setRefreshToken(data.refresh_token);
return data.token;
});
const done = () => {
refreshing = null;
};
refreshing.then(done, done);
}
return refreshing;
}
// 恢复登录态:先续期,续不了就整套重新登录
function recoverAuth() {
return refreshAccessToken().catch(() => {
clearToken();
if (!reAuth) throw new Error('登录已过期');
return reAuth();
});
}
// 统一请求:注入 token,解析 {code,message,data},成功返回 data,失败 reject(Error)。
// token 失效(40100)时自动续期/重新登录并重试一次,避免 JWT 过期后小程序静默白屏。
function request(options, _retried) {
const { url, method = 'GET', data, header = {}, noAuthRetry } = options;
return new Promise((resolve, reject) => {
const token = getToken();
wx.request({
url: BASE_URL + url,
method,
data,
timeout: TIMEOUT,
header: Object.assign(
{ 'Content-Type': 'application/json' },
token ? { Authorization: 'Bearer ' + token } : {},
header,
),
success(res) {
const body = res.data;
if (body && typeof body === 'object' && 'code' in body) {
if (body.code === 0) return resolve(body.data);
if (body.code === 40100) {
// 只重试一次,防止刷新/登录接口本身出问题时无限循环
if (_retried || noAuthRetry) {
clearToken();
return reject(new Error(body.message || '登录已过期,请重进小程序'));
}
return recoverAuth()
.then(() => request(options, true).then(resolve, reject))
.catch(() => {
clearToken();
reject(new Error(body.message || '登录已过期,请重进小程序'));
});
}
return reject(new Error(body.message || '请求失败'));
}
resolve(body);
},
fail(err) {
const msg = (err && err.errMsg) || '网络错误';
reject(new Error(/timeout/i.test(msg) ? '网络超时,请稍后重试' : msg));
},
});
});
}
// 文件上传(multipart)。三重兜底,应对「第一次报错、重试就好」的偶发:
// 1) 上传前若还没 token(冷启动竞态),先续期拿到再传;
// 2) token 过期(40100)→ 续期后重试;
// 3) 网络 fail / 网关 5xx(响应非 JSON)等瞬时错误 → 自动重试一次。
// 后端按 MD5 去重,重复上传同一张图返回同一条记录,重试是安全的。
function uploadFile(filePath, _retried) {
return new Promise((resolve, reject) => {
// 瞬时错误:还没重试过就延后再传一次,否则直接失败
const softRetry = (err) => {
if (_retried) return reject(err);
setTimeout(() => uploadFile(filePath, true).then(resolve, reject), 500);
};
const fire = () => {
const token = getToken();
wx.uploadFile({
url: BASE_URL + '/api/upload',
filePath,
name: 'file',
timeout: 60000,
header: token ? { Authorization: 'Bearer ' + token } : {},
success(res) {
let body;
try {
body = JSON.parse(res.data);
} catch (e) {
// 非 JSON 多为网关 5xx / 服务重启窗口,按瞬时错误重试
return softRetry(new Error('上传失败,请重试'));
}
if (body.code === 0) return resolve(body.data);
if (body.code === 40100 && !_retried) {
return recoverAuth()
.then(() => uploadFile(filePath, true).then(resolve, reject))
.catch(() => reject(new Error('登录已过期,请重进小程序')));
}
reject(new Error(body.message || '上传失败'));
},
fail(err) {
softRetry(new Error((err && err.errMsg) || '上传失败'));
},
});
};
// 冷启动竞态:点得太快、登录还没完成就上传,先把 token 拿到再传
if (!getToken() && !_retried) {
return recoverAuth().then(fire, fire);
}
fire();
});
}
module.exports = {
request,
uploadFile,
getToken,
setToken,
getRefreshToken,
setRefreshToken,
clearToken,
setReAuthHandler,
};
-107
View File
@@ -1,107 +0,0 @@
// 全局状态:登录、宠物列表、当前宠物;服务端驱动 + 发布订阅
const api = require('./api.js');
const { setReAuthHandler } = require('./request.js');
const state = {
user: null,
pets: [],
currentId: 0,
};
const listeners = new Set();
let bootstrapPromise = null;
function getPets() {
return state.pets;
}
function getPet() {
return state.pets.find((p) => p.id === state.currentId) || state.pets[0] || {};
}
function currentPetId() {
const p = getPet();
return p && p.id;
}
function notify() {
const pet = getPet();
listeners.forEach((fn) => {
try {
fn(pet);
} catch (e) {
/* noop */
}
});
}
function subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
// 登录 + 拉宠物列表(只跑一次)
// token 失效时清掉引导缓存并重新登录一次,让 request 层能自动重试
setReAuthHandler(async () => {
bootstrapPromise = null;
await ready();
});
function ready() {
if (!bootstrapPromise) {
bootstrapPromise = (async () => {
const res = await api.login();
state.user = res.user;
await loadPets();
})().catch((e) => {
bootstrapPromise = null; // 允许重试
throw e;
});
}
return bootstrapPromise;
}
async function loadPets() {
const pets = await api.getPets();
state.pets = pets || [];
if (!state.currentId && state.pets[0]) {
state.currentId = state.pets[0].id;
}
// 当前宠物被删或不存在时回退第一只
if (!state.pets.find((p) => p.id === state.currentId) && state.pets[0]) {
state.currentId = state.pets[0].id;
}
notify();
return state.pets;
}
function switchPet(id) {
if (state.currentId === id) return;
state.currentId = id;
notify();
}
// 本地更新当前宠物字段(服务端已改动后同步 UI)
function patchCurrent(patch) {
const pet = getPet();
Object.assign(pet, patch);
notify();
}
function setUser(u) {
state.user = u;
}
function getUser() {
return state.user;
}
module.exports = {
ready,
loadPets,
getPets,
getPet,
currentPetId,
switchPet,
patchCurrent,
subscribe,
setUser,
getUser,
};
-14
View File
@@ -1,14 +0,0 @@
// tabBar 高亮同步。
//
// 本来想让 custom-tab-bar 自己在 pageLifetimes.show 里算,页面一行都不用写。
// 实测不行:自定义 tabBar 不在页面节点树里,pageLifetimes 拿不到可靠的时机,
// 结果是切到「我的」时高亮还留在「首页」。只能回到官方那套「页面 onShow 里推一把」。
//
// 但索引仍然由 tabBar 按当前路由自己算,页面这边不写死 0/1/2/3/4——
// 调 tab 顺序时改一个 LIST 就够了,五个页面一行都不用动。
function syncTabBar(page) {
const bar = page.getTabBar && page.getTabBar();
if (bar && bar.syncSelected) bar.syncSelected();
}
module.exports = { syncTabBar };
-15
View File
@@ -1,15 +0,0 @@
// 轻量 UI 提示:统一错误反馈,避免请求失败时页面一片空白、用户不知道发生了什么。
let lastMsg = '';
let lastAt = 0;
// 提示错误。相同文案 3 秒内不重复弹,避免多个并发请求同时失败时刷屏。
function toastErr(e, fallback) {
const msg = (e && e.message) || fallback || '加载失败';
const now = Date.now();
if (msg === lastMsg && now - lastAt < 3000) return;
lastMsg = msg;
lastAt = now;
wx.showToast({ title: msg, icon: 'none', duration: 2000 });
}
module.exports = { toastErr };
-55
View File
@@ -1,55 +0,0 @@
// 公共图片上传:选图(仅图片)→ 上传后端(/api/upload,MD5 去重)→ 返回 {id,url,md5}
const { uploadFile } = require('./request.js');
const IMG_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'heic', 'heif'];
// 仅允许图片:按扩展名二次校验(chooseMedia mediaType 已限制,这里双保险)
function isImagePath(p) {
const m = /\.([a-zA-Z0-9]+)(\?.*)?$/.exec(p || '');
return !!m && IMG_EXT.indexOf(m[1].toLowerCase()) >= 0;
}
// 选择并上传图片,返回 Promise<[{id,url,md5}]>。用户取消时 reject({canceled:true})。
function chooseAndUploadImages(count) {
count = count || 1;
return new Promise((resolve, reject) => {
wx.chooseMedia({
count,
mediaType: ['image'], // 仅图片,不允许视频/文件
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success(res) {
const files = (res.tempFiles || []).filter((f) => isImagePath(f.tempFilePath));
if (!files.length) {
reject(new Error('只能上传图片文件'));
return;
}
wx.showLoading({ title: '上传中...', mask: true });
Promise.all(files.map((f) => uploadFile(f.tempFilePath)))
.then((list) => {
wx.hideLoading();
resolve(list);
})
.catch((e) => {
wx.hideLoading();
reject(e);
});
},
fail(err) {
const msg = (err && err.errMsg) || '';
if (/cancel/i.test(msg)) {
reject({ canceled: true });
return;
}
reject(new Error(msg || '选择图片失败'));
},
});
});
}
// 单张图片,返回 Promise<{id,url,md5}>
function chooseAndUploadImage() {
return chooseAndUploadImages(1).then((list) => list[0]);
}
module.exports = { chooseAndUploadImages, chooseAndUploadImage, isImagePath };