Skip to content

Commit 3ed8dcd

Browse files
committed
fix: persist deselected problem paragraph associations
1 parent 5738af7 commit 3ed8dcd

4 files changed

Lines changed: 239 additions & 15 deletions

File tree

‎ui/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ OSS 文件接口。前端业务接口需要可用的后端服务。
6161
`npm run lint` 当前仅配置了 `run-s "lint:*"`,但尚未定义任何 `lint:*` 子脚本,不能作为已执行
6262
ESLint 检查的依据。需要检查代码时,可运行 `npx eslint <待检查的文件或目录>`。
6363

64-
路由菜单与知识库角色权限回归测试使用 Node 内置测试运行器:`node --test tests/*.test.mjs`。
64+
路由菜单、知识库角色权限与问题分段关联回归测试使用 Node 内置测试运行器:`node --test tests/*.test.mjs`。
6565

6666
默认构建分别输出 `dist/admin/index.html` 和 `dist/chat/index.html`,HTML 由构建插件统一重命名。
6767
静态资源使用相对路径(`base: './'`);部署时仍需为 History 路由配置对应入口回退,

‎ui/src/views/VIEW_README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -282,8 +282,9 @@ System 资源管理或 System 共享 Knowledge API 的 `postKnowledgeHitTest`;
282282
图片原图引用同时传给分段详情;请求失败由请求层统一提示,保留当前关联内容。抽屉暴露 `refresh()`,由问题页在批量关联成功后刷新当前关联分段。
283283
单项、批量、详情的“关联分段”入口统一使用 `AssociateParagraphDialog`,勾选仅维护本次选择,
284284
跨文档、图片类型及搜索保留选择;单个问题打开时查询已有关系并回填勾选,所有选项均可切换,批量问题打开时只维护新增选择。
285-
确认后通过 `putBatchAssociateProblems` 一次提交问题与新增分段列表;没有新增选择时直接关闭。
286-
成功关闭并刷新问题列表,失败保留选择,取消不提交。解除关联仍由问题详情维护。
285+
确认后通过 `putBatchAssociateProblems` 一次提交问题与新增分段列表;单个问题取消已有勾选时,
286+
通过 Paragraph API 的 `putUnassociateParagraphProblem` 逐项解除关联,支持全部取消;没有变更时直接关闭。
287+
成功关闭并刷新问题列表,失败保留选择与已完成操作的关系快照,重试仅提交剩余差异;取消不提交。
287288
Workspace 共享只展示和搜索问题列表,不显示维护入口,也不打开缺少查询接口的问题详情。
288289

289290
## 文档分段

