Files
sundynix-pets/pets-fe/utils/api.js
T
Blizzard cbfcaada55 feat: 宠物身份卡(canvas 绘制 + 小程序码 + 存相册/分享)
竞品那张「居民身份卡」,完整做了。

## 后端
service/wxacode.go —— 小程序码。两步:先拿 access_token(2h 有效,进程内缓存、
提前 5 分钟过期),再调 getwxacodeunlimit 换一张 PNG 存到 MinIO 返回 URL。
同一只宠物的码 object 名按 pet id 定死,存在就直接返回不重新生成 ——
微信侧有日调用配额,不能每次点都烧一次。

⚠️ getwxacodeunlimit 要求 page 在**已发布**的小程序版本里。开发/未发布时返回
errcode 41030,实测确认。这时身份卡照常出、只是没码(qr_ready=false),
发布后自动就有,不用改代码。best-effort,取不到不算错。

service/idcard.go —— 组装卡片内容。身份 ID 用「宠物雪花 id 的数字 + 生日」
拼成 18 位、每 4 位空一下,纯装饰不入库不校验,同一只每次都一样。
介绍按物种给默认(汪星人/喵星人来地球啦~)。有效期用生日起算。

GET /api/pets/:id/id-card
storage 加了 Exists(StatObject),生成前先查。

## 前端 pages/idcard
离屏 canvas 2d 画卡,导出成图展示 + 存相册 + 分享(open-type=share,
imageUrl 用画好的卡当封面)。

两张远程图(头像 + 小程序码)都用 node.createImage() 异步加载,
**Promise.all 等两张都就绪再开画** —— 否则小程序码会画不上或画一半
(规划里专门点过名的坑)。任一张加载失败 resolve(null),缺图也照画:
头像缺→画物种 emoji,码缺→画「小程序码 待发布」占位框。

存相册的权限被拒过一次后 wx.authorize 不再弹,走 openSetting 引导(同海报那套)。

绘制复用了 bottom-sheet 里 poster 的 canvas 模式(dpr 放大、roundRect、
canvasToTempFilePath),但 poster 只画文字不画远程图,这里的双图加载是新写的。

入口:「我的」→ 身份卡,用当前选中的宠物。

## 验证(预生产库实跑)
  id-card 接口返回:姓名/性别/品种/生日/介绍/身份ID/有效期 全对
  身份 ID = 3410 4333 1420 2503 17(宠物id数字+生日,稳定)
  qr_ready = false,日志确认 41030 invalid page(小程序未发布,预期)
  canvas 版式用 HTML 复刻确认过:未发布=占位、发布后=真码

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:48:16 +08:00

167 lines
8.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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() {
return new Promise((resolve, reject) => {
wx.login({
success: (r) => (r.code ? resolve(r.code) : reject(new Error('wx.login 未返回 code'))),
fail: reject,
});
});
}
// 登录:优先微信 code2session,失败回退开发态 Mock 登录(后端 dev_login=true
async function 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);
}
}
const api = {
login,
uploadFile,
// 用户
getProfile: () => request({ url: '/api/user/profile' }),
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;