feat: add multi-source breed encyclopedia and two-hour refresh sessions
ci-cd / verify (push) Successful in 1m59s
ci-cd / deploy (push) Skipped

This commit is contained in:
2026-10-05 13:38:24 +08:00
parent 607d709100
commit 9d7cf720a5
73 changed files with 25771 additions and 264 deletions
+2
View File
@@ -18,3 +18,5 @@
4. 用另一个微信账号报名约遛,确认已报名和退出状态。
小程序源码约 600 KB,低于微信主包 2 MB 限制。真机调试前请确认小程序后台已配置 `pet.sundynix.cn` 为合法请求域名。
底部导航顺序:日常、任务、遛狗、宠物、知识。遛狗页面内切换轨迹记录和约遛;知识提供犬种 A–Z 搜索与百科详情,宠物表单支持选品种或手动填写。访问与刷新 token 均为两小时,到期前自动续期,刷新失效时重新微信登录。
+23 -8
View File
@@ -1,15 +1,26 @@
App({
globalData: { token: "", user: null },
globalData: { token: "", user: null, expiresAt: 0 },
onLaunch() {
this.globalData.token = wx.getStorageSync("pet_token") || "";
this.globalData.expiresAt = Number(wx.getStorageSync("pet_token_expires_at")) || 0;
// Start immediately; pages await the same promise and display failures themselves.
this.login().catch(() => {});
},
async login() {
if (this.loginPromise) return this.loginPromise;
if (this.globalData.token) return this.globalData.token;
if (this.globalData.token && (!this.globalData.expiresAt || this.globalData.expiresAt > Date.now() / 1000 + 60)) return this.globalData.token;
this.loginPromise = (async () => {
const { request } = require("./utils/api");
const refreshToken = wx.getStorageSync("pet_refresh_token");
if (refreshToken) {
try {
const pair = await request("/auth/refresh", "POST", { refresh_token: refreshToken }, false);
return this.saveSession(pair);
} catch (error) {
if (error.status !== 401 && error.status !== 403) throw error;
wx.removeStorageSync("pet_refresh_token");
}
}
const { code } = await new Promise((resolve, reject) =>
wx.login({
timeout: 10000,
@@ -19,16 +30,20 @@ App({
);
if (!code) throw new Error("微信登录未取得凭证,请重新连接");
const data = await request("/auth/login", "POST", { code }, false);
if (!data || typeof data.token !== "string" || !data.token) {
throw new Error("登录未完成,请重新连接");
}
this.globalData.token = data.token;
this.globalData.user = data.user;
wx.setStorageSync("pet_token", data.token);
return data.token;
return this.saveSession(data);
})().finally(() => {
this.loginPromise = null;
});
return this.loginPromise;
},
saveSession(data) {
if (!data || typeof data.token !== "string" || !data.token) throw new Error("登录未完成,请重新连接");
this.globalData.token = data.token;
this.globalData.expiresAt = Number(data.expires_at) || 0;
wx.setStorageSync("pet_token", data.token);
wx.setStorageSync("pet_token_expires_at", this.globalData.expiresAt);
if (data.refresh_token) wx.setStorageSync("pet_refresh_token", data.refresh_token);
return data.token;
},
});
+3 -2
View File
@@ -1,14 +1,15 @@
{
"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",
"pages/walk-records/walk-records"
"pages/walk-records/walk-records",
"pages/knowledge/knowledge",
"pages/breed-detail/breed-detail"
],
"window": {
"navigationStyle": "custom",
+88
View File
@@ -0,0 +1,88 @@
const { loadBreeds, groupBreeds } = require("../../utils/breeds");
Component({
properties: {
value: { type: String, value: "" },
breedId: { type: String, value: "" },
},
data: {
visible: false,
loading: false,
error: "",
query: "",
groups: [],
count: 0,
anchor: "",
manual: false,
custom: "",
},
methods: {
async open() {
this.setData({
visible: true,
query: "",
manual: false,
custom: this.data.value,
});
await this.load();
},
close() {
this.setData({ visible: false });
},
noop() {},
async load(event) {
if (this.data.loading) return;
this.setData({ loading: true, error: "" });
try {
this.rows = await loadBreeds(!!event);
this.filter();
} catch (error) {
this.setData({
error: error.message || "品种列表暂不可用,仍可手动填写",
});
} finally {
this.setData({ loading: false });
}
},
filter() {
const groups = groupBreeds(this.rows || [], this.data.query);
this.setData({
groups,
count: groups.reduce((n, g) => n + g.items.length, 0),
});
},
search(e) {
this.setData({ query: e.detail.value, anchor: "" });
this.filter();
},
jump(e) {
this.setData({ anchor: e.currentTarget.dataset.anchor });
},
select(e) {
const { id, name } = e.currentTarget.dataset;
this.triggerEvent("change", { id, name });
this.close();
},
enterManual() {
this.setData({
manual: true,
custom: this.data.query || this.data.value,
});
},
customInput(e) {
this.setData({ custom: e.detail.value });
},
useCustom() {
const name = this.data.custom.trim();
if (!name) {
wx.showToast({ title: "请输入品种名称", icon: "none" });
return;
}
this.triggerEvent("change", { id: null, name });
this.close();
},
clear() {
this.triggerEvent("change", { id: null, name: "" });
this.close();
},
},
});
@@ -0,0 +1 @@
{"component":true}
@@ -0,0 +1,27 @@
<view class="breed-field" bindtap="open" aria-role="button" aria-label="选择或填写狗狗品种"><text class="{{value?'':'placeholder'}}">{{value||'选择品种,也可以手动填写'}}</text><view class="chevron"></view></view>
<root-portal wx:if="{{visible}}"><view class="overlay" catchtap="close" catchtouchmove="noop">
<view class="sheet" catchtap="noop">
<view class="sheet-head"><view><text class="title">{{manual?'填写它的品种':'找到它的品种'}}</text><text class="subtitle">{{manual?'混种、地方犬种也都有自己的名字':'按拼音 A–Z 排序,支持名称、别名和拼音搜索'}}</text></view><view class="close" bindtap="close" aria-role="button" aria-label="关闭">×</view></view>
<block wx:if="{{!manual}}">
<input class="search" value="{{query}}" bindinput="search" placeholder="搜品种,比如:柯基、泰迪、keji" maxlength="128" />
<view wx:if="{{loading}}" class="hint">正在寻找毛孩子的品种…</view>
<view wx:elif="{{error}}" class="hint"><text>{{error}}</text><text class="retry" bindtap="load">重新加载</text></view>
<view wx:else class="catalog">
<scroll-view class="breed-list" scroll-y enhanced show-scrollbar="{{false}}" scroll-into-view="{{anchor}}" scroll-with-animation>
<view wx:for="{{groups}}" wx:for-item="group" wx:key="letter" id="{{group.anchor}}"><view class="letter">{{group.letter}}</view><view wx:for="{{group.items}}" wx:key="id" class="breed-row {{breedId===item.id?'selected':''}}" data-id="{{item.id}}" data-name="{{item.name}}" bindtap="select"><text>{{item.name}}</text><text wx:if="{{breedId===item.id}}" class="selected-label">已选</text></view></view>
<view wx:if="{{!count}}" class="hint">没有找到?可以在下方手动填写</view>
</scroll-view>
<view wx:if="{{!query}}" class="letter-rail"><text wx:for="{{groups}}" wx:key="letter" data-anchor="{{item.anchor}}" bindtap="jump">{{item.letter}}</text></view>
</view>
<view class="footer"><button class="manual-button" bindtap="enterManual">没找到品种?手动填写</button><text class="skip" bindtap="clear">暂不填写</text></view>
</block>
<block wx:else>
<input class="custom-input" value="{{custom}}" bindinput="customInput" placeholder="例如:中华田园犬、柯基混种" maxlength="128" focus="{{true}}" />
<text class="manual-note">填写的名称仅用于这只宠物的档案。</text>
<button class="confirm" bindtap="useCustom">使用这个名称</button>
<view class="back" bindtap="open">返回品种列表</view>
</block>
</view>
</view>
</root-portal>
@@ -0,0 +1,30 @@
:host { display:block; }
.breed-field { box-sizing:border-box;display:flex;align-items:center;justify-content:space-between;gap:18rpx;min-height:104rpx;padding:26rpx;background:#fff;border-radius:20rpx;color:#604a40;font-size:28rpx; }
.breed-field text { flex:1;min-width:0; }
.placeholder { color:#999; }
.chevron { width:12rpx;height:12rpx;border-right:3rpx solid #a48d7e;border-bottom:3rpx solid #a48d7e;transform:rotate(45deg);flex:none; }
.overlay { position:fixed;top:0;bottom:0;left:0;right:0;z-index:100;background:#604a4066;display:flex;align-items:flex-end; }
.sheet { width:100%;box-sizing:border-box;padding:30rpx 32rpx calc(env(safe-area-inset-bottom) + 24rpx);border-radius:36rpx 36rpx 0 0;background:#fff9f3; }
.sheet-head { display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:24rpx;gap:12rpx; }
.title { display:block;font-size:32rpx;font-weight:600;color:#604a40; }
.subtitle { display:block;font-size:21rpx;color:#9d8779;margin-top:8rpx; }
.close { font-size:40rpx;color:#9d8779;line-height:1;padding:8rpx; }
.search,.custom-input { box-sizing:border-box;width:100%;height:92rpx;padding:0 24rpx;border-radius:20rpx;background:#fff;color:#604a40;font-size:26rpx; }
.catalog { position:relative;margin-top:16rpx; }
.breed-list { height:48vh; }
.letter { font-size:24rpx;font-weight:600;color:#b95738;padding:14rpx 20rpx;background:#fff0e2;border-radius:12rpx;margin:10rpx 38rpx 6rpx 0; }
.breed-row { padding:23rpx 46rpx 23rpx 20rpx;font-size:26rpx;color:#604a40;display:flex;gap:12rpx;justify-content:space-between;border-bottom:1rpx solid #eee3da; }
.selected { color:#b95738;background:#fff2e6; }
.selected-label { font-size:21rpx;flex:none; }
.letter-rail { position:absolute;top:0;right:-12rpx;bottom:0;display:flex;flex-direction:column;justify-content:center;width:40rpx; }
.letter-rail text { font-size:19rpx;color:#b95738;text-align:center;line-height:1.25; }
.hint { padding:60rpx 24rpx;text-align:center;color:#9d8779;font-size:24rpx; }
.retry { display:block;margin-top:20rpx;color:#b95738; }
.footer { margin-top:20rpx; }
button { border:0; }
button::after { border:0; }
.manual-button,.confirm { border-radius:20rpx;background:#fff0e2;color:#b95738;font-size:26rpx;line-height:1.4;padding:22rpx; }
.skip,.back { display:block;text-align:center;font-size:23rpx;color:#9d8779;margin-top:20rpx;padding:6rpx; }
.manual-note { display:block;font-size:23rpx;color:#9d8779;margin:20rpx 0 30rpx; }
.confirm { background:#b95738;color:#fff; }
::-webkit-scrollbar { display:none;width:0;height:0; }
+143
View File
@@ -0,0 +1,143 @@
const { secure, toast } = require("../../utils/api");
Component({
options: { styleIsolation: "apply-shared" },
lifetimes: {
attached() {
this.load();
},
},
pageLifetimes: {
show() {
this.load();
},
},
data: {
meetups: [],
pets: [],
selected: [],
active: null,
userId: "",
joined: false,
busy: false,
},
methods: {
async load() {
if (this.loading) return;
this.loading = true;
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);
} finally {
this.loading = false;
}
},
create() {
wx.navigateTo({ url: "/pages/meetup-edit/meetup-edit" });
},
async open(e) {
const summary = this.data.meetups[e.currentTarget.dataset.index];
try {
const active = await secure("/meetups/" + summary.id);
const participant = (active.people || []).find(
(person) => person.user_id === this.data.userId,
);
this.setData({
active: {
...active,
date: new Date(active.starts_at).toLocaleString("zh-CN"),
},
joined: !!participant,
selected: participant ? participant.pet_ids : [],
});
} catch (error) {
toast(error);
}
},
close() {
this.setData({ active: null, joined: false, selected: [] });
},
pick(e) {
this.setData({ selected: e.detail.selected });
},
async join() {
if (this.data.busy) return;
if (!this.data.selected.length) {
wx.showToast({ title: "请选择同行的狗狗", icon: "none" });
return;
}
this.setData({ busy: true });
try {
await secure("/meetups/" + this.data.active.id + "/join", "POST", {
pet_ids: this.data.selected,
});
wx.showToast({ title: "报名成功", icon: "success" });
this.close();
await this.load();
} catch (e) {
toast(e);
} finally {
this.setData({ busy: false });
}
},
async leave() {
if (this.data.busy) return;
const result = await wx.showModal({
title: "退出约遛",
content: "确定不参加这场约遛了吗?",
});
if (!result.confirm) return;
this.setData({ busy: true });
try {
await secure("/meetups/" + this.data.active.id + "/join", "DELETE");
this.close();
await this.load();
} catch (error) {
toast(error);
} finally {
this.setData({ busy: false });
}
},
location() {
const active = this.data.active;
if (active && active.latitude && active.longitude) {
wx.openLocation({
latitude: active.latitude,
longitude: active.longitude,
name: active.place_name,
scale: 16,
});
}
},
async cancel() {
if (this.data.busy) return;
const r = await wx.showModal({
title: "取消约遛",
content: "确定取消这场约遛吗?",
});
if (!r.confirm) return;
this.setData({ busy: true });
try {
await secure("/meetups/" + this.data.active.id + "/cancel", "POST");
this.close();
await this.load();
} catch (e) {
toast(e);
} finally {
this.setData({ busy: false });
}
},
},
});
@@ -1,7 +1,6 @@
{
"disableScroll": true,
"component": true,
"usingComponents": {
"tab-bar": "/components/tab-bar/index",
"pet-picker": "/components/pet-picker/index"
}
}
@@ -1,4 +1,3 @@
<page-layout id="page-layout" title="约个伴一起遛">
<view class="screen">
<view class="banner">
<view class="banner-copy">
@@ -35,8 +34,7 @@
</view>
<button class="primary" bindtap="create">+ 发起一场约遛</button>
</view>
</page-layout>
<view wx:if="{{active}}" class="overlay">
<root-portal wx:if="{{active}}"><view class="overlay">
<view class="sheet">
<view class="row">
<strong>{{active.title}}</strong>
@@ -57,4 +55,6 @@
<button wx:else class="secondary" bindtap="cancel" disabled="{{busy}}">取消这场约遛</button>
</view>
</view>
<tab-bar current="/pages/meetups/meetups" />
</root-portal>
+2 -2
View File
@@ -1,9 +1,9 @@
const tabs = [
{ path: "/pages/home/home", label: "日常", key: "daily" },
{ path: "/pages/meetups/meetups", label: "约遛", key: "meetups" },
{ path: "/pages/walk/walk", label: "遛狗", key: "walk" },
{ path: "/pages/tasks/tasks", label: "任务", key: "tasks" },
{ path: "/pages/walk/walk", label: "遛狗", key: "walk" },
{ path: "/pages/pets/pets", label: "宠物", key: "pets" },
{ path: "/pages/knowledge/knowledge", label: "知识", key: "knowledge" },
];
Component({
properties: { current: String },
+1 -4
View File
@@ -25,10 +25,7 @@
<view class="daily-rays"></view>
<view class="daily-sun"></view>
</view>
<view wx:elif="{{item.key==='meetups'}}" class="friends-icon">
<view class="friend friend-left"></view>
<view class="friend friend-right"></view>
</view>
<view wx:elif="{{item.key==='knowledge'}}" class="book-icon"><view class="book-spine"></view><view class="book-line"></view></view>
<view wx:elif="{{item.key==='tasks'}}" class="task-icon"><view class="task-tick"></view></view>
<view wx:else class="pet-icon"><app-icon kind="pet" size="44" color="{{current===item.path?'#b95738':'#9d8779'}}" /></view>
</view>
+3 -26
View File
@@ -101,32 +101,9 @@
background: #fff;
}
.active .daily-sun { background: #fff0e2; }
.friends-icon {
position: relative;
width: 48rpx;
height: 45rpx;
}
.friend {
position: absolute;
bottom: 2rpx;
width: 25rpx;
height: 16rpx;
border: 4rpx solid currentColor;
border-radius: 18rpx 18rpx 5rpx 5rpx;
}
.friend::before {
content: "";
position: absolute;
top: -25rpx;
left: 3rpx;
width: 13rpx;
height: 13rpx;
border: 4rpx solid currentColor;
border-radius: 50%;
}
.friend-left { left: 1rpx; }
.friend-right { right: 1rpx; background: #fff; }
.active .friend-right { background: #fff0e2; }
.book-icon { position:relative;width:42rpx;height:34rpx;border:3rpx solid currentColor;border-radius:8rpx 8rpx 5rpx 5rpx; }
.book-spine { position:absolute;left:18rpx;top:0;height:100%;border-left:3rpx solid currentColor; }
.book-line { position:absolute;left:5rpx;right:5rpx;top:9rpx;border-top:2rpx solid currentColor; }
.task-icon {
position: relative;
width: 35rpx;
@@ -0,0 +1,85 @@
const { secure } = require("../../utils/api");
const providers = {
cku: "CKU",
fci: "FCI",
akc: "AKC",
rkc: "英国皇家犬业俱乐部",
editorial: "编辑整理",
};
Page({
data: {
breed: null,
loading: true,
error: "",
traits: [],
facts: [],
references: [],
traitSource: "",
},
onLoad(options) {
this.id = options.id;
this.load();
},
async load() {
if (this.pending) return;
this.pending = true;
this.setData({ loading: true, error: "" });
try {
const breed = await secure("/breeds/" + this.id);
let scores = {};
try {
scores = JSON.parse(breed.traits || "{}") || {};
} catch (_) {}
const fieldSources = breed.field_sources || {};
const facts = [
["origin", "起源地"],
["fci_number", "FCI 编号"],
["height", "参考肩高"],
["weight", "参考体重"],
["life_span", "寿命参考"],
["breed_group", "犬种分组"],
].map(([key, label]) => ({
key,
label,
value: breed[key] || "待补充",
source: breed[key] ? providers[fieldSources[key]] || "" : "",
}));
const references = (breed.references || []).map((ref) => ({
...ref,
name: providers[ref.provider] || ref.provider,
}));
this.setData({
breed,
facts,
references,
traits: Object.keys(scores)
.filter(
(name) =>
Number.isInteger(scores[name]) &&
scores[name] >= 1 &&
scores[name] <= 5,
)
.map((name) => ({ name, value: scores[name] })),
traitSource: providers[fieldSources.traits] || "来源资料",
exerciseSource: providers[fieldSources.exercise] || "",
groomingSource: providers[fieldSources.grooming] || "",
});
} catch (error) {
this.setData({ error: error.message });
} finally {
this.pending = false;
this.setData({ loading: false });
}
},
source(e) {
const ref = this.data.references.find(
(item) => item.id === e.currentTarget.dataset.id,
);
const url = ref ? ref.url : this.data.breed?.source_url;
if (!url) return;
wx.setClipboardData({
data: url,
success: () => wx.showToast({ title: "资料链接已复制", icon: "none" }),
});
},
});
@@ -0,0 +1 @@
{"disableScroll":true}
@@ -0,0 +1,14 @@
<page-layout id="page-layout" title="犬种小百科" back="{{true}}">
<view class="screen detail-screen">
<view wx:if="{{loading}}" class="empty">正在认识这位小伙伴…</view>
<view wx:elif="{{error}}" class="empty"><text>{{error}}</text><button class="secondary" bindtap="load">重新加载</button></view>
<block wx:elif="{{breed}}">
<view class="breed-cover"><app-icon kind="pet" size="100" color="#b95738"/><text class="breed-name">{{breed.name}}</text><text class="english">{{breed.english_name}}</text><text wx:if="{{breed.aliases}}" class="aliases">也叫:{{breed.aliases}}</text></view>
<view class="card"><view class="section-heading">认识它</view><text class="paragraph">{{breed.summary||'这个犬种的详细资料还在整理中,可以通过下方来源链接查看官方资料。'}}</text><view class="facts"><view wx:for="{{facts}}" wx:key="key"><text>{{item.label}}</text><text class="fact-value">{{item.value}}</text><text wx:if="{{item.source}}" class="fact-source">{{item.source}}</text></view></view></view>
<view wx:if="{{breed.temperament||traits.length}}" class="card"><view class="section-heading">它的小性格</view><text class="paragraph">{{breed.temperament}}</text><view wx:for="{{traits}}" wx:key="name" class="trait"><text>{{item.name}}</text><view class="trait-meter"><view style="width:{{item.value*20}}%"></view></view><text>{{item.value}}/5</text></view><text wx:if="{{traits.length}}" class="footnote">{{traitSource}} 指数 · 仅作为犬种倾向参考</text></view>
<view wx:if="{{breed.exercise||breed.grooming||breed.care_notes}}" class="card"><view class="section-heading">一起过好每一天</view><view wx:if="{{breed.exercise}}" class="care-item"><app-icon kind="paw" size="30" color="#bd7955"/><view><text class="care-title">散步与互动</text><text class="paragraph">{{breed.exercise}}</text><text wx:if="{{exerciseSource}}" class="footnote">{{exerciseSource}}</text></view></view><view wx:if="{{breed.grooming}}" class="care-item"><view class="care-circle"></view><view><text class="care-title">毛发小护理</text><text class="paragraph">{{breed.grooming}}</text><text wx:if="{{groomingSource}}" class="footnote">{{groomingSource}}</text></view></view><text wx:if="{{breed.care_notes}}" class="paragraph care-note">{{breed.care_notes}}</text><text class="footnote">需求会随年龄、身体状态和个体而变化。</text></view>
<view class="source"><view class="section-heading">这份资料从哪里来</view><view wx:for="{{references}}" wx:key="id" class="source-row" data-id="{{item.id}}" bindtap="source"><view><text class="source-name">{{item.name}}</text><text class="source-date">核对于 {{item.retrieved_at}}</text></view><text class="source-link">复制链接 ›</text></view><block wx:if="{{!references.length}}"><text>资料来源 · {{breed.source||'编辑整理'}}</text><text class="source-link" wx:if="{{breed.source_url}}" bindtap="source">复制来源链接 ›</text></block><text wx:if="{{breed.encyclopedia_updated_at}}" class="source-date">中文资料整理于 {{breed.encyclopedia_updated_at}}</text></view>
<view class="individual-note">品种是认识它的起点,每只狗狗也有自己的个性。</view>
</block>
</view>
</page-layout>
@@ -0,0 +1,30 @@
.detail-screen {padding-bottom:calc(50rpx + env(safe-area-inset-bottom));}
.breed-cover {display:flex;flex-direction:column;align-items:center;text-align:center;padding:46rpx 30rpx;background:linear-gradient(130deg,#fff0df,#eee8f7);border-radius:34rpx;margin-bottom:24rpx;}
.breed-name {font-size:37rpx;font-weight:600;margin:20rpx 0 10rpx;}
.english,.aliases {font-size:23rpx;color:#9d8779;line-height:1.7;}
.section-heading {font-size:29rpx;font-weight:600;margin-bottom:20rpx;}
.paragraph {font-size:26rpx;line-height:1.9;white-space:pre-wrap;}
.facts {display:grid;grid-template-columns:1fr 1fr;gap:26rpx;margin:30rpx 0;}
.facts view {padding:22rpx;background:#fff9f3;border-radius:20rpx;}
.facts text {display:block;font-size:22rpx;color:#9d8779;margin-bottom:8rpx;}
.facts strong {font-size:25rpx;font-weight:500;}
.group,.footnote {display:block;font-size:22rpx;color:#9d8779;}
.trait {display:flex;align-items:center;gap:18rpx;font-size:23rpx;margin:22rpx 0;}
.trait>text:first-child {width:140rpx;flex:none;}
.trait-meter {flex:1;height:12rpx;background:#f4e8de;border-radius:99rpx;overflow:hidden;}
.trait-meter view {height:100%;background:#e5a47b;border-radius:99rpx;}
.source {display:flex;flex-direction:column;gap:10rpx;padding:24rpx;color:#9d8779;font-size:22rpx;}
.source-link {color:#b95738;}
.individual-note {text-align:center;color:#a48d7e;font-size:22rpx;margin-top:20rpx;}
.facts text.fact-value{font-size:25rpx;color:#675146;font-weight:500;line-height:1.5;margin-bottom:6rpx}
.facts text.fact-source{font-size:19rpx;color:#ba9f8d;margin:0}
.care-item{display:flex;gap:18rpx;padding:22rpx 0;border-bottom:1rpx solid #f3e9df}
.care-item>view:last-child{flex:1;min-width:0}
.care-title{display:block;font-size:25rpx;color:#aa6e4e;margin-bottom:8rpx}
.care-circle{height:28rpx;width:28rpx;border:4rpx solid #d8af92;border-radius:50%;flex:none;box-sizing:border-box;margin-top:4rpx}
.care-note{display:block;margin:24rpx 0}
.source{background:#fff4e8;border-radius:28rpx;margin-top:24rpx}
.source-row{display:flex;align-items:center;justify-content:space-between;gap:20rpx;padding:18rpx 0;border-bottom:1rpx solid #efdfcf}
.source-name{display:block;font-size:25rpx;color:#735e51}
.source-date{display:block;font-size:20rpx;line-height:1.8;color:#b49a88}
.source-link{flex:none;font-size:23rpx}
+50
View File
@@ -0,0 +1,50 @@
const { loadBreeds, groupBreeds } = require("../../utils/breeds");
Page({
data: {
groups: [],
query: "",
loading: true,
error: "",
total: 0,
initial: "",
},
onLoad() {
this.load();
},
async load() {
if (this.pending) return;
this.pending = true;
this.setData({ loading: true, error: "" });
try {
this.rows = await loadBreeds();
this.filter();
} catch (error) {
this.setData({ error: error.message });
} finally {
this.pending = false;
this.setData({ loading: false });
}
},
filter() {
let groups = groupBreeds(this.rows || [], this.data.query);
if (this.data.initial)
groups = groups.filter((g) => g.letter === this.data.initial);
this.setData({
groups,
total: groups.reduce((n, g) => n + g.items.length, 0),
});
},
search(e) {
this.setData({ query: e.detail.value, initial: "" });
this.filter();
},
letter(e) {
this.setData({ initial: e.currentTarget.dataset.letter });
this.filter();
},
open(e) {
wx.navigateTo({
url: "/pages/breed-detail/breed-detail?id=" + e.currentTarget.dataset.id,
});
},
});
+1
View File
@@ -0,0 +1 @@
{"disableScroll":true,"usingComponents":{"tab-bar":"/components/tab-bar/index"}}
+12
View File
@@ -0,0 +1,12 @@
<page-layout id="page-layout" title="一起懂它更多">
<view class="screen">
<view class="knowledge-hero"><text class="eyebrow">肉垫小百科</text><view class="hero-title"><text>每个小伙伴</text><text>都有自己的模样</text></view><text class="hero-copy">从认识品种开始,慢慢读懂它。</text><view class="hero-book"><view class="book-middle"></view><view class="book-stripe"></view></view></view>
<input class="knowledge-search" value="{{query}}" bindinput="search" placeholder="搜索犬种、常用名或拼音" maxlength="128" />
<scroll-view class="alphabet-scroll" scroll-x enhanced show-scrollbar="{{false}}"><view class="alphabet"><text class="{{initial===''?'selected':''}}" data-letter="" bindtap="letter">全部</text><text wx:for="{{['A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z']}}" wx:key="*this" class="{{initial===item?'selected':''}}" data-letter="{{item}}" bindtap="letter">{{item}}</text></view></scroll-view>
<view class="catalog-heading"><text>犬种百科</text><text class="catalog-count">{{total}} 个小伙伴</text></view>
<view wx:if="{{loading}}" class="empty">正在打开小百科…</view>
<view wx:elif="{{error}}" class="empty"><text>{{error}}</text><button class="secondary" bindtap="load">重新加载</button></view>
<block wx:else><view wx:for="{{groups}}" wx:for-item="group" wx:key="letter"><view class="group-letter">{{group.letter}}</view><view wx:for="{{group.items}}" wx:key="id" class="breed-entry" data-id="{{item.id}}" bindtap="open"><view class="entry-letter">{{item.initial}}</view><view class="entry-copy"><text class="entry-name">{{item.name}}</text><text class="entry-alias">{{item.origin||item.aliases||item.english_name||'点开认识这位小伙伴'}}</text><text class="entry-status">{{item.has_details?'认识它的小性格':'资料整理中'}}</text></view><text class="entry-arrow">›</text></view></view><view wx:if="{{!total}}" class="empty">暂时没找到这个品种,换个名字试试吧。</view></block>
</view>
</page-layout>
<tab-bar current="/pages/knowledge/knowledge" />
+25
View File
@@ -0,0 +1,25 @@
.knowledge-hero { position:relative;overflow:hidden;padding:34rpx;background:#eee8f7;border-radius:34rpx;color:#79658c; }
.knowledge-hero .eyebrow {color:#9782aa;letter-spacing:1rpx;}
.hero-title {font-size:37rpx;font-weight:600;line-height:1.5;margin:16rpx 0 12rpx;}
.hero-copy {font-size:23rpx;}
.hero-book {position:absolute;right:45rpx;top:70rpx;width:106rpx;height:94rpx;border:5rpx solid #b4a3c5;border-radius:20rpx 20rpx 8rpx 8rpx;transform:rotate(9deg);background:#fff9f380;}
.book-middle {position:absolute;left:48rpx;height:100%;border-left:4rpx solid #b4a3c5;}
.book-stripe {position:absolute;left:14rpx;right:14rpx;top:26rpx;height:24rpx;border-top:3rpx solid #b4a3c5;border-bottom:3rpx solid #b4a3c5;}
.knowledge-search {height:92rpx;padding:0 28rpx;background:#fff;border-radius:24rpx;margin:28rpx 0 16rpx;font-size:26rpx;}
.alphabet-scroll {width:100%;white-space:nowrap;}
.alphabet {display:inline-flex;gap:10rpx;padding:4rpx 0 12rpx;}
.alphabet text {display:inline-block;padding:10rpx 20rpx;border-radius:16rpx;color:#9d8779;background:#fff;}
.alphabet .selected {color:#b95738;background:#ffeadc;}
.catalog-heading {display:flex;align-items:center;justify-content:space-between;margin:30rpx 0 8rpx;font-size:32rpx;font-weight:600;}
.catalog-count {font-size:23rpx;color:#9d8779;font-weight:400;}
.group-letter {color:#b95738;font-size:29rpx;font-weight:600;padding:20rpx 8rpx 12rpx;}
.breed-entry {display:flex;align-items:center;gap:20rpx;padding:26rpx;background:#fff;border-radius:26rpx;margin-bottom:14rpx;}
.entry-letter {width:66rpx;height:66rpx;display:flex;align-items:center;justify-content:center;flex:none;background:#fff0e2;border-radius:22rpx;color:#ba7355;font-size:30rpx;}
.entry-copy {flex:1;min-width:0;}
.entry-name {display:block;font-size:27rpx;font-weight:600;}
.entry-alias {display:block;color:#9d8779;font-size:21rpx;margin-top:6rpx;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;}
.entry-arrow {color:#bfa697;font-size:36rpx;}
.entry-status{display:block;font-size:19rpx;color:#bc9c85;margin-top:6rpx}
.hero-title>text{display:block}
-108
View File
@@ -1,108 +0,0 @@
const { secure, toast } = require("../../utils/api");
Page({
data: { meetups: [], pets: [], selected: [], active: null, userId: "", joined: false, busy: false },
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" });
},
async open(e) {
const summary = this.data.meetups[e.currentTarget.dataset.index];
try {
const active = await secure("/meetups/" + summary.id);
const participant = (active.people || []).find((person) => person.user_id === this.data.userId);
this.setData({
active: { ...active, date: new Date(active.starts_at).toLocaleString("zh-CN") },
joined: !!participant,
selected: participant ? participant.pet_ids : [],
});
} catch (error) {
toast(error);
}
},
close() {
this.setData({ active: null, joined: false, selected: [] });
},
pick(e) {
this.setData({ selected: e.detail.selected });
},
async join() {
if (this.data.busy) return;
if (!this.data.selected.length) {
wx.showToast({ title: "请选择同行的狗狗", icon: "none" });
return;
}
this.setData({ busy: true });
try {
await secure("/meetups/" + this.data.active.id + "/join", "POST", {
pet_ids: this.data.selected,
});
wx.showToast({ title: "报名成功", icon: "success" });
this.close();
await this.load();
} catch (e) {
toast(e);
} finally {
this.setData({ busy: false });
}
},
async leave() {
if (this.data.busy) return;
const result = await wx.showModal({ title: "退出约遛", content: "确定不参加这场约遛了吗?" });
if (!result.confirm) return;
this.setData({ busy: true });
try {
await secure("/meetups/" + this.data.active.id + "/join", "DELETE");
this.close();
await this.load();
} catch (error) {
toast(error);
} finally {
this.setData({ busy: false });
}
},
location() {
const active = this.data.active;
if (active && active.latitude && active.longitude) {
wx.openLocation({ latitude: active.latitude, longitude: active.longitude, name: active.place_name, scale: 16 });
}
},
async cancel() {
if (this.data.busy) return;
const r = await wx.showModal({
title: "取消约遛",
content: "确定取消这场约遛吗?",
});
if (!r.confirm) return;
this.setData({ busy: true });
try {
await secure("/meetups/" + this.data.active.id + "/cancel", "POST");
this.close();
await this.load();
} catch (e) {
toast(e);
} finally {
this.setData({ busy: false });
}
},
});
+5
View File
@@ -4,6 +4,7 @@ Page({
id: "",
name: "",
breed: "",
breed_id: "",
gender: "male",
birth_date: "",
weight_kg: "",
@@ -30,6 +31,9 @@ Page({
field(e) {
this.setData({ [e.currentTarget.dataset.key]: e.detail.value });
},
breedChange(e) {
this.setData({ breed: e.detail.name, breed_id: e.detail.id || "" });
},
date(e) {
this.setData({ birth_date: e.detail.value });
},
@@ -55,6 +59,7 @@ Page({
name: this.data.name,
species: "dog",
breed: this.data.breed,
breed_id: this.data.breed_id || null,
gender: this.data.gender,
birth_date: this.data.birth_date || "",
weight_kg: Number(this.data.weight_kg) || 0,
+3 -1
View File
@@ -1,4 +1,6 @@
{
"disableScroll": true,
"usingComponents": {}
"usingComponents": {
"breed-picker": "/components/breed-picker/index"
}
}
+1 -1
View File
@@ -16,7 +16,7 @@
</view>
<view class="field">
<view class="field-label">品种</view>
<input placeholder="填写它的品种" value="{{breed}}" data-key="breed" bindinput="field" />
<breed-picker value="{{breed}}" breed-id="{{breed_id||''}}" bind:change="breedChange" />
</view>
<view class="field">
<view class="field-label">性别</view>
+5
View File
@@ -1,6 +1,7 @@
const { secure, toast } = require("../../utils/api");
Page({
data: {
mode: "walk",
pets: [],
selected: [],
walk: null,
@@ -14,6 +15,10 @@ Page({
busy: false,
locationError: "",
},
changeMode(e) {
const mode = e.currentTarget.dataset.mode;
if (mode !== this.data.mode) this.setData({ mode });
},
onShow() {
this.load();
clearInterval(this.clock);
+2 -1
View File
@@ -2,6 +2,7 @@
"disableScroll": true,
"usingComponents": {
"tab-bar": "/components/tab-bar/index",
"pet-picker": "/components/pet-picker/index"
"pet-picker": "/components/pet-picker/index",
"meetup-panel": "/components/meetup-panel/index"
}
}
+3 -1
View File
@@ -1,5 +1,7 @@
<page-layout id="page-layout" title="一起去兜风">
<view class="screen walk-screen">
<view class="walk-modes"><view class="mode {{mode==='walk'?'chosen':''}}" data-mode="walk" bindtap="changeMode">记录遛狗<text wx:if="{{walk&&walk.status==='active'}}" class="recording-dot"></text></view><view class="mode {{mode==='meetups'?'chosen':''}}" data-mode="meetups" bindtap="changeMode">约个伴</view></view>
<meetup-panel wx:if="{{mode==='meetups'}}" />
<view wx:else class="screen walk-screen">
<view class="map-box">
<map
id="walk-map"
+5
View File
@@ -111,3 +111,8 @@
border-radius: 20rpx;
font-size: 26rpx;
}
.walk-modes { margin:12rpx 32rpx 22rpx; padding:7rpx; display:flex; border-radius:99rpx; background:#f5e9de; }
.mode { flex:1;padding:16rpx;text-align:center;color:#9d8779;font-size:26rpx;border-radius:99rpx; }
.mode.chosen { background:#fff;color:#b95738;box-shadow:0 5rpx 15rpx #604a4010; }
.recording-dot { display:inline-block;width:10rpx;height:10rpx;background:#b95738;border-radius:50%;margin-left:10rpx; }
+46
View File
@@ -0,0 +1,46 @@
const { secure } = require("./api");
let cached;
let pending;
function loadBreeds(force = false) {
if (pending) return pending;
if (cached && !force) return Promise.resolve(cached);
pending = secure("/breeds")
.then((rows) => {
cached = rows;
return rows;
})
.finally(() => {
pending = null;
});
return pending;
}
function groupBreeds(rows, query = "") {
const q = query.trim().toLowerCase().replace(/\s/g, "");
const groups = new Map();
for (const row of rows) {
if (
q &&
![
row.name,
row.aliases,
row.english_name,
row.pinyin,
row.pinyin_initials,
].some((value) =>
(value || "").toLowerCase().replace(/\s/g, "").includes(q),
)
)
continue;
const letter = row.initial || "#";
if (!groups.has(letter)) groups.set(letter, []);
groups.get(letter).push(row);
}
return [...groups]
.sort(([a], [b]) => a.localeCompare(b))
.map(([letter, items]) => ({
letter,
anchor: letter === "#" ? "letter-other" : "letter-" + letter,
items,
}));
}
module.exports = { loadBreeds, groupBreeds };