feat: 优化界面显示,对deck添加编辑功能

This commit is contained in:
xion 2024-09-26 21:08:38 +08:00
parent 02a1752a13
commit 12f1084612
29 changed files with 801 additions and 165 deletions

View File

@ -27,6 +27,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"copy-to-clipboard": "^3.3.3", "copy-to-clipboard": "^3.3.3",
"d3": "^7.9.0", "d3": "^7.9.0",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1", "immer": "^10.1.1",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"marked": "^14.1.2", "marked": "^14.1.2",
@ -42,8 +43,10 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.11.0", "@eslint/js": "^9.11.0",
"@tailwindcss/aspect-ratio": "^0.4.2", "@tailwindcss/aspect-ratio": "^0.4.2",
"@tailwindcss/line-clamp": "^0.4.4",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.5.5", "@types/node": "^22.5.5",
"@types/react": "^18.3.8", "@types/react": "^18.3.8",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
@ -53,6 +56,7 @@
"eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.12", "eslint-plugin-react-refresh": "^0.4.12",
"globals": "^15.9.0", "globals": "^15.9.0",
"postcss-import": "^16.1.0",
"react-is": "^18.3.1", "react-is": "^18.3.1",
"tailwind-merge": "^2.5.2", "tailwind-merge": "^2.5.2",
"tailwindcss": "^3.4.13", "tailwindcss": "^3.4.13",

25
plugins/flex.js Normal file
View File

@ -0,0 +1,25 @@
const plugin = require('tailwindcss/plugin');
const flexCenterBaseStyles = {
display: 'flex',
'justify-content': 'center',
'align-items': 'center',
};
/** flex 居中 */
const flexCenter = plugin(function ({ addUtilities }) {
addUtilities({
/** flex 居中 */
'.flex-row-center': flexCenterBaseStyles,
'.flex-col-center': { ...flexCenterBaseStyles, 'flex-direction': 'column' },
'.layout-menu': {},
'.scrollbar': {},
'.card': {},
'.card-title': {},
'.card-subtitle': {},
'.card-body': {},
'.card-footer': {},
});
});
module.exports = flexCenter;

46
pnpm-lock.yaml generated
View File

@ -53,6 +53,9 @@ importers:
d3: d3:
specifier: ^7.9.0 specifier: ^7.9.0
version: 7.9.0 version: 7.9.0
eventemitter3:
specifier: ^5.0.1
version: 5.0.1
immer: immer:
specifier: ^10.1.1 specifier: ^10.1.1
version: 10.1.1 version: 10.1.1
@ -93,12 +96,18 @@ importers:
'@tailwindcss/aspect-ratio': '@tailwindcss/aspect-ratio':
specifier: ^0.4.2 specifier: ^0.4.2
version: 0.4.2(tailwindcss@3.4.13) version: 0.4.2(tailwindcss@3.4.13)
'@tailwindcss/line-clamp':
specifier: ^0.4.4
version: 0.4.4(tailwindcss@3.4.13)
'@tailwindcss/typography': '@tailwindcss/typography':
specifier: ^0.5.15 specifier: ^0.5.15
version: 0.5.15(tailwindcss@3.4.13) version: 0.5.15(tailwindcss@3.4.13)
'@types/d3': '@types/d3':
specifier: ^7.4.3 specifier: ^7.4.3
version: 7.4.3 version: 7.4.3
'@types/lodash-es':
specifier: ^4.17.12
version: 4.17.12
'@types/node': '@types/node':
specifier: ^22.5.5 specifier: ^22.5.5
version: 22.5.5 version: 22.5.5
@ -126,6 +135,9 @@ importers:
globals: globals:
specifier: ^15.9.0 specifier: ^15.9.0
version: 15.9.0 version: 15.9.0
postcss-import:
specifier: ^16.1.0
version: 16.1.0(postcss@8.4.47)
react-is: react-is:
specifier: ^18.3.1 specifier: ^18.3.1
version: 18.3.1 version: 18.3.1
@ -733,6 +745,11 @@ packages:
peerDependencies: peerDependencies:
tailwindcss: '>=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1' tailwindcss: '>=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1'
'@tailwindcss/line-clamp@0.4.4':
resolution: {integrity: sha512-5U6SY5z8N42VtrCrKlsTAA35gy2VSyYtHWCsg1H87NU1SXnEfekTVlrga9fzUDrrHcGi2Lb5KenUWb4lRQT5/g==}
peerDependencies:
tailwindcss: '>=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1'
'@tailwindcss/typography@0.5.15': '@tailwindcss/typography@0.5.15':
resolution: {integrity: sha512-AqhlCXl+8grUz8uqExv5OTtgpjuVIwFTSXTrh8y9/pw6q2ek7fJ+Y8ZEVw7EB2DCcuCOtEjf9w3+J3rzts01uA==} resolution: {integrity: sha512-AqhlCXl+8grUz8uqExv5OTtgpjuVIwFTSXTrh8y9/pw6q2ek7fJ+Y8ZEVw7EB2DCcuCOtEjf9w3+J3rzts01uA==}
peerDependencies: peerDependencies:
@ -855,6 +872,12 @@ packages:
'@types/hast@3.0.4': '@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
'@types/lodash-es@4.17.12':
resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==}
'@types/lodash@4.17.9':
resolution: {integrity: sha512-w9iWudx1XWOHW5lQRS9iKpK/XuRhnN+0T7HvdCCd802FYkT1AMTnxndJHGrNJwRoRHkslGr4S29tjm1cT7x/7w==}
'@types/mdast@4.0.4': '@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
@ -1861,6 +1884,12 @@ packages:
peerDependencies: peerDependencies:
postcss: ^8.0.0 postcss: ^8.0.0
postcss-import@16.1.0:
resolution: {integrity: sha512-7hsAZ4xGXl4MW+OKEWCnF6T5jqBw80/EE9aXg1r2yyn1RsVEU8EtKXbijEODa+rg7iih4bKf7vlvTGYR4CnPNg==}
engines: {node: '>=18.0.0'}
peerDependencies:
postcss: ^8.0.0
postcss-js@4.0.1: postcss-js@4.0.1:
resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==}
engines: {node: ^12 || ^14 || >= 16} engines: {node: ^12 || ^14 || >= 16}
@ -3142,6 +3171,10 @@ snapshots:
dependencies: dependencies:
tailwindcss: 3.4.13 tailwindcss: 3.4.13
'@tailwindcss/line-clamp@0.4.4(tailwindcss@3.4.13)':
dependencies:
tailwindcss: 3.4.13
'@tailwindcss/typography@0.5.15(tailwindcss@3.4.13)': '@tailwindcss/typography@0.5.15(tailwindcss@3.4.13)':
dependencies: dependencies:
lodash.castarray: 4.4.0 lodash.castarray: 4.4.0
@ -3300,6 +3333,12 @@ snapshots:
dependencies: dependencies:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
'@types/lodash-es@4.17.12':
dependencies:
'@types/lodash': 4.17.9
'@types/lodash@4.17.9': {}
'@types/mdast@4.0.4': '@types/mdast@4.0.4':
dependencies: dependencies:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
@ -4428,6 +4467,13 @@ snapshots:
read-cache: 1.0.0 read-cache: 1.0.0
resolve: 1.22.8 resolve: 1.22.8
postcss-import@16.1.0(postcss@8.4.47):
dependencies:
postcss: 8.4.47
postcss-value-parser: 4.2.0
read-cache: 1.0.0
resolve: 1.22.8
postcss-js@4.0.1(postcss@8.4.47): postcss-js@4.0.1(postcss@8.4.47):
dependencies: dependencies:
camelcase-css: 2.0.1 camelcase-css: 2.0.1

