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
+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; }