Initial commit

This commit is contained in:
2026-06-05 17:46:51 +08:00
commit c04c890186
96 changed files with 6477 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
{
"navigationStyle": "custom",
"usingComponents": {
"post-card": "/components/post-card/post-card"
}
}

View File

@@ -0,0 +1,155 @@
import { Post } from '../../types/index'
import { api } from '../../utils/api'
import { getAvatarGradient, getPetEmoji } from '../../utils/format'
import { PAGE_SIZE } from '../../utils/constants'
interface FeedTab { key: 'feed' | 'nearby' | 'hot'; label: string }
const app = getApp<{ userInfo: any; currentLocation: any }>()
Page({
data: {
statusBarHeight: 0,
tabBarHeight: 56,
posts: [] as Post[],
stories: [] as any[],
activeTab: 'feed' as 'feed' | 'nearby' | 'hot',
feedTabs: [
{ key: 'feed', label: '关注' },
{ key: 'nearby', label: '附近' },
{ key: 'hot', label: '热门' },
] as FeedTab[],
loading: false,
loadingMore: false,
refreshing: false,
hasMore: true,
page: 0,
hasUnread: false,
myAvatarGradient: 'linear-gradient(135deg, #ffd6e8, #fff1a6)',
myEmoji: '🐶',
},
onLoad() {
const info = wx.getSystemInfoSync()
this.setData({
statusBarHeight: info.statusBarHeight,
tabBarHeight: info.windowHeight - info.safeArea.bottom + 56,
})
this.loadFeed(true)
this.loadStories()
this.syncMyInfo()
},
onShow() {
const tabBar = this.getTabBar() as any
tabBar?.setSelected?.(0)
},
onPullDownRefresh() {
this.onRefresh()
},
async onRefresh() {
this.setData({ refreshing: true, page: 0, hasMore: true })
await this.loadFeed(true)
this.setData({ refreshing: false })
},
async loadFeed(reset = false) {
if (!reset && !this.data.hasMore) return
if (this.data.loading && !reset) return
const page = reset ? 0 : this.data.page
this.setData(reset ? { loading: true } : { loadingMore: true })
try {
const loc = app.globalData?.currentLocation
const res = await api.getPosts({
page,
type: this.data.activeTab,
latitude: loc?.latitude,
longitude: loc?.longitude,
})
const posts = reset ? res.list : [...this.data.posts, ...res.list]
this.setData({
posts,
page: page + 1,
hasMore: res.hasMore,
})
} catch (e) {
wx.showToast({ title: '加载失败,下拉重试', icon: 'none' })
} finally {
this.setData({ loading: false, loadingMore: false })
}
},
async loadStories() {
try {
const res = await wx.cloud.callFunction({
name: 'getStories',
data: {},
}) as any
if (res.result?.code === 0) {
this.setData({ stories: res.result.data || [] })
}
} catch {}
},
syncMyInfo() {
const user = app.globalData?.userInfo
if (!user) return
const pet = user.pets?.[0]
this.setData({
myAvatarGradient: getAvatarGradient(user.openid || ''),
myEmoji: pet ? getPetEmoji(pet.species, pet.breed) : '🐶',
})
},
onSwitchTab(e: WechatMiniprogram.CustomEvent) {
const key = e.currentTarget.dataset.key as 'feed' | 'nearby' | 'hot'
if (key === this.data.activeTab) return
this.setData({ activeTab: key })
this.loadFeed(true)
},
onLoadMore() {
this.loadFeed(false)
},
onLikeChange(e: WechatMiniprogram.CustomEvent) {
const { postId, liked, count } = e.detail
const posts = this.data.posts.map(p =>
p._id === postId ? { ...p, isLiked: liked, likeCount: count } : p
)
this.setData({ posts })
},
onSearch() {
wx.navigateTo({ url: '/pages/search/search' })
},
onNotify() {
wx.navigateTo({ url: '/pages/notifications/notifications' })
},
onMyStory() {
wx.navigateTo({ url: '/pages/post/post?type=story' })
},
onStoryTap(e: WechatMiniprogram.CustomEvent) {
const id = e.currentTarget.dataset.id
wx.navigateTo({ url: `/pages/story/story?id=${id}` })
},
onGoPost() {
wx.navigateTo({ url: '/pages/post/post' })
},
onShareAppMessage() {
return {
title: '汪圈 — 宠物社区',
path: '/pages/feed/feed',
}
},
})

