feat: 玩家黑名单 - 记录外部平台坑玩家

- 新增 player_blacklist collection 迁移
- 添加 PlayerTag/PlayerBlacklistEntry 类型定义和 API
- 创建 PlayerBlacklistMain + CreatePlayerBlacklistDialog 组件
- BlacklistView 支持 Tab 切换游戏/玩家黑名单
- 支持搜索、标签筛选、严重程度筛选、实时订阅

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
congsh
2026-04-19 13:03:55 +08:00
parent 60ad9a04cd
commit d528358867
6 changed files with 1202 additions and 4 deletions
+59
View File
@@ -0,0 +1,59 @@
import { pb } from './pocketbase'
import type { PlayerBlacklistEntry } from '@/types'
export async function createPlayerBlacklistEntry(data: {
group: string
playerId: string
platform: string
tags: string[]
customTag?: string
description: string
severity: string
}): Promise<PlayerBlacklistEntry> {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
const payload: Record<string, any> = {
group: data.group,
reporter: user.id,
playerId: data.playerId,
platform: data.platform,
tags: data.tags,
description: data.description,
severity: data.severity,
}
if (data.customTag) payload.customTag = data.customTag
const record = await pb.collection('player_blacklist').create(payload)
return record as unknown as PlayerBlacklistEntry
}
export async function listPlayerBlacklist(
groupId: string,
options?: { tag?: string; severity?: string }
): Promise<PlayerBlacklistEntry[]> {
let filter = `group="${groupId}"`
if (options?.tag) filter += ` && tags~"${options.tag}"`
if (options?.severity) filter += ` && severity="${options.severity}"`
const result = await pb.collection('player_blacklist').getFullList({
filter,
sort: '-created',
expand: 'reporter',
$autoCancel: false,
})
return result as unknown as PlayerBlacklistEntry[]
}
export async function deletePlayerBlacklistEntry(entryId: string): Promise<void> {
await pb.collection('player_blacklist').delete(entryId)
}
export async function subscribePlayerBlacklist(
groupId: string,
callback: (data: any) => void
): Promise<() => void> {
return pb.collection('player_blacklist').subscribe('*', (data) => {
if (data.record?.group === groupId) callback(data)
})
}
@@ -0,0 +1,220 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { createPlayerBlacklistEntry } from '@/api/playerBlacklist'
import { useGroupStore } from '@/stores/group'
import { PlayerTagMap, BlacklistSeverityMap } from '@/types'
import type { PlayerTag, BlacklistSeverity } from '@/types'
const visible = defineModel<boolean>({ default: false })
const emit = defineEmits<{
created: []
}>()
const groupStore = useGroupStore()
const form = ref({
playerId: '',
platform: '',
tags: [] as PlayerTag[],
customTag: '',
severity: '' as BlacklistSeverity | '',
description: '',
})
const loading = ref(false)
function resetForm() {
form.value = {
playerId: '',
platform: '',
tags: [],
customTag: '',
severity: '',
description: '',
}
}
async function handleSubmit() {
if (!form.value.playerId.trim()) {
ElMessage.warning('请输入玩家ID')
return
}
if (!form.value.platform.trim()) {
ElMessage.warning('请输入游戏/平台')
return
}
if (form.value.tags.length === 0) {
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 createPlayerBlacklistEntry({
group: groupId,
playerId: form.value.playerId.trim(),
platform: form.value.platform.trim(),
tags: form.value.tags,
customTag: form.value.customTag.trim() || undefined,
description: form.value.description.trim(),
severity: form.value.severity,
})
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>玩家ID <span class="required">*</span></label>
<el-input
v-model="form.playerId"
placeholder="输入玩家的游戏内ID或昵称"
maxlength="200"
show-word-limit
/>
</div>
<div class="form-field">
<label>游戏/平台 <span class="required">*</span></label>
<el-input
v-model="form.platform"
placeholder="如:英雄联盟、Steam、绝地求生"
maxlength="100"
/>
</div>
<div class="form-field">
<label>标签 <span class="required">*</span></label>
<el-checkbox-group v-model="form.tags">
<el-checkbox
v-for="(label, key) in PlayerTagMap"
:key="key"
:value="key"
:label="label"
/>
</el-checkbox-group>
</div>
<div class="form-field">
<label>自定义标签</label>
<el-input
v-model="form.customTag"
placeholder="补充标签(选填)"
maxlength="50"
/>
</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>
@@ -0,0 +1,694 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import { useGroupStore } from '@/stores/group'
import { listPlayerBlacklist, subscribePlayerBlacklist } from '@/api/playerBlacklist'
import { deletePlayerBlacklistEntry } from '@/api/playerBlacklist'
import { ElMessage, ElPopconfirm } from 'element-plus'
import { pb } from '@/api/pocketbase'
import { displayName } from '@/types'
import { PlayerTagMap, BlacklistSeverityMap } from '@/types'
import type { PlayerBlacklistEntry, PlayerTag, BlacklistSeverity } from '@/types'
import CreatePlayerBlacklistDialog from './CreatePlayerBlacklistDialog.vue'
const groupStore = useGroupStore()
const allEntries = ref<PlayerBlacklistEntry[]>([])
const loading = ref(false)
const showCreate = ref(false)
// 筛选
const filterTag = ref<PlayerTag | ''>('')
const filterSeverity = ref<BlacklistSeverity | ''>('')
// 搜索
const searchPlayerId = ref('')
let unsubscribeFn: (() => void) | null = null
// 按玩家ID聚合
const groupedByPlayer = computed(() => {
let filtered = allEntries.value
if (searchPlayerId.value.trim()) {
const q = searchPlayerId.value.trim().toLowerCase()
filtered = filtered.filter(e => e.playerId.toLowerCase().includes(q))
}
const map = new Map<string, PlayerBlacklistEntry[]>()
for (const entry of filtered) {
const key = `${entry.playerId}|${entry.platform}`
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 playerGroups = computed(() => {
const groups: { playerId: string; platform: string; entries: PlayerBlacklistEntry[] }[] = []
for (const [key, entries] of groupedByPlayer.value) {
const [playerId, platform] = key.split('|')
groups.push({ playerId, platform, entries })
}
groups.sort((a, b) => b.entries.length - a.entries.length)
return groups
})
// 展开的玩家
const expandedPlayer = ref<string | null>(null)
async function loadEntries() {
const groupId = groupStore.currentGroupId
if (!groupId) return
loading.value = true
try {
const options: { tag?: string; severity?: string } = {}
if (filterTag.value) options.tag = filterTag.value
if (filterSeverity.value) options.severity = filterSeverity.value
allEntries.value = await listPlayerBlacklist(groupId, options)
} catch (error) {
console.error('加载玩家黑名单失败:', error)
} finally {
loading.value = false
}
}
async function startSubscription() {
const groupId = groupStore.currentGroupId
if (!groupId) return
unsubscribeFn = await subscribePlayerBlacklist(groupId, () => loadEntries())
}
function stopSubscription() {
if (unsubscribeFn) {
unsubscribeFn()
unsubscribeFn = null
}
}
function togglePlayer(key: string) {
expandedPlayer.value = expandedPlayer.value === key ? null : key
}
function handleCreated() {
showCreate.value = false
loadEntries()
}
const currentUserId = computed(() => pb.authStore.model?.id)
const isOwner = computed(() => groupStore.isGroupOwner)
function canDelete(entry: PlayerBlacklistEntry): boolean {
return entry.reporter === currentUserId.value || isOwner.value
}
async function handleDelete(entryId: string) {
try {
await deletePlayerBlacklistEntry(entryId)
ElMessage.success('已删除')
loadEntries()
} catch (error: any) {
ElMessage.error(error.message || '删除失败')
}
}
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()}`
if (diffDays > 0) return `${diffDays}天前`
if (diffHours > 0) return `${diffHours}小时前`
if (diffMinutes > 0) return `${diffMinutes}分钟前`
return '刚刚'
}
function reporterName(entry: PlayerBlacklistEntry): string {
return entry.expand?.reporter ? displayName(entry.expand.reporter) : '未知用户'
}
function severityClass(severity: BlacklistSeverity): string {
return `severity--${severity}`
}
onMounted(() => {
if (groupStore.currentGroupId) {
loadEntries()
startSubscription()
}
})
watch(() => groupStore.currentGroupId, (newId, oldId) => {
if (newId && newId !== oldId) {
expandedPlayer.value = null
stopSubscription()
loadEntries()
startSubscription()
}
})
watch([filterTag, filterSeverity], () => loadEntries())
onUnmounted(() => stopSubscription())
</script>
<template>
<div class="player-blacklist-main">
<div class="player-blacklist-main__header">
<h3 class="player-blacklist-main__title">玩家黑名单</h3>
<div class="player-blacklist-main__actions">
<div class="player-blacklist-main__filters">
<el-input
v-model="searchPlayerId"
placeholder="搜索玩家ID"
clearable
size="small"
class="player-blacklist-main__search"
/>
<el-select
v-model="filterTag"
placeholder="标签筛选"
clearable
size="small"
class="player-blacklist-main__filter-select"
>
<el-option
v-for="(label, key) in PlayerTagMap"
:key="key"
:label="label"
:value="key"
/>
</el-select>
<el-select
v-model="filterSeverity"
placeholder="严重程度"
clearable
size="small"
class="player-blacklist-main__filter-select"
>
<el-option
v-for="(label, key) in BlacklistSeverityMap"
:key="key"
:label="label"
:value="key"
/>
</el-select>
</div>
<button class="player-blacklist-main__create-btn" @click="showCreate = true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="player-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>
<CreatePlayerBlacklistDialog v-model="showCreate" @created="handleCreated" />
<!-- 玩家卡片列表 -->
<div v-if="playerGroups.length > 0" class="player-blacklist-main__content">
<div v-for="group in playerGroups" :key="group.playerId + group.platform" class="player-blacklist-main__player-group">
<!-- 玩家卡片 -->
<div class="player-card" @click="togglePlayer(group.playerId + group.platform)">
<div class="player-card__info">
<div class="player-card__name-row">
<span class="player-card__name">{{ group.playerId }}</span>
<span class="player-card__platform">{{ group.platform }}</span>
<span class="player-card__badge">{{ group.entries.length }} 次标记</span>
</div>
<div class="player-card__tags">
<span
v-for="tag in [...new Set(group.entries.flatMap(e => e.tags))].slice(0, 4)"
:key="tag"
class="player-card__tag"
>
{{ PlayerTagMap[tag] }}
</span>
<span
v-for="entry in [...new Set(group.entries.map(e => e.customTag).filter(Boolean))].slice(0, 2)"
:key="entry"
class="player-card__tag player-card__tag--custom"
>
{{ entry }}
</span>
</div>
<div v-if="group.entries[0]?.description" class="player-card__desc">
{{ group.entries[0].description }}
</div>
</div>
<div class="player-card__arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="player-card__arrow-icon">
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
</div>
<!-- 展开的详细记录 -->
<div v-if="expandedPlayer === group.playerId + group.platform" class="player-blacklist-main__expanded">
<div
v-for="(entry, index) in group.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="删除" @click.stop>
<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 v-for="tag in entry.tags" :key="tag" class="entry__tag">{{ PlayerTagMap[tag] }}</span>
<span v-if="entry.customTag" class="entry__tag entry__tag--custom">{{ entry.customTag }}</span>
<span class="entry__severity" :class="severityClass(entry.severity)">{{ BlacklistSeverityMap[entry.severity] }}</span>
</div>
<div class="entry__desc">{{ entry.description }}</div>
</div>
</div>
</div>
</div>
<div v-else-if="loading" class="player-blacklist-main__loading">
<p class="player-blacklist-main__loading-text">加载中...</p>
</div>
<div v-else class="player-blacklist-main__empty">
<svg class="player-blacklist-main__empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<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>
<p class="player-blacklist-main__empty-text">暂无玩家黑名单记录</p>
<p class="player-blacklist-main__empty-hint">标记坑人的玩家下次遇到有准备</p>
</div>
</div>
</template>
<style scoped>
.player-blacklist-main {
display: flex;
flex-direction: column;
gap: 20px;
}
.player-blacklist-main__header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
}
.player-blacklist-main__title {
font-size: 18px;
font-weight: 700;
color: var(--gg-text);
margin: 0;
}
.player-blacklist-main__actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.player-blacklist-main__filters {
display: flex;
align-items: center;
gap: 8px;
}
.player-blacklist-main__search {
width: 140px;
}
.player-blacklist-main__filter-select {
width: 110px;
}
.player-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;
}
.player-blacklist-main__create-btn:hover {
opacity: 0.85;
}
.player-blacklist-main__create-icon {
width: 16px;
height: 16px;
}
.player-blacklist-main__content {
display: flex;
flex-direction: column;
gap: 12px;
}
.player-blacklist-main__player-group {
display: flex;
flex-direction: column;
}
.player-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);
}
/* 玩家卡片 */
.player-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;
}
.player-card:hover {
border-color: var(--gg-primary-light);
box-shadow: 0 0 20px rgba(5, 150, 105, 0.1);
transform: translateY(-1px);
}
.player-card__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.player-card__name-row {
display: flex;
align-items: center;
gap: 10px;
}
.player-card__name {
font-size: 15px;
font-weight: 600;
color: var(--gg-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.player-card__platform {
flex-shrink: 0;
padding: 1px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
background: rgba(99, 102, 241, 0.1);
color: #6366f1;
}
.player-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);
}
.player-card__tags {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.player-card__tag {
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);
}
.player-card__tag--custom {
background: rgba(99, 102, 241, 0.1);
color: #6366f1;
}
.player-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;
}
.player-card__arrow {
flex-shrink: 0;
display: flex;
align-items: center;
}
.player-card__arrow-icon {
width: 18px;
height: 18px;
color: var(--gg-text-muted);
transition: transform 0.2s;
}
.player-card:hover .player-card__arrow-icon {
color: var(--gg-primary);
}
/* 记录条目 */
.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;
flex-wrap: wrap;
}
.entry__tag {
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__tag--custom {
background: rgba(99, 102, 241, 0.1);
color: #6366f1;
}
.entry__severity {
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
line-height: 18px;
}
.severity--mild {
background: rgba(16, 185, 129, 0.1);
color: var(--gg-success);
}
.severity--medium {
background: rgba(245, 158, 11, 0.1);
color: var(--gg-warning);
}
.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;
}
/* 加载/空状态 */
.player-blacklist-main__loading {
display: flex;
justify-content: center;
padding: 40px 20px;
}
.player-blacklist-main__loading-text {
font-size: 14px;
color: var(--gg-text-muted);
}
.player-blacklist-main__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 20px;
text-align: center;
}
.player-blacklist-main__empty-icon {
width: 48px;
height: 48px;
color: var(--gg-text-muted);
margin-bottom: 12px;
opacity: 0.5;
}
.player-blacklist-main__empty-text {
font-size: 15px;
font-weight: 600;
color: var(--gg-text-secondary);
margin: 0 0 4px;
}
.player-blacklist-main__empty-hint {
font-size: 13px;
color: var(--gg-text-muted);
margin: 0;
}
@media (max-width: 640px) {
.player-blacklist-main__header {
flex-direction: column;
align-items: flex-start;
}
.player-blacklist-main__search {
width: 100px;
}
.player-blacklist-main__filter-select {
width: 90px;
}
}
</style>
+33
View File
@@ -345,6 +345,20 @@ export const BlacklistReasonMap: Record<BlacklistReason, string> = {
other: '其他'
}
// 玩家黑名单标签
export type PlayerTag = 'afk' | 'feeder' | 'toxic' | 'cheater' | 'quitter' | 'noob' | 'fragile' | 'other'
export const PlayerTagMap: Record<PlayerTag, string> = {
afk: '挂机',
feeder: '送人头',
toxic: '喷人',
cheater: '外挂',
quitter: '始乱终弃',
noob: '坑货',
fragile: '玻璃心',
other: '其他'
}
// 黑名单严重程度
export type BlacklistSeverity = 'mild' | 'medium' | 'severe'
@@ -373,6 +387,25 @@ export interface BlacklistEntry {
}
}
// 玩家黑名单
export interface PlayerBlacklistEntry {
id: string
group: string
reporter: string
playerId: string
platform: string
tags: PlayerTag[]
customTag?: string
description: string
severity: BlacklistSeverity
created: string
updated: string
expand?: {
reporter?: User
group?: Group
}
}
// 竞猜状态
export type BetStatus = 'open' | 'closed' | 'settled'
+72 -4
View File
@@ -1,9 +1,10 @@
<!-- src/views/BlacklistView.vue -->
<script setup lang="ts">
import { onMounted, computed } from 'vue'
import { onMounted, computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useGroupStore } from '@/stores/group'
import BlacklistMain from '@/components/gameBlacklist/BlacklistMain.vue'
import PlayerBlacklistMain from '@/components/playerBlacklist/PlayerBlacklistMain.vue'
import { ArrowLeft } from '@element-plus/icons-vue'
const route = useRoute()
@@ -12,6 +13,8 @@ const groupId = route.params.groupId as string
const group = computed(() => groupStore.currentGroup)
const activeTab = ref<'game' | 'player'>('game')
onMounted(async () => {
await groupStore.setCurrentGroup(groupId)
})
@@ -25,13 +28,39 @@ onMounted(async () => {
<el-icon><ArrowLeft /></el-icon> 返回群组
</router-link>
<div class="header-content">
<h1 class="page-title">游戏黑名单</h1>
<h1 class="page-title">黑名单</h1>
<span class="group-badge">{{ group?.name }}</span>
</div>
</section>
<!-- 黑名单内容 -->
<BlacklistMain />
<!-- Tab 切换 -->
<div class="blacklist-tabs">
<button
:class="['blacklist-tab', { 'blacklist-tab--active': activeTab === 'game' }]"
@click="activeTab = 'game'"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="blacklist-tab__icon">
<rect x="2" y="6" width="20" height="12" rx="2" />
<path d="M6 12h4" />
<path d="M6 9h4" />
</svg>
游戏黑名单
</button>
<button
:class="['blacklist-tab', { 'blacklist-tab--active': activeTab === 'player' }]"
@click="activeTab = 'player'"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="blacklist-tab__icon">
<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>
玩家黑名单
</button>
</div>
<!-- 内容 -->
<BlacklistMain v-if="activeTab === 'game'" />
<PlayerBlacklistMain v-else />
</div>
</template>
@@ -99,4 +128,43 @@ onMounted(async () => {
color: var(--gg-primary-light);
font-weight: 500;
}
/* Tab */
.blacklist-tabs {
display: flex;
gap: 8px;
}
.blacklist-tab {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-md);
background: var(--gg-bg-card);
color: var(--gg-text-secondary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
-webkit-tap-highlight-color: transparent;
}
.blacklist-tab:hover {
border-color: var(--gg-primary-light);
color: var(--gg-primary);
}
.blacklist-tab--active {
border-color: var(--gg-primary);
background: rgba(5, 150, 105, 0.08);
color: var(--gg-primary);
font-weight: 600;
}
.blacklist-tab__icon {
width: 16px;
height: 16px;
}
</style>