View File

@ -1,5 +1,5 @@
import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-dom'; import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-dom';
import { ConfigProvider } from 'antd'; import { ConfigProvider, App as AntApp } from 'antd';
import { App as ContainerApp } from './pages/container'; import { App as ContainerApp } from './pages/container';
import { App as PanelApp } from './pages/panel'; import { App as PanelApp } from './pages/panel';
import { App as PublishApp } from './pages/publish'; import { App as PublishApp } from './pages/publish';

View File

@ -0,0 +1,17 @@
import clsx from 'clsx';
import twMerge from 'tailwind-merge';
type CardBlankProps = {
number?: number;
className?: string;
};
export const CardBlank = (props: CardBlankProps) => {
const { number = 4, className } = props;
return (
<>
{new Array(number).fill(0).map((_, index) => {
return <div key={index} className={clsx('w-[300px] flex-shrink-0', className)}></div>;
})}
</>
);
};

View File

@ -3,6 +3,13 @@
@tailwind utilities; @tailwind utilities;
@layer base { @layer base {
html,
body {
width: 100%;
height: 100%;
font-size: 16px;
font-family: 'Montserrat', sans-serif;
}
h1 { h1 {
@apply text-2xl font-bold; @apply text-2xl font-bold;
} }
@ -12,7 +19,40 @@
h3 { h3 {
@apply text-lg font-bold; @apply text-lg font-bold;
} }
}
@layer components {
.btn {
@apply bg-blue-500 text-white font-bold py-2 px-4 rounded;
}
.card {
@apply bg-white shadow-md rounded-lg p-4;
.card-title {
@apply text-lg font-bold;
}
.card-subtitle {
@apply text-sm text-gray-500;
}
.card-description {
@apply text-gray-700 break-words;
}
.card-code {
@apply bg-gray-100 p-2;
}
.card-body {
@apply text-gray-700;
}
.card-footer {
@apply text-sm text-gray-500;
}
}
}
@layer utilities {
.layout-menu { .layout-menu {
@apply bg-gray-900 p-2 text-white flex justify-between h-12; @apply bg-gray-900 p-2 text-white flex justify-between h-12;
} }
.bg-custom-blue {
background-color: #3490dc;
}
} }

1
src/hooks/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './message';

22
src/hooks/message.tsx Normal file
View File

@ -0,0 +1,22 @@
import { App } from 'antd';
export const useMessage = () => {
const { message: antMessage, modal, notification } = App.useApp();
return {
success: antMessage.success,
error: antMessage.error,
warning: antMessage.warning,
info: antMessage.info,
loading: antMessage.loading,
open: antMessage.open,
destroy: antMessage.destroy,
modal: modal,
notification: notification,
message: antMessage,
com: (
<>
<App />
</>
),
};
};

View File

