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:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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('*')
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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 || '未知'
|
||||
}
|
||||
|
||||
@@ -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