diff --git a/.gitignore b/.gitignore index 45720f0..b063280 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,11 @@ backend/pb_migrations.bak/ # Temporary files *.tmp -.cache/.playwright-mcp/ +.cache/ + +# Test screenshots and Playwright data +*.png +.playwright-mcp/ + +# PocketBase UAT data +backend/pb_data_uat/ diff --git a/backend/pb_migrations/1776520001_created_game_blacklist.js b/backend/pb_migrations/1776520001_created_game_blacklist.js new file mode 100644 index 0000000..715e0e3 --- /dev/null +++ b/backend/pb_migrations/1776520001_created_game_blacklist.js @@ -0,0 +1,114 @@ +/// +migrate((db) => { + const collection = new Collection({ + "id": "gblacklist_col", + "created": "2026-04-18 21:00:01.000Z", + "updated": "2026-04-18 21:00:01.000Z", + "name": "game_blacklist", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "gb_group", + "name": "group", + "type": "relation", + "required": true, + "options": { + "collectionId": "es63bkyiblpnxdf", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "gb_reporter", + "name": "reporter", + "type": "relation", + "required": true, + "options": { + "collectionId": "_pb_users_auth_", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "gb_game", + "name": "game", + "type": "relation", + "required": false, + "options": { + "collectionId": "x5adjlc0txf16r8", + "cascadeDelete": false, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "gb_gamename", + "name": "gameName", + "type": "text", + "required": true, + "options": { + "min": 1, + "max": 200, + "pattern": "" + } + }, + { + "system": false, + "id": "gb_reason", + "name": "reason", + "type": "select", + "required": true, + "options": { + "maxSelect": 1, + "values": ["behavior", "cheating", "abandonment", "toxic", "other"] + } + }, + { + "system": false, + "id": "gb_desc", + "name": "description", + "type": "text", + "required": true, + "options": { + "min": 1, + "max": 500, + "pattern": "" + } + }, + { + "system": false, + "id": "gb_severity", + "name": "severity", + "type": "select", + "required": true, + "options": { + "maxSelect": 1, + "values": ["mild", "medium", "severe"] + } + } + ], + "indexes": [], + "listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "updateRule": null, + "deleteRule": "reporter = @request.auth.id || group.owner = @request.auth.id", + "options": {} + }); + + return Dao(db).saveCollection(collection); +}, (db) => { + const dao = new Dao(db); + const collection = dao.findCollectionByNameOrId("gblacklist_col"); + return dao.deleteCollection(collection); +}) diff --git a/backend/pb_migrations/1776520002_created_bets.js b/backend/pb_migrations/1776520002_created_bets.js new file mode 100644 index 0000000..733e40a --- /dev/null +++ b/backend/pb_migrations/1776520002_created_bets.js @@ -0,0 +1,149 @@ +/// +migrate((db) => { + const collection = new Collection({ + "id": "bets_col", + "created": "2026-04-18 21:00:02.000Z", + "updated": "2026-04-18 21:00:02.000Z", + "name": "bets", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "bt_group", + "name": "group", + "type": "relation", + "required": true, + "options": { + "collectionId": "es63bkyiblpnxdf", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "bt_creator", + "name": "creator", + "type": "relation", + "required": true, + "options": { + "collectionId": "_pb_users_auth_", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "bt_title", + "name": "title", + "type": "text", + "required": true, + "options": { + "min": 1, + "max": 200, + "pattern": "" + } + }, + { + "system": false, + "id": "bt_desc", + "name": "description", + "type": "text", + "required": false, + "options": { + "min": null, + "max": 1000, + "pattern": "" + } + }, + { + "system": false, + "id": "bt_minstake", + "name": "minStake", + "type": "number", + "required": true, + "options": { + "min": 1, + "max": null, + "noDecimal": true + } + }, + { + "system": false, + "id": "bt_maxstake", + "name": "maxStake", + "type": "number", + "required": true, + "options": { + "min": 1, + "max": null, + "noDecimal": true + } + }, + { + "system": false, + "id": "bt_status", + "name": "status", + "type": "select", + "required": true, + "options": { + "maxSelect": 1, + "values": ["open", "closed", "settled"] + } + }, + { + "system": false, + "id": "bt_result", + "name": "resultOption", + "type": "relation", + "required": false, + "options": { + "collectionId": "betopts_col", + "cascadeDelete": false, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "bt_deadline", + "name": "deadline", + "type": "date", + "required": true, + "options": { + "min": "", + "max": "" + } + }, + { + "system": false, + "id": "bt_settledat", + "name": "settledAt", + "type": "date", + "required": false, + "options": { + "min": "", + "max": "" + } + } + ], + "indexes": [], + "listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "updateRule": "creator = @request.auth.id", + "deleteRule": "creator = @request.auth.id || group.owner = @request.auth.id", + "options": {} + }); + + return Dao(db).saveCollection(collection); +}, (db) => { + const dao = new Dao(db); + const collection = dao.findCollectionByNameOrId("bets_col"); + return dao.deleteCollection(collection); +}) diff --git a/backend/pb_migrations/1776520003_created_bet_options.js b/backend/pb_migrations/1776520003_created_bet_options.js new file mode 100644 index 0000000..5b8631c --- /dev/null +++ b/backend/pb_migrations/1776520003_created_bet_options.js @@ -0,0 +1,64 @@ +/// +migrate((db) => { + const collection = new Collection({ + "id": "betopts_col", + "created": "2026-04-18 21:00:03.000Z", + "updated": "2026-04-18 21:00:03.000Z", + "name": "bet_options", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "bo_bet", + "name": "bet", + "type": "relation", + "required": true, + "options": { + "collectionId": "bets_col", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "bo_content", + "name": "content", + "type": "text", + "required": true, + "options": { + "min": 1, + "max": 200, + "pattern": "" + } + }, + { + "system": false, + "id": "bo_order", + "name": "order", + "type": "number", + "required": true, + "options": { + "min": 1, + "max": null, + "noDecimal": true + } + } + ], + "indexes": [], + "listRule": "@request.auth.id != \"\" && bet.group.members ~ @request.auth.id", + "viewRule": "@request.auth.id != \"\" && bet.group.members ~ @request.auth.id", + "createRule": "@request.auth.id != \"\" && bet.group.members ~ @request.auth.id", + "updateRule": "bet.creator = @request.auth.id", + "deleteRule": "bet.creator = @request.auth.id", + "options": {} + }); + + return Dao(db).saveCollection(collection); +}, (db) => { + const dao = new Dao(db); + const collection = dao.findCollectionByNameOrId("betopts_col"); + return dao.deleteCollection(collection); +}) diff --git a/backend/pb_migrations/1776520004_created_bet_entries.js b/backend/pb_migrations/1776520004_created_bet_entries.js new file mode 100644 index 0000000..3d7aa15 --- /dev/null +++ b/backend/pb_migrations/1776520004_created_bet_entries.js @@ -0,0 +1,88 @@ +/// +migrate((db) => { + const collection = new Collection({ + "id": "betentries_col", + "created": "2026-04-18 21:00:04.000Z", + "updated": "2026-04-18 21:00:04.000Z", + "name": "bet_entries", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "be_bet", + "name": "bet", + "type": "relation", + "required": true, + "options": { + "collectionId": "bets_col", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "be_user", + "name": "user", + "type": "relation", + "required": true, + "options": { + "collectionId": "_pb_users_auth_", + "cascadeDelete": false, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "be_option", + "name": "option", + "type": "relation", + "required": true, + "options": { + "collectionId": "betopts_col", + "cascadeDelete": false, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "be_stake", + "name": "stake", + "type": "number", + "required": true, + "options": { + "min": 1, + "max": null, + "noDecimal": true + } + }, + { + "system": false, + "id": "be_won", + "name": "won", + "type": "bool", + "required": false, + "options": {} + } + ], + "indexes": [], + "listRule": "@request.auth.id != \"\" && bet.group.members ~ @request.auth.id", + "viewRule": "@request.auth.id != \"\" && bet.group.members ~ @request.auth.id", + "createRule": "@request.auth.id != \"\" && user = @request.auth.id && bet.group.members ~ @request.auth.id", + "updateRule": "bet.creator = @request.auth.id", + "deleteRule": "user = @request.auth.id && bet.status = \"open\"", + "options": {} + }); + + return Dao(db).saveCollection(collection); +}, (db) => { + const dao = new Dao(db); + const collection = dao.findCollectionByNameOrId("betentries_col"); + return dao.deleteCollection(collection); +}) diff --git a/backend/pb_migrations/1776521947_updated_point_logs.js b/backend/pb_migrations/1776521947_updated_point_logs.js new file mode 100644 index 0000000..900f404 --- /dev/null +++ b/backend/pb_migrations/1776521947_updated_point_logs.js @@ -0,0 +1,116 @@ +/// +migrate((db) => { + const dao = new Dao(db) + const collection = dao.findCollectionByNameOrId("h5adxdw9gm0aw8s") + + // update + collection.schema.addField(new SchemaField({ + "system": false, + "id": "sd27cbh8", + "name": "action", + "type": "select", + "required": true, + "presentable": false, + "unique": false, + "options": { + "maxSelect": 1, + "values": [ + "vote", + "team", + "memory", + "bet", + "settle" + ] + } + })) + + // update + collection.schema.addField(new SchemaField({ + "system": false, + "id": "iszqa13h", + "name": "points", + "type": "number", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": null, + "noDecimal": false + } + })) + + // update + collection.schema.addField(new SchemaField({ + "system": false, + "id": "pnipfzbd", + "name": "relatedId", + "type": "text", + "required": false, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + })) + + return dao.saveCollection(collection) +}, (db) => { + const dao = new Dao(db) + const collection = dao.findCollectionByNameOrId("h5adxdw9gm0aw8s") + + // update + collection.schema.addField(new SchemaField({ + "system": false, + "id": "sd27cbh8", + "name": "action", + "type": "select", + "required": true, + "presentable": false, + "unique": false, + "options": { + "maxSelect": 1, + "values": [ + "vote", + "team", + "memory" + ] + } + })) + + // update + collection.schema.addField(new SchemaField({ + "system": false, + "id": "iszqa13h", + "name": "points", + "type": "number", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": 1, + "max": null, + "noDecimal": false + } + })) + + // update + collection.schema.addField(new SchemaField({ + "system": false, + "id": "pnipfzbd", + "name": "relatedId", + "type": "text", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + })) + + return dao.saveCollection(collection) +}) diff --git a/frontend/src/api/bets.ts b/frontend/src/api/bets.ts new file mode 100644 index 0000000..c4004a9 --- /dev/null +++ b/frontend/src/api/bets.ts @@ -0,0 +1,218 @@ +import { pb } from './pocketbase' +import type { Bet, BetOption, BetEntry } from '@/types' + +export async function createBet(data: { + group: string + title: string + description?: string + options: string[] + minStake?: number + maxStake?: number + deadline: string +}): Promise { + const user = pb.authStore.model + if (!user) throw new Error('未登录') + + const bet = await pb.collection('bets').create({ + group: data.group, + creator: user.id, + title: data.title, + description: data.description || '', + minStake: data.minStake || 1, + maxStake: data.maxStake || 10, + status: 'open', + deadline: data.deadline, + }) + + for (let i = 0; i < data.options.length; i++) { + await pb.collection('bet_options').create({ + bet: bet.id, + content: data.options[i], + order: i + 1, + }) + } + + return bet as unknown as Bet +} + +export async function listBets(groupId: string, status?: string): Promise { + let filter = `group="${groupId}"` + if (status) filter += ` && status="${status}"` + + const result = await pb.collection('bets').getFullList({ + filter, + sort: '-created', + expand: 'creator', + $autoCancel: false, + }) + return result as unknown as Bet[] +} + +export async function getBet(betId: string): Promise { + const result = await pb.collection('bets').getOne(betId, { + expand: 'creator,resultOption', + $autoCancel: false, + }) + return result as unknown as Bet +} + +export async function getBetOptions(betId: string): Promise { + const result = await pb.collection('bet_options').getFullList({ + filter: `bet="${betId}"`, + sort: 'order', + $autoCancel: false, + }) + return result as unknown as BetOption[] +} + +export async function getBetEntries(betId: string): Promise { + const result = await pb.collection('bet_entries').getFullList({ + filter: `bet="${betId}"`, + expand: 'user,option', + $autoCancel: false, + }) + return result as unknown as BetEntry[] +} + +export async function placeBet(betId: string, optionId: string, stake: number): Promise { + const user = pb.authStore.model + if (!user) throw new Error('未登录') + + // 验证竞猜状态和下注范围 (H1) + const betData = await pb.collection('bets').getOne(betId, { $autoCancel: false }) + if ((betData as any).status !== 'open') throw new Error('竞猜已关闭,无法下注') + if (stake < (betData as any).minStake || stake > (betData as any).maxStake) { + throw new Error(`下注积分需在 ${(betData as any).minStake}~${(betData as any).maxStake} 之间`) + } + + // 防止重复下注 + const existing = await pb.collection('bet_entries').getList(1, 1, { + filter: `bet="${betId}" && user="${user.id}"`, + $autoCancel: false, + }) + if (existing.items.length > 0) throw new Error('你已经下注过了') + + // 重新读取最新积分缓解 TOCTOU (C2) + const currentUser = await pb.collection('users').getOne(user.id, { $autoCancel: false }) + const currentPoints = (currentUser as any).points || 0 + if (currentPoints < stake) throw new Error('积分不足') + + await pb.collection('users').update(user.id, { + points: currentPoints - stake, + }) + + const entry = await pb.collection('bet_entries').create({ + bet: betId, + user: user.id, + option: optionId, + stake, + }) + + await pb.collection('point_logs').create({ + user: user.id, + action: 'bet', + points: -stake, + relatedId: entry.id, + }) + + return entry as unknown as BetEntry +} + +export async function closeBet(betId: string): Promise { + const record = await pb.collection('bets').update(betId, { status: 'closed' }) + return record as unknown as Bet +} + +export async function settleBet(betId: string, resultOptionId: string): Promise { + const user = pb.authStore.model + if (!user) throw new Error('未登录') + + // 先标记为 settled 防止双重结算 (C1) + const betData = await pb.collection('bets').getOne(betId, { $autoCancel: false }) + if ((betData as any).status === 'settled') throw new Error('竞猜已结算,请勿重复操作') + if ((betData as any).creator !== user.id) throw new Error('只有发起人可以开奖') // (H2) + + const now = new Date().toISOString().replace('T', ' ').slice(0, 19) + '.000Z' + await pb.collection('bets').update(betId, { + status: 'settled', + resultOption: resultOptionId, + settledAt: now, + }) + + const entries = await getBetEntries(betId) + const totalPool = entries.reduce((sum, e) => sum + e.stake, 0) + + const winnerEntries = entries.filter(e => e.option === resultOptionId) + const loserEntries = entries.filter(e => e.option !== resultOptionId) + + // 并行标记输赢 + const markPromises: Promise[] = [] + + if (winnerEntries.length === 0) { + // 无人猜中,退还所有积分 + for (const entry of entries) { + markPromises.push((async () => { + const entryUser = await pb.collection('users').getOne(entry.user, { $autoCancel: false }) + await pb.collection('users').update(entry.user, { + points: ((entryUser as any).points || 0) + entry.stake, + }) + await pb.collection('point_logs').create({ + user: entry.user, + action: 'bet', + points: entry.stake, + relatedId: entry.id, + }) + await pb.collection('bet_entries').update(entry.id, { won: false }) + })()) + } + } else { + const winnerTotalStake = winnerEntries.reduce((sum, e) => sum + e.stake, 0) + let distributed = 0 + + for (const entry of winnerEntries) { + const winAmount = Math.floor((entry.stake / winnerTotalStake) * totalPool) + distributed += winAmount + + markPromises.push((async () => { + const entryUser = await pb.collection('users').getOne(entry.user, { $autoCancel: false }) + await pb.collection('users').update(entry.user, { + points: ((entryUser as any).points || 0) + winAmount, + }) + await pb.collection('point_logs').create({ + user: entry.user, + action: 'bet', + points: winAmount, + relatedId: entry.id, + }) + await pb.collection('bet_entries').update(entry.id, { won: true }) + })()) + } + + // 余数分给最后一个赢家而非庄家 (C3) + const remainder = totalPool - distributed + if (remainder > 0 && winnerEntries.length > 0) { + const lastWinner = winnerEntries[winnerEntries.length - 1] + markPromises.push((async () => { + const entryUser = await pb.collection('users').getOne(lastWinner.user, { $autoCancel: false }) + await pb.collection('users').update(lastWinner.user, { + points: ((entryUser as any).points || 0) + remainder, + }) + })()) + } + + for (const entry of loserEntries) { + markPromises.push(pb.collection('bet_entries').update(entry.id, { won: false }).then(() => {})) + } + } + + await Promise.all(markPromises) +} + +export async function subscribeBets( + groupId: string, + callback: (data: any) => void +): Promise<() => void> { + return pb.collection('bets').subscribe('*', (data) => { + if (data.record?.group === groupId) callback(data) + }) +} diff --git a/frontend/src/api/gameBlacklist.ts b/frontend/src/api/gameBlacklist.ts new file mode 100644 index 0000000..24d9870 --- /dev/null +++ b/frontend/src/api/gameBlacklist.ts @@ -0,0 +1,57 @@ +import { pb } from './pocketbase' +import type { BlacklistEntry } from '@/types' + +export async function createBlacklistEntry(data: { + group: string + game?: string + gameName: string + reason: string + description: string + severity: string +}): Promise { + const user = pb.authStore.model + if (!user) throw new Error('未登录') + + const payload: Record = { + group: data.group, + reporter: user.id, + gameName: data.gameName, + reason: data.reason, + description: data.description, + severity: data.severity, + } + if (data.game) payload.game = data.game + + const record = await pb.collection('game_blacklist').create(payload) + return record as unknown as BlacklistEntry +} + +export async function listBlacklist( + groupId: string, + options?: { reason?: string; severity?: string } +): Promise { + let filter = `group="${groupId}"` + if (options?.reason) filter += ` && reason="${options.reason}"` + if (options?.severity) filter += ` && severity="${options.severity}"` + + const result = await pb.collection('game_blacklist').getFullList({ + filter, + sort: '-created', + expand: 'reporter,game', + $autoCancel: false, + }) + return result as unknown as BlacklistEntry[] +} + +export async function deleteBlacklistEntry(entryId: string): Promise { + await pb.collection('game_blacklist').delete(entryId) +} + +export async function subscribeBlacklist( + groupId: string, + callback: (data: any) => void +): Promise<() => void> { + return pb.collection('game_blacklist').subscribe('*', (data) => { + if (data.record?.group === groupId) callback(data) + }) +} diff --git a/frontend/src/api/points.ts b/frontend/src/api/points.ts index aaf8785..abaa9a3 100644 --- a/frontend/src/api/points.ts +++ b/frontend/src/api/points.ts @@ -4,7 +4,8 @@ import type { PointLog, PointAction } from '@/types' const POINT_MAP: Record = { vote: 1, team: 2, - memory: 1 + memory: 1, + bet: 0 // 竞猜积分变动不固定,通过 bets.ts 直接操作 } export async function awardPoints(action: PointAction, relatedId: string): Promise { diff --git a/frontend/src/components/bet/BetCard.vue b/frontend/src/components/bet/BetCard.vue new file mode 100644 index 0000000..59df500 --- /dev/null +++ b/frontend/src/components/bet/BetCard.vue @@ -0,0 +1,320 @@ + + + + + + + + {{ statusInfo.label }} + + + 已下注 + + + + + {{ bet.title }} + + + + + + + + {{ creatorName }} + + + + + + + + + 奖池 + {{ totalPool }} 积分 + + + + + + + {{ option.content }} + + {{ totalPool > 0 ? Math.round((optionStakes[option.id] || 0) / totalPool * 100) : 0 }}% + + + + + + + + + + + + + + diff --git a/frontend/src/components/bet/BetDetail.vue b/frontend/src/components/bet/BetDetail.vue new file mode 100644 index 0000000..27d63c9 --- /dev/null +++ b/frontend/src/components/bet/BetDetail.vue @@ -0,0 +1,650 @@ + + + + + + + + + 返回 + + + + 关闭竞猜 + + + 开奖 + + + + + + + + 加载中... + + + + + + + {{ bet.title }} + + + + + + + {{ creatorName }} 发起 + + + + + + + 奖池 {{ totalPool }} 积分 + + + 下注范围 {{ bet.minStake }}~{{ bet.maxStake }} 积分 + + + + + + + {{ bet.description }} + + + + + + + + {{ option.content }} + + 正确答案 + + + + + {{ (optionStats[option.id]?.count || 0) }} 人 + + + {{ optionStats[option.id]?.totalStake || 0 }} 积分 + + + + + + + + + + + + 下注 + + + 选择选项 + + + {{ option.content }} + + + + + 下注积分 + + + + 下注 + + + + + + + + + + + + 你已下注 {{ currentUserEntry.stake }} 积分,选择「{{ currentUserEntry.expand?.option?.content || '未知' }}」 + + + + + + 下注记录 + + + + + + + + + + diff --git a/frontend/src/components/bet/BetEntryList.vue b/frontend/src/components/bet/BetEntryList.vue new file mode 100644 index 0000000..7cf81dc --- /dev/null +++ b/frontend/src/components/bet/BetEntryList.vue @@ -0,0 +1,145 @@ + + + + + + 暂无下注记录 + + + + + + {{ displayName(entry.expand?.user).charAt(0) }} + + {{ displayName(entry.expand?.user) }} + + + + + + {{ entry.expand?.option?.content || '未知选项' }} + + {{ entry.stake }} 积分 + + + + + 赢 + 输 + + + + + + diff --git a/frontend/src/components/bet/BetList.vue b/frontend/src/components/bet/BetList.vue new file mode 100644 index 0000000..5edc269 --- /dev/null +++ b/frontend/src/components/bet/BetList.vue @@ -0,0 +1,344 @@ + + + + + + + 竞猜 + + + + + + 发起竞猜 + + + + + + + + + + 进行中 + {{ activeBets.length }} + + + + + + + + + + + 已结束 + {{ settledBets.length }} + + + + + + + + + 加载中... + + + + + + + + + 暂无竞猜 + 群组内还没有发起过竞猜 + + + + + diff --git a/frontend/src/components/bet/CreateBetDialog.vue b/frontend/src/components/bet/CreateBetDialog.vue new file mode 100644 index 0000000..8c9120a --- /dev/null +++ b/frontend/src/components/bet/CreateBetDialog.vue @@ -0,0 +1,331 @@ + + + + + + + + 标题 * + + + + + + 描述 + + + + + + 竞猜选项 + + + + + 删除 + + + + 添加选项 + + + + + + + 截止时间 * + + + + + + 下注积分范围 + + + 最小 + + + ~ + + 最大 + + + + + + + + 取消 + + {{ loading ? '创建中...' : '发起竞猜' }} + + + + + + diff --git a/frontend/src/components/bet/SettleBetDialog.vue b/frontend/src/components/bet/SettleBetDialog.vue new file mode 100644 index 0000000..39c7a68 --- /dev/null +++ b/frontend/src/components/bet/SettleBetDialog.vue @@ -0,0 +1,131 @@ + + + + + + 请选择正确的选项来结算竞猜: + + + + + {{ option.content }} + + + + + + + 取消 + + {{ loading ? '结算中...' : '确认开奖' }} + + + + + + diff --git a/frontend/src/components/gameBlacklist/BlacklistEntryList.vue b/frontend/src/components/gameBlacklist/BlacklistEntryList.vue new file mode 100644 index 0000000..25af6d7 --- /dev/null +++ b/frontend/src/components/gameBlacklist/BlacklistEntryList.vue @@ -0,0 +1,264 @@ + + + + + + + + + + {{ reporterName(entry).charAt(0) }} + + {{ reporterName(entry) }} + + + {{ formatTime(entry.created) }} + + + + + + + + + + + + + + + + + {{ BlacklistReasonMap[entry.reason] }} + + + {{ BlacklistSeverityMap[entry.severity] }} + + + + + + {{ entry.description }} + + + + + + diff --git a/frontend/src/components/gameBlacklist/BlacklistGameCard.vue b/frontend/src/components/gameBlacklist/BlacklistGameCard.vue new file mode 100644 index 0000000..237ec57 --- /dev/null +++ b/frontend/src/components/gameBlacklist/BlacklistGameCard.vue @@ -0,0 +1,220 @@ + + + + + + + + {{ gameName }} + {{ reportCount }} 次标记 + + + + + + {{ latestReason }} + + + {{ maxSeverityLabel }} + + + + + + {{ previewDesc }} + + + + + + + + + + + + + diff --git a/frontend/src/components/gameBlacklist/BlacklistMain.vue b/frontend/src/components/gameBlacklist/BlacklistMain.vue new file mode 100644 index 0000000..0d4895a --- /dev/null +++ b/frontend/src/components/gameBlacklist/BlacklistMain.vue @@ -0,0 +1,344 @@ + + + + + + + 游戏黑名单 + + + + + + + + + + + + + + + 标记游戏 + + + + + + + + + + + + + + + + + + + + 加载中... + + + + + + + + + 暂无黑名单记录 + 标记体验差的游戏,提醒队友避开 + + + + + diff --git a/frontend/src/components/gameBlacklist/CreateBlacklistDialog.vue b/frontend/src/components/gameBlacklist/CreateBlacklistDialog.vue new file mode 100644 index 0000000..ce28d09 --- /dev/null +++ b/frontend/src/components/gameBlacklist/CreateBlacklistDialog.vue @@ -0,0 +1,205 @@ + + + + + + + + 游戏名称 * + + + + + + 原因 * + + + + + + + + 严重程度 * + + + + + + + + 描述 * + + + + + + 取消 + + {{ loading ? '提交中...' : '提交' }} + + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index b95ff47..664b209 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -47,6 +47,13 @@ const routes: RouteRecordRaw[] = [ props: true, meta: { requiresAuth: true } }, + { + path: 'group/:groupId/blacklist', + name: 'BlacklistView', + component: () => import('@/views/BlacklistView.vue'), + props: true, + meta: { requiresAuth: true } + }, { path: 'games', name: 'GamesLibrary', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 8195a2e..2c20b8c 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -235,7 +235,7 @@ export interface AppNotification { } // 积分行为类型 -export type PointAction = 'vote' | 'team' | 'memory' +export type PointAction = 'vote' | 'team' | 'memory' | 'bet' // 积分流水 export interface PointLog { @@ -334,6 +334,94 @@ export interface Asset { } } +// 游戏黑名单原因 +export type BlacklistReason = 'behavior' | 'cheating' | 'abandonment' | 'toxic' | 'other' + +export const BlacklistReasonMap: Record = { + behavior: '队友坑', + cheating: '外挂多', + abandonment: '坑货多', + toxic: '环境差', + other: '其他' +} + +// 黑名单严重程度 +export type BlacklistSeverity = 'mild' | 'medium' | 'severe' + +export const BlacklistSeverityMap: Record = { + mild: '轻微', + medium: '中等', + severe: '严重' +} + +// 游戏黑名单 +export interface BlacklistEntry { + id: string + group: string + reporter: string + game?: string + gameName: string + reason: BlacklistReason + description: string + severity: BlacklistSeverity + created: string + updated: string + expand?: { + reporter?: User + group?: Group + game?: Game + } +} + +// 竞猜状态 +export type BetStatus = 'open' | 'closed' | 'settled' + +// 竞猜 +export interface Bet { + id: string + group: string + creator: string + title: string + description?: string + minStake: number + maxStake: number + status: BetStatus + resultOption?: string + deadline: string + settledAt?: string + created: string + updated: string + expand?: { + creator?: User + group?: Group + resultOption?: BetOption + } +} + +// 竞猜选项 +export interface BetOption { + id: string + bet: string + content: string + order: number +} + +// 下注记录 +export interface BetEntry { + id: string + bet: string + user: string + option: string + stake: number + won?: boolean + created: string + updated: string + expand?: { + user?: User + option?: BetOption + } +} + // 获取用户显示名称(优先 name,回退 username) export function displayName(user?: { name?: string; username: string } | null): string { return user?.name || user?.username || '未知' diff --git a/frontend/src/views/BlacklistView.vue b/frontend/src/views/BlacklistView.vue new file mode 100644 index 0000000..3133043 --- /dev/null +++ b/frontend/src/views/BlacklistView.vue @@ -0,0 +1,102 @@ + + + + + + + + + 返回群组 + + + 游戏黑名单 + {{ group?.name }} + + + + + + + + + diff --git a/frontend/src/views/Changelog.vue b/frontend/src/views/Changelog.vue index bd36c4f..9f13997 100644 --- a/frontend/src/views/Changelog.vue +++ b/frontend/src/views/Changelog.vue @@ -10,6 +10,27 @@ interface LogEntry { } const logs = ref([ + { + version: 'v0.3.0', + date: '2026-04-19', + 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: '竞猜列表展示奖池进度条和「已下注」状态标签' }, + { type: 'fix', text: '修复下注选项在 Chrome/Edge 无法选中的问题,改为按钮卡片式选择' }, + { type: 'fix', text: '修复 point_logs schema 不支持竞猜动作和负数积分的问题' }, + { type: 'fix', text: '修复竞猜双重结算和 TOCTOU 竞态安全漏洞' }, + { type: 'fix', text: '修复黑名单条目允许非创建者修改的安全问题' }, + { type: 'fix', text: '修复 BetDetail 订阅累积和群组切换时订阅未清理的内存泄漏' }, + { type: 'fix', text: '修复 GroupView 定时器变量覆盖导致内存泄漏' }, + ] + }, { version: 'v0.2.0', date: '2026-04-18', diff --git a/frontend/src/views/GroupView.vue b/frontend/src/views/GroupView.vue index 2b72927..7ad136f 100644 --- a/frontend/src/views/GroupView.vue +++ b/frontend/src/views/GroupView.vue @@ -6,6 +6,7 @@ import { useGroupStore } from '@/stores/group' import { useTeamStore } from '@/stores/team' import { pb } from '@/api/pocketbase' import { settlePoll } from '@/api/polls' +import { closeBet } from '@/api/bets' import TeamSessionPanel from '@/components/team/TeamSessionPanel.vue' import IdleMembersList from '@/components/team/IdleMembersList.vue' import GroupMembersPanel from '@/components/group/GroupMembersPanel.vue' @@ -13,18 +14,22 @@ 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, Wallet, Box } from '@element-plus/icons-vue' -import { DataLine, PictureFilled, TrendCharts } from '@element-plus/icons-vue' +import BetList from '@/components/bet/BetList.vue' +import BetDetail from '@/components/bet/BetDetail.vue' +import { Promotion, Opportunity, UserFilled, Wallet, Box, Warning } from '@element-plus/icons-vue' +import { DataLine, PictureFilled, TrendCharts, Trophy } from '@element-plus/icons-vue' const route = useRoute() const groupStore = useGroupStore() const teamStore = useTeamStore() -const activeTab = ref(route.query.tab === 'polls' ? 'polls' : route.query.tab === 'memories' ? 'memories' : route.query.tab === 'stats' ? 'stats' : 'activity') +const activeTab = ref(route.query.tab === 'polls' ? 'polls' : route.query.tab === 'memories' ? 'memories' : route.query.tab === 'stats' ? 'stats' : route.query.tab === 'bets' ? 'bets' : 'activity') const viewingPollId = ref(null) +const viewingBetId = ref(null) const unsubFns: (() => Promise)[] = [] -let settleTimer: ReturnType | null = null +let pollTimer: ReturnType | null = null +let betTimer: ReturnType | null = null const groupId = route.params.id as string @@ -89,7 +94,10 @@ onMounted(async () => { })) // 投票结算定时器:每 60 秒检查过期投票 - settleTimer = setInterval(checkExpiredPolls, 60000) + pollTimer = setInterval(checkExpiredPolls, 60000) + + // 竞猜自动关闭定时器:每 60 秒检查 + betTimer = setInterval(checkExpiredBets, 60000) }) onUnmounted(async () => { @@ -97,9 +105,13 @@ onUnmounted(async () => { try { await unsub() } catch (_) {} } unsubFns.length = 0 - if (settleTimer) { - clearInterval(settleTimer) - settleTimer = null + if (pollTimer) { + clearInterval(pollTimer) + pollTimer = null + } + if (betTimer) { + clearInterval(betTimer) + betTimer = null } }) @@ -111,10 +123,8 @@ 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 @@ -133,6 +143,31 @@ async function checkExpiredPolls() { console.error('检查过期投票失败:', e) } } + +async function checkExpiredBets() { + const currentGroupId = groupStore.currentGroupId + const user = pb.authStore.model + if (!currentGroupId || !user) return + + try { + const result = await pb.collection('bets').getList(1, 50, { + filter: `group="${currentGroupId}" && status="open" && creator="${user.id}"`, + $autoCancel: false + }) + const now = new Date() + for (const bet of result.items as any[]) { + if (bet.deadline && new Date(bet.deadline) <= now) { + try { + await closeBet(bet.id) + } catch (e) { + console.error('关闭竞猜失败:', bet.id, e) + } + } + } + } catch (e) { + console.error('检查过期竞猜失败:', e) + } +} @@ -162,6 +197,10 @@ async function checkExpiredPolls() { 资产 + | + + 黑名单 + @@ -251,6 +290,14 @@ async function checkExpiredPolls() { + + + + 竞猜 + + + +
暂无竞猜
群组内还没有发起过竞猜
请选择正确的选项来结算竞猜:
加载中...
暂无黑名单记录
标记体验差的游戏,提醒队友避开