feat: 支持知识库文档批量删除与分块内联编辑

This commit is contained in:
2026-09-04 11:33:09 +08:00
parent 0f126ad489
commit 65c85180c2
15 changed files with 3419 additions and 493 deletions

View File

@@ -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';
@@ -305,4 +306,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);
});
}); });

View File

@@ -9,6 +9,7 @@ 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; refreshUrl?: string;
pageSize?: number; pageSize?: number;
@@ -44,6 +45,7 @@ interface PageDataLoadEvent {
lightweight: boolean; lightweight: boolean;
pageNumber: number; pageNumber: number;
recordCount: number; recordCount: number;
silent: boolean;
} }
interface PageDataLoadErrorEvent extends PageDataLoadEvent { interface PageDataLoadErrorEvent extends PageDataLoadEvent {
@@ -51,6 +53,7 @@ interface PageDataLoadErrorEvent extends PageDataLoadEvent {
} }
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, refreshUrl: undefined,
@@ -113,6 +116,7 @@ const loadPageListOnce = async (request: PageDataRequest) => {
lightweight: true, lightweight: true,
pageNumber: pageInfo.pageNumber, pageNumber: pageInfo.pageNumber,
recordCount: pageList.value.length, recordCount: pageList.value.length,
silent: request.silent,
}); });
return; return;
} }
@@ -130,6 +134,7 @@ const loadPageListOnce = async (request: PageDataRequest) => {
lightweight: false, lightweight: false,
pageNumber: pageInfo.pageNumber, pageNumber: pageInfo.pageNumber,
recordCount: pageList.value.length, recordCount: pageList.value.length,
silent: request.silent,
}); });
} }
} catch (error) { } catch (error) {
@@ -144,6 +149,7 @@ const loadPageListOnce = async (request: PageDataRequest) => {
lightweight, lightweight,
pageNumber: pageInfo.pageNumber, pageNumber: pageInfo.pageNumber,
recordCount: pageList.value.length, recordCount: pageList.value.length,
silent: request.silent,
}); });
} }
} }
@@ -197,13 +203,21 @@ const reload = (options: PageDataReloadOptions = {}) =>
requestPageList(Boolean(options.silent), Boolean(options.lightweight)); 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();
}; };
@@ -213,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),
@@ -254,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;
@@ -285,9 +326,11 @@ const restoreState = (state: PageDataRestoreState) => {
// 暴露方法给父组件 // 暴露方法给父组件
defineExpose({ defineExpose({
getPageRows,
getPageState, getPageState,
reload, reload,
patchRowById, patchRowById,
removeRowById,
restoreState, restoreState,
setQuery, setQuery,
}); });
@@ -319,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"

View File

@@ -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."
} }

View File

@@ -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": "分块内容为空,保存后将删除该分块。删除后无法恢复。"
} }

View File

@@ -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(' ');
});
});

View File

@@ -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 {

View File

@@ -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();
});
});

View File

@@ -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);
confirmButtonText: $t('button.confirm'), try {
cancelButtonText: $t('button.cancel'), await ElMessageBox.confirm(
type: 'warning', $t('message.deleteAlert'),
}).then(async () => { $t('message.noticeTitle'),
const res = await props.requestClient.post( {
buildKnowledgePath(props.endpointPrefix, '/api/v1/document/removeDoc'), confirmButtonText: $t('button.confirm'),
{ id: row.id }, cancelButtonText: $t('button.cancel'),
type: 'warning',
},
); );
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;

View File

@@ -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"
/> />

View File

@@ -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' },

View File

@@ -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) {
markUserEdited(); if (!(event instanceof KeyboardEvent) || isEditingKey(event)) {
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) {

View File

@@ -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';

View File

@@ -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 = [

View File

@@ -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;
};