feat: 支持知识库文档批量删除与分块内联编辑
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import { ElPagination } from 'element-plus';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import PageData from './PageData.vue';
|
||||
@@ -202,4 +203,73 @@ describe('page data recovery', () => {
|
||||
params: { pageNumber: 1, pageSize: 10, title: '月报' },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the current page when an unsaved-change guard rejects navigation', async () => {
|
||||
const get = vi.fn().mockResolvedValue({
|
||||
data: { records: [{ id: 'row' }], totalRow: 30 },
|
||||
});
|
||||
const beforePageChange = vi.fn().mockResolvedValue(false);
|
||||
const wrapper = mount(PageData, {
|
||||
global: { directives: { loading: {} } },
|
||||
props: {
|
||||
beforePageChange,
|
||||
pageUrl: '/page',
|
||||
requestClient: { get },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
wrapper.findComponent(ElPagination).vm.$emit('current-change', 2);
|
||||
await flushPromises();
|
||||
|
||||
expect(beforePageChange).toHaveBeenCalledTimes(1);
|
||||
expect((wrapper.vm as any).getPageState().pageNumber).toBe(1);
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('removes a persisted row locally without waiting for another page request', async () => {
|
||||
const get = vi.fn().mockResolvedValue({
|
||||
data: { records: [{ id: 'a' }, { id: 'b' }], totalRow: 2 },
|
||||
});
|
||||
const wrapper = mount(PageData, {
|
||||
global: { directives: { loading: {} } },
|
||||
props: { pageUrl: '/page', requestClient: { get } },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect((wrapper.vm as any).removeRowById('a')).toBe(true);
|
||||
await flushPromises();
|
||||
|
||||
expect((wrapper.vm as any).getPageRows()).toEqual([{ id: 'b' }]);
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('silently backfills the current page after deleting from a non-last page', async () => {
|
||||
const initialRows = Array.from({ length: 10 }, (_, index) => ({
|
||||
id: `row-${index + 1}`,
|
||||
}));
|
||||
const backfilledRows = [
|
||||
...initialRows.slice(1),
|
||||
{ id: 'row-11' },
|
||||
];
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
data: { records: initialRows, totalRow: 11 },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: { records: backfilledRows, totalRow: 10 },
|
||||
});
|
||||
const wrapper = mount(PageData, {
|
||||
global: { directives: { loading: {} } },
|
||||
props: { pageUrl: '/page', requestClient: { get } },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect((wrapper.vm as any).removeRowById('row-1')).toBe(true);
|
||||
await flushPromises();
|
||||
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
expect((wrapper.vm as any).getPageRows()).toEqual(backfilledRows);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,9 @@ import { api } from '#/api/request';
|
||||
import { getEmptyStateImageUrl } from '#/utils/assets';
|
||||
|
||||
interface PageDataProps {
|
||||
beforePageChange?: () => boolean | Promise<boolean>;
|
||||
pageUrl: string;
|
||||
refreshUrl?: string;
|
||||
pageSize?: number;
|
||||
pageSizes?: number[];
|
||||
extraQueryParams?: Record<string, any>;
|
||||
@@ -29,23 +31,40 @@ interface PageDataRestoreState extends PageDataState {
|
||||
}
|
||||
|
||||
interface PageDataReloadOptions {
|
||||
lightweight?: boolean;
|
||||
silent?: boolean;
|
||||
}
|
||||
|
||||
interface PageDataRequest {
|
||||
lightweight: boolean;
|
||||
silent: boolean;
|
||||
version: number;
|
||||
}
|
||||
|
||||
interface PageDataLoadEvent {
|
||||
lightweight: boolean;
|
||||
pageNumber: number;
|
||||
recordCount: number;
|
||||
silent: boolean;
|
||||
}
|
||||
|
||||
interface PageDataLoadErrorEvent extends PageDataLoadEvent {
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<PageDataProps>(), {
|
||||
beforePageChange: undefined,
|
||||
pageSize: 10,
|
||||
pageSizes: () => [10, 20, 50, 100],
|
||||
refreshUrl: undefined,
|
||||
extraQueryParams: () => ({}),
|
||||
initialPageNumber: 1,
|
||||
initialQueryParams: () => ({}),
|
||||
requestClient: () => api,
|
||||
});
|
||||
const emit = defineEmits<{
|
||||
(e: 'loadError', event: PageDataLoadErrorEvent): void;
|
||||
(e: 'loadSuccess', event: PageDataLoadEvent): void;
|
||||
(e: 'stateChange', state: PageDataState): void;
|
||||
}>();
|
||||
|
||||
@@ -67,10 +86,10 @@ const pageInfo = reactive({
|
||||
});
|
||||
|
||||
// 模拟 API 调用 - 这里需要根据你的实际 API 调用方式调整
|
||||
const doGet = async (params: Record<string, any>) => {
|
||||
const doGet = async (url: string, params: Record<string, any>) => {
|
||||
// 这里替换为你的实际 API 调用
|
||||
// 例如:return await api.get(props.pageUrl, { params })
|
||||
const response = await props.requestClient.get(`${props.pageUrl}`, {
|
||||
const response = await props.requestClient.get(url, {
|
||||
params,
|
||||
});
|
||||
const data = await response.data;
|
||||
@@ -78,14 +97,29 @@ const doGet = async (params: Record<string, any>) => {
|
||||
};
|
||||
|
||||
const loadPageListOnce = async (request: PageDataRequest) => {
|
||||
const lightweight = Boolean(
|
||||
request.lightweight && props.refreshUrl && pageInfo.pageNumber === 1,
|
||||
);
|
||||
try {
|
||||
const res = await doGet({
|
||||
const res = await doGet(lightweight ? props.refreshUrl! : props.pageUrl, {
|
||||
pageNumber: pageInfo.pageNumber,
|
||||
pageSize: pageInfo.pageSize,
|
||||
...props.extraQueryParams,
|
||||
...queryParams.value,
|
||||
});
|
||||
if (request.version === pageRequestVersion) {
|
||||
if (lightweight) {
|
||||
pageList.value = Array.isArray(res.data)
|
||||
? res.data
|
||||
: res.data?.records || [];
|
||||
emit('loadSuccess', {
|
||||
lightweight: true,
|
||||
pageNumber: pageInfo.pageNumber,
|
||||
recordCount: pageList.value.length,
|
||||
silent: request.silent,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const rawTotal = Number(res.data?.totalRow || 0);
|
||||
const total = Number.isFinite(rawTotal) ? Math.max(0, rawTotal) : 0;
|
||||
const lastPage = Math.max(1, Math.ceil(total / pageInfo.pageSize));
|
||||
@@ -96,6 +130,12 @@ const loadPageListOnce = async (request: PageDataRequest) => {
|
||||
return;
|
||||
}
|
||||
pageList.value = res.data?.records || [];
|
||||
emit('loadSuccess', {
|
||||
lightweight: false,
|
||||
pageNumber: pageInfo.pageNumber,
|
||||
recordCount: pageList.value.length,
|
||||
silent: request.silent,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (request.version === pageRequestVersion) {
|
||||
@@ -104,12 +144,25 @@ const loadPageListOnce = async (request: PageDataRequest) => {
|
||||
pageList.value = [];
|
||||
pageInfo.total = 0;
|
||||
}
|
||||
emit('loadError', {
|
||||
error,
|
||||
lightweight,
|
||||
pageNumber: pageInfo.pageNumber,
|
||||
recordCount: pageList.value.length,
|
||||
silent: request.silent,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const requestPageList = (silent: boolean) => {
|
||||
const requestPageList = (silent: boolean, lightweight = false) => {
|
||||
// 自动刷新只补充最新行;已有请求能够提供同等或更完整的数据时直接复用,
|
||||
// 避免它使正在进行的分页查询失效或额外排队。
|
||||
if (activePageRequest && lightweight) {
|
||||
return activePageRequest;
|
||||
}
|
||||
const request: PageDataRequest = {
|
||||
lightweight,
|
||||
silent: silent && pageList.value.length > 0,
|
||||
version: ++pageRequestVersion,
|
||||
};
|
||||
@@ -120,6 +173,7 @@ const requestPageList = (silent: boolean) => {
|
||||
if (activePageRequest) {
|
||||
pendingPageRequest = pendingPageRequest
|
||||
? {
|
||||
lightweight: pendingPageRequest.lightweight && request.lightweight,
|
||||
silent: pendingPageRequest.silent && request.silent,
|
||||
version: request.version,
|
||||
}
|
||||
@@ -143,19 +197,27 @@ const requestPageList = (silent: boolean) => {
|
||||
};
|
||||
|
||||
// 获取页面数据
|
||||
const getPageList = () => requestPageList(false);
|
||||
const getPageList = () => requestPageList(false, false);
|
||||
|
||||
const reload = (options: PageDataReloadOptions = {}) =>
|
||||
requestPageList(Boolean(options.silent));
|
||||
requestPageList(Boolean(options.silent), Boolean(options.lightweight));
|
||||
|
||||
// 分页事件处理
|
||||
const handleSizeChange = (newSize: number) => {
|
||||
const canChangePage = async () => (await props.beforePageChange?.()) !== false;
|
||||
|
||||
const handleSizeChange = async (newSize: number) => {
|
||||
if (!(await canChangePage())) {
|
||||
return;
|
||||
}
|
||||
pageInfo.pageSize = newSize;
|
||||
pageInfo.pageNumber = 1; // 重置到第一页
|
||||
emitPageState();
|
||||
};
|
||||
|
||||
const handleCurrentChange = (newPage: number) => {
|
||||
const handleCurrentChange = async (newPage: number) => {
|
||||
if (!(await canChangePage())) {
|
||||
return;
|
||||
}
|
||||
pageInfo.pageNumber = newPage;
|
||||
emitPageState();
|
||||
};
|
||||
@@ -165,6 +227,8 @@ const getPageState = (): PageDataState => ({
|
||||
pageSize: pageInfo.pageSize,
|
||||
});
|
||||
|
||||
const getPageRows = () => pageList.value;
|
||||
|
||||
function normalizeQueryParams(value: Record<string, any> = {}) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter(([, item]) => item !== undefined),
|
||||
@@ -206,6 +270,31 @@ const patchRowById = (
|
||||
return true;
|
||||
};
|
||||
|
||||
const removeRowById = (id: number | string): boolean => {
|
||||
const rowIndex = pageList.value.findIndex(
|
||||
(item) => String(item?.id ?? '') === String(id ?? ''),
|
||||
);
|
||||
if (rowIndex === -1) return false;
|
||||
const nextPageList = [...pageList.value];
|
||||
nextPageList.splice(rowIndex, 1);
|
||||
pageList.value = nextPageList;
|
||||
pageInfo.total = Math.max(0, pageInfo.total - 1);
|
||||
if (pageList.value.length === 0 && pageInfo.pageNumber > 1) {
|
||||
pageInfo.pageNumber--;
|
||||
emitPageState();
|
||||
} else {
|
||||
const pageStart = (pageInfo.pageNumber - 1) * pageInfo.pageSize;
|
||||
const expectedRowCount = Math.min(
|
||||
pageInfo.pageSize,
|
||||
Math.max(0, pageInfo.total - pageStart),
|
||||
);
|
||||
if (pageList.value.length < expectedRowCount) {
|
||||
void reload({ silent: true });
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// 暴露给父组件的方法 (替代 useImperativeHandle)
|
||||
const setQuery = (newQueryParams: Record<string, any>) => {
|
||||
pageInfo.pageNumber = 1;
|
||||
@@ -237,9 +326,11 @@ const restoreState = (state: PageDataRestoreState) => {
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
getPageRows,
|
||||
getPageState,
|
||||
reload,
|
||||
patchRowById,
|
||||
removeRowById,
|
||||
restoreState,
|
||||
setQuery,
|
||||
});
|
||||
@@ -271,8 +362,8 @@ onMounted(() => {
|
||||
class="page-data-container__pagination mx-auto mt-6 w-fit"
|
||||
>
|
||||
<ElPagination
|
||||
v-model:current-page="pageInfo.pageNumber"
|
||||
v-model:page-size="pageInfo.pageSize"
|
||||
:current-page="pageInfo.pageNumber"
|
||||
:page-size="pageInfo.pageSize"
|
||||
:total="pageInfo.total"
|
||||
:page-sizes="pageSizes"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
|
||||
@@ -269,5 +269,24 @@
|
||||
"dimensionOfVectorModelTips": "After successful vector data, it is not allowed to modify the dimensions of the vector model",
|
||||
"dimensionOfVectorModel": "Dimension of vector model",
|
||||
"managePermissionHint": "Only the creator or super admin can modify this knowledge base",
|
||||
"processingDeleteBlocked": "Documents in progress cannot be deleted"
|
||||
"processingDeleteBlocked": "Documents in progress cannot be deleted",
|
||||
"selectedCount": "{count} selected",
|
||||
"batchDelete": "Delete",
|
||||
"cancelSelection": "Clear selection",
|
||||
"batchDeleteConfirm": "Delete the selected {count} documents? In-progress items are excluded. This cannot be undone.",
|
||||
"batchDeleteSuccess": "Deleted {count} documents",
|
||||
"batchDeletePartial": "Deletion finished: {success} succeeded, {failed} failed",
|
||||
"chunkUnsavedTitle": "Leave editing",
|
||||
"chunkUnsavedConfirm": "Discard unsaved changes and leave?",
|
||||
"continueEditing": "Keep editing",
|
||||
"discardChanges": "Discard changes",
|
||||
"chunkSourceFallback": "Switched to source editing",
|
||||
"chunkSyncPending": "Updating search index",
|
||||
"chunkSyncSucceeded": "Search index updated",
|
||||
"chunkSyncFailed": "Index sync failed. Click to retry",
|
||||
"chunkSyncRetryFailed": "Failed to retry index sync",
|
||||
"chunkLiveMode": "Live",
|
||||
"chunkSourceMode": "Source",
|
||||
"deleteChunk": "Delete chunk",
|
||||
"emptyChunkDeleteConfirm": "This chunk is empty. Saving will delete it permanently."
|
||||
}
|
||||
|
||||
@@ -269,5 +269,24 @@
|
||||
"dimensionOfVectorModelTips": "成功向量数据之后不允许修改向量模型维度",
|
||||
"dimensionOfVectorModel": "向量模型维度",
|
||||
"managePermissionHint": "仅创建者或超级管理员可修改当前知识库",
|
||||
"processingDeleteBlocked": "文档处理中,暂不允许删除"
|
||||
"processingDeleteBlocked": "文档处理中,暂不允许删除",
|
||||
"selectedCount": "已选 {count} 项",
|
||||
"batchDelete": "删除",
|
||||
"cancelSelection": "取消选择",
|
||||
"batchDeleteConfirm": "确认删除选中的 {count} 个文档?处理中项不会删除,删除后无法恢复。",
|
||||
"batchDeleteSuccess": "已删除 {count} 个文档",
|
||||
"batchDeletePartial": "删除完成:成功 {success} 个,失败 {failed} 个",
|
||||
"chunkUnsavedTitle": "离开编辑",
|
||||
"chunkUnsavedConfirm": "当前有未保存修改,确认离开?",
|
||||
"continueEditing": "继续编辑",
|
||||
"discardChanges": "放弃修改",
|
||||
"chunkSourceFallback": "已切换到源码编辑",
|
||||
"chunkSyncPending": "正在更新检索索引",
|
||||
"chunkSyncSucceeded": "检索索引已更新",
|
||||
"chunkSyncFailed": "索引同步失败,点击重试",
|
||||
"chunkSyncRetryFailed": "索引同步重试失败",
|
||||
"chunkLiveMode": "实时",
|
||||
"chunkSourceMode": "源码",
|
||||
"deleteChunk": "删除分块",
|
||||
"emptyChunkDeleteConfirm": "分块内容为空,保存后将删除该分块。删除后无法恢复。"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,781 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import PageData from '#/components/page/PageData.vue';
|
||||
|
||||
import ChunkDocumentTable from './ChunkDocumentTable.vue';
|
||||
|
||||
const tableState = vi.hoisted(() => ({ rows: [] as any[] }));
|
||||
const liveEditorState = vi.hoisted(() => ({
|
||||
flushedMarkdown: undefined as string | undefined,
|
||||
focus: vi.fn(),
|
||||
}));
|
||||
const mountedWrappers: Array<ReturnType<typeof mount>> = [];
|
||||
|
||||
vi.mock('element-plus', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('element-plus')>();
|
||||
const { defineComponent, h } = await import('vue');
|
||||
const ElTable = defineComponent({
|
||||
name: 'ElTable',
|
||||
props: ['data'],
|
||||
setup(props, { slots }) {
|
||||
return () => {
|
||||
tableState.rows = props.data || [];
|
||||
return h('div', { class: 'table-stub' }, slots.default?.());
|
||||
};
|
||||
},
|
||||
});
|
||||
const ElTableColumn = defineComponent({
|
||||
name: 'ElTableColumn',
|
||||
setup(_props, { slots }) {
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{ class: 'table-column-stub' },
|
||||
tableState.rows.map((row) => slots.default?.({ row })),
|
||||
);
|
||||
},
|
||||
});
|
||||
return { ...actual, ElTable, ElTableColumn };
|
||||
});
|
||||
|
||||
vi.mock('#/api/request', () => ({ api: {} }));
|
||||
|
||||
vi.mock('@easyflow/locales', () => ({
|
||||
$t: (key: string) => {
|
||||
const messages: Record<string, string> = {
|
||||
'documentCollection.continueEditing': '继续编辑',
|
||||
'documentCollection.deleteChunk': '删除分块',
|
||||
'documentCollection.emptyChunkDeleteConfirm':
|
||||
'分块内容为空,保存后将删除该分块。删除后无法恢复。',
|
||||
'documentCollection.chunkSyncPending': '正在更新检索索引',
|
||||
'documentCollection.chunkSyncSucceeded': '检索索引已更新',
|
||||
'documentCollection.chunkSyncFailed': '索引同步失败,点击重试',
|
||||
'documentCollection.chunkSyncRetryFailed': '索引同步重试失败',
|
||||
};
|
||||
return messages[key] || key;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('vue-element-plus-x/es/XMarkdown/index.js', () => ({
|
||||
default: {
|
||||
props: ['markdown'],
|
||||
template: '<div class="markdown-output">{{ markdown }}</div>',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@easyflow-core/editor-ui', () => ({
|
||||
containsRawHtml: (markdown: string) => /<\/?[a-z][^>]*>/i.test(markdown),
|
||||
EMPTY_IMAGE_DATA_URL: 'data:image/gif;base64,empty',
|
||||
hasEquivalentMarkdownSemantics: (source: string, normalized: string) =>
|
||||
source.trim() === normalized.trim(),
|
||||
MAX_LIVE_MARKDOWN_CHARACTERS: 300_000,
|
||||
MarkdownSourceEditor: {
|
||||
emits: ['save', 'update:modelValue'],
|
||||
methods: { focus: () => undefined },
|
||||
name: 'MarkdownSourceEditor',
|
||||
props: ['highlight', 'modelValue', 'readonly'],
|
||||
template:
|
||||
'<textarea class="editor-stub" :readonly="readonly" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
||||
},
|
||||
resolveSafeExternalMarkdownImageUrl: (url: string) =>
|
||||
url.startsWith('https:') ? url : 'data:image/gif;base64,empty',
|
||||
}));
|
||||
|
||||
vi.mock('@easyflow-core/editor-ui/markdown-live-editor', () => ({
|
||||
default: {
|
||||
emits: ['error', 'fidelityLoss', 'save', 'update:modelValue'],
|
||||
methods: {
|
||||
flushMarkdown(this: { modelValue?: string }): string {
|
||||
return liveEditorState.flushedMarkdown ?? this.modelValue ?? '';
|
||||
},
|
||||
focus() {
|
||||
liveEditorState.focus();
|
||||
},
|
||||
},
|
||||
mounted(this: { autofocus?: boolean; focus: () => void }) {
|
||||
if (this.autofocus) this.focus();
|
||||
},
|
||||
name: 'MarkdownLiveEditor',
|
||||
props: {
|
||||
autofocus: Boolean,
|
||||
modelValue: String,
|
||||
readonly: Boolean,
|
||||
resolveImageUrl: Function,
|
||||
variant: String,
|
||||
},
|
||||
template:
|
||||
'<textarea class="editor-stub" :readonly="readonly" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
||||
},
|
||||
}));
|
||||
|
||||
const row = {
|
||||
content: '检索正文',
|
||||
documentCollectionId: '11',
|
||||
documentId: '22',
|
||||
id: '33',
|
||||
indexSyncStatus: 'SYNCED',
|
||||
indexSyncVersion: 0,
|
||||
options: {
|
||||
keep: 'value',
|
||||
imageRefs: ['http://localhost:9000/trusted.png'],
|
||||
renderMarkdown: '# 原内容',
|
||||
sourceFileExt: 'xlsx',
|
||||
},
|
||||
sorting: 1,
|
||||
};
|
||||
|
||||
function mountTable(
|
||||
post = vi.fn(),
|
||||
pageRows = [row],
|
||||
attachTo?: Element,
|
||||
componentProps: Record<string, unknown> = {},
|
||||
) {
|
||||
liveEditorState.flushedMarkdown = undefined;
|
||||
liveEditorState.focus.mockClear();
|
||||
const get = vi.fn().mockResolvedValue({
|
||||
data: { records: pageRows, totalRow: pageRows.length },
|
||||
});
|
||||
const wrapper = mount(ChunkDocumentTable, {
|
||||
attachTo,
|
||||
global: { directives: { loading: {} } },
|
||||
props: {
|
||||
documentId: '22',
|
||||
requestClient: { get, post },
|
||||
...componentProps,
|
||||
},
|
||||
});
|
||||
mountedWrappers.push(wrapper);
|
||||
return {
|
||||
get,
|
||||
post,
|
||||
wrapper,
|
||||
};
|
||||
}
|
||||
|
||||
function getButton(wrapper: ReturnType<typeof mount>, label: string) {
|
||||
const button = wrapper
|
||||
.findAll('button')
|
||||
.find((candidate) => candidate.text().includes(label));
|
||||
if (!button) {
|
||||
throw new Error(`未找到按钮: ${label}`);
|
||||
}
|
||||
return button;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
mountedWrappers.splice(0).forEach((wrapper) => wrapper.unmount());
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('chunkDocumentTable', () => {
|
||||
it('在分块内只挂载一个实时编辑器并原位保存', async () => {
|
||||
const post = vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
content: '新内容',
|
||||
options: { keep: 'value', renderMarkdown: '# 新内容' },
|
||||
},
|
||||
errorCode: 0,
|
||||
});
|
||||
const { wrapper } = mountTable(post);
|
||||
await flushPromises();
|
||||
|
||||
await getButton(wrapper, 'button.edit').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.findAll('.editor-stub')).toHaveLength(1);
|
||||
expect(wrapper.get('.inline-editor').classes()).toContain('is-live-mode');
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'MarkdownLiveEditor' }).props(),
|
||||
).toMatchObject({ autofocus: true, variant: 'inline' });
|
||||
expect(liveEditorState.focus).toHaveBeenCalledOnce();
|
||||
await wrapper.get('.editor-stub').setValue('# 新内容');
|
||||
await getButton(wrapper, 'button.save').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/api/v1/documentChunk/update', {
|
||||
content: '# 新内容',
|
||||
id: '33',
|
||||
});
|
||||
expect(wrapper.find('.inline-editor').exists()).toBe(false);
|
||||
expect(wrapper.text()).toContain('# 新内容');
|
||||
});
|
||||
|
||||
it('保存前读取实时编辑器最新文档,避免防抖期间丢失格式操作', async () => {
|
||||
const post = vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
content: '**工具栏更新**',
|
||||
options: { renderMarkdown: '**工具栏更新**' },
|
||||
},
|
||||
errorCode: 0,
|
||||
});
|
||||
const { wrapper } = mountTable(post);
|
||||
await flushPromises();
|
||||
await getButton(wrapper, 'button.edit').trigger('click');
|
||||
await flushPromises();
|
||||
liveEditorState.flushedMarkdown = '**工具栏更新**';
|
||||
|
||||
await getButton(wrapper, 'button.save').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/api/v1/documentChunk/update', {
|
||||
content: '**工具栏更新**',
|
||||
id: '33',
|
||||
});
|
||||
});
|
||||
|
||||
it('快速离开前同步读取最新草稿并触发放弃确认', async () => {
|
||||
const confirm = vi
|
||||
.spyOn(ElMessageBox, 'confirm')
|
||||
.mockRejectedValue('cancel');
|
||||
const { wrapper } = mountTable();
|
||||
await flushPromises();
|
||||
await getButton(wrapper, 'button.edit').trigger('click');
|
||||
await flushPromises();
|
||||
liveEditorState.flushedMarkdown = '尚未经过防抖回调的输入';
|
||||
|
||||
await getButton(wrapper, 'button.cancel').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(confirm).toHaveBeenCalledOnce();
|
||||
expect(wrapper.find('.inline-editor').exists()).toBe(true);
|
||||
expect(
|
||||
(wrapper.get('.editor-stub').element as HTMLTextAreaElement).value,
|
||||
).toBe('尚未经过防抖回调的输入');
|
||||
});
|
||||
|
||||
it('撤销回原始语义后可直接退出编辑', async () => {
|
||||
const confirm = vi.spyOn(ElMessageBox, 'confirm');
|
||||
const { wrapper } = mountTable();
|
||||
await flushPromises();
|
||||
await getButton(wrapper, 'button.edit').trigger('click');
|
||||
await flushPromises();
|
||||
liveEditorState.flushedMarkdown = '# 原内容\n';
|
||||
|
||||
await getButton(wrapper, 'button.cancel').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(wrapper.find('.inline-editor').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('切换源码模式前同步实时编辑器的最后输入', async () => {
|
||||
const { wrapper } = mountTable();
|
||||
await flushPromises();
|
||||
await getButton(wrapper, 'button.edit').trigger('click');
|
||||
await flushPromises();
|
||||
liveEditorState.flushedMarkdown = '防抖窗口内的最后输入';
|
||||
|
||||
await getButton(wrapper, 'documentCollection.chunkSourceMode').trigger(
|
||||
'click',
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.findComponent({ name: 'MarkdownSourceEditor' })
|
||||
.props('modelValue'),
|
||||
).toBe('防抖窗口内的最后输入');
|
||||
});
|
||||
|
||||
it('源码中的等价格式改动切回实时后仍会保存', async () => {
|
||||
const post = vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
content: '# 原内容\n',
|
||||
options: { renderMarkdown: '# 原内容\n' },
|
||||
},
|
||||
errorCode: 0,
|
||||
});
|
||||
const { wrapper } = mountTable(post);
|
||||
await flushPromises();
|
||||
await getButton(wrapper, 'button.edit').trigger('click');
|
||||
await flushPromises();
|
||||
await getButton(wrapper, 'documentCollection.chunkSourceMode').trigger(
|
||||
'click',
|
||||
);
|
||||
await flushPromises();
|
||||
await wrapper.get('.editor-stub').setValue('# 原内容\n');
|
||||
|
||||
await getButton(wrapper, 'documentCollection.chunkLiveMode').trigger(
|
||||
'click',
|
||||
);
|
||||
await flushPromises();
|
||||
await getButton(wrapper, 'button.save').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/api/v1/documentChunk/update', {
|
||||
content: '# 原内容\n',
|
||||
id: '33',
|
||||
});
|
||||
});
|
||||
|
||||
it('关闭页面前同步读取最新草稿并阻止离开', async () => {
|
||||
const { wrapper } = mountTable();
|
||||
await flushPromises();
|
||||
await getButton(wrapper, 'button.edit').trigger('click');
|
||||
await flushPromises();
|
||||
liveEditorState.flushedMarkdown = '尚未经过防抖回调的输入';
|
||||
const event = new Event('beforeunload', { cancelable: true });
|
||||
|
||||
const allowed = window.dispatchEvent(event);
|
||||
await flushPromises();
|
||||
|
||||
expect(allowed).toBe(false);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(
|
||||
(wrapper.get('.editor-stub').element as HTMLTextAreaElement).value,
|
||||
).toBe('尚未经过防抖回调的输入');
|
||||
});
|
||||
|
||||
it('普通文档也在原表格行内使用轻量实时编辑器', async () => {
|
||||
const pdfRow = {
|
||||
...row,
|
||||
options: { ...row.options, sourceFileExt: 'pdf' },
|
||||
};
|
||||
const { wrapper } = mountTable(vi.fn(), [pdfRow]);
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('.chunk-data-table').exists()).toBe(true);
|
||||
await getButton(wrapper, 'button.edit').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.findAll('.editor-stub')).toHaveLength(1);
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'MarkdownLiveEditor' }).props('variant'),
|
||||
).toBe('inline');
|
||||
});
|
||||
|
||||
it('原始 HTML 分块直接使用源码模式,避免伪装成实时渲染', async () => {
|
||||
const htmlRow = {
|
||||
...row,
|
||||
options: {
|
||||
...row.options,
|
||||
renderMarkdown: '<table><tr><td>内容</td></tr></table>',
|
||||
sourceFileExt: 'pdf',
|
||||
},
|
||||
};
|
||||
const { wrapper } = mountTable(vi.fn(), [htmlRow]);
|
||||
await flushPromises();
|
||||
|
||||
await getButton(wrapper, 'button.edit').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.findComponent({ name: 'MarkdownLiveEditor' }).exists()).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'MarkdownSourceEditor' }).exists(),
|
||||
).toBe(true);
|
||||
expect(wrapper.get('.inline-editor').classes()).toContain('is-source-mode');
|
||||
});
|
||||
|
||||
it('空内容保存只确认一次并直接删除分块', async () => {
|
||||
const post = vi.fn().mockResolvedValue({ data: true, errorCode: 0 });
|
||||
const confirm = vi
|
||||
.spyOn(ElMessageBox, 'confirm')
|
||||
.mockResolvedValue('confirm' as never);
|
||||
const { wrapper } = mountTable(post);
|
||||
await flushPromises();
|
||||
|
||||
await getButton(wrapper, 'button.edit').trigger('click');
|
||||
await flushPromises();
|
||||
await wrapper.get('.editor-stub').setValue(' \n ');
|
||||
await getButton(wrapper, 'button.save').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(confirm).toHaveBeenCalledTimes(1);
|
||||
expect(confirm).toHaveBeenCalledWith(
|
||||
'分块内容为空,保存后将删除该分块。删除后无法恢复。',
|
||||
'删除分块',
|
||||
expect.objectContaining({
|
||||
cancelButtonText: '继续编辑',
|
||||
confirmButtonText: '删除分块',
|
||||
}),
|
||||
);
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
expect(post).toHaveBeenCalledWith('/api/v1/documentChunk/removeChunk', {
|
||||
id: '33',
|
||||
});
|
||||
expect(wrapper.find('[data-chunk-id="33"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('空内容删除确认期间阻止重复确认和重复请求', async () => {
|
||||
let resolveConfirm!: (value: unknown) => void;
|
||||
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveConfirm = resolve;
|
||||
}) as never,
|
||||
);
|
||||
const post = vi.fn().mockResolvedValue({ data: true, errorCode: 0 });
|
||||
const { wrapper } = mountTable(post);
|
||||
await flushPromises();
|
||||
|
||||
await getButton(wrapper, 'button.edit').trigger('click');
|
||||
await flushPromises();
|
||||
await wrapper.get('.editor-stub').setValue(' ');
|
||||
const saveButton = getButton(wrapper, 'button.save');
|
||||
await saveButton.trigger('click');
|
||||
await saveButton.trigger('click');
|
||||
|
||||
expect(confirm).toHaveBeenCalledTimes(1);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
expect(getButton(wrapper, 'button.save').attributes()).toHaveProperty(
|
||||
'disabled',
|
||||
);
|
||||
|
||||
resolveConfirm('confirm');
|
||||
await flushPromises();
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('取消空内容删除后保留草稿并把焦点还给编辑器', async () => {
|
||||
vi.spyOn(ElMessageBox, 'confirm').mockRejectedValue('cancel');
|
||||
const { wrapper } = mountTable(vi.fn(), [row], document.body);
|
||||
await flushPromises();
|
||||
|
||||
await getButton(wrapper, 'button.edit').trigger('click');
|
||||
await flushPromises();
|
||||
await wrapper.get('.editor-stub').setValue(' ');
|
||||
await getButton(wrapper, 'button.save').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
const editor = wrapper.get('.editor-stub');
|
||||
expect((editor.element as HTMLTextAreaElement).value).toBe(' ');
|
||||
expect(document.activeElement).toBe(editor.element);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('保存失败时保留当前草稿和编辑状态', async () => {
|
||||
const post = vi.fn().mockResolvedValue({
|
||||
errorCode: 1,
|
||||
message: '保存失败',
|
||||
});
|
||||
const { wrapper } = mountTable(post);
|
||||
await flushPromises();
|
||||
|
||||
await getButton(wrapper, 'button.edit').trigger('click');
|
||||
await flushPromises();
|
||||
await wrapper.get('.editor-stub').setValue('未保存草稿');
|
||||
await getButton(wrapper, 'button.save').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('.inline-editor').exists()).toBe(true);
|
||||
expect(
|
||||
(wrapper.get('.editor-stub').element as HTMLTextAreaElement).value,
|
||||
).toBe('未保存草稿');
|
||||
});
|
||||
|
||||
it('保存后立即回到阅读态并仅在固定状态位展示同步中', async () => {
|
||||
const successMessage = vi.spyOn(ElMessage, 'success');
|
||||
const post = vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
content: '新正文',
|
||||
indexSyncStatus: 'PENDING',
|
||||
indexSyncVersion: 1,
|
||||
options: { renderMarkdown: '新正文' },
|
||||
},
|
||||
errorCode: 0,
|
||||
});
|
||||
const { wrapper } = mountTable(post);
|
||||
await flushPromises();
|
||||
await getButton(wrapper, 'button.edit').trigger('click');
|
||||
await flushPromises();
|
||||
await wrapper.get('.editor-stub').setValue('新正文');
|
||||
|
||||
expect(getButton(wrapper, 'button.cancel').classes()).toContain(
|
||||
'el-button--small',
|
||||
);
|
||||
expect(getButton(wrapper, 'button.save').classes()).toContain(
|
||||
'el-button--small',
|
||||
);
|
||||
|
||||
await getButton(wrapper, 'button.save').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('.inline-editor').exists()).toBe(false);
|
||||
expect(wrapper.find('.el-loading-mask').exists()).toBe(false);
|
||||
expect(wrapper.get('.chunk-sync-status').attributes('aria-live')).toBe(
|
||||
'polite',
|
||||
);
|
||||
expect(wrapper.text()).toContain('新正文');
|
||||
expect(successMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('同步失败时保留原位重试入口并按当前版本重试', async () => {
|
||||
const failedRow = {
|
||||
...row,
|
||||
indexSyncStatus: 'FAILED',
|
||||
indexSyncVersion: 4,
|
||||
};
|
||||
const post = vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
...failedRow,
|
||||
indexSyncStatus: 'PENDING',
|
||||
},
|
||||
errorCode: 0,
|
||||
});
|
||||
const { wrapper } = mountTable(post, [failedRow]);
|
||||
await flushPromises();
|
||||
|
||||
await wrapper
|
||||
.get('button[aria-label="索引同步失败,点击重试"]')
|
||||
.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/api/v1/documentChunk/retrySync', {
|
||||
id: '33',
|
||||
indexSyncVersion: 4,
|
||||
});
|
||||
expect(wrapper.find('.chunk-sync-status__pending').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('只读入口不展示索引同步重试操作', async () => {
|
||||
const failedRow = {
|
||||
...row,
|
||||
indexSyncStatus: 'FAILED',
|
||||
indexSyncVersion: 4,
|
||||
};
|
||||
const { wrapper } = mountTable(vi.fn(), [failedRow], undefined, {
|
||||
manageable: false,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
wrapper.find('button[aria-label="索引同步失败,点击重试"]').exists(),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('忽略页面刷新后返回的旧轮询状态', async () => {
|
||||
vi.useFakeTimers();
|
||||
let resolvePoll!: (value: unknown) => void;
|
||||
const post = vi.fn().mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolvePoll = resolve;
|
||||
}),
|
||||
);
|
||||
const pendingRow = {
|
||||
...row,
|
||||
indexSyncStatus: 'PENDING',
|
||||
indexSyncVersion: 7,
|
||||
};
|
||||
const { wrapper } = mountTable(post, [pendingRow]);
|
||||
await flushPromises();
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
const pageData = wrapper.findComponent(PageData);
|
||||
(pageData.vm as any).patchRowById('33', { indexSyncStatus: 'SYNCED' });
|
||||
pageData.vm.$emit('loadSuccess', {});
|
||||
await flushPromises();
|
||||
|
||||
resolvePoll({
|
||||
data: [{ id: '33', indexSyncStatus: 'PENDING', indexSyncVersion: 7 }],
|
||||
errorCode: 0,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect((pageData.vm as any).getPageRows()[0].indexSyncStatus).toBe(
|
||||
'SYNCED',
|
||||
);
|
||||
});
|
||||
|
||||
it('忽略新版本保存后返回的旧版本重试响应', async () => {
|
||||
let resolveRetry!: (value: unknown) => void;
|
||||
const post = vi.fn().mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveRetry = resolve;
|
||||
}),
|
||||
);
|
||||
const failedRow = {
|
||||
...row,
|
||||
indexSyncStatus: 'FAILED',
|
||||
indexSyncVersion: 4,
|
||||
};
|
||||
const { wrapper } = mountTable(post, [failedRow]);
|
||||
await flushPromises();
|
||||
await wrapper
|
||||
.get('button[aria-label="索引同步失败,点击重试"]')
|
||||
.trigger('click');
|
||||
const pageData = wrapper.findComponent(PageData);
|
||||
(pageData.vm as any).patchRowById('33', {
|
||||
indexSyncStatus: 'PENDING',
|
||||
indexSyncVersion: 5,
|
||||
});
|
||||
|
||||
resolveRetry({
|
||||
data: { ...failedRow, indexSyncStatus: 'PENDING' },
|
||||
errorCode: 0,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect((pageData.vm as any).getPageRows()[0].indexSyncVersion).toBe(5);
|
||||
});
|
||||
|
||||
it('仅轮询待同步行并在完成后停止', async () => {
|
||||
vi.useFakeTimers();
|
||||
const pendingRow = {
|
||||
...row,
|
||||
indexSyncStatus: 'PENDING',
|
||||
indexSyncVersion: 7,
|
||||
};
|
||||
const post = vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: '33',
|
||||
indexSyncStatus: 'SYNCED',
|
||||
indexSyncVersion: 7,
|
||||
},
|
||||
],
|
||||
errorCode: 0,
|
||||
});
|
||||
const { wrapper } = mountTable(post, [pendingRow]);
|
||||
await flushPromises();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await flushPromises();
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/api/v1/documentChunk/syncStatus', {
|
||||
documentId: '22',
|
||||
ids: ['33'],
|
||||
});
|
||||
expect(wrapper.find('.chunk-sync-status__pending').exists()).toBe(false);
|
||||
expect(
|
||||
wrapper.get('.chunk-sync-status__success').attributes('aria-label'),
|
||||
).toBe('检索索引已更新');
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2200);
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('.chunk-sync-status__success').exists()).toBe(false);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('实时编辑器保真失败时使用同一草稿切换源码模式', async () => {
|
||||
const { wrapper } = mountTable();
|
||||
await flushPromises();
|
||||
|
||||
await getButton(wrapper, 'button.edit').trigger('click');
|
||||
await flushPromises();
|
||||
await wrapper.get('.editor-stub').setValue('保留的草稿');
|
||||
wrapper
|
||||
.findComponent({ name: 'MarkdownLiveEditor' })
|
||||
.vm.$emit('fidelityLoss');
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'MarkdownSourceEditor' }).exists(),
|
||||
).toBe(true);
|
||||
expect(
|
||||
wrapper
|
||||
.findComponent({ name: 'MarkdownSourceEditor' })
|
||||
.props('highlight'),
|
||||
).toBe(false);
|
||||
expect(wrapper.get('.inline-editor').classes()).toContain('is-source-mode');
|
||||
expect(
|
||||
(wrapper.get('.editor-stub').element as HTMLTextAreaElement).value,
|
||||
).toBe('保留的草稿');
|
||||
});
|
||||
|
||||
it('保存期间锁定编辑器和模式切换', async () => {
|
||||
let resolveSave!: (value: unknown) => void;
|
||||
const post = vi.fn().mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveSave = resolve;
|
||||
}),
|
||||
);
|
||||
const { wrapper } = mountTable(post);
|
||||
await flushPromises();
|
||||
await getButton(wrapper, 'button.edit').trigger('click');
|
||||
await flushPromises();
|
||||
await wrapper.get('.editor-stub').setValue('保存中的草稿');
|
||||
|
||||
await getButton(wrapper, 'button.save').trigger('click');
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'MarkdownLiveEditor' }).props('readonly'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
getButton(wrapper, 'documentCollection.chunkLiveMode').attributes(),
|
||||
).toHaveProperty('disabled');
|
||||
|
||||
resolveSave({
|
||||
data: {
|
||||
content: '保存中的草稿',
|
||||
options: { renderMarkdown: '保存中的草稿' },
|
||||
},
|
||||
errorCode: 0,
|
||||
});
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it('只解析后端登记的本地图片引用', async () => {
|
||||
const { wrapper } = mountTable();
|
||||
await flushPromises();
|
||||
await getButton(wrapper, 'button.edit').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
const resolver = wrapper
|
||||
.findComponent({ name: 'MarkdownLiveEditor' })
|
||||
.props('resolveImageUrl') as (url: string) => string;
|
||||
expect(resolver('http://localhost:9000/trusted.png')).toBe(
|
||||
'http://localhost:9000/trusted.png',
|
||||
);
|
||||
expect(resolver('http://localhost:9000/untrusted.png')).toBe(
|
||||
'data:image/gif;base64,empty',
|
||||
);
|
||||
expect(resolver('https://cdn.example.com/image.png')).toBe(
|
||||
'https://cdn.example.com/image.png',
|
||||
);
|
||||
});
|
||||
|
||||
it('编辑一个分块时禁用其他分块删除并保留草稿', async () => {
|
||||
const secondRow = {
|
||||
...row,
|
||||
id: '44',
|
||||
options: { ...row.options, renderMarkdown: '# 第二块' },
|
||||
sorting: 2,
|
||||
};
|
||||
const post = vi.fn();
|
||||
const { wrapper } = mountTable(post, [row, secondRow]);
|
||||
await flushPromises();
|
||||
|
||||
await getButton(wrapper, 'button.edit').trigger('click');
|
||||
await flushPromises();
|
||||
await wrapper.get('.editor-stub').setValue('A 的草稿');
|
||||
const deleteButtons = wrapper
|
||||
.findAll('button')
|
||||
.filter((button) => button.text().includes('button.delete'));
|
||||
expect(deleteButtons.at(-1)?.attributes()).toHaveProperty('disabled');
|
||||
await deleteButtons.at(-1)?.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
expect(
|
||||
(wrapper.get('.editor-stub').element as HTMLTextAreaElement).value,
|
||||
).toBe('A 的草稿');
|
||||
});
|
||||
|
||||
it('空内容删除失败时保留空草稿和编辑状态', async () => {
|
||||
const post = vi.fn().mockResolvedValue({
|
||||
data: false,
|
||||
errorCode: 1,
|
||||
message: '删除失败',
|
||||
});
|
||||
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never);
|
||||
const { wrapper } = mountTable(post);
|
||||
await flushPromises();
|
||||
|
||||
await getButton(wrapper, 'button.edit').trigger('click');
|
||||
await flushPromises();
|
||||
await wrapper.get('.editor-stub').setValue(' ');
|
||||
await getButton(wrapper, 'button.save').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
expect(wrapper.find('.inline-editor').exists()).toBe(true);
|
||||
expect(
|
||||
(wrapper.get('.editor-stub').element as HTMLTextAreaElement).value,
|
||||
).toBe(' ');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, nextTick, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { useAccess } from '@easyflow/access';
|
||||
import { $t } from '@easyflow/locales';
|
||||
@@ -71,9 +71,12 @@ const knowledgeId = ref<string>((route.query.id as string) || '');
|
||||
const activeMenu = ref<string>((route.query.activeMenu as string) || '');
|
||||
const knowledgeInfo = ref<any>({});
|
||||
const selectedCategory = ref('');
|
||||
const canManageKnowledgePermission = computed(() =>
|
||||
const canUpdateKnowledgePermission = computed(() =>
|
||||
hasAccessByCodes(['/api/v1/documentCollection/save']),
|
||||
);
|
||||
const canDeleteKnowledgePermission = computed(() =>
|
||||
hasAccessByCodes(['/api/v1/documentCollection/remove']),
|
||||
);
|
||||
|
||||
const isSuperAdmin = computed(() => {
|
||||
return (
|
||||
@@ -82,16 +85,23 @@ const isSuperAdmin = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const canManageCurrentKnowledge = computed(() => {
|
||||
if (!knowledgeInfo.value?.id || !canManageKnowledgePermission.value) {
|
||||
return false;
|
||||
}
|
||||
const isCurrentKnowledgeOwner = computed(() => {
|
||||
if (!knowledgeInfo.value?.id) return false;
|
||||
return (
|
||||
isSuperAdmin.value ||
|
||||
String(userStore.userInfo?.id || '') ===
|
||||
String(knowledgeInfo.value.createdBy || '')
|
||||
);
|
||||
});
|
||||
const canManageCurrentKnowledge = computed(
|
||||
() => canUpdateKnowledgePermission.value && isCurrentKnowledgeOwner.value,
|
||||
);
|
||||
const canDeleteCurrentKnowledge = computed(
|
||||
() => canDeleteKnowledgePermission.value && isCurrentKnowledgeOwner.value,
|
||||
);
|
||||
const canOperateCurrentKnowledge = computed(
|
||||
() => canManageCurrentKnowledge.value || canDeleteCurrentKnowledge.value,
|
||||
);
|
||||
|
||||
const syncNavTitle = (title: string) => {
|
||||
if (!title) {
|
||||
@@ -152,6 +162,9 @@ onMounted(() => {
|
||||
getKnowledge();
|
||||
});
|
||||
const back = async () => {
|
||||
if (!(await canLeaveChunk())) {
|
||||
return;
|
||||
}
|
||||
await navigateBackToList(
|
||||
router,
|
||||
route.query,
|
||||
@@ -195,6 +208,7 @@ const headerButtons = [
|
||||
];
|
||||
const panelMode = ref<'chunk' | 'list' | 'process'>('list');
|
||||
const documentTableRef = ref();
|
||||
const chunkDocumentTableRef = ref();
|
||||
const batchStatusRefreshKey = ref(0);
|
||||
const documentTitle = ref('');
|
||||
const handleSearch = (searchParams: string) => {
|
||||
@@ -213,7 +227,14 @@ const handleButtonClick = (event: any) => {
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleCategoryClick = (menuKey: string) => {
|
||||
const canLeaveChunk = async () =>
|
||||
panelMode.value !== 'chunk' ||
|
||||
(await chunkDocumentTableRef.value?.confirmLeave?.()) !== false;
|
||||
|
||||
const handleCategoryClick = async (menuKey: string) => {
|
||||
if (!(await canLeaveChunk())) {
|
||||
return;
|
||||
}
|
||||
selectedCategory.value = menuKey;
|
||||
panelMode.value = 'list';
|
||||
};
|
||||
@@ -229,6 +250,9 @@ const continueProcess = (doc: any) => {
|
||||
documentTitle.value = doc?.title || '';
|
||||
};
|
||||
const backDoc = async () => {
|
||||
if (!(await canLeaveChunk())) {
|
||||
return;
|
||||
}
|
||||
if (panelMode.value !== 'list') {
|
||||
panelMode.value = 'list';
|
||||
}
|
||||
@@ -237,6 +261,8 @@ const backDoc = async () => {
|
||||
documentTableRef.value?.reload?.();
|
||||
batchStatusRefreshKey.value += 1;
|
||||
};
|
||||
|
||||
onBeforeRouteLeave(() => canLeaveChunk());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -254,7 +280,7 @@ const backDoc = async () => {
|
||||
<div class="description">
|
||||
{{ knowledgeInfo.description || '' }}
|
||||
</div>
|
||||
<div v-if="!canManageCurrentKnowledge" class="permission-tip">
|
||||
<div v-if="!canOperateCurrentKnowledge" class="permission-tip">
|
||||
{{ $t('documentCollection.managePermissionHint') }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -304,7 +330,7 @@ const backDoc = async () => {
|
||||
:knowledge-id="knowledgeId"
|
||||
:permissions="{
|
||||
canCreateContent: canManageCurrentKnowledge,
|
||||
canDeleteContent: canManageCurrentKnowledge,
|
||||
canDeleteContent: canDeleteCurrentKnowledge,
|
||||
canDownloadContent: true,
|
||||
}"
|
||||
@continue-process="continueProcess"
|
||||
@@ -313,8 +339,11 @@ const backDoc = async () => {
|
||||
|
||||
<ChunkDocumentTable
|
||||
v-else-if="panelMode === 'chunk'"
|
||||
ref="chunkDocumentTableRef"
|
||||
:document-id="documentId"
|
||||
:manageable="canManageCurrentKnowledge"
|
||||
:editable="canManageCurrentKnowledge"
|
||||
:deletable="canDeleteCurrentKnowledge"
|
||||
:default-summary-prompt="knowledgeInfo.summaryPrompt"
|
||||
/>
|
||||
<SegmenterDoc
|
||||
@@ -389,7 +418,7 @@ const backDoc = async () => {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding: 20px 14px 0;
|
||||
background-color: var(--el-bg-color);
|
||||
background-color: hsl(var(--surface-panel));
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -472,7 +501,7 @@ const backDoc = async () => {
|
||||
}
|
||||
|
||||
.doc-table {
|
||||
background-color: var(--el-bg-color);
|
||||
background-color: hsl(var(--surface-panel));
|
||||
}
|
||||
|
||||
.doc-sub-back {
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import DocumentTable from './DocumentTable.vue';
|
||||
|
||||
const sseMocks = vi.hoisted(() => ({
|
||||
abort: vi.fn(),
|
||||
post: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const tableState = vi.hoisted(() => ({ rows: [] as any[] }));
|
||||
|
||||
vi.mock('element-plus', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('element-plus')>();
|
||||
const { defineComponent, h } = await import('vue');
|
||||
const ElTable = defineComponent({
|
||||
name: 'ElTable',
|
||||
props: ['data'],
|
||||
emits: ['selection-change'],
|
||||
setup(props, { expose, slots }) {
|
||||
expose({
|
||||
clearSelection: vi.fn(),
|
||||
toggleRowSelection: vi.fn(),
|
||||
});
|
||||
return () => {
|
||||
tableState.rows = props.data || [];
|
||||
return h('div', { class: 'table-stub' }, slots.default?.());
|
||||
};
|
||||
},
|
||||
});
|
||||
const ElTableColumn = defineComponent({
|
||||
name: 'ElTableColumn',
|
||||
props: ['label', 'reserveSelection', 'selectable', 'type'],
|
||||
setup(_props, { slots }) {
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{ class: 'table-column-stub' },
|
||||
tableState.rows.map((row) => slots.default?.({ row })),
|
||||
);
|
||||
},
|
||||
});
|
||||
return { ...actual, ElTable, ElTableColumn };
|
||||
});
|
||||
|
||||
vi.mock('#/api/request', () => ({
|
||||
api: {},
|
||||
SseClient: class {
|
||||
abort = sseMocks.abort;
|
||||
post = sseMocks.post;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@easyflow/locales', () => ({
|
||||
$t: (key: string, params?: Record<string, number>) =>
|
||||
params ? `${key}:${JSON.stringify(params)}` : key,
|
||||
}));
|
||||
|
||||
const rows = [
|
||||
{ id: '1', processStatus: 'COMPLETED', title: '一.docx' },
|
||||
{ id: '2', processStatus: 'PARSING', title: '处理中.docx' },
|
||||
{ id: '3', processStatus: 'COMPLETED', title: '三.pdf' },
|
||||
{ id: '4', processStatus: 'PARSE_FAILED', title: '四.xlsx' },
|
||||
{ id: '5', processStatus: 'COMPLETED', title: '五.docx' },
|
||||
{ id: '6', processStatus: 'COMPLETED', title: '六.pdf' },
|
||||
{ id: '7', processStatus: 'COMPLETED', title: '七.xlsx' },
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('documentTable', () => {
|
||||
it('处理中行不可选,批量删除使用最多三个并发并汇总部分失败', async () => {
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
const post = vi.fn().mockImplementation(async (_url: string, body: any) => {
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
active -= 1;
|
||||
return body.id === '4' || body.id === '6'
|
||||
? { data: false, errorCode: 0, message: '失败' }
|
||||
: { data: true, errorCode: 0 };
|
||||
});
|
||||
const get = vi.fn().mockResolvedValue({
|
||||
data: { records: rows, totalRow: rows.length },
|
||||
});
|
||||
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never);
|
||||
|
||||
const wrapper = mount(DocumentTable, {
|
||||
global: { directives: { loading: {} } },
|
||||
props: {
|
||||
knowledgeId: '10',
|
||||
requestClient: { download: vi.fn(), get, post },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
const warning = vi.spyOn(ElMessage, 'warning');
|
||||
|
||||
const table = wrapper.findComponent({ name: 'ElTable' });
|
||||
const selectionColumn = table
|
||||
.findAllComponents({ name: 'ElTableColumn' })
|
||||
.find((column) => column.props('type') === 'selection');
|
||||
expect(selectionColumn?.props('selectable')(rows[0])).toBe(true);
|
||||
expect(selectionColumn?.props('selectable')(rows[1])).toBe(false);
|
||||
expect(selectionColumn?.props('reserveSelection')).toBe(true);
|
||||
|
||||
table.vm.$emit('selection-change', [
|
||||
rows[0],
|
||||
rows[2],
|
||||
rows[3],
|
||||
rows[4],
|
||||
rows[5],
|
||||
rows[6],
|
||||
]);
|
||||
await wrapper.vm.$nextTick();
|
||||
const deleteButton = wrapper
|
||||
.findAll('.batch-toolbar button')
|
||||
.find((button) =>
|
||||
button.text().includes('documentCollection.batchDelete'),
|
||||
);
|
||||
if (!deleteButton) throw new Error('未找到批量删除按钮');
|
||||
await deleteButton.trigger('click');
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
await flushPromises();
|
||||
|
||||
expect(post).toHaveBeenCalledTimes(6);
|
||||
expect(maxActive).toBe(3);
|
||||
expect(maxActive).toBeLessThanOrEqual(3);
|
||||
expect(warning).toHaveBeenCalledWith(
|
||||
'documentCollection.batchDeletePartial:{"failed":2,"success":4}',
|
||||
);
|
||||
expect(wrapper.find('.batch-toolbar').text()).toContain(
|
||||
'documentCollection.selectedCount:{"count":2}',
|
||||
);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('批量删除确认期间阻止重复确认和重复请求', async () => {
|
||||
let resolveConfirm!: (value: unknown) => void;
|
||||
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveConfirm = resolve;
|
||||
}) as never,
|
||||
);
|
||||
const post = vi.fn().mockResolvedValue({ data: true, errorCode: 0 });
|
||||
const get = vi.fn().mockResolvedValue({
|
||||
data: { records: rows, totalRow: rows.length },
|
||||
});
|
||||
const wrapper = mount(DocumentTable, {
|
||||
global: { directives: { loading: {} } },
|
||||
props: {
|
||||
knowledgeId: '10',
|
||||
requestClient: { download: vi.fn(), get, post },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
wrapper
|
||||
.findComponent({ name: 'ElTable' })
|
||||
.vm.$emit('selection-change', [rows[0], rows[2]]);
|
||||
await wrapper.vm.$nextTick();
|
||||
const deleteButton = wrapper
|
||||
.findAll('.batch-toolbar button')
|
||||
.find((button) =>
|
||||
button.text().includes('documentCollection.batchDelete'),
|
||||
);
|
||||
if (!deleteButton) throw new Error('未找到批量删除按钮');
|
||||
await deleteButton.trigger('click');
|
||||
await deleteButton.trigger('click');
|
||||
|
||||
expect(confirm).toHaveBeenCalledTimes(1);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
|
||||
resolveConfirm('confirm');
|
||||
await flushPromises();
|
||||
expect(post).toHaveBeenCalledTimes(2);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('搜索和显式重载会清空当前页选择', async () => {
|
||||
const get = vi.fn().mockResolvedValue({
|
||||
data: { records: rows, totalRow: rows.length },
|
||||
});
|
||||
const wrapper = mount(DocumentTable, {
|
||||
global: { directives: { loading: {} } },
|
||||
props: {
|
||||
knowledgeId: '10',
|
||||
requestClient: { download: vi.fn(), get, post: vi.fn() },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const table = wrapper.findComponent({ name: 'ElTable' });
|
||||
table.vm.$emit('selection-change', [rows[0]]);
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('.batch-toolbar').exists()).toBe(true);
|
||||
|
||||
wrapper.vm.search('关键字');
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('.batch-toolbar').exists()).toBe(false);
|
||||
|
||||
table.vm.$emit('selection-change', [rows[2]]);
|
||||
await wrapper.vm.$nextTick();
|
||||
wrapper.vm.reload();
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('.batch-toolbar').exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('后台状态更新只移除转为处理中的已选文档', async () => {
|
||||
const get = vi.fn().mockResolvedValue({
|
||||
data: { records: rows, totalRow: rows.length },
|
||||
});
|
||||
const wrapper = mount(DocumentTable, {
|
||||
global: { directives: { loading: {} } },
|
||||
props: {
|
||||
knowledgeId: '10',
|
||||
requestClient: { download: vi.fn(), get, post: vi.fn() },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const table = wrapper.findComponent({ name: 'ElTable' });
|
||||
table.vm.$emit('selection-change', [rows[0], rows[2]]);
|
||||
await wrapper.vm.$nextTick();
|
||||
const streamOptions = sseMocks.post.mock.calls.at(-1)?.[2];
|
||||
streamOptions?.onMessage?.({
|
||||
data: JSON.stringify({
|
||||
documentId: String(rows[1]?.id || ''),
|
||||
knowledgeId: '10',
|
||||
processStatus: 'PARSING',
|
||||
type: 'document-status',
|
||||
}),
|
||||
event: 'document-status',
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('.batch-toolbar').text()).toContain(
|
||||
'documentCollection.selectedCount:{"count":2}',
|
||||
);
|
||||
|
||||
streamOptions?.onMessage?.({
|
||||
data: JSON.stringify({
|
||||
documentId: String(rows[2]?.id || ''),
|
||||
knowledgeId: '10',
|
||||
processStatus: 'INDEXING',
|
||||
type: 'document-status',
|
||||
}),
|
||||
event: 'document-status',
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('.batch-toolbar').text()).toContain(
|
||||
'documentCollection.selectedCount:{"count":1}',
|
||||
);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('后台静默重载按 ID 保留当前页有效选择', async () => {
|
||||
const get = vi.fn().mockResolvedValue({
|
||||
data: { records: rows, totalRow: rows.length },
|
||||
});
|
||||
const wrapper = mount(DocumentTable, {
|
||||
global: { directives: { loading: {} } },
|
||||
props: {
|
||||
knowledgeId: '10',
|
||||
requestClient: { download: vi.fn(), get, post: vi.fn() },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const table = wrapper.findComponent({ name: 'ElTable' });
|
||||
table.vm.$emit('selection-change', [rows[0]]);
|
||||
await wrapper.vm.$nextTick();
|
||||
const pageData = wrapper.findComponent({ name: 'PageData' });
|
||||
await (pageData.vm as any).reload({ silent: true });
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('.batch-toolbar').text()).toContain(
|
||||
'documentCollection.selectedCount:{"count":1}',
|
||||
);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('单条删除请求期间锁定行操作并阻止重复提交', async () => {
|
||||
let resolveDelete!: (value: unknown) => void;
|
||||
const post = vi.fn().mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveDelete = resolve;
|
||||
}),
|
||||
);
|
||||
const get = vi.fn().mockResolvedValue({
|
||||
data: { records: rows, totalRow: rows.length },
|
||||
});
|
||||
const confirm = vi
|
||||
.spyOn(ElMessageBox, 'confirm')
|
||||
.mockResolvedValue('confirm' as never);
|
||||
const wrapper = mount(DocumentTable, {
|
||||
global: { directives: { loading: {} } },
|
||||
props: {
|
||||
knowledgeId: '10',
|
||||
requestClient: { download: vi.fn(), get, post },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const deleteButton = wrapper.get('button[aria-label="button.delete"]');
|
||||
await deleteButton.trigger('click');
|
||||
await deleteButton.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(confirm).toHaveBeenCalledTimes(1);
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
wrapper.get('button[aria-label="button.delete"]').attributes(),
|
||||
).toHaveProperty('disabled');
|
||||
|
||||
resolveDelete({ data: true, errorCode: 0 });
|
||||
await flushPromises();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('只读权限不渲染选择列和批量入口', async () => {
|
||||
const get = vi.fn().mockResolvedValue({
|
||||
data: { records: rows, totalRow: rows.length },
|
||||
});
|
||||
const wrapper = mount(DocumentTable, {
|
||||
global: { directives: { loading: {} } },
|
||||
props: {
|
||||
knowledgeId: '10',
|
||||
permissions: { canDeleteContent: false },
|
||||
requestClient: { download: vi.fn(), get, post: vi.fn() },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const columns = wrapper
|
||||
.findComponent({ name: 'ElTable' })
|
||||
.findAllComponents({ name: 'ElTableColumn' });
|
||||
expect(columns.some((column) => column.props('type') === 'selection')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(wrapper.find('.batch-toolbar').exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -57,6 +57,10 @@ interface DocumentTablePermissions {
|
||||
canDownloadContent?: boolean;
|
||||
}
|
||||
|
||||
interface PageDataLoadEvent {
|
||||
silent?: boolean;
|
||||
}
|
||||
|
||||
type PermissionKey = keyof DocumentTablePermissions;
|
||||
|
||||
const props = defineProps({
|
||||
@@ -88,7 +92,12 @@ const STREAM_RECONNECT_DELAY = 1500;
|
||||
const STREAM_RELOAD_DELAY = 3000;
|
||||
|
||||
const pageDataRef = ref();
|
||||
const tableRef = ref();
|
||||
const retryingDocumentIds = ref<Set<string>>(new Set());
|
||||
const selectedRows = ref<any[]>([]);
|
||||
const batchDeleting = ref(false);
|
||||
const batchDeletingIds = ref<Set<string>>(new Set());
|
||||
const deletingDocumentIds = ref<Set<string>>(new Set());
|
||||
const taskStatusStreamClient = new SseClient();
|
||||
let reconnectTimer: null | ReturnType<typeof setTimeout> = null;
|
||||
let reloadTimer: null | ReturnType<typeof setTimeout> = null;
|
||||
@@ -96,9 +105,13 @@ let disposed = false;
|
||||
|
||||
defineExpose({
|
||||
reload() {
|
||||
if (batchDeleting.value) return;
|
||||
clearSelection();
|
||||
pageDataRef.value?.reload?.();
|
||||
},
|
||||
search(searchText: string) {
|
||||
if (batchDeleting.value) return;
|
||||
clearSelection();
|
||||
pageDataRef.value?.setQuery?.({
|
||||
keyword: searchText.trim() || undefined,
|
||||
});
|
||||
@@ -106,6 +119,7 @@ defineExpose({
|
||||
});
|
||||
|
||||
const processingStatuses = new Set(['INDEXING', 'PARSING', 'SPLITTING']);
|
||||
const BATCH_DELETE_CONCURRENCY = 3;
|
||||
|
||||
const isProcessingStatus = (status?: string) =>
|
||||
processingStatuses.has(status || '');
|
||||
@@ -277,9 +291,12 @@ const patchDocumentRow = (payload: DocumentStatusPayload) => {
|
||||
if (payload.taskModifiedAt) {
|
||||
nextPatch.modified = payload.taskModifiedAt;
|
||||
}
|
||||
return (
|
||||
pageDataRef.value?.patchRowById?.(payload.documentId, nextPatch) ?? false
|
||||
);
|
||||
const patched =
|
||||
pageDataRef.value?.patchRowById?.(payload.documentId, nextPatch) ?? false;
|
||||
if (patched && isProcessingStatus(payload.processStatus)) {
|
||||
reconcileSelection();
|
||||
}
|
||||
return patched;
|
||||
};
|
||||
|
||||
const buildTaskStreamUrl = () => {
|
||||
@@ -432,28 +449,203 @@ const handleDownload = async (row: any) => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (row: any) => {
|
||||
if (!ensurePermission('canDeleteContent')) {
|
||||
const removeDocument = async (row: any) => {
|
||||
const res = await props.requestClient.post(
|
||||
buildKnowledgePath(props.endpointPrefix, '/api/v1/document/removeDoc'),
|
||||
{ id: row.id },
|
||||
);
|
||||
if (res.errorCode !== 0 || res.data === false) {
|
||||
throw new Error(res.message || $t('message.deleteErrorMessage'));
|
||||
}
|
||||
};
|
||||
|
||||
const clearSelection = (force = false) => {
|
||||
if (batchDeleting.value && !force) return;
|
||||
selectedRows.value = [];
|
||||
tableRef.value?.clearSelection?.();
|
||||
};
|
||||
|
||||
const reconcileSelection = () => {
|
||||
if (batchDeleting.value || selectedRows.value.length === 0) return;
|
||||
const selectedIds = new Set(selectedRows.value.map((row) => String(row.id)));
|
||||
const currentRows = pageDataRef.value?.getPageRows?.() || [];
|
||||
const nextRows = currentRows.filter(
|
||||
(row: any) =>
|
||||
selectedIds.has(String(row.id)) && !isProcessingStatus(row.processStatus),
|
||||
);
|
||||
tableRef.value?.clearSelection?.();
|
||||
nextRows.forEach((row: any) =>
|
||||
tableRef.value?.toggleRowSelection?.(row, true),
|
||||
);
|
||||
selectedRows.value = nextRows;
|
||||
};
|
||||
|
||||
const handleSelectionChange = (rows: any[]) => {
|
||||
if (batchDeleting.value) return;
|
||||
selectedRows.value = rows;
|
||||
};
|
||||
|
||||
const handleListLoaded = (event?: PageDataLoadEvent) => {
|
||||
if (batchDeleting.value) return;
|
||||
if (event?.silent) {
|
||||
reconcileSelection();
|
||||
return;
|
||||
}
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
const isRowSelectable = (row: any) =>
|
||||
!batchDeleting.value &&
|
||||
!deletingDocumentIds.value.has(String(row.id)) &&
|
||||
!isProcessingStatus(row.processStatus);
|
||||
const isBatchLocked = (row: any) =>
|
||||
batchDeleting.value && batchDeletingIds.value.has(String(row.id));
|
||||
const isDocumentDeleting = (row: any) =>
|
||||
deletingDocumentIds.value.has(String(row.id));
|
||||
|
||||
const setDocumentDeleting = (row: any, deleting: boolean) => {
|
||||
const next = new Set(deletingDocumentIds.value);
|
||||
const id = String(row.id);
|
||||
if (deleting) next.add(id);
|
||||
else next.delete(id);
|
||||
deletingDocumentIds.value = next;
|
||||
};
|
||||
|
||||
const handleDelete = async (row: any) => {
|
||||
if (
|
||||
batchDeleting.value ||
|
||||
isDocumentDeleting(row) ||
|
||||
!ensurePermission('canDeleteContent')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (processingStatuses.has(row.processStatus)) {
|
||||
ElMessage.warning($t('documentCollection.processingDeleteBlocked'));
|
||||
return;
|
||||
}
|
||||
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
|
||||
confirmButtonText: $t('button.confirm'),
|
||||
cancelButtonText: $t('button.cancel'),
|
||||
type: 'warning',
|
||||
}).then(async () => {
|
||||
const res = await props.requestClient.post(
|
||||
buildKnowledgePath(props.endpointPrefix, '/api/v1/document/removeDoc'),
|
||||
{ id: row.id },
|
||||
setDocumentDeleting(row, true);
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
$t('message.deleteAlert'),
|
||||
$t('message.noticeTitle'),
|
||||
{
|
||||
confirmButtonText: $t('button.confirm'),
|
||||
cancelButtonText: $t('button.cancel'),
|
||||
type: 'warning',
|
||||
},
|
||||
);
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success($t('message.deleteOkMessage'));
|
||||
pageDataRef.value?.reload?.();
|
||||
} catch {
|
||||
setDocumentDeleting(row, false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await removeDocument(row);
|
||||
ElMessage.success($t('message.deleteOkMessage'));
|
||||
clearSelection();
|
||||
pageDataRef.value?.reload?.();
|
||||
} catch (error) {
|
||||
ElMessage.error(
|
||||
error instanceof Error ? error.message : $t('message.deleteErrorMessage'),
|
||||
);
|
||||
} finally {
|
||||
setDocumentDeleting(row, false);
|
||||
}
|
||||
};
|
||||
|
||||
const runBatchDelete = async (rows: any[]) => {
|
||||
const queue = [...rows];
|
||||
const failedIds = new Set<string>();
|
||||
let successCount = 0;
|
||||
let failureCount = 0;
|
||||
const worker = async () => {
|
||||
while (queue.length > 0) {
|
||||
const row = queue.shift();
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await removeDocument(row);
|
||||
successCount += 1;
|
||||
} catch {
|
||||
failureCount += 1;
|
||||
failedIds.add(String(row.id));
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
await Promise.all(
|
||||
Array.from(
|
||||
{
|
||||
length: Math.min(BATCH_DELETE_CONCURRENCY, rows.length),
|
||||
},
|
||||
() => worker(),
|
||||
),
|
||||
);
|
||||
return { failedIds, failureCount, successCount };
|
||||
};
|
||||
|
||||
const restoreFailedSelection = (failedIds: Set<string>) => {
|
||||
const currentRows = pageDataRef.value?.getPageRows?.() || [];
|
||||
const failedRows = currentRows.filter((row: any) =>
|
||||
failedIds.has(String(row.id)),
|
||||
);
|
||||
tableRef.value?.clearSelection?.();
|
||||
failedRows.forEach((row: any) =>
|
||||
tableRef.value?.toggleRowSelection?.(row, true),
|
||||
);
|
||||
selectedRows.value = failedRows;
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (
|
||||
batchDeleting.value ||
|
||||
deletingDocumentIds.value.size > 0 ||
|
||||
!ensurePermission('canDeleteContent') ||
|
||||
selectedRows.value.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const rows = selectedRows.value.filter((row) => isRowSelectable(row));
|
||||
if (rows.length === 0) {
|
||||
clearSelection();
|
||||
return;
|
||||
}
|
||||
batchDeleting.value = true;
|
||||
batchDeletingIds.value = new Set(rows.map((row) => String(row.id)));
|
||||
try {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
$t('documentCollection.batchDeleteConfirm', { count: rows.length }),
|
||||
$t('message.noticeTitle'),
|
||||
{
|
||||
confirmButtonText: $t('button.confirm'),
|
||||
cancelButtonText: $t('button.cancel'),
|
||||
type: 'warning',
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const result = await runBatchDelete(rows);
|
||||
if (result.failureCount === 0) {
|
||||
ElMessage.success(
|
||||
$t('documentCollection.batchDeleteSuccess', {
|
||||
count: result.successCount,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
ElMessage.warning(
|
||||
$t('documentCollection.batchDeletePartial', {
|
||||
failed: result.failureCount,
|
||||
success: result.successCount,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await pageDataRef.value?.reload?.();
|
||||
restoreFailedSelection(result.failedIds);
|
||||
} finally {
|
||||
batchDeletingIds.value = new Set();
|
||||
batchDeleting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const primaryActionConfigs: Record<
|
||||
@@ -507,6 +699,7 @@ const getPrimaryActionLabel = (row: any) => {
|
||||
};
|
||||
|
||||
const handlePrimaryAction = (row: any) => {
|
||||
if (isBatchLocked(row)) return;
|
||||
const config = primaryActionConfigs[row.processStatus || ''];
|
||||
if (!config) {
|
||||
return;
|
||||
@@ -532,6 +725,7 @@ onBeforeUnmount(() => {
|
||||
watch(
|
||||
() => `${props.endpointPrefix}:${props.knowledgeId}`,
|
||||
() => {
|
||||
clearSelection();
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
@@ -554,9 +748,46 @@ watch(
|
||||
sort: 'desc',
|
||||
sortKey: 'created',
|
||||
}"
|
||||
@load-success="handleListLoaded"
|
||||
>
|
||||
<template #default="{ pageList }">
|
||||
<ElTable :data="pageList" style="width: 100%" size="large">
|
||||
<div
|
||||
v-if="hasPermission('canDeleteContent') && selectedRows.length > 0"
|
||||
class="batch-toolbar"
|
||||
>
|
||||
<span>{{
|
||||
$t('documentCollection.selectedCount', { count: selectedRows.length })
|
||||
}}</span>
|
||||
<div class="batch-toolbar__actions">
|
||||
<ElButton link :disabled="batchDeleting" @click="clearSelection()">
|
||||
{{ $t('documentCollection.cancelSelection') }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
link
|
||||
type="danger"
|
||||
:disabled="deletingDocumentIds.size > 0"
|
||||
:loading="batchDeleting"
|
||||
@click="handleBatchDelete"
|
||||
>
|
||||
{{ $t('documentCollection.batchDelete') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
<ElTable
|
||||
ref="tableRef"
|
||||
:data="pageList"
|
||||
row-key="id"
|
||||
style="width: 100%"
|
||||
size="large"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<ElTableColumn
|
||||
v-if="hasPermission('canDeleteContent')"
|
||||
type="selection"
|
||||
width="48"
|
||||
:reserve-selection="true"
|
||||
:selectable="isRowSelectable"
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="fileName"
|
||||
:label="$t('documentCollection.fileName')"
|
||||
@@ -677,7 +908,11 @@ watch(
|
||||
v-if="getPrimaryActionLabel(row)"
|
||||
link
|
||||
type="primary"
|
||||
:disabled="isRetrying(row)"
|
||||
:disabled="
|
||||
isRetrying(row) ||
|
||||
isBatchLocked(row) ||
|
||||
isDocumentDeleting(row)
|
||||
"
|
||||
:loading="isRetrying(row)"
|
||||
@click="handlePrimaryAction(row)"
|
||||
>
|
||||
@@ -693,6 +928,7 @@ watch(
|
||||
link
|
||||
:icon="Download"
|
||||
:aria-label="$t('button.download')"
|
||||
:disabled="isBatchLocked(row) || isDocumentDeleting(row)"
|
||||
@click="handleDownload(row)"
|
||||
/>
|
||||
</ElTooltip>
|
||||
@@ -707,6 +943,8 @@ watch(
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
:aria-label="$t('button.delete')"
|
||||
:disabled="isBatchLocked(row) || isDocumentDeleting(row)"
|
||||
:loading="isDocumentDeleting(row)"
|
||||
@click="handleDelete(row)"
|
||||
/>
|
||||
</ElTooltip>
|
||||
@@ -719,6 +957,26 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.batch-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 40px;
|
||||
padding: var(--space-1) var(--space-3);
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
background: hsl(var(--surface-subtle));
|
||||
border-bottom: 1px solid hsl(var(--line-subtle));
|
||||
}
|
||||
|
||||
.batch-toolbar__actions {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.time-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { onBeforeRouteLeave } from 'vue-router';
|
||||
|
||||
import { $t } from '@easyflow/locales';
|
||||
|
||||
@@ -29,6 +30,7 @@ const panelMode = ref<'chunk' | 'list' | 'process'>('list');
|
||||
const documentId = ref('');
|
||||
const documentTitle = ref('');
|
||||
const documentTableRef = ref();
|
||||
const chunkDocumentTableRef = ref();
|
||||
const importDocModalRef = ref<InstanceType<typeof ImportKnowledgeDocFile>>();
|
||||
|
||||
const isFaqCollection = computed(
|
||||
@@ -50,7 +52,12 @@ const knowledgeDescription = computed(
|
||||
() => knowledgeInfo.value.description || '',
|
||||
);
|
||||
const permissionScopeSet = computed(
|
||||
() => new Set((permissionScopes.value || []).map((item) => String(item || '').toUpperCase())),
|
||||
() =>
|
||||
new Set(
|
||||
(permissionScopes.value || []).map((item) =>
|
||||
String(item || '').toUpperCase(),
|
||||
),
|
||||
),
|
||||
);
|
||||
const canCreateContent = computed(() =>
|
||||
permissionScopeSet.value.has('CONTENT_CREATE'),
|
||||
@@ -64,9 +71,7 @@ const canUpdateContent = computed(() =>
|
||||
const canUpdateConfig = computed(() =>
|
||||
permissionScopeSet.value.has('CONFIG_UPDATE'),
|
||||
);
|
||||
const canDownloadContent = computed(() =>
|
||||
permissionScopeSet.value.has('VIEW'),
|
||||
);
|
||||
const canDownloadContent = computed(() => permissionScopeSet.value.has('VIEW'));
|
||||
const canManageContent = computed(
|
||||
() =>
|
||||
canCreateContent.value ||
|
||||
@@ -116,7 +121,12 @@ const handleViewDoc = (id: string) => {
|
||||
panelMode.value = 'chunk';
|
||||
};
|
||||
|
||||
const backToDocumentList = () => {
|
||||
const canLeaveChunk = async () =>
|
||||
panelMode.value !== 'chunk' ||
|
||||
(await chunkDocumentTableRef.value?.confirmLeave?.()) !== false;
|
||||
|
||||
const backToDocumentList = async () => {
|
||||
if (!(await canLeaveChunk())) return;
|
||||
panelMode.value = 'list';
|
||||
documentTitle.value = '';
|
||||
documentTableRef.value?.reload?.();
|
||||
@@ -132,7 +142,8 @@ const openImport = () => {
|
||||
importDocModalRef.value?.openDialog?.();
|
||||
};
|
||||
|
||||
const handleCategoryClick = (key: string) => {
|
||||
const handleCategoryClick = async (key: string) => {
|
||||
if (!(await canLeaveChunk())) return;
|
||||
selectedCategory.value = key;
|
||||
panelMode.value = 'list';
|
||||
};
|
||||
@@ -140,6 +151,8 @@ const handleCategoryClick = (key: string) => {
|
||||
onMounted(() => {
|
||||
loadKnowledge();
|
||||
});
|
||||
|
||||
onBeforeRouteLeave(() => canLeaveChunk());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -191,9 +204,9 @@ onMounted(() => {
|
||||
ref="documentTableRef"
|
||||
:knowledge-id="knowledgeInfo.id"
|
||||
:permissions="{
|
||||
canCreateContent: canCreateContent,
|
||||
canDeleteContent: canDeleteContent,
|
||||
canDownloadContent: canDownloadContent,
|
||||
canCreateContent,
|
||||
canDeleteContent,
|
||||
canDownloadContent,
|
||||
}"
|
||||
:request-client="knowledgeShareApi"
|
||||
:endpoint-prefix="endpointPrefix"
|
||||
@@ -202,8 +215,11 @@ onMounted(() => {
|
||||
/>
|
||||
<ChunkDocumentTable
|
||||
v-else-if="panelMode === 'chunk'"
|
||||
ref="chunkDocumentTableRef"
|
||||
:document-id="documentId"
|
||||
:manageable="canManageContent"
|
||||
:editable="canUpdateContent"
|
||||
:deletable="canDeleteContent"
|
||||
:request-client="knowledgeShareApi"
|
||||
:endpoint-prefix="endpointPrefix"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user