| <script setup lang="ts"> |
| import { computed, ref } from 'vue' |
| import { ElMessage } from 'element-plus' |
|
|
| const props = defineProps<{ |
| text: string |
| highlights?: Array<{ start: number; end: number; term: string; translation?: string; reasoning?: string }> |
| }>() |
|
|
| const hoveredIndex = ref<number | null>(null) |
| const popupX = ref(0) |
| const popupY = ref(0) |
|
|
| const sortedHighlights = computed(() => { |
| return (props.highlights || []).slice().sort((a, b) => a.start - b.start) |
| }) |
|
|
| function onEnter(e: MouseEvent, idx: number) { |
| hoveredIndex.value = idx |
| popupX.value = e.clientX + 12 |
| popupY.value = e.clientY + 12 |
| } |
|
|
| function onLeave() { |
| hoveredIndex.value = null |
| } |
|
|
| function onClick(h: any) { |
| ElMessage.info(`${h.term}: ${h.translation || '无翻译'}`) |
| } |
|
|
| function renderHtml() { |
| const source = props.text |
| const parts = sortedHighlights.value |
| let html = '' |
| let last = 0 |
| for (let i = 0; i < parts.length; i++) { |
| const h = parts[i] |
| html += escapeHtml(source.slice(last, h.start)) |
| html += `<span class="trans-hl" data-index="${i}">${escapeHtml(source.slice(h.start, h.end))}</span>` |
| last = h.end |
| } |
| html += escapeHtml(source.slice(last)) |
| return html |
| } |
|
|
| function escapeHtml(s: string) { |
| return s.replace(/[&<>"']/g, (c) => |
| ({ |
| '&': '&', |
| '<': '<', |
| '>': '>', |
| '"': '"', |
| "'": ''', |
| } as any)[c] |
| ) |
| } |
|
|
| function onMouseOver(e: MouseEvent) { |
| const target = e.target as HTMLElement |
| if (target && target.classList.contains('trans-hl')) { |
| const idx = Number(target.dataset.index) |
| if (!isNaN(idx)) { |
| onEnter(e, idx) |
| } |
| } |
| } |
|
|
| function onMouseOut(e: MouseEvent) { |
| const target = e.target as HTMLElement |
| if (target && target.classList.contains('trans-hl')) { |
| onLeave() |
| } |
| } |
|
|
| function onSpanClick(e: MouseEvent) { |
| const target = e.target as HTMLElement |
| if (target && target.classList.contains('trans-hl')) { |
| const idx = Number(target.dataset.index) |
| if (!isNaN(idx)) { |
| onClick(sortedHighlights.value[idx]) |
| } |
| } |
| } |
| </script> |
|
|
| <template> |
| <div |
| class="highlighted-source" |
| v-html="renderHtml()" |
| @mouseover="onMouseOver" |
| @mouseout="onMouseOut" |
| @click="onSpanClick" |
| /> |
| <div |
| v-if="hoveredIndex !== null" |
| class="reason-popup show" |
| :style="{ left: popupX + 'px', top: popupY + 'px' }" |
| > |
| <div class="reason-term">{{ sortedHighlights[hoveredIndex]?.term }}</div> |
| <div class="reason-detail"> |
| 翻译:{{ sortedHighlights[hoveredIndex]?.translation || '—' }} |
| <br /> |
| {{ sortedHighlights[hoveredIndex]?.reasoning || '' }} |
| </div> |
| </div> |
| </template> |
|
|
| <style scoped> |
| .highlighted-source { |
| line-height: 1.8; |
| white-space: pre-wrap; |
| } |
| :deep(.trans-hl) { |
| background: linear-gradient(135deg, rgba(139, 92, 246, 0.15), rgba(99, 102, 241, 0.12)); |
| border-bottom: 2px solid rgba(139, 92, 246, 0.4); |
| border-radius: 2px; |
| padding: 0 2px; |
| cursor: pointer; |
| position: relative; |
| transition: all 0.2s; |
| } |
| :deep(.trans-hl):hover { |
| background: linear-gradient(135deg, rgba(139, 92, 246, 0.3), rgba(99, 102, 241, 0.25)); |
| box-shadow: 0 0 12px rgba(139, 92, 246, 0.35); |
| } |
| .reason-popup { |
| position: fixed; |
| z-index: 400; |
| max-width: 340px; |
| background: var(--surface); |
| border: 1px solid var(--border); |
| border-radius: 12px; |
| box-shadow: var(--shadow-lg); |
| padding: 16px; |
| } |
| .reason-term { |
| font-weight: 600; |
| font-size: 14px; |
| margin-bottom: 4px; |
| } |
| .reason-detail { |
| font-size: 12px; |
| color: var(--text-muted); |
| line-height: 1.6; |
| } |
| </style> |
|
|