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:
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user