View File

@@ -0,0 +1,111 @@
<view class="feed-page">
<!-- 状态栏占位 -->
<view class="safe-top" style="height: {{statusBarHeight}}px"></view>
<!-- 顶部栏 -->
<view class="topbar">
<view class="topbar-logo">汪<text class="logo-accent">圈</text></view>
<view class="topbar-tabs">
<view
wx:for="{{feedTabs}}"
wx:key="key"
class="feed-tab {{activeTab === item.key ? 'feed-tab-active' : ''}}"
bindtap="onSwitchTab"
data-key="{{item.key}}"
>{{item.label}}</view>
</view>
<view class="topbar-actions">
<view class="topbar-btn" bindtap="onSearch">🔍</view>
<view class="topbar-btn {{hasUnread ? 'has-badge' : ''}}" bindtap="onNotify">🔔</view>
</view>
</view>
<!-- 故事圈 -->
<scroll-view class="stories-scroll" scroll-x="true" enhanced="true" show-scrollbar="false">
<!-- 我的故事 -->
<view class="story" bindtap="onMyStory">
<view class="story-ring story-ring-me">
<view class="story-inner" style="background: {{myAvatarGradient}}">
<text class="story-emoji">{{myEmoji}}</text>
</view>
</view>
<text class="story-name">我的</text>
</view>
<!-- 关注的宠物故事 -->
<view
wx:for="{{stories}}"
wx:key="_id"
class="story"
bindtap="onStoryTap"
data-id="{{item._id}}"
>
<view class="story-ring {{item.hasNew ? '' : 'story-ring-seen'}}">
<view class="story-inner" style="background: {{item.gradient}}">
<text class="story-emoji">{{item.emoji}}</text>
</view>
</view>
<text class="story-name">{{item.name}}</text>
</view>
</scroll-view>
<!-- 信息流 -->
<scroll-view
class="feed-scroll"
scroll-y="true"
enhanced="true"
show-scrollbar="false"
bindscrolltolower="onLoadMore"
refresher-enabled="true"
bindrefresherrefresh="onRefresh"
refresher-triggered="{{refreshing}}"
>
<!-- 骨架屏 -->
<view wx:if="{{loading && posts.length === 0}}" class="skeleton-wrap">
<view wx:for="{{[1,2,3]}}" wx:key="index" class="skeleton-card">
<view class="skeleton-header">
<view class="skeleton skeleton-avatar"></view>
<view class="skeleton-meta">
<view class="skeleton skeleton-line w60"></view>
<view class="skeleton skeleton-line w40"></view>
</view>
</view>
<view class="skeleton skeleton-img"></view>
<view style="padding: 20rpx 24rpx;">
<view class="skeleton skeleton-line w100"></view>
<view class="skeleton skeleton-line w80" style="margin-top:12rpx"></view>
</view>
</view>
</view>
<!-- 帖子列表 -->
<post-card
wx:for="{{posts}}"
wx:key="_id"
post="{{item}}"
bind:likeChange="onLikeChange"
/>
<!-- 加载更多 -->
<view class="load-more" wx:if="{{loadingMore}}">
<view class="load-dot"></view>
<view class="load-dot load-dot-2"></view>
<view class="load-dot load-dot-3"></view>
</view>
<!-- 到底了 -->
<view class="end-tip" wx:if="{{!hasMore && posts.length > 0}}">
<text>— 已经到底啦,去遛遛狗吧 🐾 —</text>
</view>
<!-- 空态 -->
<view wx:if="{{!loading && posts.length === 0}}" class="empty-feed">
<text class="empty-emoji">🐾</text>
<text class="empty-title">还没有内容</text>
<text class="empty-desc">去附近找找小伙伴,或者发一条动态吧</text>
<view class="btn-primary empty-btn" bindtap="onGoPost">发第一条</view>
</view>
<view class="safe-bottom"></view>
<view style="height: {{tabBarHeight}}px"></view>
</scroll-view>
</view>

View File

