feat: 支持知识库文档批量删除与分块内联编辑
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { flushPromises, mount } from '@vue/test-utils';
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
|
|
||||||
|
import { ElPagination } from 'element-plus';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import PageData from './PageData.vue';
|
import PageData from './PageData.vue';
|
||||||
@@ -202,4 +203,73 @@ describe('page data recovery', () => {
|
|||||||
params: { pageNumber: 1, pageSize: 10, title: '月报' },
|
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';
|
import { getEmptyStateImageUrl } from '#/utils/assets';
|
||||||
|
|
||||||
interface PageDataProps {
|
interface PageDataProps {
|
||||||
|
beforePageChange?: () => boolean | Promise<boolean>;
|
||||||
pageUrl: string;
|
pageUrl: string;
|
||||||
|
refreshUrl?: string;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
pageSizes?: number[];
|
pageSizes?: number[];
|
||||||
extraQueryParams?: Record<string, any>;
|
extraQueryParams?: Record<string, any>;
|
||||||
@@ -29,23 +31,40 @@ interface PageDataRestoreState extends PageDataState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface PageDataReloadOptions {
|
interface PageDataReloadOptions {
|
||||||
|
lightweight?: boolean;
|
||||||
silent?: boolean;
|
silent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PageDataRequest {
|
interface PageDataRequest {
|
||||||
|
lightweight: boolean;
|
||||||
silent: boolean;
|
silent: boolean;
|
||||||
version: number;
|
version: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PageDataLoadEvent {
|
||||||
|
lightweight: boolean;
|
||||||
|
pageNumber: number;
|
||||||
|
recordCount: number;
|
||||||
|
silent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageDataLoadErrorEvent extends PageDataLoadEvent {
|
||||||
|
error: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<PageDataProps>(), {
|
const props = withDefaults(defineProps<PageDataProps>(), {
|
||||||
|
beforePageChange: undefined,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
pageSizes: () => [10, 20, 50, 100],
|
pageSizes: () => [10, 20, 50, 100],
|
||||||
|
refreshUrl: undefined,
|
||||||
extraQueryParams: () => ({}),
|
extraQueryParams: () => ({}),
|
||||||
initialPageNumber: 1,
|
initialPageNumber: 1,
|
||||||
initialQueryParams: () => ({}),
|
initialQueryParams: () => ({}),
|
||||||
requestClient: () => api,
|
requestClient: () => api,
|
||||||
});
|
});
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
(e: 'loadError', event: PageDataLoadErrorEvent): void;
|
||||||
|
(e: 'loadSuccess', event: PageDataLoadEvent): void;
|
||||||
(e: 'stateChange', state: PageDataState): void;
|
(e: 'stateChange', state: PageDataState): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -67,10 +86,10 @@ const pageInfo = reactive({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 模拟 API 调用 - 这里需要根据你的实际 API 调用方式调整
|
// 模拟 API 调用 - 这里需要根据你的实际 API 调用方式调整
|
||||||
const doGet = async (params: Record<string, any>) => {
|
const doGet = async (url: string, params: Record<string, any>) => {
|
||||||
// 这里替换为你的实际 API 调用
|
// 这里替换为你的实际 API 调用
|
||||||
// 例如:return await api.get(props.pageUrl, { params })
|
// 例如:return await api.get(props.pageUrl, { params })
|
||||||
const response = await props.requestClient.get(`${props.pageUrl}`, {
|
const response = await props.requestClient.get(url, {
|
||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
const data = await response.data;
|
const data = await response.data;
|
||||||
@@ -78,14 +97,29 @@ const doGet = async (params: Record<string, any>) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loadPageListOnce = async (request: PageDataRequest) => {
|
const loadPageListOnce = async (request: PageDataRequest) => {
|
||||||
|
const lightweight = Boolean(
|
||||||
|
request.lightweight && props.refreshUrl && pageInfo.pageNumber === 1,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
const res = await doGet({
|
const res = await doGet(lightweight ? props.refreshUrl! : props.pageUrl, {
|
||||||
pageNumber: pageInfo.pageNumber,
|
pageNumber: pageInfo.pageNumber,
|
||||||
pageSize: pageInfo.pageSize,
|
pageSize: pageInfo.pageSize,
|
||||||
...props.extraQueryParams,
|
...props.extraQueryParams,
|
||||||
...queryParams.value,
|
...queryParams.value,
|
||||||
});
|
});
|
||||||
if (request.version === pageRequestVersion) {
|
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 rawTotal = Number(res.data?.totalRow || 0);
|
||||||
const total = Number.isFinite(rawTotal) ? Math.max(0, rawTotal) : 0;
|
const total = Number.isFinite(rawTotal) ? Math.max(0, rawTotal) : 0;
|
||||||
const lastPage = Math.max(1, Math.ceil(total / pageInfo.pageSize));
|
const lastPage = Math.max(1, Math.ceil(total / pageInfo.pageSize));
|
||||||
@@ -96,6 +130,12 @@ const loadPageListOnce = async (request: PageDataRequest) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pageList.value = res.data?.records || [];
|
pageList.value = res.data?.records || [];
|
||||||
|
emit('loadSuccess', {
|
||||||
|
lightweight: false,
|
||||||
|
pageNumber: pageInfo.pageNumber,
|
||||||
|
recordCount: pageList.value.length,
|
||||||
|
silent: request.silent,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (request.version === pageRequestVersion) {
|
if (request.version === pageRequestVersion) {
|
||||||
@@ -104,12 +144,25 @@ const loadPageListOnce = async (request: PageDataRequest) => {
|
|||||||
pageList.value = [];
|
pageList.value = [];
|
||||||
pageInfo.total = 0;
|
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 = {
|
const request: PageDataRequest = {
|
||||||
|
lightweight,
|
||||||
silent: silent && pageList.value.length > 0,
|
silent: silent && pageList.value.length > 0,
|
||||||
version: ++pageRequestVersion,
|
version: ++pageRequestVersion,
|
||||||
};
|
};
|
||||||
@@ -120,6 +173,7 @@ const requestPageList = (silent: boolean) => {
|
|||||||
if (activePageRequest) {
|
if (activePageRequest) {
|
||||||
pendingPageRequest = pendingPageRequest
|
pendingPageRequest = pendingPageRequest
|
||||||
? {
|
? {
|
||||||
|
lightweight: pendingPageRequest.lightweight && request.lightweight,
|
||||||
silent: pendingPageRequest.silent && request.silent,
|
silent: pendingPageRequest.silent && request.silent,
|
||||||
version: request.version,
|
version: request.version,
|
||||||
}
|
}
|
||||||
@@ -143,19 +197,27 @@ const requestPageList = (silent: boolean) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 获取页面数据
|
// 获取页面数据
|
||||||
const getPageList = () => requestPageList(false);
|
const getPageList = () => requestPageList(false, false);
|
||||||
|
|
||||||
const reload = (options: PageDataReloadOptions = {}) =>
|
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.pageSize = newSize;
|
||||||
pageInfo.pageNumber = 1; // 重置到第一页
|
pageInfo.pageNumber = 1; // 重置到第一页
|
||||||
emitPageState();
|
emitPageState();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCurrentChange = (newPage: number) => {
|
const handleCurrentChange = async (newPage: number) => {
|
||||||
|
if (!(await canChangePage())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
pageInfo.pageNumber = newPage;
|
pageInfo.pageNumber = newPage;
|
||||||
emitPageState();
|
emitPageState();
|
||||||
};
|
};
|
||||||
@@ -165,6 +227,8 @@ const getPageState = (): PageDataState => ({
|
|||||||
pageSize: pageInfo.pageSize,
|
pageSize: pageInfo.pageSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const getPageRows = () => pageList.value;
|
||||||
|
|
||||||
function normalizeQueryParams(value: Record<string, any> = {}) {
|
function normalizeQueryParams(value: Record<string, any> = {}) {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries(value).filter(([, item]) => item !== undefined),
|
Object.entries(value).filter(([, item]) => item !== undefined),
|
||||||
@@ -206,6 +270,31 @@ const patchRowById = (
|
|||||||
return true;
|
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)
|
// 暴露给父组件的方法 (替代 useImperativeHandle)
|
||||||
const setQuery = (newQueryParams: Record<string, any>) => {
|
const setQuery = (newQueryParams: Record<string, any>) => {
|
||||||
pageInfo.pageNumber = 1;
|
pageInfo.pageNumber = 1;
|
||||||
@@ -237,9 +326,11 @@ const restoreState = (state: PageDataRestoreState) => {
|
|||||||
|
|
||||||
// 暴露方法给父组件
|
// 暴露方法给父组件
|
||||||
defineExpose({
|
defineExpose({
|
||||||
|
getPageRows,
|
||||||
getPageState,
|
getPageState,
|
||||||
reload,
|
reload,
|
||||||
patchRowById,
|
patchRowById,
|
||||||
|
removeRowById,
|
||||||
restoreState,
|
restoreState,
|
||||||
setQuery,
|
setQuery,
|
||||||
});
|
});
|
||||||
@@ -271,8 +362,8 @@ onMounted(() => {
|
|||||||
class="page-data-container__pagination mx-auto mt-6 w-fit"
|
class="page-data-container__pagination mx-auto mt-6 w-fit"
|
||||||
>
|
>
|
||||||
<ElPagination
|
<ElPagination
|
||||||
v-model:current-page="pageInfo.pageNumber"
|
:current-page="pageInfo.pageNumber"
|
||||||
v-model:page-size="pageInfo.pageSize"
|
:page-size="pageInfo.pageSize"
|
||||||
:total="pageInfo.total"
|
:total="pageInfo.total"
|
||||||
:page-sizes="pageSizes"
|
:page-sizes="pageSizes"
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
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",
|
"dimensionOfVectorModelTips": "After successful vector data, it is not allowed to modify the dimensions of the vector model",
|
||||||
"dimensionOfVectorModel": "Dimension of vector model",
|
"dimensionOfVectorModel": "Dimension of vector model",
|
||||||
"managePermissionHint": "Only the creator or super admin can modify this knowledge base",
|
"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": "成功向量数据之后不允许修改向量模型维度",
|
"dimensionOfVectorModelTips": "成功向量数据之后不允许修改向量模型维度",
|
||||||
"dimensionOfVectorModel": "向量模型维度",
|
"dimensionOfVectorModel": "向量模型维度",
|
||||||
"managePermissionHint": "仅创建者或超级管理员可修改当前知识库",
|
"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">
|
<script setup lang="ts">
|
||||||
import { computed, defineAsyncComponent, nextTick, onMounted, ref } from 'vue';
|
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 { useAccess } from '@easyflow/access';
|
||||||
import { $t } from '@easyflow/locales';
|
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 activeMenu = ref<string>((route.query.activeMenu as string) || '');
|
||||||
const knowledgeInfo = ref<any>({});
|
const knowledgeInfo = ref<any>({});
|
||||||
const selectedCategory = ref('');
|
const selectedCategory = ref('');
|
||||||
const canManageKnowledgePermission = computed(() =>
|
const canUpdateKnowledgePermission = computed(() =>
|
||||||
hasAccessByCodes(['/api/v1/documentCollection/save']),
|
hasAccessByCodes(['/api/v1/documentCollection/save']),
|
||||||
);
|
);
|
||||||
|
const canDeleteKnowledgePermission = computed(() =>
|
||||||
|
hasAccessByCodes(['/api/v1/documentCollection/remove']),
|
||||||
|
);
|
||||||
|
|
||||||
const isSuperAdmin = computed(() => {
|
const isSuperAdmin = computed(() => {
|
||||||
return (
|
return (
|
||||||
@@ -82,16 +85,23 @@ const isSuperAdmin = computed(() => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const canManageCurrentKnowledge = computed(() => {
|
const isCurrentKnowledgeOwner = computed(() => {
|
||||||
if (!knowledgeInfo.value?.id || !canManageKnowledgePermission.value) {
|
if (!knowledgeInfo.value?.id) return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
isSuperAdmin.value ||
|
isSuperAdmin.value ||
|
||||||
String(userStore.userInfo?.id || '') ===
|
String(userStore.userInfo?.id || '') ===
|
||||||
String(knowledgeInfo.value.createdBy || '')
|
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) => {
|
const syncNavTitle = (title: string) => {
|
||||||
if (!title) {
|
if (!title) {
|
||||||
@@ -152,6 +162,9 @@ onMounted(() => {
|
|||||||
getKnowledge();
|
getKnowledge();
|
||||||
});
|
});
|
||||||
const back = async () => {
|
const back = async () => {
|
||||||
|
if (!(await canLeaveChunk())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await navigateBackToList(
|
await navigateBackToList(
|
||||||
router,
|
router,
|
||||||
route.query,
|
route.query,
|
||||||
@@ -195,6 +208,7 @@ const headerButtons = [
|
|||||||
];
|
];
|
||||||
const panelMode = ref<'chunk' | 'list' | 'process'>('list');
|
const panelMode = ref<'chunk' | 'list' | 'process'>('list');
|
||||||
const documentTableRef = ref();
|
const documentTableRef = ref();
|
||||||
|
const chunkDocumentTableRef = ref();
|
||||||
const batchStatusRefreshKey = ref(0);
|
const batchStatusRefreshKey = ref(0);
|
||||||
const documentTitle = ref('');
|
const documentTitle = ref('');
|
||||||
const handleSearch = (searchParams: string) => {
|
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;
|
selectedCategory.value = menuKey;
|
||||||
panelMode.value = 'list';
|
panelMode.value = 'list';
|
||||||
};
|
};
|
||||||
@@ -229,6 +250,9 @@ const continueProcess = (doc: any) => {
|
|||||||
documentTitle.value = doc?.title || '';
|
documentTitle.value = doc?.title || '';
|
||||||
};
|
};
|
||||||
const backDoc = async () => {
|
const backDoc = async () => {
|
||||||
|
if (!(await canLeaveChunk())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (panelMode.value !== 'list') {
|
if (panelMode.value !== 'list') {
|
||||||
panelMode.value = 'list';
|
panelMode.value = 'list';
|
||||||
}
|
}
|
||||||
@@ -237,6 +261,8 @@ const backDoc = async () => {
|
|||||||
documentTableRef.value?.reload?.();
|
documentTableRef.value?.reload?.();
|
||||||
batchStatusRefreshKey.value += 1;
|
batchStatusRefreshKey.value += 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onBeforeRouteLeave(() => canLeaveChunk());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -254,7 +280,7 @@ const backDoc = async () => {
|
|||||||
<div class="description">
|
<div class="description">
|
||||||
{{ knowledgeInfo.description || '' }}
|
{{ knowledgeInfo.description || '' }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!canManageCurrentKnowledge" class="permission-tip">
|
<div v-if="!canOperateCurrentKnowledge" class="permission-tip">
|
||||||
{{ $t('documentCollection.managePermissionHint') }}
|
{{ $t('documentCollection.managePermissionHint') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -304,7 +330,7 @@ const backDoc = async () => {
|
|||||||
:knowledge-id="knowledgeId"
|
:knowledge-id="knowledgeId"
|
||||||
:permissions="{
|
:permissions="{
|
||||||
canCreateContent: canManageCurrentKnowledge,
|
canCreateContent: canManageCurrentKnowledge,
|
||||||
canDeleteContent: canManageCurrentKnowledge,
|
canDeleteContent: canDeleteCurrentKnowledge,
|
||||||
canDownloadContent: true,
|
canDownloadContent: true,
|
||||||
}"
|
}"
|
||||||
@continue-process="continueProcess"
|
@continue-process="continueProcess"
|
||||||
@@ -313,8 +339,11 @@ const backDoc = async () => {
|
|||||||
|
|
||||||
<ChunkDocumentTable
|
<ChunkDocumentTable
|
||||||
v-else-if="panelMode === 'chunk'"
|
v-else-if="panelMode === 'chunk'"
|
||||||
|
ref="chunkDocumentTableRef"
|
||||||
:document-id="documentId"
|
:document-id="documentId"
|
||||||
:manageable="canManageCurrentKnowledge"
|
:manageable="canManageCurrentKnowledge"
|
||||||
|
:editable="canManageCurrentKnowledge"
|
||||||
|
:deletable="canDeleteCurrentKnowledge"
|
||||||
:default-summary-prompt="knowledgeInfo.summaryPrompt"
|
:default-summary-prompt="knowledgeInfo.summaryPrompt"
|
||||||
/>
|
/>
|
||||||
<SegmenterDoc
|
<SegmenterDoc
|
||||||
@@ -389,7 +418,7 @@ const backDoc = async () => {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 20px 14px 0;
|
padding: 20px 14px 0;
|
||||||
background-color: var(--el-bg-color);
|
background-color: hsl(var(--surface-panel));
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,7 +501,7 @@ const backDoc = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.doc-table {
|
.doc-table {
|
||||||
background-color: var(--el-bg-color);
|
background-color: hsl(var(--surface-panel));
|
||||||
}
|
}
|
||||||
|
|
||||||
.doc-sub-back {
|
.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;
|
canDownloadContent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PageDataLoadEvent {
|
||||||
|
silent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
type PermissionKey = keyof DocumentTablePermissions;
|
type PermissionKey = keyof DocumentTablePermissions;
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -88,7 +92,12 @@ const STREAM_RECONNECT_DELAY = 1500;
|
|||||||
const STREAM_RELOAD_DELAY = 3000;
|
const STREAM_RELOAD_DELAY = 3000;
|
||||||
|
|
||||||
const pageDataRef = ref();
|
const pageDataRef = ref();
|
||||||
|
const tableRef = ref();
|
||||||
const retryingDocumentIds = ref<Set<string>>(new Set());
|
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();
|
const taskStatusStreamClient = new SseClient();
|
||||||
let reconnectTimer: null | ReturnType<typeof setTimeout> = null;
|
let reconnectTimer: null | ReturnType<typeof setTimeout> = null;
|
||||||
let reloadTimer: null | ReturnType<typeof setTimeout> = null;
|
let reloadTimer: null | ReturnType<typeof setTimeout> = null;
|
||||||
@@ -96,9 +105,13 @@ let disposed = false;
|
|||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
reload() {
|
reload() {
|
||||||
|
if (batchDeleting.value) return;
|
||||||
|
clearSelection();
|
||||||
pageDataRef.value?.reload?.();
|
pageDataRef.value?.reload?.();
|
||||||
},
|
},
|
||||||
search(searchText: string) {
|
search(searchText: string) {
|
||||||
|
if (batchDeleting.value) return;
|
||||||
|
clearSelection();
|
||||||
pageDataRef.value?.setQuery?.({
|
pageDataRef.value?.setQuery?.({
|
||||||
keyword: searchText.trim() || undefined,
|
keyword: searchText.trim() || undefined,
|
||||||
});
|
});
|
||||||
@@ -106,6 +119,7 @@ defineExpose({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const processingStatuses = new Set(['INDEXING', 'PARSING', 'SPLITTING']);
|
const processingStatuses = new Set(['INDEXING', 'PARSING', 'SPLITTING']);
|
||||||
|
const BATCH_DELETE_CONCURRENCY = 3;
|
||||||
|
|
||||||
const isProcessingStatus = (status?: string) =>
|
const isProcessingStatus = (status?: string) =>
|
||||||
processingStatuses.has(status || '');
|
processingStatuses.has(status || '');
|
||||||
@@ -277,9 +291,12 @@ const patchDocumentRow = (payload: DocumentStatusPayload) => {
|
|||||||
if (payload.taskModifiedAt) {
|
if (payload.taskModifiedAt) {
|
||||||
nextPatch.modified = payload.taskModifiedAt;
|
nextPatch.modified = payload.taskModifiedAt;
|
||||||
}
|
}
|
||||||
return (
|
const patched =
|
||||||
pageDataRef.value?.patchRowById?.(payload.documentId, nextPatch) ?? false
|
pageDataRef.value?.patchRowById?.(payload.documentId, nextPatch) ?? false;
|
||||||
);
|
if (patched && isProcessingStatus(payload.processStatus)) {
|
||||||
|
reconcileSelection();
|
||||||
|
}
|
||||||
|
return patched;
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildTaskStreamUrl = () => {
|
const buildTaskStreamUrl = () => {
|
||||||
@@ -432,28 +449,203 @@ const handleDownload = async (row: any) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (row: any) => {
|
const removeDocument = async (row: any) => {
|
||||||
if (!ensurePermission('canDeleteContent')) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (processingStatuses.has(row.processStatus)) {
|
if (processingStatuses.has(row.processStatus)) {
|
||||||
ElMessage.warning($t('documentCollection.processingDeleteBlocked'));
|
ElMessage.warning($t('documentCollection.processingDeleteBlocked'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
|
setDocumentDeleting(row, true);
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
$t('message.deleteAlert'),
|
||||||
|
$t('message.noticeTitle'),
|
||||||
|
{
|
||||||
confirmButtonText: $t('button.confirm'),
|
confirmButtonText: $t('button.confirm'),
|
||||||
cancelButtonText: $t('button.cancel'),
|
cancelButtonText: $t('button.cancel'),
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
}).then(async () => {
|
},
|
||||||
const res = await props.requestClient.post(
|
|
||||||
buildKnowledgePath(props.endpointPrefix, '/api/v1/document/removeDoc'),
|
|
||||||
{ id: row.id },
|
|
||||||
);
|
);
|
||||||
if (res.errorCode === 0) {
|
} catch {
|
||||||
ElMessage.success($t('message.deleteOkMessage'));
|
setDocumentDeleting(row, false);
|
||||||
pageDataRef.value?.reload?.();
|
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<
|
const primaryActionConfigs: Record<
|
||||||
@@ -507,6 +699,7 @@ const getPrimaryActionLabel = (row: any) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePrimaryAction = (row: any) => {
|
const handlePrimaryAction = (row: any) => {
|
||||||
|
if (isBatchLocked(row)) return;
|
||||||
const config = primaryActionConfigs[row.processStatus || ''];
|
const config = primaryActionConfigs[row.processStatus || ''];
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return;
|
return;
|
||||||
@@ -532,6 +725,7 @@ onBeforeUnmount(() => {
|
|||||||
watch(
|
watch(
|
||||||
() => `${props.endpointPrefix}:${props.knowledgeId}`,
|
() => `${props.endpointPrefix}:${props.knowledgeId}`,
|
||||||
() => {
|
() => {
|
||||||
|
clearSelection();
|
||||||
if (disposed) {
|
if (disposed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -554,9 +748,46 @@ watch(
|
|||||||
sort: 'desc',
|
sort: 'desc',
|
||||||
sortKey: 'created',
|
sortKey: 'created',
|
||||||
}"
|
}"
|
||||||
|
@load-success="handleListLoaded"
|
||||||
>
|
>
|
||||||
<template #default="{ pageList }">
|
<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
|
<ElTableColumn
|
||||||
prop="fileName"
|
prop="fileName"
|
||||||
:label="$t('documentCollection.fileName')"
|
:label="$t('documentCollection.fileName')"
|
||||||
@@ -677,7 +908,11 @@ watch(
|
|||||||
v-if="getPrimaryActionLabel(row)"
|
v-if="getPrimaryActionLabel(row)"
|
||||||
link
|
link
|
||||||
type="primary"
|
type="primary"
|
||||||
:disabled="isRetrying(row)"
|
:disabled="
|
||||||
|
isRetrying(row) ||
|
||||||
|
isBatchLocked(row) ||
|
||||||
|
isDocumentDeleting(row)
|
||||||
|
"
|
||||||
:loading="isRetrying(row)"
|
:loading="isRetrying(row)"
|
||||||
@click="handlePrimaryAction(row)"
|
@click="handlePrimaryAction(row)"
|
||||||
>
|
>
|
||||||
@@ -693,6 +928,7 @@ watch(
|
|||||||
link
|
link
|
||||||
:icon="Download"
|
:icon="Download"
|
||||||
:aria-label="$t('button.download')"
|
:aria-label="$t('button.download')"
|
||||||
|
:disabled="isBatchLocked(row) || isDocumentDeleting(row)"
|
||||||
@click="handleDownload(row)"
|
@click="handleDownload(row)"
|
||||||
/>
|
/>
|
||||||
</ElTooltip>
|
</ElTooltip>
|
||||||
@@ -707,6 +943,8 @@ watch(
|
|||||||
type="danger"
|
type="danger"
|
||||||
:icon="Delete"
|
:icon="Delete"
|
||||||
:aria-label="$t('button.delete')"
|
:aria-label="$t('button.delete')"
|
||||||
|
:disabled="isBatchLocked(row) || isDocumentDeleting(row)"
|
||||||
|
:loading="isDocumentDeleting(row)"
|
||||||
@click="handleDelete(row)"
|
@click="handleDelete(row)"
|
||||||
/>
|
/>
|
||||||
</ElTooltip>
|
</ElTooltip>
|
||||||
@@ -719,6 +957,26 @@ watch(
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.time-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { onBeforeRouteLeave } from 'vue-router';
|
||||||
|
|
||||||
import { $t } from '@easyflow/locales';
|
import { $t } from '@easyflow/locales';
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ const panelMode = ref<'chunk' | 'list' | 'process'>('list');
|
|||||||
const documentId = ref('');
|
const documentId = ref('');
|
||||||
const documentTitle = ref('');
|
const documentTitle = ref('');
|
||||||
const documentTableRef = ref();
|
const documentTableRef = ref();
|
||||||
|
const chunkDocumentTableRef = ref();
|
||||||
const importDocModalRef = ref<InstanceType<typeof ImportKnowledgeDocFile>>();
|
const importDocModalRef = ref<InstanceType<typeof ImportKnowledgeDocFile>>();
|
||||||
|
|
||||||
const isFaqCollection = computed(
|
const isFaqCollection = computed(
|
||||||
@@ -50,7 +52,12 @@ const knowledgeDescription = computed(
|
|||||||
() => knowledgeInfo.value.description || '',
|
() => knowledgeInfo.value.description || '',
|
||||||
);
|
);
|
||||||
const permissionScopeSet = computed(
|
const permissionScopeSet = computed(
|
||||||
() => new Set((permissionScopes.value || []).map((item) => String(item || '').toUpperCase())),
|
() =>
|
||||||
|
new Set(
|
||||||
|
(permissionScopes.value || []).map((item) =>
|
||||||
|
String(item || '').toUpperCase(),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const canCreateContent = computed(() =>
|
const canCreateContent = computed(() =>
|
||||||
permissionScopeSet.value.has('CONTENT_CREATE'),
|
permissionScopeSet.value.has('CONTENT_CREATE'),
|
||||||
@@ -64,9 +71,7 @@ const canUpdateContent = computed(() =>
|
|||||||
const canUpdateConfig = computed(() =>
|
const canUpdateConfig = computed(() =>
|
||||||
permissionScopeSet.value.has('CONFIG_UPDATE'),
|
permissionScopeSet.value.has('CONFIG_UPDATE'),
|
||||||
);
|
);
|
||||||
const canDownloadContent = computed(() =>
|
const canDownloadContent = computed(() => permissionScopeSet.value.has('VIEW'));
|
||||||
permissionScopeSet.value.has('VIEW'),
|
|
||||||
);
|
|
||||||
const canManageContent = computed(
|
const canManageContent = computed(
|
||||||
() =>
|
() =>
|
||||||
canCreateContent.value ||
|
canCreateContent.value ||
|
||||||
@@ -116,7 +121,12 @@ const handleViewDoc = (id: string) => {
|
|||||||
panelMode.value = 'chunk';
|
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';
|
panelMode.value = 'list';
|
||||||
documentTitle.value = '';
|
documentTitle.value = '';
|
||||||
documentTableRef.value?.reload?.();
|
documentTableRef.value?.reload?.();
|
||||||
@@ -132,7 +142,8 @@ const openImport = () => {
|
|||||||
importDocModalRef.value?.openDialog?.();
|
importDocModalRef.value?.openDialog?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCategoryClick = (key: string) => {
|
const handleCategoryClick = async (key: string) => {
|
||||||
|
if (!(await canLeaveChunk())) return;
|
||||||
selectedCategory.value = key;
|
selectedCategory.value = key;
|
||||||
panelMode.value = 'list';
|
panelMode.value = 'list';
|
||||||
};
|
};
|
||||||
@@ -140,6 +151,8 @@ const handleCategoryClick = (key: string) => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadKnowledge();
|
loadKnowledge();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onBeforeRouteLeave(() => canLeaveChunk());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -191,9 +204,9 @@ onMounted(() => {
|
|||||||
ref="documentTableRef"
|
ref="documentTableRef"
|
||||||
:knowledge-id="knowledgeInfo.id"
|
:knowledge-id="knowledgeInfo.id"
|
||||||
:permissions="{
|
:permissions="{
|
||||||
canCreateContent: canCreateContent,
|
canCreateContent,
|
||||||
canDeleteContent: canDeleteContent,
|
canDeleteContent,
|
||||||
canDownloadContent: canDownloadContent,
|
canDownloadContent,
|
||||||
}"
|
}"
|
||||||
:request-client="knowledgeShareApi"
|
:request-client="knowledgeShareApi"
|
||||||
:endpoint-prefix="endpointPrefix"
|
:endpoint-prefix="endpointPrefix"
|
||||||
@@ -202,8 +215,11 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
<ChunkDocumentTable
|
<ChunkDocumentTable
|
||||||
v-else-if="panelMode === 'chunk'"
|
v-else-if="panelMode === 'chunk'"
|
||||||
|
ref="chunkDocumentTableRef"
|
||||||
:document-id="documentId"
|
:document-id="documentId"
|
||||||
:manageable="canManageContent"
|
:manageable="canManageContent"
|
||||||
|
:editable="canUpdateContent"
|
||||||
|
:deletable="canDeleteContent"
|
||||||
:request-client="knowledgeShareApi"
|
:request-client="knowledgeShareApi"
|
||||||
:endpoint-prefix="endpointPrefix"
|
:endpoint-prefix="endpointPrefix"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const mockState = vi.hoisted(() => ({
|
|||||||
config: undefined as MockCrepeConfig | undefined,
|
config: undefined as MockCrepeConfig | undefined,
|
||||||
dispatch: vi.fn(),
|
dispatch: vi.fn(),
|
||||||
destroy: vi.fn(async () => undefined),
|
destroy: vi.fn(async () => undefined),
|
||||||
|
focus: vi.fn(),
|
||||||
listSpreadNodes: [] as Array<{
|
listSpreadNodes: [] as Array<{
|
||||||
attrs: Record<string, unknown>;
|
attrs: Record<string, unknown>;
|
||||||
marks: unknown[];
|
marks: unknown[];
|
||||||
@@ -107,7 +108,7 @@ vi.mock('@milkdown/crepe', () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (slice.name === 'editorView') {
|
if (slice.name === 'editorView') {
|
||||||
return { dispatch: mockState.dispatch };
|
return { dispatch: mockState.dispatch, focus: mockState.focus };
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
@@ -141,6 +142,7 @@ describe('markdownLiveEditor', () => {
|
|||||||
mockState.createPromise = undefined;
|
mockState.createPromise = undefined;
|
||||||
mockState.dispatch.mockClear();
|
mockState.dispatch.mockClear();
|
||||||
mockState.firstNode = undefined;
|
mockState.firstNode = undefined;
|
||||||
|
mockState.focus.mockClear();
|
||||||
mockState.replaceAll.mockClear();
|
mockState.replaceAll.mockClear();
|
||||||
mockState.resolve.mockClear();
|
mockState.resolve.mockClear();
|
||||||
mockState.selectionFrom = 2;
|
mockState.selectionFrom = 2;
|
||||||
@@ -251,6 +253,7 @@ describe('markdownLiveEditor', () => {
|
|||||||
|
|
||||||
const pointerup = vi.fn();
|
const pointerup = vi.fn();
|
||||||
hiddenAddButton.addEventListener('pointerup', pointerup);
|
hiddenAddButton.addEventListener('pointerup', pointerup);
|
||||||
|
trigger.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
|
||||||
trigger.click();
|
trigger.click();
|
||||||
expect(pointerup).toHaveBeenCalledOnce();
|
expect(pointerup).toHaveBeenCalledOnce();
|
||||||
trigger.dispatchEvent(
|
trigger.dispatchEvent(
|
||||||
@@ -265,6 +268,297 @@ describe('markdownLiveEditor', () => {
|
|||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('adds accessible labels to the selected-text toolbar', async () => {
|
||||||
|
const wrapper = mount(MarkdownLiveEditor, {
|
||||||
|
props: { modelValue: '# Initial' },
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const toolbar = document.createElement('div');
|
||||||
|
toolbar.className = 'milkdown-toolbar';
|
||||||
|
toolbar.innerHTML = Array.from(
|
||||||
|
{ length: 5 },
|
||||||
|
() => '<button class="toolbar-item"></button>',
|
||||||
|
).join('');
|
||||||
|
wrapper.get('.easyflow-markdown-live-editor__host').element.append(toolbar);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(toolbar.getAttribute('role')).toBe('toolbar');
|
||||||
|
expect(toolbar.getAttribute('aria-label')).toBe('文本格式');
|
||||||
|
expect(
|
||||||
|
[...toolbar.querySelectorAll('.toolbar-item')].map((item) =>
|
||||||
|
item.getAttribute('aria-label'),
|
||||||
|
),
|
||||||
|
).toEqual(['加粗', '斜体', '删除线', '行内代码', '链接']);
|
||||||
|
});
|
||||||
|
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the inline toolbar only for a text selection inside this editor', async () => {
|
||||||
|
const wrapper = mount(MarkdownLiveEditor, {
|
||||||
|
attachTo: document.body,
|
||||||
|
props: { modelValue: '# Initial', variant: 'inline' },
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
const host = wrapper.get('.easyflow-markdown-live-editor__host').element;
|
||||||
|
const text = document.createElement('p');
|
||||||
|
text.textContent = '可选择文本';
|
||||||
|
const toolbar = document.createElement('div');
|
||||||
|
toolbar.className = 'milkdown-toolbar';
|
||||||
|
toolbar.innerHTML = '<button class="toolbar-item"></button>';
|
||||||
|
host.append(text, toolbar);
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(toolbar.dataset.easyflowVisible).toBe('false'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(text);
|
||||||
|
const selection = document.getSelection();
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
selection?.addRange(range);
|
||||||
|
document.dispatchEvent(new Event('selectionchange'));
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(toolbar.dataset.easyflowVisible).toBe('true'),
|
||||||
|
);
|
||||||
|
|
||||||
|
selection?.collapseToEnd();
|
||||||
|
document.dispatchEvent(new Event('selectionchange'));
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(toolbar.dataset.easyflowVisible).toBe('false'),
|
||||||
|
);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps hidden table controls out of navigation and labels them when shown', async () => {
|
||||||
|
const wrapper = mount(MarkdownLiveEditor, {
|
||||||
|
props: { modelValue: '# Initial' },
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const tableBlock = document.createElement('div');
|
||||||
|
tableBlock.className = 'milkdown-table-block';
|
||||||
|
tableBlock.innerHTML = [
|
||||||
|
'<div class="handle cell-handle" data-role="col-drag-handle" data-show="false">',
|
||||||
|
'<div class="button-group"><button></button><button></button><button></button><button></button></div>',
|
||||||
|
'</div>',
|
||||||
|
].join('');
|
||||||
|
wrapper
|
||||||
|
.get('.easyflow-markdown-live-editor__host')
|
||||||
|
.element.append(tableBlock);
|
||||||
|
const handle = tableBlock.querySelector<HTMLElement>('.handle');
|
||||||
|
expect(handle).not.toBeNull();
|
||||||
|
if (!handle) {
|
||||||
|
throw new Error('Expected table handle to exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(handle.getAttribute('aria-hidden')).toBe('true');
|
||||||
|
expect(
|
||||||
|
[...handle.querySelectorAll('button')].map((button) => [
|
||||||
|
button.getAttribute('aria-label'),
|
||||||
|
button.getAttribute('tabindex'),
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
['左对齐', '-1'],
|
||||||
|
['居中对齐', '-1'],
|
||||||
|
['右对齐', '-1'],
|
||||||
|
['删除列', '-1'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
handle.dataset.show = 'true';
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(handle.hasAttribute('aria-hidden')).toBe(false);
|
||||||
|
expect(handle.querySelector('button')?.hasAttribute('tabindex')).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publishes meaningful toolbar transactions without requiring keyboard input', async () => {
|
||||||
|
const wrapper = mount(MarkdownLiveEditor, {
|
||||||
|
props: { modelValue: '# Initial' },
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const toolbar = document.createElement('div');
|
||||||
|
toolbar.className = 'milkdown-toolbar';
|
||||||
|
const toolbarButton = document.createElement('button');
|
||||||
|
toolbarButton.className = 'toolbar-item';
|
||||||
|
toolbar.append(toolbarButton);
|
||||||
|
wrapper.get('.easyflow-markdown-live-editor__host').element.append(toolbar);
|
||||||
|
toolbarButton.dispatchEvent(
|
||||||
|
new PointerEvent('pointerdown', { bubbles: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
mockState.markdown = '**Initial**';
|
||||||
|
mockState.markdownUpdated?.({}, '**Initial**');
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([
|
||||||
|
'**Initial**',
|
||||||
|
]);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flushes the current document before emitting the save shortcut', async () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
const wrapper = mount(MarkdownLiveEditor, {
|
||||||
|
props: {
|
||||||
|
modelValue: '# Initial',
|
||||||
|
onSave: () => events.push('save'),
|
||||||
|
'onUpdate:modelValue': (value: string) =>
|
||||||
|
events.push(`update:${value}`),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
mockState.markdown = '# Last input';
|
||||||
|
await wrapper
|
||||||
|
.get('.easyflow-markdown-live-editor__host')
|
||||||
|
.trigger('beforeinput');
|
||||||
|
|
||||||
|
await wrapper
|
||||||
|
.get('.easyflow-markdown-live-editor__host')
|
||||||
|
.trigger('keydown', { key: 's', metaKey: true });
|
||||||
|
(wrapper.vm as unknown as { flushMarkdown: () => string }).flushMarkdown();
|
||||||
|
|
||||||
|
expect(events).toEqual(['update:# Last input', 'save']);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the original source when a flush only changes equivalent formatting', async () => {
|
||||||
|
const source = '- first\n- second';
|
||||||
|
const wrapper = mount(MarkdownLiveEditor, {
|
||||||
|
props: { modelValue: source },
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
mockState.markdown = '* first\n* second';
|
||||||
|
|
||||||
|
const flushed = (
|
||||||
|
wrapper.vm as unknown as { flushMarkdown: () => string }
|
||||||
|
).flushMarkdown();
|
||||||
|
|
||||||
|
expect(flushed).toBe(source);
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not flush internal document changes before the user edits', async () => {
|
||||||
|
const source = '# Initial';
|
||||||
|
const wrapper = mount(MarkdownLiveEditor, {
|
||||||
|
props: { modelValue: source },
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
mockState.markdown = '## Internal normalization';
|
||||||
|
|
||||||
|
const flushed = (
|
||||||
|
wrapper.vm as unknown as { flushMarkdown: () => string }
|
||||||
|
).flushMarkdown();
|
||||||
|
|
||||||
|
expect(flushed).toBe(source);
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat keyboard text selection as an edit', async () => {
|
||||||
|
const source = '# Initial';
|
||||||
|
const wrapper = mount(MarkdownLiveEditor, {
|
||||||
|
props: { modelValue: source },
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
const host = wrapper.get('.easyflow-markdown-live-editor__host');
|
||||||
|
|
||||||
|
await host.trigger('keydown', { key: 'Home' });
|
||||||
|
await host.trigger('keydown', { key: 'End', shiftKey: true });
|
||||||
|
mockState.markdown = '## Internal normalization';
|
||||||
|
|
||||||
|
const flushed = (
|
||||||
|
wrapper.vm as unknown as { flushMarkdown: () => string }
|
||||||
|
).flushMarkdown();
|
||||||
|
|
||||||
|
expect(flushed).toBe(source);
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publishes undo and redo changes after a previous update was synchronized', async () => {
|
||||||
|
const wrapper = mount(MarkdownLiveEditor, {
|
||||||
|
props: { modelValue: '# Initial' },
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
const host = wrapper.get('.easyflow-markdown-live-editor__host');
|
||||||
|
|
||||||
|
await host.trigger('beforeinput');
|
||||||
|
mockState.markdown = '# Edited';
|
||||||
|
mockState.markdownUpdated?.({}, '# Edited');
|
||||||
|
|
||||||
|
await host.trigger('keydown', { key: 'z', metaKey: true });
|
||||||
|
await wrapper.setProps({ modelValue: '# Edited' });
|
||||||
|
mockState.markdown = '# Initial';
|
||||||
|
mockState.markdownUpdated?.({}, '# Initial');
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['# Initial']);
|
||||||
|
|
||||||
|
await wrapper.setProps({ modelValue: '# Initial' });
|
||||||
|
await host.trigger('keydown', { key: 'z', metaKey: true, shiftKey: true });
|
||||||
|
mockState.markdown = '# Edited';
|
||||||
|
mockState.markdownUpdated?.({}, '# Edited');
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['# Edited']);
|
||||||
|
|
||||||
|
await wrapper.setProps({ modelValue: '# Edited' });
|
||||||
|
await host.trigger('keydown', { ctrlKey: true, key: 'y' });
|
||||||
|
mockState.markdown = '# Initial';
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
wrapper.vm as unknown as { flushMarkdown: () => string }
|
||||||
|
).flushMarkdown(),
|
||||||
|
).toBe('# Initial');
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats table-handle dragging as a user edit', async () => {
|
||||||
|
const wrapper = mount(MarkdownLiveEditor, {
|
||||||
|
props: { modelValue: '# Initial' },
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
const handle = document.createElement('div');
|
||||||
|
handle.className = 'handle';
|
||||||
|
const tableBlock = document.createElement('div');
|
||||||
|
tableBlock.className = 'milkdown-table-block';
|
||||||
|
tableBlock.append(handle);
|
||||||
|
wrapper
|
||||||
|
.get('.easyflow-markdown-live-editor__host')
|
||||||
|
.element.append(tableBlock);
|
||||||
|
|
||||||
|
handle.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
|
||||||
|
mockState.markdown = '| moved |';
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
wrapper.vm as unknown as { flushMarkdown: () => string }
|
||||||
|
).flushMarkdown(),
|
||||||
|
).toBe('| moved |');
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('focuses without selecting content in the inline autofocus variant', async () => {
|
||||||
|
const wrapper = mount(MarkdownLiveEditor, {
|
||||||
|
props: {
|
||||||
|
autofocus: true,
|
||||||
|
modelValue: '# Initial',
|
||||||
|
variant: 'inline',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(wrapper.get('.easyflow-markdown-live-editor').classes()).toContain(
|
||||||
|
'is-inline',
|
||||||
|
);
|
||||||
|
expect(mockState.focus).toHaveBeenCalledOnce();
|
||||||
|
expect(mockState.setSelection).not.toHaveBeenCalled();
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
it('emits the save shortcut only while the editor is writable', async () => {
|
it('emits the save shortcut only while the editor is writable', async () => {
|
||||||
const wrapper = mount(MarkdownLiveEditor, {
|
const wrapper = mount(MarkdownLiveEditor, {
|
||||||
props: { modelValue: '# Initial' },
|
props: { modelValue: '# Initial' },
|
||||||
|
|||||||
@@ -20,17 +20,21 @@ import '@milkdown/crepe/theme/common/style.css';
|
|||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
autofocus?: boolean;
|
||||||
modelValue: string;
|
modelValue: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
resolveImageUrl?: (url: string) => Promise<string> | string;
|
resolveImageUrl?: (url: string) => Promise<string> | string;
|
||||||
uploadImage?: (file: File) => Promise<string>;
|
uploadImage?: (file: File) => Promise<string>;
|
||||||
|
variant?: 'document' | 'inline';
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
|
autofocus: false,
|
||||||
placeholder: '输入 Markdown 内容…',
|
placeholder: '输入 Markdown 内容…',
|
||||||
readonly: false,
|
readonly: false,
|
||||||
resolveImageUrl: undefined,
|
resolveImageUrl: undefined,
|
||||||
uploadImage: undefined,
|
uploadImage: undefined,
|
||||||
|
variant: 'document',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -55,6 +59,15 @@ let ready = false;
|
|||||||
let syncingFromOutside = false;
|
let syncingFromOutside = false;
|
||||||
let synchronizedMarkdown = '';
|
let synchronizedMarkdown = '';
|
||||||
let domObserver: MutationObserver | undefined;
|
let domObserver: MutationObserver | undefined;
|
||||||
|
let selectionFrame: number | undefined;
|
||||||
|
const ENHANCED_DOM_SELECTOR = [
|
||||||
|
'a[href]',
|
||||||
|
'.milkdown-block-handle',
|
||||||
|
'.milkdown-slash-menu',
|
||||||
|
'.milkdown-table-block .handle',
|
||||||
|
'.milkdown-toolbar',
|
||||||
|
'.toolbar-item',
|
||||||
|
].join(',');
|
||||||
|
|
||||||
async function createEditor() {
|
async function createEditor() {
|
||||||
if (!hostRef.value) return;
|
if (!hostRef.value) return;
|
||||||
@@ -173,6 +186,7 @@ async function createEditor() {
|
|||||||
crepe.setReadonly(props.readonly || fidelityLoss.value);
|
crepe.setReadonly(props.readonly || fidelityLoss.value);
|
||||||
crepeRef.value = crepe;
|
crepeRef.value = crepe;
|
||||||
ready = true;
|
ready = true;
|
||||||
|
if (props.autofocus && !props.readonly && !fidelityLoss.value) focus();
|
||||||
emit('ready');
|
emit('ready');
|
||||||
} catch (error_) {
|
} catch (error_) {
|
||||||
failed.value = true;
|
failed.value = true;
|
||||||
@@ -229,14 +243,16 @@ onMounted(() => {
|
|||||||
hostRef.value?.addEventListener('click', handleLinkClick);
|
hostRef.value?.addEventListener('click', handleLinkClick);
|
||||||
hostRef.value?.addEventListener('click', handleFrontmatterActivate);
|
hostRef.value?.addEventListener('click', handleFrontmatterActivate);
|
||||||
hostRef.value?.addEventListener('beforeinput', prepareUserEdit, true);
|
hostRef.value?.addEventListener('beforeinput', prepareUserEdit, true);
|
||||||
hostRef.value?.addEventListener('drop', markUserEdited);
|
hostRef.value?.addEventListener('drop', markUserEdited, true);
|
||||||
hostRef.value?.addEventListener('keydown', handleKeydown, true);
|
hostRef.value?.addEventListener('keydown', handleKeydown, true);
|
||||||
|
hostRef.value?.addEventListener('focusout', handleEditorFocusOut, true);
|
||||||
hostRef.value?.addEventListener('paste', prepareUserEdit, true);
|
hostRef.value?.addEventListener('paste', prepareUserEdit, true);
|
||||||
hostRef.value?.addEventListener('pointerup', prepareBlockMenuEdit, true);
|
hostRef.value?.addEventListener('pointerdown', prepareControlEdit, true);
|
||||||
|
document.addEventListener('selectionchange', handleSelectionChange);
|
||||||
if (hostRef.value) {
|
if (hostRef.value) {
|
||||||
domObserver = new MutationObserver(refreshRenderedContent);
|
domObserver = new MutationObserver(handleDomMutations);
|
||||||
domObserver.observe(hostRef.value, {
|
domObserver.observe(hostRef.value, {
|
||||||
attributeFilter: ['href'],
|
attributeFilter: ['data-show', 'href'],
|
||||||
attributes: true,
|
attributes: true,
|
||||||
childList: true,
|
childList: true,
|
||||||
subtree: true,
|
subtree: true,
|
||||||
@@ -251,10 +267,13 @@ onBeforeUnmount(async () => {
|
|||||||
hostRef.value?.removeEventListener('click', handleLinkClick);
|
hostRef.value?.removeEventListener('click', handleLinkClick);
|
||||||
hostRef.value?.removeEventListener('click', handleFrontmatterActivate);
|
hostRef.value?.removeEventListener('click', handleFrontmatterActivate);
|
||||||
hostRef.value?.removeEventListener('beforeinput', prepareUserEdit, true);
|
hostRef.value?.removeEventListener('beforeinput', prepareUserEdit, true);
|
||||||
hostRef.value?.removeEventListener('drop', markUserEdited);
|
hostRef.value?.removeEventListener('drop', markUserEdited, true);
|
||||||
hostRef.value?.removeEventListener('keydown', handleKeydown, true);
|
hostRef.value?.removeEventListener('keydown', handleKeydown, true);
|
||||||
|
hostRef.value?.removeEventListener('focusout', handleEditorFocusOut, true);
|
||||||
hostRef.value?.removeEventListener('paste', prepareUserEdit, true);
|
hostRef.value?.removeEventListener('paste', prepareUserEdit, true);
|
||||||
hostRef.value?.removeEventListener('pointerup', prepareBlockMenuEdit, true);
|
hostRef.value?.removeEventListener('pointerdown', prepareControlEdit, true);
|
||||||
|
document.removeEventListener('selectionchange', handleSelectionChange);
|
||||||
|
if (selectionFrame !== undefined) cancelAnimationFrame(selectionFrame);
|
||||||
domObserver?.disconnect();
|
domObserver?.disconnect();
|
||||||
domObserver = undefined;
|
domObserver = undefined;
|
||||||
await crepeRef.value?.destroy();
|
await crepeRef.value?.destroy();
|
||||||
@@ -313,11 +332,17 @@ function markUserEdited() {
|
|||||||
hasUserEdited = true;
|
hasUserEdited = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function prepareBlockMenuEdit(event: PointerEvent) {
|
function prepareControlEdit(event: PointerEvent) {
|
||||||
const target = event.target as HTMLElement | null;
|
const target = event.target as HTMLElement | null;
|
||||||
if (
|
if (
|
||||||
target?.closest(
|
target?.closest(
|
||||||
'.milkdown-block-handle .operation-item, .milkdown-slash-menu .menu-group li',
|
[
|
||||||
|
'.milkdown-block-handle .operation-item',
|
||||||
|
'.milkdown-slash-menu li',
|
||||||
|
'.milkdown-table-block .handle',
|
||||||
|
'.milkdown-table-block button',
|
||||||
|
'.milkdown-toolbar .toolbar-item',
|
||||||
|
].join(','),
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
markUserEdited();
|
markUserEdited();
|
||||||
@@ -329,8 +354,10 @@ function prepareBlockMenuEdit(event: PointerEvent) {
|
|||||||
* ProseMirror otherwise allows a gap selection before the first atom, which
|
* ProseMirror otherwise allows a gap selection before the first atom, which
|
||||||
* would serialize body content ahead of the required YAML metadata block.
|
* would serialize body content ahead of the required YAML metadata block.
|
||||||
*/
|
*/
|
||||||
function prepareUserEdit() {
|
function prepareUserEdit(event?: Event) {
|
||||||
|
if (!(event instanceof KeyboardEvent) || isEditingKey(event)) {
|
||||||
markUserEdited();
|
markUserEdited();
|
||||||
|
}
|
||||||
const crepe = crepeRef.value;
|
const crepe = crepeRef.value;
|
||||||
if (!crepe) return;
|
if (!crepe) return;
|
||||||
crepe.editor.action((ctx) => {
|
crepe.editor.action((ctx) => {
|
||||||
@@ -351,6 +378,15 @@ function prepareUserEdit() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isEditingKey(event: KeyboardEvent) {
|
||||||
|
if (['Backspace', 'Delete', 'Enter', 'Tab'].includes(event.key)) return true;
|
||||||
|
if (event.key.length === 1 && !event.metaKey && !event.ctrlKey) return true;
|
||||||
|
return (
|
||||||
|
(event.metaKey || event.ctrlKey) &&
|
||||||
|
['b', 'i', 'y', 'z'].includes(event.key.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeRenderedLinks() {
|
function sanitizeRenderedLinks() {
|
||||||
hostRef.value
|
hostRef.value
|
||||||
?.querySelectorAll<HTMLAnchorElement>('a[href]')
|
?.querySelectorAll<HTMLAnchorElement>('a[href]')
|
||||||
@@ -371,6 +407,112 @@ function sanitizeRenderedLinks() {
|
|||||||
function refreshRenderedContent() {
|
function refreshRenderedContent() {
|
||||||
sanitizeRenderedLinks();
|
sanitizeRenderedLinks();
|
||||||
enhanceBlockMenu();
|
enhanceBlockMenu();
|
||||||
|
enhanceTableControls();
|
||||||
|
enhanceToolbar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDomMutations(records: MutationRecord[]) {
|
||||||
|
const needsRefresh = records.some((record) => {
|
||||||
|
if (record.type === 'attributes') return true;
|
||||||
|
return [...record.addedNodes].some(
|
||||||
|
(node) =>
|
||||||
|
node instanceof Element &&
|
||||||
|
(node.matches(ENHANCED_DOM_SELECTOR) ||
|
||||||
|
Boolean(node.querySelector(ENHANCED_DOM_SELECTOR))),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (needsRefresh) refreshRenderedContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
function enhanceTableControls() {
|
||||||
|
const host = hostRef.value;
|
||||||
|
if (!host) return;
|
||||||
|
const buttonLabels: Record<string, string[]> = {
|
||||||
|
'col-drag-handle': ['左对齐', '居中对齐', '右对齐', '删除列'],
|
||||||
|
'row-drag-handle': ['删除行'],
|
||||||
|
'x-line-drag-handle': ['插入行'],
|
||||||
|
'y-line-drag-handle': ['插入列'],
|
||||||
|
};
|
||||||
|
host
|
||||||
|
.querySelectorAll<HTMLElement>('.milkdown-table-block .handle[data-role]')
|
||||||
|
.forEach((handle) => {
|
||||||
|
const isVisible = handle.dataset.show !== 'false';
|
||||||
|
if (isVisible) handle.removeAttribute('aria-hidden');
|
||||||
|
else handle.setAttribute('aria-hidden', 'true');
|
||||||
|
const labels = buttonLabels[handle.dataset.role || ''] || [];
|
||||||
|
handle
|
||||||
|
.querySelectorAll<HTMLButtonElement>('button')
|
||||||
|
.forEach((button, index) => {
|
||||||
|
const label = labels[index] || '表格操作';
|
||||||
|
button.setAttribute('aria-label', label);
|
||||||
|
button.setAttribute('title', label);
|
||||||
|
if (isVisible) button.removeAttribute('tabindex');
|
||||||
|
else button.setAttribute('tabindex', '-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function enhanceToolbar() {
|
||||||
|
const toolbar =
|
||||||
|
hostRef.value?.querySelector<HTMLElement>('.milkdown-toolbar');
|
||||||
|
if (!toolbar) return;
|
||||||
|
toolbar.setAttribute('aria-label', '文本格式');
|
||||||
|
toolbar.setAttribute('role', 'toolbar');
|
||||||
|
const labels = ['加粗', '斜体', '删除线', '行内代码', '链接'];
|
||||||
|
toolbar
|
||||||
|
.querySelectorAll<HTMLButtonElement>('.toolbar-item')
|
||||||
|
.forEach((button, index) => {
|
||||||
|
const label = labels[index] || `格式操作 ${index + 1}`;
|
||||||
|
button.setAttribute('aria-label', label);
|
||||||
|
button.setAttribute('title', label);
|
||||||
|
});
|
||||||
|
updateInlineToolbarVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectionChange() {
|
||||||
|
if (props.variant !== 'inline') return;
|
||||||
|
if (selectionFrame !== undefined) cancelAnimationFrame(selectionFrame);
|
||||||
|
selectionFrame = requestAnimationFrame(() => {
|
||||||
|
selectionFrame = undefined;
|
||||||
|
updateInlineToolbarVisibility();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditorFocusOut() {
|
||||||
|
if (props.variant !== 'inline') return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!hostRef.value?.contains(document.activeElement)) {
|
||||||
|
setInlineToolbarVisible(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateInlineToolbarVisibility() {
|
||||||
|
if (props.variant !== 'inline') return;
|
||||||
|
const host = hostRef.value;
|
||||||
|
const selection = document.getSelection();
|
||||||
|
const visible = Boolean(
|
||||||
|
host &&
|
||||||
|
selection &&
|
||||||
|
!selection.isCollapsed &&
|
||||||
|
selection.toString().trim() &&
|
||||||
|
selection.anchorNode &&
|
||||||
|
selection.focusNode &&
|
||||||
|
host.contains(selection.anchorNode) &&
|
||||||
|
host.contains(selection.focusNode),
|
||||||
|
);
|
||||||
|
setInlineToolbarVisible(visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setInlineToolbarVisible(visible: boolean) {
|
||||||
|
const toolbar =
|
||||||
|
hostRef.value?.querySelector<HTMLElement>('.milkdown-toolbar');
|
||||||
|
if (!toolbar) return;
|
||||||
|
toolbar.dataset.easyflowVisible = String(visible);
|
||||||
|
toolbar.setAttribute('aria-hidden', String(!visible));
|
||||||
|
toolbar
|
||||||
|
.querySelectorAll<HTMLButtonElement>('.toolbar-item')
|
||||||
|
.forEach((button) => button.setAttribute('tabindex', visible ? '0' : '-1'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function enhanceBlockMenu() {
|
function enhanceBlockMenu() {
|
||||||
@@ -409,12 +551,20 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
activateBlockMenu(blockMenuTrigger);
|
activateBlockMenu(blockMenuTrigger);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
|
if (event.key === 'Escape' && props.variant === 'inline') {
|
||||||
event.preventDefault();
|
document.getSelection()?.collapseToEnd();
|
||||||
if (!props.readonly) emit('save');
|
setInlineToolbarVisible(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
prepareUserEdit();
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!props.readonly) {
|
||||||
|
flushMarkdown();
|
||||||
|
emit('save');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
prepareUserEdit(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
function reportFidelityLoss(crepe: Crepe) {
|
function reportFidelityLoss(crepe: Crepe) {
|
||||||
@@ -458,10 +608,47 @@ function normalizeListSpreadAttributes(crepe: Crepe) {
|
|||||||
});
|
});
|
||||||
return repaired;
|
return repaired;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function flushMarkdown() {
|
||||||
|
const crepe = crepeRef.value;
|
||||||
|
if (!crepe || !ready || fidelityLoss.value) return props.modelValue || '';
|
||||||
|
if (!hasUserEdited) return props.modelValue || '';
|
||||||
|
normalizeListSpreadAttributes(crepe);
|
||||||
|
const nextMarkdown = normalizeLiveMarkdownUpdate(
|
||||||
|
props.modelValue || '',
|
||||||
|
crepe.getMarkdown(),
|
||||||
|
);
|
||||||
|
const hasMeaningfulDifference = hasMeaningfulMarkdownDifference(
|
||||||
|
props.modelValue || '',
|
||||||
|
nextMarkdown,
|
||||||
|
);
|
||||||
|
if (!hasMeaningfulDifference) {
|
||||||
|
synchronizedMarkdown = nextMarkdown;
|
||||||
|
return props.modelValue || '';
|
||||||
|
}
|
||||||
|
const shouldEmit =
|
||||||
|
!syncingFromOutside &&
|
||||||
|
nextMarkdown !== props.modelValue &&
|
||||||
|
nextMarkdown !== synchronizedMarkdown;
|
||||||
|
synchronizedMarkdown = nextMarkdown;
|
||||||
|
if (shouldEmit) emit('update:modelValue', nextMarkdown);
|
||||||
|
return nextMarkdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function focus() {
|
||||||
|
const crepe = crepeRef.value;
|
||||||
|
if (!crepe || props.readonly || fidelityLoss.value) return;
|
||||||
|
crepe.editor.action((ctx) => ctx.get(editorViewCtx).focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ flushMarkdown, focus });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="easyflow-markdown-live-editor">
|
<div
|
||||||
|
class="easyflow-markdown-live-editor"
|
||||||
|
:class="[`is-${variant}`, { 'is-readonly': readonly || fidelityLoss }]"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
ref="hostRef"
|
ref="hostRef"
|
||||||
class="easyflow-markdown-live-editor__host"
|
class="easyflow-markdown-live-editor__host"
|
||||||
@@ -998,6 +1185,128 @@ function normalizeListSpreadAttributes(crepe: Crepe) {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor.is-inline {
|
||||||
|
min-height: 64px;
|
||||||
|
overflow: visible;
|
||||||
|
scrollbar-gutter: auto;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.72;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor.is-inline .easyflow-markdown-live-editor__host,
|
||||||
|
.easyflow-markdown-live-editor.is-inline :deep(.milkdown) {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor.is-inline :deep(.milkdown) {
|
||||||
|
--crepe-color-selected: hsl(var(--text-muted) / 38%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor.is-inline :deep(.ProseMirror) {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 64px;
|
||||||
|
padding: var(--space-1) 0;
|
||||||
|
margin: 0;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor.is-inline :deep(.ProseMirror > p) {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor.is-inline :deep(.ProseMirror::selection),
|
||||||
|
.easyflow-markdown-live-editor.is-inline :deep(.ProseMirror *::selection) {
|
||||||
|
color: hsl(var(--text-strong));
|
||||||
|
background: hsl(var(--text-muted) / 38%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor.is-inline
|
||||||
|
:deep(.ProseMirror .ProseMirror-selectednode) {
|
||||||
|
background: hsl(var(--text-muted) / 14%);
|
||||||
|
outline: 1px solid hsl(var(--text-muted) / 52%);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor.is-inline :deep(.milkdown-block-handle) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor.is-inline :deep(.milkdown-toolbar) {
|
||||||
|
max-width: calc(100vw - var(--space-8));
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor.is-inline
|
||||||
|
:deep(.milkdown-toolbar[data-easyflow-visible='false']) {
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor.is-inline
|
||||||
|
:deep(.milkdown-toolbar .toolbar-item) {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: var(--space-1);
|
||||||
|
margin: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor.is-inline
|
||||||
|
:deep(.milkdown-toolbar .toolbar-item svg) {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
fill: hsl(var(--text-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor.is-inline
|
||||||
|
:deep(.milkdown-toolbar .toolbar-item.active svg) {
|
||||||
|
color: hsl(var(--text-strong));
|
||||||
|
fill: hsl(var(--text-strong));
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor.is-inline :deep(.milkdown-toolbar .divider) {
|
||||||
|
height: 20px;
|
||||||
|
margin: var(--space-2) var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor.is-inline :deep(.milkdown-table-block) {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor.is-inline
|
||||||
|
:deep(.milkdown-table-block .table-wrapper) {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor.is-inline
|
||||||
|
:deep(.milkdown-table-block .table-wrapper > table.children) {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor.is-inline :deep(th),
|
||||||
|
.easyflow-markdown-live-editor.is-inline :deep(td) {
|
||||||
|
min-width: 8rem;
|
||||||
|
overflow-wrap: normal;
|
||||||
|
word-break: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor.is-readonly :deep(.milkdown-toolbar),
|
||||||
|
.easyflow-markdown-live-editor.is-readonly :deep(.milkdown-block-handle),
|
||||||
|
.easyflow-markdown-live-editor.is-readonly :deep(.milkdown-table-block .handle),
|
||||||
|
.easyflow-markdown-live-editor.is-readonly :deep(.milkdown-slash-menu) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
@supports not (color: color-mix(in srgb, red, blue)) {
|
@supports not (color: color-mix(in srgb, red, blue)) {
|
||||||
.easyflow-markdown-live-editor :deep(.ProseMirror code),
|
.easyflow-markdown-live-editor :deep(.ProseMirror code),
|
||||||
.easyflow-markdown-live-editor :deep(.ProseMirror pre) {
|
.easyflow-markdown-live-editor :deep(.ProseMirror pre) {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
export { default as CodeEditor } from './CodeEditor.vue';
|
export { default as CodeEditor } from './CodeEditor.vue';
|
||||||
export { default as CodeViewer } from './CodeViewer.vue';
|
export { default as CodeViewer } from './CodeViewer.vue';
|
||||||
|
export {
|
||||||
|
containsRawHtml,
|
||||||
|
hasEquivalentMarkdownSemantics,
|
||||||
|
} from './markdown-fidelity';
|
||||||
export * from './markdown-security';
|
export * from './markdown-security';
|
||||||
export { default as MarkdownLiveEditor } from './MarkdownLiveEditor.vue';
|
export { default as MarkdownLiveEditor } from './MarkdownLiveEditor.vue';
|
||||||
export { default as MarkdownSourceEditor } from './MarkdownSourceEditor.vue';
|
export { default as MarkdownSourceEditor } from './MarkdownSourceEditor.vue';
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
containsRawHtml,
|
||||||
hasEquivalentMarkdownSemantics,
|
hasEquivalentMarkdownSemantics,
|
||||||
normalizeLiveMarkdownUpdate,
|
normalizeLiveMarkdownUpdate,
|
||||||
} from './markdown-fidelity';
|
} from './markdown-fidelity';
|
||||||
|
|
||||||
|
describe('raw HTML detection', () => {
|
||||||
|
it('detects rendered HTML but ignores HTML-looking text inside code', () => {
|
||||||
|
expect(containsRawHtml('<table><tr><td>A</td></tr></table>')).toBe(true);
|
||||||
|
expect(containsRawHtml('`<table>`')).toBe(false);
|
||||||
|
expect(containsRawHtml('```html\n<table>\n```')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('markdown semantic fidelity', () => {
|
describe('markdown semantic fidelity', () => {
|
||||||
it('accepts equivalent list markers, rules, and table delimiter widths', () => {
|
it('accepts equivalent list markers, rules, and table delimiter widths', () => {
|
||||||
const source = [
|
const source = [
|
||||||
|
|||||||
@@ -21,6 +21,24 @@ export function hasEquivalentMarkdownSemantics(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw HTML is preserved as text by the live editor for safety, so callers that
|
||||||
|
* promise rendered editing should use their source-mode fallback instead.
|
||||||
|
*/
|
||||||
|
export function containsRawHtml(markdown: string) {
|
||||||
|
try {
|
||||||
|
const nodes: MarkdownNode[] = [markdownParser.parse(markdown)];
|
||||||
|
while (nodes.length > 0) {
|
||||||
|
const node = nodes.pop();
|
||||||
|
if (node?.type === 'html') return true;
|
||||||
|
if (node?.children) nodes.push(...node.children);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes blank-paragraph markers generated by the live editor without
|
* Removes blank-paragraph markers generated by the live editor without
|
||||||
* changing an explicit HTML break that already exists in the source.
|
* changing an explicit HTML break that already exists in the source.
|
||||||
@@ -71,3 +89,8 @@ function semanticTree(markdown: string) {
|
|||||||
(key, value) => (key === 'position' ? undefined : value),
|
(key, value) => (key === 'position' ? undefined : value),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MarkdownNode = {
|
||||||
|
children?: MarkdownNode[];
|
||||||
|
type?: string;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user