feat: add multi-source breed encyclopedia and two-hour refresh sessions
This commit is contained in:
@@ -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; }
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {
|
||||
"pet-picker": "/components/pet-picker/index"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<view class="screen">
|
||||
<view class="banner">
|
||||
<view class="banner-copy">
|
||||
<text>好天气,和新朋友一起走走</text>
|
||||
<view class="banner-title first">下一次快乐</view>
|
||||
<view class="banner-title">从这里出发</view>
|
||||
</view>
|
||||
<image src="/assets/corgi-family-five.png" mode="aspectFit" />
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="section-title">即将开始的约遛</text>
|
||||
<text class="add" bindtap="create">+ 发起约遛</text>
|
||||
</view>
|
||||
<view
|
||||
wx:for="{{meetups}}"
|
||||
wx:key="id"
|
||||
class="card meetup"
|
||||
data-index="{{index}}"
|
||||
bindtap="open"
|
||||
>
|
||||
<view class="row">
|
||||
<text class="badge">
|
||||
{{item.participants}} / {{item.max_people}} 人
|
||||
</text>
|
||||
<text class="arrow">›</text>
|
||||
</view>
|
||||
<strong>{{item.title}}</strong>
|
||||
<view class="meta">地点 · {{item.place_name}}</view>
|
||||
<view class="meta">时间 · {{item.date}}</view>
|
||||
<view class="desc">{{item.description}}</view>
|
||||
</view>
|
||||
<view wx:if="{{!meetups.length}}" class="empty">
|
||||
还没有约遛,发起第一场吧
|
||||
</view>
|
||||
<button class="primary" bindtap="create">+ 发起一场约遛</button>
|
||||
</view>
|
||||
<root-portal wx:if="{{active}}"><view class="overlay">
|
||||
<view class="sheet">
|
||||
<view class="row">
|
||||
<strong>{{active.title}}</strong>
|
||||
<text bindtap="close">×</text>
|
||||
</view>
|
||||
<view class="detail">
|
||||
<view bindtap="location">地点 · {{active.place_name}} {{active.latitude?'· 点开地图':''}}</view>
|
||||
<view>时间 · {{active.date}}</view>
|
||||
<view> {{active.participants}} / {{active.max_people}} 人已报名</view>
|
||||
</view>
|
||||
<view class="desc">{{active.description}}</view>
|
||||
<view wx:if="{{active.host_user_id!==userId&&!joined}}">
|
||||
<view class="select-title">带哪几只?可以多选</view>
|
||||
<pet-picker pets="{{pets}}" selected="{{selected}}" bind:change="pick" />
|
||||
<button class="primary" bindtap="join" disabled="{{busy||active.participants>=active.max_people}}">{{active.participants>=active.max_people?'人数已满':'带狗狗报名'}}</button>
|
||||
</view>
|
||||
<button wx:elif="{{joined&&active.host_user_id!==userId}}" class="secondary" bindtap="leave" disabled="{{busy}}">已报名 · 退出约遛</button>
|
||||
<button wx:else class="secondary" bindtap="cancel" disabled="{{busy}}">取消这场约遛</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
|
||||
</root-portal>
|
||||
@@ -0,0 +1,101 @@
|
||||
.banner {
|
||||
background: #edf2fb;
|
||||
border-radius: 34rpx;
|
||||
min-height: 245rpx;
|
||||
padding: 30rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.banner-copy {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.banner-copy text,
|
||||
.banner-title {
|
||||
display: block;
|
||||
}
|
||||
.banner-copy text {
|
||||
color: #576c88;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
.banner-title {
|
||||
font-size: 35rpx;
|
||||
line-height: 1.3;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.banner-title.first {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.banner image {
|
||||
width: 52%;
|
||||
height: 190rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.add {
|
||||
font-size: 24rpx;
|
||||
color: #b95738;
|
||||
font-weight: 600;
|
||||
}
|
||||
.meetup strong {
|
||||
display: block;
|
||||
font-size: 32rpx;
|
||||
margin: 15rpx 0;
|
||||
}
|
||||
.badge {
|
||||
background: #edf2fb;
|
||||
color: #b95738;
|
||||
border-radius: 99rpx;
|
||||
padding: 8rpx 15rpx;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
.arrow {
|
||||
font-size: 39rpx;
|
||||
color: #9d8779;
|
||||
}
|
||||
.meta {
|
||||
font-size: 24rpx;
|
||||
color: #886c5f;
|
||||
margin-top: 9rpx;
|
||||
}
|
||||
.desc {
|
||||
font-size: 24rpx;
|
||||
color: #886c5f;
|
||||
margin-top: 15rpx;
|
||||
}
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #604a40a0;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.sheet {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-radius: 36rpx 36rpx 0 0;
|
||||
padding: 35rpx 32rpx calc(env(safe-area-inset-bottom) + 35rpx);
|
||||
}
|
||||
.sheet > .row strong {
|
||||
font-size: 34rpx;
|
||||
}
|
||||
.sheet > .row text {
|
||||
font-size: 45rpx;
|
||||
}
|
||||
.detail {
|
||||
font-size: 25rpx;
|
||||
color: #886c5f;
|
||||
line-height: 2;
|
||||
margin: 25rpx 0;
|
||||
}
|
||||
.select-title {
|
||||
font-size: 25rpx;
|
||||
font-weight: 600;
|
||||
margin: 30rpx 0 15rpx;
|
||||
}
|
||||
.sheet button {
|
||||
margin-top: 26rpx;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user