Initial commit
This commit is contained in:
146
SETUP.md
Normal file
146
SETUP.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# 汪圈小程序 — 上线部署指南
|
||||||
|
|
||||||
|
## 前置条件
|
||||||
|
|
||||||
|
1. 微信公众平台账号 → 注册小程序,记录 **AppID**
|
||||||
|
2. 开通 **微信云开发**,记录 **云环境 ID**
|
||||||
|
3. 安装 **微信开发者工具** (1.06+)
|
||||||
|
4. Node.js 16+
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第一步:填入配置
|
||||||
|
|
||||||
|
**a) `project.config.json`**
|
||||||
|
```json
|
||||||
|
"appid": "你的小程序 AppID"
|
||||||
|
```
|
||||||
|
|
||||||
|
**b) `miniprogram/utils/constants.ts`**
|
||||||
|
```ts
|
||||||
|
export const CLOUD_ENV = '你的云环境 ID'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第二步:初始化云开发数据库
|
||||||
|
|
||||||
|
在微信开发者工具 → 云开发控制台 → 数据库,新建以下集合:
|
||||||
|
|
||||||
|
| 集合名 | 权限设置 | 说明 |
|
||||||
|
|--------|----------|------|
|
||||||
|
| `users` | 所有用户可读,仅创建者可写 | 用户档案 |
|
||||||
|
| `pets` | 所有用户可读,仅创建者可写 | 宠物档案 |
|
||||||
|
| `posts` | 所有用户可读,仅创建者可写 | 帖子 |
|
||||||
|
| `likes` | 仅创建者可读写 | 点赞记录 |
|
||||||
|
| `comments` | 所有用户可读,仅创建者可写 | 评论 |
|
||||||
|
| `follows` | 仅创建者可读写 | 关注关系 |
|
||||||
|
| `messages` | 仅创建者可读写 | 私信 |
|
||||||
|
| `topics` | 所有用户可读,仅管理员可写 | 话题热度 |
|
||||||
|
|
||||||
|
**`posts` 集合需要建立以下索引:**
|
||||||
|
- `authorId` (单字段)
|
||||||
|
- `createdAt` (单字段,降序)
|
||||||
|
- `location.latitude` + `location.longitude` (复合,附近功能)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第三步:部署云函数
|
||||||
|
|
||||||
|
在开发者工具中,右键每个云函数目录 → **上传并部署(云端安装依赖)**:
|
||||||
|
|
||||||
|
```
|
||||||
|
cloudfunctions/
|
||||||
|
├── login ← 必须先部署
|
||||||
|
├── getPosts
|
||||||
|
├── createPost
|
||||||
|
├── getNearbyPets
|
||||||
|
├── likePost
|
||||||
|
├── updateLocation
|
||||||
|
└── sendGreeting
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第四步:配置云存储目录
|
||||||
|
|
||||||
|
云开发控制台 → 存储,无需额外配置,代码自动按以下路径上传:
|
||||||
|
- `posts/` — 帖子图片
|
||||||
|
- `avatars/` — 用户头像
|
||||||
|
|
||||||
|
**建议设置存储 CDN 加速**(在控制台开启)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第五步:小程序隐私配置
|
||||||
|
|
||||||
|
在微信公众平台 → 设置 → 服务类目,选择:`社交 > 同城社交`
|
||||||
|
|
||||||
|
在 **「用户隐私保护指引」** 中声明以下数据使用:
|
||||||
|
- 位置信息(附近功能)
|
||||||
|
- 昵称、头像(展示用户身份)
|
||||||
|
- 相机、相册(发帖上传图片)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第六步:本地调试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 打开微信开发者工具
|
||||||
|
# 导入项目:选择 wangquan/ 目录
|
||||||
|
# 填入 AppID
|
||||||
|
# 点击「编译」
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 环境变量清单
|
||||||
|
|
||||||
|
| 位置 | 字段 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `project.config.json` | `appid` | 小程序 AppID |
|
||||||
|
| `utils/constants.ts` | `CLOUD_ENV` | 云环境 ID |
|
||||||
|
| `pages/login/login.ts` | `YOUR_PRIVACY_URL` | 隐私政策页面 URL |
|
||||||
|
| `pages/login/login.ts` | `YOUR_TERMS_URL` | 用户协议页面 URL |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 项目目录说明
|
||||||
|
|
||||||
|
```
|
||||||
|
wangquan/
|
||||||
|
├── miniprogram/
|
||||||
|
│ ├── app.ts/json/wxss # 全局入口、配置、样式
|
||||||
|
│ ├── pages/
|
||||||
|
│ │ ├── feed/ # 广场(信息流 + 故事圈)
|
||||||
|
│ │ ├── nearby/ # 附近(地图 + 列表双视图)
|
||||||
|
│ │ ├── post/ # 发布动态
|
||||||
|
│ │ ├── friends/ # 汪友(关注/粉丝)
|
||||||
|
│ │ ├── profile/ # 我的(宠物档案 + 动态)
|
||||||
|
│ │ ├── login/ # 登录授权
|
||||||
|
│ │ ├── pet-detail/ # 用户/宠物详情页
|
||||||
|
│ │ └── chat/ # 私聊
|
||||||
|
│ ├── components/
|
||||||
|
│ │ └── post-card/ # 帖子卡片(广场/我的复用)
|
||||||
|
│ ├── custom-tab-bar/ # 自定义底部导航(含中间 FAB)
|
||||||
|
│ ├── utils/ # api / auth / format / constants
|
||||||
|
│ └── types/ # TypeScript 类型定义
|
||||||
|
└── cloudfunctions/
|
||||||
|
├── login # 登录 → 自动创建用户
|
||||||
|
├── getPosts # 帖子列表(关注/附近/热门)
|
||||||
|
├── createPost # 发帖(含话题统计)
|
||||||
|
├── getNearbyPets # 地理围栏查询附近用户
|
||||||
|
├── likePost # 原子点赞/取消
|
||||||
|
├── updateLocation # 心跳更新在线位置
|
||||||
|
└── sendGreeting # 打招呼(含 5min 防刷)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 后续扩展建议
|
||||||
|
|
||||||
|
- **评论系统**:`getComments` + `addComment` 云函数,参考 `getPosts` 结构
|
||||||
|
- **搜索**:云开发支持 `where._.all()` 全文检索,或接入腾讯云搜索
|
||||||
|
- **推送通知**:微信订阅消息 (`wx.requestSubscribeMessage`)
|
||||||
|
- **宠物档案编辑**:新建 `pages/pet-edit/` 页面,调用 `updatePet` 云函数
|
||||||
|
- **性能优化**:为 `posts` 集合开启 CDN 缓存,大流量时接入腾讯云 COS 存储
|
||||||
57
cloudfunctions/createPost/index.js
Normal file
57
cloudfunctions/createPost/index.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
const cloud = require('wx-server-sdk')
|
||||||
|
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
|
||||||
|
const db = cloud.database()
|
||||||
|
const _ = db.command
|
||||||
|
|
||||||
|
exports.main = async (event, context) => {
|
||||||
|
const { OPENID } = cloud.getWXContext()
|
||||||
|
const { content, images = [], video, location, hashtags = [], mood, petId } = event
|
||||||
|
|
||||||
|
if (!content?.trim() && images.length === 0) {
|
||||||
|
return { code: -1, message: '内容不能为空' }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const post = {
|
||||||
|
authorId: OPENID,
|
||||||
|
petId: petId || null,
|
||||||
|
content: (content || '').trim(),
|
||||||
|
images,
|
||||||
|
video: video || null,
|
||||||
|
location: location || null,
|
||||||
|
hashtags,
|
||||||
|
mood: mood || null,
|
||||||
|
likeCount: 0,
|
||||||
|
commentCount: 0,
|
||||||
|
createdAt: db.serverDate(),
|
||||||
|
updatedAt: db.serverDate(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await db.collection('posts').add({ data: post })
|
||||||
|
|
||||||
|
// 更新用户帖子统计
|
||||||
|
await db.collection('users')
|
||||||
|
.where({ openid: OPENID })
|
||||||
|
.update({ data: { 'stats.posts': _.inc(1) } })
|
||||||
|
|
||||||
|
// 更新话题统计
|
||||||
|
if (hashtags.length > 0) {
|
||||||
|
const batch = hashtags.map(tag =>
|
||||||
|
db.collection('topics')
|
||||||
|
.where({ tag })
|
||||||
|
.get()
|
||||||
|
.then(r => {
|
||||||
|
if (r.data.length === 0) {
|
||||||
|
return db.collection('topics').add({ data: { tag, count: 1, createdAt: db.serverDate() } })
|
||||||
|
}
|
||||||
|
return db.collection('topics').where({ tag }).update({ data: { count: _.inc(1) } })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
await Promise.all(batch).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { code: 0, data: { _id: res._id, ...post } }
|
||||||
|
} catch (e) {
|
||||||
|
return { code: -1, message: e.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
1
cloudfunctions/createPost/package.json
Normal file
1
cloudfunctions/createPost/package.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{ "name": "createPost", "version": "1.0.0", "main": "index.js", "dependencies": { "wx-server-sdk": "~2.1.2" } }
|
||||||
76
cloudfunctions/getNearbyPets/index.js
Normal file
76
cloudfunctions/getNearbyPets/index.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
const cloud = require('wx-server-sdk')
|
||||||
|
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
|
||||||
|
const db = cloud.database()
|
||||||
|
const _ = db.command
|
||||||
|
|
||||||
|
// 粗略经纬度范围换算(1度≈111km)
|
||||||
|
function latDelta(km) { return km / 111 }
|
||||||
|
function lngDelta(km, lat) { return km / (111 * Math.cos(lat * Math.PI / 180)) }
|
||||||
|
|
||||||
|
function calcDistance(lat1, lng1, lat2, lng2) {
|
||||||
|
const R = 6371
|
||||||
|
const dLat = (lat2 - lat1) * Math.PI / 180
|
||||||
|
const dLng = (lng2 - lng1) * Math.PI / 180
|
||||||
|
const a = Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2
|
||||||
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) * 1000 // meters
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.main = async (event, context) => {
|
||||||
|
const { OPENID } = cloud.getWXContext()
|
||||||
|
const { latitude, longitude, radiusKm = 5 } = event
|
||||||
|
|
||||||
|
if (!latitude || !longitude) {
|
||||||
|
return { code: -1, message: '缺少位置信息' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在线判断:60秒内更新过位置的用户为在线
|
||||||
|
const onlineThreshold = new Date(Date.now() - 60 * 1000).toISOString()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dLat = latDelta(radiusKm)
|
||||||
|
const dLng = lngDelta(radiusKm, latitude)
|
||||||
|
|
||||||
|
const usersRes = await db.collection('users')
|
||||||
|
.where(
|
||||||
|
_.and([
|
||||||
|
{ openid: _.neq(OPENID) }, // 排除自己
|
||||||
|
{ 'lastLocation.latitude': _.gt(latitude - dLat).and(_.lt(latitude + dLat)) },
|
||||||
|
{ 'lastLocation.longitude': _.gt(longitude - dLng).and(_.lt(longitude + dLng)) },
|
||||||
|
{ pets: _.exists(true) },
|
||||||
|
])
|
||||||
|
)
|
||||||
|
.field({
|
||||||
|
openid: true, nickName: true, avatarUrl: true,
|
||||||
|
lastLocation: true, lastSeen: true, isOnline: true,
|
||||||
|
pets: true, location: true, bio: true,
|
||||||
|
})
|
||||||
|
.limit(30)
|
||||||
|
.get()
|
||||||
|
|
||||||
|
const nearby = usersRes.data
|
||||||
|
.map(user => {
|
||||||
|
const userLat = user.lastLocation?.latitude
|
||||||
|
const userLng = user.lastLocation?.longitude
|
||||||
|
if (!userLat || !userLng) return null
|
||||||
|
|
||||||
|
const distance = calcDistance(latitude, longitude, userLat, userLng)
|
||||||
|
if (distance > radiusKm * 1000) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: user.openid,
|
||||||
|
user,
|
||||||
|
pet: user.pets?.[0] || null,
|
||||||
|
distance,
|
||||||
|
isOnline: user.isOnline && user.lastSeen > onlineThreshold,
|
||||||
|
location: { latitude: userLat, longitude: userLng },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => a.distance - b.distance)
|
||||||
|
|
||||||
|
return { code: 0, data: nearby }
|
||||||
|
} catch (e) {
|
||||||
|
return { code: -1, message: e.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
1
cloudfunctions/getNearbyPets/package.json
Normal file
1
cloudfunctions/getNearbyPets/package.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{ "name": "getNearbyPets", "version": "1.0.0", "main": "index.js", "dependencies": { "wx-server-sdk": "~2.1.2" } }
|
||||||
34
cloudfunctions/getPost/index.js
Normal file
34
cloudfunctions/getPost/index.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
const cloud = require('wx-server-sdk')
|
||||||
|
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
|
||||||
|
const db = cloud.database()
|
||||||
|
|
||||||
|
exports.main = async (event, context) => {
|
||||||
|
const { OPENID } = cloud.getWXContext()
|
||||||
|
const { postId } = event
|
||||||
|
if (!postId) return { code: -1, message: '缺少 postId' }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const post = await db.collection('posts').doc(postId).get()
|
||||||
|
if (!post.data) return { code: -1, message: '帖子不存在' }
|
||||||
|
|
||||||
|
const authorRes = await db.collection('users')
|
||||||
|
.where({ openid: post.data.authorId })
|
||||||
|
.field({ nickName: true, avatarUrl: true, openid: true, pets: true })
|
||||||
|
.get()
|
||||||
|
|
||||||
|
const likeRes = await db.collection('likes')
|
||||||
|
.where({ userId: OPENID, postId })
|
||||||
|
.count()
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
...post.data,
|
||||||
|
author: authorRes.data[0] || null,
|
||||||
|
isLiked: likeRes.total > 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return { code: -1, message: e.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
1
cloudfunctions/getPost/package.json
Normal file
1
cloudfunctions/getPost/package.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{ "name": "getPost", "version": "1.0.0", "main": "index.js", "dependencies": { "wx-server-sdk": "~2.1.2" } }
|
||||||
83
cloudfunctions/getPosts/index.js
Normal file
83
cloudfunctions/getPosts/index.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
const cloud = require('wx-server-sdk')
|
||||||
|
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
|
||||||
|
const db = cloud.database()
|
||||||
|
const _ = db.command
|
||||||
|
const PAGE_SIZE = 10
|
||||||
|
|
||||||
|
exports.main = async (event, context) => {
|
||||||
|
const { OPENID } = cloud.getWXContext()
|
||||||
|
const { page = 0, type = 'hot', userId, latitude, longitude } = event
|
||||||
|
|
||||||
|
try {
|
||||||
|
let query = db.collection('posts')
|
||||||
|
const skip = page * PAGE_SIZE
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
// 某用户的帖子
|
||||||
|
query = query.where({ authorId: userId })
|
||||||
|
} else if (type === 'feed') {
|
||||||
|
// 关注的用户帖子
|
||||||
|
const followsRes = await db.collection('follows')
|
||||||
|
.where({ followerId: OPENID })
|
||||||
|
.field({ followeeId: true })
|
||||||
|
.get()
|
||||||
|
const followeeIds = followsRes.data.map(f => f.followeeId)
|
||||||
|
followeeIds.push(OPENID)
|
||||||
|
query = query.where({ authorId: _.in(followeeIds) })
|
||||||
|
} else if (type === 'nearby' && latitude && longitude) {
|
||||||
|
// 附近帖子:使用地理围栏查询(简化版,用 geo_near)
|
||||||
|
query = query.where(
|
||||||
|
_.and([
|
||||||
|
{ 'location.latitude': _.gt(latitude - 0.1) },
|
||||||
|
{ 'location.latitude': _.lt(latitude + 0.1) },
|
||||||
|
{ 'location.longitude': _.gt(longitude - 0.15) },
|
||||||
|
{ 'location.longitude': _.lt(longitude + 0.15) },
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// type === 'hot' 全量按热度排序
|
||||||
|
|
||||||
|
const total = await query.count()
|
||||||
|
const postsRes = await query
|
||||||
|
.orderBy('createdAt', 'desc')
|
||||||
|
.skip(skip)
|
||||||
|
.limit(PAGE_SIZE)
|
||||||
|
.get()
|
||||||
|
|
||||||
|
const posts = postsRes.data
|
||||||
|
|
||||||
|
// 批量获取作者信息
|
||||||
|
const authorIds = [...new Set(posts.map(p => p.authorId))]
|
||||||
|
const authorsRes = await db.collection('users')
|
||||||
|
.where({ openid: _.in(authorIds) })
|
||||||
|
.field({ nickName: true, avatarUrl: true, openid: true, pets: true, location: true })
|
||||||
|
.get()
|
||||||
|
const authorMap = {}
|
||||||
|
authorsRes.data.forEach(u => { authorMap[u.openid] = u })
|
||||||
|
|
||||||
|
// 获取当前用户点赞状态
|
||||||
|
const postIds = posts.map(p => p._id)
|
||||||
|
const likesRes = await db.collection('likes')
|
||||||
|
.where({ userId: OPENID, postId: _.in(postIds) })
|
||||||
|
.field({ postId: true })
|
||||||
|
.get()
|
||||||
|
const likedSet = new Set(likesRes.data.map(l => l.postId))
|
||||||
|
|
||||||
|
const enriched = posts.map(p => ({
|
||||||
|
...p,
|
||||||
|
author: authorMap[p.authorId] || null,
|
||||||
|
isLiked: likedSet.has(p._id),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
list: enriched,
|
||||||
|
total: total.total,
|
||||||
|
hasMore: skip + posts.length < total.total,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return { code: -1, message: e.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
1
cloudfunctions/getPosts/package.json
Normal file
1
cloudfunctions/getPosts/package.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{ "name": "getPosts", "version": "1.0.0", "main": "index.js", "dependencies": { "wx-server-sdk": "~2.1.2" } }
|
||||||
54
cloudfunctions/likePost/index.js
Normal file
54
cloudfunctions/likePost/index.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
const cloud = require('wx-server-sdk')
|
||||||
|
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
|
||||||
|
const db = cloud.database()
|
||||||
|
const _ = db.command
|
||||||
|
|
||||||
|
exports.main = async (event, context) => {
|
||||||
|
const { OPENID } = cloud.getWXContext()
|
||||||
|
const { postId } = event
|
||||||
|
|
||||||
|
if (!postId) return { code: -1, message: '缺少 postId' }
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 检查是否已点赞
|
||||||
|
const existRes = await db.collection('likes')
|
||||||
|
.where({ userId: OPENID, postId })
|
||||||
|
.get()
|
||||||
|
|
||||||
|
let liked
|
||||||
|
let delta
|
||||||
|
|
||||||
|
if (existRes.data.length > 0) {
|
||||||
|
// 取消点赞
|
||||||
|
await db.collection('likes').doc(existRes.data[0]._id).remove()
|
||||||
|
delta = -1
|
||||||
|
liked = false
|
||||||
|
} else {
|
||||||
|
// 点赞
|
||||||
|
await db.collection('likes').add({
|
||||||
|
data: {
|
||||||
|
userId: OPENID,
|
||||||
|
postId,
|
||||||
|
createdAt: db.serverDate(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
delta = 1
|
||||||
|
liked = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原子更新帖子点赞数
|
||||||
|
await db.collection('posts').doc(postId).update({
|
||||||
|
data: { likeCount: _.inc(delta) },
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取最新计数
|
||||||
|
const postRes = await db.collection('posts').doc(postId).field({ likeCount: true }).get()
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 0,
|
||||||
|
data: { liked, count: postRes.data.likeCount },
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return { code: -1, message: e.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
1
cloudfunctions/likePost/package.json
Normal file
1
cloudfunctions/likePost/package.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{ "name": "likePost", "version": "1.0.0", "main": "index.js", "dependencies": { "wx-server-sdk": "~2.1.2" } }
|
||||||
48
cloudfunctions/login/index.js
Normal file
48
cloudfunctions/login/index.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
const cloud = require('wx-server-sdk')
|
||||||
|
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
|
||||||
|
const db = cloud.database()
|
||||||
|
|
||||||
|
exports.main = async (event, context) => {
|
||||||
|
const { OPENID, APPID } = cloud.getWXContext()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 查找或创建用户
|
||||||
|
const userRes = await db.collection('users').where({ openid: OPENID }).get()
|
||||||
|
let userInfo
|
||||||
|
|
||||||
|
if (userRes.data.length === 0) {
|
||||||
|
// 新用户 - 创建档案
|
||||||
|
const newUser = {
|
||||||
|
openid: OPENID,
|
||||||
|
nickName: event.nickName || '宠物爱好者',
|
||||||
|
avatarUrl: event.avatarUrl || '',
|
||||||
|
handle: '',
|
||||||
|
location: '',
|
||||||
|
bio: '',
|
||||||
|
stats: { posts: 0, friends: 0, likes: 0 },
|
||||||
|
lastLocation: null,
|
||||||
|
isOnline: true,
|
||||||
|
lastSeen: new Date().toISOString(),
|
||||||
|
pets: [],
|
||||||
|
createdAt: db.serverDate(),
|
||||||
|
updatedAt: db.serverDate(),
|
||||||
|
}
|
||||||
|
const addRes = await db.collection('users').add({ data: newUser })
|
||||||
|
userInfo = { _id: addRes._id, ...newUser }
|
||||||
|
} else {
|
||||||
|
// 老用户 - 更新在线状态
|
||||||
|
userInfo = userRes.data[0]
|
||||||
|
await db.collection('users').doc(userInfo._id).update({
|
||||||
|
data: {
|
||||||
|
isOnline: true,
|
||||||
|
lastSeen: db.serverDate(),
|
||||||
|
updatedAt: db.serverDate(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { code: 0, data: { userInfo } }
|
||||||
|
} catch (e) {
|
||||||
|
return { code: -1, message: e.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
9
cloudfunctions/login/package.json
Normal file
9
cloudfunctions/login/package.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "login",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "微信云函数 - 用户登录",
|
||||||
|
"main": "index.js",
|
||||||
|
"dependencies": {
|
||||||
|
"wx-server-sdk": "~2.1.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
53
cloudfunctions/sendGreeting/index.js
Normal file
53
cloudfunctions/sendGreeting/index.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
const cloud = require('wx-server-sdk')
|
||||||
|
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
|
||||||
|
const db = cloud.database()
|
||||||
|
|
||||||
|
exports.main = async (event, context) => {
|
||||||
|
const { OPENID } = cloud.getWXContext()
|
||||||
|
const { toUserId, petId } = event
|
||||||
|
|
||||||
|
if (!toUserId) return { code: -1, message: '缺少目标用户' }
|
||||||
|
if (toUserId === OPENID) return { code: -1, message: '不能给自己打招呼' }
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 防刷:5分钟内同一对用户只能打一次招呼
|
||||||
|
const cooldownMs = 5 * 60 * 1000
|
||||||
|
const recent = await db.collection('messages')
|
||||||
|
.where({
|
||||||
|
fromId: OPENID,
|
||||||
|
toId: toUserId,
|
||||||
|
type: 'greeting',
|
||||||
|
createdAt: db.command.gt(new Date(Date.now() - cooldownMs).toISOString()),
|
||||||
|
})
|
||||||
|
.count()
|
||||||
|
|
||||||
|
if (recent.total > 0) {
|
||||||
|
return { code: -1, message: '打招呼太频繁了,稍后再试' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取发送者信息
|
||||||
|
const senderRes = await db.collection('users')
|
||||||
|
.where({ openid: OPENID })
|
||||||
|
.field({ nickName: true, pets: true })
|
||||||
|
.get()
|
||||||
|
const sender = senderRes.data[0]
|
||||||
|
const pet = sender?.pets?.find(p => p._id === petId) || sender?.pets?.[0]
|
||||||
|
|
||||||
|
const greeting = `${sender?.nickName || '小伙伴'} 想认识你的宠物!${pet ? `TA 的 ${pet.name} 向你打了个招呼 🐾` : '来聊聊吧 🐾'}`
|
||||||
|
|
||||||
|
await db.collection('messages').add({
|
||||||
|
data: {
|
||||||
|
fromId: OPENID,
|
||||||
|
toId: toUserId,
|
||||||
|
content: greeting,
|
||||||
|
type: 'greeting',
|
||||||
|
isRead: false,
|
||||||
|
createdAt: db.serverDate(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { code: 0, data: null }
|
||||||
|
} catch (e) {
|
||||||
|
return { code: -1, message: e.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
1
cloudfunctions/sendGreeting/package.json
Normal file
1
cloudfunctions/sendGreeting/package.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{ "name": "sendGreeting", "version": "1.0.0", "main": "index.js", "dependencies": { "wx-server-sdk": "~2.1.2" } }
|
||||||
25
cloudfunctions/updateLocation/index.js
Normal file
25
cloudfunctions/updateLocation/index.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
const cloud = require('wx-server-sdk')
|
||||||
|
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
|
||||||
|
const db = cloud.database()
|
||||||
|
|
||||||
|
exports.main = async (event, context) => {
|
||||||
|
const { OPENID } = cloud.getWXContext()
|
||||||
|
const { latitude, longitude } = event
|
||||||
|
|
||||||
|
if (!latitude || !longitude) return { code: -1, message: '缺少位置' }
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.collection('users')
|
||||||
|
.where({ openid: OPENID })
|
||||||
|
.update({
|
||||||
|
data: {
|
||||||
|
lastLocation: { latitude, longitude },
|
||||||
|
isOnline: true,
|
||||||
|
lastSeen: db.serverDate(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { code: 0, data: null }
|
||||||
|
} catch (e) {
|
||||||
|
return { code: -1, message: e.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
1
cloudfunctions/updateLocation/package.json
Normal file
1
cloudfunctions/updateLocation/package.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{ "name": "updateLocation", "version": "1.0.0", "main": "index.js", "dependencies": { "wx-server-sdk": "~2.1.2" } }
|
||||||
51
miniprogram/app.json
Normal file
51
miniprogram/app.json
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"pages": [
|
||||||
|
"pages/feed/feed",
|
||||||
|
"pages/nearby/nearby",
|
||||||
|
"pages/friends/friends",
|
||||||
|
"pages/profile/profile",
|
||||||
|
"pages/login/login",
|
||||||
|
"pages/post/post",
|
||||||
|
"pages/pet-detail/pet-detail",
|
||||||
|
"pages/chat/chat",
|
||||||
|
"pages/post-detail/post-detail",
|
||||||
|
"pages/pet-edit/pet-edit",
|
||||||
|
"pages/edit-profile/edit-profile",
|
||||||
|
"pages/search/search",
|
||||||
|
"pages/notifications/notifications",
|
||||||
|
"pages/story/story",
|
||||||
|
"pages/webview/webview"
|
||||||
|
],
|
||||||
|
"tabBar": {
|
||||||
|
"custom": true,
|
||||||
|
"color": "#9b8fa8",
|
||||||
|
"selectedColor": "#ff4f91",
|
||||||
|
"backgroundColor": "#ffffff",
|
||||||
|
"list": [
|
||||||
|
{ "pagePath": "pages/feed/feed", "text": "广场" },
|
||||||
|
{ "pagePath": "pages/nearby/nearby", "text": "附近" },
|
||||||
|
{ "pagePath": "pages/friends/friends", "text": "汪友" },
|
||||||
|
{ "pagePath": "pages/profile/profile", "text": "我的" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"window": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"backgroundTextStyle": "dark",
|
||||||
|
"backgroundColor": "#fff9fb"
|
||||||
|
},
|
||||||
|
"permission": {
|
||||||
|
"scope.userLocation": {
|
||||||
|
"desc": "汪圈需要您的位置来显示附近的宠物和精准内容"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"requiredPrivateInfos": ["getLocation", "chooseLocation"],
|
||||||
|
"sitemapLocation": "sitemap.json",
|
||||||
|
"style": "v2",
|
||||||
|
"componentFramework": "glass-easel",
|
||||||
|
"lazyCodeLoading": "requiredComponents",
|
||||||
|
"networkTimeout": {
|
||||||
|
"request": 10000,
|
||||||
|
"uploadFile": 60000,
|
||||||
|
"downloadFile": 60000
|
||||||
|
}
|
||||||
|
}
|
||||||
92
miniprogram/app.ts
Normal file
92
miniprogram/app.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { UserProfile, GeoPoint } from './types/index'
|
||||||
|
import { getCachedUser, login } from './utils/auth'
|
||||||
|
import { CLOUD_ENV, HEARTBEAT_INTERVAL_MS } from './utils/constants'
|
||||||
|
import { api } from './utils/api'
|
||||||
|
|
||||||
|
App<{
|
||||||
|
userInfo: UserProfile | null
|
||||||
|
isLoggedIn: boolean
|
||||||
|
currentLocation: GeoPoint | null
|
||||||
|
_locationTimer: number | null
|
||||||
|
}>({
|
||||||
|
globalData: {
|
||||||
|
userInfo: null,
|
||||||
|
isLoggedIn: false,
|
||||||
|
currentLocation: null,
|
||||||
|
_locationTimer: null,
|
||||||
|
},
|
||||||
|
|
||||||
|
onLaunch() {
|
||||||
|
wx.cloud.init({
|
||||||
|
env: CLOUD_ENV,
|
||||||
|
traceUser: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const cached = getCachedUser()
|
||||||
|
if (cached) {
|
||||||
|
this.globalData.userInfo = cached
|
||||||
|
this.globalData.isLoggedIn = true
|
||||||
|
}
|
||||||
|
|
||||||
|
this._initLocation()
|
||||||
|
},
|
||||||
|
|
||||||
|
onShow() {
|
||||||
|
this._startLocationHeartbeat()
|
||||||
|
},
|
||||||
|
|
||||||
|
onHide() {
|
||||||
|
this._stopLocationHeartbeat()
|
||||||
|
},
|
||||||
|
|
||||||
|
async _initLogin() {
|
||||||
|
try {
|
||||||
|
const userInfo = await login()
|
||||||
|
this.globalData.userInfo = userInfo
|
||||||
|
this.globalData.isLoggedIn = true
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Login failed', e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_initLocation() {
|
||||||
|
wx.getLocation({
|
||||||
|
type: 'gcj02',
|
||||||
|
success: res => {
|
||||||
|
this.globalData.currentLocation = {
|
||||||
|
latitude: res.latitude,
|
||||||
|
longitude: res.longitude,
|
||||||
|
}
|
||||||
|
if (this.globalData.isLoggedIn) {
|
||||||
|
api.updateLocation(res.latitude, res.longitude).catch(() => {})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => {},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
_startLocationHeartbeat() {
|
||||||
|
if (this.globalData._locationTimer) return
|
||||||
|
this.globalData._locationTimer = setInterval(() => {
|
||||||
|
if (!this.globalData.isLoggedIn) return
|
||||||
|
wx.getLocation({
|
||||||
|
type: 'gcj02',
|
||||||
|
success: res => {
|
||||||
|
this.globalData.currentLocation = {
|
||||||
|
latitude: res.latitude,
|
||||||
|
longitude: res.longitude,
|
||||||
|
}
|
||||||
|
api.updateLocation(res.latitude, res.longitude).catch(() => {})
|
||||||
|
},
|
||||||
|
fail: () => {},
|
||||||
|
})
|
||||||
|
}, HEARTBEAT_INTERVAL_MS)
|
||||||
|
},
|
||||||
|
|
||||||
|
_stopLocationHeartbeat() {
|
||||||
|
if (this.globalData._locationTimer) {
|
||||||
|
clearInterval(this.globalData._locationTimer)
|
||||||
|
this.globalData._locationTimer = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
134
miniprogram/app.wxss
Normal file
134
miniprogram/app.wxss
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/* ── 全局设计令牌 ── */
|
||||||
|
page {
|
||||||
|
--pink: #ff4f91;
|
||||||
|
--pink-light: #ffe5f0;
|
||||||
|
--orange: #ff9f1c;
|
||||||
|
--yellow: #ffe15a;
|
||||||
|
--green: #2fd37a;
|
||||||
|
--mint: #67e8c9;
|
||||||
|
--blue: #4d8dff;
|
||||||
|
--violet: #8c5cff;
|
||||||
|
--ink: #272235;
|
||||||
|
|
||||||
|
--bg-primary: #fff9fb;
|
||||||
|
--bg-page: linear-gradient(180deg, #fff8f2 0%, #fff4fb 50%, #f5f0ff 100%);
|
||||||
|
|
||||||
|
--text-primary: #272235;
|
||||||
|
--text-secondary: #6a6178;
|
||||||
|
--text-tertiary: #9b8fa8;
|
||||||
|
|
||||||
|
--border-card: rgba(255, 255, 255, 0.72);
|
||||||
|
--border-subtle: rgba(43, 37, 61, 0.08);
|
||||||
|
|
||||||
|
--shadow-soft: 0 18rpx 40rpx rgba(95, 49, 104, 0.12);
|
||||||
|
--shadow-card: 0 10rpx 28rpx rgba(78, 56, 96, 0.10);
|
||||||
|
--shadow-pop: 0 10rpx 22rpx rgba(255, 79, 145, 0.28);
|
||||||
|
|
||||||
|
--radius-sm: 16rpx;
|
||||||
|
--radius-md: 28rpx;
|
||||||
|
--radius-lg: 44rpx;
|
||||||
|
--radius-full: 9999rpx;
|
||||||
|
|
||||||
|
--tab-h: 112rpx;
|
||||||
|
|
||||||
|
font-family: "PingFang SC", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 重置 ── */
|
||||||
|
view, text, image, button, input, textarea {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
line-height: 1;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 通用卡片 ── */
|
||||||
|
.card {
|
||||||
|
background: rgba(255, 255, 255, 0.86);
|
||||||
|
border: 1rpx solid var(--border-card);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 渐变按钮 ── */
|
||||||
|
.btn-primary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, var(--pink), var(--orange));
|
||||||
|
color: #fff;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
box-shadow: var(--shadow-pop);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 页面顶部状态栏占位 ── */
|
||||||
|
.status-placeholder {
|
||||||
|
height: env(safe-area-inset-top, 44rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 滚动区通用 ── */
|
||||||
|
.scroll-view {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hashtag 标签 ── */
|
||||||
|
.hashtag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #8b3cff;
|
||||||
|
background: #f0e8ff;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
padding: 6rpx 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 在线绿点 ── */
|
||||||
|
.online-dot {
|
||||||
|
width: 14rpx;
|
||||||
|
height: 14rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--green);
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 0 0 5rpx rgba(47, 211, 122, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 骨架屏占位 ── */
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(90deg, #f0eef5 25%, #e8e4f0 50%, #f0eef5 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: skeleton-shine 1.4s infinite;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton-shine {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 安全区 ── */
|
||||||
|
.safe-top {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.safe-bottom {
|
||||||
|
height: env(safe-area-inset-bottom, 0rpx);
|
||||||
|
}
|
||||||
4
miniprogram/components/post-card/post-card.json
Normal file
4
miniprogram/components/post-card/post-card.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"component": true,
|
||||||
|
"usingComponents": {}
|
||||||
|
}
|
||||||
87
miniprogram/components/post-card/post-card.ts
Normal file
87
miniprogram/components/post-card/post-card.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { Post } from '../../types/index'
|
||||||
|
import { formatTime, formatCount, getAvatarGradient } from '../../utils/format'
|
||||||
|
import { api } from '../../utils/api'
|
||||||
|
|
||||||
|
Component({
|
||||||
|
properties: {
|
||||||
|
post: {
|
||||||
|
type: Object,
|
||||||
|
value: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
data: {
|
||||||
|
avatarGradient: '',
|
||||||
|
timeText: '',
|
||||||
|
likeCount: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
observers: {
|
||||||
|
post(val: Post) {
|
||||||
|
if (!val) return
|
||||||
|
this.setData({
|
||||||
|
avatarGradient: getAvatarGradient(val.authorId || ''),
|
||||||
|
timeText: formatTime(val.createdAt),
|
||||||
|
likeCount: val.likeCount,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
onCardTap() {
|
||||||
|
const post = this.data.post as Post
|
||||||
|
if (!post) return
|
||||||
|
wx.navigateTo({ url: `/pages/post-detail/post-detail?id=${post._id}` })
|
||||||
|
},
|
||||||
|
|
||||||
|
onAuthorTap(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
e.stopPropagation()
|
||||||
|
const post = this.data.post as Post
|
||||||
|
if (!post?.authorId) return
|
||||||
|
wx.navigateTo({ url: `/pages/pet-detail/pet-detail?userId=${post.authorId}` })
|
||||||
|
},
|
||||||
|
|
||||||
|
onImageTap(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
const post = this.data.post as Post
|
||||||
|
if (!post?.images?.length) return
|
||||||
|
wx.previewImage({
|
||||||
|
current: post.images[e.currentTarget.dataset.idx as number],
|
||||||
|
urls: post.images,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async onLike(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
e.stopPropagation()
|
||||||
|
const post = this.data.post as Post
|
||||||
|
if (!post) return
|
||||||
|
const wasLiked = post.isLiked
|
||||||
|
const prevCount = this.data.likeCount
|
||||||
|
|
||||||
|
this.setData({
|
||||||
|
'post.isLiked': !wasLiked,
|
||||||
|
likeCount: wasLiked ? prevCount - 1 : prevCount + 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.likePost(post._id)
|
||||||
|
this.setData({ likeCount: res.count, 'post.isLiked': res.liked })
|
||||||
|
this.triggerEvent('likeChange', { postId: post._id, liked: res.liked, count: res.count })
|
||||||
|
} catch {
|
||||||
|
this.setData({ 'post.isLiked': wasLiked, likeCount: prevCount })
|
||||||
|
wx.showToast({ title: '操作失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onComment(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
e.stopPropagation()
|
||||||
|
const post = this.data.post as Post
|
||||||
|
if (!post) return
|
||||||
|
wx.navigateTo({ url: `/pages/post-detail/post-detail?id=${post._id}&focus=comment` })
|
||||||
|
},
|
||||||
|
|
||||||
|
onShare(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
e.stopPropagation()
|
||||||
|
wx.showShareMenu({ withShareTicket: true, menus: ['shareAppMessage', 'shareTimeline'] })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
72
miniprogram/components/post-card/post-card.wxml
Normal file
72
miniprogram/components/post-card/post-card.wxml
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<view class="post-card" bindtap="onCardTap">
|
||||||
|
<!-- 作者头部 -->
|
||||||
|
<view class="post-header">
|
||||||
|
<view class="post-avatar" style="background: {{avatarGradient}}" bindtap="onAuthorTap" catchtap="onAuthorTap">
|
||||||
|
<text>{{post.author.nickName[0] || '?'}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="post-meta">
|
||||||
|
<text class="post-username">{{post.author.nickName}}</text>
|
||||||
|
<view class="post-loc" wx:if="{{post.location}}">
|
||||||
|
<text class="loc-icon">📍</text>
|
||||||
|
<text class="loc-text">{{post.location.name}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="post-time">{{timeText}}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 图片 -->
|
||||||
|
<view wx:if="{{post.images && post.images.length > 0}}" class="post-images-wrap">
|
||||||
|
<!-- 单图全宽 -->
|
||||||
|
<view wx:if="{{post.images.length === 1}}" class="post-img-single">
|
||||||
|
<image
|
||||||
|
class="img-single"
|
||||||
|
src="{{post.images[0]}}"
|
||||||
|
mode="aspectFill"
|
||||||
|
lazy-load="true"
|
||||||
|
bindtap="onImageTap"
|
||||||
|
data-idx="0"
|
||||||
|
/>
|
||||||
|
<view wx:if="{{post.mood}}" class="post-tag-chip">{{post.mood}}</view>
|
||||||
|
</view>
|
||||||
|
<!-- 多图网格 -->
|
||||||
|
<view wx:else class="post-img-grid post-img-grid-{{post.images.length > 3 ? 'multi' : post.images.length}}">
|
||||||
|
<image
|
||||||
|
wx:for="{{post.images}}"
|
||||||
|
wx:key="index"
|
||||||
|
wx:if="{{index < 9}}"
|
||||||
|
class="img-grid-item"
|
||||||
|
src="{{item}}"
|
||||||
|
mode="aspectFill"
|
||||||
|
lazy-load="true"
|
||||||
|
bindtap="onImageTap"
|
||||||
|
data-idx="{{index}}"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 正文 -->
|
||||||
|
<view class="post-body">
|
||||||
|
<text class="post-caption" decode="{{true}}">{{post.content}}</text>
|
||||||
|
|
||||||
|
<!-- hashtags -->
|
||||||
|
<view wx:if="{{post.hashtags && post.hashtags.length > 0}}" class="post-tags">
|
||||||
|
<text wx:for="{{post.hashtags}}" wx:key="index" class="hashtag">{{item}}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 操作栏 -->
|
||||||
|
<view class="post-actions">
|
||||||
|
<view class="action-btn {{post.isLiked ? 'liked' : ''}}" bindtap="onLike" catchtap="onLike">
|
||||||
|
<text class="action-icon">{{post.isLiked ? '❤️' : '🤍'}}</text>
|
||||||
|
<text class="action-count">{{likeCount}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="action-btn" bindtap="onComment" catchtap="onComment">
|
||||||
|
<text class="action-icon">💬</text>
|
||||||
|
<text class="action-count">{{post.commentCount}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="action-btn" bindtap="onShare" catchtap="onShare">
|
||||||
|
<text class="action-icon">↗</text>
|
||||||
|
<text class="action-count">转发</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
185
miniprogram/components/post-card/post-card.wxss
Normal file
185
miniprogram/components/post-card/post-card.wxss
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
.post-card {
|
||||||
|
margin: 0 28rpx 32rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.88);
|
||||||
|
border-radius: 48rpx;
|
||||||
|
border: 1rpx solid rgba(255, 255, 255, 0.72);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 18rpx 40rpx rgba(95, 49, 104, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 头部 */
|
||||||
|
.post-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
padding: 24rpx 24rpx 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-avatar {
|
||||||
|
width: 76rpx;
|
||||||
|
height: 76rpx;
|
||||||
|
border-radius: 28rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: rgba(39, 34, 53, 0.72);
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-meta {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-username {
|
||||||
|
display: block;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #272235;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-loc {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4rpx;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-icon { font-size: 20rpx; }
|
||||||
|
|
||||||
|
.loc-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #9b8fa8;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-time {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #9b8fa8;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 图片区 */
|
||||||
|
.post-img-single {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-single {
|
||||||
|
width: 100%;
|
||||||
|
height: 360rpx;
|
||||||
|
display: block;
|
||||||
|
background: #f0eef5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-tag-chip {
|
||||||
|
position: absolute;
|
||||||
|
top: 20rpx;
|
||||||
|
left: 20rpx;
|
||||||
|
background: rgba(39, 34, 53, 0.88);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
padding: 8rpx 18rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-img-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-img-grid-2 {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
height: 300rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-img-grid-3 {
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
grid-template-rows: 1fr 1fr;
|
||||||
|
height: 320rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-img-grid-3 .img-grid-item:first-child {
|
||||||
|
grid-row: 1 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-img-grid-multi {
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
height: 320rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-grid-item {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #f0eef5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 正文区 */
|
||||||
|
.post-body {
|
||||||
|
padding: 20rpx 24rpx 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-caption {
|
||||||
|
display: block;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #272235;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 10rpx;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hashtag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #8b3cff;
|
||||||
|
background: #f0e8ff;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
padding: 6rpx 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 操作栏 */
|
||||||
|
.post-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding-top: 18rpx;
|
||||||
|
border-top: 1rpx solid rgba(43, 37, 61, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #6a6178;
|
||||||
|
background: #f7f3ff;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
padding: 10rpx 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.liked {
|
||||||
|
color: #ff4f91;
|
||||||
|
background: #ffe5f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-icon {
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-count {
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
4
miniprogram/custom-tab-bar/index.json
Normal file
4
miniprogram/custom-tab-bar/index.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"component": true,
|
||||||
|
"usingComponents": {}
|
||||||
|
}
|
||||||
34
miniprogram/custom-tab-bar/index.ts
Normal file
34
miniprogram/custom-tab-bar/index.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
Component({
|
||||||
|
data: {
|
||||||
|
selected: 0,
|
||||||
|
unreadCount: 0,
|
||||||
|
tabs: [
|
||||||
|
{ pagePath: '/pages/feed/feed' },
|
||||||
|
{ pagePath: '/pages/nearby/nearby' },
|
||||||
|
{ pagePath: '/pages/friends/friends' },
|
||||||
|
{ pagePath: '/pages/profile/profile' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
onTab(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
const index = e.currentTarget.dataset.index as number
|
||||||
|
const path = e.currentTarget.dataset.path as string
|
||||||
|
if (index === this.data.selected) return
|
||||||
|
this.setData({ selected: index })
|
||||||
|
wx.switchTab({ url: path })
|
||||||
|
},
|
||||||
|
|
||||||
|
onPost() {
|
||||||
|
wx.navigateTo({ url: '/pages/post/post' })
|
||||||
|
},
|
||||||
|
|
||||||
|
setSelected(index: number) {
|
||||||
|
this.setData({ selected: index })
|
||||||
|
},
|
||||||
|
|
||||||
|
setUnread(count: number) {
|
||||||
|
this.setData({ unreadCount: count })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
56
miniprogram/custom-tab-bar/index.wxml
Normal file
56
miniprogram/custom-tab-bar/index.wxml
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<view class="tab-bar">
|
||||||
|
|
||||||
|
<!-- 广场 -->
|
||||||
|
<view class="tab-item {{selected === 0 ? 'active' : ''}}" bindtap="onTab" data-index="0" data-path="/pages/feed/feed">
|
||||||
|
<view class="icon-wrap">
|
||||||
|
<view class="icon-grid">
|
||||||
|
<view class="sq"></view><view class="sq"></view>
|
||||||
|
<view class="sq"></view><view class="sq"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="tab-label">广场</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 附近 -->
|
||||||
|
<view class="tab-item {{selected === 1 ? 'active' : ''}}" bindtap="onTab" data-index="1" data-path="/pages/nearby/nearby">
|
||||||
|
<view class="icon-wrap">
|
||||||
|
<view class="icon-pin">
|
||||||
|
<view class="pin-head"></view>
|
||||||
|
<view class="pin-tail"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="tab-label">附近</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 发布 FAB -->
|
||||||
|
<view class="tab-fab-wrap">
|
||||||
|
<view class="tab-fab" bindtap="onPost">
|
||||||
|
<text class="fab-plus">+</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 汪友 -->
|
||||||
|
<view class="tab-item {{selected === 2 ? 'active' : ''}}" bindtap="onTab" data-index="2" data-path="/pages/friends/friends">
|
||||||
|
<view class="icon-wrap" style="position:relative">
|
||||||
|
<view class="icon-ppl">
|
||||||
|
<view class="ppl-head ppl-head-l"></view>
|
||||||
|
<view class="ppl-head ppl-head-r"></view>
|
||||||
|
<view class="ppl-body"></view>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{unreadCount > 0}}" class="badge">{{unreadCount > 99 ? '99+' : unreadCount}}</view>
|
||||||
|
</view>
|
||||||
|
<text class="tab-label">汪友</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 我的 -->
|
||||||
|
<view class="tab-item {{selected === 3 ? 'active' : ''}}" bindtap="onTab" data-index="3" data-path="/pages/profile/profile">
|
||||||
|
<view class="icon-wrap">
|
||||||
|
<view class="icon-user">
|
||||||
|
<view class="user-head"></view>
|
||||||
|
<view class="user-body"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="tab-label">我的</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
</view>
|
||||||
218
miniprogram/custom-tab-bar/index.wxss
Normal file
218
miniprogram/custom-tab-bar/index.wxss
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
/* ── 底部导航栏 ── */
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-top: 1rpx solid rgba(255, 255, 255, 0.65);
|
||||||
|
background: rgba(255, 255, 255, 0.90);
|
||||||
|
padding: 10rpx 0 calc(10rpx + env(safe-area-inset-bottom));
|
||||||
|
box-shadow: 0 -10rpx 24rpx rgba(110, 78, 131, 0.08);
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tab 项 ── */
|
||||||
|
.tab-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5rpx;
|
||||||
|
border-radius: 22rpx;
|
||||||
|
padding: 8rpx 0 6rpx;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item.active {
|
||||||
|
background: linear-gradient(180deg, rgba(255, 79, 145, 0.12), rgba(255, 225, 90, 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-label {
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #9b8fa8;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item.active .tab-label {
|
||||||
|
color: #ff4f91;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 图标容器 ── */
|
||||||
|
.icon-wrap {
|
||||||
|
width: 44rpx;
|
||||||
|
height: 44rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 广场图标:2×2 圆角方块网格 ── */
|
||||||
|
.icon-grid {
|
||||||
|
width: 34rpx;
|
||||||
|
height: 34rpx;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
grid-template-rows: 1fr 1fr;
|
||||||
|
gap: 5rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-grid .sq {
|
||||||
|
background: #9b8fa8;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item.active .icon-grid .sq {
|
||||||
|
background: #ff4f91;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 附近图标:地图 Pin ── */
|
||||||
|
.icon-pin {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-head {
|
||||||
|
width: 22rpx;
|
||||||
|
height: 22rpx;
|
||||||
|
border-radius: 50% 50% 50% 0;
|
||||||
|
background: #9b8fa8;
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-tail {
|
||||||
|
width: 4rpx;
|
||||||
|
height: 8rpx;
|
||||||
|
background: #9b8fa8;
|
||||||
|
border-radius: 0 0 4rpx 4rpx;
|
||||||
|
margin-top: -2rpx;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item.active .pin-head,
|
||||||
|
.tab-item.active .pin-tail {
|
||||||
|
background: #ff4f91;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 汪友图标:双人轮廓 ── */
|
||||||
|
.icon-ppl {
|
||||||
|
width: 36rpx;
|
||||||
|
height: 30rpx;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ppl-head {
|
||||||
|
position: absolute;
|
||||||
|
width: 14rpx;
|
||||||
|
height: 14rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #9b8fa8;
|
||||||
|
top: 0;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ppl-head-l { left: 2rpx; }
|
||||||
|
.ppl-head-r { right: 2rpx; }
|
||||||
|
|
||||||
|
.ppl-body {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 14rpx;
|
||||||
|
background: #9b8fa8;
|
||||||
|
border-radius: 8rpx 8rpx 0 0;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item.active .ppl-head,
|
||||||
|
.tab-item.active .ppl-body {
|
||||||
|
background: #ff4f91;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 我的图标:单人轮廓 ── */
|
||||||
|
.icon-user {
|
||||||
|
width: 28rpx;
|
||||||
|
height: 34rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-head {
|
||||||
|
width: 18rpx;
|
||||||
|
height: 18rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #9b8fa8;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-body {
|
||||||
|
width: 28rpx;
|
||||||
|
height: 14rpx;
|
||||||
|
border-radius: 8rpx 8rpx 0 0;
|
||||||
|
background: #9b8fa8;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item.active .user-head,
|
||||||
|
.tab-item.active .user-body {
|
||||||
|
background: #ff4f91;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 发布 FAB ── */
|
||||||
|
.tab-fab-wrap {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-bottom: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-fab {
|
||||||
|
width: 92rpx;
|
||||||
|
height: 92rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #ff4f91, #ff9f1c 55%, #ffe15a);
|
||||||
|
border: 4rpx solid rgba(255, 255, 255, 0.90);
|
||||||
|
box-shadow: 0 10rpx 22rpx rgba(255, 79, 145, 0.28);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab-plus {
|
||||||
|
font-size: 52rpx;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1;
|
||||||
|
margin-top: -4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 未读角标 ── */
|
||||||
|
.badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -4rpx;
|
||||||
|
right: -6rpx;
|
||||||
|
min-width: 30rpx;
|
||||||
|
height: 30rpx;
|
||||||
|
border-radius: 15rpx;
|
||||||
|
background: #ff4f91;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 6rpx;
|
||||||
|
border: 2rpx solid #fff;
|
||||||
|
}
|
||||||
1
miniprogram/pages/chat/chat.json
Normal file
1
miniprogram/pages/chat/chat.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{ "navigationStyle": "custom", "usingComponents": {} }
|
||||||
92
miniprogram/pages/chat/chat.ts
Normal file
92
miniprogram/pages/chat/chat.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { api } from '../../utils/api'
|
||||||
|
|
||||||
|
const app = getApp<{ userInfo: any }>()
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
statusBarHeight: 0,
|
||||||
|
peerId: '',
|
||||||
|
peerName: '',
|
||||||
|
messages: [] as any[],
|
||||||
|
inputText: '',
|
||||||
|
scrollToMsg: '',
|
||||||
|
},
|
||||||
|
|
||||||
|
_watcher: null as any,
|
||||||
|
|
||||||
|
onLoad(query: { userId?: string }) {
|
||||||
|
const info = wx.getSystemInfoSync()
|
||||||
|
this.setData({ statusBarHeight: info.statusBarHeight, peerId: query.userId || '' })
|
||||||
|
this.loadMessages()
|
||||||
|
this.subscribeRealtime()
|
||||||
|
},
|
||||||
|
|
||||||
|
onUnload() {
|
||||||
|
this._watcher?.close()
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadMessages() {
|
||||||
|
try {
|
||||||
|
const res = await api.getMessages()
|
||||||
|
const msgs = res.list.map((m: any) => ({
|
||||||
|
...m,
|
||||||
|
isMe: m.fromId === app.globalData?.userInfo?._id,
|
||||||
|
}))
|
||||||
|
this.setData({ messages: msgs })
|
||||||
|
this.scrollToBottom()
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
subscribeRealtime() {
|
||||||
|
const db = wx.cloud.database()
|
||||||
|
this._watcher = db.collection('messages')
|
||||||
|
.where({ toId: app.globalData?.userInfo?._id })
|
||||||
|
.watch({
|
||||||
|
onChange: snapshot => {
|
||||||
|
if (snapshot.type === 'add') {
|
||||||
|
const newMsgs = snapshot.docs.map((d: any) => ({ ...d, isMe: false }))
|
||||||
|
this.setData({ messages: [...this.data.messages, ...newMsgs] })
|
||||||
|
this.scrollToBottom()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
scrollToBottom() {
|
||||||
|
const msgs = this.data.messages
|
||||||
|
if (msgs.length > 0) {
|
||||||
|
this.setData({ scrollToMsg: `msg-${msgs[msgs.length - 1]._id}` })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onInput(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
this.setData({ inputText: e.detail.value })
|
||||||
|
},
|
||||||
|
|
||||||
|
async onSend() {
|
||||||
|
const text = this.data.inputText.trim()
|
||||||
|
if (!text) return
|
||||||
|
this.setData({ inputText: '' })
|
||||||
|
|
||||||
|
const tempMsg = {
|
||||||
|
_id: `temp_${Date.now()}`,
|
||||||
|
content: text,
|
||||||
|
isMe: true,
|
||||||
|
fromId: app.globalData?.userInfo?._id,
|
||||||
|
toId: this.data.peerId,
|
||||||
|
}
|
||||||
|
this.setData({ messages: [...this.data.messages, tempMsg] })
|
||||||
|
this.scrollToBottom()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.sendMessage(this.data.peerId, text)
|
||||||
|
} catch {
|
||||||
|
wx.showToast({ title: '发送失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onBack() {
|
||||||
|
wx.navigateBack()
|
||||||
|
},
|
||||||
|
})
|
||||||
21
miniprogram/pages/chat/chat.wxml
Normal file
21
miniprogram/pages/chat/chat.wxml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<view class="chat-page">
|
||||||
|
<view class="safe-top" style="height: {{statusBarHeight}}px"></view>
|
||||||
|
<view class="chat-nav">
|
||||||
|
<view class="nav-back" bindtap="onBack">‹</view>
|
||||||
|
<view class="nav-title">{{peerName}}</view>
|
||||||
|
<view class="nav-more">⋯</view>
|
||||||
|
</view>
|
||||||
|
<scroll-view scroll-y="true" class="msg-scroll" scroll-into-view="{{scrollToMsg}}" enhanced="true" show-scrollbar="false">
|
||||||
|
<view wx:for="{{messages}}" wx:key="_id" id="msg-{{item._id}}"
|
||||||
|
class="msg-row {{item.isMe ? 'msg-right' : 'msg-left'}}">
|
||||||
|
<view class="msg-bubble {{item.isMe ? 'bubble-me' : 'bubble-peer'}}">
|
||||||
|
<text>{{item.content}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view style="height: 160rpx;"></view>
|
||||||
|
</scroll-view>
|
||||||
|
<view class="input-bar">
|
||||||
|
<input class="msg-input" value="{{inputText}}" bindinput="onInput" placeholder="发消息..." adjust-position="true" cursor-spacing="20" />
|
||||||
|
<view class="send-btn {{inputText ? '' : 'send-disabled'}}" bindtap="onSend">发送</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
16
miniprogram/pages/chat/chat.wxss
Normal file
16
miniprogram/pages/chat/chat.wxss
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
.chat-page { display: flex; flex-direction: column; height: 100vh; background: #f5f0ff; }
|
||||||
|
.chat-nav { display: flex; align-items: center; padding: 16rpx 28rpx; background: rgba(255,255,255,0.90); border-bottom: 1rpx solid rgba(43,37,61,0.08); }
|
||||||
|
.nav-back { font-size: 60rpx; color: #272235; font-weight: 300; padding: 0 10rpx; line-height: 1; }
|
||||||
|
.nav-title { flex: 1; font-size: 32rpx; font-weight: 900; color: #272235; text-align: center; }
|
||||||
|
.nav-more { font-size: 36rpx; color: #9b8fa8; padding: 0 10rpx; }
|
||||||
|
.msg-scroll { flex: 1; padding: 20rpx 28rpx; }
|
||||||
|
.msg-row { display: flex; margin-bottom: 20rpx; }
|
||||||
|
.msg-right { justify-content: flex-end; }
|
||||||
|
.msg-left { justify-content: flex-start; }
|
||||||
|
.msg-bubble { max-width: 75%; padding: 18rpx 24rpx; border-radius: 28rpx; font-size: 28rpx; line-height: 1.5; }
|
||||||
|
.bubble-me { background: linear-gradient(135deg, #ff4f91, #ff9f1c); color: #fff; border-bottom-right-radius: 8rpx; }
|
||||||
|
.bubble-peer { background: rgba(255,255,255,0.90); color: #272235; border-bottom-left-radius: 8rpx; box-shadow: 0 6rpx 14rpx rgba(78,56,96,0.09); }
|
||||||
|
.input-bar { display: flex; align-items: center; gap: 14rpx; padding: 16rpx 28rpx; padding-bottom: calc(16rpx + env(safe-area-inset-bottom)); background: rgba(255,255,255,0.92); border-top: 1rpx solid rgba(43,37,61,0.08); }
|
||||||
|
.msg-input { flex: 1; height: 80rpx; background: rgba(255,255,255,0.80); border: 1rpx solid rgba(43,37,61,0.10); border-radius: 999rpx; padding: 0 24rpx; font-size: 28rpx; color: #272235; }
|
||||||
|
.send-btn { background: linear-gradient(135deg, #ff4f91, #ff9f1c); color: #fff; border-radius: 999rpx; padding: 18rpx 30rpx; font-size: 28rpx; font-weight: 800; flex-shrink: 0; }
|
||||||
|
.send-disabled { background: #e8e4f0; color: #9b8fa8; }
|
||||||
1
miniprogram/pages/edit-profile/edit-profile.json
Normal file
1
miniprogram/pages/edit-profile/edit-profile.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{ "navigationStyle": "custom", "usingComponents": {} }
|
||||||
46
miniprogram/pages/edit-profile/edit-profile.ts
Normal file
46
miniprogram/pages/edit-profile/edit-profile.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
const app = getApp<{ userInfo: any }>()
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
statusBarHeight: 0,
|
||||||
|
form: { nickName: '', bio: '', location: '' },
|
||||||
|
},
|
||||||
|
|
||||||
|
onLoad() {
|
||||||
|
const info = wx.getSystemInfoSync()
|
||||||
|
this.setData({ statusBarHeight: info.statusBarHeight })
|
||||||
|
const user = app.globalData?.userInfo
|
||||||
|
if (user) {
|
||||||
|
this.setData({
|
||||||
|
form: { nickName: user.nickName || '', bio: user.bio || '', location: user.location || '' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onField(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
this.setData({ [`form.${e.currentTarget.dataset.key}`]: e.detail.value })
|
||||||
|
},
|
||||||
|
|
||||||
|
async onSave() {
|
||||||
|
const { form } = this.data
|
||||||
|
if (!form.nickName.trim()) {
|
||||||
|
wx.showToast({ title: '昵称不能为空', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.showLoading({ title: '保存中...' })
|
||||||
|
try {
|
||||||
|
await wx.cloud.callFunction({ name: 'updateProfile', data: form })
|
||||||
|
if (app.globalData.userInfo) {
|
||||||
|
Object.assign(app.globalData.userInfo, form)
|
||||||
|
}
|
||||||
|
wx.hideLoading()
|
||||||
|
wx.showToast({ title: '保存成功', icon: 'success' })
|
||||||
|
setTimeout(() => wx.navigateBack(), 800)
|
||||||
|
} catch {
|
||||||
|
wx.hideLoading()
|
||||||
|
wx.showToast({ title: '保存失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onBack() { wx.navigateBack() },
|
||||||
|
})
|
||||||
27
miniprogram/pages/edit-profile/edit-profile.wxml
Normal file
27
miniprogram/pages/edit-profile/edit-profile.wxml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<view class="page">
|
||||||
|
<view class="safe-top" style="height: {{statusBarHeight}}px"></view>
|
||||||
|
<view class="nav">
|
||||||
|
<view class="nav-back" bindtap="onBack">取消</view>
|
||||||
|
<view class="nav-title">编辑资料</view>
|
||||||
|
<view class="nav-save" bindtap="onSave">保存</view>
|
||||||
|
</view>
|
||||||
|
<scroll-view scroll-y="true" enhanced="true" show-scrollbar="false">
|
||||||
|
<view class="form">
|
||||||
|
<view class="field-group">
|
||||||
|
<view class="field-item">
|
||||||
|
<text class="field-label">昵称</text>
|
||||||
|
<input class="field-input" value="{{form.nickName}}" bindinput="onField" data-key="nickName" placeholder="你的昵称" maxlength="20" />
|
||||||
|
</view>
|
||||||
|
<view class="field-item field-item-col">
|
||||||
|
<text class="field-label">个人简介</text>
|
||||||
|
<textarea class="field-textarea" value="{{form.bio}}" bindinput="onField" data-key="bio" placeholder="介绍一下你和你的宠物..." maxlength="80" auto-height="true" show-confirm-bar="false" />
|
||||||
|
</view>
|
||||||
|
<view class="field-item">
|
||||||
|
<text class="field-label">所在城市</text>
|
||||||
|
<input class="field-input" value="{{form.location}}" bindinput="onField" data-key="location" placeholder="所在城市" maxlength="20" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view style="height: 60rpx;"></view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
13
miniprogram/pages/edit-profile/edit-profile.wxss
Normal file
13
miniprogram/pages/edit-profile/edit-profile.wxss
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
.page { min-height: 100vh; background: linear-gradient(180deg, #fff8f2 0%, #f5f0ff 100%); }
|
||||||
|
.nav { display: flex; align-items: center; padding: 14rpx 28rpx; gap: 16rpx; }
|
||||||
|
.nav-back { font-size: 28rpx; font-weight: 700; color: #9b8fa8; }
|
||||||
|
.nav-title { flex: 1; font-size: 32rpx; font-weight: 900; color: #272235; text-align: center; }
|
||||||
|
.nav-save { font-size: 28rpx; font-weight: 900; color: #ff4f91; }
|
||||||
|
.form { padding: 24rpx 28rpx; }
|
||||||
|
.field-group { background: rgba(255,255,255,0.90); border-radius: 36rpx; border: 1rpx solid rgba(255,255,255,0.72); overflow: hidden; box-shadow: 0 12rpx 26rpx rgba(78,56,96,0.08); }
|
||||||
|
.field-item { display: flex; align-items: center; justify-content: space-between; padding: 24rpx 28rpx; border-bottom: 1rpx solid rgba(43,37,61,0.06); }
|
||||||
|
.field-item:last-child { border-bottom: none; }
|
||||||
|
.field-item-col { flex-direction: column; align-items: flex-start; gap: 14rpx; }
|
||||||
|
.field-label { font-size: 28rpx; font-weight: 700; color: #272235; flex-shrink: 0; margin-right: 20rpx; }
|
||||||
|
.field-input { flex: 1; font-size: 28rpx; color: #272235; text-align: right; }
|
||||||
|
.field-textarea { width: 100%; min-height: 80rpx; font-size: 28rpx; color: #272235; line-height: 1.6; }
|
||||||
6
miniprogram/pages/feed/feed.json
Normal file
6
miniprogram/pages/feed/feed.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"usingComponents": {
|
||||||
|
"post-card": "/components/post-card/post-card"
|
||||||
|
}
|
||||||
|
}
|
||||||
155
miniprogram/pages/feed/feed.ts
Normal file
155
miniprogram/pages/feed/feed.ts
Normal 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',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
111
miniprogram/pages/feed/feed.wxml
Normal file
111
miniprogram/pages/feed/feed.wxml
Normal 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>
|
||||||
264
miniprogram/pages/feed/feed.wxss
Normal file
264
miniprogram/pages/feed/feed.wxss
Normal 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;
|
||||||
|
}
|
||||||
4
miniprogram/pages/friends/friends.json
Normal file
4
miniprogram/pages/friends/friends.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"usingComponents": {}
|
||||||
|
}
|
||||||
87
miniprogram/pages/friends/friends.ts
Normal file
87
miniprogram/pages/friends/friends.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { UserProfile } from '../../types/index'
|
||||||
|
import { api } from '../../utils/api'
|
||||||
|
import { getAvatarGradient } from '../../utils/format'
|
||||||
|
|
||||||
|
const app = getApp<{ userInfo: any }>()
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
statusBarHeight: 0,
|
||||||
|
tabBarHeight: 56,
|
||||||
|
activeTab: 'following' as 'following' | 'followers',
|
||||||
|
list: [] as any[],
|
||||||
|
followingCount: 0,
|
||||||
|
followersCount: 0,
|
||||||
|
loading: false,
|
||||||
|
refreshing: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
onLoad() {
|
||||||
|
const info = wx.getSystemInfoSync()
|
||||||
|
this.setData({ statusBarHeight: info.statusBarHeight })
|
||||||
|
this.loadList()
|
||||||
|
},
|
||||||
|
|
||||||
|
onShow() {
|
||||||
|
const tabBar = this.getTabBar() as any
|
||||||
|
tabBar?.setSelected?.(2)
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadList() {
|
||||||
|
this.setData({ loading: true })
|
||||||
|
try {
|
||||||
|
const list = await api.getFollowList(this.data.activeTab)
|
||||||
|
const enriched = list.map(u => ({
|
||||||
|
...u,
|
||||||
|
gradient: getAvatarGradient(u._id),
|
||||||
|
isOnline: u.isOnline,
|
||||||
|
}))
|
||||||
|
this.setData({
|
||||||
|
list: enriched,
|
||||||
|
[this.data.activeTab === 'following' ? 'followingCount' : 'followersCount']: list.length,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
wx.showToast({ title: '加载失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
this.setData({ loading: false, refreshing: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
switchTab(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
const tab = e.currentTarget.dataset.tab as 'following' | 'followers'
|
||||||
|
if (tab === this.data.activeTab) return
|
||||||
|
this.setData({ activeTab: tab, list: [] })
|
||||||
|
this.loadList()
|
||||||
|
},
|
||||||
|
|
||||||
|
async onFollowTap(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
const uid = e.currentTarget.dataset.uid as string
|
||||||
|
try {
|
||||||
|
await api.followUser(uid)
|
||||||
|
if (this.data.activeTab === 'following') {
|
||||||
|
const list = this.data.list.filter((u: any) => u._id !== uid)
|
||||||
|
this.setData({ list, followingCount: list.length })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
wx.showToast({ title: '操作失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onItemTap(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
const uid = e.currentTarget.dataset.uid
|
||||||
|
wx.navigateTo({ url: `/pages/pet-detail/pet-detail?userId=${uid}` })
|
||||||
|
},
|
||||||
|
|
||||||
|
onFindFriends() {
|
||||||
|
wx.switchTab({ url: '/pages/nearby/nearby' })
|
||||||
|
},
|
||||||
|
|
||||||
|
onGoNearby() {
|
||||||
|
wx.switchTab({ url: '/pages/nearby/nearby' })
|
||||||
|
},
|
||||||
|
|
||||||
|
async onRefresh() {
|
||||||
|
this.setData({ refreshing: true })
|
||||||
|
await this.loadList()
|
||||||
|
},
|
||||||
|
})
|
||||||
66
miniprogram/pages/friends/friends.wxml
Normal file
66
miniprogram/pages/friends/friends.wxml
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<view class="friends-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 class="ftab {{activeTab === 'following' ? 'ftab-active' : ''}}" bindtap="switchTab" data-tab="following">
|
||||||
|
关注 <text class="ftab-count">{{followingCount}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="ftab {{activeTab === 'followers' ? 'ftab-active' : ''}}" bindtap="switchTab" data-tab="followers">
|
||||||
|
粉丝 <text class="ftab-count">{{followersCount}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="topbar-btn" bindtap="onFindFriends">+</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 空态 -->
|
||||||
|
<view wx:if="{{!loading && list.length === 0}}" class="empty-wrap">
|
||||||
|
<view class="empty-inner">
|
||||||
|
<text class="empty-emoji">🐾</text>
|
||||||
|
<text class="empty-title">还没有{{activeTab === 'following' ? '关注' : '粉丝'}}</text>
|
||||||
|
<text class="empty-desc">去附近认识新朋友,一起遛狗打卡吧</text>
|
||||||
|
<view class="btn-primary go-btn" bindtap="onGoNearby">去附近找</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 列表 -->
|
||||||
|
<scroll-view
|
||||||
|
wx:else
|
||||||
|
scroll-y="true"
|
||||||
|
class="friends-scroll"
|
||||||
|
enhanced="true"
|
||||||
|
show-scrollbar="false"
|
||||||
|
refresher-enabled="true"
|
||||||
|
bindrefresherrefresh="onRefresh"
|
||||||
|
refresher-triggered="{{refreshing}}"
|
||||||
|
>
|
||||||
|
<view
|
||||||
|
wx:for="{{list}}"
|
||||||
|
wx:key="_id"
|
||||||
|
class="friend-item"
|
||||||
|
bindtap="onItemTap"
|
||||||
|
data-uid="{{item._id}}"
|
||||||
|
>
|
||||||
|
<view class="friend-avatar" style="background: {{item.gradient}}">
|
||||||
|
<text>{{item.nickName[0]}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="friend-info">
|
||||||
|
<view class="friend-name-row">
|
||||||
|
<text class="friend-name">{{item.nickName}}</text>
|
||||||
|
<view wx:if="{{item.isOnline}}" class="online-tag">在线</view>
|
||||||
|
</view>
|
||||||
|
<text class="friend-bio">{{item.pets[0] ? item.pets[0].emoji + ' ' + item.pets[0].name + ' · ' + item.pets[0].breed : item.bio || '宠物爱好者'}}</text>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
class="follow-btn {{activeTab === 'following' ? 'following' : ''}}"
|
||||||
|
catchtap="onFollowTap"
|
||||||
|
data-uid="{{item._id}}"
|
||||||
|
>
|
||||||
|
{{activeTab === 'following' ? '已关注' : '关注'}}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view style="height: {{tabBarHeight}}px"></view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
139
miniprogram/pages/friends/friends.wxss
Normal file
139
miniprogram/pages/friends/friends.wxss
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
.friends-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(180deg, #fff8f2 0%, #f5f0ff 100%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16rpx 28rpx 12rpx;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-logo { font-size: 44rpx; font-weight: 900; color: #272235; }
|
||||||
|
|
||||||
|
.logo-accent { color: #ff4f91; }
|
||||||
|
|
||||||
|
.topbar-tabs { flex: 1; display: flex; gap: 6rpx; }
|
||||||
|
|
||||||
|
.ftab {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #9b8fa8;
|
||||||
|
padding: 10rpx 20rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ftab-active { color: #ff4f91; background: rgba(255, 79, 145, 0.10); font-weight: 900; }
|
||||||
|
|
||||||
|
.ftab-count { font-size: 22rpx; font-weight: 800; }
|
||||||
|
|
||||||
|
.topbar-btn {
|
||||||
|
width: 60rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #ff4f91, #ff9f1c);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 8rpx 16rpx rgba(255, 79, 145, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空态 */
|
||||||
|
.empty-wrap {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding: 0 60rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-emoji { font-size: 80rpx; }
|
||||||
|
|
||||||
|
.empty-title { font-size: 34rpx; font-weight: 900; color: #272235; }
|
||||||
|
|
||||||
|
.empty-desc { font-size: 26rpx; color: #9b8fa8; text-align: center; line-height: 1.6; }
|
||||||
|
|
||||||
|
.go-btn { margin-top: 20rpx; padding: 22rpx 60rpx; font-size: 28rpx; font-weight: 800; }
|
||||||
|
|
||||||
|
/* 好友列表 */
|
||||||
|
.friends-scroll { flex: 1; }
|
||||||
|
|
||||||
|
.friend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
padding: 20rpx 28rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.80);
|
||||||
|
border-bottom: 1rpx solid rgba(43, 37, 61, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-avatar {
|
||||||
|
width: 88rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
border-radius: 28rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: rgba(39, 34, 53, 0.72);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-info { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.friend-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-bottom: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-name { font-size: 28rpx; font-weight: 800; color: #272235; }
|
||||||
|
|
||||||
|
.online-tag {
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #2fd37a;
|
||||||
|
background: rgba(47, 211, 122, 0.12);
|
||||||
|
border-radius: 999rpx;
|
||||||
|
padding: 4rpx 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-bio {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #9b8fa8;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.follow-btn {
|
||||||
|
background: linear-gradient(135deg, #ff4f91, #ff9f1c);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
padding: 14rpx 28rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.follow-btn.following {
|
||||||
|
background: rgba(255, 255, 255, 0.80);
|
||||||
|
border: 1rpx solid rgba(43, 37, 61, 0.12);
|
||||||
|
color: #9b8fa8;
|
||||||
|
}
|
||||||
1
miniprogram/pages/login/login.json
Normal file
1
miniprogram/pages/login/login.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{ "navigationStyle": "custom", "usingComponents": {} }
|
||||||
57
miniprogram/pages/login/login.ts
Normal file
57
miniprogram/pages/login/login.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { login } from '../../utils/auth'
|
||||||
|
|
||||||
|
const app = getApp<{ userInfo: any; isLoggedIn: boolean }>()
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
onLoad() {
|
||||||
|
if (app.globalData?.isLoggedIn) {
|
||||||
|
wx.switchTab({ url: '/pages/feed/feed' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async onLogin() {
|
||||||
|
if (this.data.loading) return
|
||||||
|
this.setData({ loading: true })
|
||||||
|
|
||||||
|
try {
|
||||||
|
wx.showLoading({ title: '登录中...', mask: true })
|
||||||
|
|
||||||
|
// 先获取用户授权
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
wx.getUserProfile({
|
||||||
|
desc: '用于展示您的汪圈个人主页',
|
||||||
|
success: () => resolve(),
|
||||||
|
fail: reject,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const userInfo = await login()
|
||||||
|
app.globalData.userInfo = userInfo
|
||||||
|
app.globalData.isLoggedIn = true
|
||||||
|
|
||||||
|
wx.hideLoading()
|
||||||
|
wx.showToast({ title: '欢迎加入汪圈!', icon: 'success' })
|
||||||
|
setTimeout(() => wx.switchTab({ url: '/pages/feed/feed' }), 800)
|
||||||
|
} catch (e: any) {
|
||||||
|
wx.hideLoading()
|
||||||
|
if (e?.errMsg?.includes('deny') || e?.errMsg?.includes('cancel')) {
|
||||||
|
wx.showToast({ title: '需要授权才能登录', icon: 'none' })
|
||||||
|
} else {
|
||||||
|
wx.showToast({ title: '登录失败,请重试', icon: 'none' })
|
||||||
|
}
|
||||||
|
this.setData({ loading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onPrivacy() {
|
||||||
|
wx.navigateTo({ url: '/pages/webview/webview?url=YOUR_PRIVACY_URL' })
|
||||||
|
},
|
||||||
|
|
||||||
|
onTerms() {
|
||||||
|
wx.navigateTo({ url: '/pages/webview/webview?url=YOUR_TERMS_URL' })
|
||||||
|
},
|
||||||
|
})
|
||||||
52
miniprogram/pages/login/login.wxml
Normal file
52
miniprogram/pages/login/login.wxml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<view class="login-page">
|
||||||
|
<view class="login-bg">
|
||||||
|
<view class="bg-blob blob1"></view>
|
||||||
|
<view class="bg-blob blob2"></view>
|
||||||
|
<view class="bg-blob blob3"></view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="login-content">
|
||||||
|
<view class="app-logo">
|
||||||
|
<text class="logo-icon">🐾</text>
|
||||||
|
<text class="logo-text">汪<text class="logo-accent">圈</text></text>
|
||||||
|
<text class="logo-tagline">找到你们的共同话题</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="pet-row">
|
||||||
|
<text>🐩</text><text>🐕</text><text>🐈</text><text>🦮</text><text>🐶</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="feature-list">
|
||||||
|
<view class="feature-item">
|
||||||
|
<text class="f-icon">📍</text>
|
||||||
|
<text class="f-text">发现附近的宠物伙伴</text>
|
||||||
|
</view>
|
||||||
|
<view class="feature-item">
|
||||||
|
<text class="f-icon">📸</text>
|
||||||
|
<text class="f-text">分享毛孩子的日常故事</text>
|
||||||
|
</view>
|
||||||
|
<view class="feature-item">
|
||||||
|
<text class="f-icon">💬</text>
|
||||||
|
<text class="f-text">与同城铲屎官互动</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="login-btn"
|
||||||
|
open-type="getUserInfo"
|
||||||
|
bindgetuserinfo="onGetUserInfo"
|
||||||
|
wx:if="{{false}}"
|
||||||
|
>微信一键登录</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="login-btn"
|
||||||
|
bindtap="onLogin"
|
||||||
|
loading="{{loading}}"
|
||||||
|
>
|
||||||
|
<text wx:if="{{!loading}}">🐾 微信一键登录</text>
|
||||||
|
<text wx:else>登录中...</text>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<text class="privacy-tip">登录即同意<text class="link-text" bindtap="onPrivacy">《隐私政策》</text>和<text class="link-text" bindtap="onTerms">《用户协议》</text></text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
148
miniprogram/pages/login/login.wxss
Normal file
148
miniprogram/pages/login/login.wxss
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(160deg, #fff8bd 0%, #ffd6e8 34%, #cff7ff 68%, #e9ddff 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 背景光晕 */
|
||||||
|
.login-bg { position: absolute; inset: 0; pointer-events: none; }
|
||||||
|
|
||||||
|
.bg-blob {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(60rpx);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blob1 {
|
||||||
|
width: 400rpx; height: 400rpx;
|
||||||
|
background: rgba(255, 79, 145, 0.25);
|
||||||
|
top: -80rpx; left: -80rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blob2 {
|
||||||
|
width: 360rpx; height: 360rpx;
|
||||||
|
background: rgba(103, 232, 201, 0.30);
|
||||||
|
bottom: -60rpx; right: -60rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blob3 {
|
||||||
|
width: 300rpx; height: 300rpx;
|
||||||
|
background: rgba(255, 225, 90, 0.30);
|
||||||
|
bottom: 20%; left: 10%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 内容 */
|
||||||
|
.login-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 60rpx;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logo */
|
||||||
|
.app-logo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 48rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-icon {
|
||||||
|
font-size: 120rpx;
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
filter: drop-shadow(0 20rpx 30rpx rgba(95, 49, 104, 0.25));
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-text {
|
||||||
|
font-size: 80rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #272235;
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-accent { color: #ff4f91; }
|
||||||
|
|
||||||
|
.logo-tagline {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #6a6178;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 宠物 emoji 装饰 */
|
||||||
|
.pet-row {
|
||||||
|
font-size: 52rpx;
|
||||||
|
letter-spacing: 16rpx;
|
||||||
|
margin-bottom: 56rpx;
|
||||||
|
text-shadow: 0 10rpx 20rpx rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 特性列表 */
|
||||||
|
.feature-list {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.65);
|
||||||
|
border: 1rpx solid rgba(255, 255, 255, 0.72);
|
||||||
|
border-radius: 40rpx;
|
||||||
|
padding: 32rpx 36rpx;
|
||||||
|
margin-bottom: 48rpx;
|
||||||
|
box-shadow: 0 18rpx 40rpx rgba(95, 49, 104, 0.10);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f-icon { font-size: 40rpx; line-height: 1; }
|
||||||
|
|
||||||
|
.f-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #272235;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 登录按钮 */
|
||||||
|
.login-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 96rpx;
|
||||||
|
background: linear-gradient(135deg, #ff4f91, #ff9f1c 55%, #ffe15a) !important;
|
||||||
|
border-radius: 999rpx !important;
|
||||||
|
font-size: 32rpx !important;
|
||||||
|
font-weight: 900 !important;
|
||||||
|
color: #fff !important;
|
||||||
|
box-shadow: 0 16rpx 32rpx rgba(255, 79, 145, 0.35) !important;
|
||||||
|
border: none !important;
|
||||||
|
margin-bottom: 28rpx !important;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn::after { border: none !important; }
|
||||||
|
|
||||||
|
/* 隐私 */
|
||||||
|
.privacy-tip {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #9b8fa8;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-text {
|
||||||
|
color: #4d8dff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
4
miniprogram/pages/nearby/nearby.json
Normal file
4
miniprogram/pages/nearby/nearby.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"usingComponents": {}
|
||||||
|
}
|
||||||
227
miniprogram/pages/nearby/nearby.ts
Normal file
227
miniprogram/pages/nearby/nearby.ts
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { NearbyUser } from '../../types/index'
|
||||||
|
import { api } from '../../utils/api'
|
||||||
|
import { formatDistance, formatLastSeen, getAvatarGradient, getPetEmoji } from '../../utils/format'
|
||||||
|
import { requireLocation } from '../../utils/auth'
|
||||||
|
import { NEARBY_RADIUS_KM } from '../../utils/constants'
|
||||||
|
|
||||||
|
const DEFAULT_LAT = 31.2304
|
||||||
|
const DEFAULT_LNG = 121.4737
|
||||||
|
|
||||||
|
const app = getApp<{ userInfo: any; currentLocation: any }>()
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
statusBarHeight: 0,
|
||||||
|
tabBarHeight: 56,
|
||||||
|
viewMode: 'map' as 'map' | 'list',
|
||||||
|
mapCenter: { latitude: DEFAULT_LAT, longitude: DEFAULT_LNG },
|
||||||
|
mapScale: 15,
|
||||||
|
markers: [] as any[],
|
||||||
|
circles: [] as any[],
|
||||||
|
nearbyList: [] as any[],
|
||||||
|
onlineCount: 0,
|
||||||
|
activePet: null as any,
|
||||||
|
loading: false,
|
||||||
|
refreshing: false,
|
||||||
|
hasMore: true,
|
||||||
|
myLocation: null as any,
|
||||||
|
},
|
||||||
|
|
||||||
|
onLoad() {
|
||||||
|
const info = wx.getSystemInfoSync()
|
||||||
|
this.setData({ statusBarHeight: info.statusBarHeight })
|
||||||
|
this.initLocation()
|
||||||
|
},
|
||||||
|
|
||||||
|
onShow() {
|
||||||
|
const tabBar = this.getTabBar() as any
|
||||||
|
tabBar?.setSelected?.(1)
|
||||||
|
},
|
||||||
|
|
||||||
|
async initLocation() {
|
||||||
|
try {
|
||||||
|
const loc = await requireLocation()
|
||||||
|
this.setData({
|
||||||
|
mapCenter: loc,
|
||||||
|
myLocation: loc,
|
||||||
|
circles: [{
|
||||||
|
latitude: loc.latitude,
|
||||||
|
longitude: loc.longitude,
|
||||||
|
radius: NEARBY_RADIUS_KM * 1000,
|
||||||
|
strokeWidth: 2,
|
||||||
|
strokeColor: 'rgba(255, 79, 145, 0.3)',
|
||||||
|
fillColor: 'rgba(255, 79, 145, 0.05)',
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
app.globalData.currentLocation = loc
|
||||||
|
this.loadNearby(loc.latitude, loc.longitude)
|
||||||
|
} catch {
|
||||||
|
this.loadNearby(DEFAULT_LAT, DEFAULT_LNG)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadNearby(lat: number, lng: number) {
|
||||||
|
this.setData({ loading: true })
|
||||||
|
try {
|
||||||
|
const list = await api.getNearbyPets(lat, lng, NEARBY_RADIUS_KM)
|
||||||
|
const nearbyList = list.map(item => this.enrichItem(item, lat, lng))
|
||||||
|
const onlineCount = nearbyList.filter(i => i.isOnline).length
|
||||||
|
this.setData({
|
||||||
|
nearbyList,
|
||||||
|
onlineCount,
|
||||||
|
markers: this.buildMarkers(nearbyList, lat, lng),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
wx.showToast({ title: '加载附近失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
this.setData({ loading: false, refreshing: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
enrichItem(item: NearbyUser, myLat: number, myLng: number): any {
|
||||||
|
const pet = item.pet || item.user?.pets?.[0]
|
||||||
|
return {
|
||||||
|
userId: item.userId,
|
||||||
|
petId: pet?._id,
|
||||||
|
petName: pet?.name || '未知',
|
||||||
|
breed: pet?.breed || '宠物',
|
||||||
|
emoji: pet ? getPetEmoji(pet.species, pet.breed) : '🐾',
|
||||||
|
gradient: getAvatarGradient(item.userId),
|
||||||
|
distanceText: formatDistance(item.distance),
|
||||||
|
isOnline: item.isOnline,
|
||||||
|
lastSeenText: formatLastSeen(item.user?.lastSeen || ''),
|
||||||
|
locationName: item.user?.location || '',
|
||||||
|
latitude: item.location?.latitude || myLat,
|
||||||
|
longitude: item.location?.longitude || myLng,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
buildMarkers(list: any[], myLat: number, myLng: number): any[] {
|
||||||
|
const markers: any[] = [
|
||||||
|
{
|
||||||
|
id: 0,
|
||||||
|
latitude: myLat,
|
||||||
|
longitude: myLng,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
callout: {
|
||||||
|
content: '我在这',
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 11,
|
||||||
|
borderRadius: 12,
|
||||||
|
bgColor: '#ff4f91',
|
||||||
|
padding: 6,
|
||||||
|
display: 'ALWAYS',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
list.forEach((item, idx) => {
|
||||||
|
markers.push({
|
||||||
|
id: idx + 1,
|
||||||
|
latitude: item.latitude,
|
||||||
|
longitude: item.longitude,
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
callout: {
|
||||||
|
content: item.petName,
|
||||||
|
color: '#272235',
|
||||||
|
fontSize: 11,
|
||||||
|
borderRadius: 12,
|
||||||
|
bgColor: 'rgba(255,255,255,0.92)',
|
||||||
|
padding: 6,
|
||||||
|
display: 'BYCLICK',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return markers
|
||||||
|
},
|
||||||
|
|
||||||
|
onMarkerTap(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
const id = e.markerId
|
||||||
|
if (id === 0) return
|
||||||
|
const item = this.data.nearbyList[id - 1]
|
||||||
|
if (!item) return
|
||||||
|
this.setData({ activePet: item })
|
||||||
|
},
|
||||||
|
|
||||||
|
onPopupTap() {
|
||||||
|
const pet = this.data.activePet
|
||||||
|
if (!pet) return
|
||||||
|
wx.navigateTo({ url: `/pages/pet-detail/pet-detail?userId=${pet.userId}` })
|
||||||
|
},
|
||||||
|
|
||||||
|
async onGreeting(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
const { uid, pid } = e.currentTarget.dataset
|
||||||
|
if (!uid) return
|
||||||
|
try {
|
||||||
|
await api.sendGreeting(uid, pid)
|
||||||
|
wx.showToast({ title: '打招呼成功!', icon: 'success' })
|
||||||
|
this.setData({ activePet: null })
|
||||||
|
} catch {
|
||||||
|
wx.showToast({ title: '发送失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onPreviewTap(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
const { uid, lat, lng } = e.currentTarget.dataset
|
||||||
|
const item = this.data.nearbyList.find((i: any) => i.userId === uid)
|
||||||
|
if (!item) return
|
||||||
|
this.setData({
|
||||||
|
mapCenter: { latitude: Number(lat), longitude: Number(lng) },
|
||||||
|
activePet: item,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onItemTap(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
const uid = e.currentTarget.dataset.uid
|
||||||
|
wx.navigateTo({ url: `/pages/pet-detail/pet-detail?userId=${uid}` })
|
||||||
|
},
|
||||||
|
|
||||||
|
setViewMode(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
const mode = e.currentTarget.dataset.mode as 'map' | 'list'
|
||||||
|
this.setData({ viewMode: mode, activePet: null })
|
||||||
|
},
|
||||||
|
|
||||||
|
onLocateMe() {
|
||||||
|
const loc = this.data.myLocation
|
||||||
|
if (loc) {
|
||||||
|
this.setData({ mapCenter: { ...loc } })
|
||||||
|
} else {
|
||||||
|
this.initLocation()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onZoomIn() {
|
||||||
|
const scale = Math.min(20, this.data.mapScale + 1)
|
||||||
|
this.setData({ mapScale: scale })
|
||||||
|
},
|
||||||
|
|
||||||
|
onZoomOut() {
|
||||||
|
const scale = Math.max(10, this.data.mapScale - 1)
|
||||||
|
this.setData({ mapScale: scale })
|
||||||
|
},
|
||||||
|
|
||||||
|
onRegionChange() {},
|
||||||
|
|
||||||
|
onFilter() {
|
||||||
|
wx.showActionSheet({
|
||||||
|
itemList: ['500m 内', '1km 内', '3km 内', '5km 内'],
|
||||||
|
success: res => {
|
||||||
|
const radii = [0.5, 1, 3, 5]
|
||||||
|
const r = radii[res.tapIndex]
|
||||||
|
const loc = this.data.myLocation
|
||||||
|
if (loc) this.loadNearby(loc.latitude, loc.longitude)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async onRefresh() {
|
||||||
|
this.setData({ refreshing: true })
|
||||||
|
const loc = this.data.myLocation
|
||||||
|
if (loc) await this.loadNearby(loc.latitude, loc.longitude)
|
||||||
|
else this.setData({ refreshing: false })
|
||||||
|
},
|
||||||
|
|
||||||
|
onLoadMore() {},
|
||||||
|
})
|
||||||
142
miniprogram/pages/nearby/nearby.wxml
Normal file
142
miniprogram/pages/nearby/nearby.wxml
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<view class="nearby-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="view-toggle">
|
||||||
|
<view class="toggle-btn {{viewMode === 'map' ? 'toggle-active' : ''}}" bindtap="setViewMode" data-mode="map">🗺 地图</view>
|
||||||
|
<view class="toggle-btn {{viewMode === 'list' ? 'toggle-active' : ''}}" bindtap="setViewMode" data-mode="list">📋 列表</view>
|
||||||
|
</view>
|
||||||
|
<view class="topbar-btn" bindtap="onFilter">⚙️</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 地图视图 -->
|
||||||
|
<view wx:if="{{viewMode === 'map'}}" class="map-wrap">
|
||||||
|
<map
|
||||||
|
class="map"
|
||||||
|
id="petMap"
|
||||||
|
latitude="{{mapCenter.latitude}}"
|
||||||
|
longitude="{{mapCenter.longitude}}"
|
||||||
|
scale="{{mapScale}}"
|
||||||
|
markers="{{markers}}"
|
||||||
|
circles="{{circles}}"
|
||||||
|
show-location="true"
|
||||||
|
enable-zoom="true"
|
||||||
|
enable-scroll="true"
|
||||||
|
enable-rotate="false"
|
||||||
|
show-compass="false"
|
||||||
|
bindmarkertap="onMarkerTap"
|
||||||
|
bindregionchange="onRegionChange"
|
||||||
|
></map>
|
||||||
|
|
||||||
|
<!-- 地图控件 -->
|
||||||
|
<view class="map-controls">
|
||||||
|
<view class="map-ctrl-btn" bindtap="onLocateMe">📍</view>
|
||||||
|
<view class="map-ctrl-btn" bindtap="onZoomIn">+</view>
|
||||||
|
<view class="map-ctrl-btn" bindtap="onZoomOut">−</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 宠物弹窗 -->
|
||||||
|
<view wx:if="{{activePet}}" class="pet-popup" bindtap="onPopupTap">
|
||||||
|
<view class="popup-avatar" style="background: {{activePet.gradient}}">
|
||||||
|
<text>{{activePet.emoji}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="popup-info">
|
||||||
|
<text class="popup-name">{{activePet.petName}}</text>
|
||||||
|
<text class="popup-sub">{{activePet.breed}} · {{activePet.distanceText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="popup-hi btn-primary" catchtap="onGreeting" data-uid="{{activePet.userId}}" data-pid="{{activePet.petId}}">
|
||||||
|
打招呼 👋
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 在线数 -->
|
||||||
|
<view class="online-badge">{{onlineCount}}只在线</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 列表视图 -->
|
||||||
|
<scroll-view
|
||||||
|
wx:if="{{viewMode === 'list'}}"
|
||||||
|
class="list-scroll"
|
||||||
|
scroll-y="true"
|
||||||
|
enhanced="true"
|
||||||
|
show-scrollbar="false"
|
||||||
|
bindscrolltolower="onLoadMore"
|
||||||
|
refresher-enabled="true"
|
||||||
|
bindrefresherrefresh="onRefresh"
|
||||||
|
refresher-triggered="{{refreshing}}"
|
||||||
|
>
|
||||||
|
<view class="list-header">
|
||||||
|
<text class="list-title">附近的汪</text>
|
||||||
|
<view class="online-chip">{{onlineCount}}只在线</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 骨架 -->
|
||||||
|
<view wx:if="{{loading && nearbyList.length === 0}}">
|
||||||
|
<view wx:for="{{[1,2,3,4]}}" wx:key="index" class="nearby-skeleton"></view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 列表项 -->
|
||||||
|
<view
|
||||||
|
wx:for="{{nearbyList}}"
|
||||||
|
wx:key="userId"
|
||||||
|
class="nearby-item"
|
||||||
|
bindtap="onItemTap"
|
||||||
|
data-uid="{{item.userId}}"
|
||||||
|
>
|
||||||
|
<view class="nearby-avatar" style="background: {{item.gradient}}">
|
||||||
|
<text class="avatar-emoji">{{item.emoji}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="nearby-info">
|
||||||
|
<view class="nearby-name-row">
|
||||||
|
<text class="nearby-pet-name">{{item.petName}}</text>
|
||||||
|
<view class="breed-chip">{{item.breed}}</view>
|
||||||
|
</view>
|
||||||
|
<view class="nearby-sub">
|
||||||
|
<view wx:if="{{item.isOnline}}" class="online-dot"></view>
|
||||||
|
<text>{{item.lastSeenText}} · {{item.locationName}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="nearby-right">
|
||||||
|
<text class="dist-text">{{item.distanceText}}</text>
|
||||||
|
<view class="say-hi" catchtap="onGreeting" data-uid="{{item.userId}}" data-pid="{{item.petId}}">打招呼</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 空态 -->
|
||||||
|
<view wx:if="{{!loading && nearbyList.length === 0}}" class="empty-nearby">
|
||||||
|
<text class="empty-emoji">🐾</text>
|
||||||
|
<text class="empty-title">附近暂时没有汪</text>
|
||||||
|
<text class="empty-desc">去广场发帖,等小伙伴们上线吧~</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view style="height: {{tabBarHeight}}px"></view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- 地图模式下的底部列表预览 -->
|
||||||
|
<view wx:if="{{viewMode === 'map'}}" class="map-preview-list">
|
||||||
|
<scroll-view scroll-x="true" enhanced="true" show-scrollbar="false" class="preview-scroll">
|
||||||
|
<view
|
||||||
|
wx:for="{{nearbyList}}"
|
||||||
|
wx:key="userId"
|
||||||
|
class="preview-card"
|
||||||
|
bindtap="onPreviewTap"
|
||||||
|
data-uid="{{item.userId}}"
|
||||||
|
data-lat="{{item.latitude}}"
|
||||||
|
data-lng="{{item.longitude}}"
|
||||||
|
>
|
||||||
|
<view class="preview-avatar" style="background: {{item.gradient}}">
|
||||||
|
<text>{{item.emoji}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="preview-info">
|
||||||
|
<text class="preview-name">{{item.petName}}</text>
|
||||||
|
<text class="preview-dist">{{item.distanceText}}</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{item.isOnline}}" class="online-dot" style="width:12rpx;height:12rpx;"></view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view style="height: {{tabBarHeight}}px" wx:if="{{viewMode === 'list'}}"></view>
|
||||||
|
</view>
|
||||||
388
miniprogram/pages/nearby/nearby.wxss
Normal file
388
miniprogram/pages/nearby/nearby.wxss
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
.nearby-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
background: linear-gradient(180deg, #c9f7df 0%, #d8efff 40%, #fff1a6 100%);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶栏 */
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16rpx 28rpx 12rpx;
|
||||||
|
gap: 16rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-logo {
|
||||||
|
font-size: 44rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #272235;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-accent { color: #ff4f91; }
|
||||||
|
|
||||||
|
.view-toggle {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
background: rgba(255, 255, 255, 0.60);
|
||||||
|
border-radius: 999rpx;
|
||||||
|
padding: 4rpx;
|
||||||
|
gap: 4rpx;
|
||||||
|
border: 1rpx solid rgba(255, 255, 255, 0.80);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #9b8fa8;
|
||||||
|
padding: 12rpx 0;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-active {
|
||||||
|
background: rgba(255, 255, 255, 0.90);
|
||||||
|
color: #272235;
|
||||||
|
font-weight: 900;
|
||||||
|
box-shadow: 0 4rpx 10rpx rgba(78, 56, 96, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 地图 */
|
||||||
|
.map-wrap {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 地图控件 */
|
||||||
|
.map-controls {
|
||||||
|
position: absolute;
|
||||||
|
right: 24rpx;
|
||||||
|
bottom: 300rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14rpx;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-ctrl-btn {
|
||||||
|
width: 72rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.90);
|
||||||
|
border: 1rpx solid rgba(255, 255, 255, 0.80);
|
||||||
|
border-radius: 22rpx;
|
||||||
|
box-shadow: 0 10rpx 20rpx rgba(82, 66, 105, 0.16);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #4d8dff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 宠物弹窗 */
|
||||||
|
.pet-popup {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 300rpx;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
border: 1rpx solid rgba(255, 255, 255, 0.72);
|
||||||
|
border-radius: 30rpx;
|
||||||
|
padding: 20rpx 24rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18rpx;
|
||||||
|
box-shadow: 0 18rpx 40rpx rgba(95, 49, 104, 0.18);
|
||||||
|
min-width: 520rpx;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-avatar {
|
||||||
|
width: 72rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
border-radius: 22rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 36rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-info { flex: 1; }
|
||||||
|
|
||||||
|
.popup-name {
|
||||||
|
display: block;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #272235;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-sub {
|
||||||
|
display: block;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #9b8fa8;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-hi {
|
||||||
|
padding: 16rpx 24rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 在线数角标 */
|
||||||
|
.online-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 20rpx;
|
||||||
|
left: 20rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.90);
|
||||||
|
border-radius: 999rpx;
|
||||||
|
padding: 10rpx 20rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #ff4f91;
|
||||||
|
box-shadow: 0 8rpx 16rpx rgba(78, 56, 96, 0.12);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部预览条 */
|
||||||
|
.map-preview-list {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 15;
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
border-top: 1rpx solid rgba(255, 255, 255, 0.72);
|
||||||
|
padding: 20rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-scroll {
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.80);
|
||||||
|
border: 1rpx solid rgba(255, 255, 255, 0.72);
|
||||||
|
border-radius: 24rpx;
|
||||||
|
padding: 14rpx 18rpx;
|
||||||
|
margin-right: 16rpx;
|
||||||
|
box-shadow: 0 8rpx 16rpx rgba(78, 56, 96, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-avatar {
|
||||||
|
width: 60rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 30rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-name {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #272235;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-dist {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #4d8dff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 列表视图 */
|
||||||
|
.list-scroll {
|
||||||
|
flex: 1;
|
||||||
|
background: linear-gradient(180deg, #fff9fb 0%, #f5f0ff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 28rpx 28rpx 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #272235;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-chip {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ff4f91;
|
||||||
|
background: #ffe5f0;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
padding: 8rpx 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 列表项 */
|
||||||
|
.nearby-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.80);
|
||||||
|
border: 1rpx solid rgba(255, 255, 255, 0.72);
|
||||||
|
border-radius: 40rpx;
|
||||||
|
margin: 0 28rpx 20rpx;
|
||||||
|
padding: 20rpx;
|
||||||
|
box-shadow: 0 12rpx 26rpx rgba(80, 58, 108, 0.09);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nearby-avatar {
|
||||||
|
width: 88rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
border-radius: 30rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 40rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-emoji { font-size: 40rpx; line-height: 1; }
|
||||||
|
|
||||||
|
.nearby-info { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.nearby-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10rpx;
|
||||||
|
margin-bottom: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nearby-pet-name {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #272235;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breed-chip {
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
background: #ffe5f0;
|
||||||
|
color: #a91d5b;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
padding: 4rpx 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nearby-sub {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #9b8fa8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-dot {
|
||||||
|
width: 14rpx;
|
||||||
|
height: 14rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #2fd37a;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 0 0 4rpx rgba(47, 211, 122, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nearby-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 10rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #4d8dff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.say-hi {
|
||||||
|
background: linear-gradient(135deg, #fff, #fff4b7);
|
||||||
|
border: 1rpx solid rgba(255, 255, 255, 0.8);
|
||||||
|
border-radius: 999rpx;
|
||||||
|
padding: 12rpx 22rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #272235;
|
||||||
|
box-shadow: 0 8rpx 16rpx rgba(255, 159, 28, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 骨架 */
|
||||||
|
.nearby-skeleton {
|
||||||
|
height: 128rpx;
|
||||||
|
background: linear-gradient(90deg, #f0eef5 25%, #e8e4f0 50%, #f0eef5 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shine 1.4s infinite;
|
||||||
|
border-radius: 40rpx;
|
||||||
|
margin: 0 28rpx 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shine {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空态 */
|
||||||
|
.empty-nearby {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 120rpx 60rpx;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-emoji { font-size: 80rpx; }
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #272235;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-desc {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #9b8fa8;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
1
miniprogram/pages/notifications/notifications.json
Normal file
1
miniprogram/pages/notifications/notifications.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{ "navigationStyle": "custom", "usingComponents": {} }
|
||||||
36
miniprogram/pages/notifications/notifications.ts
Normal file
36
miniprogram/pages/notifications/notifications.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { getAvatarGradient, formatTime } from '../../utils/format'
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: { statusBarHeight: 0, list: [] as any[] },
|
||||||
|
|
||||||
|
onLoad() {
|
||||||
|
const info = wx.getSystemInfoSync()
|
||||||
|
this.setData({ statusBarHeight: info.statusBarHeight })
|
||||||
|
this.loadNotifications()
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadNotifications() {
|
||||||
|
try {
|
||||||
|
const res = await wx.cloud.callFunction({ name: 'getNotifications', data: {} }) as any
|
||||||
|
const list = (res.result?.data || []).map((n: any) => ({
|
||||||
|
...n,
|
||||||
|
gradient: getAvatarGradient(n.fromId || ''),
|
||||||
|
timeText: formatTime(n.createdAt),
|
||||||
|
}))
|
||||||
|
this.setData({ list })
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
onItemTap(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
const item = e.currentTarget.dataset.item as any
|
||||||
|
if (item?.postId) wx.navigateTo({ url: `/pages/post-detail/post-detail?id=${item.postId}` })
|
||||||
|
},
|
||||||
|
|
||||||
|
onReadAll() {
|
||||||
|
wx.cloud.callFunction({ name: 'markAllRead', data: {} }).catch(() => {})
|
||||||
|
const list = this.data.list.map((n: any) => ({ ...n, isRead: true }))
|
||||||
|
this.setData({ list })
|
||||||
|
},
|
||||||
|
|
||||||
|
onBack() { wx.navigateBack() },
|
||||||
|
})
|
||||||
23
miniprogram/pages/notifications/notifications.wxml
Normal file
23
miniprogram/pages/notifications/notifications.wxml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<view class="page">
|
||||||
|
<view class="safe-top" style="height: {{statusBarHeight}}px"></view>
|
||||||
|
<view class="nav">
|
||||||
|
<view class="nav-back" bindtap="onBack">‹</view>
|
||||||
|
<view class="nav-title">消息通知</view>
|
||||||
|
<view class="nav-right" bindtap="onReadAll">全部已读</view>
|
||||||
|
</view>
|
||||||
|
<scroll-view scroll-y="true" enhanced="true" show-scrollbar="false">
|
||||||
|
<view wx:if="{{list.length === 0}}" class="empty">
|
||||||
|
<text class="empty-emoji">🔔</text>
|
||||||
|
<text class="empty-tip">暂时没有新消息</text>
|
||||||
|
</view>
|
||||||
|
<view wx:for="{{list}}" wx:key="_id" class="notify-item {{item.isRead ? '' : 'unread'}}" bindtap="onItemTap" data-item="{{item}}">
|
||||||
|
<view class="n-avatar" style="background: {{item.gradient}}">{{item.fromNick[0]}}</view>
|
||||||
|
<view class="n-body">
|
||||||
|
<text class="n-text">{{item.fromNick}} {{item.action}}了你的{{item.target}}</text>
|
||||||
|
<text class="n-time">{{item.timeText}}</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{!item.isRead}}" class="unread-dot"></view>
|
||||||
|
</view>
|
||||||
|
<view style="height: 60rpx;"></view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
15
miniprogram/pages/notifications/notifications.wxss
Normal file
15
miniprogram/pages/notifications/notifications.wxss
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
.page { min-height: 100vh; background: linear-gradient(180deg, #fff8f2 0%, #f5f0ff 100%); }
|
||||||
|
.nav { display: flex; align-items: center; padding: 14rpx 28rpx; gap: 16rpx; }
|
||||||
|
.nav-back { font-size: 60rpx; color: #272235; font-weight: 300; line-height: 1; }
|
||||||
|
.nav-title { flex: 1; font-size: 32rpx; font-weight: 900; color: #272235; text-align: center; }
|
||||||
|
.nav-right { font-size: 26rpx; color: #9b8fa8; }
|
||||||
|
.empty { display: flex; flex-direction: column; align-items: center; padding: 120rpx 60rpx; gap: 20rpx; }
|
||||||
|
.empty-emoji { font-size: 80rpx; }
|
||||||
|
.empty-tip { font-size: 28rpx; color: #9b8fa8; }
|
||||||
|
.notify-item { display: flex; align-items: center; gap: 20rpx; padding: 22rpx 28rpx; background: rgba(255,255,255,0.80); border-bottom: 1rpx solid rgba(43,37,61,0.05); }
|
||||||
|
.notify-item.unread { background: rgba(255,229,240,0.40); }
|
||||||
|
.n-avatar { width: 80rpx; height: 80rpx; border-radius: 26rpx; display: flex; align-items: center; justify-content: center; font-size: 34rpx; font-weight: 800; color: rgba(39,34,53,0.72); flex-shrink: 0; }
|
||||||
|
.n-body { flex: 1; }
|
||||||
|
.n-text { display: block; font-size: 28rpx; color: #272235; line-height: 1.5; }
|
||||||
|
.n-time { display: block; font-size: 22rpx; color: #9b8fa8; margin-top: 6rpx; }
|
||||||
|
.unread-dot { width: 14rpx; height: 14rpx; border-radius: 50%; background: #ff4f91; flex-shrink: 0; }
|
||||||
1
miniprogram/pages/pet-detail/pet-detail.json
Normal file
1
miniprogram/pages/pet-detail/pet-detail.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{ "navigationStyle": "custom", "usingComponents": { "post-card": "/components/post-card/post-card" } }
|
||||||
78
miniprogram/pages/pet-detail/pet-detail.ts
Normal file
78
miniprogram/pages/pet-detail/pet-detail.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { api } from '../../utils/api'
|
||||||
|
import { getAvatarGradient, getPetEmoji } from '../../utils/format'
|
||||||
|
|
||||||
|
const app = getApp<{ userInfo: any }>()
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
statusBarHeight: 0,
|
||||||
|
userId: '',
|
||||||
|
user: {} as any,
|
||||||
|
posts: [] as any[],
|
||||||
|
avatarGradient: '',
|
||||||
|
isMe: false,
|
||||||
|
isFollowing: false,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
onLoad(query: { userId?: string }) {
|
||||||
|
const info = wx.getSystemInfoSync()
|
||||||
|
this.setData({ statusBarHeight: info.statusBarHeight })
|
||||||
|
|
||||||
|
const userId = query.userId || ''
|
||||||
|
const myId = app.globalData?.userInfo?._id || ''
|
||||||
|
this.setData({ userId, isMe: userId === myId })
|
||||||
|
this.loadUser(userId)
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadUser(userId: string) {
|
||||||
|
this.setData({ loading: true })
|
||||||
|
try {
|
||||||
|
const [user, postsRes] = await Promise.all([
|
||||||
|
api.getUserProfile(userId),
|
||||||
|
api.getUserPosts(userId),
|
||||||
|
])
|
||||||
|
const enrichedPets = (user.pets || []).map((p: any) => ({
|
||||||
|
...p,
|
||||||
|
emoji: getPetEmoji(p.species, p.breed),
|
||||||
|
}))
|
||||||
|
this.setData({
|
||||||
|
user: { ...user, pets: enrichedPets },
|
||||||
|
avatarGradient: getAvatarGradient(userId),
|
||||||
|
posts: postsRes.list,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
wx.showToast({ title: '加载失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
this.setData({ loading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async onFollow() {
|
||||||
|
try {
|
||||||
|
const res = await api.followUser(this.data.userId)
|
||||||
|
this.setData({ isFollowing: res.following })
|
||||||
|
} catch {
|
||||||
|
wx.showToast({ title: '操作失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onMessage() {
|
||||||
|
wx.navigateTo({ url: `/pages/chat/chat?userId=${this.data.userId}` })
|
||||||
|
},
|
||||||
|
|
||||||
|
onBack() {
|
||||||
|
wx.navigateBack()
|
||||||
|
},
|
||||||
|
|
||||||
|
onMore() {
|
||||||
|
wx.showActionSheet({
|
||||||
|
itemList: ['举报', '拉黑'],
|
||||||
|
success: res => {
|
||||||
|
if (res.tapIndex === 0) {
|
||||||
|
wx.showToast({ title: '已提交举报', icon: 'success' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
65
miniprogram/pages/pet-detail/pet-detail.wxml
Normal file
65
miniprogram/pages/pet-detail/pet-detail.wxml
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<view class="detail-page">
|
||||||
|
<view class="safe-top" style="height: {{statusBarHeight}}px"></view>
|
||||||
|
|
||||||
|
<!-- 顶部导航 -->
|
||||||
|
<view class="detail-nav">
|
||||||
|
<view class="nav-back" bindtap="onBack">‹</view>
|
||||||
|
<view class="nav-title">{{user.nickName || '宠物主页'}}</view>
|
||||||
|
<view class="nav-more" bindtap="onMore">⋯</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<scroll-view scroll-y="true" class="detail-scroll" enhanced="true" show-scrollbar="false">
|
||||||
|
<!-- 用户卡片 -->
|
||||||
|
<view class="user-card">
|
||||||
|
<view class="card-top">
|
||||||
|
<view class="card-avatar" style="background: {{avatarGradient}}">
|
||||||
|
<text>{{user.nickName ? user.nickName[0] : '?'}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="card-meta">
|
||||||
|
<text class="card-name">{{user.nickName}}</text>
|
||||||
|
<text class="card-location">📍 {{user.location || '未知位置'}}</text>
|
||||||
|
<text class="card-bio">{{user.bio || '还没有介绍~'}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 宠物列表 -->
|
||||||
|
<view wx:if="{{user.pets && user.pets.length > 0}}" class="pets-row">
|
||||||
|
<view
|
||||||
|
wx:for="{{user.pets}}"
|
||||||
|
wx:key="_id"
|
||||||
|
class="pet-chip"
|
||||||
|
>
|
||||||
|
<text class="chip-emoji">{{item.emoji}}</text>
|
||||||
|
<view class="chip-info">
|
||||||
|
<text class="chip-name">{{item.name}}</text>
|
||||||
|
<text class="chip-breed">{{item.breed}} · {{item.age}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<view wx:if="{{!isMe}}" class="action-row">
|
||||||
|
<view
|
||||||
|
class="follow-action {{isFollowing ? 'following' : ''}}"
|
||||||
|
bindtap="onFollow"
|
||||||
|
>
|
||||||
|
{{isFollowing ? '✓ 已关注' : '+ 关注'}}
|
||||||
|
</view>
|
||||||
|
<view class="msg-action" bindtap="onMessage">💬 发消息</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 帖子 -->
|
||||||
|
<view class="posts-section">
|
||||||
|
<view class="section-hd">
|
||||||
|
<text class="section-title">TA 的动态</text>
|
||||||
|
</view>
|
||||||
|
<post-card wx:for="{{posts}}" wx:key="_id" post="{{item}}" />
|
||||||
|
<view wx:if="{{!loading && posts.length === 0}}" class="empty-posts">
|
||||||
|
<text>还没有发布过动态</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view style="height: 60rpx;"></view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
29
miniprogram/pages/pet-detail/pet-detail.wxss
Normal file
29
miniprogram/pages/pet-detail/pet-detail.wxss
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
.detail-page { min-height: 100vh; background: linear-gradient(180deg, #fff8f2 0%, #f5f0ff 100%); }
|
||||||
|
.detail-nav { display: flex; align-items: center; padding: 16rpx 28rpx; gap: 16rpx; }
|
||||||
|
.nav-back { font-size: 60rpx; color: #272235; font-weight: 300; padding: 0 10rpx; line-height: 1; }
|
||||||
|
.nav-title { flex: 1; font-size: 32rpx; font-weight: 900; color: #272235; text-align: center; }
|
||||||
|
.nav-more { font-size: 36rpx; color: #9b8fa8; padding: 0 10rpx; }
|
||||||
|
.detail-scroll { min-height: 80vh; }
|
||||||
|
|
||||||
|
.user-card { margin: 0 28rpx 28rpx; background: rgba(255,255,255,0.88); border-radius: 48rpx; border: 1rpx solid rgba(255,255,255,0.72); padding: 28rpx; box-shadow: 0 18rpx 40rpx rgba(95,49,104,0.12); }
|
||||||
|
.card-top { display: flex; gap: 24rpx; margin-bottom: 24rpx; }
|
||||||
|
.card-avatar { width: 120rpx; height: 120rpx; border-radius: 36rpx; display: flex; align-items: center; justify-content: center; font-size: 52rpx; font-weight: 800; color: rgba(39,34,53,0.72); flex-shrink: 0; }
|
||||||
|
.card-meta { flex: 1; }
|
||||||
|
.card-name { display: block; font-size: 34rpx; font-weight: 900; color: #272235; margin-bottom: 8rpx; }
|
||||||
|
.card-location { display: block; font-size: 24rpx; color: #9b8fa8; margin-bottom: 10rpx; }
|
||||||
|
.card-bio { display: block; font-size: 26rpx; color: #6a6178; line-height: 1.5; }
|
||||||
|
|
||||||
|
.pets-row { display: flex; gap: 16rpx; flex-wrap: wrap; margin-bottom: 24rpx; }
|
||||||
|
.pet-chip { display: flex; align-items: center; gap: 12rpx; background: linear-gradient(135deg, rgba(255,255,255,0.8), rgba(201,247,223,0.6)); border: 1rpx solid rgba(255,255,255,0.72); border-radius: 24rpx; padding: 16rpx 20rpx; }
|
||||||
|
.chip-emoji { font-size: 36rpx; }
|
||||||
|
.chip-name { display: block; font-size: 26rpx; font-weight: 800; color: #272235; }
|
||||||
|
.chip-breed { display: block; font-size: 22rpx; color: #9b8fa8; }
|
||||||
|
|
||||||
|
.action-row { display: flex; gap: 16rpx; }
|
||||||
|
.follow-action { flex: 1; background: linear-gradient(135deg, #ff4f91, #ff9f1c); color: #fff; border-radius: 999rpx; padding: 20rpx; text-align: center; font-size: 28rpx; font-weight: 800; box-shadow: 0 10rpx 22rpx rgba(255,79,145,0.28); }
|
||||||
|
.follow-action.following { background: rgba(255,255,255,0.80); border: 1rpx solid rgba(43,37,61,0.12); color: #9b8fa8; box-shadow: none; }
|
||||||
|
.msg-action { flex: 1; background: rgba(255,255,255,0.80); border: 1rpx solid rgba(43,37,61,0.10); border-radius: 999rpx; padding: 20rpx; text-align: center; font-size: 28rpx; font-weight: 800; color: #272235; }
|
||||||
|
|
||||||
|
.section-hd { padding: 20rpx 28rpx 10rpx; }
|
||||||
|
.section-title { font-size: 26rpx; font-weight: 900; color: #9b8fa8; }
|
||||||
|
.empty-posts { text-align: center; font-size: 26rpx; color: #9b8fa8; padding: 60rpx; }
|
||||||
1
miniprogram/pages/pet-edit/pet-edit.json
Normal file
1
miniprogram/pages/pet-edit/pet-edit.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{ "navigationStyle": "custom", "usingComponents": {} }
|
||||||
169
miniprogram/pages/pet-edit/pet-edit.ts
Normal file
169
miniprogram/pages/pet-edit/pet-edit.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { api } from '../../utils/api'
|
||||||
|
import { BREED_OPTIONS } from '../../utils/constants'
|
||||||
|
import { getPetEmoji } from '../../utils/format'
|
||||||
|
|
||||||
|
const SPECIES_LABELS: Record<string, string> = { dog: '狗狗', cat: '猫猫', other: '其他' }
|
||||||
|
const PET_TAGS = ['活泼', '温顺', '黏人', '独立', '爱玩', '怕生', '聪明', '贪吃', '爱运动', '安静']
|
||||||
|
|
||||||
|
const app = getApp<{ userInfo: any }>()
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
statusBarHeight: 0,
|
||||||
|
isEdit: false,
|
||||||
|
petId: '',
|
||||||
|
form: {
|
||||||
|
name: '',
|
||||||
|
species: 'dog' as 'dog' | 'cat' | 'other',
|
||||||
|
breed: '',
|
||||||
|
gender: 'female' as 'female' | 'male',
|
||||||
|
birthday: '',
|
||||||
|
bio: '',
|
||||||
|
tags: [] as string[],
|
||||||
|
emoji: '🐶',
|
||||||
|
},
|
||||||
|
speciesLabel: '狗狗',
|
||||||
|
allTags: PET_TAGS,
|
||||||
|
},
|
||||||
|
|
||||||
|
onLoad(query: { id?: string }) {
|
||||||
|
const info = wx.getSystemInfoSync()
|
||||||
|
this.setData({ statusBarHeight: info.statusBarHeight })
|
||||||
|
|
||||||
|
if (query.id) {
|
||||||
|
this.setData({ isEdit: true, petId: query.id })
|
||||||
|
this.loadPet(query.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadPet(id: string) {
|
||||||
|
const user = app.globalData?.userInfo
|
||||||
|
const pet = user?.pets?.find((p: any) => p._id === id)
|
||||||
|
if (!pet) return
|
||||||
|
this.setData({
|
||||||
|
form: {
|
||||||
|
name: pet.name,
|
||||||
|
species: pet.species,
|
||||||
|
breed: pet.breed,
|
||||||
|
gender: pet.gender,
|
||||||
|
birthday: pet.birthday || '',
|
||||||
|
bio: pet.bio || '',
|
||||||
|
tags: pet.tags || [],
|
||||||
|
emoji: pet.emoji,
|
||||||
|
},
|
||||||
|
speciesLabel: SPECIES_LABELS[pet.species] || '狗狗',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onField(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
const key = e.currentTarget.dataset.key as string
|
||||||
|
this.setData({ [`form.${key}`]: e.detail.value })
|
||||||
|
},
|
||||||
|
|
||||||
|
onSpecies() {
|
||||||
|
wx.showActionSheet({
|
||||||
|
itemList: ['狗狗', '猫猫', '其他'],
|
||||||
|
success: res => {
|
||||||
|
const species = (['dog', 'cat', 'other'] as const)[res.tapIndex]
|
||||||
|
this.setData({
|
||||||
|
'form.species': species,
|
||||||
|
'form.breed': '',
|
||||||
|
speciesLabel: SPECIES_LABELS[species],
|
||||||
|
'form.emoji': getPetEmoji(species),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onBreed() {
|
||||||
|
const breeds = BREED_OPTIONS[this.data.form.species] || BREED_OPTIONS.dog
|
||||||
|
wx.showActionSheet({
|
||||||
|
itemList: breeds,
|
||||||
|
success: res => {
|
||||||
|
const breed = breeds[res.tapIndex]
|
||||||
|
this.setData({
|
||||||
|
'form.breed': breed,
|
||||||
|
'form.emoji': getPetEmoji(this.data.form.species, breed),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onGender(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
this.setData({ 'form.gender': e.currentTarget.dataset.v })
|
||||||
|
},
|
||||||
|
|
||||||
|
onBirthday(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
const dob = e.detail.value as string
|
||||||
|
const diffMs = Date.now() - new Date(dob).getTime()
|
||||||
|
const years = Math.floor(diffMs / (365.25 * 24 * 3600 * 1000))
|
||||||
|
const months = Math.floor((diffMs % (365.25 * 24 * 3600 * 1000)) / (30.44 * 24 * 3600 * 1000))
|
||||||
|
const age = years > 0 ? `${years}岁${months > 0 ? months + '个月' : ''}` : `${months}个月`
|
||||||
|
this.setData({ 'form.birthday': dob, 'form.age': age })
|
||||||
|
},
|
||||||
|
|
||||||
|
onTagToggle(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
const tag = e.currentTarget.dataset.tag as string
|
||||||
|
const tags = [...this.data.form.tags]
|
||||||
|
const idx = tags.indexOf(tag)
|
||||||
|
if (idx >= 0) tags.splice(idx, 1)
|
||||||
|
else if (tags.length < 5) tags.push(tag)
|
||||||
|
this.setData({ 'form.tags': tags })
|
||||||
|
},
|
||||||
|
|
||||||
|
async onChoosePhoto() {
|
||||||
|
wx.chooseMedia({
|
||||||
|
count: 1,
|
||||||
|
mediaType: ['image'],
|
||||||
|
sourceType: ['album', 'camera'],
|
||||||
|
success: async res => {
|
||||||
|
const path = res.tempFiles[0].tempFilePath
|
||||||
|
wx.showLoading({ title: '上传中...' })
|
||||||
|
try {
|
||||||
|
const cloudPath = `pet-avatars/${Date.now()}.jpg`
|
||||||
|
const r = await wx.cloud.uploadFile({ cloudPath, filePath: path })
|
||||||
|
this.setData({ 'form.avatarUrl': r.fileID })
|
||||||
|
wx.hideLoading()
|
||||||
|
} catch {
|
||||||
|
wx.hideLoading()
|
||||||
|
wx.showToast({ title: '上传失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async onSave() {
|
||||||
|
const { form, isEdit, petId } = this.data
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
wx.showToast({ title: '请填写宠物名字', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.showLoading({ title: '保存中...' })
|
||||||
|
try {
|
||||||
|
await api.updatePet(isEdit ? { ...form, _id: petId } : form)
|
||||||
|
wx.hideLoading()
|
||||||
|
wx.showToast({ title: '保存成功', icon: 'success' })
|
||||||
|
setTimeout(() => wx.navigateBack(), 800)
|
||||||
|
} catch {
|
||||||
|
wx.hideLoading()
|
||||||
|
wx.showToast({ title: '保存失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onDelete() {
|
||||||
|
wx.showModal({
|
||||||
|
title: '删除宠物档案',
|
||||||
|
content: '确定要删除吗?',
|
||||||
|
confirmText: '删除',
|
||||||
|
confirmColor: '#ff4f91',
|
||||||
|
success: res => {
|
||||||
|
if (res.confirm) {
|
||||||
|
wx.showToast({ title: '已删除', icon: 'success' })
|
||||||
|
setTimeout(() => wx.navigateBack(), 800)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onBack() { wx.navigateBack() },
|
||||||
|
})
|
||||||
101
miniprogram/pages/pet-edit/pet-edit.wxml
Normal file
101
miniprogram/pages/pet-edit/pet-edit.wxml
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<view class="page">
|
||||||
|
<view class="safe-top" style="height: {{statusBarHeight}}px"></view>
|
||||||
|
<view class="nav">
|
||||||
|
<view class="nav-back" bindtap="onBack">取消</view>
|
||||||
|
<view class="nav-title">{{isEdit ? '编辑宠物' : '添加宠物'}}</view>
|
||||||
|
<view class="nav-save" bindtap="onSave">保存</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<scroll-view scroll-y="true" class="scroll" enhanced="true" show-scrollbar="false">
|
||||||
|
<view class="form">
|
||||||
|
|
||||||
|
<!-- 宠物头像 -->
|
||||||
|
<view class="avatar-section" bindtap="onChoosePhoto">
|
||||||
|
<view class="pet-avatar" style="background: linear-gradient(135deg, #c9f7df, #d9d2ff);">
|
||||||
|
<text class="pet-emoji-big">{{form.emoji || '🐾'}}</text>
|
||||||
|
</view>
|
||||||
|
<text class="avatar-tip">点击更换照片</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 名字 -->
|
||||||
|
<view class="field-group">
|
||||||
|
<view class="field-item">
|
||||||
|
<text class="field-label">宠物名字 *</text>
|
||||||
|
<input class="field-input" value="{{form.name}}" bindinput="onField" data-key="name" placeholder="给TA起个名字" maxlength="10" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 物种 -->
|
||||||
|
<view class="field-item" bindtap="onSpecies">
|
||||||
|
<text class="field-label">物种 *</text>
|
||||||
|
<view class="field-select">
|
||||||
|
<text>{{speciesLabel}}</text>
|
||||||
|
<text class="select-arrow">›</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 品种 -->
|
||||||
|
<view class="field-item" bindtap="onBreed">
|
||||||
|
<text class="field-label">品种</text>
|
||||||
|
<view class="field-select">
|
||||||
|
<text>{{form.breed || '选择品种'}}</text>
|
||||||
|
<text class="select-arrow">›</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 性别 -->
|
||||||
|
<view class="field-item">
|
||||||
|
<text class="field-label">性别</text>
|
||||||
|
<view class="gender-btns">
|
||||||
|
<view class="gender-btn {{form.gender === 'female' ? 'g-active' : ''}}" bindtap="onGender" data-v="female">女生 ♀</view>
|
||||||
|
<view class="gender-btn {{form.gender === 'male' ? 'g-active' : ''}}" bindtap="onGender" data-v="male">男生 ♂</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 生日 -->
|
||||||
|
<view class="field-item">
|
||||||
|
<text class="field-label">生日</text>
|
||||||
|
<picker mode="date" value="{{form.birthday}}" bindchange="onBirthday">
|
||||||
|
<view class="field-select">
|
||||||
|
<text>{{form.birthday || '选择生日'}}</text>
|
||||||
|
<text class="select-arrow">›</text>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 简介 -->
|
||||||
|
<view class="field-item field-item-col">
|
||||||
|
<text class="field-label">简介</text>
|
||||||
|
<textarea
|
||||||
|
class="field-textarea"
|
||||||
|
value="{{form.bio}}"
|
||||||
|
bindinput="onField"
|
||||||
|
data-key="bio"
|
||||||
|
placeholder="写几句关于TA的介绍..."
|
||||||
|
maxlength="100"
|
||||||
|
auto-height="true"
|
||||||
|
show-confirm-bar="false"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 性格标签 -->
|
||||||
|
<view class="tags-section">
|
||||||
|
<text class="tags-title">性格标签</text>
|
||||||
|
<view class="tag-grid">
|
||||||
|
<view
|
||||||
|
wx:for="{{allTags}}"
|
||||||
|
wx:key="index"
|
||||||
|
class="tag-chip {{form.tags.includes(item) ? 'tag-on' : ''}}"
|
||||||
|
bindtap="onTagToggle"
|
||||||
|
data-tag="{{item}}"
|
||||||
|
>{{item}}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 删除 -->
|
||||||
|
<view wx:if="{{isEdit}}" class="delete-btn" bindtap="onDelete">删除这只宠物</view>
|
||||||
|
|
||||||
|
<view style="height: 60rpx;"></view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
35
miniprogram/pages/pet-edit/pet-edit.wxss
Normal file
35
miniprogram/pages/pet-edit/pet-edit.wxss
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
.page { min-height: 100vh; background: linear-gradient(180deg, #fff8f2 0%, #f5f0ff 100%); }
|
||||||
|
.nav { display: flex; align-items: center; padding: 14rpx 28rpx; gap: 16rpx; }
|
||||||
|
.nav-back { font-size: 28rpx; font-weight: 700; color: #9b8fa8; }
|
||||||
|
.nav-title { flex: 1; font-size: 32rpx; font-weight: 900; color: #272235; text-align: center; }
|
||||||
|
.nav-save { font-size: 28rpx; font-weight: 900; color: #ff4f91; }
|
||||||
|
.scroll { }
|
||||||
|
|
||||||
|
.form { padding: 0 28rpx; }
|
||||||
|
|
||||||
|
.avatar-section { display: flex; flex-direction: column; align-items: center; padding: 28rpx 0 32rpx; gap: 12rpx; }
|
||||||
|
.pet-avatar { width: 140rpx; height: 140rpx; border-radius: 44rpx; display: flex; align-items: center; justify-content: center; box-shadow: 0 12rpx 24rpx rgba(78,56,96,0.15); }
|
||||||
|
.pet-emoji-big { font-size: 72rpx; line-height: 1; }
|
||||||
|
.avatar-tip { font-size: 24rpx; color: #9b8fa8; }
|
||||||
|
|
||||||
|
.field-group { background: rgba(255,255,255,0.90); border-radius: 36rpx; border: 1rpx solid rgba(255,255,255,0.72); overflow: hidden; box-shadow: 0 12rpx 26rpx rgba(78,56,96,0.08); margin-bottom: 24rpx; }
|
||||||
|
.field-item { display: flex; align-items: center; justify-content: space-between; padding: 24rpx 28rpx; border-bottom: 1rpx solid rgba(43,37,61,0.06); }
|
||||||
|
.field-item:last-child { border-bottom: none; }
|
||||||
|
.field-item-col { flex-direction: column; align-items: flex-start; gap: 14rpx; }
|
||||||
|
.field-label { font-size: 28rpx; font-weight: 700; color: #272235; flex-shrink: 0; }
|
||||||
|
.field-input { flex: 1; font-size: 28rpx; color: #272235; text-align: right; }
|
||||||
|
.field-select { display: flex; align-items: center; gap: 8rpx; font-size: 28rpx; color: #9b8fa8; }
|
||||||
|
.select-arrow { font-size: 36rpx; color: #c4b8d0; font-weight: 300; }
|
||||||
|
.field-textarea { width: 100%; min-height: 100rpx; font-size: 28rpx; color: #272235; line-height: 1.6; }
|
||||||
|
|
||||||
|
.gender-btns { display: flex; gap: 14rpx; }
|
||||||
|
.gender-btn { background: rgba(255,255,255,0.80); border: 1rpx solid rgba(43,37,61,0.12); border-radius: 999rpx; padding: 12rpx 24rpx; font-size: 26rpx; color: #9b8fa8; font-weight: 700; }
|
||||||
|
.g-active { background: #ffe5f0; border-color: #ffb6d0; color: #a91d5b; }
|
||||||
|
|
||||||
|
.tags-section { margin-bottom: 28rpx; }
|
||||||
|
.tags-title { display: block; font-size: 26rpx; font-weight: 900; color: #9b8fa8; margin-bottom: 16rpx; }
|
||||||
|
.tag-grid { display: flex; gap: 14rpx; flex-wrap: wrap; }
|
||||||
|
.tag-chip { background: rgba(255,255,255,0.80); border: 1rpx solid rgba(43,37,61,0.10); border-radius: 999rpx; padding: 12rpx 24rpx; font-size: 26rpx; color: #6a6178; font-weight: 700; }
|
||||||
|
.tag-on { background: #f0e8ff; border-color: #d4b8ff; color: #8c5cff; }
|
||||||
|
|
||||||
|
.delete-btn { text-align: center; font-size: 28rpx; color: #ff4f91; background: rgba(255,79,145,0.08); border-radius: 999rpx; padding: 24rpx; margin-top: 40rpx; }
|
||||||
1
miniprogram/pages/post-detail/post-detail.json
Normal file
1
miniprogram/pages/post-detail/post-detail.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{ "navigationStyle": "custom", "usingComponents": {} }
|
||||||
117
miniprogram/pages/post-detail/post-detail.ts
Normal file
117
miniprogram/pages/post-detail/post-detail.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { api } from '../../utils/api'
|
||||||
|
import { formatTime, getAvatarGradient } from '../../utils/format'
|
||||||
|
|
||||||
|
const app = getApp<{ userInfo: any }>()
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
statusBarHeight: 0,
|
||||||
|
postId: '',
|
||||||
|
post: null as any,
|
||||||
|
comments: [] as any[],
|
||||||
|
avatarGradient: '',
|
||||||
|
timeText: '',
|
||||||
|
likeCount: 0,
|
||||||
|
commentText: '',
|
||||||
|
focusInput: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
onLoad(query: { id?: string; focus?: string }) {
|
||||||
|
const info = wx.getSystemInfoSync()
|
||||||
|
this.setData({ statusBarHeight: info.statusBarHeight, postId: query.id || '' })
|
||||||
|
this.loadPost(query.id || '')
|
||||||
|
if (query.focus === 'comment') {
|
||||||
|
this.setData({ focusInput: true })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadPost(id: string) {
|
||||||
|
if (!id) return
|
||||||
|
try {
|
||||||
|
const res = await wx.cloud.callFunction({
|
||||||
|
name: 'getPost',
|
||||||
|
data: { postId: id },
|
||||||
|
}) as any
|
||||||
|
const post = res.result?.data
|
||||||
|
if (!post) return
|
||||||
|
this.setData({
|
||||||
|
post,
|
||||||
|
avatarGradient: getAvatarGradient(post.authorId),
|
||||||
|
timeText: formatTime(post.createdAt),
|
||||||
|
likeCount: post.likeCount,
|
||||||
|
})
|
||||||
|
this.loadComments(id)
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadComments(postId: string) {
|
||||||
|
try {
|
||||||
|
const res = await api.getComments(postId)
|
||||||
|
const comments = res.list.map((c: any) => ({
|
||||||
|
...c,
|
||||||
|
gradient: getAvatarGradient(c.authorId),
|
||||||
|
timeText: formatTime(c.createdAt),
|
||||||
|
}))
|
||||||
|
this.setData({ comments })
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
async onLike() {
|
||||||
|
const post = this.data.post
|
||||||
|
if (!post) return
|
||||||
|
const was = post.isLiked
|
||||||
|
this.setData({ 'post.isLiked': !was, likeCount: was ? this.data.likeCount - 1 : this.data.likeCount + 1 })
|
||||||
|
try {
|
||||||
|
const res = await api.likePost(post._id)
|
||||||
|
this.setData({ likeCount: res.count, 'post.isLiked': res.liked })
|
||||||
|
} catch {
|
||||||
|
this.setData({ 'post.isLiked': was, likeCount: post.likeCount })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onCommentInput(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
this.setData({ commentText: e.detail.value })
|
||||||
|
},
|
||||||
|
|
||||||
|
async onSend() {
|
||||||
|
const text = this.data.commentText.trim()
|
||||||
|
if (!text || !this.data.postId) return
|
||||||
|
this.setData({ commentText: '' })
|
||||||
|
try {
|
||||||
|
await api.addComment(this.data.postId, text)
|
||||||
|
const comments = await api.getComments(this.data.postId)
|
||||||
|
this.setData({
|
||||||
|
comments: comments.list.map((c: any) => ({
|
||||||
|
...c,
|
||||||
|
gradient: getAvatarGradient(c.authorId),
|
||||||
|
timeText: formatTime(c.createdAt),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
wx.showToast({ title: '发送失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
focusComment() {
|
||||||
|
this.setData({ focusInput: true })
|
||||||
|
},
|
||||||
|
|
||||||
|
onAuthorTap() {
|
||||||
|
const post = this.data.post
|
||||||
|
if (post?.authorId) wx.navigateTo({ url: `/pages/pet-detail/pet-detail?userId=${post.authorId}` })
|
||||||
|
},
|
||||||
|
|
||||||
|
onImgTap(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
const post = this.data.post
|
||||||
|
wx.previewImage({ current: post.images[e.currentTarget.dataset.idx], urls: post.images })
|
||||||
|
},
|
||||||
|
|
||||||
|
onMore() {
|
||||||
|
wx.showActionSheet({
|
||||||
|
itemList: ['举报'],
|
||||||
|
success: () => wx.showToast({ title: '已提交举报', icon: 'success' }),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onBack() { wx.navigateBack() },
|
||||||
|
})
|
||||||
104
miniprogram/pages/post-detail/post-detail.wxml
Normal file
104
miniprogram/pages/post-detail/post-detail.wxml
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<view class="page">
|
||||||
|
<view class="safe-top" style="height: {{statusBarHeight}}px"></view>
|
||||||
|
|
||||||
|
<view class="nav">
|
||||||
|
<view class="nav-back" bindtap="onBack">‹</view>
|
||||||
|
<view class="nav-title">动态详情</view>
|
||||||
|
<view class="nav-more" bindtap="onMore">⋯</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<scroll-view scroll-y="true" class="scroll" enhanced="true" show-scrollbar="false">
|
||||||
|
<!-- 帖子主体 -->
|
||||||
|
<view wx:if="{{post}}" class="post-wrap">
|
||||||
|
<!-- 作者 -->
|
||||||
|
<view class="post-header" bindtap="onAuthorTap">
|
||||||
|
<view class="avatar" style="background: {{avatarGradient}}">
|
||||||
|
<text>{{post.author.nickName[0]}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="meta">
|
||||||
|
<text class="uname">{{post.author.nickName}}</text>
|
||||||
|
<text class="utime">{{timeText}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 文字 -->
|
||||||
|
<text class="content">{{post.content}}</text>
|
||||||
|
|
||||||
|
<!-- 图片 -->
|
||||||
|
<view wx:if="{{post.images.length > 0}}" class="images">
|
||||||
|
<image
|
||||||
|
wx:for="{{post.images}}"
|
||||||
|
wx:key="index"
|
||||||
|
class="img {{post.images.length === 1 ? 'img-full' : 'img-grid'}}"
|
||||||
|
src="{{item}}"
|
||||||
|
mode="aspectFill"
|
||||||
|
lazy-load="true"
|
||||||
|
bindtap="onImgTap"
|
||||||
|
data-idx="{{index}}"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 标签行 -->
|
||||||
|
<view class="tags-row">
|
||||||
|
<text wx:for="{{post.hashtags}}" wx:key="index" class="hashtag">{{item}}</text>
|
||||||
|
<view wx:if="{{post.location}}" class="loc-tag">📍{{post.location.name}}</view>
|
||||||
|
<view wx:if="{{post.mood}}" class="mood-tag">{{post.mood}}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 操作 -->
|
||||||
|
<view class="actions">
|
||||||
|
<view class="action-btn {{post.isLiked ? 'liked' : ''}}" bindtap="onLike">
|
||||||
|
<text>{{post.isLiked ? '❤️' : '🤍'}}</text>
|
||||||
|
<text class="act-count">{{likeCount}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="action-btn" bindtap="focusComment">
|
||||||
|
<text>💬</text>
|
||||||
|
<text class="act-count">{{comments.length}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 评论区 -->
|
||||||
|
<view class="divider"></view>
|
||||||
|
<view class="comments-section">
|
||||||
|
<text class="comments-title">评论 {{comments.length}}</text>
|
||||||
|
|
||||||
|
<view wx:if="{{comments.length === 0}}" class="no-comment">
|
||||||
|
<text>还没有评论,来说第一句话吧 🐾</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
wx:for="{{comments}}"
|
||||||
|
wx:key="_id"
|
||||||
|
class="comment-item"
|
||||||
|
>
|
||||||
|
<view class="c-avatar" style="background: {{item.gradient}}">
|
||||||
|
<text>{{item.author.nickName[0]}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="c-body">
|
||||||
|
<text class="c-name">{{item.author.nickName}}</text>
|
||||||
|
<text class="c-text">{{item.content}}</text>
|
||||||
|
<text class="c-time">{{item.timeText}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view style="height: 160rpx;"></view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- 评论输入框 -->
|
||||||
|
<view class="input-bar">
|
||||||
|
<input
|
||||||
|
class="comment-input"
|
||||||
|
id="commentInput"
|
||||||
|
value="{{commentText}}"
|
||||||
|
bindinput="onCommentInput"
|
||||||
|
placeholder="写下你的想法..."
|
||||||
|
confirm-type="send"
|
||||||
|
bindconfirm="onSend"
|
||||||
|
adjust-position="true"
|
||||||
|
cursor-spacing="20"
|
||||||
|
/>
|
||||||
|
<view class="send-btn {{commentText ? '' : 'send-off'}}" bindtap="onSend">发送</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
46
miniprogram/pages/post-detail/post-detail.wxss
Normal file
46
miniprogram/pages/post-detail/post-detail.wxss
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
.page { display: flex; flex-direction: column; min-height: 100vh; background: linear-gradient(180deg, #fff8f2 0%, #f5f0ff 100%); }
|
||||||
|
.nav { display: flex; align-items: center; padding: 14rpx 28rpx; gap: 16rpx; }
|
||||||
|
.nav-back { font-size: 60rpx; color: #272235; font-weight: 300; line-height: 1; padding-right: 10rpx; }
|
||||||
|
.nav-title { flex: 1; font-size: 32rpx; font-weight: 900; color: #272235; text-align: center; }
|
||||||
|
.nav-more { font-size: 36rpx; color: #9b8fa8; }
|
||||||
|
.scroll { flex: 1; }
|
||||||
|
|
||||||
|
.post-wrap { margin: 0 28rpx; background: rgba(255,255,255,0.90); border-radius: 48rpx; border: 1rpx solid rgba(255,255,255,0.72); padding: 28rpx; box-shadow: 0 18rpx 40rpx rgba(95,49,104,0.10); }
|
||||||
|
.post-header { display: flex; align-items: center; gap: 20rpx; margin-bottom: 20rpx; }
|
||||||
|
.avatar { width: 80rpx; height: 80rpx; border-radius: 28rpx; display: flex; align-items: center; justify-content: center; font-size: 34rpx; font-weight: 800; color: rgba(39,34,53,0.72); flex-shrink: 0; }
|
||||||
|
.meta { flex: 1; }
|
||||||
|
.uname { display: block; font-size: 28rpx; font-weight: 800; color: #272235; }
|
||||||
|
.utime { display: block; font-size: 22rpx; color: #9b8fa8; margin-top: 4rpx; }
|
||||||
|
.content { display: block; font-size: 30rpx; color: #272235; line-height: 1.65; margin-bottom: 20rpx; }
|
||||||
|
|
||||||
|
.images { display: flex; flex-wrap: wrap; gap: 8rpx; margin-bottom: 20rpx; border-radius: 24rpx; overflow: hidden; }
|
||||||
|
.img-full { width: 100%; height: 360rpx; border-radius: 24rpx; background: #f0eef5; }
|
||||||
|
.img-grid { width: calc(50% - 4rpx); height: 240rpx; background: #f0eef5; }
|
||||||
|
|
||||||
|
.tags-row { display: flex; gap: 10rpx; flex-wrap: wrap; margin-bottom: 20rpx; }
|
||||||
|
.hashtag { font-size: 22rpx; font-weight: 800; color: #8b3cff; background: #f0e8ff; border-radius: 999rpx; padding: 6rpx 14rpx; }
|
||||||
|
.loc-tag { font-size: 22rpx; color: #9b8fa8; background: rgba(255,255,255,0.80); border: 1rpx solid rgba(43,37,61,0.10); border-radius: 999rpx; padding: 6rpx 14rpx; }
|
||||||
|
.mood-tag { font-size: 22rpx; color: #6f4b00; background: #fff0a8; border-radius: 999rpx; padding: 6rpx 14rpx; }
|
||||||
|
|
||||||
|
.actions { display: flex; gap: 16rpx; padding-top: 20rpx; border-top: 1rpx solid rgba(43,37,61,0.07); }
|
||||||
|
.action-btn { display: flex; align-items: center; gap: 8rpx; font-size: 24rpx; font-weight: 800; color: #6a6178; background: #f7f3ff; border-radius: 999rpx; padding: 10rpx 18rpx; }
|
||||||
|
.action-btn.liked { color: #ff4f91; background: #ffe5f0; }
|
||||||
|
.act-count { font-size: 24rpx; }
|
||||||
|
|
||||||
|
.divider { height: 16rpx; background: rgba(43,37,61,0.04); margin: 16rpx 0; }
|
||||||
|
|
||||||
|
.comments-section { padding: 0 28rpx; }
|
||||||
|
.comments-title { display: block; font-size: 26rpx; font-weight: 900; color: #9b8fa8; margin-bottom: 20rpx; }
|
||||||
|
.no-comment { text-align: center; font-size: 26rpx; color: #9b8fa8; padding: 40rpx 0; }
|
||||||
|
|
||||||
|
.comment-item { display: flex; gap: 16rpx; margin-bottom: 24rpx; }
|
||||||
|
.c-avatar { width: 64rpx; height: 64rpx; border-radius: 20rpx; display: flex; align-items: center; justify-content: center; font-size: 28rpx; font-weight: 800; color: rgba(39,34,53,0.72); flex-shrink: 0; }
|
||||||
|
.c-body { flex: 1; background: rgba(255,255,255,0.80); border-radius: 24rpx; padding: 16rpx 20rpx; }
|
||||||
|
.c-name { display: block; font-size: 24rpx; font-weight: 800; color: #272235; margin-bottom: 6rpx; }
|
||||||
|
.c-text { display: block; font-size: 26rpx; color: #272235; line-height: 1.5; margin-bottom: 8rpx; }
|
||||||
|
.c-time { display: block; font-size: 20rpx; color: #9b8fa8; }
|
||||||
|
|
||||||
|
.input-bar { position: fixed; bottom: 0; left: 0; right: 0; display: flex; align-items: center; gap: 14rpx; padding: 16rpx 28rpx; padding-bottom: calc(16rpx + env(safe-area-inset-bottom)); background: rgba(255,255,255,0.92); border-top: 1rpx solid rgba(43,37,61,0.08); z-index: 100; }
|
||||||
|
.comment-input { flex: 1; height: 80rpx; background: rgba(255,255,255,0.80); border: 1rpx solid rgba(43,37,61,0.12); border-radius: 999rpx; padding: 0 24rpx; font-size: 28rpx; color: #272235; }
|
||||||
|
.send-btn { background: linear-gradient(135deg, #ff4f91, #ff9f1c); color: #fff; border-radius: 999rpx; padding: 18rpx 30rpx; font-size: 28rpx; font-weight: 800; flex-shrink: 0; }
|
||||||
|
.send-off { background: #e8e4f0; color: #9b8fa8; }
|
||||||
4
miniprogram/pages/post/post.json
Normal file
4
miniprogram/pages/post/post.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"usingComponents": {}
|
||||||
|
}
|
||||||
196
miniprogram/pages/post/post.ts
Normal file
196
miniprogram/pages/post/post.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { api } from '../../utils/api'
|
||||||
|
import { chooseAndUploadMedia } from '../../utils/auth'
|
||||||
|
import { MOOD_OPTIONS, HOT_HASHTAGS } from '../../utils/constants'
|
||||||
|
import { getPetEmoji } from '../../utils/format'
|
||||||
|
|
||||||
|
const app = getApp<{ userInfo: any; currentLocation: any }>()
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
statusBarHeight: 0,
|
||||||
|
content: '',
|
||||||
|
images: [] as any[],
|
||||||
|
location: null as any,
|
||||||
|
selectedPetId: '',
|
||||||
|
selectedHashtags: [] as string[],
|
||||||
|
hashtagInput: '',
|
||||||
|
selectedMood: '',
|
||||||
|
moods: MOOD_OPTIONS,
|
||||||
|
hotHashtags: HOT_HASHTAGS.slice(0, 6),
|
||||||
|
myPets: [] as any[],
|
||||||
|
submitting: false,
|
||||||
|
canSubmit: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
onLoad() {
|
||||||
|
const info = wx.getSystemInfoSync()
|
||||||
|
this.setData({ statusBarHeight: info.statusBarHeight })
|
||||||
|
this.loadMyPets()
|
||||||
|
this.autoGetLocation()
|
||||||
|
},
|
||||||
|
|
||||||
|
loadMyPets() {
|
||||||
|
const user = app.globalData?.userInfo
|
||||||
|
if (!user?.pets?.length) return
|
||||||
|
const pets = user.pets.map((p: any) => ({
|
||||||
|
...p,
|
||||||
|
emoji: getPetEmoji(p.species, p.breed),
|
||||||
|
}))
|
||||||
|
this.setData({
|
||||||
|
myPets: pets,
|
||||||
|
selectedPetId: pets[0]?._id || '',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
autoGetLocation() {
|
||||||
|
const loc = app.globalData?.currentLocation
|
||||||
|
if (!loc) return
|
||||||
|
wx.reverseGeocoder({
|
||||||
|
location: { longitude: loc.longitude, latitude: loc.latitude },
|
||||||
|
success: (res: any) => {
|
||||||
|
const name = res.result?.address || ''
|
||||||
|
if (name) {
|
||||||
|
this.setData({
|
||||||
|
location: { name, latitude: loc.latitude, longitude: loc.longitude },
|
||||||
|
})
|
||||||
|
this.checkCanSubmit()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => {},
|
||||||
|
} as any)
|
||||||
|
},
|
||||||
|
|
||||||
|
onContentInput(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
this.setData({ content: e.detail.value })
|
||||||
|
this.checkCanSubmit()
|
||||||
|
},
|
||||||
|
|
||||||
|
checkCanSubmit() {
|
||||||
|
const { content, images } = this.data
|
||||||
|
this.setData({ canSubmit: content.trim().length > 0 || images.length > 0 })
|
||||||
|
},
|
||||||
|
|
||||||
|
async onChooseImage() {
|
||||||
|
try {
|
||||||
|
const fileIDs = await chooseAndUploadMedia(9 - this.data.images.length)
|
||||||
|
const newImages = fileIDs.map(id => ({ fileID: id, tempPath: id }))
|
||||||
|
this.setData({ images: [...this.data.images, ...newImages] })
|
||||||
|
this.checkCanSubmit()
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.errMsg?.includes('cancel')) return
|
||||||
|
wx.showToast({ title: '上传失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onRemoveImage(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
const idx = e.currentTarget.dataset.idx as number
|
||||||
|
const images = [...this.data.images]
|
||||||
|
images.splice(idx, 1)
|
||||||
|
this.setData({ images })
|
||||||
|
this.checkCanSubmit()
|
||||||
|
},
|
||||||
|
|
||||||
|
onPreviewImage(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
const idx = e.currentTarget.dataset.idx as number
|
||||||
|
wx.previewImage({
|
||||||
|
current: this.data.images[idx]?.tempPath || this.data.images[idx],
|
||||||
|
urls: this.data.images.map((img: any) => img.tempPath || img),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onChooseLocation() {
|
||||||
|
wx.chooseLocation({
|
||||||
|
success: res => {
|
||||||
|
this.setData({
|
||||||
|
location: {
|
||||||
|
name: res.name || res.address,
|
||||||
|
latitude: res.latitude,
|
||||||
|
longitude: res.longitude,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
fail: () => {},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onRemoveLocation() {
|
||||||
|
this.setData({ location: null })
|
||||||
|
},
|
||||||
|
|
||||||
|
onSelectPet(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
const id = e.currentTarget.dataset.id as string
|
||||||
|
this.setData({ selectedPetId: this.data.selectedPetId === id ? '' : id })
|
||||||
|
},
|
||||||
|
|
||||||
|
onHashtagInput(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
this.setData({ hashtagInput: e.detail.value })
|
||||||
|
},
|
||||||
|
|
||||||
|
onAddHashtag() {
|
||||||
|
let tag = this.data.hashtagInput.trim().replace(/^#/, '')
|
||||||
|
if (!tag) return
|
||||||
|
const tags = [...this.data.selectedHashtags]
|
||||||
|
if (!tags.includes(tag) && tags.length < 5) {
|
||||||
|
tags.push(tag)
|
||||||
|
}
|
||||||
|
this.setData({ selectedHashtags: tags, hashtagInput: '' })
|
||||||
|
},
|
||||||
|
|
||||||
|
onRemoveHashtag(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
const idx = e.currentTarget.dataset.idx as number
|
||||||
|
const tags = [...this.data.selectedHashtags]
|
||||||
|
tags.splice(idx, 1)
|
||||||
|
this.setData({ selectedHashtags: tags })
|
||||||
|
},
|
||||||
|
|
||||||
|
onHotTag(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
const tag = (e.currentTarget.dataset.tag as string).replace(/^#/, '')
|
||||||
|
const tags = [...this.data.selectedHashtags]
|
||||||
|
if (!tags.includes(tag) && tags.length < 5) {
|
||||||
|
tags.push(tag)
|
||||||
|
this.setData({ selectedHashtags: tags })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onSelectMood(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
const val = e.currentTarget.dataset.val as string
|
||||||
|
this.setData({ selectedMood: this.data.selectedMood === val ? '' : val })
|
||||||
|
},
|
||||||
|
|
||||||
|
async onSubmit() {
|
||||||
|
if (!this.data.canSubmit || this.data.submitting) return
|
||||||
|
this.setData({ submitting: true })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const moodLabel = MOOD_OPTIONS.find(m => m.value === this.data.selectedMood)?.label
|
||||||
|
await api.createPost({
|
||||||
|
content: this.data.content.trim(),
|
||||||
|
images: this.data.images.map((img: any) => img.fileID || img),
|
||||||
|
location: this.data.location,
|
||||||
|
hashtags: this.data.selectedHashtags.map(t => `#${t}`),
|
||||||
|
mood: moodLabel,
|
||||||
|
petId: this.data.selectedPetId || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
wx.showToast({ title: '发布成功!', icon: 'success' })
|
||||||
|
setTimeout(() => wx.navigateBack(), 1200)
|
||||||
|
} catch {
|
||||||
|
wx.showToast({ title: '发布失败,请重试', icon: 'none' })
|
||||||
|
this.setData({ submitting: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onCancel() {
|
||||||
|
if (this.data.content || this.data.images.length > 0) {
|
||||||
|
wx.showModal({
|
||||||
|
title: '放弃编辑?',
|
||||||
|
content: '内容将不会保存',
|
||||||
|
confirmText: '放弃',
|
||||||
|
cancelText: '继续',
|
||||||
|
success: res => { if (res.confirm) wx.navigateBack() },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
wx.navigateBack()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
136
miniprogram/pages/post/post.wxml
Normal file
136
miniprogram/pages/post/post.wxml
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
<view class="post-page">
|
||||||
|
<view class="safe-top" style="height: {{statusBarHeight}}px"></view>
|
||||||
|
|
||||||
|
<!-- 顶部导航 -->
|
||||||
|
<view class="post-header">
|
||||||
|
<view class="cancel-btn" bindtap="onCancel">取消</view>
|
||||||
|
<view class="header-title">发布动态</view>
|
||||||
|
<view class="submit-btn {{canSubmit ? '' : 'submit-disabled'}}" bindtap="onSubmit">
|
||||||
|
{{submitting ? '发布中...' : '发布'}}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<scroll-view scroll-y="true" class="post-scroll" enhanced="true" show-scrollbar="false">
|
||||||
|
<view class="post-body">
|
||||||
|
|
||||||
|
<!-- 图片上传区 -->
|
||||||
|
<view class="upload-section">
|
||||||
|
<view wx:if="{{images.length === 0}}" class="upload-placeholder" bindtap="onChooseImage">
|
||||||
|
<view class="upload-icon">📷</view>
|
||||||
|
<text class="upload-hint">点击添加图片或视频</text>
|
||||||
|
<text class="upload-sub">最多9张</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:else class="image-grid">
|
||||||
|
<view
|
||||||
|
wx:for="{{images}}"
|
||||||
|
wx:key="index"
|
||||||
|
class="image-item"
|
||||||
|
bindtap="onPreviewImage"
|
||||||
|
data-idx="{{index}}"
|
||||||
|
>
|
||||||
|
<image class="img-preview" src="{{item.tempPath || item}}" mode="aspectFill" />
|
||||||
|
<view class="img-remove" catchtap="onRemoveImage" data-idx="{{index}}">×</view>
|
||||||
|
<view wx:if="{{item.uploading}}" class="img-uploading">
|
||||||
|
<view class="upload-spinner"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{images.length < 9}}" class="image-add" bindtap="onChooseImage">
|
||||||
|
<text class="add-plus">+</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 文字输入 -->
|
||||||
|
<textarea
|
||||||
|
class="content-input"
|
||||||
|
placeholder="分享你和毛孩子的今天 🐾"
|
||||||
|
placeholder-class="input-placeholder"
|
||||||
|
value="{{content}}"
|
||||||
|
bindinput="onContentInput"
|
||||||
|
maxlength="500"
|
||||||
|
auto-height="true"
|
||||||
|
show-confirm-bar="false"
|
||||||
|
adjust-position="true"
|
||||||
|
cursor-spacing="120"
|
||||||
|
/>
|
||||||
|
<view class="word-count">{{content.length}}/500</view>
|
||||||
|
|
||||||
|
<!-- 位置标签 -->
|
||||||
|
<view class="section-title">📍 位置</view>
|
||||||
|
<view class="tag-row">
|
||||||
|
<view
|
||||||
|
class="tag-pill {{location ? 'tag-selected' : ''}}"
|
||||||
|
bindtap="onChooseLocation"
|
||||||
|
>
|
||||||
|
<text>{{location ? location.name : '添加位置'}}</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{location}}" class="tag-remove" bindtap="onRemoveLocation">×</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 关联宠物 -->
|
||||||
|
<view class="section-title">🐾 关联宠物</view>
|
||||||
|
<view class="tag-row">
|
||||||
|
<view
|
||||||
|
wx:for="{{myPets}}"
|
||||||
|
wx:key="_id"
|
||||||
|
class="tag-pill {{selectedPetId === item._id ? 'tag-selected' : ''}}"
|
||||||
|
bindtap="onSelectPet"
|
||||||
|
data-id="{{item._id}}"
|
||||||
|
>
|
||||||
|
<text>{{item.emoji}} {{item.name}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 话题标签 -->
|
||||||
|
<view class="section-title">🏷 话题</view>
|
||||||
|
<view class="hashtag-wrap">
|
||||||
|
<view class="hashtag-input-row">
|
||||||
|
<input
|
||||||
|
class="hashtag-input"
|
||||||
|
placeholder="输入话题..."
|
||||||
|
value="{{hashtagInput}}"
|
||||||
|
bindinput="onHashtagInput"
|
||||||
|
bindconfirm="onAddHashtag"
|
||||||
|
maxlength="20"
|
||||||
|
/>
|
||||||
|
<view class="hashtag-add-btn" bindtap="onAddHashtag">添加</view>
|
||||||
|
</view>
|
||||||
|
<view class="tag-row" style="margin-top: 16rpx;">
|
||||||
|
<view
|
||||||
|
wx:for="{{selectedHashtags}}"
|
||||||
|
wx:key="index"
|
||||||
|
class="hashtag-chip"
|
||||||
|
>
|
||||||
|
<text>#{{item}}</text>
|
||||||
|
<text class="chip-remove" catchtap="onRemoveHashtag" data-idx="{{index}}">×</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="hot-tags">
|
||||||
|
<text class="hot-tags-label">热门:</text>
|
||||||
|
<view
|
||||||
|
wx:for="{{hotHashtags}}"
|
||||||
|
wx:key="index"
|
||||||
|
class="hot-tag"
|
||||||
|
bindtap="onHotTag"
|
||||||
|
data-tag="{{item}}"
|
||||||
|
>{{item}}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 心情 -->
|
||||||
|
<view class="section-title">😊 心情</view>
|
||||||
|
<view class="mood-row">
|
||||||
|
<view
|
||||||
|
wx:for="{{moods}}"
|
||||||
|
wx:key="value"
|
||||||
|
class="mood-pill {{selectedMood === item.value ? 'mood-on' : ''}}"
|
||||||
|
bindtap="onSelectMood"
|
||||||
|
data-val="{{item.value}}"
|
||||||
|
>{{item.label}}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view style="height: 60rpx;"></view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
324
miniprogram/pages/post/post.wxss
Normal file
324
miniprogram/pages/post/post.wxss
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
.post-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(180deg, #fff8f2 0%, #fff4fb 100%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部 */
|
||||||
|
.post-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16rpx 28rpx;
|
||||||
|
border-bottom: 1rpx solid rgba(255, 255, 255, 0.72);
|
||||||
|
background: rgba(255, 255, 255, 0.60);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #9b8fa8;
|
||||||
|
padding: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #272235;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
background: linear-gradient(135deg, #ff4f91, #ff9f1c);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
padding: 14rpx 34rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
box-shadow: 0 10rpx 22rpx rgba(255, 79, 145, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-disabled {
|
||||||
|
background: #e8e4f0;
|
||||||
|
color: #9b8fa8;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动区 */
|
||||||
|
.post-scroll { flex: 1; }
|
||||||
|
|
||||||
|
.post-body { padding: 24rpx 28rpx; }
|
||||||
|
|
||||||
|
/* 图片上传 */
|
||||||
|
.upload-section {
|
||||||
|
margin-bottom: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 320rpx;
|
||||||
|
background: linear-gradient(135deg, #fff0a8, #ffd6e8 48%, #c9f7df);
|
||||||
|
border: 3rpx dashed rgba(39, 34, 53, 0.18);
|
||||||
|
border-radius: 48rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, 0.65), 0 16rpx 32rpx rgba(83, 62, 100, 0.09);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-icon { font-size: 64rpx; }
|
||||||
|
|
||||||
|
.upload-hint {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #272235;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-sub {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #9b8fa8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 图片网格 */
|
||||||
|
.image-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-item {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #f0eef5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: 6rpx;
|
||||||
|
right: 6rpx;
|
||||||
|
width: 44rpx;
|
||||||
|
height: 44rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(39, 34, 53, 0.72);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 28rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-uploading {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-spinner {
|
||||||
|
width: 40rpx;
|
||||||
|
height: 40rpx;
|
||||||
|
border: 4rpx solid rgba(255, 79, 145, 0.2);
|
||||||
|
border-top-color: #ff4f91;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.image-add {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
border: 3rpx dashed rgba(39, 34, 53, 0.18);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-plus {
|
||||||
|
font-size: 60rpx;
|
||||||
|
color: #9b8fa8;
|
||||||
|
font-weight: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 文字输入 */
|
||||||
|
.content-input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 160rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.80);
|
||||||
|
border: 1rpx solid rgba(255, 255, 255, 0.72);
|
||||||
|
border-radius: 36rpx;
|
||||||
|
padding: 24rpx 28rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #272235;
|
||||||
|
font-family: "PingFang SC", sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
box-shadow: 0 10rpx 24rpx rgba(78, 56, 96, 0.07);
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-placeholder { color: #c4b8d0; }
|
||||||
|
|
||||||
|
.word-count {
|
||||||
|
text-align: right;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #9b8fa8;
|
||||||
|
margin-bottom: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 通用 section */
|
||||||
|
.section-title {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #9b8fa8;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
margin-bottom: 14rpx;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 14rpx;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 28rpx;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-pill {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.78);
|
||||||
|
border: 1rpx solid rgba(255, 255, 255, 0.72);
|
||||||
|
border-radius: 999rpx;
|
||||||
|
padding: 14rpx 24rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #6a6178;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-selected {
|
||||||
|
background: #ffe5f0;
|
||||||
|
border-color: #ffb6d0;
|
||||||
|
color: #a91d5b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-remove {
|
||||||
|
width: 44rpx;
|
||||||
|
height: 44rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ffe5f0;
|
||||||
|
color: #ff4f91;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 话题 */
|
||||||
|
.hashtag-wrap { margin-bottom: 28rpx; }
|
||||||
|
|
||||||
|
.hashtag-input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 14rpx;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hashtag-input {
|
||||||
|
flex: 1;
|
||||||
|
height: 72rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.80);
|
||||||
|
border: 1rpx solid rgba(255, 255, 255, 0.72);
|
||||||
|
border-radius: 999rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #272235;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hashtag-add-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.80);
|
||||||
|
border: 1rpx solid rgba(255, 255, 255, 0.72);
|
||||||
|
border-radius: 999rpx;
|
||||||
|
padding: 14rpx 28rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #272235;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hashtag-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #8b3cff;
|
||||||
|
background: #f0e8ff;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
padding: 8rpx 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-remove {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #8b3cff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hot-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10rpx;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hot-tags-label {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #9b8fa8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hot-tag {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #9b8fa8;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
border: 1rpx solid rgba(43, 37, 61, 0.10);
|
||||||
|
border-radius: 999rpx;
|
||||||
|
padding: 6rpx 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 心情 */
|
||||||
|
.mood-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 14rpx;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mood-pill {
|
||||||
|
background: rgba(255, 255, 255, 0.78);
|
||||||
|
border: 1rpx solid rgba(255, 255, 255, 0.72);
|
||||||
|
border-radius: 999rpx;
|
||||||
|
padding: 14rpx 26rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #6a6178;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mood-on {
|
||||||
|
background: #fff0a8;
|
||||||
|
border-color: #ffe15a;
|
||||||
|
color: #6f4b00;
|
||||||
|
}
|
||||||
6
miniprogram/pages/profile/profile.json
Normal file
6
miniprogram/pages/profile/profile.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"usingComponents": {
|
||||||
|
"post-card": "/components/post-card/post-card"
|
||||||
|
}
|
||||||
|
}
|
||||||
167
miniprogram/pages/profile/profile.ts
Normal file
167
miniprogram/pages/profile/profile.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { Post } from '../../types/index'
|
||||||
|
import { api } from '../../utils/api'
|
||||||
|
import { getAvatarGradient, getPetEmoji, formatCount } from '../../utils/format'
|
||||||
|
|
||||||
|
const app = getApp<{ userInfo: any; isLoggedIn: boolean }>()
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
statusBarHeight: 0,
|
||||||
|
tabBarHeight: 56,
|
||||||
|
userInfo: {
|
||||||
|
nickName: '',
|
||||||
|
handle: '',
|
||||||
|
bio: '',
|
||||||
|
location: '',
|
||||||
|
stats: { posts: 0, friends: 0, likes: 0 },
|
||||||
|
pets: [],
|
||||||
|
} as any,
|
||||||
|
formattedLikes: '0',
|
||||||
|
avatarGradient: 'linear-gradient(135deg, #ffd6e8, #fff1a6)',
|
||||||
|
posts: [] as Post[],
|
||||||
|
postView: 'grid' as 'grid' | 'list',
|
||||||
|
loading: false,
|
||||||
|
refreshing: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
onLoad() {
|
||||||
|
const info = wx.getSystemInfoSync()
|
||||||
|
this.setData({ statusBarHeight: info.statusBarHeight })
|
||||||
|
this.loadProfile()
|
||||||
|
},
|
||||||
|
|
||||||
|
onShow() {
|
||||||
|
const tabBar = this.getTabBar() as any
|
||||||
|
tabBar?.setSelected?.(3)
|
||||||
|
this.syncFromGlobal()
|
||||||
|
},
|
||||||
|
|
||||||
|
syncFromGlobal() {
|
||||||
|
const user = app.globalData?.userInfo
|
||||||
|
if (!user) {
|
||||||
|
wx.redirectTo({ url: '/pages/login/login' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const pets = (user.pets || []).map((p: any) => ({
|
||||||
|
...p,
|
||||||
|
emoji: getPetEmoji(p.species, p.breed),
|
||||||
|
gradient: getAvatarGradient(p._id),
|
||||||
|
}))
|
||||||
|
this.setData({
|
||||||
|
userInfo: { ...user, pets },
|
||||||
|
formattedLikes: formatCount(user.stats?.likes || 0),
|
||||||
|
avatarGradient: getAvatarGradient(user.openid || user._id || ''),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadProfile() {
|
||||||
|
this.setData({ loading: true })
|
||||||
|
try {
|
||||||
|
const [_user, postsRes] = await Promise.all([
|
||||||
|
api.getUserProfile(app.globalData?.userInfo?._id || '').catch(() => null),
|
||||||
|
api.getUserPosts(app.globalData?.userInfo?._id || '').catch(() => ({ list: [], total: 0 })),
|
||||||
|
])
|
||||||
|
if (_user) {
|
||||||
|
app.globalData.userInfo = _user
|
||||||
|
this.syncFromGlobal()
|
||||||
|
}
|
||||||
|
this.setData({ posts: postsRes.list })
|
||||||
|
} catch {
|
||||||
|
} finally {
|
||||||
|
this.setData({ loading: false, refreshing: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setPostView(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
this.setData({ postView: e.currentTarget.dataset.v as 'grid' | 'list' })
|
||||||
|
},
|
||||||
|
|
||||||
|
onStatTap(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
const type = e.currentTarget.dataset.type
|
||||||
|
if (type === 'following') {
|
||||||
|
wx.switchTab({ url: '/pages/friends/friends' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onGridItemTap(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
const id = e.currentTarget.dataset.id
|
||||||
|
wx.navigateTo({ url: `/pages/post-detail/post-detail?id=${id}` })
|
||||||
|
},
|
||||||
|
|
||||||
|
onPetTap(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
const pid = e.currentTarget.dataset.pid
|
||||||
|
wx.navigateTo({ url: `/pages/pet-edit/pet-edit?id=${pid}` })
|
||||||
|
},
|
||||||
|
|
||||||
|
onAddPet() {
|
||||||
|
wx.navigateTo({ url: '/pages/pet-edit/pet-edit' })
|
||||||
|
},
|
||||||
|
|
||||||
|
onEditProfile() {
|
||||||
|
wx.navigateTo({ url: '/pages/edit-profile/edit-profile' })
|
||||||
|
},
|
||||||
|
|
||||||
|
onEditAvatar() {
|
||||||
|
wx.showActionSheet({
|
||||||
|
itemList: ['从相册选取', '拍照'],
|
||||||
|
success: res => {
|
||||||
|
wx.chooseMedia({
|
||||||
|
count: 1,
|
||||||
|
mediaType: ['image'],
|
||||||
|
sourceType: [res.tapIndex === 0 ? 'album' : 'camera'],
|
||||||
|
success: async mediaRes => {
|
||||||
|
const path = mediaRes.tempFiles[0].tempFilePath
|
||||||
|
const cloudPath = `avatars/${app.globalData.userInfo?._id || Date.now()}.jpg`
|
||||||
|
wx.showLoading({ title: '上传中...' })
|
||||||
|
try {
|
||||||
|
const r = await wx.cloud.uploadFile({ cloudPath, filePath: path })
|
||||||
|
wx.hideLoading()
|
||||||
|
wx.showToast({ title: '头像已更新', icon: 'success' })
|
||||||
|
} catch {
|
||||||
|
wx.hideLoading()
|
||||||
|
wx.showToast({ title: '上传失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onSettings() {
|
||||||
|
wx.showActionSheet({
|
||||||
|
itemList: ['账号设置', '隐私设置', '退出登录'],
|
||||||
|
success: res => {
|
||||||
|
if (res.tapIndex === 2) {
|
||||||
|
wx.showModal({
|
||||||
|
title: '退出登录',
|
||||||
|
content: '确定要退出汪圈吗?',
|
||||||
|
success: modal => {
|
||||||
|
if (modal.confirm) {
|
||||||
|
wx.clearStorageSync()
|
||||||
|
app.globalData.userInfo = null
|
||||||
|
app.globalData.isLoggedIn = false
|
||||||
|
wx.redirectTo({ url: '/pages/login/login' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onGoPost() {
|
||||||
|
wx.navigateTo({ url: '/pages/post/post' })
|
||||||
|
},
|
||||||
|
|
||||||
|
async onRefresh() {
|
||||||
|
this.setData({ refreshing: true })
|
||||||
|
await this.loadProfile()
|
||||||
|
},
|
||||||
|
|
||||||
|
onShareAppMessage() {
|
||||||
|
return {
|
||||||
|
title: `${this.data.userInfo.nickName}的汪圈主页`,
|
||||||
|
path: `/pages/pet-detail/pet-detail?userId=${app.globalData?.userInfo?._id}`,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
148
miniprogram/pages/profile/profile.wxml
Normal file
148
miniprogram/pages/profile/profile.wxml
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
<view class="profile-page">
|
||||||
|
<view class="safe-top" style="height: {{statusBarHeight}}px"></view>
|
||||||
|
|
||||||
|
<scroll-view
|
||||||
|
scroll-y="true"
|
||||||
|
class="profile-scroll"
|
||||||
|
enhanced="true"
|
||||||
|
show-scrollbar="false"
|
||||||
|
refresher-enabled="true"
|
||||||
|
bindrefresherrefresh="onRefresh"
|
||||||
|
refresher-triggered="{{refreshing}}"
|
||||||
|
>
|
||||||
|
<!-- 用户信息区 -->
|
||||||
|
<view class="profile-header">
|
||||||
|
<!-- 头部操作栏 -->
|
||||||
|
<view class="header-topbar">
|
||||||
|
<view class="settings-btn" bindtap="onSettings">⚙️</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 头像 + 基本信息 -->
|
||||||
|
<view class="user-top">
|
||||||
|
<view class="user-avatar-wrap">
|
||||||
|
<view class="user-avatar" style="background: {{avatarGradient}}">
|
||||||
|
<text class="avatar-text">{{userInfo.nickName[0] || '?'}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="avatar-edit" bindtap="onEditAvatar">✏️</view>
|
||||||
|
</view>
|
||||||
|
<view class="user-meta">
|
||||||
|
<view class="user-name-row">
|
||||||
|
<text class="user-name">{{userInfo.nickName}}</text>
|
||||||
|
</view>
|
||||||
|
<text class="user-handle">{{userInfo.handle || '@' + userInfo.openid.slice(0,8)}} · {{userInfo.location || '上海'}}</text>
|
||||||
|
<text class="user-bio">{{userInfo.bio || '还没有介绍~'}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="edit-profile-btn" bindtap="onEditProfile">编辑</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 数据统计 -->
|
||||||
|
<view class="stats-row">
|
||||||
|
<view class="stat-item" bindtap="onStatTap" data-type="posts">
|
||||||
|
<text class="stat-num">{{userInfo.stats.posts || 0}}</text>
|
||||||
|
<text class="stat-label">动态</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-divider"></view>
|
||||||
|
<view class="stat-item" bindtap="onStatTap" data-type="following">
|
||||||
|
<text class="stat-num">{{userInfo.stats.friends || 0}}</text>
|
||||||
|
<text class="stat-label">汪友</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-divider"></view>
|
||||||
|
<view class="stat-item" bindtap="onStatTap" data-type="likes">
|
||||||
|
<text class="stat-num">{{formattedLikes}}</text>
|
||||||
|
<text class="stat-label">获赞</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 宠物档案卡 -->
|
||||||
|
<view class="section-wrap">
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">我的宝贝</text>
|
||||||
|
<view class="add-pet-btn" bindtap="onAddPet">+ 添加</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 宠物卡片横滑 -->
|
||||||
|
<scroll-view scroll-x="true" enhanced="true" show-scrollbar="false" class="pets-scroll">
|
||||||
|
<view
|
||||||
|
wx:for="{{userInfo.pets}}"
|
||||||
|
wx:key="_id"
|
||||||
|
class="pet-card"
|
||||||
|
bindtap="onPetTap"
|
||||||
|
data-pid="{{item._id}}"
|
||||||
|
>
|
||||||
|
<view class="pet-avatar" style="background: {{item.gradient || 'linear-gradient(135deg, #c9f7df, #d9d2ff)'}}">
|
||||||
|
<text class="pet-emoji">{{item.emoji || '🐾'}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="pet-info">
|
||||||
|
<text class="pet-name">{{item.name}}</text>
|
||||||
|
<text class="pet-breed">{{item.breed}} · {{item.gender === 'female' ? '女生' : '男生'}} · {{item.age}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="pet-badge">我的宝贝</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 空态时添加宠物 -->
|
||||||
|
<view wx:if="{{userInfo.pets.length === 0}}" class="pet-card pet-card-empty" bindtap="onAddPet">
|
||||||
|
<view class="pet-avatar pet-avatar-add">
|
||||||
|
<text style="font-size: 48rpx;">+</text>
|
||||||
|
</view>
|
||||||
|
<view class="pet-info">
|
||||||
|
<text class="pet-name">添加宠物</text>
|
||||||
|
<text class="pet-breed">建立您的宠物档案</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 动态网格 / 列表切换 -->
|
||||||
|
<view class="section-wrap">
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">全部动态</text>
|
||||||
|
<view class="view-toggle">
|
||||||
|
<view class="toggle-item {{postView === 'grid' ? 'active' : ''}}" bindtap="setPostView" data-v="grid">▦</view>
|
||||||
|
<view class="toggle-item {{postView === 'list' ? 'active' : ''}}" bindtap="setPostView" data-v="list">☰</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 网格视图 -->
|
||||||
|
<view wx:if="{{postView === 'grid'}}" class="post-grid">
|
||||||
|
<view
|
||||||
|
wx:for="{{posts}}"
|
||||||
|
wx:key="_id"
|
||||||
|
class="grid-item"
|
||||||
|
bindtap="onGridItemTap"
|
||||||
|
data-id="{{item._id}}"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
wx:if="{{item.images && item.images.length > 0}}"
|
||||||
|
class="grid-img"
|
||||||
|
src="{{item.images[0]}}"
|
||||||
|
mode="aspectFill"
|
||||||
|
lazy-load="true"
|
||||||
|
/>
|
||||||
|
<view wx:else class="grid-no-img">
|
||||||
|
<text>{{item.content.slice(0, 20)}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 空态 -->
|
||||||
|
<view wx:if="{{posts.length === 0}}" class="grid-empty">
|
||||||
|
<text class="empty-emoji">📸</text>
|
||||||
|
<text class="empty-tip">还没有动态,去发一条吧</text>
|
||||||
|
<view class="btn-primary post-btn" bindtap="onGoPost">发动态</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 列表视图 -->
|
||||||
|
<view wx:else>
|
||||||
|
<post-card
|
||||||
|
wx:for="{{posts}}"
|
||||||
|
wx:key="_id"
|
||||||
|
post="{{item}}"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view style="height: {{tabBarHeight}}px"></view>
|
||||||
|
<view class="safe-bottom"></view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
310
miniprogram/pages/profile/profile.wxss
Normal file
310
miniprogram/pages/profile/profile.wxss
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
.profile-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(180deg, #fff8f2 0%, #fff4fb 50%, #f5f0ff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-scroll { min-height: 100vh; }
|
||||||
|
|
||||||
|
/* 用户信息区 */
|
||||||
|
.profile-header {
|
||||||
|
padding: 0 28rpx 28rpx;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 225, 90, 0.18), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 16rpx 0 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-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: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 24rpx;
|
||||||
|
margin-bottom: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar-wrap {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 128rpx;
|
||||||
|
height: 128rpx;
|
||||||
|
border-radius: 40rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 12rpx 24rpx rgba(98, 60, 118, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-text {
|
||||||
|
font-size: 56rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: rgba(39, 34, 53, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-edit {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -6rpx;
|
||||||
|
right: -6rpx;
|
||||||
|
width: 44rpx;
|
||||||
|
height: 44rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 4rpx 10rpx rgba(78, 56, 96, 0.16);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-meta {
|
||||||
|
flex: 1;
|
||||||
|
padding-top: 6rpx;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-bottom: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #272235;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-handle {
|
||||||
|
display: block;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #9b8fa8;
|
||||||
|
margin-bottom: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-bio {
|
||||||
|
display: block;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #6a6178;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-profile-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.80);
|
||||||
|
border: 1rpx solid rgba(255, 255, 255, 0.72);
|
||||||
|
border-radius: 999rpx;
|
||||||
|
padding: 14rpx 28rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #272235;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 8rpx 18rpx rgba(78, 56, 96, 0.08);
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 数据统计 */
|
||||||
|
.stats-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
flex: 1;
|
||||||
|
padding: 24rpx 0;
|
||||||
|
text-align: center;
|
||||||
|
background: rgba(255, 255, 255, 0.78);
|
||||||
|
border: 1rpx solid rgba(255, 255, 255, 0.72);
|
||||||
|
border-radius: 30rpx;
|
||||||
|
box-shadow: 0 10rpx 22rpx rgba(78, 56, 96, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-num {
|
||||||
|
display: block;
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #ff4f91;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #9b8fa8;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 区块 */
|
||||||
|
.section-wrap {
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20rpx 28rpx 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #9b8fa8;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-pet-btn {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #ff4f91;
|
||||||
|
background: #ffe5f0;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
padding: 8rpx 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 宠物卡片 */
|
||||||
|
.pets-scroll {
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0 28rpx 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pet-card {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18rpx;
|
||||||
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.82), rgba(201, 247, 223, 0.72));
|
||||||
|
border: 1rpx solid rgba(255, 255, 255, 0.72);
|
||||||
|
border-radius: 36rpx;
|
||||||
|
padding: 20rpx 24rpx;
|
||||||
|
margin-right: 20rpx;
|
||||||
|
box-shadow: 0 12rpx 26rpx rgba(78, 56, 96, 0.09);
|
||||||
|
white-space: normal;
|
||||||
|
vertical-align: top;
|
||||||
|
max-width: 560rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pet-card-empty {
|
||||||
|
background: rgba(255, 255, 255, 0.60);
|
||||||
|
border: 3rpx dashed rgba(43, 37, 61, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pet-avatar {
|
||||||
|
width: 84rpx;
|
||||||
|
height: 84rpx;
|
||||||
|
border-radius: 28rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pet-avatar-add { background: rgba(255, 255, 255, 0.60); color: #9b8fa8; }
|
||||||
|
|
||||||
|
.pet-emoji { font-size: 44rpx; line-height: 1; }
|
||||||
|
|
||||||
|
.pet-info { min-width: 0; }
|
||||||
|
|
||||||
|
.pet-name {
|
||||||
|
display: block;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #272235;
|
||||||
|
margin-bottom: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pet-breed {
|
||||||
|
display: block;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #9b8fa8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pet-badge {
|
||||||
|
background: #ffe5f0;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
padding: 8rpx 16rpx;
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #a91d5b;
|
||||||
|
font-weight: 900;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 视图切换 */
|
||||||
|
.view-toggle {
|
||||||
|
display: flex;
|
||||||
|
background: rgba(255, 255, 255, 0.60);
|
||||||
|
border: 1rpx solid rgba(43, 37, 61, 0.10);
|
||||||
|
border-radius: 14rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-item {
|
||||||
|
padding: 10rpx 20rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #9b8fa8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-item.active {
|
||||||
|
background: rgba(255, 79, 145, 0.12);
|
||||||
|
color: #ff4f91;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 动态网格 */
|
||||||
|
.post-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 6rpx;
|
||||||
|
padding: 0 28rpx 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-item {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 28rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f0eef5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-img { width: 100%; height: 100%; }
|
||||||
|
|
||||||
|
.grid-no-img {
|
||||||
|
padding: 12rpx;
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #9b8fa8;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-empty {
|
||||||
|
grid-column: span 3;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 60rpx 0;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-emoji { font-size: 72rpx; }
|
||||||
|
|
||||||
|
.empty-tip { font-size: 26rpx; color: #9b8fa8; }
|
||||||
|
|
||||||
|
.post-btn { padding: 18rpx 48rpx; font-size: 26rpx; font-weight: 800; margin-top: 8rpx; }
|
||||||
1
miniprogram/pages/search/search.json
Normal file
1
miniprogram/pages/search/search.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{ "navigationStyle": "custom", "usingComponents": { "post-card": "/components/post-card/post-card" } }
|
||||||
73
miniprogram/pages/search/search.ts
Normal file
73
miniprogram/pages/search/search.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { getAvatarGradient } from '../../utils/format'
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
statusBarHeight: 0,
|
||||||
|
keyword: '',
|
||||||
|
resultTab: 'posts' as 'posts' | 'users',
|
||||||
|
postResults: [] as any[],
|
||||||
|
userResults: [] as any[],
|
||||||
|
hotTopics: [] as any[],
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
onLoad() {
|
||||||
|
const info = wx.getSystemInfoSync()
|
||||||
|
this.setData({ statusBarHeight: info.statusBarHeight })
|
||||||
|
this.loadHotTopics()
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadHotTopics() {
|
||||||
|
try {
|
||||||
|
const res = await wx.cloud.callFunction({ name: 'getHotTopics', data: {} }) as any
|
||||||
|
this.setData({ hotTopics: res.result?.data || [] })
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
onInput(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
this.setData({ keyword: e.detail.value })
|
||||||
|
},
|
||||||
|
|
||||||
|
onClear() {
|
||||||
|
this.setData({ keyword: '', postResults: [], userResults: [] })
|
||||||
|
},
|
||||||
|
|
||||||
|
async onSearch() {
|
||||||
|
const kw = this.data.keyword.trim()
|
||||||
|
if (!kw) return
|
||||||
|
this.setData({ loading: true })
|
||||||
|
try {
|
||||||
|
const [posts, users] = await Promise.all([
|
||||||
|
wx.cloud.callFunction({ name: 'searchPosts', data: { keyword: kw } }) as any,
|
||||||
|
wx.cloud.callFunction({ name: 'searchUsers', data: { keyword: kw } }) as any,
|
||||||
|
])
|
||||||
|
this.setData({
|
||||||
|
postResults: posts.result?.data || [],
|
||||||
|
userResults: (users.result?.data || []).map((u: any) => ({
|
||||||
|
...u,
|
||||||
|
gradient: getAvatarGradient(u._id),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
wx.showToast({ title: '搜索失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
this.setData({ loading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onTopicTap(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
const tag = e.currentTarget.dataset.tag as string
|
||||||
|
this.setData({ keyword: tag })
|
||||||
|
this.onSearch()
|
||||||
|
},
|
||||||
|
|
||||||
|
onResultTab(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
this.setData({ resultTab: e.currentTarget.dataset.t as 'posts' | 'users' })
|
||||||
|
},
|
||||||
|
|
||||||
|
onUserTap(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
wx.navigateTo({ url: `/pages/pet-detail/pet-detail?userId=${e.currentTarget.dataset.uid}` })
|
||||||
|
},
|
||||||
|
|
||||||
|
onBack() { wx.navigateBack() },
|
||||||
|
})
|
||||||
74
miniprogram/pages/search/search.wxml
Normal file
74
miniprogram/pages/search/search.wxml
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<view class="page">
|
||||||
|
<view class="safe-top" style="height: {{statusBarHeight}}px"></view>
|
||||||
|
<view class="search-bar">
|
||||||
|
<view class="back" bindtap="onBack">‹</view>
|
||||||
|
<view class="input-wrap">
|
||||||
|
<text class="search-icon">🔍</text>
|
||||||
|
<input
|
||||||
|
class="search-input"
|
||||||
|
value="{{keyword}}"
|
||||||
|
bindinput="onInput"
|
||||||
|
bindconfirm="onSearch"
|
||||||
|
placeholder="搜索宠物、话题、用户..."
|
||||||
|
focus="{{true}}"
|
||||||
|
confirm-type="search"
|
||||||
|
/>
|
||||||
|
<view wx:if="{{keyword}}" class="clear" bindtap="onClear">×</view>
|
||||||
|
</view>
|
||||||
|
<view class="search-btn" bindtap="onSearch">搜索</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 热门话题 -->
|
||||||
|
<view wx:if="{{!keyword && !loading}}" class="hot-section">
|
||||||
|
<text class="section-title">热门话题</text>
|
||||||
|
<view class="hot-tags">
|
||||||
|
<view
|
||||||
|
wx:for="{{hotTopics}}"
|
||||||
|
wx:key="index"
|
||||||
|
class="hot-tag"
|
||||||
|
bindtap="onTopicTap"
|
||||||
|
data-tag="{{item.tag}}"
|
||||||
|
>
|
||||||
|
<text class="tag-rank">{{index + 1}}</text>
|
||||||
|
<text class="tag-text">{{item.tag}}</text>
|
||||||
|
<text class="tag-count">{{item.count}}篇</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 搜索结果 -->
|
||||||
|
<scroll-view wx:if="{{keyword}}" scroll-y="true" class="results-scroll" enhanced="true" show-scrollbar="false">
|
||||||
|
<view class="results-tabs">
|
||||||
|
<view class="rtab {{resultTab === 'posts' ? 'rtab-on' : ''}}" bindtap="onResultTab" data-t="posts">帖子</view>
|
||||||
|
<view class="rtab {{resultTab === 'users' ? 'rtab-on' : ''}}" bindtap="onResultTab" data-t="users">用户</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{resultTab === 'posts'}}">
|
||||||
|
<post-card wx:for="{{postResults}}" wx:key="_id" post="{{item}}" />
|
||||||
|
<view wx:if="{{!loading && postResults.length === 0}}" class="empty">
|
||||||
|
<text>没有找到相关帖子</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{resultTab === 'users'}}">
|
||||||
|
<view
|
||||||
|
wx:for="{{userResults}}"
|
||||||
|
wx:key="_id"
|
||||||
|
class="user-result"
|
||||||
|
bindtap="onUserTap"
|
||||||
|
data-uid="{{item._id}}"
|
||||||
|
>
|
||||||
|
<view class="u-avatar" style="background: {{item.gradient}}">{{item.nickName[0]}}</view>
|
||||||
|
<view class="u-info">
|
||||||
|
<text class="u-name">{{item.nickName}}</text>
|
||||||
|
<text class="u-bio">{{item.bio || '宠物爱好者'}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{!loading && userResults.length === 0}}" class="empty">
|
||||||
|
<text>没有找到相关用户</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view style="height: 60rpx;"></view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
30
miniprogram/pages/search/search.wxss
Normal file
30
miniprogram/pages/search/search.wxss
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
.page { min-height: 100vh; background: linear-gradient(180deg, #fff8f2 0%, #f5f0ff 100%); }
|
||||||
|
.search-bar { display: flex; align-items: center; gap: 14rpx; padding: 14rpx 28rpx; }
|
||||||
|
.back { font-size: 60rpx; color: #272235; font-weight: 300; line-height: 1; }
|
||||||
|
.input-wrap { flex: 1; display: flex; align-items: center; background: rgba(255,255,255,0.88); border: 1rpx solid rgba(255,255,255,0.72); border-radius: 999rpx; padding: 0 20rpx; height: 76rpx; gap: 10rpx; box-shadow: 0 8rpx 18rpx rgba(78,56,96,0.08); }
|
||||||
|
.search-icon { font-size: 30rpx; }
|
||||||
|
.search-input { flex: 1; font-size: 28rpx; color: #272235; }
|
||||||
|
.clear { font-size: 36rpx; color: #9b8fa8; }
|
||||||
|
.search-btn { font-size: 28rpx; font-weight: 800; color: #ff4f91; padding: 0 6rpx; }
|
||||||
|
|
||||||
|
.hot-section { padding: 20rpx 28rpx; }
|
||||||
|
.section-title { display: block; font-size: 26rpx; font-weight: 900; color: #9b8fa8; margin-bottom: 20rpx; }
|
||||||
|
.hot-tags { display: flex; flex-direction: column; gap: 0; background: rgba(255,255,255,0.88); border-radius: 36rpx; overflow: hidden; border: 1rpx solid rgba(255,255,255,0.72); box-shadow: 0 12rpx 26rpx rgba(78,56,96,0.08); }
|
||||||
|
.hot-tag { display: flex; align-items: center; gap: 20rpx; padding: 22rpx 28rpx; border-bottom: 1rpx solid rgba(43,37,61,0.05); }
|
||||||
|
.hot-tag:last-child { border-bottom: none; }
|
||||||
|
.tag-rank { font-size: 30rpx; font-weight: 900; color: #ff4f91; width: 32rpx; text-align: center; }
|
||||||
|
.hot-tag:nth-child(n+4) .tag-rank { color: #9b8fa8; }
|
||||||
|
.tag-text { flex: 1; font-size: 28rpx; font-weight: 700; color: #272235; }
|
||||||
|
.tag-count { font-size: 24rpx; color: #9b8fa8; }
|
||||||
|
|
||||||
|
.results-tabs { display: flex; padding: 0 28rpx 20rpx; gap: 8rpx; }
|
||||||
|
.rtab { font-size: 28rpx; font-weight: 700; color: #9b8fa8; padding: 10rpx 20rpx; border-radius: 999rpx; }
|
||||||
|
.rtab-on { color: #ff4f91; background: rgba(255,79,145,0.10); font-weight: 900; }
|
||||||
|
|
||||||
|
.user-result { display: flex; align-items: center; gap: 20rpx; padding: 20rpx 28rpx; background: rgba(255,255,255,0.80); border-bottom: 1rpx solid rgba(43,37,61,0.05); }
|
||||||
|
.u-avatar { width: 88rpx; height: 88rpx; border-radius: 28rpx; display: flex; align-items: center; justify-content: center; font-size: 36rpx; font-weight: 800; color: rgba(39,34,53,0.72); flex-shrink: 0; }
|
||||||
|
.u-name { display: block; font-size: 28rpx; font-weight: 800; color: #272235; }
|
||||||
|
.u-bio { display: block; font-size: 24rpx; color: #9b8fa8; margin-top: 4rpx; }
|
||||||
|
|
||||||
|
.empty { text-align: center; font-size: 26rpx; color: #9b8fa8; padding: 80rpx; }
|
||||||
|
.results-scroll { }
|
||||||
1
miniprogram/pages/story/story.json
Normal file
1
miniprogram/pages/story/story.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{ "navigationStyle": "custom", "usingComponents": {} }
|
||||||
60
miniprogram/pages/story/story.ts
Normal file
60
miniprogram/pages/story/story.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { getAvatarGradient, formatTime } from '../../utils/format'
|
||||||
|
|
||||||
|
const DURATION = 5000
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
stories: [] as any[],
|
||||||
|
current: 0,
|
||||||
|
progress: 0,
|
||||||
|
currentStory: {} as any,
|
||||||
|
},
|
||||||
|
|
||||||
|
_timer: null as any,
|
||||||
|
|
||||||
|
onLoad(query: { id?: string }) {
|
||||||
|
this.loadStory(query.id || '')
|
||||||
|
},
|
||||||
|
|
||||||
|
onUnload() {
|
||||||
|
if (this._timer) clearInterval(this._timer)
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadStory(id: string) {
|
||||||
|
try {
|
||||||
|
const res = await wx.cloud.callFunction({ name: 'getStory', data: { id } }) as any
|
||||||
|
const stories = (res.result?.data || []).map((s: any) => ({
|
||||||
|
...s,
|
||||||
|
gradient: getAvatarGradient(s.authorId),
|
||||||
|
timeText: formatTime(s.createdAt),
|
||||||
|
}))
|
||||||
|
this.setData({ stories, currentStory: stories[0] || {} })
|
||||||
|
this.startProgress()
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
startProgress() {
|
||||||
|
if (this._timer) clearInterval(this._timer)
|
||||||
|
const interval = 100
|
||||||
|
let elapsed = 0
|
||||||
|
this._timer = setInterval(() => {
|
||||||
|
elapsed += interval
|
||||||
|
const pct = Math.min(100, (elapsed / DURATION) * 100)
|
||||||
|
this.setData({ progress: pct })
|
||||||
|
if (elapsed >= DURATION) this.onNext()
|
||||||
|
}, interval)
|
||||||
|
},
|
||||||
|
|
||||||
|
onNext() {
|
||||||
|
const { current, stories } = this.data
|
||||||
|
if (current + 1 >= stories.length) {
|
||||||
|
wx.navigateBack()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const next = current + 1
|
||||||
|
this.setData({ current: next, progress: 0, currentStory: stories[next] })
|
||||||
|
this.startProgress()
|
||||||
|
},
|
||||||
|
|
||||||
|
onClose() { wx.navigateBack() },
|
||||||
|
})
|
||||||
26
miniprogram/pages/story/story.wxml
Normal file
26
miniprogram/pages/story/story.wxml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<view class="story-page" bindtap="onNext">
|
||||||
|
<!-- 进度条 -->
|
||||||
|
<view class="progress-row">
|
||||||
|
<view wx:for="{{stories}}" wx:key="index" class="progress-bar">
|
||||||
|
<view class="progress-fill" style="width: {{index < current ? '100%' : (index === current ? progress + '%' : '0%')}}"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<!-- 作者头 -->
|
||||||
|
<view class="story-nav">
|
||||||
|
<view class="story-avatar" style="background: {{currentStory.gradient}}">
|
||||||
|
<text>{{currentStory.authorName[0]}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="story-meta">
|
||||||
|
<text class="story-author">{{currentStory.authorName}}</text>
|
||||||
|
<text class="story-time">{{currentStory.timeText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="close-btn" catchtap="onClose">✕</view>
|
||||||
|
</view>
|
||||||
|
<!-- 内容 -->
|
||||||
|
<view class="story-content">
|
||||||
|
<image wx:if="{{currentStory.imageUrl}}" class="story-img" src="{{currentStory.imageUrl}}" mode="aspectFill" />
|
||||||
|
<view class="story-text-wrap">
|
||||||
|
<text class="story-text">{{currentStory.text}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
13
miniprogram/pages/story/story.wxss
Normal file
13
miniprogram/pages/story/story.wxss
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
.story-page { width: 100vw; height: 100vh; background: #111; position: relative; overflow: hidden; }
|
||||||
|
.progress-row { position: absolute; top: calc(env(safe-area-inset-top, 44rpx) + 16rpx); left: 16rpx; right: 16rpx; display: flex; gap: 6rpx; z-index: 10; }
|
||||||
|
.progress-bar { flex: 1; height: 4rpx; background: rgba(255,255,255,0.35); border-radius: 2rpx; overflow: hidden; }
|
||||||
|
.progress-fill { height: 100%; background: #fff; border-radius: 2rpx; transition: width 0.1s linear; }
|
||||||
|
.story-nav { position: absolute; top: calc(env(safe-area-inset-top, 44rpx) + 28rpx); left: 20rpx; right: 20rpx; display: flex; align-items: center; gap: 14rpx; z-index: 10; }
|
||||||
|
.story-avatar { width: 72rpx; height: 72rpx; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 32rpx; font-weight: 800; color: rgba(39,34,53,0.72); border: 3rpx solid rgba(255,255,255,0.72); }
|
||||||
|
.story-author { display: block; font-size: 28rpx; font-weight: 800; color: #fff; }
|
||||||
|
.story-time { display: block; font-size: 22rpx; color: rgba(255,255,255,0.72); }
|
||||||
|
.close-btn { margin-left: auto; font-size: 40rpx; color: rgba(255,255,255,0.80); padding: 10rpx; }
|
||||||
|
.story-content { width: 100%; height: 100%; }
|
||||||
|
.story-img { width: 100%; height: 100%; }
|
||||||
|
.story-text-wrap { position: absolute; bottom: 100rpx; left: 28rpx; right: 28rpx; background: rgba(0,0,0,0.42); border-radius: 20rpx; padding: 20rpx 24rpx; }
|
||||||
|
.story-text { font-size: 32rpx; color: #fff; font-weight: 700; line-height: 1.6; }
|
||||||
1
miniprogram/pages/webview/webview.json
Normal file
1
miniprogram/pages/webview/webview.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{ "usingComponents": {} }
|
||||||
9
miniprogram/pages/webview/webview.ts
Normal file
9
miniprogram/pages/webview/webview.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
Page({
|
||||||
|
data: { url: '' },
|
||||||
|
onLoad(query: { url?: string }) {
|
||||||
|
const url = decodeURIComponent(query.url || '')
|
||||||
|
this.setData({ url })
|
||||||
|
wx.setNavigationBarTitle({ title: '汪圈' })
|
||||||
|
},
|
||||||
|
onMessage() {},
|
||||||
|
})
|
||||||
1
miniprogram/pages/webview/webview.wxml
Normal file
1
miniprogram/pages/webview/webview.wxml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<web-view src="{{url}}" bindmessage="onMessage"></web-view>
|
||||||
1
miniprogram/pages/webview/webview.wxss
Normal file
1
miniprogram/pages/webview/webview.wxss
Normal file
@@ -0,0 +1 @@
|
|||||||
|
web-view { width: 100%; height: 100%; }
|
||||||
9
miniprogram/sitemap.json
Normal file
9
miniprogram/sitemap.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html",
|
||||||
|
"rules": [
|
||||||
|
{ "action": "allow", "page": "pages/feed/feed" },
|
||||||
|
{ "action": "allow", "page": "pages/pet-detail/pet-detail" },
|
||||||
|
{ "action": "disallow", "page": "pages/post/post" },
|
||||||
|
{ "action": "disallow", "page": "pages/chat/chat" }
|
||||||
|
]
|
||||||
|
}
|
||||||
108
miniprogram/types/index.ts
Normal file
108
miniprogram/types/index.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
export interface Pet {
|
||||||
|
_id: string
|
||||||
|
ownerId: string
|
||||||
|
name: string
|
||||||
|
species: 'dog' | 'cat' | 'other'
|
||||||
|
breed: string
|
||||||
|
gender: 'male' | 'female'
|
||||||
|
birthday?: string
|
||||||
|
age: string
|
||||||
|
emoji: string
|
||||||
|
photos: string[]
|
||||||
|
tags: string[]
|
||||||
|
bio?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
_id: string
|
||||||
|
openid: string
|
||||||
|
nickName: string
|
||||||
|
avatarUrl: string
|
||||||
|
handle: string
|
||||||
|
location: string
|
||||||
|
bio: string
|
||||||
|
stats: {
|
||||||
|
posts: number
|
||||||
|
friends: number
|
||||||
|
likes: number
|
||||||
|
}
|
||||||
|
lastLocation?: GeoPoint
|
||||||
|
isOnline: boolean
|
||||||
|
lastSeen: string
|
||||||
|
pets: Pet[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeoPoint {
|
||||||
|
latitude: number
|
||||||
|
longitude: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocationTag {
|
||||||
|
name: string
|
||||||
|
latitude: number
|
||||||
|
longitude: number
|
||||||
|
address?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Post {
|
||||||
|
_id: string
|
||||||
|
authorId: string
|
||||||
|
author?: UserProfile
|
||||||
|
petId?: string
|
||||||
|
pet?: Pet
|
||||||
|
content: string
|
||||||
|
images: string[]
|
||||||
|
video?: string
|
||||||
|
location?: LocationTag
|
||||||
|
hashtags: string[]
|
||||||
|
mood?: string
|
||||||
|
likeCount: number
|
||||||
|
commentCount: number
|
||||||
|
isLiked?: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Comment {
|
||||||
|
_id: string
|
||||||
|
postId: string
|
||||||
|
authorId: string
|
||||||
|
author?: UserProfile
|
||||||
|
content: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
_id: string
|
||||||
|
fromId: string
|
||||||
|
from?: UserProfile
|
||||||
|
toId: string
|
||||||
|
content: string
|
||||||
|
type: 'greeting' | 'text' | 'image'
|
||||||
|
isRead: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NearbyUser {
|
||||||
|
userId: string
|
||||||
|
user: UserProfile
|
||||||
|
pet: Pet
|
||||||
|
distance: number
|
||||||
|
distanceText: string
|
||||||
|
isOnline: boolean
|
||||||
|
lastSeenText: string
|
||||||
|
location: GeoPoint
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabBarItem {
|
||||||
|
pagePath: string
|
||||||
|
text: string
|
||||||
|
icon: string
|
||||||
|
activeIcon: string
|
||||||
|
selected: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppGlobalData {
|
||||||
|
userInfo: UserProfile | null
|
||||||
|
isLoggedIn: boolean
|
||||||
|
currentLocation: GeoPoint | null
|
||||||
|
}
|
||||||
81
miniprogram/utils/api.ts
Normal file
81
miniprogram/utils/api.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { Post, UserProfile, NearbyUser, Pet } from '../types/index'
|
||||||
|
|
||||||
|
type CloudResult<T> = { result: { code: number; data: T; message?: string } }
|
||||||
|
|
||||||
|
async function call<T>(name: string, data?: Record<string, unknown>): Promise<T> {
|
||||||
|
const res = await wx.cloud.callFunction({ name, data: data || {} }) as CloudResult<T>
|
||||||
|
if (res.result.code !== 0) throw new Error(res.result.message || '请求失败')
|
||||||
|
return res.result.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
login: () =>
|
||||||
|
call<{ userInfo: UserProfile; token: string }>('login'),
|
||||||
|
|
||||||
|
getUserProfile: (userId: string) =>
|
||||||
|
call<UserProfile>('getUser', { userId }),
|
||||||
|
|
||||||
|
getPosts: (params: { page?: number; type?: 'feed' | 'nearby' | 'hot'; latitude?: number; longitude?: number }) =>
|
||||||
|
call<{ list: Post[]; total: number; hasMore: boolean }>('getPosts', params),
|
||||||
|
|
||||||
|
getUserPosts: (userId: string, page = 0) =>
|
||||||
|
call<{ list: Post[]; total: number }>('getPosts', { userId, page }),
|
||||||
|
|
||||||
|
createPost: (params: {
|
||||||
|
content: string
|
||||||
|
images: string[]
|
||||||
|
video?: string
|
||||||
|
location?: { name: string; latitude: number; longitude: number }
|
||||||
|
hashtags: string[]
|
||||||
|
mood?: string
|
||||||
|
petId?: string
|
||||||
|
}) => call<Post>('createPost', params),
|
||||||
|
|
||||||
|
deletePost: (postId: string) =>
|
||||||
|
call<void>('deletePost', { postId }),
|
||||||
|
|
||||||
|
likePost: (postId: string) =>
|
||||||
|
call<{ liked: boolean; count: number }>('likePost', { postId }),
|
||||||
|
|
||||||
|
getComments: (postId: string, page = 0) =>
|
||||||
|
call<{ list: Comment[]; total: number }>('getComments', { postId, page }),
|
||||||
|
|
||||||
|
addComment: (postId: string, content: string) =>
|
||||||
|
call<Comment>('addComment', { postId, content }),
|
||||||
|
|
||||||
|
getNearbyPets: (latitude: number, longitude: number, radiusKm = 5) =>
|
||||||
|
call<NearbyUser[]>('getNearbyPets', { latitude, longitude, radiusKm }),
|
||||||
|
|
||||||
|
updateLocation: (latitude: number, longitude: number) =>
|
||||||
|
call<void>('updateLocation', { latitude, longitude }),
|
||||||
|
|
||||||
|
sendGreeting: (toUserId: string, petId: string) =>
|
||||||
|
call<void>('sendGreeting', { toUserId, petId }),
|
||||||
|
|
||||||
|
followUser: (targetId: string) =>
|
||||||
|
call<{ following: boolean }>('followUser', { targetId }),
|
||||||
|
|
||||||
|
getFollowList: (type: 'following' | 'followers', userId?: string) =>
|
||||||
|
call<UserProfile[]>('getFollowList', { type, userId }),
|
||||||
|
|
||||||
|
getMessages: (page = 0) =>
|
||||||
|
call<{ list: any[]; unreadCount: number }>('getMessages', { page }),
|
||||||
|
|
||||||
|
sendMessage: (toId: string, content: string, type = 'text') =>
|
||||||
|
call<void>('sendMessage', { toId, content, type }),
|
||||||
|
|
||||||
|
updatePet: (pet: Partial<Pet> & { _id?: string }) =>
|
||||||
|
call<Pet>('updatePet', { pet }),
|
||||||
|
|
||||||
|
uploadImage: async (filePath: string): Promise<string> => {
|
||||||
|
const ext = filePath.split('.').pop() || 'jpg'
|
||||||
|
const cloudPath = `posts/${Date.now()}_${Math.random().toString(36).slice(2)}.${ext}`
|
||||||
|
const res = await wx.cloud.uploadFile({ cloudPath, filePath })
|
||||||
|
return res.fileID
|
||||||
|
},
|
||||||
|
|
||||||
|
getFileURL: async (fileID: string): Promise<string> => {
|
||||||
|
const res = await wx.cloud.getTempFileURL({ fileList: [fileID] })
|
||||||
|
return res.fileList[0]?.tempFileURL || fileID
|
||||||
|
},
|
||||||
|
}
|
||||||
74
miniprogram/utils/auth.ts
Normal file
74
miniprogram/utils/auth.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { UserProfile } from '../types/index'
|
||||||
|
import { api } from './api'
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'wangquan_token'
|
||||||
|
const USER_KEY = 'wangquan_user'
|
||||||
|
|
||||||
|
export async function login(): Promise<UserProfile> {
|
||||||
|
const { userInfo } = await api.login()
|
||||||
|
wx.setStorageSync(USER_KEY, JSON.stringify(userInfo))
|
||||||
|
return userInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCachedUser(): UserProfile | null {
|
||||||
|
try {
|
||||||
|
const raw = wx.getStorageSync(USER_KEY)
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAuth(): void {
|
||||||
|
wx.removeStorageSync(TOKEN_KEY)
|
||||||
|
wx.removeStorageSync(USER_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireLocation(): Promise<{ latitude: number; longitude: number }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
wx.getLocation({
|
||||||
|
type: 'gcj02',
|
||||||
|
success: res => resolve({ latitude: res.latitude, longitude: res.longitude }),
|
||||||
|
fail: () => {
|
||||||
|
wx.showModal({
|
||||||
|
title: '需要位置权限',
|
||||||
|
content: '汪圈需要获取您的位置来显示附近的宠物,请在设置中开启位置权限',
|
||||||
|
confirmText: '去设置',
|
||||||
|
success: res => {
|
||||||
|
if (res.confirm) wx.openSetting({})
|
||||||
|
reject(new Error('位置权限被拒绝'))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function chooseAndUploadMedia(count = 9): Promise<string[]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
wx.chooseMedia({
|
||||||
|
count,
|
||||||
|
mediaType: ['image'],
|
||||||
|
sourceType: ['album', 'camera'],
|
||||||
|
maxDuration: 30,
|
||||||
|
camera: 'back',
|
||||||
|
success: async res => {
|
||||||
|
try {
|
||||||
|
wx.showLoading({ title: '上传中...', mask: true })
|
||||||
|
const uploads = res.tempFiles.map(f => {
|
||||||
|
const ext = f.tempFilePath.split('.').pop() || 'jpg'
|
||||||
|
const cloudPath = `posts/${Date.now()}_${Math.random().toString(36).slice(2)}.${ext}`
|
||||||
|
return wx.cloud.uploadFile({ cloudPath, filePath: f.tempFilePath })
|
||||||
|
})
|
||||||
|
const results = await Promise.all(uploads)
|
||||||
|
wx.hideLoading()
|
||||||
|
resolve(results.map(r => r.fileID))
|
||||||
|
} catch (e) {
|
||||||
|
wx.hideLoading()
|
||||||
|
reject(e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: reject,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
58
miniprogram/utils/constants.ts
Normal file
58
miniprogram/utils/constants.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
export const CLOUD_ENV = 'YOUR_CLOUD_ENV_ID'
|
||||||
|
|
||||||
|
export const COLORS = {
|
||||||
|
pink: '#ff4f91',
|
||||||
|
orange: '#ff9f1c',
|
||||||
|
yellow: '#ffe15a',
|
||||||
|
green: '#2fd37a',
|
||||||
|
mint: '#67e8c9',
|
||||||
|
blue: '#4d8dff',
|
||||||
|
violet: '#8c5cff',
|
||||||
|
ink: '#272235',
|
||||||
|
bgPrimary: '#fff9fb',
|
||||||
|
textPrimary: '#272235',
|
||||||
|
textSecondary: '#6a6178',
|
||||||
|
textTertiary: '#9b8fa8',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AVATAR_GRADIENTS = [
|
||||||
|
'linear-gradient(135deg, #ffd6e8, #fff1a6)',
|
||||||
|
'linear-gradient(135deg, #ffe7a0, #b7f4d2)',
|
||||||
|
'linear-gradient(135deg, #c9f7df, #d9d2ff)',
|
||||||
|
'linear-gradient(135deg, #cfe8ff, #ffd6e8)',
|
||||||
|
'linear-gradient(135deg, #e3d8ff, #ffd6e8)',
|
||||||
|
]
|
||||||
|
|
||||||
|
export const MOOD_OPTIONS = [
|
||||||
|
{ label: '开心 😄', value: 'happy' },
|
||||||
|
{ label: '累了 😮💨', value: 'tired' },
|
||||||
|
{ label: '求玩伴 🐾', value: 'playmate' },
|
||||||
|
{ label: '晒娃 📸', value: 'show' },
|
||||||
|
{ label: '散步中 🌿', value: 'walking' },
|
||||||
|
{ label: '在医院 🏥', value: 'hospital' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const HOT_HASHTAGS = [
|
||||||
|
'#上海遛狗',
|
||||||
|
'#求玩伴',
|
||||||
|
'#静安区',
|
||||||
|
'#比熊',
|
||||||
|
'#柴犬',
|
||||||
|
'#金毛',
|
||||||
|
'#布偶猫',
|
||||||
|
'#英短',
|
||||||
|
'#宠物友好',
|
||||||
|
'#周末遛狗',
|
||||||
|
]
|
||||||
|
|
||||||
|
export const BREED_OPTIONS = {
|
||||||
|
dog: ['比熊', '柴犬', '金毛', '泰迪', '边牧', '法斗', '哈士奇', '萨摩耶', '拉布拉多', '其他'],
|
||||||
|
cat: ['英短', '布偶', '美短', '加菲', '暹罗', '橘猫', '黑猫', '其他'],
|
||||||
|
other: ['兔子', '仓鼠', '鹦鹉', '龟', '其他'],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NEARBY_RADIUS_KM = 5
|
||||||
|
|
||||||
|
export const PAGE_SIZE = 10
|
||||||
|
|
||||||
|
export const HEARTBEAT_INTERVAL_MS = 60000
|
||||||
72
miniprogram/utils/format.ts
Normal file
72
miniprogram/utils/format.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
export function formatDistance(meters: number): string {
|
||||||
|
if (meters < 1000) return `${Math.round(meters)}m`
|
||||||
|
return `${(meters / 1000).toFixed(1)}km`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(isoString: string): string {
|
||||||
|
const date = new Date(isoString)
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now.getTime() - date.getTime()
|
||||||
|
const diffMin = Math.floor(diffMs / 60000)
|
||||||
|
const diffHour = Math.floor(diffMin / 60)
|
||||||
|
const diffDay = Math.floor(diffHour / 24)
|
||||||
|
|
||||||
|
if (diffMin < 1) return '刚刚'
|
||||||
|
if (diffMin < 60) return `${diffMin}分钟前`
|
||||||
|
if (diffHour < 24) return `${diffHour}小时前`
|
||||||
|
if (diffDay < 7) return `${diffDay}天前`
|
||||||
|
|
||||||
|
const month = date.getMonth() + 1
|
||||||
|
const day = date.getDate()
|
||||||
|
return `${month}月${day}日`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLastSeen(isoString: string): string {
|
||||||
|
const diffMin = Math.floor((Date.now() - new Date(isoString).getTime()) / 60000)
|
||||||
|
if (diffMin < 2) return '刚刚在线'
|
||||||
|
if (diffMin < 60) return `${diffMin}分钟前`
|
||||||
|
const diffHour = Math.floor(diffMin / 60)
|
||||||
|
if (diffHour < 24) return `${diffHour}小时前`
|
||||||
|
return '昨天在线'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCount(n: number): string {
|
||||||
|
if (n < 1000) return String(n)
|
||||||
|
if (n < 10000) return `${(n / 1000).toFixed(1)}k`
|
||||||
|
return `${Math.floor(n / 10000)}w`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAvatarGradient(seed: string): string {
|
||||||
|
const gradients = [
|
||||||
|
'linear-gradient(135deg, #ffd6e8, #fff1a6)',
|
||||||
|
'linear-gradient(135deg, #ffe7a0, #b7f4d2)',
|
||||||
|
'linear-gradient(135deg, #c9f7df, #d9d2ff)',
|
||||||
|
'linear-gradient(135deg, #cfe8ff, #ffd6e8)',
|
||||||
|
'linear-gradient(135deg, #e3d8ff, #c9f7df)',
|
||||||
|
]
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) & 0xffffffff
|
||||||
|
return gradients[Math.abs(hash) % gradients.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPetEmoji(species: string, breed?: string): string {
|
||||||
|
if (species === 'cat') return '🐱'
|
||||||
|
if (species === 'other') return '🐾'
|
||||||
|
const dogBreedEmoji: Record<string, string> = {
|
||||||
|
'比熊': '🐩',
|
||||||
|
'柴犬': '🐕',
|
||||||
|
'金毛': '🦮',
|
||||||
|
'哈士奇': '🐺',
|
||||||
|
'萨摩耶': '🐕🦺',
|
||||||
|
}
|
||||||
|
return (breed && dogBreedEmoji[breed]) || '🐶'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcDistance(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||||
|
const R = 6371000
|
||||||
|
const dLat = (lat2 - lat1) * Math.PI / 180
|
||||||
|
const dLng = (lng2 - lng1) * Math.PI / 180
|
||||||
|
const a = Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2
|
||||||
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
||||||
|
}
|
||||||
49
project.config.json
Normal file
49
project.config.json
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"appid": "YOUR_APPID_HERE",
|
||||||
|
"compileType": "miniprogram",
|
||||||
|
"libVersion": "2.25.0",
|
||||||
|
"packOptions": {
|
||||||
|
"ignore": [
|
||||||
|
{ "type": "regexp", "value": "\\.eslintrc\\.js" },
|
||||||
|
{ "type": "regexp", "value": "\\.gitignore" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"setting": {
|
||||||
|
"coverView": true,
|
||||||
|
"es6": true,
|
||||||
|
"enhance": true,
|
||||||
|
"postcss": true,
|
||||||
|
"preloadBackgroundData": false,
|
||||||
|
"minified": true,
|
||||||
|
"newFeature": true,
|
||||||
|
"uglifyFileName": false,
|
||||||
|
"uploadWithSourceMap": true,
|
||||||
|
"useIsolateContext": true,
|
||||||
|
"nodeModules": false,
|
||||||
|
"autoAudits": false,
|
||||||
|
"useMultiFrameRuntime": true,
|
||||||
|
"useApiHook": true,
|
||||||
|
"useApiEventHook": true,
|
||||||
|
"babelSetting": {
|
||||||
|
"ignore": [],
|
||||||
|
"disablePlugins": [],
|
||||||
|
"outputPath": ""
|
||||||
|
},
|
||||||
|
"enableEngineNative": false,
|
||||||
|
"bundle": false,
|
||||||
|
"useIsolateContext": true,
|
||||||
|
"userConfirmedBundleSwitch": false,
|
||||||
|
"packNpmManually": false,
|
||||||
|
"packNpmRelationList": [],
|
||||||
|
"minifyWXSS": true,
|
||||||
|
"disableUseStrict": false,
|
||||||
|
"ignoreUploadUnusedFiles": true,
|
||||||
|
"uploadWithSourceMap": true
|
||||||
|
},
|
||||||
|
"condition": {},
|
||||||
|
"editorSetting": {
|
||||||
|
"tabIndent": "insertSpaces",
|
||||||
|
"tabSize": 2
|
||||||
|
},
|
||||||
|
"srcMiniprogramRoot": "miniprogram/"
|
||||||
|
}
|
||||||
26
tsconfig.json
Normal file
26
tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"module": "CommonJS",
|
||||||
|
"target": "ES2017",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"noImplicitThis": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"alwaysStrict": true,
|
||||||
|
"inlineSourceMap": true,
|
||||||
|
"inlineSources": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"strict": false,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["miniprogram/*"]
|
||||||
|
},
|
||||||
|
"typeRoots": ["./typings"]
|
||||||
|
},
|
||||||
|
"include": ["miniprogram/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "miniprogram/**/*.d.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user