commit c04c89018619fdce000d279f6ad4c5a9f5387ffc Author: chenwu Date: Fri Jun 5 17:46:51 2026 +0800 Initial commit diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..a2ca39a --- /dev/null +++ b/SETUP.md @@ -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 存储 diff --git a/cloudfunctions/createPost/index.js b/cloudfunctions/createPost/index.js new file mode 100644 index 0000000..2cea1fa --- /dev/null +++ b/cloudfunctions/createPost/index.js @@ -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 } + } +} diff --git a/cloudfunctions/createPost/package.json b/cloudfunctions/createPost/package.json new file mode 100644 index 0000000..33a5e5d --- /dev/null +++ b/cloudfunctions/createPost/package.json @@ -0,0 +1 @@ +{ "name": "createPost", "version": "1.0.0", "main": "index.js", "dependencies": { "wx-server-sdk": "~2.1.2" } } diff --git a/cloudfunctions/getNearbyPets/index.js b/cloudfunctions/getNearbyPets/index.js new file mode 100644 index 0000000..6aba16d --- /dev/null +++ b/cloudfunctions/getNearbyPets/index.js @@ -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 } + } +} diff --git a/cloudfunctions/getNearbyPets/package.json b/cloudfunctions/getNearbyPets/package.json new file mode 100644 index 0000000..7357349 --- /dev/null +++ b/cloudfunctions/getNearbyPets/package.json @@ -0,0 +1 @@ +{ "name": "getNearbyPets", "version": "1.0.0", "main": "index.js", "dependencies": { "wx-server-sdk": "~2.1.2" } } diff --git a/cloudfunctions/getPost/index.js b/cloudfunctions/getPost/index.js new file mode 100644 index 0000000..a3b31b0 --- /dev/null +++ b/cloudfunctions/getPost/index.js @@ -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 } + } +} diff --git a/cloudfunctions/getPost/package.json b/cloudfunctions/getPost/package.json new file mode 100644 index 0000000..ca870cb --- /dev/null +++ b/cloudfunctions/getPost/package.json @@ -0,0 +1 @@ +{ "name": "getPost", "version": "1.0.0", "main": "index.js", "dependencies": { "wx-server-sdk": "~2.1.2" } } diff --git a/cloudfunctions/getPosts/index.js b/cloudfunctions/getPosts/index.js new file mode 100644 index 0000000..9b8086a --- /dev/null +++ b/cloudfunctions/getPosts/index.js @@ -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 } + } +} diff --git a/cloudfunctions/getPosts/package.json b/cloudfunctions/getPosts/package.json new file mode 100644 index 0000000..bed7e44 --- /dev/null +++ b/cloudfunctions/getPosts/package.json @@ -0,0 +1 @@ +{ "name": "getPosts", "version": "1.0.0", "main": "index.js", "dependencies": { "wx-server-sdk": "~2.1.2" } } diff --git a/cloudfunctions/likePost/index.js b/cloudfunctions/likePost/index.js new file mode 100644 index 0000000..8383478 --- /dev/null +++ b/cloudfunctions/likePost/index.js @@ -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 } + } +} diff --git a/cloudfunctions/likePost/package.json b/cloudfunctions/likePost/package.json new file mode 100644 index 0000000..c1daa3b --- /dev/null +++ b/cloudfunctions/likePost/package.json @@ -0,0 +1 @@ +{ "name": "likePost", "version": "1.0.0", "main": "index.js", "dependencies": { "wx-server-sdk": "~2.1.2" } } diff --git a/cloudfunctions/login/index.js b/cloudfunctions/login/index.js new file mode 100644 index 0000000..9ea4031 --- /dev/null +++ b/cloudfunctions/login/index.js @@ -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 } + } +} diff --git a/cloudfunctions/login/package.json b/cloudfunctions/login/package.json new file mode 100644 index 0000000..5ff9962 --- /dev/null +++ b/cloudfunctions/login/package.json @@ -0,0 +1,9 @@ +{ + "name": "login", + "version": "1.0.0", + "description": "微信云函数 - 用户登录", + "main": "index.js", + "dependencies": { + "wx-server-sdk": "~2.1.2" + } +} diff --git a/cloudfunctions/sendGreeting/index.js b/cloudfunctions/sendGreeting/index.js new file mode 100644 index 0000000..3b4c7c1 --- /dev/null +++ b/cloudfunctions/sendGreeting/index.js @@ -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 } + } +} diff --git a/cloudfunctions/sendGreeting/package.json b/cloudfunctions/sendGreeting/package.json new file mode 100644 index 0000000..23d32a3 --- /dev/null +++ b/cloudfunctions/sendGreeting/package.json @@ -0,0 +1 @@ +{ "name": "sendGreeting", "version": "1.0.0", "main": "index.js", "dependencies": { "wx-server-sdk": "~2.1.2" } } diff --git a/cloudfunctions/updateLocation/index.js b/cloudfunctions/updateLocation/index.js new file mode 100644 index 0000000..8c0de2a --- /dev/null +++ b/cloudfunctions/updateLocation/index.js @@ -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 } + } +} diff --git a/cloudfunctions/updateLocation/package.json b/cloudfunctions/updateLocation/package.json new file mode 100644 index 0000000..939abe5 --- /dev/null +++ b/cloudfunctions/updateLocation/package.json @@ -0,0 +1 @@ +{ "name": "updateLocation", "version": "1.0.0", "main": "index.js", "dependencies": { "wx-server-sdk": "~2.1.2" } } diff --git a/miniprogram/app.json b/miniprogram/app.json new file mode 100644 index 0000000..7cba45c --- /dev/null +++ b/miniprogram/app.json @@ -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 + } +} diff --git a/miniprogram/app.ts b/miniprogram/app.ts new file mode 100644 index 0000000..748cf23 --- /dev/null +++ b/miniprogram/app.ts @@ -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 + } + }, +}) diff --git a/miniprogram/app.wxss b/miniprogram/app.wxss new file mode 100644 index 0000000..02da22a --- /dev/null +++ b/miniprogram/app.wxss @@ -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); +} diff --git a/miniprogram/components/post-card/post-card.json b/miniprogram/components/post-card/post-card.json new file mode 100644 index 0000000..a89ef4d --- /dev/null +++ b/miniprogram/components/post-card/post-card.json @@ -0,0 +1,4 @@ +{ + "component": true, + "usingComponents": {} +} diff --git a/miniprogram/components/post-card/post-card.ts b/miniprogram/components/post-card/post-card.ts new file mode 100644 index 0000000..d00b8c4 --- /dev/null +++ b/miniprogram/components/post-card/post-card.ts @@ -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'] }) + }, + }, +}) diff --git a/miniprogram/components/post-card/post-card.wxml b/miniprogram/components/post-card/post-card.wxml new file mode 100644 index 0000000..e55e1f0 --- /dev/null +++ b/miniprogram/components/post-card/post-card.wxml @@ -0,0 +1,72 @@ + + + + + {{post.author.nickName[0] || '?'}} + + + {{timeText}} + + + + + + + + + + + + + + + + + + {{post.content}} + + + + + + + + {{post.isLiked ? '❤️' : '🤍'}} + {{likeCount}} + + + 💬 + {{post.commentCount}} + + + + 转发 + + + + diff --git a/miniprogram/components/post-card/post-card.wxss b/miniprogram/components/post-card/post-card.wxss new file mode 100644 index 0000000..9a0e1a4 --- /dev/null +++ b/miniprogram/components/post-card/post-card.wxss @@ -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; +} diff --git a/miniprogram/custom-tab-bar/index.json b/miniprogram/custom-tab-bar/index.json new file mode 100644 index 0000000..a89ef4d --- /dev/null +++ b/miniprogram/custom-tab-bar/index.json @@ -0,0 +1,4 @@ +{ + "component": true, + "usingComponents": {} +} diff --git a/miniprogram/custom-tab-bar/index.ts b/miniprogram/custom-tab-bar/index.ts new file mode 100644 index 0000000..6b11f9c --- /dev/null +++ b/miniprogram/custom-tab-bar/index.ts @@ -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 }) + }, + }, +}) diff --git a/miniprogram/custom-tab-bar/index.wxml b/miniprogram/custom-tab-bar/index.wxml new file mode 100644 index 0000000..1f953f2 --- /dev/null +++ b/miniprogram/custom-tab-bar/index.wxml @@ -0,0 +1,56 @@ + + + + + + + + + + + 广场 + + + + + + + + + + + 附近 + + + + + + + + + + + + + + + + + + {{unreadCount > 99 ? '99+' : unreadCount}} + + 汪友 + + + + + + + + + + + 我的 + + + diff --git a/miniprogram/custom-tab-bar/index.wxss b/miniprogram/custom-tab-bar/index.wxss new file mode 100644 index 0000000..290697d --- /dev/null +++ b/miniprogram/custom-tab-bar/index.wxss @@ -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; +} diff --git a/miniprogram/pages/chat/chat.json b/miniprogram/pages/chat/chat.json new file mode 100644 index 0000000..b12ad85 --- /dev/null +++ b/miniprogram/pages/chat/chat.json @@ -0,0 +1 @@ +{ "navigationStyle": "custom", "usingComponents": {} } diff --git a/miniprogram/pages/chat/chat.ts b/miniprogram/pages/chat/chat.ts new file mode 100644 index 0000000..6734eda --- /dev/null +++ b/miniprogram/pages/chat/chat.ts @@ -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() + }, +}) diff --git a/miniprogram/pages/chat/chat.wxml b/miniprogram/pages/chat/chat.wxml new file mode 100644 index 0000000..f0f8459 --- /dev/null +++ b/miniprogram/pages/chat/chat.wxml @@ -0,0 +1,21 @@ + + + + + {{peerName}} + + + + + + {{item.content}} + + + + + + + 发送 + + diff --git a/miniprogram/pages/chat/chat.wxss b/miniprogram/pages/chat/chat.wxss new file mode 100644 index 0000000..7659cca --- /dev/null +++ b/miniprogram/pages/chat/chat.wxss @@ -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; } diff --git a/miniprogram/pages/edit-profile/edit-profile.json b/miniprogram/pages/edit-profile/edit-profile.json new file mode 100644 index 0000000..b12ad85 --- /dev/null +++ b/miniprogram/pages/edit-profile/edit-profile.json @@ -0,0 +1 @@ +{ "navigationStyle": "custom", "usingComponents": {} } diff --git a/miniprogram/pages/edit-profile/edit-profile.ts b/miniprogram/pages/edit-profile/edit-profile.ts new file mode 100644 index 0000000..245fcb0 --- /dev/null +++ b/miniprogram/pages/edit-profile/edit-profile.ts @@ -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() }, +}) diff --git a/miniprogram/pages/edit-profile/edit-profile.wxml b/miniprogram/pages/edit-profile/edit-profile.wxml new file mode 100644 index 0000000..f09933a --- /dev/null +++ b/miniprogram/pages/edit-profile/edit-profile.wxml @@ -0,0 +1,27 @@ + + + + 取消 + 编辑资料 + 保存 + + + + + + 昵称 + + + + 个人简介 +