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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
congsh
2026-04-18 18:19:46 +08:00
parent 71742da600
commit c5d3ac01ca
33 changed files with 5180 additions and 59 deletions
+99
View File
@@ -0,0 +1,99 @@
import { pb } from './pocketbase'
import type { Memory } from '@/types'
export const GROUP_STORAGE_LIMIT = 10 * 1024 * 1024 * 1024
export function detectFileType(file: File): 'image' | 'video' | 'audio' | 'document' | 'other' {
const mime = file.type || ''
if (mime.startsWith('image/')) return 'image'
if (mime.startsWith('video/')) return 'video'
if (mime.startsWith('audio/')) return 'audio'
if (
mime.startsWith('application/pdf') ||
mime.startsWith('application/msword') ||
mime.startsWith('application/vnd.') ||
mime.startsWith('text/')
) return 'document'
return 'other'
}
export async function uploadMemory(
groupId: string,
file: File,
meta?: { title?: string; description?: string; tags?: string[] }
) {
const used = await getGroupStorageUsed(groupId)
if (used + file.size > GROUP_STORAGE_LIMIT) {
throw new Error('群组存储空间不足,无法上传')
}
const user = pb.authStore.model
const formData = new FormData()
formData.append('group', groupId)
formData.append('uploader', user?.id || '')
formData.append('file', file)
formData.append('fileType', detectFileType(file))
formData.append('size', file.size.toString())
formData.append('title', meta?.title || file.name)
if (meta?.description) formData.append('description', meta.description)
return pb.collection('memories').create(formData)
}
export async function listMemories(
groupId: string,
options?: {
page?: number
limit?: number
fileType?: string
}
): Promise<{ items: Memory[]; total: number }> {
const { page = 1, limit = 30, fileType } = options || {}
let filter = `group="${groupId}"`
if (fileType) filter += ` && fileType="${fileType}"`
const result = await pb.collection('memories').getList(page, limit, {
filter,
sort: '-created',
expand: 'uploader'
})
return { items: result.items as unknown as Memory[], total: result.totalItems }
}
export async function deleteMemory(memoryId: string) {
return pb.collection('memories').delete(memoryId)
}
export async function getGroupStorageUsed(groupId: string): Promise<number> {
let total = 0
let page = 1
const batchSize = 500
let hasMore = true
while (hasMore) {
const result = await pb.collection('memories').getList(page, batchSize, {
filter: `group="${groupId}"`,
fields: 'size'
})
total += result.items.reduce((sum: number, r: any) => sum + (r.size || 0), 0)
hasMore = result.items.length === batchSize && page * batchSize < result.totalItems
page++
}
return total
}
export function getGroupStorageLimit(): number {
return GROUP_STORAGE_LIMIT
}
export function subscribeMemories(
groupId: string,
callback: (data: any) => void
) {
return pb.collection('memories').subscribe('*', (data) => {
if (data.record?.group === groupId) {
callback(data)
}
})
}
+89
View File
@@ -0,0 +1,89 @@
import { pb } from './pocketbase'
import type { AppNotification } from '@/types'
export async function listNotifications(options?: {
page?: number
limit?: number
unreadOnly?: boolean
}): Promise<{ items: AppNotification[], total: number }> {
const { page = 1, limit = 50, unreadOnly = false } = options || {}
const user = pb.authStore.model
if (!user) return { items: [], total: 0 }
let filter = `user="${user.id}"`
if (unreadOnly) filter += ' && read=false'
const result = await pb.collection('notifications').getList(page, limit, {
filter,
sort: '-created'
})
return { items: result.items as unknown as AppNotification[], total: result.totalItems }
}
export async function markAsRead(notificationId: string): Promise<AppNotification> {
return pb.collection('notifications').update(notificationId, { read: true }) as unknown as Promise<AppNotification>
}
export async function markAllAsRead(): Promise<void> {
const user = pb.authStore.model
if (!user) return
let page = 1
const batchSize = 50
let hasMore = true
while (hasMore) {
const result = await pb.collection('notifications').getList(page, batchSize, {
filter: `user="${user.id}" && read=false`
})
if (result.items.length === 0) break
await Promise.all(
result.items.map(item => pb.collection('notifications').update(item.id, { read: true }))
)
hasMore = result.items.length === batchSize && page * batchSize < result.totalItems
page++
}
}
export async function deleteNotification(notificationId: string): Promise<void> {
await pb.collection('notifications').delete(notificationId)
}
export async function batchDelete(notificationIds: string[]): Promise<void> {
const batchSize = 20
for (let i = 0; i < notificationIds.length; i += batchSize) {
const batch = notificationIds.slice(i, i + batchSize)
await Promise.all(batch.map(id => pb.collection('notifications').delete(id)))
}
}
export async function createNotification(data: {
user: string
type: string
title: string
content?: string
relatedId?: string
relatedType?: string
}): Promise<AppNotification> {
return pb.collection('notifications').create(data) as unknown as Promise<AppNotification>
}
export async function subscribeNotifications(
callback: (data: { action: string, record: AppNotification }) => void
): Promise<() => void> {
const user = pb.authStore.model
if (!user) return () => {}
await pb.collection('notifications').subscribe('*', (e) => {
if (e.record?.user === user.id) {
callback({ action: e.action, record: e.record as unknown as AppNotification })
}
})
return () => {
pb.collection('notifications').unsubscribe('*')
}
}
+92
View File
@@ -0,0 +1,92 @@
import { pb } from './pocketbase'
import type { PointLog, PointAction } from '@/types'
const POINT_MAP: Record<PointAction, number> = {
vote: 1,
team: 2,
memory: 1
}
export async function awardPoints(action: PointAction, relatedId: string): Promise<void> {
const user = pb.authStore.model
if (!user) return
const existing = await pb.collection('point_logs').getList(1, 1, {
filter: `user="${user.id}" && action="${action}" && relatedId="${relatedId}"`,
$autoCancel: false
})
if (existing.items.length > 0) return
const points = POINT_MAP[action]
try {
await pb.collection('point_logs').create({
user: user.id,
action,
points,
relatedId
})
} catch (error: any) {
if (error?.response?.data?.user || error?.message?.includes('unique')) {
return
}
throw error
}
const currentUser = await pb.collection('users').getOne(user.id)
await pb.collection('users').update(user.id, {
points: ((currentUser as any).points || 0) + points
})
}
export async function deductPoints(action: PointAction, relatedId: string): Promise<void> {
const user = pb.authStore.model
if (!user) return
const existing = await pb.collection('point_logs').getList(1, 1, {
filter: `user="${user.id}" && action="${action}" && relatedId="${relatedId}"`,
$autoCancel: false
})
if (existing.items.length === 0) return
const log = existing.items[0]
try {
await pb.collection('point_logs').delete(log.id)
} catch (error: any) {
if (error?.status !== 404) {
throw error
}
}
const pointsToDeduct = POINT_MAP[action]
const currentUser = await pb.collection('users').getOne(user.id)
const currentPoints = (currentUser as any).points || 0
const newPoints = Math.max(0, currentPoints - pointsToDeduct)
await pb.collection('users').update(user.id, {
points: newPoints
})
}
export async function getUserPointLogs(userId: string): Promise<PointLog[]> {
const result = await pb.collection('point_logs').getList(1, 100, {
filter: `user="${userId}"`,
sort: '-created',
$autoCancel: false
})
return result.items as unknown as PointLog[]
}
export async function getGroupMemberRanking(groupId: string, limit = 20) {
const group = await pb.collection('groups').getOne(groupId, {
expand: 'members',
$autoCancel: false
}) as any
const members: any[] = group.expand?.members || []
return members
.map((m: any) => ({ userId: m.id, points: m.points || 0, name: m.name || m.username }))
.sort((a: any, b: any) => b.points - a.points)
.slice(0, limit)
}
+220
View File
@@ -0,0 +1,220 @@
import { pb } from './pocketbase'
import { awardPoints, deductPoints } from './points'
import type { Poll, PollOption, PollVote } from '@/types'
export async function createPoll(data: {
group: string
title: string
type?: 'option' | 'rollcall'
anonymous?: boolean
deadline?: string
maxParticipants?: number
options: string[]
}): Promise<Poll> {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
const poll = await pb.collection('polls').create({
group: data.group,
title: data.title,
type: data.type || 'option',
anonymous: data.anonymous || false,
deadline: data.deadline || '',
maxParticipants: data.type === 'rollcall' ? data.maxParticipants : null,
status: 'active',
creator: user.id,
})
for (let i = 0; i < data.options.length; i++) {
await pb.collection('poll_options').create({
poll: poll.id,
content: data.options[i],
order: i + 1,
})
}
return poll as unknown as Poll
}
export async function listPolls(
groupId: string,
status?: string
): Promise<Poll[]> {
let filter = `group="${groupId}"`
if (status) filter += ` && status="${status}"`
const result = await pb.collection('polls').getFullList({
filter,
sort: '-created',
expand: 'creator',
$autoCancel: false,
})
return result as unknown as Poll[]
}
export async function getPoll(pollId: string): Promise<Poll> {
const result = await pb.collection('polls').getOne(pollId, {
expand: 'creator',
})
return result as unknown as Poll
}
export async function getPollOptions(pollId: string): Promise<PollOption[]> {
const result = await pb.collection('poll_options').getFullList({
filter: `poll="${pollId}"`,
sort: 'order',
$autoCancel: false,
})
return result as unknown as PollOption[]
}
export async function getPollVotes(pollId: string): Promise<PollVote[]> {
const result = await pb.collection('poll_votes').getFullList({
filter: `poll="${pollId}"`,
expand: 'user,option',
$autoCancel: false,
})
return result as unknown as PollVote[]
}
export async function votePoll(
pollId: string,
optionId: string
): Promise<PollVote> {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
const poll = await pb.collection('polls').getOne(pollId)
if (poll.status !== 'active') {
throw new Error('投票已结束')
}
const existing = await pb.collection('poll_votes').getList(1, 1, {
filter: `poll="${pollId}" && user="${user.id}"`,
})
if (existing.items.length > 0) {
throw new Error('你已经投过票了')
}
try {
const vote = await pb.collection('poll_votes').create({
poll: pollId,
option: optionId,
user: user.id,
})
await awardPoints('vote', pollId)
return vote as unknown as PollVote
} catch (error: any) {
if (error?.response?.data?.poll || error?.message?.includes('unique')) {
throw new Error('你已经投过票了')
}
throw error
}
}
export async function cancelVote(pollId: string): Promise<void> {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
const poll = await pb.collection('polls').getOne(pollId)
if (poll.status !== 'active') {
throw new Error('投票已结束,无法取消')
}
const existing = await pb.collection('poll_votes').getFullList({
filter: `poll="${pollId}" && user="${user.id}"`,
})
for (const vote of existing) {
await pb.collection('poll_votes').delete(vote.id)
}
await deductPoints('vote', pollId)
}
export async function settlePoll(pollId: string): Promise<Poll> {
const result = await pb.collection('polls').update(pollId, {
status: 'settled',
settledAt: new Date().toISOString(),
})
return result as unknown as Poll
}
export async function updatePoll(pollId: string, data: {
title?: string
deadline?: string
maxParticipants?: number | null
}): Promise<Poll> {
const result = await pb.collection('polls').update(pollId, data)
return result as unknown as Poll
}
export async function addPollOption(pollId: string, content: string): Promise<PollOption> {
const existing = await pb.collection('poll_options').getFullList({
filter: `poll="${pollId}"`,
sort: '-order',
$autoCancel: false,
})
const maxOrder = existing.reduce((max: number, o: any) => Math.max(max, o.order || 0), 0)
const result = await pb.collection('poll_options').create({
poll: pollId,
content,
order: maxOrder + 1,
})
return result as unknown as PollOption
}
export async function updatePollOption(optionId: string, content: string): Promise<PollOption> {
const result = await pb.collection('poll_options').update(optionId, { content })
return result as unknown as PollOption
}
export async function deletePollOption(optionId: string): Promise<void> {
await pb.collection('poll_options').delete(optionId)
}
export async function getUserVote(pollId: string): Promise<PollVote | null> {
const user = pb.authStore.model
if (!user) return null
const result = await pb.collection('poll_votes').getList(1, 1, {
filter: `poll="${pollId}" && user="${user.id}"`,
expand: 'option',
$autoCancel: false,
})
return result.items.length > 0
? (result.items[0] as unknown as PollVote)
: null
}
export async function subscribePolls(
groupId: string,
callback: (data: any) => void
): Promise<() => void> {
await pb.collection('polls').subscribe('*', (data) => {
if (data.record?.group === groupId) {
callback(data)
}
})
return () => {
pb.collection('polls').unsubscribe('*')
}
}
export async function subscribePollVotes(
pollId: string,
callback: (data: any) => void
): Promise<() => void> {
await pb.collection('poll_votes').subscribe('*', (data) => {
if (data.record?.poll === pollId) {
callback(data)
}
})
return () => {
pb.collection('poll_votes').unsubscribe('*')
}
}