修复动态发布与互动问题
This commit is contained in:
@@ -8,8 +8,9 @@
|
||||
{ "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": "postUpdate", "runtime": "Nodejs16.13", "handler": "index.main", "timeout": 10, "memorySize": 128 },
|
||||
{ "name": "postDelete", "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 },
|
||||
@@ -17,7 +18,7 @@
|
||||
{ "name": "locationUpdate", "runtime": "Nodejs16.13", "handler": "index.main", "timeout": 10, "memorySize": 128 },
|
||||
{ "name": "matchLike", "runtime": "Nodejs16.13", "handler": "index.main", "timeout": 10, "memorySize": 128 },
|
||||
{ "name": "messageList", "runtime": "Nodejs16.13", "handler": "index.main", "timeout": 10, "memorySize": 128 },
|
||||
{ "name": "messageSend", "runtime": "Nodejs16.13", "handler": "index.main", "timeout": 10, "memorySize": 128 },
|
||||
{ "name": "messageSend", "runtime": "Nodejs16.13", "handler": "index.main", "timeout": 10, "memorySize": 128, "envVariables": { "WQ_MESSAGE_TEMPLATE_ID": "xirZ0SkI9s_CCYAioAzz3U-qBg4ZEmR1avQTrM-0DrE" } },
|
||||
{ "name": "messageThread", "runtime": "Nodejs16.13", "handler": "index.main", "timeout": 10, "memorySize": 128 },
|
||||
{ "name": "conversationRead", "runtime": "Nodejs16.13", "handler": "index.main", "timeout": 10, "memorySize": 128 },
|
||||
{ "name": "followToggle", "runtime": "Nodejs16.13", "handler": "index.main", "timeout": 10, "memorySize": 128 },
|
||||
|
||||
@@ -28,9 +28,104 @@ function formatTimeText(ts) {
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日`
|
||||
}
|
||||
|
||||
function normalizeKeyword(keyword) {
|
||||
return String(keyword || '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function postMatchesKeyword(post, keyword) {
|
||||
if (!keyword) return true
|
||||
|
||||
return [
|
||||
post.content,
|
||||
post.locationText,
|
||||
post.authorSnapshot && post.authorSnapshot.name,
|
||||
post.petSnapshot && post.petSnapshot.name,
|
||||
post.petSnapshot && post.petSnapshot.breed,
|
||||
...(Array.isArray(post.topics) ? post.topics : [])
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(keyword)
|
||||
}
|
||||
|
||||
function isCloudFileId(value) {
|
||||
return typeof value === 'string' && value.startsWith('cloud://')
|
||||
}
|
||||
|
||||
function extractMediaUrl(media) {
|
||||
if (!media) return ''
|
||||
if (typeof media === 'string') return media
|
||||
return media.url || media.fileId || media.fileID || ''
|
||||
}
|
||||
|
||||
async function resolveTempUrlMap(urls) {
|
||||
const fileIds = [...new Set(urls.filter(isCloudFileId))]
|
||||
if (!fileIds.length) return new Map()
|
||||
|
||||
try {
|
||||
const result = await cloud.getTempFileURL({ fileList: fileIds })
|
||||
const fileList = Array.isArray(result.fileList) ? result.fileList : []
|
||||
return new Map(fileList.map(file => [
|
||||
file.fileID || file.fileId,
|
||||
file.tempFileURL || file.tempFileUrl || ''
|
||||
]).filter(([fileId, tempUrl]) => fileId && tempUrl))
|
||||
} catch (e) {
|
||||
return new Map()
|
||||
}
|
||||
}
|
||||
|
||||
function readableUrl(url, tempUrlMap) {
|
||||
if (!url) return ''
|
||||
if (!isCloudFileId(url)) return url
|
||||
return tempUrlMap.get(url) || ''
|
||||
}
|
||||
|
||||
async function queryPosts(query, limit, cursor) {
|
||||
const nextQuery = { ...query }
|
||||
if (cursor) nextQuery.createdAt = _.lt(Number(cursor))
|
||||
|
||||
return db.collection('posts')
|
||||
.where(nextQuery)
|
||||
.orderBy('createdAt', 'desc')
|
||||
.limit(limit)
|
||||
.get()
|
||||
}
|
||||
|
||||
async function loadTopicOptions(query) {
|
||||
try {
|
||||
const topicQuery = { ...query }
|
||||
delete topicQuery.topics
|
||||
delete topicQuery.createdAt
|
||||
|
||||
const res = await db.collection('posts')
|
||||
.where(topicQuery)
|
||||
.orderBy('createdAt', 'desc')
|
||||
.limit(100)
|
||||
.get()
|
||||
|
||||
const counts = new Map()
|
||||
res.data.forEach(post => {
|
||||
const postTopics = post.topics || []
|
||||
postTopics.forEach(topic => {
|
||||
if (!topic) return
|
||||
counts.set(topic, (counts.get(topic) || 0) + 1)
|
||||
})
|
||||
})
|
||||
|
||||
return [...counts.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 12)
|
||||
.map(([topic]) => topic)
|
||||
} catch (e) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
exports.main = async event => {
|
||||
const { OPENID } = cloud.getWXContext()
|
||||
const pageSize = Math.min(event.pageSize || 20, 50)
|
||||
const keyword = normalizeKeyword(event.keyword)
|
||||
const query = { visibility: 'public' }
|
||||
|
||||
if (event.topic) query.topics = event.topic
|
||||
@@ -48,35 +143,45 @@ exports.main = async event => {
|
||||
query.visibility = _.in(['public', 'friends'])
|
||||
}
|
||||
|
||||
if (event.cursor) {
|
||||
query.createdAt = _.lt(Number(event.cursor))
|
||||
const topics = event.cursor ? [] : await loadTopicOptions(query)
|
||||
const fetchLimit = keyword ? 50 : pageSize
|
||||
const matchedPosts = []
|
||||
let scanCursor = event.cursor || ''
|
||||
let nextCursor = ''
|
||||
|
||||
while (matchedPosts.length < pageSize) {
|
||||
const res = await queryPosts(query, fetchLimit, scanCursor)
|
||||
if (!res.data.length) break
|
||||
|
||||
for (const post of res.data) {
|
||||
scanCursor = String(post.createdAt || '')
|
||||
if (!postMatchesKeyword(post, keyword)) continue
|
||||
|
||||
matchedPosts.push(post)
|
||||
if (matchedPosts.length >= pageSize) {
|
||||
nextCursor = scanCursor
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedPosts.length >= pageSize || res.data.length < fetchLimit) break
|
||||
scanCursor = String(res.data[res.data.length - 1].createdAt || '')
|
||||
}
|
||||
|
||||
const res = await db.collection('posts')
|
||||
.where(query)
|
||||
.orderBy('createdAt', 'desc')
|
||||
.limit(pageSize)
|
||||
.get()
|
||||
if (!matchedPosts.length) return { list: [], nextCursor: '', topics }
|
||||
|
||||
if (!res.data.length) return { list: [], nextCursor: '' }
|
||||
const postIds = matchedPosts.map(p => p._id)
|
||||
|
||||
const postIds = res.data.map(p => p._id)
|
||||
|
||||
// Batch-check liked/favorited status — skip if no valid OPENID (non-WeChat context)
|
||||
// Batch-check liked status, skip if no valid OPENID (non-WeChat context).
|
||||
let likedSet = new Set()
|
||||
let favSet = new Set()
|
||||
if (OPENID) {
|
||||
const [likedRes, favRes] = await Promise.all([
|
||||
safeGet('postLikes', { openid: OPENID, postId: _.in(postIds) }),
|
||||
safeGet('postFavorites', { openid: OPENID, postId: _.in(postIds) })
|
||||
])
|
||||
const likedRes = await safeGet('postLikes', { openid: OPENID, postId: _.in(postIds) })
|
||||
likedSet = new Set(likedRes.data.map(l => l.postId))
|
||||
favSet = new Set(favRes.data.map(f => f.postId))
|
||||
}
|
||||
|
||||
// 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 authorIds = [...new Set(matchedPosts.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)
|
||||
@@ -84,36 +189,70 @@ exports.main = async event => {
|
||||
usersRes.data.forEach(u => authorMap.set(u._id, u))
|
||||
}
|
||||
|
||||
const list = res.data.map(post => {
|
||||
const author = authorMap.get(post.authorId)
|
||||
const authorOpenids = [...new Set(matchedPosts.map(p => p.authorOpenid).filter(Boolean))]
|
||||
const authorOpenidMap = new Map()
|
||||
for (let i = 0; i < authorOpenids.length; i += 100) {
|
||||
const chunk = authorOpenids.slice(i, i + 100)
|
||||
const usersRes = await safeGet('users', { openid: _.in(chunk) }, 100)
|
||||
usersRes.data.forEach(u => authorOpenidMap.set(u.openid, u))
|
||||
}
|
||||
const findAuthor = post => authorMap.get(post.authorId) || authorOpenidMap.get(post.authorOpenid)
|
||||
|
||||
// Real presence per author: a recent, visible location (same signal as 附近).
|
||||
const ONLINE_WINDOW_MS = 5 * 60 * 1000
|
||||
const onlineNow = Date.now()
|
||||
const onlineSet = new Set()
|
||||
for (let i = 0; i < authorOpenids.length; i += 100) {
|
||||
const chunk = authorOpenids.slice(i, i + 100)
|
||||
const locRes = await safeGet('locations', { openid: _.in(chunk) }, 100)
|
||||
locRes.data.forEach(loc => {
|
||||
if (loc.visible && loc.updatedAt && onlineNow - loc.updatedAt < ONLINE_WINDOW_MS) onlineSet.add(loc.openid)
|
||||
})
|
||||
}
|
||||
|
||||
const assetUrls = []
|
||||
matchedPosts.forEach(post => {
|
||||
const author = findAuthor(post)
|
||||
assetUrls.push((author && author.avatarUrl) || post.authorSnapshot?.avatarUrl || '')
|
||||
if (Array.isArray(post.media)) {
|
||||
post.media.forEach(media => assetUrls.push(extractMediaUrl(media)))
|
||||
}
|
||||
})
|
||||
const tempUrlMap = await resolveTempUrlMap(assetUrls)
|
||||
|
||||
const list = matchedPosts.map(post => {
|
||||
const author = findAuthor(post)
|
||||
const media = Array.isArray(post.media)
|
||||
? post.media
|
||||
.map(extractMediaUrl)
|
||||
.map(url => readableUrl(url, tempUrlMap))
|
||||
.filter(Boolean)
|
||||
: []
|
||||
|
||||
return {
|
||||
_id: post._id,
|
||||
author: {
|
||||
id: post.authorId || '',
|
||||
id: (author && 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 || ''
|
||||
avatarUrl: readableUrl((author && author.avatarUrl) || post.authorSnapshot?.avatarUrl || '', tempUrlMap),
|
||||
online: onlineSet.has(post.authorOpenid)
|
||||
},
|
||||
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)
|
||||
: [],
|
||||
media,
|
||||
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
|
||||
comments: post.counts?.comments || 0
|
||||
},
|
||||
likedByMe: likedSet.has(post._id),
|
||||
favoritedByMe: favSet.has(post._id)
|
||||
likedByMe: likedSet.has(post._id)
|
||||
}
|
||||
})
|
||||
|
||||
const last = res.data[res.data.length - 1]
|
||||
return { list, nextCursor: last ? String(last.createdAt) : '' }
|
||||
return { list, nextCursor, topics }
|
||||
}
|
||||
|
||||
@@ -3,12 +3,25 @@ const cloud = require('wx-server-sdk')
|
||||
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
|
||||
const db = cloud.database()
|
||||
|
||||
// Reading a collection that was never created throws in WeChat 云开发, and this
|
||||
// function reads `follows` before it ever adds to it — so on a fresh database the
|
||||
// first follow would always fail. Create it up-front (no-op if it already exists).
|
||||
async function ensureCollection(name) {
|
||||
try {
|
||||
await db.createCollection(name)
|
||||
} catch (e) {
|
||||
// Already-exists error is expected on every call after the first.
|
||||
}
|
||||
}
|
||||
|
||||
exports.main = async event => {
|
||||
const { OPENID } = cloud.getWXContext()
|
||||
if (!OPENID) throw new Error('no openid in wx context')
|
||||
const { targetUserId, follow } = event
|
||||
if (!targetUserId) throw new Error('targetUserId is required')
|
||||
|
||||
await ensureCollection('follows')
|
||||
|
||||
const meRes = await db.collection('users').where({ openid: OPENID }).limit(1).get()
|
||||
if (!meRes.data.length) throw new Error('user not found')
|
||||
const meId = meRes.data[0]._id
|
||||
|
||||
@@ -5,17 +5,36 @@ const db = cloud.database()
|
||||
|
||||
exports.main = async event => {
|
||||
const { OPENID } = cloud.getWXContext()
|
||||
|
||||
// Reading a non-existent collection throws in WeChat 云开发, and this function
|
||||
// starts with a read — so the collection would never get created by the later
|
||||
// add(). Create it up-front (no-op if it already exists) to break that deadlock.
|
||||
try {
|
||||
await db.createCollection('locations')
|
||||
} catch (e) {
|
||||
// -501001 / "already exists" is expected on every call after the first.
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const loc = event.location || null
|
||||
const visible = Boolean(event.visible)
|
||||
const hasCoord = loc && typeof loc.latitude === 'number' && typeof loc.longitude === 'number'
|
||||
|
||||
const data = {
|
||||
openid: OPENID,
|
||||
location: event.location || null,
|
||||
visible: Boolean(event.visible),
|
||||
location: hasCoord ? { latitude: loc.latitude, longitude: loc.longitude } : null,
|
||||
// `nearbyPets` currently filters by distance in memory off `location`, so the
|
||||
// `visible` flag below is what gates discovery. `geo` is kept (Point form) only
|
||||
// to enable a future geoNear migration if the user base outgrows the in-memory
|
||||
// scan — it needs a manually-created geo index to be queried.
|
||||
geo: hasCoord && visible ? db.Geo.Point(loc.longitude, loc.latitude) : null,
|
||||
visible,
|
||||
updatedAt: now
|
||||
}
|
||||
|
||||
const existed = await db.collection('locations').where({ openid: OPENID }).limit(1).get()
|
||||
if (existed.data.length) await db.collection('locations').doc(existed.data[0]._id).update({ data })
|
||||
else await db.collection('locations').add({ data })
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
|
||||
@@ -9,11 +9,16 @@ function makeHandle(openid) {
|
||||
return `@user_${suffix}`
|
||||
}
|
||||
|
||||
// Default avatar pool to randomise new user avatars
|
||||
const AVATAR_KEYS = [
|
||||
'gradient-avatar-1', 'gradient-avatar-2', 'gradient-avatar-3',
|
||||
'gradient-avatar-4', 'gradient-avatar-5', 'gradient-avatar-6'
|
||||
]
|
||||
// Deterministic gradient swatch (.gradient-avatar-1..6) from the user's openid, so
|
||||
// a user who hasn't set a real avatar always gets the same stable colour.
|
||||
function gradientAvatarKey(seed) {
|
||||
let h = 5381
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
h = ((h << 5) + h) ^ seed.charCodeAt(i)
|
||||
h >>>= 0
|
||||
}
|
||||
return `gradient-avatar-${(h % 6) + 1}`
|
||||
}
|
||||
|
||||
exports.main = async event => {
|
||||
const { OPENID } = cloud.getWXContext()
|
||||
@@ -35,7 +40,7 @@ exports.main = async event => {
|
||||
}
|
||||
|
||||
// New user — create with sensible defaults
|
||||
const avatarKey = AVATAR_KEYS[Math.floor(Math.random() * AVATAR_KEYS.length)]
|
||||
const avatarKey = gradientAvatarKey(OPENID)
|
||||
const user = {
|
||||
openid: OPENID,
|
||||
nickname: event.userInfo?.nickname || '宠物达人',
|
||||
@@ -48,7 +53,7 @@ exports.main = async event => {
|
||||
level: 1,
|
||||
bio: '',
|
||||
verified: false,
|
||||
stats: { posts: 0, following: 0, followers: 0, favorites: 0 },
|
||||
stats: { posts: 0, following: 0, followers: 0 },
|
||||
preferences: { notifications: true, nearbyVisible: true },
|
||||
createdAt: now,
|
||||
lastLoginAt: now
|
||||
|
||||
@@ -4,11 +4,25 @@ cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
|
||||
const db = cloud.database()
|
||||
const _ = db.command
|
||||
|
||||
// Reading a collection that was never created throws in WeChat 云开发, and this
|
||||
// function reads `matches` before it ever adds to it — so the collection would
|
||||
// never get created and every "like" would fail. Create it up-front (no-op if it
|
||||
// already exists) to break that deadlock.
|
||||
async function ensureCollection(name) {
|
||||
try {
|
||||
await db.createCollection(name)
|
||||
} catch (e) {
|
||||
// Already-exists error is expected on every call after the first.
|
||||
}
|
||||
}
|
||||
|
||||
exports.main = async event => {
|
||||
const { OPENID } = cloud.getWXContext()
|
||||
const { petId } = event
|
||||
if (!petId) throw new Error('petId is required')
|
||||
|
||||
await ensureCollection('matches')
|
||||
|
||||
// Get current user and their pets
|
||||
const userRes = await db.collection('users').where({ openid: OPENID }).limit(1).get()
|
||||
if (!userRes.data.length) throw new Error('user not found')
|
||||
|
||||
@@ -40,6 +40,17 @@ async function fetchUsers(ids) {
|
||||
return out
|
||||
}
|
||||
|
||||
async function fetchUsersByOpenids(openids) {
|
||||
if (!openids.length) return []
|
||||
const out = []
|
||||
for (let i = 0; i < openids.length; i += 100) {
|
||||
const chunk = openids.slice(i, i + 100)
|
||||
const res = await db.collection('users').where({ openid: _.in(chunk) }).limit(100).get()
|
||||
out.push(...res.data)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
exports.main = async event => {
|
||||
const { OPENID } = cloud.getWXContext()
|
||||
if (!OPENID) throw new Error('no openid in wx context')
|
||||
@@ -70,22 +81,46 @@ exports.main = async event => {
|
||||
convRes = { data: [] }
|
||||
}
|
||||
|
||||
const peerOpenids = [
|
||||
...new Set(
|
||||
convRes.data
|
||||
.filter(c => (c.type || 'single') === 'single')
|
||||
.map(c => (c.memberOpenids || []).find(memberOpenid => memberOpenid !== OPENID))
|
||||
.filter(Boolean)
|
||||
)
|
||||
]
|
||||
const peerUsers = await fetchUsersByOpenids(peerOpenids)
|
||||
const peerUserByOpenid = new Map(peerUsers.map(user => [user.openid, user]))
|
||||
|
||||
const conversations = convRes.data.map(c => {
|
||||
const unreadOpenids = c.unreadOpenids || []
|
||||
const unreadCounts = c.unreadCounts || {}
|
||||
const unreadCount = unreadCounts[OPENID] || (unreadOpenids.includes(OPENID) ? 1 : 0)
|
||||
const type = c.type || 'single'
|
||||
const peerOpenid = type === 'single'
|
||||
? (c.memberOpenids || []).find(memberOpenid => memberOpenid !== OPENID)
|
||||
: ''
|
||||
const peer = peerOpenid ? peerUserByOpenid.get(peerOpenid) : null
|
||||
|
||||
return {
|
||||
_id: c._id,
|
||||
type: c.type || 'single',
|
||||
title: c.title || '未知用户',
|
||||
type,
|
||||
title: type === 'single'
|
||||
? peer?.nickname || c.title || '未知用户'
|
||||
: c.title || '系统消息',
|
||||
petName: c.petName || undefined,
|
||||
avatarKey: c.avatarKey || 'gradient-avatar-1',
|
||||
avatarKey: type === 'single'
|
||||
? peer?.avatarKey || c.avatarKey || 'gradient-avatar-1'
|
||||
: c.avatarKey || 'gradient-avatar-1',
|
||||
avatarUrl: type === 'single'
|
||||
? peer?.avatarUrl || c.avatarUrl || ''
|
||||
: c.avatarUrl || '',
|
||||
preview: c.lastMessage?.content || c.preview || '',
|
||||
timeText: formatTimeText(c.updatedAt || c.createdAt),
|
||||
unreadCount,
|
||||
pinned: Boolean(c.pinned),
|
||||
muted: Boolean(c.muted),
|
||||
online: Boolean(c.online)
|
||||
online: type === 'single' ? Boolean(peer?.online || c.online) : Boolean(c.online)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -3,14 +3,148 @@ const cloud = require('wx-server-sdk')
|
||||
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
|
||||
const db = cloud.database()
|
||||
const _ = db.command
|
||||
const SERVER_MESSAGE_LIMIT = 500
|
||||
const CONVERSATIONS_COLLECTION = 'conversations'
|
||||
const MESSAGES_COLLECTION = 'messages'
|
||||
const CHAT_MESSAGE_TEMPLATE_ID = process.env.WQ_MESSAGE_TEMPLATE_ID || process.env.TARO_APP_MESSAGE_TEMPLATE_ID || ''
|
||||
|
||||
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 ensureCollection(name) {
|
||||
if (typeof db.createCollection !== 'function') {
|
||||
throw new Error(`${name} collection does not exist`)
|
||||
}
|
||||
|
||||
try {
|
||||
await db.createCollection(name)
|
||||
} catch (error) {
|
||||
if (!isCollectionAlreadyExists(error)) throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function getExistingConversation(openid, toOpenid) {
|
||||
try {
|
||||
return await db.collection(CONVERSATIONS_COLLECTION)
|
||||
.where({ type: 'single', memberOpenids: _.all([openid, toOpenid]) })
|
||||
.limit(1)
|
||||
.get()
|
||||
} catch (error) {
|
||||
if (!isCollectionMissing(error)) throw error
|
||||
await ensureCollection(CONVERSATIONS_COLLECTION)
|
||||
return { data: [] }
|
||||
}
|
||||
}
|
||||
|
||||
async function getConversationById(conversationId) {
|
||||
try {
|
||||
return await db.collection(CONVERSATIONS_COLLECTION).doc(conversationId).get()
|
||||
} catch (error) {
|
||||
if (!isCollectionMissing(error)) throw error
|
||||
return { data: null }
|
||||
}
|
||||
}
|
||||
|
||||
async function addConversation(data) {
|
||||
try {
|
||||
return await db.collection(CONVERSATIONS_COLLECTION).add({ data })
|
||||
} catch (error) {
|
||||
if (!isCollectionMissing(error)) throw error
|
||||
await ensureCollection(CONVERSATIONS_COLLECTION)
|
||||
return db.collection(CONVERSATIONS_COLLECTION).add({ data })
|
||||
}
|
||||
}
|
||||
|
||||
async function addMessage(data) {
|
||||
try {
|
||||
return await db.collection(MESSAGES_COLLECTION).add({ data })
|
||||
} catch (error) {
|
||||
if (!isCollectionMissing(error)) throw error
|
||||
await ensureCollection(MESSAGES_COLLECTION)
|
||||
return db.collection(MESSAGES_COLLECTION).add({ data })
|
||||
}
|
||||
}
|
||||
|
||||
async function trimConversationMessages(conversationId) {
|
||||
for (;;) {
|
||||
const oldMessages = await db.collection(MESSAGES_COLLECTION)
|
||||
.where({ conversationId })
|
||||
.orderBy('createdAt', 'desc')
|
||||
.skip(SERVER_MESSAGE_LIMIT)
|
||||
.limit(100)
|
||||
.get()
|
||||
.catch(() => ({ data: [] }))
|
||||
|
||||
if (!oldMessages.data.length) return
|
||||
|
||||
await Promise.all(
|
||||
oldMessages.data.map(item =>
|
||||
db.collection(MESSAGES_COLLECTION).doc(item._id).remove().catch(() => null)
|
||||
)
|
||||
)
|
||||
|
||||
if (oldMessages.data.length < 100) return
|
||||
}
|
||||
}
|
||||
|
||||
function pad(value) {
|
||||
return String(value).padStart(2, '0')
|
||||
}
|
||||
|
||||
function formatDateTime(ts) {
|
||||
const d = new Date(ts)
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
function templateValue(value, max = 20) {
|
||||
const text = String(value || '').trim()
|
||||
return text.length > max ? `${text.slice(0, max - 1)}…` : text
|
||||
}
|
||||
|
||||
async function sendChatNotifications({ recipients, conversationId, senderName, content, type, createdAt }) {
|
||||
if (!CHAT_MESSAGE_TEMPLATE_ID || !recipients.length || !cloud.openapi?.subscribeMessage?.send) return
|
||||
|
||||
const preview = type === 'image' ? '[图片]' : content
|
||||
await Promise.all(
|
||||
recipients.map(touser =>
|
||||
cloud.openapi.subscribeMessage.send({
|
||||
touser,
|
||||
templateId: CHAT_MESSAGE_TEMPLATE_ID,
|
||||
page: `pages/chat/index?conversationId=${conversationId}`,
|
||||
data: {
|
||||
thing31: { value: templateValue(senderName || '汪友') },
|
||||
thing2: { value: templateValue(preview || '你收到一条新消息') },
|
||||
time3: { value: formatDateTime(createdAt) }
|
||||
}
|
||||
}).catch(error => {
|
||||
console.log('[messageSend] subscribe message skipped', error?.errMsg || error?.message || error)
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
exports.main = async event => {
|
||||
const { OPENID } = cloud.getWXContext()
|
||||
const content = String(event.content || '').trim()
|
||||
if (!content) throw new Error('content is required')
|
||||
if (!OPENID) throw new Error('no openid in wx context')
|
||||
|
||||
const now = Date.now()
|
||||
let convId = event.conversationId
|
||||
let conversation = null
|
||||
|
||||
// Create a 1:1 conversation if not provided
|
||||
if (!convId) {
|
||||
@@ -19,53 +153,64 @@ exports.main = async event => {
|
||||
if (!toOpenid && event.toUserId) {
|
||||
const toUserById = await db.collection('users').doc(event.toUserId).get().catch(() => null)
|
||||
if (toUserById && toUserById.data) toOpenid = toUserById.data.openid
|
||||
if (!toOpenid) {
|
||||
const toUserByOpenid = await db.collection('users')
|
||||
.where({ openid: event.toUserId })
|
||||
.limit(1)
|
||||
.get()
|
||||
.catch(() => ({ data: [] }))
|
||||
if (toUserByOpenid.data[0]) toOpenid = toUserByOpenid.data[0].openid
|
||||
}
|
||||
}
|
||||
if (!toOpenid) throw new Error('conversationId or toOpenid/toUserId is required')
|
||||
if (!toOpenid) throw new Error('target user not found')
|
||||
|
||||
// Look for existing conversation between these two users
|
||||
const existing = await db.collection('conversations')
|
||||
.where({ type: 'single', memberOpenids: _.all([OPENID, toOpenid]) })
|
||||
.limit(1)
|
||||
.get()
|
||||
const existing = await getExistingConversation(OPENID, toOpenid)
|
||||
|
||||
if (existing.data.length) {
|
||||
convId = existing.data[0]._id
|
||||
conversation = existing.data[0]
|
||||
convId = conversation._id
|
||||
} else {
|
||||
const toUserRes = await db.collection('users').where({ openid: toOpenid }).limit(1).get()
|
||||
const toUser = toUserRes.data[0] || { nickname: '用户', avatarKey: 'gradient-avatar-1' }
|
||||
const newConv = await db.collection('conversations').add({
|
||||
data: {
|
||||
type: 'single',
|
||||
memberOpenids: [OPENID, toOpenid],
|
||||
unreadOpenids: [toOpenid],
|
||||
unreadCounts: { [toOpenid]: 1 },
|
||||
title: toUser.nickname,
|
||||
avatarKey: toUser.avatarKey,
|
||||
lastMessage: { content: content.slice(0, 50), createdAt: now },
|
||||
pinned: false,
|
||||
muted: false,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
})
|
||||
conversation = {
|
||||
type: 'single',
|
||||
memberOpenids: [OPENID, toOpenid],
|
||||
unreadOpenids: [],
|
||||
unreadCounts: {},
|
||||
title: toUser.nickname,
|
||||
avatarKey: toUser.avatarKey,
|
||||
lastMessage: { content: content.slice(0, 50), createdAt: now },
|
||||
pinned: false,
|
||||
muted: false,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
const newConv = await addConversation(conversation)
|
||||
convId = newConv._id
|
||||
}
|
||||
}
|
||||
|
||||
if (!conversation) {
|
||||
const convRes = await getConversationById(convId)
|
||||
conversation = convRes.data
|
||||
}
|
||||
if (!conversation) throw new Error('conversation not found')
|
||||
|
||||
const members = conversation.memberOpenids || []
|
||||
if (!members.includes(OPENID)) throw new Error('permission denied')
|
||||
if ((conversation.type || 'single') !== 'single') throw new Error('unsupported conversation type')
|
||||
|
||||
// Add message to messages collection
|
||||
const msgResult = await db.collection('messages').add({
|
||||
data: {
|
||||
conversationId: convId,
|
||||
fromOpenid: OPENID,
|
||||
content: content.slice(0, 1000),
|
||||
type: event.msgType || 'text',
|
||||
createdAt: now
|
||||
}
|
||||
const msgResult = await addMessage({
|
||||
conversationId: convId,
|
||||
fromOpenid: OPENID,
|
||||
content: content.slice(0, 1000),
|
||||
type: event.msgType || 'text',
|
||||
createdAt: now
|
||||
})
|
||||
|
||||
// Update conversation: last message + unread counts for all recipients
|
||||
const convRes = await db.collection('conversations').doc(convId).get()
|
||||
const members = convRes.data.memberOpenids || []
|
||||
const recipients = members.filter(id => id !== OPENID)
|
||||
|
||||
const updateData = {
|
||||
@@ -78,11 +223,21 @@ exports.main = async event => {
|
||||
})
|
||||
|
||||
// Rebuild unreadOpenids to include all recipients (remove duplicates)
|
||||
const existingUnread = convRes.data.unreadOpenids || []
|
||||
const existingUnread = conversation.unreadOpenids || []
|
||||
const newUnread = [...new Set([...existingUnread, ...recipients])]
|
||||
updateData.unreadOpenids = newUnread
|
||||
|
||||
await db.collection('conversations').doc(convId).update({ data: updateData })
|
||||
await db.collection(CONVERSATIONS_COLLECTION).doc(convId).update({ data: updateData })
|
||||
await trimConversationMessages(convId)
|
||||
const senderRes = await db.collection('users').where({ openid: OPENID }).limit(1).get().catch(() => ({ data: [] }))
|
||||
await sendChatNotifications({
|
||||
recipients,
|
||||
conversationId: convId,
|
||||
senderName: senderRes.data[0]?.nickname || conversation.title || '汪友',
|
||||
content,
|
||||
type: event.msgType || 'text',
|
||||
createdAt: now
|
||||
})
|
||||
|
||||
return { messageId: msgResult._id, conversationId: convId }
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ const cloud = require('wx-server-sdk')
|
||||
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
|
||||
const db = cloud.database()
|
||||
const _ = db.command
|
||||
const SERVER_MESSAGE_LIMIT = 50
|
||||
|
||||
function timeText(ts) {
|
||||
if (!ts) return ''
|
||||
@@ -18,12 +19,30 @@ async function userToPeer(u) {
|
||||
return { id: u._id, name: u.nickname || '用户', avatarKey: u.avatarKey || 'gradient-avatar-1', avatarUrl: u.avatarUrl || '' }
|
||||
}
|
||||
|
||||
function normalizeLimit(value) {
|
||||
const limit = Number(value || SERVER_MESSAGE_LIMIT)
|
||||
if (!Number.isFinite(limit)) return SERVER_MESSAGE_LIMIT
|
||||
return Math.max(1, Math.min(Math.floor(limit), SERVER_MESSAGE_LIMIT))
|
||||
}
|
||||
|
||||
function messageToClient(m, openid) {
|
||||
return {
|
||||
_id: m._id,
|
||||
fromMe: m.fromOpenid === openid,
|
||||
content: m.content,
|
||||
type: m.type || 'text',
|
||||
createdAt: m.createdAt,
|
||||
timeText: timeText(m.createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
exports.main = async event => {
|
||||
const { OPENID } = cloud.getWXContext()
|
||||
if (!OPENID) throw new Error('no openid in wx context')
|
||||
|
||||
let convId = event.conversationId || ''
|
||||
let peer = null
|
||||
const pageSize = normalizeLimit(event.pageSize || event.limit)
|
||||
|
||||
// Open by target user — resolve peer and find an existing conversation.
|
||||
if (!convId && event.targetUserId) {
|
||||
@@ -56,27 +75,32 @@ exports.main = async event => {
|
||||
}
|
||||
|
||||
let messages = []
|
||||
let nextCursor = ''
|
||||
if (convId) {
|
||||
const query = { conversationId: convId }
|
||||
const cursor = Number(event.cursor || 0)
|
||||
if (Number.isFinite(cursor) && cursor > 0) query.createdAt = _.lt(cursor)
|
||||
|
||||
const res = await db.collection('messages')
|
||||
.where({ conversationId: convId })
|
||||
.where(query)
|
||||
.orderBy('createdAt', 'desc')
|
||||
.limit(50)
|
||||
.limit(pageSize + 1)
|
||||
.get()
|
||||
.catch(() => ({ data: [] }))
|
||||
messages = res.data.reverse().map(m => ({
|
||||
_id: m._id,
|
||||
fromMe: m.fromOpenid === OPENID,
|
||||
content: m.content,
|
||||
type: m.type || 'text',
|
||||
createdAt: m.createdAt,
|
||||
timeText: timeText(m.createdAt)
|
||||
}))
|
||||
const page = res.data.slice(0, pageSize)
|
||||
messages = page.reverse().map(m => messageToClient(m, OPENID))
|
||||
if (res.data.length > pageSize && messages[0]) {
|
||||
nextCursor = String(messages[0].createdAt || '')
|
||||
}
|
||||
|
||||
// Mark this conversation read for me.
|
||||
await db.collection('conversations').doc(convId).update({
|
||||
data: { unreadOpenids: _.pull(OPENID), [`unreadCounts.${OPENID}`]: 0 }
|
||||
}).catch(() => {})
|
||||
// Mark this conversation read for me only when the client asks for it.
|
||||
// Silent polling and loading older history should not write the conversation.
|
||||
if (event.markRead !== false) {
|
||||
await db.collection('conversations').doc(convId).update({
|
||||
data: { unreadOpenids: _.pull(OPENID), [`unreadCounts.${OPENID}`]: 0 }
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
return { conversationId: convId, peer, messages }
|
||||
return { conversationId: convId, peer, messages, nextCursor }
|
||||
}
|
||||
|
||||
@@ -4,108 +4,172 @@ cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
|
||||
const db = cloud.database()
|
||||
const _ = db.command
|
||||
|
||||
async function fetchUsers(ids) {
|
||||
if (!ids.length) return []
|
||||
// Only people within this radius are "nearby". Online = location reported recently.
|
||||
const RADIUS_METERS = 5000
|
||||
const ONLINE_WINDOW_MS = 5 * 60 * 1000
|
||||
// How many visible locations to scan in memory. No geo index is required this way
|
||||
// (geoNear would need a manually-created index in the cloud console); fine for an
|
||||
// early-stage user base. Switch to geoNear on `locations.geo` if this grows large.
|
||||
const MAX_SCAN = 500
|
||||
const MAX_RESULTS = 50
|
||||
|
||||
// In WeChat 云开发, querying a collection that was never created throws
|
||||
// ("collection not exists"). Treat that as an empty result so an unused
|
||||
// collection (e.g. follows/matches before anyone follows/likes) can't break
|
||||
// the whole nearby query.
|
||||
function isMissingCollection(e) {
|
||||
if (!e) return false
|
||||
const msg = String(e.errMsg || e.message || '')
|
||||
return e.errCode === -502005 || /not exist/i.test(msg) || /CollectionNotExists/i.test(msg)
|
||||
}
|
||||
|
||||
async function safeGet(query) {
|
||||
try {
|
||||
return await query.get()
|
||||
} catch (e) {
|
||||
if (isMissingCollection(e)) return { data: [] }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchByChunks(collection, field, values) {
|
||||
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()
|
||||
for (let i = 0; i < values.length; i += 100) {
|
||||
const chunk = values.slice(i, i + 100)
|
||||
const res = await safeGet(db.collection(collection).where({ [field]: _.in(chunk) }).limit(100))
|
||||
out.push(...res.data)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function hashId(id) {
|
||||
let h = 5381
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
h = ((h << 5) + h) ^ id.charCodeAt(i)
|
||||
h = h >>> 0 // keep as unsigned 32-bit
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// Generate a stable map pin position from pet id (fallback abstract layer)
|
||||
function stablePin(id) {
|
||||
const h = hashId(id)
|
||||
const left = 10 + (h % 70)
|
||||
const top = 15 + ((h >> 8) % 55)
|
||||
return { left, top }
|
||||
}
|
||||
|
||||
// Scatter a pet deterministically within roughly ±0.9km of the user, so on a
|
||||
// real map markers cluster nearby (stable per pet id across requests).
|
||||
function syntheticCoord(id, userLoc) {
|
||||
const h = hashId(id)
|
||||
const dLat = ((h % 1600) - 800) / 100000 // ±0.008° ≈ ±0.9km
|
||||
const dLng = (((h >> 8) % 1600) - 800) / 100000
|
||||
return { latitude: userLoc.latitude + dLat, longitude: userLoc.longitude + dLng }
|
||||
}
|
||||
|
||||
// Rough distance text from coordinate delta (fallback when no real distance)
|
||||
function distanceText(petLoc, userLoc) {
|
||||
if (!petLoc || !userLoc) return null
|
||||
// Great-circle distance in metres between two {latitude, longitude} points.
|
||||
function distanceMeters(a, b) {
|
||||
if (!a || !b || typeof a.latitude !== 'number' || typeof b.latitude !== 'number') return Infinity
|
||||
const R = 6371000
|
||||
const dLat = ((petLoc.latitude - userLoc.latitude) * Math.PI) / 180
|
||||
const dLon = ((petLoc.longitude - userLoc.longitude) * Math.PI) / 180
|
||||
const a =
|
||||
const dLat = ((b.latitude - a.latitude) * Math.PI) / 180
|
||||
const dLon = ((b.longitude - a.longitude) * Math.PI) / 180
|
||||
const s =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos((userLoc.latitude * Math.PI) / 180) *
|
||||
Math.cos((petLoc.latitude * Math.PI) / 180) *
|
||||
Math.cos((a.latitude * Math.PI) / 180) *
|
||||
Math.cos((b.latitude * Math.PI) / 180) *
|
||||
Math.sin(dLon / 2) ** 2
|
||||
const meters = Math.round(R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)))
|
||||
if (meters < 1000) return `${meters}m`
|
||||
return `${(meters / 1000).toFixed(1)}km`
|
||||
return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s))
|
||||
}
|
||||
|
||||
function distanceText(meters) {
|
||||
if (!isFinite(meters)) return null
|
||||
const m = Math.round(meters)
|
||||
if (m < 1000) return `${m}m`
|
||||
return `${(m / 1000).toFixed(1)}km`
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// No usable origin → nothing to anchor "nearby" to.
|
||||
if (!userLoc || typeof userLoc.latitude !== 'number' || typeof userLoc.longitude !== 'number') {
|
||||
return { list: [] }
|
||||
}
|
||||
|
||||
// Identify the caller and their follow graph (for friend/following badges).
|
||||
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 meRes = await safeGet(db.collection('users').where({ openid: OPENID }).limit(1))
|
||||
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()
|
||||
safeGet(db.collection('follows').where({ followerId: meId }).limit(500)),
|
||||
safeGet(db.collection('follows').where({ followeeId: meId }).limit(500))
|
||||
])
|
||||
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,
|
||||
pin: stablePin(pet._id)
|
||||
// 1) Scan visible locations (paginated) and keep those inside the radius.
|
||||
const near = []
|
||||
let scanned = 0
|
||||
for (let skip = 0; skip < MAX_SCAN; skip += 100) {
|
||||
const res = await safeGet(db.collection('locations').where({ visible: true }).skip(skip).limit(100))
|
||||
scanned += res.data.length
|
||||
if (!res.data.length) break
|
||||
for (const rec of res.data) {
|
||||
if (!rec.openid || !rec.location) continue
|
||||
const dist = distanceMeters(userLoc, rec.location)
|
||||
if (dist <= RADIUS_METERS) near.push({ rec, dist })
|
||||
}
|
||||
if (res.data.length < 100) break
|
||||
}
|
||||
near.sort((a, b) => a.dist - b.dist)
|
||||
const top = near.slice(0, MAX_RESULTS)
|
||||
console.log('[nearbyPets] origin=', userLoc, 'visibleScanned=', scanned, 'inRadius=', near.length, 'nearestKm=', near[0] ? (near[0].dist / 1000).toFixed(2) : 'n/a')
|
||||
if (!top.length) return { list: [] }
|
||||
|
||||
const distByOpenid = new Map()
|
||||
const recByOpenid = new Map()
|
||||
const openids = []
|
||||
top.forEach(({ rec, dist }) => {
|
||||
if (recByOpenid.has(rec.openid)) return
|
||||
distByOpenid.set(rec.openid, dist)
|
||||
recByOpenid.set(rec.openid, rec)
|
||||
openids.push(rec.openid)
|
||||
})
|
||||
|
||||
return { list }
|
||||
// 2) Resolve those openids to user records.
|
||||
const users = await fetchByChunks('users', 'openid', openids)
|
||||
const userByOpenid = new Map(users.map(u => [u.openid, u]))
|
||||
const openidByUserId = new Map(users.map(u => [u._id, u.openid]))
|
||||
const ownerIds = users.map(u => u._id)
|
||||
if (!ownerIds.length) return { list: [] }
|
||||
|
||||
// 3) Their pets (optionally narrowed by species).
|
||||
let pets = await fetchByChunks('pets', 'ownerId', ownerIds)
|
||||
if (filter === 'dog' || filter === 'cat') pets = pets.filter(p => p.species === filter)
|
||||
|
||||
// 4) Which of these pets the caller already liked.
|
||||
const likedSet = new Set()
|
||||
const petIds = pets.map(p => p._id).filter(Boolean)
|
||||
if (OPENID && petIds.length) {
|
||||
const liked = await fetchByChunks('matches', 'petId', petIds).then(rows =>
|
||||
rows.filter(m => m.fromOpenid === OPENID)
|
||||
)
|
||||
liked.forEach(m => likedSet.add(m.petId))
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const list = pets
|
||||
.map(pet => {
|
||||
const ownerOpenid = openidByUserId.get(pet.ownerId)
|
||||
const rec = ownerOpenid ? recByOpenid.get(ownerOpenid) : null
|
||||
const coord = rec && rec.location ? rec.location : null
|
||||
const dist = ownerOpenid != null && distByOpenid.has(ownerOpenid) ? distByOpenid.get(ownerOpenid) : Infinity
|
||||
const owner = userByOpenid.get(ownerOpenid)
|
||||
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,
|
||||
likedByMe: likedSet.has(pet._id),
|
||||
latitude: coord ? coord.latitude : undefined,
|
||||
longitude: coord ? coord.longitude : undefined,
|
||||
distanceText: distanceText(dist),
|
||||
online: rec ? now - (rec.updatedAt || 0) < ONLINE_WINDOW_MS : false,
|
||||
_dist: dist
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a._dist - b._dist)
|
||||
|
||||
list.forEach(p => delete p._dist)
|
||||
|
||||
const finalList = filter === 'online' ? list.filter(p => p.online) : list
|
||||
console.log('[nearbyPets] nearbyUsers=', openids.length, 'matchedUsers=', users.length, 'pets=', pets.length, 'returned=', finalList.length)
|
||||
return { list: finalList }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ const cloud = require('wx-server-sdk')
|
||||
|
||||
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
|
||||
const db = cloud.database()
|
||||
const MAX_PETS_PER_USER = 10
|
||||
|
||||
exports.main = async event => {
|
||||
const { OPENID } = cloud.getWXContext()
|
||||
@@ -20,8 +21,12 @@ exports.main = async event => {
|
||||
return { petId: event.pet._id }
|
||||
}
|
||||
|
||||
const countRes = await db.collection('pets').where({ ownerId: users.data[0]._id }).count()
|
||||
if ((countRes.total || 0) >= MAX_PETS_PER_USER) {
|
||||
throw new Error(`最多只能添加 ${MAX_PETS_PER_USER} 只宠物`)
|
||||
}
|
||||
|
||||
pet.createdAt = Date.now()
|
||||
const result = await db.collection('pets').add({ data: pet })
|
||||
return { petId: result._id }
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ exports.main = async event => {
|
||||
const post = {
|
||||
authorOpenid: OPENID,
|
||||
authorId: user._id,
|
||||
authorSnapshot: { name: user.nickname, avatarKey: user.avatarKey },
|
||||
authorSnapshot: { name: user.nickname, avatarKey: user.avatarKey, avatarUrl: user.avatarUrl || '' },
|
||||
petId: event.petId || '',
|
||||
petSnapshot,
|
||||
content: content.slice(0, 2000),
|
||||
@@ -50,7 +50,7 @@ exports.main = async event => {
|
||||
? { latitude: event.latitude, longitude: event.longitude }
|
||||
: null,
|
||||
visibility: event.visibility || 'public',
|
||||
counts: { likes: 0, comments: 0, shares: 0, favorites: 0 },
|
||||
counts: { likes: 0, comments: 0, shares: 0 },
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
|
||||
66
cloudfunctions/postDelete/index.js
Normal file
66
cloudfunctions/postDelete/index.js
Normal file
@@ -0,0 +1,66 @@
|
||||
const cloud = require('wx-server-sdk')
|
||||
|
||||
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
|
||||
const db = cloud.database()
|
||||
const _ = db.command
|
||||
|
||||
function isCloudFileId(v) {
|
||||
return typeof v === 'string' && v.startsWith('cloud://')
|
||||
}
|
||||
|
||||
function extractMediaId(m) {
|
||||
if (!m) return ''
|
||||
if (typeof m === 'string') return m
|
||||
return m.fileId || m.fileID || m.url || ''
|
||||
}
|
||||
|
||||
// Remove all docs matching `where`, tolerating a collection that was never created.
|
||||
async function safeRemoveWhere(collection, where) {
|
||||
try {
|
||||
await db.collection(collection).where(where).remove()
|
||||
} catch (e) {
|
||||
/* collection missing or nothing to remove */
|
||||
}
|
||||
}
|
||||
|
||||
exports.main = async event => {
|
||||
const { OPENID } = cloud.getWXContext()
|
||||
if (!OPENID) throw new Error('no openid in wx context')
|
||||
if (!event.postId) throw new Error('postId is required')
|
||||
|
||||
let postRes
|
||||
try {
|
||||
postRes = await db.collection('posts').doc(event.postId).get()
|
||||
} catch (_) {
|
||||
return { ok: true } // already gone
|
||||
}
|
||||
const post = postRes.data
|
||||
if (!post) return { ok: true }
|
||||
if (post.authorOpenid !== OPENID) throw new Error('not allowed')
|
||||
|
||||
await db.collection('posts').doc(event.postId).remove()
|
||||
|
||||
// Decrement the author's post count (never below 0 in practice).
|
||||
if (post.authorId) {
|
||||
await db.collection('users').doc(post.authorId).update({
|
||||
data: { 'stats.posts': _.inc(-1) }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// Best-effort cleanup of derived data so no orphans linger.
|
||||
await Promise.all([
|
||||
safeRemoveWhere('postLikes', { postId: event.postId }),
|
||||
safeRemoveWhere('postFavorites', { postId: event.postId }),
|
||||
safeRemoveWhere('comments', { postId: event.postId })
|
||||
])
|
||||
|
||||
// Best-effort removal of the post's uploaded media from storage.
|
||||
const fileList = (Array.isArray(post.media) ? post.media : [])
|
||||
.map(extractMediaId)
|
||||
.filter(isCloudFileId)
|
||||
if (fileList.length) {
|
||||
await cloud.deleteCloudFile({ fileList }).catch(() => {})
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
{
|
||||
"name": "postFavorite",
|
||||
"name": "postDelete",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"wx-server-sdk": "latest"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,63 +29,116 @@ function formatTimeText(ts) {
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日`
|
||||
}
|
||||
|
||||
function isCloudFileId(value) {
|
||||
return typeof value === 'string' && value.startsWith('cloud://')
|
||||
}
|
||||
|
||||
function extractMediaUrl(media) {
|
||||
if (!media) return ''
|
||||
if (typeof media === 'string') return media
|
||||
return media.url || media.fileId || media.fileID || ''
|
||||
}
|
||||
|
||||
async function resolveTempUrlMap(urls) {
|
||||
const fileIds = [...new Set(urls.filter(isCloudFileId))]
|
||||
if (!fileIds.length) return new Map()
|
||||
|
||||
try {
|
||||
const result = await cloud.getTempFileURL({ fileList: fileIds })
|
||||
const fileList = Array.isArray(result.fileList) ? result.fileList : []
|
||||
return new Map(fileList.map(file => [
|
||||
file.fileID || file.fileId,
|
||||
file.tempFileURL || file.tempFileUrl || ''
|
||||
]).filter(([fileId, tempUrl]) => fileId && tempUrl))
|
||||
} catch (e) {
|
||||
return new Map()
|
||||
}
|
||||
}
|
||||
|
||||
function readableUrl(url, tempUrlMap) {
|
||||
if (!url) return ''
|
||||
if (!isCloudFileId(url)) return url
|
||||
return tempUrlMap.get(url) || ''
|
||||
}
|
||||
|
||||
exports.main = async event => {
|
||||
const { OPENID } = cloud.getWXContext()
|
||||
const postId = String(event.postId || '').trim()
|
||||
if (!postId) throw new Error('postId is required')
|
||||
const commentPageSize = Math.max(1, Math.min(Number(event.commentPageSize || 50), 100))
|
||||
|
||||
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.
|
||||
// Resolve the author's current profile (fresh avatar) and liked state.
|
||||
let author = null
|
||||
if (post.authorId) {
|
||||
const authorRes = await safeGet('users', { _id: post.authorId }, 1)
|
||||
author = authorRes.data[0] || null
|
||||
}
|
||||
if (!author && post.authorOpenid) {
|
||||
const authorRes = await safeGet('users', { openid: post.authorOpenid }, 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)
|
||||
])
|
||||
const likedRes = await safeGet('postLikes', { openid: OPENID, postId }, 1)
|
||||
likedByMe = likedRes.data.length > 0
|
||||
favoritedByMe = favRes.data.length > 0
|
||||
}
|
||||
|
||||
const postAssetUrls = [
|
||||
(author && author.avatarUrl) || post.authorSnapshot?.avatarUrl || '',
|
||||
...(Array.isArray(post.media) ? post.media.map(extractMediaUrl) : [])
|
||||
]
|
||||
const postTempUrlMap = await resolveTempUrlMap(postAssetUrls)
|
||||
const postMedia = Array.isArray(post.media)
|
||||
? post.media
|
||||
.map(extractMediaUrl)
|
||||
.map(url => readableUrl(url, postTempUrlMap))
|
||||
.filter(Boolean)
|
||||
: []
|
||||
|
||||
const mappedPost = {
|
||||
_id: post._id,
|
||||
author: {
|
||||
id: post.authorId || '',
|
||||
id: (author && 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 || ''
|
||||
avatarUrl: readableUrl((author && author.avatarUrl) || post.authorSnapshot?.avatarUrl || '', postTempUrlMap)
|
||||
},
|
||||
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)
|
||||
: [],
|
||||
media: postMedia,
|
||||
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
|
||||
comments: post.counts?.comments || 0
|
||||
},
|
||||
likedByMe,
|
||||
favoritedByMe
|
||||
likedByMe
|
||||
}
|
||||
|
||||
// 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 commentWhere = { postId }
|
||||
const commentCursor = Number(event.commentCursor || 0)
|
||||
if (Number.isFinite(commentCursor) && commentCursor > 0) commentWhere.createdAt = _.gt(commentCursor)
|
||||
const commentsRes = await safeGet(
|
||||
'comments',
|
||||
commentWhere,
|
||||
commentPageSize + 1,
|
||||
{ field: 'createdAt', direction: 'asc' }
|
||||
)
|
||||
const commentPage = commentsRes.data.slice(0, commentPageSize)
|
||||
const commentsNextCursor = commentsRes.data.length > commentPageSize && commentPage[commentPage.length - 1]
|
||||
? String(commentPage[commentPage.length - 1].createdAt || '')
|
||||
: ''
|
||||
|
||||
const commentAuthorIds = [...new Set(commentPage.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)
|
||||
@@ -93,20 +146,34 @@ exports.main = async event => {
|
||||
usersRes.data.forEach(u => authorMap.set(u._id, u))
|
||||
}
|
||||
|
||||
const comments = commentsRes.data.map(c => {
|
||||
const u = authorMap.get(c.authorId)
|
||||
const commentAuthorOpenids = [...new Set(commentPage.map(c => c.openid).filter(Boolean))]
|
||||
const authorOpenidMap = new Map()
|
||||
for (let i = 0; i < commentAuthorOpenids.length; i += 100) {
|
||||
const chunk = commentAuthorOpenids.slice(i, i + 100)
|
||||
const usersRes = await safeGet('users', { openid: _.in(chunk) }, 100)
|
||||
usersRes.data.forEach(u => authorOpenidMap.set(u.openid, u))
|
||||
}
|
||||
const findCommentAuthor = comment => authorMap.get(comment.authorId) || authorOpenidMap.get(comment.openid)
|
||||
|
||||
const commentTempUrlMap = await resolveTempUrlMap(commentPage.map(c => {
|
||||
const u = findCommentAuthor(c)
|
||||
return (u && u.avatarUrl) || c.authorSnapshot?.avatarUrl || ''
|
||||
}))
|
||||
|
||||
const comments = commentPage.map(c => {
|
||||
const u = findCommentAuthor(c)
|
||||
return {
|
||||
_id: c._id,
|
||||
author: {
|
||||
id: c.authorId || '',
|
||||
id: (u && u._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 || ''
|
||||
avatarUrl: readableUrl((u && u.avatarUrl) || c.authorSnapshot?.avatarUrl || '', commentTempUrlMap)
|
||||
},
|
||||
content: c.content || '',
|
||||
timeText: formatTimeText(c.createdAt || Date.now())
|
||||
}
|
||||
})
|
||||
|
||||
return { post: mappedPost, comments }
|
||||
return { post: mappedPost, comments, commentsNextCursor }
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
const cloud = require('wx-server-sdk')
|
||||
|
||||
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
|
||||
const db = cloud.database()
|
||||
const _ = db.command
|
||||
|
||||
exports.main = async event => {
|
||||
const { OPENID } = cloud.getWXContext()
|
||||
const { postId, favorited } = event
|
||||
if (!postId) throw new Error('postId is required')
|
||||
|
||||
const existed = await db.collection('postFavorites').where({ postId, openid: OPENID }).limit(1).get()
|
||||
let delta = 0
|
||||
|
||||
if (favorited && !existed.data.length) {
|
||||
await db.collection('postFavorites').add({ data: { postId, openid: OPENID, createdAt: Date.now() } })
|
||||
delta = 1
|
||||
}
|
||||
|
||||
if (!favorited && existed.data.length) {
|
||||
await db.collection('postFavorites').doc(existed.data[0]._id).remove()
|
||||
delta = -1
|
||||
}
|
||||
|
||||
if (delta) {
|
||||
await db.collection('posts').doc(postId).update({ data: { 'counts.favorites': _.inc(delta) } })
|
||||
}
|
||||
|
||||
const post = await db.collection('posts').doc(postId).get()
|
||||
return { favorited: Boolean(favorited), favorites: post.data.counts?.favorites || 0 }
|
||||
}
|
||||
|
||||
36
cloudfunctions/postUpdate/index.js
Normal file
36
cloudfunctions/postUpdate/index.js
Normal file
@@ -0,0 +1,36 @@
|
||||
const cloud = require('wx-server-sdk')
|
||||
|
||||
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
|
||||
const db = cloud.database()
|
||||
|
||||
const ALLOWED_VISIBILITY = ['public', 'friends', 'private']
|
||||
|
||||
// Lightweight edit: only the post's text content and visibility can change here.
|
||||
exports.main = async event => {
|
||||
const { OPENID } = cloud.getWXContext()
|
||||
if (!OPENID) throw new Error('no openid in wx context')
|
||||
if (!event.postId) throw new Error('postId is required')
|
||||
|
||||
let postRes
|
||||
try {
|
||||
postRes = await db.collection('posts').doc(event.postId).get()
|
||||
} catch (_) {
|
||||
throw new Error('post not found')
|
||||
}
|
||||
const post = postRes.data
|
||||
if (!post) throw new Error('post not found')
|
||||
if (post.authorOpenid !== OPENID) throw new Error('not allowed')
|
||||
|
||||
const data = { updatedAt: Date.now() }
|
||||
if (typeof event.content === 'string') {
|
||||
const content = event.content.trim()
|
||||
if (!content) throw new Error('content is required')
|
||||
data.content = content.slice(0, 2000)
|
||||
}
|
||||
if (typeof event.visibility === 'string' && ALLOWED_VISIBILITY.includes(event.visibility)) {
|
||||
data.visibility = event.visibility
|
||||
}
|
||||
|
||||
await db.collection('posts').doc(event.postId).update({ data })
|
||||
return { ok: true }
|
||||
}
|
||||
8
cloudfunctions/postUpdate/package.json
Normal file
8
cloudfunctions/postUpdate/package.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "postUpdate",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"wx-server-sdk": "latest"
|
||||
}
|
||||
}
|
||||
@@ -16,13 +16,12 @@ async function safeCount(collection, where) {
|
||||
// Derive live stats from the source collections so the displayed numbers are
|
||||
// always accurate instead of relying on a stored field that can drift.
|
||||
async function computeStats(user) {
|
||||
const [posts, favorites, following, followers] = await Promise.all([
|
||||
const [posts, following, followers] = await Promise.all([
|
||||
safeCount('posts', { authorId: user._id }),
|
||||
safeCount('postFavorites', { openid: user.openid }),
|
||||
safeCount('follows', { followerId: user._id }),
|
||||
safeCount('follows', { followeeId: user._id })
|
||||
])
|
||||
return { posts, following, followers, favorites }
|
||||
return { posts, following, followers }
|
||||
}
|
||||
|
||||
exports.main = async event => {
|
||||
|
||||
@@ -26,11 +26,44 @@ function formatTimeText(ts) {
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日`
|
||||
}
|
||||
|
||||
function isCloudFileId(value) {
|
||||
return typeof value === 'string' && value.startsWith('cloud://')
|
||||
}
|
||||
|
||||
function extractMediaUrl(media) {
|
||||
if (!media) return ''
|
||||
if (typeof media === 'string') return media
|
||||
return media.url || media.fileId || media.fileID || ''
|
||||
}
|
||||
|
||||
async function resolveTempUrlMap(urls) {
|
||||
const fileIds = [...new Set(urls.filter(isCloudFileId))]
|
||||
if (!fileIds.length) return new Map()
|
||||
|
||||
try {
|
||||
const result = await cloud.getTempFileURL({ fileList: fileIds })
|
||||
const fileList = Array.isArray(result.fileList) ? result.fileList : []
|
||||
return new Map(fileList.map(file => [
|
||||
file.fileID || file.fileId,
|
||||
file.tempFileURL || file.tempFileUrl || ''
|
||||
]).filter(([fileId, tempUrl]) => fileId && tempUrl))
|
||||
} catch (e) {
|
||||
return new Map()
|
||||
}
|
||||
}
|
||||
|
||||
function readableUrl(url, tempUrlMap) {
|
||||
if (!url) return ''
|
||||
if (!isCloudFileId(url)) return url
|
||||
return tempUrlMap.get(url) || ''
|
||||
}
|
||||
|
||||
// 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')
|
||||
const pageSize = Math.max(1, Math.min(Number(event.pageSize || 20), 50))
|
||||
|
||||
let authorOpenid = OPENID
|
||||
let viewingSelf = true
|
||||
@@ -43,35 +76,59 @@ exports.main = async event => {
|
||||
|
||||
const where = { authorOpenid }
|
||||
if (!viewingSelf) where.visibility = 'public'
|
||||
const cursor = Number(event.cursor || 0)
|
||||
if (Number.isFinite(cursor) && cursor > 0) where.createdAt = _.lt(cursor)
|
||||
|
||||
const res = await db.collection('posts')
|
||||
.where(where)
|
||||
.orderBy('createdAt', 'desc')
|
||||
.limit(50)
|
||||
.limit(pageSize + 1)
|
||||
.get()
|
||||
|
||||
if (!res.data.length) return { list: [] }
|
||||
if (!res.data.length) return { list: [], nextCursor: '' }
|
||||
|
||||
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 page = res.data.slice(0, pageSize)
|
||||
const nextCursor = res.data.length > pageSize && page[page.length - 1]
|
||||
? String(page[page.length - 1].createdAt || '')
|
||||
: ''
|
||||
|
||||
const authorRes = await db.collection('users').where({ openid: authorOpenid }).limit(1).get().catch(() => null)
|
||||
const authorUser = authorRes && authorRes.data ? authorRes.data[0] : null
|
||||
|
||||
// Real presence: author has a recent, visible location (same signal as 附近).
|
||||
const ONLINE_WINDOW_MS = 5 * 60 * 1000
|
||||
const locRes = await safeGet('locations', { openid: authorOpenid }, 1)
|
||||
const authorLoc = locRes.data[0]
|
||||
const authorOnline = Boolean(
|
||||
authorLoc && authorLoc.visible && authorLoc.updatedAt && Date.now() - authorLoc.updatedAt < ONLINE_WINDOW_MS
|
||||
)
|
||||
const postIds = page.map(p => p._id)
|
||||
const likedRes = await safeGet('postLikes', { 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 assetUrls = [
|
||||
authorUser?.avatarUrl || '',
|
||||
...page.flatMap(post => Array.isArray(post.media) ? post.media.map(extractMediaUrl) : [])
|
||||
]
|
||||
const tempUrlMap = await resolveTempUrlMap(assetUrls)
|
||||
|
||||
const list = res.data.map(post => ({
|
||||
const list = page.map(post => ({
|
||||
_id: post._id,
|
||||
author: {
|
||||
id: post.authorId || '',
|
||||
name: post.authorSnapshot?.name || '用户',
|
||||
avatarKey: post.authorSnapshot?.avatarKey || 'gradient-avatar-1'
|
||||
avatarKey: authorUser?.avatarKey || post.authorSnapshot?.avatarKey || 'gradient-avatar-1',
|
||||
avatarUrl: readableUrl(authorUser?.avatarUrl || post.authorSnapshot?.avatarUrl || '', tempUrlMap),
|
||||
online: authorOnline
|
||||
},
|
||||
pet: post.petSnapshot || { name: '', breed: '' },
|
||||
body: post.content || '',
|
||||
topics: post.topics || [],
|
||||
visibility: post.visibility || 'public',
|
||||
media: Array.isArray(post.media)
|
||||
? post.media.map(m => (typeof m === 'string' ? m : m && (m.url || m.fileId))).filter(Boolean)
|
||||
? post.media
|
||||
.map(extractMediaUrl)
|
||||
.map(url => readableUrl(url, tempUrlMap))
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
mediaTone: post.mediaTone || 'pet-1',
|
||||
sticker: post.sticker || undefined,
|
||||
@@ -79,12 +136,10 @@ exports.main = async event => {
|
||||
timeText: formatTimeText(post.createdAt || Date.now()),
|
||||
counts: {
|
||||
likes: post.counts?.likes || 0,
|
||||
comments: post.counts?.comments || 0,
|
||||
favorites: post.counts?.favorites || 0
|
||||
comments: post.counts?.comments || 0
|
||||
},
|
||||
likedByMe: likedSet.has(post._id),
|
||||
favoritedByMe: favSet.has(post._id)
|
||||
likedByMe: likedSet.has(post._id)
|
||||
}))
|
||||
|
||||
return { list }
|
||||
return { list, nextCursor }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export default {
|
||||
env: {
|
||||
TARO_APP_CLOUD_ENV: JSON.stringify(process.env.TARO_APP_CLOUD_ENV || 'cloud1-d4g697lte499543d8')
|
||||
TARO_APP_CLOUD_ENV: JSON.stringify(process.env.TARO_APP_CLOUD_ENV || 'cloud1-d4g697lte499543d8'),
|
||||
TARO_APP_MESSAGE_TEMPLATE_ID: JSON.stringify(process.env.TARO_APP_MESSAGE_TEMPLATE_ID || 'xirZ0SkI9s_CCYAioAzz3U-qBg4ZEmR1avQTrM-0DrE')
|
||||
},
|
||||
defineConstants: {}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export default {
|
||||
env: {
|
||||
TARO_APP_CLOUD_ENV: JSON.stringify(process.env.TARO_APP_CLOUD_ENV || 'cloud1-d4g697lte499543d8')
|
||||
TARO_APP_CLOUD_ENV: JSON.stringify(process.env.TARO_APP_CLOUD_ENV || 'cloud1-d4g697lte499543d8'),
|
||||
TARO_APP_MESSAGE_TEMPLATE_ID: JSON.stringify(process.env.TARO_APP_MESSAGE_TEMPLATE_ID || 'xirZ0SkI9s_CCYAioAzz3U-qBg4ZEmR1avQTrM-0DrE')
|
||||
},
|
||||
defineConstants: {}
|
||||
}
|
||||
|
||||
|
||||
2
dist/app-origin.wxss
vendored
2
dist/app-origin.wxss
vendored
@@ -1 +1 @@
|
||||
@-webkit-keyframes slideUp{from{-webkit-transform:translateY(100%);transform:translateY(100%)}to{-webkit-transform:translateY(0);transform:translateY(0)}}@keyframes slideUp{from{-webkit-transform:translateY(100%);transform:translateY(100%)}to{-webkit-transform:translateY(0);transform:translateY(0)}}@-webkit-keyframes pulse{0%{opacity:.9;-webkit-transform:scale(.2);transform:scale(.2)}80%{opacity:0;-webkit-transform:scale(2.2);transform:scale(2.2)}100%{opacity:0;-webkit-transform:scale(2.2);transform:scale(2.2)}}@keyframes pulse{0%{opacity:.9;-webkit-transform:scale(.2);transform:scale(.2)}80%{opacity:0;-webkit-transform:scale(2.2);transform:scale(2.2)}100%{opacity:0;-webkit-transform:scale(2.2);transform:scale(2.2)}}@-webkit-keyframes softAppear{from{opacity:0;-webkit-transform:translateY(16rpx);transform:translateY(16rpx)}to{opacity:1;-webkit-transform:translateY(0);transform:translateY(0)}}@keyframes softAppear{from{opacity:0;-webkit-transform:translateY(16rpx);transform:translateY(16rpx)}to{opacity:1;-webkit-transform:translateY(0);transform:translateY(0)}}.wx-icon-text{font-family:Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;line-height:1}.no-scrollbar{scrollbar-width:none}.no-scrollbar::-webkit-scrollbar{display:none}.gradient-avatar-1{background:linear-gradient(135deg,#ffb3c8,#ffd9a8)}.gradient-avatar-2{background:linear-gradient(135deg,#d8b4ff,#a4d4ff)}.gradient-avatar-3{background:linear-gradient(135deg,#8fe5b5,#d8b4ff)}.gradient-avatar-4{background:linear-gradient(135deg,#ffd9a8,#ffb3c8)}.gradient-avatar-5{background:linear-gradient(135deg,#a4d4ff,#ffd9a8)}.gradient-avatar-6{background:linear-gradient(135deg,#ffb3c8,#d8b4ff)}page{background:#14102b;color:#f0e9ff;font-family:Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;min-height:100%}button{background:transparent;border:0;color:inherit;line-height:inherit;padding:0}button::after{border:0}scroll-view{-webkit-box-sizing:border-box;box-sizing:border-box}
|
||||
@-webkit-keyframes slideUp{from{-webkit-transform:translateY(100%);transform:translateY(100%)}to{-webkit-transform:translateY(0);transform:translateY(0)}}@keyframes slideUp{from{-webkit-transform:translateY(100%);transform:translateY(100%)}to{-webkit-transform:translateY(0);transform:translateY(0)}}@-webkit-keyframes pulse{0%{opacity:.9;-webkit-transform:scale(.2);transform:scale(.2)}80%{opacity:0;-webkit-transform:scale(2.2);transform:scale(2.2)}100%{opacity:0;-webkit-transform:scale(2.2);transform:scale(2.2)}}@keyframes pulse{0%{opacity:.9;-webkit-transform:scale(.2);transform:scale(.2)}80%{opacity:0;-webkit-transform:scale(2.2);transform:scale(2.2)}100%{opacity:0;-webkit-transform:scale(2.2);transform:scale(2.2)}}@-webkit-keyframes softAppear{from{opacity:0;-webkit-transform:translateY(16rpx);transform:translateY(16rpx)}to{opacity:1;-webkit-transform:translateY(0);transform:translateY(0)}}@keyframes softAppear{from{opacity:0;-webkit-transform:translateY(16rpx);transform:translateY(16rpx)}to{opacity:1;-webkit-transform:translateY(0);transform:translateY(0)}}.wx-icon-text{font-family:Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;line-height:1}.no-scrollbar{scrollbar-width:none}.no-scrollbar::-webkit-scrollbar{display:none}.gradient-avatar-1{background:linear-gradient(135deg,#ffb3c8,#ffd9a8)}.gradient-avatar-2{background:linear-gradient(135deg,#d8b4ff,#a4d4ff)}.gradient-avatar-3{background:linear-gradient(135deg,#8fe5b5,#d8b4ff)}.gradient-avatar-4{background:linear-gradient(135deg,#ffd9a8,#ffb3c8)}.gradient-avatar-5{background:linear-gradient(135deg,#a4d4ff,#ffd9a8)}.gradient-avatar-6{background:linear-gradient(135deg,#ffb3c8,#d8b4ff)}page{--app-bg:radial-gradient(ellipse 70% 50% at 25% 0%,rgba(216,180,255,.1),transparent 60%),radial-gradient(ellipse 60% 50% at 75% 100%,rgba(255,179,200,.08),transparent 60%),linear-gradient(180deg,#1c1442,#14102b 60%,#0f0a23);background:#14102b;color:#f0e9ff;font-family:Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;min-height:100%}button{background:transparent;border:0;color:inherit;line-height:inherit;padding:0}button::after{border:0}scroll-view{-webkit-box-sizing:border-box;box-sizing:border-box}
|
||||
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","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"}
|
||||
{"pages":["pages/home/index","pages/publish/index","pages/profile-edit/index","pages/pet-edit/index","pages/chat/index","pages/contacts/index","pages/my-posts/index","pages/user-profile/index","pages/post-detail/index","pages/post-edit/index"],"window":{"navigationStyle":"custom","backgroundColor":"#14102b","backgroundTextStyle":"dark"},"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/custom-tab-bar/index.js
vendored
2
dist/custom-tab-bar/index.js
vendored
@@ -1 +1 @@
|
||||
(wx["webpackJsonp"]=wx["webpackJsonp"]||[]).push([[777],{3241:function(){Component({})}},function(n){var o=function(o){return n(n.s=o)};o(3241)}]);
|
||||
Component({});
|
||||
4
dist/custom-tab-bar/index.json
vendored
4
dist/custom-tab-bar/index.json
vendored
@@ -1 +1,3 @@
|
||||
{"component":true}
|
||||
{
|
||||
"component": true
|
||||
}
|
||||
|
||||
2
dist/pages/chat/index.js
vendored
2
dist/pages/chat/index.js
vendored
File diff suppressed because one or more lines are too long
2
dist/pages/chat/index.wxss
vendored
2
dist/pages/chat/index.wxss
vendored
@@ -1 +1 @@
|
||||
.chat{background:#14102b;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;height:100vh}.chat__nav{-ms-flex-negative:0;background:#14102b;border-bottom:2rpx solid rgba(216,180,255,.06);flex-shrink:0}.chat__nav-row{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;height:100rpx;padding:0 24rpx}.chat__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}.chat__back-icon{-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.chat__title{color:#f0e9ff;-ms-flex:1;flex:1;font:700 32rpx/1 Space Grotesk,Inter,system-ui,sans-serif;text-align:center}.chat__nav-spacer{width:72rpx}.chat__scroll{-ms-flex:1;flex:1;min-height:0}.chat__list{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;gap:28rpx;padding:32rpx}.chat__empty{color:#8a7aab;font:400 26rpx/1.6 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;padding:80rpx 0;text-align:center}.chat__row{display:-ms-flexbox;display:flex;-ms-flex-align:end;align-items:flex-end;gap:16rpx;max-width:80%}.chat__row--me{-ms-flex-item-align:end;align-self:flex-end;-ms-flex-direction:row-reverse;flex-direction:row-reverse}.chat__bubble-wrap{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;gap:8rpx}.chat__bubble{background:rgba(45,34,85,.6);border:2rpx solid rgba(216,180,255,.1);border-radius:28rpx;color:#f0e9ff;font:400 28rpx/1.5 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;padding:20rpx 26rpx;word-break:break-word}.chat__bubble--me{background:linear-gradient(135deg,#d8b4ff,#ffb3c8);border-color:transparent;color:#2a1d10}.chat__time{color:#8a7aab;font:400 20rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;padding:0 8rpx}.chat__row--me .chat__time{text-align:right}.chat__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}.chat__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}.chat__input-ph{color:#8a7aab}.chat__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}.chat__send--disabled{opacity:.5}
|
||||
.chat{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;height:100vh}.chat__nav{-ms-flex-negative:0;background:#14102b;border-bottom:2rpx solid rgba(216,180,255,.06);flex-shrink:0}.chat__nav-row{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;height:100rpx;padding:0 24rpx}.chat__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}.chat__back-icon{-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.chat__title{color:#f0e9ff;-ms-flex:1;flex:1;font:700 32rpx/1 Space Grotesk,Inter,system-ui,sans-serif;text-align:center}.chat__nav-spacer{width:72rpx}.chat__scroll{-ms-flex:1;flex:1;min-height:0}.chat__list{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;gap:28rpx;margin:32rpx}.chat__empty{color:#8a7aab;font:400 26rpx/1.6 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;padding:80rpx 0;text-align:center}.chat__history-state{-ms-flex-item-align:center;align-self:center;color:#8a7aab;font:400 24rpx/1.4 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;padding:4rpx 0 8rpx}.chat__row{display:-ms-flexbox;display:flex;-ms-flex-align:end;align-items:flex-end;gap:16rpx;max-width:80%}.chat__row--me{-ms-flex-item-align:end;align-self:flex-end;-ms-flex-direction:row-reverse;flex-direction:row-reverse}.chat__bubble-wrap{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;gap:8rpx}.chat__bubble{background:rgba(45,34,85,.6);border:2rpx solid rgba(216,180,255,.1);border-radius:28rpx;color:#f0e9ff;font:400 28rpx/1.5 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;padding:20rpx 26rpx;word-break:break-word}.chat__bubble--me{background:linear-gradient(135deg,#d8b4ff,#ffb3c8);border-color:transparent;color:#2a1d10}.chat__image{background:rgba(45,34,85,.6);border:2rpx solid rgba(216,180,255,.12);border-radius:28rpx;height:264rpx;max-width:60vw;width:368rpx}.chat__image--me{border-color:hsla(0,0%,100%,.24)}.chat__time{color:#8a7aab;font:400 20rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;padding:0 8rpx}.chat__row--me .chat__time{text-align:right}.chat__bottom-anchor{height:2rpx}.chat__emoji-panel{-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:16rpx;padding:16rpx 32rpx}.chat__emoji{background:rgba(31,23,66,.68);border:2rpx solid rgba(216,180,255,.1);border-radius:36rpx;font:400 40rpx/72rpx Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;height:72rpx;text-align:center;width:72rpx}.chat__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}.chat__tool{-ms-flex-negative:0;border-radius:40rpx;display:-ms-flexbox;display:flex;flex-shrink:0;height:80rpx;width:80rpx;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;background:rgba(31,23,66,.68);border:2rpx solid rgba(216,180,255,.1);color:#f0e9ff;justify-content:center}.chat__tool--active{background:rgba(216,180,255,.18);border-color:rgba(216,180,255,.28);color:#2a1d10}.chat__tool--disabled{opacity:.45}.chat__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}.chat__input-ph{color:#8a7aab}.chat__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}.chat__send--disabled{opacity:.5}.chat__send--sending{opacity:.75}
|
||||
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/contacts/index.wxss
vendored
2
dist/pages/contacts/index.wxss
vendored
@@ -1 +1 @@
|
||||
.contacts{background:#14102b;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;height:100vh}.contacts__nav{-ms-flex-negative:0;background:#14102b;flex-shrink:0}.contacts__nav-row{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;height:100rpx;padding:0 24rpx}.contacts__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}.contacts__back-icon{-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.contacts__title{color:#f0e9ff;-ms-flex:1;flex:1;font:700 32rpx/1 Space Grotesk,Inter,system-ui,sans-serif;text-align:center}.contacts__nav-spacer{width:72rpx}.contacts__search{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;background:rgba(31,23,66,.68);border:2rpx solid rgba(216,180,255,.1);border-radius:19998rpx;gap:16rpx;height:80rpx;margin:24rpx 40rpx 8rpx;padding:0 28rpx}.contacts__search-icon{color:#8a7aab}.contacts__search-input{color:#f0e9ff;-ms-flex:1;flex:1;font:400 28rpx/80rpx Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;height:80rpx}.contacts__search-ph{color:#8a7aab}.contacts__scroll{-ms-flex:1;flex:1;min-height:0}.contacts__list{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;padding:16rpx 40rpx 48rpx}.contacts__item{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;border-bottom:2rpx solid rgba(216,180,255,.05);gap:24rpx;padding:24rpx 0}.contacts__left{display:-ms-flexbox;display:flex;-ms-flex:1;flex:1;min-width:0;-ms-flex-align:center;align-items:center;gap:24rpx}.contacts__meta{-ms-flex:1;flex:1;min-width:0}.contacts__name{color:#f0e9ff;display:block;font:600 30rpx/1.3 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif}.contacts__bio{color:#8a7aab;display:block;font:400 24rpx/1.3 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;margin-top:4rpx;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.contacts__actions{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;gap:16rpx;-ms-flex-negative:0;flex-shrink:0}.contacts__friend-tag{background:rgba(216,180,255,.14);border-radius:19998rpx;color:#d8b4ff;font:600 20rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;padding:6rpx 16rpx}.contacts__follow{background:linear-gradient(135deg,#d8b4ff,#ffb3c8);border-radius:19998rpx;color:#2a1d10;font:600 24rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;padding:14rpx 32rpx}.contacts__follow--on{background:transparent;border:2rpx solid rgba(216,180,255,.3);color:#8a7aab}.contacts__empty{color:#8a7aab;font:400 26rpx/1.6 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;padding:96rpx 40rpx;text-align:center}
|
||||
.contacts{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;height:100vh}.contacts__nav{-ms-flex-negative:0;background:#14102b;flex-shrink:0}.contacts__nav-row{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;height:100rpx;padding:0 24rpx}.contacts__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}.contacts__back-icon{-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.contacts__title{color:#f0e9ff;-ms-flex:1;flex:1;font:700 32rpx/1 Space Grotesk,Inter,system-ui,sans-serif;text-align:center}.contacts__nav-spacer{width:72rpx}.contacts__search{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;background:rgba(31,23,66,.68);border:2rpx solid rgba(216,180,255,.1);border-radius:19998rpx;gap:16rpx;height:80rpx;margin:24rpx 40rpx 8rpx;padding:0 28rpx}.contacts__search-icon{color:#8a7aab}.contacts__search-input{color:#f0e9ff;-ms-flex:1;flex:1;font:400 28rpx/80rpx Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;height:80rpx}.contacts__search-ph{color:#8a7aab}.contacts__scroll{-ms-flex:1;flex:1;min-height:0}.contacts__list{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;padding:16rpx 40rpx 48rpx}.contacts__item{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;border-bottom:2rpx solid rgba(216,180,255,.05);gap:24rpx;padding:24rpx 0}.contacts__left{display:-ms-flexbox;display:flex;-ms-flex:1;flex:1;min-width:0;-ms-flex-align:center;align-items:center;gap:24rpx}.contacts__meta{-ms-flex:1;flex:1;min-width:0}.contacts__name{color:#f0e9ff;display:block;font:600 30rpx/1.3 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif}.contacts__bio{color:#8a7aab;display:block;font:400 24rpx/1.3 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;margin-top:4rpx;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.contacts__actions{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;gap:16rpx;-ms-flex-negative:0;flex-shrink:0}.contacts__friend-tag{background:rgba(216,180,255,.14);border-radius:19998rpx;color:#d8b4ff;font:600 20rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;padding:6rpx 16rpx}.contacts__follow{background:linear-gradient(135deg,#d8b4ff,#ffb3c8);border-radius:19998rpx;color:#2a1d10;font:600 24rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;padding:14rpx 32rpx}.contacts__follow--on{background:transparent;border:2rpx solid rgba(216,180,255,.3);color:#8a7aab}.contacts__empty{color:#8a7aab;font:400 26rpx/1.6 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;padding:96rpx 40rpx;text-align:center}
|
||||
1
dist/pages/home/index.js
vendored
Normal file
1
dist/pages/home/index.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/pages/home/index.json
vendored
Normal file
1
dist/pages/home/index.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"navigationStyle":"custom","disableScroll":true,"usingComponents":{"comp":"../../comp"}}
|
||||
1
dist/pages/home/index.wxss
vendored
Normal file
1
dist/pages/home/index.wxss
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/pages/messages/index.js
vendored
1
dist/pages/messages/index.js
vendored
@@ -1 +0,0 @@
|
||||
"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
dist/pages/messages/index.json
vendored
1
dist/pages/messages/index.json
vendored
@@ -1 +0,0 @@
|
||||
{"navigationBarTitleText":"汪友","navigationStyle":"custom","disableScroll":true,"usingComponents":{"comp":"../../comp"}}
|
||||
1
dist/pages/messages/index.wxss
vendored
1
dist/pages/messages/index.wxss
vendored
@@ -1 +0,0 @@
|
||||
.active-user-row{-webkit-box-sizing:border-box;box-sizing:border-box;padding:24rpx 0 16rpx;width:100%}.active-user-row__inner{display:-ms-flexbox;display:flex;gap:24rpx;padding:0 40rpx}.active-user-row__item{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;-ms-flex-align:center;align-items:center;gap:12rpx}.active-user-row__avatar-wrap{position:relative}.active-user-row__add{border-radius:50%;display:-ms-flexbox;display:flex;height:108rpx;width:108rpx;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;background:rgba(216,180,255,.08);border:2rpx dashed rgba(216,180,255,.35);color:#d8b4ff;justify-content:center}.active-user-row__live{background:-webkit-gradient(linear,left top,right top,from(#d8b4ff),to(#ffb3c8));background:linear-gradient(90deg,#d8b4ff,#ffb3c8);border:3rpx solid #14102b;border-radius:12rpx;bottom:-4rpx;color:#2a1d10;font:700 18rpx/1.2 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;padding:4rpx 12rpx;position:absolute;right:-8rpx}.active-user-row__name{color:#c7b9e0;font:500 22rpx/1.2 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;max-width:120rpx;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.conversation-item{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;gap:24rpx;padding:24rpx 40rpx;-webkit-transition:background .2s;transition:background .2s}.conversation-item:active{background:rgba(216,180,255,.06)}.conversation-item__main{-ms-flex:1;flex:1;min-width:0}.conversation-item__bottom,.conversation-item__name-row,.conversation-item__top{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center}.conversation-item__top{-ms-flex-pack:justify;justify-content:space-between}.conversation-item__name-row{gap:8rpx;min-width:0}.conversation-item__name{color:#f0e9ff;font:600 28rpx/1.2 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.conversation-item__pet{color:#d8b4ff;font:500 22rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif}.conversation-item__time{color:#8a7aab;font:400 22rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif}.conversation-item__bottom{-ms-flex-pack:justify;gap:16rpx;justify-content:space-between;margin-top:12rpx}.conversation-item__preview{color:#c7b9e0;-ms-flex:1;flex:1;font:400 24rpx/1.3 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.conversation-item__badge{background:#ffb3c8;border-radius:18rpx;-webkit-box-sizing:border-box;box-sizing:border-box;color:#2a1d10;font:700 20rpx/36rpx Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;height:36rpx;min-width:36rpx;padding:0 12rpx;text-align:center}.conversation-item__muted{background:#8a7aab;border-radius:50%;height:16rpx;opacity:.4;width:16rpx}.conversation-item__pin{color:#ffd9a8}.messages-page__divider{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;color:#8a7aab;font:500 20rpx/1 Space Grotesk,Inter,system-ui,sans-serif;gap:20rpx;letter-spacing:.18em;padding:24rpx 40rpx 12rpx}.messages-page__divider::after{background:rgba(216,180,255,.08);content:"";-ms-flex:1;flex:1;height:2rpx}.messages-page__list{padding-bottom:24rpx}.messages-page__empty{color:#8a7aab;display:block;font:400 26rpx/1.6 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;padding:80rpx 48rpx;text-align:center}
|
||||
2
dist/pages/my-posts/index.js
vendored
2
dist/pages/my-posts/index.js
vendored
File diff suppressed because one or more lines are too long
2
dist/pages/my-posts/index.wxss
vendored
2
dist/pages/my-posts/index.wxss
vendored
@@ -1 +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}
|
||||
.my-posts{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
dist/pages/nearby/index.js
vendored
1
dist/pages/nearby/index.js
vendored
File diff suppressed because one or more lines are too long
1
dist/pages/nearby/index.json
vendored
1
dist/pages/nearby/index.json
vendored
@@ -1 +0,0 @@
|
||||
{"navigationBarTitleText":"附近","navigationStyle":"custom","disableScroll":true,"usingComponents":{"comp":"../../comp"}}
|
||||
1
dist/pages/nearby/index.wxss
vendored
1
dist/pages/nearby/index.wxss
vendored
File diff suppressed because one or more lines are too long
2
dist/pages/pet-edit/index.js
vendored
2
dist/pages/pet-edit/index.js
vendored
File diff suppressed because one or more lines are too long
2
dist/pages/pet-edit/index.wxss
vendored
2
dist/pages/pet-edit/index.wxss
vendored
@@ -1 +1 @@
|
||||
.pet-edit{background:#14102b;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;height:100vh}.pet-edit__nav{-ms-flex-negative:0;background:#14102b;flex-shrink:0}.pet-edit__nav-row{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;height:108rpx;padding:0 32rpx}.pet-edit__close{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}.pet-edit__nav-title{color:#f0e9ff;-ms-flex:1;flex:1;font:700 34rpx/1 Space Grotesk,Inter,system-ui,sans-serif;text-align:center}.pet-edit__nav-spacer{width:72rpx}.pet-edit__scroll{-webkit-box-sizing:border-box;box-sizing:border-box;-ms-flex:1;flex:1;min-height:0;padding:16rpx 40rpx 24rpx}.pet-edit__photo-row{display:-ms-flexbox;display:flex;-ms-flex-pack:center;justify-content:center;margin:16rpx 0 48rpx}.pet-edit__photo{border-radius:40rpx;display:-ms-flexbox;display:flex;height:240rpx;overflow:hidden;width:240rpx;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;border:2rpx dashed rgba(216,180,255,.3);justify-content:center}.pet-edit__photo-img{border-radius:40rpx;height:240rpx;width:240rpx}.pet-edit__photo-empty{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;-ms-flex-align:center;align-items:center;color:#8a7aab;gap:12rpx}.pet-edit__photo-hint{font:500 24rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif}.pet-edit__picker{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;background:rgba(31,23,66,.68);border:2rpx solid rgba(216,180,255,.1);border-radius:24rpx;-webkit-box-sizing:border-box;box-sizing:border-box;height:92rpx;justify-content:space-between;padding:0 28rpx}.pet-edit__picker-value{color:#f0e9ff;font:400 28rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif}.pet-edit__picker-ph{color:#8a7aab;font:400 28rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif}.pet-edit__picker-arrow{color:#8a7aab;-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.pet-edit__field{margin-bottom:36rpx}.pet-edit__label{color:#c7b9e0;display:block;font:600 26rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;margin-bottom:16rpx}.pet-edit__input{background:rgba(31,23,66,.68);border:2rpx solid rgba(216,180,255,.1);border-radius:24rpx;-webkit-box-sizing:border-box;box-sizing:border-box;color:#f0e9ff;font:400 28rpx/92rpx Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;height:92rpx;padding:0 28rpx;width:100%}.pet-edit__placeholder{color:#8a7aab}.pet-edit__input--mt{margin-top:20rpx}.pet-edit__tags{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;gap:16rpx}.pet-edit__tag{background:rgba(45,34,85,.4);border:2rpx solid rgba(216,180,255,.1);border-radius:19998rpx;color:#c7b9e0;font:500 24rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;padding:16rpx 24rpx}.pet-edit__tag--active{background:-webkit-gradient(linear,left top,right top,from(rgba(216,180,255,.22)),to(rgba(255,179,200,.22)));background:linear-gradient(90deg,rgba(216,180,255,.22),rgba(255,179,200,.22));border-color:rgba(216,180,255,.4);color:#f0e9ff}.pet-edit__tag--add{border-color:rgba(216,180,255,.4);border-style:dashed;color:#d8b4ff}.pet-edit__delete{background:rgba(255,138,155,.1);border:2rpx solid rgba(255,138,155,.2);border-radius:24rpx;color:#ff8a9b;font:600 28rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;margin-top:24rpx;padding:26rpx;text-align:center}.pet-edit__footer{-ms-flex-negative:0;background:#14102b;border-top:2rpx solid rgba(216,180,255,.06);flex-shrink:0;padding:24rpx 40rpx 32rpx}.pet-edit__submit{background:-webkit-gradient(linear,left top,right top,from(#d8b4ff),to(#ffb3c8));background:linear-gradient(90deg,#d8b4ff,#ffb3c8);border-radius:19998rpx;-webkit-box-sizing:border-box;box-sizing:border-box;color:#2a1d10;font:700 30rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;padding:30rpx;text-align:center;width:100%}.pet-edit__submit:active{opacity:.9}
|
||||
.pet-edit{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;height:100vh}.pet-edit__nav{-ms-flex-negative:0;background:#14102b;flex-shrink:0}.pet-edit__nav-row{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;height:108rpx;padding:0 32rpx}.pet-edit__close{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}.pet-edit__nav-title{color:#f0e9ff;-ms-flex:1;flex:1;font:700 34rpx/1 Space Grotesk,Inter,system-ui,sans-serif;text-align:center}.pet-edit__nav-spacer{width:72rpx}.pet-edit__scroll{-webkit-box-sizing:border-box;box-sizing:border-box;-ms-flex:1;flex:1;min-height:0;padding:16rpx 40rpx 24rpx}.pet-edit__photo-row{display:-ms-flexbox;display:flex;-ms-flex-pack:center;justify-content:center;margin:16rpx 0 48rpx}.pet-edit__photo{border-radius:40rpx;display:-ms-flexbox;display:flex;height:240rpx;overflow:hidden;width:240rpx;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;border:2rpx dashed rgba(216,180,255,.3);justify-content:center}.pet-edit__photo-img{border-radius:40rpx;height:240rpx;width:240rpx}.pet-edit__photo-empty{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;-ms-flex-align:center;align-items:center;color:#8a7aab;gap:12rpx}.pet-edit__photo-hint{font:500 24rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif}.pet-edit__picker{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;background:rgba(31,23,66,.68);border:2rpx solid rgba(216,180,255,.1);border-radius:24rpx;-webkit-box-sizing:border-box;box-sizing:border-box;height:92rpx;justify-content:space-between;padding:0 28rpx}.pet-edit__picker-value{color:#f0e9ff;font:400 28rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif}.pet-edit__picker-ph{color:#8a7aab;font:400 28rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif}.pet-edit__picker-arrow{color:#8a7aab;-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.pet-edit__field{margin-bottom:36rpx}.pet-edit__label{color:#c7b9e0;display:block;font:600 26rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;margin-bottom:16rpx}.pet-edit__input{background:rgba(31,23,66,.68);border:2rpx solid rgba(216,180,255,.1);border-radius:24rpx;-webkit-box-sizing:border-box;box-sizing:border-box;color:#f0e9ff;font:400 28rpx/92rpx Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;height:92rpx;padding:0 28rpx;width:100%}.pet-edit__placeholder{color:#8a7aab}.pet-edit__input--mt{margin-top:20rpx}.pet-edit__tags{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;gap:16rpx}.pet-edit__tag{background:rgba(45,34,85,.4);border:2rpx solid rgba(216,180,255,.1);border-radius:19998rpx;color:#c7b9e0;font:500 24rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;padding:16rpx 24rpx}.pet-edit__tag--active{background:-webkit-gradient(linear,left top,right top,from(rgba(216,180,255,.22)),to(rgba(255,179,200,.22)));background:linear-gradient(90deg,rgba(216,180,255,.22),rgba(255,179,200,.22));border-color:rgba(216,180,255,.4);color:#f0e9ff}.pet-edit__tag--add{border-color:rgba(216,180,255,.4);border-style:dashed;color:#d8b4ff}.pet-edit__delete{background:rgba(255,138,155,.1);border:2rpx solid rgba(255,138,155,.2);border-radius:24rpx;color:#ff8a9b;font:600 28rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;margin-top:24rpx;padding:26rpx;text-align:center}.pet-edit__footer{-ms-flex-negative:0;background:#14102b;border-top:2rpx solid rgba(216,180,255,.06);flex-shrink:0;padding:24rpx 40rpx 32rpx}.pet-edit__submit{background:-webkit-gradient(linear,left top,right top,from(#d8b4ff),to(#ffb3c8));background:linear-gradient(90deg,#d8b4ff,#ffb3c8);border-radius:19998rpx;-webkit-box-sizing:border-box;box-sizing:border-box;color:#2a1d10;font:700 30rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;padding:30rpx;text-align:center;width:100%}.pet-edit__submit:active{opacity:.9}
|
||||
1
dist/pages/plaza/index.js
vendored
1
dist/pages/plaza/index.js
vendored
@@ -1 +0,0 @@
|
||||
"use strict";(wx["webpackJsonp"]=wx["webpackJsonp"]||[]).push([[407],{4761:function(e,a,n){var s=n(8870),t=n(1212),r=n(9379),c=n(467),i=n(5544),l=n(6540),o=n(758),u=n.n(o),d=n(118),p=n(7797),f=n(8324),h=n(6503),x=n(7377),_=n(326),m=n(7032),v=n(7896),A=n(4848);function j(){return(0,A.jsxs)(d.Ss,{className:"feed-ad-card",children:[(0,A.jsx)(d.Ss,{className:"feed-ad-card__mark",children:"\u6c6a"}),(0,A.jsxs)(d.Ss,{className:"feed-ad-card__text",children:[(0,A.jsx)(d.EY,{className:"feed-ad-card__title",children:"\u5468\u672b\u5ba0\u7269\u6d3e\u5bf9 \xb7 \u6ee8\u6c5f\u7eff\u5730"}),(0,A.jsx)(d.EY,{className:"feed-ad-card__sub",children:"\u672c\u5468\u516d 14:00 \xb7 \u540d\u989d\u5269 12 \u4e2a"})]}),(0,A.jsx)(d.Ss,{className:"feed-ad-card__cta",children:"\u62a5\u540d"})]})}var k=j,b=n(3972),g=n(4149),S=n(994),y=[{key:"feed",label:"\u63a8\u8350"},{key:"nearby",label:"\u9644\u8fd1"},{key:"follow",label:"\u5173\u6ce8"}],w=["\u5168\u90e8","\u6ee8\u6c5f\u516c\u56ed","\u91d1\u6bdb\u65e5\u5e38","\u732b\u54aa","\u5468\u672b\u805a\u4f1a","\u65b0\u624b\u517b\u5ba0"];function N(){var e=(0,l.useState)("feed"),a=(0,i.A)(e,2),n=a[0],s=a[1],j=(0,l.useState)("\u5168\u90e8"),N=(0,i.A)(j,2),z=N[0],B=N[1],E=(0,l.useState)([]),M=(0,i.A)(E,2),Y=M[0],O=M[1],T=(0,l.useState)(!0),C=(0,i.A)(T,2),J=C[0],D=C[1],I=(0,S.d)(480),L=(0,l.useRef)(!0),P=function(){D(!0),(0,g.lr)(n,"\u5168\u90e8"===z?void 0:z).then(O).finally(function(){return D(!1)})};(0,l.useEffect)(function(){P()},[n,z]),(0,o.useDidShow)(function(){L.current?L.current=!1:P()});var R=function(){var e=(0,c.A)((0,t.A)().m(function e(a){var n,s,c,i;return(0,t.A)().w(function(e){while(1)switch(e.p=e.n){case 0:if(n=Y,s=Y.map(function(e){if(e._id!==a)return e;var n=!e.likedByMe;return n&&I.show(),(0,r.A)((0,r.A)({},e),{},{likedByMe:n,counts:(0,r.A)((0,r.A)({},e.counts),{},{likes:Math.max(0,e.counts.likes+(n?1:-1))})})}),O(s),c=s.find(function(e){return e._id===a}),c){e.n=1;break}return e.a(2);case 1:return e.p=1,e.n=2,(0,g.O$)(a,c.likedByMe);case 2:i=e.v,O(function(e){return e.map(function(e){return e._id===a?(0,r.A)((0,r.A)({},e),{},{likedByMe:i.liked,counts:(0,r.A)((0,r.A)({},e.counts),{},{likes:i.likes})}):e})}),e.n=4;break;case 3:e.p=3,e.v,O(n),u().showToast({title:"\u70b9\u8d5e\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5",icon:"none"});case 4:return e.a(2)}},e,null,[[1,3]])}));return function(a){return e.apply(this,arguments)}}(),U=function(e){u().navigateTo({url:"/pages/post-detail/index?postId=".concat(e)})};return(0,A.jsxs)(f.A,{className:"plaza-page",children:[(0,A.jsx)(p.A,{title:"\u5e7f\u573a"}),(0,A.jsx)(x.A,{placeholder:"\u641c\u7d22\u8bdd\u9898\u3001\u5ba0\u7269\u3001\u9644\u8fd1\u6d3b\u52a8"}),(0,A.jsx)(_.A,{items:y,value:n,onChange:s}),(0,A.jsx)(d.BM,{scrollX:!0,className:"plaza-page__chips",showScrollbar:!1,children:(0,A.jsx)(d.Ss,{className:"plaza-page__chips-inner",children:w.map(function(e){return(0,A.jsx)(m.A,{active:z===e,onClick:function(){return B(e)},children:e},e)})})}),(0,A.jsxs)(d.Ss,{className:"plaza-page__feed",children:[Y.map(function(e,a){return(0,A.jsxs)(d.Ss,{children:[(0,A.jsx)(b.A,{post:e,onLike:R,onOpen:U}),0===a?(0,A.jsx)(k,{}):null]},e._id)}),J&&0===Y.length?(0,A.jsx)(d.EY,{className:"plaza-page__empty",children:"\u52a0\u8f7d\u4e2d\u2026"}):J||0!==Y.length?(0,A.jsx)(d.EY,{className:"plaza-page__empty",children:"\u6682\u65f6\u6ca1\u6709\u66f4\u591a\u4e86\uff0c\u7a0d\u540e\u518d\u6765\u770b\u770b\u5427"}):(0,A.jsx)(d.EY,{className:"plaza-page__empty",children:"follow"===n?"\u5173\u6ce8\u7684\u4eba\u8fd8\u6ca1\u6709\u53d1\u5e03\u52a8\u6001\uff0c\u53bb\u53d1\u73b0\u66f4\u591a\u6c6a\u53cb\u5427":"\u8fd8\u6ca1\u6709\u52a8\u6001\uff0c\u70b9\u51fb + \u53d1\u5e03\u7b2c\u4e00\u6761\u5427"})]}),(0,A.jsx)(v.A,{visible:I.visible}),(0,A.jsx)(h.A,{active:"plaza"})]})}var z={navigationBarTitleText:"\u5e7f\u573a",navigationStyle:"custom",disableScroll:!0},B=(0,s.eU)(N,"pages/plaza/index",{root:{cn:[]}},z||{});N&&N.behaviors&&(B.behaviors=(B.behaviors||[]).concat(N.behaviors));Page(B)}},function(e){var a=function(a){return e(e.s=a)};e.O(0,[907,96,76],function(){return a(4761)});e.O()}]);
|
||||
1
dist/pages/plaza/index.json
vendored
1
dist/pages/plaza/index.json
vendored
@@ -1 +0,0 @@
|
||||
{"navigationBarTitleText":"广场","navigationStyle":"custom","disableScroll":true,"usingComponents":{"comp":"../../comp"}}
|
||||
1
dist/pages/plaza/index.wxss
vendored
1
dist/pages/plaza/index.wxss
vendored
@@ -1 +0,0 @@
|
||||
.feed-ad-card{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;background:linear-gradient(135deg,rgba(255,179,200,.12),rgba(255,217,168,.08));border:2rpx solid rgba(255,217,168,.12);border-radius:32rpx;gap:24rpx;margin:0 32rpx 28rpx;padding:28rpx}.feed-ad-card__mark{background:linear-gradient(135deg,#ffb3c8,#ffd9a8);border-radius:28rpx;display:-ms-flexbox;display:flex;height:100rpx;width:100rpx;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;color:#2a1d10;font:700 36rpx/1 Space Grotesk,Inter,system-ui,sans-serif;justify-content:center}.feed-ad-card__text{display:-ms-flexbox;display:flex;-ms-flex:1;flex:1;-ms-flex-direction:column;flex-direction:column;gap:8rpx}.feed-ad-card__title{color:#f0e9ff;font:600 26rpx/1.3 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif}.feed-ad-card__sub{color:#c7b9e0;font:400 22rpx/1.4 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif}.feed-ad-card__cta{background:#ffd9a8;border-radius:19998rpx;color:#2a1d10;font:600 22rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;padding:12rpx 24rpx}.plaza-page__chips{padding-bottom:24rpx;width:100%}.plaza-page__chips-inner{display:-ms-flexbox;display:flex;gap:16rpx;padding:0 40rpx}.plaza-page__feed{padding-top:4rpx}.plaza-page__empty{color:#8a7aab;display:block;font:400 24rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;padding:16rpx 0 40rpx;text-align:center}
|
||||
2
dist/pages/post-detail/index.js
vendored
2
dist/pages/post-detail/index.js
vendored
File diff suppressed because one or more lines are too long
2
dist/pages/post-detail/index.wxss
vendored
2
dist/pages/post-detail/index.wxss
vendored
@@ -1 +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}
|
||||
.post-detail{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
dist/pages/post-edit/index.js
vendored
Normal file
1
dist/pages/post-edit/index.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
"use strict";(wx["webpackJsonp"]=wx["webpackJsonp"]||[]).push([[986],{4924:function(e,s,t){var n=t(8870),a=t(1212),i=t(467),o=t(5544),c=t(6540),r=t(758),l=t.n(r),u=t(118),d=t(374),p=t(3401),h=t(2978),v=t(4149),_=t(5313),m=t(4848),x={public:"\u516c\u5f00",friends:"\u6c6a\u53cb\u53ef\u89c1",private:"\u4ec5\u81ea\u5df1"};function b(){var e=(0,d.v)(),s=e.statusBarHeight,t=e.safeBottom,n=(0,r.useRouter)(),b=n.params.postId||"",f=(0,_.bI)(b),j=(0,c.useState)((null===f||void 0===f?void 0:f.body)||""),w=(0,o.A)(j,2),N=w[0],S=w[1],g=(0,c.useState)((null===f||void 0===f?void 0:f.visibility)||"public"),k=(0,o.A)(g,2),A=k[0],y=k[1],C=(0,c.useState)(!1),B=(0,o.A)(C,2),E=B[0],T=B[1],Y=(0,c.useState)(!1),I=(0,o.A)(Y,2),M=I[0],z=I[1];(0,c.useEffect)(function(){!f&&b&&(0,v.aC)(b).then(function(e){var s=e.post;s&&(S(s.body||""),y(s.visibility||"public"))}).catch(function(){})},[]);var H=N.trim().length,J=H>0&&H<=2e3,O=function(){var e=(0,i.A)((0,a.A)().m(function e(){return(0,a.A)().w(function(e){while(1)switch(e.p=e.n){case 0:if(!M){e.n=1;break}return e.a(2);case 1:if(J){e.n=2;break}return l().showToast({title:H?"\u6b63\u6587\u6700\u591a 2000 \u5b57":"\u5199\u70b9\u4ec0\u4e48\u5427",icon:"none"}),e.a(2);case 2:return z(!0),e.p=3,e.n=4,(0,v.gg)(b,{content:N.trim(),visibility:A});case 4:l().showToast({title:"\u5df2\u4fdd\u5b58",icon:"success"}),setTimeout(function(){return l().navigateBack()},500),e.n=6;break;case 5:e.p=5,e.v,l().showToast({title:"\u4fdd\u5b58\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5",icon:"none"});case 6:return e.p=6,z(!1),e.f(6);case 7:return e.a(2)}},e,null,[[3,5,6,7]])}));return function(){return e.apply(this,arguments)}}();return(0,m.jsxs)(u.Ss,{className:"post-edit",children:[(0,m.jsx)(u.Ss,{className:"post-edit__nav",style:{paddingTop:"".concat(s,"px")},children:(0,m.jsxs)(u.Ss,{className:"post-edit__nav-row",children:[(0,m.jsx)(u.Ss,{className:"post-edit__close",onClick:function(){return l().navigateBack()},children:(0,m.jsx)(p.A,{name:"close",size:22})}),(0,m.jsx)(u.EY,{className:"post-edit__nav-title",children:"\u7f16\u8f91\u52a8\u6001"}),(0,m.jsx)(u.Ss,{className:"post-edit__nav-spacer"})]})}),(0,m.jsxs)(u.BM,{scrollY:!0,className:"post-edit__body",enhanced:!0,showScrollbar:!1,children:[(0,m.jsx)(u.TM,{className:"post-edit__textarea",value:N,placeholder:"\u8bf4\u70b9\u4ec0\u4e48\u2026",placeholderClass:"post-edit__placeholder",maxlength:2e3,autoHeight:!0,onInput:function(e){return S(e.detail.value)}}),(0,m.jsxs)(u.EY,{className:"post-edit__count",children:[H,"/2000"]}),(0,m.jsxs)(u.Ss,{className:"post-edit__row",onClick:function(){return T(!0)},children:[(0,m.jsx)(u.EY,{className:"post-edit__row-label",children:"\u53ef\u89c1\u8303\u56f4"}),(0,m.jsxs)(u.Ss,{className:"post-edit__row-value",children:[(0,m.jsx)(u.EY,{children:x[A]}),(0,m.jsx)(p.A,{name:"chev",size:16})]})]}),(0,m.jsx)(u.EY,{className:"post-edit__hint",children:"\u6b64\u5904\u4ec5\u7f16\u8f91\u6587\u5b57\u4e0e\u53ef\u89c1\u8303\u56f4\uff1b\u5982\u9700\u4fee\u6539\u56fe\u7247\u6216\u8bdd\u9898\uff0c\u8bf7\u5220\u9664\u540e\u91cd\u65b0\u53d1\u5e03\u3002"})]}),(0,m.jsx)(u.Ss,{className:"post-edit__footer",style:{paddingBottom:"".concat(Math.max(16,t),"px")},children:(0,m.jsx)(u.Ss,{className:"post-edit__submit ".concat(J&&!M?"":"post-edit__submit--disabled"),onClick:O,children:M?"\u4fdd\u5b58\u4e2d\u2026":"\u4fdd\u5b58"})}),(0,m.jsx)(h.A,{visible:E,value:A,onChange:y,onClose:function(){return T(!1)}})]})}var f={},j=(0,n.eU)(b,"pages/post-edit/index",{root:{cn:[]}},f||{});b&&b.behaviors&&(j.behaviors=(j.behaviors||[]).concat(b.behaviors));Page(j)}},function(e){var s=function(s){return e(e.s=s)};e.O(0,[907,96,76],function(){return s(4924)});e.O()}]);
|
||||
1
dist/pages/post-edit/index.json
vendored
Normal file
1
dist/pages/post-edit/index.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"usingComponents":{"comp":"../../comp"}}
|
||||
1
dist/pages/post-edit/index.wxss
vendored
Normal file
1
dist/pages/post-edit/index.wxss
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.post-edit{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;height:100vh}.post-edit__nav{-ms-flex-negative:0;-webkit-box-sizing:border-box;box-sizing:border-box;flex-shrink:0;position:relative;z-index:10}.post-edit__nav-row{display:-ms-flexbox;display:flex;height:96rpx;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;justify-content:space-between;padding:0 32rpx}.post-edit__close{display:-ms-flexbox;display:flex;height:76rpx;width:76rpx;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;color:#f0e9ff;justify-content:center}.post-edit__nav-title{color:#f0e9ff;font:600 34rpx/1 Space Grotesk,Inter,system-ui,sans-serif}.post-edit__nav-spacer{height:76rpx;width:76rpx}.post-edit__body{-webkit-box-sizing:border-box;box-sizing:border-box;-ms-flex:1;flex:1;padding:16rpx 32rpx 48rpx}.post-edit__footer{-ms-flex-negative:0;background:#14102b;border-top:2rpx solid rgba(216,180,255,.06);flex-shrink:0;padding:24rpx 40rpx 32rpx}.post-edit__submit{background:-webkit-gradient(linear,left top,right top,from(#d8b4ff),to(#ffb3c8));background:linear-gradient(90deg,#d8b4ff,#ffb3c8);border-radius:19998rpx;-webkit-box-sizing:border-box;box-sizing:border-box;color:#2a1d10;font:700 30rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;padding:30rpx;text-align:center;width:100%}.post-edit__submit:active{opacity:.9}.post-edit__submit--disabled{opacity:.45}.post-edit__textarea{background:rgba(31,23,66,.6);border:2rpx solid rgba(216,180,255,.1);border-radius:28rpx;-webkit-box-sizing:border-box;box-sizing:border-box;color:#f0e9ff;font:400 30rpx/1.6 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;min-height:320rpx;padding:28rpx;width:100%}.post-edit__placeholder{color:#8a7aab}.post-edit__count{color:#8a7aab;display:block;font:400 22rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;margin-top:16rpx;text-align:right}.post-edit__row{background:rgba(31,23,66,.6);border:2rpx solid rgba(216,180,255,.1);border-radius:28rpx;display:-ms-flexbox;display:flex;height:100rpx;margin-top:36rpx;padding:0 28rpx;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;justify-content:space-between}.post-edit__row-label{color:#f0e9ff;font:500 28rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif}.post-edit__row-value{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;color:#8a7aab;font:400 26rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;gap:8rpx}.post-edit__hint{color:#8a7aab;display:block;font:400 24rpx/1.6 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;margin-top:32rpx}
|
||||
2
dist/pages/profile-edit/index.js
vendored
2
dist/pages/profile-edit/index.js
vendored
File diff suppressed because one or more lines are too long
2
dist/pages/profile-edit/index.wxss
vendored
2
dist/pages/profile-edit/index.wxss
vendored
@@ -1 +1 @@
|
||||
.profile-edit{background:#14102b;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;height:100vh}.profile-edit__nav{-ms-flex-negative:0;background:#14102b;flex-shrink:0}.profile-edit__nav-row{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;height:108rpx;padding:0 32rpx}.profile-edit__close{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}.profile-edit__nav-title{color:#f0e9ff;-ms-flex:1;flex:1;font:700 34rpx/1 Space Grotesk,Inter,system-ui,sans-serif;text-align:center}.profile-edit__nav-spacer{width:72rpx}.profile-edit__scroll{-webkit-box-sizing:border-box;box-sizing:border-box;-ms-flex:1;flex:1;min-height:0;padding:16rpx 40rpx 80rpx}.profile-edit__avatar-row{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;-ms-flex-align:center;align-items:center;margin:16rpx 0 48rpx}.profile-edit__avatar-btn{background:transparent;border-radius:50%;height:128rpx;line-height:normal;margin:0;padding:0;position:relative;width:128rpx}.profile-edit__avatar-btn::after{border:none}.profile-edit__avatar-img{border-radius:50%;height:128rpx;width:128rpx}.profile-edit__avatar-edit{background:#d8b4ff;border-radius:50%;bottom:-4rpx;color:#2a1d10;display:-ms-flexbox;display:flex;height:44rpx;position:absolute;right:-4rpx;width:44rpx;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;border:4rpx solid #14102b;justify-content:center}.profile-edit__avatar-hint{color:#8a7aab;font:400 24rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;margin-top:20rpx}.profile-edit__field{margin-bottom:36rpx}.profile-edit__label{color:#c7b9e0;display:block;font:600 26rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;margin-bottom:16rpx}.profile-edit__input{background:rgba(31,23,66,.68);border:2rpx solid rgba(216,180,255,.1);border-radius:24rpx;-webkit-box-sizing:border-box;box-sizing:border-box;color:#f0e9ff;font:400 28rpx/92rpx Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;height:92rpx;padding:0 28rpx;width:100%}.profile-edit__textarea{background:rgba(31,23,66,.68);border:2rpx solid rgba(216,180,255,.1);border-radius:24rpx;-webkit-box-sizing:border-box;box-sizing:border-box;color:#f0e9ff;font:400 28rpx/1.5 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;height:168rpx;padding:24rpx 28rpx;width:100%}.profile-edit__placeholder{color:#8a7aab}.profile-edit__picker{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;background:rgba(31,23,66,.68);border:2rpx solid rgba(216,180,255,.1);border-radius:24rpx;-webkit-box-sizing:border-box;box-sizing:border-box;height:92rpx;justify-content:space-between;padding:0 28rpx}.profile-edit__picker-value{color:#f0e9ff;font:400 28rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif}.profile-edit__picker-ph{color:#8a7aab;font:400 28rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif}.profile-edit__picker-arrow{color:#8a7aab;-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.profile-edit__footer{-ms-flex-negative:0;background:#14102b;border-top:2rpx solid rgba(216,180,255,.06);flex-shrink:0;padding:24rpx 40rpx 32rpx}.profile-edit__submit{background:-webkit-gradient(linear,left top,right top,from(#d8b4ff),to(#ffb3c8));background:linear-gradient(90deg,#d8b4ff,#ffb3c8);border-radius:19998rpx;-webkit-box-sizing:border-box;box-sizing:border-box;color:#2a1d10;font:700 30rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;padding:30rpx;text-align:center;width:100%}.profile-edit__submit:active{opacity:.9}
|
||||
.profile-edit{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;height:100vh}.profile-edit__nav{-ms-flex-negative:0;background:#14102b;flex-shrink:0}.profile-edit__nav-row{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;height:108rpx;padding:0 32rpx}.profile-edit__close{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}.profile-edit__nav-title{color:#f0e9ff;-ms-flex:1;flex:1;font:700 34rpx/1 Space Grotesk,Inter,system-ui,sans-serif;text-align:center}.profile-edit__nav-spacer{width:72rpx}.profile-edit__scroll{-webkit-box-sizing:border-box;box-sizing:border-box;-ms-flex:1;flex:1;min-height:0;padding:16rpx 40rpx 80rpx}.profile-edit__avatar-row{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;-ms-flex-align:center;align-items:center;margin:16rpx 0 48rpx}.profile-edit__avatar-btn{background:transparent;border-radius:50%;height:128rpx;line-height:normal;margin:0;padding:0;position:relative;width:128rpx}.profile-edit__avatar-btn::after{border:none}.profile-edit__avatar-img{border-radius:50%;height:128rpx;width:128rpx}.profile-edit__avatar-edit{background:#d8b4ff;border-radius:50%;bottom:-4rpx;color:#2a1d10;display:-ms-flexbox;display:flex;height:44rpx;position:absolute;right:-4rpx;width:44rpx;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;border:4rpx solid #14102b;justify-content:center}.profile-edit__avatar-hint{color:#8a7aab;font:400 24rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;margin-top:20rpx}.profile-edit__field{margin-bottom:36rpx}.profile-edit__label{color:#c7b9e0;display:block;font:600 26rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;margin-bottom:16rpx}.profile-edit__input{background:rgba(31,23,66,.68);border:2rpx solid rgba(216,180,255,.1);border-radius:24rpx;-webkit-box-sizing:border-box;box-sizing:border-box;color:#f0e9ff;font:400 28rpx/92rpx Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;height:92rpx;padding:0 28rpx;width:100%}.profile-edit__textarea{background:rgba(31,23,66,.68);border:2rpx solid rgba(216,180,255,.1);border-radius:24rpx;-webkit-box-sizing:border-box;box-sizing:border-box;color:#f0e9ff;font:400 28rpx/1.5 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;height:168rpx;padding:24rpx 28rpx;width:100%}.profile-edit__placeholder{color:#8a7aab}.profile-edit__picker{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;background:rgba(31,23,66,.68);border:2rpx solid rgba(216,180,255,.1);border-radius:24rpx;-webkit-box-sizing:border-box;box-sizing:border-box;height:92rpx;justify-content:space-between;padding:0 28rpx}.profile-edit__picker-value{color:#f0e9ff;font:400 28rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif}.profile-edit__picker-ph{color:#8a7aab;font:400 28rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif}.profile-edit__picker-arrow{color:#8a7aab;-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.profile-edit__footer{-ms-flex-negative:0;background:#14102b;border-top:2rpx solid rgba(216,180,255,.06);flex-shrink:0;padding:24rpx 40rpx 32rpx}.profile-edit__submit{background:-webkit-gradient(linear,left top,right top,from(#d8b4ff),to(#ffb3c8));background:linear-gradient(90deg,#d8b4ff,#ffb3c8);border-radius:19998rpx;-webkit-box-sizing:border-box;box-sizing:border-box;color:#2a1d10;font:700 30rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;padding:30rpx;text-align:center;width:100%}.profile-edit__submit:active{opacity:.9}
|
||||
1
dist/pages/profile/index.js
vendored
1
dist/pages/profile/index.js
vendored
File diff suppressed because one or more lines are too long
1
dist/pages/profile/index.json
vendored
1
dist/pages/profile/index.json
vendored
@@ -1 +0,0 @@
|
||||
{"navigationBarTitleText":"我的","navigationStyle":"custom","disableScroll":true,"usingComponents":{"comp":"../../comp"}}
|
||||
2
dist/pages/profile/index.wxml
vendored
2
dist/pages/profile/index.wxml
vendored
@@ -1,2 +0,0 @@
|
||||
<import src="../../base.wxml"/>
|
||||
<template is="taro_tmpl" data="{{root:root}}" />
|
||||
1
dist/pages/profile/index.wxss
vendored
1
dist/pages/profile/index.wxss
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
2
dist/pages/publish/index.wxss
vendored
2
dist/pages/publish/index.wxss
vendored
File diff suppressed because one or more lines are too long
1
dist/pages/user-profile/index.js
vendored
Normal file
1
dist/pages/user-profile/index.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/pages/user-profile/index.json
vendored
Normal file
1
dist/pages/user-profile/index.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"usingComponents":{"comp":"../../comp"}}
|
||||
1
dist/pages/user-profile/index.wxss
vendored
Normal file
1
dist/pages/user-profile/index.wxss
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.user-profile{background:#14102b;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;height:100vh}.user-profile__nav{-ms-flex-negative:0;background:#14102b;flex-shrink:0}.user-profile__nav-row{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;height:100rpx;padding:0 24rpx}.user-profile__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}.user-profile__back-icon{-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.user-profile__title{color:#f0e9ff;-ms-flex:1;flex:1;font:700 32rpx/1 Space Grotesk,Inter,system-ui,sans-serif;text-align:center}.user-profile__nav-spacer{width:72rpx}.user-profile__scroll{-ms-flex:1;flex:1;min-height:0}.user-profile__actions{padding:0 40rpx 40rpx}.user-profile__primary{border-radius:20rpx;display:-ms-flexbox;display:flex;height:88rpx;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;background:-webkit-gradient(linear,left top,right top,from(#d8b4ff),to(#ffb3c8));background:linear-gradient(90deg,#d8b4ff,#ffb3c8);color:#2a1d10;font:700 28rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;justify-content:center}.user-profile__section{padding:0 40rpx 24rpx}.user-profile__section-title{color:#c7b9e0;font:600 26rpx/1 Space Grotesk,Inter,system-ui,sans-serif;letter-spacing:.08em;text-transform:uppercase}.user-profile__pet-scroll{margin:0 -40rpx}.user-profile__pet-list{display:-ms-flexbox;display:flex;gap:20rpx;padding:0 40rpx 44rpx}.user-profile__pet-card{width:264rpx;-ms-flex-negative:0;background:rgba(45,34,85,.4);border:2rpx solid rgba(216,180,255,.08);border-radius:32rpx;flex-shrink:0;padding:24rpx}.user-profile__pet-photo{border-radius:20rpx;height:156rpx;overflow:hidden;position:relative}.user-profile__pet-img{border-radius:20rpx;height:156rpx;width:100%}.user-profile__pet-label{background:rgba(0,0,0,.4);border-radius:19998rpx;bottom:16rpx;color:#f0e9ff;font:500 18rpx/1 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;left:16rpx;padding:6rpx 16rpx;position:absolute}.user-profile__pet-name{color:#f0e9ff;display:block;font:600 26rpx/1.2 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;margin-top:20rpx;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.user-profile__pet-meta{color:#8a7aab;display:block;font:400 20rpx/1.3 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;margin-top:6rpx}.user-profile__feed{padding:0 32rpx 48rpx}.user-profile__empty,.user-profile__empty-small{color:#8a7aab;display:block;font:400 26rpx/1.6 Inter,system-ui,-apple-system,PingFang SC,Microsoft YaHei,sans-serif;text-align:center}.user-profile__empty{padding:96rpx 48rpx}.user-profile__empty-small{padding:44rpx 0;width:100%}
|
||||
2
dist/project.config.json
vendored
2
dist/project.config.json
vendored
@@ -22,7 +22,7 @@
|
||||
"uploadWithSourceMap": true,
|
||||
"compileHotReLoad": false,
|
||||
"lazyloadPlaceholderEnable": false,
|
||||
"useMultiFrameRuntime": true,
|
||||
"useMultiFrameRuntime": false,
|
||||
"useApiHook": true,
|
||||
"useApiHostProcess": true,
|
||||
"compileWorklet": false,
|
||||
|
||||
2
dist/runtime.js
vendored
2
dist/runtime.js
vendored
@@ -1 +1 @@
|
||||
(function(){"use strict";var n={},t={};function r(e){var o=t[e];if(void 0!==o)return o.exports;var u=t[e]={exports:{}};return n[e](u,u.exports,r),u.exports}r.m=n,function(){var n=[];r.O=function(t,e,o,u){if(!e){var i=1/0;for(l=0;l<n.length;l++){e=n[l][0],o=n[l][1],u=n[l][2];for(var f=!0,c=0;c<e.length;c++)(!1&u||i>=u)&&Object.keys(r.O).every(function(n){return r.O[n](e[c])})?e.splice(c--,1):(f=!1,u<i&&(i=u));if(f){n.splice(l--,1);var a=o();void 0!==a&&(t=a)}}return t}u=u||0;for(var l=n.length;l>0&&n[l-1][2]>u;l--)n[l]=n[l-1];n[l]=[e,o,u]}}(),function(){r.n=function(n){var t=n&&n.__esModule?function(){return n["default"]}:function(){return n};return r.d(t,{a:t}),t}}(),function(){var n,t=Object.getPrototypeOf?function(n){return Object.getPrototypeOf(n)}:function(n){return n.__proto__};r.t=function(e,o){if(1&o&&(e=this(e)),8&o)return e;if("object"===typeof e&&e){if(4&o&&e.__esModule)return e;if(16&o&&"function"===typeof e.then)return e}var u=Object.create(null);r.r(u);var i={};n=n||[null,t({}),t([]),t(t)];for(var f=2&o&&e;"object"==typeof f&&!~n.indexOf(f);f=t(f))Object.getOwnPropertyNames(f).forEach(function(n){i[n]=function(){return e[n]}});return i["default"]=function(){return e},r.d(u,i),u}}(),function(){r.d=function(n,t){for(var e in t)r.o(t,e)&&!r.o(n,e)&&Object.defineProperty(n,e,{enumerable:!0,get:t[e]})}}(),function(){r.g=function(){if("object"===typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(n){if("object"===typeof window)return window}}()}(),function(){r.o=function(n,t){return Object.prototype.hasOwnProperty.call(n,t)}}(),function(){r.r=function(n){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(n,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(n,"__esModule",{value:!0})}}(),function(){r.p="/"}(),function(){var n={121:0};r.O.j=function(t){return 0===n[t]};var t=function(t,e){var o,u,i=e[0],f=e[1],c=e[2],a=0;if(i.some(function(t){return 0!==n[t]})){for(o in f)r.o(f,o)&&(r.m[o]=f[o]);if(c)var l=c(r)}for(t&&t(e);a<i.length;a++)u=i[a],r.o(n,u)&&n[u]&&n[u][0](),n[u]=0;return r.O(l)},e=wx["webpackJsonp"]=wx["webpackJsonp"]||[];e.forEach(t.bind(null,0)),e.push=t.bind(null,e.push.bind(e))}()})();
|
||||
(function(){"use strict";var n={},t={};function r(e){var o=t[e];if(void 0!==o)return o.exports;var u=t[e]={exports:{}};return n[e](u,u.exports,r),u.exports}r.m=n,function(){var n=[];r.O=function(t,e,o,u){if(!e){var i=1/0;for(l=0;l<n.length;l++){e=n[l][0],o=n[l][1],u=n[l][2];for(var f=!0,c=0;c<e.length;c++)(!1&u||i>=u)&&Object.keys(r.O).every(function(n){return r.O[n](e[c])})?e.splice(c--,1):(f=!1,u<i&&(i=u));if(f){n.splice(l--,1);var a=o();void 0!==a&&(t=a)}}return t}u=u||0;for(var l=n.length;l>0&&n[l-1][2]>u;l--)n[l]=n[l-1];n[l]=[e,o,u]}}(),function(){r.n=function(n){var t=n&&n.__esModule?function(){return n["default"]}:function(){return n};return r.d(t,{a:t}),t}}(),function(){var n,t=Object.getPrototypeOf?function(n){return Object.getPrototypeOf(n)}:function(n){return n.__proto__};r.t=function(e,o){if(1&o&&(e=this(e)),8&o)return e;if("object"===typeof e&&e){if(4&o&&e.__esModule)return e;if(16&o&&"function"===typeof e.then)return e}var u=Object.create(null);r.r(u);var i={};n=n||[null,t({}),t([]),t(t)];for(var f=2&o&&e;"object"==typeof f&&!~n.indexOf(f);f=t(f))Object.getOwnPropertyNames(f).forEach(function(n){i[n]=function(){return e[n]}});return i["default"]=function(){return e},r.d(u,i),u}}(),function(){r.d=function(n,t){for(var e in t)r.o(t,e)&&!r.o(n,e)&&Object.defineProperty(n,e,{enumerable:!0,get:t[e]})}}(),function(){r.g=function(){if("object"===typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(n){if("object"===typeof window)return window}}()}(),function(){r.o=function(n,t){return Object.prototype.hasOwnProperty.call(n,t)}}(),function(){r.r=function(n){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(n,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(n,"__esModule",{value:!0})}}(),function(){var n={121:0};r.O.j=function(t){return 0===n[t]};var t=function(t,e){var o,u,i=e[0],f=e[1],c=e[2],a=0;if(i.some(function(t){return 0!==n[t]})){for(o in f)r.o(f,o)&&(r.m[o]=f[o]);if(c)var l=c(r)}for(t&&t(e);a<i.length;a++)u=i[a],r.o(n,u)&&n[u]&&n[u][0](),n[u]=0;return r.O(l)},e=wx["webpackJsonp"]=wx["webpackJsonp"]||[];e.forEach(t.bind(null,0)),e.push=t.bind(null,e.push.bind(e))}()})();
|
||||
2
dist/vendors.js
vendored
2
dist/vendors.js
vendored
File diff suppressed because one or more lines are too long
309
docs/功能缺陷审查报告.md
309
docs/功能缺陷审查报告.md
@@ -1,170 +1,303 @@
|
||||
# 汪圈小程序 — 功能逻辑缺陷审查报告
|
||||
# 汪圈小程序 — 功能逻辑缺陷审查报告 v2.0
|
||||
|
||||
> 审查日期:2026-06-18
|
||||
> 审查范围:全部 10 个页面、10 个 Service、23 个云函数、3 个类型文件、关键组件
|
||||
> 审查日期:2026-06-19
|
||||
> 基线版本:v1.0 报告审查后的最新代码
|
||||
> 审查范围:全部 11 个页面、10 个 Service、25+ 个云函数、4 个类型文件、3 个 Store/Hook、1 个新增 `cloud-file` 工具模块
|
||||
> 审查分支:`master`
|
||||
|
||||
---
|
||||
|
||||
## v1 → v2 修复总结
|
||||
|
||||
v1 报告共提出 **36 条** 问题(含跨页面系统性问题 6 条),本次审查确认的修复情况如下:
|
||||
|
||||
| 类别 | v1 问题数 | ✅ 已修复 | ⚠️ 部分修复 | ❌ 未修复 | 🆕 新发现 |
|
||||
|------|-----------|-----------|-------------|-----------|-----------|
|
||||
| 广场页 | 7 | 5 | 0 | 1 | 3 |
|
||||
| 附近页 | 5 | 4 | 0 | 0 | 2 |
|
||||
| 发布页 | 4 | 4 | 0 | 0 | 2 |
|
||||
| 消息页 | 4 | 4 | 0 | 0 | 0 |
|
||||
| 聊天页 | 6 | 6 | 0 | 0 | 2 |
|
||||
| 个人资料页 | 2 | 0 | 1 | 0 | 1 |
|
||||
| 资料编辑页 | 3 | 1 | 0 | 2 | 0 |
|
||||
| 宠物编辑页 | 2 | 0 | 0 | 2 | 0 |
|
||||
| 联系人页 | 3 | 0 | 1 | 2 | 0 |
|
||||
| 我的动态页 | 3 | 2 | 0 | 0 | 2 |
|
||||
| 帖子详情页 | — | — | — | — | 4 (新增页) |
|
||||
| 跨页面系统 | 6 | 4 | 0 | 2 | 2 |
|
||||
| **合计** | **36+** | **26** | **2** | **7** | **18** |
|
||||
|
||||
---
|
||||
|
||||
## 一、广场页 `pages/plaza/index.tsx`
|
||||
|
||||
### ✅ v1 修复确认
|
||||
|
||||
| v1 # | 问题 | 状态 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 1 | Feed 无分页 / 无限滚动 | ✅ 已修复 | 引入 `nextCursor`、`loadNextPage`、`PageShell lowerThreshold={160}`、`loadingMore` 状态,实现了完整的无限滚动。 |
|
||||
| 2 | 话题列表硬编码 | ✅ 已修复 | 引入 `topicOptions` 状态 + `normalizeTopicOptions()`,从云端 `result.topics` 动态合并。 |
|
||||
| 3 | 广告卡片位置固定 | ✅ 已修复 | 引入 `createAdStartIndex()` 随机起点 + `AD_INTERVAL = 6` 间隔,每次加载重新随机化。 |
|
||||
| 5 | 没有帖子详情页 | ✅ 已修复 | 新增 `pages/post-detail/index` 页面,`PostCard` 支持 `onOpen` 导航。 |
|
||||
| 6 | 点赞乐观更新竞态 | ✅ 已修复 | 引入 `feedRequestSeq` + `pendingLikeIds` / `pendingLikeOverrides` 防重复机制,失败时回滚到 `previousPost` 快照。 |
|
||||
| 7 | 搜索栏无功能 | ✅ 已修复 | 绑定 `onInput` + 300ms 防抖 `debouncedKeyword`,搜索时展示结果计数 + 清除按钮。 |
|
||||
|
||||
### ❌ v1 遗留
|
||||
|
||||
| v1 # | 问题 | 说明 |
|
||||
|------|------|------|
|
||||
| 4 | 收藏功能入口缺失 | `Post.favoritedByMe` 字段和 `feed.service.setPostFavorite()` 仍然存在,`PostCard` 仍然没有收藏操作的 UI 入口。跨页面系统性问题 #2 未解决。 |
|
||||
|
||||
### 🆕 v2 新发现
|
||||
|
||||
| # | 缺陷 | 严重程度 | 位置 | 说明 |
|
||||
|---|------|----------|------|------|
|
||||
| 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` 回调,搜索框为纯装饰。 |
|
||||
| 1 | **`loadFeed` 依赖数组包含 `debouncedKeyword` 导致频繁重置** | 🟡 中 | `useEffect` (L156-158) | `loadFeed` 的 `useCallback` 依赖了 `debouncedKeyword`。防抖 300ms 后 `debouncedKeyword` 变化 → `loadFeed` 重建 → `useEffect` 触发 → 清空列表重新加载。用户每输入一个字符(停顿 300ms 后),整个列表会被清空重载一次,体验上表现为列表频繁闪烁空白再重新出现。应将 `loadFeed` 拆分为"参数变化时的首次加载"和"追加加载",或使用 ref 追踪 keyword 变化而非作为 `useCallback` 依赖。 |
|
||||
| 2 | **`PostCard` 移除了 `more` 按钮但无替代操作** | 🟡 中 | `PostCard` (L44-66) | v1 中 `PostCard` 有一个 `more` 图标按钮(三点点),v2 中完全移除了。用户无法对帖子执行更多操作(举报、分享、复制链接等),丢失了内容管理入口。 |
|
||||
| 3 | **话题搜索结果与关键词搜索混合逻辑不一致** | 🟡 中 | 搜索逻辑 (L278-306) | 关键词搜索通过 `debouncedKeyword` 传给 `getFeedList`,服务端过滤。但话题筛选通过 `topic` 参数传给同一个 `getFeedList`。两者不能同时生效——用户选择了某个话题后又输入搜索词,行为不明确。UI 上也没有层级提示告诉用户"在话题内搜索"还是"全局搜索"。 |
|
||||
|
||||
---
|
||||
|
||||
## 二、附近页 `pages/nearby/index.tsx`
|
||||
|
||||
### ✅ v1 修复确认
|
||||
|
||||
| v1 # | 问题 | 状态 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 1 | 没有调用 `updateLocation` 上报自身位置 | ✅ 已修复 | 获取位置后调用 `updateLocation(loc, true)`,`relocate` 时也会更新,`nearbyVisible === false` 时传 `null, false`。 |
|
||||
| 2 | 没有尊重 `preferences.nearbyVisible` 偏好 | ✅ 已修复 | 先 `getProfile()` 读取偏好,`nearbyVisible === false` 时不请求位置、清空宠物列表、不上报位置。 |
|
||||
| 3 | `likePet` 没有乐观更新和错误回滚 | ✅ 已修复 | 引入 `pendingLikeIds`、乐观设置 `likedByMe: true`,失败时回滚到 `previousLiked`。 |
|
||||
| 4 | `filter: 'online'` 后端过滤能力不确定 | ✅ 已修复 | `nearby.service.ts` 新增 `filterNearbyPets()` 前端兜底过滤。`nearbyPets` 云函数也已支持 `online` filter。 |
|
||||
|
||||
### 🆕 v2 新发现
|
||||
|
||||
| # | 缺陷 | 严重程度 | 位置 | 说明 |
|
||||
|---|------|----------|------|------|
|
||||
| 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 四种,缺少按距离、年龄段、标签等维度。 |
|
||||
| 1 | **`nearbyVisible` 为 `null` 时地图显示默认中心上海但无数据** | 🟡 中 | 初始化流程 (L32, L57-83) | `nearbyVisible` 初始值为 `null`(等待 `getProfile` 返回),此时地图渲染 `DEFAULT_CENTER`(上海),`center` 为 `null` 所以 `loadPets` 不触发,宠物列表为空。用户看到一个空地图和空列表,直到 `getProfile` 完成(可能 1-2 秒)。应显示加载指示器或骨架屏。 |
|
||||
| 2 | **没有清理自身旧位置(登出 / 关闭可见性)** | 🟢 低 | `nearbyVisible === false` 分支 (L61-67) | 调用 `updateLocation(null, false)` 但没有确认云函数是否正确处理 `null` location(从数据库中移除旧坐标)。如果云函数只是不写入,用户的旧位置数据会一直留在数据库中。 |
|
||||
|
||||
---
|
||||
|
||||
## 三、发布页 `pages/publish/index.tsx`
|
||||
|
||||
### ✅ v1 修复确认
|
||||
|
||||
| v1 # | 问题 | 状态 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 1 | 没有恢复草稿的逻辑 | ✅ 已修复 | `useEffect` 调用 `getDraft()`,通过 `hasDraftContent()` 判断后 `restoreDraft()` 回填所有表单字段。 |
|
||||
| 2 | 没有内容字数上限预警 | ✅ 已修复 | `canSubmit` 检查 `textLength <= 2000`,超限时 `submit()` 弹 `toast: '正文最多 2000 字'`。 |
|
||||
| 3 | 图片上传失败静默跳过 | ✅ 已修复 | 新增 `UploadPostMediaError` 自定义错误类,`showUploadFailure()` 弹窗明确告知第几张图片上传失败。 |
|
||||
| 4 | 返回没有未保存提示 | ✅ 已修复 | `handleClose()` 检查 `hasContent`,弹窗询问"保存草稿?" 或 "不保存"离开。 |
|
||||
|
||||
### 🆕 v2 新发现
|
||||
|
||||
| # | 缺陷 | 严重程度 | 位置 | 说明 |
|
||||
|---|------|----------|------|------|
|
||||
| 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`,有内容时弹窗确认。 |
|
||||
| 1 | **`useUploadMedia` 的 `setMedia` 暴露了内部 setter** | 🟢 低 | `useUploadMedia` 解构 (L53) | `const { media, setMedia, addImages, removeMedia } = useUploadMedia([])` — 直接暴露 `setMedia` 并在 `restoreDraft` 中使用。绕过了 `useUploadMedia` 的内部管理逻辑,如果该 hook 未来添加了额外状态(如上传状态),直接 `setMedia` 不会触发这些逻辑。应通过 hook 暴露一个 `replaceMedia` 方法。 |
|
||||
| 2 | **草稿恢复成功后宠物默认选中逻辑冲突** | 🟡 中 | `useEffect` (L81-87) | `getPetList` 的 `useEffect` 有 `setSelectedPetId(prev => prev \|\| (draftRestoredRef.current ? '' : list[0]._id))`。当草稿恢复成功时 `draftRestoredRef.current = true`,但如果草稿没有设置 `petId`(`draft.petId` 为空),宠物不会被默认选中——这可能不是用户期望的(用户之前可能选中了一个宠物但草稿没保存 petId)。 |
|
||||
|
||||
---
|
||||
|
||||
## 四、消息页 `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>`),但清空搜索后立即恢复,交互上不连贯。 |
|
||||
### ✅ v1 修复确认
|
||||
|
||||
| v1 # | 问题 | 状态 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 1 | `markConversationRead` 是 fire-and-forget | ✅ 已修复 | `openConversation` 中正确 `await markConversationRead`,失败时回滚 `unreadCount`。`navigateTo` 失败时也回滚。 |
|
||||
| 2 | 没有区分 single / system 会话交互 | ✅ 已修复 | `item.type === 'system'` 时直接调用 `markConversationRead` 标记已读并 toast "系统消息已读",不再跳转聊天页。 |
|
||||
| 3 | 没有消息通知 / 推送机制 | ⚠️ 部分修复 | — | 见跨页面系统问题 #3(推送机制仍未接入,但消息页本身已正确处理未读状态)。 |
|
||||
| 4 | 搜索不会过滤 activeUsers | ✅ 已修复 | 搜索时也过滤 `activeUsers` → `visibleActiveUsers`,有匹配时显示"在线汪友"分区。 |
|
||||
|
||||
### v2 无新增问题
|
||||
|
||||
---
|
||||
|
||||
## 五、聊天页 `pages/chat/index.tsx`
|
||||
|
||||
### ✅ v1 修复确认
|
||||
|
||||
| v1 # | 问题 | 状态 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 1 | 消息列表没有分页加载历史 | ✅ 已修复 | 引入 `loadOlder`、`nextCursor`、`upperThreshold={48}` 触发加载。加载后滚动到锚点消息位置。 |
|
||||
| 2 | 轮询频率固定 5 秒 | ✅ 已修复 | 引入 `ACTIVE_POLL_MS = 3000` / `IDLE_POLL_MS = 12000` / `ACTIVE_POLL_WINDOW_MS = 60000` 三档智能调节。 |
|
||||
| 3 | 发送失败时输入框闪空 | ✅ 已修复 | `send()` 中不再先 `setDraft('')`,而是在 `sendMessage` 成功后才 `setDraft('')`。`sendImageMessage` 也类似处理。 |
|
||||
| 4 | 没有消息发送状态指示 | ✅ 已修复 | `sending` 状态驱动 UI:按钮显示"发送中"、输入框 `disabled`、工具栏按钮 disabled。 |
|
||||
| 5 | `scrollTop` 递增 hack 不可靠 | ✅ 已修复 | 改用 `scrollIntoView` + 动态 `bottomAnchorId`(`scrollSeq` 递增)实现可靠滚动到底部。 |
|
||||
| 6 | 不支持图片 / 表情消息 | ✅ 已修复 | 新增图片发送 (`sendImages`)、表情面板 (`EMOJI_OPTIONS`)、图片消息展示 + 预览 (`previewImage`)。 |
|
||||
|
||||
### 🆕 v2 新发现
|
||||
|
||||
| # | 缺陷 | 严重程度 | 位置 | 说明 |
|
||||
|---|------|----------|------|------|
|
||||
| 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` 字段存在但没有被使用,输入区域也没有附件按钮。 |
|
||||
| 1 | **图片消息只显示非 `cloud://` URL 的图片** | 🔴 高 | 图片渲染逻辑 (L442) | `m.content && !m.content.startsWith('cloud://')` — 当消息的 content 是 `cloud://` 文件 ID(已上传到云端但 `resolveCloudFileUrl` 没有成功转换时),会显示"图片暂不可用"文本气泡。`messageThread` 云函数和 `resolveThreadAssets` 应该已经解析了这些 URL,但如果解析失败,用户会看到"图片暂不可用",没有重试机制。更严重的是,任何以 `cloud://` 开头的内容都被认为是"不可用图片"而非普通文本,如果某些消息的 type 被错误标记为 `image` 但 content 是 cloud ID,将无法展示。 |
|
||||
| 2 | **`loadLatest` 提前返回时可能不清除 `loadingInitial`** | 🟢 低 | `loadLatest` (L183-220) | 如果 `requestId !== latestRequestSeq.current` 导致提前 return,`loadingInitial` 不会被清除——但 `finally` 块仍会执行(return 在 try 块内),所以实际不会出现此问题。仅限极端竞态场景。 |
|
||||
|
||||
---
|
||||
|
||||
## 六、个人资料页 `pages/profile/index.tsx`
|
||||
|
||||
### ⚠️ v1 部分修复
|
||||
|
||||
| v1 # | 问题 | 状态 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 1 | 偏好 `changePreference` 无错误回滚 | ⚠️ 部分修复 | `updateProfile` 内部调用 `markStale('profile')`,数据会在下次 `useRefreshOnShow` 时刷新。但 `changePreference` 本身仍然没有 `try-catch`——如果 `updateProfile` 抛出异常,UI 状态已改但数据未持久化,直到下次刷新才恢复。用户仍然会看到一个短暂的"假成功"状态。 |
|
||||
|
||||
### 🆕 v2 新发现
|
||||
|
||||
| # | 缺陷 | 严重程度 | 位置 | 说明 |
|
||||
|---|------|----------|------|------|
|
||||
| 1 | **偏好 `changePreference` 无错误回滚** | 🟡 中 | `changePreference()` (L92-102) | 先乐观更新 UI 再 `await updateProfile`,但整个函数没有 `try-catch`。如果云端更新失败,UI 状态已改但数据未持久化,下次加载会回滚,造成用户困惑(以为保存了但实际没有)。 |
|
||||
| 2 | **"关于"功能是空壳** | 🟢 低 | `handleAction('about')` (L89) | 只弹 `toast: '敬请期待新功能'`。MVP 阶段可接受,但应在版本规划中补充。 |
|
||||
| 1 | **`useSession` 初始化时 `ensureLogin` 与 `ProfilePage` 自身的 `ensureLogin` 重复调用** | 🟢 低 | `useEffect` (L40-47) | `useSession` hook 内部在 mount 时已经调用了 `ensureLogin()`,`ProfilePage` 在 `useEffect` 中又调用了一次。虽然有 `inflight` 去重机制不会产生两次网络请求,但代码结构上造成了理解困惑。可考虑依赖 `useSession` 的 user 即可。 |
|
||||
|
||||
---
|
||||
|
||||
## 七、资料编辑页 `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()` 没有检测是否有未保存修改。应比较当前表单值与初始值的差异,有差异时弹窗确认。 |
|
||||
### ❌ v1 遗留
|
||||
|
||||
| v1 # | 问题 | 状态 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 1 | `type='nickname'` 仅真机生效 | ❌ 未修复 | 仍然是 `type='nickname'`,没有非真机环境的校验提示。这是微信平台限制,可接受。 |
|
||||
| 2 | `avatarUrl` 与 `avatarKey` 共存冲突 | ❌ 未修复 | 新增了 `avatarDisplayUrl` 变量(L25: `avatarUrl && !avatarUrl.startsWith('cloud://') ? avatarUrl : ''`),解决了 `cloud://` ID 在 `<Image>` 中无法显示的问题。但 `avatarKey` 在头像已上传的情况下仍然是一个无用的遗留值,`Avatar` 组件和 `Image` 组件的优先级关系仍不明确。 |
|
||||
| 3 | 没有未保存离开提示 | ❌ 未修复 | 关闭按钮仍然是直接 `navigateBack()`,没有检测是否有未保存修改。 |
|
||||
|
||||
### v2 无新增问题
|
||||
|
||||
---
|
||||
|
||||
## 八、宠物编辑页 `pages/pet-edit/index.tsx`
|
||||
|
||||
| # | 缺陷 | 严重程度 | 位置 | 说明 |
|
||||
|---|------|----------|------|------|
|
||||
| 1 | **没有宠物数量上限限制** | 🟡 中 | `submit()` (L132-168) | 没有检查用户已添加的宠物数量。用户可以无限添加宠物,可能导致数据膨胀。建议在后端限制(如最多 5 只),前端也做预检提示。 |
|
||||
| 2 | **照片隐式非必填但交互暗示必填** | 🟡 中 | `choosePhoto()` + 验证逻辑 | 提交验证只检查 `name` 和 `breed`,无照片也能保存。但展示逻辑依赖 `photoKey` 的兜底图,用户可能以为照片是必须的。建议明确标注照片为"可选",或在空照片时展示更友好的占位。 |
|
||||
### ❌ v1 遗留
|
||||
|
||||
| v1 # | 问题 | 状态 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 1 | 没有宠物数量上限限制 | ❌ 未修复 | `PetShelf` 组件仍然无条件显示"添加宠物"按钮。后端也没有数量限制。 |
|
||||
| 2 | 照片隐式非必填但交互暗示必填 | ❌ 未修复 | 提交验证只检查 `name` 和 `breed`,无照片也能保存。新增了 `photoDisplayUrl` 逻辑(L81),解决了 `cloud://` ID 显示问题。 |
|
||||
|
||||
### v2 无新增问题
|
||||
|
||||
---
|
||||
|
||||
## 九、联系人页 `pages/contacts/index.tsx`
|
||||
|
||||
| # | 缺陷 | 严重程度 | 位置 | 说明 |
|
||||
|---|------|----------|------|------|
|
||||
| 1 | **"发现"搜索需手动提交,不支持实时搜索** | 🟡 中 | `<Input onConfirm>` (L101-102) | 搜索需要点击键盘"搜索"按钮才触发 (`onConfirm`),没有 `onInput` 实时触发搜索。对于"发现陌生人"场景,用户期望输入即搜索,应增加防抖实时搜索。 |
|
||||
| 2 | **点击用户只能发起聊天,无个人主页入口** | 🟡 中 | `openChat()` (L63-67) | 点击用户头像/名称只能跳转到聊天页,没有查看对方个人主页(宠物列表、帖子等)的选项。用户想了解对方更多信息需要退出再找,交互断裂。 |
|
||||
| 3 | **互关后 `isFriend` 未即时更新** | 🟡 中 | `onFollow()` (L51-61) | `toggleFollow` 返回 `{ mutual }` 但只更新了 `isFollowing`,没有同步更新 `isFriend`。互关后列表中不会立即显示"汪友"标签,需手动刷新页面才能看到。 |
|
||||
### ⚠️ v1 部分修复
|
||||
|
||||
| v1 # | 问题 | 状态 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 3 | 互关后 `isFriend` 未即时更新 | ⚠️ 部分修复 | v1 报告指出 `onFollow` 只更新 `isFollowing` 不更新 `isFriend`。当前代码仍然只更新 `isFollowing`,`isFriend` 未同步。需调用 `toggleFollow` 后根据返回的 `mutual` 更新 `isFriend`。 |
|
||||
|
||||
### ❌ v1 遗留
|
||||
|
||||
| v1 # | 问题 | 状态 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 1 | "发现"搜索需手动提交 | ❌ 未修复 | 仍然只有 `onConfirm` 触发搜索,没有实时搜索或防抖输入触发。 |
|
||||
| 2 | 点击用户只能发起聊天 | ❌ 未修复 | 仍然没有用户个人主页入口。 |
|
||||
|
||||
### v2 无新增问题
|
||||
|
||||
---
|
||||
|
||||
## 十、我的动态页 `pages/my-posts/index.tsx`
|
||||
|
||||
### ✅ v1 修复确认
|
||||
|
||||
| v1 # | 问题 | 状态 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 2 | `useDidShow` 首次触发导致重复加载 | ✅ 已修复 | 改用 `useRefreshOnShow('myPosts', () => load(true))`,首次 onShow 被跳过。 |
|
||||
|
||||
### ❌ v1 遗留
|
||||
|
||||
| v1 # | 问题 | 状态 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 3 | `likePost` 没有错误回滚 | ❌ 未修复 | `likePost` 仍然没有 try-catch 和回滚逻辑——`await setPostLike(postId, target.likedByMe)` 失败时不会恢复之前的点赞状态。 |
|
||||
|
||||
### 🆕 v2 新发现
|
||||
|
||||
| # | 缺陷 | 严重程度 | 位置 | 说明 |
|
||||
|---|------|----------|------|------|
|
||||
| 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 | **仍然没有分页加载** | 🟡 中 | `load()` (L21-31) | `userPosts` 云函数硬编码 `limit(50)`,前端一次性加载全部 50 条。如果用户帖子超过 50 条,超出部分无法查看。应添加 `cursor` / `pageSize` 参数到 `userPosts` 云函数和前端。 |
|
||||
| 2 | **`likePost` 通过 `onPostLike` 事件同步但有重复调用** | 🟡 中 | `likePost` (L49-63) | `likePost` 做了乐观更新 + 调用 `setPostLike`(后者通过 `emitPostLike` 广播)。同时还有一个 `useEffect` 订阅 `onPostLike` 事件。当用户在"我的动态"页点赞时,`setPostLike` → `emitPostLike` → 本页的 `onPostLike` 监听器也会触发 `setPosts`,导致同一条帖子的点赞状态被**设置了两次**(第一次是 `likePost` 直接设置,第二次是事件监听器设置)。虽然结果相同,但多了一次不必要的 re-render。 |
|
||||
|
||||
---
|
||||
|
||||
## 十一、跨页面系统性问题
|
||||
## 十一、帖子详情页 `pages/post-detail/index.tsx` 🆕 新增页面
|
||||
|
||||
| # | 问题 | 影响范围 | 说明 |
|
||||
|---|------|----------|------|
|
||||
| 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 不会报错但运行时可能失败。 |
|
||||
| # | 缺陷 | 严重程度 | 位置 | 说明 |
|
||||
|---|------|----------|------|------|
|
||||
| 1 | **评论缺少分页加载** | 🟡 中 | `load()` (L30-59) | `postDetail` 云函数一次返回最多 200 条评论(`safeGet('comments', { postId }, 200)`),前端没有分页。如果帖子评论超过 200 条,超出部分无法查看。 |
|
||||
| 2 | **`likePost` 的乐观更新使用闭包中的 `post` 而非 ref** | 🟡 中 | `likePost` (L66-85) | `const previousPost = post`(L82 回滚时使用)来自当前渲染闭包中的 `post` state。如果用户快速双击点赞(第一次 `setPost(next)` 还没 re-render,第二次进入时 `post` 仍是旧值),乐观更新的"之前"状态会不准确。应使用 ref 追踪最新的 post。 |
|
||||
| 3 | **发送评论后新评论的 `author` 硬编码为"我"** | 🟡 中 | `send()` (L87-116) | 新评论被乐观添加到列表,但 `author: { id: 'me', name: '我', avatarKey: 'gradient-avatar-1' }` 是硬编码的,没有使用当前用户的真实昵称和头像。用户看到自己评论显示为"我"而非自己的昵称。应从 `useSession()` 获取当前用户信息。 |
|
||||
| 4 | **`load` 函数的 `finally` 中总是设置 `commentsLoaded = true`** | 🟢 低 | `load()` (L58) | 即使 `getPostDetail` 抛出异常,`commentsLoaded` 也会被设为 `true`,导致"评论加载中…"变成"还没有评论"。但此时用户不知道加载失败了(没有错误提示)。应在 catch 中显示错误状态。 |
|
||||
|
||||
---
|
||||
|
||||
## 十二、修复优先级建议
|
||||
## 十二、跨页面系统性问题
|
||||
|
||||
### P0 — 必须立即修复(影响核心功能)
|
||||
### ✅ v1 修复确认
|
||||
|
||||
1. **Feed / 我的动态分页** — 社交应用内容流的基础能力
|
||||
2. **发布页草稿恢复** — `getDraft()` 已存在,只需在页面 mount 时调用
|
||||
3. **附近页上报自身位置** — `updateLocation()` 已存在,只需在获取位置后调用
|
||||
| v1 # | 问题 | 状态 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 3 | 草稿系统断裂 | ✅ 已修复 | 发布页 mount 时调用 `getDraft()`,`resolveDraftMedia()` 解析草稿中的 cloud file URL,表单完整回填。 |
|
||||
| 5 | 没有全局错误边界 | ✅ 已修复 | 各页面和 service 层普遍添加了 try-catch 和错误回滚。虽然不是 React ErrorBoundary,但每个操作都有用户可感知的错误提示。 |
|
||||
| 6 | `CloudFunctionName` 缺少 `commentCreate` | ✅ 已修复 | `cloud.ts` 中已包含 `commentCreate`。 |
|
||||
|
||||
### P1 — 短期内修复(影响用户体验)
|
||||
### ❌ v1 遗留
|
||||
|
||||
4. 我的动态页 `useDidShow` 重复加载 → 添加 `firstShow` ref
|
||||
5. 我的动态页 `likePost` 错误回滚
|
||||
6. 附近页 `likePet` 添加乐观更新
|
||||
7. 附近页尊重 `nearbyVisible` 偏好
|
||||
8. 聊天页消息分页加载
|
||||
9. 个人资料页 `changePreference` 添加 `try-catch`
|
||||
10. 收藏功能 UI 入口补全
|
||||
| v1 # | 问题 | 说明 |
|
||||
|------|------|------|
|
||||
| 1 | 全应用没有全局登录态守卫 | `session.store.ts` 实现了共享 session + `ensureLogin`,`app.tsx` 在 launch 时预热登录。但广场、附近、消息、发布等页面仍然**没有检查登录态**——未登录用户能看到空数据但不会跳转到登录页。 |
|
||||
| 4 | `StatsRow` 数据可能与子页面不同步 | 未修复。`profile.getProfile()` 和 `contacts.getFollowList()` 返回的计数仍然可能因缓存延迟不一致。 |
|
||||
|
||||
### P2 — 中期迭代(完善产品体验)
|
||||
### 🆕 v2 新发现
|
||||
|
||||
11. 搜索栏功能实现(广场页)
|
||||
12. 联系人页实时搜索
|
||||
13. 聊天页发送状态指示 + 发送失败 UX
|
||||
14. 未保存离开提示(发布页、编辑页)
|
||||
15. 全局登录态守卫
|
||||
16. 全局错误边界
|
||||
17. 消息通知 / 推送接入
|
||||
| # | 问题 | 影响范围 | 严重程度 | 说明 |
|
||||
|---|------|----------|----------|------|
|
||||
| 1 | **`cloud-file.ts` 的 `tempUrlCache` 永不过期** | 全应用 | 🟡 中 | `resolveCloudFileUrls` 将 cloud file ID 到临时 URL 的映射缓存在内存 Map 中,但**永不清除**。长时间使用小程序后,这个 Map 会持续增长(每次加载帖子/头像/评论都添加条目)。虽然单个 URL 映射很小,但如果用户大量浏览内容(数百帖子,数千评论),累积效果不应忽视。应添加 LRU 或 TTL 淘汰策略。 |
|
||||
| 2 | **`dataBus.ts` 的 `postCache` 永不清除** | 跨页面 | 🟢 低 | `cachePost` 将帖子数据存入全局 Map,`getCachedPost` 只在 `PostDetailPage` 中调用。这个 Map 永远不会被清除——如果用户浏览了很多帖子,所有数据都会留在内存中。应在一定时间后或 Map 大小超过阈值时清除。 |
|
||||
| 3 | **`messageList` 云函数中 `peerOpenids` 过滤的变量遮蔽** | 消息页 | 🟡 中 | `messageList/index.js` L88: `(c.memberOpenids \|\| []).find(openid => openid !== OPENID)` — `find` 的回调参数名 `openid` 遮蔽了外层的 `OPENID` 常量。逻辑上结果正确(参数 `openid` !== `OPENID` 等价于"找到不是我的那个成员"),但变量命名遮蔽在未来维护时容易引入真实 bug。应重命名回调参数(如 `member`)。 |
|
||||
| 4 | **`messageThread` 云函数每次轮询都执行一次已读标记 update** | 聊天页 → 消息页 | 🟢 低 | `messageThread/index.js` L97-99: 每次请求消息列表都会 `update` conversation 清除未读。聊天页的轮询(3-12 秒一次)会频繁触发不必要的数据库写入。如果两个用户同时在看同一会话,一方的轮询会清除另一方的未读状态——虽然 `_.pull(OPENID)` 只清除当前用户,但频繁 update 仍是不必要的开销。应区分首次加载(标记已读)和轮询刷新(不标记已读)。 |
|
||||
| 5 | **`nearbyPets` 云函数不按距离排序** | 附近页 | 🔴 高 | `nearbyPets/index.js` L86: `db.collection('pets').where(query).limit(50).get()` — 没有 `.orderBy` 按距离排序。返回的宠物列表按 MongoDB 默认的写入顺序排列,用户看到的附近宠物不是按距离远近排列的。对于"附近"功能来说,距离排序是核心体验。应使用 geoNear 或在应用层按 `distanceText` 排序。 |
|
||||
|
||||
### P3 — 长期优化(性能和扩展性)
|
||||
---
|
||||
|
||||
18. 话题列表动态化
|
||||
19. 广告系统正规化
|
||||
20. 图片 / 富媒体消息
|
||||
21. 帖子详情页 + 评论列表
|
||||
22. 聊天页轮询策略优化(WebSocket)
|
||||
23. 宠物数量上限
|
||||
24. 用户个人主页入口(从联系人页)
|
||||
## 十三、修复优先级建议
|
||||
|
||||
### P0 — 必须立即修复
|
||||
|
||||
1. **`nearbyPets` 云函数不按距离排序** — "附近"功能的核心体验缺失,用户期望看到最近的宠物排在最前面
|
||||
2. **聊天页图片消息 `cloud://` 不显示** — 用户发送/接收的图片消息可能显示"图片暂不可用"
|
||||
3. **`messageList` 云函数 `peerOpenids` 变量遮蔽** — 代码可维护性风险,未来修改时容易引入真实 bug
|
||||
|
||||
### P1 — 短期内修复
|
||||
|
||||
4. 联系人页 `onFollow` 互关后未即时更新 `isFriend`
|
||||
5. 联系人页"发现"搜索无实时搜索
|
||||
6. 我的动态页 `likePost` 错误回滚
|
||||
7. 我的动态页无分页 + `userPosts` 云函数 `limit(50)` 硬编码
|
||||
8. 广场页搜索输入导致列表频繁重载(`debouncedKeyword` → `loadFeed` 重建 → 清空列表)
|
||||
9. 帖子详情页评论 `author` 硬编码"我"
|
||||
10. 收藏功能 UI 入口补全(`PostCard` 添加收藏按钮)
|
||||
11. `changePreference` 添加 `try-catch`
|
||||
12. 帖子详情页 `likePost` 闭包问题 → ref
|
||||
|
||||
### P2 — 中期迭代
|
||||
|
||||
13. 资料编辑页 / 宠物编辑页未保存离开提示
|
||||
14. 帖子详情页评论分页
|
||||
15. `PostCard` 恢复"更多操作"按钮
|
||||
16. 宠物数量上限
|
||||
17. 全局登录态守卫(非 TabBar 页面检查登录)
|
||||
18. 联系人页添加用户个人主页入口
|
||||
19. `messageThread` 轮询时避免不必要的已读标记 update
|
||||
20. 附近页 `nearbyVisible` 加载状态提示
|
||||
|
||||
### P3 — 长期优化
|
||||
|
||||
21. `cloud-file.ts` tempUrlCache 添加 TTL / LRU 淘汰
|
||||
22. `dataBus.ts` postCache 添加大小限制 / TTL
|
||||
23. 消息通知 / 推送接入
|
||||
24. 用户个人主页(完整帖子列表 + 宠物列表展示)
|
||||
25. 广场页话题与关键词搜索混合逻辑优化
|
||||
|
||||
---
|
||||
|
||||
|
||||
343
docs/功能缺陷核验与修复设计.md
Normal file
343
docs/功能缺陷核验与修复设计.md
Normal file
@@ -0,0 +1,343 @@
|
||||
# 汪圈小程序 - 功能缺陷核验与修复设计
|
||||
|
||||
> 核验日期:2026-06-20
|
||||
> 来源报告:`docs/功能缺陷审查报告.md`
|
||||
> 核验基线:当前工作区源码,重点核验 `src` 与 `cloudfunctions`,忽略 `dist` 构建产物
|
||||
> 目标:逐项确认报告中仍被标为缺陷/风险的条目是否真实存在,并为需要修改的条目形成可落地设计
|
||||
|
||||
## 1. 总体结论
|
||||
|
||||
报告中的“已修复确认”条目,当前代码整体能支撑报告结论;本次未发现需要重新打开的已修复项。消息页、发布页、附近页、聊天页的主要 v1 修复点均能在当前实现中找到对应逻辑。
|
||||
|
||||
报告中仍被标为遗留、新发现或系统性问题的条目共核验 35 项:
|
||||
|
||||
| 结论 | 数量 | 说明 |
|
||||
|------|------|------|
|
||||
| 成立 | 24 | 当前代码中确实存在对应行为或缺口 |
|
||||
| 部分成立 | 5 | 现象存在,但报告描述、严重程度或根因需要修正 |
|
||||
| 不成立 | 6 | 当前代码已解决或报告判断与实现不符 |
|
||||
|
||||
需要特别修正报告优先级:
|
||||
|
||||
- `nearbyPets` 云函数不按距离排序:不成立。当前 `cloudfunctions/nearbyPets/index.js` 会计算距离、筛选半径内位置,并按 `_dist` 升序排序。
|
||||
- `messageList` 变量遮蔽:成立,但只是维护性风险,不应列为 P0。
|
||||
- 聊天图片 `cloud://` 不显示:部分成立。服务层已经尝试解析 `cloud://` 为临时 URL;真实问题是解析失败后会丢失可重试的 fileId,页面只能显示“图片暂不可用”。
|
||||
- 当前没有阻断级 P0 缺陷;建议从 P1 的数据一致性、分页、错误回滚开始修。
|
||||
|
||||
## 2. 已修复条目复核摘要
|
||||
|
||||
| 页面/模块 | 报告已修复项 | 本次复核 |
|
||||
|-----------|--------------|----------|
|
||||
| 广场页 | 分页、动态话题、广告随机、详情页、点赞竞态、搜索入口 | 代码中存在 `nextCursor`、`normalizeTopicOptions`、`createAdStartIndex`、`post-detail` 导航、`pendingLikeIds`/`pendingLikeOverrides`、搜索防抖 |
|
||||
| 附近页 | 位置上报、尊重可见性、喜欢乐观更新、online 过滤 | 代码中存在 `updateLocation`、`nearbyVisible` 分支、`pendingLikeIds` 回滚、前后端 online 过滤 |
|
||||
| 发布页 | 草稿恢复、字数上限、上传失败提示、关闭前保存草稿提示 | 代码中存在 `getDraft`/`restoreDraft`、`textLength <= 2000`、`UploadPostMediaError`、`handleClose` 草稿弹窗 |
|
||||
| 消息页 | 已读等待、系统会话区分、搜索 activeUsers | 代码中 `openConversation` 会等待跳转/已读结果,系统消息不进聊天,搜索同时过滤在线汪友 |
|
||||
| 聊天页 | 历史分页、智能轮询、发送失败不清空、发送状态、可靠滚动、图片/表情 | 代码中存在 `loadOlder`、3s/12s 轮询、成功后清空输入、`sending` 状态、`scrollIntoView`、图片和表情发送 |
|
||||
| 我的动态 | 首次 onShow 重复加载 | `useRefreshOnShow` 会跳过首次 show |
|
||||
| 跨页面 | 草稿系统、操作错误提示、`commentCreate` 类型 | 当前代码已接入草稿解析、常见操作 try/catch、`CloudFunctionName` 包含 `commentCreate` |
|
||||
|
||||
## 3. 逐项核验清单
|
||||
|
||||
| # | 报告条目 | 核验结论 | 是否需要修改 | 设计归属 |
|
||||
|---|----------|----------|--------------|----------|
|
||||
| 1 | 广场收藏功能入口缺失 | 成立。`Post`/云函数有收藏字段与能力,但 `PostCard` 无入口,`feed.service.ts` 也没有封装 `setPostFavorite` | 需要 | D1 |
|
||||
| 2 | 广场 `loadFeed` 依赖 `debouncedKeyword` 导致频繁清空 | 部分成立。每次防抖关键词变化都会重载并清空列表;这是搜索行为的一部分,但会造成闪空体验 | 建议修改 | D4 |
|
||||
| 3 | `PostCard` 移除更多按钮 | 成立。当前只剩点赞/评论,无举报、分享等入口 | 暂缓,需产品确认更多菜单内容 | D6 |
|
||||
| 4 | 话题搜索与关键词不能同时生效 | 不成立。前端同时传 `topic` 和 `keyword`,云函数也会按 topic 查询后做关键词匹配 | 不修改,仅可优化提示文案 | D4 |
|
||||
| 5 | 附近页 `nearbyVisible=null` 时显示默认上海与空列表 | 成立。偏好加载期间页面会渲染默认中心与空状态 | 需要 | D4 |
|
||||
| 6 | 关闭附近可见不清理旧位置 | 不成立。`locationUpdate` 在 `visible=false` 时会写入 `location:null`、`geo:null`、`visible:false`,不会被附近查询命中 | 不修改 | - |
|
||||
| 7 | `useUploadMedia` 暴露内部 `setMedia` | 成立。发布页直接用 setter 恢复草稿 | 建议小改 | D4 |
|
||||
| 8 | 草稿恢复后宠物默认选中逻辑冲突 | 部分成立。当前逻辑会把“草稿无 petId”解释为“不关联宠物”,但无法区分历史缺字段与用户主动不关联 | 需要明确语义后修改 | D4 |
|
||||
| 9 | 聊天图片 `cloud://` 不显示 | 部分成立。正常路径会解析为临时 URL;解析失败时会丢失 fileId,无法重试 | 需要 | D3 |
|
||||
| 10 | `loadLatest` 提前返回不清 `loadingInitial` | 不成立。`return` 位于 `try` 内,`finally` 仍会执行 | 不修改 | - |
|
||||
| 11 | 个人页偏好开关无失败回滚 | 成立。`changePreference` 先改 UI,`await updateProfile` 无 catch | 需要 | D1 |
|
||||
| 12 | `useSession` 与个人页重复 `ensureLogin` | 成立但影响很低。store 有 inflight 去重,不会造成重复请求 | 暂不修改,可作为结构清理 | D6 |
|
||||
| 13 | 资料编辑 `type='nickname'` 仅真机生效 | 成立但属于微信平台限制 | 不按缺陷修,可补充提示 | D6 |
|
||||
| 14 | `avatarUrl` 与 `avatarKey` 共存冲突 | 不成立。当前是图片优先、渐变头像兜底;`getProfile` 已解析 cloud URL | 不修改 | - |
|
||||
| 15 | 资料编辑页无未保存离开提示 | 成立。关闭按钮直接 `navigateBack` | 需要 | D4 |
|
||||
| 16 | 宠物数量无上限 | 部分成立。当前确实无限制,但是否是缺陷取决于产品策略 | 需要产品确定上限后修改 | D6 |
|
||||
| 17 | 宠物照片交互暗示必填 | 不成立。当前无必填标识,校验也只要求名字和品种 | 不修改,可加“可选”文案 | - |
|
||||
| 18 | 联系人互关后 `isFriend` 未即时更新 | 成立。`toggleFollow` 返回 `mutual`,页面未使用 | 需要 | D1 |
|
||||
| 19 | 联系人“发现”搜索需手动提交 | 成立。`onInput` 只更新本地关键词,只有 `onConfirm` 请求 | 建议修改 | D1 |
|
||||
| 20 | 点击用户只能发起聊天,无个人主页 | 成立。当前没有用户主页页面 | 中期功能 | D6 |
|
||||
| 21 | 我的动态点赞无错误回滚 | 成立。`likePost` 无 try/catch | 需要 | D1 |
|
||||
| 22 | 我的动态无分页,云函数 `limit(50)` | 成立。`userPosts` 无 cursor,前端一次加载 | 需要 | D2 |
|
||||
| 23 | 我的动态点赞事件导致重复 setPosts | 成立但影响低。服务成功会广播,页面本地也已设置 | 可随点赞回滚一起收敛 | D1 |
|
||||
| 24 | 帖子详情评论缺少分页 | 成立。`postDetail` 一次最多 200 条评论 | 需要 | D2 |
|
||||
| 25 | 帖子详情点赞闭包竞态 | 成立。回滚用当前渲染闭包中的 `post` | 需要 | D1 |
|
||||
| 26 | 发送评论后作者硬编码“我” | 成立。乐观评论未使用当前用户资料 | 需要 | D1 |
|
||||
| 27 | 帖子详情加载失败也设 `commentsLoaded=true` | 成立。`load` 无 catch,失败后会显示空评论 | 需要 | D4 |
|
||||
| 28 | 全局登录态守卫缺失 | 成立。`logout` 是本地标记,非 profile 页面未统一拦截 | 需要设计为轻量会话门禁 | D5 |
|
||||
| 29 | `StatsRow` 计数可能不同步 | 部分成立。`profileGet` 已实时计算,但 follow/favorite 等操作后没有统一标记 profile 失效 | 需要 | D5 |
|
||||
| 30 | `cloud-file.ts` 临时 URL 缓存永不过期 | 成立 | 需要 | D5 |
|
||||
| 31 | `dataBus.ts` `postCache` 永不清除 | 成立 | 需要 | D5 |
|
||||
| 32 | `messageList` 中 `openid` 回调变量遮蔽 | 成立,但只是可维护性问题 | 顺手修 | D6 |
|
||||
| 33 | `messageThread` 每次轮询都写已读 | 成立。云函数每次取线程都会 update 会话已读 | 需要 | D3 |
|
||||
| 34 | `nearbyPets` 不按距离排序 | 不成立。当前会按距离排序 | 不修改 | - |
|
||||
| 35 | 消息通知/推送未接入 | 成立,但这是新功能,不是当前消息列表逻辑缺陷 | 长期迭代 | D6 |
|
||||
|
||||
## 4. 修复设计
|
||||
|
||||
### D1. 互动状态一致性与回滚
|
||||
|
||||
目标:统一点赞、收藏、关注、偏好开关、评论乐观更新的失败回滚与跨页面同步,优先解决用户可感知的数据错乱。
|
||||
|
||||
涉及文件:
|
||||
|
||||
- `src/services/feed.service.ts`
|
||||
- `src/services/social.service.ts`
|
||||
- `src/pages/my-posts/index.tsx`
|
||||
- `src/pages/post-detail/index.tsx`
|
||||
- `src/pages/profile/index.tsx`
|
||||
- `src/pages/contacts/index.tsx`
|
||||
- `src/features/feed/components/PostCard/index.tsx`
|
||||
- `cloudfunctions/postFavorite/index.js`
|
||||
|
||||
设计:
|
||||
|
||||
1. 收藏能力补齐:
|
||||
- 在 `feed.service.ts` 新增 `setPostFavorite(postId, favorited)`,调用 `postFavorite` 云函数。
|
||||
- `postFavorite` 云函数补齐缺集合兜底,避免新库第一次收藏失败。
|
||||
- `PostCard` 增加收藏按钮与数量展示,使用 `post.favoritedByMe` 和 `counts.favorites`。
|
||||
- 广场、我的动态、帖子详情统一实现乐观收藏、失败回滚、成功后写入服务端返回值。
|
||||
- 新增收藏同步事件或复用 dataBus 扩展,例如 `emitPostFavorite({ postId, favorited, favorites })`。
|
||||
|
||||
2. 我的动态点赞:
|
||||
- 引入 `postsRef` 和 `pendingLikeIds`,按广场页模式保存操作前快照。
|
||||
- `setPostLike` 失败时恢复 `likedByMe` 与 `counts.likes`。
|
||||
- `onPostLike` 监听中先比较目标 post 当前值,相同则返回原数组,减少重复 re-render。
|
||||
|
||||
3. 帖子详情点赞:
|
||||
- 新增 `postRef` 保存最新 post,`likePost` 以 ref 为准,不用渲染闭包中的 `post`。
|
||||
- 加 `pendingLikeIds` 防止同一帖子连续点击造成并发请求。
|
||||
- 失败时回滚到请求开始时的 snapshot。
|
||||
|
||||
4. 评论乐观作者:
|
||||
- `PostDetailPage` 使用 `useSession()` 获取当前用户。
|
||||
- 乐观评论 author 使用 `user._id`、`user.nickname`、`user.avatarKey`、`user.avatarUrl`。
|
||||
- `commentCreate` 可在后续返回完整 comment;短期先保持 `{ commentId, comments }` 返回结构不变。
|
||||
|
||||
5. 联系人关注:
|
||||
- `onFollow` 使用 `toggleFollow` 返回的 `following` 与 `mutual` 回写 `isFollowing`、`isFriend`。
|
||||
- 失败回滚到完整旧 user snapshot,而不是只反转 `isFollowing`。
|
||||
- `social.service.toggleFollow` 成功后调用 `markStale('profile')`,让资料页统计在返回时刷新。
|
||||
|
||||
6. 偏好开关:
|
||||
- `changePreference` 保存 previous user。
|
||||
- 乐观更新后 `try/catch` 调用 `updateProfile`;失败恢复 previous user 并 toast。
|
||||
- `updateProfile` 的 `markStale('profile')` 建议移动到云函数成功后,避免失败也标记新鲜/脏状态混乱。
|
||||
|
||||
验收:
|
||||
|
||||
- 断网或云函数失败时,点赞/收藏/关注/偏好开关都能回滚 UI。
|
||||
- 互关后联系人页立即显示“汪友”。
|
||||
- 新评论展示当前用户昵称和头像,不再硬编码“我”。
|
||||
- 同一帖子在广场、我的动态、详情页的点赞/收藏状态保持一致。
|
||||
|
||||
### D2. 列表与评论分页
|
||||
|
||||
目标:移除 50/200 条硬上限,保证长列表可继续加载。
|
||||
|
||||
涉及文件:
|
||||
|
||||
- `cloudfunctions/userPosts/index.js`
|
||||
- `cloudfunctions/postDetail/index.js`
|
||||
- `src/services/feed.service.ts`
|
||||
- `src/pages/my-posts/index.tsx`
|
||||
- `src/pages/post-detail/index.tsx`
|
||||
- `src/types/cloud.ts`
|
||||
|
||||
设计:
|
||||
|
||||
1. `userPosts` 分页:
|
||||
- 云函数入参新增 `cursor?: string`、`pageSize?: number`。
|
||||
- 按 `createdAt desc` 查询 `pageSize + 1` 条,cursor 使用上一页最后一条 `createdAt`。
|
||||
- 返回 `{ list, nextCursor }`。
|
||||
- `getUserPosts` 改为返回 `CursorResponse<Post>`,兼容 `authorId`。
|
||||
- `MyPostsPage` 增加 `nextCursor`、`loadingMore`、`loadMore`,在滚动到底部加载。
|
||||
|
||||
2. 评论分页:
|
||||
- `postDetail` 入参新增 `commentCursor?: string`、`commentPageSize?: number`。
|
||||
- 评论按 `createdAt asc` 返回,初始取最早一页;下一页使用 `createdAt > commentCursor`。
|
||||
- 返回 `{ post, comments, commentsNextCursor }`。
|
||||
- `PostDetailPage` 增加 `commentsNextCursor`、`loadingCommentsMore`、底部“加载更多评论”。
|
||||
- 为避免刷新帖子详情时重复替换图片,继续保留当前“只更新计数”的策略。
|
||||
|
||||
验收:
|
||||
|
||||
- 我的动态超过 50 条时可以继续加载。
|
||||
- 帖子评论超过单页限制时可以继续加载下一页。
|
||||
- 分页加载失败有 toast,当前列表不被清空。
|
||||
|
||||
### D3. 聊天图片可靠展示与已读写入控制
|
||||
|
||||
目标:图片临时 URL 解析失败后可重试;聊天轮询不再每次写数据库。
|
||||
|
||||
涉及文件:
|
||||
|
||||
- `src/services/message.service.ts`
|
||||
- `src/pages/chat/index.tsx`
|
||||
- `src/types/domain.ts`
|
||||
- `cloudfunctions/messageThread/index.js`
|
||||
|
||||
设计:
|
||||
|
||||
1. 图片消息数据结构:
|
||||
- `ChatMessage` 增加可选字段 `fileId?: string`、`assetState?: 'ready' | 'unresolved'`。
|
||||
- `resolveThreadAssets` 解析图片时保留原始 `cloud://` 到 `fileId`,`content` 存临时 URL;解析失败时 `content=''` 且 `assetState='unresolved'`。
|
||||
- 页面渲染 unresolved 图片时展示占位和“重试”操作。
|
||||
- 重试时调用 `resolveCloudFileUrl(fileId)`,成功后只更新该条消息。
|
||||
|
||||
2. 已读写入控制:
|
||||
- `messageThread` 入参新增 `markRead?: boolean`,默认 `true` 以兼容旧调用。
|
||||
- 首次打开会话、用户主动刷新时传 `markRead:true`。
|
||||
- 静默轮询 `loadLatest({ silent:true })` 和加载历史传 `markRead:false`。
|
||||
- 云函数仅在 `markRead !== false` 且会话存在时执行已读 update。
|
||||
|
||||
验收:
|
||||
|
||||
- 图片临时 URL 获取失败后,页面不会永久丢失 cloud fileId。
|
||||
- 静默轮询不再触发会话已读 update。
|
||||
- 首次进入聊天仍会清除当前用户未读数。
|
||||
|
||||
### D4. 页面加载、搜索与离开保护
|
||||
|
||||
目标:减少空白闪烁,补齐错误状态与未保存提示。
|
||||
|
||||
涉及文件:
|
||||
|
||||
- `src/pages/plaza/index.tsx`
|
||||
- `src/pages/nearby/index.tsx`
|
||||
- `src/features/nearby/components/NearbySheet/index.tsx`
|
||||
- `src/pages/post-detail/index.tsx`
|
||||
- `src/pages/profile-edit/index.tsx`
|
||||
- `src/hooks/useUploadMedia.ts`
|
||||
- `src/pages/publish/index.tsx`
|
||||
|
||||
设计:
|
||||
|
||||
1. 广场搜索:
|
||||
- 搜索参数变化时保留旧列表,设置 `searching=true`,新结果回来后替换。
|
||||
- 或仅在用户按确认键时清空列表,防抖输入走静默刷新。
|
||||
- 搜索结果文案增加当前 topic,例如“在 #遛弯 中搜索...”,解决语义提示问题。
|
||||
|
||||
2. 附近偏好加载:
|
||||
- 增加 `preferenceLoading = nearbyVisible === null`。
|
||||
- 偏好未返回前,地图可显示默认中心,但列表区展示“正在读取附近设置”,不展示“附近暂时没有汪友”。
|
||||
- `NearbySheet` 接收 `loading` 或 `state`,区分 loading、disabled、empty 三种状态。
|
||||
|
||||
3. 帖子详情加载失败:
|
||||
- `load` 增加 catch,设置 `commentsError`。
|
||||
- 失败时展示“评论加载失败,点击重试”,不显示“还没有评论”。
|
||||
- `finally` 只负责关闭 loading,不吞掉错误状态。
|
||||
|
||||
4. 资料编辑未保存提示:
|
||||
- 加 `initialRef` 保存首次加载后的表单快照。
|
||||
- 关闭按钮走 `handleClose`:dirty 时弹窗确认放弃/继续编辑。
|
||||
- 保存成功后更新 dirty 基线或直接返回。
|
||||
|
||||
5. 上传媒体 hook:
|
||||
- `useUploadMedia` 对外暴露 `replaceMedia(next)`,发布页草稿恢复改用该方法。
|
||||
- 保留内部 `setMedia` 私有,后续上传状态不会被外部绕过。
|
||||
|
||||
6. 草稿宠物语义:
|
||||
- 新草稿保存时增加 `petSelectionExplicit: boolean` 或将 `petId` 扩展为 `string | null`。
|
||||
- `petId=null` 表示用户明确选择“不关联宠物”;`petId` 缺失表示历史草稿/未选择,可默认第一只宠物。
|
||||
- 对旧草稿做兼容:没有该字段时默认第一只宠物,除非后续产品明确要求保持“不关联”。
|
||||
|
||||
验收:
|
||||
|
||||
- 广场搜索输入时不出现明显空白闪烁。
|
||||
- 附近页读取设置期间不显示错误的空列表语义。
|
||||
- 评论加载失败有可见错误和重试。
|
||||
- 编辑资料有未保存离开确认。
|
||||
- 草稿恢复不会误把历史缺字段理解为用户主动不关联。
|
||||
|
||||
### D5. 会话门禁、统计失效与缓存淘汰
|
||||
|
||||
目标:统一“本地登出/资料未完成”的门禁语义,并控制长期运行内存增长。
|
||||
|
||||
涉及文件:
|
||||
|
||||
- `src/store/session.store.ts`
|
||||
- `src/services/auth.service.ts`
|
||||
- `src/services/social.service.ts`
|
||||
- `src/services/feed.service.ts`
|
||||
- `src/services/pet.service.ts`
|
||||
- `src/store/dataBus.ts`
|
||||
- `src/services/cloud-file.ts`
|
||||
- `src/pages/home/index.tsx`
|
||||
|
||||
设计:
|
||||
|
||||
1. 轻量登录态守卫:
|
||||
- 新增 `useSessionGate({ requireCompleted?: boolean })` 或等价 helper。
|
||||
- 对发布、消息、联系人、附近等需要真实用户态的页面/操作,若 `isLoggedOut()` 或 `!user.profileCompleted`,引导到“我的”完成登录资料。
|
||||
- 广场可保持只读,但点赞、收藏、评论、关注等写操作必须先过 gate。
|
||||
- 由于微信 openid 是静默登录,本设计不把“未登录”理解为无 openid,而是“用户本地退出或资料未完成”。
|
||||
|
||||
2. 统计失效:
|
||||
- follow、favorite、post create/delete、pet save/delete 成功后统一 `markStale('profile')`。
|
||||
- 资料页返回时通过现有 `useTabRefresh('profile')` 获取实时 `profileGet` 统计。
|
||||
- 如后续新增联系人资源 key,可扩展 `ResourceKey` 为 `contacts`,用于关注列表自身失效。
|
||||
|
||||
3. 临时 URL 缓存:
|
||||
- `tempUrlCache` 从 `Map<string,string>` 改为 `Map<string,{ url:string; expiresAt:number; lastAccess:number }>`。
|
||||
- 默认 TTL 50 分钟,低于微信临时 URL 常见有效期。
|
||||
- 最大容量建议 500;超出时按 `lastAccess` 淘汰最旧项。
|
||||
- 每次 resolve 前执行轻量 prune。
|
||||
|
||||
4. 帖子 hand-off 缓存:
|
||||
- `postCache` 增加 TTL 10 分钟、最大容量 100。
|
||||
- `getCachedPost` 读取过期项时删除并返回 `undefined`。
|
||||
- `cachePost` 写入时执行容量淘汰。
|
||||
|
||||
验收:
|
||||
|
||||
- 本地退出后,写操作不会继续静默执行。
|
||||
- 关注/收藏后回到资料页,统计能刷新。
|
||||
- 长时间浏览大量帖子/图片后,缓存 Map 不会无界增长。
|
||||
|
||||
### D6. 中长期产品能力与低风险清理
|
||||
|
||||
这些条目成立或部分成立,但不建议混入第一批稳定性修复。
|
||||
|
||||
1. 更多菜单:
|
||||
- `PostCard` 预留 `onMore`。
|
||||
- 菜单项建议先做“分享”“复制内容/链接”“举报”。
|
||||
- 举报若上线,需要新增 `postReport` 云函数和审核字段,不只是前端按钮。
|
||||
|
||||
2. 用户个人主页:
|
||||
- 新增 `/pages/user-profile/index?userId=...`。
|
||||
- 复用 `profileGet({ userId })`、`userPosts({ authorId })`,只展示公开资料与公开动态。
|
||||
- 联系人列表头像/名称点击进主页,聊天按钮保留为独立操作。
|
||||
|
||||
3. 宠物数量上限:
|
||||
- 需要先确定产品上限,例如 6 或 10。
|
||||
- 前端 `PetShelf` 达上限隐藏/禁用“添加宠物”。
|
||||
- 后端 `petSave` 新增创建前 count 校验,防止绕过前端。
|
||||
|
||||
4. 消息推送:
|
||||
- 作为订阅消息能力单独立项。
|
||||
- 需要用户授权订阅模板、发送时机、频率限制、退订处理。
|
||||
|
||||
5. 低风险清理:
|
||||
- `messageList` 回调参数 `openid` 改名为 `memberOpenid`。
|
||||
- 个人页重复 `ensureLogin` 可在重构时简化,但当前无需优先。
|
||||
- `type='nickname'` 平台限制可在开发/非真机环境补充提示,不作为功能修复。
|
||||
|
||||
## 5. 推荐实施顺序
|
||||
|
||||
| 阶段 | 内容 | 原因 |
|
||||
|------|------|------|
|
||||
| P1 | D1 互动状态一致性、D2 我的动态分页、D3 聊天图片重试、D4 帖子详情错误状态 | 直接影响用户操作结果与内容可见性 |
|
||||
| P2 | D2 评论分页、D4 广场/附近加载体验、资料编辑离开保护、D5 统计失效与会话门禁 | 提升长列表、返回刷新和基础流程可靠性 |
|
||||
| P3 | D5 缓存淘汰、D6 更多菜单/用户主页/宠物上限/推送/清理项 | 风险较低或属于产品能力扩展 |
|
||||
|
||||
## 6. 开发校验建议
|
||||
|
||||
1. 每个云函数分页改动都保留旧入参兼容,避免旧包调用失败。
|
||||
2. 点赞、收藏、关注、偏好开关需要分别模拟云函数失败,确认 UI 回滚。
|
||||
3. 图片消息需要模拟 `getTempFileURL` 失败,确认 fileId 被保留且可重试。
|
||||
4. 使用 `npm run typecheck` 验证类型改动。
|
||||
5. 微信开发者工具中重点回归:广场搜索、附近首次进入、聊天轮询、我的动态滚动加载、帖子详情评论加载。
|
||||
@@ -22,7 +22,7 @@
|
||||
"uploadWithSourceMap": true,
|
||||
"compileHotReLoad": false,
|
||||
"lazyloadPlaceholderEnable": false,
|
||||
"useMultiFrameRuntime": true,
|
||||
"useMultiFrameRuntime": false,
|
||||
"useApiHook": true,
|
||||
"useApiHostProcess": true,
|
||||
"compileWorklet": false,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"skylineRenderEnable": false,
|
||||
"preloadBackgroundData": false,
|
||||
"autoAudits": false,
|
||||
"useMultiFrameRuntime": false,
|
||||
"useApiHook": true,
|
||||
"showShadowRootInWxmlPanel": true,
|
||||
"useStaticServer": false,
|
||||
|
||||
@@ -1,35 +1,25 @@
|
||||
export default defineAppConfig({
|
||||
// The four main tabs are rendered inside the single `home` page (state-based
|
||||
// switching) instead of being separate tabBar pages — this removes the
|
||||
// switchTab transition that caused the white flash. The rest stay as
|
||||
// navigateTo pages.
|
||||
pages: [
|
||||
'pages/plaza/index',
|
||||
'pages/nearby/index',
|
||||
'pages/home/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'
|
||||
'pages/user-profile/index',
|
||||
'pages/post-detail/index',
|
||||
'pages/post-edit/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: '用于发现附近的毛孩子,以及在发布动态时标记位置'
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
@use "@/styles/utilities.scss";
|
||||
|
||||
page {
|
||||
// `page` background must be a SOLID color: WeChat only renders solid page
|
||||
// backgrounds during page (navigateTo) transitions — a gradient/CSS-var here
|
||||
// falls back to white for one frame (the flash we chased). The gradient lives
|
||||
// on `.home` instead (see home/index.scss), exposed via this variable so
|
||||
// theming is a one-place change.
|
||||
--app-bg:
|
||||
radial-gradient(ellipse 70% 50% at 25% 0%, rgba(216, 180, 255, 0.1), transparent 60%),
|
||||
radial-gradient(ellipse 60% 50% at 75% 100%, rgba(255, 179, 200, 0.08), transparent 60%),
|
||||
linear-gradient(180deg, #1c1442 0%, #14102b 60%, #0f0a23 100%);
|
||||
min-height: 100%;
|
||||
background: $bg;
|
||||
color: $fg;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PropsWithChildren } from 'react'
|
||||
import Taro, { useLaunch } from '@tarojs/taro'
|
||||
import { ensureLogin } from '@/store/session.store'
|
||||
import './app.scss'
|
||||
|
||||
function App({ children }: PropsWithChildren) {
|
||||
@@ -12,6 +13,10 @@ function App({ children }: PropsWithChildren) {
|
||||
traceUser: true
|
||||
})
|
||||
}
|
||||
|
||||
// Warm the shared session at launch so the first page already has the user
|
||||
// and doesn't need to wait on its own login roundtrip.
|
||||
ensureLogin()
|
||||
})
|
||||
|
||||
return children
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
.page-shell {
|
||||
// Background comes from the global `page` layer (see app.scss). Keeping this
|
||||
// transparent means there's a single background layer → no tab-switch flash.
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(ellipse 70% 50% at 25% 0%, rgba(216, 180, 255, 0.1), transparent 60%),
|
||||
radial-gradient(ellipse 60% 50% at 75% 100%, rgba(255, 179, 200, 0.08), transparent 60%),
|
||||
linear-gradient(180deg, #1c1442 0%, #14102b 60%, #0f0a23 100%);
|
||||
color: $fg;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -7,13 +7,17 @@ interface PageShellProps {
|
||||
className?: string
|
||||
scroll?: boolean
|
||||
withTabBar?: boolean
|
||||
lowerThreshold?: number
|
||||
onScrollToLower?: () => void
|
||||
}
|
||||
|
||||
export function PageShell({
|
||||
children,
|
||||
className = '',
|
||||
scroll = true,
|
||||
withTabBar = true
|
||||
withTabBar = true,
|
||||
lowerThreshold = 80,
|
||||
onScrollToLower
|
||||
}: PropsWithChildren<PageShellProps>) {
|
||||
const { safeBottom } = useSystemLayout()
|
||||
const content = (
|
||||
@@ -28,7 +32,14 @@ export function PageShell({
|
||||
return (
|
||||
<View className='page-shell'>
|
||||
{scroll ? (
|
||||
<ScrollView scrollY className='page-shell__scroll' enhanced showScrollbar={false}>
|
||||
<ScrollView
|
||||
scrollY
|
||||
className='page-shell__scroll'
|
||||
enhanced
|
||||
showScrollbar={false}
|
||||
lowerThreshold={lowerThreshold}
|
||||
onScrollToLower={onScrollToLower}
|
||||
>
|
||||
{content}
|
||||
</ScrollView>
|
||||
) : (
|
||||
@@ -39,4 +50,3 @@ export function PageShell({
|
||||
}
|
||||
|
||||
export default PageShell
|
||||
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
import { Text, View } from '@tarojs/components'
|
||||
import { useSystemLayout } from '@/hooks/useSystemLayout'
|
||||
import { ensureProfileReady } from '@/store/session.store'
|
||||
import { tabs, type MainTabKey } from './tabConfig'
|
||||
import './index.scss'
|
||||
|
||||
interface BottomTabBarProps {
|
||||
active: MainTabKey
|
||||
// In the single-page host, switching tabs is a state change (no page
|
||||
// transition → no flash). Falls back to switchTab if used standalone.
|
||||
onSwitch?: (key: MainTabKey) => void
|
||||
}
|
||||
|
||||
export function BottomTabBar({ active }: BottomTabBarProps) {
|
||||
export function BottomTabBar({ active, onSwitch }: BottomTabBarProps) {
|
||||
const { safeBottom } = useSystemLayout()
|
||||
|
||||
const go = (key: MainTabKey, path: string) => {
|
||||
if (key === active) return
|
||||
if (onSwitch) {
|
||||
onSwitch(key)
|
||||
return
|
||||
}
|
||||
Taro.switchTab({ url: path })
|
||||
}
|
||||
|
||||
@@ -31,11 +39,16 @@ export function BottomTabBar({ active }: BottomTabBarProps) {
|
||||
)
|
||||
}
|
||||
|
||||
const goPublish = async () => {
|
||||
if (!(await ensureProfileReady())) return
|
||||
Taro.navigateTo({ url: '/pages/publish/index' })
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='bottom-tabbar' style={{ paddingBottom: `${Math.max(22, safeBottom)}px` }}>
|
||||
{renderTab(0)}
|
||||
{renderTab(1)}
|
||||
<View className='bottom-tabbar__fab' onClick={() => Taro.navigateTo({ url: '/pages/publish/index' })}>
|
||||
<View className='bottom-tabbar__fab' onClick={goPublish}>
|
||||
<View className='bottom-tabbar__fab-inner'>
|
||||
<View className='bottom-tabbar__fab-icon' />
|
||||
</View>
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
export type MainTabKey = 'plaza' | 'nearby' | 'messages' | 'profile'
|
||||
|
||||
// Props the single-page host (pages/home) passes to each tab view: whether it is
|
||||
// the visible tab, and a counter that bumps when the host returns to foreground.
|
||||
export interface TabViewProps {
|
||||
active?: boolean
|
||||
showTick?: number
|
||||
}
|
||||
|
||||
export interface TabConfig {
|
||||
key: MainTabKey
|
||||
label: string
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PropsWithChildren } from 'react'
|
||||
import { PropsWithChildren, useEffect, useState } from 'react'
|
||||
import { Image, Text, View } from '@tarojs/components'
|
||||
import './index.scss'
|
||||
|
||||
@@ -20,11 +20,15 @@ export function Avatar({
|
||||
petTag,
|
||||
children
|
||||
}: PropsWithChildren<AvatarProps>) {
|
||||
const hasImage = Boolean(avatarUrl)
|
||||
// Gracefully fall back to the gradient if the temp URL fails to load (e.g. a
|
||||
// 403 from storage permissions, or an expired signed URL).
|
||||
const [failed, setFailed] = useState(false)
|
||||
useEffect(() => setFailed(false), [avatarUrl])
|
||||
const hasImage = Boolean(avatarUrl && !avatarUrl.startsWith('cloud://')) && !failed
|
||||
return (
|
||||
<View className={`avatar avatar--${size} ${hasImage ? 'avatar--image' : avatarKey}`}>
|
||||
{hasImage ? (
|
||||
<Image className='avatar__image' src={avatarUrl as string} mode='aspectFill' />
|
||||
<Image className='avatar__image' src={avatarUrl as string} mode='aspectFill' onError={() => setFailed(true)} />
|
||||
) : (
|
||||
children || (label ? <Text className='avatar__label'>{label.slice(0, 1)}</Text> : null)
|
||||
)}
|
||||
@@ -35,4 +39,3 @@ export function Avatar({
|
||||
}
|
||||
|
||||
export default Avatar
|
||||
|
||||
|
||||
@@ -9,11 +9,6 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icon--more {
|
||||
letter-spacing: 1px;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.icon--info {
|
||||
border-radius: 50%;
|
||||
border: 1px solid currentColor;
|
||||
|
||||
@@ -12,7 +12,6 @@ export type IconName =
|
||||
| 'heart'
|
||||
| 'heart-fill'
|
||||
| 'bookmark'
|
||||
| 'more'
|
||||
| 'close'
|
||||
| 'close-dot'
|
||||
| 'image'
|
||||
@@ -45,7 +44,6 @@ const iconText: Record<IconName, string> = {
|
||||
heart: '♡',
|
||||
'heart-fill': '♥',
|
||||
bookmark: '⌑',
|
||||
more: '•••',
|
||||
close: '×',
|
||||
'close-dot': '○',
|
||||
image: '▧',
|
||||
|
||||
@@ -6,9 +6,10 @@ interface SearchBarProps {
|
||||
placeholder: string
|
||||
value?: string
|
||||
onInput?: (value: string) => void
|
||||
onConfirm?: () => void
|
||||
}
|
||||
|
||||
export function SearchBar({ placeholder, value = '', onInput }: SearchBarProps) {
|
||||
export function SearchBar({ placeholder, value = '', onInput, onConfirm }: SearchBarProps) {
|
||||
return (
|
||||
<View className='search-bar'>
|
||||
<Icon name='search' size={14} />
|
||||
@@ -17,11 +18,12 @@ export function SearchBar({ placeholder, value = '', onInput }: SearchBarProps)
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
placeholderClass='search-bar__placeholder'
|
||||
confirmType='search'
|
||||
onInput={event => onInput?.(String(event.detail.value))}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchBar
|
||||
|
||||
|
||||
@@ -19,6 +19,27 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.post-card__more {
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
width: 32px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
color: $muted;
|
||||
}
|
||||
|
||||
.post-card__more-dots {
|
||||
font: 700 16px / 1 $font-body;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.post-card__more:active {
|
||||
background: rgba(216, 180, 255, 0.1);
|
||||
}
|
||||
|
||||
.post-card__name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -64,15 +85,6 @@
|
||||
background: $muted;
|
||||
}
|
||||
|
||||
.post-card__more {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $muted;
|
||||
}
|
||||
|
||||
.post-card__body {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
@@ -80,14 +92,6 @@
|
||||
font: 400 14px / 1.55 $font-body;
|
||||
}
|
||||
|
||||
.post-card__media {
|
||||
position: relative;
|
||||
height: 178px;
|
||||
margin-top: 12px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.post-card__photos {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
@@ -114,46 +118,6 @@
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
.post-card__media--pet-1 {
|
||||
background:
|
||||
radial-gradient(circle at 30% 40%, rgba(255, 217, 168, 0.6), transparent 50%),
|
||||
linear-gradient(135deg, #6b4f9a 0%, #3a2a6a 100%);
|
||||
}
|
||||
|
||||
.post-card__media--pet-2 {
|
||||
background:
|
||||
radial-gradient(circle at 70% 50%, rgba(255, 179, 200, 0.5), transparent 60%),
|
||||
linear-gradient(135deg, #4a3a7a 0%, #251c4a 100%);
|
||||
}
|
||||
|
||||
.post-card__media--pet-3 {
|
||||
background:
|
||||
radial-gradient(circle at 50% 30%, rgba(143, 229, 181, 0.4), transparent 60%),
|
||||
linear-gradient(135deg, #5a4a8a 0%, #2a1f4a 100%);
|
||||
}
|
||||
|
||||
.post-card__silhouette {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 20%;
|
||||
width: 120px;
|
||||
height: 90px;
|
||||
border-radius: 55% 55% 45% 45%;
|
||||
background: radial-gradient(ellipse at 50% 30%, rgba(255, 255, 255, 0.18) 0%, transparent 60%);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.post-card__sticker {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
bottom: 12px;
|
||||
padding: 4px 10px;
|
||||
border-radius: $radius-pill;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
color: $fg;
|
||||
font: 500 10px / 1 $font-body;
|
||||
}
|
||||
|
||||
.post-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
import { Image, Text, View } from '@tarojs/components'
|
||||
import Avatar from '@/components/ui/Avatar'
|
||||
import Icon from '@/components/ui/Icon'
|
||||
import type { Post } from '@/types/domain'
|
||||
import './index.scss'
|
||||
|
||||
@@ -15,21 +14,23 @@ interface PostCardProps {
|
||||
// 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
|
||||
// When provided, shows a "···" button (used on 我的动态 for edit/delete).
|
||||
onMore?: (postId: string) => void
|
||||
}
|
||||
|
||||
function PostActionIcon({ type, onClick }: { type: 'heart' | 'comment'; onClick?: () => void }) {
|
||||
return <View className={`icon icon--${type}`} onClick={onClick} />
|
||||
}
|
||||
|
||||
export function PostCard({ post, onLike, onComment, onOpen, enableImagePreview }: PostCardProps) {
|
||||
export function PostCard({ post, onLike, onComment, onOpen, enableImagePreview, onMore }: PostCardProps) {
|
||||
const openDetail = onOpen ? () => onOpen(post._id) : undefined
|
||||
const handleComment = onOpen ? openDetail : onComment ? () => onComment(post._id) : undefined
|
||||
const urls = post.media || []
|
||||
const urls = (post.media || []).filter(url => !url.startsWith('cloud://'))
|
||||
const previewImage = (current: string) => Taro.previewImage({ current, urls })
|
||||
return (
|
||||
<View className='post-card'>
|
||||
<View className='post-card__head'>
|
||||
<Avatar avatarKey={post.author.avatarKey} avatarUrl={post.author.avatarUrl} size='md' online />
|
||||
<Avatar avatarKey={post.author.avatarKey} avatarUrl={post.author.avatarUrl} size='md' online={post.author.online} />
|
||||
<View className='post-card__meta'>
|
||||
<View className='post-card__name-row'>
|
||||
<Text className='post-card__name'>{post.author.name}</Text>
|
||||
@@ -43,16 +44,24 @@ export function PostCard({ post, onLike, onComment, onOpen, enableImagePreview }
|
||||
<Text>{post.timeText}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className='post-card__more'>
|
||||
<Icon name='more' size={16} />
|
||||
</View>
|
||||
{onMore ? (
|
||||
<View
|
||||
className='post-card__more'
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
onMore(post._id)
|
||||
}}
|
||||
>
|
||||
<Text className='post-card__more-dots'>···</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<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) => (
|
||||
{urls.length ? (
|
||||
<View className={`post-card__photos post-card__photos--${Math.min(urls.length, 3)}`}>
|
||||
{urls.slice(0, 9).map((src, i) => (
|
||||
<Image
|
||||
key={i}
|
||||
className='post-card__photo'
|
||||
@@ -62,12 +71,7 @@ export function PostCard({ post, onLike, onComment, onOpen, enableImagePreview }
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<View className={`post-card__actions ${post.likedByMe ? 'post-card__actions--liked' : ''}`}>
|
||||
<PostActionIcon type='heart' onClick={() => onLike(post._id)} />
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
.active-user-row {
|
||||
width: 100%;
|
||||
padding: 12px 0 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.active-user-row__inner {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 0 20px;
|
||||
padding: 12px 20px 8px;
|
||||
}
|
||||
|
||||
.active-user-row__item {
|
||||
@@ -53,4 +52,3 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,18 +8,23 @@ interface ActiveUserRowProps {
|
||||
users: ActiveUser[]
|
||||
onSelect?: (user: ActiveUser) => void
|
||||
onDiscover?: () => void
|
||||
showDiscover?: boolean
|
||||
}
|
||||
|
||||
export function ActiveUserRow({ users, onSelect, onDiscover }: ActiveUserRowProps) {
|
||||
export function ActiveUserRow({ users, onSelect, onDiscover, showDiscover = true }: ActiveUserRowProps) {
|
||||
if (!showDiscover && users.length === 0) return null
|
||||
|
||||
return (
|
||||
<ScrollView scrollX className='active-user-row' showScrollbar={false}>
|
||||
<View className='active-user-row__inner'>
|
||||
<View className='active-user-row__item' onClick={onDiscover}>
|
||||
<View className='active-user-row__avatar-wrap active-user-row__add'>
|
||||
<Icon name='plus' size={22} />
|
||||
{showDiscover ? (
|
||||
<View className='active-user-row__item' onClick={onDiscover}>
|
||||
<View className='active-user-row__avatar-wrap active-user-row__add'>
|
||||
<Icon name='plus' size={22} />
|
||||
</View>
|
||||
<Text className='active-user-row__name'>找汪友</Text>
|
||||
</View>
|
||||
<Text className='active-user-row__name'>找汪友</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{users.map(user => (
|
||||
<View key={user.id} className='active-user-row__item' onClick={() => onSelect?.(user)}>
|
||||
<View className='active-user-row__avatar-wrap'>
|
||||
|
||||
@@ -12,7 +12,14 @@ interface ConversationItemProps {
|
||||
export function ConversationItem({ conversation, onClick }: ConversationItemProps) {
|
||||
return (
|
||||
<View className='conversation-item' onClick={onClick}>
|
||||
<Avatar avatarKey={conversation.avatarKey} avatarUrl={conversation.avatarUrl} size='lg' online={conversation.online} petTag={conversation.type === 'single' ? '🐾' : undefined} />
|
||||
<Avatar
|
||||
avatarKey={conversation.avatarKey}
|
||||
avatarUrl={conversation.avatarUrl}
|
||||
label={conversation.title}
|
||||
size='lg'
|
||||
online={conversation.online}
|
||||
petTag={conversation.type === 'single' ? '🐾' : undefined}
|
||||
/>
|
||||
<View className='conversation-item__main'>
|
||||
<View className='conversation-item__top'>
|
||||
<View className='conversation-item__name-row'>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Map } from '@tarojs/components'
|
||||
import type { GeoPoint, NearbyPet } from '@/types/domain'
|
||||
import './index.scss'
|
||||
@@ -59,6 +60,25 @@ export function NearbyMap({ pets, center, scale, activeId, onSelect, onRegionCha
|
||||
if (pet) onSelect(pet._id)
|
||||
}
|
||||
|
||||
// include-points must NEVER be empty: the Tencent (gljs) map runs fitBounds on
|
||||
// it and crashes ("Cannot read property 'lat' of undefined") on an empty set.
|
||||
// With real location data the pet list can be empty, so fall back to a small
|
||||
// box around the centre. Memoised so manual zoom isn't refit between loads.
|
||||
const includePoints = useMemo(() => {
|
||||
const pts = pets
|
||||
.filter(p => typeof p.latitude === 'number' && typeof p.longitude === 'number')
|
||||
.map(p => ({ latitude: p.latitude as number, longitude: p.longitude as number }))
|
||||
if (pts.length) {
|
||||
// Keep "me" in view and guarantee ≥2 points so a lone pet doesn't max-zoom.
|
||||
return [{ latitude: center.latitude, longitude: center.longitude }, ...pts]
|
||||
}
|
||||
const d = 0.02 // ≈ 2km — an empty map still frames the neighbourhood.
|
||||
return [
|
||||
{ latitude: center.latitude - d, longitude: center.longitude - d },
|
||||
{ latitude: center.latitude + d, longitude: center.longitude + d }
|
||||
]
|
||||
}, [pets, center.latitude, center.longitude])
|
||||
|
||||
return (
|
||||
<Map
|
||||
id={NEARBY_MAP_ID}
|
||||
@@ -73,6 +93,7 @@ export function NearbyMap({ pets, center, scale, activeId, onSelect, onRegionCha
|
||||
subkey={MAP_SUBKEY || undefined}
|
||||
layerStyle={MAP_SUBKEY ? MAP_LAYER_STYLE : undefined}
|
||||
markers={markers as any}
|
||||
includePoints={includePoints as any}
|
||||
onMarkerTap={onMarkerTap}
|
||||
onRegionChange={(e: any) => {
|
||||
if (e?.type === 'end') onRegionChange?.(e.causedBy || '')
|
||||
|
||||
@@ -23,6 +23,14 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nearby-pet-card__photo-img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.photo-gold {
|
||||
background:
|
||||
radial-gradient(circle at 50% 30%, rgba(255, 217, 168, 0.5) 0%, transparent 50%),
|
||||
@@ -173,6 +181,13 @@
|
||||
}
|
||||
|
||||
.nearby-pet-card__btn--like {
|
||||
background: rgba(20, 16, 43, 0.5);
|
||||
border: 1px solid rgba(216, 180, 255, 0.08);
|
||||
color: $fg-2;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.nearby-pet-card__btn--liked {
|
||||
background: linear-gradient(135deg, $accent-2 0%, #ff8aab 100%);
|
||||
border: 0;
|
||||
color: #fff;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Text, View } from '@tarojs/components'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Image, Text, View } from '@tarojs/components'
|
||||
import Icon from '@/components/ui/Icon'
|
||||
import type { NearbyPet } from '@/types/domain'
|
||||
import './index.scss'
|
||||
@@ -12,6 +13,12 @@ interface NearbyPetCardProps {
|
||||
}
|
||||
|
||||
export function NearbyPetCard({ pet, active, onSelect, onFriendAction, onLike }: NearbyPetCardProps) {
|
||||
// Real uploaded photo (already resolved to a temp URL upstream) takes priority;
|
||||
// otherwise — or if the image fails to load (403 / expired) — fall back to the
|
||||
// photoKey gradient swatch.
|
||||
const [imgFailed, setImgFailed] = useState(false)
|
||||
useEffect(() => setImgFailed(false), [pet.photoUrl])
|
||||
const photoUrl = !imgFailed && pet.photoUrl && !pet.photoUrl.startsWith('cloud://') ? pet.photoUrl : ''
|
||||
const relation = pet.isMine
|
||||
? { key: 'mine', label: '我的', icon: 'user' as const }
|
||||
: pet.isFriend
|
||||
@@ -22,7 +29,15 @@ export function NearbyPetCard({ pet, active, onSelect, onFriendAction, onLike }:
|
||||
|
||||
return (
|
||||
<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 ${photoUrl ? '' : pet.photoKey || ''}`}>
|
||||
{photoUrl ? (
|
||||
<Image
|
||||
className='nearby-pet-card__photo-img'
|
||||
src={photoUrl}
|
||||
mode='aspectFill'
|
||||
onError={() => setImgFailed(true)}
|
||||
/>
|
||||
) : null}
|
||||
<Text className='nearby-pet-card__distance'>{pet.distanceText}</Text>
|
||||
</View>
|
||||
<View className='nearby-pet-card__info'>
|
||||
@@ -54,13 +69,13 @@ export function NearbyPetCard({ pet, active, onSelect, onFriendAction, onLike }:
|
||||
<Text className='nearby-pet-card__friend-text'>{relation.label}</Text>
|
||||
</View>
|
||||
<View
|
||||
className='nearby-pet-card__btn nearby-pet-card__btn--like'
|
||||
className={`nearby-pet-card__btn nearby-pet-card__btn--like ${pet.likedByMe ? 'nearby-pet-card__btn--liked' : ''}`}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
onLike()
|
||||
}}
|
||||
>
|
||||
<Icon name='heart-fill' size={16} />
|
||||
<Icon name={pet.likedByMe ? 'heart-fill' : 'heart'} size={16} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -83,3 +83,10 @@
|
||||
padding: 0 16px 28px;
|
||||
}
|
||||
|
||||
.nearby-sheet__empty {
|
||||
padding: 32px 24px;
|
||||
text-align: center;
|
||||
color: $muted;
|
||||
font: 500 13px / 1.6 $font-body;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ interface NearbySheetProps {
|
||||
pets: NearbyPet[]
|
||||
activeId?: string
|
||||
filter: string
|
||||
state?: 'ready' | 'loading' | 'disabled'
|
||||
onFilterChange: (filter: string) => void
|
||||
onSelect: (id: string) => void
|
||||
onFriendAction: (pet: NearbyPet) => void
|
||||
@@ -22,8 +23,22 @@ const filters = [
|
||||
{ key: 'online', label: '在线' }
|
||||
]
|
||||
|
||||
export function NearbySheet({ pets, activeId, filter, onFilterChange, onSelect, onFriendAction, onLike }: NearbySheetProps) {
|
||||
export function NearbySheet({
|
||||
pets,
|
||||
activeId,
|
||||
filter,
|
||||
state = 'ready',
|
||||
onFilterChange,
|
||||
onSelect,
|
||||
onFriendAction,
|
||||
onLike
|
||||
}: NearbySheetProps) {
|
||||
const sheet = useDragSheet(220)
|
||||
const emptyText = state === 'loading'
|
||||
? '正在读取附近设置…'
|
||||
: state === 'disabled'
|
||||
? '附近可见已关闭,可在「我的」里重新开启'
|
||||
: '附近暂时没有汪友,换个时间或挪个位置再看看~'
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -57,16 +72,20 @@ export function NearbySheet({ pets, activeId, filter, onFilterChange, onSelect,
|
||||
</View>
|
||||
|
||||
<View className='nearby-sheet__list'>
|
||||
{pets.map(pet => (
|
||||
<NearbyPetCard
|
||||
key={pet._id}
|
||||
pet={pet}
|
||||
active={pet._id === activeId}
|
||||
onSelect={() => onSelect(pet._id)}
|
||||
onFriendAction={() => onFriendAction(pet)}
|
||||
onLike={() => onLike(pet._id)}
|
||||
/>
|
||||
))}
|
||||
{pets.length === 0 ? (
|
||||
<Text className='nearby-sheet__empty'>{emptyText}</Text>
|
||||
) : (
|
||||
pets.map(pet => (
|
||||
<NearbyPetCard
|
||||
key={pet._id}
|
||||
pet={pet}
|
||||
active={pet._id === activeId}
|
||||
onSelect={() => onSelect(pet._id)}
|
||||
onFriendAction={() => onFriendAction(pet)}
|
||||
onLike={() => onLike(pet._id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -85,6 +85,10 @@
|
||||
background: rgba(45, 34, 85, 0.22);
|
||||
}
|
||||
|
||||
.pet-shelf__card--limit {
|
||||
min-height: 130px;
|
||||
}
|
||||
|
||||
.pet-shelf__add-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
@@ -99,4 +103,3 @@
|
||||
margin-top: 8px;
|
||||
font: 500 11px / 1 $font-body;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,16 +8,20 @@ interface PetShelfProps {
|
||||
pets: Pet[]
|
||||
}
|
||||
|
||||
const MAX_PETS = 10
|
||||
|
||||
function goEdit(petId?: string) {
|
||||
const query = petId ? `?petId=${petId}` : ''
|
||||
Taro.navigateTo({ url: `/pages/pet-edit/index${query}` })
|
||||
}
|
||||
|
||||
export function PetShelf({ pets }: PetShelfProps) {
|
||||
const reachedLimit = pets.length >= MAX_PETS
|
||||
|
||||
return (
|
||||
<View className='pet-shelf'>
|
||||
<View className='pet-shelf__head'>
|
||||
<Text className='pet-shelf__title'>我的毛孩子 · {pets.length}</Text>
|
||||
<Text className='pet-shelf__title'>我的毛孩子 · {pets.length}/{MAX_PETS}</Text>
|
||||
</View>
|
||||
<View className='pet-shelf__list'>
|
||||
{pets.map(pet => (
|
||||
@@ -32,16 +36,21 @@ export function PetShelf({ pets }: PetShelfProps) {
|
||||
<Text className='pet-shelf__meta'>{pet.ageText || '待完善'} · {pet.tags[0] || '待完善'}</Text>
|
||||
</View>
|
||||
))}
|
||||
<View className='pet-shelf__card pet-shelf__card--add' onClick={() => goEdit()}>
|
||||
<View className='pet-shelf__add-icon'>
|
||||
<Icon name='plus' size={18} />
|
||||
{reachedLimit ? (
|
||||
<View className='pet-shelf__card pet-shelf__card--add pet-shelf__card--limit'>
|
||||
<Text className='pet-shelf__add-label'>已达上限</Text>
|
||||
</View>
|
||||
<Text className='pet-shelf__add-label'>添加宠物</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className='pet-shelf__card pet-shelf__card--add' onClick={() => goEdit()}>
|
||||
<View className='pet-shelf__add-icon'>
|
||||
<Icon name='plus' size={18} />
|
||||
</View>
|
||||
<Text className='pet-shelf__add-label'>添加宠物</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default PetShelf
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ const labels: Array<[StatKey, string]> = [
|
||||
]
|
||||
|
||||
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 }
|
||||
return (
|
||||
<View className='stats-row'>
|
||||
{labels.map(([key, label]) => (
|
||||
@@ -31,4 +31,3 @@ export function StatsRow({ stats, onSelect }: StatsRowProps) {
|
||||
}
|
||||
|
||||
export default StatsRow
|
||||
|
||||
|
||||
@@ -16,7 +16,9 @@ export function MediaGrid({ media, onAdd, onRemove }: MediaGridProps) {
|
||||
<View className='publish-media-grid'>
|
||||
{media.map((item, index) => (
|
||||
<View key={item.id} className={`publish-media-grid__slot ${item.url ? '' : fillClasses[index % fillClasses.length]}`}>
|
||||
{item.url ? <Image className='publish-media-grid__img' src={item.url} mode='aspectFill' /> : null}
|
||||
{item.url && !item.url.startsWith('cloud://') ? (
|
||||
<Image className='publish-media-grid__img' src={item.url} mode='aspectFill' />
|
||||
) : null}
|
||||
<Text className='publish-media-grid__cover'>{index + 1}/{media.length}</Text>
|
||||
<View className='publish-media-grid__remove' onClick={() => onRemove(item.id)}>
|
||||
<PublishIcon name='close' size={12} />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user