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
+99
View File
@@ -0,0 +1,99 @@
import { pb } from './pocketbase'
import type { Memory } from '@/types'
export const GROUP_STORAGE_LIMIT = 10 * 1024 * 1024 * 1024
export function detectFileType(file: File): 'image' | 'video' | 'audio' | 'document' | 'other' {
const mime = file.type || ''
if (mime.startsWith('image/')) return 'image'
if (mime.startsWith('video/')) return 'video'
if (mime.startsWith('audio/')) return 'audio'
if (
mime.startsWith('application/pdf') ||
mime.startsWith('application/msword') ||
mime.startsWith('application/vnd.') ||
mime.startsWith('text/')
) return 'document'
return 'other'
}
export async function uploadMemory(
groupId: string,
file: File,
meta?: { title?: string; description?: string; tags?: string[] }
) {
const used = await getGroupStorageUsed(groupId)
if (used + file.size > GROUP_STORAGE_LIMIT) {
throw new Error('群组存储空间不足,无法上传')
}
const user = pb.authStore.model
const formData = new FormData()
formData.append('group', groupId)
formData.append('uploader', user?.id || '')
formData.append('file', file)
formData.append('fileType', detectFileType(file))
formData.append('size', file.size.toString())
formData.append('title', meta?.title || file.name)
if (meta?.description) formData.append('description', meta.description)
return pb.collection('memories').create(formData)
}
export async function listMemories(
groupId: string,
options?: {
page?: number
limit?: number
fileType?: string
}
): Promise<{ items: Memory[]; total: number }> {
const { page = 1, limit = 30, fileType } = options || {}
let filter = `group="${groupId}"`
if (fileType) filter += ` && fileType="${fileType}"`
const result = await pb.collection('memories').getList(page, limit, {
filter,
sort: '-created',
expand: 'uploader'
})
return { items: result.items as unknown as Memory[], total: result.totalItems }
}
export async function deleteMemory(memoryId: string) {
return pb.collection('memories').delete(memoryId)
}
export async function getGroupStorageUsed(groupId: string): Promise<number> {
let total = 0
let page = 1
const batchSize = 500
let hasMore = true
while (hasMore) {
const result = await pb.collection('memories').getList(page, batchSize, {
filter: `group="${groupId}"`,
fields: 'size'
})
total += result.items.reduce((sum: number, r: any) => sum + (r.size || 0), 0)
hasMore = result.items.length === batchSize && page * batchSize < result.totalItems
page++
}
return total
}
export function getGroupStorageLimit(): number {
return GROUP_STORAGE_LIMIT
}
export function subscribeMemories(
groupId: string,
callback: (data: any) => void
) {
return pb.collection('memories').subscribe('*', (data) => {
if (data.record?.group === groupId) {
callback(data)
}
})
}
+89
View File
@@ -0,0 +1,89 @@
import { pb } from './pocketbase'
import type { AppNotification } from '@/types'
export async function listNotifications(options?: {
page?: number
limit?: number
unreadOnly?: boolean
}): Promise<{ items: AppNotification[], total: number }> {
const { page = 1, limit = 50, unreadOnly = false } = options || {}
const user = pb.authStore.model
if (!user) return { items: [], total: 0 }
let filter = `user="${user.id}"`
if (unreadOnly) filter += ' && read=false'
const result = await pb.collection('notifications').getList(page, limit, {
filter,
sort: '-created'
})
return { items: result.items as unknown as AppNotification[], total: result.totalItems }
}
export async function markAsRead(notificationId: string): Promise<AppNotification> {
return pb.collection('notifications').update(notificationId, { read: true }) as unknown as Promise<AppNotification>
}
export async function markAllAsRead(): Promise<void> {
const user = pb.authStore.model
if (!user) return
let page = 1
const batchSize = 50
let hasMore = true
while (hasMore) {
const result = await pb.collection('notifications').getList(page, batchSize, {
filter: `user="${user.id}" && read=false`
})
if (result.items.length === 0) break
await Promise.all(
result.items.map(item => pb.collection('notifications').update(item.id, { read: true }))
)
hasMore = result.items.length === batchSize && page * batchSize < result.totalItems
page++
}
}
export async function deleteNotification(notificationId: string): Promise<void> {
await pb.collection('notifications').delete(notificationId)
}
export async function batchDelete(notificationIds: string[]): Promise<void> {
const batchSize = 20
for (let i = 0; i < notificationIds.length; i += batchSize) {
const batch = notificationIds.slice(i, i + batchSize)
await Promise.all(batch.map(id => pb.collection('notifications').delete(id)))
}
}
export async function createNotification(data: {
user: string
type: string
title: string
content?: string
relatedId?: string
relatedType?: string
}): Promise<AppNotification> {
return pb.collection('notifications').create(data) as unknown as Promise<AppNotification>
}
export async function subscribeNotifications(
callback: (data: { action: string, record: AppNotification }) => void
): Promise<() => void> {
const user = pb.authStore.model
if (!user) return () => {}
await pb.collection('notifications').subscribe('*', (e) => {
if (e.record?.user === user.id) {
callback({ action: e.action, record: e.record as unknown as AppNotification })
}
})
return () => {
pb.collection('notifications').unsubscribe('*')
}
}
+92
View File
@@ -0,0 +1,92 @@
import { pb } from './pocketbase'
import type { PointLog, PointAction } from '@/types'
const POINT_MAP: Record<PointAction, number> = {
vote: 1,
team: 2,
memory: 1
}
export async function awardPoints(action: PointAction, relatedId: string): Promise<void> {
const user = pb.authStore.model
if (!user) return
const existing = await pb.collection('point_logs').getList(1, 1, {
filter: `user="${user.id}" && action="${action}" && relatedId="${relatedId}"`,
$autoCancel: false
})
if (existing.items.length > 0) return
const points = POINT_MAP[action]
try {
await pb.collection('point_logs').create({
user: user.id,
action,
points,
relatedId
})
} catch (error: any) {
if (error?.response?.data?.user || error?.message?.includes('unique')) {
return
}
throw error
}
const currentUser = await pb.collection('users').getOne(user.id)
await pb.collection('users').update(user.id, {
points: ((currentUser as any).points || 0) + points
})
}
export async function deductPoints(action: PointAction, relatedId: string): Promise<void> {
const user = pb.authStore.model
if (!user) return
const existing = await pb.collection('point_logs').getList(1, 1, {
filter: `user="${user.id}" && action="${action}" && relatedId="${relatedId}"`,
$autoCancel: false
})
if (existing.items.length === 0) return
const log = existing.items[0]
try {
await pb.collection('point_logs').delete(log.id)
} catch (error: any) {
if (error?.status !== 404) {
throw error
}
}
const pointsToDeduct = POINT_MAP[action]
const currentUser = await pb.collection('users').getOne(user.id)
const currentPoints = (currentUser as any).points || 0
const newPoints = Math.max(0, currentPoints - pointsToDeduct)
await pb.collection('users').update(user.id, {
points: newPoints
})
}
export async function getUserPointLogs(userId: string): Promise<PointLog[]> {
const result = await pb.collection('point_logs').getList(1, 100, {
filter: `user="${userId}"`,
sort: '-created',
$autoCancel: false
})
return result.items as unknown as PointLog[]
}
export async function getGroupMemberRanking(groupId: string, limit = 20) {
const group = await pb.collection('groups').getOne(groupId, {
expand: 'members',
$autoCancel: false
}) as any
const members: any[] = group.expand?.members || []
return members
.map((m: any) => ({ userId: m.id, points: m.points || 0, name: m.name || m.username }))
.sort((a: any, b: any) => b.points - a.points)
.slice(0, limit)
}
+220
View File
@@ -0,0 +1,220 @@
import { pb } from './pocketbase'
import { awardPoints, deductPoints } from './points'
import type { Poll, PollOption, PollVote } from '@/types'
export async function createPoll(data: {
group: string
title: string
type?: 'option' | 'rollcall'
anonymous?: boolean
deadline?: string
maxParticipants?: number
options: string[]
}): Promise<Poll> {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
const poll = await pb.collection('polls').create({
group: data.group,
title: data.title,
type: data.type || 'option',
anonymous: data.anonymous || false,
deadline: data.deadline || '',
maxParticipants: data.type === 'rollcall' ? data.maxParticipants : null,
status: 'active',
creator: user.id,
})
for (let i = 0; i < data.options.length; i++) {
await pb.collection('poll_options').create({
poll: poll.id,
content: data.options[i],
order: i + 1,
})
}
return poll as unknown as Poll
}
export async function listPolls(
groupId: string,
status?: string
): Promise<Poll[]> {
let filter = `group="${groupId}"`
if (status) filter += ` && status="${status}"`
const result = await pb.collection('polls').getFullList({
filter,
sort: '-created',
expand: 'creator',
$autoCancel: false,
})
return result as unknown as Poll[]
}
export async function getPoll(pollId: string): Promise<Poll> {
const result = await pb.collection('polls').getOne(pollId, {
expand: 'creator',
})
return result as unknown as Poll
}
export async function getPollOptions(pollId: string): Promise<PollOption[]> {
const result = await pb.collection('poll_options').getFullList({
filter: `poll="${pollId}"`,
sort: 'order',
$autoCancel: false,
})
return result as unknown as PollOption[]
}
export async function getPollVotes(pollId: string): Promise<PollVote[]> {
const result = await pb.collection('poll_votes').getFullList({
filter: `poll="${pollId}"`,
expand: 'user,option',
$autoCancel: false,
})
return result as unknown as PollVote[]
}
export async function votePoll(
pollId: string,
optionId: string
): Promise<PollVote> {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
const poll = await pb.collection('polls').getOne(pollId)
if (poll.status !== 'active') {
throw new Error('投票已结束')
}
const existing = await pb.collection('poll_votes').getList(1, 1, {
filter: `poll="${pollId}" && user="${user.id}"`,
})
if (existing.items.length > 0) {
throw new Error('你已经投过票了')
}
try {
const vote = await pb.collection('poll_votes').create({
poll: pollId,
option: optionId,
user: user.id,
})
await awardPoints('vote', pollId)
return vote as unknown as PollVote
} catch (error: any) {
if (error?.response?.data?.poll || error?.message?.includes('unique')) {
throw new Error('你已经投过票了')
}
throw error
}
}
export async function cancelVote(pollId: string): Promise<void> {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
const poll = await pb.collection('polls').getOne(pollId)
if (poll.status !== 'active') {
throw new Error('投票已结束,无法取消')
}
const existing = await pb.collection('poll_votes').getFullList({
filter: `poll="${pollId}" && user="${user.id}"`,
})
for (const vote of existing) {
await pb.collection('poll_votes').delete(vote.id)
}
await deductPoints('vote', pollId)
}
export async function settlePoll(pollId: string): Promise<Poll> {
const result = await pb.collection('polls').update(pollId, {
status: 'settled',
settledAt: new Date().toISOString(),
})
return result as unknown as Poll
}
export async function updatePoll(pollId: string, data: {
title?: string
deadline?: string
maxParticipants?: number | null
}): Promise<Poll> {
const result = await pb.collection('polls').update(pollId, data)
return result as unknown as Poll
}
export async function addPollOption(pollId: string, content: string): Promise<PollOption> {
const existing = await pb.collection('poll_options').getFullList({
filter: `poll="${pollId}"`,
sort: '-order',
$autoCancel: false,
})
const maxOrder = existing.reduce((max: number, o: any) => Math.max(max, o.order || 0), 0)
const result = await pb.collection('poll_options').create({
poll: pollId,
content,
order: maxOrder + 1,
})
return result as unknown as PollOption
}
export async function updatePollOption(optionId: string, content: string): Promise<PollOption> {
const result = await pb.collection('poll_options').update(optionId, { content })
return result as unknown as PollOption
}
export async function deletePollOption(optionId: string): Promise<void> {
await pb.collection('poll_options').delete(optionId)
}
export async function getUserVote(pollId: string): Promise<PollVote | null> {
const user = pb.authStore.model
if (!user) return null
const result = await pb.collection('poll_votes').getList(1, 1, {
filter: `poll="${pollId}" && user="${user.id}"`,
expand: 'option',
$autoCancel: false,
})
return result.items.length > 0
? (result.items[0] as unknown as PollVote)
: null
}
export async function subscribePolls(
groupId: string,
callback: (data: any) => void
): Promise<() => void> {
await pb.collection('polls').subscribe('*', (data) => {
if (data.record?.group === groupId) {
callback(data)
}
})
return () => {
pb.collection('polls').unsubscribe('*')
}
}
export async function subscribePollVotes(
pollId: string,
callback: (data: any) => void
): Promise<() => void> {
await pb.collection('poll_votes').subscribe('*', (data) => {
if (data.record?.poll === pollId) {
callback(data)
}
})
return () => {
pb.collection('poll_votes').unsubscribe('*')
}
}
@@ -0,0 +1,531 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useGroupStore } from '@/stores/group'
import { useMemoryStore } from '@/stores/memory'
import { subscribeMemories } from '@/api/memories'
import { pb } from '@/api/pocketbase'
import { displayName } from '@/types'
import { Plus, PictureFilled, Document, VideoCamera, Headset, Loading, Delete } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import MemoryUploadDialog from './MemoryUploadDialog.vue'
const groupStore = useGroupStore()
const memoryStore = useMemoryStore()
const showUploadDialog = ref(false)
const activeFilter = ref<string>('')
let unsubscribeFn: (() => Promise<void>) | null = null
// 存储容量信息
const storageUsedGB = computed(() => (memoryStore.storageUsed / (1024 * 1024 * 1024)).toFixed(2))
const storageLimitGB = computed(() => (memoryStore.storageLimit / (1024 * 1024 * 1024)).toFixed(0))
const storagePercent = computed(() => memoryStore.storagePercent)
const isStorageWarning = computed(() => storagePercent.value >= 80)
// 过滤后的记忆列表
const filteredMemories = computed(() => {
if (!activeFilter.value) return memoryStore.memories
return memoryStore.memories.filter((m: any) => m.fileType === activeFilter.value)
})
// 获取文件访问 URL(支持缩略图)
function getFileUrl(memory: any, thumb?: string): string {
if (!memory?.file) return ''
return pb.files.getUrl(memory, memory.file, thumb ? { thumb } : {})
}
// 格式化文件大小
function formatSize(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
const size = (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)
return `${size} ${units[i]}`
}
// 判断当前用户是否为上传者或群管理员
function isOwnerOrAdmin(memory: any): boolean {
const userId = pb.authStore.model?.id
if (!userId) return false
if (memory.uploader === userId) return true
// 群管理员 = 群主
if (groupStore.currentGroup?.owner === userId) return true
return false
}
// 获取文件类型图标
function getFileIcon(fileType: string) {
switch (fileType) {
case 'image': return PictureFilled
case 'video': return VideoCamera
case 'audio': return Headset
default: return Document
}
}
// 点击文件卡片(打开预览弹窗)
const showPreview = ref(false)
const previewMemory = ref<any>(null)
function handleCardClick(memory: any) {
if (memory.fileType === 'image' || memory.fileType === 'video') {
previewMemory.value = memory
showPreview.value = true
}
}
// 删除记忆
async function handleDelete(memory: any) {
try {
await ElMessageBox.confirm('确定要删除这条记忆吗?', '确认删除', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning'
})
await memoryStore.remove(memory.id, groupStore.currentGroupId)
ElMessage.success('删除成功')
} catch {
// 用户取消
}
}
// 格式化时间
function formatTime(dateStr: string): string {
const d = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - d.getTime()
const diffMin = Math.floor(diffMs / 60000)
const diffHour = Math.floor(diffMs / 3600000)
const diffDay = Math.floor(diffMs / 86400000)
if (diffMin < 1) return '刚刚'
if (diffMin < 60) return `${diffMin} 分钟前`
if (diffHour < 24) return `${diffHour} 小时前`
if (diffDay < 7) return `${diffDay} 天前`
return d.toLocaleDateString('zh-CN')
}
// 加载数据
async function loadData() {
const groupId = groupStore.currentGroupId
if (!groupId) return
await memoryStore.loadMemories(groupId, activeFilter.value || undefined)
}
// 订阅变更
async function setupSubscription() {
const groupId = groupStore.currentGroupId
if (!groupId) return
try {
unsubscribeFn = await subscribeMemories(groupId, () => {
loadData()
})
} catch (error) {
console.error('订阅记忆变更失败:', error)
}
}
onMounted(async () => {
if (groupStore.currentGroupId) {
await loadData()
await setupSubscription()
}
})
watch(() => groupStore.currentGroupId, async (newId, oldId) => {
if (newId && newId !== oldId) {
await loadData()
if (!unsubscribeFn) await setupSubscription()
}
})
onUnmounted(async () => {
if (unsubscribeFn) {
try {
await unsubscribeFn()
} catch {
// 忽略
}
unsubscribeFn = null
}
})
</script>
<template>
<div class="memory-grid-container">
<!-- 顶部标题栏 -->
<div class="memory-header">
<h3 class="memory-title">记忆相册</h3>
<el-button type="primary" :icon="Plus" @click="showUploadDialog = true">
上传
</el-button>
</div>
<!-- 存储容量条 -->
<div class="storage-section" :class="{ warning: isStorageWarning }">
<div class="storage-bar">
<div
class="storage-bar-fill"
:class="{ 'bar-warning': isStorageWarning }"
:style="{ width: Math.min(storagePercent, 100) + '%' }"
/>
</div>
<div class="storage-label">
{{ storageUsedGB }} GB / {{ storageLimitGB }} GB
<span v-if="isStorageWarning" class="storage-warn-text">空间不足</span>
</div>
</div>
<!-- 类型过滤 -->
<div class="filter-bar">
<el-button
:type="activeFilter === '' ? 'primary' : 'default'"
size="small"
@click="activeFilter = ''"
>全部</el-button>
<el-button
:type="activeFilter === 'image' ? 'primary' : 'default'"
size="small"
@click="activeFilter = 'image'"
>图片</el-button>
<el-button
:type="activeFilter === 'video' ? 'primary' : 'default'"
size="small"
@click="activeFilter = 'video'"
>视频</el-button>
<el-button
:type="activeFilter === 'audio' ? 'primary' : 'default'"
size="small"
@click="activeFilter = 'audio'"
>音频</el-button>
<el-button
:type="activeFilter === 'document' ? 'primary' : 'default'"
size="small"
@click="activeFilter = 'document'"
>文档</el-button>
</div>
<!-- 加载状态 -->
<div v-if="memoryStore.loading" class="loading-state">
<el-icon class="is-loading" :size="24"><Loading /></el-icon>
<span>加载中...</span>
</div>
<!-- 空状态 -->
<div v-else-if="filteredMemories.length === 0" class="empty-state">
<el-icon :size="48" class="empty-icon"><PictureFilled /></el-icon>
<p>还没有记忆快来上传第一个吧</p>
</div>
<!-- 文件网格 -->
<div v-else class="memory-grid">
<div
v-for="memory in filteredMemories"
:key="memory.id"
class="memory-card"
@click="handleCardClick(memory)"
>
<!-- 文件预览区 -->
<div class="card-preview">
<!-- 图片缩略图 -->
<img
v-if="memory.fileType === 'image'"
:src="getFileUrl(memory, '300x300')"
:alt="memory.title"
class="preview-image"
loading="lazy"
/>
<!-- 视频缩略图取第一帧 -->
<video
v-else-if="memory.fileType === 'video'"
:src="getFileUrl(memory)"
class="preview-video"
preload="metadata"
muted
/>
<!-- 其他文件图标 -->
<div v-else class="preview-icon">
<el-icon :size="36"><component :is="getFileIcon(memory.fileType)" /></el-icon>
</div>
<!-- 视频播放按钮遮罩 -->
<div v-if="memory.fileType === 'video'" class="video-play-overlay">
<svg viewBox="0 0 24 24" fill="white" width="40" height="40">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" />
<polygon points="10,8 16,12 10,16" fill="white" />
</svg>
</div>
<!-- 删除按钮 -->
<el-button
v-if="isOwnerOrAdmin(memory)"
class="delete-btn"
:icon="Delete"
circle
size="small"
type="danger"
@click.stop="handleDelete(memory)"
/>
</div>
<!-- 文件信息 -->
<div class="card-info">
<div class="card-title" :title="memory.title">{{ memory.title }}</div>
<div class="card-meta">
<span class="card-uploader">{{ displayName(memory.expand?.uploader) }}</span>
<span class="card-size">{{ formatSize(memory.size) }}</span>
</div>
<div class="card-time">{{ formatTime(memory.created) }}</div>
</div>
</div>
</div>
<!-- 图片/视频预览弹窗 -->
<el-dialog
v-model="showPreview"
:title="previewMemory?.title"
width="80%"
class="preview-dialog"
@close="previewMemory = null"
>
<img
v-if="previewMemory?.fileType === 'image'"
:src="getFileUrl(previewMemory)"
class="preview-fullscreen"
/>
<video
v-else-if="previewMemory?.fileType === 'video'"
:src="getFileUrl(previewMemory)"
controls
autoplay
class="preview-fullscreen"
/>
</el-dialog>
<!-- 上传弹窗 -->
<MemoryUploadDialog
v-model="showUploadDialog"
@uploaded="loadData"
/>
</div>
</template>
<style scoped>
.memory-grid-container {
display: flex;
flex-direction: column;
gap: 16px;
}
/* 顶部 */
.memory-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.memory-title {
font-size: 18px;
font-weight: 600;
color: var(--gg-text-primary);
margin: 0;
}
/* 存储条 */
.storage-section {
padding: 0;
}
.storage-bar {
height: 6px;
background: var(--gg-bg-secondary);
border-radius: 3px;
overflow: hidden;
}
.storage-bar-fill {
height: 100%;
background: var(--gg-primary);
border-radius: 3px;
transition: width 0.3s ease;
}
.storage-bar-fill.bar-warning {
background: var(--el-color-warning);
}
.storage-label {
margin-top: 4px;
font-size: 12px;
color: var(--gg-text-hint);
}
.storage-warn-text {
color: var(--el-color-warning);
font-weight: 500;
margin-left: 6px;
}
.storage-section.warning .storage-bar {
background: var(--el-color-warning-light-9);
}
/* 过滤栏 */
.filter-bar {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
/* 加载 & 空状态 */
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 0;
color: var(--gg-text-hint);
font-size: 14px;
}
.empty-icon {
color: var(--gg-text-hint);
opacity: 0.4;
}
/* 文件网格 */
.memory-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
/* 卡片 */
.memory-card {
border: 1px solid var(--gg-border);
border-radius: 8px;
overflow: hidden;
background: var(--gg-bg-primary);
transition: box-shadow 0.2s, transform 0.15s;
cursor: pointer;
}
.memory-card:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
/* 预览区 */
.card-preview {
position: relative;
width: 100%;
height: 150px;
background: var(--gg-bg-secondary);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.preview-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-icon {
color: var(--gg-text-hint);
}
.delete-btn {
position: absolute;
top: 8px;
right: 8px;
opacity: 0;
transition: opacity 0.2s;
}
.memory-card:hover .delete-btn {
opacity: 1;
}
/* 信息区 */
.card-info {
padding: 10px 12px;
}
.card-title {
font-size: 14px;
font-weight: 500;
color: var(--gg-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 4px;
font-size: 12px;
color: var(--gg-text-hint);
}
.card-uploader {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 8px;
}
.card-time {
margin-top: 2px;
font-size: 11px;
color: var(--gg-text-hint);
}
/* 视频预览 */
.preview-video {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-play-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
.memory-card:hover .video-play-overlay {
opacity: 1;
}
/* 预览弹窗 */
.preview-fullscreen {
max-width: 100%;
max-height: 70vh;
display: block;
margin: 0 auto;
}
/* 响应式 */
@media (max-width: 640px) {
.memory-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
}
.card-preview {
height: 120px;
}
}
</style>
@@ -0,0 +1,236 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useGroupStore } from '@/stores/group'
import { useMemoryStore } from '@/stores/memory'
import { ElMessage, type UploadFile } from 'element-plus'
const visible = defineModel<boolean>({ default: false })
const emit = defineEmits<{ uploaded: [] }>()
const groupStore = useGroupStore()
const memoryStore = useMemoryStore()
const title = ref('')
const description = ref('')
const fileList = ref<UploadFile[]>([])
const loading = ref(false)
// 存储容量信息
const storageUsedGB = computed(() => (memoryStore.storageUsed / (1024 * 1024 * 1024)).toFixed(2))
const storageLimitGB = computed(() => (memoryStore.storageLimit / (1024 * 1024 * 1024)).toFixed(0))
const storagePercent = computed(() => memoryStore.storagePercent)
const isStorageWarning = computed(() => storagePercent.value >= 80)
// 文件选择变更
function handleFileChange(_file: UploadFile, uploadFileList: UploadFile[]) {
fileList.value = uploadFileList
}
// 移除文件
function handleFileRemove(_file: UploadFile, uploadFileList: UploadFile[]) {
fileList.value = uploadFileList
}
// 超出限制
function handleExceed() {
ElMessage.warning('只能上传一个文件,请先移除已选文件')
}
// 提交上传
async function handleSubmit() {
if (!title.value.trim()) {
ElMessage.warning('请输入标题')
return
}
if (fileList.value.length === 0) {
ElMessage.warning('请选择文件')
return
}
const rawFile = fileList.value[0].raw
if (!rawFile) {
ElMessage.error('文件读取失败,请重新选择')
return
}
const groupId = groupStore.currentGroupId
if (!groupId) {
ElMessage.error('未选择群组')
return
}
try {
loading.value = true
await memoryStore.upload(groupId, rawFile, {
title: title.value.trim(),
description: description.value.trim() || undefined
})
ElMessage.success('上传成功')
resetForm()
emit('uploaded')
visible.value = false
} catch (error: any) {
ElMessage.error(error.message || '上传失败')
} finally {
loading.value = false
}
}
// 重置表单
function resetForm() {
title.value = ''
description.value = ''
fileList.value = []
}
// 关闭时重置
function handleClose() {
resetForm()
}
</script>
<template>
<el-dialog
v-model="visible"
title="上传记忆"
width="480px"
@close="handleClose"
>
<div class="upload-form">
<div class="field">
<label>标题 *</label>
<el-input v-model="title" placeholder="给这段记忆起个名字" maxlength="100" />
</div>
<div class="field">
<label>描述</label>
<el-input
v-model="description"
type="textarea"
placeholder="简单描述一下(可选)"
:rows="3"
maxlength="500"
show-word-limit
/>
</div>
<div class="field">
<label>文件 *</label>
<el-upload
:auto-upload="false"
:limit="1"
:file-list="fileList"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
:on-exceed="handleExceed"
drag
>
<div class="upload-trigger">
<el-icon class="upload-icon"><Plus /></el-icon>
<div>将文件拖到此处<em>点击上传</em></div>
</div>
</el-upload>
</div>
<!-- 存储信息 -->
<div class="storage-info" :class="{ warning: isStorageWarning }">
<div class="storage-bar">
<div
class="storage-bar-fill"
:class="{ 'bar-warning': isStorageWarning }"
:style="{ width: Math.min(storagePercent, 100) + '%' }"
/>
</div>
<div class="storage-text">
已用 {{ storageUsedGB }} GB / {{ storageLimitGB }} GB
<span v-if="isStorageWarning" class="warning-text">存储空间不足</span>
</div>
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">上传</el-button>
</template>
</el-dialog>
</template>
<script lang="ts">
import { Plus } from '@element-plus/icons-vue'
export default { name: 'MemoryUploadDialog' }
</script>
<style scoped>
.upload-form {
display: flex;
flex-direction: column;
gap: 18px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field label {
font-size: 13px;
font-weight: 500;
color: var(--gg-text-secondary);
}
.upload-trigger {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: var(--gg-text-secondary);
font-size: 13px;
}
.upload-trigger em {
color: var(--gg-primary);
font-style: normal;
}
.upload-icon {
font-size: 28px;
color: var(--gg-text-hint);
}
/* 存储信息 */
.storage-info {
padding: 10px 0 0;
}
.storage-bar {
height: 6px;
background: var(--gg-bg-secondary);
border-radius: 3px;
overflow: hidden;
}
.storage-bar-fill {
height: 100%;
background: var(--gg-primary);
border-radius: 3px;
transition: width 0.3s ease;
}
.storage-bar-fill.bar-warning {
background: var(--el-color-warning);
}
.storage-text {
margin-top: 6px;
font-size: 12px;
color: var(--gg-text-hint);
}
.warning-text {
color: var(--el-color-warning);
font-weight: 500;
}
.storage-info.warning .storage-bar {
background: var(--el-color-warning-light-9);
}
</style>
@@ -0,0 +1,284 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { createPoll } from '@/api/polls'
import { useGroupStore } from '@/stores/group'
const visible = defineModel<boolean>({ default: false })
const emit = defineEmits<{
created: []
}>()
const groupStore = useGroupStore()
// 投票类型: option=选项投票, rollcall=接龙报名
const pollType = ref<'option' | 'rollcall'>('option')
const form = ref({
title: '',
options: ['', ''],
maxParticipants: 10,
deadline: '' as string | Date,
anonymous: false,
})
const loading = ref(false)
// 是否可以添加选项
const canAddOption = computed(() => form.value.options.length < 10)
function addOption() {
if (canAddOption.value) {
form.value.options.push('')
}
}
function removeOption(index: number) {
form.value.options.splice(index, 1)
}
function resetForm() {
pollType.value = 'option'
form.value = {
title: '',
options: ['', ''],
maxParticipants: 10,
deadline: '',
anonymous: false,
}
}
async function handleSubmit() {
// 标题必填
if (!form.value.title.trim()) {
ElMessage.warning('请输入投票标题')
return
}
// 选项投票模式: 至少2个非空选项
if (pollType.value === 'option') {
const nonEmpty = form.value.options.filter((o) => o.trim())
if (nonEmpty.length < 2) {
ElMessage.warning('选项投票至少需要2个非空选项')
return
}
}
const groupId = groupStore.currentGroupId
if (!groupId) {
ElMessage.error('请先选择群组')
return
}
loading.value = true
try {
// 接龙模式自动创建一个"报名参加"选项
const options =
pollType.value === 'rollcall'
? ['报名参加']
: form.value.options.filter((o) => o.trim())
await createPoll({
group: groupId,
title: form.value.title.trim(),
type: pollType.value,
anonymous: form.value.anonymous,
deadline: form.value.deadline ? new Date(String(form.value.deadline)).toISOString() : undefined,
maxParticipants: pollType.value === 'rollcall' ? form.value.maxParticipants : undefined,
options,
})
visible.value = false
resetForm()
ElMessage.success('投票创建成功')
emit('created')
} catch (error: any) {
ElMessage.error(error.message || '创建投票失败')
} finally {
loading.value = false
}
}
function handleOpen() {
resetForm()
}
</script>
<template>
<el-dialog
v-model="visible"
title="发起投票"
width="480px"
@open="handleOpen"
>
<div class="create-form">
<!-- 投票类型切换 -->
<div class="form-field">
<label>投票类型</label>
<div class="type-switch">
<button
:class="['type-btn', { active: pollType === 'option' }]"
@click="pollType = 'option'"
>
选项投票
</button>
<button
:class="['type-btn', { active: pollType === 'rollcall' }]"
@click="pollType = 'rollcall'"
>
接龙报名
</button>
</div>
</div>
<!-- 标题 -->
<div class="form-field">
<label>标题 <span class="required">*</span></label>
<el-input
v-model="form.title"
placeholder="请输入投票标题"
maxlength="100"
show-word-limit
/>
</div>
<!-- 选项列表仅选项投票模式 -->
<div v-if="pollType === 'option'" class="form-field">
<label>投票选项</label>
<div class="options-list">
<div
v-for="(_, index) in form.options"
:key="index"
class="option-item"
>
<el-input
v-model="form.options[index]"
:placeholder="`选项 ${index + 1}`"
maxlength="100"
/>
<el-button
v-if="form.options.length > 2"
type="danger"
text
@click="removeOption(index)"
>
删除
</el-button>
</div>
<el-button
v-if="canAddOption"
type="primary"
text
@click="addOption"
>
+ 添加选项
</el-button>
</div>
</div>
<!-- 人数上限仅接龙模式 -->
<div v-if="pollType === 'rollcall'" class="form-field">
<label>人数上限</label>
<el-input-number
v-model="form.maxParticipants"
:min="2"
:max="100"
/>
</div>
<!-- 截止时间 -->
<div class="form-field">
<label>截止时间</label>
<el-date-picker
v-model="form.deadline"
type="datetime"
placeholder="选择截止时间(可选)"
format="YYYY-MM-DD HH:mm"
style="width: 100%"
/>
</div>
<!-- 匿名开关 -->
<div class="form-field">
<label>匿名投票</label>
<el-switch v-model="form.anonymous" />
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">
创建
</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.create-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-field {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-field label {
font-size: 14px;
font-weight: 500;
color: var(--gg-text);
}
.required {
color: var(--gg-danger);
}
.type-switch {
display: flex;
gap: 8px;
}
.type-btn {
flex: 1;
padding: 8px 16px;
border: 1px solid var(--gg-border, #dcdfe6);
border-radius: 6px;
background: var(--gg-bg, #fff);
color: var(--gg-text-secondary, #909399);
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.type-btn:hover {
border-color: var(--gg-primary, #67c23a);
color: var(--gg-primary, #67c23a);
}
.type-btn.active {
background: var(--gg-primary, #67c23a);
border-color: var(--gg-primary, #67c23a);
color: #fff;
}
.options-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.option-item {
display: flex;
align-items: center;
gap: 8px;
}
.option-item .el-input {
flex: 1;
}
</style>
@@ -0,0 +1,318 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { usePollStore } from '@/stores/poll'
import type { PollOption, PollVote } from '@/types'
const visible = defineModel<boolean>({ default: false })
const props = defineProps<{
pollId: string
pollType: 'option' | 'rollcall'
pollAnonymous: boolean
options: PollOption[]
votes: PollVote[]
}>()
const emit = defineEmits<{
saved: []
}>()
const pollStore = usePollStore()
interface EditOption {
id?: string
content: string
hasVotes: boolean
}
const form = ref({
title: '',
options: [] as EditOption[],
maxParticipants: 10 as number | null,
deadline: '' as string | Date,
})
const loading = ref(false)
// 哪些选项被标记删除
const deletedIds = ref<Set<string>>(new Set())
const activeOptions = computed(() =>
form.value.options.filter(o => !deletedIds.value.has(o.id || ''))
)
const canAddOption = computed(() => activeOptions.value.length < 10)
function initForm() {
deletedIds.value = new Set()
// 从 store 读取当前投票数据
const poll = pollStore.currentPoll
if (!poll) return
form.value.title = poll.title
form.value.maxParticipants = poll.maxParticipants || null
form.value.deadline = poll.deadline ? new Date(poll.deadline) : ''
// 获取每个选项是否有投票
const voteCounts: Record<string, number> = {}
for (const v of pollStore.currentVotes) {
voteCounts[v.option] = (voteCounts[v.option] || 0) + 1
}
form.value.options = pollStore.currentOptions.map(o => ({
id: o.id,
content: o.content,
hasVotes: (voteCounts[o.id] || 0) > 0,
}))
}
function addOption() {
if (!canAddOption.value) return
form.value.options.push({ content: '', hasVotes: false })
}
function removeOption(opt: EditOption) {
if (opt.id) {
deletedIds.value.add(opt.id)
} else {
const idx = form.value.options.indexOf(opt)
if (idx !== -1) form.value.options.splice(idx, 1)
}
}
async function handleSubmit() {
if (!form.value.title.trim()) {
ElMessage.warning('请输入投票标题')
return
}
const kept = activeOptions.value.filter(o => o.content.trim())
if (props.pollType === 'option' && kept.length < 2) {
ElMessage.warning('选项投票至少需要2个非空选项')
return
}
loading.value = true
try {
await pollStore.edit(props.pollId, {
title: form.value.title.trim(),
deadline: form.value.deadline ? new Date(form.value.deadline).toISOString() : '',
maxParticipants: props.pollType === 'rollcall' ? form.value.maxParticipants : null,
options: activeOptions.value.map(o => ({
id: o.id,
content: o.content.trim(),
hasVotes: o.hasVotes,
})),
deletedOptionIds: Array.from(deletedIds.value),
})
visible.value = false
ElMessage.success('投票已更新')
emit('saved')
} catch (error: any) {
ElMessage.error(error.message || '更新投票失败')
} finally {
loading.value = false
}
}
</script>
<template>
<el-dialog
v-model="visible"
title="编辑投票"
width="480px"
@open="initForm"
>
<div class="edit-form">
<!-- 投票类型只读 -->
<div class="form-field">
<label>投票类型</label>
<div class="type-readonly">
{{ pollType === 'option' ? '选项投票' : '接龙报名' }}
</div>
</div>
<!-- 标题 -->
<div class="form-field">
<label>标题 <span class="required">*</span></label>
<el-input
v-model="form.title"
placeholder="请输入投票标题"
maxlength="100"
show-word-limit
/>
</div>
<!-- 选项列表仅选项投票模式 -->
<div v-if="pollType === 'option'" class="form-field">
<label>投票选项</label>
<div class="options-list">
<div
v-for="opt in form.options"
:key="opt.id || opt.content"
v-show="!deletedIds.has(opt.id || '')"
class="option-item"
>
<el-input
v-model="opt.content"
placeholder="选项内容"
maxlength="100"
/>
<el-button
v-if="opt.hasVotes"
type="info"
text
disabled
title="已有投票,不可删除"
>
已投票
</el-button>
<el-button
v-else-if="activeOptions.length > 2"
type="danger"
text
@click="removeOption(opt)"
>
删除
</el-button>
</div>
<button
v-if="canAddOption"
class="add-option-btn"
@click="addOption"
>
<el-icon><Plus /></el-icon> 添加选项
</button>
</div>
</div>
<!-- 人数上限仅接龙模式 -->
<div v-if="pollType === 'rollcall'" class="form-field">
<label>人数上限</label>
<el-input-number
v-model="form.maxParticipants"
:min="2"
:max="100"
/>
</div>
<!-- 截止时间 -->
<div class="form-field">
<label>截止时间</label>
<el-date-picker
v-model="form.deadline"
type="datetime"
placeholder="选择截止时间(可选)"
format="YYYY-MM-DD HH:mm"
style="width: 100%"
/>
</div>
<!-- 匿名只读 -->
<div class="form-field">
<label>匿名投票</label>
<div class="type-readonly">{{ pollAnonymous ? '是' : '否' }}</div>
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<button class="save-btn" :disabled="loading" @click="handleSubmit">
{{ loading ? '保存中...' : '保存' }}
</button>
</template>
</el-dialog>
</template>
<style scoped>
.edit-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-field {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-field label {
font-size: 14px;
font-weight: 500;
color: var(--gg-text);
}
.required {
color: var(--gg-danger);
}
.type-readonly {
padding: 8px 16px;
border: 1px solid var(--gg-border);
border-radius: 6px;
background: var(--gg-bg-elevated);
color: var(--gg-text-secondary);
font-size: 14px;
}
.options-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.option-item {
display: flex;
align-items: center;
gap: 8px;
}
.option-item .el-input {
flex: 1;
}
.add-option-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 7px 16px;
border: none;
border-radius: var(--gg-radius-sm);
background: var(--gg-gradient-green);
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.add-option-btn:hover {
opacity: 0.85;
}
.save-btn {
padding: 8px 20px;
border: none;
border-radius: var(--gg-radius-sm);
background: var(--gg-gradient-green);
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.save-btn:hover {
opacity: 0.85;
}
.save-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
+322
View File
@@ -0,0 +1,322 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Poll, PollOption, PollVote } from '@/types'
const props = defineProps<{
poll: Poll
options: PollOption[]
votes: PollVote[]
currentUserVote: string | null
}>()
const emit = defineEmits<{
click: []
}>()
// 投票类型标签
const typeLabel = computed(() => {
return props.poll.type === 'option' ? '选项投票' : '接龙报名'
})
// 状态标签
const statusLabel = computed(() => {
return props.poll.status === 'active' ? '进行中' : '已结束'
})
// 是否进行中
const isActive = computed(() => props.poll.status === 'active')
// 总投票人数(去重)
const totalVotes = computed(() => {
const uniqueUsers = new Set(props.votes.map(v => v.user))
return uniqueUsers.size
})
// 各选项得票数
const optionVoteCounts = computed(() => {
const counts: Record<string, number> = {}
for (const vote of props.votes) {
counts[vote.option] = (counts[vote.option] || 0) + 1
}
return counts
})
// 领先选项(最高票)
const topOption = computed(() => {
if (props.options.length === 0 || props.votes.length === 0) return null
let topId = ''
let topCount = 0
for (const [optionId, count] of Object.entries(optionVoteCounts.value)) {
if (count > topCount) {
topCount = count
topId = optionId
}
}
const option = props.options.find(o => o.id === topId)
return option ? { content: option.content, count: topCount } : null
})
// 截止时间倒计时
const deadlineText = computed(() => {
if (!props.poll.deadline) return null
const deadline = new Date(props.poll.deadline).getTime()
const now = Date.now()
const diff = deadline - now
if (diff <= 0) return '已截止'
const minutes = Math.floor(diff / (1000 * 60))
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 0) return `${days}天后截止`
if (hours > 0) return `${hours}小时后截止`
if (minutes > 0) return `${minutes}分钟后截止`
return '即将截止'
})
</script>
<template>
<div class="poll-card" @click="emit('click')">
<!-- 顶部标签行 -->
<div class="poll-card__tags">
<span class="poll-card__type-tag" :class="`poll-card__type-tag--${poll.type}`">
{{ typeLabel }}
</span>
<span class="poll-card__status-tag" :class="isActive ? 'poll-card__status-tag--active' : 'poll-card__status-tag--settled'">
{{ statusLabel }}
</span>
<span v-if="poll.anonymous" class="poll-card__anon-tag">
匿名
</span>
<span v-if="currentUserVote" class="poll-card__voted-badge">
已参与
</span>
</div>
<!-- 标题 -->
<div class="poll-card__title">
{{ poll.title }}
</div>
<!-- 统计信息 -->
<div class="poll-card__stats">
<span class="poll-card__stat">
<svg class="poll-card__stat-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
{{ totalVotes }} 人参与
</span>
<span v-if="poll.maxParticipants" class="poll-card__stat">
<svg class="poll-card__stat-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="9" y1="9" x2="15" y2="15" />
<line x1="15" y1="9" x2="9" y2="15" />
</svg>
{{ poll.maxParticipants }}
</span>
</div>
<!-- 领先选项 -->
<div v-if="topOption" class="poll-card__top-option">
<span class="poll-card__top-label">领先</span>
<span class="poll-card__top-content">{{ topOption.content }}</span>
<span class="poll-card__top-count">{{ topOption.count }} </span>
</div>
<!-- 底部截止时间 -->
<div v-if="deadlineText" class="poll-card__footer">
<svg class="poll-card__footer-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span :class="{ 'poll-card__deadline--urgent': deadlineText === '即将截止' }">
{{ deadlineText }}
</span>
</div>
</div>
</template>
<style scoped>
.poll-card {
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-md);
padding: 16px 18px;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
}
.poll-card:hover {
border-color: var(--gg-primary-light);
box-shadow: 0 0 20px rgba(5, 150, 105, 0.1);
transform: translateY(-1px);
}
/* 标签行 */
.poll-card__tags {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.poll-card__type-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
line-height: 18px;
}
.poll-card__type-tag--option {
background: rgba(5, 150, 105, 0.1);
color: var(--gg-primary);
}
.poll-card__type-tag--rollcall {
background: rgba(13, 148, 136, 0.1);
color: var(--gg-accent);
}
.poll-card__status-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
line-height: 18px;
}
.poll-card__status-tag--active {
background: rgba(16, 185, 129, 0.1);
color: var(--gg-success);
}
.poll-card__status-tag--settled {
background: var(--gg-bg-elevated);
color: var(--gg-text-muted);
}
.poll-card__anon-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 500;
line-height: 18px;
background: rgba(245, 158, 11, 0.1);
color: var(--gg-warning);
}
.poll-card__voted-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
line-height: 18px;
background: rgba(5, 150, 105, 0.15);
color: var(--gg-primary);
}
/* 标题 */
.poll-card__title {
font-size: 15px;
font-weight: 600;
color: var(--gg-text);
line-height: 1.5;
margin-bottom: 10px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
/* 统计信息 */
.poll-card__stats {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 8px;
}
.poll-card__stat {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--gg-text-secondary);
}
.poll-card__stat-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
}
/* 领先选项 */
.poll-card__top-option {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: rgba(5, 150, 105, 0.06);
border-radius: 6px;
margin-bottom: 8px;
}
.poll-card__top-label {
font-size: 11px;
font-weight: 600;
color: var(--gg-primary);
white-space: nowrap;
}
.poll-card__top-content {
font-size: 13px;
color: var(--gg-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.poll-card__top-count {
font-size: 12px;
color: var(--gg-text-muted);
white-space: nowrap;
flex-shrink: 0;
}
/* 底部 */
.poll-card__footer {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--gg-text-muted);
}
.poll-card__footer-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
}
.poll-card__deadline--urgent {
color: var(--gg-danger);
font-weight: 600;
}
</style>
+460
View File
@@ -0,0 +1,460 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Edit } from '@element-plus/icons-vue'
import { usePollStore } from '@/stores/poll'
import { pb } from '@/api/pocketbase'
import { subscribePollVotes } from '@/api/polls'
import { displayName } from '@/types'
import EditPollDialog from './EditPollDialog.vue'
const props = defineProps<{
pollId: string
}>()
const emit = defineEmits<{
back: []
}>()
const pollStore = usePollStore()
let unsubscribeFn: (() => void) | null = null
const showEdit = ref(false)
// 每选项的投票数
const optionVoteCounts = computed(() => {
const counts: Record<string, number> = {}
for (const vote of pollStore.currentVotes) {
counts[vote.option] = (counts[vote.option] || 0) + 1
}
return counts
})
// 总投票人数(去重)
const totalVotes = computed(() => {
const users = new Set(pollStore.currentVotes.map((v) => v.user))
return users.size
})
// 接龙是否满员
const isRollcallFull = computed(() => {
const poll = pollStore.currentPoll
if (!poll || poll.type !== 'rollcall') return false
if (!poll.maxParticipants) return false
return totalVotes.value >= poll.maxParticipants
})
// 当前用户是否是发起人
const isCreator = computed(() => {
return pollStore.currentPoll?.creator === pb.authStore.model?.id
})
// 当前用户已投的选项ID集合
const votedOptionIds = computed(() => {
const user = pb.authStore.model
if (!user) return new Set<string>()
return new Set(
pollStore.currentVotes
.filter((v) => v.user === user.id)
.map((v) => v.option)
)
})
// 当前用户是否已投票
const hasVoted = computed(() => votedOptionIds.value.size > 0)
// 投票是否已结束
const isSettled = computed(() => pollStore.currentPoll?.status === 'settled')
// 获取某个选项的投票人列表
function getVotersForOption(optionId: string) {
return pollStore.currentVotes.filter((v) => v.option === optionId)
}
// 判断选项是否可点击
function canVote(_optionId: string) {
if (isSettled.value) return false
if (hasVoted.value) return false
if (isRollcallFull.value) return false
return true
}
// 点击选项投票
async function handleVote(optionId: string) {
if (!canVote(optionId)) return
try {
await pollStore.vote(props.pollId, optionId)
} catch (error: any) {
ElMessage.error(error.message || '投票失败')
}
}
// 取消投票
async function handleCancelVote() {
try {
await ElMessageBox.confirm('确定要取消投票吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
await pollStore.unvote(props.pollId)
ElMessage.success('已取消投票')
} catch {
// 用户取消操作
}
}
// 结束投票
async function handleSettle() {
try {
await ElMessageBox.confirm('确定要结束投票吗?结束后将无法再投票。', '结束投票', {
confirmButtonText: '确定结束',
cancelButtonText: '取消',
type: 'warning',
})
await pollStore.settle(props.pollId)
ElMessage.success('投票已结束')
} catch {
// 用户取消操作
}
}
onMounted(async () => {
try {
await pollStore.loadPollDetail(props.pollId)
// 订阅投票记录变更
unsubscribeFn = await subscribePollVotes(props.pollId, () => {
pollStore.loadPollDetail(props.pollId)
})
} catch (error) {
console.error('加载投票详情失败:', error)
}
})
onUnmounted(() => {
if (unsubscribeFn) {
unsubscribeFn()
unsubscribeFn = null
}
pollStore.clearCurrent()
})
</script>
<template>
<div class="poll-detail">
<!-- 顶部操作栏 -->
<div class="detail-header">
<el-button text @click="emit('back')">
<el-icon><ArrowLeft /></el-icon>
返回
</el-button>
<div class="detail-header-actions">
<button
v-if="isCreator && !isSettled"
class="action-btn action-btn--edit"
@click="showEdit = true"
>
<el-icon><Edit /></el-icon> 编辑
</button>
<button
v-if="isCreator && !isSettled"
class="action-btn action-btn--settle"
@click="handleSettle"
>
结束投票
</button>
</div>
</div>
<!-- 加载中 -->
<div v-if="pollStore.loading" class="loading-state">
<el-icon class="is-loading"><ArrowLeft /></el-icon>
<span>加载中...</span>
</div>
<!-- 投票内容 -->
<template v-else-if="pollStore.currentPoll">
<!-- 标题区域 -->
<div class="poll-title-area">
<h2 class="poll-title">{{ pollStore.currentPoll.title }}</h2>
<div class="poll-meta">
<span v-if="pollStore.currentPoll.type === 'rollcall'" class="poll-type-tag rollcall">
接龙报名
</span>
<span v-else class="poll-type-tag option">选项投票</span>
<span v-if="pollStore.currentPoll.anonymous" class="poll-type-tag anonymous">
匿名
</span>
<span class="poll-info">
{{ totalVotes }} 人参与
</span>
<span
v-if="pollStore.currentPoll.type === 'rollcall' && pollStore.currentPoll.maxParticipants"
class="poll-info"
>
{{ totalVotes }} / {{ pollStore.currentPoll.maxParticipants }}
</span>
</div>
</div>
<!-- 选项列表 -->
<div class="options-area">
<div
v-for="option in pollStore.currentOptions"
:key="option.id"
:class="[
'option-card',
{
voted: votedOptionIds.has(option.id),
disabled: !canVote(option.id),
},
]"
@click="handleVote(option.id)"
>
<div class="option-header">
<span class="option-text">{{ option.content }}</span>
<span class="option-count">{{ optionVoteCounts[option.id] || 0 }} </span>
</div>
<!-- 进度条 -->
<div class="option-progress">
<div
class="progress-bar"
:style="{
width: totalVotes > 0
? ((optionVoteCounts[option.id] || 0) / totalVotes * 100) + '%'
: '0%',
}"
/>
</div>
<!-- 投票人列表非匿名时 -->
<div
v-if="!pollStore.currentPoll.anonymous && getVotersForOption(option.id).length > 0"
class="voter-list"
>
<span
v-for="vote in getVotersForOption(option.id)"
:key="vote.id"
class="voter-name"
>
{{ displayName(vote.expand?.user) }}
</span>
</div>
</div>
</div>
<!-- 底部操作 -->
<div v-if="hasVoted && !isSettled" class="detail-footer">
<el-button type="warning" text @click="handleCancelVote">
取消投票
</el-button>
</div>
</template>
<EditPollDialog
v-model="showEdit"
:poll-id="pollId"
:poll-type="pollStore.currentPoll?.type || 'option'"
:poll-anonymous="pollStore.currentPoll?.anonymous || false"
:options="pollStore.currentOptions"
:votes="pollStore.currentVotes"
@saved="pollStore.loadPollDetail(pollId)"
/>
</div>
</template>
<style scoped>
.poll-detail {
display: flex;
flex-direction: column;
gap: 16px;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.detail-header-actions {
display: flex;
gap: 8px;
}
.action-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 6px 14px;
border: none;
border-radius: var(--gg-radius-sm);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.action-btn--edit {
background: var(--gg-gradient-green);
color: #fff;
}
.action-btn--edit:hover {
opacity: 0.85;
}
.action-btn--settle {
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
color: var(--gg-text-secondary);
}
.action-btn--settle:hover {
border-color: var(--gg-danger);
color: var(--gg-danger);
}
.loading-state {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 40px 0;
color: var(--gg-text-secondary, #909399);
}
.poll-title-area {
display: flex;
flex-direction: column;
gap: 8px;
}
.poll-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--gg-text);
}
.poll-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.poll-type-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.poll-type-tag.option {
background: rgba(103, 194, 58, 0.1);
color: var(--gg-primary, #67c23a);
}
.poll-type-tag.rollcall {
background: rgba(64, 158, 255, 0.1);
color: #409eff;
}
.poll-type-tag.anonymous {
background: rgba(144, 147, 153, 0.1);
color: var(--gg-text-secondary, #909399);
}
.poll-info {
font-size: 13px;
color: var(--gg-text-secondary, #909399);
}
.options-area {
display: flex;
flex-direction: column;
gap: 10px;
}
.option-card {
padding: 12px 16px;
border: 1px solid var(--gg-border, #ebeef5);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.option-card:hover:not(.disabled) {
border-color: var(--gg-primary, #67c23a);
background: rgba(103, 194, 58, 0.03);
}
.option-card.voted {
border-color: var(--gg-primary, #67c23a);
background: rgba(103, 194, 58, 0.06);
}
.option-card.disabled {
cursor: default;
opacity: 0.75;
}
.option-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.option-text {
font-size: 14px;
font-weight: 500;
color: var(--gg-text);
}
.option-count {
font-size: 13px;
color: var(--gg-text-secondary, #909399);
}
.option-progress {
height: 4px;
background: var(--gg-bg-page, #f5f7fa);
border-radius: 2px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: var(--gg-primary, #67c23a);
border-radius: 2px;
transition: width 0.3s ease;
}
.voted .progress-bar {
background: var(--gg-primary, #67c23a);
}
.voter-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.voter-name {
font-size: 12px;
padding: 2px 6px;
background: var(--gg-bg-page, #f5f7fa);
border-radius: 4px;
color: var(--gg-text-secondary, #909399);
}
.detail-footer {
display: flex;
justify-content: center;
padding-top: 8px;
}
</style>
+318
View File
@@ -0,0 +1,318 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import { usePollStore } from '@/stores/poll'
import { useGroupStore } from '@/stores/group'
import { subscribePolls, getPollOptions, getPollVotes, getUserVote } from '@/api/polls'
import PollCard from './PollCard.vue'
import CreatePollDialog from './CreatePollDialog.vue'
import type { PollOption, PollVote } from '@/types'
const emit = defineEmits<{
viewPoll: [pollId: string]
}>()
const pollStore = usePollStore()
const groupStore = useGroupStore()
const showCreate = ref(false)
let unsubscribeFn: (() => void) | null = null
// 缓存每个 poll 的 options / votes / userVote
interface PollExtra {
options: PollOption[]
votes: PollVote[]
userVote: string | null
}
const pollExtras = ref<Record<string, PollExtra>>({})
// 加载所有投票及其详情
async function loadAll() {
const groupId = groupStore.currentGroupId
if (!groupId) return
await pollStore.loadPolls(groupId)
// 并行加载每条 poll 的 options / votes / userVote
const allPolls = [...pollStore.activePolls, ...pollStore.settledPolls]
const results = await Promise.allSettled(
allPolls.map(async (poll) => {
const [options, votes, userVote] = await Promise.all([
getPollOptions(poll.id),
getPollVotes(poll.id),
getUserVote(poll.id),
])
return {
pollId: poll.id,
options,
votes,
userVote: userVote?.option ?? null,
}
})
)
const extras: Record<string, PollExtra> = {}
for (const r of results) {
if (r.status === 'fulfilled') {
extras[r.value.pollId] = {
options: r.value.options,
votes: r.value.votes,
userVote: r.value.userVote,
}
}
}
pollExtras.value = extras
}
// 实时订阅投票变更
async function startSubscription() {
const groupId = groupStore.currentGroupId
if (!groupId) return
unsubscribeFn = await subscribePolls(groupId, () => {
loadAll()
})
}
function stopSubscription() {
if (unsubscribeFn) {
unsubscribeFn()
unsubscribeFn = null
}
}
// 获取某个 poll 的缓存 extras
function getExtra(pollId: string): PollExtra {
return pollExtras.value[pollId] || { options: [], votes: [], userVote: null }
}
// 分栏数据
const activePolls = computed(() => pollStore.activePolls)
const settledPolls = computed(() => pollStore.settledPolls)
onMounted(() => {
if (groupStore.currentGroupId) {
loadAll()
startSubscription()
}
})
watch(() => groupStore.currentGroupId, (newId, oldId) => {
if (newId && newId !== oldId) {
loadAll()
if (!unsubscribeFn) startSubscription()
}
})
onUnmounted(() => {
stopSubscription()
})
</script>
<template>
<div class="poll-list">
<!-- 顶部操作栏 -->
<div class="poll-list__header">
<h3 class="poll-list__title">投票</h3>
<button class="poll-list__create-btn" @click="showCreate = true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="poll-list__create-icon">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
发起投票
</button>
</div>
<CreatePollDialog
v-model="showCreate"
@created="loadAll(); showCreate = false"
/>
<!-- 进行中 -->
<section v-if="activePolls.length > 0" class="poll-list__section">
<div class="poll-list__section-header">
<span class="poll-list__section-dot poll-list__section-dot--active"></span>
<span class="poll-list__section-label">进行中</span>
<span class="poll-list__section-count">{{ activePolls.length }}</span>
</div>
<div class="poll-list__grid">
<PollCard
v-for="poll in activePolls"
:key="poll.id"
:poll="poll"
:options="getExtra(poll.id).options"
:votes="getExtra(poll.id).votes"
:current-user-vote="getExtra(poll.id).userVote"
@click="emit('viewPoll', poll.id)"
/>
</div>
</section>
<!-- 已结束 -->
<section v-if="settledPolls.length > 0" class="poll-list__section">
<div class="poll-list__section-header">
<span class="poll-list__section-dot poll-list__section-dot--settled"></span>
<span class="poll-list__section-label">已结束</span>
<span class="poll-list__section-count">{{ settledPolls.length }}</span>
</div>
<div class="poll-list__grid">
<PollCard
v-for="poll in settledPolls"
:key="poll.id"
:poll="poll"
:options="getExtra(poll.id).options"
:votes="getExtra(poll.id).votes"
:current-user-vote="getExtra(poll.id).userVote"
@click="emit('viewPoll', poll.id)"
/>
</div>
</section>
<!-- 空状态 -->
<div
v-if="activePolls.length === 0 && settledPolls.length === 0 && !pollStore.loading"
class="poll-list__empty"
>
<svg class="poll-list__empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="9" y1="9" x2="15" y2="9" />
<line x1="9" y1="13" x2="13" y2="13" />
</svg>
<p class="poll-list__empty-text">暂无投票</p>
<p class="poll-list__empty-hint">群组内还没有发起过投票</p>
</div>
</div>
</template>
<style scoped>
.poll-list {
display: flex;
flex-direction: column;
gap: 20px;
}
/* 顶部操作栏 */
.poll-list__header {
display: flex;
align-items: center;
justify-content: space-between;
}
.poll-list__title {
font-size: 18px;
font-weight: 700;
color: var(--gg-text);
margin: 0;
}
.poll-list__create-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 16px;
border: none;
border-radius: var(--gg-radius-sm);
background: var(--gg-gradient-green);
color: #ffffff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.poll-list__create-btn:hover {
opacity: 0.85;
}
.poll-list__create-icon {
width: 16px;
height: 16px;
}
/* 分栏 */
.poll-list__section {
display: flex;
flex-direction: column;
gap: 12px;
}
.poll-list__section-header {
display: flex;
align-items: center;
gap: 6px;
}
.poll-list__section-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
}
.poll-list__section-dot--active {
background: var(--gg-success);
box-shadow: 0 0 6px var(--gg-success);
}
.poll-list__section-dot--settled {
background: var(--gg-text-muted);
}
.poll-list__section-label {
font-size: 14px;
font-weight: 600;
color: var(--gg-text-secondary);
}
.poll-list__section-count {
font-size: 12px;
color: var(--gg-text-muted);
background: var(--gg-bg-elevated);
padding: 1px 7px;
border-radius: 10px;
}
/* 网格布局 */
.poll-list__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
/* 空状态 */
.poll-list__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 20px;
text-align: center;
}
.poll-list__empty-icon {
width: 48px;
height: 48px;
color: var(--gg-text-muted);
margin-bottom: 12px;
opacity: 0.5;
}
.poll-list__empty-text {
font-size: 15px;
font-weight: 600;
color: var(--gg-text-secondary);
margin: 0 0 4px;
}
.poll-list__empty-hint {
font-size: 13px;
color: var(--gg-text-muted);
margin: 0;
}
/* 响应式 */
@media (max-width: 640px) {
.poll-list__grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,312 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { useGroupStore } from '@/stores/group'
import { pb } from '@/api/pocketbase'
import { displayName } from '@/types'
import { TrendCharts, Trophy } from '@element-plus/icons-vue'
const groupStore = useGroupStore()
const weekSessionCount = ref(0)
const pollParticipationRate = ref('0%')
const topMembers = ref<{ name: string; points: number }[]>([])
const loading = ref(true)
async function loadStats() {
const groupId = groupStore.currentGroupId
if (!groupId) return
loading.value = true
try {
// 查询本周组队次数
const sevenDaysAgo = new Date()
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7)
const sessionResult = await pb.collection('team_sessions').getList(1, 1, {
filter: `sourceGroup="${groupId}" && created>="${sevenDaysAgo.toISOString()}"`,
$autoCancel: false,
})
weekSessionCount.value = sessionResult.totalItems
// 查询投票参与率(已投票人数 / 群成员数)
const memberCount = groupStore.currentMembers.length || 1
const memberIds = groupStore.currentMembers.map(m => m.id)
const pollResult = await pb.collection('polls').getFullList({
filter: `group="${groupId}" && status="active"`,
$autoCancel: false,
})
let votedUserCount = 0
if (pollResult.length > 0 && memberIds.length > 0) {
const pollIds = (pollResult as any[]).map(p => p.id)
const pollFilter = pollIds.map(id => `poll="${id}"`).join(' || ')
const votes = await pb.collection('poll_votes').getFullList({
filter: `(${pollFilter})`,
$autoCancel: false,
})
const votedUsers = new Set((votes as any[]).map(v => v.user))
votedUserCount = votedUsers.size
}
const rate = memberCount > 0 ? Math.round((votedUserCount * 100) / memberCount) : 0
pollParticipationRate.value = `${rate}%`
// 群成员积分排行 top 10
const sorted = [...groupStore.currentMembers]
.sort((a, b) => (b.points || 0) - (a.points || 0))
.slice(0, 10)
topMembers.value = sorted.map(m => ({
name: displayName(m),
points: m.points || 0,
}))
} catch (error) {
console.error('加载统计数据失败:', error)
} finally {
loading.value = false
}
}
onMounted(() => {
if (groupStore.currentGroupId) loadStats()
})
watch(() => groupStore.currentGroupId, (newId, oldId) => {
if (newId && newId !== oldId) loadStats()
})
</script>
<template>
<div class="stats-panel">
<!-- 加载状态 -->
<div v-if="loading" class="stats-loading">
<el-icon class="is-loading" :size="20"><TrendCharts /></el-icon>
<span>加载统计数据中...</span>
</div>
<template v-else>
<!-- 统计卡片 -->
<div class="stats-cards">
<!-- 本周组队 -->
<div class="stat-card">
<div class="stat-card-icon">
<el-icon :size="24"><TrendCharts /></el-icon>
</div>
<div class="stat-card-body">
<div class="stat-card-value">{{ weekSessionCount }}</div>
<div class="stat-card-label">本周组队</div>
</div>
</div>
<!-- 投票参与率 -->
<div class="stat-card">
<div class="stat-card-icon icon-poll">
<el-icon :size="24"><TrendCharts /></el-icon>
</div>
<div class="stat-card-body">
<div class="stat-card-value">{{ pollParticipationRate }}</div>
<div class="stat-card-label">投票参与率</div>
</div>
</div>
<!-- 群成员 -->
<div class="stat-card">
<div class="stat-card-icon icon-members">
<el-icon :size="24"><TrendCharts /></el-icon>
</div>
<div class="stat-card-body">
<div class="stat-card-value">{{ groupStore.currentMembers.length }}</div>
<div class="stat-card-label">群成员</div>
</div>
</div>
</div>
<!-- 活跃排行 -->
<div class="ranking-section">
<div class="ranking-header">
<el-icon><Trophy /></el-icon>
<span>积分排行</span>
</div>
<div v-if="topMembers.length === 0" class="ranking-empty">暂无排行数据</div>
<div v-else class="ranking-list">
<div
v-for="(member, index) in topMembers"
:key="index"
class="ranking-item"
>
<span class="ranking-index" :class="{ 'top-three': index < 3 }">
{{ index + 1 }}
</span>
<span class="ranking-name">{{ member.name }}</span>
<span class="ranking-points">{{ member.points }} </span>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.stats-panel {
display: flex;
flex-direction: column;
gap: 24px;
}
.stats-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 48px 0;
color: var(--gg-text-secondary);
font-size: 14px;
}
/* 统计卡片 */
.stats-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.stat-card {
display: flex;
align-items: center;
gap: 14px;
padding: 20px;
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-md);
transition: box-shadow 0.2s;
}
.stat-card:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.stat-card-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 12px;
background: rgba(5, 150, 105, 0.12);
color: var(--gg-primary);
flex-shrink: 0;
}
.stat-card-icon.icon-poll {
background: rgba(64, 158, 255, 0.12);
color: #409eff;
}
.stat-card-icon.icon-members {
background: rgba(230, 162, 60, 0.12);
color: #e6a23c;
}
.stat-card-body {
display: flex;
flex-direction: column;
gap: 4px;
}
.stat-card-value {
font-size: 24px;
font-weight: 700;
color: var(--gg-text);
line-height: 1.2;
}
.stat-card-label {
font-size: 13px;
color: var(--gg-text-muted);
}
/* 排行 */
.ranking-section {
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-md);
padding: 20px;
}
.ranking-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
color: var(--gg-text);
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--gg-border);
}
.ranking-empty {
text-align: center;
padding: 24px 0;
color: var(--gg-text-muted);
font-size: 14px;
}
.ranking-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.ranking-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: var(--gg-radius-sm);
background: var(--gg-bg);
transition: background 0.2s;
}
.ranking-item:hover {
background: var(--gg-bg-elevated);
}
.ranking-index {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
font-size: 13px;
font-weight: 600;
color: var(--gg-text-muted);
background: var(--gg-bg-elevated);
flex-shrink: 0;
}
.ranking-index.top-three {
background: rgba(5, 150, 105, 0.15);
color: var(--gg-primary);
}
.ranking-name {
flex: 1;
font-size: 14px;
font-weight: 500;
color: var(--gg-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ranking-points {
font-size: 13px;
font-weight: 600;
color: var(--gg-primary-light);
}
/* 响应式 */
@media (max-width: 640px) {
.stats-cards {
grid-template-columns: 1fr;
}
}
</style>
+82
View File
@@ -0,0 +1,82 @@
// src/stores/memory.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Memory } from '@/types'
import {
listMemories,
uploadMemory,
deleteMemory,
getGroupStorageUsed,
getGroupStorageLimit
} from '@/api/memories'
export const useMemoryStore = defineStore('memory', () => {
const memories = ref<Memory[]>([])
const storageUsed = ref(0)
const storageLimit = ref(getGroupStorageLimit())
const loading = ref(false)
// 计算属性:已用/总容量百分比
const storagePercent = computed(() => {
if (storageLimit.value === 0) return 0
return Math.round((storageUsed.value / storageLimit.value) * 10000) / 100
})
// 加载记忆列表
async function loadMemories(groupId: string, fileType?: string) {
try {
loading.value = true
const result = await listMemories(groupId, { fileType })
memories.value = result.items
// 同时刷新存储用量
storageUsed.value = await getGroupStorageUsed(groupId)
} catch (error) {
console.error('加载记忆列表失败:', error)
} finally {
loading.value = false
}
}
// 上传记忆文件
async function upload(groupId: string, file: File, meta?: { title?: string; description?: string; tags?: string[] }) {
try {
loading.value = true
await uploadMemory(groupId, file, meta)
// 上传后刷新列表和容量
await loadMemories(groupId)
} catch (error) {
console.error('上传记忆失败:', error)
throw error
} finally {
loading.value = false
}
}
// 删除记忆
async function remove(memoryId: string, groupId: string) {
try {
await deleteMemory(memoryId)
// 从本地列表中移除
const index = memories.value.findIndex(m => m.id === memoryId)
if (index !== -1) {
memories.value.splice(index, 1)
}
// 刷新容量
storageUsed.value = await getGroupStorageUsed(groupId)
} catch (error) {
console.error('删除记忆失败:', error)
throw error
}
}
return {
memories,
storageUsed,
storageLimit,
loading,
storagePercent,
loadMemories,
upload,
remove
}
})
+70 -2
View File
@@ -1,8 +1,15 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Invitation, JoinRequest } from '@/types'
import type { Invitation, JoinRequest, AppNotification } from '@/types'
import { getPendingInvitations, subscribeInvitations } from '@/api/invitations'
import { getMyGroupsJoinRequests } from '@/api/groups'
import {
listNotifications,
markAsRead,
markAllAsRead as apiMarkAllRead,
deleteNotification,
subscribeNotifications
} from '@/api/notifications'
import { pb } from '@/api/pocketbase'
export const useNotificationStore = defineStore('notification', () => {
@@ -12,9 +19,17 @@ export const useNotificationStore = defineStore('notification', () => {
const showPanel = ref(false)
let unsubFn: (() => Promise<void> | void) | null = null
let unsubJoinFn: (() => Promise<void> | void) | null = null
let unsubAppFn: (() => Promise<void> | void) | null = null
// 站内应用通知
const appNotifications = ref<AppNotification[]>([])
const appUnreadCount = computed(() =>
appNotifications.value.filter(n => !n.read).length
)
const unreadCount = computed(() =>
pendingInvitations.value.length + pendingJoinRequests.value.length
pendingInvitations.value.length + pendingJoinRequests.value.length + appUnreadCount.value
)
async function loadPendingInvitations() {
@@ -29,10 +44,21 @@ export const useNotificationStore = defineStore('notification', () => {
}
}
async function loadAppNotifications() {
try {
const result = await listNotifications({ page: 1, limit: 50 })
appNotifications.value = result.items
} catch (error) {
console.error('加载应用通知失败:', error)
}
}
async function startListening() {
const user = pb.authStore.model
if (!user) return
stopListening()
unsubFn = await subscribeInvitations(() => {
loadPendingInvitations()
})
@@ -40,6 +66,10 @@ export const useNotificationStore = defineStore('notification', () => {
unsubJoinFn = await pb.collection('join_requests').subscribe('*', () => {
loadPendingInvitations()
})
unsubAppFn = await subscribeNotifications(() => {
loadAppNotifications()
})
}
function stopListening() {
@@ -51,6 +81,10 @@ export const useNotificationStore = defineStore('notification', () => {
unsubJoinFn()
unsubJoinFn = null
}
if (unsubAppFn) {
unsubAppFn()
unsubAppFn = null
}
}
function removeInvitation(invitationId: string) {
@@ -61,6 +95,34 @@ export const useNotificationStore = defineStore('notification', () => {
pendingJoinRequests.value = pendingJoinRequests.value.filter(r => r.id !== requestId)
}
async function markRead(id: string) {
try {
await markAsRead(id)
const notification = appNotifications.value.find(n => n.id === id)
if (notification) notification.read = true
} catch (error) {
console.error('标记已读失败:', error)
}
}
async function markAllRead() {
try {
await apiMarkAllRead()
appNotifications.value.forEach(n => { n.read = true })
} catch (error) {
console.error('全部标记已读失败:', error)
}
}
async function removeNotification(id: string) {
try {
await deleteNotification(id)
appNotifications.value = appNotifications.value.filter(n => n.id !== id)
} catch (error) {
console.error('删除通知失败:', error)
}
}
function togglePanel() {
showPanel.value = !showPanel.value
}
@@ -71,11 +133,17 @@ export const useNotificationStore = defineStore('notification', () => {
loading,
showPanel,
unreadCount,
appNotifications,
appUnreadCount,
loadPendingInvitations,
loadAppNotifications,
startListening,
stopListening,
removeInvitation,
removeJoinRequest,
markRead,
markAllRead,
removeNotification,
togglePanel
}
})
+161
View File
@@ -0,0 +1,161 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Poll, PollOption, PollVote } from '@/types'
import {
listPolls,
getPoll,
getPollOptions,
getPollVotes,
getUserVote,
votePoll,
cancelVote,
settlePoll,
updatePoll as apiUpdatePoll,
addPollOption as apiAddPollOption,
updatePollOption as apiUpdatePollOption,
deletePollOption as apiDeletePollOption,
} from '@/api/polls'
export const usePollStore = defineStore('poll', () => {
// 状态
const activePolls = ref<Poll[]>([])
const settledPolls = ref<Poll[]>([])
const currentPoll = ref<Poll | null>(null)
const currentOptions = ref<PollOption[]>([])
const currentVotes = ref<PollVote[]>([])
const currentUserVote = ref<PollVote | null>(null)
const loading = ref(false)
// 加载群组投票列表
async function loadPolls(groupId: string) {
try {
loading.value = true
const [active, settled] = await Promise.all([
listPolls(groupId, 'active'),
listPolls(groupId, 'settled'),
])
activePolls.value = active
settledPolls.value = settled
} catch (error) {
console.error('加载投票列表失败:', error)
} finally {
loading.value = false
}
}
// 加载投票详情(含选项、投票记录、当前用户投票)
async function loadPollDetail(pollId: string) {
try {
loading.value = true
const [poll, options, votes, userVote] = await Promise.all([
getPoll(pollId),
getPollOptions(pollId),
getPollVotes(pollId),
getUserVote(pollId),
])
currentPoll.value = poll
currentOptions.value = options
currentVotes.value = votes
currentUserVote.value = userVote
} catch (error) {
console.error('加载投票详情失败:', error)
throw error
} finally {
loading.value = false
}
}
// 投票
async function vote(pollId: string, optionId: string) {
try {
await votePoll(pollId, optionId)
// 重新加载详情以获取最新数据
await loadPollDetail(pollId)
} catch (error) {
console.error('投票失败:', error)
throw error
}
}
// 取消投票
async function unvote(pollId: string) {
try {
await cancelVote(pollId)
// 重新加载详情以获取最新数据
await loadPollDetail(pollId)
} catch (error) {
console.error('取消投票失败:', error)
throw error
}
}
// 关闭投票
async function settle(pollId: string) {
try {
await settlePoll(pollId)
await loadPollDetail(pollId)
} catch (error) {
console.error('关闭投票失败:', error)
throw error
}
}
// 编辑投票
async function edit(pollId: string, data: {
title: string
deadline: string
maxParticipants: number | null
options: { id?: string; content: string; hasVotes?: boolean }[]
deletedOptionIds: string[]
}) {
try {
await apiUpdatePoll(pollId, {
title: data.title,
deadline: data.deadline,
maxParticipants: data.maxParticipants,
})
for (const opt of data.options) {
if (opt.id && opt.content) {
await apiUpdatePollOption(opt.id, opt.content)
} else if (!opt.id && opt.content) {
await apiAddPollOption(pollId, opt.content)
}
}
for (const id of data.deletedOptionIds) {
await apiDeletePollOption(id)
}
await loadPollDetail(pollId)
} catch (error) {
console.error('编辑投票失败:', error)
throw error
}
}
// 清除当前投票详情
function clearCurrent() {
currentPoll.value = null
currentOptions.value = []
currentVotes.value = []
currentUserVote.value = null
}
return {
activePolls,
settledPolls,
currentPoll,
currentOptions,
currentVotes,
currentUserVote,
loading,
loadPolls,
loadPollDetail,
vote,
unvote,
settle,
edit,
clearCurrent,
}
})
+83 -7
View File
@@ -172,18 +172,89 @@ export interface JoinRequest {
}
}
// 获取用户显示名称(优先 name,回退 username
export function displayName(user?: { name?: string; username: string } | null): string {
return user?.name || user?.username || '未知'
// 投票类型
export type PollType = 'option' | 'rollcall'
export type PollStatus = 'active' | 'settled'
// 投票
export interface Poll {
id: string
group: string
creator: string
title: string
type: PollType
anonymous: boolean
deadline?: string
maxParticipants?: number
status: PollStatus
settledAt?: string
created: string
updated: string
expand?: {
creator?: User
group?: Group
}
}
// 多媒体记忆类型 (二期规划)
export type MemoryFileType = 'image' | 'video' | 'audio' | 'other'
// 投票选项
export interface PollOption {
id: string
poll: string
content: string
order: number
}
// 多媒体记忆 (二期规划)
// 投票记录
export interface PollVote {
id: string
poll: string
option: string
user: string
created: string
expand?: {
user?: User
option?: PollOption
}
}
// 通知类型
export type NotificationType = 'poll_new' | 'poll_deadline' | 'poll_result' | 'team_invite' | 'team_starting' | 'join_request' | 'member_joined'
// 站内通知
export interface AppNotification {
id: string
user: string
type: NotificationType
title: string
content?: string
read: boolean
relatedId?: string
relatedType?: 'poll' | 'team' | 'group'
created: string
updated: string
}
// 积分行为类型
export type PointAction = 'vote' | 'team' | 'memory'
// 积分流水
export interface PointLog {
id: string
user: string
action: PointAction
points: number
relatedId: string
created: string
updated: string
}
// 多媒体记忆文件类型
export type MemoryFileType = 'image' | 'video' | 'audio' | 'document' | 'other'
// 多媒体记忆
export interface Memory {
id: string
groupId: string
group: string
uploader: string
title: string
description?: string
@@ -197,3 +268,8 @@ export interface Memory {
group?: Group
}
}
// 获取用户显示名称(优先 name,回退 username
export function displayName(user?: { name?: string; username: string } | null): string {
return user?.name || user?.username || '未知'
}
+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>