feat: 违禁词高亮

This commit is contained in:
rd
2025-08-12 18:11:43 +08:00
parent f3a9da6e77
commit c188db3e22
2 changed files with 188 additions and 6 deletions

View File

@ -0,0 +1,183 @@
<template>
<div class="highlight-textarea-container">
<!-- 透明输入层 -->
<a-textarea
v-model="inputValue"
placeholder="请输入作品描述"
:disabled="disabled"
show-word-limit
maxlength="1000"
size="large"
class="textarea-input h-full w-full"
@input="handleInput"
@scroll="syncScroll"
:style="textareaStyle"
/>
<!-- 高亮显示层 -->
<div
class="textarea-highlight"
:style="{ visibility: inputValue ? 'visible' : 'hidden' }"
v-html="highlightedHtml"
@scroll="syncScroll"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, defineProps, defineEmits } from 'vue';
import { isString } from '@/utils/is';
// 定义Props类型
interface ViolationItem {
word: string;
risk_level: number;
}
interface LevelMapItem {
color: string;
}
const props = defineProps<{
modelValue?: string;
prohibitedWords?: ViolationItem[];
levelMap?: Map<number, LevelMapItem>;
placeholder?: string;
disabled?: boolean;
maxLength?: number;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
}>();
// 内部状态管理
const inputValue = ref(props.modelValue || '');
const scrollTop = ref(0);
const highlightedHtml = computed(() => generateHighlightedHtml());
// 监听外部modelValue变化
watch(
() => props.modelValue,
(newVal) => {
if (newVal !== inputValue.value) {
inputValue.value = newVal || '';
}
},
);
// 处理输入事件
const handleInput = (value: string) => {
emit('update:modelValue', value);
};
// 同步滚动位置
const syncScroll = (e: Event) => {
console.log('syncScroll');
scrollTop.value = (e.target as HTMLTextAreaElement).scrollTop;
};
const escapeHtml = (str: string): string => {
if (!isString(str)) return '';
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
const generateHighlightedHtml = (): string => {
if (!inputValue.value) return '';
// 获取违禁词列表并按长度倒序排序(避免短词匹配长词)
const words = (props.prohibitedWords || [])
.filter((item) => item.word && item.risk_level !== undefined)
.sort((a, b) => b.word.length - a.word.length);
if (words.length === 0) {
return escapeHtml(inputValue.value);
}
// 转义正则特殊字符
const escapeRegExp = (word: string) => {
return word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
};
// 创建匹配正则表达式
const pattern = new RegExp(`(${words.map((item) => escapeRegExp(item.word)).join('|')})`, 'gi');
// 替换匹配的违禁词为带样式的span标签
return inputValue.value.replace(pattern, (match) => {
// 找到对应的违禁词信息
const wordInfo = words.find((item) => item.word.toLowerCase() === match.toLowerCase());
if (!wordInfo) return match;
// 获取风险等级对应的样式
const levelStyle = props.levelMap?.get(wordInfo.risk_level);
const color = levelStyle?.color || '#F64B31';
return `<span class="s1" style="color: ${color};">${escapeHtml(match)}</span>`;
});
};
</script>
<style scoped lang="scss">
.highlight-textarea-container {
position: relative;
width: 100%;
height: 100%;
.textarea-input,
.textarea-highlight {
box-sizing: border-box;
width: 100%;
height: 100%;
font-size: 14px;
line-height: 22px;
font-weight: 400px;
border: 1px solid #e5e6eb;
border-radius: 4px;
resize: vertical;
// font-family: inherit;
white-space: pre-wrap;
word-wrap: break-word;
}
.s1 {
font-size: 14px;
line-height: 22px;
font-weight: 400px;
}
.textarea-input {
:deep(.arco-textarea) {
overflow: hidden;
position: absolute;
z-index: 1;
width: 100%;
padding: 8px 12px;
background: transparent;
color: transparent;
caret-color: #211f24 !important;
}
}
.textarea-highlight {
position: absolute;
top: 0;
left: 0;
z-index: 0;
padding: 8px 12px;
pointer-events: none;
background: #fff;
overflow: hidden;
}
/* 字数统计样式 */
.word-count {
margin-top: 8px;
text-align: right;
font-size: 12px;
color: #86909c;
}
}
</style>

View File

@ -16,6 +16,7 @@ import {
Message as AMessage,
} from '@arco-design/web-vue';
import TextOverTips from '@/components/text-over-tips';
import HighlightTextarea from './highlight-textarea';
import 'swiper/css';
import 'swiper/css/navigation';
@ -214,14 +215,12 @@ export default {
/>
</FormItem>
<FormItem label="作品描述" field="content" class="flex-1 content-form-item">
<Textarea
<HighlightTextarea
v-model={props.modelValue.content}
placeholder="请输入作品描述"
size="large"
show-word-limit
maxLength={1000}
show-word-limit
disabled={props.checkLoading}
prohibitedWords={aiReview.value?.violation_items ?? []}
levelMap={LEVEL_MAP}
class="w-full"
/>
</FormItem>
</Form>