feat: 支持工作流文件节点多格式文档导出
- 补齐 md/html/pdf/docx 导出与统一渲染服务 - 收口文件生成节点配置与格式校验 - 修复 PDF 中文字体与 Markdown 渲染链路
This commit is contained in:
@@ -42,6 +42,16 @@
|
||||
"fileGeneration": "FileGeneration",
|
||||
"fileSettings": "FileSettings",
|
||||
"fileDownloadURL": "FileDownloadURL",
|
||||
"targetFormat": "TargetFormat",
|
||||
"fileName": "FileName",
|
||||
"documents": "Documents",
|
||||
"count": "Count",
|
||||
"fileNamePlaceholder": "For example: meeting-notes",
|
||||
"templateStyle": "TemplateStyle",
|
||||
"defaultTemplateStyle": "Default",
|
||||
"plain_text": "Plain Text",
|
||||
"markdown": "Markdown",
|
||||
"html": "HTML",
|
||||
"pluginSelect": "PluginSelect",
|
||||
"saveData": "SaveData",
|
||||
"saveDataset": "Write Data",
|
||||
@@ -105,6 +115,9 @@
|
||||
"fileGeneration": "Generate Word, PDF, HTML, etc. files for users to download",
|
||||
"fileType": "Please select the type of file to generate",
|
||||
"fileDownloadURL": "Generated file URL",
|
||||
"targetFormat": "Choose the final document format to export",
|
||||
"fileName": "Optional. The system will sanitize invalid characters and enforce the target extension",
|
||||
"templateStyle": "Only the default style is available in the first version. Unknown values fall back automatically",
|
||||
"plugin": "Select a predefined plugin",
|
||||
"saveData": "Save data to data hub",
|
||||
"saveDataset": "Write data into a managed table",
|
||||
|
||||
@@ -42,6 +42,16 @@
|
||||
"fileGeneration": "文件生成",
|
||||
"fileSettings": "文件设置",
|
||||
"fileDownloadURL": "文件下载地址",
|
||||
"targetFormat": "输出文档类型",
|
||||
"fileName": "文件名",
|
||||
"documents": "文档列表",
|
||||
"count": "数量",
|
||||
"fileNamePlaceholder": "例如:会议纪要",
|
||||
"templateStyle": "模板样式",
|
||||
"defaultTemplateStyle": "默认样式",
|
||||
"plain_text": "纯文本",
|
||||
"markdown": "Markdown",
|
||||
"html": "HTML",
|
||||
"pluginSelect": "插件选择",
|
||||
"saveData": "保存数据",
|
||||
"saveDataset": "写入数据",
|
||||
@@ -105,6 +115,9 @@
|
||||
"fileGeneration": "生成 Word、PDF、HTML 等文件供用户下载",
|
||||
"fileType": "请选择生成的文件类型",
|
||||
"fileDownloadURL": "生成后的文件地址",
|
||||
"targetFormat": "选择最终要导出的文档格式",
|
||||
"fileName": "可选,系统会自动补齐目标扩展名并清洗非法字符",
|
||||
"templateStyle": "首版仅提供默认样式,未知值会自动回退",
|
||||
"plugin": "选择定义好的插件",
|
||||
"saveData": "保存数据到数据中枢",
|
||||
"saveDataset": "将数据写入已接入表",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getMakeFileNodeFormPatch,
|
||||
getMakeFileTargetFormatOptions,
|
||||
isMakeFileFormatCombinationAllowed,
|
||||
resolveMakeFileNodeTargetFormat,
|
||||
resolveMakeFileNodeTemplateStyle,
|
||||
sanitizeMakeFileNodeFileName,
|
||||
} from '../makeFileNode';
|
||||
|
||||
describe('makeFileNode helpers', () => {
|
||||
it('清洗文件名中的非法字符与首尾空白', () => {
|
||||
expect(sanitizeMakeFileNodeFileName(' a/b\\\\c:*?<>| ')).toBe('abc');
|
||||
});
|
||||
|
||||
it('识别受支持与不受支持的格式组合', () => {
|
||||
expect(isMakeFileFormatCombinationAllowed('markdown', 'pdf')).toBe(true);
|
||||
expect(isMakeFileFormatCombinationAllowed('html', 'html')).toBe(true);
|
||||
expect(isMakeFileFormatCombinationAllowed('html', 'md')).toBe(false);
|
||||
expect(isMakeFileFormatCombinationAllowed('html', 'docx')).toBe(false);
|
||||
});
|
||||
|
||||
it('按当前 targetFormat 回显目标格式', () => {
|
||||
expect(resolveMakeFileNodeTargetFormat({ targetFormat: 'pdf' })).toBe('pdf');
|
||||
expect(resolveMakeFileNodeTargetFormat({ targetFormat: 'html' })).toBe('html');
|
||||
});
|
||||
|
||||
it('对未知 templateStyle 做默认回退', () => {
|
||||
expect(resolveMakeFileNodeTemplateStyle({ templateStyle: 'fancy' })).toBe('default');
|
||||
});
|
||||
|
||||
it('在 html 源格式下仅暴露 html 与 pdf 目标格式', () => {
|
||||
expect(getMakeFileTargetFormatOptions('html').map((item) => item.value)).toEqual([
|
||||
'html',
|
||||
'pdf',
|
||||
]);
|
||||
});
|
||||
|
||||
it('在 sourceFormat 切换为 html 时自动修正非法目标格式', () => {
|
||||
expect(
|
||||
getMakeFileNodeFormPatch('sourceFormat', 'html', {
|
||||
sourceFormat: 'markdown',
|
||||
targetFormat: 'docx',
|
||||
}),
|
||||
).toEqual({
|
||||
sourceFormat: 'html',
|
||||
targetFormat: 'html',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,117 @@ import { $t } from '#/locales';
|
||||
|
||||
import nodeNames from './nodeNames';
|
||||
|
||||
const INVALID_FILE_NAME_PATTERN = /[\\/:*?"<>|\u0000-\u001f\u007f]/g;
|
||||
|
||||
export const makeFileTargetFormatValues = ['md', 'html', 'pdf', 'docx'] as const;
|
||||
export const makeFileSourceFormatValues = ['plain_text', 'markdown', 'html'] as const;
|
||||
const DEFAULT_TARGET_FORMAT = 'docx';
|
||||
const DEFAULT_SOURCE_FORMAT = 'markdown';
|
||||
const DEFAULT_TEMPLATE_STYLE = 'default';
|
||||
|
||||
type MakeFileTargetFormat = (typeof makeFileTargetFormatValues)[number];
|
||||
type MakeFileSourceFormat = (typeof makeFileSourceFormatValues)[number];
|
||||
|
||||
function isMakeFileTargetFormat(value: unknown): value is MakeFileTargetFormat {
|
||||
return typeof value === 'string' && makeFileTargetFormatValues.includes(value as MakeFileTargetFormat);
|
||||
}
|
||||
|
||||
function isMakeFileSourceFormat(value: unknown): value is MakeFileSourceFormat {
|
||||
return typeof value === 'string' && makeFileSourceFormatValues.includes(value as MakeFileSourceFormat);
|
||||
}
|
||||
|
||||
function getConfiguredTargetFormat(data?: Record<string, any>) {
|
||||
const candidate = data?.targetFormat;
|
||||
return isMakeFileTargetFormat(candidate) ? candidate : DEFAULT_TARGET_FORMAT;
|
||||
}
|
||||
|
||||
export function sanitizeMakeFileNodeFileName(value?: string) {
|
||||
return (value ?? '').replace(INVALID_FILE_NAME_PATTERN, '').trim();
|
||||
}
|
||||
|
||||
export function isMakeFileFormatCombinationAllowed(
|
||||
sourceFormat = DEFAULT_SOURCE_FORMAT,
|
||||
targetFormat = 'docx',
|
||||
) {
|
||||
return !(
|
||||
sourceFormat === 'html' &&
|
||||
(targetFormat === 'md' || targetFormat === 'docx')
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveMakeFileNodeSourceFormat(data?: Record<string, any>): MakeFileSourceFormat {
|
||||
return isMakeFileSourceFormat(data?.sourceFormat) ? data.sourceFormat : DEFAULT_SOURCE_FORMAT;
|
||||
}
|
||||
|
||||
export function resolveMakeFileNodeTargetFormat(data?: Record<string, any>): MakeFileTargetFormat {
|
||||
const sourceFormat = resolveMakeFileNodeSourceFormat(data);
|
||||
const targetFormat = getConfiguredTargetFormat(data);
|
||||
if (isMakeFileFormatCombinationAllowed(sourceFormat, targetFormat)) {
|
||||
return targetFormat;
|
||||
}
|
||||
return sourceFormat === 'html' ? 'html' : DEFAULT_TARGET_FORMAT;
|
||||
}
|
||||
|
||||
export function resolveMakeFileNodeTemplateStyle(data?: Record<string, any>) {
|
||||
return data?.templateStyle === DEFAULT_TEMPLATE_STYLE ? DEFAULT_TEMPLATE_STYLE : DEFAULT_TEMPLATE_STYLE;
|
||||
}
|
||||
|
||||
export function getMakeFileTargetFormatOptions(sourceFormat = DEFAULT_SOURCE_FORMAT) {
|
||||
return makeFileTargetFormatValues
|
||||
.filter((targetFormat) => isMakeFileFormatCombinationAllowed(sourceFormat, targetFormat))
|
||||
.map((value) => ({
|
||||
label: value,
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getMakeFileNodeFormPatch(
|
||||
name: string,
|
||||
nextValue: string | number | boolean | undefined,
|
||||
data?: Record<string, any>,
|
||||
) {
|
||||
if (name === 'sourceFormat') {
|
||||
const sourceFormat = isMakeFileSourceFormat(nextValue) ? nextValue : DEFAULT_SOURCE_FORMAT;
|
||||
const currentTargetFormat = getConfiguredTargetFormat(data);
|
||||
if (isMakeFileFormatCombinationAllowed(sourceFormat, currentTargetFormat)) {
|
||||
return { sourceFormat };
|
||||
}
|
||||
return {
|
||||
sourceFormat,
|
||||
targetFormat: sourceFormat === 'html' ? 'html' : DEFAULT_TARGET_FORMAT,
|
||||
};
|
||||
}
|
||||
|
||||
if (name === 'targetFormat') {
|
||||
const sourceFormat = resolveMakeFileNodeSourceFormat(data);
|
||||
const targetFormat = isMakeFileTargetFormat(nextValue) ? nextValue : DEFAULT_TARGET_FORMAT;
|
||||
return {
|
||||
targetFormat: isMakeFileFormatCombinationAllowed(sourceFormat, targetFormat)
|
||||
? targetFormat
|
||||
: sourceFormat === 'html'
|
||||
? 'html'
|
||||
: DEFAULT_TARGET_FORMAT,
|
||||
};
|
||||
}
|
||||
|
||||
if (name === 'templateStyle') {
|
||||
return { templateStyle: resolveMakeFileNodeTemplateStyle({ templateStyle: nextValue }) };
|
||||
}
|
||||
|
||||
return { [name]: nextValue };
|
||||
}
|
||||
|
||||
function handleFileNameInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement | null;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const sanitized = sanitizeMakeFileNodeFileName(target.value);
|
||||
if (target.value !== sanitized) {
|
||||
target.value = sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
[nodeNames.makeFileNode]: {
|
||||
title: $t('aiWorkflow.fileGeneration'),
|
||||
@@ -9,8 +120,8 @@ export default {
|
||||
description: $t('aiWorkflow.descriptions.fileGeneration'),
|
||||
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M14 13.5V8C14 5.79086 12.2091 4 10 4C7.79086 4 6 5.79086 6 8V13.5C6 17.0899 8.91015 20 12.5 20C16.0899 20 19 17.0899 19 13.5V4H21V13.5C21 18.1944 17.1944 22 12.5 22C7.80558 22 4 18.1944 4 13.5V8C4 4.68629 6.68629 2 10 2C13.3137 2 16 4.68629 16 8V13.5C16 15.433 14.433 17 12.5 17C10.567 17 9 15.433 9 13.5V8H11V13.5C11 14.3284 11.6716 15 12.5 15C13.3284 15 14 14.3284 14 13.5Z"></path></svg>',
|
||||
sortNo: 802,
|
||||
parametersAddEnable: true,
|
||||
outputDefsAddEnable: true,
|
||||
parametersAddEnable: false,
|
||||
outputDefsAddEnable: false,
|
||||
forms: [
|
||||
{
|
||||
type: 'heading',
|
||||
@@ -18,16 +129,42 @@ export default {
|
||||
},
|
||||
{
|
||||
type: 'select',
|
||||
label: $t('documentCollection.splitterDoc.fileType'),
|
||||
description: $t('aiWorkflow.descriptions.fileType'),
|
||||
name: 'suffix',
|
||||
defaultValue: 'docx',
|
||||
label: $t('aiWorkflow.targetFormat'),
|
||||
description: $t('aiWorkflow.descriptions.targetFormat'),
|
||||
name: 'targetFormat',
|
||||
defaultValue: DEFAULT_TARGET_FORMAT,
|
||||
resolveValue: resolveMakeFileNodeTargetFormat,
|
||||
resolveOptions: (data: Record<string, any>) =>
|
||||
getMakeFileTargetFormatOptions(resolveMakeFileNodeSourceFormat(data)),
|
||||
onValueChange: (value: string | number | boolean | undefined, data: Record<string, any>) =>
|
||||
getMakeFileNodeFormPatch('targetFormat', value, data),
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
label: $t('aiWorkflow.fileName'),
|
||||
description: $t('aiWorkflow.descriptions.fileName'),
|
||||
name: 'fileName',
|
||||
placeholder: $t('aiWorkflow.fileNamePlaceholder'),
|
||||
attrs: {
|
||||
maxlength: 128,
|
||||
oninput: handleFileNameInput,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'select',
|
||||
label: $t('aiWorkflow.templateStyle'),
|
||||
description: $t('aiWorkflow.descriptions.templateStyle'),
|
||||
name: 'templateStyle',
|
||||
defaultValue: DEFAULT_TEMPLATE_STYLE,
|
||||
resolveValue: resolveMakeFileNodeTemplateStyle,
|
||||
options: [
|
||||
{
|
||||
label: 'docx',
|
||||
value: 'docx',
|
||||
label: $t('aiWorkflow.defaultTemplateStyle'),
|
||||
value: DEFAULT_TEMPLATE_STYLE,
|
||||
},
|
||||
],
|
||||
onValueChange: (value: string | number | boolean | undefined, data: Record<string, any>) =>
|
||||
getMakeFileNodeFormPatch('templateStyle', value, data),
|
||||
},
|
||||
],
|
||||
parameters: [
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
import {Button, Chosen, Heading, Input, Select, Textarea} from '../base';
|
||||
import RefParameterList from '../core/RefParameterList.svelte';
|
||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
||||
import {fillParameterId, useAddParameter} from '../utils/useAddParameter.svelte';
|
||||
import {getOptions} from '../utils/NodeUtils';
|
||||
import OutputDefList from '../core/OutputDefList.svelte';
|
||||
import {fillParameterId} from '../utils/useAddParameter.svelte.js';
|
||||
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
|
||||
|
||||
const { data, ...rest }: {
|
||||
@@ -23,15 +22,31 @@
|
||||
const editorParameters = $derived.by(() => {
|
||||
return (currentNode?.current?.data?.parameters as Array<any>) || data.parameters || [];
|
||||
});
|
||||
const currentNodeData = $derived.by(() => {
|
||||
return (currentNode?.current?.data as Record<string, any>) || (data as Record<string, any>) || {};
|
||||
});
|
||||
|
||||
const updateNodeData = (data: Record<string, any>) => {
|
||||
updateNodeDataInner(currentNodeId, data);
|
||||
};
|
||||
|
||||
const updateNodeDataByEvent = (name: string, event: Event) => {
|
||||
updateNodeData({
|
||||
[name]: (event.target as any)?.value
|
||||
});
|
||||
const resolveFormValue = (form: any) => {
|
||||
return form.resolveValue?.(currentNodeData) ?? currentNodeData?.[form.name] ?? form.defaultValue;
|
||||
};
|
||||
|
||||
const resolveFormOptions = (form: any) => {
|
||||
return form.resolveOptions?.(currentNodeData) ?? form.options ?? [];
|
||||
};
|
||||
|
||||
const updateFormValue = (form: any, nextValue: any) => {
|
||||
const patch = form.onValueChange?.(nextValue, currentNodeData) ?? {
|
||||
[form.name]: nextValue
|
||||
};
|
||||
updateNodeData(patch);
|
||||
};
|
||||
|
||||
const updateNodeDataByEvent = (form: any, event: Event) => {
|
||||
updateFormValue(form, (event.target as any)?.value);
|
||||
};
|
||||
|
||||
const node = {
|
||||
@@ -118,21 +133,21 @@
|
||||
mode="input"
|
||||
placeholder={form.placeholder}
|
||||
style="width: 100%"
|
||||
value={data[form.name] || form.defaultValue}
|
||||
value={resolveFormValue(form)}
|
||||
parameters={editorParameters}
|
||||
{...form.attrs}
|
||||
oninput={(e)=>{
|
||||
updateNodeDataByEvent(form.name,e)
|
||||
oninput={(e:any)=>{
|
||||
updateNodeDataByEvent(form,e)
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<Input
|
||||
placeholder={form.placeholder}
|
||||
style="width: 100%"
|
||||
value={data[form.name] || form.defaultValue}
|
||||
value={resolveFormValue(form)}
|
||||
{...form.attrs}
|
||||
onchange={(e)=>{
|
||||
updateNodeDataByEvent(form.name,e)
|
||||
updateNodeDataByEvent(form,e)
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
@@ -146,11 +161,11 @@
|
||||
rows={3}
|
||||
placeholder={form.placeholder}
|
||||
style="width: 100%"
|
||||
value={data[form.name] || form.defaultValue}
|
||||
value={resolveFormValue(form)}
|
||||
parameters={editorParameters}
|
||||
{...form.attrs}
|
||||
oninput={(e)=>{
|
||||
updateNodeDataByEvent(form.name,e)
|
||||
oninput={(e:any)=>{
|
||||
updateNodeDataByEvent(form,e)
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
@@ -158,10 +173,10 @@
|
||||
rows={3}
|
||||
placeholder={form.placeholder}
|
||||
style="width: 100%"
|
||||
value={data[form.name] || form.defaultValue}
|
||||
value={resolveFormValue(form)}
|
||||
{...form.attrs}
|
||||
onchange={(e)=>{
|
||||
updateNodeDataByEvent(form.name,e)
|
||||
updateNodeDataByEvent(form,e)
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
@@ -176,19 +191,16 @@
|
||||
type="range"
|
||||
{...form.attrs}
|
||||
value={data[form.name] ?? form.defaultValue}
|
||||
oninput={(e) => updateNodeData({ [form.name]: parseFloat(e.target.value) })}
|
||||
oninput={(e:any) => updateNodeData({ [form.name]: parseFloat(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else if form.type === 'select'}
|
||||
<div class="setting-title">{form.label}</div>
|
||||
<div class="setting-item">
|
||||
<Select items={form.options||[]} style="width: 100%" placeholder={form.placeholder} onSelect={(item)=>{
|
||||
const newValue = item.value;
|
||||
updateNodeData({
|
||||
[form.name]: newValue
|
||||
})
|
||||
}} value={data[form.name] ? [data[form.name]] : [form.defaultValue]} />
|
||||
<Select items={resolveFormOptions(form)} style="width: 100%" placeholder={form.placeholder} onSelect={(item)=>{
|
||||
updateFormValue(form, item.value)
|
||||
}} value={[resolveFormValue(form)]} />
|
||||
</div>
|
||||
{:else if form.type === 'chosen'}
|
||||
<div class="setting-title">{form.label}</div>
|
||||
|
||||
@@ -46,6 +46,12 @@ export type CustomNodeForm = {
|
||||
defaultValue?: string | number | boolean;
|
||||
attrs?: Record<string, any>;
|
||||
options?: SelectItem[];
|
||||
resolveValue?: (data: Record<string, any>) => string | number | boolean | undefined;
|
||||
resolveOptions?: (data: Record<string, any>) => SelectItem[];
|
||||
onValueChange?: (
|
||||
value: string | number | boolean | undefined,
|
||||
data: Record<string, any>,
|
||||
) => Record<string, any> | void;
|
||||
templateSupport?: boolean;
|
||||
chosen?: {
|
||||
labelDataKey: string;
|
||||
@@ -66,6 +72,7 @@ export type CustomNode = {
|
||||
icon?: string;
|
||||
sortNo?: number;
|
||||
group?: 'base' | 'tools';
|
||||
renderFirst?: boolean;
|
||||
rootClass?: string;
|
||||
rootStyle?: string;
|
||||
parameters?: Parameter[];
|
||||
|
||||
Reference in New Issue
Block a user