Files
xiaocai/client/src/pages/group-manage/index.vue

759 lines
18 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="page">
<view class="header-fixed">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="nav-bar">
<view class="nav-back" @tap="goBack">
<Icon name="arrowLeft" :size="40" color="#2D1B1B" />
</view>
<text class="nav-title">一起记</text>
<view :style="{ width: capsuleRight + 72 + 'px' }"></view>
</view>
</view>
<!-- 创建/加入 Tab 卡片 -->
<view class="action-card">
<view class="tab-bar">
<view class="tab-item" :class="{ active: actionTab === 'create' }" @tap="actionTab = 'create'">
<text class="tab-text">创建群组</text>
</view>
<view class="tab-item" :class="{ active: actionTab === 'join' }" @tap="actionTab = 'join'">
<text class="tab-text">加入群组</text>
</view>
</view>
<!-- 创建 -->
<view v-if="actionTab === 'create'" class="action-body">
<input class="action-input" v-model="newGroupName" placeholder="输入群组名称" maxlength="100" />
<view class="action-btn" :class="{ disabled: saving }" @tap="handleCreate">
<Icon name="plus" :size="28" color="#FFFFFF" />
<text class="action-btn-text">创建</text>
</view>
</view>
<!-- 加入 -->
<view v-else class="action-body">
<input class="action-input" v-model="inviteCode" placeholder="输入 6 位邀请码" maxlength="6" />
<view class="action-btn" :class="{ disabled: saving }" @tap="handleJoin">
<text class="action-btn-text">加入</text>
</view>
</view>
</view>
<!-- 我的群组列表 -->
<view class="section-header">
<text class="section-title">我的群组</text>
</view>
<view v-if="groupStore.loading" class="loading-box">
<text class="loading-text">加载中...</text>
</view>
<view v-else-if="groupStore.groups.length === 0" class="empty-box">
<Icon name="user" :size="48" color="#BFB3B3" />
<text class="empty-text">还没有加入任何群组</text>
</view>
<view v-else class="group-list">
<view v-for="group in groupStore.groups" :key="group.id" class="group-item">
<view class="group-icon">
<Icon name="user" :size="32" color="#FF8C69" />
</view>
<view class="group-info">
<view class="group-name-row">
<text class="group-name">{{ group.name }}</text>
<view class="role-badge" :style="{ background: group.role === 'owner' ? '#FFE8E0' : '#F0E0D6' }">
<text class="role-badge-text" :style="{ color: group.role === 'owner' ? '#FF8C69' : '#8B7E7E' }">{{ group.role === 'owner' ? '群主' : '成员' }}</text>
</view>
</view>
<text class="group-meta">{{ group.member_count }} </text>
<!-- 邀请码行 -->
<view class="invite-row">
<text class="invite-label">邀请码</text>
<text class="invite-code" @tap="copyInviteCode(group.invite_code)">{{ group.invite_code }}</text>
<view class="invite-action" @tap="copyInviteCode(group.invite_code)">
<text class="invite-action-text">复制</text>
</view>
<!-- #ifdef MP-WEIXIN -->
<button class="invite-action share-btn" open-type="share" @tap="shareInviteCode = group.invite_code">
<text class="invite-action-text">分享</text>
</button>
<!-- #endif -->
<view v-if="group.role === 'owner'" class="invite-action" @tap="handleRefreshCode(group)">
<Icon name="refresh" :size="24" color="#8B7E7E" />
<text class="invite-action-text">刷新</text>
</view>
</view>
</view>
<view class="group-actions">
<view v-if="group.role === 'owner'" class="group-btn manage" @tap="openMemberModal(group)">
<text class="group-btn-text manage-text">成员</text>
</view>
<view v-if="group.role === 'owner'" class="group-btn dissolve" @tap="handleDissolve(group)">
<text class="group-btn-text dissolve-text">解散</text>
</view>
<view v-else class="group-btn leave" @tap="handleLeave(group)">
<text class="group-btn-text leave-text">退出</text>
</view>
</view>
</view>
</view>
<!-- 成员管理弹窗 -->
<view class="modal-mask" v-if="showMemberModal" @tap="showMemberModal = false">
<view class="member-modal" @tap.stop>
<view class="modal-header">
<text class="modal-title">成员管理</text>
<view class="modal-close" @tap="showMemberModal = false">
<text class="modal-close-text">×</text>
</view>
</view>
<view v-if="membersLoading" class="modal-loading">
<text class="loading-text">加载中...</text>
</view>
<view v-else-if="members.length === 0" class="modal-empty">
<text class="loading-text">暂无成员</text>
</view>
<scroll-view v-else class="member-list" scroll-y>
<view v-for="m in members" :key="m.user_id" class="member-item">
<image class="member-avatar" :src="getAvatarUrl(m.avatar_url)" mode="aspectFill" />
<view class="member-info">
<text class="member-name">{{ m.nickname || '用户' + m.user_id }}</text>
<view class="member-role-wrap">
<text class="member-role-text" :style="{ color: m.role === 'owner' ? '#FF8C69' : '#BFB3B3' }">{{ m.role === 'owner' ? '群主' : '成员' }}</text>
</view>
</view>
<view v-if="m.role !== 'owner' && currentGroup?.role === 'owner'" class="member-remove" @tap="handleRemoveMember(m)">
<text class="member-remove-text">移除</text>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { onShow, onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { useGroupStore } from '@/stores/group'
import { useConfigStore } from '@/stores/config'
import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight, capsuleRight } from '@/utils/system'
import { API_BASE } from '@/config'
import Icon from '@/components/Icon/Icon.vue'
import type { GroupMember, Group } from '@/api/group'
const groupStore = useGroupStore()
const configStore = useConfigStore()
const actionTab = ref<'create' | 'join'>('create')
const newGroupName = ref('')
const inviteCode = ref('')
const saving = ref(false)
const initialLoaded = ref(false)
const shareInviteCode = ref('') // 当前要分享的邀请码
// 成员管理弹窗
const showMemberModal = ref(false)
const membersLoading = ref(false)
const members = ref<GroupMember[]>([])
const currentGroup = ref<Group | null>(null)
onMounted(async () => {
await waitForReady()
await groupStore.fetchGroups()
initialLoaded.value = true
})
// 返回页面时静默刷新
onShow(() => {
if (initialLoaded.value) groupStore.fetchGroups()
})
onPullDownRefresh(async () => {
await groupStore.fetchGroups()
uni.stopPullDownRefresh()
})
// #ifdef MP-WEIXIN
onShareAppMessage(() => {
// 空值保护:通过右上角菜单触发时 invite_code 可能为空
const code = shareInviteCode.value
const path = code
? `/pages/group-manage/index?invite_code=${code}`
: '/pages/group-manage/index'
return {
title: '一起来记账吧!',
path
}
})
onShareTimeline(() => ({
title: `${configStore.appName} — 一起来记账吧!`
}))
// #endif
function goBack() {
uni.navigateBack()
}
function copyInviteCode(code: string) {
uni.setClipboardData({
data: code,
success: () => uni.showToast({ title: '已复制', icon: 'success' })
})
}
/** 获取头像 URL */
function getAvatarUrl(avatarUrl: string): string {
if (!avatarUrl) return ''
if (avatarUrl.startsWith('http') || avatarUrl.startsWith('blob:')) return avatarUrl
return `${API_BASE}/user/avatar/${avatarUrl}`
}
async function handleCreate() {
if (saving.value) return
const name = newGroupName.value.trim()
if (!name) {
uni.showToast({ title: '请输入群组名称', icon: 'none' })
return
}
saving.value = true
try {
const result = await groupStore.createGroup(name)
newGroupName.value = ''
uni.setClipboardData({
data: result.invite_code,
success: () => {
uni.showModal({
title: '创建成功',
content: `邀请码 ${result.invite_code} 已复制,分享给好友即可加入`,
showCancel: false
})
}
})
} catch (e: any) {
uni.showToast({ title: e.message || '创建失败', icon: 'none' })
} finally {
saving.value = false
}
}
async function handleJoin() {
if (saving.value) return
const code = inviteCode.value.trim().toUpperCase()
if (!code) {
uni.showToast({ title: '请输入邀请码', icon: 'none' })
return
}
saving.value = true
try {
const result = await groupStore.joinGroup(code)
inviteCode.value = ''
uni.showToast({ title: `已加入「${result.name}`, icon: 'success' })
} catch (e: any) {
uni.showToast({ title: e.message || '加入失败', icon: 'none' })
} finally {
saving.value = false
}
}
/** 刷新邀请码 */
function handleRefreshCode(group: Group) {
uni.showModal({
title: '刷新邀请码',
content: `刷新后旧邀请码将失效,确定继续?`,
success: async (res) => {
if (res.confirm) {
try {
const result = await groupStore.refreshInviteCode(group.id)
uni.setClipboardData({
data: result.invite_code,
success: () => uni.showToast({ title: '新邀请码已复制', icon: 'success' })
})
} catch (e: any) {
uni.showToast({ title: e.message || '刷新失败', icon: 'none' })
}
}
}
})
}
/** 打开成员管理弹窗 */
async function openMemberModal(group: Group) {
currentGroup.value = group
showMemberModal.value = true
membersLoading.value = true
try {
members.value = await groupStore.fetchGroupMembers(group.id)
} catch (e: any) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
members.value = []
} finally {
membersLoading.value = false
}
}
/** 移除成员 */
function handleRemoveMember(member: GroupMember) {
if (!currentGroup.value) return
uni.showModal({
title: '移除成员',
content: `确定移除「${member.nickname || '该用户'}」?该成员的记录将回到个人账本。`,
success: async (res) => {
if (res.confirm) {
try {
await groupStore.removeMember(currentGroup.value!.id, member.user_id)
members.value = members.value.filter(m => m.user_id !== member.user_id)
uni.showToast({ title: '已移除', icon: 'success' })
} catch (e: any) {
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
}
}
}
})
}
function handleLeave(group: Group) {
uni.showModal({
title: '退出群组',
content: `确定退出「${group.name}」?你在该群组的记录将回到个人账本。`,
success: async (res) => {
if (res.confirm) {
try {
await groupStore.leaveGroup(group.id)
uni.showToast({ title: '已退出', icon: 'success' })
} catch (e: any) {
uni.showToast({ title: e.message || '退出失败', icon: 'none' })
}
}
}
})
}
function handleDissolve(group: Group) {
uni.showModal({
title: '解散群组',
content: `确定解散「${group.name}」?群组记录将回到各成员的个人账本。`,
success: async (res) => {
if (res.confirm) {
try {
await groupStore.deleteGroup(group.id)
uni.showToast({ title: '已解散', icon: 'success' })
} catch (e: any) {
uni.showToast({ title: e.message || '解散失败', icon: 'none' })
}
}
}
})
}
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
@include page-base;
}
.header-fixed {
@include sticky-header;
}
.status-bar { @include status-bar; }
.nav-bar {
display: flex;
align-items: center;
padding: $space-sm $space-xl $space-md;
}
.nav-back {
@include nav-back;
}
.nav-title {
flex: 1;
text-align: center;
font-size: $font-2xl;
font-weight: 600;
color: $text;
}
/* Tab 卡片 */
.action-card {
margin: $space-md $space-xl 0;
background: $surface;
border-radius: $radius-2xl;
border: 2rpx solid $border;
box-shadow: $shadow-md;
overflow: hidden;
}
.tab-bar {
display: flex;
padding: $space-sm $space-sm 0;
gap: $space-xs;
}
.tab-item {
flex: 1;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: $radius-lg $radius-lg 0 0;
transition: all $transition-normal;
&.active {
background: $surface-warm;
.tab-text { color: $primary; }
}
}
.tab-text {
font-size: $font-lg;
font-weight: 500;
color: $text-sec;
}
.action-body {
display: flex;
gap: $space-sm;
padding: $space-md $space-lg $space-lg;
}
.action-input {
flex: 1;
height: 80rpx;
padding: 0 $space-md;
background: $bg;
border-radius: $radius-lg;
border: 2rpx solid $border;
font-size: $font-lg;
color: $text;
}
.action-btn {
height: 80rpx;
padding: 0 $space-lg;
background: linear-gradient(135deg, $primary, #E67355);
border-radius: $radius-lg;
display: flex;
align-items: center;
justify-content: center;
gap: $space-xs;
&.disabled { opacity: 0.5; }
&:active { opacity: 0.8; }
}
.action-btn-text {
font-size: $font-lg;
font-weight: 600;
color: $surface;
white-space: nowrap;
}
/* 群组列表 */
.section-header {
padding: $space-xl $space-xl $space-sm;
}
.section-title {
font-size: $font-lg;
font-weight: 500;
color: $text-sec;
}
.loading-box, .empty-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $space-sm;
padding: 80rpx 0;
}
.loading-text, .empty-text {
font-size: $font-lg;
color: $text-muted;
}
.group-list {
padding: 0 $space-xl;
}
.group-item {
display: flex;
align-items: flex-start;
padding: $font-lg $space-lg;
margin-bottom: $space-sm;
background: $surface;
border-radius: $radius-xl;
border: 2rpx solid $border;
box-shadow: $shadow-md;
gap: $space-md;
}
.group-icon {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: $surface-warm;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 4rpx;
}
.group-info {
flex: 1;
min-width: 0;
}
.group-name-row {
display: flex;
align-items: center;
gap: 12rpx;
}
.group-name {
font-size: $font-lg;
font-weight: 600;
color: $text;
}
.role-badge {
padding: 2rpx 12rpx;
border-radius: $radius-xs;
}
.role-badge-text {
font-size: $font-xs;
font-weight: 500;
}
.group-meta {
font-size: $font-md;
color: $text-muted;
display: block;
margin-top: 4rpx;
}
/* 邀请码行 */
.invite-row {
display: flex;
align-items: center;
margin-top: 12rpx;
gap: $space-xs;
flex-wrap: wrap;
}
.invite-label {
font-size: $font-md;
color: $text-sec;
}
.invite-code {
font-size: $font-md;
font-weight: 600;
color: $primary;
font-family: 'Fredoka', monospace;
}
.invite-action {
display: flex;
align-items: center;
gap: 4rpx;
padding: 4rpx 12rpx;
background: $bg;
border-radius: $radius-sm;
&:active { background: $border; }
}
.invite-action-text {
font-size: $font-sm;
color: $text-sec;
}
/* 分享按钮重置默认样式 */
.share-btn {
margin: 0;
padding: 4rpx 12rpx;
line-height: normal;
background: $bg;
border: none;
border-radius: $radius-sm;
font-size: inherit;
color: inherit;
&::after { border: none; }
}
/* 操作按钮 */
.group-actions {
display: flex;
flex-direction: column;
gap: $space-xs;
flex-shrink: 0;
}
.group-btn {
padding: 12rpx $space-md;
border-radius: $radius-md;
display: flex;
align-items: center;
justify-content: center;
&.manage { background: $surface-warm; }
&.leave { background: $surface-warm; }
&.dissolve { background: $danger-light; }
&:active { opacity: 0.7; }
}
.group-btn-text {
font-size: $font-md;
font-weight: 500;
white-space: nowrap;
&.manage-text { color: $primary; }
&.leave-text { color: $primary; }
&.dissolve-text { color: $danger; }
}
/* 成员管理弹窗 */
.modal-mask {
@include modal-mask;
}
.member-modal {
width: 100%;
max-height: 75vh;
background: $surface;
border-radius: $radius-2xl $radius-2xl 0 0;
display: flex;
flex-direction: column;
overflow: hidden;
padding-bottom: env(safe-area-inset-bottom);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $space-lg $space-xl $space-md;
border-bottom: 2rpx solid $border;
}
.modal-title {
font-size: $font-xl;
font-weight: 600;
color: $text;
}
.modal-close {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: $border;
&:active { background: darken($border, 5%); }
}
.modal-close-text {
font-size: $font-2xl;
color: $text-sec;
line-height: 1;
}
.modal-loading, .modal-empty {
display: flex;
align-items: center;
justify-content: center;
padding: 80rpx 0;
}
.member-list {
max-height: 60vh;
padding: $space-sm $space-xl $space-xl;
width: 100%;
}
.member-item {
display: flex;
align-items: center;
padding: $space-lg 0;
gap: $space-lg;
border-bottom: 2rpx solid #F8F0EB;
width: 100%;
&:last-child { border-bottom: none; }
}
.member-avatar {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: $border;
flex-shrink: 0;
}
.member-info {
flex: 1;
min-width: 0;
}
.member-name {
font-size: $font-lg;
font-weight: 500;
color: $text;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.member-role-wrap {
margin-top: 4rpx;
}
.member-role-text {
font-size: $font-sm;
}
.member-remove {
padding: 12rpx $space-md;
background: $danger-light;
border-radius: $radius-md;
flex-shrink: 0;
&:active { opacity: 0.7; }
}
.member-remove-text {
font-size: $font-md;
font-weight: 500;
color: $danger;
}
@media (prefers-reduced-motion: reduce) {
.tab-item, .action-btn, .invite-action, .group-btn, .member-remove {
transition: none;
}
}
</style>