@ -1,38 +1,112 @@
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { useAiStore } from './store/ai-store'; import { useAiStore } from './store/ai-store';
import { CloseOutlined } from '@ant-design/icons'; import { CloseOutlined } from '@ant-design/icons';
import { Button } from 'antd'; import { Button, Form, Input } from 'antd';
import { useEffect } from 'react';
import { TextArea } from '../container/components/TextArea';
import clsx from 'clsx';
import { marked } from 'marked';
export const AiMoudle = () => { export const AiMoudle = () => {
const [form] = Form.useForm();
const aiStore = useAiStore( const aiStore = useAiStore(
useShallow((state) => { useShallow((state) => {
return { return {
open: state.open, open: state.open,
setOpen: state.setOpen, setOpen: state.setOpen,
runAi: state.runAi, runAi: state.runAi,
formData: state.formData,
setFormData: state.setFormData,
messages: state.messages,
setMessages: state.setMessage,
}; };
}), }),
); );
useEffect(() => {
if (!aiStore.open) { if (!aiStore.open) {
return null; return;
} }
const isNull = JSON.stringify(aiStore.formData) === '{}';
if (!isNull) {
form.setFieldsValue(aiStore.formData);
} else {
form.setFieldsValue({ inputs: [] });
}
}, [aiStore.open, aiStore.formData]);
useEffect(() => {
if (!aiStore.open) {
aiStore.setMessages([]);
}
}, [aiStore.open]);
const onSend = () => {
const data = form.getFieldsValue();
aiStore.setFormData(data);
aiStore.runAi();
};
return ( return (
<div className='w-96 flex-shrink-0 bg-gray-100 border-l-2 shadow-lg flex flex-col'> <div className={clsx('w-[600px] flex-shrink-0 bg-gray-100 border-l-2 shadow-lg flex flex-col', !aiStore?.open && 'hidden')}>
<div className='flex gap-4 bg-slate-400 p-2'> <div className='flex gap-4 bg-slate-400 p-2'>
<Button className='position ml-4 !bg-slate-400 !border-black' onClick={() => aiStore.setOpen(false)} icon={<CloseOutlined />}></Button> <Button className='position ml-4 !bg-slate-400 !border-black' onClick={() => aiStore.setOpen(false)} icon={<CloseOutlined />}></Button>
<h1 className='ml-10'>Ai Moudle</h1> <h1 className='ml-10'>Ai Moudle</h1>
</div> </div>
<div className='flex-grow p-2'> <div className='flex-grow p-2 overflow-hidden h-full flex flex-col'>
<div> chat message</div> <div className='flex flex-col'>
<div> <div className='mb-3'> chat message</div>
<Button {aiStore?.messages?.map((message, index) => {
onClick={() => { const html = marked.parse(message?.content);
aiStore.runAi(); return (
}}> <div key={index} className=' justify-between px-4 w-full'>
<div className='h3 font-bold'>{message?.role}</div>
<div className='p-4 text-xs border shadow-sm rounded-sm scrollbar max-h-[200px] w-full overflow-scroll' dangerouslySetInnerHTML={{ __html: html }}>
</div>
</div>
);
})}
</div>
<div className='mt-4'>
<Form form={form}>
<Form.Item hidden name='id'>
<Input placeholder='message' />
</Form.Item>
<Form.Item name='messages' hidden>
<Input placeholder='message' />
</Form.Item>
<Form.Item name='key' hidden>
<Input placeholder='key' />
</Form.Item>
<Form.List name={'inputs'} initialValue={[]}>
{(fields, { add, remove }) => {
return (
<>
{fields.map((field, index) => {
const key = form.getFieldValue(['inputs', index, 'key']);
console.log('key', key);
const isTitle = key === 'title';
return (
<div key={field.name + '-' + index} className=''>
<Form.Item name={[field.name, 'key']} rules={[{ required: true, message: 'Missing name' }]} hidden noStyle>
<Input placeholder='name' />
</Form.Item>
<Form.Item className='!mb-0' name={[field.name, 'value']} rules={[{ required: true, message: 'Missing value' }]}>
<TextArea className='scrollbar' style={{ minHeight: isTitle ? 40 : 300 }} placeholder={key} />
</Form.Item>
</div>
);
})}
</>
);
}}
</Form.List>
</Form>
<div className='p-4'>
<Button className='mt-4' onClick={onSend}>
Send Send
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
</div>
); );
}; };

View File

@ -1,6 +1,19 @@
import { query } from '@/modules'; import { query } from '@/modules';
import { message } from 'antd'; import { message } from 'antd';
import { create } from 'zustand'; import { create } from 'zustand';
type ResData = {
created_at: string;
done?: boolean;
done_reason?: string;
eval_count?: number;
eval_duration?: number;
load_duration?: number;
message?: { role?: string; content?: string }[];
model?: string;
prompt_eval_count?: number;
prompt_eval_duration?: number;
total_duration?: number;
};
export type AiStore = { export type AiStore = {
open: boolean; open: boolean;
@ -12,9 +25,13 @@ export type AiStore = {
sendMsg: (msg: string) => void; sendMsg: (msg: string) => void;
formData: any; formData: any;
setFormData: (data: any) => void; setFormData: (data: any) => void;
data: any;
setData: (data: any) => void;
runAi: () => any; runAi: () => any;
title: string; title: string;
setTitle: (title: string) => void; setTitle: (title: string) => void;
messages: { role: string; content: string }[];
setMessage: (message: { role: string; content: string }[]) => void;
}; };
export const useAiStore = create<AiStore>((set, get) => { export const useAiStore = create<AiStore>((set, get) => {
@ -28,31 +45,60 @@ export const useAiStore = create<AiStore>((set, get) => {
}, },
formData: {}, formData: {},
setFormData: (data) => set({ formData: data }), setFormData: (data) => set({ formData: data }),
data: {},
setData: (data) => {
const { key, presetData = {} } = data;
console.log('key', presetData, data);
if (!key) {
console.error('key is required');
return;
}
const { inputs = [] } = presetData.data || {};
const formData = {
key,
inputs: inputs.map((input) => {
return {
key: input.key,
value: input.value,
type: 'string',
};
}),
messages: [],
};
console.log('formData', formData);
set({ key, data, formData });
},
runAi: async () => { runAi: async () => {
const { formData } = get(); const { formData, messages } = get();
const loading = message.loading('loading');
const res = await query.post({ const res = await query.post({
path: 'ai', path: 'ai',
key: 'run', key: 'run',
data: { data: {
key: formData.key, key: formData.key,
inputs: [ inputs: [
{ // {
key: 'title', // key: 'title',
value: '根据描述生成代码', // value: '根据描述生成代码',
}, // },
{ // {
key: 'description', // key: 'description',
value: '我想获取一个card, 包含标题和内容标题是evision内容是这是一个测试', // value: '我想获取一个card, 包含标题和内容标题是evision内容是这是一个测试',
}, // },
...formData.inputs,
], ],
}, },
}); });
loading();
if (res.code === 200) { if (res.code === 200) {
console.log(res.data); console.log(res.data);
message.success('Success'); message.success('Success');
set({ messages: [...messages, res.data.message] });
} }
}, },
title: '', title: '',
setTitle: (title) => set({ title }), setTitle: (title) => set({ title }),
messages: [],
setMessage: (messages) => set({ messages }),
}; };
}); });

