feat: phase 2 - polls, memories, notifications, stats v0.1.0

- Group polls with option/rollcall modes, edit by creator, auto-settle
- Multimedia memories with upload, preview, inline video playback
- In-app notifications for poll/team/group events
- Points system and group stats dashboard
- Group detail tabs with icons (activity/polls/memories/stats)
- Fix: nginx file upload size, static cache blocking API, timezone, auto-cancel

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
congsh
2026-04-18 18:19:46 +08:00
parent 71742da600
commit c5d3ac01ca
33 changed files with 5180 additions and 59 deletions
+18
View File
@@ -10,6 +10,24 @@ interface LogEntry {
}
const logs = ref<LogEntry[]>([
{
version: 'v0.1.0',
date: '2026-04-18',
title: '二期功能:投票、回忆、统计',
items: [
{ type: 'feat', text: '群组投票:支持选项投票和接龙报名两种模式,可设置截止时间、匿名投票' },
{ type: 'feat', text: '投票编辑:发起人可修改标题、选项、截止时间,已投票选项不可删除' },
{ type: 'feat', text: '投票结算:到达截止时间自动结算,发起人也可手动结束投票' },
{ type: 'feat', text: '多媒体回忆:群组内上传图片/视频/音频/文档,缩略图预览和弹窗播放' },
{ type: 'feat', text: '数据统计:群组内展示本周组队次数、投票参与率、积分排行' },
{ type: 'feat', text: '站内通知:新增投票、组队、入群等场景通知,铃铛图标显示未读数' },
{ type: 'feat', text: '积分体系:参与投票/组队/上传获取积分,群组内展示排行' },
{ type: 'feat', text: '群组详情页 Tab 重构:动态/投票/回忆/统计四个 Tab,带图标醒目展示' },
{ type: 'fix', text: '修复 nginx 代理文件上传 413 问题和静态资源误拦截 API 文件请求' },
{ type: 'fix', text: '修复投票列表 auto-cancel 竞态问题和选项排序 0 值校验失败' },
{ type: 'fix', text: '修复截止时间时区偏差,统一使用 ISO 格式存储' },
]
},
{
version: 'v0.0.3',
date: '2026-04-18',
+123 -4
View File
@@ -1,20 +1,30 @@
<!-- src/views/GroupView.vue -->
<script setup lang="ts">
import { onMounted, onUnmounted, computed } from 'vue'
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useGroupStore } from '@/stores/group'
import { useTeamStore } from '@/stores/team'
import { pb } from '@/api/pocketbase'
import { settlePoll } from '@/api/polls'
import TeamSessionPanel from '@/components/team/TeamSessionPanel.vue'
import IdleMembersList from '@/components/team/IdleMembersList.vue'
import GroupMembersPanel from '@/components/group/GroupMembersPanel.vue'
import PollList from '@/components/poll/PollList.vue'
import PollDetail from '@/components/poll/PollDetail.vue'
import MemoryGrid from '@/components/memory/MemoryGrid.vue'
import GroupStatsPanel from '@/components/stats/GroupStatsPanel.vue'
import { Promotion, Opportunity, UserFilled } from '@element-plus/icons-vue'
import { DataLine, PictureFilled, TrendCharts } from '@element-plus/icons-vue'
const route = useRoute()
const groupStore = useGroupStore()
const teamStore = useTeamStore()
const activeTab = ref('activity')
const viewingPollId = ref<string | null>(null)
const unsubFns: (() => Promise<void>)[] = []
let settleTimer: ReturnType<typeof setInterval> | null = null
const groupId = route.params.id as string
@@ -77,6 +87,9 @@ onMounted(async () => {
unsubFns.push(await pb.collection('team_sessions').subscribe('*', () => {
teamStore.loadActiveSession()
}))
// 投票结算定时器:每 60 秒检查过期投票
settleTimer = setInterval(checkExpiredPolls, 60000)
})
onUnmounted(async () => {
@@ -84,11 +97,42 @@ onUnmounted(async () => {
try { await unsub() } catch (_) {}
}
unsubFns.length = 0
if (settleTimer) {
clearInterval(settleTimer)
settleTimer = null
}
})
async function refreshMembers() {
await groupStore.setCurrentGroup(groupId)
}
async function checkExpiredPolls() {
const currentGroupId = groupStore.currentGroupId
const user = pb.authStore.model
if (!currentGroupId || !user) return
try {
// 优化:只有当前登录用户是投票发起人时,才主动去结算自己发起的投票
// 这避免了群里有 10 个成员在线时,10 个客户端同时发起请求的问题
const result = await pb.collection('polls').getList(1, 50, {
filter: `group="${currentGroupId}" && status="active" && deadline!="" && creator="${user.id}"`,
$autoCancel: false
})
const now = new Date()
for (const poll of result.items as any[]) {
if (poll.deadline && new Date(poll.deadline) <= now) {
try {
await settlePoll(poll.id)
} catch (e) {
console.error('结算投票失败:', poll.id, e)
}
}
}
} catch (e) {
console.error('检查过期投票失败:', e)
}
}
</script>
<template>
@@ -113,8 +157,14 @@ async function refreshMembers() {
</div>
</section>
<!-- 主体双栏 -->
<div class="body-grid">
<!-- Tab 系统 -->
<el-tabs v-model="activeTab" class="group-tabs group-tabs--fancy">
<el-tab-pane name="activity">
<template #label>
<span class="fancy-tab"><el-icon><Promotion /></el-icon> 动态</span>
</template>
<!-- 主体双栏 -->
<div class="body-grid">
<!-- 左列: 主内容 -->
<div class="left-col">
<!-- 当前临时小组 -->
@@ -169,7 +219,31 @@ async function refreshMembers() {
<aside class="right-col">
<GroupMembersPanel />
</aside>
</div>
</div>
</el-tab-pane>
<el-tab-pane name="polls">
<template #label>
<span class="fancy-tab"><el-icon><DataLine /></el-icon> 投票</span>
</template>
<PollDetail v-if="viewingPollId" :poll-id="viewingPollId" @back="viewingPollId = null" />
<PollList v-else @view-poll="viewingPollId = $event" />
</el-tab-pane>
<el-tab-pane name="memories">
<template #label>
<span class="fancy-tab"><el-icon><PictureFilled /></el-icon> 回忆</span>
</template>
<MemoryGrid />
</el-tab-pane>
<el-tab-pane name="stats">
<template #label>
<span class="fancy-tab"><el-icon><TrendCharts /></el-icon> 统计</span>
</template>
<GroupStatsPanel />
</el-tab-pane>
</el-tabs>
</div>
</template>
@@ -502,4 +576,49 @@ async function refreshMembers() {
transition: width 0.4s ease;
min-width: 0;
}
/* ── Tab 样式 ── */
.group-tabs--fancy :deep(.el-tabs__header) {
margin-bottom: 20px;
}
.group-tabs--fancy :deep(.el-tabs__nav-wrap::after) {
height: 2px;
background: var(--gg-border);
}
.group-tabs--fancy :deep(.el-tabs__active-bar) {
height: 3px;
border-radius: 2px;
background: var(--gg-primary);
}
.group-tabs--fancy :deep(.el-tabs__item) {
font-size: 15px;
font-weight: 500;
padding: 0 24px;
height: 44px;
line-height: 44px;
color: var(--gg-text-muted);
transition: color 0.2s, transform 0.15s;
}
.group-tabs--fancy :deep(.el-tabs__item:hover) {
color: var(--gg-primary);
}
.group-tabs--fancy :deep(.el-tabs__item.is-active) {
color: var(--gg-primary);
font-weight: 700;
}
.fancy-tab {
display: inline-flex;
align-items: center;
gap: 6px;
}
.fancy-tab .el-icon {
font-size: 17px;
}
</style>