变更预识别板块修改
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user