Files
hejiu/pages/circle/circle.vue
T
cg 527b0e8a8d feat(event): 添加酒局编辑删除功能并优化状态管理
- 移除 UniAppWebSocket 类实现,使用独立的 websocket 模块
- 添加 UpdateEvent 和 DeleteEvent API 接口
- 在 EventCard 组件中添加发起人管理操作按钮
- 实现酒局状态数字枚举到字符串的映射转换
- 在 circle 页面集成编辑删除事件处理
- 重构 event-create 页面支持编辑模式
- 在 event-detail 页面添加发起人操作区域
- 实现删除确认对话框和页面返回刷新逻辑
2026-08-05 22:58:26 +08:00

711 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view :class="themeClass" class="page-container circle-page">
<!-- 顶部导航 -->
<view class="circle-header" :style="{ paddingTop: headerPaddingTop }">
<view class="circle-title-row" :style="{ paddingRight: headerPaddingRight }">
<text class="circle-title">酒友圈</text>
<view class="msg-entry" @click="goChatList">
<text class="msg-entry-icon">💬</text>
<view v-if="unreadTotal > 0" class="msg-badge">
<text class="msg-badge-text">{{ unreadTotal > 99 ? '99+' : unreadTotal }}</text>
</view>
</view>
</view>
<!-- Tab 切换 -->
<view class="circle-tabs">
<view
v-for="(tab, i) in tabs"
:key="i"
class="circle-tab"
:class="{ active: currentTab === i }"
@click="switchTab(i)"
>
<text class="circle-tab-text">{{ tab }}</text>
<view v-if="currentTab === i" class="circle-tab-line"></view>
</view>
</view>
</view>
<!-- 动态 Tab -->
<scroll-view
v-if="currentTab === 0"
class="circle-scroll"
scroll-y
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
@scrolltolower="loadMore"
>
<view class="circle-content">
<FeedCard
v-for="feed in feeds"
:key="feed.id"
:feed="feed"
:can-delete="isMyFeed(feed)"
@like="handleLike"
@comment="goDetail"
@share="handleShare"
@delete="confirmDeleteFeed"
@item-click="goDetail"
/>
<view v-if="loading" class="circle-loading">
<text class="circle-loading-text">加载中...</text>
</view>
<view v-if="!hasMore && feeds.length" class="circle-nomore">
<text class="circle-nomore-text"> 没有更多了 </text>
</view>
<EmptyState
v-if="!feeds.length && !loading"
icon="🍻"
title="还没有动态"
desc="发布你的第一条饮酒动态吧"
actionText="发动态"
@action="goPublish"
/>
</view>
</scroll-view>
<!-- 酒友 Tab -->
<scroll-view v-if="currentTab === 1" class="circle-scroll" scroll-y>
<view class="circle-content">
<!-- 好友请求提醒 -->
<view v-if="friendRequests.length" class="request-banner" @click="goFriends">
<text class="request-icon">🔔</text>
<text class="request-text">{{ friendRequests.length }} 条新的好友请求</text>
<text class="request-arrow"></text>
</view>
<!-- 酒友管理入口有好友时也可进入处理请求/删除酒友 -->
<view v-if="friends.length && !friendRequests.length" class="friends-entry" @click="goFriends">
<text class="friends-entry-icon">👥</text>
<text class="friends-entry-text">管理酒友 · 处理好友请求</text>
<text class="friends-entry-arrow"></text>
</view>
<FriendItem
v-for="f in friends"
:key="f.id"
:friend="f"
@item-click="goChat(f)"
/>
<!-- 有好友时也保留邀请入口直接触发带邀请码的转发 -->
<button v-if="friends.length" class="invite-btn" open-type="share" @click="prepareInviteShare">
<text class="invite-btn-icon">👥</text>
<text class="invite-btn-text">邀请更多酒友</text>
</button>
<EmptyState
v-if="!friends.length"
icon="👥"
title="还没有酒友"
desc="邀请好友一起记录饮酒生活"
actionText="邀请好友"
@action="goFriends"
/>
</view>
</scroll-view>
<!-- 酒局 Tab -->
<scroll-view v-if="currentTab === 2" class="circle-scroll" scroll-y>
<view class="circle-content">
<EventCard
v-for="evt in events"
:key="evt.id"
:event="evt"
:can-manage="evt.isOrganizer === true"
@edit="goEditEvent"
@delete="confirmDeleteEvent"
@item-click="goEventDetail"
/>
<EmptyState
v-if="!events.length"
icon="🎉"
title="暂无酒局"
desc="发起一场酒局,约上酒友一起喝"
actionText="发起酒局"
@action="goCreateEvent"
/>
</view>
</scroll-view>
<!-- FAB 发布按钮 -->
<view class="fab" @click="handleFab">
<text class="fab-icon">{{ currentTab === 2 ? '🎉' : '✏️' }}</text>
</view>
</view>
</template>
<script>
import FeedCard from '../../components/FeedCard.vue'
import FriendItem from '../../components/FriendItem.vue'
import EventCard from '../../components/EventCard.vue'
import EmptyState from '../../components/EmptyState.vue'
import { GetCircleFeeds, LikeFeed, UnlikeFeed, DeleteFeed, GetFriends, GetFriendRequests, GetEvents, DeleteEvent, GetUnreadCount } from '../../common/api'
import wsManager from '../../common/websocket'
import { getMyInviteCode } from '../../common/invite'
import themeMixin from '../../common/theme-mixin'
export default {
mixins: [themeMixin],
components: { FeedCard, FriendItem, EventCard, EmptyState },
data() {
const menuBtn = uni.getMenuButtonBoundingClientRect()
const sysInfo = uni.getSystemInfoSync()
return {
tabs: ['动态', '酒友', '酒局'],
currentTab: 0,
headerPaddingTop: (menuBtn.top + 8) + 'px',
headerPaddingRight: (sysInfo.windowWidth - menuBtn.left + 8) + 'px',
// 动态
feeds: [],
lastId: 0,
hasMore: true,
loading: false,
refreshing: false,
// 酒友
friends: [],
friendRequests: [],
// 酒局
events: [],
// 私信未读
unreadTotal: 0,
// 当前分享的动态
shareFeed: null,
// 分享模式:feed(分享动态) / invite(邀请好友) / default
shareMode: 'default',
// 我的邀请码(分享时携带,一次性)
inviteCode: '',
// 当前登录用户ID(用于判断动态是否为自己发布)
myUserId: ''
}
},
onShow() {
this.loadFeeds(true)
this.loadFriends()
this.loadEvents()
this.loadUnread()
this.bindWsEvents()
// 读取当前用户ID(删除自己的动态时用)
try {
const user = JSON.parse(uni.getStorageSync('user_info') || '{}')
this.myUserId = user.id !== undefined && user.id !== null ? String(user.id) : ''
} catch (e) { this.myUserId = '' }
// 预生成邀请码(仅首次/用完后生成,避免每次进页都创建无效邀请)
this.ensureInviteCode()
// 页面显示时确保 WS 已连接(登录后/断线后兼容)
wsManager.connect()
},
onHide() {
this.unbindWsEvents()
},
onUnload() {
this.unbindWsEvents()
},
methods: {
// === WebSocket 事件 ===
bindWsEvents() {
if (this._wsBound) return
this._wsBound = true
this._wsMsgHandler = this.onWsMessage
this._wsConnectHandler = this.onWsConnect
wsManager.on('message', this._wsMsgHandler)
wsManager.on('connect', this._wsConnectHandler)
},
unbindWsEvents() {
if (!this._wsBound) return
this._wsBound = false
if (this._wsMsgHandler) {
wsManager.off('message', this._wsMsgHandler)
this._wsMsgHandler = null
}
if (this._wsConnectHandler) {
wsManager.off('connect', this._wsConnectHandler)
this._wsConnectHandler = null
}
},
/** 收到新私信:未读角标实时 +1(自己发的/自己读的不计数) */
onWsMessage(data) {
if (!data) return
const senderId = data.senderId
if (senderId && wsManager.userId && String(senderId) === String(wsManager.userId)) return
this.unreadTotal++
},
/** (重)连成功后拉取真实未读数,修正断线期间的漏计/多计 */
onWsConnect() {
this.loadUnread()
},
switchTab(i) {
this.currentTab = i
},
// === 动态 ===
async loadFeeds(reset = false) {
if (this.loading) return
if (reset) {
this.lastId = 0
this.hasMore = true
}
this.loading = true
try {
const res = await GetCircleFeeds({ lastId: this.lastId, pageSize: 10 })
const data = res.data
if (reset) {
this.feeds = data.list || []
} else {
this.feeds = [...this.feeds, ...(data.list || [])]
}
this.hasMore = data.hasMore
// 记录最后一条ID作为下次请求的游标
if (data.list && data.list.length) {
this.lastId = data.list[data.list.length - 1].id
}
// 存入 globalData 供详情页查找
getApp().globalData = getApp().globalData || {}
getApp().globalData.circleFeeds = this.feeds
} catch (e) {
console.warn('加载动态失败', e)
}
this.loading = false
this.refreshing = false
},
onRefresh() {
this.refreshing = true
this.loadFeeds(true)
},
loadMore() {
if (this.hasMore && !this.loading) {
this.loadFeeds(false)
}
},
async handleLike(feed) {
feed.liked = !feed.liked
feed.likes += feed.liked ? 1 : -1
try {
if (feed.liked) {
await LikeFeed({ feedId: feed.id })
} else {
await UnlikeFeed({ feedId: feed.id })
}
} catch (e) { /* ignore */ }
},
/** 判断是否为自己发布的动态(仅自己可删) */
isMyFeed(feed) {
if (!this.myUserId) return false
return feed && feed.userId !== undefined && feed.userId !== null && String(feed.userId) === this.myUserId
},
/** 删除自己的动态(二次确认) */
confirmDeleteFeed(feed) {
uni.showModal({
title: '删除动态',
content: '确定要删除这条动态吗?删除后不可恢复',
confirmText: '删除',
confirmColor: '#FF6B6B',
success: async (res) => {
if (!res.confirm) return
try {
await DeleteFeed({ feedId: feed.id })
this.feeds = this.feeds.filter(f => String(f.id) !== String(feed.id))
uni.showToast({ title: '已删除', icon: 'none' })
} catch (e) {
// httpRequest 已全局弹错误提示(如非本人动态后端会拒绝)
}
}
})
},
goDetail(feed) {
uni.navigateTo({ url: `/pages/circle-detail/circle-detail?id=${feed.id}` })
},
/** 记录当前要分享的动态,供 onShareAppMessage 读取 */
handleShare(feed) {
this.shareMode = 'feed'
this.shareFeed = feed
},
/** 点击邀请按钮:标记本次分享为邀请模式 */
prepareInviteShare() {
this.shareMode = 'invite'
},
/** 确保邀请码已生成(仅缺失时拉取,避免频繁创建一次性邀请) */
ensureInviteCode() {
if (this.inviteCode) return
getMyInviteCode().then(code => {
if (code) this.inviteCode = code
})
},
goPublish() {
uni.navigateTo({ url: '/pages/circle-publish/circle-publish' })
},
// === 酒友 ===
async loadFriends() {
try {
const [friendsRes, reqRes] = await Promise.all([
GetFriends({}),
GetFriendRequests()
])
this.friends = friendsRes.data.list
this.friendRequests = reqRes.data.list
} catch (e) {
console.warn('加载酒友失败', e)
}
},
goFriends() {
uni.navigateTo({ url: '/pages/friends/friends' })
},
goChat(friend) {
// 导航防抖锁:防止事件重复触发导致页面打开两次
if (this._navLock) return
this._navLock = true
setTimeout(() => { this._navLock = false }, 600)
uni.navigateTo({
url: `/pages/chat/chat?friendId=${friend.id}&nickname=${encodeURIComponent(friend.nickname)}`
})
},
goChatList() {
uni.navigateTo({ url: '/pages/chat-list/chat-list' })
},
async loadUnread() {
try {
const res = await GetUnreadCount()
if (res.data && typeof res.data.total === 'number') {
this.unreadTotal = res.data.total
}
} catch (e) { /* ignore */ }
},
// === 酒局 ===
async loadEvents() {
try {
const res = await GetEvents({})
this.events = res.data.list
} catch (e) {
console.warn('加载酒局失败', e)
}
},
goEventDetail(evt) {
uni.navigateTo({ url: `/pages/event-detail/event-detail?id=${evt.id}` })
},
/** 编辑自己发起的酒局(复用发起页的编辑模式) */
goEditEvent(evt) {
uni.navigateTo({ url: `/pages/event-create/event-create?id=${evt.id}` })
},
/** 删除自己发起的酒局(二次确认) */
confirmDeleteEvent(evt) {
uni.showModal({
title: '删除酒局',
content: '确定要删除这场酒局吗?删除后不可恢复',
confirmText: '删除',
confirmColor: '#FF6B6B',
success: async (res) => {
if (!res.confirm) return
try {
await DeleteEvent({ eventId: evt.id })
this.events = this.events.filter(e => String(e.id) !== String(evt.id))
uni.showToast({ title: '已删除', icon: 'none' })
} catch (e) {
// httpRequest 已全局弹错误提示(如非本人酒局后端会拒绝)
}
}
})
},
goCreateEvent() {
uni.navigateTo({ url: '/pages/event-create/event-create' })
},
// === FAB ===
handleFab() {
if (this.currentTab === 2) {
this.goCreateEvent()
} else {
this.goPublish()
}
}
},
onShareAppMessage() {
// 邀请好友分享:链接携带一次性邀请码,好友点开后自动接受邀请
if (this.shareMode === 'invite') {
let user = {}
try {
user = JSON.parse(uni.getStorageSync('user_info') || '{}')
} catch (e) { /* ignore */ }
const parts = []
if (this.inviteCode) parts.push(`inviteCode=${encodeURIComponent(this.inviteCode)}`)
if (user.nickname) parts.push(`inviterNick=${encodeURIComponent(user.nickname)}`)
const query = parts.length ? `?${parts.join('&')}` : ''
const nick = user.nickname ? `「${user.nickname}」` : ''
// 本次分享已用掉当前邀请码,异步生成新码供下次使用
this.inviteCode = ''
this.ensureInviteCode()
this.shareMode = 'default'
return {
title: `${nick}邀请你成为酒友,一起记录饮酒生活 🍻`,
path: `/pages/index/index${query}`
}
}
const feed = this.shareFeed
if (this.shareMode === 'feed' && feed && feed.id) {
this.shareMode = 'default'
const text = (feed.text || '').replace(/\s+/g, ' ').trim()
const title = text
? `${feed.nickname || '酒友'}${text.length > 24 ? text.slice(0, 24) + '…' : text}`
: `${feed.nickname || '酒友'}的动态 - 碰盏日记`
return {
title,
path: `/pages/circle-detail/circle-detail?id=${feed.id}&nick=${encodeURIComponent(feed.nickname || '')}&text=${encodeURIComponent(text.slice(0, 60))}&time=${encodeURIComponent(feed.time || '')}`
}
}
return {
title: '酒友圈 - 碰盏日记',
path: '/pages/index/index'
}
},
onShareTimeline() {
return {
title: '酒友圈 - 碰盏日记'
}
}
}
</script>
<style lang="scss" scoped>
.circle-page {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.circle-header {
padding-left: $sp-lg;
padding-right: $sp-lg;
padding-bottom: $sp-md;
background: $bg-base;
position: relative;
z-index: 10;
}
.circle-title-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: $sp-lg;
}
.circle-title {
font-size: $fs-2xl;
font-weight: $fw-black;
color: $text-primary;
}
.msg-entry {
position: relative;
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: $bg-card;
&:active {
background: $bg-card-alt;
}
}
.msg-entry-icon {
font-size: 36rpx;
}
.msg-badge {
position: absolute;
top: -4rpx;
right: -4rpx;
min-width: 32rpx;
height: 32rpx;
padding: 0 8rpx;
border-radius: 16rpx;
background: $coral;
display: flex;
align-items: center;
justify-content: center;
}
.msg-badge-text {
font-size: 18rpx;
color: #fff;
font-weight: $fw-bold;
line-height: 1;
}
.circle-tabs {
display: flex;
gap: $sp-xl;
}
.circle-tab {
position: relative;
padding-bottom: $sp-sm;
}
.circle-tab-text {
font-size: $fs-base;
color: $text-tertiary;
font-weight: $fw-medium;
transition: color $duration-fast $ease-out;
.circle-tab.active & {
color: $text-primary;
font-weight: $fw-bold;
}
}
.circle-tab-line {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 40rpx;
height: 6rpx;
border-radius: 3rpx;
background: $amber;
}
.circle-scroll {
flex: 1;
height: 0;
}
.circle-content {
padding: $sp-md $sp-lg;
padding-bottom: calc(env(safe-area-inset-bottom) + 140rpx);
}
.circle-loading,
.circle-nomore {
text-align: center;
padding: $sp-xl 0;
}
.circle-loading-text,
.circle-nomore-text {
font-size: $fs-sm;
color: $text-tertiary;
}
/* 好友请求横幅 */
.request-banner {
display: flex;
align-items: center;
gap: $sp-sm;
padding: $sp-lg;
background: rgba(232,168,56,0.08);
border: 1rpx solid rgba(232,168,56,0.2);
border-radius: $radius-lg;
margin-bottom: $sp-md;
&:active {
background: rgba(232,168,56,0.12);
}
}
.request-icon {
font-size: $fs-base;
}
.request-text {
flex: 1;
font-size: $fs-sm;
color: $amber;
font-weight: $fw-medium;
}
.request-arrow {
font-size: $fs-xl;
color: $amber;
opacity: 0.6;
}
/* 酒友管理入口 */
.friends-entry {
display: flex;
align-items: center;
gap: $sp-sm;
padding: $sp-md $sp-lg;
background: $bg-card;
border: 1rpx solid var(--border-faint, rgba(255,255,255,0.08));
border-radius: $radius-lg;
margin-bottom: $sp-md;
&:active {
background: $bg-card-alt;
}
}
.friends-entry-icon {
font-size: $fs-base;
}
.friends-entry-text {
flex: 1;
font-size: $fs-sm;
color: $text-secondary;
font-weight: $fw-medium;
}
.friends-entry-arrow {
font-size: $fs-xl;
color: $text-tertiary;
opacity: 0.6;
}
/* 邀请更多酒友按钮(重置 button 默认样式) */
.invite-btn {
display: flex;
align-items: center;
justify-content: center;
gap: $sp-sm;
width: 100%;
margin: $sp-md 0 0;
padding: $sp-lg 0;
background: rgba(232,168,56,0.08);
border: 1rpx dashed rgba(232,168,56,0.35);
border-radius: $radius-lg;
line-height: 1;
font-size: inherit;
&:active {
background: rgba(232,168,56,0.14);
}
&::after {
border: none;
}
}
.invite-btn-icon {
font-size: $fs-base;
}
.invite-btn-text {
font-size: $fs-sm;
color: $amber;
font-weight: $fw-bold;
}
/* FAB */
.fab {
position: fixed;
right: $sp-xl;
bottom: calc(env(safe-area-inset-bottom) + 180rpx);
width: 108rpx;
height: 108rpx;
border-radius: 50%;
background: linear-gradient(135deg, $amber, $amber-deep);
display: flex;
align-items: center;
justify-content: center;
box-shadow: $shadow-amber;
z-index: 50;
&:active {
transform: scale(0.9);
}
}
.fab-icon {
font-size: 44rpx;
}
</style>