Add one-way task completion and exportable pet identity cards
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
function profile(pet) {
|
||||
return {
|
||||
...pet,
|
||||
genderLabel:
|
||||
pet.gender === "male"
|
||||
? "男孩"
|
||||
: pet.gender === "female"
|
||||
? "女孩"
|
||||
: "未填写",
|
||||
birthday: pet.birth_date ? pet.birth_date.slice(0, 10) : "未填写",
|
||||
weightLabel: pet.weight_kg ? `${pet.weight_kg} kg` : "未填写",
|
||||
cardNumber:
|
||||
"PET-" + (pet.id || "").replace(/-/g, "").slice(0, 12).toUpperCase(),
|
||||
};
|
||||
}
|
||||
|
||||
function round(ctx, x, y, w, h, r) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||
ctx.arcTo(x, y + h, x, y, r);
|
||||
ctx.arcTo(x, y, x + w, y, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
function text(ctx, value, x, y, size, color, width, bold = false) {
|
||||
ctx.font = `${bold ? "600 " : ""}${size}px sans-serif`;
|
||||
ctx.fillStyle = color;
|
||||
let label = String(value || "未填写");
|
||||
if (width && ctx.measureText(label).width > width) {
|
||||
while (label && ctx.measureText(label + "…").width > width)
|
||||
label = label.slice(0, -1);
|
||||
label += "…";
|
||||
}
|
||||
ctx.fillText(label, x, y);
|
||||
}
|
||||
function image(canvas, path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = canvas.createImage();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error("照片加载失败,请重试"));
|
||||
img.src = path;
|
||||
});
|
||||
}
|
||||
async function renderCard(canvas, pet) {
|
||||
const p = profile(pet);
|
||||
canvas.width = 1200;
|
||||
canvas.height = 760;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.clearRect(0, 0, 1200, 760);
|
||||
const gradient = ctx.createLinearGradient(0, 0, 1200, 760);
|
||||
gradient.addColorStop(0, "#fff3e5");
|
||||
gradient.addColorStop(1, "#f0e9f8");
|
||||
ctx.fillStyle = gradient;
|
||||
round(ctx, 0, 0, 1200, 760, 48);
|
||||
ctx.fill();
|
||||
ctx.save();
|
||||
round(ctx, 0, 0, 1200, 760, 48);
|
||||
ctx.clip();
|
||||
ctx.fillStyle = "#ffffff66";
|
||||
ctx.beginPath();
|
||||
ctx.arc(1080, 140, 180, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.arc(1020, 700, 210, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
ctx.strokeStyle = "#e9d6c7";
|
||||
ctx.lineWidth = 2;
|
||||
round(ctx, 20, 20, 1160, 720, 34);
|
||||
ctx.stroke();
|
||||
const logo = await image(canvas, "/assets/logo-corgi-v2.png");
|
||||
ctx.drawImage(logo, 54, 52, 70, 70);
|
||||
text(ctx, "肉垫计划", 140, 82, 27, "#795a4b", 220, true);
|
||||
text(ctx, "PAWPRINT ID", 140, 113, 18, "#a48d7e", 220);
|
||||
text(ctx, "毛孩身份卡", 842, 103, 38, "#604a40", 300, true);
|
||||
ctx.strokeStyle = "#dfcdbf";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(54, 155);
|
||||
ctx.lineTo(1146, 155);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = "#fffaf4";
|
||||
round(ctx, 58, 200, 310, 340, 28);
|
||||
ctx.fill();
|
||||
if (p.avatar_url) {
|
||||
const info = await new Promise((resolve, reject) =>
|
||||
wx.getImageInfo({
|
||||
src: p.avatar_url,
|
||||
success: resolve,
|
||||
fail: () => reject(new Error("照片下载失败,请检查网络后重试")),
|
||||
}),
|
||||
);
|
||||
const photo = await image(canvas, info.path);
|
||||
ctx.save();
|
||||
round(ctx, 70, 212, 286, 316, 20);
|
||||
ctx.clip();
|
||||
// Aspect fill, with a centered crop matching the card's portrait window.
|
||||
const ratio = 286 / 316;
|
||||
const sw = Math.min(photo.width, photo.height * ratio),
|
||||
sh = sw / ratio;
|
||||
ctx.drawImage(
|
||||
photo,
|
||||
(photo.width - sw) / 2,
|
||||
(photo.height - sh) / 2,
|
||||
sw,
|
||||
sh,
|
||||
70,
|
||||
212,
|
||||
286,
|
||||
316,
|
||||
);
|
||||
ctx.restore();
|
||||
} else {
|
||||
ctx.fillStyle = "#d18b67";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(213, 399, 53, 42, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
for (const [x, y, r] of [
|
||||
[145, 335, 20],
|
||||
[188, 309, 22],
|
||||
[235, 309, 22],
|
||||
[278, 335, 20],
|
||||
]) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
text(ctx, "等待一张可爱照片", 114, 491, 23, "#9b7966", 230);
|
||||
}
|
||||
text(ctx, p.name, 410, 250, 58, "#604a40", 700, true);
|
||||
text(
|
||||
ctx,
|
||||
`${p.breed || "品种未填写"} · ${p.genderLabel}`,
|
||||
412,
|
||||
298,
|
||||
28,
|
||||
"#886c5f",
|
||||
700,
|
||||
);
|
||||
const fields = [
|
||||
["生日", p.birthday, 412, 368],
|
||||
["体重", p.weightLabel, 805, 368],
|
||||
["毛色", p.coat || "未填写", 412, 465],
|
||||
];
|
||||
for (const [label, value, x, y] of fields) {
|
||||
text(ctx, label, x, y, 23, "#a48d7e", 290);
|
||||
text(
|
||||
ctx,
|
||||
value,
|
||||
x,
|
||||
y + 43,
|
||||
31,
|
||||
"#604a40",
|
||||
x === 412 && y === 465 ? 680 : 300,
|
||||
true,
|
||||
);
|
||||
}
|
||||
ctx.strokeStyle = "#dfcdbf";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(54, 585);
|
||||
ctx.lineTo(1146, 585);
|
||||
ctx.stroke();
|
||||
text(ctx, "陪伴编号", 58, 635, 20, "#a48d7e", 200);
|
||||
text(ctx, p.cardNumber, 58, 676, 29, "#795a4b", 650, true);
|
||||
text(ctx, "每一位家人,都值得被好好照顾", 650, 669, 24, "#886c5f", 490);
|
||||
return new Promise((resolve, reject) =>
|
||||
wx.canvasToTempFilePath({
|
||||
canvas,
|
||||
fileType: "png",
|
||||
success: (res) => resolve(res.tempFilePath),
|
||||
fail: () => reject(new Error("档案图片生成失败,请重试")),
|
||||
}),
|
||||
);
|
||||
}
|
||||
async function saveToAlbum(filePath) {
|
||||
try {
|
||||
await new Promise((resolve, reject) =>
|
||||
wx.saveImageToPhotosAlbum({ filePath, success: resolve, fail: reject }),
|
||||
);
|
||||
} catch (error) {
|
||||
if (/cancel/.test(error.errMsg || "")) return false;
|
||||
const settings = await new Promise((resolve, reject) =>
|
||||
wx.getSetting({ success: resolve, fail: reject }),
|
||||
);
|
||||
if (settings.authSetting["scope.writePhotosAlbum"] !== false)
|
||||
throw new Error("保存失败,请在真机上重试");
|
||||
const confirm = await wx.showModal({
|
||||
title: "允许保存档案卡",
|
||||
content: "需要相册权限,将这张毛孩身份卡保存到你的手机。",
|
||||
confirmText: "去设置",
|
||||
});
|
||||
if (!confirm.confirm) return false;
|
||||
const enabled = await new Promise((resolve, reject) =>
|
||||
wx.openSetting({ success: resolve, fail: reject }),
|
||||
);
|
||||
if (!enabled.authSetting["scope.writePhotosAlbum"]) return false;
|
||||
await new Promise((resolve, reject) =>
|
||||
wx.saveImageToPhotosAlbum({
|
||||
filePath,
|
||||
success: resolve,
|
||||
fail: () => reject(new Error("保存失败,请重试")),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
module.exports = { profile, renderCard, saveToAlbum };
|
||||
@@ -0,0 +1,73 @@
|
||||
const { secure, toast } = require("./api");
|
||||
const { localDate } = require("./date");
|
||||
|
||||
function apply(page, ids) {
|
||||
const tasks = page.data.tasks.map((task) =>
|
||||
ids.includes(task.id) ? { ...task, completed: true } : task,
|
||||
);
|
||||
page.setData({
|
||||
tasks,
|
||||
done: tasks.filter((t) => t.completed).length,
|
||||
undoneCount: tasks.filter((t) => !t.completed).length,
|
||||
});
|
||||
if (wx.vibrateShort) wx.vibrateShort({ type: "light", fail() {} });
|
||||
}
|
||||
|
||||
async function complete(page, task) {
|
||||
if (
|
||||
!task ||
|
||||
task.completed ||
|
||||
page.data.completingAll ||
|
||||
page.data.taskBusyIds.length
|
||||
)
|
||||
return;
|
||||
const date = page.data.date || localDate();
|
||||
page.setData({ taskBusyIds: [task.id] });
|
||||
try {
|
||||
await secure("/tasks/" + task.id + "/completion", "PUT", {
|
||||
date,
|
||||
completed: true,
|
||||
});
|
||||
if (page.data.date && page.data.date !== date) return;
|
||||
apply(page, [task.id]);
|
||||
wx.showToast({ title: "照顾好了,记下一份陪伴", icon: "success" });
|
||||
} catch (error) {
|
||||
toast(error);
|
||||
} finally {
|
||||
page.setData({ taskBusyIds: [] });
|
||||
}
|
||||
}
|
||||
|
||||
async function completeAll(page) {
|
||||
if (page.data.completingAll || page.data.taskBusyIds.length) return;
|
||||
const tasks = page.data.tasks.filter((t) => !t.completed);
|
||||
if (!tasks.length) return;
|
||||
const date = page.data.date || localDate();
|
||||
page.setData({ completingAll: true });
|
||||
try {
|
||||
const confirmation = await wx.showModal({
|
||||
title: "都照顾好了吗?",
|
||||
content: `将 ${date} 的 ${tasks.length} 项待办记为完成,完成后不能撤销。`,
|
||||
confirmText: "全部完成",
|
||||
confirmColor: "#b95738",
|
||||
});
|
||||
if (!confirmation.confirm) return;
|
||||
// The API accepts bounded batches; avoid partial completion of larger lists.
|
||||
if (tasks.length > 200) throw new Error("任务较多,请分项完成后再试");
|
||||
await secure("/tasks/completions", "PUT", {
|
||||
date,
|
||||
task_ids: tasks.map((t) => t.id),
|
||||
});
|
||||
if (page.data.date && page.data.date !== date) return;
|
||||
apply(
|
||||
page,
|
||||
tasks.map((t) => t.id),
|
||||
);
|
||||
wx.showToast({ title: "日常照顾全部完成", icon: "success" });
|
||||
} catch (error) {
|
||||
toast(error);
|
||||
} finally {
|
||||
page.setData({ completingAll: false });
|
||||
}
|
||||
}
|
||||
module.exports = { complete, completeAll };
|
||||
Reference in New Issue
Block a user