修复动态发布与互动问题

This commit is contained in:
2026-06-23 18:43:03 +08:00
parent 702b578e1e
commit cda8685956
151 changed files with 4993 additions and 914 deletions

View File

@@ -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 },

View File

@@ -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 }
}

View File

@@ -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

View File

@@ -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 }
}

View File

@@ -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

View File

@@ -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')

View File

@@ -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)
}
})

View File

@@ -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 }
}

View File

@@ -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 }
}

View File

@@ -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 }
}

View File

@@ -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 }
}

View File

@@ -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
}

View 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 }
}

View File

@@ -1,9 +1,8 @@
{
"name": "postFavorite",
"name": "postDelete",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"wx-server-sdk": "latest"
}
}

View File

@@ -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 }
}

View File

@@ -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 }
}

View 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 }
}

View File

@@ -0,0 +1,8 @@
{
"name": "postUpdate",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"wx-server-sdk": "latest"
}
}

View File

@@ -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 => {

View File

@@ -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 }
}

View File

@@ -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: {}
}

View File

@@ -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: {}
}

View File

@@ -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

File diff suppressed because one or more lines are too long

2
dist/app.json vendored
View File

@@ -1 +1 @@
{"pages":["pages/plaza/index","pages/nearby/index","pages/publish/index","pages/messages/index","pages/profile/index","pages/profile-edit/index","pages/pet-edit/index","pages/chat/index","pages/contacts/index","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

File diff suppressed because one or more lines are too long

2
dist/common.wxss vendored

File diff suppressed because one or more lines are too long

View File

@@ -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({});

View File

@@ -1 +1,3 @@
{"component":true}
{
"component": true
}

File diff suppressed because one or more lines are too long

View File

@@ -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}

File diff suppressed because one or more lines are too long

View File

@@ -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

File diff suppressed because one or more lines are too long

1
dist/pages/home/index.json vendored Normal file
View File

@@ -0,0 +1 @@
{"navigationStyle":"custom","disableScroll":true,"usingComponents":{"comp":"../../comp"}}

1
dist/pages/home/index.wxss vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -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()}]);

View File

@@ -1 +0,0 @@
{"navigationBarTitleText":"汪友","navigationStyle":"custom","disableScroll":true,"usingComponents":{"comp":"../../comp"}}

View File

@@ -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}

File diff suppressed because one or more lines are too long

View File

@@ -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}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
{"navigationBarTitleText":"附近","navigationStyle":"custom","disableScroll":true,"usingComponents":{"comp":"../../comp"}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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}

View File

@@ -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()}]);

View File

@@ -1 +0,0 @@
{"navigationBarTitleText":"广场","navigationStyle":"custom","disableScroll":true,"usingComponents":{"comp":"../../comp"}}

View File

@@ -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}

File diff suppressed because one or more lines are too long

View File

@@ -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
View 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
View File

@@ -0,0 +1 @@
{"usingComponents":{"comp":"../../comp"}}

1
dist/pages/post-edit/index.wxss vendored Normal file
View 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}

File diff suppressed because one or more lines are too long

View File

@@ -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}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
{"navigationBarTitleText":"我的","navigationStyle":"custom","disableScroll":true,"usingComponents":{"comp":"../../comp"}}

View File

@@ -1,2 +0,0 @@
<import src="../../base.wxml"/>
<template is="taro_tmpl" data="{{root:root}}" />

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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
View File

@@ -0,0 +1 @@
{"usingComponents":{"comp":"../../comp"}}

1
dist/pages/user-profile/index.wxss vendored Normal file
View 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%}

View File

@@ -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
View File

@@ -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

File diff suppressed because one or more lines are too long

View File

@@ -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` 限制 12000 字,但 `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. 广场页话题与关键词搜索混合逻辑优化
---

View 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. 微信开发者工具中重点回归:广场搜索、附近首次进入、聊天轮询、我的动态滚动加载、帖子详情评论加载。

View File

@@ -22,7 +22,7 @@
"uploadWithSourceMap": true,
"compileHotReLoad": false,
"lazyloadPlaceholderEnable": false,
"useMultiFrameRuntime": true,
"useMultiFrameRuntime": false,
"useApiHook": true,
"useApiHostProcess": true,
"compileWorklet": false,
@@ -51,4 +51,4 @@
"include": []
},
"editorSetting": {}
}
}

View File

@@ -9,6 +9,7 @@
"skylineRenderEnable": false,
"preloadBackgroundData": false,
"autoAudits": false,
"useMultiFrameRuntime": false,
"useApiHook": true,
"showShadowRootInWxmlPanel": true,
"useStaticServer": false,
@@ -19,4 +20,4 @@
"ignoreDevUnusedFiles": true,
"bigPackageSizeSupport": false
}
}
}

View File

@@ -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: '用于发现附近的毛孩子,以及在发布动态时标记位置'

View File

@@ -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;

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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: '▧',

View File

@@ -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

View File

@@ -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;

View File

@@ -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)} />

View File

@@ -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;
}

View File

@@ -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'>

View File

@@ -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'>

View File

@@ -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 || '')

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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>
)

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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

View File

@@ -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