‎ui/src/views/knowledge-detail/problem/components/AssociateParagraphDialog.vue‎

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,13 @@ const selectedDocumentId = ref('')
2626
const paragraphSearchText = ref('')
2727
const paragraphs = ref<ParagraphItem[]>([])
2828
const selectedParagraphs = ref<ProblemParagraphReference[]>([])
29-
const associatedParagraphIds = ref(new Set<string>())
29+
const associatedParagraphs = ref<ProblemParagraphReference[]>([])
3030
const infiniteScrollRef = useTemplateRef<{ reset: () => Promise<void> }>('infiniteScrollRef')
3131
const paragraphLayoutRef = useTemplateRef<InstanceType<typeof MkViewLayout>>('paragraphLayoutRef')
3232
3333
const selectedDocument = computed(() => documentData.value.find(({ id }) => id === selectedDocumentId.value))
3434
const selectedParagraphIds = computed(() => new Set(selectedParagraphs.value.map(({ paragraph_id }) => paragraph_id)))
35+
const associatedParagraphIds = computed(() => new Set(associatedParagraphs.value.map(({ paragraph_id }) => paragraph_id)))
3536
3637
function getSelectedCount(documentId: string) {
3738
return selectedParagraphs.value.filter(({ document_id }) => document_id === documentId).length
@@ -88,7 +89,7 @@ function handleParagraphSearch() {
8889
return refreshParagraphs()
8990
}
9091
91-
// 已关联项回显后也可切换勾选,确认时只提交新增关联。
92+
// 勾选仅修改草稿,确认时保存与已有关系的差异。
9293
function handleToggleParagraph(paragraph: ParagraphItem) {
9394
if (operationLoading.value) return
9495
if (isSelected(paragraph.id)) {
@@ -99,20 +100,33 @@ function handleToggleParagraph(paragraph: ParagraphItem) {
99100
}
100101
101102
function handleSubmit() {
102-
if (operationLoading.value || documentLoading.value || !problemIds.value.length) return
103+
const problemId = problemIds.value[0]
104+
if (operationLoading.value || documentLoading.value || !problemId) return
103105
const paragraphReferences = selectedParagraphs.value.filter(({ paragraph_id }) => !associatedParagraphIds.value.has(paragraph_id))
104-
if (!paragraphReferences.length) {
106+
const unassociatedParagraphs = associatedParagraphs.value.filter(({ paragraph_id }) => !selectedParagraphIds.value.has(paragraph_id))
107+
if (!paragraphReferences.length && !unassociatedParagraphs.length) {
105108
visible.value = false
106109
return
107110
}
108111
operationLoading.value = true
109-
return resourceApi.problem
110-
.putBatchAssociateProblems(knowledgeId.value, {
111-
problem_id_list: [...problemIds.value],
112-
paragraph_list: paragraphReferences,
112+
const associationRequest = paragraphReferences.length
113+
? resourceApi.problem.putBatchAssociateProblems(knowledgeId.value, {
114+
problem_id_list: [...problemIds.value],
115+
paragraph_list: paragraphReferences,
116+
})
117+
: Promise.resolve()
118+
return associationRequest
119+
.then(async () => {
120+
// 每次成功后更新关系快照,部分失败重试时不重复提交已完成的操作。
121+
associatedParagraphs.value.push(...paragraphReferences)
122+
for (const { document_id, paragraph_id } of unassociatedParagraphs) {
123+
await resourceApi.paragraph.putUnassociateParagraphProblem(knowledgeId.value, document_id, paragraph_id, problemId).then(() => {
124+
associatedParagraphs.value = associatedParagraphs.value.filter((paragraph) => paragraph.paragraph_id !== paragraph_id)
125+
})
126+
}
113127
})
114128
.then(() => {
115-
MsgSuccess('关联成功')
129+
MsgSuccess('保存成功')
116130
visible.value = false
117131
emit('success')
118132
})
@@ -134,8 +148,8 @@ function open(targetProblemIds: string[]) {
134148
targetProblemIds.length === 1 ? resourceApi.problem.getProblemDetail(knowledgeId.value, targetProblemIds[0]) : [],
135149
])
136150
.then(([, problemParagraphs]) => {
137-
associatedParagraphIds.value = new Set(problemParagraphs.map(({ id }) => id))
138-
selectedParagraphs.value = problemParagraphs.map(({ id, document_id }) => ({ paragraph_id: id, document_id }))
151+
associatedParagraphs.value = problemParagraphs.map(({ id, document_id }) => ({ paragraph_id: id, document_id }))
152+
selectedParagraphs.value = [...associatedParagraphs.value]
139153
})
140154
.catch(() => {
141155
// 请求层统一提示错误,失败时保持空列表。
@@ -153,7 +167,7 @@ function handleClosed() {
153167
paragraphSearchText.value = ''
154168
paragraphs.value = []
155169
selectedParagraphs.value = []
156-
associatedParagraphIds.value.clear()
170+
associatedParagraphs.value = []
157171
resourceType.value = DOCUMENT_RESOURCE_TYPE.DOCUMENT
158172
}
159173
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import assert from 'node:assert/strict'
2+
import { readFileSync } from 'node:fs'
3+
import { test } from 'node:test'
4+
import { compileFunction } from 'node:vm'
5+
import { compileScript, parse } from '@vue/compiler-sfc'
6+
import ts from 'typescript'
7+
import * as Vue from 'vue'
8+
9+
const filename = new URL('../src/views/knowledge-detail/problem/components/AssociateParagraphDialog.vue', import.meta.url)
10+
const { descriptor } = parse(readFileSync(filename, 'utf8'))
11+
const script = compileScript(descriptor, { id: 'problem-association-test' })
12+
const { outputText } = ts.transpileModule(script.content, {
13+
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
14+
})
15+
16+
// 执行实际弹窗的 setup 和交互方法,仅替换页面上下文、请求与无 DOM 的模板引用。
17+
function createDialog(initialParagraphs = [], { associate, unassociate } = {}) {
18+
const calls = []
19+
const events = []
20+
const messages = []
21+
const resourceApi = {
22+
document: { getAllDocuments: async () => [] },
23+
problem: {
24+
getProblemDetail: async (...args) => {
25+
calls.push(['detail', ...args])
26+
return initialParagraphs
27+
},
28+
putBatchAssociateProblems: async (...args) => {
29+
calls.push(['associate', ...args])
30+
await associate?.(...args)
31+
},
32+
},
33+
paragraph: {
34+
putUnassociateParagraphProblem: async (...args) => {
35+
calls.push(['unassociate', ...args])
36+
await unassociate?.(...args)
37+
},
38+
},
39+
}
40+
const dependencies = {
41+
vue: { ...Vue, useTemplateRef: () => Vue.ref() },
42+
'@/components/mk-search-list/index.vue': {},
43+
'@/api/enums': { DOCUMENT_RESOURCE_TYPE: { DOCUMENT: 'DOCUMENT', IMAGE: 'IMAGE' } },
44+
'@/constants/knowledge': { KNOWLEDGE_TYPE_KEY: {}, KNOWLEDGE_TYPE_MAP: {} },
45+
'@/utils/common': { getFileUrl: () => '' },
46+
'@/utils/icon': { getFileIconUrl: () => '' },
47+
'@/utils/message': { MsgSuccess: (message) => messages.push(message) },
48+
'@/views/knowledge-detail/context': {
49+
useKnowledgeDetailContext: () => ({ knowledgeId: Vue.ref('knowledge-a'), resourceApi }),
50+
},
51+
}
52+
const exports = {}
53+
compileFunction(outputText, ['require', 'exports'])((id) => {
54+
assert.ok(Object.hasOwn(dependencies, id), `Unexpected dependency: ${id}`)
55+
return dependencies[id]
56+
}, exports)
57+
const dialog = exports.default.setup({}, { expose() {}, emit: (event) => events.push(event) })
58+
return { dialog, calls, events, messages }
59+
}
60+
61+
const webParagraph = { id: 'paragraph-web', document_id: 'document-pdf' }
62+
const textParagraph = { id: 'paragraph-text', document_id: 'document-text' }
63+
const imageParagraph = { id: 'paragraph-image', document_id: 'document-image' }
64+
65+
test('取消唯一已关联分段后确认,提交解除关联并刷新列表', async () => {
66+
const { dialog, calls, events } = createDialog([webParagraph])
67+
await dialog.open(['problem-a'])
68+
dialog.handleToggleParagraph(webParagraph)
69+
assert.equal(dialog.selectedParagraphs.value.length, 0)
70+
await dialog.handleSubmit()
71+
assert.deepEqual(calls, [
72+
['detail', 'knowledge-a', 'problem-a'],
73+
['unassociate', 'knowledge-a', 'document-pdf', 'paragraph-web', 'problem-a'],
74+
])
75+
assert.equal(dialog.visible.value, false)
76+
assert.deepEqual(events, ['success'])
77+
})
78+
79+
test('同时新增和取消跨文档分段,只提交差异并保留未变更关联', async () => {
80+
const { dialog, calls } = createDialog([webParagraph, textParagraph])
81+
await dialog.open(['problem-a'])
82+
dialog.handleToggleParagraph(webParagraph)
83+
dialog.handleToggleParagraph(imageParagraph)
84+
await dialog.handleSubmit()
85+
assert.deepEqual(calls.slice(1), [
86+
[
87+
'associate',
88+
'knowledge-a',
89+
{
90+
problem_id_list: ['problem-a'],
91+
paragraph_list: [{ paragraph_id: 'paragraph-image', document_id: 'document-image' }],
92+
},
93+
],
94+
['unassociate', 'knowledge-a', 'document-pdf', 'paragraph-web', 'problem-a'],
95+
])
96+
})
97+
98+
test('恢复原勾选后确认不发写请求,取消关闭也不保存草稿', async () => {
99+
const { dialog, calls, events } = createDialog([webParagraph])
100+
await dialog.open(['problem-a'])
101+
dialog.handleToggleParagraph(webParagraph)
102+
dialog.handleToggleParagraph(webParagraph)
103+
await dialog.handleSubmit()
104+
assert.equal(dialog.visible.value, false)
105+
assert.equal(calls.length, 1)
106+
assert.deepEqual(events, [])
107+
dialog.handleClosed()
108+
await dialog.open(['problem-a'])
109+
dialog.handleToggleParagraph(webParagraph)
110+
dialog.visible.value = false
111+
dialog.handleClosed()
112+
await dialog.open(['problem-a'])
113+
assert.equal(dialog.isSelected(webParagraph.id), true)
114+
assert.ok(calls.every(([method]) => method === 'detail'))
115+
})
116+
117+
test('批量问题继续只添加所选分段,不查询或解除各问题原有关联', async () => {
118+
const { dialog, calls } = createDialog([webParagraph])
119+
await dialog.open(['problem-a', 'problem-b'])
120+
dialog.handleToggleParagraph(imageParagraph)
121+
await dialog.handleSubmit()
122+
assert.deepEqual(calls, [
123+
[
124+
'associate',
125+
'knowledge-a',
126+
{
127+
problem_id_list: ['problem-a', 'problem-b'],
128+
paragraph_list: [{ paragraph_id: 'paragraph-image', document_id: 'document-image' }],
129+
},
130+
],
131+
])
132+
})
133+
134+
test('解除失败保留选择,恢复提交状态并允许重试', async () => {
135+
let failing = true
136+
const { dialog, calls, events, messages } = createDialog([webParagraph], {
137+
unassociate: async () => {
138+
if (failing) throw new Error('Failed to unlink')
139+
},
140+
})
141+
await dialog.open(['problem-a'])
142+
dialog.handleToggleParagraph(webParagraph)
143+
await dialog.handleSubmit()
144+
assert.equal(dialog.visible.value, true)
145+
assert.equal(dialog.isSelected(webParagraph.id), false)
146+
assert.equal(dialog.operationLoading.value, false)
147+
assert.deepEqual(events, [])
148+
assert.deepEqual(messages, [])
149+
failing = false
150+
await dialog.handleSubmit()
151+
assert.equal(calls.filter(([method]) => method === 'unassociate').length, 2)
152+
assert.equal(dialog.visible.value, false)
153+
assert.deepEqual(events, ['success'])
154+
})
155+
156+
test('部分成功后重试跳过已解除项,重新勾选已解除项时能补回关联', async () => {
157+
for (const reselect of [false, true]) {
158+
let failing = true
159+
const { dialog, calls } = createDialog([webParagraph, textParagraph], {
160+
unassociate: async (_knowledgeId, _documentId, paragraphId) => {
161+
if (paragraphId === textParagraph.id && failing) throw new Error('Failed to unlink')
162+
},
163+
})
164+
await dialog.open(['problem-a'])
165+
dialog.handleToggleParagraph(webParagraph)
166+
dialog.handleToggleParagraph(textParagraph)
167+
await dialog.handleSubmit()
168+
assert.equal(dialog.visible.value, true)
169+
failing = false
170+
if (reselect) dialog.handleToggleParagraph(webParagraph)
171+
const completedCalls = calls.length
172+
await dialog.handleSubmit()
173+
const expectedCalls = [['unassociate', 'knowledge-a', 'document-text', 'paragraph-text', 'problem-a']]
174+
if (reselect)
175+
expectedCalls.unshift([
176+
'associate',
177+
'knowledge-a',
178+
{
179+
problem_id_list: ['problem-a'],
180+
paragraph_list: [{ paragraph_id: 'paragraph-web', document_id: 'document-pdf' }],
181+
},
182+
])
183+
assert.deepEqual(calls.slice(completedCalls), expectedCalls)
184+
assert.equal(dialog.visible.value, false)
185+
}
186+
})
187+
188+
test('新增成功但解除失败后,可以取消刚新增的关联并再次提交', async () => {
189+
let failing = true
190+
const { dialog, calls } = createDialog([webParagraph], {
191+
unassociate: async () => {
192+
if (failing) throw new Error('Failed to unlink')
193+
},
194+
})
195+
await dialog.open(['problem-a'])
196+
dialog.handleToggleParagraph(webParagraph)
197+
dialog.handleToggleParagraph(imageParagraph)
198+
await dialog.handleSubmit()
199+
assert.equal(dialog.visible.value, true)
200+
failing = false
201+
dialog.handleToggleParagraph(imageParagraph)
202+
const completedCalls = calls.length
203+
await dialog.handleSubmit()
204+
assert.deepEqual(calls.slice(completedCalls), [
205+
['unassociate', 'knowledge-a', 'document-pdf', 'paragraph-web', 'problem-a'],
206+
['unassociate', 'knowledge-a', 'document-image', 'paragraph-image', 'problem-a'],
207+
])
208+
assert.equal(dialog.visible.value, false)
209+
})

0 commit comments

Comments
 (0)