@jhl548/duplicate-doc-vue 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +676 -0
- package/dist/DuplicateDocumentEditor.vue.d.ts +31 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +418 -0
- package/package.json +56 -0
- package/src/style.css +669 -0
package/README.md
ADDED
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
# @jhl548/duplicate-doc-vue 集成说明
|
|
2
|
+
|
|
3
|
+
`@jhl548/duplicate-doc-vue` 是面�?Vue3 项目�?Word 文档重复点编辑与高亮插件。插件自身只负责单个标准化文档的渲染、编辑、重复点高亮、滚动定位和编辑快照输出;主从文档关系、重复点列表、上传分析、导出回写等业务编排应由宿主项目完成�?
|
|
4
|
+
|
|
5
|
+
本文档说明两部分内容�?
|
|
6
|
+
|
|
7
|
+
- 前端 Vue3 项目如何集成插件,并保证功能和样式正常使用�?
|
|
8
|
+
- 后端需要如何处�?Word 格式转换、文本范围映射和重复点数据,保证插件可以准确高亮�?
|
|
9
|
+
|
|
10
|
+
## 适用场景
|
|
11
|
+
|
|
12
|
+
插件适合用于以下业务�?
|
|
13
|
+
|
|
14
|
+
- 后端已经�?`.doc` / `.docx` 转成可编�?HTML�?
|
|
15
|
+
- 后端或算法服务可以输出文档纯文本、结构块范围和重复点范围�?
|
|
16
|
+
- 前端需要在 Vue3 页面中展示一个或多个富文本编辑器,并定位重复内容�?
|
|
17
|
+
- 用户编辑后,前端需要把最�?HTML / plainText / Tiptap JSON 回传后端用于保存或导出�?
|
|
18
|
+
|
|
19
|
+
插件不负责:
|
|
20
|
+
|
|
21
|
+
- Word 文件上传�?
|
|
22
|
+
- 多文档主从关系编排�?
|
|
23
|
+
- 相似度算法�?
|
|
24
|
+
- DOCX 导出�?
|
|
25
|
+
- 后端格式转换�?
|
|
26
|
+
- 业务权限、审批、版本管理等系统逻辑�?
|
|
27
|
+
|
|
28
|
+
## 安装与引�?
|
|
29
|
+
|
|
30
|
+
在现�?Vue3 项目中安装插件:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm install @jhl548/duplicate-doc-vue
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
在应用入口导入样式:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { createApp } from "vue";
|
|
40
|
+
import App from "./App.vue";
|
|
41
|
+
import "@jhl548/duplicate-doc-vue/style.css";
|
|
42
|
+
|
|
43
|
+
createApp(App).mount("#app");
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
在业务组件中导入组件和类型:
|
|
47
|
+
|
|
48
|
+
```vue
|
|
49
|
+
<script setup lang="ts">
|
|
50
|
+
import {
|
|
51
|
+
DuplicateDocumentEditor,
|
|
52
|
+
type DuplicateHighlight,
|
|
53
|
+
type EditorChangePayload,
|
|
54
|
+
type NormalizedDocument
|
|
55
|
+
} from "@jhl548/duplicate-doc-vue";
|
|
56
|
+
import { computed, ref } from "vue";
|
|
57
|
+
|
|
58
|
+
const documentModel = ref<NormalizedDocument>({
|
|
59
|
+
documentId: "main-001",
|
|
60
|
+
role: "main",
|
|
61
|
+
fileName: "主文�?docx",
|
|
62
|
+
html: '<h1 data-dupdoc-block="b-0">项目计划�?/h1><p data-dupdoc-block="b-1">这是一段需要高亮的重复内容�?/p>',
|
|
63
|
+
plainText: "项目计划书\n这是一段需要高亮的重复内容�?,
|
|
64
|
+
rangeMap: [
|
|
65
|
+
{
|
|
66
|
+
blockId: "b-0",
|
|
67
|
+
textStart: 0,
|
|
68
|
+
textEnd: 5,
|
|
69
|
+
selector: '[data-dupdoc-block="b-0"]'
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
blockId: "b-1",
|
|
73
|
+
textStart: 6,
|
|
74
|
+
textEnd: 20,
|
|
75
|
+
selector: '[data-dupdoc-block="b-1"]'
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const highlights = computed<DuplicateHighlight[]>(() => [
|
|
81
|
+
{
|
|
82
|
+
duplicateId: "dup-001",
|
|
83
|
+
documentId: "main-001",
|
|
84
|
+
similarity: 0.92,
|
|
85
|
+
active: true,
|
|
86
|
+
label: "重复�?1",
|
|
87
|
+
ranges: [{ start: 6, end: 20, blockId: "b-1" }]
|
|
88
|
+
}
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
function handleChange(payload: EditorChangePayload) {
|
|
92
|
+
console.log("最新编辑内�?, payload);
|
|
93
|
+
}
|
|
94
|
+
</script>
|
|
95
|
+
|
|
96
|
+
<template>
|
|
97
|
+
<DuplicateDocumentEditor
|
|
98
|
+
:document-model="documentModel"
|
|
99
|
+
:highlights="highlights"
|
|
100
|
+
:editable="true"
|
|
101
|
+
:autofocus-highlight="true"
|
|
102
|
+
@change="handleChange"
|
|
103
|
+
/>
|
|
104
|
+
</template>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## 组件 API
|
|
108
|
+
|
|
109
|
+
### Props
|
|
110
|
+
|
|
111
|
+
`documentModel: NormalizedDocument`
|
|
112
|
+
|
|
113
|
+
当前编辑器要渲染的单个标准化文档。该字段是必填项,插件会读取其中�?`html` 作为初始编辑内容,并通过 `documentId` 与高亮数据匹配�?
|
|
114
|
+
|
|
115
|
+
`highlights?: DuplicateHighlight[]`
|
|
116
|
+
|
|
117
|
+
当前文档需要渲染的重复点高亮。宿主页面应先根据当前文�?ID、当前重复点、主从文档关系计算出该编辑器真正需要展示的高亮数组�?
|
|
118
|
+
|
|
119
|
+
`editable?: boolean`
|
|
120
|
+
|
|
121
|
+
是否允许编辑,默�?`true`。设�?`false` 后,编辑器进入只读展示状态�?
|
|
122
|
+
|
|
123
|
+
`autofocusHighlight?: boolean`
|
|
124
|
+
|
|
125
|
+
高亮变化后是否自动滚动到当前高亮位置,默�?`true`�?
|
|
126
|
+
|
|
127
|
+
### Events
|
|
128
|
+
|
|
129
|
+
`change(payload: EditorChangePayload)`
|
|
130
|
+
|
|
131
|
+
编辑器内容变化时触发。`payload` 包含�?
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
interface EditorChangePayload {
|
|
135
|
+
documentId: string;
|
|
136
|
+
html: string;
|
|
137
|
+
plainText: string;
|
|
138
|
+
json?: unknown;
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`ready()`
|
|
143
|
+
|
|
144
|
+
编辑器初始化完成时触发,可用于宿主页面做埋点、加载状态切换或首次定位�?
|
|
145
|
+
|
|
146
|
+
### Ref 方法
|
|
147
|
+
|
|
148
|
+
可以通过组件 `ref` 主动读取编辑器快照:
|
|
149
|
+
|
|
150
|
+
```vue
|
|
151
|
+
<script setup lang="ts">
|
|
152
|
+
import { ref } from "vue";
|
|
153
|
+
import { DuplicateDocumentEditor, type EditorChangePayload } from "@jhl548/duplicate-doc-vue";
|
|
154
|
+
|
|
155
|
+
const editorRef = ref<{
|
|
156
|
+
getSnapshot: () => EditorChangePayload;
|
|
157
|
+
getHTML: () => string;
|
|
158
|
+
getPlainText: () => string;
|
|
159
|
+
focus: () => void;
|
|
160
|
+
} | null>(null);
|
|
161
|
+
|
|
162
|
+
function submit() {
|
|
163
|
+
const snapshot = editorRef.value?.getSnapshot();
|
|
164
|
+
if (!snapshot) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
console.log(snapshot.html, snapshot.plainText, snapshot.json);
|
|
169
|
+
}
|
|
170
|
+
</script>
|
|
171
|
+
|
|
172
|
+
<template>
|
|
173
|
+
<DuplicateDocumentEditor ref="editorRef" :document-model="documentModel" />
|
|
174
|
+
</template>
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## 前端页面推荐数据结构
|
|
178
|
+
|
|
179
|
+
真实业务中建议后端一次返回页面渲染所需的完整结构化数据,避免前端通过多个接口拼装页面。页面可以只保留少量交互状态,例如当前选中的重复点、当前从文档、编辑缓存�?
|
|
180
|
+
|
|
181
|
+
推荐接口�?
|
|
182
|
+
|
|
183
|
+
```http
|
|
184
|
+
GET /api/projects/{projectId}/duplicate-doc-view
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
推荐响应外壳�?
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
interface ApiResponse<T> {
|
|
191
|
+
code: number;
|
|
192
|
+
message: string;
|
|
193
|
+
data: T;
|
|
194
|
+
meta?: {
|
|
195
|
+
traceId?: string;
|
|
196
|
+
timestamp?: string;
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
推荐 `data` 结构�?
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
interface DuplicateDocViewData {
|
|
205
|
+
project: {
|
|
206
|
+
projectId: string;
|
|
207
|
+
name: string;
|
|
208
|
+
mainDocumentId: string;
|
|
209
|
+
};
|
|
210
|
+
documents: NormalizedDocument[];
|
|
211
|
+
duplicates: DuplicatePoint[];
|
|
212
|
+
activeDefaults: {
|
|
213
|
+
duplicateId?: string;
|
|
214
|
+
slaveDocumentId?: string;
|
|
215
|
+
};
|
|
216
|
+
stats: {
|
|
217
|
+
documentTotal: number;
|
|
218
|
+
duplicateTotal: number;
|
|
219
|
+
rangeMapTotal: number;
|
|
220
|
+
plainTextTotal: number;
|
|
221
|
+
};
|
|
222
|
+
capabilities: {
|
|
223
|
+
supportedFileTypes: string[];
|
|
224
|
+
supportedContentTypes: string[];
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
前端收到该结构后,按当前选中�?`duplicateId` 和文�?ID 生成插件入参�?
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
function buildMainHighlights(duplicate: DuplicatePoint, mainDocumentId: string): DuplicateHighlight[] {
|
|
233
|
+
return [
|
|
234
|
+
{
|
|
235
|
+
duplicateId: duplicate.duplicateId,
|
|
236
|
+
documentId: mainDocumentId,
|
|
237
|
+
similarity: duplicate.similarity,
|
|
238
|
+
ranges: duplicate.main.ranges,
|
|
239
|
+
active: true,
|
|
240
|
+
label: duplicate.label,
|
|
241
|
+
reason: duplicate.reason,
|
|
242
|
+
ignored: duplicate.ignored,
|
|
243
|
+
severity: duplicate.severity,
|
|
244
|
+
semanticType: duplicate.semanticType,
|
|
245
|
+
region: duplicate.region,
|
|
246
|
+
noiseReason: duplicate.noiseReason
|
|
247
|
+
}
|
|
248
|
+
];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function buildSlaveHighlights(duplicate: DuplicatePoint, slaveDocumentId: string): DuplicateHighlight[] {
|
|
252
|
+
const slave = duplicate.slaves.find((item) => item.documentId === slaveDocumentId);
|
|
253
|
+
if (!slave) {
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return [
|
|
258
|
+
{
|
|
259
|
+
duplicateId: duplicate.duplicateId,
|
|
260
|
+
documentId: slaveDocumentId,
|
|
261
|
+
similarity: duplicate.similarity,
|
|
262
|
+
ranges: slave.ranges,
|
|
263
|
+
active: true,
|
|
264
|
+
label: duplicate.label,
|
|
265
|
+
reason: duplicate.reason,
|
|
266
|
+
ignored: duplicate.ignored,
|
|
267
|
+
severity: duplicate.severity,
|
|
268
|
+
semanticType: duplicate.semanticType,
|
|
269
|
+
region: duplicate.region,
|
|
270
|
+
noiseReason: duplicate.noiseReason
|
|
271
|
+
}
|
|
272
|
+
];
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
## 后端数据处理总览
|
|
277
|
+
|
|
278
|
+
后端的核心职责是把原�?Word 文件转换成插件能稳定消费的数据结构。建议流程如下:
|
|
279
|
+
|
|
280
|
+
```mermaid
|
|
281
|
+
flowchart LR
|
|
282
|
+
UploadWord[Upload Word] --> ConvertHtml[Convert to HTML]
|
|
283
|
+
ConvertHtml --> NormalizeHtml[Normalize HTML Blocks]
|
|
284
|
+
NormalizeHtml --> PlainText[Build plainText]
|
|
285
|
+
NormalizeHtml --> RangeMap[Build rangeMap]
|
|
286
|
+
PlainText --> DuplicateEngine[Duplicate Engine]
|
|
287
|
+
RangeMap --> DuplicateEngine
|
|
288
|
+
DuplicateEngine --> DuplicatePoint[Build DuplicatePoint]
|
|
289
|
+
PlainText --> NormalizedDocument[Build NormalizedDocument]
|
|
290
|
+
RangeMap --> NormalizedDocument
|
|
291
|
+
NormalizedDocument --> ViewData[Build Page View Data]
|
|
292
|
+
DuplicatePoint --> ViewData
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
后端至少需要输出两类数据:
|
|
296
|
+
|
|
297
|
+
- `NormalizedDocument[]`:每个可编辑文档的标准化内容�?
|
|
298
|
+
- `DuplicatePoint[]`:重复点及其在主文档、从文档中的纯文本范围�?
|
|
299
|
+
|
|
300
|
+
## Word �?HTML 的格式转�?
|
|
301
|
+
|
|
302
|
+
### 推荐转换方式
|
|
303
|
+
|
|
304
|
+
优先使用 LibreOffice headless 转换�?
|
|
305
|
+
|
|
306
|
+
```bash
|
|
307
|
+
soffice --headless --convert-to html --outdir ./converted ./input.docx
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
优点�?
|
|
311
|
+
|
|
312
|
+
- �?`.doc` �?`.docx` 都比较友好�?
|
|
313
|
+
- 能保留更�?Word 中的标题、段落、表格、列表、图片和基础样式�?
|
|
314
|
+
- 适合服务端批量转换�?
|
|
315
|
+
|
|
316
|
+
如果服务器没�?LibreOffice,可以对 `.docx` 使用 `python-docx` 做兜底转换,但兜底通常只能保留基础段落文本,格式损失会明显增加�?
|
|
317
|
+
|
|
318
|
+
不建议后端直接把 Word 原始二进制交给前端处理。插件需要的是标准化 HTML、纯文本和范围映射,而不�?Word 文件本身�?
|
|
319
|
+
|
|
320
|
+
### 转换后的 HTML 规范�?
|
|
321
|
+
|
|
322
|
+
转换得到�?HTML 不能直接丢给插件使用,建议后端做一次规范化�?
|
|
323
|
+
|
|
324
|
+
1. 提取正文区域,忽略无关的 Word 导出样式壳�?
|
|
325
|
+
2. 按块级结构切分内容,例如 `h1`、`h2`、`h3`、`p`、`table`、`ul`、`ol`、`blockquote`、`pre`�?
|
|
326
|
+
3. 为每个结构块生成稳定�?`blockId`�?
|
|
327
|
+
4. �?HTML 节点补充 `data-dupdoc-block` 属性�?
|
|
328
|
+
5. 生成�?HTML 同步�?`plainText`�?
|
|
329
|
+
6. 生成每个块对应的 `rangeMap`�?
|
|
330
|
+
|
|
331
|
+
示例 HTML�?
|
|
332
|
+
|
|
333
|
+
```html
|
|
334
|
+
<h1 data-dupdoc-block="b-0">项目计划�?/h1>
|
|
335
|
+
<p data-dupdoc-block="b-1">投标人须提供营业执照、资质证书和类似业绩证明�?/p>
|
|
336
|
+
<table data-dupdoc-block="b-2" data-dupdoc-table="table-001">
|
|
337
|
+
<tbody>
|
|
338
|
+
<tr>
|
|
339
|
+
<th>项目名称</th>
|
|
340
|
+
<th>合同金额</th>
|
|
341
|
+
</tr>
|
|
342
|
+
<tr>
|
|
343
|
+
<td>智慧园区平台</td>
|
|
344
|
+
<td>380万元</td>
|
|
345
|
+
</tr>
|
|
346
|
+
</tbody>
|
|
347
|
+
</table>
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
对应纯文本建议使用块级节点之间的 `\n` 分隔�?
|
|
351
|
+
|
|
352
|
+
```text
|
|
353
|
+
项目计划�?
|
|
354
|
+
投标人须提供营业执照、资质证书和类似业绩证明�?
|
|
355
|
+
项目名称 合同金额 智慧园区平台 380万元
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
这个换行规则非常重要。插件内部会按块级节点补齐换行偏移,如果后端 `plainText` 与前�?Tiptap 文档中的文本提取规则不一致,重复点会出现高亮偏移�?
|
|
359
|
+
|
|
360
|
+
## NormalizedDocument 数据结构
|
|
361
|
+
|
|
362
|
+
每个文档必须转换�?`NormalizedDocument`�?
|
|
363
|
+
|
|
364
|
+
```ts
|
|
365
|
+
interface NormalizedDocument {
|
|
366
|
+
documentId: string;
|
|
367
|
+
role: "main" | "slave";
|
|
368
|
+
fileName: string;
|
|
369
|
+
html: string;
|
|
370
|
+
plainText: string;
|
|
371
|
+
rangeMap: RangeMapEntry[];
|
|
372
|
+
assets?: DocumentAsset[];
|
|
373
|
+
meta?: Record<string, unknown>;
|
|
374
|
+
}
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
字段说明�?
|
|
378
|
+
|
|
379
|
+
- `documentId`:后端生成的稳定文档 ID,同一次页面数据中必须唯一�?
|
|
380
|
+
- `role`:`main` 表示主文档,`slave` 表示从文档�?
|
|
381
|
+
- `fileName`:原始文件名或业务文件名�?
|
|
382
|
+
- `html`:可�?Tiptap 渲染和编辑的标准�?HTML�?
|
|
383
|
+
- `plainText`:算法使用的纯文本,重复�?`start/end` 必须基于它计算�?
|
|
384
|
+
- `rangeMap`:结构块与纯文本偏移之间的映射�?
|
|
385
|
+
- `assets`:图片、字体等资源信息,可选�?
|
|
386
|
+
- `meta`:转换器、原始文件名、格式损失标记等调试信息,可选�?
|
|
387
|
+
|
|
388
|
+
`rangeMap` 示例�?
|
|
389
|
+
|
|
390
|
+
```ts
|
|
391
|
+
interface RangeMapEntry {
|
|
392
|
+
blockId: string;
|
|
393
|
+
textStart: number;
|
|
394
|
+
textEnd: number;
|
|
395
|
+
selector?: string;
|
|
396
|
+
sectionPath?: string[];
|
|
397
|
+
region?: "body" | "header" | "footer" | "table" | "caption" | "unknown";
|
|
398
|
+
listMarker?: string;
|
|
399
|
+
tableContext?: TableCellContext;
|
|
400
|
+
semanticType?: TenderSemanticType;
|
|
401
|
+
}
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
注意事项�?
|
|
405
|
+
|
|
406
|
+
- `textStart` �?`textEnd` 是在 `plainText` 中的字符偏移�?
|
|
407
|
+
- `textEnd` 建议使用左闭右开区间,即包含 `textStart`,不包含 `textEnd`�?
|
|
408
|
+
- 块之间如果在 `plainText` 中插入了 `\n`,必须同步增加后续块的偏移�?
|
|
409
|
+
- `selector` 建议指向 `data-dupdoc-block`,方便调试和后续扩展�?
|
|
410
|
+
- 表格建议保留 `tableContext`,方便前端解释重复点所在行列和表头�?
|
|
411
|
+
|
|
412
|
+
## DuplicatePoint 数据结构
|
|
413
|
+
|
|
414
|
+
后端查重或模�?agent 需要输�?`DuplicatePoint[]`�?
|
|
415
|
+
|
|
416
|
+
```ts
|
|
417
|
+
interface DuplicatePoint {
|
|
418
|
+
duplicateId: string;
|
|
419
|
+
groupId?: string;
|
|
420
|
+
similarity: number;
|
|
421
|
+
label?: string;
|
|
422
|
+
summary?: string;
|
|
423
|
+
reason?: string;
|
|
424
|
+
ignored?: boolean;
|
|
425
|
+
severity?: "noise" | "low" | "medium" | "high";
|
|
426
|
+
semanticType?: TenderSemanticType;
|
|
427
|
+
region?: DocumentRegion;
|
|
428
|
+
noiseReason?: string;
|
|
429
|
+
main: {
|
|
430
|
+
documentId: string;
|
|
431
|
+
ranges: TextRange[];
|
|
432
|
+
};
|
|
433
|
+
slaves: Array<{
|
|
434
|
+
documentId: string;
|
|
435
|
+
ranges: TextRange[];
|
|
436
|
+
}>;
|
|
437
|
+
}
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
`TextRange` 示例�?
|
|
441
|
+
|
|
442
|
+
```ts
|
|
443
|
+
interface TextRange {
|
|
444
|
+
start: number;
|
|
445
|
+
end: number;
|
|
446
|
+
blockId?: string;
|
|
447
|
+
sectionPath?: string[];
|
|
448
|
+
region?: DocumentRegion;
|
|
449
|
+
tableContext?: TableCellContext;
|
|
450
|
+
confidence?: number;
|
|
451
|
+
semanticType?: TenderSemanticType;
|
|
452
|
+
}
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
关键要求�?
|
|
456
|
+
|
|
457
|
+
- `start/end` 必须基于对应文档�?`plainText`�?
|
|
458
|
+
- `documentId` 必须能匹�?`documents` 中的文档�?
|
|
459
|
+
- 一个重复点可以关联一个主文档范围和多个从文档范围�?
|
|
460
|
+
- `similarity` 范围建议�?`0` �?`1`�?
|
|
461
|
+
- `ignored` �?`noiseReason` 可用于标记项目名称、页眉页脚、日期等自然重复内容�?
|
|
462
|
+
- 如果重复点落在表格中,建议带�?`tableContext`�?
|
|
463
|
+
|
|
464
|
+
## 格式转换重点
|
|
465
|
+
|
|
466
|
+
### 段落和标�?
|
|
467
|
+
|
|
468
|
+
后端应尽量保留标题层级:
|
|
469
|
+
|
|
470
|
+
- Word 标题 1 转成 `h1`�?
|
|
471
|
+
- Word 标题 2 转成 `h2`�?
|
|
472
|
+
- Word 标题 3 转成 `h3`�?
|
|
473
|
+
- 普通正文转�?`p`�?
|
|
474
|
+
|
|
475
|
+
同时可以根据标题生成 `sectionPath`,例如:
|
|
476
|
+
|
|
477
|
+
```ts
|
|
478
|
+
sectionPath: ["第一�?商务部分", "资格审查"]
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
### 列表
|
|
482
|
+
|
|
483
|
+
有序列表和无序列表建议保留为 `ol` / `ul` / `li`。生成纯文本时需要明确列表项之间是否加入空格或换行,并保持算法与插件一致�?
|
|
484
|
+
|
|
485
|
+
建议�?
|
|
486
|
+
|
|
487
|
+
- 块级列表整体作为一个结构块�?
|
|
488
|
+
- `listMarker` 保留原始编号,例�?`1.`、`一、`、`(一)`�?
|
|
489
|
+
- 列表项文本可以合并为该块的文本�?
|
|
490
|
+
|
|
491
|
+
### 表格
|
|
492
|
+
|
|
493
|
+
表格是最容易导致偏移不一致的结构,建议后端明确处理:
|
|
494
|
+
|
|
495
|
+
- HTML 中保�?`table`、`tr`、`th`、`td`�?
|
|
496
|
+
- 给表格生�?`data-dupdoc-table`�?
|
|
497
|
+
- `plainText` 中按单元格顺序提取文本�?
|
|
498
|
+
- 单元格之间建议使用空格分隔�?
|
|
499
|
+
- 表格块在 `rangeMap` 中标�?`region: "table"`�?
|
|
500
|
+
- 重复点落在某个单元格时,尽量补充 `tableContext`�?
|
|
501
|
+
|
|
502
|
+
`tableContext` 示例�?
|
|
503
|
+
|
|
504
|
+
```ts
|
|
505
|
+
interface TableCellContext {
|
|
506
|
+
tableId: string;
|
|
507
|
+
rowIndex: number;
|
|
508
|
+
cellIndex: number;
|
|
509
|
+
headerText?: string;
|
|
510
|
+
rowHeaderText?: string;
|
|
511
|
+
}
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
### 图片和附件资�?
|
|
515
|
+
|
|
516
|
+
如果 Word 中包含图片:
|
|
517
|
+
|
|
518
|
+
- 简单场景可以转�?base64 图片放在 HTML 中�?
|
|
519
|
+
- 生产场景建议后端上传图片资源,HTML 中引用资�?URL�?
|
|
520
|
+
- `assets` 中记录图�?ID、URL 和类型�?
|
|
521
|
+
|
|
522
|
+
示例�?
|
|
523
|
+
|
|
524
|
+
```ts
|
|
525
|
+
assets: [
|
|
526
|
+
{
|
|
527
|
+
id: "asset-001",
|
|
528
|
+
url: "https://cdn.example.com/docs/asset-001.png",
|
|
529
|
+
type: "image"
|
|
530
|
+
}
|
|
531
|
+
]
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
### 行内样式
|
|
535
|
+
|
|
536
|
+
插件基于 Tiptap,建议保留以下常�?HTML�?
|
|
537
|
+
|
|
538
|
+
- `strong` / `b`:加粗�?
|
|
539
|
+
- `em` / `i`:斜体�?
|
|
540
|
+
- `u`:下划线�?
|
|
541
|
+
- `s` / `strike`:删除线�?
|
|
542
|
+
- `code`:行内代码�?
|
|
543
|
+
- `mark`:高亮�?
|
|
544
|
+
- `span style="color: ..."`:文字颜色�?
|
|
545
|
+
- `sub` / `sup`:上下标�?
|
|
546
|
+
- `a href="..."`:链接�?
|
|
547
|
+
|
|
548
|
+
后端转换时不需要保�?Word 的全部样式细节,但应保留影响业务阅读和编辑的基础语义�?
|
|
549
|
+
|
|
550
|
+
## 偏移计算规则
|
|
551
|
+
|
|
552
|
+
重复点高亮是否准确,主要取决于三件事�?
|
|
553
|
+
|
|
554
|
+
1. 后端 `plainText` 的生成规则�?
|
|
555
|
+
2. 后端 `DuplicatePoint.ranges[].start/end` 的计算规则�?
|
|
556
|
+
3. 前端 Tiptap 文档中实际文本的顺序�?
|
|
557
|
+
|
|
558
|
+
建议统一以下规则�?
|
|
559
|
+
|
|
560
|
+
- 每个块级节点之间用一�?`\n` 拼接�?
|
|
561
|
+
- 块内文本按视觉阅读顺序提取�?
|
|
562
|
+
- 表格按行、列顺序提取单元格文本�?
|
|
563
|
+
- 空块尽量不参�?`plainText`,除非业务需要保留�?
|
|
564
|
+
- `start/end` 使用 JavaScript 字符串下标语义,�?UTF-16 code unit 偏移�?
|
|
565
|
+
|
|
566
|
+
如果后端使用 Python 计算偏移,需要注意中文通常没有问题,但 emoji、生僻字、组合字符可能造成 Python 字符下标与浏览器 UTF-16 下标不一致。若业务文档可能包含这类字符,建议统一使用 UTF-16 code unit 规则计算偏移,或在前后端增加契约测试�?
|
|
567
|
+
|
|
568
|
+
## 编辑回传与导�?
|
|
569
|
+
|
|
570
|
+
用户编辑后,前端可以通过 `change` 事件或组�?`ref` 获取最新快照:
|
|
571
|
+
|
|
572
|
+
```ts
|
|
573
|
+
interface EditorChangePayload {
|
|
574
|
+
documentId: string;
|
|
575
|
+
html: string;
|
|
576
|
+
plainText: string;
|
|
577
|
+
json?: unknown;
|
|
578
|
+
}
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
后端收到编辑快照后通常有两种处理方式:
|
|
582
|
+
|
|
583
|
+
- 保存为草稿:记录 `documentId`、`html`、`plainText`、`json`、更新时间和编辑人�?
|
|
584
|
+
- 导出 DOCX:将 `html` 包装成完�?HTML 文档,再通过 LibreOffice 转成 `.docx`�?
|
|
585
|
+
|
|
586
|
+
导出建议优先使用 LibreOffice�?
|
|
587
|
+
|
|
588
|
+
```bash
|
|
589
|
+
soffice --headless --convert-to docx --outdir ./converted ./edited.html
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
如果没有 LibreOffice,可以用 `python-docx` 做纯文本兜底导出,但复杂表格、图片、样式会明显丢失�?
|
|
593
|
+
|
|
594
|
+
## 最小后端伪代码
|
|
595
|
+
|
|
596
|
+
```py
|
|
597
|
+
def build_view_data(main_file, slave_files):
|
|
598
|
+
main_document = upload_to_normalized_document(main_file, role="main")
|
|
599
|
+
slave_documents = [
|
|
600
|
+
upload_to_normalized_document(file, role="slave")
|
|
601
|
+
for file in slave_files
|
|
602
|
+
]
|
|
603
|
+
|
|
604
|
+
documents = [main_document, *slave_documents]
|
|
605
|
+
duplicates = duplicate_engine(documents)
|
|
606
|
+
|
|
607
|
+
return {
|
|
608
|
+
"code": 0,
|
|
609
|
+
"message": "ok",
|
|
610
|
+
"data": {
|
|
611
|
+
"project": {
|
|
612
|
+
"projectId": "project-001",
|
|
613
|
+
"name": "查重项目",
|
|
614
|
+
"mainDocumentId": main_document["documentId"],
|
|
615
|
+
},
|
|
616
|
+
"documents": documents,
|
|
617
|
+
"duplicates": duplicates,
|
|
618
|
+
"activeDefaults": {
|
|
619
|
+
"duplicateId": duplicates[0]["duplicateId"] if duplicates else None,
|
|
620
|
+
"slaveDocumentId": duplicates[0]["slaves"][0]["documentId"] if duplicates and duplicates[0]["slaves"] else None,
|
|
621
|
+
},
|
|
622
|
+
"stats": {
|
|
623
|
+
"documentTotal": len(documents),
|
|
624
|
+
"duplicateTotal": len(duplicates),
|
|
625
|
+
"rangeMapTotal": sum(len(doc["rangeMap"]) for doc in documents),
|
|
626
|
+
"plainTextTotal": sum(len(doc["plainText"]) for doc in documents),
|
|
627
|
+
},
|
|
628
|
+
"capabilities": {
|
|
629
|
+
"supportedFileTypes": ["DOC", "DOCX"],
|
|
630
|
+
"supportedContentTypes": ["标题", "段落", "表格", "列表", "图片", "基础行内样式"],
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
"meta": {
|
|
634
|
+
"traceId": "trace-id",
|
|
635
|
+
"timestamp": "2026-05-14T16:00:00+08:00",
|
|
636
|
+
},
|
|
637
|
+
}
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
## 前后端联调检查清�?
|
|
641
|
+
|
|
642
|
+
- 前端已导�?`@jhl548/duplicate-doc-vue/style.css`�?
|
|
643
|
+
- `documentModel.documentId` �?`highlights[].documentId` 一致�?
|
|
644
|
+
- `DuplicatePoint.main.documentId` 能匹配主文档�?
|
|
645
|
+
- `DuplicatePoint.slaves[].documentId` 能匹配从文档�?
|
|
646
|
+
- `TextRange.start/end` 不越界�?
|
|
647
|
+
- `rangeMap.textStart/textEnd` �?`plainText` 对齐�?
|
|
648
|
+
- 后端块级换行规则与插件映射规则一致�?
|
|
649
|
+
- 表格、列表、标题、图片在 HTML 中仍�?Tiptap 可解析结构�?
|
|
650
|
+
- 编辑�?`change` 事件能返回最�?HTML �?plainText�?
|
|
651
|
+
- 导出 DOCX 时后端能接受前端回传�?HTML�?
|
|
652
|
+
|
|
653
|
+
## 常见问题
|
|
654
|
+
|
|
655
|
+
### 高亮位置偏了怎么�?
|
|
656
|
+
|
|
657
|
+
优先检查后�?`plainText` 是否和插件渲染后的文本顺序一致。常见原因是表格单元格分隔符、列表项换行、空段落、HTML 实体、Word 导出冗余节点导致偏移不一致�?
|
|
658
|
+
|
|
659
|
+
### 样式没有生效怎么�?
|
|
660
|
+
|
|
661
|
+
确认应用入口已经导入�?
|
|
662
|
+
|
|
663
|
+
```ts
|
|
664
|
+
import "@jhl548/duplicate-doc-vue/style.css";
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
如果宿主项目�?CSS reset �?scoped 样式覆盖插件样式,需要检�?`.dupdoc-editor`、`.dupdoc-editor__toolbar`、`.dupdoc-highlight` �?class 是否被覆盖�?
|
|
668
|
+
|
|
669
|
+
### 能否只传 HTML,不�?plainText �?rangeMap
|
|
670
|
+
|
|
671
|
+
不建议。插件可以只�?HTML 渲染编辑器,但重复点高亮依赖 `plainText` 偏移和后端算法输出。缺�?`plainText` �?`rangeMap` 会让联调和问题排查变得困难�?
|
|
672
|
+
|
|
673
|
+
### 重复点范围能否直接用 DOM selector
|
|
674
|
+
|
|
675
|
+
当前插件的高亮核心是纯文本偏移到 ProseMirror 位置的映射。`selector` 可以作为调试和扩展字段,但不建议�?DOM selector 作为唯一定位依据,因为用户编辑后 DOM 结构可能变化�?
|
|
676
|
+
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { DuplicateHighlight, EditorChangePayload, NormalizedDocument } from '@jhl548/duplicate-doc-core';
|
|
2
|
+
type __VLS_Props = {
|
|
3
|
+
documentModel: NormalizedDocument;
|
|
4
|
+
highlights?: DuplicateHighlight[];
|
|
5
|
+
editable?: boolean;
|
|
6
|
+
autofocusHighlight?: boolean;
|
|
7
|
+
};
|
|
8
|
+
interface SnapshotSource {
|
|
9
|
+
getHTML: () => string;
|
|
10
|
+
getText: () => string;
|
|
11
|
+
getJSON: () => unknown;
|
|
12
|
+
}
|
|
13
|
+
declare function getSnapshot(targetEditor?: SnapshotSource): EditorChangePayload;
|
|
14
|
+
declare const __VLS_export: import('vue').DefineComponent<__VLS_Props, {
|
|
15
|
+
getSnapshot: typeof getSnapshot;
|
|
16
|
+
getHTML: () => string;
|
|
17
|
+
getPlainText: () => string;
|
|
18
|
+
focus: () => boolean | undefined;
|
|
19
|
+
}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {
|
|
20
|
+
change: (payload: EditorChangePayload) => any;
|
|
21
|
+
ready: () => any;
|
|
22
|
+
}, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
23
|
+
onChange?: ((payload: EditorChangePayload) => any) | undefined;
|
|
24
|
+
onReady?: (() => any) | undefined;
|
|
25
|
+
}>, {
|
|
26
|
+
highlights: DuplicateHighlight[];
|
|
27
|
+
editable: boolean;
|
|
28
|
+
autofocusHighlight: boolean;
|
|
29
|
+
}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, any>;
|
|
30
|
+
declare const _default: typeof __VLS_export;
|
|
31
|
+
export default _default;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { default as DuplicateDocumentEditor } from './DuplicateDocumentEditor.vue';
|
|
2
|
+
import { EditorChangePayload } from '@jhl548/duplicate-doc-core';
|
|
3
|
+
export { DuplicateDocumentEditor };
|
|
4
|
+
export interface DuplicateDocumentEditorRef {
|
|
5
|
+
getSnapshot: () => EditorChangePayload;
|
|
6
|
+
getHTML: () => string;
|
|
7
|
+
getPlainText: () => string;
|
|
8
|
+
focus: () => void;
|
|
9
|
+
}
|
|
10
|
+
export * from '@jhl548/duplicate-doc-core';
|