变更预识别板块修改

This commit is contained in:
2026-09-01 16:12:33 +08:00
parent 935df1d5e6
commit 378829da21
5 changed files with 832 additions and 233 deletions
@@ -0,0 +1,165 @@
<script setup lang="ts">
import { computed, h, ref, watch } from 'vue'
import { NButton, NDataTable, NInput } from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { Icon } from '@iconify/vue'
import RichTextInput from './RichTextInput.vue'
interface ChangeItem {
item: string
before_text: string
after_text: string
}
const props = defineProps<{
data: ChangeItem[]
editable?: boolean
}>()
const emit = defineEmits<{
(e: 'update:data', data: ChangeItem[]): void
}>()
const localData = ref<ChangeItem[]>([])
watch(
() => props.data,
(val: ChangeItem[]) => {
// 深度拷贝以避免直接修改 props,并确保响应式
localData.value = val ? JSON.parse(JSON.stringify(val)) : []
},
{ immediate: true, deep: true },
)
function addRow() {
localData.value.push({ item: '新项目', before_text: '', after_text: '' })
emit('update:data', localData.value)
}
function removeRow(idx: number) {
localData.value.splice(idx, 1)
emit('update:data', localData.value)
}
function onCellChange() {
emit('update:data', localData.value)
}
const columns = computed<DataTableColumns<ChangeItem>>(() => {
const cols: DataTableColumns<ChangeItem> = [
{
title: '变更项目',
key: 'project',
width: 100,
render: (row: ChangeItem) =>
props.editable
? h(NInput, {
'value': row.item,
'size': 'small',
'placeholder': '项目名称',
'onUpdate:value': (v: string) => {
row.item = v
onCellChange()
},
})
: h('span', null, row.item),
},
{
title: '变更前',
key: 'before_text',
width: 200,
render: (row: ChangeItem) =>
props.editable
? h(RichTextInput, {
'modelValue': row.before_text,
'minRows': 1,
'placeholder': '变更前内容',
'onUpdate:modelValue': (v: string) => {
row.before_text = v
onCellChange()
},
})
: h('div', { class: 'cell-content', innerHTML: row.before_text }),
},
{
title: '变更后',
key: 'after_text',
width: 200,
render: (row: ChangeItem) =>
props.editable
? h(RichTextInput, {
'modelValue': row.after_text,
'minRows': 1,
'placeholder': '变更后内容',
'onUpdate:modelValue': (v: string) => {
row.after_text = v
onCellChange()
},
})
: h('div', { class: 'cell-content', innerHTML: row.after_text }),
},
]
if (props.editable) {
cols.push({
title: '',
key: 'actions',
width: 50,
align: 'center',
render: (_row: ChangeItem, rowIndex: number) =>
h(NButton, {
text: true,
size: 'tiny',
type: 'error',
onClick: () => removeRow(rowIndex),
}, () => h(Icon, { icon: 'line-md:close-circle', class: 'size-16px' })),
})
}
return cols
})
</script>
<template>
<div class="change-compare-table">
<NDataTable
:columns="columns"
:data="localData"
:pagination="false"
size="small"
:bordered="true"
/>
<div v-if="editable" class="add-row-btn">
<NButton block @click="addRow">
<Icon icon="ic:round-plus" class="size-16px" /> 添加行
</NButton>
</div>
</div>
</template>
<style scoped>
.change-compare-table {
width: 100%;
}
:deep(.n-data-table-td) {
padding: 8px !important;
vertical-align: top;
}
.cell-content {
font-size: 13px;
line-height: 1.6;
color: #1d2129;
word-break: break-all;
white-space: pre-wrap;
}
/* 覆盖编辑器内部的一些基础背景避免在表格里太突兀 */
:deep(.rich-text-input) {
background-color: #fff;
border-radius: 6px;
}
.add-row-btn {
margin-top: 8px;
}
</style>
@@ -1,36 +0,0 @@
<script setup lang="ts">
// 变更后单元格蓝色高亮
import { computed } from "vue";
import { useThemeStore } from '@/store/modules/theme';
const themeStore = useThemeStore();
const props = defineProps<{ text: string; hl?: string[] }>();
interface Seg { t: string; hit: boolean }
const segs = computed<Seg[]>(() => {
const text = props.text;
if (!props.hl || props.hl.length === 0) return [{ t: text, hit: false }];
const out: Seg[] = [];
let rest = text;
const hls = [...props.hl].sort((a, b) => b.length - a.length);
while (rest.length) {
let idx = -1, kw = "";
for (const h of hls) {
const i = rest.indexOf(h);
if (i >= 0 && (idx < 0 || i < idx)) { idx = i; kw = h; }
}
if (idx < 0) { out.push({ t: rest, hit: false }); break; }
if (idx > 0) out.push({ t: rest.slice(0, idx), hit: false });
out.push({ t: kw, hit: true });
rest = rest.slice(idx + kw.length);
}
return out;
});
</script>
<template>
<span class="whitespace-pre-line"><template v-for="(s, i) in segs" :key="i">
<mark
v-if="s.hit" class="rounded bg-blue-100 px-0.5"
:style="{color: themeStore.themeColor}"
>{{ s.t }}</mark>
<template v-else>{{ s.t }}</template></template></span>
</template>
@@ -0,0 +1,472 @@
<script lang="ts" setup>
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const props = withDefaults(defineProps<{
modelValue?: string
placeholder?: string
minRows?: number
maxRows?: number
disabled?: boolean
}>(), {
modelValue: '',
placeholder: '',
minRows: 1,
maxRows: 100,
disabled: false,
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const editorRef = ref<HTMLDivElement | null>(null)
const isFocused = ref(false)
const showColorPicker = ref(false)
const LINE_HEIGHT = 22 // 单行高度(px)
const colorOptions = [
{ label: '红色', value: '#e8364e', bg: '#e8364e' },
{ label: '蓝色', value: '#1890ff', bg: '#1890ff' },
{ label: '橙色', value: '#fa8c16', bg: '#fa8c16' },
{ label: '绿色', value: '#52c41a', bg: '#52c41a' },
{ label: '紫色', value: '#722ed1', bg: '#722ed1' },
{ label: '默认', value: '', bg: '#333333' },
]
// 获取编辑器中去掉 HTML 标签后的纯文本,用于判断是否为空
function getPlainText(): string {
if (!editorRef.value)
return ''
return editorRef.value.innerText?.trim() || ''
}
const hasContent = ref(false)
// 设置编辑器内容(仅在内容确实不同时才更新,防止光标重置)
function setEditorHtml(html: string) {
if (!editorRef.value)
return
if (editorRef.value.innerHTML !== html)
editorRef.value.innerHTML = html || ''
}
// 输入事件:同步内容到 modelValue
function onInput() {
if (!editorRef.value)
return
const html = editorRef.value.innerHTML || ''
// 如果内容只是 <br> 或空 div 则视为空
const plain = getPlainText()
hasContent.value = !!plain
emit('update:modelValue', plain ? html : '')
}
function onFocus() {
isFocused.value = true
}
function onBlur() {
// 延迟关闭,让工具栏按钮的 mousedown 有时间执行
setTimeout(() => {
isFocused.value = false
showColorPicker.value = false
}, 200)
}
// =============== 工具栏操作 ===============
// 保存和恢复选区
let savedSelection: Range | null = null
function saveSelection() {
const sel = window.getSelection()
if (sel && sel.rangeCount > 0)
savedSelection = sel.getRangeAt(0).cloneRange()
}
function restoreSelection() {
if (savedSelection) {
const sel = window.getSelection()
if (sel) {
sel.removeAllRanges()
sel.addRange(savedSelection)
}
}
}
// 执行粗体
function execBold(e: Event) {
e.preventDefault()
restoreSelection()
document.execCommand('bold', false)
editorRef.value?.focus()
onInput()
}
// 执行文字颜色
function execColor(color: string, e: Event) {
e.preventDefault()
restoreSelection()
if (color) {
document.execCommand('foreColor', false, color)
}
else {
// 移除颜色:先获取选区文本,删除后用无格式文本替换
document.execCommand('removeFormat', false)
}
showColorPicker.value = false
editorRef.value?.focus()
onInput()
}
function toggleColorPicker(e: Event) {
e.preventDefault()
saveSelection()
showColorPicker.value = !showColorPicker.value
}
// 工具栏按钮 mousedown 时保存选区
function onToolbarMousedown(e: Event) {
e.preventDefault()
saveSelection()
}
// 监听粘贴事件,只粘贴纯文本(保留已有的 HTML 标记,但不引入外部格式)
function onPaste(e: ClipboardEvent) {
e.preventDefault()
// 优先粘贴 HTML(保留粗体/颜色),fallback 纯文本
const html = e.clipboardData?.getData('text/html')
const text = e.clipboardData?.getData('text/plain') || ''
if (html) {
// 清理外部 HTML:只保留简单的格式标签
const cleanHtml = sanitizeHtml(html)
document.execCommand('insertHTML', false, cleanHtml)
}
else {
document.execCommand('insertText', false, text)
}
onInput()
}
// 简易 HTML 清洗:只保留 b/strong/span/em/i/br 标签
function sanitizeHtml(html: string): string {
const div = document.createElement('div')
div.innerHTML = html
// 递归清洗
function cleanNode(node: Node): string {
if (node.nodeType === Node.TEXT_NODE)
return node.textContent || ''
if (node.nodeType !== Node.ELEMENT_NODE)
return ''
const el = node as HTMLElement
const tag = el.tagName.toLowerCase()
const allowedTags = ['b', 'strong', 'span', 'em', 'i', 'br', 'u']
let inner = ''
el.childNodes.forEach((child) => {
inner += cleanNode(child)
})
if (tag === 'br')
return '<br>'
if (allowedTags.includes(tag)) {
// 保留 span 的 style 中的 color
if (tag === 'span' && el.style.color)
return `<span style="color:${el.style.color}">${inner}</span>`
if (tag === 'b' || tag === 'strong')
return `<b>${inner}</b>`
if (tag === 'em' || tag === 'i')
return `<em>${inner}</em>`
return inner
}
// 块级元素转换为换行
const blockTags = ['div', 'p', 'li', 'tr', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6']
if (blockTags.includes(tag))
return `${inner}<br>`
return inner
}
return cleanNode(div)
}
// 监听外部 modelValue 变化
watch(
() => props.modelValue,
(newVal:string) => {
if (!editorRef.value)
return
// 仅在编辑器不聚焦或内容确实不同时更新
if (!isFocused.value || editorRef.value.innerHTML !== newVal) {
setEditorHtml(newVal || '')
hasContent.value = !!getPlainText()
}
},
)
onMounted(() => {
nextTick(() => {
setEditorHtml(props.modelValue || '')
hasContent.value = !!getPlainText()
})
})
// 点击编辑器外部关闭颜色选择器
function handleDocClick(e: MouseEvent) {
const target = e.target as HTMLElement
if (!target.closest('.rich-text-input'))
showColorPicker.value = false
}
onMounted(() => {
document.addEventListener('click', handleDocClick)
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleDocClick)
})
</script>
<template>
<div
class="rich-text-input"
:class="{ 'is-focused': isFocused, 'is-disabled': disabled }"
>
<!-- 工具栏 -->
<div v-show="isFocused && !disabled" class="rti-toolbar">
<button
class="rti-btn"
title="粗体 (Ctrl+B)"
@mousedown="onToolbarMousedown"
@click="execBold"
>
<b>B</b>
</button>
<div class="rti-color-wrap">
<button
class="rti-btn rti-btn-color"
title="文字颜色"
@mousedown="toggleColorPicker"
>
<span class="rti-color-icon">A</span>
<span class="rti-color-bar" />
</button>
<div v-show="showColorPicker" class="rti-color-dropdown">
<button
v-for="c in colorOptions"
:key="c.value"
class="rti-color-option"
:title="c.label"
@mousedown="(e: Event) => execColor(c.value, e)"
>
<span class="rti-color-dot" :style="{ background: c.bg }" />
<span class="rti-color-label">{{ c.label }}</span>
</button>
</div>
</div>
</div>
<!-- 编辑区域 -->
<div class="rti-editor-wrap">
<div
ref="editorRef"
class="rti-editor"
:contenteditable="!disabled"
:style="{
maxHeight: `${maxRows * LINE_HEIGHT}px`,
}"
@input="onInput"
@focus="onFocus"
@blur="onBlur"
@paste="onPaste"
@mouseup="saveSelection"
@keyup="saveSelection"
></div>
<div v-if="!hasContent && !isFocused" class="rti-placeholder">
{{ placeholder }}
</div>
</div>
</div>
</template>
<style scoped>
.rich-text-input {
position: relative;
border: 1px solid #e5e8ef;
border-radius: 6px;
background: #fafbfc;
transition: all 0.2s ease;
overflow: visible;
}
.rich-text-input:hover {
border-color: #2080f0;
}
.rich-text-input.is-focused {
border-color: #2080f0;
background: #ffffff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
}
.rich-text-input.is-disabled {
opacity: 0.8;
background: transparent;
border-color: transparent;
}
.rich-text-input.is-disabled:hover {
border-color: transparent;
}
/* 工具栏 */
.rti-toolbar {
display: flex;
align-items: center;
gap: 2px;
padding: 4px 8px;
border-bottom: 1px solid #f0f0f0;
background: #fafbfc;
border-radius: 6px 6px 0 0;
}
.rti-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 26px;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
color: #4e5969;
transition: all 0.15s;
}
.rti-btn:hover {
background: #e8f4ff;
color: #2080f0;
}
.rti-btn b {
font-weight: 700;
font-size: 14px;
}
/* 颜色按钮 */
.rti-color-wrap {
position: relative;
}
.rti-btn-color {
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
width: 28px;
}
.rti-color-icon {
font-weight: 700;
font-size: 13px;
line-height: 1;
}
.rti-color-bar {
width: 14px;
height: 3px;
background: linear-gradient(90deg, #e8364e, #2080f0, #fa8c16, #52c41a);
border-radius: 1px;
}
/* 颜色下拉 */
.rti-color-dropdown {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
padding: 6px;
background: #fff;
border-radius: 8px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
z-index: 100;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 90px;
}
.rti-color-option {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 8px;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
color: #4e5969;
transition: background 0.15s;
white-space: nowrap;
}
.rti-color-option:hover {
background: #f2f3f5;
}
.rti-color-dot {
width: 14px;
height: 14px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.06);
flex-shrink: 0;
}
.rti-color-label {
font-size: 12px;
}
/* 编辑区域 */
.rti-editor-wrap {
position: relative;
}
.rti-editor {
padding: 2px 10px;
font-size: 14px;
line-height: 22px;
color: #1d2129;
overflow-y: auto;
outline: none;
word-break: break-word;
white-space: pre-wrap;
}
.rti-editor:empty::before {
content: '';
}
/* 粗体和颜色在编辑器内的渲染 */
.rti-editor :deep(b),
.rti-editor :deep(strong) {
font-weight: 700;
}
.rti-placeholder {
position: absolute;
top: 2px;
left: 10px;
color: #c9cdd4;
font-size: 14px;
line-height: 22px;
pointer-events: none;
user-select: none;
}
</style>