修改bug
This commit is contained in:
@@ -5,9 +5,12 @@
|
||||
"functions": [
|
||||
{ "name": "login", "runtime": "Nodejs16.13", "handler": "index.main", "timeout": 10, "memorySize": 128 },
|
||||
{ "name": "feedList", "runtime": "Nodejs16.13", "handler": "index.main", "timeout": 10, "memorySize": 128 },
|
||||
{ "name": "userPosts", "runtime": "Nodejs16.13", "handler": "index.main", "timeout": 10, "memorySize": 128 },
|
||||
{ "name": "postDetail", "runtime": "Nodejs16.13", "handler": "index.main", "timeout": 10, "memorySize": 128 },
|
||||
{ "name": "postCreate", "runtime": "Nodejs16.13", "handler": "index.main", "timeout": 10, "memorySize": 128 },
|
||||
{ "name": "postLike", "runtime": "Nodejs16.13", "handler": "index.main", "timeout": 10, "memorySize": 128 },
|
||||
{ "name": "postFavorite", "runtime": "Nodejs16.13", "handler": "index.main", "timeout": 10, "memorySize": 128 },
|
||||
{ "name": "commentCreate", "runtime": "Nodejs16.13", "handler": "index.main", "timeout": 10, "memorySize": 128 },
|
||||
{ "name": "draftSave", "runtime": "Nodejs16.13", "handler": "index.main", "timeout": 10, "memorySize": 128 },
|
||||
{ "name": "draftGet", "runtime": "Nodejs16.13", "handler": "index.main", "timeout": 10, "memorySize": 128 },
|
||||
{ "name": "nearbyPets", "runtime": "Nodejs16.13", "handler": "index.main", "timeout": 10, "memorySize": 128 },
|
||||
|
||||
91
cloudfunctions/commentCreate/index.js
Normal file
91
cloudfunctions/commentCreate/index.js
Normal file
@@ -0,0 +1,91 @@
|
||||
const cloud = require('wx-server-sdk')
|
||||
|
||||
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
|
||||
const db = cloud.database()
|
||||
const _ = db.command
|
||||
const COMMENTS_COLLECTION = 'comments'
|
||||
|
||||
function isCollectionMissing(error) {
|
||||
const text = String(error?.errMsg || error?.message || error || '')
|
||||
return (
|
||||
text.includes('collection not exist') ||
|
||||
text.includes('collection not exists') ||
|
||||
text.includes('collection is not exists') ||
|
||||
text.includes('DATABASE_COLLECTION_NOT_EXIST') ||
|
||||
text.includes('-502005')
|
||||
)
|
||||
}
|
||||
|
||||
function isCollectionAlreadyExists(error) {
|
||||
const text = String(error?.errMsg || error?.message || error || '')
|
||||
return text.includes('collection already exists') || text.includes('DATABASE_COLLECTION_ALREADY_EXIST')
|
||||
}
|
||||
|
||||
async function ensureCommentsCollection() {
|
||||
if (typeof db.createCollection !== 'function') {
|
||||
throw new Error('comments collection does not exist')
|
||||
}
|
||||
|
||||
try {
|
||||
await db.createCollection(COMMENTS_COLLECTION)
|
||||
} catch (error) {
|
||||
if (!isCollectionAlreadyExists(error)) throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function addComment(data) {
|
||||
try {
|
||||
return await db.collection(COMMENTS_COLLECTION).add({ data })
|
||||
} catch (error) {
|
||||
if (!isCollectionMissing(error)) throw error
|
||||
await ensureCommentsCollection()
|
||||
return db.collection(COMMENTS_COLLECTION).add({ data })
|
||||
}
|
||||
}
|
||||
|
||||
exports.main = async event => {
|
||||
const { OPENID } = cloud.getWXContext()
|
||||
if (!OPENID) throw new Error('no openid in wx context')
|
||||
|
||||
const postId = String(event.postId || '').trim()
|
||||
const content = String(event.content || '').trim()
|
||||
if (!postId) throw new Error('postId is required')
|
||||
if (!content) throw new Error('content is required')
|
||||
|
||||
const [postRes, userRes] = await Promise.all([
|
||||
db.collection('posts').doc(postId).get(),
|
||||
db.collection('users').where({ openid: OPENID }).limit(1).get()
|
||||
])
|
||||
|
||||
if (!postRes.data) throw new Error('post not found')
|
||||
if (!userRes.data.length) throw new Error('user not found')
|
||||
|
||||
const now = Date.now()
|
||||
const user = userRes.data[0]
|
||||
const result = await addComment({
|
||||
postId,
|
||||
openid: OPENID,
|
||||
authorId: user._id,
|
||||
authorSnapshot: {
|
||||
name: user.nickname || '',
|
||||
avatarKey: user.avatarKey || 'gradient-avatar-1',
|
||||
avatarUrl: user.avatarUrl || ''
|
||||
},
|
||||
content: content.slice(0, 500),
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
await db.collection('posts').doc(postId).update({
|
||||
data: {
|
||||
'counts.comments': _.inc(1),
|
||||
updatedAt: now
|
||||
}
|
||||
})
|
||||
|
||||
const updatedPost = await db.collection('posts').doc(postId).get()
|
||||
return {
|
||||
commentId: result._id,
|
||||
comments: updatedPost.data.counts?.comments || 0
|
||||
}
|
||||
}
|
||||
8
cloudfunctions/commentCreate/package.json
Normal file
8
cloudfunctions/commentCreate/package.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "commentCreate",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"wx-server-sdk": "latest"
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,31 @@ const cloud = require('wx-server-sdk')
|
||||
|
||||
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
|
||||
const db = cloud.database()
|
||||
const DRAFTS_COLLECTION = 'drafts'
|
||||
|
||||
function isCollectionMissing(error) {
|
||||
const text = String(error?.errMsg || error?.message || error || '')
|
||||
return (
|
||||
text.includes('collection not exist') ||
|
||||
text.includes('collection not exists') ||
|
||||
text.includes('collection is not exists') ||
|
||||
text.includes('DATABASE_COLLECTION_NOT_EXIST') ||
|
||||
text.includes('-502005')
|
||||
)
|
||||
}
|
||||
|
||||
exports.main = async () => {
|
||||
const { OPENID } = cloud.getWXContext()
|
||||
if (!OPENID) throw new Error('no openid in wx context')
|
||||
const res = await db.collection('drafts').where({ openid: OPENID }).limit(1).get()
|
||||
|
||||
let res
|
||||
try {
|
||||
res = await db.collection(DRAFTS_COLLECTION).where({ openid: OPENID }).limit(1).get()
|
||||
} catch (error) {
|
||||
if (isCollectionMissing(error)) return { draft: null }
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!res.data.length) return { draft: null }
|
||||
const record = res.data[0]
|
||||
return { draft: record.draft || null, updatedAt: record.updatedAt }
|
||||
|
||||
@@ -2,23 +2,64 @@ const cloud = require('wx-server-sdk')
|
||||
|
||||
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
|
||||
const db = cloud.database()
|
||||
const DRAFTS_COLLECTION = 'drafts'
|
||||
|
||||
function isCollectionMissing(error) {
|
||||
const text = String(error?.errMsg || error?.message || error || '')
|
||||
return (
|
||||
text.includes('collection not exist') ||
|
||||
text.includes('collection not exists') ||
|
||||
text.includes('collection is not exists') ||
|
||||
text.includes('DATABASE_COLLECTION_NOT_EXIST') ||
|
||||
text.includes('-502005')
|
||||
)
|
||||
}
|
||||
|
||||
function isCollectionAlreadyExists(error) {
|
||||
const text = String(error?.errMsg || error?.message || error || '')
|
||||
return text.includes('collection already exists') || text.includes('DATABASE_COLLECTION_ALREADY_EXIST')
|
||||
}
|
||||
|
||||
async function ensureDraftsCollection() {
|
||||
if (typeof db.createCollection !== 'function') {
|
||||
throw new Error('drafts collection does not exist')
|
||||
}
|
||||
|
||||
try {
|
||||
await db.createCollection(DRAFTS_COLLECTION)
|
||||
} catch (error) {
|
||||
if (!isCollectionAlreadyExists(error)) throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function getExistingDraft(openid) {
|
||||
try {
|
||||
return await db.collection(DRAFTS_COLLECTION).where({ openid }).limit(1).get()
|
||||
} catch (error) {
|
||||
if (!isCollectionMissing(error)) throw error
|
||||
await ensureDraftsCollection()
|
||||
return { data: [] }
|
||||
}
|
||||
}
|
||||
|
||||
exports.main = async event => {
|
||||
const { OPENID } = cloud.getWXContext()
|
||||
if (!OPENID) throw new Error('no openid in wx context')
|
||||
|
||||
const now = Date.now()
|
||||
const data = {
|
||||
openid: OPENID,
|
||||
draft: event.draft || {},
|
||||
updatedAt: now
|
||||
}
|
||||
const existed = await db.collection('drafts').where({ openid: OPENID }).limit(1).get()
|
||||
const existed = await getExistingDraft(OPENID)
|
||||
const collection = db.collection(DRAFTS_COLLECTION)
|
||||
|
||||
if (existed.data.length) {
|
||||
await db.collection('drafts').doc(existed.data[0]._id).update({ data })
|
||||
await collection.doc(existed.data[0]._id).update({ data })
|
||||
return { draftId: existed.data[0]._id, updatedAt: now }
|
||||
}
|
||||
|
||||
const result = await db.collection('drafts').add({ data })
|
||||
const result = await collection.add({ data })
|
||||
return { draftId: result._id, updatedAt: now }
|
||||
}
|
||||
|
||||
|
||||
@@ -74,12 +74,25 @@ exports.main = async event => {
|
||||
favSet = new Set(favRes.data.map(f => f.postId))
|
||||
}
|
||||
|
||||
const list = res.data.map(post => ({
|
||||
// Resolve authors' current profile (real avatar image) so feed avatars load
|
||||
// and stay fresh, even for posts whose snapshot predates avatar upload.
|
||||
const authorIds = [...new Set(res.data.map(p => p.authorId).filter(Boolean))]
|
||||
const authorMap = new Map()
|
||||
for (let i = 0; i < authorIds.length; i += 100) {
|
||||
const chunk = authorIds.slice(i, i + 100)
|
||||
const usersRes = await safeGet('users', { _id: _.in(chunk) }, 100)
|
||||
usersRes.data.forEach(u => authorMap.set(u._id, u))
|
||||
}
|
||||
|
||||
const list = res.data.map(post => {
|
||||
const author = authorMap.get(post.authorId)
|
||||
return {
|
||||
_id: post._id,
|
||||
author: {
|
||||
id: post.authorId || '',
|
||||
name: post.authorSnapshot?.name || '用户',
|
||||
avatarKey: post.authorSnapshot?.avatarKey || 'gradient-avatar-1'
|
||||
name: (author && author.nickname) || post.authorSnapshot?.name || '用户',
|
||||
avatarKey: (author && author.avatarKey) || post.authorSnapshot?.avatarKey || 'gradient-avatar-1',
|
||||
avatarUrl: (author && author.avatarUrl) || post.authorSnapshot?.avatarUrl || ''
|
||||
},
|
||||
pet: post.petSnapshot || { name: '', breed: '' },
|
||||
body: post.content || '',
|
||||
@@ -98,7 +111,8 @@ exports.main = async event => {
|
||||
},
|
||||
likedByMe: likedSet.has(post._id),
|
||||
favoritedByMe: favSet.has(post._id)
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
const last = res.data[res.data.length - 1]
|
||||
return { list, nextCursor: last ? String(last.createdAt) : '' }
|
||||
|
||||
@@ -2,6 +2,18 @@ const cloud = require('wx-server-sdk')
|
||||
|
||||
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
|
||||
const db = cloud.database()
|
||||
const _ = db.command
|
||||
|
||||
async function fetchUsers(ids) {
|
||||
if (!ids.length) return []
|
||||
const out = []
|
||||
for (let i = 0; i < ids.length; i += 100) {
|
||||
const chunk = ids.slice(i, i + 100)
|
||||
const res = await db.collection('users').where({ _id: _.in(chunk) }).limit(100).get()
|
||||
out.push(...res.data)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function hashId(id) {
|
||||
let h = 5381
|
||||
@@ -46,19 +58,48 @@ function distanceText(petLoc, userLoc) {
|
||||
}
|
||||
|
||||
exports.main = async event => {
|
||||
const { OPENID } = cloud.getWXContext()
|
||||
const filter = event.filters?.type || 'all'
|
||||
const userLoc = event.location || null
|
||||
const query = {}
|
||||
if (filter === 'dog' || filter === 'cat') query.species = filter
|
||||
if (filter === 'online') query.online = true
|
||||
|
||||
let meId = ''
|
||||
let followingSet = new Set()
|
||||
let followerSet = new Set()
|
||||
|
||||
if (OPENID) {
|
||||
const meRes = await db.collection('users').where({ openid: OPENID }).limit(1).get()
|
||||
const me = meRes.data[0]
|
||||
if (me) {
|
||||
meId = me._id
|
||||
const [followingRes, followerRes] = await Promise.all([
|
||||
db.collection('follows').where({ followerId: meId }).limit(500).get(),
|
||||
db.collection('follows').where({ followeeId: meId }).limit(500).get()
|
||||
])
|
||||
followingSet = new Set(followingRes.data.map(f => f.followeeId))
|
||||
followerSet = new Set(followerRes.data.map(f => f.followerId))
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.collection('pets').where(query).limit(50).get()
|
||||
const ownerIds = [...new Set(res.data.map(pet => pet.ownerId).filter(Boolean))]
|
||||
const owners = await fetchUsers(ownerIds)
|
||||
const ownerMap = new Map(owners.map(owner => [owner._id, owner]))
|
||||
|
||||
const list = res.data.map(pet => {
|
||||
const realCoord = pet.location && typeof pet.location.latitude === 'number' ? pet.location : null
|
||||
const coord = realCoord || (userLoc ? syntheticCoord(pet._id, userLoc) : null)
|
||||
const owner = ownerMap.get(pet.ownerId)
|
||||
const isFollowing = followingSet.has(pet.ownerId)
|
||||
const isFollowedBy = followerSet.has(pet.ownerId)
|
||||
return {
|
||||
...pet,
|
||||
ownerName: owner?.nickname || '',
|
||||
isMine: Boolean(meId && pet.ownerId === meId),
|
||||
isFollowing,
|
||||
isFriend: isFollowing && isFollowedBy,
|
||||
latitude: coord ? coord.latitude : undefined,
|
||||
longitude: coord ? coord.longitude : undefined,
|
||||
distanceText: distanceText(coord, userLoc) || pet.distanceText || null,
|
||||
|
||||
112
cloudfunctions/postDetail/index.js
Normal file
112
cloudfunctions/postDetail/index.js
Normal file
@@ -0,0 +1,112 @@
|
||||
const cloud = require('wx-server-sdk')
|
||||
|
||||
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
|
||||
const db = cloud.database()
|
||||
const _ = db.command
|
||||
|
||||
// Query a collection that may not exist yet (created lazily on first write).
|
||||
async function safeGet(collection, where, limit = 200, orderBy) {
|
||||
try {
|
||||
let query = db.collection(collection).where(where)
|
||||
if (orderBy) query = query.orderBy(orderBy.field, orderBy.direction)
|
||||
return await query.limit(limit).get()
|
||||
} catch (e) {
|
||||
return { data: [] }
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimeText(ts) {
|
||||
const diff = Date.now() - ts
|
||||
const mins = Math.floor(diff / 60000)
|
||||
if (mins < 1) return '刚刚'
|
||||
if (mins < 60) return `${mins} 分钟前`
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 24) return `${hours} 小时前`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days === 1) return '昨天'
|
||||
if (days < 7) return `${days} 天前`
|
||||
const d = new Date(ts)
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日`
|
||||
}
|
||||
|
||||
exports.main = async event => {
|
||||
const { OPENID } = cloud.getWXContext()
|
||||
const postId = String(event.postId || '').trim()
|
||||
if (!postId) throw new Error('postId is required')
|
||||
|
||||
const postRes = await db.collection('posts').doc(postId).get().catch(() => null)
|
||||
if (!postRes || !postRes.data) return { post: null, comments: [] }
|
||||
const post = postRes.data
|
||||
|
||||
// Resolve the author's current profile (fresh avatar), liked/favorited state.
|
||||
let author = null
|
||||
if (post.authorId) {
|
||||
const authorRes = await safeGet('users', { _id: post.authorId }, 1)
|
||||
author = authorRes.data[0] || null
|
||||
}
|
||||
|
||||
let likedByMe = false
|
||||
let favoritedByMe = false
|
||||
if (OPENID) {
|
||||
const [likedRes, favRes] = await Promise.all([
|
||||
safeGet('postLikes', { openid: OPENID, postId }, 1),
|
||||
safeGet('postFavorites', { openid: OPENID, postId }, 1)
|
||||
])
|
||||
likedByMe = likedRes.data.length > 0
|
||||
favoritedByMe = favRes.data.length > 0
|
||||
}
|
||||
|
||||
const mappedPost = {
|
||||
_id: post._id,
|
||||
author: {
|
||||
id: post.authorId || '',
|
||||
name: (author && author.nickname) || post.authorSnapshot?.name || '用户',
|
||||
avatarKey: (author && author.avatarKey) || post.authorSnapshot?.avatarKey || 'gradient-avatar-1',
|
||||
avatarUrl: (author && author.avatarUrl) || post.authorSnapshot?.avatarUrl || ''
|
||||
},
|
||||
pet: post.petSnapshot || { name: '', breed: '' },
|
||||
body: post.content || '',
|
||||
topics: post.topics || [],
|
||||
media: Array.isArray(post.media)
|
||||
? post.media.map(m => (typeof m === 'string' ? m : m && (m.url || m.fileId))).filter(Boolean)
|
||||
: [],
|
||||
mediaTone: post.mediaTone || 'pet-1',
|
||||
sticker: post.sticker || undefined,
|
||||
locationText: post.locationText || '',
|
||||
timeText: formatTimeText(post.createdAt || Date.now()),
|
||||
counts: {
|
||||
likes: post.counts?.likes || 0,
|
||||
comments: post.counts?.comments || 0,
|
||||
favorites: post.counts?.favorites || 0
|
||||
},
|
||||
likedByMe,
|
||||
favoritedByMe
|
||||
}
|
||||
|
||||
// Comments, oldest first. Resolve each author's current profile for avatars.
|
||||
const commentsRes = await safeGet('comments', { postId }, 200, { field: 'createdAt', direction: 'asc' })
|
||||
const commentAuthorIds = [...new Set(commentsRes.data.map(c => c.authorId).filter(Boolean))]
|
||||
const authorMap = new Map()
|
||||
for (let i = 0; i < commentAuthorIds.length; i += 100) {
|
||||
const chunk = commentAuthorIds.slice(i, i + 100)
|
||||
const usersRes = await safeGet('users', { _id: _.in(chunk) }, 100)
|
||||
usersRes.data.forEach(u => authorMap.set(u._id, u))
|
||||
}
|
||||
|
||||
const comments = commentsRes.data.map(c => {
|
||||
const u = authorMap.get(c.authorId)
|
||||
return {
|
||||
_id: c._id,
|
||||
author: {
|
||||
id: c.authorId || '',
|
||||
name: (u && u.nickname) || c.authorSnapshot?.name || '用户',
|
||||
avatarKey: (u && u.avatarKey) || c.authorSnapshot?.avatarKey || 'gradient-avatar-1',
|
||||
avatarUrl: (u && u.avatarUrl) || c.authorSnapshot?.avatarUrl || ''
|
||||
},
|
||||
content: c.content || '',
|
||||
timeText: formatTimeText(c.createdAt || Date.now())
|
||||
}
|
||||
})
|
||||
|
||||
return { post: mappedPost, comments }
|
||||
}
|
||||
8
cloudfunctions/postDetail/package.json
Normal file
8
cloudfunctions/postDetail/package.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "postDetail",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"wx-server-sdk": "latest"
|
||||
}
|
||||
}
|
||||
@@ -3,22 +3,63 @@ const cloud = require('wx-server-sdk')
|
||||
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
|
||||
const db = cloud.database()
|
||||
const _ = db.command
|
||||
const POST_LIKES_COLLECTION = 'postLikes'
|
||||
|
||||
function isCollectionMissing(error) {
|
||||
const text = String(error?.errMsg || error?.message || error || '')
|
||||
return (
|
||||
text.includes('collection not exist') ||
|
||||
text.includes('collection not exists') ||
|
||||
text.includes('collection is not exists') ||
|
||||
text.includes('DATABASE_COLLECTION_NOT_EXIST') ||
|
||||
text.includes('-502005')
|
||||
)
|
||||
}
|
||||
|
||||
function isCollectionAlreadyExists(error) {
|
||||
const text = String(error?.errMsg || error?.message || error || '')
|
||||
return text.includes('collection already exists') || text.includes('DATABASE_COLLECTION_ALREADY_EXIST')
|
||||
}
|
||||
|
||||
async function ensurePostLikesCollection() {
|
||||
if (typeof db.createCollection !== 'function') {
|
||||
throw new Error('postLikes collection does not exist')
|
||||
}
|
||||
|
||||
try {
|
||||
await db.createCollection(POST_LIKES_COLLECTION)
|
||||
} catch (error) {
|
||||
if (!isCollectionAlreadyExists(error)) throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function getExistingLike(postId, openid) {
|
||||
try {
|
||||
return await db.collection(POST_LIKES_COLLECTION).where({ postId, openid }).limit(1).get()
|
||||
} catch (error) {
|
||||
if (!isCollectionMissing(error)) throw error
|
||||
await ensurePostLikesCollection()
|
||||
return { data: [] }
|
||||
}
|
||||
}
|
||||
|
||||
exports.main = async event => {
|
||||
const { OPENID } = cloud.getWXContext()
|
||||
const { postId, liked } = event
|
||||
if (!OPENID) throw new Error('no openid in wx context')
|
||||
if (!postId) throw new Error('postId is required')
|
||||
|
||||
const existed = await db.collection('postLikes').where({ postId, openid: OPENID }).limit(1).get()
|
||||
const existed = await getExistingLike(postId, OPENID)
|
||||
const collection = db.collection(POST_LIKES_COLLECTION)
|
||||
let delta = 0
|
||||
|
||||
if (liked && !existed.data.length) {
|
||||
await db.collection('postLikes').add({ data: { postId, openid: OPENID, createdAt: Date.now() } })
|
||||
await collection.add({ data: { postId, openid: OPENID, createdAt: Date.now() } })
|
||||
delta = 1
|
||||
}
|
||||
|
||||
if (!liked && existed.data.length) {
|
||||
await db.collection('postLikes').doc(existed.data[0]._id).remove()
|
||||
await collection.doc(existed.data[0]._id).remove()
|
||||
delta = -1
|
||||
}
|
||||
|
||||
@@ -29,4 +70,3 @@ exports.main = async event => {
|
||||
const post = await db.collection('posts').doc(postId).get()
|
||||
return { liked: Boolean(liked), likes: post.data.counts?.likes || 0 }
|
||||
}
|
||||
|
||||
|
||||
90
cloudfunctions/userPosts/index.js
Normal file
90
cloudfunctions/userPosts/index.js
Normal file
@@ -0,0 +1,90 @@
|
||||
const cloud = require('wx-server-sdk')
|
||||
|
||||
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
|
||||
const db = cloud.database()
|
||||
const _ = db.command
|
||||
|
||||
async function safeGet(collection, where, limit = 200) {
|
||||
try {
|
||||
return await db.collection(collection).where(where).limit(limit).get()
|
||||
} catch (e) {
|
||||
return { data: [] }
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimeText(ts) {
|
||||
const diff = Date.now() - ts
|
||||
const mins = Math.floor(diff / 60000)
|
||||
if (mins < 1) return '刚刚'
|
||||
if (mins < 60) return `${mins} 分钟前`
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 24) return `${hours} 小时前`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days === 1) return '昨天'
|
||||
if (days < 7) return `${days} 天前`
|
||||
const d = new Date(ts)
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日`
|
||||
}
|
||||
|
||||
// List a user's own posts. Defaults to the caller; pass authorId to view
|
||||
// another user (only public posts are returned for others).
|
||||
exports.main = async event => {
|
||||
const { OPENID } = cloud.getWXContext()
|
||||
if (!OPENID) throw new Error('no openid in wx context')
|
||||
|
||||
let authorOpenid = OPENID
|
||||
let viewingSelf = true
|
||||
if (event.authorId) {
|
||||
const u = await db.collection('users').doc(event.authorId).get().catch(() => null)
|
||||
if (!u || !u.data) return { list: [] }
|
||||
authorOpenid = u.data.openid
|
||||
viewingSelf = authorOpenid === OPENID
|
||||
}
|
||||
|
||||
const where = { authorOpenid }
|
||||
if (!viewingSelf) where.visibility = 'public'
|
||||
|
||||
const res = await db.collection('posts')
|
||||
.where(where)
|
||||
.orderBy('createdAt', 'desc')
|
||||
.limit(50)
|
||||
.get()
|
||||
|
||||
if (!res.data.length) return { list: [] }
|
||||
|
||||
const postIds = res.data.map(p => p._id)
|
||||
const [likedRes, favRes] = await Promise.all([
|
||||
safeGet('postLikes', { openid: OPENID, postId: _.in(postIds) }),
|
||||
safeGet('postFavorites', { openid: OPENID, postId: _.in(postIds) })
|
||||
])
|
||||
const likedSet = new Set(likedRes.data.map(l => l.postId))
|
||||
const favSet = new Set(favRes.data.map(f => f.postId))
|
||||
|
||||
const list = res.data.map(post => ({
|
||||
_id: post._id,
|
||||
author: {
|
||||
id: post.authorId || '',
|
||||
name: post.authorSnapshot?.name || '用户',
|
||||
avatarKey: post.authorSnapshot?.avatarKey || 'gradient-avatar-1'
|
||||
},
|
||||
pet: post.petSnapshot || { name: '', breed: '' },
|
||||
body: post.content || '',
|
||||
topics: post.topics || [],
|
||||
media: Array.isArray(post.media)
|
||||
? post.media.map(m => (typeof m === 'string' ? m : m && (m.url || m.fileId))).filter(Boolean)
|
||||
: [],
|
||||
mediaTone: post.mediaTone || 'pet-1',
|
||||
sticker: post.sticker || undefined,
|
||||
locationText: post.locationText || '',
|
||||
timeText: formatTimeText(post.createdAt || Date.now()),
|
||||
counts: {
|
||||
likes: post.counts?.likes || 0,
|
||||
comments: post.counts?.comments || 0,
|
||||
favorites: post.counts?.favorites || 0
|
||||
},
|
||||
likedByMe: likedSet.has(post._id),
|
||||
favoritedByMe: favSet.has(post._id)
|
||||
}))
|
||||
|
||||
return { list }
|
||||
}
|
||||
8
cloudfunctions/userPosts/package.json
Normal file
8
cloudfunctions/userPosts/package.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "userPosts",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"wx-server-sdk": "latest"
|
||||
}
|
||||
}
|
||||
2
dist/app.js
vendored
2
dist/app.js
vendored
File diff suppressed because one or more lines are too long
2
dist/app.json
vendored
2
dist/app.json
vendored
@@ -1 +1 @@
|
||||
{"pages":["pages/plaza/index","pages/nearby/index","pages/publish/index","pages/messages/index","pages/profile/index","pages/profile-edit/index","pages/pet-edit/index","pages/chat/index","pages/contacts/index"],"window":{"navigationStyle":"custom","backgroundColor":"#14102b","backgroundTextStyle":"dark"},"tabBar":{"custom":true,"color":"#8a7aab","selectedColor":"#d8b4ff","backgroundColor":"#14102b","borderStyle":"black","list":[{"pagePath":"pages/plaza/index","text":"广场"},{"pagePath":"pages/nearby/index","text":"附近"},{"pagePath":"pages/messages/index","text":"消息"},{"pagePath":"pages/profile/index","text":"我的"}]},"permission":{"scope.userLocation":{"desc":"用于发现附近的毛孩子,以及在发布动态时标记位置"}},"requiredPrivateInfos":["chooseLocation","getLocation"],"lazyCodeLoading":"requiredComponents"}
|
||||
{"pages":["pages/plaza/index","pages/nearby/index","pages/publish/index","pages/messages/index","pages/profile/index","pages/profile-edit/index","pages/pet-edit/index","pages/chat/index","pages/contacts/index","pages/my-posts/index","pages/post-detail/index"],"window":{"navigationStyle":"custom","backgroundColor":"#14102b","backgroundTextStyle":"dark"},"tabBar":{"custom":true,"color":"#8a7aab","selectedColor":"#d8b4ff","backgroundColor":"#14102b","borderStyle":"black","list":[{"pagePath":"pages/plaza/index","text":"广场"},{"pagePath":"pages/nearby/index","text":"附近"},{"pagePath":"pages/messages/index","text":"汪友"},{"pagePath":"pages/profile/index","text":"我的"}]},"permission":{"scope.userLocation":{"desc":"用于发现附近的毛孩子,以及在发布动态时标记位置"}},"requiredPrivateInfos":["chooseLocation","getLocation"],"lazyCodeLoading":"requiredComponents"}
|
||||
2
dist/common.js
vendored
2
dist/common.js
vendored
File diff suppressed because one or more lines are too long
2
dist/common.wxss
vendored
2
dist/common.wxss
vendored
File diff suppressed because one or more lines are too long
2
dist/pages/contacts/index.js
vendored
2
dist/pages/contacts/index.js
vendored
File diff suppressed because one or more lines are too long
2
dist/pages/messages/index.js
vendored
2
dist/pages/messages/index.js
vendored
@@ -1 +1 @@
|
||||
"use strict";(wx["webpackJsonp"]=wx["webpackJsonp"]||[]).push([[781],{6430:function(e,s,a){var n=a(8870),t=a(1212),r=a(9379),i=a(467),c=a(5544),o=a(6540),l=a(758),u=a.n(l),m=a(118),v=a(7797),d=a(8324),_=a(6503),x=a(7377),h=a(326),p=a(7745),j=a(3401),f=a(4848);function g(e){var s=e.users,a=e.onSelect,n=e.onDiscover;return(0,f.jsx)(m.BM,{scrollX:!0,className:"active-user-row",showScrollbar:!1,children:(0,f.jsxs)(m.Ss,{className:"active-user-row__inner",children:[(0,f.jsxs)(m.Ss,{className:"active-user-row__item",onClick:n,children:[(0,f.jsx)(m.Ss,{className:"active-user-row__avatar-wrap active-user-row__add",children:(0,f.jsx)(j.A,{name:"plus",size:22})}),(0,f.jsx)(m.EY,{className:"active-user-row__name",children:"\u53d1\u73b0"})]}),s.map(function(e){return(0,f.jsxs)(m.Ss,{className:"active-user-row__item",onClick:function(){return null===a||void 0===a?void 0:a(e)},children:[(0,f.jsxs)(m.Ss,{className:"active-user-row__avatar-wrap",children:[(0,f.jsx)(p.A,{avatarKey:e.avatarKey,avatarUrl:e.avatarUrl,size:"lg",label:e.name}),e.live?(0,f.jsx)(m.EY,{className:"active-user-row__live",children:"LIVE"}):null]}),(0,f.jsx)(m.EY,{className:"active-user-row__name",children:e.name})]},e.id)})]})})}var w=g;function N(e){var s=e.conversation,a=e.onClick;return(0,f.jsxs)(m.Ss,{className:"conversation-item",onClick:a,children:[(0,f.jsx)(p.A,{avatarKey:s.avatarKey,avatarUrl:s.avatarUrl,size:"lg",online:s.online,petTag:"single"===s.type?"\ud83d\udc3e":void 0}),(0,f.jsxs)(m.Ss,{className:"conversation-item__main",children:[(0,f.jsxs)(m.Ss,{className:"conversation-item__top",children:[(0,f.jsxs)(m.Ss,{className:"conversation-item__name-row",children:[(0,f.jsx)(m.EY,{className:"conversation-item__name",children:s.title}),s.petName?(0,f.jsx)(m.EY,{className:"conversation-item__pet",children:s.petName}):null]}),(0,f.jsx)(m.EY,{className:"conversation-item__time",children:s.timeText})]}),(0,f.jsxs)(m.Ss,{className:"conversation-item__bottom",children:[(0,f.jsx)(m.EY,{className:"conversation-item__preview",children:s.preview}),s.pinned?(0,f.jsx)(m.Ss,{className:"conversation-item__pin",children:(0,f.jsx)(j.A,{name:"bookmark",size:12})}):s.unreadCount>0?(0,f.jsx)(m.EY,{className:"conversation-item__badge",children:s.unreadCount}):s.muted?(0,f.jsx)(m.Ss,{className:"conversation-item__muted"}):null]})]})]})}var S=N,A=a(9672),b=[{key:"all",label:"\u5168\u90e8"},{key:"unread",label:"\u672a\u8bfb"}];function C(){var e=(0,o.useState)("all"),s=(0,c.A)(e,2),a=s[0],n=s[1],p=(0,o.useState)([]),j=(0,c.A)(p,2),g=j[0],N=j[1],C=(0,o.useState)([]),E=(0,c.A)(C,2),k=E[0],y=E[1],Y=(0,o.useState)(""),U=(0,c.A)(Y,2),T=U[0],I=U[1],L=(0,o.useRef)(!0),z=function(){(0,A.VL)(a).then(function(e){N(e.activeUsers),y(e.conversations)})};(0,o.useEffect)(function(){z()},[a]),(0,l.useDidShow)(function(){L.current?L.current=!1:z()});var K=function(){var e=(0,i.A)((0,t.A)().m(function e(s){return(0,t.A)().w(function(e){while(1)switch(e.n){case 0:y(function(e){return e.map(function(e){return e._id===s._id?(0,r.A)((0,r.A)({},e),{},{unreadCount:0}):e})}),(0,A.vs)(s._id),u().navigateTo({url:"/pages/chat/index?conversationId=".concat(s._id,"&title=").concat(encodeURIComponent(s.title))});case 1:return e.a(2)}},e)}));return function(s){return e.apply(this,arguments)}}(),D=function(e){u().navigateTo({url:"/pages/chat/index?targetUserId=".concat(e.id,"&title=").concat(encodeURIComponent(e.name))})},R=function(){return u().navigateTo({url:"/pages/contacts/index"})},B=T.trim().toLowerCase(),J=B?k.filter(function(e){return e.title.toLowerCase().includes(B)||(e.preview||"").toLowerCase().includes(B)}):k;return(0,f.jsxs)(d.A,{className:"messages-page",children:[(0,f.jsx)(v.A,{title:"\u6d88\u606f"}),(0,f.jsx)(x.A,{placeholder:"\u641c\u7d22\u804a\u5929\u6216\u901a\u77e5",value:T,onInput:I}),(0,f.jsx)(h.A,{items:b,value:a,onChange:n}),B?null:(0,f.jsx)(w,{users:g,onSelect:D,onDiscover:R}),(0,f.jsx)(m.Ss,{className:"messages-page__divider",children:(0,f.jsx)(m.EY,{children:B?"\u641c\u7d22\u7ed3\u679c":"\u6700\u8fd1\u804a\u5929"})}),(0,f.jsxs)(m.Ss,{className:"messages-page__list",children:[J.map(function(e){return(0,f.jsx)(S,{conversation:e,onClick:function(){return K(e)}},e._id)}),0===J.length?(0,f.jsx)(m.EY,{className:"messages-page__empty",children:B?"\u6ca1\u6709\u5339\u914d\u7684\u804a\u5929":"unread"===a?"\u6ca1\u6709\u672a\u8bfb\u6d88\u606f":"\u8fd8\u6ca1\u6709\u804a\u5929\uff0c\u53bb\u300c\u53d1\u73b0\u300d\u627e\u6c6a\u53cb\u804a\u804a\u5427"}):null]}),(0,f.jsx)(_.A,{active:"messages"})]})}var E={navigationBarTitleText:"\u6d88\u606f",navigationStyle:"custom",disableScroll:!0},k=(0,n.eU)(C,"pages/messages/index",{root:{cn:[]}},E||{});C&&C.behaviors&&(k.behaviors=(k.behaviors||[]).concat(C.behaviors));Page(k)}},function(e){var s=function(s){return e(e.s=s)};e.O(0,[907,96,76],function(){return s(6430)});e.O()}]);
|
||||
"use strict";(wx["webpackJsonp"]=wx["webpackJsonp"]||[]).push([[781],{6430:function(e,s,a){var n=a(8870),t=a(1212),r=a(9379),i=a(467),c=a(5544),o=a(6540),l=a(758),u=a.n(l),m=a(118),v=a(7797),d=a(8324),_=a(6503),x=a(7377),h=a(326),p=a(7745),j=a(3401),f=a(4848);function g(e){var s=e.users,a=e.onSelect,n=e.onDiscover;return(0,f.jsx)(m.BM,{scrollX:!0,className:"active-user-row",showScrollbar:!1,children:(0,f.jsxs)(m.Ss,{className:"active-user-row__inner",children:[(0,f.jsxs)(m.Ss,{className:"active-user-row__item",onClick:n,children:[(0,f.jsx)(m.Ss,{className:"active-user-row__avatar-wrap active-user-row__add",children:(0,f.jsx)(j.A,{name:"plus",size:22})}),(0,f.jsx)(m.EY,{className:"active-user-row__name",children:"\u627e\u6c6a\u53cb"})]}),s.map(function(e){return(0,f.jsxs)(m.Ss,{className:"active-user-row__item",onClick:function(){return null===a||void 0===a?void 0:a(e)},children:[(0,f.jsxs)(m.Ss,{className:"active-user-row__avatar-wrap",children:[(0,f.jsx)(p.A,{avatarKey:e.avatarKey,avatarUrl:e.avatarUrl,size:"lg",label:e.name}),e.live?(0,f.jsx)(m.EY,{className:"active-user-row__live",children:"LIVE"}):null]}),(0,f.jsx)(m.EY,{className:"active-user-row__name",children:e.name})]},e.id)})]})})}var w=g;function N(e){var s=e.conversation,a=e.onClick;return(0,f.jsxs)(m.Ss,{className:"conversation-item",onClick:a,children:[(0,f.jsx)(p.A,{avatarKey:s.avatarKey,avatarUrl:s.avatarUrl,size:"lg",online:s.online,petTag:"single"===s.type?"\ud83d\udc3e":void 0}),(0,f.jsxs)(m.Ss,{className:"conversation-item__main",children:[(0,f.jsxs)(m.Ss,{className:"conversation-item__top",children:[(0,f.jsxs)(m.Ss,{className:"conversation-item__name-row",children:[(0,f.jsx)(m.EY,{className:"conversation-item__name",children:s.title}),s.petName?(0,f.jsx)(m.EY,{className:"conversation-item__pet",children:s.petName}):null]}),(0,f.jsx)(m.EY,{className:"conversation-item__time",children:s.timeText})]}),(0,f.jsxs)(m.Ss,{className:"conversation-item__bottom",children:[(0,f.jsx)(m.EY,{className:"conversation-item__preview",children:s.preview}),s.pinned?(0,f.jsx)(m.Ss,{className:"conversation-item__pin",children:(0,f.jsx)(j.A,{name:"bookmark",size:12})}):s.unreadCount>0?(0,f.jsx)(m.EY,{className:"conversation-item__badge",children:s.unreadCount}):s.muted?(0,f.jsx)(m.Ss,{className:"conversation-item__muted"}):null]})]})]})}var S=N,A=a(9672),b=[{key:"all",label:"\u5168\u90e8"},{key:"unread",label:"\u672a\u8bfb"}];function C(){var e=(0,o.useState)("all"),s=(0,c.A)(e,2),a=s[0],n=s[1],p=(0,o.useState)([]),j=(0,c.A)(p,2),g=j[0],N=j[1],C=(0,o.useState)([]),E=(0,c.A)(C,2),k=E[0],y=E[1],Y=(0,o.useState)(""),U=(0,c.A)(Y,2),T=U[0],I=U[1],L=(0,o.useRef)(!0),z=function(){(0,A.VL)(a).then(function(e){N(e.activeUsers),y(e.conversations)})};(0,o.useEffect)(function(){z()},[a]),(0,l.useDidShow)(function(){L.current?L.current=!1:z()});var K=function(){var e=(0,i.A)((0,t.A)().m(function e(s){return(0,t.A)().w(function(e){while(1)switch(e.n){case 0:y(function(e){return e.map(function(e){return e._id===s._id?(0,r.A)((0,r.A)({},e),{},{unreadCount:0}):e})}),(0,A.vs)(s._id),u().navigateTo({url:"/pages/chat/index?conversationId=".concat(s._id,"&title=").concat(encodeURIComponent(s.title))});case 1:return e.a(2)}},e)}));return function(s){return e.apply(this,arguments)}}(),D=function(e){u().navigateTo({url:"/pages/chat/index?targetUserId=".concat(e.id,"&title=").concat(encodeURIComponent(e.name))})},R=function(){return u().navigateTo({url:"/pages/contacts/index?tab=discover"})},B=T.trim().toLowerCase(),J=B?k.filter(function(e){return e.title.toLowerCase().includes(B)||(e.preview||"").toLowerCase().includes(B)}):k;return(0,f.jsxs)(d.A,{className:"messages-page",children:[(0,f.jsx)(v.A,{title:"\u6c6a\u53cb"}),(0,f.jsx)(x.A,{placeholder:"\u641c\u7d22\u6c6a\u53cb\u6216\u804a\u5929",value:T,onInput:I}),(0,f.jsx)(h.A,{items:b,value:a,onChange:n}),B?null:(0,f.jsx)(w,{users:g,onSelect:D,onDiscover:R}),(0,f.jsx)(m.Ss,{className:"messages-page__divider",children:(0,f.jsx)(m.EY,{children:B?"\u641c\u7d22\u7ed3\u679c":"\u6700\u8fd1\u804a\u5929"})}),(0,f.jsxs)(m.Ss,{className:"messages-page__list",children:[J.map(function(e){return(0,f.jsx)(S,{conversation:e,onClick:function(){return K(e)}},e._id)}),0===J.length?(0,f.jsx)(m.EY,{className:"messages-page__empty",children:B?"\u6ca1\u6709\u5339\u914d\u7684\u804a\u5929":"unread"===a?"\u6ca1\u6709\u672a\u8bfb\u6d88\u606f":"\u8fd8\u6ca1\u6709\u804a\u5929\uff0c\u53bb\u300c\u9644\u8fd1\u300d\u52a0\u6c6a\u53cb\u5427"}):null]}),(0,f.jsx)(_.A,{active:"messages"})]})}var E={navigationBarTitleText:"\u6c6a\u53cb",navigationStyle:"custom",disableScroll:!0},k=(0,n.eU)(C,"pages/messages/index",{root:{cn:[]}},E||{});C&&C.behaviors&&(k.behaviors=(k.behaviors||[]).concat(C.behaviors));Page(k)}},function(e){var s=function(s){return e(e.s=s)};e.O(0,[907,96,76],function(){return s(6430)});e.O()}]);
|
||||
2
dist/pages/messages/index.json
vendored
2
dist/pages/messages/index.json
vendored
@@ -1 +1 @@
|
||||
{"navigationBarTitleText":"消息","navigationStyle":"custom","disableScroll":true,"usingComponents":{"comp":"../../comp"}}
|
||||
{"navigationBarTitleText":"汪友","navigationStyle":"custom","disableScroll":true,"usingComponents":{"comp":"../../comp"}}
|
||||
1
dist/pages/my-posts/index.js
vendored
Normal file
1
dist/pages/my-posts/index.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
"use strict";(wx["webpackJsonp"]=wx["webpackJsonp"]||[]).push([[493],{8868:function(s,n,e){var t=e(8870),a=e(1212),c=e(9379),i=e(467),r=e(5544),o=e(6540),l=e(758),u=e.n(l),p=e(118),m=e(374),h=e(3401),d=e(3972),f=e(7896),_=e(4149),v=e(994),y=e(4848);function x(){var s=(0,m.v)(),n=s.statusBarHeight,e=(0,o.useState)([]),t=(0,r.A)(e,2),x=t[0],j=t[1],k=(0,o.useState)(!0),b=(0,r.A)(k,2),A=b[0],w=b[1],N=(0,v.d)(480),S=function(){w(!0),(0,_.hv)().then(j).finally(function(){return w(!1)})};(0,o.useEffect)(function(){S()},[]),(0,l.useDidShow)(S);var g=function(){var s=(0,i.A)((0,a.A)().m(function s(n){var e,t;return(0,a.A)().w(function(s){while(1)switch(s.n){case 0:if(e=x.map(function(s){if(s._id!==n)return s;var e=!s.likedByMe;return e&&N.show(),(0,c.A)((0,c.A)({},s),{},{likedByMe:e,counts:(0,c.A)((0,c.A)({},s.counts),{},{likes:Math.max(0,s.counts.likes+(e?1:-1))})})}),j(e),t=e.find(function(s){return s._id===n}),!t){s.n=1;break}return s.n=1,(0,_.O$)(n,t.likedByMe);case 1:return s.a(2)}},s)}));return function(n){return s.apply(this,arguments)}}();return(0,y.jsxs)(p.Ss,{className:"my-posts",children:[(0,y.jsx)(p.Ss,{className:"my-posts__nav",style:{paddingTop:"".concat(n,"px")},children:(0,y.jsxs)(p.Ss,{className:"my-posts__nav-row",children:[(0,y.jsx)(p.Ss,{className:"my-posts__back",onClick:function(){return u().navigateBack()},children:(0,y.jsx)(h.A,{name:"chev",size:22,className:"my-posts__back-icon"})}),(0,y.jsx)(p.EY,{className:"my-posts__title",children:"\u6211\u7684\u52a8\u6001"}),(0,y.jsx)(p.Ss,{className:"my-posts__nav-spacer"})]})}),(0,y.jsx)(p.BM,{scrollY:!0,className:"my-posts__scroll",enhanced:!0,showScrollbar:!1,children:(0,y.jsxs)(p.Ss,{className:"my-posts__feed",children:[x.map(function(s){return(0,y.jsx)(d.A,{post:s,onLike:g,onOpen:function(s){return u().navigateTo({url:"/pages/post-detail/index?postId=".concat(s)})}},s._id)}),A&&0===x.length?(0,y.jsx)(p.EY,{className:"my-posts__empty",children:"\u52a0\u8f7d\u4e2d\u2026"}):A||0!==x.length?null:(0,y.jsx)(p.EY,{className:"my-posts__empty",children:"\u8fd8\u6ca1\u6709\u53d1\u5e03\u8fc7\u52a8\u6001\uff0c\u53bb\u5e7f\u573a\u70b9 + \u53d1\u5e03\u7b2c\u4e00\u6761\u5427"})]})}),(0,y.jsx)(f.A,{visible:N.visible})]})}var j={},k=(0,t.eU)(x,"pages/my-posts/index",{root:{cn:[]}},j||{});x&&x.behaviors&&(k.behaviors=(k.behaviors||[]).concat(x.behaviors));Page(k)}},function(s){var n=function(n){return s(s.s=n)};s.O(0,[907,96,76],function(){return n(8868)});s.O()}]);
|
||||
1
dist/pages/my-posts/index.json
vendored
Normal file
1
dist/pages/my-posts/index.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"usingComponents":{"comp":"../../comp"}}
|
||||
2
dist/pages/my-posts/index.wxml
vendored
Normal file
2
dist/pages/my-posts/index.wxml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
<import src="../../base.wxml"/>
|
||||
<template is="taro_tmpl" data="{{root:root}}" />
|
||||
1
dist/pages/my-posts/index.wxss
vendored
Normal file
1
dist/pages/my-posts/index.wxss
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.my-posts{background:#14102b;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;height:100vh}.my-posts__nav{-ms-flex-negative:0;background:#14102b;flex-shrink:0}.my-posts__nav-row{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;height:100rpx;padding:0 24rpx}.my-posts__back{display:-ms-flexbox;display:flex;height:72rpx;width:72rpx;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;color:#f0e9ff;justify-content:center}.my-posts__back-icon{-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.my-posts__title{color:#f0e9ff;-ms-flex:1;flex:1;font:700 32rpx/1 Space Grotesk,Inter,system-ui,sans-serif;text-align:center}.my-posts__nav-spacer{width:72rpx}.my-posts__scroll{-ms-flex:1;flex:1;min-height:0}.my-posts__feed{padding:16rpx 32rpx 64rpx}.my-posts__empty{color:#8a7aab;display:block;font:400 26rpx/1.6 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;padding:96rpx 48rpx;text-align:center}
|
||||
2
dist/pages/nearby/index.js
vendored
2
dist/pages/nearby/index.js
vendored
File diff suppressed because one or more lines are too long
2
dist/pages/nearby/index.wxss
vendored
2
dist/pages/nearby/index.wxss
vendored
File diff suppressed because one or more lines are too long
2
dist/pages/plaza/index.js
vendored
2
dist/pages/plaza/index.js
vendored
File diff suppressed because one or more lines are too long
2
dist/pages/plaza/index.wxss
vendored
2
dist/pages/plaza/index.wxss
vendored
File diff suppressed because one or more lines are too long
1
dist/pages/post-detail/index.js
vendored
Normal file
1
dist/pages/post-detail/index.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
"use strict";(wx["webpackJsonp"]=wx["webpackJsonp"]||[]).push([[741],{8680:function(e,t,s){var a=s(8870),n=s(436),c=s(1212),i=s(9379),o=s(467),l=s(5544),r=s(6540),u=s(758),d=s.n(u),m=s(118),p=s(374),h=s(3401),_=s(7745),x=s(3972),v=s(7896),f=s(4149),A=s(994),j=s(4848);function N(){var e=(0,p.v)(),t=e.statusBarHeight,s=e.safeBottom,a=(0,u.useRouter)(),N=a.params.postId||"",k=(0,r.useState)(null),b=(0,l.A)(k,2),S=b[0],w=b[1],y=(0,r.useState)([]),g=(0,l.A)(y,2),B=g[0],E=g[1],Y=(0,r.useState)(!0),M=(0,l.A)(Y,2),T=M[0],C=M[1],I=(0,r.useState)(""),K=(0,l.A)(I,2),O=K[0],U=K[1],z=(0,r.useState)(!1),J=(0,l.A)(z,2),P=J[0],H=J[1],L=(0,A.d)(480),R=function(){N?(C(!0),(0,f.aC)(N).then(function(e){w(e.post),E(e.comments)}).finally(function(){return C(!1)})):C(!1)};(0,r.useEffect)(function(){R()},[N]);var $=function(){var e=(0,o.A)((0,c.A)().m(function e(t){var s,a,n;return(0,c.A)().w(function(e){while(1)switch(e.p=e.n){case 0:if(S){e.n=1;break}return e.a(2);case 1:return s=!S.likedByMe,s&&L.show(),a=(0,i.A)((0,i.A)({},S),{},{likedByMe:s,counts:(0,i.A)((0,i.A)({},S.counts),{},{likes:Math.max(0,S.counts.likes+(s?1:-1))})}),w(a),e.p=2,e.n=3,(0,f.O$)(t,s);case 3:n=e.v,w(function(e){return e?(0,i.A)((0,i.A)({},e),{},{likedByMe:n.liked,counts:(0,i.A)((0,i.A)({},e.counts),{},{likes:n.likes})}):e}),e.n=5;break;case 4:e.p=4,e.v,w(S),d().showToast({title:"\u70b9\u8d5e\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5",icon:"none"});case 5:return e.a(2)}},e,null,[[2,4]])}));return function(t){return e.apply(this,arguments)}}(),q=function(){var e=(0,o.A)((0,c.A)().m(function e(){var t,s;return(0,c.A)().w(function(e){while(1)switch(e.p=e.n){case 0:if(t=O.trim(),t&&!P&&S){e.n=1;break}return e.a(2);case 1:return H(!0),U(""),e.p=2,e.n=3,(0,f.SI)(S._id,t);case 3:s=e.v,E(function(e){return[].concat((0,n.A)(e),[{_id:s.commentId,author:{id:"me",name:"\u6211",avatarKey:"gradient-avatar-1"},content:t,timeText:"\u521a\u521a"}])}),w(function(e){return e?(0,i.A)((0,i.A)({},e),{},{counts:(0,i.A)((0,i.A)({},e.counts),{},{comments:s.comments})}):e}),e.n=5;break;case 4:e.p=4,e.v,d().showToast({title:"\u8bc4\u8bba\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5",icon:"none"}),U(t);case 5:return e.p=5,H(!1),e.f(5);case 6:return e.a(2)}},e,null,[[2,4,5,6]])}));return function(){return e.apply(this,arguments)}}();return(0,j.jsxs)(m.Ss,{className:"post-detail",children:[(0,j.jsx)(m.Ss,{className:"post-detail__nav",style:{paddingTop:"".concat(t,"px")},children:(0,j.jsxs)(m.Ss,{className:"post-detail__nav-row",children:[(0,j.jsx)(m.Ss,{className:"post-detail__back",onClick:function(){return d().navigateBack()},children:(0,j.jsx)(h.A,{name:"chev",size:22,className:"post-detail__back-icon"})}),(0,j.jsx)(m.EY,{className:"post-detail__title",children:"\u52a8\u6001\u8be6\u60c5"}),(0,j.jsx)(m.Ss,{className:"post-detail__nav-spacer"})]})}),(0,j.jsxs)(m.BM,{scrollY:!0,className:"post-detail__scroll",enhanced:!0,showScrollbar:!1,children:[S?(0,j.jsx)(m.Ss,{className:"post-detail__post",children:(0,j.jsx)(x.A,{post:S,onLike:$,enableImagePreview:!0})}):null,(0,j.jsxs)(m.Ss,{className:"post-detail__comments",children:[(0,j.jsxs)(m.EY,{className:"post-detail__comments-title",children:["\u8bc4\u8bba",S?" ".concat(S.counts.comments):""]}),T&&!S?(0,j.jsx)(m.EY,{className:"post-detail__empty",children:"\u52a0\u8f7d\u4e2d\u2026"}):0===B.length?(0,j.jsx)(m.EY,{className:"post-detail__empty",children:"\u8fd8\u6ca1\u6709\u8bc4\u8bba\uff0c\u6765\u62a2\u6c99\u53d1\u5427"}):B.map(function(e){return(0,j.jsxs)(m.Ss,{className:"post-detail__comment",children:[(0,j.jsx)(_.A,{avatarKey:e.author.avatarKey,avatarUrl:e.author.avatarUrl,size:"sm"}),(0,j.jsxs)(m.Ss,{className:"post-detail__comment-body",children:[(0,j.jsxs)(m.Ss,{className:"post-detail__comment-head",children:[(0,j.jsx)(m.EY,{className:"post-detail__comment-name",children:e.author.name}),(0,j.jsx)(m.EY,{className:"post-detail__comment-time",children:e.timeText})]}),(0,j.jsx)(m.EY,{className:"post-detail__comment-text",children:e.content})]})]},e._id)})]})]}),(0,j.jsxs)(m.Ss,{className:"post-detail__input-bar",style:{paddingBottom:"".concat(Math.max(12,s),"px")},children:[(0,j.jsx)(m.pd,{className:"post-detail__input",value:O,placeholder:"\u5199\u8bc4\u8bba\u2026",placeholderClass:"post-detail__input-ph",confirmType:"send",onInput:function(e){return U(e.detail.value)},onConfirm:q}),(0,j.jsx)(m.Ss,{className:"post-detail__send ".concat(O.trim()?"":"post-detail__send--disabled"),onClick:q,children:"\u53d1\u9001"})]}),(0,j.jsx)(v.A,{visible:L.visible})]})}var k={},b=(0,a.eU)(N,"pages/post-detail/index",{root:{cn:[]}},k||{});N&&N.behaviors&&(b.behaviors=(b.behaviors||[]).concat(N.behaviors));Page(b)}},function(e){var t=function(t){return e(e.s=t)};e.O(0,[907,96,76],function(){return t(8680)});e.O()}]);
|
||||
1
dist/pages/post-detail/index.json
vendored
Normal file
1
dist/pages/post-detail/index.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"usingComponents":{"comp":"../../comp"}}
|
||||
2
dist/pages/post-detail/index.wxml
vendored
Normal file
2
dist/pages/post-detail/index.wxml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
<import src="../../base.wxml"/>
|
||||
<template is="taro_tmpl" data="{{root:root}}" />
|
||||
1
dist/pages/post-detail/index.wxss
vendored
Normal file
1
dist/pages/post-detail/index.wxss
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.post-detail{background:#14102b;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;height:100vh}.post-detail__nav{-ms-flex-negative:0;background:#14102b;border-bottom:2rpx solid rgba(216,180,255,.06);flex-shrink:0}.post-detail__nav-row{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;height:100rpx;padding:0 24rpx}.post-detail__back{display:-ms-flexbox;display:flex;height:72rpx;width:72rpx;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;color:#f0e9ff;justify-content:center}.post-detail__back-icon{-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.post-detail__title{color:#f0e9ff;-ms-flex:1;flex:1;font:700 32rpx/1 Space Grotesk,Inter,system-ui,sans-serif;text-align:center}.post-detail__nav-spacer{width:72rpx}.post-detail__scroll{-ms-flex:1;flex:1;min-height:0}.post-detail__post{padding-top:24rpx}.post-detail__comments{padding:8rpx 32rpx 48rpx}.post-detail__comments-title{color:#f0e9ff;display:block;font:700 28rpx/1 Space Grotesk,Inter,system-ui,sans-serif;margin:16rpx 0 24rpx}.post-detail__empty{color:#8a7aab;display:block;font:400 26rpx/1.6 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;padding:72rpx 48rpx;text-align:center}.post-detail__comment{border-top:2rpx solid rgba(216,180,255,.06);display:-ms-flexbox;display:flex;gap:20rpx;padding:24rpx 0}.post-detail__comment-body{-ms-flex:1;flex:1;min-width:0}.post-detail__comment-head{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;gap:16rpx}.post-detail__comment-name{color:#f0e9ff;font:600 26rpx/1.2 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif}.post-detail__comment-time{color:#8a7aab;font:400 22rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif}.post-detail__comment-text{color:#f0e9ff;display:block;font:400 28rpx/1.55 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;margin-top:12rpx;word-break:break-word}.post-detail__input-bar{-ms-flex-negative:0;display:-ms-flexbox;display:flex;flex-shrink:0;-ms-flex-align:center;align-items:center;background:#14102b;border-top:2rpx solid rgba(216,180,255,.08);gap:20rpx;padding:20rpx 32rpx 24rpx}.post-detail__input{background:rgba(31,23,66,.68);border:2rpx solid rgba(216,180,255,.1);border-radius:19998rpx;-webkit-box-sizing:border-box;box-sizing:border-box;color:#f0e9ff;-ms-flex:1;flex:1;font:400 28rpx/80rpx Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;height:80rpx;padding:0 28rpx}.post-detail__input-ph{color:#8a7aab}.post-detail__send{-ms-flex-negative:0;background:linear-gradient(135deg,#d8b4ff,#ffb3c8);border-radius:19998rpx;color:#2a1d10;flex-shrink:0;font:700 28rpx/80rpx Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;height:80rpx;padding:0 36rpx}.post-detail__send--disabled{opacity:.5}
|
||||
2
dist/pages/profile/index.js
vendored
2
dist/pages/profile/index.js
vendored
File diff suppressed because one or more lines are too long
2
dist/pages/publish/index.js
vendored
2
dist/pages/publish/index.js
vendored
File diff suppressed because one or more lines are too long
171
docs/功能缺陷审查报告.md
Normal file
171
docs/功能缺陷审查报告.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# 汪圈小程序 — 功能逻辑缺陷审查报告
|
||||
|
||||
> 审查日期:2026-06-18
|
||||
> 审查范围:全部 10 个页面、10 个 Service、23 个云函数、3 个类型文件、关键组件
|
||||
> 审查分支:`master`
|
||||
|
||||
---
|
||||
|
||||
## 一、广场页 `pages/plaza/index.tsx`
|
||||
|
||||
| # | 缺陷 | 严重程度 | 位置 | 说明 |
|
||||
|---|------|----------|------|------|
|
||||
| 1 | **Feed 无分页 / 无限滚动** | 🔴 高 | `loadFeed()` (L34-38) | `getFeedList` 云函数返回 `CursorResponse<T>` 含 `nextCursor` 分页字段,但页面完全没有使用分页机制。只加载第一页数据,用户无法浏览更多帖子。Feed 是社交应用的核心功能,此为致命缺陷。 |
|
||||
| 2 | **话题列表硬编码** | 🟡 中 | `topics` (L24) | `['全部', '滨江公园', '金毛日常', ...]` 写死在组件中,不会从云端动态获取热门话题,运营新增的话题无法展示。 |
|
||||
| 3 | **广告卡片位置固定** | 🟡 中 | `PostCard` 渲染逻辑 (L130-133) | `FeedAdCard` 写死在 `index === 0` 的位置。每次 `loadFeed` 刷新后数据重置,广告位置永远在第一条帖子后,缺乏真实广告系统的随机插入和频次控制。 |
|
||||
| 4 | **收藏功能存在但入口缺失** | 🟡 中 | `PostCard` + `PlazaPage` | `feed.service.ts` 导出了 `setPostLike` / `setPostFavorite`,`Post` 类型有 `favoritedByMe` 字段,但 `PostCard` 和 `PlazaPage` 均没有收藏操作的 UI 入口,收藏功能全链路断裂。 |
|
||||
| 5 | **没有帖子详情页** | 🟡 中 | `commentPost()` (L86-113) | 评论通过 `Taro.showModal` 弹窗实现,无法查看评论列表、历史记录,也无法点击图片查看大图预览。社交类帖子的完整交互(评论链、转发、分享)均缺失。 |
|
||||
| 6 | **点赞乐观更新存在竞态** | 🟡 中 | `likePost()` (L54-84) | 连续两次 `setPosts`(乐观更新 → 服务端返回覆盖),期间若 `useDidShow` 触发 `loadFeed`,可能覆盖掉刚点赞的中间状态。应考虑用 `postId` 粒度的状态管理或防抖。 |
|
||||
| 7 | **搜索栏无功能** | 🟡 中 | `<SearchBar>` (L118) | `SearchBar` 组件已渲染但没有绑定 `onInput` / `onSubmit` 回调,搜索框为纯装饰。 |
|
||||
|
||||
---
|
||||
|
||||
## 二、附近页 `pages/nearby/index.tsx`
|
||||
|
||||
| # | 缺陷 | 严重程度 | 位置 | 说明 |
|
||||
|---|------|----------|------|------|
|
||||
| 1 | **没有调用 `updateLocation` 上报自身位置** | 🔴 高 | 整个页面 | `nearby.service.ts` 导出了 `updateLocation(location, visible)`,用户位置变化**从未上报云端**。其他用户看不到"我"在附近地图上出现,双向发现功能完全失效。应在获取到位置后调用上报,并在 relocate 时重复上报。 |
|
||||
| 2 | **没有尊重 `preferences.nearbyVisible` 偏好** | 🔴 高 | 整个页面 | 用户在个人资料中可关闭"附近可见"(`preferences.nearbyVisible`),但附近页完全不考虑此设置。即使关闭,仍请求并展示附近宠物,自身位置也没有根据偏好控制是否上报。应在 `useEffect` 中读取偏好,决定是否请求附近数据。 |
|
||||
| 3 | **`likePet` 没有乐观更新和错误回滚** | 🟡 中 | `likePet()` (L89-92) | 只调用了 `likeNearbyPet(id)`,没有先更新 UI 状态(不像 `PlazaPage.likePost` 做了乐观更新 + 回滚)。网络慢时用户看不到反馈,快速点击可能重复请求。 |
|
||||
| 4 | **`filter: 'online'` 后端过滤能力不确定** | 🟡 中 | `loadPets()` (L41-46) | 前端传 `{ type: 'online' }`,但 `nearbyPets` 云函数是否支持此过滤逻辑取决于后端实现。如果后端不识别此 filter,返回的数据未做二次前端过滤,展示可能不准确。 |
|
||||
| 5 | **搜索仅前端过滤** | 🟡 中 | `visiblePets` (L139-142) | 只按 `name` 和 `breed` 过滤,不支持按距离排序或更丰富的筛选组合。筛选器仅有 all/dog/cat/online 四种,缺少按距离、年龄段、标签等维度。 |
|
||||
|
||||
---
|
||||
|
||||
## 三、发布页 `pages/publish/index.tsx`
|
||||
|
||||
| # | 缺陷 | 严重程度 | 位置 | 说明 |
|
||||
|---|------|----------|------|------|
|
||||
| 1 | **没有恢复草稿的逻辑** | 🔴 高 | `useEffect` (L44-50) | `publish.service.ts` 导出了 `getDraft()`,但发布页 `mount` 时**从未调用它**。用户保存的草稿在下次进入发布页时不会被恢复。应添加逻辑:进入时检测未提交内容 → 提示恢复草稿 → 调用 `getDraft()` 回填表单。 |
|
||||
| 2 | **没有内容字数上限预警** | 🟡 中 | `ComposerToolbar` `count` prop | `isValidPostContent` 限制 1–2000 字,但 `content.length` 仅展示数字,没有在接近上限(如 >1800 字)时给出警告样式或禁用输入。 |
|
||||
| 3 | **图片上传失败静默跳过** | 🟡 中 | `uploadPostMedia()` (`publish.service.ts` L24-38) | 单张图片上传失败时 `catch` 返回原 `item`,用户不知道哪张图没上传成功。帖子可能携带本地临时路径 `wxfile://...` 发布,其他用户无法查看。应在 UI 上展示上传失败提示。 |
|
||||
| 4 | **返回没有未保存提示** | 🟡 中 | 关闭按钮 (L148-150) | 点击 `navigateBack()` 直接返回,如果用户已输入内容但未发布,不会提醒"是否保存为草稿"。应检测 `hasContent`,有内容时弹窗确认。 |
|
||||
|
||||
---
|
||||
|
||||
## 四、消息页 `pages/messages/index.tsx`
|
||||
|
||||
| # | 缺陷 | 严重程度 | 位置 | 说明 |
|
||||
|---|------|----------|------|------|
|
||||
| 1 | **`markConversationRead` 是 fire-and-forget** | 🟡 中 | `openConversation()` (L48-54) | 调用了 `markConversationRead` 但没有 `await`,且 `openConversation` 是 `async` 函数却没有 `try-catch`。如果 `navigateTo` 失败(如页面栈满),异常不会被捕获。 |
|
||||
| 2 | **没有区分 single / system 会话交互** | 🟡 中 | `openConversation()` (L48-54) | `Conversation` 有 `type: 'single' \| 'system'`,系统消息(如"汪圈小助手")不应有"发起聊天"行为,但 UI 上没有区分处理——点击系统消息也会跳转到 chat 页面。 |
|
||||
| 3 | **没有消息通知 / 推送机制** | 🟡 中 | 整个页面 | 没有接入微信订阅消息或任何推送通知机制。`preferences.notifications` 偏好存在但无实际功能接入,用户即使打开通知设置也不会收到新消息提醒。 |
|
||||
| 4 | **搜索不会过滤 activeUsers** | 🟡 中 | `kw` 过滤逻辑 (L64-69) | 搜索只过滤 `conversations`,不搜索 `activeUsers`。有搜索关键词时 `activeUsers` 被隐藏(`kw ? null : <ActiveUserRow>`),但清空搜索后立即恢复,交互上不连贯。 |
|
||||
|
||||
---
|
||||
|
||||
## 五、聊天页 `pages/chat/index.tsx`
|
||||
|
||||
| # | 缺陷 | 严重程度 | 位置 | 说明 |
|
||||
|---|------|----------|------|------|
|
||||
| 1 | **消息列表没有分页加载历史** | 🔴 高 | `load()` (L26-35) | 只调用一次 `getThread()` 获取当前消息,没有向上滚动加载更早消息的机制。长时间对话的历史消息无法查看。应实现 `ScrollView` 到顶触发加载上一页。 |
|
||||
| 2 | **轮询频率固定 5 秒,无智能调节** | 🟡 中 | `useDidShow` (L43-45) | 不管页面是否在前台活跃、消息密度如何,都固定 5 秒轮询一次。高频轮询消耗资源,低频则延迟高。对于聊天场景,建议使用 WebSocket 或根据消息活跃度动态调整轮询间隔。 |
|
||||
| 3 | **发送失败时输入框闪空** | 🟡 中 | `send()` (L53-71) | 发送时先 `setDraft('')` 清空输入框,失败后 `setDraft(text)` 恢复。在发送到失败的间隙,输入框会先空再恢复,视觉闪烁。建议先不清空,成功后再清空。 |
|
||||
| 4 | **没有消息发送状态指示** | 🟡 中 | 聊天气泡 UI (L92-103) | `sending` 状态变量存在但没有用在 UI 上。用户发送消息后没有 loading / 发送中指示器,不知道消息是否正在发送。 |
|
||||
| 5 | **`scrollTop` 递增 hack 不可靠** | 🟡 中 | `setScrollTop(prev => prev + 100000)` (L34) | 通过不断递增 `scrollTop` 强制滚动到底部。`Number.MAX_SAFE_INTEGER` 为 `9007199254740991`,每次 +100000 约可调用 9×10¹⁰ 次才溢出——虽然实际不太可能触发,但设计上应改用 ref 记录并 toggle 一个标志位。 |
|
||||
| 6 | **不支持图片 / 表情消息** | 🟡 中 | 输入栏 UI (L108-119) | 只支持纯文本输入,没有图片、表情等富媒体消息功能。`ChatMessage.type` 字段存在但没有被使用,输入区域也没有附件按钮。 |
|
||||
|
||||
---
|
||||
|
||||
## 六、个人资料页 `pages/profile/index.tsx`
|
||||
|
||||
| # | 缺陷 | 严重程度 | 位置 | 说明 |
|
||||
|---|------|----------|------|------|
|
||||
| 1 | **偏好 `changePreference` 无错误回滚** | 🟡 中 | `changePreference()` (L92-102) | 先乐观更新 UI 再 `await updateProfile`,但整个函数没有 `try-catch`。如果云端更新失败,UI 状态已改但数据未持久化,下次加载会回滚,造成用户困惑(以为保存了但实际没有)。 |
|
||||
| 2 | **"关于"功能是空壳** | 🟢 低 | `handleAction('about')` (L89) | 只弹 `toast: '敬请期待新功能'`。MVP 阶段可接受,但应在版本规划中补充。 |
|
||||
|
||||
---
|
||||
|
||||
## 七、资料编辑页 `pages/profile-edit/index.tsx`
|
||||
|
||||
| # | 缺陷 | 严重程度 | 位置 | 说明 |
|
||||
|---|------|----------|------|------|
|
||||
| 1 | **`type='nickname'` 仅真机生效** | 🟡 中 | `<Input type='nickname'>` (L116) | `type='nickname'` 是微信小程序获取微信昵称的特殊能力,仅在真机上有效。H5 预览 / 开发工具中用户可手动输入任意昵称(绕过微信身份验证)。应在非真机环境增加校验提示。 |
|
||||
| 2 | **`avatarUrl` 与 `avatarKey` 共存冲突** | 🟡 中 | `submit()` (L56-82) | 编辑后 `uploadAvatar` 返回 `fileID` 赋给 `patch.avatarUrl`,但 `avatarKey` 没有被更新或清除。如果 `Avatar` 组件优先读 `avatarUrl`,则 `avatarKey` 多余;若优先读 `avatarKey`,可能显示旧头像。需要明确两者的优先级关系。 |
|
||||
| 3 | **没有未保存离开提示** | 🟡 中 | 关闭按钮 (L88-90) | 直接 `navigateBack()` 没有检测是否有未保存修改。应比较当前表单值与初始值的差异,有差异时弹窗确认。 |
|
||||
|
||||
---
|
||||
|
||||
## 八、宠物编辑页 `pages/pet-edit/index.tsx`
|
||||
|
||||
| # | 缺陷 | 严重程度 | 位置 | 说明 |
|
||||
|---|------|----------|------|------|
|
||||
| 1 | **没有宠物数量上限限制** | 🟡 中 | `submit()` (L132-168) | 没有检查用户已添加的宠物数量。用户可以无限添加宠物,可能导致数据膨胀。建议在后端限制(如最多 5 只),前端也做预检提示。 |
|
||||
| 2 | **照片隐式非必填但交互暗示必填** | 🟡 中 | `choosePhoto()` + 验证逻辑 | 提交验证只检查 `name` 和 `breed`,无照片也能保存。但展示逻辑依赖 `photoKey` 的兜底图,用户可能以为照片是必须的。建议明确标注照片为"可选",或在空照片时展示更友好的占位。 |
|
||||
|
||||
---
|
||||
|
||||
## 九、联系人页 `pages/contacts/index.tsx`
|
||||
|
||||
| # | 缺陷 | 严重程度 | 位置 | 说明 |
|
||||
|---|------|----------|------|------|
|
||||
| 1 | **"发现"搜索需手动提交,不支持实时搜索** | 🟡 中 | `<Input onConfirm>` (L101-102) | 搜索需要点击键盘"搜索"按钮才触发 (`onConfirm`),没有 `onInput` 实时触发搜索。对于"发现陌生人"场景,用户期望输入即搜索,应增加防抖实时搜索。 |
|
||||
| 2 | **点击用户只能发起聊天,无个人主页入口** | 🟡 中 | `openChat()` (L63-67) | 点击用户头像/名称只能跳转到聊天页,没有查看对方个人主页(宠物列表、帖子等)的选项。用户想了解对方更多信息需要退出再找,交互断裂。 |
|
||||
| 3 | **互关后 `isFriend` 未即时更新** | 🟡 中 | `onFollow()` (L51-61) | `toggleFollow` 返回 `{ mutual }` 但只更新了 `isFollowing`,没有同步更新 `isFriend`。互关后列表中不会立即显示"汪友"标签,需手动刷新页面才能看到。 |
|
||||
|
||||
---
|
||||
|
||||
## 十、我的动态页 `pages/my-posts/index.tsx`
|
||||
|
||||
| # | 缺陷 | 严重程度 | 位置 | 说明 |
|
||||
|---|------|----------|------|------|
|
||||
| 1 | **没有分页加载** | 🔴 高 | `load()` (L19-23) | 只加载一次 `getUserPosts()`,没有翻页。如果用户帖子很多,无法查看全部历史帖子。应实现 `ScrollView` 到底触发加载下一页。 |
|
||||
| 2 | **`useDidShow` 首次触发导致重复加载** | 🔴 高 | `useDidShow(load)` (L31) | `useDidShow` 没有用 `firstShow` ref 跳过首次加载(对比 `PlazaPage` 和 `MessagesPage` 有此保护),首次进入页面时 `load()` 会被调用**两次**(`useEffect` + `useDidShow`),浪费一次网络请求。 |
|
||||
| 3 | **`likePost` 没有错误回滚** | 🟡 中 | `likePost()` (L33-47) | 乐观更新后调用 `setPostLike`,但如果失败不会回滚到之前的状态。对比 `PlazaPage.likePost` 有 `previousPosts` 回滚逻辑,此处遗漏了。 |
|
||||
|
||||
---
|
||||
|
||||
## 十一、跨页面系统性问题
|
||||
|
||||
| # | 问题 | 影响范围 | 说明 |
|
||||
|---|------|----------|------|
|
||||
| 1 | **全应用没有全局登录态守卫** | 所有页面 | 所有 service 通过 `canUseCloud()` 判断是否可用,但没有统一的"未登录则拦截/跳转"逻辑。只有 `ProfilePage` 单独处理了登录态,其他页面(广场、附近、消息、发布等)均假定已登录,未登录用户可能看到空数据或报错。 |
|
||||
| 2 | **`Post.favoritedByMe` 收藏能力全链路断裂** | PlazaPage, PostCard | 类型定义有 `favoritedByMe` 字段,`feed.service.ts` 导出 `setPostFavorite` 方法,但 `PostCard` 组件和 `PlazaPage` 均没有收藏操作的 UI 入口,前端从未调用过该 service 方法。 |
|
||||
| 3 | **草稿系统断裂** | PublishPage | `draftSave` → `draftGet` 云函数完整存在,`publish.service.ts` 导出了 `getDraft()`,但发布页 `mount` 时从不调用它。草稿保存后永远无法恢复,草稿功能形同虚设。 |
|
||||
| 4 | **`StatsRow` 数据可能与子页面不同步** | ProfilePage → ContactsPage, MyPostsPage | 个人资料页 `stats.posts/following/followers` 来自 `getProfile()`,跳转到 `contacts?tab=following` 时 `getFollowList()` 可能因缓存延迟返回不同计数,造成数字前后不一致。 |
|
||||
| 5 | **没有全局错误边界** | 全应用 | 任何 service 层未处理的异常会直接在页面崩溃。没有 React ErrorBoundary 或全局 Toast 兜底机制,用户体验差。 |
|
||||
| 6 | **`CloudFunctionName` 联合类型缺少 `commentCreate`** | feed.service.ts L43 | `createPostComment` 调用了 `callCloud('commentCreate', ...)`,但 `CloudFunctionName` 联合类型中是否包含 `commentCreate` 取决于 `cloud.ts` 的定义。如果未声明,TypeScript 不会报错但运行时可能失败。 |
|
||||
|
||||
---
|
||||
|
||||
## 十二、修复优先级建议
|
||||
|
||||
### P0 — 必须立即修复(影响核心功能)
|
||||
|
||||
1. **Feed / 我的动态分页** — 社交应用内容流的基础能力
|
||||
2. **发布页草稿恢复** — `getDraft()` 已存在,只需在页面 mount 时调用
|
||||
3. **附近页上报自身位置** — `updateLocation()` 已存在,只需在获取位置后调用
|
||||
|
||||
### P1 — 短期内修复(影响用户体验)
|
||||
|
||||
4. 我的动态页 `useDidShow` 重复加载 → 添加 `firstShow` ref
|
||||
5. 我的动态页 `likePost` 错误回滚
|
||||
6. 附近页 `likePet` 添加乐观更新
|
||||
7. 附近页尊重 `nearbyVisible` 偏好
|
||||
8. 聊天页消息分页加载
|
||||
9. 个人资料页 `changePreference` 添加 `try-catch`
|
||||
10. 收藏功能 UI 入口补全
|
||||
|
||||
### P2 — 中期迭代(完善产品体验)
|
||||
|
||||
11. 搜索栏功能实现(广场页)
|
||||
12. 联系人页实时搜索
|
||||
13. 聊天页发送状态指示 + 发送失败 UX
|
||||
14. 未保存离开提示(发布页、编辑页)
|
||||
15. 全局登录态守卫
|
||||
16. 全局错误边界
|
||||
17. 消息通知 / 推送接入
|
||||
|
||||
### P3 — 长期优化(性能和扩展性)
|
||||
|
||||
18. 话题列表动态化
|
||||
19. 广告系统正规化
|
||||
20. 图片 / 富媒体消息
|
||||
21. 帖子详情页 + 评论列表
|
||||
22. 聊天页轮询策略优化(WebSocket)
|
||||
23. 宠物数量上限
|
||||
24. 用户个人主页入口(从联系人页)
|
||||
|
||||
---
|
||||
|
||||
> 本报告基于源码静态审查生成,所有问题均指向代码层面的逻辑缺陷,不涉及 UI/UX 视觉设计层面的评审。
|
||||
@@ -8,7 +8,9 @@ export default defineAppConfig({
|
||||
'pages/profile-edit/index',
|
||||
'pages/pet-edit/index',
|
||||
'pages/chat/index',
|
||||
'pages/contacts/index'
|
||||
'pages/contacts/index',
|
||||
'pages/my-posts/index',
|
||||
'pages/post-detail/index'
|
||||
],
|
||||
window: {
|
||||
navigationStyle: 'custom',
|
||||
@@ -24,7 +26,7 @@ export default defineAppConfig({
|
||||
list: [
|
||||
{ pagePath: 'pages/plaza/index', text: '广场' },
|
||||
{ pagePath: 'pages/nearby/index', text: '附近' },
|
||||
{ pagePath: 'pages/messages/index', text: '消息' },
|
||||
{ pagePath: 'pages/messages/index', text: '汪友' },
|
||||
{ pagePath: 'pages/profile/index', text: '我的' }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -10,6 +10,6 @@ export interface TabConfig {
|
||||
export const tabs: TabConfig[] = [
|
||||
{ key: 'plaza', label: '广场', path: '/pages/plaza/index' },
|
||||
{ key: 'nearby', label: '附近', path: '/pages/nearby/index' },
|
||||
{ key: 'messages', label: '消息', path: '/pages/messages/index', dot: true },
|
||||
{ key: 'messages', label: '汪友', path: '/pages/messages/index', dot: true },
|
||||
{ key: 'profile', label: '我的', path: '/pages/profile/index' }
|
||||
]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
import { Image, Text, View } from '@tarojs/components'
|
||||
import Avatar from '@/components/ui/Avatar'
|
||||
import Icon from '@/components/ui/Icon'
|
||||
@@ -7,17 +8,28 @@ import './index.scss'
|
||||
interface PostCardProps {
|
||||
post: Post
|
||||
onLike: (postId: string) => void
|
||||
onComment?: (postId: string) => void
|
||||
// Opens the post detail page (triggered by tapping the body, photos or the
|
||||
// comment button). When provided it takes precedence over onComment.
|
||||
onOpen?: (postId: string) => void
|
||||
// When true, tapping a photo opens the native full-screen image preview
|
||||
// instead of navigating. Used on the detail page where there's nowhere to go.
|
||||
enableImagePreview?: boolean
|
||||
}
|
||||
|
||||
function PostActionIcon({ type, onClick }: { type: 'heart' | 'comment'; onClick?: () => void }) {
|
||||
return <View className={`icon icon--${type}`} onClick={onClick} />
|
||||
}
|
||||
|
||||
export function PostCard({ post, onLike }: PostCardProps) {
|
||||
export function PostCard({ post, onLike, onComment, onOpen, enableImagePreview }: PostCardProps) {
|
||||
const openDetail = onOpen ? () => onOpen(post._id) : undefined
|
||||
const handleComment = onOpen ? openDetail : onComment ? () => onComment(post._id) : undefined
|
||||
const urls = post.media || []
|
||||
const previewImage = (current: string) => Taro.previewImage({ current, urls })
|
||||
return (
|
||||
<View className='post-card'>
|
||||
<View className='post-card__head'>
|
||||
<Avatar avatarKey={post.author.avatarKey} size='md' online />
|
||||
<Avatar avatarKey={post.author.avatarKey} avatarUrl={post.author.avatarUrl} size='md' online />
|
||||
<View className='post-card__meta'>
|
||||
<View className='post-card__name-row'>
|
||||
<Text className='post-card__name'>{post.author.name}</Text>
|
||||
@@ -36,16 +48,22 @@ export function PostCard({ post, onLike }: PostCardProps) {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text className='post-card__body'>{post.body}</Text>
|
||||
<Text className='post-card__body' onClick={openDetail}>{post.body}</Text>
|
||||
|
||||
{post.media && post.media.length ? (
|
||||
<View className={`post-card__photos post-card__photos--${Math.min(post.media.length, 3)}`}>
|
||||
{post.media.slice(0, 9).map((src, i) => (
|
||||
<Image key={i} className='post-card__photo' src={src} mode='aspectFill' />
|
||||
<Image
|
||||
key={i}
|
||||
className='post-card__photo'
|
||||
src={src}
|
||||
mode='aspectFill'
|
||||
onClick={enableImagePreview ? () => previewImage(src) : openDetail}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<View className={`post-card__media post-card__media--${post.mediaTone}`}>
|
||||
<View className={`post-card__media post-card__media--${post.mediaTone}`} onClick={openDetail}>
|
||||
<View className='post-card__silhouette' />
|
||||
{post.sticker ? <Text className='post-card__sticker'>{post.sticker}</Text> : null}
|
||||
</View>
|
||||
@@ -54,8 +72,8 @@ export function PostCard({ post, onLike }: PostCardProps) {
|
||||
<View className={`post-card__actions ${post.likedByMe ? 'post-card__actions--liked' : ''}`}>
|
||||
<PostActionIcon type='heart' onClick={() => onLike(post._id)} />
|
||||
<Text onClick={() => onLike(post._id)}>{post.counts.likes}</Text>
|
||||
<PostActionIcon type='comment' />
|
||||
<Text>{post.counts.comments}</Text>
|
||||
<PostActionIcon type='comment' onClick={handleComment} />
|
||||
<Text onClick={handleComment}>{post.counts.comments}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@ export function ActiveUserRow({ users, onSelect, onDiscover }: ActiveUserRowProp
|
||||
<View className='active-user-row__avatar-wrap active-user-row__add'>
|
||||
<Icon name='plus' size={22} />
|
||||
</View>
|
||||
<Text className='active-user-row__name'>发现</Text>
|
||||
<Text className='active-user-row__name'>找汪友</Text>
|
||||
</View>
|
||||
{users.map(user => (
|
||||
<View key={user.id} className='active-user-row__item' onClick={() => onSelect?.(user)}>
|
||||
@@ -35,4 +35,3 @@ export function ActiveUserRow({ users, onSelect, onDiscover }: ActiveUserRowProp
|
||||
}
|
||||
|
||||
export default ActiveUserRow
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ export function NearbyMap({ pets, center, scale, activeId, onSelect, onRegionCha
|
||||
onRegionChange={(e: any) => {
|
||||
if (e?.type === 'end') onRegionChange?.(e.causedBy || '')
|
||||
}}
|
||||
onError={() => undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -114,13 +114,55 @@
|
||||
|
||||
.nearby-pet-card__actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
width: 78px;
|
||||
}
|
||||
|
||||
.nearby-pet-card__friend {
|
||||
width: 76px;
|
||||
height: 30px;
|
||||
border-radius: $radius-pill;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
box-sizing: border-box;
|
||||
color: $primary-on;
|
||||
background: linear-gradient(135deg, $accent, $accent-2);
|
||||
box-shadow: 0 6px 16px rgba(216, 180, 255, 0.22);
|
||||
transition: transform 180ms, opacity 180ms, background 180ms;
|
||||
}
|
||||
|
||||
.nearby-pet-card__friend:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.nearby-pet-card__friend--friend {
|
||||
background: rgba(143, 229, 181, 0.16);
|
||||
border: 1px solid rgba(143, 229, 181, 0.32);
|
||||
color: $success;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.nearby-pet-card__friend--pending,
|
||||
.nearby-pet-card__friend--mine {
|
||||
background: rgba(216, 180, 255, 0.08);
|
||||
border: 1px solid rgba(216, 180, 255, 0.18);
|
||||
color: $muted;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.nearby-pet-card__friend-text {
|
||||
font: 600 10px / 1 $font-body;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nearby-pet-card__btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: rgba(20, 16, 43, 0.5);
|
||||
border: 1px solid rgba(216, 180, 255, 0.08);
|
||||
@@ -136,4 +178,3 @@
|
||||
color: #fff;
|
||||
box-shadow: 0 6px 16px rgba(255, 138, 171, 0.35);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,19 @@ interface NearbyPetCardProps {
|
||||
pet: NearbyPet
|
||||
active: boolean
|
||||
onSelect: () => void
|
||||
onFriendAction: () => void
|
||||
onLike: () => void
|
||||
}
|
||||
|
||||
export function NearbyPetCard({ pet, active, onSelect, onLike }: NearbyPetCardProps) {
|
||||
export function NearbyPetCard({ pet, active, onSelect, onFriendAction, onLike }: NearbyPetCardProps) {
|
||||
const relation = pet.isMine
|
||||
? { key: 'mine', label: '我的', icon: 'user' as const }
|
||||
: pet.isFriend
|
||||
? { key: 'friend', label: '聊天', icon: 'message' as const }
|
||||
: pet.isFollowing
|
||||
? { key: 'pending', label: '待回关', icon: 'check' as const }
|
||||
: { key: 'add', label: '加汪友', icon: 'plus' as const }
|
||||
|
||||
return (
|
||||
<View className={`nearby-pet-card ${active ? 'nearby-pet-card--active' : ''}`} onClick={onSelect}>
|
||||
<View className={`nearby-pet-card__photo ${pet.photoKey}`}>
|
||||
@@ -34,8 +43,15 @@ export function NearbyPetCard({ pet, active, onSelect, onLike }: NearbyPetCardPr
|
||||
</View>
|
||||
</View>
|
||||
<View className='nearby-pet-card__actions'>
|
||||
<View className='nearby-pet-card__btn'>
|
||||
<Icon name='message' size={16} />
|
||||
<View
|
||||
className={`nearby-pet-card__friend nearby-pet-card__friend--${relation.key}`}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
onFriendAction()
|
||||
}}
|
||||
>
|
||||
<Icon name={relation.icon} size={13} />
|
||||
<Text className='nearby-pet-card__friend-text'>{relation.label}</Text>
|
||||
</View>
|
||||
<View
|
||||
className='nearby-pet-card__btn nearby-pet-card__btn--like'
|
||||
@@ -52,4 +68,3 @@ export function NearbyPetCard({ pet, active, onSelect, onLike }: NearbyPetCardPr
|
||||
}
|
||||
|
||||
export default NearbyPetCard
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ interface NearbySheetProps {
|
||||
filter: string
|
||||
onFilterChange: (filter: string) => void
|
||||
onSelect: (id: string) => void
|
||||
onFriendAction: (pet: NearbyPet) => void
|
||||
onLike: (id: string) => void
|
||||
}
|
||||
|
||||
@@ -21,7 +22,7 @@ const filters = [
|
||||
{ key: 'online', label: '在线' }
|
||||
]
|
||||
|
||||
export function NearbySheet({ pets, activeId, filter, onFilterChange, onSelect, onLike }: NearbySheetProps) {
|
||||
export function NearbySheet({ pets, activeId, filter, onFilterChange, onSelect, onFriendAction, onLike }: NearbySheetProps) {
|
||||
const sheet = useDragSheet(220)
|
||||
|
||||
return (
|
||||
@@ -62,6 +63,7 @@ export function NearbySheet({ pets, activeId, filter, onFilterChange, onSelect,
|
||||
pet={pet}
|
||||
active={pet._id === activeId}
|
||||
onSelect={() => onSelect(pet._id)}
|
||||
onFriendAction={() => onFriendAction(pet)}
|
||||
onLike={() => onLike(pet._id)}
|
||||
/>
|
||||
))}
|
||||
@@ -71,4 +73,3 @@ export function NearbySheet({ pets, activeId, filter, onFilterChange, onSelect,
|
||||
}
|
||||
|
||||
export default NearbySheet
|
||||
|
||||
|
||||
@@ -3,17 +3,18 @@ import { formatCount } from '@/utils/date'
|
||||
import type { UserProfile } from '@/types/domain'
|
||||
import './index.scss'
|
||||
|
||||
type StatKey = 'posts' | 'following' | 'followers'
|
||||
|
||||
interface StatsRowProps {
|
||||
stats: UserProfile['stats']
|
||||
onSelect?: (key: 'posts' | 'following' | 'followers' | 'favorites') => void
|
||||
onSelect?: (key: StatKey) => void
|
||||
}
|
||||
|
||||
const labels = [
|
||||
const labels: Array<[StatKey, string]> = [
|
||||
['posts', '动态'],
|
||||
['following', '关注'],
|
||||
['followers', '粉丝'],
|
||||
['favorites', '收藏']
|
||||
] as const
|
||||
['followers', '粉丝']
|
||||
]
|
||||
|
||||
export function StatsRow({ stats, onSelect }: StatsRowProps) {
|
||||
const safeStats = stats || { posts: 0, following: 0, followers: 0, favorites: 0 }
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '消息',
|
||||
navigationBarTitleText: '汪友',
|
||||
navigationStyle: 'custom',
|
||||
disableScroll: true
|
||||
})
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function MessagesPage() {
|
||||
})
|
||||
}
|
||||
|
||||
const goContacts = () => Taro.navigateTo({ url: '/pages/contacts/index' })
|
||||
const goContacts = () => Taro.navigateTo({ url: '/pages/contacts/index?tab=discover' })
|
||||
|
||||
const kw = keyword.trim().toLowerCase()
|
||||
const visibleConversations = kw
|
||||
@@ -70,8 +70,8 @@ export default function MessagesPage() {
|
||||
|
||||
return (
|
||||
<PageShell className='messages-page'>
|
||||
<CustomNavBar title='消息' />
|
||||
<SearchBar placeholder='搜索聊天或通知' value={keyword} onInput={setKeyword} />
|
||||
<CustomNavBar title='汪友' />
|
||||
<SearchBar placeholder='搜索汪友或聊天' value={keyword} onInput={setKeyword} />
|
||||
<SegmentedTabs items={tabs as any} value={tab} onChange={setTab} />
|
||||
{kw ? null : <ActiveUserRow users={activeUsers} onSelect={openChatWith} onDiscover={goContacts} />}
|
||||
<View className='messages-page__divider'>
|
||||
@@ -87,7 +87,7 @@ export default function MessagesPage() {
|
||||
? '没有匹配的聊天'
|
||||
: tab === 'unread'
|
||||
? '没有未读消息'
|
||||
: '还没有聊天,去「发现」找汪友聊聊吧'}
|
||||
: '还没有聊天,去「附近」加汪友吧'}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
59
src/pages/my-posts/index.scss
Normal file
59
src/pages/my-posts/index.scss
Normal file
@@ -0,0 +1,59 @@
|
||||
.my-posts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.my-posts__nav {
|
||||
flex-shrink: 0;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.my-posts__nav-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 50px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.my-posts__back {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $fg;
|
||||
}
|
||||
|
||||
.my-posts__back-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.my-posts__title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
color: $fg;
|
||||
font: 700 16px / 1 $font-display;
|
||||
}
|
||||
|
||||
.my-posts__nav-spacer {
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.my-posts__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.my-posts__feed {
|
||||
padding: 8px 16px 32px;
|
||||
}
|
||||
|
||||
.my-posts__empty {
|
||||
display: block;
|
||||
text-align: center;
|
||||
color: $muted;
|
||||
font: 400 13px / 1.6 $font-body;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
82
src/pages/my-posts/index.tsx
Normal file
82
src/pages/my-posts/index.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import Taro, { useDidShow } from '@tarojs/taro'
|
||||
import { ScrollView, Text, View } from '@tarojs/components'
|
||||
import { useSystemLayout } from '@/hooks/useSystemLayout'
|
||||
import Icon from '@/components/ui/Icon'
|
||||
import PostCard from '@/features/feed/components/PostCard'
|
||||
import ToastOverlay from '@/components/feedback/ToastOverlay'
|
||||
import { getUserPosts, setPostLike } from '@/services/feed.service'
|
||||
import { useToast } from '@/hooks/useToast'
|
||||
import type { Post } from '@/types/domain'
|
||||
import './index.scss'
|
||||
|
||||
export default function MyPostsPage() {
|
||||
const { statusBarHeight } = useSystemLayout()
|
||||
const [posts, setPosts] = useState<Post[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const toast = useToast(480)
|
||||
|
||||
const load = () => {
|
||||
setLoading(true)
|
||||
getUserPosts()
|
||||
.then(setPosts)
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
// Refresh on return (e.g. after publishing a new post).
|
||||
useDidShow(load)
|
||||
|
||||
const likePost = async (postId: string) => {
|
||||
const next = posts.map(post => {
|
||||
if (post._id !== postId) return post
|
||||
const likedByMe = !post.likedByMe
|
||||
if (likedByMe) toast.show()
|
||||
return {
|
||||
...post,
|
||||
likedByMe,
|
||||
counts: { ...post.counts, likes: Math.max(0, post.counts.likes + (likedByMe ? 1 : -1)) }
|
||||
}
|
||||
})
|
||||
setPosts(next)
|
||||
const target = next.find(p => p._id === postId)
|
||||
if (target) await setPostLike(postId, target.likedByMe)
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='my-posts'>
|
||||
<View className='my-posts__nav' style={{ paddingTop: `${statusBarHeight}px` }}>
|
||||
<View className='my-posts__nav-row'>
|
||||
<View className='my-posts__back' onClick={() => Taro.navigateBack()}>
|
||||
<Icon name='chev' size={22} className='my-posts__back-icon' />
|
||||
</View>
|
||||
<Text className='my-posts__title'>我的动态</Text>
|
||||
<View className='my-posts__nav-spacer' />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView scrollY className='my-posts__scroll' enhanced showScrollbar={false}>
|
||||
<View className='my-posts__feed'>
|
||||
{posts.map(post => (
|
||||
<PostCard
|
||||
key={post._id}
|
||||
post={post}
|
||||
onLike={likePost}
|
||||
onOpen={id => Taro.navigateTo({ url: `/pages/post-detail/index?postId=${id}` })}
|
||||
/>
|
||||
))}
|
||||
{loading && posts.length === 0 ? (
|
||||
<Text className='my-posts__empty'>加载中…</Text>
|
||||
) : !loading && posts.length === 0 ? (
|
||||
<Text className='my-posts__empty'>还没有发布过动态,去广场点 + 发布第一条吧</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<ToastOverlay visible={toast.visible} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import MapControls from '@/features/nearby/components/MapControls'
|
||||
import NearbySheet from '@/features/nearby/components/NearbySheet'
|
||||
import { useSystemLayout } from '@/hooks/useSystemLayout'
|
||||
import { getNearbyPets, likeNearbyPet } from '@/services/nearby.service'
|
||||
import { toggleFollow } from '@/services/social.service'
|
||||
import { mockNearbyPets } from '@/services/mock'
|
||||
import { useToast } from '@/hooks/useToast'
|
||||
import type { GeoPoint, NearbyPet } from '@/types/domain'
|
||||
@@ -90,6 +91,51 @@ export default function NearbyPage() {
|
||||
await likeNearbyPet(id)
|
||||
}
|
||||
|
||||
const updateOwnerRelation = (
|
||||
ownerId: string,
|
||||
patch: Pick<NearbyPet, 'isFollowing' | 'isFriend'>
|
||||
) => {
|
||||
setPets(current => current.map(pet => (pet.ownerId === ownerId ? { ...pet, ...patch } : pet)))
|
||||
}
|
||||
|
||||
const openChatWithPet = (pet: NearbyPet) => {
|
||||
if (!pet.ownerId) return
|
||||
const title = pet.ownerName || pet.name
|
||||
Taro.navigateTo({
|
||||
url: `/pages/chat/index?targetUserId=${pet.ownerId}&title=${encodeURIComponent(title)}`
|
||||
})
|
||||
}
|
||||
|
||||
const handleFriendAction = async (pet: NearbyPet) => {
|
||||
if (pet.isMine) {
|
||||
Taro.showToast({ title: '这是你自己的毛孩子', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
if (pet.isFriend) {
|
||||
openChatWithPet(pet)
|
||||
return
|
||||
}
|
||||
|
||||
if (pet.isFollowing) {
|
||||
Taro.showToast({ title: '已关注,等对方回关', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
updateOwnerRelation(pet.ownerId, { isFollowing: true, isFriend: false })
|
||||
try {
|
||||
const result = await toggleFollow(pet.ownerId, true)
|
||||
updateOwnerRelation(pet.ownerId, { isFollowing: true, isFriend: result.mutual })
|
||||
Taro.showToast({
|
||||
title: result.mutual ? '已成为汪友' : '已关注,等对方回关',
|
||||
icon: 'none'
|
||||
})
|
||||
} catch {
|
||||
updateOwnerRelation(pet.ownerId, { isFollowing: false, isFriend: false })
|
||||
Taro.showToast({ title: '添加失败,请稍后再试', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const q = query.trim().toLowerCase()
|
||||
const visiblePets = q
|
||||
? pets.filter(p => p.name.toLowerCase().includes(q) || (p.breed || '').toLowerCase().includes(q))
|
||||
@@ -129,6 +175,7 @@ export default function NearbyPage() {
|
||||
filter={filter}
|
||||
onFilterChange={setFilter}
|
||||
onSelect={setActiveId}
|
||||
onFriendAction={handleFriendAction}
|
||||
onLike={likePet}
|
||||
/>
|
||||
<ToastOverlay visible={toast.visible} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useDidShow } from '@tarojs/taro'
|
||||
import Taro, { useDidShow } from '@tarojs/taro'
|
||||
import { ScrollView, Text, View } from '@tarojs/components'
|
||||
import CustomNavBar from '@/components/layout/CustomNavBar'
|
||||
import PageShell from '@/components/layout/PageShell'
|
||||
@@ -52,6 +52,7 @@ export default function PlazaPage() {
|
||||
})
|
||||
|
||||
const likePost = async (postId: string) => {
|
||||
const previousPosts = posts
|
||||
const nextPosts = posts.map(post => {
|
||||
if (post._id !== postId) return post
|
||||
const likedByMe = !post.likedByMe
|
||||
@@ -67,7 +68,23 @@ export default function PlazaPage() {
|
||||
})
|
||||
setPosts(nextPosts)
|
||||
const next = nextPosts.find(post => post._id === postId)
|
||||
if (next) await setPostLike(postId, next.likedByMe)
|
||||
if (!next) return
|
||||
|
||||
try {
|
||||
const result = await setPostLike(postId, next.likedByMe)
|
||||
setPosts(current => current.map(post => (
|
||||
post._id === postId
|
||||
? { ...post, likedByMe: result.liked, counts: { ...post.counts, likes: result.likes } }
|
||||
: post
|
||||
)))
|
||||
} catch {
|
||||
setPosts(previousPosts)
|
||||
Taro.showToast({ title: '点赞失败,请重试', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const openPost = (postId: string) => {
|
||||
Taro.navigateTo({ url: `/pages/post-detail/index?postId=${postId}` })
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -87,7 +104,7 @@ export default function PlazaPage() {
|
||||
<View className='plaza-page__feed'>
|
||||
{posts.map((post, index) => (
|
||||
<View key={post._id}>
|
||||
<PostCard post={post} onLike={likePost} />
|
||||
<PostCard post={post} onLike={likePost} onOpen={openPost} />
|
||||
{index === 0 ? <FeedAdCard /> : null}
|
||||
</View>
|
||||
))}
|
||||
|
||||
147
src/pages/post-detail/index.scss
Normal file
147
src/pages/post-detail/index.scss
Normal file
@@ -0,0 +1,147 @@
|
||||
.post-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.post-detail__nav {
|
||||
flex-shrink: 0;
|
||||
background: $bg;
|
||||
border-bottom: 1px solid rgba(216, 180, 255, 0.06);
|
||||
}
|
||||
|
||||
.post-detail__nav-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 50px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.post-detail__back {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $fg;
|
||||
}
|
||||
|
||||
.post-detail__back-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.post-detail__title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
color: $fg;
|
||||
font: 700 16px / 1 $font-display;
|
||||
}
|
||||
|
||||
.post-detail__nav-spacer {
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.post-detail__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.post-detail__post {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.post-detail__comments {
|
||||
padding: 4px 16px 24px;
|
||||
}
|
||||
|
||||
.post-detail__comments-title {
|
||||
display: block;
|
||||
margin: 8px 0 12px;
|
||||
color: $fg;
|
||||
font: 700 14px / 1 $font-display;
|
||||
}
|
||||
|
||||
.post-detail__empty {
|
||||
display: block;
|
||||
text-align: center;
|
||||
color: $muted;
|
||||
font: 400 13px / 1.6 $font-body;
|
||||
padding: 36px 24px;
|
||||
}
|
||||
|
||||
.post-detail__comment {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid rgba(216, 180, 255, 0.06);
|
||||
}
|
||||
|
||||
.post-detail__comment-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.post-detail__comment-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.post-detail__comment-name {
|
||||
color: $fg;
|
||||
font: 600 13px / 1.2 $font-body;
|
||||
}
|
||||
|
||||
.post-detail__comment-time {
|
||||
color: $muted;
|
||||
font: 400 11px / 1 $font-body;
|
||||
}
|
||||
|
||||
.post-detail__comment-text {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: $fg;
|
||||
font: 400 14px / 1.55 $font-body;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.post-detail__input-bar {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px 12px;
|
||||
background: $bg;
|
||||
border-top: 1px solid rgba(216, 180, 255, 0.08);
|
||||
}
|
||||
|
||||
.post-detail__input {
|
||||
flex: 1;
|
||||
height: 40px;
|
||||
padding: 0 14px;
|
||||
box-sizing: border-box;
|
||||
border-radius: $radius-pill;
|
||||
background: rgba(31, 23, 66, 0.68);
|
||||
border: 1px solid rgba(216, 180, 255, 0.1);
|
||||
color: $fg;
|
||||
font: 400 14px / 40px $font-body;
|
||||
}
|
||||
|
||||
.post-detail__input-ph {
|
||||
color: $muted;
|
||||
}
|
||||
|
||||
.post-detail__send {
|
||||
flex-shrink: 0;
|
||||
padding: 0 18px;
|
||||
height: 40px;
|
||||
border-radius: $radius-pill;
|
||||
background: linear-gradient(135deg, $accent, $accent-2);
|
||||
color: $primary-on;
|
||||
font: 700 14px / 40px $font-body;
|
||||
}
|
||||
|
||||
.post-detail__send--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
163
src/pages/post-detail/index.tsx
Normal file
163
src/pages/post-detail/index.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import Taro, { useRouter } from '@tarojs/taro'
|
||||
import { Input, ScrollView, Text, View } from '@tarojs/components'
|
||||
import { useSystemLayout } from '@/hooks/useSystemLayout'
|
||||
import Icon from '@/components/ui/Icon'
|
||||
import Avatar from '@/components/ui/Avatar'
|
||||
import PostCard from '@/features/feed/components/PostCard'
|
||||
import ToastOverlay from '@/components/feedback/ToastOverlay'
|
||||
import { createPostComment, getPostDetail, setPostLike } from '@/services/feed.service'
|
||||
import { useToast } from '@/hooks/useToast'
|
||||
import type { Post, PostComment } from '@/types/domain'
|
||||
import './index.scss'
|
||||
|
||||
export default function PostDetailPage() {
|
||||
const { statusBarHeight, safeBottom } = useSystemLayout()
|
||||
const router = useRouter()
|
||||
const postId = router.params.postId || ''
|
||||
|
||||
const [post, setPost] = useState<Post | null>(null)
|
||||
const [comments, setComments] = useState<PostComment[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [draft, setDraft] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
const toast = useToast(480)
|
||||
|
||||
const load = () => {
|
||||
if (!postId) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
getPostDetail(postId)
|
||||
.then(res => {
|
||||
setPost(res.post)
|
||||
setComments(res.comments)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [postId])
|
||||
|
||||
const likePost = async (id: string) => {
|
||||
if (!post) return
|
||||
const likedByMe = !post.likedByMe
|
||||
if (likedByMe) toast.show()
|
||||
const next = {
|
||||
...post,
|
||||
likedByMe,
|
||||
counts: { ...post.counts, likes: Math.max(0, post.counts.likes + (likedByMe ? 1 : -1)) }
|
||||
}
|
||||
setPost(next)
|
||||
try {
|
||||
const result = await setPostLike(id, likedByMe)
|
||||
setPost(current =>
|
||||
current ? { ...current, likedByMe: result.liked, counts: { ...current.counts, likes: result.likes } } : current
|
||||
)
|
||||
} catch {
|
||||
setPost(post)
|
||||
Taro.showToast({ title: '点赞失败,请重试', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const send = async () => {
|
||||
const content = draft.trim()
|
||||
if (!content || sending || !post) return
|
||||
setSending(true)
|
||||
setDraft('')
|
||||
try {
|
||||
const result = await createPostComment(post._id, content)
|
||||
setComments(current => [
|
||||
...current,
|
||||
{
|
||||
_id: result.commentId,
|
||||
author: {
|
||||
id: 'me',
|
||||
name: '我',
|
||||
avatarKey: 'gradient-avatar-1'
|
||||
},
|
||||
content,
|
||||
timeText: '刚刚'
|
||||
}
|
||||
])
|
||||
setPost(current =>
|
||||
current ? { ...current, counts: { ...current.counts, comments: result.comments } } : current
|
||||
)
|
||||
} catch {
|
||||
Taro.showToast({ title: '评论失败,请重试', icon: 'none' })
|
||||
setDraft(content)
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='post-detail'>
|
||||
<View className='post-detail__nav' style={{ paddingTop: `${statusBarHeight}px` }}>
|
||||
<View className='post-detail__nav-row'>
|
||||
<View className='post-detail__back' onClick={() => Taro.navigateBack()}>
|
||||
<Icon name='chev' size={22} className='post-detail__back-icon' />
|
||||
</View>
|
||||
<Text className='post-detail__title'>动态详情</Text>
|
||||
<View className='post-detail__nav-spacer' />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView scrollY className='post-detail__scroll' enhanced showScrollbar={false}>
|
||||
{post ? (
|
||||
<View className='post-detail__post'>
|
||||
<PostCard post={post} onLike={likePost} enableImagePreview />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View className='post-detail__comments'>
|
||||
<Text className='post-detail__comments-title'>
|
||||
评论{post ? ` ${post.counts.comments}` : ''}
|
||||
</Text>
|
||||
|
||||
{loading && !post ? (
|
||||
<Text className='post-detail__empty'>加载中…</Text>
|
||||
) : comments.length === 0 ? (
|
||||
<Text className='post-detail__empty'>还没有评论,来抢沙发吧</Text>
|
||||
) : (
|
||||
comments.map(comment => (
|
||||
<View key={comment._id} className='post-detail__comment'>
|
||||
<Avatar avatarKey={comment.author.avatarKey} avatarUrl={comment.author.avatarUrl} size='sm' />
|
||||
<View className='post-detail__comment-body'>
|
||||
<View className='post-detail__comment-head'>
|
||||
<Text className='post-detail__comment-name'>{comment.author.name}</Text>
|
||||
<Text className='post-detail__comment-time'>{comment.timeText}</Text>
|
||||
</View>
|
||||
<Text className='post-detail__comment-text'>{comment.content}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View className='post-detail__input-bar' style={{ paddingBottom: `${Math.max(12, safeBottom)}px` }}>
|
||||
<Input
|
||||
className='post-detail__input'
|
||||
value={draft}
|
||||
placeholder='写评论…'
|
||||
placeholderClass='post-detail__input-ph'
|
||||
confirmType='send'
|
||||
onInput={e => setDraft(e.detail.value)}
|
||||
onConfirm={send}
|
||||
/>
|
||||
<View
|
||||
className={`post-detail__send ${draft.trim() ? '' : 'post-detail__send--disabled'}`}
|
||||
onClick={send}
|
||||
>
|
||||
发送
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ToastOverlay visible={toast.visible} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -118,7 +118,8 @@ export default function ProfilePage() {
|
||||
<StatsRow
|
||||
stats={user.stats}
|
||||
onSelect={key => {
|
||||
if (key === 'following') Taro.navigateTo({ url: '/pages/contacts/index?tab=following' })
|
||||
if (key === 'posts') Taro.navigateTo({ url: '/pages/my-posts/index' })
|
||||
else if (key === 'following') Taro.navigateTo({ url: '/pages/contacts/index?tab=following' })
|
||||
else if (key === 'followers') Taro.navigateTo({ url: '/pages/contacts/index?tab=followers' })
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -80,7 +80,7 @@ export default function PublishPage() {
|
||||
}
|
||||
}
|
||||
try {
|
||||
const res = await Taro.chooseLocation()
|
||||
const res = await Taro.chooseLocation({})
|
||||
if (res && (res.name || res.address)) {
|
||||
setLocation(res.name || res.address)
|
||||
setLocationGeo({ latitude: res.latitude, longitude: res.longitude })
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import { callCloud, canUseCloud } from './cloud'
|
||||
import { mockPosts } from './mock'
|
||||
import type { FeedChannel, Post } from '@/types/domain'
|
||||
import type { FeedChannel, Post, PostComment } from '@/types/domain'
|
||||
|
||||
export async function getUserPosts(authorId?: string): Promise<Post[]> {
|
||||
if (!canUseCloud()) return mockPosts
|
||||
const result = await callCloud<object, { list: Post[] }>('userPosts', authorId ? { authorId } : {})
|
||||
return result.list || []
|
||||
}
|
||||
|
||||
export async function getPostDetail(
|
||||
postId: string
|
||||
): Promise<{ post: Post | null; comments: PostComment[] }> {
|
||||
if (!canUseCloud()) {
|
||||
return { post: mockPosts.find(item => item._id === postId) || null, comments: [] }
|
||||
}
|
||||
const result = await callCloud<{ postId: string }, { post: Post | null; comments: PostComment[] }>(
|
||||
'postDetail',
|
||||
{ postId }
|
||||
)
|
||||
return { post: result.post || null, comments: result.comments || [] }
|
||||
}
|
||||
|
||||
export async function getFeedList(channel: FeedChannel, topic?: string): Promise<Post[]> {
|
||||
// Only fall back to sample posts in non-cloud previews (e.g. H5 dev).
|
||||
@@ -28,3 +47,11 @@ export async function setPostFavorite(
|
||||
return callCloud('postFavorite', { postId, favorited })
|
||||
}
|
||||
|
||||
export async function createPostComment(postId: string, content: string): Promise<{ commentId: string; comments: number }> {
|
||||
if (!canUseCloud()) {
|
||||
const post = mockPosts.find(item => item._id === postId)
|
||||
return { commentId: `local_comment_${Date.now()}`, comments: (post?.counts.comments || 0) + 1 }
|
||||
}
|
||||
|
||||
return callCloud('commentCreate', { postId, content })
|
||||
}
|
||||
|
||||
@@ -103,6 +103,10 @@ export const mockPosts: Post[] = [
|
||||
export const mockNearbyPets: NearbyPet[] = [
|
||||
{
|
||||
...mockPets[0],
|
||||
ownerName: '阿布的家长',
|
||||
isMine: true,
|
||||
isFollowing: false,
|
||||
isFriend: false,
|
||||
distanceText: '260m',
|
||||
online: true,
|
||||
pin: { left: 24, top: 28 }
|
||||
@@ -117,6 +121,9 @@ export const mockNearbyPets: NearbyPet[] = [
|
||||
distanceText: '420m',
|
||||
avatarKey: 'gradient-avatar-3',
|
||||
photoKey: 'photo-green',
|
||||
ownerName: 'Lucky 之家',
|
||||
isFollowing: true,
|
||||
isFriend: true,
|
||||
tags: ['精力旺盛', '会接飞盘'],
|
||||
mood: '找玩伴',
|
||||
online: true,
|
||||
@@ -132,6 +139,9 @@ export const mockNearbyPets: NearbyPet[] = [
|
||||
distanceText: '530m',
|
||||
avatarKey: 'gradient-avatar-6',
|
||||
photoKey: 'photo-pink',
|
||||
ownerName: '豆包妈',
|
||||
isFollowing: false,
|
||||
isFriend: false,
|
||||
tags: ['慢热', '爱追球'],
|
||||
mood: '想闻新朋友',
|
||||
pin: { left: 42, top: 56 }
|
||||
@@ -146,6 +156,9 @@ export const mockNearbyPets: NearbyPet[] = [
|
||||
distanceText: '780m',
|
||||
avatarKey: 'gradient-avatar-2',
|
||||
photoKey: 'photo-blue',
|
||||
ownerName: '白桃家',
|
||||
isFollowing: true,
|
||||
isFriend: false,
|
||||
tags: ['温柔', '适合新手'],
|
||||
mood: '公园散步中',
|
||||
pin: { left: 76, top: 62 }
|
||||
@@ -160,6 +173,9 @@ export const mockNearbyPets: NearbyPet[] = [
|
||||
distanceText: '960m',
|
||||
avatarKey: 'gradient-avatar-5',
|
||||
photoKey: 'photo-gold',
|
||||
ownerName: '摩卡家',
|
||||
isFollowing: false,
|
||||
isFriend: false,
|
||||
tags: ['安静围观', '亲人'],
|
||||
mood: '正在车里看热闹',
|
||||
pin: { left: 18, top: 70 }
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
export type CloudFunctionName =
|
||||
| 'login'
|
||||
| 'feedList'
|
||||
| 'userPosts'
|
||||
| 'postDetail'
|
||||
| 'postCreate'
|
||||
| 'postLike'
|
||||
| 'postFavorite'
|
||||
| 'commentCreate'
|
||||
| 'draftSave'
|
||||
| 'draftGet'
|
||||
| 'nearbyPets'
|
||||
@@ -32,4 +35,3 @@ export interface CloudResult<T> {
|
||||
data?: T
|
||||
error?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ export interface Post {
|
||||
id: string
|
||||
name: string
|
||||
avatarKey: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
pet: {
|
||||
name: string
|
||||
@@ -86,7 +87,23 @@ export interface Post {
|
||||
favoritedByMe?: boolean
|
||||
}
|
||||
|
||||
export interface PostComment {
|
||||
_id: string
|
||||
author: {
|
||||
id: string
|
||||
name: string
|
||||
avatarKey: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
content: string
|
||||
timeText: string
|
||||
}
|
||||
|
||||
export interface NearbyPet extends Pet {
|
||||
ownerName?: string
|
||||
isMine?: boolean
|
||||
isFollowing?: boolean
|
||||
isFriend?: boolean
|
||||
pin: {
|
||||
left: number
|
||||
top: number
|
||||
|
||||
Reference in New Issue
Block a user