feat: 违禁词高亮与替换
This commit is contained in:
@ -29,6 +29,7 @@ export function configAutoImport() {
|
|||||||
'merge',
|
'merge',
|
||||||
'debounce',
|
'debounce',
|
||||||
'isEqual',
|
'isEqual',
|
||||||
|
'isString'
|
||||||
],
|
],
|
||||||
'@/hooks': ['useModal'],
|
'@/hooks': ['useModal'],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,3 +1,7 @@
|
|||||||
|
export const escapeRegExp = (str: string) => {
|
||||||
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
};
|
||||||
|
|
||||||
export const FORM_RULES = {
|
export const FORM_RULES = {
|
||||||
title: [{ required: true, message: '请输入标题' }],
|
title: [{ required: true, message: '请输入标题' }],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,179 @@
|
|||||||
|
<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';
|
||||||
|
import { escapeRegExp } from './constants';
|
||||||
|
|
||||||
|
// 定义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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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>
|
||||||
@ -20,7 +20,7 @@ import TextOverTips from '@/components/text-over-tips';
|
|||||||
import 'swiper/css';
|
import 'swiper/css';
|
||||||
import 'swiper/css/navigation';
|
import 'swiper/css/navigation';
|
||||||
import { Navigation } from 'swiper/modules';
|
import { Navigation } from 'swiper/modules';
|
||||||
import { FORM_RULES, enumTab, TAB_LIST, RESULT_LIST, LEVEL_MAP } from './constants';
|
import { FORM_RULES, enumTab, TAB_LIST, RESULT_LIST, LEVEL_MAP, escapeRegExp } from './constants';
|
||||||
import { getImagePreSignedUrl } from '@/api/all/common';
|
import { getImagePreSignedUrl } from '@/api/all/common';
|
||||||
import { EnumManuscriptType } from '@/views/creative-generation-workshop/manuscript/list/constants';
|
import { EnumManuscriptType } from '@/views/creative-generation-workshop/manuscript/list/constants';
|
||||||
|
|
||||||
@ -48,21 +48,36 @@ export default {
|
|||||||
emits: ['update:modelValue', 'filesChange', 'selectImage', 'againCheck', 'startCheck'],
|
emits: ['update:modelValue', 'filesChange', 'selectImage', 'againCheck', 'startCheck'],
|
||||||
setup(props, { emit, expose }) {
|
setup(props, { emit, expose }) {
|
||||||
const activeTab = ref(enumTab.TEXT);
|
const activeTab = ref(enumTab.TEXT);
|
||||||
const aiCheckLoading = ref(false);
|
const aiReplaceLoading = ref(false);
|
||||||
const formRef = ref(null);
|
const formRef = ref(null);
|
||||||
const uploadRef = ref(null);
|
const uploadRef = ref(null);
|
||||||
const modules = [Navigation];
|
const modules = [Navigation];
|
||||||
|
|
||||||
const isTextTab = computed(() => activeTab.value === enumTab.TEXT);
|
const isTextTab = computed(() => activeTab.value === enumTab.TEXT);
|
||||||
const aiReview = computed(() => props.modelValue.ai_review);
|
const aiReview = computed(() => props.modelValue.ai_review);
|
||||||
|
const isDisabled = computed(() => props.checkLoading || aiReplaceLoading.value);
|
||||||
|
|
||||||
const onAiReplace = () => {
|
const onAiReplace = () => {
|
||||||
if (aiCheckLoading.value) return;
|
if (aiReplaceLoading.value) return;
|
||||||
|
|
||||||
aiCheckLoading.value = true;
|
aiReplaceLoading.value = true;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
aiCheckLoading.value = false;
|
const content = props.modelValue.content;
|
||||||
}, 2000);
|
const rules = aiReview.value?.violation_items ?? [];
|
||||||
|
const sortedRules = [...rules].sort((a, b) => b.word.length - a.word.length);
|
||||||
|
|
||||||
|
const replacedContent = sortedRules.reduce((result, rule) => {
|
||||||
|
if (!rule.word || !rule.replace_word) return result;
|
||||||
|
|
||||||
|
const escapedWord = escapeRegExp(rule.word);
|
||||||
|
const regex = new RegExp(escapedWord, 'g');
|
||||||
|
|
||||||
|
return result.replace(regex, rule.replace_word);
|
||||||
|
}, content);
|
||||||
|
|
||||||
|
props.modelValue.content = replacedContent;
|
||||||
|
aiReplaceLoading.value = false;
|
||||||
|
}, 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAgainCheck = () => {
|
const onAgainCheck = () => {
|
||||||
@ -92,7 +107,7 @@ export default {
|
|||||||
const reset = () => {
|
const reset = () => {
|
||||||
formRef.value?.resetFields?.();
|
formRef.value?.resetFields?.();
|
||||||
formRef.value?.clearValidate?.();
|
formRef.value?.clearValidate?.();
|
||||||
aiCheckLoading.value = false;
|
aiReplaceLoading.value = false;
|
||||||
};
|
};
|
||||||
const getFileExtension = (filename) => {
|
const getFileExtension = (filename) => {
|
||||||
const match = filename.match(/\.([^.]+)$/);
|
const match = filename.match(/\.([^.]+)$/);
|
||||||
@ -170,12 +185,12 @@ export default {
|
|||||||
const renderFooterRow = () => {
|
const renderFooterRow = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button class="mr-12px" size="medium" onClick={onAgainCheck} disabled={props.checkLoading}>
|
<Button class="mr-12px" size="medium" onClick={onAgainCheck} disabled={isDisabled.value}>
|
||||||
再次审核
|
再次审核
|
||||||
</Button>
|
</Button>
|
||||||
{isTextTab.value ? (
|
{isTextTab.value ? (
|
||||||
<Button size="medium" type="outline" class="w-123px" onClick={onAiReplace} disabled={props.checkLoading}>
|
<Button size="medium" type="outline" class="w-123px" onClick={onAiReplace} disabled={isDisabled.value}>
|
||||||
{aiCheckLoading.value ? (
|
{aiReplaceLoading.value ? (
|
||||||
<>
|
<>
|
||||||
<IconLoading size={14} />
|
<IconLoading size={14} />
|
||||||
<span class="ml-8px check-text">AI生成中</span>
|
<span class="ml-8px check-text">AI生成中</span>
|
||||||
@ -210,7 +225,7 @@ export default {
|
|||||||
size="large"
|
size="large"
|
||||||
maxLength={30}
|
maxLength={30}
|
||||||
show-word-limit
|
show-word-limit
|
||||||
disabled={props.checkLoading}
|
disabled={isDisabled.value}
|
||||||
/>
|
/>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
<FormItem label="作品描述" field="content" class="flex-1 content-form-item">
|
<FormItem label="作品描述" field="content" class="flex-1 content-form-item">
|
||||||
@ -220,7 +235,7 @@ export default {
|
|||||||
size="large"
|
size="large"
|
||||||
show-word-limit
|
show-word-limit
|
||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
disabled={props.checkLoading}
|
disabled={isDisabled.value}
|
||||||
/>
|
/>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@ -1,3 +1,7 @@
|
|||||||
|
export const escapeRegExp = (str: string) => {
|
||||||
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
};
|
||||||
|
|
||||||
export const FORM_RULES = {
|
export const FORM_RULES = {
|
||||||
title: [{ required: true, message: '请输入标题' }],
|
title: [{ required: true, message: '请输入标题' }],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -27,6 +27,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, defineProps, defineEmits } from 'vue';
|
import { ref, computed, watch, defineProps, defineEmits } from 'vue';
|
||||||
import { isString } from '@/utils/is';
|
import { isString } from '@/utils/is';
|
||||||
|
import { escapeRegExp } from './constants';
|
||||||
|
|
||||||
// 定义Props类型
|
// 定义Props类型
|
||||||
interface ViolationItem {
|
interface ViolationItem {
|
||||||
@ -99,11 +100,6 @@ const generateHighlightedHtml = (): string => {
|
|||||||
return escapeHtml(inputValue.value);
|
return escapeHtml(inputValue.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转义正则特殊字符
|
|
||||||
const escapeRegExp = (word: string) => {
|
|
||||||
return word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
};
|
|
||||||
|
|
||||||
// 创建匹配正则表达式
|
// 创建匹配正则表达式
|
||||||
const pattern = new RegExp(`(${words.map((item) => escapeRegExp(item.word)).join('|')})`, 'gi');
|
const pattern = new RegExp(`(${words.map((item) => escapeRegExp(item.word)).join('|')})`, 'gi');
|
||||||
|
|
||||||
|
|||||||
@ -21,7 +21,7 @@ import HighlightTextarea from './highlight-textarea';
|
|||||||
import 'swiper/css';
|
import 'swiper/css';
|
||||||
import 'swiper/css/navigation';
|
import 'swiper/css/navigation';
|
||||||
import { Navigation } from 'swiper/modules';
|
import { Navigation } from 'swiper/modules';
|
||||||
import { FORM_RULES, enumTab, TAB_LIST, RESULT_LIST, LEVEL_MAP } from './constants';
|
import { FORM_RULES, enumTab, TAB_LIST, RESULT_LIST, LEVEL_MAP, escapeRegExp } from './constants';
|
||||||
import { getImagePreSignedUrl } from '@/api/all/common';
|
import { getImagePreSignedUrl } from '@/api/all/common';
|
||||||
import { EnumManuscriptType } from '@/views/creative-generation-workshop/manuscript/list/constants';
|
import { EnumManuscriptType } from '@/views/creative-generation-workshop/manuscript/list/constants';
|
||||||
|
|
||||||
@ -49,21 +49,36 @@ export default {
|
|||||||
emits: ['update:modelValue', 'filesChange', 'selectImage', 'againCheck', 'startCheck'],
|
emits: ['update:modelValue', 'filesChange', 'selectImage', 'againCheck', 'startCheck'],
|
||||||
setup(props, { emit, expose }) {
|
setup(props, { emit, expose }) {
|
||||||
const activeTab = ref(enumTab.TEXT);
|
const activeTab = ref(enumTab.TEXT);
|
||||||
const aiCheckLoading = ref(false);
|
const aiReplaceLoading = ref(false);
|
||||||
const formRef = ref(null);
|
const formRef = ref(null);
|
||||||
const uploadRef = ref(null);
|
const uploadRef = ref(null);
|
||||||
const modules = [Navigation];
|
const modules = [Navigation];
|
||||||
|
|
||||||
const isTextTab = computed(() => activeTab.value === enumTab.TEXT);
|
const isTextTab = computed(() => activeTab.value === enumTab.TEXT);
|
||||||
const aiReview = computed(() => props.modelValue.ai_review);
|
const aiReview = computed(() => props.modelValue.ai_review);
|
||||||
|
const isDisabled = computed(() => props.checkLoading || aiReplaceLoading.value);
|
||||||
|
|
||||||
const onAiReplace = () => {
|
const onAiReplace = () => {
|
||||||
if (aiCheckLoading.value) return;
|
if (aiReplaceLoading.value) return;
|
||||||
|
|
||||||
aiCheckLoading.value = true;
|
aiReplaceLoading.value = true;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
aiCheckLoading.value = false;
|
const content = props.modelValue.content;
|
||||||
}, 2000);
|
const rules = aiReview.value?.violation_items ?? [];
|
||||||
|
const sortedRules = [...rules].sort((a, b) => b.word.length - a.word.length);
|
||||||
|
|
||||||
|
const replacedContent = sortedRules.reduce((result, rule) => {
|
||||||
|
if (!rule.word || !rule.replace_word) return result;
|
||||||
|
|
||||||
|
const escapedWord = escapeRegExp(rule.word);
|
||||||
|
const regex = new RegExp(escapedWord, 'g');
|
||||||
|
|
||||||
|
return result.replace(regex, rule.replace_word);
|
||||||
|
}, content);
|
||||||
|
|
||||||
|
props.modelValue.content = replacedContent;
|
||||||
|
aiReplaceLoading.value = false;
|
||||||
|
}, 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAgainCheck = () => {
|
const onAgainCheck = () => {
|
||||||
@ -93,7 +108,7 @@ export default {
|
|||||||
const reset = () => {
|
const reset = () => {
|
||||||
formRef.value?.resetFields?.();
|
formRef.value?.resetFields?.();
|
||||||
formRef.value?.clearValidate?.();
|
formRef.value?.clearValidate?.();
|
||||||
aiCheckLoading.value = false;
|
aiReplaceLoading.value = false;
|
||||||
};
|
};
|
||||||
const getFileExtension = (filename) => {
|
const getFileExtension = (filename) => {
|
||||||
const match = filename.match(/\.([^.]+)$/);
|
const match = filename.match(/\.([^.]+)$/);
|
||||||
@ -171,12 +186,12 @@ export default {
|
|||||||
const renderFooterRow = () => {
|
const renderFooterRow = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button class="mr-12px" size="medium" onClick={onAgainCheck} disabled={props.checkLoading}>
|
<Button class="mr-12px" size="medium" onClick={onAgainCheck} disabled={isDisabled.value}>
|
||||||
再次审核
|
再次审核
|
||||||
</Button>
|
</Button>
|
||||||
{isTextTab.value ? (
|
{isTextTab.value ? (
|
||||||
<Button size="medium" type="outline" class="w-123px" onClick={onAiReplace} disabled={props.checkLoading}>
|
<Button size="medium" type="outline" class="w-123px" onClick={onAiReplace} disabled={isDisabled.value}>
|
||||||
{aiCheckLoading.value ? (
|
{aiReplaceLoading.value ? (
|
||||||
<>
|
<>
|
||||||
<IconLoading size={14} />
|
<IconLoading size={14} />
|
||||||
<span class="ml-8px check-text">AI生成中</span>
|
<span class="ml-8px check-text">AI生成中</span>
|
||||||
@ -211,13 +226,13 @@ export default {
|
|||||||
size="large"
|
size="large"
|
||||||
maxLength={30}
|
maxLength={30}
|
||||||
show-word-limit
|
show-word-limit
|
||||||
disabled={props.checkLoading}
|
disabled={isDisabled.value}
|
||||||
/>
|
/>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
<FormItem label="作品描述" field="content" class="flex-1 content-form-item">
|
<FormItem label="作品描述" field="content" class="flex-1 content-form-item">
|
||||||
<HighlightTextarea
|
<HighlightTextarea
|
||||||
v-model={props.modelValue.content}
|
v-model={props.modelValue.content}
|
||||||
disabled={props.checkLoading}
|
disabled={isDisabled.value}
|
||||||
prohibitedWords={aiReview.value?.violation_items ?? []}
|
prohibitedWords={aiReview.value?.violation_items ?? []}
|
||||||
levelMap={LEVEL_MAP}
|
levelMap={LEVEL_MAP}
|
||||||
class="w-full"
|
class="w-full"
|
||||||
|
|||||||
Reference in New Issue
Block a user