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('*')
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user