You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

819 lines
42 KiB

<template>
<view class="campus-page">
<view class="header" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="brand-line">
<text class="brand">校园热榜</text>
<text class="eating">热议中</text>
</view>
<view class="subtitle">每天都有新鲜事全校都在聊</view>
<view class="search-line">
<view class="search-box">
<uni-icons type="search" size="19" color="#8a9692"></uni-icons>
<input class="search-input" v-model.trim="keyword" placeholder="搜索校园热议、关键词" placeholder-class="placeholder" confirm-type="search" @confirm="search" />
<view class="search-button" @tap="search">搜索</view>
</view>
<view class="release-fab" @tap="goPublish">
<view class="fab-spark fab-spark--one"></view>
<view class="fab-spark fab-spark--two"></view>
<view class="fab-camera">
<view class="fab-camera-lens"></view>
</view>
<text style="margin-top: 6rpx;">发布</text>
</view>
</view>
<view class="category-row">
<view
class="category-card"
:class="{ active: activeCategory === item.tone }"
v-for="item in channels"
:key="item.tone"
@tap="selectCategory(item.tone)"
>
<view class="hot-category-icon">
<image class="hot-category-img" :src="item.icon" mode="aspectFit"></image>
</view>
<view class="category-badge" v-if="item.tone === 'mine' && myCircleUnread > 0">{{formatBadge(myCircleUnread)}}</view>
<view class="hot-category-name">{{item.name}}</view>
<view class="hot-category-desc">{{item.desc}}</view>
</view>
</view>
</view>
<scroll-view class="page-scroll" scroll-y :style="{ paddingTop: headerHeight + 'px' }" :lower-threshold="120" @scrolltolower="loadMore">
<view class="sub-category-scroll" v-if="activeCategory !== 'mine'">
<view
class="sub-category"
:class="{ active: !activeCategoryId && postSortType !== 'latest' }"
@tap="selectSecondCategory('')"
>全部</view>
<view
class="sub-category"
:class="{ active: postSortType === 'latest' }"
@tap="selectLatest"
>最新</view>
<view
class="sub-category"
:class="{ active: postSortType !== 'latest' && activeCategoryId === item.id }"
v-for="item in secondCategoryOptions"
:key="item.id"
@tap="selectSecondCategory(item.id)"
>{{item.name}}</view>
</view>
<view class="filter-panel" v-if="activeCategory !== 'wall' && activeCategory !== 'mine'">
<view class="filter-head">
<text>随机找搭子</text>
<view class="match-btn" :class="{ matching }" @tap="startMatch">{{matching ? '匹配中...' : '发起匹配'}}</view>
</view>
<view class="match-animation" v-if="matching">
<view></view><view></view><view></view>
</view>
<scroll-view scroll-x class="tag-scroll" v-if="tagOptions.length">
<view
class="tag"
:class="{ active: selectedTag === item.name }"
v-for="item in tagOptions"
:key="item.key"
@tap="toggleTag(item.name)"
>{{item.name}}</view>
</scroll-view>
</view>
<view class="section-head">
<view>
<view class="section-title">{{sectionTitle}}</view>
<view class="section-sub">{{sectionSubTitle}}</view>
</view>
<view class="live-dot"><text></text>{{postSortType === 'latest' ? '最新优先' : (activeCategory === 'wall' ? '实时更新' : '热度优先')}}</view>
</view>
<view class="post-list">
<view
class="post-card"
:class="{ 'post-card--image': getImages(item).length }"
v-for="item in posts"
:key="item.id"
@tap="goDetail(item)"
>
<view class="post-top">
<image class="avatar" :src="displayAvatar(item)" mode="aspectFill"></image>
<view class="author-main">
<view class="author">{{displayName(item)}}</view>
<view class="time">{{item.createTime || ''}} · {{item.categoryName || '本校'}}</view>
</view>
<view class="partner-action" v-if="canUsePartnerChat(item)" @tap.stop="goChat(item)">搭一下</view>
</view>
<view class="title">{{item.title}}</view>
<view class="content">{{item.contentText || stripHtml(item.content)}}</view>
<view v-if="getImages(item).length" class="image-grid" :class="'image-grid--' + Math.min(getImages(item).length, 3)">
<image
v-for="(pic, index) in getImages(item).slice(0, 3)"
:key="pic"
:src="pic"
:mode="getImages(item).length === 1 ? 'aspectFit' : 'aspectFill'"
:style="getPostImageStyle(pic, getImages(item).length)"
@load="onPostImageLoad(pic, $event)"
@tap.stop="previewImages(item, index)"
></image>
</view>
<view class="tag-row" v-if="getTags(item).length">
<text v-for="tag in getTags(item)" :key="tag">{{tag}}</text>
</view>
<view class="partner-info" v-if="item.postType === 'partner'">
<text>{{item.location || '地点待定'}}</text>
<text>{{item.activityTime || '时间可商量'}}</text>
<text>{{item.joinedCount || 0}}/{{item.targetCount || '不限'}} 人联系</text>
</view>
<view class="contact-row" v-if="item.contactWechat || item.contactPhone">
<text v-if="item.contactWechat">微信 {{item.contactWechat}}</text>
<text v-if="item.contactPhone">手机 {{item.contactPhone}}</text>
</view>
<view class="post-footer">
<view class="meta">{{item.commentCount || 0}} 评论 · 热度 {{item.hotScore || 0}}</view>
<view class="reaction-row">
<view
class="reaction"
:class="{ 'reaction--active': reactionEffects[item.id + ':' + emoji] }"
v-for="emoji in reactions"
:key="emoji"
@tap.stop="react(item, emoji)"
>
<view class="reaction-emoji">{{emoji}}</view>
<text class="reaction-count">{{getReactionCount(item, emoji)}}</text>
</view>
</view>
</view>
</view>
</view>
<view class="empty" v-if="!loading && !posts.length">暂无内容,来发布第一条吧</view>
<view class="load-state">{{loading ? '加载中...' : (hasMore ? '上滑加载更多' : '已经到底了')}}</view>
<view class="bottom-space"></view>
</scroll-view>
<view class="page-reaction-effect" v-if="reactionBurstEffects.length">
<view
class="page-reaction-float"
:class="effect.className"
:style="effect.style"
v-for="effect in reactionBurstEffects"
:key="effect.id"
>{{effect.emoji}}</view>
</view>
<view class="match-popup-mask" v-if="showMatchPanel" @tap="closeMatchPanel">
<view class="match-popup" @tap.stop>
<view class="match-popup-head">
<view>
<view class="match-popup-title">模糊匹配找搭子</view>
<view class="match-popup-sub">按标题关键词和二级分类,从当前搭子列表里挑一个最合适的。</view>
</view>
<view class="match-close" @tap="closeMatchPanel">×</view>
</view>
<view class="match-field-title">想找什么搭子</view>
<input class="match-input" v-model.trim="matchKeyword" maxlength="30" placeholder="例如:王者、夜跑、饭搭、自习" />
<view class="match-field-title">选择二级分类</view>
<view class="match-tag-wrap">
<view class="match-tag" :class="{ active: !matchCategoryId }" @tap="setMatchCategory('')">不限</view>
<view
class="match-tag"
:class="{ active: matchCategoryId === item.id }"
v-for="item in secondCategoryOptions"
:key="item.key"
@tap="setMatchCategory(item.id)"
>{{item.name}}</view>
</view>
<view class="match-actions">
<view class="match-cancel" @tap="closeMatchPanel">取消</view>
<view class="match-start" @tap="confirmMatch">开始匹配</view>
</view>
</view>
</view>
<view class="creating-mask" v-if="matching">
<view class="creating-card">
<view class="creating-orbit">
<view class="creating-dot dot-a"></view>
<view class="creating-dot dot-b"></view>
<view class="creating-dot dot-c"></view>
<view class="creating-core">搭</view>
</view>
<view class="creating-title">正在漂流匹配</view>
<view class="creating-desc">根据关键词和标签寻找最合适的搭子...</view>
</view>
</view>
</view>
</template>
<script>
import {
listCampusCategories,
pageCampusPosts,
pageCampusPartnerPosts,
matchCampusPartnerPost,
pageMyCampusPosts,
toggleCampusLike,
createCampusChatSession,
getCampusUnreadSummary
} from '@/common/campusApi.js'
import { getIeProfile } from '@/common/ieApi.js'
import { ensureIeVerifiedBeforeAction } from '@/common/ieRealNameAuth.js'
const CHANNELS = [
{ tone: 'wall', name: '校园墙', desc: '新鲜事', icon: 'https://jewel-shop.oss-cn-beijing.aliyuncs.com/f3b050e7f6d74bb9adca54de0bf3fc17.png' },
{ tone: 'game', name: '游戏搭子', desc: '开黑组队', icon: 'https://jewel-shop.oss-cn-beijing.aliyuncs.com/f8d490dfec0a4d17b7c18d710502f6f2.png' },
{ tone: 'daily', name: '日常搭子', desc: '吃饭自习', icon: 'https://jewel-shop.oss-cn-beijing.aliyuncs.com/e810837993204689bda2fc68d1d29448.png' },
{ tone: 'sport', name: '运动搭子', desc: '约球夜跑', icon: 'https://jewel-shop.oss-cn-beijing.aliyuncs.com/01444d0812c544fe9cde7cbbb919b473.png' },
{ tone: 'mine', name: '我的圈子', desc: '消息动态', icon: 'https://jewel-shop.oss-cn-beijing.aliyuncs.com/430e4ae880574845b7d028bd96abde02.jpg' }
]
export default {
data() {
return {
statusBarHeight: 24,
channels: CHANNELS,
activeCategory: 'wall',
activeCategoryId: '',
keyword: '',
selectedTag: '',
postSortType: 'hot',
posts: [],
pageNum: 1,
pageSize: 10,
totalPages: 1,
loading: false,
inited: false,
matching: false,
showMatchPanel: false,
matchKeyword: '',
matchTag: '',
matchCategoryId: '',
myCircleUnread: 0,
categoryMap: {},
tagMap: {},
reactions: ['😂', '😭', '🤢', '👍'],
reactionLocks: {},
reactionEffects: {},
reactionBurstSeq: 0,
reactionBurstTimer: null,
reactionBurstEffects: [],
imageSizeMap: {},
defaultAvatar: '/static/images/img/songshu.png'
}
},
computed: {
headerHeight() {
return this.statusBarHeight + (uni.upx2px ? uni.upx2px(424) : 212)
},
hasMore() {
return this.pageNum < this.totalPages
},
tagOptions() {
return (this.tagMap[this.activeCategory] || []).map((item, index) => ({
...item,
key: item.id || item.code || item.name || String(index)
}))
},
secondCategoryOptions() {
return (this.categoryMap[this.activeCategory] || []).map((item, index) => ({
...item,
key: item.id || item.code || item.name || String(index)
}))
},
sectionTitle() {
if (this.activeCategory === 'mine') return '我的圈子'
const item = this.channels.find(row => row.tone === this.activeCategory)
return item ? item.name : '校园圈'
},
sectionSubTitle() {
if (this.postSortType === 'latest') return '按发布时间倒序排列'
if (this.activeCategory === 'wall') return '按点赞和评论综合热度排序'
if (this.activeCategory === 'mine') return '我的发布、评论和聊天动态'
return '支持关键词和热门标签模糊匹配'
}
},
mounted() {
if (uni.getMenuButtonBoundingClientRect) {
this.statusBarHeight = uni.getMenuButtonBoundingClientRect().top || this.statusBarHeight
}
uni.$on('campusUnreadChange', this.applyUnread)
uni.$on('campusPostPublished', this.refreshAfterCampusPublish)
},
beforeDestroy() {
uni.$off('campusUnreadChange', this.applyUnread)
uni.$off('campusPostPublished', this.refreshAfterCampusPublish)
if (this.reactionBurstTimer) clearTimeout(this.reactionBurstTimer)
},
methods: {
init() {
if (this.inited && this.loading) return
this.inited = true
this.loadUnread()
this.loadCategories().finally(() => {
this.reload()
})
},
refreshAfterCampusPublish() {
if (!this.inited) return
this.loadUnread()
this.reload()
},
getArea() {
const area = uni.getStorageSync('area')
if (!area) return {}
if (typeof area === 'string') {
try { return JSON.parse(area) } catch (e) { return {} }
}
return area
},
loadCategories() {
const area = this.getArea()
return Promise.all([
listCampusCategories({ regionId: area.id || '', categoryType: 'category' }),
listCampusCategories({ regionId: area.id || '', categoryType: 'tag' })
]).then(([categories, tags]) => {
const byParent = {}
;(Array.isArray(tags) ? tags : []).forEach(item => {
const key = item.parentCode
if (!byParent[key]) byParent[key] = []
byParent[key].push(item)
})
this.tagMap = byParent
const categoryList = Array.isArray(categories) ? categories : []
const categoryByParent = {}
categoryList.forEach(item => {
const key = item.parentCode
if (!categoryByParent[key]) categoryByParent[key] = []
categoryByParent[key].push(item)
})
this.categoryMap = categoryByParent
this.channels = CHANNELS
})
},
loadUnread() {
getCampusUnreadSummary().then(summary => this.applyUnread(summary)).catch(() => {})
},
applyUnread(summary = {}) {
this.myCircleUnread = Number(summary.myCircleUnread || summary.campusUnread) || 0
},
selectCategory(tone) {
if (this.activeCategory === tone) return
this.activeCategory = tone
this.activeCategoryId = ''
this.selectedTag = ''
this.postSortType = 'hot'
this.reload()
},
selectSecondCategory(id) {
if (this.activeCategoryId === id && this.postSortType !== 'latest') return
this.activeCategoryId = id
this.postSortType = 'hot'
this.reload()
},
selectLatest() {
if (this.postSortType === 'latest') return
this.activeCategoryId = ''
this.postSortType = 'latest'
this.reload()
},
search() {
this.reload()
},
toggleTag(tag) {
this.selectedTag = this.selectedTag === tag ? '' : tag
this.reload()
},
startMatch() {
if (this.matching) return
this.matchKeyword = this.keyword || ''
this.matchTag = this.selectedTag || ''
this.matchCategoryId = this.activeCategoryId || ''
this.showMatchPanel = true
},
closeMatchPanel() {
if (this.matching) return
this.showMatchPanel = false
},
setMatchTag(tag) {
this.matchTag = tag
},
setMatchCategory(id) {
this.matchCategoryId = id || ''
},
confirmMatch() {
if (this.matching) return
this.showMatchPanel = false
this.matching = true
const area = this.getArea()
const query = {
regionId: area.id || '',
partnerType: this.activeCategory,
categoryId: this.matchCategoryId,
keyword: this.matchKeyword,
tag: this.matchTag
}
const matchRequest = matchCampusPartnerPost(query).catch(() => null)
setTimeout(() => {
matchRequest.then(matched => {
this.matching = false
if (!matched) {
uni.showToast({ title: '暂时没有匹配到合适搭子', icon: 'none' })
return
}
this.goDetail(matched)
})
}, 1500)
},
reload() {
this.pageNum = 1
this.totalPages = 1
this.posts = []
this.loadPosts()
},
loadMore() {
if (this.loading || !this.hasMore) return
this.pageNum += 1
this.loadPosts()
},
loadPosts() {
if (this.loading) return
this.loading = true
const area = this.getArea()
const query = {
pageNum: this.pageNum,
pageSize: this.pageSize,
regionId: area.id || '',
keyword: this.keyword,
categoryId: this.activeCategoryId,
tag: this.selectedTag,
sortType: this.postSortType
}
let request
if (this.activeCategory === 'mine') {
request = pageMyCampusPosts(query)
} else if (this.activeCategory === 'wall') {
request = pageCampusPosts({ ...query, postType: 'wall' })
} else {
request = pageCampusPartnerPosts({ ...query, partnerType: this.activeCategory })
}
request.then(page => {
const records = page && (page.records || page.content) ? (page.records || page.content) : []
this.totalPages = Number(page && (page.pages || page.totalPages)) || 1
this.posts = this.pageNum === 1 ? records : this.posts.concat(records)
}).finally(() => {
this.loading = false
})
},
getImages(item) {
if (!item) return []
if (Array.isArray(item.imageList)) return item.imageList
if (Array.isArray(item.images)) return item.images
if (!item.images) return []
try { return JSON.parse(item.images) || [] } catch (e) { return [] }
},
onPostImageLoad(pic, e) {
if (!pic || !e || !e.detail) return
this.$set(this.imageSizeMap, this.getImageKey(pic), {
width: Number(e.detail.width) || 0,
height: Number(e.detail.height) || 0
})
},
getImageKey(pic) {
const value = String(pic || '')
let hash = 0
for (let i = 0; i < value.length; i++) {
hash = ((hash << 5) - hash) + value.charCodeAt(i)
hash |= 0
}
return 'img_' + Math.abs(hash)
},
getPostImageStyle(pic, count) {
if (count > 1) return ''
const maxWidth = 560
const maxHeight = 360
const size = this.imageSizeMap[this.getImageKey(pic)]
if (!size || !size.width || !size.height) {
return 'width:' + maxWidth + 'rpx;height:320rpx;max-width:' + maxWidth + 'rpx;max-height:' + maxHeight + 'rpx;'
}
const ratio = size.width / size.height
let width = maxWidth
let height = Math.round(width / ratio)
if (height > maxHeight) {
height = maxHeight
width = Math.round(height * ratio)
}
return 'width:' + width + 'rpx;height:' + height + 'rpx;max-width:' + maxWidth + 'rpx;max-height:' + maxHeight + 'rpx;'
},
getTags(item) {
if (!item) return []
if (Array.isArray(item.tagList)) return item.tagList
if (Array.isArray(item.tags)) return item.tags
if (!item.tags) return []
try { return JSON.parse(item.tags) || [] } catch (e) { return [] }
},
displayName(item) {
return item && item.userName ? item.userName : '半径同学'
},
displayAvatar(item) {
return item && item.userAvatar ? item.userAvatar : this.defaultAvatar
},
canUsePartnerChat(item) {
return !!(item && item.postType === 'partner' && item.partnerType !== 'game')
},
getReactionCount(item, emoji) {
if (!item || !item.reactionCounts) return 0
return Number(item.reactionCounts[emoji] || 0)
},
stripHtml(html = '') {
return String(html).replace(/<[^>]+>/g, '')
},
previewImages(item, index) {
const urls = this.getImages(item)
if (!urls.length) return
uni.previewImage({ urls, current: urls[index] || urls[0] })
},
goDetail(item) {
uni.navigateTo({ url: '/package1/post/postDetail?id=' + item.id + '&source=campus' })
},
goPublish() {
uni.navigateTo({ url: '/package1/campus/campusRelease?type=' + (this.activeCategory === 'wall' || this.activeCategory === 'mine' ? 'wall' : this.activeCategory) })
},
async goChat(item) {
if (!this.canUsePartnerChat(item)) return
const ok = await this.ensureVerified('联系校园圈搭子')
if (!ok) return
createCampusChatSession(item.id).then(session => {
if (!session || !session.sessionId) return
uni.navigateTo({ url: '/package1/campus/campusChat?sessionId=' + session.sessionId })
})
},
async react(item, emoji) {
const key = item.id + ':' + emoji
const now = Date.now()
if (this.reactionLocks[key] && now - this.reactionLocks[key] < 1200) return
this.reactionLocks[key] = now
this.$set(this.reactionEffects, key, true)
this.playReactionBurst(emoji)
setTimeout(() => {
this.$delete(this.reactionEffects, key)
}, 680)
toggleCampusLike({ targetType: 'reaction', targetId: item.id, reaction: emoji }).then(res => {
const counts = item.reactionCounts || {}
if (res && res.reactionCount !== undefined && res.reactionCount !== null) {
this.$set(counts, emoji, Number(res.reactionCount) || 0)
this.$set(item, 'reactionCounts', counts)
} else if (res) {
const nextCount = Math.max(0, Number(counts[emoji] || 0) + (res.liked === false ? -1 : 1))
this.$set(counts, emoji, nextCount)
this.$set(item, 'reactionCounts', counts)
}
if (res && res.liked !== false) {
item.likeCount = Number(item.likeCount || 0) + 1
}
})
},
playReactionBurst(emoji) {
const burstId = Date.now() + '-' + (++this.reactionBurstSeq)
const tracks = ['one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve']
const emojis = [emoji, emoji, emoji, emoji, ...this.reactions]
this.reactionBurstEffects = tracks.map((name, index) => ({
id: burstId + '-' + index,
emoji: emojis[index % emojis.length],
className: 'page-reaction-float--' + name,
style: `left:${4 + ((index * 47) % 92)}%; animation-delay:${index * 72}ms;`
}))
if (this.reactionBurstTimer) clearTimeout(this.reactionBurstTimer)
this.reactionBurstTimer = setTimeout(() => {
if (this.reactionBurstEffects.length && String(this.reactionBurstEffects[0].id).indexOf(burstId) === 0) {
this.reactionBurstEffects = []
}
this.reactionBurstTimer = null
}, 3100)
},
async ensureVerified(actionText) {
const profile = await getIeProfile().catch(() => null)
return ensureIeVerifiedBeforeAction({
profile: profile || {},
actionText,
authDialog: { badge: '校园圈', title: '实名认证', desc: actionText + '前需完成实名认证' },
reload: getIeProfile
})
},
formatBadge(count) {
const value = Number(count) || 0
return value > 99 ? '99+' : value
}
}
}
</script>
<style scoped>
.campus-page { height: 100vh; overflow: hidden; background: radial-gradient(circle at 90rpx 30rpx, rgba(198, 247, 231, .65), transparent 250rpx), linear-gradient(180deg, #f1fffb 0, #fff 720rpx, #f8fbf9 100%); position: relative; color: #161d1a; font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif; }
.page-scroll { width: 100%; height: 100%; box-sizing: border-box; }
.header { position: fixed; left: 0; right: 0; top: 0; z-index: 900; padding: 20rpx 28rpx 18rpx; background: linear-gradient(180deg, rgba(237, 255, 249, .99), rgba(249, 255, 252, .98)); box-shadow: 0 8rpx 24rpx rgba(33, 111, 87, .06); box-sizing: border-box; }
.brand-line { height: 58rpx; display: flex; align-items: center; }
.brand { color: #12362e; font-size: 39rpx; font-weight: 900; letter-spacing: -1rpx; }
.eating { margin-left: 13rpx; padding: 7rpx 17rpx; border-radius: 24rpx; background: #31c78a; color: #fff; font-size: 19rpx; font-weight: 800; }
.subtitle { margin-top: 2rpx; color: #69726f; font-size: 19rpx; letter-spacing: 1rpx; }
.search-line { display: flex; align-items: center; gap: 16rpx; margin-top: 20rpx; }
.search-box { position: relative; flex: 1; min-width: 0; height: 66rpx; display: flex; align-items: center; padding-left: 20rpx; border: 2rpx solid #57d4ac; border-radius: 36rpx; background: rgba(255,255,255,.92); box-shadow: 0 9rpx 22rpx rgba(35, 145, 111, .08); }
.search-input { flex: 1; height: 62rpx; margin-left: 9rpx; color: #26302c; font-size: 22rpx; line-height: 62rpx; }
.placeholder { color: #a0a6a4; }
.search-button { width: 94rpx; height: 56rpx; margin-right: 5rpx; border-radius: 30rpx; background: linear-gradient(135deg, #2bd398, #0db779); box-shadow: 0 7rpx 15rpx rgba(20, 182, 123, .24); color: #fff; font-size: 21rpx; font-weight: 800; line-height: 56rpx; text-align: center; }
.category-row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 14rpx; margin-top: 18rpx; padding: 10rpx 24rpx 20rpx; }
.category-card { position: relative; height: 170rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; border-radius: 24rpx; background: linear-gradient(145deg, #ffffff, #edf5ff); text-align: center; box-shadow: 0 10rpx 24rpx rgba(35, 74, 61, 0.075); border: 2rpx solid transparent; box-sizing: border-box; }
.category-card.active { border-color: rgba(34, 191, 134, 0.55); box-shadow: 0 14rpx 30rpx rgba(34, 191, 134, 0.14); transform: translateY(-3rpx); }
.category-badge { position: absolute; right: 8rpx; top: 8rpx; min-width: 32rpx; height: 32rpx; line-height: 32rpx; padding: 0 8rpx; border-radius: 999rpx; background: #ff5b5b; color: #fff; font-size: 19rpx; box-sizing: border-box; }
.hot-category-icon { width: 84rpx; height: 84rpx; display: flex; align-items: center; justify-content: center; margin-bottom: 4rpx; border-radius: 26rpx; background: rgba(255, 255, 255, 0.72); box-shadow: inset 0 2rpx 0 rgba(255, 255, 255, 0.92), 0 9rpx 18rpx rgba(33, 111, 87, 0.08); }
.hot-category-img { width: 88rpx; height: 88rpx; border-radius: 22rpx; overflow: hidden; }
.hot-category-name { color: #173f36; font-size: 21rpx; font-weight: 900; line-height: 30rpx; }
.hot-category-desc { margin-top: 5rpx; color: #7d8e88; font-size: 17rpx; font-weight: 700; line-height: 23rpx; }
.sub-category-scroll { min-height: 62rpx; display: flex; align-items: center; gap: 18rpx; overflow-x: auto; padding: 12rpx 24rpx 4rpx; box-sizing: border-box; }
.sub-category { display: inline-block; margin-right: 18rpx; padding: 12rpx 4rpx; color: #6c7d78; font-size: 25rpx; border-bottom: 5rpx solid transparent; }
.sub-category.active { color: #18b892; font-weight: 800; border-bottom-color: #18d0a4; }
.filter-panel { margin: 18rpx 24rpx 0; padding: 20rpx; background: #fff; border-radius: 28rpx; box-shadow: 0 10rpx 30rpx rgba(41, 80, 73, .08); }
.filter-head { display: flex; justify-content: space-between; align-items: center; font-weight: 700; color: #183a32; }
.match-btn { padding: 10rpx 18rpx; border-radius: 999rpx; background: #232f3d; color: #fff; font-size: 22rpx; }
.match-btn.matching { background: #34c7a2; }
.match-animation { display: flex; gap: 12rpx; justify-content: center; padding: 18rpx 0; }
.match-animation view { width: 18rpx; height: 18rpx; border-radius: 50%; background: #34c7a2; animation: pulse 1s infinite alternate; }
.match-animation view:nth-child(2) { animation-delay: .18s; }
.match-animation view:nth-child(3) { animation-delay: .36s; }
.match-popup-mask { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; background: rgba(14, 28, 25, .38); padding: 40rpx 28rpx 180rpx; box-sizing: border-box; }
.match-popup { width: 100%; max-height: 78vh; padding: 30rpx; border-radius: 34rpx; background: linear-gradient(180deg, #ffffff, #f3fffb); box-shadow: 0 22rpx 58rpx rgba(22, 69, 58, .18); box-sizing: border-box; overflow-y: auto; }
.match-popup-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 20rpx; }
.match-popup-title { color: #173a32; font-size: 32rpx; font-weight: 900; }
.match-popup-sub { margin-top: 8rpx; color: #788984; font-size: 23rpx; line-height: 1.45; }
.match-close { flex: 0 0 54rpx; width: 54rpx; height: 54rpx; line-height: 50rpx; text-align: center; border-radius: 50%; background: #eef6f3; color: #657772; font-size: 34rpx; }
.match-field-title { margin-top: 24rpx; color: #213d37; font-size: 25rpx; font-weight: 800; }
.match-input { margin-top: 12rpx; width: 100%; height: 76rpx; box-sizing: border-box; padding: 0 22rpx; border-radius: 22rpx; background: #f5fbf9; color: #263d38; font-size: 26rpx; border: 1rpx solid #e2efeb; }
.match-tag-wrap { margin-top: 14rpx; display: flex; flex-wrap: wrap; gap: 12rpx; max-height: 190rpx; overflow-y: auto; }
.match-tag { padding: 12rpx 20rpx; border-radius: 999rpx; background: #eef6f3; color: #526761; font-size: 23rpx; font-weight: 700; }
.match-tag.active { background: linear-gradient(135deg, #34d8ad, #25c6c5); color: #fff; box-shadow: 0 8rpx 18rpx rgba(38, 196, 169, .18); }
.match-actions { display: flex; gap: 18rpx; margin-top: 30rpx; }
.match-cancel, .match-start { flex: 1; height: 82rpx; line-height: 82rpx; text-align: center; border-radius: 999rpx; font-size: 27rpx; font-weight: 900; }
.match-cancel { background: #eef3f1; color: #526761; }
.match-start { background: linear-gradient(135deg, #34d8ad, #25c6c5); color: #fff; box-shadow: 0 12rpx 28rpx rgba(38, 196, 169, .26); }
.creating-mask { position: fixed; inset: 0; z-index: 90; display: flex; align-items: center; justify-content: center; background: rgba(12, 24, 22, .48); backdrop-filter: blur(6rpx); }
.creating-card { width: 500rpx; padding: 46rpx 34rpx; border-radius: 36rpx; background: radial-gradient(circle at 50% 24%, rgba(64, 225, 176, .22), transparent 150rpx), #fff; text-align: center; box-shadow: 0 28rpx 70rpx rgba(12, 50, 42, .18); }
.creating-orbit { position: relative; width: 190rpx; height: 190rpx; margin: 0 auto 24rpx; border-radius: 50%; border: 2rpx solid rgba(44, 202, 161, .26); animation: matchOrbitRotate 1.5s linear infinite; }
.creating-core { position: absolute; left: 50%; top: 50%; width: 92rpx; height: 92rpx; margin-left: -46rpx; margin-top: -46rpx; border-radius: 50%; background: linear-gradient(135deg, #34d8ad, #25c6c5); color: #fff; font-size: 34rpx; font-weight: 900; line-height: 92rpx; box-shadow: 0 12rpx 28rpx rgba(38, 196, 169, .28); animation: matchCorePulse .75s ease-in-out infinite alternate; }
.creating-dot { position: absolute; border-radius: 50%; background: #41d9b2; box-shadow: 0 0 18rpx rgba(65, 217, 178, .48); }
.dot-a { left: 18rpx; top: 38rpx; width: 22rpx; height: 22rpx; }
.dot-b { right: 14rpx; top: 84rpx; width: 16rpx; height: 16rpx; background: #74e6ff; }
.dot-c { left: 86rpx; bottom: 10rpx; width: 18rpx; height: 18rpx; background: #ffd36f; }
.creating-title { color: #173a32; font-size: 31rpx; font-weight: 900; }
.creating-desc { margin-top: 10rpx; color: #71837d; font-size: 24rpx; }
.tag-scroll { white-space: nowrap; margin-top: 16rpx; }
.tag { display: inline-block; padding: 10rpx 20rpx; border-radius: 999rpx; margin-right: 12rpx; background: #f0f6f4; color: #51645f; font-size: 23rpx; }
.tag.active { background: #34c7a2; color: #fff; }
.section-head { display: flex; justify-content: space-between; align-items: center; padding: 28rpx 28rpx 14rpx; }
.section-title { font-size: 34rpx; font-weight: 800; color: #1d352f; }
.section-sub { margin-top: 6rpx; font-size: 23rpx; color: #83918d; }
.live-dot { display: flex; align-items: center; gap: 8rpx; font-size: 22rpx; color: #5a716c; }
.live-dot text { width: 12rpx; height: 12rpx; border-radius: 50%; background: #34c7a2; }
.post-list { padding: 0 24rpx; }
.post-card { margin-bottom: 20rpx; padding: 24rpx; background: #fff; border-radius: 30rpx; box-shadow: 0 12rpx 34rpx rgba(38, 76, 69, .09); }
.post-card--image { background: linear-gradient(180deg, #fff 0%, #fbfffd 100%); }
.post-top { display: flex; align-items: center; }
.avatar { width: 62rpx; height: 62rpx; border-radius: 50%; background: #dff8f0; color: #257c69; text-align: center; line-height: 62rpx; font-weight: 700; }
.author-main { flex: 1; margin-left: 14rpx; min-width: 0; }
.author { font-size: 26rpx; font-weight: 700; color: #263d38; }
.time { margin-top: 4rpx; font-size: 21rpx; color: #93a09c; }
.partner-action { padding: 10rpx 18rpx; border-radius: 999rpx; background: #34c7a2; color: #fff; font-size: 23rpx; }
.title { margin-top: 20rpx; font-size: 31rpx; font-weight: 800; color: #162d28; }
.content { margin-top: 12rpx; font-size: 26rpx; color: #51645f; line-height: 1.6; }
.image-grid { display: grid; gap: 8rpx; margin-top: 18rpx; }
.image-grid--1 { display: flex; align-items: center; justify-content: flex-start; max-width: 560rpx; max-height: 360rpx; overflow: hidden; border-radius: 20rpx; background: transparent; }
.image-grid--2 { grid-template-columns: repeat(2, 1fr); }
.image-grid--3 { grid-template-columns: repeat(3, 1fr); }
.image-grid image { width: 100%; height: 190rpx; max-width: 100%; border-radius: 20rpx; background: #eef5f3; }
.image-grid--1 image { max-width: 560rpx; max-height: 360rpx; background: transparent; }
.tag-row { margin-top: 16rpx; display: flex; flex-wrap: wrap; gap: 10rpx; }
.tag-row text { padding: 8rpx 16rpx; border-radius: 999rpx; background: #eefaf6; color: #2f9a81; font-size: 22rpx; }
.partner-info { margin-top: 14rpx; display: flex; flex-wrap: wrap; gap: 12rpx; color: #71807c; font-size: 22rpx; }
.contact-row { margin-top: 14rpx; display: flex; flex-wrap: wrap; gap: 10rpx; }
.contact-row text { padding: 8rpx 14rpx; border-radius: 999rpx; background: #f0fbf7; color: #2b8d76; font-size: 22rpx; }
.post-footer { margin-top: 18rpx; display: flex; justify-content: space-between; align-items: flex-end; gap: 12rpx; }
.meta { color: #889692; font-size: 22rpx; }
.reaction-row { display: flex; gap: 14rpx; overflow: visible; }
.reaction { position: relative; z-index: 1; width: 52rpx; min-height: 62rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; border-radius: 18rpx; background: transparent; transform-origin: center center; transition: background .16s ease, box-shadow .16s ease; overflow: visible; }
.reaction-emoji { font-size: 31rpx; line-height: 34rpx; }
.reaction-count { margin-top: 2rpx; color: #667671; font-size: 18rpx; line-height: 20rpx; font-weight: 700; }
.reaction--active { z-index: 5; background: #fff9dc; box-shadow: 0 12rpx 28rpx rgba(255, 186, 61, .30), 0 0 0 8rpx rgba(255, 207, 88, .16); animation: reactionBoom .68s cubic-bezier(.18, .88, .24, 1.22); }
.reaction--active::after { content: ''; position: absolute; left: 50%; top: 50%; width: 56rpx; height: 56rpx; margin-left: -28rpx; margin-top: -28rpx; border-radius: 50%; border: 4rpx solid rgba(255, 199, 63, .42); animation: reactionRing .68s ease-out; pointer-events: none; }
.empty, .load-state { text-align: center; color: #8a9692; font-size: 24rpx; padding: 30rpx 0; }
.bottom-space { height: 160rpx; }
.page-reaction-effect { position: fixed; top: 0; left: 0; right: 0; bottom: 0; overflow: hidden; pointer-events: none; z-index: 99999; }
.page-reaction-float { position: absolute; bottom: -160rpx; width: 86rpx; height: 86rpx; line-height: 86rpx; text-align: center; opacity: 0; transform-origin: center; font-size: 48rpx; filter: drop-shadow(0 18rpx 28rpx rgba(35, 74, 61, .18)); animation-duration: 2600ms; animation-timing-function: cubic-bezier(.14, .66, .22, 1); animation-fill-mode: forwards; }
.page-reaction-float--one { font-size: 66rpx; animation-name: reactionFloatOne; }
.page-reaction-float--two { font-size: 50rpx; animation-name: reactionFloatTwo; }
.page-reaction-float--three { font-size: 74rpx; animation-name: reactionFloatThree; }
.page-reaction-float--four { font-size: 44rpx; animation-name: reactionFloatFour; }
.page-reaction-float--five { font-size: 58rpx; animation-name: reactionFloatFive; }
.page-reaction-float--six { font-size: 42rpx; animation-name: reactionFloatSix; }
.page-reaction-float--seven { font-size: 68rpx; animation-name: reactionFloatSeven; }
.page-reaction-float--eight { font-size: 48rpx; animation-name: reactionFloatEight; }
.page-reaction-float--nine { font-size: 56rpx; animation-name: reactionFloatNine; }
.page-reaction-float--ten { font-size: 46rpx; animation-name: reactionFloatTen; }
.page-reaction-float--eleven { font-size: 62rpx; animation-name: reactionFloatEleven; }
.page-reaction-float--twelve { font-size: 40rpx; animation-name: reactionFloatTwelve; }
.release-fab { position: relative; flex: 0 0 82rpx; width: 82rpx; height: 82rpx; border-radius: 50%; background: radial-gradient(circle at 35% 28%, #8cf7a9 0%, #49dc7b 42%, #24ba5b 100%); color: #fff; box-shadow: 0 0 0 8rpx rgba(119, 232, 146, .18), 0 0 22rpx 8rpx rgba(80, 225, 118, .26), 0 10rpx 18rpx rgba(18, 151, 76, .20); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 3; animation: fabFloat 3s ease-in-out infinite; overflow: visible; border: 3rpx solid rgba(239, 255, 241, .78); }
.release-fab::before { content: ''; position: absolute; inset: -13rpx; border-radius: 50%; background: radial-gradient(circle, rgba(148, 255, 167, .34) 0%, rgba(115, 238, 144, .15) 46%, rgba(115, 238, 144, 0) 72%); animation: fabGlow 2s ease-in-out infinite; z-index: -1; }
.release-fab::after { content: ''; position: absolute; inset: 8rpx; border-radius: 50%; border: 2rpx solid rgba(255,255,255,.42); box-shadow: inset 0 6rpx 12rpx rgba(255,255,255,.18); pointer-events: none; }
.fab-camera { position: relative; width: 30rpx; height: 22rpx; border-radius: 7rpx; background: #167c3f; box-shadow: inset 0 -3rpx 6rpx rgba(0,0,0,.10); }
.fab-camera::before { content: ''; position: absolute; left: 9rpx; top: -6rpx; width: 13rpx; height: 8rpx; border-radius: 6rpx 6rpx 0 0; background: #167c3f; }
.fab-camera::after { content: ''; position: absolute; right: 4rpx; top: 5rpx; width: 5rpx; height: 5rpx; border-radius: 50%; background: rgba(255,255,255,.75); }
.fab-camera-lens { position: absolute; left: 9rpx; top: 6rpx; width: 12rpx; height: 12rpx; border-radius: 50%; background: #78ee99; box-shadow: 0 0 0 3rpx rgba(255,255,255,.24); }
.release-fab text { position: relative; margin-top: 3rpx; font-size: 18rpx; line-height: 22rpx; font-weight: 800; text-shadow: 0 2rpx 6rpx rgba(0, 101, 49, .22); }
.fab-spark { position: absolute; background: #96f4a6; transform: rotate(45deg); animation: sparkBlink 1.8s ease-in-out infinite; }
.fab-spark--one { right: -6rpx; top: -12rpx; width: 18rpx; height: 18rpx; }
.fab-spark--two { right: 22rpx; top: -30rpx; width: 10rpx; height: 10rpx; animation-delay: .45s; }
@keyframes pulse { from { opacity: .35; transform: scale(.8); } to { opacity: 1; transform: scale(1.15); } }
@keyframes matchOrbitRotate { to { transform: rotate(360deg); } }
@keyframes matchCorePulse { from { transform: scale(.92); } to { transform: scale(1.08); } }
@keyframes fabGlow { 0%, 100% { opacity: .55; transform: scale(.94); } 50% { opacity: 1; transform: scale(1.08); } }
@keyframes fabFloat { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-6rpx); } }
@keyframes sparkBlink { 0%, 100% { opacity: .45; transform: rotate(45deg) scale(.85); } 50% { opacity: 1; transform: rotate(45deg) scale(1.16); } }
@keyframes reactionBoom {
0% { transform: translateY(0) scale(1) rotate(0); }
22% { transform: translateY(-18rpx) scale(2.25) rotate(-8deg); }
48% { transform: translateY(-8rpx) scale(1.55) rotate(6deg); }
68% { transform: translateY(-12rpx) scale(1.82) rotate(-3deg); }
100% { transform: translateY(0) scale(1) rotate(0); }
}
@keyframes reactionRing {
0% { opacity: .95; transform: scale(.7); }
70% { opacity: .35; transform: scale(1.75); }
100% { opacity: 0; transform: scale(2.1); }
}
@keyframes reactionFloatOne {
0% { opacity: 0; transform: translate3d(0, 0, 0) scale(.68) rotate(-8deg); }
14% { opacity: .92; transform: translate3d(-18rpx, -92rpx, 0) scale(1) rotate(5deg); }
62% { opacity: .56; transform: translate3d(-72rpx, -370rpx, 0) scale(.94) rotate(-12deg); }
100% { opacity: 0; transform: translate3d(-112rpx, -650rpx, 0) scale(.72) rotate(-22deg); }
}
@keyframes reactionFloatTwo {
0% { opacity: 0; transform: translate3d(0, 0, 0) scale(.62) rotate(6deg); }
16% { opacity: .84; transform: translate3d(28rpx, -112rpx, 0) scale(.92) rotate(-6deg); }
64% { opacity: .48; transform: translate3d(96rpx, -350rpx, 0) scale(.82) rotate(12deg); }
100% { opacity: 0; transform: translate3d(146rpx, -600rpx, 0) scale(.62) rotate(22deg); }
}
@keyframes reactionFloatThree {
0% { opacity: 0; transform: translate3d(0, 0, 0) scale(.64) rotate(-4deg); }
12% { opacity: .78; transform: translate3d(8rpx, -86rpx, 0) scale(.94) rotate(7deg); }
60% { opacity: .42; transform: translate3d(-36rpx, -420rpx, 0) scale(.78) rotate(-12deg); }
100% { opacity: 0; transform: translate3d(-68rpx, -710rpx, 0) scale(.58) rotate(-24deg); }
}
@keyframes reactionFloatFour {
0% { opacity: 0; transform: translate3d(0, 0, 0) scale(.58) rotate(12deg); }
18% { opacity: .8; transform: translate3d(-26rpx, -124rpx, 0) scale(.86) rotate(-6deg); }
66% { opacity: .44; transform: translate3d(-112rpx, -320rpx, 0) scale(.72) rotate(14deg); }
100% { opacity: 0; transform: translate3d(-166rpx, -560rpx, 0) scale(.54) rotate(26deg); }
}
@keyframes reactionFloatFive {
0% { opacity: 0; transform: translate3d(0, 0, 0) scale(.64) rotate(-10deg); }
14% { opacity: .86; transform: translate3d(34rpx, -96rpx, 0) scale(.96) rotate(6deg); }
62% { opacity: .48; transform: translate3d(68rpx, -380rpx, 0) scale(.78) rotate(-10deg); }
100% { opacity: 0; transform: translate3d(104rpx, -650rpx, 0) scale(.58) rotate(-20deg); }
}
@keyframes reactionFloatSix {
0% { opacity: 0; transform: translate3d(0, 0, 0) scale(.52) rotate(8deg); }
20% { opacity: .76; transform: translate3d(6rpx, -132rpx, 0) scale(.8) rotate(-8deg); }
68% { opacity: .4; transform: translate3d(44rpx, -290rpx, 0) scale(.64) rotate(12deg); }
100% { opacity: 0; transform: translate3d(70rpx, -500rpx, 0) scale(.48) rotate(22deg); }
}
@keyframes reactionFloatSeven {
0% { opacity: 0; transform: translate3d(0, 0, 0) scale(.62) rotate(-8deg); }
13% { opacity: .78; transform: translate3d(-28rpx, -82rpx, 0) scale(.88) rotate(6deg); }
58% { opacity: .42; transform: translate3d(-86rpx, -390rpx, 0) scale(.72) rotate(-12deg); }
100% { opacity: 0; transform: translate3d(-132rpx, -680rpx, 0) scale(.52) rotate(-22deg); }
}
@keyframes reactionFloatEight {
0% { opacity: 0; transform: translate3d(0, 0, 0) scale(.56) rotate(10deg); }
18% { opacity: .8; transform: translate3d(38rpx, -118rpx, 0) scale(.84) rotate(-8deg); }
64% { opacity: .44; transform: translate3d(122rpx, -338rpx, 0) scale(.68) rotate(14deg); }
100% { opacity: 0; transform: translate3d(178rpx, -580rpx, 0) scale(.5) rotate(26deg); }
}
@keyframes reactionFloatNine {
0% { opacity: 0; transform: translate3d(0, 0, 0) scale(.55) rotate(-14deg); }
16% { opacity: .82; transform: translate3d(-46rpx, -110rpx, 0) scale(.88) rotate(8deg); }
62% { opacity: .46; transform: translate3d(-150rpx, -360rpx, 0) scale(.68) rotate(-10deg); }
100% { opacity: 0; transform: translate3d(-210rpx, -620rpx, 0) scale(.5) rotate(-24deg); }
}
@keyframes reactionFloatTen {
0% { opacity: 0; transform: translate3d(0, 0, 0) scale(.52) rotate(14deg); }
18% { opacity: .74; transform: translate3d(50rpx, -96rpx, 0) scale(.82) rotate(-8deg); }
64% { opacity: .38; transform: translate3d(170rpx, -300rpx, 0) scale(.62) rotate(12deg); }
100% { opacity: 0; transform: translate3d(230rpx, -540rpx, 0) scale(.46) rotate(24deg); }
}
@keyframes reactionFloatEleven {
0% { opacity: 0; transform: translate3d(0, 0, 0) scale(.58) rotate(-6deg); }
14% { opacity: .82; transform: translate3d(4rpx, -136rpx, 0) scale(.9) rotate(6deg); }
62% { opacity: .42; transform: translate3d(-24rpx, -450rpx, 0) scale(.7) rotate(-10deg); }
100% { opacity: 0; transform: translate3d(-48rpx, -760rpx, 0) scale(.48) rotate(-20deg); }
}
@keyframes reactionFloatTwelve {
0% { opacity: 0; transform: translate3d(0, 0, 0) scale(.48) rotate(8deg); }
18% { opacity: .72; transform: translate3d(22rpx, -150rpx, 0) scale(.72) rotate(-8deg); }
66% { opacity: .36; transform: translate3d(82rpx, -260rpx, 0) scale(.56) rotate(12deg); }
100% { opacity: 0; transform: translate3d(126rpx, -470rpx, 0) scale(.42) rotate(22deg); }
}
</style>