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
+31 -25
View File
@@ -1,28 +1,34 @@
const store = require('./utils/store.js');
App({
globalData: {
statusBarHeight: 20,
navBarHeight: 44,
menuRight: 10
},
globalData: { token: "", user: null },
onLaunch() {
// 启动即登录并加载宠物(失败不阻塞 UI,页面会各自重试)
store.ready().catch((e) => console.warn('[app] 启动登录失败:', e && e.message));
// 计算自定义导航栏所需的状态栏 / 胶囊尺寸
try {
const win = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync();
const menu = wx.getMenuButtonBoundingClientRect();
const statusBarHeight = win.statusBarHeight || 20;
// 导航栏高度 = 胶囊高度 + 上下等距间隙
const navBarHeight = (menu.top - statusBarHeight) * 2 + menu.height;
this.globalData.statusBarHeight = statusBarHeight;
this.globalData.navBarHeight = navBarHeight;
this.globalData.menuRight = win.windowWidth - menu.right;
this.globalData.menuHeight = menu.height;
this.globalData.menuTop = menu.top;
} catch (e) {
// 兜底
}
}
this.globalData.token = wx.getStorageSync("pet_token") || "";
},
async login() {
if (this.globalData.token) return this.globalData.token;
if (!this.loginPromise)
this.loginPromise = (async () => {
const { isSimulator } = require("./utils/config");
const { request } = require("./utils/api");
let data;
try {
const { code } = await wx.login();
data = await request("/auth/login", "POST", { code }, false);
} catch (error) {
if (!isSimulator) throw error;
data = await request(
"/auth/dev",
"POST",
{ key: "wechat-devtools", nickname: "本地铲屎官" },
false,
);
}
this.globalData.token = data.token;
this.globalData.user = data.user;
wx.setStorageSync("pet_token", data.token);
return data.token;
})().finally(() => {
this.loginPromise = null;
});
return this.loginPromise;
},
});
+6 -50
View File
@@ -1,53 +1,9 @@
{
"pages": [
"pages/onboarding/onboarding",
"pages/home/home",
"pages/plan/plan",
"pages/record/record",
"pages/addrecord/addrecord",
"pages/history/history",
"pages/expense/expense",
"pages/petform/petform",
"pages/idcard/idcard",
"pages/report/report",
"pages/community/community",
"pages/learn/learn",
"pages/article/article",
"pages/mine/mine",
"pages/decorate/decorate",
"pages/settings/settings",
"pages/user/user",
"pages/relations/relations"
],
"window": {
"navigationStyle": "custom",
"backgroundColor": "#F8F6F2",
"backgroundTextStyle": "dark"
},
"tabBar": {
"custom": true,
"color": "#8D8277",
"selectedColor": "#DD8420",
"backgroundColor": "#FFFFFF",
"list": [
{
"pagePath": "pages/home/home",
"text": "首页"
},
{
"pagePath": "pages/report/report",
"text": "报告"
},
{
"pagePath": "pages/community/community",
"text": "社区"
},
{
"pagePath": "pages/mine/mine",
"text": "我的"
}
]
},
"pages": ["pages/home/home", "pages/meetups/meetups", "pages/walk/walk", "pages/tasks/tasks", "pages/pets/pets", "pages/pet-edit/pet-edit", "pages/meetup-edit/meetup-edit", "pages/share/share"],
"window": { "navigationStyle": "custom", "backgroundColor": "#f6f9f6", "backgroundTextStyle": "dark" },
"permission": { "scope.userLocation": { "desc": "遛狗时记录轨迹,并在你主动开启分享后展示实时位置" } },
"requiredPrivateInfos": ["getLocation", "chooseLocation"],
"sitemapLocation": "sitemap.json",
"style": "v2"
"style": "v2",
"lazyCodeLoading": "requiredComponents"
}
+88 -532
View File
@@ -1,551 +1,107 @@
/* ===== 全局设计令牌 + 通用样式 =====
规则:任何页面/组件都不要再写死色值、圆角、字号,一律用下面的 token。
收敛前的现状是 22 种圆角、26 档字号、9 种近似浅橙、7 处一次性阴影,
font-weight 900 到处都是——什么都在喊,于是什么都不突出。 */
page{
/* ── 底色与文字 ── */
--bg:#F8F6F2;
--bg2:#FBF8F3;
--card:#FFFFFF;
--surface-2:#FBFAF8; /* 卡片内的次级底色(原本硬编码出现 10+ 次) */
--text:#2D2925;
--text-2:#5F554D; /* 正文次级灰(原本有 7 种近似值散落各处) */
--muted:#8D8277;
--muted2:#B5A99D;
--line:#EEE6DC;
/* ── 主色 ── */
--primary:#F5A947;
--primary2:#FFBF68;
--primary-dark:#DD8420;
--primary-soft:#FFF4E4; /* 唯一的浅橙底(收编了 9 种近似值) */
--primary-line:#FFD6A1; /* 浅橙块的描边 */
--primary-ink:#A96500; /* 浅橙底上的文字 */
/* 门面品牌色带(首页/记录页顶部,方案 A)*/
--band-a:#FFC768;
--band-b:#F5A947;
--band-ink:#7A4A08; /* 色带上的深棕文字 */
/* ── 语义色:soft 底 / base 主体 / ink 文字,三件套配齐 ── */
--green:#73BE9D; --green-soft:#EAF7F1; --green-ink:#2E7D5B;
--red:#EE7D73; --red-soft:#FFF0EE; --red-ink:#C95348;
--blue:#78A6E8; --blue-soft:#EEF5FF; --blue-ink:#3C6EBA;
--purple:#A993E8; --purple-soft:#F4F0FF; --purple-ink:#765AC5;
--yellow-soft:#FFF6E4;
/* ── 深色主 CTA。全页最多一个,用来指明「这一步该点哪」 ── */
--cta:#2D2925;
/* ── 圆角 5 档 ── */
--r-xs:12rpx;
--r-sm:20rpx;
--r-md:28rpx;
--r-lg:40rpx; /* 卡片 */
--r-full:999rpx;
--radius:var(--r-lg); /* 兼容旧名,页面里还有引用 */
/* ── 字号 6 档 ── */
--fs-cap:22rpx;
--fs-sm:24rpx;
--fs-md:28rpx;
--fs-lg:32rpx;
--fs-xl:40rpx;
--fs-2xl:48rpx;
/* ── 字重只留两档。强调靠颜色和字号,不靠把所有东西都加粗 ── */
--fw:400;
--fw-b:600;
/* ── 间距 6 档 ── */
--sp-1:8rpx; --sp-2:12rpx; --sp-3:16rpx;
--sp-4:24rpx; --sp-5:32rpx; --sp-6:40rpx;
/* ── 控件高度 3 档 ── */
--h-sm:68rpx;
--h-md:80rpx;
--h-lg:96rpx;
/* ── 阴影 3 档(原来 7 处一次性阴影,且都偏重) ── */
--sd-1:0 12rpx 32rpx rgba(70,48,25,.06);
--sd-2:0 20rpx 48rpx rgba(70,48,25,.10);
--sd-3:0 -20rpx 60rpx rgba(70,48,25,.14);
--shadow:var(--sd-1); /* 兼容旧名 */
--shadow2:var(--sd-2);
/* 页面留白。FAB 固定在 bottom:190rpx、高 116rpx,上沿距底 306rpx,
内容必须让开这段距离,否则最后一张卡会被它压住。 */
--pad-x:40rpx;
--pad-b-fab:calc(330rpx + env(safe-area-inset-bottom));
--pad-b-plain:calc(60rpx + env(safe-area-inset-bottom));
/* tab 页底下有原生 tabBar(高 108rpx)。报告页/我的没有 FAB,用 no-fab 的
60rpx 让不开 tabBar,最后一张卡会被 tabBar 压住 —— 这两页要用这个 */
--pad-b-tab:calc(150rpx + env(safe-area-inset-bottom));
color:var(--text);
font-weight:var(--fw);
font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text","PingFang SC","Microsoft YaHei",sans-serif;
/* 整页暖橘竖向淡出:顶部品牌橘暖调 → 约半屏处褪到米白。
渐变铺在 page 上(不滚),内容在 .page-scroll 里滚过它,顶部常暖。 */
background:
linear-gradient(180deg,
#FFC66E 0%,
#FFD489 10%,
#FCE6C4 28%,
var(--bg) 48%,
var(--bg) 100%);
/* 页面自身不滚,滚动交给 .page-scroll,这样 nav-bar 和 tabBar 才能固定住。
这 4 行原本在 9 个页面的 wxss 里逐字重复。 */
height:100vh;
overflow:hidden;
display:flex;
flex-direction:column;
position:relative; /* 门面色带 .brand-band 绝对定位锚在页面上 */
page {
background: #f6f9f6;
color: #173e36;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
font-size: 28rpx;
}
view,text{box-sizing:border-box}
/* 页面骨架:nav-bar → .page-scroll(承载滚动) → .page-body(内容留白) */
.page-scroll{flex:1;min-height:0}
.page-body{padding:var(--sp-1) var(--pad-x) var(--pad-b-fab)}
/* 没挂 FAB 的页面(目前只有文章详情)不需要给它让位 */
.page-body.no-fab{padding-bottom:var(--pad-b-plain)}
.page-body.tab-nofab{padding-bottom:var(--pad-b-tab)}
/* 全局隐藏滚动条(页面级 + 组件级),保留正常滚动 */
::-webkit-scrollbar{
width:0 !important;
height:0 !important;
display:none;
-webkit-appearance:none;
background:transparent;
color:transparent;
view,
text,
button,
input,
textarea {
box-sizing: border-box;
}
page{scrollbar-width:none;-ms-overflow-style:none}
/* 文本 */
.muted{color:var(--muted)}
.tiny{font-size:var(--fs-sm);color:var(--muted)}
.bold{font-weight:var(--fw-b)}
/* 区块标题 */
.section-head{
display:flex;align-items:center;justify-content:space-between;margin:var(--sp-1) 0 var(--sp-4);
button {
border: 0;
}
.section-head .sh-title{font-size:var(--fs-lg);letter-spacing:-.4rpx;font-weight:var(--fw-b)}
.link{color:var(--primary-dark);font-weight:var(--fw-b);font-size:var(--fs-md)}
/* 一组 .link 当「切换」用时:未选中压成灰,不然两个都是橙的、看不出当前在哪。
单独一个 .link-tabs 而不是挂在 .head-links 上——首页的「管理 / 全部完成」
也用 head-links,那是两个并列动作,不该有一个是灰的 */
.link-tabs{display:flex;gap:var(--sp-4)}
.link-tabs .link{color:var(--muted)}
.link-tabs .link.on{color:var(--primary-dark)}
.head-links{display:flex;align-items:center;gap:var(--sp-4)}
/* 页面标题。
小程序没有 h2/p/b/span 标签,从 preview.html 迁移来的标签选择器全是死规则,
各页当初都自建了替身 class(.pt-h2 在 5 个文件里逐字重复),这里统一收编。 */
.page-title{margin:4rpx 0 var(--sp-4)}
.pt-h2{font-size:var(--fs-2xl);letter-spacing:-.6rpx;font-weight:var(--fw-b)}
.pt-p{color:var(--muted);font-size:var(--fs-md);margin-top:10rpx}
/* 按钮 */
.btn{
border:0;height:var(--h-lg);border-radius:var(--r-md);padding:0 var(--sp-5);
display:inline-flex;align-items:center;justify-content:center;gap:var(--sp-2);
font-weight:var(--fw-b);font-size:var(--fs-lg);line-height:1.3;transition:.16s ease;
min-height:var(--h-lg);height:auto;padding-top:var(--sp-2);padding-bottom:var(--sp-2);
button::after {
border: 0;
}
.btn::after{border:none}
.btn:active{transform:scale(.98)}
/* 主按钮保持暖橙:它在 36 处被使用,绝大多数是弹层里的「保存」,
把它改成深色会让整个 App 的主色消失。深色 CTA 走 .btn-dark。 */
.btn-primary{color:#fff;background:var(--primary);box-shadow:0 12rpx 28rpx rgba(245,169,71,.24)}
.btn-dark{color:#fff;background:var(--cta)}
.btn-ghost{color:var(--text);background:#fff;border:1rpx solid var(--line)}
.btn-soft{color:var(--primary-ink);background:var(--primary-soft)}
.btn-green{color:#fff;background:var(--green)}
.btn-block{width:100%;display:flex}
.btn-small{min-height:var(--h-sm);border-radius:var(--r-sm);padding:0 var(--sp-4);font-size:var(--fs-sm)}
/* 卡片 */
.card{
background:var(--card);border-radius:var(--r-lg);box-shadow:var(--sd-1);
padding:var(--sp-5);margin-bottom:var(--sp-4);position:relative;overflow:hidden;
.screen {
min-height: 100vh;
padding: calc(env(safe-area-inset-top) + 36rpx) 32rpx 170rpx;
}
.card.no-shadow{box-shadow:none;border:1rpx solid var(--line)}
/* 标签 */
.tag{
display:inline-flex;align-items:center;height:48rpx;border-radius:var(--r-full);padding:0 var(--sp-3);
font-size:var(--fs-sm);font-weight:var(--fw-b);background:var(--green-soft);color:var(--green-ink);white-space:nowrap;
.eyebrow {
color: #8fa39b;
font-size: 22rpx;
font-weight: 600;
letter-spacing: 2rpx;
}
.tag.warn{background:var(--primary-soft);color:var(--primary-ink)}
.tag.red{background:var(--red-soft);color:var(--red-ink)}
.tag.blue{background:var(--blue-soft);color:var(--blue-ink)}
.tag.purple{background:var(--purple-soft);color:var(--purple-ink)}
/* 状态 chip:比 tag 矮,可带图标,用于「体重稳定」「驱虫将到期」这类瞬时状态 */
.chip{
display:inline-flex;align-items:center;gap:var(--sp-1);height:44rpx;padding:0 var(--sp-3);
border-radius:var(--r-full);font-size:var(--fs-cap);font-weight:var(--fw-b);
background:var(--primary-soft);color:var(--primary-ink);white-space:nowrap;
.title {
font-size: 48rpx;
font-weight: 800;
line-height: 1.2;
margin: 12rpx 0 26rpx;
}
.chip.green{background:var(--green-soft);color:var(--green-ink)}
.chip.red{background:var(--red-soft);color:var(--red-ink)}
.chip.blue{background:var(--blue-soft);color:var(--blue-ink)}
/* 4 色分类块。用在快速记录入口这类并列的功能格上,靠颜色区分类别而不是靠 emoji */
.tone-1{background:var(--primary-soft);color:var(--primary-ink)}
.tone-2{background:var(--green-soft);color:var(--green-ink)}
.tone-3{background:var(--blue-soft);color:var(--blue-ink)}
.tone-4{background:var(--purple-soft);color:var(--purple-ink)}
/* 记录入口宫格。首页(4 列 8 个)和记录页(3 列 9 个)原本是同一个东西的两个副本,
高度/圆角/阴影/字号全不一样,这里合成一套。 */
.quick-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:var(--sp-2)}
.quick-grid.cols-3{grid-template-columns:repeat(3,1fr)}
.quick{
border-radius:var(--r-md);min-height:128rpx;
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--sp-1);
font-weight:var(--fw-b);font-size:var(--fs-sm);
.section-title {
font-size: 34rpx;
font-weight: 800;
margin: 38rpx 0 20rpx;
}
/* 空态。原本有 6 套长得差不多的写法散在各页 */
.empty{padding:56rpx var(--sp-1);text-align:center;color:var(--muted);font-size:var(--fs-sm)}
.empty.lg{padding:96rpx 0}
/* 表单 */
.field{margin-bottom:var(--sp-4)}
.field:last-child{margin-bottom:0}
.field label{display:block;font-size:var(--fs-md);color:var(--muted);font-weight:var(--fw-b);margin-bottom:var(--sp-3)}
.input,input,textarea,.picker-box{
width:100%;border:1rpx solid var(--line);background:var(--surface-2);height:88rpx;border-radius:var(--r-md);
padding:0 var(--sp-4);color:var(--text);font-size:var(--fs-lg);outline:none;box-sizing:border-box;
display:flex;align-items:center;
.muted {
color: #8b9f96;
}
.textarea{
width:100%;border:1rpx solid var(--line);background:var(--surface-2);height:184rpx;border-radius:var(--r-md);
padding:var(--sp-4);color:var(--text);font-size:var(--fs-lg);box-sizing:border-box;
.card {
background: #fff;
border-radius: 34rpx;
box-shadow: 0 15rpx 45rpx rgba(30, 70, 55, 0.05);
padding: 28rpx;
margin-bottom: 20rpx;
}
/* 上面那条共用规则的 flex+align-items:center 是给单行 input 竖向居中用的,
但选择器带了 textarea,导致多行输入的占位/文字被竖向居中、不从第一行开始。
这里把 textarea 改回块级顶部对齐,各自的高度仍由具体 class 决定 */
textarea{display:block;padding:var(--sp-4);line-height:1.6}
.placeholder{color:var(--muted2)}
/* 分段选择 */
.seg{display:flex;gap:var(--sp-3)}
.seg .seg-btn{
flex:1;height:var(--h-md);line-height:var(--h-md);text-align:center;border-radius:var(--r-md);
border:1rpx solid var(--line);background:#fff;color:var(--muted);font-weight:var(--fw-b);font-size:var(--fs-md);
.primary {
background: #194f42;
color: white;
border-radius: 23rpx;
font-size: 28rpx;
font-weight: 700;
padding: 22rpx 30rpx;
line-height: 1.3;
}
.seg .seg-btn.selected{color:var(--primary-dark);background:var(--primary-soft);border-color:var(--primary-line)}
/* 多选 check */
.check-list{display:flex;flex-direction:column;gap:var(--sp-3);margin:var(--sp-5) 0}
.check{
display:flex;align-items:center;gap:var(--sp-3);background:#fff;box-shadow:var(--sd-1);
border-radius:var(--r-md);padding:var(--sp-4);border:1rpx solid transparent;font-size:var(--fs-md);font-weight:var(--fw-b);
.secondary {
background: #e7f0e7;
color: #28674e;
border-radius: 23rpx;
font-size: 27rpx;
font-weight: 700;
padding: 20rpx;
}
.box{
width:44rpx;height:44rpx;border-radius:var(--r-xs);border:4rpx solid #DDCEC1;
display:flex;align-items:center;justify-content:center;color:#fff;flex:none;
font-size:var(--fs-md);font-weight:var(--fw-b);
.row {
display: flex;
align-items: center;
justify-content: space-between;
}
.check.selected{background:var(--primary-soft);border-color:var(--primary-line)}
.check.selected .box{background:var(--primary);border-color:var(--primary)}
/* 圆形/条形选项 */
.mini-options{display:flex;flex-wrap:wrap;gap:var(--sp-3);margin:var(--sp-3) 0 var(--sp-4)}
.option{
border:1rpx solid var(--line);background:#fff;border-radius:var(--r-full);height:var(--h-sm);line-height:var(--h-sm);
padding:0 var(--sp-4);color:var(--text-2);font-weight:var(--fw-b);font-size:var(--fs-sm);
.pill {
display: inline-flex;
align-items: center;
border-radius: 99rpx;
background: #e9f3e8;
color: #36805c;
padding: 8rpx 18rpx;
font-size: 23rpx;
}
.option.selected{background:var(--primary-soft);color:var(--primary-dark);border-color:var(--primary-line)}
/* 进度条 */
.progress-row{
display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--sp-3);
font-size:var(--fs-md);color:var(--text-2);font-weight:var(--fw-b);
.empty {
text-align: center;
color: #9daf9f;
padding: 64rpx 24rpx;
}
.bar{height:var(--sp-3);border-radius:var(--r-full);background:#F0E7DD;overflow:hidden}
.bar .bar-fill{
height:100%;border-radius:var(--r-full);
background:linear-gradient(90deg,var(--primary),#FFD57F);display:block;transition:.25s ease;
.field {
margin: 22rpx 0;
}
/* 头像 */
.avatar{
width:120rpx;height:120rpx;border-radius:var(--r-lg);
background:linear-gradient(135deg,#FFE5B7,#FFC06B);
display:flex;align-items:center;justify-content:center;font-size:64rpx;
box-shadow:var(--sd-1);flex:none;
.field-label {
font-size: 24rpx;
color: #718b7e;
margin-bottom: 12rpx;
}
/* 多宠切换 */
.pet-switch{display:flex;gap:var(--sp-3);overflow-x:auto;padding:0 0 var(--sp-4);white-space:nowrap}
.pet-switch::-webkit-scrollbar{display:none}
.pet-chip{
flex:none;display:inline-flex;border:1rpx solid var(--line);background:#fff;border-radius:var(--r-full);
padding:var(--sp-2) var(--sp-4);align-items:center;gap:var(--sp-2);color:var(--muted);font-weight:var(--fw-b);font-size:var(--fs-md);
.field input,
.field textarea,
.field picker {
width: 100%;
background: #fff;
border-radius: 20rpx;
padding: 22rpx;
font-size: 28rpx;
min-height: 76rpx;
}
.pet-chip.active{color:var(--primary-dark);background:var(--primary-soft);border-color:var(--primary-line)}
/* 任务列表 */
.task-list{display:flex;flex-direction:column;gap:var(--sp-2)}
.task{display:flex;gap:var(--sp-3);align-items:flex-start;background:var(--surface-2);border-radius:var(--r-md);padding:var(--sp-4);transition:.15s ease}
.circle{
width:44rpx;height:44rpx;border-radius:50%;border:4rpx solid #DED1C4;display:flex;
align-items:center;justify-content:center;color:#fff;font-size:var(--fs-sm);flex:none;margin-top:2rpx;
.field textarea {
height: 170rpx;
}
.task.done{opacity:.55}
.task.done .circle{background:var(--green);border-color:var(--green)}
.task-text{flex:1}
.tt-b{font-size:var(--fs-md);font-weight:var(--fw-b)}
.tt-p{font-size:var(--fs-sm);color:var(--muted);margin-top:var(--sp-1);line-height:1.45}
.priority{
font-size:var(--fs-cap);color:var(--primary-ink);background:var(--primary-soft);
border-radius:var(--r-full);padding:6rpx var(--sp-2);font-weight:var(--fw-b);flex:none;
}
/* 记录行 */
.record-row{
display:flex;justify-content:space-between;align-items:center;padding:var(--sp-4) 0;
border-bottom:1rpx solid var(--line);font-size:var(--fs-md);
}
.record-row:last-child{border-bottom:0}
/* 统计格 */
.stats-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--sp-3);margin:var(--sp-4) 0}
.stat{background:#fff;border:1rpx solid var(--line);border-radius:var(--r-md);padding:var(--sp-4) var(--sp-2);text-align:center}
.stat-b{display:block;font-size:var(--fs-xl);margin-bottom:6rpx;font-weight:var(--fw-b)}
.stat-s{font-size:var(--fs-cap);color:var(--muted)}
/* 文章卡 */
.article-card{
display:flex;gap:var(--sp-4);background:#fff;border-radius:var(--r-lg);padding:var(--sp-4);box-shadow:var(--sd-1);margin-bottom:var(--sp-4);
}
.article-icon{
width:104rpx;height:104rpx;border-radius:var(--r-md);flex:none;display:flex;align-items:center;
justify-content:center;background:var(--primary-soft);color:var(--primary-ink);font-size:48rpx;
}
.ac-b{font-size:var(--fs-lg);font-weight:var(--fw-b)}
.ac-p{color:var(--muted);font-size:var(--fs-sm);line-height:1.45;margin-top:10rpx}
/* 海报 */
.poster{
background:
radial-gradient(circle at 100% 0%, rgba(245,169,71,.24), transparent 35%),
linear-gradient(160deg,#FFF7E8,#FFFFFF);
border:1rpx solid #F2DFC3;border-radius:var(--r-lg);padding:var(--sp-5);text-align:center;overflow:hidden;position:relative;
}
/* 圆头像 + 白边,和保存的图一致 */
.poster-avatar{
width:160rpx;height:160rpx;margin:var(--sp-4) auto var(--sp-3);border-radius:50%;
background:linear-gradient(135deg,#FFE5B7,#FFC06B);display:flex;align-items:center;justify-content:center;
font-size:80rpx;box-shadow:var(--sd-1);overflow:hidden;border:6rpx solid #fff;
}
.poster-avatar-img{width:100%;height:100%;display:block}
.poster-meta{font-size:var(--fs-md);color:var(--muted)}
/* 周期做成小胶囊 */
.poster-period{
display:inline-block;margin-top:12rpx;padding:4rpx var(--sp-3);border-radius:var(--r-full);
background:var(--primary-soft);color:var(--primary-ink);
font-size:var(--fs-cap);font-weight:var(--fw-b);
}
/* 数据清单:每行 打勾 + 项目 + 数值 右对齐 */
.poster-list{
background:rgba(255,255,255,.78);border-radius:var(--r-md);
padding:var(--sp-3) var(--sp-4);margin:var(--sp-5) 0 var(--sp-4);
}
.poster-row{display:flex;align-items:center;gap:var(--sp-3);padding:var(--sp-2) 0}
.poster-tick{
width:36rpx;height:36rpx;flex:none;border-radius:50%;
background:var(--green-soft);color:var(--green-ink);
display:flex;align-items:center;justify-content:center;
}
.poster-rt{flex:1;text-align:left;font-size:var(--fs-md);color:var(--text-2)}
.poster-rn{font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)}
/* 健康状态徽章 */
.poster-status{
display:flex;align-items:center;justify-content:center;gap:var(--sp-2);
font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text);margin-top:var(--sp-2);
}
.poster-badge{
padding:4rpx var(--sp-3);border-radius:var(--r-full);
background:var(--green-soft);color:var(--green-ink);font-size:var(--fs-sm);
}
.poster-from{margin-top:var(--sp-4);font-size:var(--fs-cap);color:var(--muted2)}
/* 风险盒 */
.risk-box{
background:var(--primary-soft);color:var(--primary-ink);padding:var(--sp-4);border-radius:var(--r-md);font-weight:var(--fw-b);
display:flex;gap:var(--sp-3);margin-bottom:var(--sp-4);align-items:center;
}
/* 弹层内动作行 */
.sheet-actions{display:flex;gap:var(--sp-3);margin-top:var(--sp-4)}
.sheet-actions .btn{flex:1}
/* 聊天气泡 */
.plan-chat{display:flex;flex-direction:column;gap:var(--sp-3);margin-bottom:var(--sp-4)}
.msg{max-width:88%;border-radius:var(--r-md);padding:var(--sp-3) var(--sp-4);font-size:var(--fs-md);line-height:1.55}
.msg.ai{background:var(--green-soft);align-self:flex-start}
.msg.user{background:var(--primary-soft);align-self:flex-end}
/* 没有宠物时的引导块。四个主页面都会 currentPetId 为空直接 return,
留下一堆空卡片却不解释为什么——用户以为是坏了。 */
.no-pet{
background:#fff;border-radius:var(--r-lg);box-shadow:var(--sd-1);
padding:var(--sp-6) var(--sp-5);text-align:center;margin-bottom:var(--sp-4);
}
.no-pet-ic{
width:104rpx;height:104rpx;border-radius:var(--r-md);margin:0 auto var(--sp-3);
display:flex;align-items:center;justify-content:center;
background:var(--primary-soft);color:var(--primary-ink);
}
.no-pet-b{font-size:var(--fs-lg);font-weight:var(--fw-b)}
.no-pet-p{color:var(--muted);font-size:var(--fs-sm);margin:var(--sp-2) 0 var(--sp-4);line-height:1.6}
/* 顶部固定条:宠物切换 / 社区分类。
它在文档流里而不是浮层——内容永远不会滚到它下面,所以不需要任何背景。
之前给了不透明底色 + 分隔线,等于在页面渐变上贴了块补丁,
屏幕上就出现三条颜色不搭的横带。 */
.top-bar{flex:none;padding:var(--sp-2) var(--pad-x) 0}
.top-bar .pet-switch{padding-bottom:var(--sp-2)}
.top-bar .st-pill{padding-bottom:var(--sp-2)}
/* 切换条压在暖色顶部:chip 改成半透明白 + 深棕字,选中的实白 */
.top-bar.on-band .pet-chip{
background:rgba(255,255,255,.42);border-color:transparent;color:var(--band-ink);
}
.top-bar.on-band .pet-chip.active{background:#fff;color:var(--text);border-color:transparent}
.top-bar.on-band .pet-chip-add{color:rgba(122,74,8,.75)}
/* 帖子卡。社区和用户主页都要用,页面级 wxss 不跨页生效,所以放全局 */
.post-card{background:#fff;border-radius:var(--r-lg);box-shadow:var(--sd-1);padding:var(--sp-4);margin-bottom:var(--sp-4)}
.post-head{display:flex;align-items:center;gap:var(--sp-3);margin-bottom:var(--sp-3)}
.post-avatar{
width:80rpx;height:80rpx;border-radius:var(--r-sm);background:var(--primary-soft);
display:flex;align-items:center;justify-content:center;font-size:40rpx;flex:none;
}
.post-user{flex:1;min-width:0}
.pu-b{font-size:var(--fs-md);display:block;font-weight:var(--fw-b)}
.ai-tag{
display:inline-block;margin-left:var(--sp-1);padding:0 var(--sp-1);border-radius:var(--r-xs);
background:var(--blue-soft);color:var(--blue-ink);font-size:var(--fs-cap);font-weight:var(--fw-b);vertical-align:1rpx;
}
.pu-s{font-size:var(--fs-sm);color:var(--muted)}
.post-content{font-size:var(--fs-md);line-height:1.62;color:var(--text-2);margin-bottom:var(--sp-3)}
.photo-grid{display:flex;flex-wrap:wrap;gap:var(--sp-1);margin-bottom:var(--sp-3)}
.photo-tile{
/* 固定尺寸小缩略图,正方形。不用 aspect-ratio(微信渲染不生效,竖图会被撑长),
显式 width/height 最稳;mode=aspectFill 裁成正方,点击再走 previewImage 放大 */
width:210rpx;height:210rpx;flex:none;overflow:hidden;
border-radius:var(--r-sm);background:var(--primary-soft);
display:flex;align-items:center;justify-content:center;font-size:56rpx;
}
/* 三等分。原来是 space-around 排三个宽度不一的项,点赞数一变宽度就跳 */
.post-actions{display:flex;border-top:1rpx solid var(--line);padding-top:var(--sp-2)}
.pa-btn{
flex:1;height:72rpx;
display:flex;align-items:center;justify-content:center;gap:var(--sp-1);
color:var(--muted);font-weight:var(--fw);font-size:var(--fs-sm);
}
.pa-n{line-height:1}
.pa-btn.liked{color:var(--red-ink)}
.pa-btn:active{background:var(--surface-2);border-radius:var(--r-sm)}
/* 分享用微信原生 open-type=share,得抹掉 button 默认外观 */
.pa-share{background:transparent;border:none;padding:0;margin:0;line-height:inherit}
.pa-share::after{border:none}
/* ===== 设置项列表 =====
settings 和 mine 都在用。页面级 wxss 跨不了页,放在任一页里另一页就是裸的 */
.profile-list{background:#fff;border-radius:var(--r-lg);box-shadow:var(--sd-1);overflow:hidden;margin-bottom:var(--sp-4)}
.profile-row{
display:flex;align-items:center;gap:var(--sp-3);min-height:104rpx;padding:0 var(--sp-4);
border-bottom:1rpx solid var(--line);font-size:var(--fs-md);font-weight:var(--fw-b);
}
.profile-row:last-child{border-bottom:0}
.pr-ic{
width:56rpx;height:56rpx;border-radius:var(--r-xs);flex:none;
display:flex;align-items:center;justify-content:center;
background:var(--primary-soft);color:var(--primary-ink);
}
.pr-label{flex:1;min-width:0}
.pr-val{color:var(--muted);font-weight:var(--fw);font-size:var(--fs-sm)}
.pr-arrow{color:var(--muted2);display:flex;align-items:center;flex:none}
/* ===== 关注按钮 =====
社区列表和关注/粉丝列表都在用。同上:两页共用的就不能留在任一页的 wxss 里 */
.follow-btn{
flex:none;font-size:var(--fs-cap);font-weight:var(--fw-b);padding:6rpx var(--sp-3);border-radius:var(--r-full);
color:var(--primary-dark);background:var(--primary-soft);border:1rpx solid var(--primary-line);
}
.follow-btn.on{color:var(--muted);background:#F4F1EC;border-color:var(--line)}
/* ===== 关注按钮 =====
社区列表和关注/粉丝列表都在用。同上:两页共用的就不能留在任一页的 wxss 里 */
.follow-btn{
flex:none;font-size:var(--fs-cap);font-weight:var(--fw-b);padding:6rpx var(--sp-3);border-radius:var(--r-full);
color:var(--primary-dark);background:var(--primary-soft);border:1rpx solid var(--primary-line);
}
.follow-btn.on{color:var(--muted);background:#F4F1EC;border-color:var(--line)}
/* ===== 记录类型分组宫格 =====
记录页和历史页都在用。页面级 wxss 跨不了页,所以放全局。
组的着色走 g-1..g-4,不要复用 tone-N —— tone-N 自带背景色,
套在分组外层会把整块涂成色块。 */
.tg{margin-bottom:var(--sp-5)}
.tg:last-of-type{margin-bottom:0}
.tg-head{
display:flex;align-items:center;gap:var(--sp-2);
font-size:var(--fs-md);font-weight:var(--fw-b);margin-bottom:var(--sp-3);
}
.tg-bar{width:6rpx;height:26rpx;border-radius:3rpx;background:currentColor}
.g-1 .tg-head{color:var(--primary-ink)}
.g-2 .tg-head{color:var(--green-ink)}
.g-3 .tg-head{color:var(--blue-ink)}
.g-4 .tg-head{color:var(--purple-ink)}
/* ===== 关注按钮 =====
社区列表和关注/粉丝列表都在用。同上:两页共用的就不能留在任一页的 wxss 里 */
.follow-btn{
flex:none;font-size:var(--fs-cap);font-weight:var(--fw-b);padding:6rpx var(--sp-3);border-radius:var(--r-full);
color:var(--primary-dark);background:var(--primary-soft);border:1rpx solid var(--primary-line);
}
.follow-btn.on{color:var(--muted);background:#F4F1EC;border-color:var(--line)}
/* ===== 记录类型分组宫格 =====
记录页和「快速记录」弹层都在用,页面级 wxss 跨不了页
四组各一个色系,色值全走 token 的 soft/ink 二件套,没有新色。
.quick 的配色由 .tone-N 决定(app.wxss 里已有),这里只管组标题和留白 */
.tg{margin-bottom:var(--sp-4)}
.tg:last-of-type{margin-bottom:0}
.tg-head{
display:flex;align-items:center;gap:var(--sp-2);
font-size:var(--fs-md);font-weight:var(--fw-b);margin-bottom:var(--sp-3);
}
.tg-bar{width:6rpx;height:26rpx;border-radius:3rpx;background:currentColor}
.tg-n{margin-left:auto;font-size:var(--fs-cap);font-weight:var(--fw);color:var(--muted2)}
.tg.tone-1 .tg-head{color:var(--primary-ink)}
.tg.tone-2 .tg-head{color:var(--green-ink)}
.tg.tone-3 .tg-head{color:var(--blue-ink)}
.tg.tone-4 .tg-head{color:var(--purple-ink)}
/* ===== 日历网格 =====
首页(可展开日历)和计划页(30 天计划日历)都在用 */
.cal-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--sp-3);font-weight:var(--fw-b);font-size:var(--fs-lg)}
.cal-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:var(--sp-2);text-align:center}
.cal-w{font-size:var(--fs-cap);color:var(--muted);padding:var(--sp-1) 0}
.cal-cell{
height:76rpx;border-radius:var(--r-sm);display:flex;align-items:center;justify-content:center;
font-weight:var(--fw-b);font-size:var(--fs-sm);background:var(--surface-2);position:relative;
}
.cal-cell.blank{background:transparent}
.cal-cell.today{background:var(--primary-soft);color:var(--primary-dark)}
.cal-cell.tasked::after{content:"";position:absolute;bottom:8rpx;width:8rpx;height:8rpx;border-radius:50%;background:var(--green)}
.cal-cell.sel{background:var(--primary);color:#fff}
.cal-cell.sel.tasked::after{background:#fff}
Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

-17
View File
@@ -1,17 +0,0 @@
<svg width="900" height="300" viewBox="0 0 900 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="肉垫计划">
<defs><linearGradient id="bgL" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFD08A"/><stop offset="1" stop-color="#F49A38"/></linearGradient></defs>
<g transform="translate(40,30) scale(0.46)">
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#bgL)"/>
<g transform="translate(-102.4,-187.6) scale(1.4)">
<path d="M256 418 C190 366,148 326,148 282 C148 251,173 231,202 231 C229 231,250 249,256 271 C262 249,283 231,310 231 C339 231,364 251,364 282 C364 326,322 366,256 418 Z" fill="#fff"/>
</g>
<g fill="#F49A38" transform="translate(128,140.5) scale(0.5)">
<ellipse cx="138" cy="222" rx="38" ry="47" transform="rotate(-20 138 222)"/>
<ellipse cx="214" cy="168" rx="40" ry="52" transform="rotate(-7 214 168)"/>
<ellipse cx="298" cy="168" rx="40" ry="52" transform="rotate(7 298 168)"/>
<ellipse cx="374" cy="222" rx="38" ry="47" transform="rotate(20 374 222)"/>
<ellipse cx="256" cy="320" rx="106" ry="82"/>
</g></g>
<text x="315" y="168" font-family="PingFang SC, Hiragino Sans GB, Microsoft YaHei, system-ui, sans-serif" font-size="96" font-weight="800" fill="#4A3524" letter-spacing="2">肉垫计划</text>
<text x="318" y="222" font-family="Poppins, Montserrat, system-ui, sans-serif" font-size="30" font-weight="600" fill="#E08A2B" letter-spacing="8">PAW PAD · CARE PLAN</text>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

-13
View File
@@ -1,13 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgD" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFD08A"/><stop offset="1" stop-color="#F49A38"/></linearGradient></defs>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#bgD)"/>
<g transform="translate(-102.4,-187.6) scale(1.4)">
<path d="M256 418 C190 366,148 326,148 282 C148 251,173 231,202 231 C229 231,250 249,256 271 C262 249,283 231,310 231 C339 231,364 251,364 282 C364 326,322 366,256 418 Z" fill="#fff"/>
</g>
<g fill="#F49A38" transform="translate(128,140.5) scale(0.5)">
<ellipse cx="138" cy="222" rx="38" ry="47" transform="rotate(-20 138 222)"/>
<ellipse cx="214" cy="168" rx="40" ry="52" transform="rotate(-7 214 168)"/>
<ellipse cx="298" cy="168" rx="40" ry="52" transform="rotate(7 298 168)"/>
<ellipse cx="374" cy="222" rx="38" ry="47" transform="rotate(20 374 222)"/>
<ellipse cx="256" cy="320" rx="106" ry="82"/>
</g></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-12
View File
@@ -1,12 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgA" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFD08A"/><stop offset="1" stop-color="#F49A38"/></linearGradient>
<radialGradient id="glA" cx="0.5" cy="0.28" r="0.75"><stop offset="0" stop-color="#fff" stop-opacity=".35"/><stop offset=".6" stop-color="#fff" stop-opacity="0"/></radialGradient></defs>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#bgA)"/>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#glA)"/>
<g fill="#fff">
<ellipse cx="138" cy="222" rx="38" ry="47" transform="rotate(-20 138 222)"/>
<ellipse cx="214" cy="168" rx="40" ry="52" transform="rotate(-7 214 168)"/>
<ellipse cx="298" cy="168" rx="40" ry="52" transform="rotate(7 298 168)"/>
<ellipse cx="374" cy="222" rx="38" ry="47" transform="rotate(20 374 222)"/>
<path d="M256 418 C190 366,148 326,148 282 C148 251,173 231,202 231 C229 231,250 249,256 271 C262 249,283 231,310 231 C339 231,364 251,364 282 C364 326,322 366,256 418 Z"/>
</g></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

-14
View File
@@ -1,14 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgB" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFD08A"/><stop offset="1" stop-color="#F49A38"/></linearGradient></defs>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#bgB)"/>
<rect x="176" y="104" width="28" height="62" rx="14" fill="#fff"/>
<rect x="308" y="104" width="28" height="62" rx="14" fill="#fff"/>
<rect x="112" y="138" width="288" height="258" rx="40" fill="#fff"/>
<path d="M152 138 H360 A40 40 0 0 1 400 178 V196 H112 V178 A40 40 0 0 1 152 138 Z" fill="#E88A22"/>
<g fill="#F49A38" transform="translate(140.8,183.45) scale(0.45)">
<ellipse cx="138" cy="222" rx="38" ry="47" transform="rotate(-20 138 222)"/>
<ellipse cx="214" cy="168" rx="40" ry="52" transform="rotate(-7 214 168)"/>
<ellipse cx="298" cy="168" rx="40" ry="52" transform="rotate(7 298 168)"/>
<ellipse cx="374" cy="222" rx="38" ry="47" transform="rotate(20 374 222)"/>
<ellipse cx="256" cy="320" rx="106" ry="82"/>
</g></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

-11
View File
@@ -1,11 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgC" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFD08A"/><stop offset="1" stop-color="#F49A38"/></linearGradient></defs>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#bgC)"/>
<path d="M172 206 L150 104 L250 164 Z" fill="#fff"/>
<ellipse cx="358" cy="248" rx="42" ry="76" transform="rotate(20 358 248)" fill="#fff"/>
<circle cx="256" cy="288" r="112" fill="#fff"/>
<circle cx="216" cy="276" r="13" fill="#E88A22"/>
<circle cx="296" cy="276" r="13" fill="#E88A22"/>
<path d="M256 306 C268 306 274 314 268 322 C264 328 248 328 244 322 C238 314 244 306 256 306 Z" fill="#E88A22"/>
<path d="M256 324 V338 M256 338 C244 350 226 346 222 336 M256 338 C268 350 286 346 290 336" stroke="#E88A22" stroke-width="9" stroke-linecap="round" fill="none"/>
</svg>

Before

Width:  |  Height:  |  Size: 967 B

-349
View File
@@ -1,349 +0,0 @@
<!doctype html><meta charset="utf-8"><title>肉垫计划 · Logo 方案</title>
<style>
body{margin:0;padding:32px;font-family:PingFang SC,system-ui,sans-serif;background:#FAF7F2;color:#3A2E23}
h1{font-size:22px;font-weight:600;margin:0 0 4px}
p.sub{margin:0 0 28px;color:#8A7A69;font-size:14px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:20px}
.card{background:#fff;border:1px solid #EDE4D8;border-radius:16px;padding:20px;text-align:center}
.big svg{width:150px;height:150px;display:block;margin:0 auto}
.row{display:flex;align-items:center;justify-content:center;gap:14px;margin-top:16px;padding-top:14px;border-top:1px solid #F2EBE1}
.row svg{display:block}
.s72 svg{width:72px;height:72px}.s44 svg{width:44px;height:44px}.s28 svg{width:28px;height:28px}
.name{margin-top:14px;font-size:16px;font-weight:600}
.desc{margin-top:4px;font-size:13px;color:#8A7A69;line-height:1.5}
.tag{display:inline-block;margin-top:10px;font-size:11px;color:#B26A12;background:#FFF3E1;padding:3px 10px;border-radius:99px}
</style>
<h1>肉垫计划 · Logo 方案</h1>
<p class="sub">每个方案下方是 72 / 44 / 28px 小尺寸预览 —— 小程序里实际就这么大,先看小图能不能认出来</p>
<div class="grid">
<div class="card"><div class="big">
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgA" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFD08A"/><stop offset="1" stop-color="#F49A38"/></linearGradient>
<radialGradient id="glA" cx="0.5" cy="0.28" r="0.75"><stop offset="0" stop-color="#fff" stop-opacity=".35"/><stop offset=".6" stop-color="#fff" stop-opacity="0"/></radialGradient></defs>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#bgA)"/>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#glA)"/>
<g fill="#fff">
<ellipse cx="138" cy="222" rx="38" ry="47" transform="rotate(-20 138 222)"/>
<ellipse cx="214" cy="168" rx="40" ry="52" transform="rotate(-7 214 168)"/>
<ellipse cx="298" cy="168" rx="40" ry="52" transform="rotate(7 298 168)"/>
<ellipse cx="374" cy="222" rx="38" ry="47" transform="rotate(20 374 222)"/>
<path d="M256 418 C190 366,148 326,148 282 C148 251,173 231,202 231 C229 231,250 249,256 271 C262 249,283 231,310 231 C339 231,364 251,364 282 C364 326,322 366,256 418 Z"/>
</g></svg>
</div>
<div class="row"><span class="s72">
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgA" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFD08A"/><stop offset="1" stop-color="#F49A38"/></linearGradient>
<radialGradient id="glA" cx="0.5" cy="0.28" r="0.75"><stop offset="0" stop-color="#fff" stop-opacity=".35"/><stop offset=".6" stop-color="#fff" stop-opacity="0"/></radialGradient></defs>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#bgA)"/>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#glA)"/>
<g fill="#fff">
<ellipse cx="138" cy="222" rx="38" ry="47" transform="rotate(-20 138 222)"/>
<ellipse cx="214" cy="168" rx="40" ry="52" transform="rotate(-7 214 168)"/>
<ellipse cx="298" cy="168" rx="40" ry="52" transform="rotate(7 298 168)"/>
<ellipse cx="374" cy="222" rx="38" ry="47" transform="rotate(20 374 222)"/>
<path d="M256 418 C190 366,148 326,148 282 C148 251,173 231,202 231 C229 231,250 249,256 271 C262 249,283 231,310 231 C339 231,364 251,364 282 C364 326,322 366,256 418 Z"/>
</g></svg>
</span><span class="s44">
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgA" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFD08A"/><stop offset="1" stop-color="#F49A38"/></linearGradient>
<radialGradient id="glA" cx="0.5" cy="0.28" r="0.75"><stop offset="0" stop-color="#fff" stop-opacity=".35"/><stop offset=".6" stop-color="#fff" stop-opacity="0"/></radialGradient></defs>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#bgA)"/>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#glA)"/>
<g fill="#fff">
<ellipse cx="138" cy="222" rx="38" ry="47" transform="rotate(-20 138 222)"/>
<ellipse cx="214" cy="168" rx="40" ry="52" transform="rotate(-7 214 168)"/>
<ellipse cx="298" cy="168" rx="40" ry="52" transform="rotate(7 298 168)"/>
<ellipse cx="374" cy="222" rx="38" ry="47" transform="rotate(20 374 222)"/>
<path d="M256 418 C190 366,148 326,148 282 C148 251,173 231,202 231 C229 231,250 249,256 271 C262 249,283 231,310 231 C339 231,364 251,364 282 C364 326,322 366,256 418 Z"/>
</g></svg>
</span><span class="s28">
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgA" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFD08A"/><stop offset="1" stop-color="#F49A38"/></linearGradient>
<radialGradient id="glA" cx="0.5" cy="0.28" r="0.75"><stop offset="0" stop-color="#fff" stop-opacity=".35"/><stop offset=".6" stop-color="#fff" stop-opacity="0"/></radialGradient></defs>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#bgA)"/>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#glA)"/>
<g fill="#fff">
<ellipse cx="138" cy="222" rx="38" ry="47" transform="rotate(-20 138 222)"/>
<ellipse cx="214" cy="168" rx="40" ry="52" transform="rotate(-7 214 168)"/>
<ellipse cx="298" cy="168" rx="40" ry="52" transform="rotate(7 298 168)"/>
<ellipse cx="374" cy="222" rx="38" ry="47" transform="rotate(20 374 222)"/>
<path d="M256 418 C190 366,148 326,148 282 C148 251,173 231,202 231 C229 231,250 249,256 271 C262 249,283 231,310 231 C339 231,364 251,364 282 C364 326,322 366,256 418 Z"/>
</g></svg>
</span></div>
<div class="name">A · 爪心</div><div class="desc">爪印的主肉垫做成爱心 —— 宠物 + 用心照护,一眼看懂</div><div class="tag">最百搭</div></div>
<div class="card"><div class="big">
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgB" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFD08A"/><stop offset="1" stop-color="#F49A38"/></linearGradient></defs>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#bgB)"/>
<rect x="176" y="104" width="28" height="62" rx="14" fill="#fff"/>
<rect x="308" y="104" width="28" height="62" rx="14" fill="#fff"/>
<rect x="112" y="138" width="288" height="258" rx="40" fill="#fff"/>
<path d="M152 138 H360 A40 40 0 0 1 400 178 V196 H112 V178 A40 40 0 0 1 152 138 Z" fill="#E88A22"/>
<g fill="#F49A38" transform="translate(140.8,183.45) scale(0.45)">
<ellipse cx="138" cy="222" rx="38" ry="47" transform="rotate(-20 138 222)"/>
<ellipse cx="214" cy="168" rx="40" ry="52" transform="rotate(-7 214 168)"/>
<ellipse cx="298" cy="168" rx="40" ry="52" transform="rotate(7 298 168)"/>
<ellipse cx="374" cy="222" rx="38" ry="47" transform="rotate(20 374 222)"/>
<ellipse cx="256" cy="320" rx="106" ry="82"/>
</g></svg>
</div>
<div class="row"><span class="s72">
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgB" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFD08A"/><stop offset="1" stop-color="#F49A38"/></linearGradient></defs>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#bgB)"/>
<rect x="176" y="104" width="28" height="62" rx="14" fill="#fff"/>
<rect x="308" y="104" width="28" height="62" rx="14" fill="#fff"/>
<rect x="112" y="138" width="288" height="258" rx="40" fill="#fff"/>
<path d="M152 138 H360 A40 40 0 0 1 400 178 V196 H112 V178 A40 40 0 0 1 152 138 Z" fill="#E88A22"/>
<g fill="#F49A38" transform="translate(140.8,183.45) scale(0.45)">
<ellipse cx="138" cy="222" rx="38" ry="47" transform="rotate(-20 138 222)"/>
<ellipse cx="214" cy="168" rx="40" ry="52" transform="rotate(-7 214 168)"/>
<ellipse cx="298" cy="168" rx="40" ry="52" transform="rotate(7 298 168)"/>
<ellipse cx="374" cy="222" rx="38" ry="47" transform="rotate(20 374 222)"/>
<ellipse cx="256" cy="320" rx="106" ry="82"/>
</g></svg>
</span><span class="s44">
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgB" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFD08A"/><stop offset="1" stop-color="#F49A38"/></linearGradient></defs>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#bgB)"/>
<rect x="176" y="104" width="28" height="62" rx="14" fill="#fff"/>
<rect x="308" y="104" width="28" height="62" rx="14" fill="#fff"/>
<rect x="112" y="138" width="288" height="258" rx="40" fill="#fff"/>
<path d="M152 138 H360 A40 40 0 0 1 400 178 V196 H112 V178 A40 40 0 0 1 152 138 Z" fill="#E88A22"/>
<g fill="#F49A38" transform="translate(140.8,183.45) scale(0.45)">
<ellipse cx="138" cy="222" rx="38" ry="47" transform="rotate(-20 138 222)"/>
<ellipse cx="214" cy="168" rx="40" ry="52" transform="rotate(-7 214 168)"/>
<ellipse cx="298" cy="168" rx="40" ry="52" transform="rotate(7 298 168)"/>
<ellipse cx="374" cy="222" rx="38" ry="47" transform="rotate(20 374 222)"/>
<ellipse cx="256" cy="320" rx="106" ry="82"/>
</g></svg>
</span><span class="s28">
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgB" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFD08A"/><stop offset="1" stop-color="#F49A38"/></linearGradient></defs>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#bgB)"/>
<rect x="176" y="104" width="28" height="62" rx="14" fill="#fff"/>
<rect x="308" y="104" width="28" height="62" rx="14" fill="#fff"/>
<rect x="112" y="138" width="288" height="258" rx="40" fill="#fff"/>
<path d="M152 138 H360 A40 40 0 0 1 400 178 V196 H112 V178 A40 40 0 0 1 152 138 Z" fill="#E88A22"/>
<g fill="#F49A38" transform="translate(140.8,183.45) scale(0.45)">
<ellipse cx="138" cy="222" rx="38" ry="47" transform="rotate(-20 138 222)"/>
<ellipse cx="214" cy="168" rx="40" ry="52" transform="rotate(-7 214 168)"/>
<ellipse cx="298" cy="168" rx="40" ry="52" transform="rotate(7 298 168)"/>
<ellipse cx="374" cy="222" rx="38" ry="47" transform="rotate(20 374 222)"/>
<ellipse cx="256" cy="320" rx="106" ry="82"/>
</g></svg>
</span></div>
<div class="name">B · 计划爪</div><div class="desc">日历卡片里嵌爪印,直接点题「计划」两个字</div><div class="tag">最点题</div></div>
<div class="card"><div class="big">
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgC" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFD08A"/><stop offset="1" stop-color="#F49A38"/></linearGradient></defs>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#bgC)"/>
<path d="M172 206 L150 104 L250 164 Z" fill="#fff"/>
<ellipse cx="358" cy="248" rx="42" ry="76" transform="rotate(20 358 248)" fill="#fff"/>
<circle cx="256" cy="288" r="112" fill="#fff"/>
<circle cx="216" cy="276" r="13" fill="#E88A22"/>
<circle cx="296" cy="276" r="13" fill="#E88A22"/>
<path d="M256 306 C268 306 274 314 268 322 C264 328 248 328 244 322 C238 314 244 306 256 306 Z" fill="#E88A22"/>
<path d="M256 324 V338 M256 338 C244 350 226 346 222 336 M256 338 C268 350 286 346 290 336" stroke="#E88A22" stroke-width="9" stroke-linecap="round" fill="none"/>
</svg>
</div>
<div class="row"><span class="s72">
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgC" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFD08A"/><stop offset="1" stop-color="#F49A38"/></linearGradient></defs>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#bgC)"/>
<path d="M172 206 L150 104 L250 164 Z" fill="#fff"/>
<ellipse cx="358" cy="248" rx="42" ry="76" transform="rotate(20 358 248)" fill="#fff"/>
<circle cx="256" cy="288" r="112" fill="#fff"/>
<circle cx="216" cy="276" r="13" fill="#E88A22"/>
<circle cx="296" cy="276" r="13" fill="#E88A22"/>
<path d="M256 306 C268 306 274 314 268 322 C264 328 248 328 244 322 C238 314 244 306 256 306 Z" fill="#E88A22"/>
<path d="M256 324 V338 M256 338 C244 350 226 346 222 336 M256 338 C268 350 286 346 290 336" stroke="#E88A22" stroke-width="9" stroke-linecap="round" fill="none"/>
</svg>
</span><span class="s44">
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgC" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFD08A"/><stop offset="1" stop-color="#F49A38"/></linearGradient></defs>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#bgC)"/>
<path d="M172 206 L150 104 L250 164 Z" fill="#fff"/>
<ellipse cx="358" cy="248" rx="42" ry="76" transform="rotate(20 358 248)" fill="#fff"/>
<circle cx="256" cy="288" r="112" fill="#fff"/>
<circle cx="216" cy="276" r="13" fill="#E88A22"/>
<circle cx="296" cy="276" r="13" fill="#E88A22"/>
<path d="M256 306 C268 306 274 314 268 322 C264 328 248 328 244 322 C238 314 244 306 256 306 Z" fill="#E88A22"/>
<path d="M256 324 V338 M256 338 C244 350 226 346 222 336 M256 338 C268 350 286 346 290 336" stroke="#E88A22" stroke-width="9" stroke-linecap="round" fill="none"/>
</svg>
</span><span class="s28">
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgC" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFD08A"/><stop offset="1" stop-color="#F49A38"/></linearGradient></defs>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#bgC)"/>
<path d="M172 206 L150 104 L250 164 Z" fill="#fff"/>
<ellipse cx="358" cy="248" rx="42" ry="76" transform="rotate(20 358 248)" fill="#fff"/>
<circle cx="256" cy="288" r="112" fill="#fff"/>
<circle cx="216" cy="276" r="13" fill="#E88A22"/>
<circle cx="296" cy="276" r="13" fill="#E88A22"/>
<path d="M256 306 C268 306 274 314 268 322 C264 328 248 328 244 322 C238 314 244 306 256 306 Z" fill="#E88A22"/>
<path d="M256 324 V338 M256 338 C244 350 226 346 222 336 M256 338 C268 350 286 346 290 336" stroke="#E88A22" stroke-width="9" stroke-linecap="round" fill="none"/>
</svg>
</span></div>
<div class="name">C · 半猫半狗</div><div class="desc">左边猫耳、右边狗耳,一张脸两种身份 = 所有毛孩子</div><div class="tag">最有记忆点</div></div>
<div class="card"><div class="big">
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgD" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFD08A"/><stop offset="1" stop-color="#F49A38"/></linearGradient></defs>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#bgD)"/>
<g transform="translate(-102.4,-187.6) scale(1.4)">
<path d="M256 418 C190 366,148 326,148 282 C148 251,173 231,202 231 C229 231,250 249,256 271 C262 249,283 231,310 231 C339 231,364 251,364 282 C364 326,322 366,256 418 Z" fill="#fff"/>
</g>
<g fill="#F49A38" transform="translate(128,140.5) scale(0.5)">
<ellipse cx="138" cy="222" rx="38" ry="47" transform="rotate(-20 138 222)"/>
<ellipse cx="214" cy="168" rx="40" ry="52" transform="rotate(-7 214 168)"/>
<ellipse cx="298" cy="168" rx="40" ry="52" transform="rotate(7 298 168)"/>
<ellipse cx="374" cy="222" rx="38" ry="47" transform="rotate(20 374 222)"/>
<ellipse cx="256" cy="320" rx="106" ry="82"/>
</g></svg>
</div>
<div class="row"><span class="s72">
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgD" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFD08A"/><stop offset="1" stop-color="#F49A38"/></linearGradient></defs>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#bgD)"/>
<g transform="translate(-102.4,-187.6) scale(1.4)">
<path d="M256 418 C190 366,148 326,148 282 C148 251,173 231,202 231 C229 231,250 249,256 271 C262 249,283 231,310 231 C339 231,364 251,364 282 C364 326,322 366,256 418 Z" fill="#fff"/>
</g>
<g fill="#F49A38" transform="translate(128,140.5) scale(0.5)">
<ellipse cx="138" cy="222" rx="38" ry="47" transform="rotate(-20 138 222)"/>
<ellipse cx="214" cy="168" rx="40" ry="52" transform="rotate(-7 214 168)"/>
<ellipse cx="298" cy="168" rx="40" ry="52" transform="rotate(7 298 168)"/>
<ellipse cx="374" cy="222" rx="38" ry="47" transform="rotate(20 374 222)"/>
<ellipse cx="256" cy="320" rx="106" ry="82"/>
</g></svg>
</span><span class="s44">
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgD" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFD08A"/><stop offset="1" stop-color="#F49A38"/></linearGradient></defs>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#bgD)"/>
<g transform="translate(-102.4,-187.6) scale(1.4)">
<path d="M256 418 C190 366,148 326,148 282 C148 251,173 231,202 231 C229 231,250 249,256 271 C262 249,283 231,310 231 C339 231,364 251,364 282 C364 326,322 366,256 418 Z" fill="#fff"/>
</g>
<g fill="#F49A38" transform="translate(128,140.5) scale(0.5)">
<ellipse cx="138" cy="222" rx="38" ry="47" transform="rotate(-20 138 222)"/>
<ellipse cx="214" cy="168" rx="40" ry="52" transform="rotate(-7 214 168)"/>
<ellipse cx="298" cy="168" rx="40" ry="52" transform="rotate(7 298 168)"/>
<ellipse cx="374" cy="222" rx="38" ry="47" transform="rotate(20 374 222)"/>
<ellipse cx="256" cy="320" rx="106" ry="82"/>
</g></svg>
</span><span class="s28">
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgD" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFD08A"/><stop offset="1" stop-color="#F49A38"/></linearGradient></defs>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#bgD)"/>
<g transform="translate(-102.4,-187.6) scale(1.4)">
<path d="M256 418 C190 366,148 326,148 282 C148 251,173 231,202 231 C229 231,250 249,256 271 C262 249,283 231,310 231 C339 231,364 251,364 282 C364 326,322 366,256 418 Z" fill="#fff"/>
</g>
<g fill="#F49A38" transform="translate(128,140.5) scale(0.5)">
<ellipse cx="138" cy="222" rx="38" ry="47" transform="rotate(-20 138 222)"/>
<ellipse cx="214" cy="168" rx="40" ry="52" transform="rotate(-7 214 168)"/>
<ellipse cx="298" cy="168" rx="40" ry="52" transform="rotate(7 298 168)"/>
<ellipse cx="374" cy="222" rx="38" ry="47" transform="rotate(20 374 222)"/>
<ellipse cx="256" cy="320" rx="106" ry="82"/>
</g></svg>
</span></div>
<div class="name">D · 心中有爪</div><div class="desc">满屏爱心,爪印镂空其中,形状最简、最像大厂 App</div><div class="tag">最简洁</div></div>
<div class="card"><div class="big">
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgE" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFD08A"/><stop offset="1" stop-color="#F49A38"/></linearGradient></defs>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#bgE)"/>
<path d="M132 286 L214 368 L330 232" stroke="#fff" stroke-width="46" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<g fill="#fff" transform="translate(298.08,92.12) scale(0.32)">
<ellipse cx="138" cy="222" rx="38" ry="47" transform="rotate(-20 138 222)"/>
<ellipse cx="214" cy="168" rx="40" ry="52" transform="rotate(-7 214 168)"/>
<ellipse cx="298" cy="168" rx="40" ry="52" transform="rotate(7 298 168)"/>
<ellipse cx="374" cy="222" rx="38" ry="47" transform="rotate(20 374 222)"/>
<ellipse cx="256" cy="320" rx="106" ry="82"/>
</g></svg>
</div>
<div class="row"><span class="s72">
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgE" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFD08A"/><stop offset="1" stop-color="#F49A38"/></linearGradient></defs>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#bgE)"/>
<path d="M132 286 L214 368 L330 232" stroke="#fff" stroke-width="46" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<g fill="#fff" transform="translate(298.08,92.12) scale(0.32)">
<ellipse cx="138" cy="222" rx="38" ry="47" transform="rotate(-20 138 222)"/>
<ellipse cx="214" cy="168" rx="40" ry="52" transform="rotate(-7 214 168)"/>
<ellipse cx="298" cy="168" rx="40" ry="52" transform="rotate(7 298 168)"/>
<ellipse cx="374" cy="222" rx="38" ry="47" transform="rotate(20 374 222)"/>
<ellipse cx="256" cy="320" rx="106" ry="82"/>
</g></svg>
</span><span class="s44">
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgE" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFD08A"/><stop offset="1" stop-color="#F49A38"/></linearGradient></defs>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#bgE)"/>
<path d="M132 286 L214 368 L330 232" stroke="#fff" stroke-width="46" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<g fill="#fff" transform="translate(298.08,92.12) scale(0.32)">
<ellipse cx="138" cy="222" rx="38" ry="47" transform="rotate(-20 138 222)"/>
<ellipse cx="214" cy="168" rx="40" ry="52" transform="rotate(-7 214 168)"/>
<ellipse cx="298" cy="168" rx="40" ry="52" transform="rotate(7 298 168)"/>
<ellipse cx="374" cy="222" rx="38" ry="47" transform="rotate(20 374 222)"/>
<ellipse cx="256" cy="320" rx="106" ry="82"/>
</g></svg>
</span><span class="s28">
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgE" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFD08A"/><stop offset="1" stop-color="#F49A38"/></linearGradient></defs>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#bgE)"/>
<path d="M132 286 L214 368 L330 232" stroke="#fff" stroke-width="46" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<g fill="#fff" transform="translate(298.08,92.12) scale(0.32)">
<ellipse cx="138" cy="222" rx="38" ry="47" transform="rotate(-20 138 222)"/>
<ellipse cx="214" cy="168" rx="40" ry="52" transform="rotate(-7 214 168)"/>
<ellipse cx="298" cy="168" rx="40" ry="52" transform="rotate(7 298 168)"/>
<ellipse cx="374" cy="222" rx="38" ry="47" transform="rotate(20 374 222)"/>
<ellipse cx="256" cy="320" rx="106" ry="82"/>
</g></svg>
</span></div>
<div class="name">E · 打勾爪</div><div class="desc">对勾 + 爪印,强调每天完成任务的成就感</div><div class="tag">最强调习惯</div></div>
<div class="card"><div class="big">
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgF" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFD08A"/><stop offset="1" stop-color="#F49A38"/></linearGradient></defs>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#bgF)"/>
<path d="M238 116 C248 108 264 108 274 116 L404 224 C412 231 416 240 416 250 V378 C416 396 402 410 384 410 H128 C110 410 96 396 96 378 V250 C96 240 100 231 108 224 Z" fill="#fff"/>
<g fill="#F49A38" transform="translate(163.84,216.76) scale(0.36)">
<ellipse cx="138" cy="222" rx="38" ry="47" transform="rotate(-20 138 222)"/>
<ellipse cx="214" cy="168" rx="40" ry="52" transform="rotate(-7 214 168)"/>
<ellipse cx="298" cy="168" rx="40" ry="52" transform="rotate(7 298 168)"/>
<ellipse cx="374" cy="222" rx="38" ry="47" transform="rotate(20 374 222)"/>
<ellipse cx="256" cy="320" rx="106" ry="82"/>
</g></svg>
</div>
<div class="row"><span class="s72">
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgF" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFD08A"/><stop offset="1" stop-color="#F49A38"/></linearGradient></defs>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#bgF)"/>
<path d="M238 116 C248 108 264 108 274 116 L404 224 C412 231 416 240 416 250 V378 C416 396 402 410 384 410 H128 C110 410 96 396 96 378 V250 C96 240 100 231 108 224 Z" fill="#fff"/>
<g fill="#F49A38" transform="translate(163.84,216.76) scale(0.36)">
<ellipse cx="138" cy="222" rx="38" ry="47" transform="rotate(-20 138 222)"/>
<ellipse cx="214" cy="168" rx="40" ry="52" transform="rotate(-7 214 168)"/>
<ellipse cx="298" cy="168" rx="40" ry="52" transform="rotate(7 298 168)"/>
<ellipse cx="374" cy="222" rx="38" ry="47" transform="rotate(20 374 222)"/>
<ellipse cx="256" cy="320" rx="106" ry="82"/>
</g></svg>
</span><span class="s44">
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgF" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFD08A"/><stop offset="1" stop-color="#F49A38"/></linearGradient></defs>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#bgF)"/>
<path d="M238 116 C248 108 264 108 274 116 L404 224 C412 231 416 240 416 250 V378 C416 396 402 410 384 410 H128 C110 410 96 396 96 378 V250 C96 240 100 231 108 224 Z" fill="#fff"/>
<g fill="#F49A38" transform="translate(163.84,216.76) scale(0.36)">
<ellipse cx="138" cy="222" rx="38" ry="47" transform="rotate(-20 138 222)"/>
<ellipse cx="214" cy="168" rx="40" ry="52" transform="rotate(-7 214 168)"/>
<ellipse cx="298" cy="168" rx="40" ry="52" transform="rotate(7 298 168)"/>
<ellipse cx="374" cy="222" rx="38" ry="47" transform="rotate(20 374 222)"/>
<ellipse cx="256" cy="320" rx="106" ry="82"/>
</g></svg>
</span><span class="s28">
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgF" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFD08A"/><stop offset="1" stop-color="#F49A38"/></linearGradient></defs>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#bgF)"/>
<path d="M238 116 C248 108 264 108 274 116 L404 224 C412 231 416 240 416 250 V378 C416 396 402 410 384 410 H128 C110 410 96 396 96 378 V250 C96 240 100 231 108 224 Z" fill="#fff"/>
<g fill="#F49A38" transform="translate(163.84,216.76) scale(0.36)">
<ellipse cx="138" cy="222" rx="38" ry="47" transform="rotate(-20 138 222)"/>
<ellipse cx="214" cy="168" rx="40" ry="52" transform="rotate(-7 214 168)"/>
<ellipse cx="298" cy="168" rx="40" ry="52" transform="rotate(7 298 168)"/>
<ellipse cx="374" cy="222" rx="38" ry="47" transform="rotate(20 374 222)"/>
<ellipse cx="256" cy="320" rx="106" ry="82"/>
</g></svg>
</span></div>
<div class="name">F · 毛孩子的家</div><div class="desc">房子里的爪印,温暖的归属感</div><div class="tag">最温暖</div></div>
</div>
-13
View File
@@ -1,13 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgD" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFD08A"/><stop offset="1" stop-color="#F49A38"/></linearGradient></defs>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#bgD)"/>
<g transform="translate(-102.4,-187.6) scale(1.4)">
<path d="M256 418 C190 366,148 326,148 282 C148 251,173 231,202 231 C229 231,250 249,256 271 C262 249,283 231,310 231 C339 231,364 251,364 282 C364 326,322 366,256 418 Z" fill="#fff"/>
</g>
<g fill="#F49A38" transform="translate(128,140.5) scale(0.5)">
<ellipse cx="138" cy="222" rx="38" ry="47" transform="rotate(-20 138 222)"/>
<ellipse cx="214" cy="168" rx="40" ry="52" transform="rotate(-7 214 168)"/>
<ellipse cx="298" cy="168" rx="40" ry="52" transform="rotate(7 298 168)"/>
<ellipse cx="374" cy="222" rx="38" ry="47" transform="rotate(20 374 222)"/>
<ellipse cx="256" cy="320" rx="106" ry="82"/>
</g></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-11
View File
@@ -1,11 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgE" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFD08A"/><stop offset="1" stop-color="#F49A38"/></linearGradient></defs>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#bgE)"/>
<path d="M132 286 L214 368 L330 232" stroke="#fff" stroke-width="46" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<g fill="#fff" transform="translate(298.08,92.12) scale(0.32)">
<ellipse cx="138" cy="222" rx="38" ry="47" transform="rotate(-20 138 222)"/>
<ellipse cx="214" cy="168" rx="40" ry="52" transform="rotate(-7 214 168)"/>
<ellipse cx="298" cy="168" rx="40" ry="52" transform="rotate(7 298 168)"/>
<ellipse cx="374" cy="222" rx="38" ry="47" transform="rotate(20 374 222)"/>
<ellipse cx="256" cy="320" rx="106" ry="82"/>
</g></svg>

Before

Width:  |  Height:  |  Size: 950 B

-11
View File
@@ -1,11 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgF" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFD08A"/><stop offset="1" stop-color="#F49A38"/></linearGradient></defs>
<path d="M256 20 C 128 20, 20 128, 20 256 C 20 384, 128 492, 256 492 C 384 492, 492 384, 492 256 C 492 128, 384 20, 256 20 Z" fill="url(#bgF)"/>
<path d="M238 116 C248 108 264 108 274 116 L404 224 C412 231 416 240 416 250 V378 C416 396 402 410 384 410 H128 C110 410 96 396 96 378 V250 C96 240 100 231 108 224 Z" fill="#fff"/>
<g fill="#F49A38" transform="translate(163.84,216.76) scale(0.36)">
<ellipse cx="138" cy="222" rx="38" ry="47" transform="rotate(-20 138 222)"/>
<ellipse cx="214" cy="168" rx="40" ry="52" transform="rotate(-7 214 168)"/>
<ellipse cx="298" cy="168" rx="40" ry="52" transform="rotate(7 298 168)"/>
<ellipse cx="374" cy="222" rx="38" ry="47" transform="rotate(20 374 222)"/>
<ellipse cx="256" cy="320" rx="106" ry="82"/>
</g></svg>

Before

Width:  |  Height:  |  Size: 1005 B

@@ -1,747 +0,0 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
const upload = require('../../utils/upload.js');
function fmtDate(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.getMonth() + 1 + '月' + d.getDate() + '日';
}
// n 天后的 YYYY-MM-DD,给提醒类弹层做默认日期
function daysLater(n) {
const d = new Date(Date.now() + n * 86400000);
const p = (x) => (x < 10 ? '0' + x : '' + x);
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate());
}
// "2026-07-30" → "2026-07-30T12:00:00+08:00"
//
// 相对时间。社区里关心的是「多久以前发的」,精确到秒没意义
function fmtAgo(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const min = Math.floor((Date.now() - d.getTime()) / 60000);
if (min < 1) return '刚刚';
if (min < 60) return min + ' 分钟前';
if (min < 60 * 24) return Math.floor(min / 60) + ' 小时前';
if (min < 60 * 24 * 7) return Math.floor(min / 1440) + ' 天前';
const p = (n) => (n < 10 ? '0' + n : '' + n);
const sameYear = d.getFullYear() === new Date().getFullYear();
return (sameYear ? '' : d.getFullYear() + '-') + p(d.getMonth() + 1) + '-' + p(d.getDate());
}
// 提醒类型(与后端 model.Reminder* 常量对应)
const REMINDER_TYPES = [
{ key: 'vaccine', label: '疫苗' },
{ key: 'deworm', label: '驱虫' },
{ key: 'weight', label: '体重' },
{ key: 'monthlyReport', label: '月度报告' },
];
const REMINDER_LABELS = REMINDER_TYPES.map((t) => t.label);
// 标签和社区顶部筛选保持一致(晒宠 / 新手求助 / 经验)
const PTAG = ['晒宠', '新手求助', '经验'];
Component({
options: { addGlobalClass: true },
properties: {
show: { type: Boolean, value: false },
type: { type: String, value: '' },
// 雪花 ID 是 18 位字符串,声明成 Number 会超出 JS 安全整数范围而丢精度
postId: { type: String, value: '' },
},
data: {
innerType: '',
pet: {},
segSel: {},
optSel: {},
// 通用简易记录(洗护/清洁那 15 种共用)
postContent: '',
pfFocus: false, // 发帖内容框聚焦标记(点整块 field 聚焦用)
postImages: [],
remForm: { id: '', typeIdx: 0, title: '', date: '', freq: '' },
remTypeLabels: REMINDER_LABELS,
myPets: [],
exportText: '',
fbContent: '',
fbContact: '',
fbDone: false,
commentText: '',
comments: [],
reminders: [],
poster: null,
reportDetail: null,
proInfo: null,
saving: false,
posterSaving: false,
commentImages: [],
replyTo: {},
cmFocus: false,
composing: false, // 评论输入面板是否展开(收起时只是一条细条)
kbHeight: 0,
total: 0,
},
observers: {
show: function (show) {
// 自定义 tabBar 会盖住弹层底部按钮;打开弹层时把它藏起来,关闭时恢复
// 用 getTabBar().hidden 开关,不用 wx.showTabBar(真机上会额外冒出原生 tabBar)
this.toggleTabBar(!!show);
},
'show, type': function (show, type) {
if (show && type) this.setType(type, true);
},
},
lifetimes: {
detached() {
this.toggleTabBar(false);
},
},
methods: {
setType(type, fresh) {
const pet = store.getPet();
const patch = { innerType: type, pet };
if (fresh) {
patch.segSel = {};
patch.optSel = {};
patch.postContent = '';
patch.postImages = [];
patch.medDose = '';
patch.remForm = { id: '', typeIdx: 0, title: '', date: '', freq: '' };
patch.exportText = '';
patch.fbContent = '';
patch.fbContact = '';
patch.fbDone = false;
patch.commentText = '';
patch.commentImages = [];
patch.replyTo = {};
patch.cmFocus = false;
patch.composing = false;
patch.kbHeight = 0;
patch.saving = false;
}
this.setData(patch);
this.loadSheetData(type);
},
// 按弹层类型拉取真实数据
loadSheetData(type) {
const id = store.currentPetId();
if (type === 'manageTasks') {
this.loadManageTasks();
} else if (type === 'managePets') {
this.loadMyPets();
} else if (type === 'exportData') {
this.buildExport();
} else if (type === 'comments' && this.data.postId) {
this.loadComments();
} else if (type === 'reminders' && id) {
this.loadReminders();
} else if (type === 'poster' && id) {
api.getPoster(id).then((p) => this.setData({ poster: p })).catch(() => {});
} else if (type === 'reportDetail' && id) {
api.weeklyReport(id).then((r) => this.setData({ reportDetail: r })).catch(() => {});
} else if (type === 'pro') {
api.getPro().then((p) => this.setData({ proInfo: p })).catch(() => {});
}
},
goSheet(e) {
this.setType(e.currentTarget.dataset.type, true);
},
close() {
this.triggerEvent('close');
},
onMaskTap() {
this.close();
},
noop() {},
// 藏/显当前 tab 页的自定义 tabBar(非 tab 页无 getTabBar,安全跳过)
toggleTabBar(hidden) {
const pages = getCurrentPages();
const page = pages[pages.length - 1];
if (page && typeof page.getTabBar === 'function' && page.getTabBar()) {
page.getTabBar().setData({ hidden });
}
},
// ---- 成长海报:画进 canvas 才能存进相册 ----
onSavePoster() {
if (this.data.posterSaving) return;
this.setData({ posterSaving: true });
this.drawPoster()
.then((tempPath) => this.saveToAlbum(tempPath))
.then(() => wx.showToast({ title: '已保存到相册', icon: 'success' }))
.catch((e) => {
if (e && e.canceled) return;
wx.showToast({ title: (e && e.message) || '保存失败', icon: 'none' });
})
.then(() => this.setData({ posterSaving: false }));
},
// 把海报画到离屏 canvas 上,返回临时图片路径
drawPoster() {
const W = 600;
const H = 840;
return new Promise((resolve, reject) => {
wx.createSelectorQuery()
.in(this)
.select('#posterCanvas')
.fields({ node: true, size: true })
.exec((res) => {
const node = res && res[0] && res[0].node;
if (!node) return reject(new Error('画布还没准备好,稍后再试'));
// 按设备像素比放大,否则在高分屏上导出的图是糊的
let dpr = 2;
try {
dpr = (wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync()).pixelRatio || 2;
} catch (err) {
dpr = 2;
}
node.width = W * dpr;
node.height = H * dpr;
const ctx = node.getContext('2d');
ctx.scale(dpr, dpr);
const pet = this.data.pet || {};
const p = this.data.poster || {};
// 优先用后端海报数据里的头像(和身份卡同一路径,可靠),store 兜底
const avatarUrl = p.pet_avatar_url || pet.avatar_url || '';
// 头像是远程图,异步加载。加载完(或没有头像)再往下画,
// 否则头像会画不上。加载失败当没有头像处理
const loadAvatar = () =>
new Promise((res) => {
if (!avatarUrl) return res(null);
const img = node.createImage();
img.onload = () => res(img);
img.onerror = () => res(null);
img.src = avatarUrl;
});
loadAvatar().then((avatar) => {
const bg = ctx.createLinearGradient(0, 0, W, H);
bg.addColorStop(0, '#FFF7E8');
bg.addColorStop(1, '#FFFFFF');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#F2DFC3';
ctx.fillRect(0, 0, W, 8);
ctx.textAlign = 'center';
ctx.fillStyle = '#2D2925';
ctx.font = '600 40px sans-serif';
ctx.fillText((pet.name || '毛孩子') + ' 的成长报告', W / 2, 96);
// 上传过照片就画圆形头像,没有才退回 emoji(默认头像太丑)
const avD = 132, avX = (W - avD) / 2, avY = 132;
if (avatar) {
ctx.save();
ctx.beginPath();
ctx.arc(W / 2, avY + avD / 2, avD / 2, 0, Math.PI * 2);
ctx.closePath();
ctx.clip();
// 居中裁剪成正方形再画,竖图/横图都不压扁
const iw = avatar.width || avD;
const ih = avatar.height || avD;
const side = Math.min(iw, ih);
const sx = (iw - side) / 2;
const sy = (ih - side) / 2;
ctx.drawImage(avatar, sx, sy, side, side, avX, avY, avD, avD);
ctx.restore();
} else {
ctx.font = '96px sans-serif';
ctx.fillText(pet.emoji || '🐾', W / 2, 216);
}
ctx.textAlign = 'center';
ctx.fillStyle = '#8D8277';
ctx.font = '24px sans-serif';
ctx.fillText([pet.age, pet.weight, pet.stage].filter(Boolean).join(' | '), W / 2, 262);
// 周报告:显示统计周期,让人知道这四个数是近 7 天的
if (p.period) {
ctx.fillStyle = '#C7A36A';
ctx.font = '22px sans-serif';
ctx.fillText('近 7 天 · ' + p.period, W / 2, 298);
}
ctx.fillStyle = 'rgba(255,255,255,.8)';
this.roundRect(ctx, 60, 316, W - 120, 232, 24);
ctx.fill();
const lines = [
'完成任务 ' + (p.tasks_completed || 0) + ' 项',
'体重记录 ' + (p.weight_records || 0) + ' 次',
'疫苗记录 ' + (p.vaccine_records || 0) + ' 次',
'高风险异常 ' + (p.high_risk_count || 0) + ' 次',
];
ctx.textAlign = 'left';
ctx.font = '28px sans-serif';
lines.forEach((t, i) => {
const y = 360 + i * 52;
ctx.fillStyle = '#73BE9D';
ctx.fillText('✓', 92, y);
ctx.fillStyle = '#443D37';
ctx.fillText(t, 132, y);
});
ctx.textAlign = 'center';
ctx.fillStyle = '#2D2925';
ctx.font = '600 30px sans-serif';
ctx.fillText('健康状态:' + (p.headline || '稳定成长'), W / 2, 620);
ctx.fillStyle = '#B5A99D';
ctx.font = '22px sans-serif';
ctx.fillText('生成自 · 肉垫计划', W / 2, 780);
// 头像加载完(或没有)才走到这里,此刻画布已完整
wx.canvasToTempFilePath(
{
canvas: node,
fileType: 'png',
success: (r) => resolve(r.tempFilePath),
fail: () => reject(new Error('生成图片失败')),
},
this,
);
}); // loadAvatar().then
});
});
},
roundRect(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();
},
// 存相册要相册写权限。用户拒过一次之后 wx.authorize 不会再弹,
// 必须引导他去设置页打开,否则这里会永远静默失败。
saveToAlbum(filePath) {
return new Promise((resolve, reject) => {
wx.saveImageToPhotosAlbum({
filePath,
success: resolve,
fail: (err) => {
const msg = (err && err.errMsg) || '';
if (msg.indexOf('cancel') >= 0) return reject({ canceled: true });
if (msg.indexOf('auth deny') >= 0 || msg.indexOf('authorize') >= 0) {
wx.showModal({
title: '需要相册权限',
content: '保存卡片需要允许访问相册,去设置里打开一下?',
confirmText: '去设置',
success: (r) => {
if (r.confirm) wx.openSetting({});
},
});
return reject({ canceled: true });
}
reject(new Error('保存失败'));
},
});
});
},
onSeg(e) {
const { group, index } = e.currentTarget.dataset;
const segSel = Object.assign({}, this.data.segSel, { [group]: Number(index) });
this.setData({ segSel });
},
onOpt(e) {
const { group, index } = e.currentTarget.dataset;
const optSel = Object.assign({}, this.data.optSel, { [group]: Number(index) });
this.setData({ optSel });
},
onPostInput(e) { this.setData({ postContent: e.detail.value }); },
// 点内容 field 任意处都聚焦(原生 textarea 在 scroll-view 里命中区域会偏)
onPostFieldTap() { this.setData({ pfFocus: true }); },
onPostBlur() { this.setData({ pfFocus: false }); },
loadComments() {
if (!this.data.postId) return;
api.listComments(this.data.postId)
.then((page) =>
this.setData({
total: page.total || 0,
comments: (page.list || []).map((c) => ({
...c,
timeText: fmtAgo(c.created_at),
// 没有头像字段,用昵称首字做个色块,比一律显示同一个 emoji 强
initial: (c.author_name || '?').slice(0, 1),
imgs: Array.isArray(c.images) ? c.images : [],
replies: (c.replies || []).map((r) => ({
...r,
timeText: fmtAgo(r.created_at),
initial: (r.author_name || '?').slice(0, 1),
})),
})),
}),
)
.catch(() => {});
},
// 长按删除自己的评论
onDeleteComment(e) {
const { id, self } = e.currentTarget.dataset;
if (!id || !self) return;
const c = { id };
wx.showModal({
title: '删除评论',
content: '确定删除这条评论?',
confirmColor: '#D9534F',
success: (res) => {
if (!res.confirm) return;
api.deleteComment(c.id)
.then(() => { this.loadComments(); this.triggerEvent('commented'); })
.catch((err) => wx.showToast({ title: err.message || '删除失败', icon: 'none' }));
},
});
},
// ---- 多宠物管理 ----
loadMyPets() {
const cur = store.currentPetId();
this.setData({
myPets: (store.getPets() || []).map((p) => ({
id: p.id, name: p.name, emoji: p.emoji,
meta: [p.type, p.stage, p.weight].filter(Boolean).join(' · '),
current: p.id === cur,
})),
});
},
onSwitchPet(e) {
const id = e.currentTarget.dataset.id;
store.switchPet(id);
this.loadMyPets();
wx.showToast({ title: '已切换', icon: 'success' });
setTimeout(() => this.close(), 500);
},
// 多宠管理里点某只去编辑。editPet 那支已经搬到 pages/petform,
// 这里必须关掉弹层再跳页 —— 留着 setType('editPet') 会开一个空白弹层
onEditPetFromList(e) {
const id = e.currentTarget.dataset.id;
store.switchPet(id);
this.close();
wx.navigateTo({ url: '/pages/petform/petform?id=' + id });
},
// ---- 导出健康档案 ----
buildExport() {
const id = store.currentPetId();
const pet = store.getPet() || {};
if (!id) {
this.setData({ exportText: '还没有建档,先添加一只毛孩子吧。' });
return;
}
Promise.all([
api.getRecords(id, { page: 1, pageSize: 50 }).catch(() => ({ list: [], total: 0 })),
api.getReminders(id).catch(() => []),
]).then(([recPage, rems]) => {
const lines = [];
lines.push('【肉垫计划 · 健康档案】');
lines.push(`宠物:${pet.name || ''}(${pet.type || ''}|${pet.gender || ''})`);
lines.push(`阶段:${pet.stage || '-'} 年龄:${pet.age || '-'} 体重:${pet.weight || '-'}`);
if (pet.breed || pet.color) lines.push(`品种/毛色:${pet.breed || '-'} / ${pet.color || '-'}`);
lines.push('');
lines.push(`— 健康记录(共 ${recPage.total || 0} 条,导出最近 ${(recPage.list || []).length} 条)—`);
(recPage.list || []).forEach((r) => {
lines.push(`· ${fmtDate(r.occurred_at)} ${r.title}${r.description ? '|' + r.description : ''}`);
});
lines.push('');
lines.push('— 提醒 —');
(rems || []).forEach((r) => {
lines.push(`· ${r.title}:${r.next_due_date ? fmtDate(r.next_due_date) : r.frequency || '未设置'}`);
});
lines.push('');
lines.push('(本档案仅供日常照护与就医参考,不构成诊断意见)');
this.setData({ exportText: lines.join('\n') });
});
},
onCopyExport() {
const t = this.data.exportText;
if (!t) return;
wx.setClipboardData({
data: t,
success: () => wx.showToast({ title: '已复制到剪贴板', icon: 'success' }),
});
},
// ---- 意见反馈 ----
onFbContent(e) { this.setData({ fbContent: e.detail.value }); },
onFbContact(e) { this.setData({ fbContact: e.detail.value }); },
onSubmitFeedback() {
const content = (this.data.fbContent || '').trim();
if (!content) return wx.showToast({ title: '写点什么再提交', icon: 'none' });
if (this.data.saving) return;
this.setData({ saving: true });
api
.submitFeedback({ content, contact: (this.data.fbContact || '').trim() })
.then(() => this.setData({ fbDone: true, saving: false }))
.catch((e) => {
wx.showToast({ title: e.message || '提交失败', icon: 'none' });
this.setData({ saving: false });
});
},
// ---- 提醒增删改 ----
loadReminders() {
const id = store.currentPetId();
if (!id) return;
api
.getReminders(id)
.then((list) =>
this.setData({
reminders: (list || []).map((r) => ({
id: r.id,
type: r.type,
title: r.title,
date: r.next_due_date ? String(r.next_due_date).slice(0, 10) : '',
freq: r.frequency || '',
when: r.next_due_date ? fmtDate(r.next_due_date) : r.frequency || '未设置',
})),
}),
)
.catch(() => {});
},
onRemTitle(e) { this.setData({ 'remForm.title': e.detail.value }); },
onRemFreq(e) { this.setData({ 'remForm.freq': e.detail.value }); },
onRemType(e) { this.setData({ 'remForm.typeIdx': Number(e.detail.value) }); },
onRemDate(e) { this.setData({ 'remForm.date': e.detail.value }); },
onEditReminder(e) {
const r = this.data.reminders[e.currentTarget.dataset.index];
let idx = REMINDER_TYPES.findIndex((t) => t.key === r.type);
if (idx < 0) idx = 0;
this.setData({ remForm: { id: r.id, typeIdx: idx, title: r.title, date: r.date, freq: r.freq } });
},
onSaveReminder() {
const f = this.data.remForm;
const title = (f.title || '').trim();
if (!title) return wx.showToast({ title: '填个提醒名', icon: 'none' });
const body = {
type: REMINDER_TYPES[f.typeIdx].key,
title,
next_due_date: f.date || '',
frequency: (f.freq || '').trim(),
};
const petId = store.currentPetId();
const p = f.id ? api.updateReminder(f.id, body) : api.createReminder(petId, body);
p.then(() => {
this.setData({ remForm: { id: '', typeIdx: 0, title: '', date: '', freq: '' } });
this.loadReminders();
this.triggerEvent('saved');
}).catch((e) => wx.showToast({ title: e.message || '保存失败', icon: 'none' }));
},
onDeleteReminder(e) {
const r = this.data.reminders[e.currentTarget.dataset.index];
wx.showModal({
title: '删除提醒',
content: '确定删除「' + r.title + '」?',
success: (res) => {
if (!res.confirm) return;
api.deleteReminder(r.id).then(() => {
this.loadReminders();
this.triggerEvent('saved');
}).catch((err) => wx.showToast({ title: err.message || '删除失败', icon: 'none' }));
},
});
},
// ---- 管理任务 ----
loadManageTasks() {
const id = store.currentPetId();
if (!id) return;
api.getTasks(id).then((tasks) => this.setData({ manageTasks: tasks || [] })).catch(() => {});
},
onPickPostImages() {
const left = 2 - this.data.postImages.length;
if (left <= 0) return wx.showToast({ title: '最多 2 张', icon: 'none' });
upload
.chooseAndUploadImages(left)
.then((list) => this.setData({ postImages: this.data.postImages.concat(list) }))
.catch((e) => {
if (e && e.canceled) return;
wx.showToast({ title: (e && e.message) || '上传失败', icon: 'none' });
});
},
onRemovePostImage(e) {
const i = e.currentTarget.dataset.index;
const arr = this.data.postImages.slice();
arr.splice(i, 1);
this.setData({ postImages: arr });
},
onCommentInput(e) { this.setData({ commentText: e.detail.value }); },
segIdx(group) {
const v = this.data.segSel[group];
return v === undefined ? 0 : v;
},
optIdx(group) {
const v = this.data.optSel[group];
return v === undefined ? 0 : v;
},
// 记录表单整体搬去 pages/addrecord 之后,这里只剩「生成就医前摘要」
// 会往 health_records 写一条 note —— 它是个动作留痕,不是用户填的表单
buildRecord() {
if (this.data.innerType === 'vetSummary') {
return { type: 'note', icon: '📄', title: '生成就医前摘要' };
}
return null;
},
async createAndClose(rec) {
const id = store.currentPetId();
if (!rec) {
this.close();
return;
}
// 没建档时点「保存」原来是静默关掉弹层,用户以为存上了,其实什么都没发生
if (!id) {
wx.showToast({ title: '先建一份宠物档案再记录', icon: 'none' });
return;
}
if (this.data.saving) return;
this.setData({ saving: true });
try {
const saved = await api.createRecord(id, rec);
if (rec.type === 'weight') store.patchCurrent({ weight: rec.title.replace('体重 ', '') });
this.triggerEvent('saved', saved);
this.close();
} catch (e) {
wx.showToast({ title: e.message || '保存失败', icon: 'none' });
this.setData({ saving: false });
}
},
onSave() {
this.createAndClose(this.buildRecord());
},
async onCreatePost() {
const content = (this.data.postContent || '').trim();
if (!content) return wx.showToast({ title: '写点什么吧', icon: 'none' });
const tag = PTAG[this.data.optSel.ptag === undefined ? 0 : this.data.optSel.ptag];
if (this.data.saving) return;
this.setData({ saving: true });
try {
// 去掉发布身份,统一用发布者昵称(后端按 user 取昵称)
const post = await api.createPost({
identity: 'user',
content,
tags: [tag],
image_file_ids: this.data.postImages.map((i) => i.id),
});
this.triggerEvent('posted');
this.close();
// 内容安全审核:没直接过审的,明确告诉用户在审核,别以为发失败/是 bug
if (post && post.status && post.status !== 'published') {
wx.showModal({
title: '发布成功,审核中',
content: '内容需要通过安全审核后才会出现在社区。可在「我的 → 我的帖子」查看审核状态。',
showCancel: false,
confirmText: '知道了',
});
} else {
wx.showToast({ title: '已发布', icon: 'success' });
}
} catch (e) {
wx.showToast({ title: e.message || '发布失败', icon: 'none' });
this.setData({ saving: false });
}
},
// 评论
async onSendComment() {
const text = (this.data.commentText || '').trim();
// 原来空评论会直接把弹层关掉,看起来像发成功了。改成明确提示
if (!text) return wx.showToast({ title: '写点什么再发', icon: 'none' });
if (!this.data.postId) return this.close();
try {
await api.createComment(
this.data.postId,
text,
this.data.commentImages.map((i) => i.url),
this.data.replyTo.id || '',
);
this.setData({ commentText: '', commentImages: [], replyTo: {}, composing: false, cmFocus: false });
this.loadComments();
this.triggerEvent('commented');
} catch (e) {
wx.showToast({ title: e.message || '评论失败', icon: 'none' });
}
},
// 评论配图,最多 3 张
onPickCommentImages() {
const left = 3 - this.data.commentImages.length;
if (left <= 0) return wx.showToast({ title: '最多 3 张', icon: 'none' });
upload
.chooseAndUploadImages(left)
.then((files) => this.setData({ commentImages: this.data.commentImages.concat(files) }))
.catch((e) => {
if (e && e.canceled) return;
wx.showToast({ title: (e && e.message) || '上传失败', icon: 'none' });
});
},
// 回复某条评论:输入区顶部显示「回复 xxx」,发送时带上 parent_id
// 小红书那套:点任意一条评论/回复,直接把输入框激活并填上「回复 @xxx」,
// 不用再去底部找输入框、也不用单独点一个「回复」按钮
onReplyTo(e) {
const { id, name } = e.currentTarget.dataset;
if (!id) return;
// 点任意评论直接展开面板并预填回复对象
this.setData({ replyTo: { id, name }, composing: true, cmFocus: true });
},
// 点细条:展开大输入面板并聚焦(拉起键盘)
onStartCompose() {
this.setData({ composing: true, cmFocus: true });
},
// 收起面板,草稿(文字/图片)留着,下次点开接着写
onCloseCompose() {
this.setData({ composing: false, cmFocus: false, kbHeight: 0 });
},
onCommentFocus(e) {
// adjust-position=false,键盘高度自己接管:弹层是 fixed 定位,
// 交给系统顶会把整块推出屏幕
this.setData({ kbHeight: (e.detail && e.detail.height) || 0 });
},
onCommentBlur() {
// 失焦先放下键盘。有草稿就保持展开(否则点「发送」会因面板收起而丢掉这一下点击);
// 空内容才收回细条
const empty = !(this.data.commentText || '').trim() && !this.data.commentImages.length;
this.setData({ kbHeight: 0, cmFocus: false, composing: empty ? false : this.data.composing });
},
onCancelReply() {
this.setData({ replyTo: {} });
},
// 一级评论默认只带 3 条回复,点开拉全量
onExpandReplies(e) {
const { id, index } = e.currentTarget.dataset;
api
.listReplies(id)
.then((list) =>
this.setData({
[`comments[${index}].replies`]: (list || []).map((r) => ({
...r,
timeText: fmtAgo(r.created_at),
initial: (r.author_name || '?').slice(0, 1),
})),
}),
)
.catch((err) => wx.showToast({ title: (err && err.message) || '加载失败', icon: 'none' }));
},
onPreviewCommentImage(e) {
const { urls, cur } = e.currentTarget.dataset;
if (urls && urls.length) wx.previewImage({ urls, current: cur });
},
onRemoveCommentImage(e) {
const list = this.data.commentImages.slice();
list.splice(e.currentTarget.dataset.index, 1);
this.setData({ commentImages: list });
},
},
});
@@ -1,6 +0,0 @@
{
"component": true,
"usingComponents": {
"pt-icon": "/components/pt-icon/index"
}
}
@@ -1,288 +0,0 @@
<wxs module="u">
module.exports.sel = function (map, key, index, def) {
var v = map[key];
if (v === undefined || v === null) v = def;
return v === index;
};
</wxs>
<view class="overlay {{show ? 'show' : ''}}" bindtap="onMaskTap">
<view class="sheet {{innerType === 'comments' ? 'sheet-cmp' : ''}}" catchtap="noop">
<view class="sheetbar"></view>
<!-- 评论:头 + 滚动列表 + 常驻输入栏。输入栏不能放进滚动区,
否则列表一滚它就跟着走,点评论回复时还得再找回来 -->
<block wx:if="{{innerType === 'comments'}}">
<view class="cmp-head">共 {{total}} 条评论</view>
<scroll-view class="cmp-list" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view wx:for="{{comments}}" wx:key="id" class="cm"
bindtap="onReplyTo" data-id="{{item.id}}" data-name="{{item.author_name}}">
<view class="cm-av">{{item.initial}}</view>
<view class="cm-body">
<view class="cm-name">{{item.author_name}}<text wx:if="{{item.is_self}}" class="mine-tag">我</text></view>
<view class="cm-text">{{item.content}}</view>
<view wx:if="{{item.imgs.length}}" class="cm-imgs">
<image wx:for="{{item.imgs}}" wx:for-item="u" wx:key="*this" class="cm-img"
src="{{u}}" mode="aspectFill" catchtap="onPreviewCommentImage"
data-urls="{{item.imgs}}" data-cur="{{u}}"></image>
</view>
<view class="cm-meta">
<text>{{item.timeText}}</text>
<text class="cm-reply-btn">回复</text>
<text wx:if="{{item.is_self}}" class="cm-del" catchtap="onDeleteComment"
data-id="{{item.id}}" data-self="{{true}}">删除</text>
</view>
<!-- 回复只缩进一层,再深手机上没法读 -->
<view wx:if="{{item.replies.length}}" class="cm-replies">
<view wx:for="{{item.replies}}" wx:for-item="r" wx:key="id" class="cm-reply"
catchtap="onReplyTo" data-id="{{r.id}}" data-name="{{r.author_name}}">
<view class="cm-av cm-av-sm">{{r.initial}}</view>
<view class="cm-body">
<view class="cm-name">
{{r.author_name}}<text wx:if="{{r.is_self}}" class="mine-tag">我</text>
<text wx:if="{{r.reply_to_name}}" class="cm-at"> 回复 {{r.reply_to_name}}</text>
</view>
<view class="cm-text">{{r.content}}</view>
<view class="cm-meta">
<text>{{r.timeText}}</text>
<text class="cm-reply-btn">回复</text>
<text wx:if="{{r.is_self}}" class="cm-del" catchtap="onDeleteComment"
data-id="{{r.id}}" data-self="{{true}}">删除</text>
</view>
</view>
</view>
<view wx:if="{{item.reply_n > item.replies.length}}" class="cm-more"
catchtap="onExpandReplies" data-id="{{item.id}}" data-index="{{index}}">
— 展开全部 {{item.reply_n}} 条回复
</view>
</view>
</view>
</view>
<view wx:if="{{!comments.length}}" class="cmp-empty">
<view class="cmp-empty-ic"><pt-icon name="comment" size="{{72}}"></pt-icon></view>
<view class="cmp-empty-t">还没有评论</view>
<view class="cmp-empty-p">来抢个沙发,说点什么吧~</view>
</view>
</scroll-view>
<!-- 收起态:一条细条,点了才展开成大写评论面板(小红书那套) -->
<view wx:if="{{!composing}}" class="cm-collapsed" catchtap="onStartCompose">
<view class="cm-collapsed-pill {{commentText ? 'has' : ''}}">{{commentText || '说点什么…'}}</view>
<view class="cm-collapsed-ic"><pt-icon name="photo" size="{{40}}"></pt-icon></view>
</view>
<!-- 展开态:大输入面板,浮在键盘上方 -->
<view wx:else class="cmp-dock" style="padding-bottom:{{kbHeight ? kbHeight + 'px' : 'calc(20rpx + env(safe-area-inset-bottom))'}}">
<view class="cm-dock-head">
<text class="cm-dock-title">{{replyTo.name ? '回复 ' + replyTo.name : '写评论'}}</text>
<view class="cm-dock-close" catchtap="onCloseCompose"><pt-icon name="close" size="{{30}}"></pt-icon></view>
</view>
<textarea class="cm-ta" placeholder="{{replyTo.name ? '回复 @' + replyTo.name + '…' : '有爱评论,说点好听的~'}}"
placeholder-class="placeholder" value="{{commentText}}" bindinput="onCommentInput"
focus="{{cmFocus}}" bindfocus="onCommentFocus" bindblur="onCommentBlur"
maxlength="500" auto-height="{{true}}" show-confirm-bar="{{false}}"
adjust-position="{{false}}" cursor-spacing="12"></textarea>
<view wx:if="{{commentImages.length}}" class="img-picker" style="margin-top:12rpx">
<view wx:for="{{commentImages}}" wx:key="id" class="img-thumb">
<image src="{{item.url}}" mode="aspectFill"></image>
<view class="img-del" catchtap="onRemoveCommentImage" data-index="{{index}}"><pt-icon name="close" size="{{24}}"></pt-icon></view>
</view>
</view>
<view class="cm-toolbar">
<view class="cm-pic" catchtap="onPickCommentImages"><pt-icon name="photo" size="{{44}}"></pt-icon></view>
<text class="cm-count">{{commentText.length}}/500</text>
<view class="cm-send {{commentText ? 'on' : ''}}" catchtap="onSendComment">发送</view>
</view>
</view>
</block>
<scroll-view wx:if="{{innerType !== 'comments'}}" class="sheet-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<!-- 那 9 种记录表单(体重/排便/饮食/异常/给药/记账/疫苗/驱虫/照片)已经整体
搬到 pages/addrecord —— 字段由后端 record_types.fields 驱动,一个页面吃掉
全部 24 种,不再每种一个 wx:elif。弹层现在只管「不是记一笔」的那些事:
提醒、海报、档案、发帖、评论…… -->
<!-- 就医前摘要。这是链上第一支,必须是 wx:if -->
<block wx:if="{{innerType === 'vetSummary'}}">
<view class="sheet-h3">就医前摘要</view>
<view class="sheet-p pre">宠物:{{pet.name}}
阶段:{{pet.stage}}
当前体重:{{pet.weight}}
近期重点:疫苗期、换粮观察、排便记录
建议携带:排便/呕吐照片、饮食变化、疫苗驱虫记录。</view>
<button class="btn btn-primary btn-block" bindtap="onSave">保存摘要</button>
</block>
<!-- 记录用药 -->
<!-- 提醒中心 -->
<block wx:elif="{{innerType === 'reminders'}}">
<view class="sheet-h3">提醒中心</view>
<view class="sheet-p">疫苗、驱虫这些别靠记,设好日期到时候提醒你。</view>
<view wx:for="{{reminders}}" wx:key="id" class="mtask-row">
<view class="mtask-body">
<text class="mtask-b">{{item.title}}</text>
<view class="mtask-p">{{item.when}}</view>
</view>
<view class="mtask-op" catchtap="onEditReminder" data-index="{{index}}">改</view>
<view class="mtask-op del" catchtap="onDeleteReminder" data-index="{{index}}">删</view>
</view>
<view wx:if="{{!reminders.length}}" class="sheet-p">还没有提醒,下面加一条吧。</view>
<view class="field" style="margin-top:12rpx"><label>{{remForm.id ? '修改提醒' : '新增提醒'}}</label>
<input class="input" placeholder="提醒名,例如:第 3 针疫苗" placeholder-class="placeholder" value="{{remForm.title}}" bindinput="onRemTitle"/></view>
<view class="field"><label>类型 / 到期日</label>
<view class="mtask-form-row">
<picker mode="selector" range="{{remTypeLabels}}" value="{{remForm.typeIdx}}" bindchange="onRemType">
<view class="picker-box">{{remTypeLabels[remForm.typeIdx]}}</view>
</picker>
<picker mode="date" value="{{remForm.date}}" bindchange="onRemDate">
<view class="picker-box">{{remForm.date || '选择到期日'}}</view>
</picker>
</view>
</view>
<view class="field"><label>重复频率(可选)</label>
<input class="input" placeholder="例如:每月一次;填了可不选到期日" placeholder-class="placeholder" value="{{remForm.freq}}" bindinput="onRemFreq"/></view>
<button class="btn btn-primary btn-block" bindtap="onSaveReminder">{{remForm.id ? '保存修改' : '添加提醒'}}</button>
</block>
<!-- 任务详情 -->
<!-- 成长海报 -->
<block wx:elif="{{innerType === 'poster'}}">
<view class="poster">
<view class="poster-h3">{{pet.name}} 的成长报告</view>
<view class="poster-avatar">
<image wx:if="{{poster.pet_avatar_url || pet.avatar_url}}" class="poster-avatar-img" src="{{poster.pet_avatar_url || pet.avatar_url}}" mode="aspectFill"></image>
<block wx:else>{{pet.emoji}}</block>
</view>
<view class="poster-meta">{{pet.age}}|{{pet.weight}}|{{pet.stage}}</view>
<view wx:if="{{poster.period}}" class="poster-period">近 7 天 · {{poster.period}}</view>
<view class="poster-list">
<view class="poster-row"><view class="poster-tick"><pt-icon name="check" size="{{22}}"></pt-icon></view><text class="poster-rt">完成任务</text><text class="poster-rn">{{poster ? poster.tasks_completed : 0}} 项</text></view>
<view class="poster-row"><view class="poster-tick"><pt-icon name="check" size="{{22}}"></pt-icon></view><text class="poster-rt">体重记录</text><text class="poster-rn">{{poster ? poster.weight_records : 0}} 次</text></view>
<view class="poster-row"><view class="poster-tick"><pt-icon name="check" size="{{22}}"></pt-icon></view><text class="poster-rt">疫苗记录</text><text class="poster-rn">{{poster ? poster.vaccine_records : 0}} 次</text></view>
<view class="poster-row"><view class="poster-tick"><pt-icon name="check" size="{{22}}"></pt-icon></view><text class="poster-rt">高风险异常</text><text class="poster-rn">{{poster ? poster.high_risk_count : 0}} 次</text></view>
</view>
<view class="poster-status">健康状态<text class="poster-badge">{{poster ? poster.headline : '稳定成长'}}</text></view>
<view class="poster-from">生成自 · 肉垫计划</view>
</view>
<view class="sheet-actions" style="margin-top:28rpx">
<button class="btn btn-ghost" open-type="share">分享给朋友</button>
<button class="btn btn-primary" bindtap="onSavePoster" disabled="{{posterSaving}}">
{{posterSaving ? '生成中…' : '保存到相册'}}
</button>
</view>
<!-- 离屏画布:海报要存成图片才能进相册。不能用 display:none,否则取不到节点 -->
<canvas type="2d" id="posterCanvas" class="poster-canvas"></canvas>
</block>
<!-- 报告详情 -->
<block wx:elif="{{innerType === 'reportDetail'}}">
<view class="sheet-h3">本周成长报告</view>
<view class="sheet-p">{{reportDetail ? reportDetail.summary : '正在汇总本周数据…'}}</view>
<view wx:if="{{reportDetail}}" class="sheet-p"><text class="bold">下周重点:</text>{{reportDetail.next_week_focus}}</view>
<button class="btn btn-primary btn-block" data-type="poster" bindtap="goSheet">生成分享卡片</button>
</block>
<!-- Pro 会员 -->
<block wx:elif="{{innerType === 'pro'}}">
<view class="sheet-h3">肉垫计划 Pro</view>
<view class="sheet-p">解锁 365 天计划、PDF 健康档案、月度报告、多宠物管理与年度账单。</view>
<view class="check-list">
<view wx:for="{{proInfo.features}}" wx:key="*this" class="check selected"><view class="box"><pt-icon name="check" size="{{26}}"></pt-icon></view>{{item}}</view>
</view>
<button wx:if="{{proInfo.status === 'active'}}" class="btn btn-soft btn-block" bindtap="close"><pt-icon name="check" size="{{30}}"></pt-icon>已开通 Pro</button>
<button wx:else class="btn btn-soft btn-block" disabled="true">即将开放</button>
<view wx:if="{{proInfo.status !== 'active'}}" class="sheet-p" style="text-align:center;margin-top:16rpx;font-size:24rpx">
会员功能还在准备中,暂未开放购买。
</view>
</block>
<!-- 添加宠物 -->
<!-- 添加/编辑宠物已经整体搬到 pages/petform:三步向导(名字+头像 → 猫狗+品种
→ 其他资料),品种从后端 breeds 表来、按首字母索引。弹层塞不下这些 -->
<block wx:elif="{{innerType === 'createPost'}}">
<view class="sheet-h3">发布图文</view>
<view class="sheet-p">用你的昵称分享养宠日常。</view>
<!-- 点整块 field 都能聚焦:原生 textarea 在 scroll-view 里命中区域会偏 -->
<view class="field" bindtap="onPostFieldTap"><label>内容</label>
<textarea class="textarea" focus="{{pfFocus}}" placeholder="分享养宠日常、求助问题、经验笔记..." placeholder-class="placeholder" value="{{postContent}}" bindinput="onPostInput" bindblur="onPostBlur"></textarea></view>
<view class="mini-options">
<view class="option {{u.sel(optSel,'ptag',0,0)?'selected':''}}" data-group="ptag" data-index="0" bindtap="onOpt">晒宠</view>
<view class="option {{u.sel(optSel,'ptag',1,0)?'selected':''}}" data-group="ptag" data-index="1" bindtap="onOpt">新手求助</view>
<view class="option {{u.sel(optSel,'ptag',2,0)?'selected':''}}" data-group="ptag" data-index="2" bindtap="onOpt">经验</view>
</view>
<view class="field"><label>图片(可选,最多 2 张)</label>
<view class="img-picker">
<view wx:for="{{postImages}}" wx:key="id" class="img-thumb">
<image src="{{item.url}}" mode="aspectFill"></image>
<view class="img-del" catchtap="onRemovePostImage" data-index="{{index}}"><pt-icon name="close" size="{{24}}"></pt-icon></view>
</view>
<view wx:if="{{postImages.length < 2}}" class="img-add" bindtap="onPickPostImages"><pt-icon name="plus" size="{{48}}"></pt-icon></view>
</view>
</view>
<button class="btn btn-primary btn-block" bindtap="onCreatePost">发布到宠友圈</button>
</block>
<!-- 任务管理那一支删了:它管的是 DailyTask,而「今日任务」已经并进计划节点,
增删改都在计划页做。留着会出现「这里加的任务」和「计划页加的节点」进不同的表 -->
<block wx:elif="{{innerType === 'managePets'}}">
<view class="sheet-h3">多宠物管理</view>
<view class="sheet-p">点一只切换为当前宠物,或编辑它的档案。</view>
<view wx:for="{{myPets}}" wx:key="id" class="mtask-row">
<view class="mtask-body" catchtap="onSwitchPet" data-id="{{item.id}}">
<text class="mtask-b">{{item.emoji}} {{item.name}}<text wx:if="{{item.current}}" class="mtask-pri">当前</text></text>
<view class="mtask-p">{{item.meta}}</view>
</view>
<view class="mtask-op" catchtap="onEditPetFromList" data-id="{{item.id}}">编辑</view>
</view>
<view wx:if="{{!myPets.length}}" class="sheet-p">还没有毛孩子。</view>
<button class="btn btn-primary btn-block" style="margin-top:16rpx" data-type="addPet" bindtap="goSheet">+ 添加毛孩子</button>
</block>
<!-- 导出健康档案 -->
<block wx:elif="{{innerType === 'exportData'}}">
<view class="sheet-h3">导出健康档案</view>
<view class="sheet-p">整理成一段文字,可复制后发给医生或存到备忘录。</view>
<view class="export-box">{{exportText || '正在整理…'}}</view>
<button class="btn btn-primary btn-block" bindtap="onCopyExport">复制全部内容</button>
</block>
<!-- 意见反馈 -->
<block wx:elif="{{innerType === 'feedback'}}">
<view class="sheet-h3">意见反馈</view>
<block wx:if="{{!fbDone}}">
<view class="sheet-p">用着哪儿别扭、想要什么功能,都可以告诉我们。</view>
<view class="field"><label>你的反馈</label>
<textarea class="textarea" placeholder="例如:希望能记录洗澡时间" placeholder-class="placeholder" value="{{fbContent}}" bindinput="onFbContent"></textarea></view>
<view class="field"><label>联系方式(可选)</label>
<input class="input" placeholder="微信号或邮箱,方便回复你" placeholder-class="placeholder" value="{{fbContact}}" bindinput="onFbContact"/></view>
<button class="btn btn-primary btn-block" bindtap="onSubmitFeedback" disabled="{{saving}}">{{saving ? '提交中…' : '提交反馈'}}</button>
</block>
<block wx:else>
<view class="sheet-p">已收到,谢谢你的反馈 🐾</view>
<button class="btn btn-primary btn-block" bindtap="close">好的</button>
</block>
</block>
<!-- 关于我们 -->
<block wx:elif="{{innerType === 'about'}}">
<view class="sheet-h3">关于肉垫计划</view>
<view class="sheet-p pre">肉垫计划是给新手养宠人做的照护记录工具。
按猫狗的品类和成长阶段生成每日照护任务和养护计划,随手记录体重、饮食、排便、用药、疫苗和开支,疫苗驱虫到期前提醒你,并把这些整理成成长报告。
版本:v0.2
反馈:我的 → 意见反馈</view>
<view class="sheet-p" style="color:var(--muted);font-size:24rpx">本小程序提供的是记录与观察建议,不能替代医生诊断。宠物出现持续异常请及时就医。</view>
<button class="btn btn-primary btn-block" bindtap="close">知道了</button>
</block>
</scroll-view>
</view>
</view>
@@ -1,170 +0,0 @@
.overlay{
position:fixed;inset:0;z-index:200;
background:rgba(26,18,12,.30);
opacity:0;pointer-events:none;transition:.2s ease;
backdrop-filter:blur(6rpx);-webkit-backdrop-filter:blur(6rpx);
}
.overlay.show{opacity:1;pointer-events:auto}
.sheet{
position:absolute;left:0;right:0;bottom:0;
background:#fff;border-radius:48rpx 48rpx 0 0;
padding:var(--sp-5) 0 0;
transform:translateY(106%);transition:.24s ease;
box-shadow:var(--sd-3);
}
.overlay.show .sheet{transform:translateY(0)}
.sheet-scroll{
box-sizing:border-box;
/* exportData / manageTasks / comments 是最长的三型,圆角间距变化后靠这个上限内滚 */
max-height:80vh;
padding:0 var(--sp-5) calc(120rpx + env(safe-area-inset-bottom));
}
.sheet-scroll::-webkit-scrollbar,.plan-chat::-webkit-scrollbar{width:0;height:0;display:none}
.sheetbar{width:80rpx;height:8rpx;border-radius:var(--r-full);background:#E1D8CF;margin:0 auto var(--sp-4)}
.sheet-h3{font-size:var(--fs-xl);font-weight:var(--fw-b);margin-bottom:var(--sp-3)}
.sheet-p{font-size:var(--fs-md);color:var(--text-2);line-height:1.65;margin-bottom:var(--sp-4)}
.sheet-p.pre,.poster-list.pre{white-space:pre-line}
.poster-h3{font-size:var(--fs-xl);font-weight:var(--fw-b);margin-bottom:var(--sp-3)}
.ai-input-row{display:flex;gap:var(--sp-3);align-items:center}
.ai-input-row .input{flex:1}
.ai-send{height:88rpx;line-height:88rpx;padding:0 var(--sp-4)}
/* 弹层内的日期选择器展示 */
.picker-box{color:var(--text)}
.del-btn{background:var(--red-soft);color:var(--red-ink)}
/* 管理任务 / 管理宠物 */
.mtask-row{display:flex;align-items:center;gap:var(--sp-3);padding:var(--sp-3) 0;border-bottom:1rpx solid var(--line)}
.mtask-body{flex:1;min-width:0}
.mtask-b{font-size:var(--fs-md);font-weight:var(--fw-b)}
.mtask-pri{
margin-left:var(--sp-2);font-size:var(--fs-cap);font-weight:var(--fw-b);
color:var(--primary-ink);background:var(--primary-soft);padding:2rpx var(--sp-2);border-radius:var(--r-full);
}
.mtask-p{margin-top:6rpx;font-size:var(--fs-sm);color:var(--muted)}
.mtask-op{
flex:none;font-size:var(--fs-sm);font-weight:var(--fw-b);color:var(--primary-dark);
padding:var(--sp-1) var(--sp-3);background:var(--surface-2);border-radius:var(--r-xs);
}
.mtask-op.del{color:var(--red-ink)}
.mtask-form-row{display:flex;align-items:center;gap:var(--sp-3)}
/* 这条覆写了全局 .seg-btn 的高度,全局值变化时要一起看 */
.mtask-form-row .seg-btn{flex:none;padding:0 var(--sp-4);height:var(--h-sm);line-height:var(--h-sm)}
.mtask-form-row .picker-box{flex:1}
/* 图片选择器 */
.img-picker{display:flex;flex-wrap:wrap;gap:var(--sp-3)}
.img-thumb{position:relative;width:160rpx;height:160rpx;border-radius:var(--r-sm);overflow:hidden}
.img-thumb image{width:100%;height:100%}
.img-del{
position:absolute;top:0;right:0;width:44rpx;height:44rpx;
display:flex;align-items:center;justify-content:center;
background:rgba(0,0,0,.55);color:#fff;border-radius:0 var(--r-sm) 0 var(--r-sm);
}
.img-add{
width:160rpx;height:160rpx;border-radius:var(--r-sm);border:2rpx dashed #D8CFC4;
display:flex;align-items:center;justify-content:center;color:var(--muted2);background:var(--surface-2);
}
/* 导出健康档案 */
.export-box{
max-height:560rpx;overflow-y:auto;background:var(--surface-2);border-radius:var(--r-md);
padding:var(--sp-4);font-size:var(--fs-sm);line-height:1.7;color:var(--text-2);white-space:pre-line;
border:1rpx solid var(--line);margin-bottom:var(--sp-4);
}
.export-box::-webkit-scrollbar{width:0;height:0;display:none}
.mine-tag{
margin-left:var(--sp-1);font-size:var(--fs-cap);font-weight:var(--fw-b);
color:var(--primary-ink);background:var(--primary-soft);padding:2rpx var(--sp-1);border-radius:var(--r-full);
}
/* 离屏画布:挪到可视区外而不是 display:none,否则 selectorQuery 取不到节点 */
.poster-canvas{position:fixed;left:-9999rpx;top:0;width:600rpx;height:840rpx}
/* ── 评论:头 + 滚动列表 + 常驻输入栏 ── */
.sheet.sheet-cmp{display:flex;flex-direction:column;height:82vh;padding-bottom:0}
.cmp-head{
flex:none;text-align:center;padding:0 0 var(--sp-3);
font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text);border-bottom:1rpx solid var(--line);
}
/* 空态:图标 + 两行字,居中在列表区,不再是一行字飘在大片留白里 */
.cmp-empty{
display:flex;flex-direction:column;align-items:center;justify-content:center;
padding:120rpx 0;color:var(--muted);
}
.cmp-empty-ic{
width:140rpx;height:140rpx;border-radius:50%;margin-bottom:var(--sp-4);
display:flex;align-items:center;justify-content:center;
background:var(--primary-soft);color:var(--primary);
}
.cmp-empty-t{font-size:var(--fs-lg);font-weight:var(--fw-b);color:var(--text-2)}
.cmp-empty-p{margin-top:var(--sp-1);font-size:var(--fs-sm);color:var(--muted2)}
.cmp-list{flex:1;min-height:0;padding:var(--sp-3) var(--sp-5) 0}
.cmp-dock{
flex:none;background:#fff;border-top:1rpx solid var(--line);
padding:var(--sp-3) var(--sp-5) calc(20rpx + env(safe-area-inset-bottom));
transition:padding-bottom .2s ease;
}
.cm{display:flex;gap:var(--sp-3);padding:var(--sp-3) 0}
.cm-av{
width:64rpx;height:64rpx;border-radius:50%;flex:none;
display:flex;align-items:center;justify-content:center;
background:var(--primary-soft);color:var(--primary-ink);
font-size:var(--fs-md);font-weight:var(--fw-b);
}
.cm-av-sm{width:48rpx;height:48rpx;font-size:var(--fs-sm)}
.cm-body{flex:1;min-width:0}
.cm-name{font-size:var(--fs-sm);color:var(--muted)}
.cm-at{color:var(--muted2)}
.cm-text{margin-top:6rpx;font-size:var(--fs-md);line-height:1.55;word-break:break-word}
.cm-imgs{display:flex;flex-wrap:wrap;gap:var(--sp-1);margin-top:var(--sp-2)}
.cm-img{width:150rpx;height:150rpx;border-radius:var(--r-sm)}
.cm-meta{display:flex;align-items:center;gap:var(--sp-4);margin-top:var(--sp-2);font-size:var(--fs-cap);color:var(--muted2)}
.cm-reply-btn{color:var(--muted)}
.cm-del{color:var(--red-ink)}
.cm-replies{margin-top:var(--sp-2)}
.cm-reply{display:flex;gap:var(--sp-2);padding:var(--sp-2) 0}
.cm-more{margin-top:4rpx;font-size:var(--fs-cap);color:var(--muted)}
/* 「回复 @xxx」的提示条 */
.cm-replying{
display:inline-flex;align-items:center;gap:var(--sp-2);margin-bottom:var(--sp-2);
padding:4rpx var(--sp-3);border-radius:var(--r-full);
background:var(--primary-soft);color:var(--primary-ink);font-size:var(--fs-cap);
}
.cm-cancel{display:flex;align-items:center}
/* 收起态:底部一条细条,点了才展开成大面板(小红书那套) */
.cm-collapsed{
flex:none;display:flex;align-items:center;gap:var(--sp-3);
background:#fff;border-top:1rpx solid var(--line);
padding:var(--sp-3) var(--sp-5) calc(20rpx + env(safe-area-inset-bottom));
}
.cm-collapsed-pill{
flex:1;min-width:0;height:76rpx;line-height:76rpx;padding:0 var(--sp-5);
border-radius:var(--r-full);background:var(--surface-2);color:var(--muted2);
font-size:var(--fs-md);overflow:hidden;white-space:nowrap;text-overflow:ellipsis;
}
.cm-collapsed-pill.has{color:var(--text)}
.cm-collapsed-ic{flex:none;display:flex;align-items:center;color:var(--muted)}
/* 展开态:大写评论面板 */
.cm-dock-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--sp-2)}
.cm-dock-title{font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)}
.cm-dock-close{display:flex;align-items:center;color:var(--muted);padding:var(--sp-1)}
.cm-ta{
width:100%;box-sizing:border-box;min-height:200rpx;max-height:520rpx;height:auto;
padding:var(--sp-4);background:var(--surface-2);border-radius:var(--r-md);
font-size:var(--fs-lg);line-height:1.5;
}
.cm-toolbar{display:flex;align-items:center;gap:var(--sp-3);margin-top:var(--sp-3)}
.cm-pic{flex:none;display:flex;align-items:center;color:var(--muted)}
.cm-count{margin-left:auto;color:var(--muted2);font-size:var(--fs-cap)}
.cm-send{
flex:none;height:76rpx;padding:0 var(--sp-6);border-radius:var(--r-full);
display:flex;align-items:center;justify-content:center;
background:var(--line);color:#fff;font-size:var(--fs-md);font-weight:var(--fw-b);transition:.16s ease;
}
.cm-send.on{background:var(--cta)}
-15
View File
@@ -1,15 +0,0 @@
Component({
options: { addGlobalClass: true },
properties: {
// 之前漏声明,icon 一直没传进来,pt-icon 收到空值退到 note 兜底字形,
// 首页那个「文档」图标就是这么来的
icon: { type: String, value: 'plus' },
// 首页快速记录用肉垫造型(呼应「肉垫计划」),社区发帖仍用普通图标
paw: { type: Boolean, value: false },
},
methods: {
onTap() {
this.triggerEvent('tap');
}
}
});
-6
View File
@@ -1,6 +0,0 @@
{
"component": true,
"usingComponents": {
"pt-icon": "/components/pt-icon/index"
}
}
-11
View File
@@ -1,11 +0,0 @@
<view class="fab {{paw ? 'fab-paw' : ''}}" bindtap="onTap">
<!-- 肉垫:一个大掌垫 + 四个脚趾垫,纯 CSS 画,呼应 app 名 -->
<view wx:if="{{paw}}" class="paw">
<view class="bean bean-1"></view>
<view class="bean bean-2"></view>
<view class="bean bean-3"></view>
<view class="bean bean-4"></view>
<view class="pad"></view>
</view>
<pt-icon wx:else name="{{icon}}" size="{{48}}"></pt-icon>
</view>
-28
View File
@@ -1,28 +0,0 @@
/* 统一暖橘圆钮:首页肉垫和社区发帖用同一套主题色 */
.fab{
position:fixed;right:36rpx;bottom:calc(190rpx + env(safe-area-inset-bottom));
width:112rpx;height:112rpx;border-radius:50%;
background:linear-gradient(150deg,#FFB865,#F5943E);
color:#fff;
display:flex;align-items:center;justify-content:center;
box-shadow:0 12rpx 28rpx rgba(214,138,52,.42),0 2rpx 6rpx rgba(214,138,52,.3);
z-index:60;
}
.fab:active{transform:scale(.94)}
/* 肉垫造型:和基础一致的暖橘圆钮,中间一只奶白肉垫 */
.fab-paw{width:116rpx;height:116rpx}
.paw{position:relative;width:66rpx;height:60rpx}
/* 掌垫:上窄下宽的圆角心形块,坐在下方 */
.paw .pad{
position:absolute;left:50%;bottom:0;transform:translateX(-50%);
width:44rpx;height:36rpx;background:#FFF7EC;
border-radius:48% 48% 44% 44%/62% 62% 40% 40%;
}
/* 四个脚趾垫,沿上方弧线排开,外侧两个更低、内侧两个更高 */
.paw .bean{position:absolute;background:#FFF7EC;border-radius:50%}
.paw .bean-1{width:15rpx;height:20rpx;left:0;top:20rpx;transform:rotate(-20deg)}
.paw .bean-2{width:16rpx;height:22rpx;left:18rpx;top:5rpx;transform:rotate(-7deg)}
.paw .bean-3{width:16rpx;height:22rpx;right:18rpx;top:5rpx;transform:rotate(7deg)}
.paw .bean-4{width:15rpx;height:20rpx;right:0;top:20rpx;transform:rotate(20deg)}
-31
View File
@@ -1,31 +0,0 @@
const app = getApp();
Component({
options: { addGlobalClass: true },
properties: {
title: { type: String, value: '肉垫计划' },
showBack: { type: Boolean, value: false }
},
data: {
statusBarHeight: 20,
navBarHeight: 44
},
lifetimes: {
attached() {
this.setData({
statusBarHeight: app.globalData.statusBarHeight,
navBarHeight: app.globalData.navBarHeight
});
}
},
methods: {
onBack() {
const pages = getCurrentPages();
if (pages.length > 1) {
wx.navigateBack();
} else {
wx.switchTab({ url: '/pages/home/home' });
}
}
}
});
-6
View File
@@ -1,6 +0,0 @@
{
"component": true,
"usingComponents": {
"pt-icon": "/components/pt-icon/index"
}
}
-8
View File
@@ -1,8 +0,0 @@
<view class="nav-wrap" style="padding-top:{{statusBarHeight}}px;height:{{statusBarHeight + navBarHeight}}px;">
<view class="nav-inner" style="height:{{navBarHeight}}px;">
<view wx:if="{{showBack}}" class="nav-back" bindtap="onBack"><pt-icon name="back" size="{{40}}"></pt-icon></view>
<view class="nav-title">{{title}}</view>
</view>
</view>
<!-- 占位,避免内容被固定导航栏遮挡 -->
<view style="height:{{statusBarHeight + navBarHeight}}px;"></view>
-26
View File
@@ -1,26 +0,0 @@
:host{display:block}
/* 整页暖橘渐变后,顶部本身就是暖色,nav 不再铺奶白/毛玻璃,透明即可,
文字用深棕压在暖底上。内容在 .page-scroll 里滚,不会跑到 nav 背后,
所以不需要毛玻璃遮挡。 */
.nav-wrap{
position:fixed;top:0;left:0;right:0;z-index:100;
background:none;
}
.nav-inner{
position:relative;display:flex;align-items:center;justify-content:center;
}
.nav-title{
font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--band-ink);
letter-spacing:.5rpx;
max-width:56%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
/* 返回键做成有边界的圆形,和右上角胶囊在视觉上对称 */
.nav-back{
position:absolute;left:var(--sp-3);top:50%;transform:translateY(-50%);
width:60rpx;height:60rpx;border-radius:50%;
display:flex;align-items:center;justify-content:center;
color:var(--band-ink);background:rgba(255,255,255,.6);
box-shadow:0 4rpx 12rpx rgba(70,48,25,.06);
}
.nav-back:active{background:rgba(255,255,255,.92)}
+26
View File
@@ -0,0 +1,26 @@
Component({
properties: {
pets: { type: Array, value: [] },
selected: { type: Array, value: [] },
},
data: { viewPets: [] },
observers: {
"pets,selected": function (pets, selected) {
this.setData({
viewPets: (pets || []).map((p) => ({
...p,
chosen: (selected || []).includes(p.id),
})),
});
},
},
methods: {
toggle(e) {
const id = e.currentTarget.dataset.id;
const selected = this.data.selected.includes(id)
? this.data.selected.filter((x) => x !== id)
: [...this.data.selected, id];
this.triggerEvent("change", { selected });
},
},
});
+1
View File
@@ -0,0 +1 @@
{ "component": true }
+14
View File
@@ -0,0 +1,14 @@
<view class="picker">
<view
wx:for="{{viewPets}}"
wx:key="id"
class="chip {{item.chosen?'chosen':''}}"
data-id="{{item.id}}"
bindtap="toggle"
>
<image wx:if="{{item.avatar_url}}" src="{{item.avatar_url}}" />
<text wx:else>🐕</text>
<text>{{item.name}}</text>
<text class="check">{{item.chosen?'✓':'+'}}</text>
</view>
</view>
+31
View File
@@ -0,0 +1,31 @@
.picker {
display: flex;
gap: 15rpx;
flex-wrap: wrap;
}
.chip {
display: flex;
align-items: center;
gap: 8rpx;
background: white;
border: 2rpx solid #e0eae2;
color: #527164;
padding: 11rpx 18rpx;
border-radius: 99rpx;
font-size: 24rpx;
}
.chip.chosen {
background: #dff0e1;
border-color: #55a273;
color: #145441;
font-weight: 700;
}
.chip image {
width: 36rpx;
height: 36rpx;
border-radius: 50%;
}
.check {
margin-left: 3rpx;
font-weight: 800;
}
@@ -1,39 +0,0 @@
const store = require('../../utils/store.js');
Component({
options: { addGlobalClass: true },
properties: {
// 「添加」这一格不是每个页面都该有:计划页/记录页只是在既有宠物之间切,
// 建档入口在首页和「我的」就够了,到处摆一个会让这条 chip 变得很长
showAdd: { type: Boolean, value: true },
},
data: {
pets: [],
currentId: 0,
},
lifetimes: {
attached() {
this.refresh();
this._unsub = store.subscribe(() => this.refresh());
},
detached() {
if (this._unsub) this._unsub();
},
},
methods: {
refresh() {
this.setData({
pets: store.getPets(),
currentId: store.currentPetId() || 0,
});
},
onPick(e) {
const id = e.currentTarget.dataset.id;
store.switchPet(id);
this.triggerEvent('change', { id });
},
onAdd() {
this.triggerEvent('add');
},
},
});
@@ -1,6 +0,0 @@
{
"component": true,
"usingComponents": {
"pt-icon": "/components/pt-icon/index"
}
}
@@ -1,7 +0,0 @@
<scroll-view class="pet-switch" scroll-x="true" enhanced="{{true}}" show-scrollbar="{{false}}">
<!-- 纯文字筛选:带头像时不同宠物 chip 高度对不齐,这里只作切换用,文字最稳 -->
<view wx:for="{{pets}}" wx:key="id"
class="pet-chip {{item.id === currentId ? 'active' : ''}}"
data-id="{{item.id}}" bindtap="onPick">{{item.name}}</view>
<view wx:if="{{showAdd}}" class="pet-chip pet-chip-add" bindtap="onAdd">+ 添加</view>
</scroll-view>
@@ -1,11 +0,0 @@
.pet-switch{
display:block;white-space:nowrap;width:100%;font-size:0;
}
/* chip 是 inline-flex,行内盒默认按 baseline 对齐,不同内容会高低不齐;
统一固定高度 + vertical-align:middle,彻底对齐。父级 font-size:0 消掉 chip 间的空白基线 */
.pet-switch .pet-chip{
margin-right:20rpx;vertical-align:middle;
height:64rpx;padding-top:0;padding-bottom:0;font-size:var(--fs-md);
}
.pet-switch .pet-chip:last-child{margin-right:0}
.pet-chip-add{color:var(--muted)}
-37
View File
@@ -1,37 +0,0 @@
// 个人主页头部。「我的」和「宠友主页」共用同一份渲染——自己看和别人看
// 长得不一样的话,两边会慢慢长歪,装扮功能也就失去意义了。
// 差别只有一处:自己看是「装扮主页」,别人看是「关注 TA」。
Component({
options: { addGlobalClass: true },
properties: {
card: { type: Object, value: null },
// 装扮页的实时预览:只看效果,不要底部那排按钮
preview: { type: Boolean, value: false },
hideWall: { type: Boolean, value: false }, // 「我的」页藏掉社交宠物墙,避免和「我的毛孩子」卡重复
},
data: { initial: '', theme: 'warm' },
observers: {
card(c) {
if (!c) return;
this.setData({
// WXML 取不了字符串下标,首字得在 JS 里算
initial: (c.nickname || '?').slice(0, 1),
theme: c.theme || 'warm',
});
},
},
methods: {
onFollow() {
this.triggerEvent('follow');
},
onDecorate() {
this.triggerEvent('decorate');
},
onRelations(e) {
this.triggerEvent('relations', { kind: e.currentTarget.dataset.kind });
},
onPet(e) {
this.triggerEvent('pet', { id: e.currentTarget.dataset.id });
},
},
});
@@ -1,6 +0,0 @@
{
"component": true,
"usingComponents": {
"pt-icon": "/components/pt-icon/index"
}
}
@@ -1,50 +0,0 @@
<view class="pf th-{{theme}}">
<!-- 头图:只有设了自定义头图才显示,没设就是干净白卡(不再留一块空色块) -->
<image wx:if="{{card.bg_url}}" class="pf-bg-img" src="{{card.bg_url}}" mode="aspectFill"></image>
<view class="pf-body">
<!-- 身份行:头像 + 昵称/签名,左对齐 -->
<view class="pf-top">
<view class="pf-av">
<image wx:if="{{card.avatar_url}}" class="pf-av-img" src="{{card.avatar_url}}" mode="aspectFill"></image>
<block wx:else>{{initial}}</block>
</view>
<view class="pf-id">
<view class="pf-name">{{card.nickname || '毛孩子家长'}}<text wx:if="{{card.is_bot}}" class="ai-tag">AI</text></view>
<view class="pf-bio">{{card.bio || '这个人很懒,还没写签名'}}</view>
</view>
</view>
<!-- 社交数:小一行,数字+标签内联 -->
<view class="pf-social">
<view class="pf-stat"><text class="pf-n">{{card.post_count || 0}}</text><text class="pf-l">帖子</text></view>
<view class="pf-stat" data-kind="following" bindtap="onRelations"><text class="pf-n">{{card.following_count || 0}}</text><text class="pf-l">关注</text></view>
<view class="pf-stat" data-kind="followers" bindtap="onRelations"><text class="pf-n">{{card.follower_count || 0}}</text><text class="pf-l">粉丝</text></view>
</view>
<!-- 养宠数据:这个 App 独有,做成高亮带,比帖子数更能说明是个认真的铲屎官 -->
<view wx:if="{{card.stats}}" class="pf-keep">
<view class="pf-keep-i"><text class="pf-kn">{{card.stats.days || 0}}</text><text class="pf-kl">养宠天数</text></view>
<view class="pf-keep-i"><text class="pf-kn">{{card.stats.records || 0}}</text><text class="pf-kl">累计记录</text></view>
<view class="pf-keep-i"><text class="pf-kn">{{card.stats.streak || 0}}</text><text class="pf-kl">连续打卡</text></view>
</view>
<!-- 宠物名片墙:给别人看的(受开关控制)。「我的」页 hideWall 藏掉 -->
<scroll-view wx:if="{{card.show_pets && card.pets.length && !hideWall}}" class="pf-wall"
scroll-x="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view wx:for="{{card.pets}}" wx:key="id" class="pf-pet" data-id="{{item.id}}" bindtap="onPet">
<view class="pf-pet-av">
<image wx:if="{{item.avatar_url}}" class="pf-pet-img" src="{{item.avatar_url}}" mode="aspectFill"></image>
<block wx:else>{{item.emoji}}</block>
</view>
<view class="pf-pet-n">{{item.name}}</view>
<view class="pf-pet-s">{{item.stage}}</view>
</view>
</scroll-view>
<!-- 别人主页的关注键(装扮按钮已挪到「我的」页) -->
<view wx:if="{{!preview && !card.is_self}}" class="btn {{card.followed ? 'btn-ghost' : 'btn-dark'}} btn-block pf-follow" bindtap="onFollow">
<pt-icon wx:if="{{!card.followed}}" name="plus" size="{{28}}"></pt-icon>{{card.followed ? '已关注' : '关注 TA'}}
</view>
</view>
</view>
@@ -1,65 +0,0 @@
/* 主题只在这里定义两个变量,下面所有着色都走变量。
后端存的是 theme key 不是色值,就是为了这套配色能随时在前端改。 */
.pf{--pf-a:#F5A947;--pf-b:#FFE6BA;--pf-ink:#8A5A18}
.th-warm{--pf-a:#F5A947;--pf-b:#FFE6BA;--pf-ink:#8A5A18}
.th-mint{--pf-a:#3FB98A;--pf-b:#D7F3E7;--pf-ink:#1F6B4E}
.th-sky{--pf-a:#4A9BE0;--pf-b:#D8ECFB;--pf-ink:#245F94}
.th-violet{--pf-a:#9B7BE0;--pf-b:#E8DFFA;--pf-ink:#5B3F9B}
.th-ink{--pf-a:#5A5550;--pf-b:#E4DED7;--pf-ink:#2D2925}
.pf{
background:#fff;border-radius:var(--r-lg);overflow:hidden;
box-shadow:var(--sd-1);margin-bottom:var(--sp-5);
}
/* 头图:设了才显示 */
.pf-bg-img{width:100%;height:200rpx;display:block}
.pf-body{padding:var(--sp-5)}
/* 身份行:头像 + 昵称/签名 左对齐 */
.pf-top{display:flex;align-items:center;gap:var(--sp-4)}
.pf-av{
flex:none;width:120rpx;height:120rpx;border-radius:50%;
background:var(--pf-b);color:var(--pf-ink);overflow:hidden;
display:flex;align-items:center;justify-content:center;
font-size:var(--fs-xl);font-weight:var(--fw-b);
}
.pf-av-img{width:100%;height:100%;display:block}
.pf-id{flex:1;min-width:0}
.pf-name{font-size:var(--fs-xl);font-weight:var(--fw-b);color:var(--text)}
.pf-bio{
font-size:var(--fs-sm);color:var(--muted);margin-top:8rpx;
line-height:1.5;word-break:break-all;
}
/* 社交数:小一行,数字+标签内联,左对齐 */
.pf-social{display:flex;gap:var(--sp-6);margin-top:var(--sp-4)}
.pf-stat{display:flex;align-items:baseline;gap:6rpx}
.pf-n{font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)}
.pf-l{font-size:var(--fs-cap);color:var(--muted)}
/* 养宠数据高亮带 */
.pf-keep{
display:flex;background:var(--pf-b);border-radius:var(--r-md);
padding:var(--sp-4) 0;margin-top:var(--sp-4);
}
.pf-keep-i{flex:1;display:flex;flex-direction:column;align-items:center;gap:2rpx}
.pf-kn{font-size:var(--fs-lg);font-weight:var(--fw-b);color:var(--pf-ink)}
.pf-kl{font-size:var(--fs-cap);color:var(--pf-ink);opacity:.75}
.pf-wall{white-space:nowrap;margin-top:var(--sp-4)}
.pf-pet{
display:inline-block;width:150rpx;vertical-align:top;
margin-right:var(--sp-3);white-space:normal;
}
.pf-pet:last-child{margin-right:0}
.pf-pet-av{
width:112rpx;height:112rpx;margin:0 auto 8rpx;border-radius:var(--r-md);
background:var(--surface-2);display:flex;align-items:center;justify-content:center;
font-size:52rpx;overflow:hidden;
}
.pf-pet-img{width:100%;height:100%;display:block}
.pf-pet-n{font-size:var(--fs-cap);font-weight:var(--fw-b);color:var(--text);text-align:center}
.pf-pet-s{font-size:var(--fs-cap);color:var(--muted);text-align:center}
.pf-follow{margin-top:var(--sp-4)}
-59
View File
@@ -1,59 +0,0 @@
// 线性图标。图标资源在 styles/iconfont.wxss(mask-image + currentColor)。
//
// name 可以直接传图标名,也可以传服务端下发的值:
// - 记录类型(HealthRecord.Type,9 值枚举,可靠)
// - 文章分类(Article.Category)
// - 历史 emoji(老数据里 icon 字段存的就是 emoji,只能兜底映射)
// 三层都命不中时退到 fallback(默认 note),绝不留空白。
// 记录类型 / 文章分类 → 图标名
const BY_TYPE = {
weight: 'weight',
poop: 'poop',
food: 'food',
symptom: 'symptom',
medicine: 'medicine',
vaccine: 'vaccine',
deworm: 'deworm',
cost: 'cost',
photo: 'photo',
note: 'doc',
};
// 老数据里存的 emoji → 图标名。新数据不再写 emoji,但历史记录得能正常显示
const BY_EMOJI = {
'⚖️': 'weight', '⚖': 'weight',
'💩': 'poop',
'🍽️': 'food', '🍽': 'food', '🍚': 'food',
'🤒': 'symptom', '🌡️': 'symptom',
'💊': 'medicine',
'💰': 'cost', '💸': 'cost',
'📷': 'photo', '📸': 'photo',
'💉': 'vaccine',
'🛡️': 'deworm', '🛡': 'deworm',
'📄': 'doc', '📋': 'doc',
'📝': 'note', '✍️': 'note', '✍': 'note',
'🐱': 'cat', '🐶': 'dog', '🐾': 'community',
'🔔': 'bell', '⚙️': 'settings', '⚙': 'settings',
'📅': 'plan', '📊': 'report', '🏠': 'home',
'⚠️': 'warn', '📖': 'book', '📚': 'book',
};
Component({
options: { addGlobalClass: true, virtualHost: true },
properties: {
name: { type: String, value: '' },
size: { type: Number, value: 32 }, // rpx
color: { type: String, value: '' }, // 留空则跟随父级文字色
fallback: { type: String, value: 'note' },
},
data: { cls: '', style: '' },
observers: {
'name,size,color,fallback': function (name, size, color, fallback) {
const n = BY_TYPE[name] || BY_EMOJI[name] || name || fallback;
const style =
'width:' + size + 'rpx;height:' + size + 'rpx' + (color ? ';background-color:' + color : '');
this.setData({ cls: 'pt-i pt-i-' + n, style });
},
},
});
-4
View File
@@ -1,4 +0,0 @@
{
"component": true,
"usingComponents": {}
}
-1
View File
@@ -1 +0,0 @@
<view class="{{cls}}" style="{{style}}"></view>
-17
View File
@@ -1,17 +0,0 @@
/* 图标资源在组件内 import:这样 mask 定义和用它的节点在同一个样式作用域,
不受组件样式隔离影响,custom-tab-bar 这种没开 addGlobalClass 的地方也能直接用。 */
@import "../../styles/iconfont.wxss";
.pt-i {
display: inline-block;
flex: none;
background-color: currentColor;
-webkit-mask-image: var(--m);
mask-image: var(--m);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-position: center;
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
}
-38
View File
@@ -1,38 +0,0 @@
// 横向切换控件。此前有三套长得像但尺寸各不同的实现:
// plan 的 .inline-tabs(灰槽 + 白块)、community 的 .feed-tab(白药丸 + 橙选中 + 阴影)、
// record 的 .rf-chip(白药丸 + 橙选中 + 无阴影,高度还差 10rpx)
// 这里合成一个:variant="solid" 对应灰槽白块,variant="pill" 对应横滑药丸。
//
// items 支持两种写法:
// ['推荐','关注','经验'] —— current 传下标
// [{key:'',label:'全部'},{key:'weight',label:'体重'}] —— current 传 key
Component({
options: { addGlobalClass: true },
properties: {
items: { type: Array, value: [] },
current: { type: null, value: 0 },
variant: { type: String, value: 'pill' },
},
data: { list: [] },
observers: {
'items,current': function (items, current) {
const byKey = typeof current === 'string';
const list = (items || []).map((it, i) => {
const obj = it && typeof it === 'object';
const key = obj ? it.key : i;
return {
key,
label: obj ? it.label : it,
active: byKey ? key === current : i === Number(current),
};
});
this.setData({ list });
},
},
methods: {
onTap(e) {
const { index, key } = e.currentTarget.dataset;
this.triggerEvent('change', { index: Number(index), key });
},
},
});
-4
View File
@@ -1,4 +0,0 @@
{
"component": true,
"usingComponents": {}
}
-9
View File
@@ -1,9 +0,0 @@
<scroll-view wx:if="{{variant === 'pill'}}" class="st st-pill" scroll-x="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view wx:for="{{list}}" wx:key="key" class="st-item {{item.active ? 'on' : ''}}"
data-index="{{index}}" data-key="{{item.key}}" bindtap="onTap">{{item.label}}</view>
</scroll-view>
<view wx:else class="st st-solid">
<view wx:for="{{list}}" wx:key="key" class="st-item {{item.active ? 'on' : ''}}"
data-index="{{index}}" data-key="{{item.key}}" bindtap="onTap">{{item.label}}</view>
</view>
-21
View File
@@ -1,21 +0,0 @@
.st{white-space:nowrap}
/* 横滑药丸:数量不定、可滚动(社区分类、记录类型筛选) */
.st-pill{padding:0 0 var(--sp-4)}
.st-pill .st-item{
display:inline-block;height:var(--h-sm);line-height:var(--h-sm);padding:0 var(--sp-4);
margin-right:var(--sp-2);border-radius:var(--r-full);border:1rpx solid var(--line);
background:#fff;color:var(--muted);font-size:var(--fs-sm);font-weight:var(--fw-b);
}
.st-pill .st-item.on{color:var(--primary-dark);background:var(--primary-soft);border-color:var(--primary-line)}
/* 灰槽白块:数量固定的互斥视图(计划页三段) */
.st-solid{
display:flex;gap:var(--sp-1);padding:var(--sp-1);border-radius:var(--r-md);
background:#EFE8DE;margin-bottom:var(--sp-4);
}
.st-solid .st-item{
flex:1;height:var(--h-sm);line-height:var(--h-sm);text-align:center;border-radius:var(--r-sm);
color:var(--muted);font-size:var(--fs-md);font-weight:var(--fw-b);
}
.st-solid .st-item.on{background:#fff;color:var(--primary-dark)}
-41
View File
@@ -1,41 +0,0 @@
// 完成度环。用 SVG data-uri 画(和 record 页的体重折线图同一套路),不用 canvas:
// canvas 在 scroll-view 里的层级、滚动跟随和截图行为全是坑。
const TAU = 2 * Math.PI;
const R = 42; // viewBox 100 下的半径,留出描边宽度
function ringSvg(percent, stroke, track, fill) {
const c = TAU * R;
const p = Math.max(0, Math.min(100, Number(percent) || 0));
const off = c * (1 - p / 100);
const circle = (color, dash) =>
'<circle cx="50" cy="50" r="' + R + '" fill="none" stroke="' + color +
'" stroke-width="' + stroke + '" stroke-linecap="round"' + dash + '/>';
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">' +
circle(track, '') +
'<g transform="rotate(-90 50 50)">' +
circle(fill, ' stroke-dasharray="' + c.toFixed(1) + '" stroke-dashoffset="' + off.toFixed(1) + '"') +
'</g></svg>'
);
}
Component({
options: { addGlobalClass: true },
properties: {
percent: { type: Number, value: 0 },
size: { type: Number, value: 180 }, // rpx
stroke: { type: Number, value: 8 }, // viewBox 单位,即直径的百分比
track: { type: String, value: '#FFE6BA' },
fill: { type: String, value: '#F5A947' },
},
data: { src: '', boxStyle: '' },
observers: {
'percent,size,stroke,track,fill': function (percent, size, stroke, track, fill) {
const svg = ringSvg(percent, stroke, track, fill);
this.setData({
src: 'data:image/svg+xml,' + encodeURIComponent(svg),
boxStyle: 'width:' + size + 'rpx;height:' + size + 'rpx',
});
},
},
});
-4
View File
@@ -1,4 +0,0 @@
{
"component": true,
"usingComponents": {}
}
-4
View File
@@ -1,4 +0,0 @@
<view class="ring" style="{{boxStyle}}">
<image class="ring-img" src="{{src}}" mode="widthFix"></image>
<view class="ring-center"><slot></slot></view>
</view>
-6
View File
@@ -1,6 +0,0 @@
.ring{position:relative;flex:none}
.ring-img{width:100%;height:100%;display:block}
.ring-center{
position:absolute;left:0;top:0;right:0;bottom:0;
display:flex;flex-direction:column;align-items:center;justify-content:center;
}
+17
View File
@@ -0,0 +1,17 @@
const tabs = [
{ path: "/pages/home/home", label: "首页", icon: "⌂" },
{ path: "/pages/meetups/meetups", label: "约遛", icon: "♡" },
{ path: "/pages/walk/walk", label: "遛狗", icon: "🐾" },
{ path: "/pages/tasks/tasks", label: "任务", icon: "☑" },
{ path: "/pages/pets/pets", label: "宠物", icon: "♙" },
];
Component({
properties: { current: String },
data: { tabs },
methods: {
go(e) {
const path = e.currentTarget.dataset.path;
if (path !== this.data.current) wx.redirectTo({ url: path });
},
},
});
+1
View File
@@ -0,0 +1 @@
{ "component": true }
+12
View File
@@ -0,0 +1,12 @@
<view class="bar">
<view
wx:for="{{tabs}}"
wx:key="path"
class="item {{current===item.path?'active':''}} {{item.label==='遛狗'?'walk':''}}"
data-path="{{item.path}}"
bindtap="go"
>
<view class="icon">{{item.icon}}</view>
<text>{{item.label}}</text>
</view>
</view>
+47
View File
@@ -0,0 +1,47 @@
.bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: calc(124rpx + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom);
background: #fff;
display: flex;
justify-content: space-around;
align-items: center;
box-shadow: 0 -8rpx 35rpx rgba(32, 68, 50, 0.06);
z-index: 20;
}
.item {
width: 20%;
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
color: #9eafa4;
font-size: 21rpx;
font-weight: 600;
}
.item.active {
color: #174d40;
}
.icon {
font-size: 46rpx;
height: 52rpx;
line-height: 52rpx;
}
.walk .icon {
width: 82rpx;
height: 82rpx;
line-height: 82rpx;
text-align: center;
background: #174d40;
color: #fff;
border-radius: 27rpx;
box-shadow: 0 10rpx 23rpx rgba(24, 74, 59, 0.23);
margin-top: -30rpx;
font-size: 39rpx;
}
.walk.active .icon {
background: #eaaa69;
}
-33
View File
@@ -1,33 +0,0 @@
const LIST = [
{ pagePath: '/pages/home/home', text: '首页', icon: 'home' },
{ pagePath: '/pages/report/report', text: '报告', icon: 'report' },
{ pagePath: '/pages/community/community', text: '社区', icon: 'community' },
{ pagePath: '/pages/mine/mine', text: '我的', icon: 'user' },
];
Component({
data: { selected: 0, hidden: false, list: LIST },
lifetimes: {
attached() {
this.syncSelected();
},
},
methods: {
// 高亮由当前页面路径算出来,页面那边不写死 0/1/2/3/4,
// 调 tab 顺序只改上面的 LIST 就够了。
//
// 触发时机必须由页面 onShow 推(见 utils/tabbar.js):自定义 tabBar 不在
// 页面节点树里,组件自己的 pageLifetimes.show 拿不到可靠时机,实测切到
// 「我的」时高亮还留在「首页」。
syncSelected() {
const pages = getCurrentPages();
const cur = pages[pages.length - 1];
if (!cur) return;
const i = LIST.findIndex((t) => t.pagePath === '/' + cur.route);
if (i >= 0) this.setData({ selected: i });
},
switchTab(e) {
wx.switchTab({ url: LIST[e.currentTarget.dataset.index].pagePath });
},
},
});
-6
View File
@@ -1,6 +0,0 @@
{
"component": true,
"usingComponents": {
"pt-icon": "../components/pt-icon/index"
}
}
-7
View File
@@ -1,7 +0,0 @@
<view class="tabbar" hidden="{{hidden}}">
<view wx:for="{{list}}" wx:key="text" class="tab {{selected === index ? 'active' : ''}}"
data-index="{{index}}" bindtap="switchTab">
<pt-icon name="{{item.icon}}" size="{{40}}"></pt-icon>
<text class="tab-text">{{item.text}}</text>
</view>
</view>
-16
View File
@@ -1,16 +0,0 @@
.tabbar{
position:fixed;left:0;right:0;bottom:0;z-index:50;
height:calc(108rpx + env(safe-area-inset-bottom));
padding-bottom:calc(6rpx + env(safe-area-inset-bottom));
background:rgba(255,255,255,.94);
/* CSS 变量是继承属性,样式隔离只挡选择器匹配、不挡继承,所以这里能直接吃 page 上的 token */
border-top:1rpx solid var(--line,#EEE6DC);
display:flex;
backdrop-filter:blur(32rpx);
-webkit-backdrop-filter:blur(32rpx);
}
.tab{
flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8rpx;
color:var(--muted,#8D8277);font-size:24rpx;
}
.tab.active{color:var(--primary-dark,#DD8420);font-weight:600}
-170
View File
@@ -1,170 +0,0 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
const upload = require('../../utils/upload.js');
const recordTypes = require('../../utils/recordTypes.js');
const { toastErr } = require('../../utils/ui.js');
function pad2(n) {
return n < 10 ? '0' + n : '' + n;
}
function today() {
const d = new Date();
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
}
// "2026-07-30" → "2026.07.30"
function fmtDate(s) {
return (s || '').replace(/-/g, '.');
}
// 后端用 time.Parse(time.RFC3339) 解 occurred_at,只给日期它解不出来会静默回退成
// time.Now()——选「昨天」会存成今天且不报错。时区要带真实偏移:转成 UTC 的 Z 形式
// 落库再转回本地时会把边界日期挪一天。取正午同样是为了远离日界。
function dayToISO(day) {
if (!day) return '';
const off = -new Date().getTimezoneOffset();
const sign = off >= 0 ? '+' : '-';
const a = Math.abs(off);
return day + 'T12:00:00' + sign + pad2(Math.floor(a / 60)) + ':' + pad2(a % 60);
}
Page({
data: {
code: '',
type: {},
fields: [],
values: [], // 和 fields 同下标
pet: {},
date: '',
dateLabel: '',
note: '',
images: [],
saving: false,
},
onLoad(q) {
const code = (q && q.type) || '';
// 从首页「今日任务」勾选进来会带 taskId:这一笔记录保存后要把那条任务标完成
this.taskId = (q && q.taskId) || '';
const d = today();
this.setData({ code, date: d, dateLabel: fmtDate(d) });
store
.ready()
.then(() => recordTypes.load())
.then(() => this.applyType())
.catch((e) => toastErr(e));
},
applyType() {
const t = recordTypes.get(this.data.code);
if (!t) {
wx.showToast({ title: '这个记录类型不存在了', icon: 'none' });
return setTimeout(() => wx.navigateBack(), 800);
}
const fields = t.fields || [];
// 单选默认不预选:预选了用户不点也会存下一个他没确认过的值
this.setData({ type: t, fields, values: fields.map(() => ''), pet: store.getPet() || {} });
},
// 多宠时切换记到哪只身上。用 actionSheet 而不是自己画一个选择器
pickPet() {
const pets = store.getPets() || [];
if (pets.length < 2) return;
wx.showActionSheet({
itemList: pets.map((p) => p.name),
success: (r) => {
const p = pets[r.tapIndex];
store.switchPet(p.id);
this.setData({ pet: p });
},
fail: () => {},
});
},
onDate(e) {
this.setData({ date: e.detail.value, dateLabel: fmtDate(e.detail.value) });
},
onField(e) {
this.setData({ ['values[' + e.currentTarget.dataset.index + ']']: e.detail.value });
},
onPickOpt(e) {
const { index, val } = e.currentTarget.dataset;
// 再点一下取消选择——单选组里没有「不选」按钮,只能靠反选
this.setData({ ['values[' + index + ']']: this.data.values[index] === val ? '' : val });
},
onNote(e) {
this.setData({ note: e.detail.value });
},
onPickImage() {
upload
.chooseAndUploadImage()
.then((f) => this.setData({ images: this.data.images.concat([f]) }))
.catch((e) => {
if (e && e.canceled) return;
toastErr(e, '上传失败');
});
},
onRemoveImage(e) {
const imgs = this.data.images.slice();
imgs.splice(e.currentTarget.dataset.index, 1);
this.setData({ images: imgs });
},
// 按字段配置组装请求体。哪个字段落到 num_value / category 完全由后端的
// to_category / kind 决定,前端不认识「体重」「金额」这些业务词
buildBody() {
const { fields, values } = this.data;
const body = { type: this.data.code, occurred_at: dayToISO(this.data.date) };
const parts = [];
fields.forEach((f, i) => {
const v = (values[i] || '').toString().trim();
if (!v) return;
if (f.kind === 'number') {
const n = parseFloat(v);
if (!isNaN(n)) {
body.num_value = n;
parts.push(v + (f.unit || ''));
}
return;
}
if (f.to_category) body.category = v;
parts.push(v);
});
body.title = parts.length ? this.data.type.label + ':' + parts.join(' ') : this.data.type.label;
body.description = (this.data.note || '').trim();
const img = this.data.images[0];
if (img) {
body.image_file_id = img.id;
body.image_url = img.url;
}
return body;
},
// 必填校验:数值字段空着存下来是一条没有数值的体重记录,趋势图上是个洞
missingField() {
const { fields, values } = this.data;
for (let i = 0; i < fields.length; i++) {
if (fields[i].kind === 'number' && !(values[i] || '').toString().trim()) {
return fields[i].label;
}
}
return '';
},
onSave() {
if (this.data.saving) return;
const id = store.currentPetId();
if (!id) return wx.showToast({ title: '先建一份宠物档案', icon: 'none' });
const miss = this.missingField();
if (miss) return wx.showToast({ title: '请填写' + miss, icon: 'none' });
this.setData({ saving: true });
api
.createRecord(id, this.buildBody())
.then(() => {
// 从任务勾选进来的:记录存好后把那条任务标完成(失败不影响记录已保存)
if (this.taskId) return api.toggleTask(this.taskId).catch(() => {});
})
.then(() => {
this.setData({ saving: false });
wx.showToast({ title: this.taskId ? '已完成' : '已记录', icon: 'success' });
setTimeout(() => wx.navigateBack(), 600);
})
.catch((e) => {
this.setData({ saving: false });
toastErr(e, '保存失败');
});
},
});
-6
View File
@@ -1,6 +0,0 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"pt-icon": "/components/pt-icon/index"
}
}
-70
View File
@@ -1,70 +0,0 @@
<nav-bar title="添加{{type.label}}记录" show-back="{{true}}"></nav-bar>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body ar-body">
<!-- 宠物 + 时间。多宠家庭最容易记错到别的宠物身上,所以放第一行 -->
<view class="card ar-card">
<view class="ar-row" bindtap="pickPet">
<text class="ar-k">记录宠物</text>
<text class="ar-v">{{pet.name || '选择'}}</text>
<pt-icon name="next" size="{{26}}"></pt-icon>
</view>
<picker mode="date" value="{{date}}" bindchange="onDate">
<view class="ar-row ar-last">
<text class="ar-k">记录时间</text>
<text class="ar-v">{{dateLabel}}</text>
<pt-icon name="next" size="{{26}}"></pt-icon>
</view>
</picker>
</view>
<!-- 类型专属字段。整段由后端 record_types.fields 驱动,
前端不认识「体重」「金额」这些词,只认识 number / options / text -->
<view wx:if="{{fields.length}}" class="card ar-card">
<block wx:for="{{fields}}" wx:key="index">
<!-- 数值 -->
<view wx:if="{{item.kind === 'number'}}" class="ar-row {{index === fields.length - 1 ? 'ar-last' : ''}}">
<text class="ar-k">{{item.label}}</text>
<input class="ar-in" type="digit" placeholder="填写{{item.label}}" placeholder-class="placeholder"
value="{{values[index]}}" data-index="{{index}}" bindinput="onField"/>
<text wx:if="{{item.unit}}" class="ar-unit">{{item.unit}}</text>
</view>
<!-- 文本 -->
<view wx:elif="{{item.kind === 'text'}}" class="ar-row {{index === fields.length - 1 ? 'ar-last' : ''}}">
<text class="ar-k">{{item.label}}</text>
<input class="ar-in" placeholder="填写{{item.label}}" placeholder-class="placeholder"
value="{{values[index]}}" data-index="{{index}}" bindinput="onField"/>
</view>
<!-- 单选 -->
<view wx:else class="ar-opt-block {{index === fields.length - 1 ? 'ar-last' : ''}}">
<text class="ar-k">{{item.label}}</text>
<view class="ar-opts">
<view wx:for="{{item.options}}" wx:for-item="o" wx:key="*this"
class="ar-opt {{values[index] === o ? 'on' : ''}}"
data-index="{{index}}" data-val="{{o}}" bindtap="onPickOpt">{{o}}</view>
</view>
</view>
</block>
</view>
<view class="card ar-card">
<view class="ar-h">描述</view>
<textarea class="ar-ta" placeholder="请输入你想要记的内容~" placeholder-class="placeholder"
value="{{note}}" maxlength="500" bindinput="onNote"></textarea>
<view class="ar-media">
<view wx:for="{{images}}" wx:key="id" class="ar-thumb">
<image src="{{item.url}}" mode="aspectFill"></image>
<view class="ar-del" catchtap="onRemoveImage" data-index="{{index}}"><pt-icon name="close" size="{{22}}"></pt-icon></view>
</view>
<view wx:if="{{images.length < 3}}" class="ar-add" bindtap="onPickImage">
<pt-icon name="photo" size="{{40}}"></pt-icon>
</view>
</view>
</view>
</view>
</scroll-view>
<!-- 保存键固定在底部:表单长了以后不该滚到最下面才能保存 -->
<view class="ar-foot">
<view class="btn btn-primary btn-block {{saving ? 'ar-busy' : ''}}" bindtap="onSave">{{saving ? '保存中…' : '保存'}}</view>
</view>
-50
View File
@@ -1,50 +0,0 @@
/* 底部固定保存键,内容要让开它 */
.ar-body{padding-bottom:calc(180rpx + env(safe-area-inset-bottom))}
.ar-card{padding:0 var(--pad-x)}
.ar-row{
display:flex;align-items:center;gap:var(--sp-3);
min-height:112rpx;border-bottom:1rpx solid var(--line);
}
.ar-last,.ar-row:last-child{border-bottom:0}
.ar-k{flex:none;font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)}
.ar-v{flex:1;text-align:right;font-size:var(--fs-md);color:var(--text-2)}
.ar-in{flex:1;text-align:right;font-size:var(--fs-md);color:var(--text)}
.ar-unit{flex:none;font-size:var(--fs-sm);color:var(--muted)}
/* 单选独占一段:选项多的时候挤在一行右侧会换行成一团 */
.ar-opt-block{padding:var(--sp-4) 0;border-bottom:1rpx solid var(--line)}
.ar-opts{display:flex;flex-wrap:wrap;gap:var(--sp-2);margin-top:var(--sp-3)}
.ar-opt{
padding:var(--sp-2) var(--sp-4);border-radius:var(--r-full);
background:var(--surface-2);border:1rpx solid var(--line);
font-size:var(--fs-sm);color:var(--text-2);
}
.ar-opt.on{
background:var(--primary-soft);border-color:var(--primary);
color:var(--primary-ink);font-weight:var(--fw-b);
}
.ar-h{font-size:var(--fs-md);font-weight:var(--fw-b);padding:var(--sp-4) 0 var(--sp-2)}
.ar-ta{width:100%;min-height:200rpx;font-size:var(--fs-md);line-height:1.6}
.ar-media{display:flex;gap:var(--sp-3);padding:var(--sp-3) 0 var(--sp-4)}
.ar-thumb{position:relative;width:132rpx;height:132rpx;border-radius:var(--r-sm);overflow:hidden}
.ar-thumb image{width:100%;height:100%}
.ar-del{
position:absolute;top:0;right:0;width:40rpx;height:40rpx;
display:flex;align-items:center;justify-content:center;
background:rgba(0,0,0,.55);color:#fff;border-radius:0 var(--r-sm) 0 var(--r-sm);
}
.ar-add{
width:132rpx;height:132rpx;border-radius:var(--r-sm);
background:var(--surface-2);color:var(--muted2);
display:flex;align-items:center;justify-content:center;
}
.ar-foot{
position:fixed;left:0;right:0;bottom:0;z-index:50;
padding:var(--sp-4) var(--pad-x) calc(var(--sp-4) + env(safe-area-inset-bottom));
background:#fff;box-shadow:0 -12rpx 32rpx rgba(70,48,25,.06);
}
.ar-busy{opacity:.6}
-31
View File
@@ -1,31 +0,0 @@
const api = require('../../utils/api.js');
Page({
data: {
article: null,
loading: true,
error: '',
},
onLoad(options) {
const id = options.id;
if (!id) {
this.setData({ loading: false, error: '文章不存在' });
return;
}
api
.getArticle(id)
.then((a) => this.setData({ article: a, loading: false }))
.catch((e) => this.setData({ loading: false, error: e.message || '加载失败' }));
},
// 从文章跳到对应的记录入口
goRecord() {
wx.navigateTo({ url: '/pages/record/record' });
},
onShareAppMessage() {
const a = this.data.article;
return {
title: a ? a.title : '肉垫计划 · 新手养宠知识',
path: a ? `/pages/article/article?id=${a.id}` : '/pages/home/home',
};
},
});
-8
View File
@@ -1,8 +0,0 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"pt-icon": "/components/pt-icon/index"
},
"navigationStyle": "custom",
"navigationBarTitleText": "新手知识"
}
-24
View File
@@ -1,24 +0,0 @@
<nav-bar title="新手知识" show-back="{{true}}"></nav-bar>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body no-fab">
<view wx:if="{{loading}}" class="empty lg">加载中…</view>
<view wx:elif="{{error}}" class="empty lg">{{error}}</view>
<block wx:elif="{{article}}">
<view class="art-head">
<view class="art-icon"><pt-icon name="{{article.icon}}" size="{{56}}" fallback="book"></pt-icon></view>
<view class="art-title">{{article.title}}</view>
<view wx:if="{{article.description}}" class="art-desc">{{article.description}}</view>
</view>
<view class="card">
<view wx:if="{{article.content}}" class="art-content">{{article.content}}</view>
<view wx:else class="empty">这篇文章还没有正文。</view>
</view>
<button class="btn btn-primary btn-block" bindtap="goRecord">去记一笔</button>
<view class="art-foot">内容仅供日常照护参考,不能替代医生诊断。</view>
</block>
</view>
</scroll-view>
-15
View File
@@ -1,15 +0,0 @@
.art-head{padding:var(--sp-2) 0 var(--sp-4)}
.art-icon{
width:96rpx;height:96rpx;border-radius:var(--r-md);margin-bottom:var(--sp-3);
display:flex;align-items:center;justify-content:center;
background:var(--primary-soft);color:var(--primary-ink);
}
.art-title{font-size:var(--fs-2xl);font-weight:var(--fw-b);line-height:1.35;letter-spacing:-.5rpx}
.art-desc{margin-top:var(--sp-2);color:var(--muted);font-size:var(--fs-md);line-height:1.6}
.art-content{
font-size:var(--fs-md);line-height:1.85;color:var(--text-2);
white-space:pre-line; /* 正文用真实换行排版 */
}
.art-foot{margin-top:var(--sp-4);text-align:center;color:var(--muted);font-size:var(--fs-cap)}
-187
View File
@@ -1,187 +0,0 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
const { toastErr } = require('../../utils/ui.js');
const { syncTabBar } = require('../../utils/tabbar.js');
// 相对时间。社区里关心的是「多久以前发的」,精确到秒没意义
function fmtAgo(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const min = Math.floor((Date.now() - d.getTime()) / 60000);
if (min < 1) return '刚刚';
if (min < 60) return min + ' 分钟前';
if (min < 60 * 24) return Math.floor(min / 60) + ' 小时前';
if (min < 60 * 24 * 7) return Math.floor(min / 1440) + ' 天前';
const p = (n) => (n < 10 ? '0' + n : '' + n);
const sameYear = d.getFullYear() === new Date().getFullYear();
return (sameYear ? '' : d.getFullYear() + '-') + p(d.getMonth() + 1) + '-' + p(d.getDate());
}
const TAG_CLASS = { 求助: 'warn', 经验: 'blue', 精选: 'purple', 避坑: 'red', 晒宠: '' };
function mapPost(p) {
const tags = p.tags || [];
const tag = tags[0] || '';
return {
id: p.id,
author_name: p.author_name,
author_emoji: p.author_emoji || '🐾',
content: p.content,
images: p.images || [],
like_count: p.like_count,
comment_count: p.comment_count,
liked: false,
tag,
tagClass: TAG_CLASS[tag] || '',
is_ai: !!p.is_ai,
user_id: p.user_id,
followed: !!p.followed,
is_self: !!p.is_self,
timeText: fmtAgo(p.created_at),
};
}
Page({
data: {
pet: {},
feedTabs: ['推荐', '关注', '新手求助', '晒宠', '经验'],
feedIdx: 0,
posts: [],
sheetShow: false,
sheetType: '',
sheetPostId: '',
loaded: false,
loadErr: '',
intoView: '',
},
onLoad() {
this._unsub = store.subscribe((pet) => this.setData({ pet }));
},
onUnload() {
if (this._unsub) this._unsub();
},
onShow() {
syncTabBar(this);
store
.ready()
.then(() => {
this.setData({ pet: store.getPet() });
this.loadPosts();
})
.catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' }));
},
loadPosts() {
const tab = this.data.feedTabs[this.data.feedIdx];
this.setData({ loadErr: '' });
api
.listPosts(tab, 1)
.then((page) => this.setData({ posts: (page.list || []).map(mapPost), loaded: true }))
.catch((e) => {
// 不能让「加载失败」显示成「还没有帖子」,那是在骗用户
this.setData({ loadErr: e.message || '加载失败', loaded: true });
toastErr(e);
});
},
// 来自 seg-tabs 的 change 事件
switchFeedTab(e) {
// 切分类必须回到顶部:不然从「推荐」滚到一半切到「关注」,
// 看到的是新列表的中间,会以为内容错乱了
this.setData({ feedIdx: Number(e.detail.index), intoView: 'feed-top' });
this.loadPosts();
},
// 滚动时清掉锚点,否则下次再设同一个值不会触发
onFeedScroll() {
if (this.data.intoView) this.setData({ intoView: '' });
},
likePost(e) {
const i = e.currentTarget.dataset.index;
const post = this.data.posts[i];
api
.likePost(post.id)
.then((res) => {
this.setData({ [`posts[${i}].like_count`]: res.like_count, [`posts[${i}].liked`]: true });
})
.catch((e) => toastErr(e, '点赞失败'));
},
openComments(e) {
this.setData({ sheetPostId: e.currentTarget.dataset.id, sheetType: 'comments', sheetShow: true });
},
openSheet(e) {
this.setData({ sheetType: e.currentTarget.dataset.type, sheetPostId: '', sheetShow: true });
},
closeSheet() {
this.setData({ sheetShow: false });
},
onPosted() {
this.setData({ sheetShow: false });
this.loadPosts();
},
onCommented() {
this.loadPosts();
},
// 点头像或昵称进 TA 的主页。关注做了却没有主页,关注完就石沉大海
goUser(e) {
const id = e.currentTarget.dataset.id;
if (id) wx.navigateTo({ url: `/pages/user/user?id=${id}` });
},
goLearn() {
wx.navigateTo({ url: '/pages/learn/learn' });
},
// 社区的悬浮键本来就该是发帖,不是记录(原来它跳 AI 聊天页)
onCreatePost() {
this.setData({ sheetType: 'createPost', sheetShow: true });
},
// 点缩略图放大预览。只取 http 图(机器人帖的 emoji 占位不参与)
onPreviewImage(e) {
const { urls, cur } = e.currentTarget.dataset;
const list = (urls || []).filter((u) => typeof u === 'string' && u.indexOf('http') === 0);
if (!list.length) return;
wx.previewImage({ current: cur, urls: list });
},
// 长按自己的帖子可删除
onLongPressPost(e) {
const p = this.data.posts[e.currentTarget.dataset.index];
if (!p || !p.is_self) return;
wx.showModal({
title: '删除帖子',
content: '确定删除这条帖子?删除后其他人将看不到。',
confirmColor: '#D9534F',
success: (res) => {
if (!res.confirm) return;
api.deletePost(p.id)
.then(() => { wx.showToast({ title: '已删除', icon: 'success' }); this.loadPosts(); })
.catch((err) => wx.showToast({ title: err.message || '删除失败', icon: 'none' }));
},
});
},
// 关注 / 取关帖子作者
toggleFollow(e) {
const i = e.currentTarget.dataset.index;
const p = this.data.posts[i];
if (!p || !p.user_id || p.is_self) return;
const fn = p.followed ? api.unfollowUser : api.followUser;
fn(p.user_id)
.then(() => {
// 同一作者的所有帖子一起更新状态
const posts = this.data.posts.map((x) =>
x.user_id === p.user_id ? { ...x, followed: !p.followed } : x,
);
this.setData({ posts });
wx.showToast({ title: p.followed ? '已取消关注' : '已关注', icon: 'none' });
if (this.data.feedTabs[this.data.feedIdx] === '关注') this.loadPosts();
})
.catch((err) => wx.showToast({ title: err.message || '操作失败', icon: 'none' }));
},
onShareAppMessage(e) {
// 从帖子上的分享按钮转发:带上该帖内容做标题
if (e && e.from === 'button') {
const c = (e.target.dataset.content || '').slice(0, 40);
return { title: c ? '宠友圈:' + c : '来宠友圈看看大家的毛孩子', path: '/pages/community/community' };
}
return { title: '宠友圈 · 和铲屎官们交流养宠经验', path: '/pages/community/community' };
},
onShareTimeline() {
return { title: '宠友圈 · 和铲屎官们交流养宠经验' };
},
});
-9
View File
@@ -1,9 +0,0 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"bottom-sheet": "/components/bottom-sheet/bottom-sheet",
"fab": "/components/fab/fab",
"pt-icon": "/components/pt-icon/index",
"seg-tabs": "/components/seg-tabs/index"
}
}
-61
View File
@@ -1,61 +0,0 @@
<wxs module="im">
module.exports.isUrl = function (s) { return s && s.indexOf('http') === 0; }
</wxs>
<nav-bar title="宠友圈"></nav-bar>
<!-- 发帖统一走右下角悬浮按钮,顶部不再放发布按钮 -->
<view class="top-bar feed-bar">
<seg-tabs items="{{feedTabs}}" current="{{feedIdx}}" bind:change="switchFeedTab"></seg-tabs>
</view>
<scroll-view class="page-scroll" scroll-y="{{true}}" scroll-into-view="{{intoView}}" bindscroll="onFeedScroll" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body">
<view id="feed-top"></view>
<view wx:for="{{posts}}" wx:key="id" class="post-card" bindlongpress="onLongPressPost" data-index="{{index}}">
<view class="post-head">
<view class="post-avatar" catchtap="goUser" data-id="{{item.user_id}}">{{item.author_emoji}}</view>
<view class="post-user" catchtap="goUser" data-id="{{item.user_id}}">
<view class="pu-b">{{item.author_name}}<text wx:if="{{item.is_ai}}" class="ai-tag">AI</text></view>
<view class="pu-s">{{item.timeText}}</view>
</view>
<view wx:if="{{!item.is_self && item.user_id}}" class="follow-btn {{item.followed ? 'on' : ''}}"
catchtap="toggleFollow" data-index="{{index}}">{{item.followed ? '已关注' : '+ 关注'}}</view>
<view wx:if="{{item.tag}}" class="tag {{item.tagClass}}">{{item.tag}}</view>
</view>
<view class="post-content">{{item.content}}</view>
<view wx:if="{{item.images.length}}" class="photo-grid">
<block wx:for="{{item.images}}" wx:for-item="img" wx:key="*this">
<image wx:if="{{im.isUrl(img)}}" class="photo-tile" src="{{img}}" mode="aspectFill"
catchtap="onPreviewImage" data-urls="{{item.images}}" data-cur="{{img}}"></image>
<view wx:else class="photo-tile">{{img}}</view>
</block>
</view>
<view class="post-actions">
<view class="pa-btn {{item.liked ? 'liked' : ''}}" data-index="{{index}}" bindtap="likePost">
<pt-icon name="{{item.liked ? 'like-on' : 'like'}}" size="{{32}}"></pt-icon>
<text class="pa-n">{{item.like_count || '点赞'}}</text>
</view>
<view class="pa-btn" data-id="{{item.id}}" bindtap="openComments">
<pt-icon name="comment" size="{{32}}"></pt-icon>
<text class="pa-n">{{item.comment_count || '评论'}}</text>
</view>
<button class="pa-btn pa-share" open-type="share" data-content="{{item.content}}">
<pt-icon name="share" size="{{32}}"></pt-icon>
<text class="pa-n">分享</text>
</button>
</view>
</view>
<view wx:if="{{loadErr}}" class="empty lg">
{{loadErr}}
<view class="retry-btn" bindtap="loadPosts">点击重试</view>
</view>
<view wx:elif="{{loaded && posts.length === 0}}" class="empty lg">
{{feedTabs[feedIdx] === '关注' ? '还没有关注的人,去「推荐」里关注几个宠友吧 🐾' : '还没有帖子,来发第一条吧 🐾'}}
</view>
</view>
</scroll-view>
<fab icon="edit" bind:tap="onCreatePost"></fab>
<bottom-sheet show="{{sheetShow}}" type="{{sheetType}}" post-id="{{sheetPostId}}"
bind:close="closeSheet" bind:posted="onPosted" bind:commented="onCommented"></bottom-sheet>
-19
View File
@@ -1,19 +0,0 @@
.compose-card{margin-bottom:var(--sp-4)}
/* 关注按钮 */
.retry-btn{
margin-top:var(--sp-3);display:inline-block;padding:var(--sp-2) var(--sp-6);border-radius:var(--r-full);
background:var(--primary-soft);color:var(--primary-dark);font-weight:var(--fw-b);font-size:var(--fs-sm);
}
/* 分类标签吸顶,发布按钮收进同一条:原来那个大黑按钮独占 124rpx,
而它是低频操作,不该在每屏都占着首屏 */
.feed-bar{display:flex;align-items:flex-start;gap:var(--sp-3)}
.feed-bar .st{flex:1;min-width:0}
.feed-post{
flex:none;display:flex;align-items:center;gap:4rpx;
height:var(--h-sm);padding:0 var(--sp-3);border-radius:var(--r-full);
background:var(--cta);color:#fff;font-size:var(--fs-sm);font-weight:var(--fw-b);
}
-129
View File
@@ -1,129 +0,0 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
const { toastErr } = require('../../utils/ui.js');
// key 要和后端 service.UserThemes 一致,色值只在 profile-head 的 wxss 里
const THEMES = [
{ key: 'warm', label: '暖橙' },
{ key: 'mint', label: '薄荷' },
{ key: 'sky', label: '天青' },
{ key: 'violet', label: '藕紫' },
{ key: 'ink', label: '墨灰' },
];
Page({
data: {
themes: THEMES,
form: { bio: '', bg_url: '', bg_file_id: '', theme: 'warm', show_pets: true },
bioLen: 0,
preview: null,
saving: false,
},
onLoad() {
store
.ready()
.then(() => this.loadCard())
.catch((e) => toastErr(e));
},
loadCard() {
const uid = (store.getUser() || {}).id;
if (!uid) return;
api
.userCard(uid)
.then((card) => {
this.setData({
form: {
bio: card.bio || '',
bg_url: card.bg_url || '',
bg_file_id: '', // 没重新上传就不带这个字段,避免把已有头图覆盖成空
theme: card.theme || 'warm',
show_pets: card.show_pets !== false,
},
bioLen: (card.bio || '').length,
});
this._card = card;
this.syncPreview();
})
.catch((e) => toastErr(e));
},
// 预览喂给 profile-head 的就是一份改过的 card,组件那边不用知道自己在被预览
syncPreview() {
const f = this.data.form;
this.setData({
preview: Object.assign({}, this._card || {}, {
bio: f.bio,
bg_url: f.bg_url,
theme: f.theme,
show_pets: f.show_pets,
}),
});
},
pickBg() {
wx.chooseMedia({
count: 1,
mediaType: ['image'],
sizeType: ['compressed'],
success: (res) => {
const path = res.tempFiles[0].tempFilePath;
// 先用本地路径顶上,别让用户对着转圈等上传
this.setData({ 'form.bg_url': path });
this.syncPreview();
wx.showLoading({ title: '上传中…' });
api
.uploadFile(path)
.then((file) => {
wx.hideLoading();
this.setData({ 'form.bg_file_id': file.id, 'form.bg_url': file.url });
this.syncPreview();
})
.catch((e) => {
wx.hideLoading();
this.setData({ 'form.bg_url': (this._card && this._card.bg_url) || '' });
this.syncPreview();
toastErr(e, '上传失败');
});
},
});
},
clearBg() {
// 空字符串是「清掉头图」的显式信号,和「没传」不是一回事
this.setData({ 'form.bg_url': '', 'form.bg_file_id': '' });
this.syncPreview();
},
pickTheme(e) {
this.setData({ 'form.theme': e.currentTarget.dataset.key });
this.syncPreview();
},
onBio(e) {
this.setData({ 'form.bio': e.detail.value, bioLen: e.detail.value.length });
this.syncPreview();
},
onShowPets(e) {
this.setData({ 'form.show_pets': e.detail.value });
this.syncPreview();
},
save() {
if (this.data.saving) return;
const f = this.data.form;
const body = { bio: f.bio, theme: f.theme, show_pets: f.show_pets };
// 只有真的动过头图才带 bg_file_id:新上传带新 id,点了「恢复默认渐变」带空串,
// 两者都没有就不传,让后端保持原样
const had = (this._card && this._card.bg_url) || '';
if (f.bg_file_id) body.bg_file_id = f.bg_file_id;
else if (had && !f.bg_url) body.bg_file_id = '';
this.setData({ saving: true });
api
.updateDecoration(body)
.then((user) => {
this.setData({ saving: false });
store.setUser(user); // 返回的就是改完的 user,别再多请求一次
wx.showToast({ title: '已保存', icon: 'success' });
setTimeout(() => wx.navigateBack(), 600);
})
.catch((e) => {
this.setData({ saving: false });
toastErr(e, '保存失败');
});
},
});
-8
View File
@@ -1,8 +0,0 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"pt-icon": "/components/pt-icon/index",
"profile-head": "/components/profile-head/index"
},
"navigationBarTitleText": "装扮主页"
}
-53
View File
@@ -1,53 +0,0 @@
<nav-bar title="装扮主页" show-back="{{true}}"></nav-bar>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body no-fab">
<!-- 预览用的就是主页那个组件本身,不是仿一个。仿的迟早会和真的长得不一样 -->
<profile-head card="{{preview}}" preview="{{true}}"></profile-head>
<view class="card">
<view class="section-head"><view class="sh-title">头图</view></view>
<view class="dc-bg" bindtap="pickBg">
<image wx:if="{{form.bg_url}}" class="dc-bg-img" src="{{form.bg_url}}" mode="aspectFill"></image>
<view wx:else class="dc-bg-empty">
<pt-icon name="photo" size="{{48}}"></pt-icon>
<text class="dc-hint">点这里换一张头图</text>
</view>
</view>
<view wx:if="{{form.bg_url}}" class="link dc-clear" bindtap="clearBg">恢复默认渐变</view>
</view>
<view class="card">
<view class="section-head"><view class="sh-title">主题色</view></view>
<view class="dc-themes">
<view wx:for="{{themes}}" wx:key="key"
class="dc-theme {{form.theme === item.key ? 'on' : ''}}"
data-key="{{item.key}}" bindtap="pickTheme">
<view class="dc-swatch sw-{{item.key}}"></view>
<text class="dc-tname">{{item.label}}</text>
</view>
</view>
</view>
<view class="card">
<view class="section-head">
<view class="sh-title">个性签名</view>
<view class="dc-count">{{bioLen}}/60</view>
</view>
<textarea class="textarea" placeholder="介绍一下你和毛孩子" placeholder-class="placeholder"
value="{{form.bio}}" maxlength="60" bindinput="onBio"></textarea>
</view>
<view class="card">
<view class="dc-row">
<view class="dc-row-main">
<view class="tt-b">展示我的毛孩子</view>
<view class="tt-p">关掉后别人访问你的主页看不到宠物名片墙</view>
</view>
<switch checked="{{form.show_pets}}" color="#F5A947" bindchange="onShowPets"></switch>
</view>
</view>
<view class="btn btn-dark btn-block" bindtap="save">{{saving ? '保存中…' : '保存'}}</view>
</view>
</scroll-view>
-30
View File
@@ -1,30 +0,0 @@
.dc-bg{
height:240rpx;border-radius:var(--r-md);overflow:hidden;
background:var(--surface-2);display:flex;align-items:center;justify-content:center;
}
.dc-bg-img{width:100%;height:100%;display:block}
.dc-bg-empty{display:flex;flex-direction:column;align-items:center;gap:var(--sp-2);color:var(--muted)}
.dc-hint{font-size:var(--fs-cap)}
.dc-clear{margin-top:var(--sp-3);text-align:center}
.dc-themes{display:flex;gap:var(--sp-3)}
.dc-theme{
flex:1;display:flex;flex-direction:column;align-items:center;gap:8rpx;
padding:var(--sp-3) 0;border-radius:var(--r-sm);border:2rpx solid transparent;
}
.dc-theme.on{border-color:var(--primary);background:var(--primary-soft)}
.dc-swatch{width:64rpx;height:64rpx;border-radius:50%;box-shadow:var(--sd-1)}
.dc-tname{font-size:var(--fs-cap);color:var(--muted)}
.dc-theme.on .dc-tname{color:var(--primary-dark);font-weight:var(--fw-b)}
/* 色值和 profile-head 的主题保持一致,改那边这里也要跟着改 */
.sw-warm{background:linear-gradient(135deg,#F5A947,#FFE6BA)}
.sw-mint{background:linear-gradient(135deg,#3FB98A,#D7F3E7)}
.sw-sky{background:linear-gradient(135deg,#4A9BE0,#D8ECFB)}
.sw-violet{background:linear-gradient(135deg,#9B7BE0,#E8DFFA)}
.sw-ink{background:linear-gradient(135deg,#5A5550,#E4DED7)}
.dc-count{font-size:var(--fs-cap);color:var(--muted)}
.dc-row{display:flex;align-items:center;gap:var(--sp-4)}
.dc-row-main{flex:1;min-width:0}
-158
View File
@@ -1,158 +0,0 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
const recordTypes = require('../../utils/recordTypes.js');
const { toastErr } = require('../../utils/ui.js');
function pad2(n) {
return n < 10 ? '0' + n : '' + n;
}
function today() {
const d = new Date();
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
}
// 同 addrecord:后端用 time.Parse(time.RFC3339) 解 occurred_at,只给日期解不出来会
// 静默回退成 now;时区要带真实偏移,转 UTC 的 Z 形式落库会把边界日期挪一天
function dayToISO(day) {
if (!day) return '';
const off = -new Date().getTimezoneOffset();
const sign = off >= 0 ? '+' : '-';
const a = Math.abs(off);
return day + 'T12:00:00' + sign + pad2(Math.floor(a / 60)) + ':' + pad2(a % 60);
}
function fmtDay(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.getMonth() + 1 + '月' + d.getDate() + '日';
}
const PAGE = 50;
Page({
data: {
bill: {},
cats: [],
cat: '',
customOn: false,
customCat: '',
amount: '',
date: '',
note: '',
list: [],
saving: false,
},
onLoad() {
this.setData({ date: today() });
store
.ready()
.then(() => recordTypes.load())
.then(() => this.loadCats())
.catch((e) => toastErr(e));
},
onShow() {
if (this._inited) this.reload();
this._inited = true;
},
onReady() {
this.reload();
},
// 类别预置读 record_types 里 cost 的字段配置——后台改一处,
// 这页和 addrecord 同时生效,不会出现两套类别把账单统计切开
loadCats() {
const t = recordTypes.get('cost');
const opt = ((t && t.fields) || []).find((f) => f.kind === 'options');
this.setData({ cats: (opt && opt.options) || [] });
},
reload() {
const id = store.currentPetId();
if (!id) return;
api
.getBill(id, 'month')
.then((bill) => this.setData({ bill: bill || {} }))
.catch(() => {});
api
.getRecords(id, { type: 'cost', page: 1, pageSize: PAGE })
.then((res) => {
const now = new Date();
const list = (res.list || [])
// 只显示本月:接口按时间倒序返回,这里前端切一刀,
// 不为了「本月」再加一个后端参数
.filter((r) => {
const d = new Date(r.occurred_at);
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth();
})
.map((r) => ({ ...r, timeText: fmtDay(r.occurred_at) }));
this.setData({ list });
})
.catch(() => {});
},
onAmount(e) {
this.setData({ amount: e.detail.value });
},
onPickCat(e) {
const v = e.currentTarget.dataset.val;
this.setData({ cat: this.data.cat === v ? '' : v, customOn: false, customCat: '' });
},
toggleCustom() {
const on = !this.data.customOn;
this.setData({ customOn: on, cat: on ? '' : this.data.cat });
},
onCustomCat(e) {
this.setData({ customCat: e.detail.value });
},
onDate(e) {
this.setData({ date: e.detail.value });
},
onNote(e) {
this.setData({ note: e.detail.value });
},
onSave() {
if (this.data.saving) return;
const id = store.currentPetId();
if (!id) return wx.showToast({ title: '先建一份宠物档案', icon: 'none' });
const amt = parseFloat(this.data.amount);
if (!(amt > 0)) return wx.showToast({ title: '填一个金额', icon: 'none' });
const cat = (this.data.customOn ? this.data.customCat : this.data.cat).trim();
if (!cat) return wx.showToast({ title: '选一个类别', icon: 'none' });
this.setData({ saving: true });
// 落的是 type='cost' 的普通记录:金额进 num_value、类别进 category,
// 和 addrecord 走同一条路,报告页的账单聚合照样认
api
.createRecord(id, {
type: 'cost',
title: '记账:' + amt + '元 ' + cat,
num_value: amt,
category: cat,
description: (this.data.note || '').trim(),
occurred_at: dayToISO(this.data.date),
})
.then(() => {
this.setData({ saving: false, amount: '', note: '', customCat: '', customOn: false, cat: '' });
wx.showToast({ title: '已记下', icon: 'success' });
this.reload();
})
.catch((e) => {
this.setData({ saving: false });
toastErr(e, '保存失败');
});
},
// 长按删除。流水里记错一笔很常见,不给删的话账单就一直是错的
onDelete(e) {
const id = e.currentTarget.dataset.id;
wx.showModal({
title: '删掉这笔?',
content: '删了账单统计会跟着变。',
confirmColor: '#EE7D73',
success: (r) => {
if (!r.confirm) return;
api
.deleteRecord(id)
.then(() => this.reload())
.catch((err) => toastErr(err, '删除失败'));
},
});
},
onShareAppMessage() {
return { title: '养宠一个月花了多少?', path: '/pages/expense/expense' };
},
});
-6
View File
@@ -1,6 +0,0 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"pt-icon": "/components/pt-icon/index"
}
}
-71
View File
@@ -1,71 +0,0 @@
<nav-bar title="养宠花销" show-back="{{true}}"></nav-bar>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body ex-body">
<!-- 本月合计 + 分类占比。走 /bill 接口,和报告页的账单是同一份数据 -->
<view class="card ex-sum">
<view class="ex-sum-k">{{bill.period || '本月'}}花销</view>
<view class="ex-sum-n">¥{{bill.total || 0}}</view>
<view wx:if="{{bill.max_single}}" class="ex-sum-s">单笔最高 ¥{{bill.max_single}}</view>
<view wx:if="{{bill.categories.length}}" class="ex-cats">
<view wx:for="{{bill.categories}}" wx:key="category" class="ex-cat">
<view class="ex-cat-top"><text>{{item.category}}</text><text>¥{{item.amount}}</text></view>
<view class="bar"><view class="bar-fill" style="width:{{item.percent}}%"></view></view>
</view>
</view>
<view wx:else class="empty">这个月还没记过花销</view>
</view>
<!-- 记一笔 -->
<view class="card">
<view class="section-head"><view class="sh-title">记一笔</view></view>
<view class="ex-amt">
<text class="ex-cur">¥</text>
<input class="ex-in" type="digit" placeholder="0.00" placeholder-class="placeholder"
value="{{amount}}" bindinput="onAmount"/>
</view>
<view class="ex-label">类别</view>
<view class="ex-chips">
<view wx:for="{{cats}}" wx:key="*this"
class="ex-chip {{cat === item ? 'on' : ''}}" data-val="{{item}}" bindtap="onPickCat">{{item}}</view>
<!-- 自定义类别:预置那几个盖不住所有花法(寄养、驱虫药、玩具…) -->
<view class="ex-chip ex-chip-add {{customOn ? 'on' : ''}}" bindtap="toggleCustom">
<pt-icon name="plus" size="{{22}}"></pt-icon>自定义
</view>
</view>
<input wx:if="{{customOn}}" class="input ex-custom" placeholder="填一个类别名,比如 寄养"
placeholder-class="placeholder" value="{{customCat}}" bindinput="onCustomCat"/>
<view class="ex-label">日期</view>
<picker mode="date" value="{{date}}" bindchange="onDate">
<view class="picker-box">{{date}}</view>
</picker>
<view class="ex-label">备注</view>
<input class="input" placeholder="买了什么、在哪买的" placeholder-class="placeholder"
value="{{note}}" bindinput="onNote"/>
<view class="btn btn-primary btn-block ex-save {{saving ? 'ex-busy' : ''}}" bindtap="onSave">
{{saving ? '保存中…' : '记下这笔'}}
</view>
</view>
<!-- 本月流水 -->
<view class="card">
<view class="section-head">
<view class="sh-title">本月流水</view>
<view wx:if="{{list.length}}" class="tiny">{{list.length}} 笔</view>
</view>
<view wx:for="{{list}}" wx:key="id" class="ex-row" data-id="{{item.id}}" bindlongpress="onDelete">
<view class="ex-row-main">
<view class="tt-b">{{item.category || '未分类'}}</view>
<view class="tt-p">{{item.timeText}}{{item.description ? '|' + item.description : ''}}</view>
</view>
<view class="ex-row-amt">¥{{item.num_value}}</view>
</view>
<view wx:if="{{!list.length}}" class="empty">还没有流水。长按某笔可以删除。</view>
</view>
</view>
</scroll-view>
-34
View File
@@ -1,34 +0,0 @@
.ex-body{padding-bottom:var(--pad-b-plain)}
/* 合计卡:数字要够大,这页第一眼就该看到花了多少 */
.ex-sum{text-align:center}
.ex-sum-k{font-size:var(--fs-sm);color:var(--muted)}
.ex-sum-n{font-size:72rpx;font-weight:var(--fw-b);color:var(--text);line-height:1.2;margin:var(--sp-1) 0}
.ex-sum-s{font-size:var(--fs-cap);color:var(--muted2)}
.ex-cats{margin-top:var(--sp-5);text-align:left}
.ex-cat{margin-bottom:var(--sp-3)}
.ex-cat:last-child{margin-bottom:0}
.ex-cat-top{display:flex;justify-content:space-between;font-size:var(--fs-sm);color:var(--text-2);margin-bottom:6rpx}
/* 金额输入做大,和键盘上的数字对得上 */
.ex-amt{display:flex;align-items:baseline;gap:var(--sp-2);padding:var(--sp-4) 0 var(--sp-2);border-bottom:2rpx solid var(--primary-line)}
.ex-cur{font-size:var(--fs-xl);font-weight:var(--fw-b);color:var(--primary-dark)}
.ex-in{flex:1;font-size:64rpx;font-weight:var(--fw-b);color:var(--text);height:84rpx}
.ex-label{font-size:var(--fs-sm);color:var(--muted);margin:var(--sp-4) 0 var(--sp-2)}
.ex-chips{display:flex;flex-wrap:wrap;gap:var(--sp-2)}
.ex-chip{
padding:var(--sp-2) var(--sp-4);border-radius:var(--r-full);
background:var(--surface-2);border:1rpx solid var(--line);
font-size:var(--fs-sm);color:var(--text-2);
}
.ex-chip.on{background:var(--primary-soft);border-color:var(--primary);color:var(--primary-ink);font-weight:var(--fw-b)}
.ex-chip-add{display:inline-flex;align-items:center;gap:4rpx}
.ex-custom{margin-top:var(--sp-3)}
.ex-save{margin-top:var(--sp-5)}
.ex-busy{opacity:.6}
.ex-row{display:flex;align-items:center;gap:var(--sp-3);padding:var(--sp-3) 0;border-bottom:1rpx solid var(--line)}
.ex-row:last-of-type{border-bottom:0}
.ex-row-main{flex:1;min-width:0}
.ex-row-amt{flex:none;font-size:var(--fs-lg);font-weight:var(--fw-b);color:var(--text)}
-149
View File
@@ -1,149 +0,0 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
const { toastErr } = require('../../utils/ui.js');
const recordTypes = require('../../utils/recordTypes.js');
function pad(n) {
return n < 10 ? '0' + n : '' + n;
}
function fmtTime(iso) {
if (!iso) return '';
const d = new Date(iso);
const now = new Date();
const hm = pad(d.getHours()) + ':' + pad(d.getMinutes());
if (d.toDateString() === now.toDateString()) return '今天 ' + hm;
const y = new Date(now.getTime() - 86400000);
if (d.toDateString() === y.toDateString()) return '昨天 ' + hm;
return d.getMonth() + 1 + '月' + d.getDate() + '日';
}
// 每条记录的分组配色。这页只读历史,引一份轻量的 code→组 映射就够
function toneOf(type) {
const t = recordTypes.get(type);
return (t && t.tone) || 'tone-1';
}
function mapRecord(r) {
return {
id: r.id,
// 图标按 type 取(可靠);老数据 icon 字段里的 emoji 只作兜底
type: r.type || r.icon || '',
tone: toneOf(r.type),
title: r.title,
desc: fmtTime(r.occurred_at) + (r.description ? '|' + r.description : ''),
image: r.image_url || '',
};
}
// 时间轴类型筛选。常用的一组,不是全部 24 种——seg-tabs 放满会挤成一条难点的横条
const REC_FILTERS = [
{ key: '', label: '全部' },
{ key: 'weight', label: '体重' },
{ key: 'poop', label: '排便' },
{ key: 'food', label: '饮食' },
{ key: 'symptom', label: '异常' },
{ key: 'vaccine', label: '疫苗' },
{ key: 'cost', label: '记账' },
{ key: 'photo', label: '照片' },
];
const REC_PAGE = 8;
Page({
data: {
pet: {},
timeline: [],
recPage: 1,
recTotal: 0,
recHasMore: false,
recFilters: REC_FILTERS,
recFilterIdx: 0,
},
onLoad() {
this._unsub = store.subscribe((pet) => {
this.setData({ pet });
if (!this._inited) return;
this.loadRecords();
});
},
onUnload() {
if (this._unsub) this._unsub();
},
onShow() {
// 分组配色要读类型缓存。这页可能直接从报告页进来,缓存没经过首页热过,
// 自己拉一次(load 内部只真请求一次),拉完刷新已有列表的配色
recordTypes.load().then(() => this.setData({ timeline: this.data.timeline.map((t) => ({ ...t, tone: toneOf(t.type) })) })).catch(() => {});
store
.ready()
.then(() => {
this._inited = true;
this.setData({ pet: store.getPet() });
this.loadRecords();
})
.catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' }));
},
curFilter() {
return this.data.recFilters[this.data.recFilterIdx].key;
},
onFilterTap(e) {
const i = Number(e.detail.index);
if (i === this.data.recFilterIdx) return;
this.setData({ recFilterIdx: i }, () => this.loadRecords());
},
loadRecords() {
const id = store.currentPetId();
if (!id) return;
api
.getRecords(id, { page: 1, pageSize: REC_PAGE, type: this.curFilter() })
.then((res) => {
const list = (res.list || []).map(mapRecord);
this.setData({ timeline: list, recPage: 1, recTotal: res.total || 0, recHasMore: list.length < (res.total || 0) });
})
.catch((e) => toastErr(e));
},
loadMoreRecords() {
const id = store.currentPetId();
if (!id) return;
const next = this.data.recPage + 1;
api
.getRecords(id, { page: next, pageSize: REC_PAGE, type: this.curFilter() })
.then((res) => {
const merged = this.data.timeline.concat((res.list || []).map(mapRecord));
this.setData({ timeline: merged, recPage: next, recTotal: res.total || 0, recHasMore: merged.length < (res.total || 0) });
})
.catch(() => {});
},
collapseRecords() {
this.loadRecords();
},
onDeleteRecord(e) {
const r = this.data.timeline[e.currentTarget.dataset.index];
if (!r || !r.id) return;
wx.showModal({
title: '删除记录',
content: '确定删除「' + r.title + '」?删除后不可恢复。',
confirmColor: '#D9534F',
success: (res) => {
if (!res.confirm) return;
api
.deleteRecord(r.id)
.then(() => {
wx.showToast({ title: '已删除', icon: 'success' });
this.loadRecords();
})
.catch((err) => wx.showToast({ title: err.message || '删除失败', icon: 'none' }));
},
});
},
previewImage(e) {
const src = e.currentTarget.dataset.src;
if (src) wx.previewImage({ urls: [src], current: src });
},
onAddPet() {
wx.navigateTo({ url: '/pages/petform/petform' });
},
onShareAppMessage() {
return { title: '用肉垫计划记录毛孩子的成长', path: '/pages/record/record' };
},
onShareTimeline() {
return { title: '用肉垫计划记录毛孩子的成长' };
},
});
-8
View File
@@ -1,8 +0,0 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"pet-switch": "/components/pet-switch/pet-switch",
"pt-icon": "/components/pt-icon/index",
"seg-tabs": "/components/seg-tabs/index"
}
}
-36
View File
@@ -1,36 +0,0 @@
<nav-bar title="全部记录" show-back="{{true}}"></nav-bar>
<view class="top-bar on-band"><pet-switch bind:add="onAddPet"></pet-switch></view>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body no-fab">
<view wx:if="{{!pet.id}}" class="no-pet">
<view class="no-pet-ic"><pt-icon name="community" size="{{52}}"></pt-icon></view>
<view class="no-pet-b">还没有毛孩子的档案</view>
<view class="no-pet-p">建好档并记上几笔,这里就会长出完整的记录流水。</view>
<view class="btn btn-dark" bindtap="onAddPet"><pt-icon name="plus" size="{{30}}"></pt-icon>建一份档案</view>
</view>
<!-- 全部记录时间轴。洞察和体重趋势已经提到报告页,这里只做「翻流水 + 按类型筛」 -->
<block wx:else>
<seg-tabs items="{{recFilters}}" current="{{recFilterIdx}}" bind:change="onFilterTap"></seg-tabs>
<view class="card">
<view class="health-timeline">
<view wx:for="{{timeline}}" wx:key="id" class="health-event"
bindlongpress="onDeleteRecord" data-index="{{index}}">
<view class="event-dot {{item.tone}}"><pt-icon name="{{item.type}}" size="{{34}}" fallback="note"></pt-icon></view>
<view class="he-body">
<text class="he-b">{{item.title}}</text><view class="he-p">{{item.desc}}</view>
<image wx:if="{{item.image}}" class="he-img" src="{{item.image}}" mode="aspectFill" catchtap="previewImage" data-src="{{item.image}}"></image>
</view>
</view>
<view wx:if="{{timeline.length === 0}}" class="empty lg">这个分类下还没有记录</view>
</view>
<view wx:if="{{timeline.length}}" class="tl-tip">长按任意一条可删除</view>
<view wx:if="{{recHasMore}}" class="tl-more" bindtap="loadMoreRecords">查看更多(共 {{recTotal}} 条)</view>
<view wx:elif="{{recPage > 1}}" class="tl-more" bindtap="collapseRecords">收起</view>
</view>
</block>
</view>
</scroll-view>
-19
View File
@@ -1,19 +0,0 @@
/* 全部记录时间轴。洞察和体重趋势已经搬到报告页,这里只留流水 + 筛选 */
.health-timeline{display:flex;flex-direction:column;gap:var(--sp-2)}
.health-event{display:flex;gap:var(--sp-3);background:var(--surface-2);border-radius:var(--r-md);padding:var(--sp-4)}
/* 底色和文字色由 .tone-N 提供(记录类型决定),这里只管形状 */
.event-dot{
width:64rpx;height:64rpx;border-radius:var(--r-sm);flex:none;
display:flex;align-items:center;justify-content:center;
}
.he-b{font-size:var(--fs-md);font-weight:var(--fw-b)}
.he-p{color:var(--muted);font-size:var(--fs-sm);line-height:1.45;margin-top:6rpx}
.he-body{flex:1;min-width:0}
.he-img{margin-top:var(--sp-2);width:220rpx;height:220rpx;border-radius:var(--r-sm)}
.tl-tip{margin-top:var(--sp-3);text-align:center;color:var(--muted);font-size:var(--fs-cap)}
.tl-more{
margin-top:var(--sp-3);padding:var(--sp-3);text-align:center;color:var(--primary-dark);
background:var(--surface-2);border-radius:var(--r-md);font-size:var(--fs-sm);font-weight:var(--fw-b);
}
+53 -221
View File
@@ -1,236 +1,68 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
const { toastErr } = require('../../utils/ui.js');
const { syncTabBar } = require('../../utils/tabbar.js');
const recordTypes = require('../../utils/recordTypes.js');
const petmeta = require('../../utils/petmeta.js');
function mapTask(t) {
return { id: t.id, title: t.title, sub: t.description, priority: t.priority, done: t.done, sheet: t.sheet_type };
}
// 时间轴上的相对时间。首页关心的是「什么时候记的」,精确到分只在今天有意义
function fmtWhen(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const p = (n) => (n < 10 ? '0' + n : '' + n);
const now = new Date();
const sameDay = d.toDateString() === now.toDateString();
if (sameDay) return '今天 ' + p(d.getHours()) + ':' + p(d.getMinutes());
const y = new Date(now.getTime() - 86400000);
if (d.toDateString() === y.toDateString()) return '昨天 ' + p(d.getHours()) + ':' + p(d.getMinutes());
return d.getMonth() + 1 + '月' + d.getDate() + '日';
}
// 首页最近记录只是速览,固定最新几条,翻全量去「全部记录」页,避免首页无限滚
const TL_LIMIT = 6;
const { secure, toast } = require("../../utils/api");
Page({
data: {
pet: {},
zodiac: '',
daysTogether: 0,
sexIcon: '',
sexTone: '',
pets: [],
selected: [],
tasks: [],
planHint: '',
drift: {},
needNextPlan: false,
monthCost: 0,
timeline: [],
hasMore: false,
},
onLoad() {
this._unsub = store.subscribe((pet) => {
this.applyPet(pet);
if (!this._inited) return;
this.loadAll();
});
},
onUnload() {
if (this._unsub) this._unsub();
walkCount: 0,
name: "铲屎官",
loading: true,
},
onShow() {
syncTabBar(this);
recordTypes.load().catch(() => {});
store
.ready()
.then(() => {
this._inited = true;
this.applyPet(store.getPet());
this.loadAll();
})
.catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' }));
this.load();
},
// 门面上那几样都是本地算的,不用等接口
applyPet(pet) {
const p = pet || {};
const badge = petmeta.genderBadge(p.gender);
this.setData({
pet: p,
zodiac: petmeta.zodiac(p.birthday),
daysTogether: petmeta.daysTogether(p),
sexIcon: badge.icon,
sexTone: badge.tone,
});
async load() {
try {
const [pets, tasks, walks, me] = await Promise.all([
secure("/pets"),
secure("/tasks"),
secure("/walks"),
secure("/me"),
]);
const chosen = wx.getStorageSync("walk_pet_ids") || [],
month = new Date().toISOString().slice(0, 7);
this.setData({
pets,
selected: chosen.filter((id) => pets.some((p) => p.id === id)),
tasks: tasks.items,
walkCount: walks.filter((w) => w.started_at.slice(0, 7) === month)
.length,
name: me.nickname || "铲屎官",
loading: false,
});
} catch (e) {
this.setData({ loading: false });
toast(e);
}
},
loadAll() {
this.loadTasks();
this.loadTimeline(1);
this.loadEntries();
this.loadDrift();
pick(e) {
this.setData({ selected: e.detail.selected });
wx.setStorageSync("walk_pet_ids", e.detail.selected);
},
loadTasks() {
const id = store.currentPetId();
if (!id) return;
api
.getTasks(id)
.then((tasks) => this.setData({ tasks: (tasks || []).map(mapTask) }))
.catch((e) => toastErr(e));
},
// 两个入口上的副标题:计划还剩几件、本月花了多少。
// 空着的话入口就是两个没有信息量的图标
// 阶段漂移检查。放在 loadEntries 里而不是单独一次 —— 它和「计划还剩几件」
// 都是首页那一屏要的,一起拉省一个来回
loadDrift() {
const id = store.currentPetId();
if (!id) return;
api
.petStageDrift(id)
.then((d) => this.setData({ drift: d || {} }))
.catch(() => this.setData({ drift: {} }));
},
loadEntries() {
const id = store.currentPetId();
if (!id) return;
// 副标题优先说「该排下月计划了」——那是有时效的事;
// 本月还剩几件是随时能看的,让位给它
api
.planMonthStatus(id)
.then((st) => {
const s = st || {};
this.setData({
needNextPlan: !!s.need_next,
planHint: s.need_next
? (s.next_label || '下月') + '计划还没排'
: s.this_undone > 0
? '本月还有 ' + s.this_undone + ' 件'
: s.this_month > 0
? '本月都做完了'
: '还没有安排',
});
})
.catch(() => this.setData({ needNextPlan: false, planHint: '' }));
api
.getBill(id, 'month')
.then((b) => this.setData({ monthCost: (b && b.total) || 0 }))
.catch(() => this.setData({ monthCost: 0 }));
},
loadTimeline() {
const id = store.currentPetId();
if (!id) return;
api
.getRecords(id, { page: 1, pageSize: TL_LIMIT })
.then((res) => {
const list = (res.list || []).map((r) => ({
id: r.id,
type: r.type,
tone: (recordTypes.get(r.type) || {}).tone || 'tone-1',
title: r.title,
description: r.description,
image: r.image_url || '',
timeText: fmtWhen(r.occurred_at),
}));
// 有更多就露出「查看全部」入口,跳到能分页筛选的全部记录页
this.setData({ timeline: list, hasMore: (res.total || 0) > list.length });
})
.catch(() => {});
},
goHistory() {
wx.navigateTo({ url: '/pages/history/history' });
},
previewImage(e) {
const url = e.currentTarget.dataset.url;
if (url) wx.previewImage({ urls: [url], current: url });
},
onDeleteRecord(e) {
const id = e.currentTarget.dataset.id;
wx.showModal({
title: '删掉这条记录?',
content: '趋势和统计会跟着变。',
confirmColor: '#EE7D73',
success: (r) => {
if (!r.confirm) return;
api
.deleteRecord(id)
.then(() => this.loadTimeline(1))
.catch((err) => toastErr(err, '删除失败'));
},
});
},
onTapTask(e) {
const i = e.currentTarget.dataset.index;
const t = this.data.tasks[i];
// 任务关联了记录类型:去记那一笔。未完成的带上 taskId,
// 在记录页保存成功后才回填这条任务完成(onShow 回来自动刷新任务+最近记录)
if (t.sheet) {
let url = '/pages/addrecord/addrecord?type=' + t.sheet;
if (!t.done) url += '&taskId=' + t.id;
wx.navigateTo({ url });
start() {
if (!this.data.selected.length) {
wx.showToast({ title: "先选择一起出门的狗狗", icon: "none" });
return;
}
// 没有关联记录类型的任务(自定义/无 sheet_type):直接切换完成。
// 后端 toggle 会在完成时补一条 note 记录,这里完成后刷新最近记录即可
api
.toggleTask(t.id)
.then((res) => {
const tasks = this.data.tasks.slice();
tasks[i] = Object.assign({}, tasks[i], { done: res.done });
this.setData({ tasks });
if (res.done) this.loadTimeline(1);
})
.catch((err) => toastErr(err, '操作失败'));
wx.redirectTo({ url: "/pages/walk/walk" });
},
goPlan() {
wx.navigateTo({ url: '/pages/plan/plan' });
goTasks() {
wx.redirectTo({ url: "/pages/tasks/tasks" });
},
// 去建档页改阶段。改完回来 onShow 会重新拉一次漂移状态,提示自然消失。
// 不在首页直接改:改阶段之后要不要套新方案是同一件事,放在一起做才连贯
// 点「一起生活的天数」空态,去建档页补到家日期
onDaysTap() {
const id = this.data.pet && this.data.pet.id;
if (!this.data.daysTogether && id) wx.navigateTo({ url: '/pages/petform/petform?id=' + id });
goWalks() {
wx.redirectTo({ url: "/pages/walk/walk" });
},
// 身份卡:门面卡右上角进,用当前宠物
goIDCard() {
const id = this.data.pet && this.data.pet.id;
if (id) wx.navigateTo({ url: '/pages/idcard/idcard?id=' + id });
},
goStageUpdate() {
const id = this.data.pet && this.data.pet.id;
if (id) wx.navigateTo({ url: '/pages/petform/petform?id=' + id });
},
goExpense() {
wx.navigateTo({ url: '/pages/expense/expense' });
},
goRecord() {
wx.navigateTo({ url: '/pages/record/record' });
},
onQuickRecord() {
wx.navigateTo({ url: '/pages/record/record' });
},
onAddPet() {
wx.navigateTo({ url: '/pages/petform/petform' });
},
onHeroTap() {
const id = this.data.pet && this.data.pet.id;
wx.navigateTo({ url: '/pages/petform/petform' + (id ? '?id=' + id : '') });
},
onShareAppMessage() {
return { title: '肉垫计划 · 每天照顾好毛孩子', path: '/pages/home/home' };
},
onShareTimeline() {
return { title: '肉垫计划 · 每天照顾好毛孩子' };
async complete(e) {
const id = e.currentTarget.dataset.id,
task = this.data.tasks.find((t) => t.id === id);
try {
await secure("/tasks/" + id + "/completion", "PUT", {
date: new Date().toLocaleDateString("sv-SE"),
completed: !task.completed,
});
await this.load();
} catch (err) {
toast(err);
}
},
});
+1 -8
View File
@@ -1,8 +1 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"pet-switch": "/components/pet-switch/pet-switch",
"fab": "/components/fab/fab",
"pt-icon": "/components/pt-icon/index"
}
}
{ "usingComponents": { "tab-bar": "/components/tab-bar/index", "pet-picker": "/components/pet-picker/index" } }
+72 -105
View File
@@ -1,116 +1,83 @@
<nav-bar title="肉垫计划"></nav-bar>
<view class="top-bar on-band"><pet-switch bind:add="onAddPet"></pet-switch></view>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body">
<!-- 门面:宠物名片。点进去编辑档案 -->
<view class="card hero-card" bindtap="onHeroTap">
<view class="hero-row">
<view class="hero-av">
<image wx:if="{{pet.avatar_url}}" class="hero-av-img" src="{{pet.avatar_url}}" mode="aspectFill"></image>
<block wx:else>{{pet.emoji || '🐾'}}</block>
<view wx:if="{{sexIcon}}" class="hero-sex {{sexTone}}">{{sexIcon}}</view>
</view>
<view class="hero-main">
<view class="hero-title">{{pet.name || '还没有毛孩子'}}</view>
<view class="hero-meta">
<text wx:if="{{pet.breed}}">{{pet.breed}}</text>
<text wx:if="{{pet.age}}">{{pet.age}}</text>
<text wx:if="{{zodiac}}">{{zodiac}}</text>
</view>
</view>
<view wx:if="{{pet.id}}" class="hero-idcard" catchtap="goIDCard">
<pt-icon name="user" size="{{30}}"></pt-icon>身份卡
<view class="screen">
<view class="eyebrow">你好,今天也一起好好生活</view>
<view class="row top">
<view class="title">早安,{{name}} ☀️</view>
<image src="/assets/logo-corgi-v2.png" class="avatar" />
</view>
<view class="hero">
<view class="hero-copy">
<text class="hero-small">今日的主角</text>
<text class="hero-title">小短腿,出发吧!</text>
<text class="hero-meta">多只狗狗 · 每一天都是好日子</text>
<view class="hero-caption">● 陪伴它们的每一天,都值得记录</view>
</view>
<image src="/assets/corgi-family-five.png" mode="aspectFit" />
</view>
<view class="row heading">
<text class="section-title">带它们出去走走</text>
<text class="link" bindtap="goWalks">遛狗记录 ›</text>
</view>
<view class="card walk-card">
<view class="walk-intro">
<view class="dog-emoji">🐕</view>
<view>
<view class="walk-title">今天的风刚刚好</view>
<view class="muted">选择同行的狗狗,一起出门转转。</view>
</view>
</view>
<!-- 一起生活的天数从到家日算。没填到家日就显示「-」,点一下去补 -->
<view wx:if="{{pet.id}}" class="hero-days {{daysTogether ? '' : 'hero-days-empty'}}" catchtap="onDaysTap">
<block wx:if="{{daysTogether}}">我们一起生活的第 <text class="hd-n">{{daysTogether}}</text> 天</block>
<block wx:else>还没填到家日期,一起生活的天数显示 <text class="hd-n">-</text></block>
<view class="picker-label">这次带谁?可以多选</view>
<pet-picker pets="{{pets}}" selected="{{selected}}" bind:change="pick" />
<button class="primary start" bindtap="start">开始遛狗 →</button>
</view>
<view class="stats">
<view>
<text>本月遛狗</text>
<strong>
{{walkCount}}
<small>次</small>
</strong>
</view>
<view>
<text>一起出发</text>
<strong>
{{selected.length}}
<small>只</small>
</strong>
</view>
<view>
<text>今日任务</text>
<strong>
{{tasks.length}}
<small>项</small>
</strong>
</view>
</view>
<!-- 阶段漂移。系统只负责发现,换不换由用户点 —— 偷偷换会让他莫名发现
计划变了却找不到原因,而且已经排好的这个月不该被推翻 -->
<view wx:if="{{drift.drifted}}" class="card drift-card" bindtap="goStageUpdate">
<view class="drift-top">
<pt-icon name="trend" size="{{32}}"></pt-icon>
<view class="drift-b">{{pet.name}}已经 {{drift.age_label}},从{{drift.stored}}进入{{drift.computed}}了</view>
</view>
<view class="drift-p">{{drift.basis}}</view>
<view class="drift-focus">这个阶段的重点:{{drift.focus}}</view>
<view wx:if="{{drift.size}}" class="drift-size">{{drift.size}}</view>
<view class="btn btn-primary btn-block drift-cta">去更新阶段和方案</view>
<view class="row heading">
<text class="section-title">今天要做的事</text>
<text class="link" bindtap="goTasks">全部任务 ›</text>
</view>
<!-- 两个入口。竞品那一排是买商品/上豪车/领猫砂盆,是电商和广告位,去掉 -->
<view class="card entry-card">
<view class="entry" bindtap="goPlan">
<view class="entry-ic tone-1">
<pt-icon name="plan" size="{{48}}"></pt-icon>
<!-- 月底还没排下月计划时提醒。角标做在图标上而不是弹 toast:
每次进首页弹一下会很烦,而这件事不紧急、看见就行 -->
<view wx:if="{{needNextPlan}}" class="entry-dot"></view>
<view class="card tasks">
<view
wx:for="{{tasks}}"
wx:key="id"
class="task"
data-id="{{item.id}}"
bindtap="complete"
>
<view class="check {{item.completed?'done':''}}">
{{item.completed?'✓':''}}
</view>
<view class="entry-b">管理计划</view>
<view class="entry-p {{needNextPlan ? 'warn' : ''}}">{{planHint}}</view>
</view>
<view class="entry" bindtap="goExpense">
<view class="entry-ic tone-2"><pt-icon name="cost" size="{{48}}"></pt-icon></view>
<view class="entry-b">记花销</view>
<view class="entry-p">{{monthCost > 0 ? '本月 ¥' + monthCost : '还没记过'}}</view>
</view>
</view>
<!-- 今日任务 -->
<view wx:if="{{tasks.length}}" class="card">
<view class="section-head">
<view class="sh-title">今日任务</view>
<!-- 任务的增删改都在计划页 —— 合并之后首页这个「管理」不该再开一个弹层 -->
<view class="head-links">
<view class="link" bindtap="goPlan">管理</view>
<view class="task-icon">
{{item.category==='water'?'💧':item.category==='food'?'🥣':'🐾'}}
</view>
<view class="task-main">
<text class="task-name">{{item.title}}</text>
<text class="muted">{{item.time_of_day}}</text>
</view>
</view>
<view class="task-list">
<view wx:for="{{tasks}}" wx:key="id" class="task {{item.done ? 'done' : ''}}" data-index="{{index}}" bindtap="onTapTask">
<view class="circle"><pt-icon wx:if="{{item.done}}" name="check" size="{{22}}"></pt-icon></view>
<view class="task-text"><text class="tt-b">{{item.title}}</text><view wx:if="{{item.sub}}" class="tt-p">{{item.sub}}</view></view>
<view wx:if="{{item.priority}}" class="priority">{{item.priority}}</view>
</view>
<view wx:if="{{!tasks.length}}" class="empty">
今天还没有任务,给狗狗安排一件小事吧 🐾
</view>
</view>
<!-- 记录时间轴。首页原来是任务/日历/接下来的堆叠,看不到「我记了什么」;
日历挪回计划页了(它本来就属于那儿),这里换成时间轴 —— 首页应该是
一条能往下翻的流水,而不是一屏聚合卡 -->
<view class="tl-head">
<view class="sh-title">最近记录</view>
<view class="link" bindtap="goRecord">+ 记一笔</view>
</view>
<view wx:for="{{timeline}}" wx:key="id" class="tl-item">
<view class="tl-rail">
<view class="tl-dot {{item.tone}}"><pt-icon name="{{item.type}}" size="{{28}}" fallback="note"></pt-icon></view>
<view class="tl-line"></view>
</view>
<view class="tl-body">
<view class="tl-when">{{item.timeText}}</view>
<view class="tl-card" data-id="{{item.id}}" bindlongpress="onDeleteRecord">
<view class="tl-title">{{item.title}}</view>
<view wx:if="{{item.description}}" class="tl-desc">{{item.description}}</view>
<image wx:if="{{item.image}}" class="tl-img" src="{{item.image}}" mode="aspectFill"
data-url="{{item.image}}" catchtap="previewImage"></image>
</view>
</view>
</view>
<view wx:if="{{!timeline.length}}" class="card">
<view class="empty lg">还没有记录。点上面的「记一笔」开始,
体重趋势和健康洞察都会从这些记录里长出来。</view>
</view>
<view wx:if="{{hasMore}}" class="tl-more" bindtap="goHistory">查看全部记录 ›</view>
</view>
</scroll-view>
<fab paw bind:tap="onQuickRecord"></fab>
<tab-bar current="/pages/home/home" />
+165 -94
View File
@@ -1,102 +1,173 @@
/* 门面卡 */
.hero-card{
background:
radial-gradient(circle at 95% 16%, rgba(245,169,71,.22), transparent 32%),
linear-gradient(135deg,#FFFFFF,#FFF6E9);
.top {
margin-top: 10rpx;
}
/* 一周日期条 */
/* ===== 日期带(原计划页「日历」tab 并过来) ===== */
/* 某天的安排 */
.dd-title{font-weight:var(--fw-b);font-size:var(--fs-md);margin-bottom:var(--sp-3)}
/* ===== 接下来 ===== */
/* ===== 门面:宠物名片 ===== */
.hero-card{padding:var(--sp-5)}
.hero-row{display:flex;align-items:center;gap:var(--sp-4)}
.hero-av{
position:relative;flex:none;width:128rpx;height:128rpx;border-radius:50%;
background:var(--primary-soft);display:flex;align-items:center;justify-content:center;
font-size:60rpx;overflow:visible;
.avatar {
width: 70rpx;
height: 70rpx;
border-radius: 50%;
border: 6rpx solid white;
box-shadow: 0 5rpx 22rpx #174b3520;
}
.hero-av-img{width:100%;height:100%;border-radius:50%}
/* 性别徽章叠在头像右下:一个很省事但很出效果的细节 */
.hero-sex{
position:absolute;right:-4rpx;bottom:-4rpx;width:42rpx;height:42rpx;border-radius:50%;
border:4rpx solid #fff;color:#fff;font-size:22rpx;line-height:1;
display:flex;align-items:center;justify-content:center;
.hero {
height: 330rpx;
background: #194f42;
border-radius: 38rpx;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
padding: 35rpx;
color: white;
}
.hero-sex.male{background:#5B9BE8}
.hero-sex.female{background:#EE85A8}
.hero-main{flex:1;min-width:0}
.hero-title{font-size:var(--fs-xl);font-weight:var(--fw-b);color:var(--text)}
.hero-meta{display:flex;flex-wrap:wrap;gap:var(--sp-3);font-size:var(--fs-sm);color:var(--muted);margin-top:6rpx}
.hero-arrow{flex:none;color:var(--muted2);display:flex;align-items:center}
.hero-days{
margin-top:var(--sp-4);background:var(--primary-soft);color:var(--primary-ink);
border-radius:var(--r-md);text-align:center;padding:var(--sp-3);
font-size:var(--fs-md);font-weight:var(--fw-b);
.hero-copy {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
width: 62%;
}
.hd-n{font-size:var(--fs-lg);margin:0 4rpx}
/* ===== 两个入口 ===== */
.entry-card{display:flex;padding:var(--sp-4) var(--sp-3)}
.entry{flex:1;display:flex;flex-direction:column;align-items:center;gap:4rpx}
.entry-ic{
width:76rpx;height:76rpx;border-radius:var(--r-md);
display:flex;align-items:center;justify-content:center;margin-bottom:2rpx;
.hero-small {
color: #abd9b8;
font-size: 23rpx;
font-weight: 700;
}
.entry-b{font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)}
.entry-p{font-size:var(--fs-cap);color:var(--muted)}
/* ===== 记录时间轴 ===== */
.tl-head{display:flex;align-items:center;justify-content:space-between;margin:var(--sp-5) 0 var(--sp-3)}
.tl-item{display:flex;gap:var(--sp-3)}
.tl-rail{flex:none;width:56rpx;display:flex;flex-direction:column;align-items:center}
.tl-dot{
width:56rpx;height:56rpx;border-radius:50%;flex:none;
display:flex;align-items:center;justify-content:center;
.hero-title {
font-size: 43rpx;
font-weight: 800;
margin: 16rpx 0;
}
/* 竖线连起来才像一条时间轴;最后一条的线会被 body 的高度自然截断 */
.tl-line{flex:1;width:2rpx;background:var(--line);margin:6rpx 0}
.tl-item:last-of-type .tl-line{display:none}
.tl-body{flex:1;min-width:0;padding-bottom:var(--sp-4)}
.tl-when{font-size:var(--fs-cap);color:var(--muted);margin-bottom:6rpx}
.tl-card{background:#fff;border-radius:var(--r-md);box-shadow:var(--sd-1);padding:var(--sp-4)}
.tl-title{font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)}
.tl-desc{font-size:var(--fs-sm);color:var(--text-2);margin-top:6rpx;line-height:1.5}
.tl-img{width:100%;height:320rpx;border-radius:var(--r-sm);margin-top:var(--sp-3);display:block}
.tl-more{text-align:center;padding:var(--sp-4);font-size:var(--fs-sm);color:var(--muted)}
.entry-ic{position:relative}
.entry-dot{
position:absolute;top:-2rpx;right:-2rpx;width:18rpx;height:18rpx;
border-radius:50%;background:var(--red);border:3rpx solid #fff;
.hero-meta {
font-size: 23rpx;
color: #d9eadd;
}
.entry-p.warn{color:var(--red-ink);font-weight:var(--fw-b)}
/* 阶段漂移提示。用主色不用红色:这是「可以做一下」不是「出问题了」 */
.drift-card{border:2rpx solid var(--primary-line);background:var(--primary-soft)}
.drift-top{display:flex;align-items:flex-start;gap:var(--sp-2);color:var(--primary-dark)}
.drift-b{flex:1;min-width:0;font-size:var(--fs-md);font-weight:var(--fw-b);line-height:1.5}
.drift-p{font-size:var(--fs-sm);color:var(--primary-ink);line-height:1.6;margin-top:var(--sp-2)}
.drift-focus{font-size:var(--fs-sm);color:var(--primary-ink);line-height:1.6;margin-top:6rpx}
.drift-size{font-size:var(--fs-cap);color:var(--primary-dark);opacity:.8;margin-top:6rpx}
.drift-cta{margin-top:var(--sp-4)}
/* 没填到家日期时的天数空态:压成灰、可点去补 */
.hero-days-empty{background:var(--surface-2);color:var(--muted)}
.hero-days-empty .hd-n{color:var(--muted)}
/* 门面卡右上角的身份卡入口。原来那里是个 next 箭头(点整卡进编辑),
身份卡做成小胶囊,和整卡点击分开 */
.hero-idcard{
flex:none;align-self:flex-start;display:flex;align-items:center;gap:4rpx;
padding:8rpx var(--sp-3);border-radius:var(--r-full);
background:var(--primary-soft);color:var(--primary-dark);
font-size:var(--fs-cap);font-weight:var(--fw-b);
.hero-caption {
font-size: 20rpx;
color: #b5e2be;
margin-top: 36rpx;
}
.hero image {
position: absolute;
width: 310rpx;
height: 290rpx;
right: -24rpx;
bottom: 8rpx;
}
.heading {
margin-top: 8rpx;
}
.link {
font-size: 24rpx;
color: #398668;
font-weight: 700;
}
.walk-card {
padding: 26rpx;
}
.walk-intro {
display: flex;
gap: 22rpx;
align-items: center;
}
.dog-emoji {
width: 145rpx;
height: 145rpx;
border-radius: 26rpx;
background: #dfeedd;
display: grid;
place-items: center;
font-size: 72rpx;
}
.walk-title {
font-size: 31rpx;
font-weight: 800;
margin-bottom: 12rpx;
}
.walk-intro .muted {
font-size: 23rpx;
}
.picker-label {
font-size: 23rpx;
color: #7d9989;
margin: 27rpx 0 16rpx;
}
.start {
margin: 24rpx 0 0;
width: 100%;
}
.stats {
display: flex;
gap: 14rpx;
margin-top: 20rpx;
}
.stats > view {
flex: 1;
background: white;
border-radius: 28rpx;
padding: 25rpx 18rpx;
}
.stats > view:nth-child(2) {
background: #f7eadc;
}
.stats > view:nth-child(3) {
background: #e8efea;
}
.stats text {
display: block;
color: #8da49a;
font-size: 22rpx;
}
.stats strong {
display: block;
font-size: 40rpx;
margin-top: 12rpx;
}
.stats small {
font-size: 22rpx;
color: #94a69d;
}
.tasks {
padding: 12rpx 25rpx;
}
.task {
display: flex;
align-items: center;
gap: 17rpx;
padding: 22rpx 0;
border-bottom: 1rpx solid #edf1ed;
}
.task:last-child {
border: 0;
}
.check {
width: 40rpx;
height: 40rpx;
border: 3rpx solid #cfdfd2;
border-radius: 13rpx;
text-align: center;
line-height: 37rpx;
color: white;
}
.check.done {
background: #3b9164;
border-color: #3b9164;
}
.task-icon {
width: 65rpx;
height: 65rpx;
border-radius: 16rpx;
background: #f0f5ee;
display: grid;
place-items: center;
font-size: 34rpx;
}
.task-main {
display: flex;
flex-direction: column;
gap: 6rpx;
}
.task-name {
font-weight: 700;
}
.task-main .muted {
font-size: 22rpx;
}
-419
View File
@@ -1,419 +0,0 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
const { toastErr } = require('../../utils/ui.js');
// 卡片画布尺寸(逻辑像素)。真实导出会按 dpr 放大
const W = 620;
const H = 1480;
// 在 canvas 2d 上加载一张图(远程或本地),resolve 出可直接 drawImage 的 image 对象。
// 加载失败 resolve(null)——头像或小程序码缺一张不该让整张卡画不出来
function loadImage(canvas, src) {
return new Promise((resolve) => {
if (!src) return resolve(null);
const img = canvas.createImage();
img.onload = () => resolve(img);
img.onerror = () => resolve(null);
img.src = src;
});
}
Page({
data: {
petId: '',
card: null,
cardImg: '',
qrReady: false,
saving: false,
needPhone: false, // 没手机号:门槛,先验证才生成
phoneRefused: false, // 用户拒绝了授权
manualMode: false, // 手动填手机号(getPhoneNumber 唤不起时的兜底)
manualPhone: '',
},
onLoad(q) {
store
.ready()
.then(() => {
const id = (q && q.id) || store.currentPetId();
if (!id) {
wx.showToast({ title: '先选一只毛孩子', icon: 'none' });
return;
}
this.setData({ petId: id });
return api.petIDCard(id);
})
.then((card) => {
if (!card) return;
this.setData({ card, qrReady: !!card.qr_ready });
// 门槛:没手机号不生成,先引导用微信验证
if (!card.phone) {
this.setData({ needPhone: true });
return;
}
// 布局要等页面渲染完 canvas 节点才拿得到,放 nextTick
wx.nextTick(() => this.draw());
})
.catch((e) => toastErr(e));
},
draw() {
wx.createSelectorQuery()
.select('#idCanvas')
.fields({ node: true, size: true })
.exec((res) => {
const node = res && res[0] && res[0].node;
if (!node) return;
let dpr = 2;
try {
dpr = (wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync()).pixelRatio || 2;
} catch (e) {
dpr = 2;
}
this._dpr = dpr;
node.width = W * dpr;
node.height = H * dpr;
const ctx = node.getContext('2d');
ctx.scale(dpr, dpr);
const c = this.data.card || {};
// 两张远程图都就绪再开画,否则小程序码会画不上或画一半(plan 里点过名的坑)
Promise.all([loadImage(node, c.avatar_url), loadImage(node, c.qr_url)]).then(([avatar, qr]) => {
this.paint(ctx, node, c, avatar, qr);
});
});
},
paint(ctx, node, c, avatar, qr) {
ctx.clearRect(0, 0, W, H);
// 外层留白背景
ctx.fillStyle = '#F3EFE8';
ctx.fillRect(0, 0, W, H);
const pad = 30;
const cardW = W - pad * 2;
const fx = pad, cardW2 = cardW;
// ══════════ 正面卡(暖黄)══════════
// 正反两张卡等高(都用 cardH),中间内容垂直居中,避免正面留空
const cardH = 510;
const fH = cardH;
const fY = pad;
const gf = ctx.createLinearGradient(fx, fY, fx, fY + fH);
gf.addColorStop(0, '#FFF7E0');
gf.addColorStop(1, '#FFE7AE');
this.roundRect(ctx, fx, fY, cardW2, fH, 30);
ctx.fillStyle = gf;
ctx.fill();
// 头像圆 + 白边
const avD = 150, avX = fx + 36, avY = fY + 52;
ctx.save();
ctx.beginPath();
ctx.arc(avX + avD / 2, avY + avD / 2, avD / 2, 0, Math.PI * 2);
ctx.clip();
if (avatar) {
const iw = avatar.width || avD, ih = avatar.height || avD, side = Math.min(iw, ih);
ctx.drawImage(avatar, (iw - side) / 2, (ih - side) / 2, side, side, avX, avY, avD, avD);
} else {
ctx.fillStyle = '#FFD9A3';
ctx.fillRect(avX, avY, avD, avD);
ctx.fillStyle = '#8A5A18';
ctx.font = '64px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(c.species === '狗狗' ? '🐶' : '🐱', avX + avD / 2, avY + avD / 2);
}
ctx.restore();
ctx.textBaseline = 'alphabetic';
ctx.beginPath();
ctx.arc(avX + avD / 2, avY + avD / 2, avD / 2, 0, Math.PI * 2);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 8;
ctx.stroke();
// 名字 + 性别(头像右侧)。性别符号必须用「名字的字号」量宽度再定位,
// 否则换成小字号后 measureText 量出的宽度偏小,符号会怼到名字上
const nx = avX + avD + 28;
const nm = c.name || '毛孩子';
ctx.textAlign = 'left';
ctx.fillStyle = '#2D2925';
ctx.font = '700 52px sans-serif';
ctx.fillText(nm, nx, avY + 46);
const nmW = ctx.measureText(nm).width;
if (c.gender) {
const isF = c.gender === '女孩';
ctx.fillStyle = isF ? '#E86A9B' : '#5B9BE8';
ctx.font = '600 34px sans-serif';
ctx.fillText(isF ? '♀' : '♂', nx + nmW + 16, avY + 44);
}
// 头像右的资料行
const kvF = (label, value, yy) => {
ctx.textAlign = 'left';
ctx.font = '24px sans-serif';
ctx.fillStyle = '#A98C5A';
ctx.fillText(label, nx, yy);
ctx.fillStyle = '#3D3427';
ctx.font = '600 26px sans-serif';
ctx.fillText(value || '—', nx + 78, yy);
};
kvF('性别', c.gender, avY + 92);
kvF('品种', c.breed, avY + 130);
kvF('毛色', c.color, avY + 168);
kvF('年龄', c.age, avY + 206);
// 介绍 quote(在 info 和底栏之间垂直居中)
if (c.intro) {
const ty = fY + 300 + Math.max(0, Math.floor(((fY + fH - 92) - (fY + 300) - 60) / 2));
ctx.fillStyle = 'rgba(255,255,255,.6)';
this.roundRect(ctx, fx + 34, ty, cardW2 - 68, 60, 16);
ctx.fill();
ctx.fillStyle = '#7A5A2E';
ctx.font = '25px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('“ ' + c.intro + ' ”', fx + 54, ty + 40);
}
// 底部:身份ID + 一起生活天数(贴卡片底边)
ctx.textAlign = 'left';
ctx.fillStyle = '#9C7A3E';
ctx.font = '22px sans-serif';
ctx.fillText('ID ' + (c.id_no || ''), fx + 34, fY + fH - 56);
if (c.days > 0) {
ctx.fillText('和主人一起生活的第 ' + c.days + ' 天', fx + 34, fY + fH - 22);
}
// 正面印章(右上角)
this.drawStamp(ctx, fx + cardW2 - 78, fY + 82);
// ══════════ 照护卡(淡紫,仿证件版式)══════════
const bY = fY + fH + 40, bH = cardH;
const gbc = ctx.createLinearGradient(fx, bY, fx, bY + bH);
gbc.addColorStop(0, '#E6E7FB');
gbc.addColorStop(1, '#F2F0FA');
this.roundRect(ctx, fx, bY, cardW2, bH, 30);
ctx.fillStyle = gbc;
ctx.fill();
// 大标题(两行左对齐):小字「X星人地球移民」+ 大字「居民身份卡」
// 汪星人=狗、喵星人=猫,其它兜底宠星人
const star = c.species === '狗狗' ? '汪' : c.species === '猫猫' ? '喵' : '宠';
ctx.textAlign = 'left';
ctx.fillStyle = '#8A90AE';
ctx.font = '600 27px sans-serif';
this.spacedText(ctx, star + '星人地球移民', fx + 44, bY + 64, 8);
ctx.fillStyle = '#3E4A63';
ctx.font = '800 58px sans-serif';
ctx.fillText('居民身份卡', fx + 44, bY + 140);
// 资料行(标签 + 值)
const rowX = fx + 44;
const kvB = (label, value, yy) => {
ctx.textAlign = 'left';
ctx.font = '25px sans-serif';
ctx.fillStyle = '#9A9FB5';
ctx.fillText(label, rowX, yy);
ctx.font = '600 26px sans-serif';
ctx.fillStyle = '#3E4A63';
ctx.fillText(value || '—', rowX + 160, yy);
};
let ry = bY + 198;
kvB('主人', c.owner, ry); ry += 46;
kvB('联系电话', c.phone || '未验证', ry); ry += 46;
kvB('签发机构', c.org || '肉垫计划', ry); ry += 46;
kvB('有效期限', c.validity || '永久有效', ry);
// 小程序码(右下角,直接落在淡紫底上)
const qrD = 114, qrX = fx + cardW2 - 44 - qrD, qrY = bY + bH - 30 - qrD;
ctx.fillStyle = '#fff';
this.roundRect(ctx, qrX, qrY, qrD, qrD, 14);
ctx.fill();
if (qr) {
ctx.drawImage(qr, qrX + 9, qrY + 9, qrD - 18, qrD - 18);
} else {
ctx.fillStyle = '#A7ABC4';
ctx.font = '18px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('小程序码', qrX + qrD / 2, qrY + qrD / 2 - 2);
ctx.fillText('待发布', qrX + qrD / 2, qrY + qrD / 2 + 24);
}
// 扫码说明(左侧,和小程序码同高)
ctx.textAlign = 'left';
ctx.fillStyle = '#8A90AE';
ctx.font = '22px sans-serif';
ctx.fillText('微信扫一扫', fx + 44, qrY + qrD / 2 - 6);
ctx.fillText('查看' + (c.name || '') + '的身份信息', fx + 44, qrY + qrD / 2 + 28);
// 背面印章(右上角)
this.drawStamp(ctx, fx + cardW2 - 84, bY + 92);
// 只导出到实际内容底部,避免卡片下面拖一条空白
const dpr = this._dpr || 2;
const contentH = bY + bH + pad;
wx.canvasToTempFilePath(
{
canvas: node,
x: 0,
y: 0,
width: W,
height: contentH,
destWidth: W * dpr,
destHeight: contentH * dpr,
fileType: 'png',
success: (r) => this.setData({ cardImg: r.tempFilePath }),
fail: () => wx.showToast({ title: '生成失败,稍后再试', icon: 'none' }),
},
this,
);
},
roundRect(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();
},
// 逐字加字距地画一行(canvas 的 letterSpacing 各端支持不一,手动排最稳)
spacedText(ctx, text, x, y, gap) {
let cx = x;
for (const ch of text) {
ctx.fillText(ch, cx, y);
cx += ctx.measureText(ch).width + gap;
}
},
// 五角星路径(调用方负责 fill/stroke)
star(ctx, cx, cy, rO, rI, n) {
ctx.beginPath();
for (let i = 0; i < n * 2; i++) {
const r = i % 2 === 0 ? rO : rI;
const a = -Math.PI / 2 + (i * Math.PI) / n;
const x = cx + r * Math.cos(a), y = cy + r * Math.sin(a);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.closePath();
},
// 红色公章:双圈 + 顶部五角星 + 「肉垫计划 / 官方认证」,略微旋转、半透明
drawStamp(ctx, cx, cy) {
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(-0.16);
ctx.globalAlpha = 0.68;
ctx.strokeStyle = '#CE4534';
ctx.fillStyle = '#CE4534';
ctx.lineWidth = 4;
ctx.beginPath();
ctx.arc(0, 0, 52, 0, Math.PI * 2);
ctx.stroke();
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(0, 0, 43, 0, Math.PI * 2);
ctx.stroke();
this.star(ctx, 0, -23, 11, 4.6, 5);
ctx.fill();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = '700 25px sans-serif';
ctx.fillText('肉垫计划', 0, 10);
ctx.font = '13px sans-serif';
ctx.fillText('官方认证', 0, 32);
ctx.restore();
ctx.globalAlpha = 1;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
},
// 微信取手机号:新版 getPhoneNumber 返回动态 code,后端换成真实手机号存库,
// 拿回后重新取卡(此时电话就有了)并重画
onGetPhone(e) {
const d = e.detail || {};
if (!d.code) {
// 用户拒绝授权:不给生成,明确提示
this.setData({ phoneRefused: true });
return wx.showToast({ title: '没有手机号,无法生成身份卡', icon: 'none' });
}
wx.showLoading({ title: '验证中…', mask: true });
api
.bindPhone(d.code)
.then(() => api.petIDCard(this.data.petId))
.then((card) => {
wx.hideLoading();
if (!card) return;
this.setData({ card, qrReady: !!card.qr_ready, needPhone: false, phoneRefused: false });
wx.nextTick(() => this.draw());
wx.showToast({ title: '手机号已验证', icon: 'success' });
})
.catch((err) => {
wx.hideLoading();
toastErr(err, '验证失败');
});
},
// 手动填手机号兜底(getPhoneNumber 需企业认证,唤不起时用这个)
showManual() {
this.setData({ manualMode: true });
},
onManualPhone(e) {
this.setData({ manualPhone: e.detail.value });
},
submitManual() {
const p = (this.data.manualPhone || '').trim();
if (!/^1\d{10}$/.test(p)) return wx.showToast({ title: '手机号格式不对', icon: 'none' });
wx.showLoading({ title: '保存中…', mask: true });
api
.updateProfile({ phone: p })
.then(() => api.petIDCard(this.data.petId))
.then((card) => {
wx.hideLoading();
if (!card) return;
this.setData({ card, qrReady: !!card.qr_ready, needPhone: false, manualMode: false });
wx.nextTick(() => this.draw());
})
.catch((err) => {
wx.hideLoading();
toastErr(err, '保存失败');
});
},
onSave() {
if (!this.data.cardImg) return wx.showToast({ title: '还在生成,稍等一下', icon: 'none' });
if (this.data.saving) return;
this.setData({ saving: true });
wx.saveImageToPhotosAlbum({
filePath: this.data.cardImg,
success: () => wx.showToast({ title: '已保存到相册', icon: 'success' }),
fail: (err) => {
const msg = (err && err.errMsg) || '';
if (msg.indexOf('cancel') >= 0) return;
// 拒过一次后 authorize 不再弹,必须引导去设置页
if (msg.indexOf('auth deny') >= 0 || msg.indexOf('authorize') >= 0) {
wx.showModal({
title: '需要相册权限',
content: '保存身份卡需要允许访问相册,去设置里打开一下?',
confirmText: '去设置',
success: (r) => r.confirm && wx.openSetting({}),
});
return;
}
wx.showToast({ title: '保存失败', icon: 'none' });
},
complete: () => this.setData({ saving: false }),
});
},
onShareAppMessage() {
const c = this.data.card || {};
return {
title: `${c.name || '我家毛孩子'} 的居民身份卡`,
path: '/pages/idcard/idcard?id=' + this.data.petId,
imageUrl: this.data.cardImg || '', // 用画好的卡当分享封面
};
},
});
-6
View File
@@ -1,6 +0,0 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"pt-icon": "/components/pt-icon/index"
}
}
-43
View File
@@ -1,43 +0,0 @@
<nav-bar title="身份卡" show-back="{{true}}"></nav-bar>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body ic-body">
<!-- 手机号门槛:没验证不生成,先唤起微信取号 -->
<view wx:if="{{needPhone}}" class="ic-gate">
<view class="ic-gate-ic"><pt-icon name="user" size="{{72}}"></pt-icon></view>
<view class="ic-gate-t">生成身份卡需要手机号</view>
<view class="ic-gate-p">身份卡背面的「联系电话」用于走失时联系你,验证后才能生成</view>
<block wx:if="{{!manualMode}}">
<button wx:if="{{!phoneRefused}}" class="btn btn-primary btn-block ic-gate-btn" open-type="getPhoneNumber" bindgetphonenumber="onGetPhone">用微信验证手机号</button>
<view wx:else class="ic-gate-refuse">没有手机号,无法生成身份卡</view>
<view class="ic-gate-manual" bindtap="showManual">唤不起来?手动填写手机号</view>
</block>
<block wx:else>
<input class="input ic-gate-input" type="number" maxlength="11" placeholder="输入 11 位手机号" placeholder-class="placeholder" value="{{manualPhone}}" bindinput="onManualPhone"/>
<button class="btn btn-primary btn-block ic-gate-btn" bindtap="submitManual">保存并生成</button>
</block>
</view>
<!-- 画好的卡当成图片展示。canvas 本身是离屏的(挪到屏幕外),只用来生成图 -->
<block wx:else>
<view class="ic-stage">
<image wx:if="{{cardImg}}" class="ic-img" src="{{cardImg}}" mode="widthFix" show-menu-by-longpress="{{true}}"></image>
<view wx:else class="ic-loading">正在生成身份卡…</view>
</view>
<view class="ic-actions">
<view class="btn btn-soft ic-btn" bindtap="onSave">
<pt-icon name="photo" size="{{32}}"></pt-icon>下载到相册
</view>
<button class="btn btn-dark ic-btn" open-type="share">
<pt-icon name="share" size="{{32}}"></pt-icon>分享给好友
</button>
</view>
</block>
</view>
</scroll-view>
<!-- 离屏 canvas:挪到屏幕外,只用来画图导出 -->
<canvas type="2d" id="idCanvas" class="ic-canvas"></canvas>
-38
View File
@@ -1,38 +0,0 @@
.ic-body{padding-top:var(--sp-4)}
/* 画好的卡片图。宽度撑满、按比例高 */
.ic-stage{border-radius:var(--r-lg);overflow:hidden;box-shadow:var(--sd-2);background:#F3EFE8;min-height:600rpx}
.ic-img{width:100%;display:block}
.ic-loading{padding:200rpx 0;text-align:center;color:var(--muted);font-size:var(--fs-sm)}
.ic-tip{
margin-top:var(--sp-4);padding:var(--sp-3);border-radius:var(--r-sm);
background:var(--primary-soft);color:var(--primary-ink);
font-size:var(--fs-cap);line-height:1.6;text-align:center;
}
.ic-actions{display:flex;gap:var(--sp-3);margin-top:var(--sp-5)}
.ic-btn{flex:1}
/* button 元素默认有边框和外边距,压平成和 .btn 一样 */
button.ic-btn{padding:0;line-height:normal}
button.ic-btn::after{border:none}
/* 离屏 canvas:挪出可视区,只用来生成图 */
.ic-canvas{position:fixed;left:-9999rpx;top:0;width:620px;height:1480px}
/* 手机号门槛卡 */
.ic-gate{
background:#fff;border-radius:var(--r-lg);box-shadow:var(--sd-1);
padding:var(--sp-6) var(--sp-5);margin-top:var(--sp-5);text-align:center;
}
.ic-gate-ic{
width:140rpx;height:140rpx;margin:0 auto var(--sp-4);border-radius:50%;
background:var(--primary-soft);color:var(--primary);
display:flex;align-items:center;justify-content:center;
}
.ic-gate-t{font-size:var(--fs-lg);font-weight:var(--fw-b);color:var(--text)}
.ic-gate-p{font-size:var(--fs-sm);color:var(--muted);line-height:1.6;margin:var(--sp-2) 0 var(--sp-5)}
.ic-gate-refuse{color:var(--red-ink);font-weight:var(--fw-b);font-size:var(--fs-md)}
.ic-gate-btn{margin-top:var(--sp-2)}
.ic-gate-manual{margin-top:var(--sp-4);color:var(--muted);font-size:var(--fs-sm);text-decoration:underline}
.ic-gate-input{margin-bottom:var(--sp-3);text-align:center}
-43
View File
@@ -1,43 +0,0 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
Page({
data: {
articles: [],
sheetShow: false,
sheetType: '',
},
onLoad() {
store
.ready()
.then(() => api.listArticles())
.then((list) => {
this.setData({
articles: (list || []).map((a) => ({
id: a.id,
icon: a.icon,
title: a.title,
desc: a.description,
})),
});
})
.catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' }));
},
// 打开文章正文详情页
openArticle(e) {
const id = e.currentTarget.dataset.id;
if (id) wx.navigateTo({ url: `/pages/article/article?id=${id}` });
},
openSheet(e) {
this.setData({ sheetType: e.currentTarget.dataset.type, sheetShow: true });
},
closeSheet() {
this.setData({ sheetShow: false });
},
onShareAppMessage() {
return { title: '新手养宠知识合集', path: '/pages/learn/learn' };
},
onShareTimeline() {
return { title: '新手养宠知识合集' };
},
});
-8
View File
@@ -1,8 +0,0 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"bottom-sheet": "/components/bottom-sheet/bottom-sheet",
"fab": "/components/fab/fab",
"pt-icon": "/components/pt-icon/index"
}
}
-17
View File
@@ -1,17 +0,0 @@
<nav-bar title="新手知识" show-back="{{true}}"></nav-bar>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body no-fab">
<view class="page-title">
<view class="pt-p">看完顺手生成你的计划。</view>
</view>
<view wx:if="{{articles.length === 0}}" class="empty lg">还没有文章</view>
<view wx:for="{{articles}}" wx:key="id" class="article-card" data-id="{{item.id}}" bindtap="openArticle">
<view class="article-icon"><pt-icon name="{{item.icon}}" size="{{48}}" fallback="book"></pt-icon></view>
<view><text class="ac-b">{{item.title}}</text><view class="ac-p">{{item.desc}}</view></view>
</view>
</view>
</scroll-view>
<bottom-sheet show="{{sheetShow}}" type="{{sheetType}}" bind:close="closeSheet"></bottom-sheet>
+77
View File
@@ -0,0 +1,77 @@
const { secure, toast } = require("../../utils/api");
Page({
data: {
pets: [],
selected: [],
title: "",
description: "",
place_name: "",
latitude: 0,
longitude: 0,
date: "",
time: "",
max_people: 8,
},
onLoad() {
this.load();
},
async load() {
try {
this.setData({ pets: await secure("/pets") });
} catch (e) {
toast(e);
}
},
field(e) {
this.setData({ [e.currentTarget.dataset.key]: e.detail.value });
},
pick(e) {
this.setData({ selected: e.detail.selected });
},
date(e) {
this.setData({ date: e.detail.value });
},
time(e) {
this.setData({ time: e.detail.value });
},
async location() {
try {
const r = await wx.chooseLocation();
this.setData({
place_name: r.name || r.address,
latitude: r.latitude,
longitude: r.longitude,
});
} catch (e) {
toast(e);
}
},
async save() {
const d = this.data;
if (
!d.title.trim() ||
!d.place_name ||
!d.date ||
!d.time ||
!d.selected.length
) {
wx.showToast({ title: "请填完时间地点并选择狗狗", icon: "none" });
return;
}
try {
await secure("/meetups", "POST", {
title: d.title,
description: d.description,
place_name: d.place_name,
latitude: d.latitude,
longitude: d.longitude,
starts_at: new Date(d.date + "T" + d.time + ":00+08:00").toISOString(),
max_people: Number(d.max_people),
pet_ids: d.selected,
});
wx.navigateBack();
} catch (e) {
toast(e);
}
},
});
@@ -0,0 +1 @@
{ "usingComponents": { "pet-picker": "/components/pet-picker/index" } }
@@ -0,0 +1,53 @@
<view class="screen">
<view class="eyebrow">HOST A WALK</view>
<view class="title">发起约遛 🌿</view>
<view class="field">
<view class="field-label">活动名称</view>
<input
placeholder="周末一起逛公园"
value="{{title}}"
data-key="title"
bindinput="field"
/>
</view>
<view class="field">
<view class="field-label">时间</view>
<view class="row">
<picker mode="date" bindchange="date">
<view class="picker">{{date||'选择日期'}}</view>
</picker>
<picker mode="time" bindchange="time">
<view class="picker">{{time||'选择时间'}}</view>
</picker>
</view>
</view>
<view class="field">
<view class="field-label">集合地点</view>
<view class="picker" bindtap="location">
📍 {{place_name||'选择地图位置'}}
</view>
</view>
<view class="field">
<view class="field-label">人数上限</view>
<input
type="number"
value="{{max_people}}"
data-key="max_people"
bindinput="field"
/>
</view>
<view class="field">
<view class="field-label">介绍一下</view>
<textarea
placeholder="聊聊这次约遛安排"
value="{{description}}"
data-key="description"
bindinput="field"
/>
</view>
<view class="field">
<view class="field-label">带哪些狗狗?可以多选</view>
<pet-picker pets="{{pets}}" selected="{{selected}}" bind:change="pick" />
</view>
<button class="primary" bindtap="save">发布约遛 →</button>
</view>
@@ -0,0 +1,13 @@
.picker {
background: white;
border-radius: 20rpx;
padding: 23rpx;
min-width: 260rpx;
}
.field .row {
gap: 15rpx;
justify-content: flex-start;
}
.field textarea {
padding: 20rpx;
}
+71
View File
@@ -0,0 +1,71 @@
const { secure, toast } = require("../../utils/api");
Page({
data: { meetups: [], pets: [], selected: [], active: null, userId: "" },
onShow() {
this.load();
},
async load() {
try {
const [meetups, pets, me] = await Promise.all([
secure("/meetups"),
secure("/pets"),
secure("/me"),
]);
this.setData({
meetups: meetups.map((m) => ({
...m,
date: new Date(m.starts_at).toLocaleString("zh-CN"),
})),
pets,
userId: me.id,
});
} catch (e) {
toast(e);
}
},
create() {
wx.navigateTo({ url: "/pages/meetup-edit/meetup-edit" });
},
open(e) {
this.setData({
active: this.data.meetups[e.currentTarget.dataset.index],
selected: [],
});
},
close() {
this.setData({ active: null });
},
pick(e) {
this.setData({ selected: e.detail.selected });
},
async join() {
if (!this.data.selected.length) {
wx.showToast({ title: "请选择同行的狗狗", icon: "none" });
return;
}
try {
await secure("/meetups/" + this.data.active.id + "/join", "POST", {
pet_ids: this.data.selected,
});
wx.showToast({ title: "报名成功", icon: "success" });
this.close();
this.load();
} catch (e) {
toast(e);
}
},
async cancel() {
const r = await wx.showModal({
title: "取消约遛",
content: "确定取消这场约遛吗?",
});
if (!r.confirm) return;
try {
await secure("/meetups/" + this.data.active.id + "/cancel", "POST");
this.close();
this.load();
} catch (e) {
toast(e);
}
},
});

Some files were not shown because too many files have changed in this diff Show More