View File

@ -4,13 +4,15 @@ import { useLocation, useNavigate } from 'react-router';
import { useCodeEditorStore, ParseData } from './store'; import { useCodeEditorStore, ParseData } from './store';
import { useToCodeEditor } from './hooks/use-to-code-editor'; import { useToCodeEditor } from './hooks/use-to-code-editor';
export { useToCodeEditor }; export { useToCodeEditor };
import { Button, message } from 'antd'; import { Button, message, Tooltip } from 'antd';
import { LeftOutlined, SaveOutlined } from '@ant-design/icons';
export const App = () => { export const App = () => {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const editorRef = useRef<typeof editor>(null); const editorRef = useRef<typeof editor>(null);
const location = useLocation(); const location = useLocation();
const store = useCodeEditorStore(); const store = useCodeEditorStore();
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const navigator = useNavigate();
useEffect(() => { useEffect(() => {
initEditor(); initEditor();
const state = location.state as ParseData; const state = location.state as ParseData;
@ -63,13 +65,28 @@ export const App = () => {
} }
store.onUpdate(value); store.onUpdate(value);
}, [store.dataType]); }, [store.dataType]);
return ( return (
<div className='w-full h-full bg-gray-400'> <div className='w-full h-full bg-gray-400 flex flex-col'>
<div className='px-2 bg-white mb-4'>Code Editor</div> <div className='layout-menu'>Code Editor</div>
<Button onClick={onSave}>Save</Button> <div className='p-4 flex-grow'>
<Button.Group className='mb-2'>
<Tooltip title='Go Back' placement='bottom'>
<Button
className=''
icon={<LeftOutlined />}
onClick={() => {
navigator(-1);
}}></Button>
</Tooltip>
<Tooltip title='Save' placement='bottom'>
<Button className='' onClick={onSave} icon={<SaveOutlined />}></Button>
</Tooltip>
</Button.Group>
<div className='w-full p-4 rounded-md bg-white' style={{ height: 'calc(100% - 130px)' }}> <div className='w-full p-4 rounded-md bg-white' style={{ height: 'calc(100% - 130px)' }}>
<div className='w-full h-full' ref={ref}></div> <div className='w-full h-full' ref={ref}></div>
</div> </div>
</div> </div>
</div>
); );
}; };

View File

@ -58,6 +58,9 @@ const FormModal = () => {
<Form.Item name='title' label='title'> <Form.Item name='title' label='title'>
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item name='description' label='description'>
<Input.TextArea rows={4} />
</Form.Item>
<Form.Item name='code' label='code'> <Form.Item name='code' label='code'>
<TextArea /> <TextArea />
</Form.Item> </Form.Item>
@ -127,7 +130,7 @@ export const ContainerList = () => {
}}> }}>
{item.title || '-'} {item.title || '-'}
</div> </div>
<div className='font-light'>{item.description ? item.description : '-'}</div> <div className='font-light text-xs mt-2'>{item.description ? item.description : '-'}</div>
</div> </div>
<div className='w-full text-xs'> <div className='w-full text-xs'>
<TextArea className='max-h-[240px] scrollbar' value={item.code} readonly /> <TextArea className='max-h-[240px] scrollbar' value={item.code} readonly />

View File

@ -9,9 +9,10 @@ export const App = () => {
<Route path='/' element={<Navigate to='/container/edit/list' />}></Route> <Route path='/' element={<Navigate to='/container/edit/list' />}></Route>
<Route path='edit/list' element={<ContainerList />} /> <Route path='edit/list' element={<ContainerList />} />
<Route path='preview/:id/wrapper' element={<PreviewWrapper />} /> <Route path='preview/:id/wrapper' element={<PreviewWrapper />} />
<Route path='/' element={<div>Home</div>} />
</Route> </Route>
<Route path='preview/:id' element={<Preview />} /> <Route path='preview/:id' element={<Preview />} />
</Routes> </Routes>
); );
}; };
export * from './module/Select';

View File

@ -0,0 +1,39 @@
import { query } from '@/modules';
import { Select as AntSelect, message, SelectProps } from 'antd';
import { useEffect, useState } from 'react';
export const Select = (props: SelectProps) => {
const [options, setOptions] = useState<{ value: string; id: string }[]>([]);
useEffect(() => {
fetch();
}, []);
const fetch = async () => {
const res = await query.post({
path: 'container',
key: 'list',
});
if (res.code !== 200) {
message.error(res.message || '获取容器列表失败');
return;
}
const data = res.data || [];
setOptions(
data.map((item: any) => {
return {
label: item.title,
value: item.id,
};
}),
);
};
return (
<AntSelect
{...props}
options={options}
// onChange={(e) => {
// const labelValue = options.find((item) => item.value === e);
// props.onChange?.(e, options);
// }}
/>
);
};

View File

