feat: phase 4 - 积分竞猜和游戏黑名单 v0.3.0
竞猜功能:发起竞猜、下注、关闭、开奖、奖池分配 黑名单功能:标记游戏、按原因/严重程度筛选、详情展开 修复:双重结算、TOCTOU竞态、订阅泄漏、选项选择兼容性 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+8
-1
@@ -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/
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
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);
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
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);
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
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);
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
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);
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
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)
|
||||
})
|
||||
@@ -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<Bet> {
|
||||
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<Bet[]> {
|
||||
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<Bet> {
|
||||
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<BetOption[]> {
|
||||
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<BetEntry[]> {
|
||||
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<BetEntry> {
|
||||
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<Bet> {
|
||||
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<void> {
|
||||
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<void>[] = []
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -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<BlacklistEntry> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
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<BlacklistEntry[]> {
|
||||
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<void> {
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import type { PointLog, PointAction } from '@/types'
|
||||
const POINT_MAP: Record<PointAction, number> = {
|
||||
vote: 1,
|
||||
team: 2,
|
||||
memory: 1
|
||||
memory: 1,
|
||||
bet: 0 // 竞猜积分变动不固定,通过 bets.ts 直接操作
|
||||
}
|
||||
|
||||
export async function awardPoints(action: PointAction, relatedId: string): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { displayName } from '@/types'
|
||||
import type { Bet, BetOption, BetEntry } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
bet: Bet
|
||||
options: BetOption[]
|
||||
entries: BetEntry[]
|
||||
currentUserEntry: BetEntry | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
|
||||
// 状态标签
|
||||
const statusMap: Record<string, { label: string; cls: string }> = {
|
||||
open: { label: '进行中', cls: 'active' },
|
||||
closed: { label: '已关闭', cls: 'closed' },
|
||||
settled: { label: '已开奖', cls: 'settled' },
|
||||
}
|
||||
|
||||
const statusInfo = computed(() => statusMap[props.bet.status] || statusMap.open)
|
||||
|
||||
// 发起人名称
|
||||
const creatorName = computed(() => displayName(props.bet.expand?.creator))
|
||||
|
||||
// 奖池总额
|
||||
const totalPool = computed(() => props.entries.reduce((sum, e) => sum + e.stake, 0))
|
||||
|
||||
// 各选项的下注总额
|
||||
const optionStakes = computed(() => {
|
||||
const map: Record<string, number> = {}
|
||||
for (const e of props.entries) {
|
||||
map[e.option] = (map[e.option] || 0) + e.stake
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
// 截止时间倒计时
|
||||
const deadlineText = computed(() => {
|
||||
if (!props.bet.deadline) return null
|
||||
const deadline = new Date(props.bet.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="bet-card" @click="emit('click')">
|
||||
<!-- 顶部标签行 -->
|
||||
<div class="bet-card__tags">
|
||||
<span class="bet-card__status-tag" :class="`bet-card__status-tag--${statusInfo.cls}`">
|
||||
{{ statusInfo.label }}
|
||||
</span>
|
||||
<span v-if="currentUserEntry" class="bet-card__voted-badge">
|
||||
已下注
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 标题 -->
|
||||
<div class="bet-card__title">{{ bet.title }}</div>
|
||||
|
||||
<!-- 发起人 -->
|
||||
<div class="bet-card__creator">
|
||||
<svg class="bet-card__creator-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
<span>{{ creatorName }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 奖池总额 -->
|
||||
<div class="bet-card__pool">
|
||||
<svg class="bet-card__pool-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="1" x2="12" y2="23" />
|
||||
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
||||
</svg>
|
||||
<span class="bet-card__pool-label">奖池</span>
|
||||
<span class="bet-card__pool-value">{{ totalPool }} 积分</span>
|
||||
</div>
|
||||
|
||||
<!-- 各选项进度条 -->
|
||||
<div v-if="options.length > 0 && totalPool > 0" class="bet-card__options">
|
||||
<div
|
||||
v-for="option in options"
|
||||
:key="option.id"
|
||||
class="bet-card__option"
|
||||
>
|
||||
<div class="bet-card__option-header">
|
||||
<span class="bet-card__option-name">{{ option.content }}</span>
|
||||
<span class="bet-card__option-pct">
|
||||
{{ totalPool > 0 ? Math.round((optionStakes[option.id] || 0) / totalPool * 100) : 0 }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="bet-card__option-bar">
|
||||
<div
|
||||
class="bet-card__option-fill"
|
||||
:style="{
|
||||
width: totalPool > 0
|
||||
? ((optionStakes[option.id] || 0) / totalPool * 100) + '%'
|
||||
: '0%',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部:截止时间 -->
|
||||
<div v-if="deadlineText" class="bet-card__footer">
|
||||
<svg class="bet-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="{ 'bet-card__deadline--urgent': deadlineText === '即将截止' }">
|
||||
{{ deadlineText }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bet-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;
|
||||
}
|
||||
|
||||
.bet-card:hover {
|
||||
border-color: var(--gg-primary-light);
|
||||
box-shadow: 0 0 20px rgba(5, 150, 105, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 标签行 */
|
||||
.bet-card__tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.bet-card__status-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.bet-card__status-tag--active {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--gg-success);
|
||||
}
|
||||
|
||||
.bet-card__status-tag--closed {
|
||||
background: var(--gg-bg-elevated);
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.bet-card__status-tag--settled {
|
||||
background: rgba(64, 158, 255, 0.1);
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.bet-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);
|
||||
}
|
||||
|
||||
/* 标题 */
|
||||
.bet-card__title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
/* 发起人 */
|
||||
.bet-card__creator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.bet-card__creator-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 奖池 */
|
||||
.bet-card__pool {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(5, 150, 105, 0.06);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.bet-card__pool-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--gg-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bet-card__pool-label {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.bet-card__pool-value {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
/* 选项进度 */
|
||||
.bet-card__options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.bet-card__option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.bet-card__option-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bet-card__option-name {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bet-card__option-pct {
|
||||
font-size: 11px;
|
||||
color: var(--gg-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bet-card__option-bar {
|
||||
height: 4px;
|
||||
background: var(--gg-bg-elevated);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bet-card__option-fill {
|
||||
height: 100%;
|
||||
background: var(--gg-primary);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* 底部 */
|
||||
.bet-card__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.bet-card__footer-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bet-card__deadline--urgent {
|
||||
color: var(--gg-danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,650 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
|
||||
import { getBet, getBetOptions, getBetEntries, placeBet, closeBet, subscribeBets } from '@/api/bets'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import { displayName } from '@/types'
|
||||
import type { Bet, BetOption, BetEntry } from '@/types'
|
||||
import BetEntryList from './BetEntryList.vue'
|
||||
import SettleBetDialog from './SettleBetDialog.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
betId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
back: []
|
||||
}>()
|
||||
|
||||
// 数据
|
||||
const bet = ref<Bet | null>(null)
|
||||
const options = ref<BetOption[]>([])
|
||||
const entries = ref<BetEntry[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
// 下注表单
|
||||
const selectedOption = ref('')
|
||||
const stakeAmount = ref(1)
|
||||
|
||||
// 结算弹窗
|
||||
const showSettle = ref(false)
|
||||
|
||||
// 实时订阅
|
||||
let unsubscribeFn: (() => void) | null = null
|
||||
|
||||
// 计算属性
|
||||
const creatorName = computed(() => displayName(bet.value?.expand?.creator))
|
||||
|
||||
const totalPool = computed(() => entries.value.reduce((sum, e) => sum + e.stake, 0))
|
||||
|
||||
// 各选项的下注总额和人数
|
||||
const optionStats = computed(() => {
|
||||
const stats: Record<string, { totalStake: number; count: number }> = {}
|
||||
for (const opt of options.value) {
|
||||
stats[opt.id] = { totalStake: 0, count: 0 }
|
||||
}
|
||||
for (const e of entries.value) {
|
||||
if (!stats[e.option]) {
|
||||
stats[e.option] = { totalStake: 0, count: 0 }
|
||||
}
|
||||
stats[e.option].totalStake += e.stake
|
||||
stats[e.option].count += 1
|
||||
}
|
||||
return stats
|
||||
})
|
||||
|
||||
// 当前用户
|
||||
const currentUserId = computed(() => pb.authStore.model?.id)
|
||||
const isCreator = computed(() => bet.value?.creator === currentUserId.value)
|
||||
|
||||
// 当前用户的下注记录
|
||||
const currentUserEntry = computed(() =>
|
||||
entries.value.find((e) => e.user === currentUserId.value) || null
|
||||
)
|
||||
|
||||
// 状态
|
||||
const isOpen = computed(() => bet.value?.status === 'open')
|
||||
const isClosed = computed(() => bet.value?.status === 'closed')
|
||||
const isSettled = computed(() => bet.value?.status === 'settled')
|
||||
|
||||
// 结果选项ID
|
||||
const resultOptionId = computed(() => bet.value?.resultOption || '')
|
||||
|
||||
// 加载数据
|
||||
async function loadDetail() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [betData, optionsData, entriesData] = await Promise.all([
|
||||
getBet(props.betId),
|
||||
getBetOptions(props.betId),
|
||||
getBetEntries(props.betId),
|
||||
])
|
||||
bet.value = betData
|
||||
options.value = optionsData
|
||||
entries.value = entriesData
|
||||
} catch (error) {
|
||||
console.error('加载竞猜详情失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 下注
|
||||
async function handlePlaceBet() {
|
||||
if (!selectedOption.value) {
|
||||
ElMessage.warning('请选择一个选项')
|
||||
return
|
||||
}
|
||||
if (!stakeAmount.value || stakeAmount.value < 1) {
|
||||
ElMessage.warning('请输入有效的积分数')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await placeBet(props.betId, selectedOption.value, stakeAmount.value)
|
||||
ElMessage.success('下注成功')
|
||||
await loadDetail()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '下注失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭竞猜
|
||||
async function handleClose() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要关闭竞猜吗?关闭后将不能再下注。', '关闭竞猜', {
|
||||
confirmButtonText: '确定关闭',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
await closeBet(props.betId)
|
||||
ElMessage.success('竞猜已关闭')
|
||||
await loadDetail()
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
// 实时订阅
|
||||
async function startSubscription() {
|
||||
if (unsubscribeFn) {
|
||||
unsubscribeFn()
|
||||
unsubscribeFn = null
|
||||
}
|
||||
if (!bet.value) return
|
||||
const groupId = bet.value.group
|
||||
unsubscribeFn = await subscribeBets(groupId, () => {
|
||||
loadDetail()
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDetail()
|
||||
startSubscription()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unsubscribeFn) {
|
||||
unsubscribeFn()
|
||||
unsubscribeFn = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bet-detail">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="bet-detail__header">
|
||||
<el-button text @click="emit('back')">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
返回
|
||||
</el-button>
|
||||
<div class="bet-detail__actions">
|
||||
<button
|
||||
v-if="isCreator && isOpen"
|
||||
class="action-btn action-btn--close"
|
||||
@click="handleClose"
|
||||
>
|
||||
关闭竞猜
|
||||
</button>
|
||||
<button
|
||||
v-if="isCreator && isClosed"
|
||||
class="action-btn action-btn--settle"
|
||||
@click="showSettle = true"
|
||||
>
|
||||
开奖
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading" class="bet-detail__loading">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
|
||||
<!-- 竞猜内容 -->
|
||||
<template v-else-if="bet">
|
||||
<!-- 标题区域 -->
|
||||
<div class="bet-detail__title-area">
|
||||
<h2 class="bet-detail__title">{{ bet.title }}</h2>
|
||||
<div class="bet-detail__meta">
|
||||
<span class="bet-detail__meta-item">
|
||||
<svg class="meta-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
{{ creatorName }} 发起
|
||||
</span>
|
||||
<span class="bet-detail__meta-item">
|
||||
<svg class="meta-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="1" x2="12" y2="23" />
|
||||
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
||||
</svg>
|
||||
奖池 {{ totalPool }} 积分
|
||||
</span>
|
||||
<span class="bet-detail__meta-item">
|
||||
下注范围 {{ bet.minStake }}~{{ bet.maxStake }} 积分
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 描述 -->
|
||||
<div v-if="bet.description" class="bet-detail__desc">
|
||||
{{ bet.description }}
|
||||
</div>
|
||||
|
||||
<!-- 选项列表 -->
|
||||
<div class="bet-detail__options">
|
||||
<div
|
||||
v-for="option in options"
|
||||
:key="option.id"
|
||||
:class="[
|
||||
'bet-detail__option',
|
||||
{
|
||||
'bet-detail__option--winner': isSettled && resultOptionId === option.id,
|
||||
'bet-detail__option--loser': isSettled && resultOptionId !== option.id,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<div class="bet-detail__option-header">
|
||||
<span class="bet-detail__option-name">
|
||||
{{ option.content }}
|
||||
<span v-if="isSettled && resultOptionId === option.id" class="winner-badge">
|
||||
正确答案
|
||||
</span>
|
||||
</span>
|
||||
<div class="bet-detail__option-stats">
|
||||
<span class="bet-detail__option-count">
|
||||
{{ (optionStats[option.id]?.count || 0) }} 人
|
||||
</span>
|
||||
<span class="bet-detail__option-stake">
|
||||
{{ optionStats[option.id]?.totalStake || 0 }} 积分
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bet-detail__option-bar">
|
||||
<div
|
||||
class="bet-detail__option-fill"
|
||||
:class="{ 'bet-detail__option-fill--winner': isSettled && resultOptionId === option.id }"
|
||||
:style="{
|
||||
width: totalPool > 0
|
||||
? ((optionStats[option.id]?.totalStake || 0) / totalPool * 100) + '%'
|
||||
: '0%',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 下注区域(状态 open 且未下注时) -->
|
||||
<div v-if="isOpen && !currentUserEntry" class="bet-detail__bet-area">
|
||||
<h4 class="bet-detail__bet-title">下注</h4>
|
||||
<div class="bet-detail__bet-form">
|
||||
<div class="bet-detail__bet-option">
|
||||
<label>选择选项</label>
|
||||
<div class="bet-detail__option-pick">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.id"
|
||||
type="button"
|
||||
:class="['bet-detail__pick-card', { 'bet-detail__pick-card--active': selectedOption === option.id }]"
|
||||
@click="selectedOption = option.id"
|
||||
>
|
||||
{{ option.content }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bet-detail__bet-stake">
|
||||
<label>下注积分</label>
|
||||
<el-input-number
|
||||
v-model="stakeAmount"
|
||||
:min="bet.minStake"
|
||||
:max="bet.maxStake"
|
||||
:step="1"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="bet-detail__bet-btn"
|
||||
:disabled="!selectedOption"
|
||||
@click="handlePlaceBet"
|
||||
>
|
||||
下注
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已下注提示 -->
|
||||
<div v-if="isOpen && currentUserEntry" class="bet-detail__already-bet">
|
||||
<svg class="bet-detail__already-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||
<polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
<span>
|
||||
你已下注 <strong>{{ currentUserEntry.stake }}</strong> 积分,选择「{{ currentUserEntry.expand?.option?.content || '未知' }}」
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 下注记录 -->
|
||||
<div class="bet-detail__entries">
|
||||
<h4 class="bet-detail__entries-title">下注记录</h4>
|
||||
<BetEntryList :entries="entries" :show-result="isSettled" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 开奖弹窗 -->
|
||||
<SettleBetDialog
|
||||
v-if="bet"
|
||||
v-model="showSettle"
|
||||
:bet-id="betId"
|
||||
:options="options"
|
||||
@settled="loadDetail"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bet-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 顶部操作栏 */
|
||||
.bet-detail__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bet-detail__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, border-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.action-btn--close {
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.action-btn--close:hover {
|
||||
border-color: var(--gg-danger);
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
.action-btn--settle {
|
||||
background: var(--gg-gradient);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.action-btn--settle:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* 加载中 */
|
||||
.bet-detail__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 40px 0;
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
/* 标题区域 */
|
||||
.bet-detail__title-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bet-detail__title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.bet-detail__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bet-detail__meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.meta-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 描述 */
|
||||
.bet-detail__desc {
|
||||
font-size: 14px;
|
||||
color: var(--gg-text-secondary);
|
||||
line-height: 1.6;
|
||||
padding: 10px 14px;
|
||||
background: var(--gg-bg-elevated);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
}
|
||||
|
||||
/* 选项列表 */
|
||||
.bet-detail__options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bet-detail__option {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.bet-detail__option--winner {
|
||||
border-color: var(--gg-success);
|
||||
background: rgba(16, 185, 129, 0.06);
|
||||
}
|
||||
|
||||
.bet-detail__option--loser {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.bet-detail__option-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.bet-detail__option-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.winner-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
color: var(--gg-success);
|
||||
}
|
||||
|
||||
.bet-detail__option-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bet-detail__option-count {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.bet-detail__option-stake {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.bet-detail__option-bar {
|
||||
height: 6px;
|
||||
background: var(--gg-bg-elevated);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bet-detail__option-fill {
|
||||
height: 100%;
|
||||
background: var(--gg-primary);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.bet-detail__option-fill--winner {
|
||||
background: var(--gg-success);
|
||||
}
|
||||
|
||||
/* 下注区域 */
|
||||
.bet-detail__bet-area {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-md);
|
||||
background: var(--gg-bg-card);
|
||||
}
|
||||
|
||||
.bet-detail__bet-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.bet-detail__bet-form {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bet-detail__bet-option,
|
||||
.bet-detail__bet-stake {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bet-detail__bet-option label,
|
||||
.bet-detail__bet-stake label {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.bet-detail__option-pick {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bet-detail__pick-card {
|
||||
padding: 6px 16px;
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-bg-card);
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.bet-detail__pick-card:hover {
|
||||
border-color: var(--gg-primary);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.bet-detail__pick-card--active {
|
||||
border-color: var(--gg-primary);
|
||||
background: rgba(5, 150, 105, 0.08);
|
||||
color: var(--gg-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bet-detail__bet-btn {
|
||||
padding: 8px 24px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-gradient);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.bet-detail__bet-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.bet-detail__bet-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 已下注提示 */
|
||||
.bet-detail__already-bet {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(5, 150, 105, 0.06);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
font-size: 14px;
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.bet-detail__already-bet strong {
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.bet-detail__already-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--gg-success);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 下注记录 */
|
||||
.bet-detail__entries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bet-detail__entries-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 640px) {
|
||||
.bet-detail__bet-form {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { displayName } from '@/types'
|
||||
import type { BetEntry } from '@/types'
|
||||
|
||||
defineProps<{
|
||||
entries: BetEntry[]
|
||||
showResult?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="entry-list">
|
||||
<div v-if="entries.length === 0" class="entry-list__empty">
|
||||
暂无下注记录
|
||||
</div>
|
||||
<div
|
||||
v-for="entry in entries"
|
||||
:key="entry.id"
|
||||
class="entry-list__item"
|
||||
>
|
||||
<!-- 用户信息 -->
|
||||
<div class="entry-list__user">
|
||||
<div class="entry-list__avatar">
|
||||
{{ displayName(entry.expand?.user).charAt(0) }}
|
||||
</div>
|
||||
<span class="entry-list__name">{{ displayName(entry.expand?.user) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 选择 & 积分 -->
|
||||
<div class="entry-list__info">
|
||||
<span class="entry-list__option">
|
||||
{{ entry.expand?.option?.content || '未知选项' }}
|
||||
</span>
|
||||
<span class="entry-list__stake">{{ entry.stake }} 积分</span>
|
||||
</div>
|
||||
|
||||
<!-- 结果标签 -->
|
||||
<div v-if="showResult" class="entry-list__result">
|
||||
<span v-if="entry.won" class="entry-list__badge entry-list__badge--win">赢</span>
|
||||
<span v-else class="entry-list__badge entry-list__badge--lose">输</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.entry-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.entry-list__empty {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.entry-list__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.entry-list__user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entry-list__avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--gg-gradient);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.entry-list__name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.entry-list__info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.entry-list__option {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-secondary);
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.entry-list__stake {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.entry-list__result {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.entry-list__badge {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.entry-list__badge--win {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
color: var(--gg-success);
|
||||
}
|
||||
|
||||
.entry-list__badge--lose {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,344 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { listBets, getBetOptions, getBetEntries, subscribeBets } from '@/api/bets'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import BetCard from './BetCard.vue'
|
||||
import CreateBetDialog from './CreateBetDialog.vue'
|
||||
import type { Bet, BetOption, BetEntry } from '@/types'
|
||||
|
||||
const emit = defineEmits<{
|
||||
viewBet: [betId: string]
|
||||
}>()
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const showCreate = ref(false)
|
||||
const loading = ref(false)
|
||||
let unsubscribeFn: (() => void) | null = null
|
||||
|
||||
// 竞猜列表
|
||||
const allBets = ref<Bet[]>([])
|
||||
|
||||
// 缓存每个 bet 的 options / entries
|
||||
interface BetExtra {
|
||||
options: BetOption[]
|
||||
entries: BetEntry[]
|
||||
currentUserEntry: BetEntry | null
|
||||
}
|
||||
const betExtras = ref<Record<string, BetExtra>>({})
|
||||
|
||||
// 分栏
|
||||
const activeBets = computed(() =>
|
||||
allBets.value.filter((b) => b.status === 'open' || b.status === 'closed')
|
||||
)
|
||||
const settledBets = computed(() =>
|
||||
allBets.value.filter((b) => b.status === 'settled')
|
||||
)
|
||||
|
||||
// 加载所有竞猜及详情
|
||||
async function loadAll() {
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const bets = await listBets(groupId)
|
||||
allBets.value = bets
|
||||
|
||||
// 并行加载每个 bet 的 options / entries
|
||||
const currentUserId = pb.authStore.model?.id
|
||||
const results = await Promise.allSettled(
|
||||
bets.map(async (bet) => {
|
||||
const [options, entries] = await Promise.all([
|
||||
getBetOptions(bet.id),
|
||||
getBetEntries(bet.id),
|
||||
])
|
||||
const currentUserEntry = currentUserId
|
||||
? entries.find((e) => e.user === currentUserId) || null
|
||||
: null
|
||||
return { betId: bet.id, options, entries, currentUserEntry }
|
||||
})
|
||||
)
|
||||
|
||||
const extras: Record<string, BetExtra> = {}
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled') {
|
||||
extras[r.value.betId] = {
|
||||
options: r.value.options,
|
||||
entries: r.value.entries,
|
||||
currentUserEntry: r.value.currentUserEntry,
|
||||
}
|
||||
}
|
||||
}
|
||||
betExtras.value = extras
|
||||
} catch (error) {
|
||||
console.error('加载竞猜列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取某个 bet 的缓存 extras
|
||||
function getExtra(betId: string): BetExtra {
|
||||
return betExtras.value[betId] || { options: [], entries: [], currentUserEntry: null }
|
||||
}
|
||||
|
||||
// 实时订阅
|
||||
async function startSubscription() {
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) return
|
||||
|
||||
unsubscribeFn = await subscribeBets(groupId, () => {
|
||||
loadAll()
|
||||
})
|
||||
}
|
||||
|
||||
function stopSubscription() {
|
||||
if (unsubscribeFn) {
|
||||
unsubscribeFn()
|
||||
unsubscribeFn = null
|
||||
}
|
||||
}
|
||||
|
||||
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="bet-list">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="bet-list__header">
|
||||
<h3 class="bet-list__title">竞猜</h3>
|
||||
<button class="bet-list__create-btn" @click="showCreate = true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="bet-list__create-icon">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
发起竞猜
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CreateBetDialog
|
||||
v-model="showCreate"
|
||||
@created="loadAll(); showCreate = false"
|
||||
/>
|
||||
|
||||
<!-- 进行中 -->
|
||||
<section v-if="activeBets.length > 0" class="bet-list__section">
|
||||
<div class="bet-list__section-header">
|
||||
<span class="bet-list__section-dot bet-list__section-dot--active"></span>
|
||||
<span class="bet-list__section-label">进行中</span>
|
||||
<span class="bet-list__section-count">{{ activeBets.length }}</span>
|
||||
</div>
|
||||
<div class="bet-list__grid">
|
||||
<BetCard
|
||||
v-for="bet in activeBets"
|
||||
:key="bet.id"
|
||||
:bet="bet"
|
||||
:options="getExtra(bet.id).options"
|
||||
:entries="getExtra(bet.id).entries"
|
||||
:current-user-entry="getExtra(bet.id).currentUserEntry"
|
||||
@click="emit('viewBet', bet.id)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 已结束 -->
|
||||
<section v-if="settledBets.length > 0" class="bet-list__section">
|
||||
<div class="bet-list__section-header">
|
||||
<span class="bet-list__section-dot bet-list__section-dot--settled"></span>
|
||||
<span class="bet-list__section-label">已结束</span>
|
||||
<span class="bet-list__section-count">{{ settledBets.length }}</span>
|
||||
</div>
|
||||
<div class="bet-list__grid">
|
||||
<BetCard
|
||||
v-for="bet in settledBets"
|
||||
:key="bet.id"
|
||||
:bet="bet"
|
||||
:options="getExtra(bet.id).options"
|
||||
:entries="getExtra(bet.id).entries"
|
||||
:current-user-entry="getExtra(bet.id).currentUserEntry"
|
||||
@click="emit('viewBet', bet.id)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading && allBets.length === 0" class="bet-list__loading">
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-if="activeBets.length === 0 && settledBets.length === 0 && !loading"
|
||||
class="bet-list__empty"
|
||||
>
|
||||
<svg class="bet-list__empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<line x1="12" y1="1" x2="12" y2="23" />
|
||||
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
||||
</svg>
|
||||
<p class="bet-list__empty-text">暂无竞猜</p>
|
||||
<p class="bet-list__empty-hint">群组内还没有发起过竞猜</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bet-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* 顶部操作栏 */
|
||||
.bet-list__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.bet-list__title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--gg-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bet-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);
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.bet-list__create-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.bet-list__create-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* 分栏 */
|
||||
.bet-list__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bet-list__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bet-list__section-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.bet-list__section-dot--active {
|
||||
background: var(--gg-success);
|
||||
box-shadow: 0 0 6px var(--gg-success);
|
||||
}
|
||||
|
||||
.bet-list__section-dot--settled {
|
||||
background: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.bet-list__section-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.bet-list__section-count {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
background: var(--gg-bg-elevated);
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* 网格布局 */
|
||||
.bet-list__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 加载中 */
|
||||
.bet-list__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 0;
|
||||
font-size: 14px;
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.bet-list__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bet-list__empty-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--gg-text-muted);
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.bet-list__empty-text {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text-secondary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.bet-list__empty-hint {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 640px) {
|
||||
.bet-list__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,331 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { createBet } from '@/api/bets'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
|
||||
const visible = defineModel<boolean>({ default: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
created: []
|
||||
}>()
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const form = ref({
|
||||
title: '',
|
||||
description: '',
|
||||
options: ['', ''],
|
||||
deadline: '' as string | Date,
|
||||
minStake: 1,
|
||||
maxStake: 10,
|
||||
})
|
||||
|
||||
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() {
|
||||
form.value = {
|
||||
title: '',
|
||||
description: '',
|
||||
options: ['', ''],
|
||||
deadline: '',
|
||||
minStake: 1,
|
||||
maxStake: 10,
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
// 标题必填
|
||||
if (!form.value.title.trim()) {
|
||||
ElMessage.warning('请输入竞猜标题')
|
||||
return
|
||||
}
|
||||
|
||||
// 至少2个非空选项
|
||||
const nonEmpty = form.value.options.filter((o) => o.trim())
|
||||
if (nonEmpty.length < 2) {
|
||||
ElMessage.warning('至少需要2个非空选项')
|
||||
return
|
||||
}
|
||||
|
||||
// 截止时间必填
|
||||
if (!form.value.deadline) {
|
||||
ElMessage.warning('请选择截止时间')
|
||||
return
|
||||
}
|
||||
|
||||
// 积分范围校验
|
||||
if (form.value.minStake < 1 || form.value.maxStake < form.value.minStake) {
|
||||
ElMessage.warning('请设置正确的积分范围')
|
||||
return
|
||||
}
|
||||
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) {
|
||||
ElMessage.error('请先选择群组')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await createBet({
|
||||
group: groupId,
|
||||
title: form.value.title.trim(),
|
||||
description: form.value.description.trim() || undefined,
|
||||
options: nonEmpty,
|
||||
minStake: form.value.minStake,
|
||||
maxStake: form.value.maxStake,
|
||||
deadline: new Date(String(form.value.deadline)).toISOString(),
|
||||
})
|
||||
|
||||
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="520px"
|
||||
@open="handleOpen"
|
||||
>
|
||||
<div class="create-form">
|
||||
<!-- 标题 -->
|
||||
<div class="form-field">
|
||||
<label>标题 <span class="required">*</span></label>
|
||||
<el-input
|
||||
v-model="form.title"
|
||||
placeholder="今晚谁 MVP?"
|
||||
maxlength="100"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 描述 -->
|
||||
<div class="form-field">
|
||||
<label>描述</label>
|
||||
<el-input
|
||||
v-model="form.description"
|
||||
type="textarea"
|
||||
placeholder="补充说明(可选)"
|
||||
:rows="2"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 选项管理 -->
|
||||
<div 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>
|
||||
<button
|
||||
v-if="canAddOption"
|
||||
class="add-option-btn"
|
||||
@click="addOption"
|
||||
>
|
||||
<el-icon><Plus /></el-icon> 添加选项
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 截止时间 -->
|
||||
<div class="form-field">
|
||||
<label>截止时间 <span class="required">*</span></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="stake-range">
|
||||
<div class="stake-range__item">
|
||||
<span class="stake-range__label">最小</span>
|
||||
<el-input-number
|
||||
v-model="form.minStake"
|
||||
:min="1"
|
||||
:max="100"
|
||||
:step="1"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<span class="stake-range__separator">~</span>
|
||||
<div class="stake-range__item">
|
||||
<span class="stake-range__label">最大</span>
|
||||
<el-input-number
|
||||
v-model="form.maxStake"
|
||||
:min="form.minStake"
|
||||
:max="100"
|
||||
:step="1"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<button
|
||||
class="submit-btn"
|
||||
:disabled="loading"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ loading ? '创建中...' : '发起竞猜' }}
|
||||
</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);
|
||||
}
|
||||
|
||||
.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);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.add-option-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.stake-range {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stake-range__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stake-range__label {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stake-range__separator {
|
||||
font-size: 16px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-gradient);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,131 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { settleBet } from '@/api/bets'
|
||||
import type { BetOption } from '@/types'
|
||||
|
||||
const visible = defineModel<boolean>({ default: false })
|
||||
|
||||
const props = defineProps<{
|
||||
betId: string
|
||||
options: BetOption[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
settled: []
|
||||
}>()
|
||||
|
||||
const selectedOption = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
function handleOpen() {
|
||||
selectedOption.value = ''
|
||||
}
|
||||
|
||||
async function handleSettle() {
|
||||
if (!selectedOption.value) {
|
||||
ElMessage.warning('请选择正确选项')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await settleBet(props.betId, selectedOption.value)
|
||||
visible.value = false
|
||||
ElMessage.success('开奖成功')
|
||||
emit('settled')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '开奖失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="开奖"
|
||||
width="420px"
|
||||
@open="handleOpen"
|
||||
>
|
||||
<div class="settle-form">
|
||||
<p class="settle-form__hint">请选择正确的选项来结算竞猜:</p>
|
||||
|
||||
<el-radio-group v-model="selectedOption" class="settle-form__options">
|
||||
<div
|
||||
v-for="option in options"
|
||||
:key="option.id"
|
||||
class="settle-form__option"
|
||||
>
|
||||
<el-radio :value="option.id">
|
||||
{{ option.content }}
|
||||
</el-radio>
|
||||
</div>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<button
|
||||
class="settle-form__submit"
|
||||
:disabled="loading || !selectedOption"
|
||||
@click="handleSettle"
|
||||
>
|
||||
{{ loading ? '结算中...' : '确认开奖' }}
|
||||
</button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settle-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settle-form__hint {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.settle-form__options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.settle-form__option {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.settle-form__option:hover {
|
||||
border-color: var(--gg-primary-light);
|
||||
}
|
||||
|
||||
.settle-form__submit {
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-gradient);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.settle-form__submit:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.settle-form__submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,264 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { deleteBlacklistEntry } from '@/api/gameBlacklist'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import { displayName } from '@/types'
|
||||
import { BlacklistReasonMap, BlacklistSeverityMap } from '@/types'
|
||||
import type { BlacklistEntry, BlacklistSeverity } from '@/types'
|
||||
|
||||
defineProps<{
|
||||
entries: BlacklistEntry[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
deleted: []
|
||||
}>()
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
// 当前用户 ID
|
||||
const currentUserId = computed(() => pb.authStore.model?.id)
|
||||
|
||||
// 是否为群主
|
||||
const isOwner = computed(() => groupStore.isGroupOwner)
|
||||
|
||||
// 是否可以删除(举报人本人或群主)
|
||||
function canDelete(entry: BlacklistEntry): boolean {
|
||||
return entry.reporter === currentUserId.value || isOwner.value
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
function formatTime(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60))
|
||||
const diffHours = Math.floor(diffMinutes / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
if (diffDays > 30) {
|
||||
return `${date.getMonth() + 1}月${date.getDate()}日`
|
||||
} else if (diffDays > 0) {
|
||||
return `${diffDays}天前`
|
||||
} else if (diffHours > 0) {
|
||||
return `${diffHours}小时前`
|
||||
} else if (diffMinutes > 0) {
|
||||
return `${diffMinutes}分钟前`
|
||||
}
|
||||
return '刚刚'
|
||||
}
|
||||
|
||||
// 获取举报人显示名称
|
||||
function reporterName(entry: BlacklistEntry): string {
|
||||
if (entry.expand?.reporter) {
|
||||
return displayName(entry.expand.reporter)
|
||||
}
|
||||
return '未知用户'
|
||||
}
|
||||
|
||||
// 严重程度 CSS class
|
||||
function severityClass(severity: BlacklistSeverity): string {
|
||||
return `entry__severity--${severity}`
|
||||
}
|
||||
|
||||
// 删除记录
|
||||
async function handleDelete(entryId: string) {
|
||||
try {
|
||||
await deleteBlacklistEntry(entryId)
|
||||
ElMessage.success('已删除')
|
||||
emit('deleted')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '删除失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="entry-list">
|
||||
<div
|
||||
v-for="(entry, index) in entries"
|
||||
:key="entry.id"
|
||||
:class="['entry', { 'entry--bordered': index > 0 }]"
|
||||
>
|
||||
<!-- 顶部:举报人 + 时间 + 删除 -->
|
||||
<div class="entry__header">
|
||||
<div class="entry__reporter">
|
||||
<div class="entry__avatar">
|
||||
{{ reporterName(entry).charAt(0) }}
|
||||
</div>
|
||||
<span class="entry__name">{{ reporterName(entry) }}</span>
|
||||
</div>
|
||||
<div class="entry__header-right">
|
||||
<span class="entry__time">{{ formatTime(entry.created) }}</span>
|
||||
<el-popconfirm
|
||||
v-if="canDelete(entry)"
|
||||
title="确定删除这条黑名单记录?"
|
||||
confirm-button-text="删除"
|
||||
cancel-button-text="取消"
|
||||
@confirm="handleDelete(entry.id)"
|
||||
>
|
||||
<template #reference>
|
||||
<button class="entry__delete-btn" title="删除">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="entry__delete-icon">
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签行 -->
|
||||
<div class="entry__tags">
|
||||
<span class="entry__reason-tag">
|
||||
{{ BlacklistReasonMap[entry.reason] }}
|
||||
</span>
|
||||
<span class="entry__severity" :class="severityClass(entry.severity)">
|
||||
{{ BlacklistSeverityMap[entry.severity] }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 描述 -->
|
||||
<div class="entry__desc">
|
||||
{{ entry.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.entry-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.entry {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.entry--bordered {
|
||||
border-top: 1px solid var(--gg-border);
|
||||
}
|
||||
|
||||
/* 顶部行 */
|
||||
.entry__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.entry__reporter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.entry__avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--gg-primary);
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.entry__name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.entry__header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.entry__time {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.entry__delete-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--gg-text-muted);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.entry__delete-btn:hover {
|
||||
color: var(--gg-danger);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
|
||||
.entry__delete-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* 标签行 */
|
||||
.entry__tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.entry__reason-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: var(--gg-warning);
|
||||
}
|
||||
|
||||
.entry__severity {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.entry__severity--mild {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--gg-success);
|
||||
}
|
||||
|
||||
.entry__severity--medium {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: var(--gg-warning);
|
||||
}
|
||||
|
||||
.entry__severity--severe {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
/* 描述 */
|
||||
.entry__desc {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,220 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { BlacklistReasonMap, BlacklistSeverityMap } from '@/types'
|
||||
import type { BlacklistEntry, BlacklistSeverity } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
gameName: string
|
||||
entries: BlacklistEntry[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
|
||||
// 被标记次数
|
||||
const reportCount = computed(() => props.entries.length)
|
||||
|
||||
// 最新一条记录
|
||||
const latestEntry = computed(() => props.entries[0])
|
||||
|
||||
// 最近标记原因
|
||||
const latestReason = computed(() => {
|
||||
const entry = latestEntry.value
|
||||
if (!entry) return ''
|
||||
return BlacklistReasonMap[entry.reason]
|
||||
})
|
||||
|
||||
// 严重程度排序:severe > medium > mild
|
||||
const severityOrder: Record<BlacklistSeverity, number> = {
|
||||
mild: 1,
|
||||
medium: 2,
|
||||
severe: 3,
|
||||
}
|
||||
|
||||
// 严重程度最高的一条
|
||||
const maxSeverity = computed(() => {
|
||||
let max: BlacklistSeverity = 'mild'
|
||||
for (const entry of props.entries) {
|
||||
if (severityOrder[entry.severity] > severityOrder[max]) {
|
||||
max = entry.severity
|
||||
}
|
||||
}
|
||||
return max
|
||||
})
|
||||
|
||||
const maxSeverityLabel = computed(() => BlacklistSeverityMap[maxSeverity.value])
|
||||
|
||||
// 严重程度对应的 CSS class
|
||||
const severityClass = computed(() => `game-card__severity--${maxSeverity.value}`)
|
||||
|
||||
// 第一条记录描述预览
|
||||
const previewDesc = computed(() => {
|
||||
const entry = latestEntry.value
|
||||
if (!entry) return ''
|
||||
return entry.description
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="game-card" @click="emit('click')">
|
||||
<!-- 左侧:游戏信息 -->
|
||||
<div class="game-card__info">
|
||||
<div class="game-card__name-row">
|
||||
<span class="game-card__name">{{ gameName }}</span>
|
||||
<span class="game-card__badge">{{ reportCount }} 次标记</span>
|
||||
</div>
|
||||
|
||||
<!-- 标签行 -->
|
||||
<div class="game-card__tags">
|
||||
<span class="game-card__reason-tag">
|
||||
{{ latestReason }}
|
||||
</span>
|
||||
<span class="game-card__severity" :class="severityClass">
|
||||
{{ maxSeverityLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 描述预览 -->
|
||||
<div v-if="previewDesc" class="game-card__desc">
|
||||
{{ previewDesc }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:展开指示 -->
|
||||
<div class="game-card__arrow">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="game-card__arrow-icon">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.game-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
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;
|
||||
}
|
||||
|
||||
.game-card:hover {
|
||||
border-color: var(--gg-primary-light);
|
||||
box-shadow: 0 0 20px rgba(5, 150, 105, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 左侧信息 */
|
||||
.game-card__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 游戏名 + 次数 */
|
||||
.game-card__name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.game-card__name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.game-card__badge {
|
||||
flex-shrink: 0;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
/* 标签行 */
|
||||
.game-card__tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.game-card__reason-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: var(--gg-warning);
|
||||
}
|
||||
|
||||
.game-card__severity {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.game-card__severity--mild {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--gg-success);
|
||||
}
|
||||
|
||||
.game-card__severity--medium {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: var(--gg-warning);
|
||||
}
|
||||
|
||||
.game-card__severity--severe {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
/* 描述预览 */
|
||||
.game-card__desc {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-secondary);
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
/* 展开箭头 */
|
||||
.game-card__arrow {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.game-card__arrow-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--gg-text-muted);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.game-card:hover .game-card__arrow-icon {
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,344 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { listBlacklist, subscribeBlacklist } from '@/api/gameBlacklist'
|
||||
import BlacklistGameCard from './BlacklistGameCard.vue'
|
||||
import BlacklistEntryList from './BlacklistEntryList.vue'
|
||||
import CreateBlacklistDialog from './CreateBlacklistDialog.vue'
|
||||
import { BlacklistReasonMap, BlacklistSeverityMap } from '@/types'
|
||||
import type { BlacklistEntry, BlacklistReason, BlacklistSeverity } from '@/types'
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const allEntries = ref<BlacklistEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const showCreate = ref(false)
|
||||
const expandedGame = ref<string | null>(null)
|
||||
|
||||
// 筛选条件
|
||||
const filterReason = ref<BlacklistReason | ''>('')
|
||||
const filterSeverity = ref<BlacklistSeverity | ''>('')
|
||||
|
||||
let unsubscribeFn: (() => void) | null = null
|
||||
|
||||
// 按游戏名聚合
|
||||
const groupedByGame = computed(() => {
|
||||
const map = new Map<string, BlacklistEntry[]>()
|
||||
for (const entry of allEntries.value) {
|
||||
const key = entry.gameName
|
||||
if (!map.has(key)) {
|
||||
map.set(key, [])
|
||||
}
|
||||
map.get(key)!.push(entry)
|
||||
}
|
||||
// 每个游戏内的条目按时间倒序
|
||||
for (const entries of map.values()) {
|
||||
entries.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime())
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
// 游戏列表(按被标记次数降序)
|
||||
const gameGroups = computed(() => {
|
||||
const groups: { gameName: string; entries: BlacklistEntry[] }[] = []
|
||||
for (const [gameName, entries] of groupedByGame.value) {
|
||||
groups.push({ gameName, entries })
|
||||
}
|
||||
groups.sort((a, b) => b.entries.length - a.entries.length)
|
||||
return groups
|
||||
})
|
||||
|
||||
async function loadEntries() {
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const options: { reason?: string; severity?: string } = {}
|
||||
if (filterReason.value) options.reason = filterReason.value
|
||||
if (filterSeverity.value) options.severity = filterSeverity.value
|
||||
|
||||
allEntries.value = await listBlacklist(groupId, options)
|
||||
} catch (error) {
|
||||
console.error('加载黑名单失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 实时订阅
|
||||
async function startSubscription() {
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) return
|
||||
|
||||
unsubscribeFn = await subscribeBlacklist(groupId, () => {
|
||||
loadEntries()
|
||||
})
|
||||
}
|
||||
|
||||
function stopSubscription() {
|
||||
if (unsubscribeFn) {
|
||||
unsubscribeFn()
|
||||
unsubscribeFn = null
|
||||
}
|
||||
}
|
||||
|
||||
// 展开/收起某个游戏的详细记录
|
||||
function toggleGame(gameName: string) {
|
||||
expandedGame.value = expandedGame.value === gameName ? null : gameName
|
||||
}
|
||||
|
||||
function handleCreated() {
|
||||
showCreate.value = false
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (groupStore.currentGroupId) {
|
||||
loadEntries()
|
||||
startSubscription()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => groupStore.currentGroupId, (newId, oldId) => {
|
||||
if (newId && newId !== oldId) {
|
||||
expandedGame.value = null
|
||||
stopSubscription()
|
||||
loadEntries()
|
||||
startSubscription()
|
||||
}
|
||||
})
|
||||
|
||||
// 筛选条件变化时重新加载
|
||||
watch([filterReason, filterSeverity], () => {
|
||||
loadEntries()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopSubscription()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="blacklist-main">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="blacklist-main__header">
|
||||
<h3 class="blacklist-main__title">游戏黑名单</h3>
|
||||
<div class="blacklist-main__actions">
|
||||
<div class="blacklist-main__filters">
|
||||
<el-select
|
||||
v-model="filterReason"
|
||||
placeholder="原因筛选"
|
||||
clearable
|
||||
size="small"
|
||||
class="blacklist-main__filter-select"
|
||||
>
|
||||
<el-option
|
||||
v-for="(label, key) in BlacklistReasonMap"
|
||||
:key="key"
|
||||
:label="label"
|
||||
:value="key"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="filterSeverity"
|
||||
placeholder="严重程度"
|
||||
clearable
|
||||
size="small"
|
||||
class="blacklist-main__filter-select"
|
||||
>
|
||||
<el-option
|
||||
v-for="(label, key) in BlacklistSeverityMap"
|
||||
:key="key"
|
||||
:label="label"
|
||||
:value="key"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<button class="blacklist-main__create-btn" @click="showCreate = true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="blacklist-main__create-icon">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
标记游戏
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CreateBlacklistDialog
|
||||
v-model="showCreate"
|
||||
@created="handleCreated"
|
||||
/>
|
||||
|
||||
<!-- 游戏卡片列表 -->
|
||||
<div v-if="gameGroups.length > 0" class="blacklist-main__content">
|
||||
<div v-for="group in gameGroups" :key="group.gameName" class="blacklist-main__game-group">
|
||||
<BlacklistGameCard
|
||||
:game-name="group.gameName"
|
||||
:entries="group.entries"
|
||||
@click="toggleGame(group.gameName)"
|
||||
/>
|
||||
<!-- 展开的详细记录 -->
|
||||
<div v-if="expandedGame === group.gameName" class="blacklist-main__expanded">
|
||||
<BlacklistEntryList :entries="group.entries" @deleted="loadEntries" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-else-if="loading" class="blacklist-main__loading">
|
||||
<p class="blacklist-main__loading-text">加载中...</p>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else class="blacklist-main__empty">
|
||||
<svg class="blacklist-main__empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
|
||||
</svg>
|
||||
<p class="blacklist-main__empty-text">暂无黑名单记录</p>
|
||||
<p class="blacklist-main__empty-hint">标记体验差的游戏,提醒队友避开</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.blacklist-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* 顶部操作栏 */
|
||||
.blacklist-main__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.blacklist-main__title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--gg-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.blacklist-main__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.blacklist-main__filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.blacklist-main__filter-select {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.blacklist-main__create-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 16px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-primary);
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.blacklist-main__create-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.blacklist-main__create-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* 内容区域 */
|
||||
.blacklist-main__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.blacklist-main__game-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.blacklist-main__expanded {
|
||||
padding: 0 16px;
|
||||
background: var(--gg-bg-elevated);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 var(--gg-radius-md) var(--gg-radius-md);
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.blacklist-main__loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.blacklist-main__loading-text {
|
||||
font-size: 14px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.blacklist-main__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.blacklist-main__empty-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--gg-text-muted);
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.blacklist-main__empty-text {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text-secondary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.blacklist-main__empty-hint {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 640px) {
|
||||
.blacklist-main__header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.blacklist-main__filter-select {
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,205 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { createBlacklistEntry } from '@/api/gameBlacklist'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { BlacklistReasonMap, BlacklistSeverityMap } from '@/types'
|
||||
import type { BlacklistReason, BlacklistSeverity } from '@/types'
|
||||
|
||||
const visible = defineModel<boolean>({ default: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
created: []
|
||||
}>()
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const form = ref({
|
||||
gameName: '',
|
||||
reason: '' as BlacklistReason | '',
|
||||
severity: '' as BlacklistSeverity | '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
gameName: '',
|
||||
reason: '',
|
||||
severity: '',
|
||||
description: '',
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
// 校验必填字段
|
||||
if (!form.value.gameName.trim()) {
|
||||
ElMessage.warning('请输入游戏名称')
|
||||
return
|
||||
}
|
||||
if (!form.value.reason) {
|
||||
ElMessage.warning('请选择原因')
|
||||
return
|
||||
}
|
||||
if (!form.value.severity) {
|
||||
ElMessage.warning('请选择严重程度')
|
||||
return
|
||||
}
|
||||
if (!form.value.description.trim()) {
|
||||
ElMessage.warning('请填写描述')
|
||||
return
|
||||
}
|
||||
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) {
|
||||
ElMessage.error('请先选择群组')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await createBlacklistEntry({
|
||||
group: groupId,
|
||||
gameName: form.value.gameName.trim(),
|
||||
reason: form.value.reason,
|
||||
severity: form.value.severity,
|
||||
description: form.value.description.trim(),
|
||||
})
|
||||
|
||||
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="460px"
|
||||
@open="handleOpen"
|
||||
>
|
||||
<div class="create-form">
|
||||
<!-- 游戏名称 -->
|
||||
<div class="form-field">
|
||||
<label>游戏名称 <span class="required">*</span></label>
|
||||
<el-input
|
||||
v-model="form.gameName"
|
||||
placeholder="输入游戏名称"
|
||||
maxlength="100"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 原因选择 -->
|
||||
<div class="form-field">
|
||||
<label>原因 <span class="required">*</span></label>
|
||||
<el-select
|
||||
v-model="form.reason"
|
||||
placeholder="选择原因"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="(label, key) in BlacklistReasonMap"
|
||||
:key="key"
|
||||
:label="label"
|
||||
:value="key"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 严重程度 -->
|
||||
<div class="form-field">
|
||||
<label>严重程度 <span class="required">*</span></label>
|
||||
<el-select
|
||||
v-model="form.severity"
|
||||
placeholder="选择严重程度"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="(label, key) in BlacklistSeverityMap"
|
||||
:key="key"
|
||||
:label="label"
|
||||
:value="key"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 描述 -->
|
||||
<div class="form-field">
|
||||
<label>描述 <span class="required">*</span></label>
|
||||
<el-input
|
||||
v-model="form.description"
|
||||
type="textarea"
|
||||
placeholder="描述一下为什么这游戏坑"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<button class="submit-btn" :disabled="loading" @click="handleSubmit">
|
||||
{{ loading ? '提交中...' : '提交' }}
|
||||
</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);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-primary);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -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',
|
||||
|
||||
@@ -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<BlacklistReason, string> = {
|
||||
behavior: '队友坑',
|
||||
cheating: '外挂多',
|
||||
abandonment: '坑货多',
|
||||
toxic: '环境差',
|
||||
other: '其他'
|
||||
}
|
||||
|
||||
// 黑名单严重程度
|
||||
export type BlacklistSeverity = 'mild' | 'medium' | 'severe'
|
||||
|
||||
export const BlacklistSeverityMap: Record<BlacklistSeverity, string> = {
|
||||
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 || '未知'
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<!-- src/views/BlacklistView.vue -->
|
||||
<script setup lang="ts">
|
||||
import { onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import BlacklistMain from '@/components/gameBlacklist/BlacklistMain.vue'
|
||||
import { ArrowLeft } from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
const groupStore = useGroupStore()
|
||||
const groupId = route.params.groupId as string
|
||||
|
||||
const group = computed(() => groupStore.currentGroup)
|
||||
|
||||
onMounted(async () => {
|
||||
await groupStore.setCurrentGroup(groupId)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="blacklist-view">
|
||||
<!-- 页面头部 -->
|
||||
<section class="page-header">
|
||||
<router-link :to="`/group/${groupId}`" class="back-link">
|
||||
<el-icon><ArrowLeft /></el-icon> 返回群组
|
||||
</router-link>
|
||||
<div class="header-content">
|
||||
<h1 class="page-title">游戏黑名单</h1>
|
||||
<span class="group-badge">{{ group?.name }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 黑名单内容 -->
|
||||
<BlacklistMain />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.blacklist-view {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 20px 24px;
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-lg);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: var(--gg-gradient);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--gg-text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.group-badge {
|
||||
font-size: 13px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
background: rgba(5, 150, 105, 0.15);
|
||||
color: var(--gg-primary-light);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -10,6 +10,27 @@ interface LogEntry {
|
||||
}
|
||||
|
||||
const logs = ref<LogEntry[]>([
|
||||
{
|
||||
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',
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
const viewingBetId = ref<string | null>(null)
|
||||
|
||||
const unsubFns: (() => Promise<void>)[] = []
|
||||
let settleTimer: ReturnType<typeof setInterval> | null = null
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let betTimer: ReturnType<typeof setInterval> | 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)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -162,6 +197,10 @@ async function checkExpiredPolls() {
|
||||
<router-link :to="`/group/${groupId}/assets`" class="meta-link">
|
||||
<el-icon><Box /></el-icon> 资产
|
||||
</router-link>
|
||||
<span class="meta-divider">|</span>
|
||||
<router-link :to="`/group/${groupId}/blacklist`" class="meta-link">
|
||||
<el-icon><Warning /></el-icon> 黑名单
|
||||
</router-link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -251,6 +290,14 @@ async function checkExpiredPolls() {
|
||||
</template>
|
||||
<GroupStatsPanel />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane name="bets">
|
||||
<template #label>
|
||||
<span class="fancy-tab"><el-icon><Trophy /></el-icon> 竞猜</span>
|
||||
</template>
|
||||
<BetDetail v-if="viewingBetId" :bet-id="viewingBetId" @back="viewingBetId = null" />
|
||||
<BetList v-else @view-bet="viewingBetId = $event" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user