termprep / frontend /src /components /HighlightedSource.vue
dzha0118's picture
Upload 179 files
29ccba8 verified
Raw
History Blame Contribute Delete
3.54 kB
<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) =>
({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
} 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>