feat(web): make avatar names in event log clickable (#41)

When avatars overlap (e.g., during sparring, talking, dual cultivation),
it's hard to click on them directly. This adds the ability to click on
colored avatar names in the event panel to open their detail view.

- Modify highlightAvatarNames to include data-avatar-id attribute
- Add click event delegation in EventPanel
- Add hover styles for clickable names
This commit is contained in:
Zihao Xu
2026-01-19 04:45:44 -08:00
committed by GitHub
parent 2e04b718e8
commit 1a34b7724b
2 changed files with 39 additions and 8 deletions

View File

@@ -1,12 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, nextTick, onMounted, h } from 'vue' import { computed, ref, watch, nextTick, onMounted, h } from 'vue'
import { useWorldStore } from '../../stores/world' import { useWorldStore } from '../../stores/world'
import { useUiStore } from '../../stores/ui'
import { NSelect, NSpin, NButton } from 'naive-ui' import { NSelect, NSpin, NButton } from 'naive-ui'
import type { SelectOption } from 'naive-ui' import type { SelectOption } from 'naive-ui'
import { highlightAvatarNames, buildAvatarColorMap, avatarIdToColor } from '../../utils/eventHelper' import { highlightAvatarNames, buildAvatarColorMap, avatarIdToColor } from '../../utils/eventHelper'
import type { GameEvent } from '../../types/core' import type { GameEvent } from '../../types/core'
const worldStore = useWorldStore() const worldStore = useWorldStore()
const uiStore = useUiStore()
const filterValue1 = ref('all') const filterValue1 = ref('all')
const filterValue2 = ref<string | null>(null) // null 表示未启用双人筛选 const filterValue2 = ref<string | null>(null) // null 表示未启用双人筛选
const eventListRef = ref<HTMLElement | null>(null) const eventListRef = ref<HTMLElement | null>(null)
@@ -164,6 +166,15 @@ function renderEventContent(event: GameEvent): string {
const text = event.content || event.text || '' const text = event.content || event.text || ''
return highlightAvatarNames(text, avatarColorMap.value) return highlightAvatarNames(text, avatarColorMap.value)
} }
// 处理事件列表中的点击,使用事件委托检测角色名点击。
function handleEventListClick(e: MouseEvent) {
const target = e.target as HTMLElement
const avatarId = target.dataset?.avatarId
if (avatarId) {
uiStore.select('avatar', avatarId)
}
}
</script> </script>
<template> <template>
@@ -208,7 +219,7 @@ function renderEventContent(event: GameEvent): string {
<span>加载中...</span> <span>加载中...</span>
</div> </div>
<div v-else-if="displayEvents.length === 0" class="empty">{{ emptyEventMessage }}</div> <div v-else-if="displayEvents.length === 0" class="empty">{{ emptyEventMessage }}</div>
<div v-else class="event-list" ref="eventListRef" @scroll="handleScroll"> <div v-else class="event-list" ref="eventListRef" @scroll="handleScroll" @click="handleEventListClick">
<!-- 顶部加载指示器 --> <!-- 顶部加载指示器 -->
<div v-if="worldStore.eventsHasMore" class="load-more-hint"> <div v-if="worldStore.eventsHasMore" class="load-more-hint">
<span v-if="worldStore.eventsLoading">加载中...</span> <span v-if="worldStore.eventsLoading">加载中...</span>
@@ -328,4 +339,15 @@ function renderEventContent(event: GameEvent): string {
font-size: 11px; font-size: 11px;
border-bottom: 1px solid #2a2a2a; border-bottom: 1px solid #2a2a2a;
} }
/* 可点击的角色名样式 */
.event-content :deep(.clickable-avatar) {
cursor: pointer;
transition: opacity 0.15s;
}
.event-content :deep(.clickable-avatar:hover) {
opacity: 0.8;
text-decoration: underline;
}
</style> </style>

View File

@@ -90,16 +90,21 @@ export function avatarIdToColor(id: string): string {
return `hsl(${hue}, 70%, 65%)`; return `hsl(${hue}, 70%, 65%)`;
} }
export interface AvatarColorInfo {
id: string;
color: string;
}
/** /**
* 根据角色列表构建 name -> color 映射表。 * 根据角色列表构建 name -> { id, color } 映射表。
*/ */
export function buildAvatarColorMap( export function buildAvatarColorMap(
avatars: Array<{ id: string; name?: string }> avatars: Array<{ id: string; name?: string }>
): Map<string, string> { ): Map<string, AvatarColorInfo> {
const map = new Map<string, string>(); const map = new Map<string, AvatarColorInfo>();
for (const av of avatars) { for (const av of avatars) {
if (av.name) { if (av.name) {
map.set(av.name, avatarIdToColor(av.id)); map.set(av.name, { id: av.id, color: avatarIdToColor(av.id) });
} }
} }
return map; return map;
@@ -115,10 +120,11 @@ const HTML_ESCAPE_MAP: Record<string, string> = {
/** /**
* 高亮文本中的角色名,返回 HTML 字符串。 * 高亮文本中的角色名,返回 HTML 字符串。
* 生成的 span 带有 data-avatar-id 属性,可用于点击跳转。
*/ */
export function highlightAvatarNames( export function highlightAvatarNames(
text: string, text: string,
colorMap: Map<string, string> colorMap: Map<string, AvatarColorInfo>
): string { ): string {
if (!text || colorMap.size === 0) return text; if (!text || colorMap.size === 0) return text;
@@ -127,9 +133,12 @@ export function highlightAvatarNames(
let result = text; let result = text;
for (const name of names) { for (const name of names) {
const color = colorMap.get(name)!; const info = colorMap.get(name)!;
const escaped = name.replace(/[&<>"']/g, c => HTML_ESCAPE_MAP[c] || c); const escaped = name.replace(/[&<>"']/g, c => HTML_ESCAPE_MAP[c] || c);
result = result.replaceAll(name, `<span style="color:${color}">${escaped}</span>`); result = result.replaceAll(
name,
`<span class="clickable-avatar" data-avatar-id="${info.id}" style="color:${info.color};cursor:pointer">${escaped}</span>`
);
} }
return result; return result;
} }