@@ -0,0 +1,264 @@
.feed-page {
min-height: 100vh;
background: linear-gradient(180deg, #fff8f2 0%, #fff4fb 50%, #f5f0ff 100%);
display: flex;
flex-direction: column;
}
/* 顶部栏 */
.topbar {
display: flex;
align-items: center;
padding: 16rpx 28rpx 12rpx;
gap: 16rpx;
flex-shrink: 0;
}
.topbar-logo {
font-size: 44rpx;
font-weight: 900;
color: #272235;
flex-shrink: 0;
}
.logo-accent { color: #ff4f91; }
.topbar-tabs {
flex: 1;
display: flex;
gap: 8rpx;
justify-content: center;
}
.feed-tab {
font-size: 26rpx;
font-weight: 700;
color: #9b8fa8;
padding: 8rpx 20rpx;
border-radius: 999rpx;
transition: all 0.2s;
}
.feed-tab-active {
color: #ff4f91;
background: rgba(255, 79, 145, 0.10);
font-weight: 900;
}
.topbar-actions {
display: flex;
gap: 12rpx;
flex-shrink: 0;
}
.topbar-btn {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.72);
box-shadow: 0 8rpx 18rpx rgba(78, 56, 96, 0.10);
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
position: relative;
}
.has-badge::after {
content: '';
position: absolute;
top: 6rpx;
right: 6rpx;
width: 14rpx;
height: 14rpx;
border-radius: 50%;
background: #ff4f91;
border: 2rpx solid #fff;
}
/* 故事圈 */
.stories-scroll {
flex-shrink: 0;
white-space: nowrap;
padding: 16rpx 28rpx 24rpx;
}
.story {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 10rpx;
margin-right: 24rpx;
white-space: normal;
}
.story-ring {
width: 112rpx;
height: 112rpx;
border-radius: 50%;
padding: 5rpx;
box-sizing: border-box;
background: conic-gradient(from 120deg, #ff4f91, #ff9f1c, #ffe15a, #67e8c9, #4d8dff, #8c5cff, #ff4f91);
box-shadow: 0 10rpx 18rpx rgba(96, 60, 115, 0.16);
}
.story-ring-seen {
background: #e8e4f0;
}
.story-ring-me {
background: linear-gradient(135deg, #ff4f91, #ff9f1c);
}
.story-inner {
width: 100%;
height: 100%;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 4rpx solid rgba(255, 255, 255, 0.90);
}
.story-emoji {
font-size: 44rpx;
line-height: 1;
}
.story-name {
font-size: 22rpx;
font-weight: 700;
color: #6a6178;
}
/* 信息流 */
.feed-scroll {
flex: 1;
padding-top: 8rpx;
}
/* 骨架屏 */
.skeleton-wrap {
padding-top: 8rpx;
}
.skeleton-card {
margin: 0 28rpx 32rpx;
background: rgba(255, 255, 255, 0.8);
border-radius: 48rpx;
overflow: hidden;
padding-bottom: 20rpx;
}
.skeleton-header {
display: flex;
align-items: center;
gap: 20rpx;
padding: 24rpx;
}
.skeleton-avatar {
width: 76rpx;
height: 76rpx;
border-radius: 28rpx;
flex-shrink: 0;
}
.skeleton-meta {
flex: 1;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.skeleton-line {
height: 26rpx;
border-radius: 8rpx;
}
.skeleton-img {
width: 100%;
height: 360rpx;
}
.w100 { width: 100%; }
.w80 { width: 80%; }
.w60 { width: 60%; }
.w40 { width: 40%; }
.skeleton {
background: linear-gradient(90deg, #f0eef5 25%, #e8e4f0 50%, #f0eef5 75%);
background-size: 200% 100%;
animation: shine 1.4s infinite;
}
@keyframes shine {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* 加载更多 */
.load-more {
display: flex;
justify-content: center;
gap: 12rpx;
padding: 32rpx;
}
.load-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: #ff4f91;
animation: bounce 1.2s infinite;
}
.load-dot-2 { animation-delay: 0.2s; }
.load-dot-3 { animation-delay: 0.4s; }
@keyframes bounce {
0%, 80%, 100% { transform: scale(0.8); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
/* 到底了 */
.end-tip {
text-align: center;
font-size: 24rpx;
color: #c4b8d0;
padding: 32rpx;
}
/* 空态 */
.empty-feed {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 60rpx 40rpx;
gap: 16rpx;
}
.empty-emoji {
font-size: 80rpx;
line-height: 1;
}
.empty-title {
font-size: 34rpx;
font-weight: 900;
color: #272235;
}
.empty-desc {
font-size: 26rpx;
color: #9b8fa8;
text-align: center;
line-height: 1.6;
}
.empty-btn {
margin-top: 24rpx;
padding: 22rpx 60rpx;
font-size: 28rpx;
font-weight: 800;
}