From 9764b5626b56cf95e1beb0a8a7db3f6b4e13be63 Mon Sep 17 00:00:00 2001 From: wangxiaogang Date: Thu, 4 Jun 2026 21:28:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BE=A4=E7=BB=84=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E4=BC=98=E5=8C=96=20+=20=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=9B=BE=E6=A0=87=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 创建/加入群组合并为 Tab 切换卡片 - 群主可刷新邀请码(后端+前端完整链路) - 群主可管理成员:查看列表、移除成员 - 新增后端 API:refresh-code / members / remove-member - 修复分类管理页 FAB 按钮缺少 plus 图标 - 新增 plus/refresh 图标资源 --- client/src/api/group.ts | 24 ++ client/src/components/Icon/Icon.vue | 7 + client/src/pages/group-manage/index.vue | 400 +++++++++++++++++++-- client/src/static/icons/plus-white.png | Bin 0 -> 223 bytes client/src/static/icons/refresh-gray.png | Bin 0 -> 231 bytes client/src/static/icons/refresh-orange.png | Bin 0 -> 231 bytes client/src/stores/group.ts | 29 +- server/src/routes/group.ts | 121 +++++++ 8 files changed, 541 insertions(+), 40 deletions(-) create mode 100644 client/src/static/icons/plus-white.png create mode 100644 client/src/static/icons/refresh-gray.png create mode 100644 client/src/static/icons/refresh-orange.png diff --git a/client/src/api/group.ts b/client/src/api/group.ts index 2846a88..e72bef4 100644 --- a/client/src/api/group.ts +++ b/client/src/api/group.ts @@ -36,3 +36,27 @@ export function leaveGroup(id: number) { export function deleteGroup(id: number) { return request({ url: `/groups/${id}`, method: 'DELETE' }) } + +/** 群组成员 */ +export interface GroupMember { + user_id: number + role: 'owner' | 'member' + nickname: string + avatar_url: string + joined_at: string +} + +/** 刷新邀请码 */ +export function refreshInviteCode(groupId: number) { + return request<{ invite_code: string }>({ url: `/groups/${groupId}/refresh-code`, method: 'POST' }) +} + +/** 获取成员列表 */ +export function getGroupMembers(groupId: number) { + return request({ url: `/groups/${groupId}/members` }) +} + +/** 移除成员 */ +export function removeMember(groupId: number, userId: number) { + return request({ url: `/groups/${groupId}/members/${userId}`, method: 'DELETE' }) +} diff --git a/client/src/components/Icon/Icon.vue b/client/src/components/Icon/Icon.vue index 8511cd9..9e01a66 100644 --- a/client/src/components/Icon/Icon.vue +++ b/client/src/components/Icon/Icon.vue @@ -56,6 +56,13 @@ const iconMap: Record> = { '#FF6B6B': '/static/icons/trash-red.png', '#FFFFFF': '/static/icons/trash-white.png', }, + plus: { + '#FFFFFF': '/static/icons/plus-white.png', + }, + refresh: { + '#8B7E7E': '/static/icons/refresh-gray.png', + '#FF8C69': '/static/icons/refresh-orange.png', + }, } const iconSrc = computed(() => { diff --git a/client/src/pages/group-manage/index.vue b/client/src/pages/group-manage/index.vue index e8d87b3..911c495 100644 --- a/client/src/pages/group-manage/index.vue +++ b/client/src/pages/group-manage/index.vue @@ -11,23 +11,30 @@ - + - 创建新群组 - - - + + + 创建群组 + + + 加入群组 + + + + + + + + 创建 - - - - 加入群组 - - - + + + + 加入 @@ -53,15 +60,35 @@ - {{ group.name }} - {{ group.member_count }} 人 · {{ group.role === 'owner' ? '群主' : '成员' }} - + + {{ group.name }} + + 群主 + + + 成员 + + + {{ group.member_count }} 人 + + + 邀请码: - {{ group.invite_code }} - 复制 + {{ group.invite_code }} + + 复制 + + + + 刷新 + + + + 成员 + 解散 @@ -71,6 +98,39 @@ + + + + + + 成员管理 + + × + + + + + 加载中... + + + + 暂无成员 + + + + + + + {{ m.nickname || '用户' + m.user_id }} + {{ m.role === 'owner' ? '群主' : '成员' }} + + + 移除 + + + + + @@ -80,15 +140,24 @@ import { onShow } from '@dcloudio/uni-app' import { useGroupStore } from '@/stores/group' 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 actionTab = ref<'create' | 'join'>('create') const newGroupName = ref('') const inviteCode = ref('') const saving = ref(false) const initialLoaded = ref(false) +// 成员管理弹窗 +const showMemberModal = ref(false) +const membersLoading = ref(false) +const members = ref([]) +const currentGroup = ref(null) + onMounted(async () => { await waitForReady() await groupStore.fetchGroups() @@ -111,6 +180,13 @@ function copyInviteCode(code: string) { }) } +/** 获取头像 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() @@ -158,7 +234,63 @@ async function handleJoin() { } } -function handleLeave(group: any) { +/** 刷新邀请码 */ +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}」?你在该群组的记录将回到个人账本。`, @@ -175,7 +307,7 @@ function handleLeave(group: any) { }) } -function handleDissolve(group: any) { +function handleDissolve(group: Group) { uni.showModal({ title: '解散群组', content: `确定解散「${group.name}」?群组记录将回到各成员的个人账本。`, @@ -233,26 +365,48 @@ function handleDissolve(group: any) { color: #2D1B1B; } +/* Tab 卡片 */ .action-card { margin: 24rpx 40rpx 0; - padding: 32rpx 40rpx; background: #FFFFFF; border-radius: 40rpx; border: 2rpx solid #F0E0D6; box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08); + overflow: hidden; } -.card-title { +.tab-bar { + display: flex; + padding: 16rpx 16rpx 0; + gap: 8rpx; +} + +.tab-item { + flex: 1; + height: 72rpx; + display: flex; + align-items: center; + justify-content: center; + border-radius: 20rpx 20rpx 0 0; + transition: all 0.2s; + + &.active { + background: #FFF0E6; + + .tab-text { color: #FF8C69; } + } +} + +.tab-text { font-size: 28rpx; - font-weight: 600; - color: #2D1B1B; - display: block; - margin-bottom: 20rpx; + font-weight: 500; + color: #8B7E7E; } -.action-row { +.action-body { display: flex; gap: 16rpx; + padding: 24rpx 32rpx 32rpx; } .action-input { @@ -274,6 +428,10 @@ function handleDissolve(group: any) { display: flex; align-items: center; justify-content: center; + gap: 8rpx; + + &.disabled { opacity: 0.5; } + &:active { opacity: 0.8; } } .action-btn-text { @@ -283,6 +441,7 @@ function handleDissolve(group: any) { white-space: nowrap; } +/* 群组列表 */ .section-header { padding: 40rpx 40rpx 16rpx; } @@ -313,7 +472,7 @@ function handleDissolve(group: any) { .group-item { display: flex; - align-items: center; + align-items: flex-start; padding: 28rpx 32rpx; margin-bottom: 16rpx; background: #FFFFFF; @@ -332,6 +491,7 @@ function handleDissolve(group: any) { align-items: center; justify-content: center; flex-shrink: 0; + margin-top: 4rpx; } .group-info { @@ -339,11 +499,32 @@ function handleDissolve(group: any) { min-width: 0; } +.group-name-row { + display: flex; + align-items: center; + gap: 12rpx; +} + .group-name { font-size: 28rpx; font-weight: 600; color: #2D1B1B; - display: block; +} + +.role-badge { + padding: 2rpx 12rpx; + border-radius: 8rpx; + + &.owner { background: #FFE8E0; } + &.member { background: #F0E0D6; } +} + +.role-badge-text { + font-size: 20rpx; + font-weight: 500; + + .owner & { color: #FF8C69; } + .member & { color: #8B7E7E; } } .group-meta { @@ -353,13 +534,13 @@ function handleDissolve(group: any) { margin-top: 4rpx; } +/* 邀请码行 */ .invite-row { display: flex; align-items: center; - margin-top: 8rpx; - padding: 8rpx 0; - min-height: 48rpx; - gap: 4rpx; + margin-top: 12rpx; + gap: 8rpx; + flex-wrap: wrap; } .invite-label { @@ -374,25 +555,38 @@ function handleDissolve(group: any) { font-family: 'Fredoka', monospace; } -.invite-copy { - font-size: 22rpx; - color: #FF8C69; - margin-left: 8rpx; - text-decoration: underline; +.invite-action { + display: flex; + align-items: center; + gap: 4rpx; + padding: 4rpx 12rpx; + background: #FFF8F0; + border-radius: 12rpx; + + &:active { background: #F0E0D6; } } +.invite-action-text { + font-size: 22rpx; + color: #8B7E7E; +} + +/* 操作按钮 */ .group-actions { + display: flex; + flex-direction: column; + gap: 8rpx; flex-shrink: 0; } .group-btn { - padding: 16rpx 28rpx; + padding: 12rpx 24rpx; border-radius: 16rpx; - min-height: 48rpx; display: flex; align-items: center; justify-content: center; + &.manage { background: #FFF0E6; } &.leave { background: #FFF0E6; } &.dissolve { background: #FFE8E8; } @@ -402,8 +596,136 @@ function handleDissolve(group: any) { .group-btn-text { font-size: 24rpx; font-weight: 500; + white-space: nowrap; + &.manage-text { color: #FF8C69; } &.leave-text { color: #FF8C69; } &.dissolve-text { color: #FF6B6B; } } + +/* 成员管理弹窗 */ +.modal-mask { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.4); + z-index: 200; + display: flex; + align-items: flex-end; +} + +.member-modal { + width: 100%; + max-height: 75vh; + background: #FFFFFF; + border-radius: 40rpx 40rpx 0 0; + display: flex; + flex-direction: column; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 32rpx 40rpx 24rpx; + border-bottom: 2rpx solid #F0E0D6; +} + +.modal-title { + font-size: 32rpx; + font-weight: 600; + color: #2D1B1B; +} + +.modal-close { + width: 56rpx; + height: 56rpx; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: #F0E0D6; + + &:active { background: #E0D6D0; } +} + +.modal-close-text { + font-size: 36rpx; + color: #8B7E7E; + line-height: 1; +} + +.modal-loading, .modal-empty { + display: flex; + align-items: center; + justify-content: center; + padding: 80rpx 0; +} + +.member-list { + max-height: 60vh; + padding: 16rpx 40rpx 40rpx; +} + +.member-item { + display: flex; + align-items: center; + padding: 20rpx 0; + gap: 20rpx; + border-bottom: 2rpx solid #F8F0EB; + + &:last-child { border-bottom: none; } +} + +.member-avatar { + width: 72rpx; + height: 72rpx; + border-radius: 50%; + background: #F0E0D6; + flex-shrink: 0; +} + +.member-info { + flex: 1; + min-width: 0; +} + +.member-name { + font-size: 28rpx; + font-weight: 500; + color: #2D1B1B; + display: block; +} + +.member-role { + font-size: 22rpx; + display: block; + margin-top: 4rpx; + + &.owner { color: #FF8C69; } + &.member { color: #BFB3B3; } +} + +.member-remove { + padding: 12rpx 24rpx; + background: #FFE8E8; + border-radius: 16rpx; + flex-shrink: 0; + + &:active { opacity: 0.7; } +} + +.member-remove-text { + font-size: 24rpx; + font-weight: 500; + color: #FF6B6B; +} + +@media (prefers-reduced-motion: reduce) { + .tab-item, .action-btn, .invite-action, .group-btn, .member-remove { + transition: none; + } +} diff --git a/client/src/static/icons/plus-white.png b/client/src/static/icons/plus-white.png new file mode 100644 index 0000000000000000000000000000000000000000..0cfa4d4bd33e0bf1d426fcb62ae46bebafed0188 GIT binary patch literal 223 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpUtWu7jMAr*7pUh?K^2;guI{JuX^ zW}?N1&n_8^FS*`%?#kW#Ojt0{T*sFu*?jh*-~^k)4=0@XQNW^P$I{4C-fr-?^gru{ z!~NQ_>w)Mse**iu9ELYj`>yjg>}A|=o&NyerZpyu?zgfr++(<5%^KnubBv#1OSD7T zk>na~hp;0>HEu^<|M1}`uHj~o{%ARmwUF0c`OqW5jt@UHI85$vAI^)5ni+7djM01U VvXFHGtAQ?N@O1TaS?83{1ONjZQ`rCj literal 0 HcmV?d00001 diff --git a/client/src/static/icons/refresh-gray.png b/client/src/static/icons/refresh-gray.png new file mode 100644 index 0000000000000000000000000000000000000000..7e4dbb2b49e00b4b09fd612946e0eeb6fadcee14 GIT binary patch literal 231 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpUtb)GJcAr*7pUb@KJ;K0EWsJ)%B z`&vZ9$CAc}7G@9Y;|^{6+V%d%_sMw@w`6~Peeo>*qNc&LhYtnLSCqTB?BP~2x+CLs zY_`IS3QmW1Zw{*nrwa>>n);?Nlq~33@c0_rg+w4Pi$V4(gOwN41;^8hul5EoOI>KF z6zKoRdxGIaUq!DzS*qvbx92DcL*MUI%*NIevjZ!GBl-qg|-o%rI} eN?l>Ew=8_0s=j~RthWK^at2RVKbLh*2~7a;Mp?7~ literal 0 HcmV?d00001 diff --git a/client/src/stores/group.ts b/client/src/stores/group.ts index 2657606..b839e6f 100644 --- a/client/src/stores/group.ts +++ b/client/src/stores/group.ts @@ -97,6 +97,32 @@ export const useGroupStore = defineStore('group', () => { await fetchGroups() } + /** 刷新邀请码(群主) */ + async function refreshInviteCode(groupId: number) { + const result = await api.refreshInviteCode(groupId) + // 更新本地 groups 中的 invite_code + const group = groups.value.find(g => g.id === groupId) + if (group) { + group.invite_code = result.invite_code + } + return result + } + + /** 获取成员列表 */ + async function fetchGroupMembers(groupId: number) { + return await api.getGroupMembers(groupId) + } + + /** 移除成员(群主) */ + async function removeMember(groupId: number, userId: number) { + await api.removeMember(groupId, userId) + // 更新本地成员数 + const group = groups.value.find(g => g.id === groupId) + if (group && group.member_count > 0) { + group.member_count-- + } + } + /** 初始化:从本地存储恢复选中状态 */ function init() { const saved = uni.getStorageSync('xc:currentGroupId') @@ -108,6 +134,7 @@ export const useGroupStore = defineStore('group', () => { return { groups, loading, currentGroupId, currentGroup, isGroupMode, switchToPersonal, switchToGroup, refreshAll, - fetchGroups, createGroup, joinGroup, leaveGroup, deleteGroup, init + fetchGroups, createGroup, joinGroup, leaveGroup, deleteGroup, + refreshInviteCode, fetchGroupMembers, removeMember, init } }) diff --git a/server/src/routes/group.ts b/server/src/routes/group.ts index fd8006a..951acd2 100644 --- a/server/src/routes/group.ts +++ b/server/src/routes/group.ts @@ -208,4 +208,125 @@ router.delete('/:id', async (req: AuthRequest, res: Response) => { } }) +/** 刷新邀请码(仅 owner) */ +router.post('/:id/refresh-code', async (req: AuthRequest, res: Response) => { + try { + const groupId = req.params.id + + // 验证是否是群主 + const [groupRows] = await pool.query( + 'SELECT created_by FROM `groups` WHERE id = ?', + [groupId] + ) + const group = (groupRows as any[])[0] + if (!group) { + return res.status(404).json({ code: 40400, message: '群组不存在' }) + } + if (group.created_by !== req.userId) { + return res.status(403).json({ code: 40300, message: '仅群主可刷新邀请码' }) + } + + // 生成新的唯一邀请码 + let inviteCode = generateInviteCode() + let attempts = 0 + while (attempts < 10) { + const [existing] = await pool.query('SELECT id FROM `groups` WHERE invite_code = ?', [inviteCode]) + if ((existing as any[]).length === 0) break + inviteCode = generateInviteCode() + attempts++ + } + + await pool.query('UPDATE `groups` SET invite_code = ? WHERE id = ?', [inviteCode, groupId]) + res.json({ code: 0, data: { invite_code: inviteCode } }) + } catch (err) { + console.error('[Group] refresh-code error:', err) + res.status(500).json({ code: 50000, message: '服务器错误' }) + } +}) + +/** 获取成员列表 */ +router.get('/:id/members', async (req: AuthRequest, res: Response) => { + try { + const groupId = req.params.id + + // 验证用户是否是群组成员 + const [memberCheck] = await pool.query( + 'SELECT id FROM group_members WHERE group_id = ? AND user_id = ?', + [groupId, req.userId] + ) + if ((memberCheck as any[]).length === 0) { + return res.status(403).json({ code: 40300, message: '无权访问此群组' }) + } + + const [members] = await pool.query( + `SELECT gm.user_id, gm.role, gm.created_at as joined_at, + u.nickname, u.avatar_url + FROM group_members gm + LEFT JOIN users u ON gm.user_id = u.id + WHERE gm.group_id = ? + ORDER BY gm.role DESC, gm.created_at ASC`, + [groupId] + ) + + res.json({ code: 0, data: members }) + } catch (err) { + console.error('[Group] members error:', err) + res.status(500).json({ code: 50000, message: '服务器错误' }) + } +}) + +/** 移除成员(仅 owner) */ +router.delete('/:id/members/:userId', async (req: AuthRequest, res: Response) => { + try { + const groupId = req.params.id + const targetUserId = Number(req.params.userId) + + // 不能移除自己 + if (targetUserId === req.userId) { + return res.status(400).json({ code: 40001, message: '不能移除自己' }) + } + + // 验证是否是群主 + const [groupRows] = await pool.query( + 'SELECT created_by FROM `groups` WHERE id = ?', + [groupId] + ) + const group = (groupRows as any[])[0] + if (!group) { + return res.status(404).json({ code: 40400, message: '群组不存在' }) + } + if (group.created_by !== req.userId) { + return res.status(403).json({ code: 40300, message: '仅群主可移除成员' }) + } + + const conn = await pool.getConnection() + try { + await conn.beginTransaction() + + // 清除该成员在群组的记录标签 + await conn.query( + 'UPDATE transactions SET group_id = NULL WHERE user_id = ? AND group_id = ?', + [targetUserId, groupId] + ) + + // 删除成员关系 + await conn.query( + 'DELETE FROM group_members WHERE group_id = ? AND user_id = ?', + [groupId, targetUserId] + ) + + await conn.commit() + res.json({ code: 0 }) + } catch (err) { + await conn.rollback() + throw err + } finally { + conn.release() + } + } catch (err) { + console.error('[Group] remove member error:', err) + res.status(500).json({ code: 50000, message: '服务器错误' }) + } +}) + export default router