Files
gamegroup2/frontend/src/components/playerBlacklist/PlayerBlacklistMain.vue
T
congsh d528358867 feat: 玩家黑名单 - 记录外部平台坑玩家
- 新增 player_blacklist collection 迁移
- 添加 PlayerTag/PlayerBlacklistEntry 类型定义和 API
- 创建 PlayerBlacklistMain + CreatePlayerBlacklistDialog 组件
- BlacklistView 支持 Tab 切换游戏/玩家黑名单
- 支持搜索、标签筛选、严重程度筛选、实时订阅

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 13:03:55 +08:00

695 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>