修改bug

This commit is contained in:
2026-06-18 15:38:28 +08:00
parent 0e43ccec72
commit 702b578e1e
57 changed files with 1378 additions and 66 deletions
+3
View File
@@ -5,9 +5,12 @@
"functions": [ "functions": [
{ "name": "login", "runtime": "Nodejs16.13", "handler": "index.main", "timeout": 10, "memorySize": 128 }, { "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": "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": "postCreate", "runtime": "Nodejs16.13", "handler": "index.main", "timeout": 10, "memorySize": 128 },
{ "name": "postLike", "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": "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": "draftSave", "runtime": "Nodejs16.13", "handler": "index.main", "timeout": 10, "memorySize": 128 },
{ "name": "draftGet", "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 }, { "name": "nearbyPets", "runtime": "Nodejs16.13", "handler": "index.main", "timeout": 10, "memorySize": 128 },
+91
View 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
}
}
@@ -0,0 +1,8 @@
{
"name": "commentCreate",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"wx-server-sdk": "latest"
}
}
+21 -1
View File
@@ -2,11 +2,31 @@ const cloud = require('wx-server-sdk')
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
const db = cloud.database() 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 () => { exports.main = async () => {
const { OPENID } = cloud.getWXContext() const { OPENID } = cloud.getWXContext()
if (!OPENID) throw new Error('no openid in wx context') 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 } if (!res.data.length) return { draft: null }
const record = res.data[0] const record = res.data[0]
return { draft: record.draft || null, updatedAt: record.updatedAt } return { draft: record.draft || null, updatedAt: record.updatedAt }
+45 -4
View File
@@ -2,23 +2,64 @@ const cloud = require('wx-server-sdk')
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
const db = cloud.database() 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 => { exports.main = async event => {
const { OPENID } = cloud.getWXContext() const { OPENID } = cloud.getWXContext()
if (!OPENID) throw new Error('no openid in wx context')
const now = Date.now() const now = Date.now()
const data = { const data = {
openid: OPENID, openid: OPENID,
draft: event.draft || {}, draft: event.draft || {},
updatedAt: now 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) { 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 } 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 } return { draftId: result._id, updatedAt: now }
} }
+18 -4
View File
@@ -74,12 +74,25 @@ exports.main = async event => {
favSet = new Set(favRes.data.map(f => f.postId)) 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, _id: post._id,
author: { author: {
id: post.authorId || '', id: post.authorId || '',
name: post.authorSnapshot?.name || '用户', name: (author && author.nickname) || post.authorSnapshot?.name || '用户',
avatarKey: post.authorSnapshot?.avatarKey || 'gradient-avatar-1' avatarKey: (author && author.avatarKey) || post.authorSnapshot?.avatarKey || 'gradient-avatar-1',
avatarUrl: (author && author.avatarUrl) || post.authorSnapshot?.avatarUrl || ''
}, },
pet: post.petSnapshot || { name: '', breed: '' }, pet: post.petSnapshot || { name: '', breed: '' },
body: post.content || '', body: post.content || '',
@@ -98,7 +111,8 @@ exports.main = async event => {
}, },
likedByMe: likedSet.has(post._id), likedByMe: likedSet.has(post._id),
favoritedByMe: favSet.has(post._id) favoritedByMe: favSet.has(post._id)
})) }
})
const last = res.data[res.data.length - 1] const last = res.data[res.data.length - 1]
return { list, nextCursor: last ? String(last.createdAt) : '' } return { list, nextCursor: last ? String(last.createdAt) : '' }
+41
View File
@@ -2,6 +2,18 @@ const cloud = require('wx-server-sdk')
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
const db = cloud.database() 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) { function hashId(id) {
let h = 5381 let h = 5381
@@ -46,19 +58,48 @@ function distanceText(petLoc, userLoc) {
} }
exports.main = async event => { exports.main = async event => {
const { OPENID } = cloud.getWXContext()
const filter = event.filters?.type || 'all' const filter = event.filters?.type || 'all'
const userLoc = event.location || null const userLoc = event.location || null
const query = {} const query = {}
if (filter === 'dog' || filter === 'cat') query.species = filter if (filter === 'dog' || filter === 'cat') query.species = filter
if (filter === 'online') query.online = true 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 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 list = res.data.map(pet => {
const realCoord = pet.location && typeof pet.location.latitude === 'number' ? pet.location : null const realCoord = pet.location && typeof pet.location.latitude === 'number' ? pet.location : null
const coord = realCoord || (userLoc ? syntheticCoord(pet._id, userLoc) : 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 { return {
...pet, ...pet,
ownerName: owner?.nickname || '',
isMine: Boolean(meId && pet.ownerId === meId),
isFollowing,
isFriend: isFollowing && isFollowedBy,
latitude: coord ? coord.latitude : undefined, latitude: coord ? coord.latitude : undefined,
longitude: coord ? coord.longitude : undefined, longitude: coord ? coord.longitude : undefined,
distanceText: distanceText(coord, userLoc) || pet.distanceText || null, distanceText: distanceText(coord, userLoc) || pet.distanceText || null,
+112
View 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
View File
@@ -0,0 +1,8 @@
{
"name": "postDetail",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"wx-server-sdk": "latest"
}
}
+44 -4
View File
@@ -3,22 +3,63 @@ const cloud = require('wx-server-sdk')
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
const db = cloud.database() const db = cloud.database()
const _ = db.command 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 => { exports.main = async event => {
const { OPENID } = cloud.getWXContext() const { OPENID } = cloud.getWXContext()
const { postId, liked } = event const { postId, liked } = event
if (!OPENID) throw new Error('no openid in wx context')
if (!postId) throw new Error('postId is required') 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 let delta = 0
if (liked && !existed.data.length) { 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 delta = 1
} }
if (!liked && existed.data.length) { 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 delta = -1
} }
@@ -29,4 +70,3 @@ exports.main = async event => {
const post = await db.collection('posts').doc(postId).get() const post = await db.collection('posts').doc(postId).get()
return { liked: Boolean(liked), likes: post.data.counts?.likes || 0 } return { liked: Boolean(liked), likes: post.data.counts?.likes || 0 }
} }
+90
View 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
View File
@@ -0,0 +1,8 @@
{
"name": "userPosts",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"wx-server-sdk": "latest"
}
}
Vendored
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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"}
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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()}]);
+1 -1
View File
@@ -1 +1 @@
{"navigationBarTitleText":"消息","navigationStyle":"custom","disableScroll":true,"usingComponents":{"comp":"../../comp"}} {"navigationBarTitleText":"汪友","navigationStyle":"custom","disableScroll":true,"usingComponents":{"comp":"../../comp"}}
+1
View 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
View File
@@ -0,0 +1 @@
{"usingComponents":{"comp":"../../comp"}}
+2
View File
@@ -0,0 +1,2 @@
<import src="../../base.wxml"/>
<template is="taro_tmpl" data="{{root:root}}" />
+1
View 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}
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1
View 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
View File
@@ -0,0 +1 @@
{"usingComponents":{"comp":"../../comp"}}
+2
View File
@@ -0,0 +1,2 @@
<import src="../../base.wxml"/>
<template is="taro_tmpl" data="{{root:root}}" />
+1
View 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}
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+171
View 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` 限制 12000 字,但 `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 视觉设计层面的评审。
+4 -2
View File
@@ -8,7 +8,9 @@ export default defineAppConfig({
'pages/profile-edit/index', 'pages/profile-edit/index',
'pages/pet-edit/index', 'pages/pet-edit/index',
'pages/chat/index', 'pages/chat/index',
'pages/contacts/index' 'pages/contacts/index',
'pages/my-posts/index',
'pages/post-detail/index'
], ],
window: { window: {
navigationStyle: 'custom', navigationStyle: 'custom',
@@ -24,7 +26,7 @@ export default defineAppConfig({
list: [ list: [
{ pagePath: 'pages/plaza/index', text: '广场' }, { pagePath: 'pages/plaza/index', text: '广场' },
{ pagePath: 'pages/nearby/index', text: '附近' }, { pagePath: 'pages/nearby/index', text: '附近' },
{ pagePath: 'pages/messages/index', text: '消息' }, { pagePath: 'pages/messages/index', text: '汪友' },
{ pagePath: 'pages/profile/index', text: '我的' } { pagePath: 'pages/profile/index', text: '我的' }
] ]
}, },
@@ -10,6 +10,6 @@ export interface TabConfig {
export const tabs: TabConfig[] = [ export const tabs: TabConfig[] = [
{ key: 'plaza', label: '广场', path: '/pages/plaza/index' }, { key: 'plaza', label: '广场', path: '/pages/plaza/index' },
{ key: 'nearby', label: '附近', path: '/pages/nearby/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' } { key: 'profile', label: '我的', path: '/pages/profile/index' }
] ]
@@ -1,3 +1,4 @@
import Taro from '@tarojs/taro'
import { Image, Text, View } from '@tarojs/components' import { Image, Text, View } from '@tarojs/components'
import Avatar from '@/components/ui/Avatar' import Avatar from '@/components/ui/Avatar'
import Icon from '@/components/ui/Icon' import Icon from '@/components/ui/Icon'
@@ -7,17 +8,28 @@ import './index.scss'
interface PostCardProps { interface PostCardProps {
post: Post post: Post
onLike: (postId: string) => void 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 }) { function PostActionIcon({ type, onClick }: { type: 'heart' | 'comment'; onClick?: () => void }) {
return <View className={`icon icon--${type}`} onClick={onClick} /> 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 ( return (
<View className='post-card'> <View className='post-card'>
<View className='post-card__head'> <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__meta'>
<View className='post-card__name-row'> <View className='post-card__name-row'>
<Text className='post-card__name'>{post.author.name}</Text> <Text className='post-card__name'>{post.author.name}</Text>
@@ -36,16 +48,22 @@ export function PostCard({ post, onLike }: PostCardProps) {
</View> </View>
</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 ? ( {post.media && post.media.length ? (
<View className={`post-card__photos post-card__photos--${Math.min(post.media.length, 3)}`}> <View className={`post-card__photos post-card__photos--${Math.min(post.media.length, 3)}`}>
{post.media.slice(0, 9).map((src, i) => ( {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>
) : ( ) : (
<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' /> <View className='post-card__silhouette' />
{post.sticker ? <Text className='post-card__sticker'>{post.sticker}</Text> : null} {post.sticker ? <Text className='post-card__sticker'>{post.sticker}</Text> : null}
</View> </View>
@@ -54,8 +72,8 @@ export function PostCard({ post, onLike }: PostCardProps) {
<View className={`post-card__actions ${post.likedByMe ? 'post-card__actions--liked' : ''}`}> <View className={`post-card__actions ${post.likedByMe ? 'post-card__actions--liked' : ''}`}>
<PostActionIcon type='heart' onClick={() => onLike(post._id)} /> <PostActionIcon type='heart' onClick={() => onLike(post._id)} />
<Text onClick={() => onLike(post._id)}>{post.counts.likes}</Text> <Text onClick={() => onLike(post._id)}>{post.counts.likes}</Text>
<PostActionIcon type='comment' /> <PostActionIcon type='comment' onClick={handleComment} />
<Text>{post.counts.comments}</Text> <Text onClick={handleComment}>{post.counts.comments}</Text>
</View> </View>
</View> </View>
) )
@@ -18,7 +18,7 @@ export function ActiveUserRow({ users, onSelect, onDiscover }: ActiveUserRowProp
<View className='active-user-row__avatar-wrap active-user-row__add'> <View className='active-user-row__avatar-wrap active-user-row__add'>
<Icon name='plus' size={22} /> <Icon name='plus' size={22} />
</View> </View>
<Text className='active-user-row__name'></Text> <Text className='active-user-row__name'></Text>
</View> </View>
{users.map(user => ( {users.map(user => (
<View key={user.id} className='active-user-row__item' onClick={() => onSelect?.(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 export default ActiveUserRow
@@ -77,6 +77,7 @@ export function NearbyMap({ pets, center, scale, activeId, onSelect, onRegionCha
onRegionChange={(e: any) => { onRegionChange={(e: any) => {
if (e?.type === 'end') onRegionChange?.(e.causedBy || '') if (e?.type === 'end') onRegionChange?.(e.causedBy || '')
}} }}
onError={() => undefined}
/> />
) )
} }
@@ -114,13 +114,55 @@
.nearby-pet-card__actions { .nearby-pet-card__actions {
display: flex; display: flex;
gap: 6px; flex-direction: column;
gap: 8px;
align-items: center; 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 { .nearby-pet-card__btn {
width: 36px; width: 34px;
height: 36px; height: 34px;
border-radius: 50%; border-radius: 50%;
background: rgba(20, 16, 43, 0.5); background: rgba(20, 16, 43, 0.5);
border: 1px solid rgba(216, 180, 255, 0.08); border: 1px solid rgba(216, 180, 255, 0.08);
@@ -136,4 +178,3 @@
color: #fff; color: #fff;
box-shadow: 0 6px 16px rgba(255, 138, 171, 0.35); box-shadow: 0 6px 16px rgba(255, 138, 171, 0.35);
} }
@@ -7,10 +7,19 @@ interface NearbyPetCardProps {
pet: NearbyPet pet: NearbyPet
active: boolean active: boolean
onSelect: () => void onSelect: () => void
onFriendAction: () => void
onLike: () => 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 ( return (
<View className={`nearby-pet-card ${active ? 'nearby-pet-card--active' : ''}`} onClick={onSelect}> <View className={`nearby-pet-card ${active ? 'nearby-pet-card--active' : ''}`} onClick={onSelect}>
<View className={`nearby-pet-card__photo ${pet.photoKey}`}> <View className={`nearby-pet-card__photo ${pet.photoKey}`}>
@@ -34,8 +43,15 @@ export function NearbyPetCard({ pet, active, onSelect, onLike }: NearbyPetCardPr
</View> </View>
</View> </View>
<View className='nearby-pet-card__actions'> <View className='nearby-pet-card__actions'>
<View className='nearby-pet-card__btn'> <View
<Icon name='message' size={16} /> 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>
<View <View
className='nearby-pet-card__btn nearby-pet-card__btn--like' 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 export default NearbyPetCard
@@ -11,6 +11,7 @@ interface NearbySheetProps {
filter: string filter: string
onFilterChange: (filter: string) => void onFilterChange: (filter: string) => void
onSelect: (id: string) => void onSelect: (id: string) => void
onFriendAction: (pet: NearbyPet) => void
onLike: (id: string) => void onLike: (id: string) => void
} }
@@ -21,7 +22,7 @@ const filters = [
{ key: 'online', label: '在线' } { 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) const sheet = useDragSheet(220)
return ( return (
@@ -62,6 +63,7 @@ export function NearbySheet({ pets, activeId, filter, onFilterChange, onSelect,
pet={pet} pet={pet}
active={pet._id === activeId} active={pet._id === activeId}
onSelect={() => onSelect(pet._id)} onSelect={() => onSelect(pet._id)}
onFriendAction={() => onFriendAction(pet)}
onLike={() => onLike(pet._id)} onLike={() => onLike(pet._id)}
/> />
))} ))}
@@ -71,4 +73,3 @@ export function NearbySheet({ pets, activeId, filter, onFilterChange, onSelect,
} }
export default NearbySheet export default NearbySheet
@@ -3,17 +3,18 @@ import { formatCount } from '@/utils/date'
import type { UserProfile } from '@/types/domain' import type { UserProfile } from '@/types/domain'
import './index.scss' import './index.scss'
type StatKey = 'posts' | 'following' | 'followers'
interface StatsRowProps { interface StatsRowProps {
stats: UserProfile['stats'] stats: UserProfile['stats']
onSelect?: (key: 'posts' | 'following' | 'followers' | 'favorites') => void onSelect?: (key: StatKey) => void
} }
const labels = [ const labels: Array<[StatKey, string]> = [
['posts', '动态'], ['posts', '动态'],
['following', '关注'], ['following', '关注'],
['followers', '粉丝'], ['followers', '粉丝']
['favorites', '收藏'] ]
] as const
export function StatsRow({ stats, onSelect }: StatsRowProps) { export function StatsRow({ stats, onSelect }: StatsRowProps) {
const safeStats = stats || { posts: 0, following: 0, followers: 0, favorites: 0 } const safeStats = stats || { posts: 0, following: 0, followers: 0, favorites: 0 }
+1 -2
View File
@@ -1,6 +1,5 @@
export default definePageConfig({ export default definePageConfig({
navigationBarTitleText: '消息', navigationBarTitleText: '汪友',
navigationStyle: 'custom', navigationStyle: 'custom',
disableScroll: true disableScroll: true
}) })
+4 -4
View File
@@ -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 kw = keyword.trim().toLowerCase()
const visibleConversations = kw const visibleConversations = kw
@@ -70,8 +70,8 @@ export default function MessagesPage() {
return ( return (
<PageShell className='messages-page'> <PageShell className='messages-page'>
<CustomNavBar title='消息' /> <CustomNavBar title='汪友' />
<SearchBar placeholder='搜索聊天或通知' value={keyword} onInput={setKeyword} /> <SearchBar placeholder='搜索汪友或聊天' value={keyword} onInput={setKeyword} />
<SegmentedTabs items={tabs as any} value={tab} onChange={setTab} /> <SegmentedTabs items={tabs as any} value={tab} onChange={setTab} />
{kw ? null : <ActiveUserRow users={activeUsers} onSelect={openChatWith} onDiscover={goContacts} />} {kw ? null : <ActiveUserRow users={activeUsers} onSelect={openChatWith} onDiscover={goContacts} />}
<View className='messages-page__divider'> <View className='messages-page__divider'>
@@ -87,7 +87,7 @@ export default function MessagesPage() {
? '没有匹配的聊天' ? '没有匹配的聊天'
: tab === 'unread' : tab === 'unread'
? '没有未读消息' ? '没有未读消息'
: '还没有聊天,去「发现」找汪友聊聊吧'} : '还没有聊天,去「附近」加汪友吧'}
</Text> </Text>
) : null} ) : null}
</View> </View>
+59
View 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
View 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>
)
}
+47
View File
@@ -10,6 +10,7 @@ import MapControls from '@/features/nearby/components/MapControls'
import NearbySheet from '@/features/nearby/components/NearbySheet' import NearbySheet from '@/features/nearby/components/NearbySheet'
import { useSystemLayout } from '@/hooks/useSystemLayout' import { useSystemLayout } from '@/hooks/useSystemLayout'
import { getNearbyPets, likeNearbyPet } from '@/services/nearby.service' import { getNearbyPets, likeNearbyPet } from '@/services/nearby.service'
import { toggleFollow } from '@/services/social.service'
import { mockNearbyPets } from '@/services/mock' import { mockNearbyPets } from '@/services/mock'
import { useToast } from '@/hooks/useToast' import { useToast } from '@/hooks/useToast'
import type { GeoPoint, NearbyPet } from '@/types/domain' import type { GeoPoint, NearbyPet } from '@/types/domain'
@@ -90,6 +91,51 @@ export default function NearbyPage() {
await likeNearbyPet(id) 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 q = query.trim().toLowerCase()
const visiblePets = q const visiblePets = q
? pets.filter(p => p.name.toLowerCase().includes(q) || (p.breed || '').toLowerCase().includes(q)) ? pets.filter(p => p.name.toLowerCase().includes(q) || (p.breed || '').toLowerCase().includes(q))
@@ -129,6 +175,7 @@ export default function NearbyPage() {
filter={filter} filter={filter}
onFilterChange={setFilter} onFilterChange={setFilter}
onSelect={setActiveId} onSelect={setActiveId}
onFriendAction={handleFriendAction}
onLike={likePet} onLike={likePet}
/> />
<ToastOverlay visible={toast.visible} /> <ToastOverlay visible={toast.visible} />
+20 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react' 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 { ScrollView, Text, View } from '@tarojs/components'
import CustomNavBar from '@/components/layout/CustomNavBar' import CustomNavBar from '@/components/layout/CustomNavBar'
import PageShell from '@/components/layout/PageShell' import PageShell from '@/components/layout/PageShell'
@@ -52,6 +52,7 @@ export default function PlazaPage() {
}) })
const likePost = async (postId: string) => { const likePost = async (postId: string) => {
const previousPosts = posts
const nextPosts = posts.map(post => { const nextPosts = posts.map(post => {
if (post._id !== postId) return post if (post._id !== postId) return post
const likedByMe = !post.likedByMe const likedByMe = !post.likedByMe
@@ -67,7 +68,23 @@ export default function PlazaPage() {
}) })
setPosts(nextPosts) setPosts(nextPosts)
const next = nextPosts.find(post => post._id === postId) 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 ( return (
@@ -87,7 +104,7 @@ export default function PlazaPage() {
<View className='plaza-page__feed'> <View className='plaza-page__feed'>
{posts.map((post, index) => ( {posts.map((post, index) => (
<View key={post._id}> <View key={post._id}>
<PostCard post={post} onLike={likePost} /> <PostCard post={post} onLike={likePost} onOpen={openPost} />
{index === 0 ? <FeedAdCard /> : null} {index === 0 ? <FeedAdCard /> : null}
</View> </View>
))} ))}
+147
View 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
View 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>
)
}
+2 -1
View File
@@ -118,7 +118,8 @@ export default function ProfilePage() {
<StatsRow <StatsRow
stats={user.stats} stats={user.stats}
onSelect={key => { 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' }) else if (key === 'followers') Taro.navigateTo({ url: '/pages/contacts/index?tab=followers' })
}} }}
/> />
+1 -1
View File
@@ -80,7 +80,7 @@ export default function PublishPage() {
} }
} }
try { try {
const res = await Taro.chooseLocation() const res = await Taro.chooseLocation({})
if (res && (res.name || res.address)) { if (res && (res.name || res.address)) {
setLocation(res.name || res.address) setLocation(res.name || res.address)
setLocationGeo({ latitude: res.latitude, longitude: res.longitude }) setLocationGeo({ latitude: res.latitude, longitude: res.longitude })
+28 -1
View File
@@ -1,6 +1,25 @@
import { callCloud, canUseCloud } from './cloud' import { callCloud, canUseCloud } from './cloud'
import { mockPosts } from './mock' 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[]> { export async function getFeedList(channel: FeedChannel, topic?: string): Promise<Post[]> {
// Only fall back to sample posts in non-cloud previews (e.g. H5 dev). // 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 }) 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 })
}
+16
View File
@@ -103,6 +103,10 @@ export const mockPosts: Post[] = [
export const mockNearbyPets: NearbyPet[] = [ export const mockNearbyPets: NearbyPet[] = [
{ {
...mockPets[0], ...mockPets[0],
ownerName: '阿布的家长',
isMine: true,
isFollowing: false,
isFriend: false,
distanceText: '260m', distanceText: '260m',
online: true, online: true,
pin: { left: 24, top: 28 } pin: { left: 24, top: 28 }
@@ -117,6 +121,9 @@ export const mockNearbyPets: NearbyPet[] = [
distanceText: '420m', distanceText: '420m',
avatarKey: 'gradient-avatar-3', avatarKey: 'gradient-avatar-3',
photoKey: 'photo-green', photoKey: 'photo-green',
ownerName: 'Lucky 之家',
isFollowing: true,
isFriend: true,
tags: ['精力旺盛', '会接飞盘'], tags: ['精力旺盛', '会接飞盘'],
mood: '找玩伴', mood: '找玩伴',
online: true, online: true,
@@ -132,6 +139,9 @@ export const mockNearbyPets: NearbyPet[] = [
distanceText: '530m', distanceText: '530m',
avatarKey: 'gradient-avatar-6', avatarKey: 'gradient-avatar-6',
photoKey: 'photo-pink', photoKey: 'photo-pink',
ownerName: '豆包妈',
isFollowing: false,
isFriend: false,
tags: ['慢热', '爱追球'], tags: ['慢热', '爱追球'],
mood: '想闻新朋友', mood: '想闻新朋友',
pin: { left: 42, top: 56 } pin: { left: 42, top: 56 }
@@ -146,6 +156,9 @@ export const mockNearbyPets: NearbyPet[] = [
distanceText: '780m', distanceText: '780m',
avatarKey: 'gradient-avatar-2', avatarKey: 'gradient-avatar-2',
photoKey: 'photo-blue', photoKey: 'photo-blue',
ownerName: '白桃家',
isFollowing: true,
isFriend: false,
tags: ['温柔', '适合新手'], tags: ['温柔', '适合新手'],
mood: '公园散步中', mood: '公园散步中',
pin: { left: 76, top: 62 } pin: { left: 76, top: 62 }
@@ -160,6 +173,9 @@ export const mockNearbyPets: NearbyPet[] = [
distanceText: '960m', distanceText: '960m',
avatarKey: 'gradient-avatar-5', avatarKey: 'gradient-avatar-5',
photoKey: 'photo-gold', photoKey: 'photo-gold',
ownerName: '摩卡家',
isFollowing: false,
isFriend: false,
tags: ['安静围观', '亲人'], tags: ['安静围观', '亲人'],
mood: '正在车里看热闹', mood: '正在车里看热闹',
pin: { left: 18, top: 70 } pin: { left: 18, top: 70 }
+3 -1
View File
@@ -1,9 +1,12 @@
export type CloudFunctionName = export type CloudFunctionName =
| 'login' | 'login'
| 'feedList' | 'feedList'
| 'userPosts'
| 'postDetail'
| 'postCreate' | 'postCreate'
| 'postLike' | 'postLike'
| 'postFavorite' | 'postFavorite'
| 'commentCreate'
| 'draftSave' | 'draftSave'
| 'draftGet' | 'draftGet'
| 'nearbyPets' | 'nearbyPets'
@@ -32,4 +35,3 @@ export interface CloudResult<T> {
data?: T data?: T
error?: string error?: string
} }
+17
View File
@@ -64,6 +64,7 @@ export interface Post {
id: string id: string
name: string name: string
avatarKey: string avatarKey: string
avatarUrl?: string
} }
pet: { pet: {
name: string name: string
@@ -86,7 +87,23 @@ export interface Post {
favoritedByMe?: boolean 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 { export interface NearbyPet extends Pet {
ownerName?: string
isMine?: boolean
isFollowing?: boolean
isFriend?: boolean
pin: { pin: {
left: number left: number
top: number top: number