@ -1,5 +1,68 @@
export const App = () => { import clsx from 'clsx';
const serverList = ['container', 'panel', 'publish', 'code-editor', 'map']; import { useNavigate } from 'react-router-dom';
const serverList = ['container', 'panel', 'publish', 'code-editor', 'map', 'ai-chat'];
const serverPath = [
{
path: 'container',
links: ['edit/list', 'preview/:id', 'edit/:id'],
},
{
path: 'panel',
links: ['edit/list', 'flow/:id', 'deck/:id'],
},
{
path: 'publish',
links: ['edit/list'],
},
{
path: 'map',
links: ['/'],
},
{
path: 'ai-chat',
links: ['/'],
},
];
const ServerPath = () => {
const navigate = useNavigate();
return (
<div className='p-2 w-full h-full bg-gray-200'>
<h1 className='p-4 w-1/2 m-auto h1'>Map</h1>
<div className='flex flex-col w-1/2 m-auto bg-white p-4 border rounded-md shadow-md'>
{serverPath.map((item) => {
const links = item.links.map((link) => {
const hasId = link.includes(':id');
const _path = link === '/' ? item.path : item.path + '/' + link;
return (
<div
key={link}
className={clsx('flex flex-col', !hasId && 'cursor-pointer')}
onClick={() => {
if (hasId) {
return;
}
if (link) {
navigate(`/${item.path}/${link}`);
} else {
navigate(`/${item.path}`);
}
}}>
<div className={clsx('border rounded-md p-2 m-2', hasId && 'bg-gray-200')}>{_path}</div>
</div>
);
});
return (
<div key={item.path} className='flex'>
{links}
</div>
);
})}
</div>
</div>
);
};
export const App = ServerPath;
export const ServerList = () => {
return ( return (
<div className='p-2 w-full h-full bg-gray-200'> <div className='p-2 w-full h-full bg-gray-200'>
<div className='flex flex-col w-1/2 m-auto bg-white p-4 border rounded-md shadow-md'> <div className='flex flex-col w-1/2 m-auto bg-white p-4 border rounded-md shadow-md'>

View File

@ -1,12 +1,14 @@
import { useEditStore } from '../store'; import { useEditStore } from '../store';
import { Button, Input, message, Modal, Table } from 'antd'; import { Button, Input, message, Modal, Table, Tooltip } from 'antd';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { Form } from 'antd'; import { Form } from 'antd';
import copy from 'copy-to-clipboard'; import copy from 'copy-to-clipboard';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useToCodeEditor } from '@/pages/code-editor'; import { useToCodeEditor } from '@/pages/code-editor';
import { CardBlank } from '@/components/card/CardBlank';
import { DeleteOutlined, EditOutlined, ForkOutlined, GoldOutlined, PlusOutlined, ToolOutlined } from '@ant-design/icons';
import { isObjectNull } from '@/utils/is-null';
const FormModal = () => { const FormModal = () => {
const [form] = Form.useForm(); const [form] = Form.useForm();
const editStore = useEditStore( const editStore = useEditStore(
@ -15,6 +17,7 @@ const FormModal = () => {
showEdit: state.showEditModal, showEdit: state.showEditModal,
setShowEdit: state.setShowEditModal, setShowEdit: state.setShowEditModal,
formData: state.formData, formData: state.formData,
setFormData: state.setFormData,
updateData: state.updateData, updateData: state.updateData,
}; };
}), }),
@ -22,17 +25,26 @@ const FormModal = () => {
useEffect(() => { useEffect(() => {
const open = editStore.showEdit; const open = editStore.showEdit;
if (open) { if (open) {
form.setFieldsValue(editStore.formData || {}); if (isObjectNull(editStore.formData.data)) {
} else { form.setFieldsValue({});
form.resetFields(); } else form.setFieldsValue(editStore.formData);
} }
}, [editStore.showEdit]); }, [editStore.showEdit]);
const onFinish = async (values: any) => { const onFinish = async (values: any) => {
let defaultData = {
nodes: [],
edges: [],
viewport: {},
};
if (!isEdit) {
values.data = defaultData;
}
editStore.updateData(values); editStore.updateData(values);
}; };
const onClose = () => { const onClose = () => {
editStore.setShowEdit(false); editStore.setShowEdit(false);
form.resetFields(); form.resetFields();
editStore.setFormData({});
}; };
const isEdit = editStore.formData.id; const isEdit = editStore.formData.id;
return ( return (
@ -52,9 +64,15 @@ const FormModal = () => {
<Form.Item name='title' label='title'> <Form.Item name='title' label='title'>
<Input /> <Input />
</Form.Item> </Form.Item>
{/* <Form.Item name='code' label='code'> <Form.Item name='description' label='description'>
<TextArea value={containerStore.formData.code} /> <Input.TextArea lang={'markdown'} />
</Form.Item> */} </Form.Item>
<Form.Item name='type' label='type' noStyle hidden>
<Input />
</Form.Item>
<Form.Item name='data' label='data' noStyle hidden>
<Input />
</Form.Item>
<Form.Item label=' ' colon={false}> <Form.Item label=' ' colon={false}>
<Button type='primary' htmlType='submit'> <Button type='primary' htmlType='submit'>
Submit Submit
@ -86,99 +104,80 @@ export const List = () => {
editStore.getList(); editStore.getList();
}, []); }, []);
const columns = [
{
title: 'ID',
dataIndex: 'id',
render: (text: string) => {
return ( return (
<div <div className='w-full h-full flex'>
className='w-40 truncate cursor-pointer' <div className='p-2 bg-white rounded-r-lg'>
title={text}
onClick={() => {
copy(text);
message.success('copy success');
}}>
{text}
</div>
);
},
},
{
title: 'Title',
dataIndex: 'title',
},
{
title: 'Operation',
dataIndex: 'operation',
render: (text: string, record: any) => {
return (
<div className='flex gap-2'>
<Button <Button
className='w-10 '
type='primary' type='primary'
onClick={() => { icon={<PlusOutlined />}
editStore.setFormData(record);
editStore.setShowEdit(true);
}}>
Edit
</Button>
<Button
onClick={() => {
navicate('/panel/flow/' + record.id);
}}>
Flow
</Button>
<Button
onClick={() => {
navicate('/panel/deck/' + record.id);
}}>
Deck
</Button>
<Button
onClick={() => {
toCodeEditor.toPagePage(record);
}}>
Source Data Editor
</Button>
<Button
danger
onClick={() => {
editStore.deleteData(record.id);
}}>
Delete
</Button>
</div>
);
},
},
];
return (
<div className='w-full h-full flex flex-col'>
<div className='mb-2 w-full p-2 bg-white rounded-lg'>
<Button
className='w-20 '
type='primary'
onClick={() => { onClick={() => {
editStore.setFormData({}); editStore.setFormData({});
editStore.setShowEdit(true); editStore.setShowEdit(true);
}}> }}></Button>
Add
</Button>
</div> </div>
<div className='flex-grow overflow-scroll'> <div className='flex-grow overflow-scroll scrollbar mt-4'>
<Table <div className=''>
pagination={false} <div className=' flex flex-wrap gap-10 justify-center h-full overflow-auto scrollbar'>
scroll={{ {editStore.list.length > 0 &&
y: 600, editStore.list.map((item, index) => {
return (
<div className='card w-[300px]' key={index}>
<div className='card-title'>{item.title}</div>
<div className='card-subtitle'> {item.description}</div>
<div className='mt-4'>
<Button.Group>
<Tooltip title='Edit'>
<Button
onClick={() => {
editStore.setFormData(item);
editStore.setShowEdit(true);
}} }}
loading={editStore.loading} icon={<EditOutlined />}
dataSource={editStore.list}
rowKey='id'
columns={columns}
/> />
</Tooltip>
<Tooltip title='to flow'>
<Button
onClick={() => {
navicate('/panel/flow/' + item.id);
}}
icon={<ForkOutlined />}
/>
</Tooltip>
<Tooltip title='to deck'>
<Button
onClick={() => {
navicate('/panel/deck/' + item.id);
}}
icon={<GoldOutlined />}
/>
</Tooltip>
<Tooltip title='to code editor'>
<Button
onClick={() => {
toCodeEditor.toPagePage(item);
}}
icon={<ToolOutlined />}
/>
</Tooltip>
<Tooltip title='delete'>
<Button
onClick={() => {
editStore.deleteData(item.id);
}}
icon={<DeleteOutlined />}
/>
</Tooltip>
</Button.Group>
</div>
</div>
);
})}
<CardBlank className='w-[300px]' />
{editStore.list.length === 0 && <div className='text-center text-gray-500'>No data</div>}
</div>
</div>
</div> </div>
<div className='h-2'></div>
<FormModal /> <FormModal />
</div> </div>
); );

View File

@ -19,8 +19,13 @@ import { useShallow } from 'zustand/react/shallow';
import { Container, useAddNode, ContainerMenusList, useMenuFlow } from '@abearxiong/flows'; import { Container, useAddNode, ContainerMenusList, useMenuFlow } from '@abearxiong/flows';
import { useMenuEmitter, ContainerMenusKeys } from '@abearxiong/flows'; import { useMenuEmitter, ContainerMenusKeys } from '@abearxiong/flows';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { Button } from 'antd'; import { Button, message, Tooltip } from 'antd';
import { usePanelStore } from '../store'; import { usePanelStore } from '../store';
import { CompassOutlined, PlusOutlined, SaveOutlined } from '@ant-design/icons';
import { generateId } from '@/utils/nanoid';
import { useMessage } from '@/hooks';
import { NodeProperties } from './properties/NodeProperties';
import { emitter } from '@abearxiong/container';
// router: Router // router: Router
const nodeTypes = { const nodeTypes = {
container: Container, container: Container,
@ -36,7 +41,6 @@ export const Flow = () => {
const ReactFlowApp = () => { const ReactFlowApp = () => {
const [nodes, setNodes, onNodesChange] = useNodesState([]); const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<any>([]); const [edges, setEdges, onEdgesChange] = useEdgesState<any>([]);
const onConnect = useCallback((params) => setEdges((eds) => addEdge(params, eds)), [setEdges]); const onConnect = useCallback((params) => setEdges((eds) => addEdge(params, eds)), [setEdges]);
const panelStore = usePanelStore( const panelStore = usePanelStore(
useShallow((state) => { useShallow((state) => {
@ -49,15 +53,47 @@ const ReactFlowApp = () => {
useEffect(() => { useEffect(() => {
if (panelStore.data?.id) { if (panelStore.data?.id) {
const { data } = panelStore.data || {}; const { data } = panelStore.data || {};
const nodes = data.nodes || []; console.log('data', panelStore.data);
const edges = data.edges || []; const nodes = [...data.nodes];
setNodes(nodes); const edges = [...data.edges];
console.log('nodes', nodes);
if (nodes.length === 0) {
nodes.push({
id: panelStore.data.id,
data: {
label: '容器',
root: true,
},
position: {
x: 100,
y: 100,
},
type: 'container',
});
}
console.log('nodes', nodes);
setNodes(nodes as any);
setEdges(edges); setEdges(edges);
} else { } else {
setNodes([]); setNodes([]);
setEdges([]); setEdges([]);
} }
}, [panelStore.data]); }, [panelStore.data]);
useEffect(() => {
emitter.on('setNodes', _setNodes);
emitter.on('setEdges', _setEdges);
return () => {
emitter.off('setNodes', _setNodes);
emitter.off('setEdges', _setEdges);
};
}, []);
const _setNodes = (data: any) => {
setNodes(data);
};
const _setEdges = (data: any) => {
setEdges(data);
};
const { menuCom, onContextMenu, onClose } = useMenuFlow( const { menuCom, onContextMenu, onClose } = useMenuFlow(
(item, cacheData) => { (item, cacheData) => {
emit({ emit({
@ -72,11 +108,37 @@ const ReactFlowApp = () => {
const { emit } = useMenuEmitter<ContainerMenusKeys>({ const { emit } = useMenuEmitter<ContainerMenusKeys>({
preview: ({ menu, data }) => { preview: ({ menu, data }) => {
console.log('preview', data); console.log('preview', data, message);
if (data?.data?.cid) { if (data?.data?.cid) {
window.open(`/container/preview/${data.data.cid}`, '_blank'); window.open(`/container/preview/${data.data.cid}`, '_blank');
} else {
message.error('未绑定容器');
} }
}, },
code: ({ menu, data }) => {
// console.log('edit', data);
const nodeData = data?.data;
if (!nodeData.cid) {
message.error('请先绑定容器');
return;
}
message.error('developing');
},
copy: ({ menu, data }) => {
message.error('developing');
},
delete: ({ menu, data }) => {
console.log('delete', data);
const nodeData = data?.data;
if (nodeData.root) {
message.error('root node can not be deleted');
return;
}
setNodes((nodes) => nodes.filter((item: any) => item.id !== data.id));
},
internalData: ({ menu, data }) => {
message.error('developing');
},
}); });
const { onNeedAdd, onAdd, onMouseMove, adding } = useAddNode(); const { onNeedAdd, onAdd, onMouseMove, adding } = useAddNode();
const onSave = useCallback(() => { const onSave = useCallback(() => {
@ -124,26 +186,41 @@ const ReactFlowApp = () => {
}}> }}>
<MiniMap /> <MiniMap />
<Controls /> <Controls />
{/* <Background gap={[14, 14]} size={2} color='#E4E5E7' /> */}
<Background color='#000' /> <Background color='#000' />
<Panel>{menuCom}</Panel> <Panel>{menuCom}</Panel>
<Panel> <Panel>
<div className='flex gap-2'> <div className='flex gap-2'>
<Button.Group>
<Tooltip title='添加节点'>
<Button <Button
type='primary' icon={<PlusOutlined />}
onClick={(e) => { onClick={(e) => {
onNeedAdd({ id: nanoid(6), data: { label: '容器' }, type: 'container' }, e); onNeedAdd({ id: 'flow' + generateId(), data: { label: '容器' }, type: 'container' }, e);
}}> }}></Button>
</Tooltip>
</Button> <Tooltip title='save'>
<Button <Button
icon={<SaveOutlined />}
onClick={() => { onClick={() => {
onSave(); onSave();
}}> }}></Button>
</Tooltip>
</Button> <Tooltip title='preview'>
<Button
icon={<CompassOutlined />}
onClick={() => {
const id = panelStore.data?.id;
if (!id) {
message.error('ID is required');
return;
}
window.open(`/panel/deck/${id}`, '_blank');
}}></Button>
</Tooltip>
</Button.Group>
</div> </div>
</Panel> </Panel>
<NodeProperties />
</ReactFlow> </ReactFlow>
); );
}; };

View File

@ -0,0 +1,2 @@
import { EventEmitter } from 'eventemitter3';
export const emitter = new EventEmitter();

View File

@ -0,0 +1,103 @@
import { Panel, useReactFlow, useStore, useStoreApi } from '@xyflow/react';
import clsx from 'clsx';
import { useEffect, useState } from 'react';
import { debounce } from 'lodash-es';
import { Button, Form, Input, message } from 'antd';
import { Select } from '@/pages/container/module/Select';
import { SaveOutlined } from '@ant-design/icons';
import { emitter } from '@abearxiong/container';
import { usePanelStore } from '../../store';
export const NodeProperties = () => {
const reactflow = useReactFlow();
const [open, setOpen] = useState(false);
const [form] = Form.useForm();
const panelStore = usePanelStore((state) => {
return {
updateNodeData: state.updateNodeData,
};
});
const store = useStore((state) => {
const setNode = (node: any) => {
const newNodes = state.nodes.map((item) => {
if (item.id === node.id) {
// return { ...item, ...node };
return { ...node };
}
return item;
});
// console.log('newNodes', newNodes);
// state.setNodes(newNodes); // 会丢失数据因为最终没有调用context的setNodes方法
emitter.emit('setNodes', newNodes);
};
return {
nodesFocusable: state.nodesFocusable,
selected: state.nodes.filter((node) => node.selected),
setNode: setNode,
};
});
const [nodeData] = store.selected as any[];
useEffect(() => {
if (!nodeData) return;
// console.log('nodeData', nodeData);
const { data } = nodeData || {};
form.setFieldsValue({
cid: data.cid,
title: data.title,
});
}, [nodeData]);
const onSave = () => {
const values = form.getFieldsValue();
// console.log('values', values);
const { cid, title } = values;
if (!cid) {
message.error('请选择容器');
return;
}
const { data, id, position, type } = nodeData || {};
// console.log('data', data, cid);
const newNodeData = {
id,
position,
selected: true,
type,
data: {
...data,
cid,
title,
},
};
// console.log('newNodeData', newNodeData, nodeData);
store.setNode(newNodeData);
panelStore.updateNodeData(newNodeData);
};
return (
<Panel title='节点属性' position='bottom-center' className='w-full'>
<div className={clsx('w-full h-[200px] card', store.selected.length > 0 ? '' : 'hidden')}>
<div className='card-title'>
{nodeData?.data?.label}
<Button.Group className='ml-2'>
<Button onClick={onSave} icon={<SaveOutlined />}></Button>
</Button.Group>
</div>
<div className='p-4'>
<Form form={form}>
<Form.Item label='名称' name='cid'>
<Select
onChange={(e, options) => {
if (Array.isArray(options)) {
} else {
const title = options?.label;
form.setFieldsValue({ title });
}
}}
/>
</Form.Item>
<Form.Item label='标题' name='title'>
<Input />
</Form.Item>
</Form>
</div>
</div>
</Panel>
);
};

View File

@ -3,18 +3,12 @@ import { Outlet } from 'react-router';
export const Main = () => { export const Main = () => {
return ( return (
<div className='flex w-full h-full flex-col bg-gray-200'> <div className='flex w-full h-full flex-col bg-gray-200'>
<div className='h-12 bg-white p-2 mb-2'>Deck And Flow</div> <div className='layout-menu'>Deck And Flow</div>
<div <div className='flex-grow w-full'>
className='flex' <div className='w-full h-full overflow-hidden'>
style={{
height: 'calc(100vh - 4rem)',
}}>
<div className='flex-grow overflow-hidden mx-2'>
<div className='w-full h-full rounded-lg'>
<Outlet /> <Outlet />
</div> </div>
</div> </div>
</div> </div>
</div>
); );
}; };

View File

@ -12,6 +12,7 @@ type PanelStore = {
setData: (data: any) => void; setData: (data: any) => void;
getPanel: (id?: string) => Promise<void>; getPanel: (id?: string) => Promise<void>;
saveNodesEdges: (data: { nodes?: any[]; edges?: any[]; viewport?: any }) => Promise<void>; saveNodesEdges: (data: { nodes?: any[]; edges?: any[]; viewport?: any }) => Promise<void>;
updateNodeData: (data: any) => Promise<void>;
}; };
export const usePanelStore = create<PanelStore>((set, get) => { export const usePanelStore = create<PanelStore>((set, get) => {
return { return {
@ -78,5 +79,23 @@ export const usePanelStore = create<PanelStore>((set, get) => {
message.error(res.msg || 'Request failed'); message.error(res.msg || 'Request failed');
} }
}, },
updateNodeData: async (data) => {
// const { getList } = get();
const { id, data: panelData } = get();
const res = await query.post({
path: 'page',
key: 'updateNode',
data: {
id: id,
nodeData: data,
},
});
if (res.code === 200) {
message.success('Success');
// getList();
} else {
message.error(res.msg || 'Request failed');
}
},
}; };
}); });

View File

@ -109,6 +109,7 @@ export const List = () => {
return { return {
open: state.open, open: state.open,
setOpen: state.setOpen, setOpen: state.setOpen,
setData: state.setData,
key: state.key, key: state.key,
setKey: state.setKey, setKey: state.setKey,
sendMsg: state.sendMsg, sendMsg: state.sendMsg,
@ -243,8 +244,9 @@ export const List = () => {
icon={<CaretRightOutlined />} icon={<CaretRightOutlined />}
onClick={() => { onClick={() => {
// navicate(`/prompt/${item.id}`); // navicate(`/prompt/${item.id}`);
promptStore.setFormData(item); // promptStore.setFormData(item);
// promptStore.runAi(); // promptStore.runAi();
aiStore.setData(item);
aiStore.setOpen(true); aiStore.setOpen(true);
}} }}
/> />

View File

@ -4,6 +4,8 @@ import { message } from 'antd';
type PromptStore = { type PromptStore = {
showEdit: boolean; showEdit: boolean;
setShowEdit: (showEdit: boolean) => void; setShowEdit: (showEdit: boolean) => void;
data: any;
setData: (data: any) => void;
formData: any; formData: any;
setFormData: (formData: any) => void; setFormData: (formData: any) => void;
loading: boolean; loading: boolean;
@ -19,7 +21,13 @@ export const usePromptStore = create<PromptStore>((set, get) => {
showEdit: false, showEdit: false,
setShowEdit: (showEdit) => set({ showEdit }), setShowEdit: (showEdit) => set({ showEdit }),
formData: {}, formData: {},
setFormData: (formData) => set({ formData }), setFormData: (formData) => {
set({ formData });
},
data: {},
setData: (data) => {
set({ data });
},
loading: false, loading: false,
setLoading: (loading) => set({ loading }), setLoading: (loading) => set({ loading }),
list: [], list: [],

1
src/utils/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './nanoid';

9
src/utils/is-null.ts Normal file
View File

@ -0,0 +1,9 @@
export const isObjectNull = (value: any) => {
if (value === null || value === undefined) {
return true;
}
if (JSON.stringify(value) === '{}') {
return true;
}
return false;
};

5
src/utils/nanoid.ts Normal file
View File

@ -0,0 +1,5 @@
import { customAlphabet, nanoid } from 'nanoid';
const number = '0123456789';
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
export const generateId = customAlphabet(number + alphabet, 6);

View File

@ -1,10 +1,26 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
darkMode: ['class'], darkMode: ['class'],
content: ['./src/**/*.{ts,tsx}', './node_modules/@abearxiong/flows/**/*.{ts,tsx}'], mod: 'jit',
plugins: [require('@tailwindcss/aspect-ratio'), require('@tailwindcss/typography')], content: ['./src/**/*.{ts,tsx}', './node_modules/@abearxiong/flows/**/*.{ts,tsx}', './src/**/*.css'],
plugins: [
require('@tailwindcss/aspect-ratio'), //
require('@tailwindcss/typography'),
require('@tailwindcss/line-clamp'),
require('tailwindcss-animate'),
require('./plugins/flex'),
],
safelist: ['layout-menu', 'bg-custom-blue'],
theme: { theme: {
extend: {}, extend: {
fontFamily: {
mon: ['Montserrat', 'sans-serif'], // 定义自定义字体族
rob: ['Roboto', 'sans-serif'],
int: ['Inter', 'sans-serif'],
orb: ['Orbitron', 'sans-serif'],
din: ['DIN', 'sans-serif'],
},
},
screen: { screen: {
sm: '640px', sm: '640px',
// => @media (min-width: 640px) { ... } // => @media (min-width: 640px) { ... }
@ -26,5 +42,4 @@ export default {
// => @media (min-width: 2560) { ... } // => @media (min-width: 2560) { ... }
}, },
}, },
plugins: [require('tailwindcss-animate')],
}; };

2
theme

@ -1 +1 @@
Subproject commit a04e057119db510b36286c8585de97592467e6d4 Subproject commit cae4f74f6c93dd2b96cd4c40551ac1be969c2bc2

View File

@ -11,7 +11,11 @@ export default defineConfig({
css: { css: {
postcss: { postcss: {
plugins: [nesting, tailwindcss, autoprefixer], plugins: [
nesting, // 作用是可以使用@import导入css文件
tailwindcss,
autoprefixer,
],
}, },
}, },
resolve: { resolve: {