generated from template/astro-template
	feat: add ai-html
This commit is contained in:
		
							
								
								
									
										25
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										25
									
								
								package.json
									
									
									
									
									
								
							@@ -1,6 +1,6 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "@kevisual/ai-pages",
 | 
			
		||||
  "version": "0.0.1",
 | 
			
		||||
  "version": "0.0.2",
 | 
			
		||||
  "description": "",
 | 
			
		||||
  "main": "index.js",
 | 
			
		||||
  "basename": "/root/ai-pages",
 | 
			
		||||
@@ -8,7 +8,7 @@
 | 
			
		||||
    "dev": "astro dev",
 | 
			
		||||
    "build": "astro build",
 | 
			
		||||
    "preview": "astro preview",
 | 
			
		||||
    "pub": "ev deploy ./dist -k ai-pages -v 0.0.1 -u",
 | 
			
		||||
    "pub": "ev deploy ./dist -k ai-pages -v 0.0.2 -u",
 | 
			
		||||
    "git:submodule": "git submodule update --init --recursive",
 | 
			
		||||
    "sn": "pnpm dlx shadcn@latest add "
 | 
			
		||||
  },
 | 
			
		||||
@@ -21,24 +21,26 @@
 | 
			
		||||
    "@astrojs/react": "^4.3.0",
 | 
			
		||||
    "@astrojs/sitemap": "^3.4.0",
 | 
			
		||||
    "@kevisual/cache": "^0.0.3",
 | 
			
		||||
    "@kevisual/codemirror": "^0.0.12",
 | 
			
		||||
    "@kevisual/query-login": "^0.0.6",
 | 
			
		||||
    "@kevisual/registry": "^0.0.1",
 | 
			
		||||
    "@radix-ui/react-alert-dialog": "^1.1.14",
 | 
			
		||||
    "@radix-ui/react-dialog": "^1.1.14",
 | 
			
		||||
    "@radix-ui/react-dropdown-menu": "^2.1.15",
 | 
			
		||||
    "@radix-ui/react-label": "^2.1.7",
 | 
			
		||||
    "@radix-ui/react-popover": "^1.1.14",
 | 
			
		||||
    "@radix-ui/react-select": "^2.2.5",
 | 
			
		||||
    "@radix-ui/react-slot": "^1.2.3",
 | 
			
		||||
    "@radix-ui/react-tooltip": "^1.2.7",
 | 
			
		||||
    "@tailwindcss/vite": "^4.1.7",
 | 
			
		||||
    "astro": "^5.8.0",
 | 
			
		||||
    "@tailwindcss/vite": "^4.1.8",
 | 
			
		||||
    "astro": "^5.8.1",
 | 
			
		||||
    "class-variance-authority": "^0.7.1",
 | 
			
		||||
    "clsx": "^2.1.1",
 | 
			
		||||
    "cmdk": "^1.1.1",
 | 
			
		||||
    "dayjs": "^1.11.13",
 | 
			
		||||
    "fuse.js": "^7.1.0",
 | 
			
		||||
    "highlight.js": "^11.11.1",
 | 
			
		||||
    "i18next": "^25.2.0",
 | 
			
		||||
    "i18next": "^25.2.1",
 | 
			
		||||
    "i18next-browser-languagedetector": "^8.1.0",
 | 
			
		||||
    "i18next-http-backend": "^3.0.2",
 | 
			
		||||
    "lodash-es": "^4.17.21",
 | 
			
		||||
@@ -51,6 +53,7 @@
 | 
			
		||||
    "react": "^19.1.0",
 | 
			
		||||
    "react-dom": "^19.1.0",
 | 
			
		||||
    "react-draggable": "^4.4.6",
 | 
			
		||||
    "react-dropzone": "^14.3.8",
 | 
			
		||||
    "react-hook-form": "^7.56.4",
 | 
			
		||||
    "react-i18next": "^15.5.2",
 | 
			
		||||
    "react-resizable-panels": "^3.0.2",
 | 
			
		||||
@@ -64,22 +67,26 @@
 | 
			
		||||
    "access": "public"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@excalidraw/excalidraw": "^0.18.0",
 | 
			
		||||
    "@kevisual/markdown-editor": "workspace:*",
 | 
			
		||||
    "@kevisual/query": "^0.0.20",
 | 
			
		||||
    "@kevisual/query-awesome": "^0.0.2",
 | 
			
		||||
    "@kevisual/router": "^0.0.21",
 | 
			
		||||
    "@kevisual/store": "^0.0.8",
 | 
			
		||||
    "@kevisual/store": "^0.0.9",
 | 
			
		||||
    "@kevisual/types": "^0.0.10",
 | 
			
		||||
    "@types/crypto-js": "^4.2.2",
 | 
			
		||||
    "@types/lodash-es": "^4.17.12",
 | 
			
		||||
    "@types/react": "^19.1.5",
 | 
			
		||||
    "@types/react": "^19.1.6",
 | 
			
		||||
    "@types/react-dom": "^19.1.5",
 | 
			
		||||
    "@types/sortablejs": "^1.15.8",
 | 
			
		||||
    "@vitejs/plugin-basic-ssl": "^2.0.0",
 | 
			
		||||
    "commander": "^14.0.0",
 | 
			
		||||
    "crypto-js": "^4.2.0",
 | 
			
		||||
    "dotenv": "^16.5.0",
 | 
			
		||||
    "idb-keyval": "^6.2.2",
 | 
			
		||||
    "inquire": "^0.4.8",
 | 
			
		||||
    "tailwindcss": "^4.1.7",
 | 
			
		||||
    "tw-animate-css": "^1.3.0",
 | 
			
		||||
    "tailwindcss": "^4.1.8",
 | 
			
		||||
    "tw-animate-css": "^1.3.3",
 | 
			
		||||
    "vite-plugin-remote-assets": "^2.0.0"
 | 
			
		||||
  },
 | 
			
		||||
  "packageManager": "pnpm@10.11.0"
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2964
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2964
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -30,7 +30,7 @@ export const ChatHistoryList = ({ storeId }: { storeId: string }) => {
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
    const url = new URL(location.href);
 | 
			
		||||
    url.searchParams.set('chatId', id);
 | 
			
		||||
    url.searchParams.set('id', id);
 | 
			
		||||
    setHistoryState({}, url.toString());
 | 
			
		||||
  };
 | 
			
		||||
  const { control, handleSubmit, reset, getValues } = useForm({ defaultValues: { title: '', id: '' } });
 | 
			
		||||
 
 | 
			
		||||
@@ -64,7 +64,7 @@ export const Chat = ({ storeId }: { storeId: string }) => {
 | 
			
		||||
    const chat = getHistoryState()[storeId];
 | 
			
		||||
    const chatId = chat?.chatId;
 | 
			
		||||
    const url = new URL(window.location.href);
 | 
			
		||||
    const urlChatId = url.searchParams.get('chatId');
 | 
			
		||||
    const urlChatId = url.searchParams.get('id');
 | 
			
		||||
    if (chatId) {
 | 
			
		||||
      setId(chatId);
 | 
			
		||||
    } else if (urlChatId) {
 | 
			
		||||
 
 | 
			
		||||
@@ -104,14 +104,14 @@ export const ModelNav = () => {
 | 
			
		||||
            <Save className='w-4 h-4' />
 | 
			
		||||
          </IconButton>
 | 
			
		||||
        </Tooltip>
 | 
			
		||||
        <Tooltip title='发送请求。'>
 | 
			
		||||
        {/* <Tooltip title='发送请求。'>
 | 
			
		||||
          <IconButton
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              chat();
 | 
			
		||||
            }}>
 | 
			
		||||
            <Send className='w-4 h-4' />
 | 
			
		||||
          </IconButton>
 | 
			
		||||
        </Tooltip>
 | 
			
		||||
        </Tooltip> */}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										169
									
								
								src/apps/ai-html/Home.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										169
									
								
								src/apps/ai-html/Home.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,169 @@
 | 
			
		||||
import { createEditor } from '@kevisual/codemirror';
 | 
			
		||||
import { Chain } from '@kevisual/codemirror/utils';
 | 
			
		||||
import { IconButton } from '@/components/a/button.tsx';
 | 
			
		||||
import { Tooltip } from '@/components/a/tooltip.tsx';
 | 
			
		||||
import { UploadIcon, UploadCloud as CloudUploadOutlined } from 'lucide-react';
 | 
			
		||||
import { useEffect, useRef, useState } from 'react';
 | 
			
		||||
import { useDropzone } from 'react-dropzone';
 | 
			
		||||
import { CacheWorkspace } from '@kevisual/cache';
 | 
			
		||||
import { useHomeStore } from './store/index.ts';
 | 
			
		||||
import { UploadModal } from './module/UploadModal.tsx';
 | 
			
		||||
import { useShallow } from 'zustand/shallow';
 | 
			
		||||
import { toast } from 'react-toastify';
 | 
			
		||||
import { SuccessModal } from './module/SuccessModal.tsx';
 | 
			
		||||
import { debounce } from 'lodash-es';
 | 
			
		||||
const chain = new Chain();
 | 
			
		||||
export const Home = () => {
 | 
			
		||||
  const editorElRef = useRef<HTMLDivElement>(null);
 | 
			
		||||
  const editorRef = useRef<ReturnType<typeof createEditor>>(null);
 | 
			
		||||
  const [isHtml, setIsHtml] = useState(true);
 | 
			
		||||
  const [html, setHtml] = useState('');
 | 
			
		||||
  const { initApp, setOpenUploadModal, setText, filename, openPreview, setOpenPreview } = useHomeStore(
 | 
			
		||||
    useShallow((state) => ({
 | 
			
		||||
      initApp: state.initApp,
 | 
			
		||||
      setOpenUploadModal: state.setOpenUploadModal,
 | 
			
		||||
      setText: state.setText,
 | 
			
		||||
      filename: state.filename,
 | 
			
		||||
      openPreview: state.openPreview,
 | 
			
		||||
      setOpenPreview: state.setOpenPreview,
 | 
			
		||||
    })),
 | 
			
		||||
  );
 | 
			
		||||
  const onDrop = (acceptedFiles) => {
 | 
			
		||||
    console.log(acceptedFiles);
 | 
			
		||||
    const file = acceptedFiles[0];
 | 
			
		||||
    const reader = new FileReader();
 | 
			
		||||
    reader.onload = (e) => {
 | 
			
		||||
      const content = e.target?.result as string;
 | 
			
		||||
      if (editorRef.current) {
 | 
			
		||||
        editorRef.current.dispatch({
 | 
			
		||||
          changes: { from: 0, to: editorRef.current.state.doc.length, insert: content },
 | 
			
		||||
        });
 | 
			
		||||
      } else {
 | 
			
		||||
        toast.error('编辑器未初始化,请稍后再试');
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
    reader.readAsText(file);
 | 
			
		||||
  };
 | 
			
		||||
  const { getRootProps, getInputProps } = useDropzone({ onDrop, accept: { 'text/html': ['.html'], 'text/javascript': ['.js'], 'text/css': ['.css'] } });
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    initApp();
 | 
			
		||||
    initEditor();
 | 
			
		||||
    return () => {
 | 
			
		||||
      if (editorRef.current) {
 | 
			
		||||
        chain.destroy();
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const initEditor = async () => {
 | 
			
		||||
    if (!editorElRef.current) return;
 | 
			
		||||
    const cache = new CacheWorkspace();
 | 
			
		||||
    let cacheData = '';
 | 
			
		||||
    try {
 | 
			
		||||
      cacheData = (await cache.storage.get('html-editor')) || '';
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error(error);
 | 
			
		||||
    }
 | 
			
		||||
    const type = filename.endsWith('.js') ? 'javascript' : filename.endsWith('.css') ? 'css' : 'html';
 | 
			
		||||
    setIsHtml(type === 'html');
 | 
			
		||||
    const debouncedSetHtml = debounce((value: string) => {
 | 
			
		||||
      setHtml(value);
 | 
			
		||||
    }, 500);
 | 
			
		||||
    const editor = createEditor(editorElRef.current, {
 | 
			
		||||
      type: type || 'html',
 | 
			
		||||
      onChange: (value) => {
 | 
			
		||||
        cache.storage.set('html-editor', value);
 | 
			
		||||
        debouncedSetHtml(value);
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
    const cmScroller = editorElRef.current.querySelector('.cm-scroller');
 | 
			
		||||
    if (cmScroller) {
 | 
			
		||||
      cmScroller.classList.add('scrollbar');
 | 
			
		||||
    }
 | 
			
		||||
    chain.setEditor(editor);
 | 
			
		||||
    editorRef.current = editor;
 | 
			
		||||
    setTimeout(() => {
 | 
			
		||||
      if (cacheData) {
 | 
			
		||||
        editorRef.current!.dispatch({
 | 
			
		||||
          changes: { from: 0, to: editorRef.current!.state.doc.length, insert: cacheData },
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    }, 300);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className='flex flex-row w-full h-full'>
 | 
			
		||||
      <div className='flex flex-col text-primary border-r border-gray-200 px-2 pt-2'>
 | 
			
		||||
        <div>
 | 
			
		||||
          <Tooltip title='部署应用' placement='right'>
 | 
			
		||||
            <IconButton
 | 
			
		||||
              onClick={() => {
 | 
			
		||||
                // const editorContent = editorRef.current?.getContent();
 | 
			
		||||
                const editorContent = editorRef.current?.state.doc.toString();
 | 
			
		||||
                if (editorContent) {
 | 
			
		||||
                  setOpenUploadModal(true);
 | 
			
		||||
                  setText(editorContent);
 | 
			
		||||
                } else {
 | 
			
		||||
                  toast.error('请先输入代码');
 | 
			
		||||
                }
 | 
			
		||||
              }}>
 | 
			
		||||
              <CloudUploadOutlined />
 | 
			
		||||
            </IconButton>
 | 
			
		||||
          </Tooltip>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div className=' mt-2' {...getRootProps()}>
 | 
			
		||||
          <Tooltip title='点击上传html或者js文件  ' placement='right'>
 | 
			
		||||
            <div>
 | 
			
		||||
              <IconButton>
 | 
			
		||||
                <UploadIcon size={16} />
 | 
			
		||||
              </IconButton>
 | 
			
		||||
              <input type='file' style={{ display: 'none' }} {...getInputProps()} />
 | 
			
		||||
            </div>
 | 
			
		||||
          </Tooltip>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div className='h-full grow px-2 flex flex-col'>
 | 
			
		||||
        <div className=' py-1'>
 | 
			
		||||
          <i className=' text-gray-500' style={{ fontSize: 10 }}>
 | 
			
		||||
            {'>'} 快速部署html小应用, 粘贴前端html代码。点击部署。(这个页面内容自动缓存到本地)
 | 
			
		||||
            <a className='text-blue-500 ml-2' href='/root/center/app/edit/list'>
 | 
			
		||||
              管理
 | 
			
		||||
            </a>
 | 
			
		||||
          </i>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div className='w-full flex overflow-hidden' style={{ height: 'calc(100% - 50px)' }}>
 | 
			
		||||
          <div className='w-full h-full border rounded-md border-gray-200 scrollbar relative'>
 | 
			
		||||
            <div className='h-full overflow-hidden' ref={editorElRef}></div>
 | 
			
		||||
            {isHtml && (
 | 
			
		||||
              <IconButton className='absolute top-2 right-2'>
 | 
			
		||||
                <Tooltip title='预览' placement='right'>
 | 
			
		||||
                  <input
 | 
			
		||||
                    className='cursor-pointer'
 | 
			
		||||
                    type='checkbox'
 | 
			
		||||
                    checked={openPreview}
 | 
			
		||||
                    onChange={(e) => {
 | 
			
		||||
                      setOpenPreview(e.target.checked);
 | 
			
		||||
                    }}
 | 
			
		||||
                  />
 | 
			
		||||
                </Tooltip>
 | 
			
		||||
              </IconButton>
 | 
			
		||||
            )}
 | 
			
		||||
          </div>
 | 
			
		||||
          {isHtml && openPreview && (
 | 
			
		||||
            <div className='w-1/2 h-full shrink-0'>
 | 
			
		||||
              <iframe
 | 
			
		||||
                className='w-full h-full border rounded-md border-gray-200 scrollbar'
 | 
			
		||||
                srcDoc={html}
 | 
			
		||||
                title='Preview'
 | 
			
		||||
                sandbox='allow-scripts allow-same-origin'
 | 
			
		||||
                style={{ width: '100%', height: '100%' }}></iframe>
 | 
			
		||||
            </div>
 | 
			
		||||
          )}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <UploadModal />
 | 
			
		||||
      <SuccessModal />
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										14
									
								
								src/apps/ai-html/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/apps/ai-html/index.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,14 @@
 | 
			
		||||
import { Home } from './Home';
 | 
			
		||||
import { ToastProvider } from '@/modules/toast/Provider';
 | 
			
		||||
 | 
			
		||||
export const App = () => {
 | 
			
		||||
  return (
 | 
			
		||||
    <ToastProvider>
 | 
			
		||||
      <AIHTML />
 | 
			
		||||
    </ToastProvider>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const AIHTML = () => {
 | 
			
		||||
  return <Home />;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										76
									
								
								src/apps/ai-html/module/SuccessModal.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								src/apps/ai-html/module/SuccessModal.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,76 @@
 | 
			
		||||
import { Modal } from '@/components/a/modal';
 | 
			
		||||
import { useShallow } from 'zustand/shallow';
 | 
			
		||||
import { useHomeStore } from '../store';
 | 
			
		||||
import { useEffect, useState } from 'react';
 | 
			
		||||
import { queryLogin } from '@/modules/query';
 | 
			
		||||
import { Button } from '@/components/a/button';
 | 
			
		||||
export const Label = ({ label, children }: { label: string; children: React.ReactNode }) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className='text-sm text-gray-500 w-full flex gap-2'>
 | 
			
		||||
      <div className='min-w-[60px]'>{label}</div>
 | 
			
		||||
      <div className=''>{children}</div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
export const SuccessModal = () => {
 | 
			
		||||
  const { openSuccessModal, setOpenSuccessModal, appKey, version, filename } = useHomeStore(
 | 
			
		||||
    useShallow((state) => ({
 | 
			
		||||
      openSuccessModal: state.openSuccessModal,
 | 
			
		||||
      setOpenSuccessModal: state.setOpenSuccessModal,
 | 
			
		||||
      appKey: state.appKey, //
 | 
			
		||||
      version: state.version, //
 | 
			
		||||
      filename: state.filename, //
 | 
			
		||||
    })),
 | 
			
		||||
  );
 | 
			
		||||
  const [link, setLink] = useState<string>('');
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    getLink();
 | 
			
		||||
  }, [openSuccessModal, appKey, filename]);
 | 
			
		||||
  const getLink = async () => {
 | 
			
		||||
    const user = await queryLogin.checkLocalUser();
 | 
			
		||||
    if (!user) {
 | 
			
		||||
      setLink('');
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    const _currentHref = new URL(window.location.href);
 | 
			
		||||
    const username = user?.username;
 | 
			
		||||
    const _filename = filename;
 | 
			
		||||
    let pathname = `/${username}/${appKey}/`;
 | 
			
		||||
    if (_filename.endsWith('.html')) {
 | 
			
		||||
      if (_filename !== 'index.html') {
 | 
			
		||||
        pathname += _filename;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    const newHref = new URL(pathname, _currentHref.origin);
 | 
			
		||||
    const link = newHref.toString();
 | 
			
		||||
    setLink(link);
 | 
			
		||||
    return link;
 | 
			
		||||
  };
 | 
			
		||||
  return (
 | 
			
		||||
    <Modal open={openSuccessModal} setOpen={setOpenSuccessModal} title='部署成功'>
 | 
			
		||||
      <div>
 | 
			
		||||
        <div className='flex flex-col gap-2 w-[400px] min-h-[100px] text-black'>
 | 
			
		||||
          <Label label='应用 Key: '>{appKey}</Label>
 | 
			
		||||
          <Label label='版本:'>{version}</Label>
 | 
			
		||||
          <Label label='访问地址:'>
 | 
			
		||||
            <a href={link} className='text-blue-500' rel='noreferrer'>
 | 
			
		||||
              {link}
 | 
			
		||||
            </a>
 | 
			
		||||
          </Label>
 | 
			
		||||
          <Label label='配置地址:'>
 | 
			
		||||
            <a href={`/root/center/app/edit/list`} className='text-blue-500' target='_self' rel='noreferrer'>
 | 
			
		||||
              {`/root/center/app/edit/list`}
 | 
			
		||||
            </a>
 | 
			
		||||
          </Label>
 | 
			
		||||
          <div className='mt-1 text-gray-500 italic' style={{ fontSize: 10 }}>
 | 
			
		||||
            注: 如果需要其他人访问,需要设置共享。
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div className='mt-4 flex justify-end'>
 | 
			
		||||
            <Button onClick={() => setOpenSuccessModal(false)}>知道了</Button>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </Modal>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										114
									
								
								src/apps/ai-html/module/UploadModal.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								src/apps/ai-html/module/UploadModal.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,114 @@
 | 
			
		||||
import { useHomeStore } from '../store';
 | 
			
		||||
import { Modal } from '@/components/a/modal';
 | 
			
		||||
 | 
			
		||||
import { Controller, useForm } from 'react-hook-form';
 | 
			
		||||
import { Button } from '@/components/a/button';
 | 
			
		||||
import { useEffect } from 'react';
 | 
			
		||||
import { customAlphabet } from 'nanoid';
 | 
			
		||||
import { toast } from 'react-toastify';
 | 
			
		||||
import { uploadFile } from './upload-file';
 | 
			
		||||
import { RefreshCcw } from 'lucide-react';
 | 
			
		||||
import { queryApp } from '../store';
 | 
			
		||||
import { Input } from '@/components/a/input';
 | 
			
		||||
import { Tooltip } from '@/components/a/tooltip';
 | 
			
		||||
 | 
			
		||||
export const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 10);
 | 
			
		||||
export const UploadModal = () => {
 | 
			
		||||
  const { appKey, version, filename, openUploadModal, text, setOpenUploadModal, setAppKey, setVersion, setFilename, setOpenSuccessModal } = useHomeStore();
 | 
			
		||||
  const { control, handleSubmit, reset, setValue } = useForm();
 | 
			
		||||
  // const { publishVersion } = useAppVersionStore(useShallow((state) => ({ publishVersion: state.publishVersion })));
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (openUploadModal) {
 | 
			
		||||
      reset({ appKey: appKey || randomAppKey(), version: version || '1.0.0', filename: filename || 'index.html' });
 | 
			
		||||
    }
 | 
			
		||||
  }, [openUploadModal]);
 | 
			
		||||
  const randomAppKey = () => {
 | 
			
		||||
    const randomAppKey = nanoid(4) + nanoid(4);
 | 
			
		||||
    return randomAppKey;
 | 
			
		||||
  };
 | 
			
		||||
  const onSubmit = async (data: any) => {
 | 
			
		||||
    console.log(data);
 | 
			
		||||
    if (!text) {
 | 
			
		||||
      toast.error('代码不能为空');
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    if (!data.appKey) {
 | 
			
		||||
      toast.error('应用key不能为空');
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    if (!data.version) {
 | 
			
		||||
      toast.error('版本不能为空');
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    if (!data.filename) {
 | 
			
		||||
      toast.error('文件名不能为空');
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    setAppKey(data.appKey);
 | 
			
		||||
    setVersion(data.version);
 | 
			
		||||
    setFilename(data.filename);
 | 
			
		||||
    const res = await uploadFile({
 | 
			
		||||
      appKey: data.appKey,
 | 
			
		||||
      version: data.version,
 | 
			
		||||
      filename: data.filename,
 | 
			
		||||
      text,
 | 
			
		||||
    });
 | 
			
		||||
    if (res?.code === 200) {
 | 
			
		||||
      // toast.success('部署成功');
 | 
			
		||||
      const toastId = toast.loading('发布中...');
 | 
			
		||||
      await new Promise((resolve) => setTimeout(resolve, 2000));
 | 
			
		||||
      const res = await queryApp.publishVersion({ data: { appKey: data.appKey, version: data.version } });
 | 
			
		||||
      toast.dismiss(toastId);
 | 
			
		||||
      if (res?.code === 200) {
 | 
			
		||||
        toast.success('发布成功');
 | 
			
		||||
        setOpenSuccessModal(true);
 | 
			
		||||
      } else {
 | 
			
		||||
        toast.error(res?.message || '发布失败');
 | 
			
		||||
      }
 | 
			
		||||
    } else {
 | 
			
		||||
      toast.error(res?.message || '部署失败');
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
  return (
 | 
			
		||||
    <Modal open={openUploadModal} setOpen={setOpenUploadModal}>
 | 
			
		||||
      <div>
 | 
			
		||||
        <div>部署页面</div>
 | 
			
		||||
        <div className='text-sm text-gray-500 p-4'>
 | 
			
		||||
          <form className='flex flex-col gap-3 pt-1 ' onSubmit={handleSubmit(onSubmit)}>
 | 
			
		||||
            <div className='flex flex-col gap-1'>
 | 
			
		||||
              <label className='text-sm flex gap-2 items-center'>
 | 
			
		||||
                应用key
 | 
			
		||||
                <Tooltip title='随机生成应用key'>
 | 
			
		||||
                  <RefreshCcw
 | 
			
		||||
                    className='cursor-pointer w-4 h-4'
 | 
			
		||||
                    onClick={() => {
 | 
			
		||||
                      setValue('appKey', randomAppKey());
 | 
			
		||||
                    }}
 | 
			
		||||
                  />
 | 
			
		||||
                </Tooltip>
 | 
			
		||||
              </label>
 | 
			
		||||
              <Controller control={control} name='appKey' render={({ field }) => <Input {...field} />} />
 | 
			
		||||
            </div>
 | 
			
		||||
            <div className='flex flex-col gap-1'>
 | 
			
		||||
              <label className='text-sm'>版本</label>
 | 
			
		||||
              <Controller control={control} name='version' render={({ field }) => <Input {...field} />} />
 | 
			
		||||
            </div>
 | 
			
		||||
            <div className='flex flex-col gap-1'>
 | 
			
		||||
              <label className='text-sm'>文件名</label>
 | 
			
		||||
              <Controller control={control} name='filename' render={({ field }) => <Input {...field} />} />
 | 
			
		||||
            </div>
 | 
			
		||||
            <div className='flex justify-end gap-2 mt-3'>
 | 
			
		||||
              <Button
 | 
			
		||||
                onClick={() => {
 | 
			
		||||
                  setOpenUploadModal(false);
 | 
			
		||||
                }}>
 | 
			
		||||
                取消
 | 
			
		||||
              </Button>
 | 
			
		||||
              <Button type='submit'>提交</Button>
 | 
			
		||||
            </div>
 | 
			
		||||
          </form>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </Modal>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										30
									
								
								src/apps/ai-html/module/upload-file.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								src/apps/ai-html/module/upload-file.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,30 @@
 | 
			
		||||
import { uploadChunkV2 } from '@/apps/draw/modules/upload';
 | 
			
		||||
import { toFile } from '@/apps/draw/modules/to-file';
 | 
			
		||||
import { toast } from 'react-toastify';
 | 
			
		||||
 | 
			
		||||
type UploadFileOpts = {
 | 
			
		||||
  appKey: string;
 | 
			
		||||
  version: string;
 | 
			
		||||
  filename: string;
 | 
			
		||||
  text: string;
 | 
			
		||||
};
 | 
			
		||||
const getFilenameExtension = (filename: string) => {
 | 
			
		||||
  return filename.split('.').pop() || '';
 | 
			
		||||
};
 | 
			
		||||
const allowFilesName = ['js', 'css', 'json', 'html'];
 | 
			
		||||
export const uploadFile = async (uploadFileOpts: UploadFileOpts) => {
 | 
			
		||||
  const { appKey, version, filename, text } = uploadFileOpts;
 | 
			
		||||
  const extension = getFilenameExtension(filename);
 | 
			
		||||
  if (!allowFilesName.includes(extension)) {
 | 
			
		||||
    toast.error('文件类型不支持');
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
  const file = toFile(text, filename);
 | 
			
		||||
  const res = await uploadChunkV2(file, {
 | 
			
		||||
    appKey,
 | 
			
		||||
    version,
 | 
			
		||||
    filename,
 | 
			
		||||
    noCheckAppFiles: false,
 | 
			
		||||
  });
 | 
			
		||||
  return res as any;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										64
									
								
								src/apps/ai-html/store/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								src/apps/ai-html/store/index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,64 @@
 | 
			
		||||
import { create } from 'zustand';
 | 
			
		||||
 | 
			
		||||
import { query } from '@/query/index.ts';
 | 
			
		||||
 | 
			
		||||
import { QueryApp } from '@/query/query-app/query-app';
 | 
			
		||||
 | 
			
		||||
export const queryApp = new QueryApp({ query });
 | 
			
		||||
 | 
			
		||||
export type HomeStore = {
 | 
			
		||||
  appKey: string;
 | 
			
		||||
  version: string;
 | 
			
		||||
  setAppKey: (appKey: string) => void;
 | 
			
		||||
  setVersion: (version: string) => void;
 | 
			
		||||
  filename: string;
 | 
			
		||||
  setFilename: (filename: string) => void;
 | 
			
		||||
  initApp: () => void;
 | 
			
		||||
  openUploadModal: boolean;
 | 
			
		||||
  setOpenUploadModal: (open: boolean) => void;
 | 
			
		||||
  text: string;
 | 
			
		||||
  setText: (text: string) => void;
 | 
			
		||||
  openSuccessModal: boolean;
 | 
			
		||||
  setOpenSuccessModal: (open: boolean) => void;
 | 
			
		||||
  openPreview: boolean;
 | 
			
		||||
  setOpenPreview: (open: boolean) => void;
 | 
			
		||||
};
 | 
			
		||||
export const useHomeStore = create<HomeStore>((set) => ({
 | 
			
		||||
  appKey: '',
 | 
			
		||||
  version: '',
 | 
			
		||||
  openPreview: false,
 | 
			
		||||
  setOpenPreview: (open: boolean) => {
 | 
			
		||||
    set({ openPreview: open });
 | 
			
		||||
  },
 | 
			
		||||
  setAppKey: (appKey: string) => {
 | 
			
		||||
    set({ appKey });
 | 
			
		||||
    localStorage.setItem('home-app-key', appKey);
 | 
			
		||||
  },
 | 
			
		||||
  setVersion: (version: string) => {
 | 
			
		||||
    set({ version });
 | 
			
		||||
    localStorage.setItem('home-app-version', version);
 | 
			
		||||
  },
 | 
			
		||||
  filename: '',
 | 
			
		||||
  setFilename: (filename: string) => {
 | 
			
		||||
    set({ filename });
 | 
			
		||||
    localStorage.setItem('home-file-name', filename);
 | 
			
		||||
  },
 | 
			
		||||
  initApp: () => {
 | 
			
		||||
    const appKey = localStorage.getItem('home-app-key') || '';
 | 
			
		||||
    const version = localStorage.getItem('home-app-version') || '';
 | 
			
		||||
    const filename = localStorage.getItem('home-file-name') || '';
 | 
			
		||||
    set({ appKey, version, filename });
 | 
			
		||||
  },
 | 
			
		||||
  openUploadModal: false,
 | 
			
		||||
  setOpenUploadModal: (open: boolean) => {
 | 
			
		||||
    set({ openUploadModal: open });
 | 
			
		||||
  },
 | 
			
		||||
  text: '',
 | 
			
		||||
  setText: (text: string) => {
 | 
			
		||||
    set({ text });
 | 
			
		||||
  },
 | 
			
		||||
  openSuccessModal: false,
 | 
			
		||||
  setOpenSuccessModal: (open: boolean) => {
 | 
			
		||||
    set({ openSuccessModal: open });
 | 
			
		||||
  },
 | 
			
		||||
}));
 | 
			
		||||
							
								
								
									
										48
									
								
								src/apps/assistant-home/data/link.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								src/apps/assistant-home/data/link.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,48 @@
 | 
			
		||||
import { isDev } from '../module/is-dev';
 | 
			
		||||
 | 
			
		||||
export const links = [
 | 
			
		||||
  {
 | 
			
		||||
    title: '单页面应用开发',
 | 
			
		||||
    description: '单页面应用开发模块。部署单个的网页的页面,适配简单的模块。复杂的使用管理中心。',
 | 
			
		||||
    link: '/root/ai-pages/apps/html/',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    title: '提示词规划器(Mark Prompts)',
 | 
			
		||||
    description: '提示词规划设计的模块。Mark的数据,type为chat的数据。',
 | 
			
		||||
    link: '/root/ai-pages/mark/ai-prompts/',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    title: '管理中心',
 | 
			
		||||
    description: '高级的代码管理中心,网页的版本上传和管理功能。',
 | 
			
		||||
    link: '/root/center/',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    title: 'Mark Link',
 | 
			
		||||
    description: 'Mark的管理模块, 网页导航搜索查询。Mark的数据,type类型为md。',
 | 
			
		||||
    link: '/root/ai-pages/mark/ai-mark/',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    title: 'Mark Draw',
 | 
			
		||||
    description: 'Mark的管理模块, Excaildraw的封装应用。Mark的数据,type类型为excaildraw。',
 | 
			
		||||
    link: '/root/ai-pages/mark/draw/',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    title: 'Editor(Dev)',
 | 
			
		||||
    description: 'Demo编辑器,支持Markdown编辑',
 | 
			
		||||
    link: '/root/ai-pages/ai-editor/',
 | 
			
		||||
  },
 | 
			
		||||
].map((item) => {
 | 
			
		||||
  if (isDev()) {
 | 
			
		||||
    console.warn('Dev mode: link replaced', item.link);
 | 
			
		||||
    item.link = item.link.replace('/root/ai-pages', '');
 | 
			
		||||
  }
 | 
			
		||||
  return item;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const links2 = [
 | 
			
		||||
  {
 | 
			
		||||
    title: 'cli',
 | 
			
		||||
    description: '命令行工具,支持多种功能',
 | 
			
		||||
    link: '/root/cli',
 | 
			
		||||
  },
 | 
			
		||||
];
 | 
			
		||||
@@ -6,7 +6,8 @@ import { getSuggestionItems } from '../ai-chat/editor/suggestion/item';
 | 
			
		||||
import { html2md } from '@kevisual/markdown-editor/tiptap/index.ts';
 | 
			
		||||
import { chatId } from '../ai-chat/utils/uuid';
 | 
			
		||||
import '../ai-chat/index.css';
 | 
			
		||||
export const App = (props: any) => {
 | 
			
		||||
import { links } from './data/link.ts';
 | 
			
		||||
export const Editor = () => {
 | 
			
		||||
  const ref = useRef<HTMLDivElement>(null);
 | 
			
		||||
  const editorRef = useRef<TextEditor | null>(null);
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
@@ -23,11 +24,32 @@ export const App = (props: any) => {
 | 
			
		||||
    };
 | 
			
		||||
  }, []);
 | 
			
		||||
  return (
 | 
			
		||||
    <div className='w-full h-full flex flex-col'>
 | 
			
		||||
      <div className='w-[600px] h-[400px] border border-gray-300 flex flex-col mx-auto'>
 | 
			
		||||
        <div className='w-full scrollbar' style={{ height: 'calc(100% - 90px)' }} ref={ref}></div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <Button>发送</Button>
 | 
			
		||||
    <div className='w-[600px] h-[400px] border border-gray-300 flex flex-col mx-auto'>
 | 
			
		||||
      <div className='w-full scrollbar' style={{ height: 'calc(100% - 90px)' }} ref={ref}></div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
export const App = (props: any) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className='w-full h-full flex flex-col'>
 | 
			
		||||
      <ShowLinks links={links} />
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const ShowLinks = (props: { links: { title: string; description: string; link: string }[] }) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className='w-full h-full flex flex-col'>
 | 
			
		||||
      <div className='flex-1 overflow-y-auto'>
 | 
			
		||||
        {props.links.map((item, index) => (
 | 
			
		||||
          <div key={index} className='p-4 border-b border-gray-200 hover:bg-gray-50'>
 | 
			
		||||
            <a href={item.link} className='text-blue-600 hover:underline' target='_blank' rel='noopener noreferrer'>
 | 
			
		||||
              {item.title}
 | 
			
		||||
            </a>
 | 
			
		||||
            <p className='text-gray-600'>{item.description}</p>
 | 
			
		||||
          </div>
 | 
			
		||||
        ))}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1
									
								
								src/apps/assistant-home/module/is-dev.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/apps/assistant-home/module/is-dev.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
export const isDev = () => location.origin.includes('localhost:4321');
 | 
			
		||||
							
								
								
									
										103
									
								
								src/apps/draw/App.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								src/apps/draw/App.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,103 @@
 | 
			
		||||
import { useEffect, useLayoutEffect, useState } from 'react';
 | 
			
		||||
import { getHistoryState, setHistoryState } from '@kevisual/store/web-page.js';
 | 
			
		||||
import { Draw } from './pages/Draw';
 | 
			
		||||
import { App as Manager, ProviderManagerName, useManagerStore } from '../mark/manager/Manager';
 | 
			
		||||
import { ToastProvider } from '@/modules/toast/Provider';
 | 
			
		||||
 | 
			
		||||
import './index.css';
 | 
			
		||||
import { useShallow } from 'zustand/shallow';
 | 
			
		||||
import { toast } from 'react-toastify';
 | 
			
		||||
 | 
			
		||||
// @ts-ignore
 | 
			
		||||
window.EXCALIDRAW_ASSET_PATH = 'https://esm.sh/@excalidraw/excalidraw@0.18.0/dist/prod/';
 | 
			
		||||
 | 
			
		||||
export const App = () => {
 | 
			
		||||
  return (
 | 
			
		||||
    <ToastProvider>
 | 
			
		||||
      <DrawApp />
 | 
			
		||||
    </ToastProvider>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
export const getUrlId = () => {
 | 
			
		||||
  const url = new URL(window.location.href);
 | 
			
		||||
  return url.searchParams.get('id') || '';
 | 
			
		||||
};
 | 
			
		||||
export const DrawApp = () => {
 | 
			
		||||
  const [id, setId] = useState('');
 | 
			
		||||
  const urlId = getUrlId();
 | 
			
		||||
 | 
			
		||||
  useLayoutEffect(() => {
 | 
			
		||||
    const state = getHistoryState();
 | 
			
		||||
    if (state?.id) {
 | 
			
		||||
      setId(state.id);
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    if (urlId) {
 | 
			
		||||
      setId(urlId);
 | 
			
		||||
    }
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className='bg-white w-full h-full flex'>
 | 
			
		||||
      <Manager
 | 
			
		||||
        showSearch={true}
 | 
			
		||||
        showAdd={true}
 | 
			
		||||
        markType={'excalidraw'}
 | 
			
		||||
        openMenu={!urlId}
 | 
			
		||||
        onClick={(data) => {
 | 
			
		||||
          if (data.id !== id) {
 | 
			
		||||
            setId('');
 | 
			
		||||
            const url = new URL(location.href);
 | 
			
		||||
            url.searchParams.set('id', data.id);
 | 
			
		||||
            console.log('set url', url.toString());
 | 
			
		||||
            setHistoryState({}, url.toString());
 | 
			
		||||
            setTimeout(() => {
 | 
			
		||||
              setId(data.id);
 | 
			
		||||
            }, 200);
 | 
			
		||||
            const _store = useManagerStore.getState(ProviderManagerName);
 | 
			
		||||
            if (_store.markData) {
 | 
			
		||||
              _store.setCurrentMarkId('');
 | 
			
		||||
              // _store.setOpen(false);
 | 
			
		||||
              _store.setMarkData(undefined);
 | 
			
		||||
            }
 | 
			
		||||
          } else if (data.id === id) {
 | 
			
		||||
            toast.success('已选择当前画布');
 | 
			
		||||
          }
 | 
			
		||||
          console.log('onClick', data, id);
 | 
			
		||||
        }}>
 | 
			
		||||
        <div className='h-full grow'>
 | 
			
		||||
          <DrawWrapper id={id} setId={setId} />
 | 
			
		||||
        </div>
 | 
			
		||||
      </Manager>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const DrawWrapper = (props: { id?: string; setId: (id: string) => void }) => {
 | 
			
		||||
  const { id, setId } = props;
 | 
			
		||||
  const store = useManagerStore(
 | 
			
		||||
    useShallow((state) => {
 | 
			
		||||
      return {
 | 
			
		||||
        currentMarkId: state.currentMarkId,
 | 
			
		||||
      };
 | 
			
		||||
    }),
 | 
			
		||||
  );
 | 
			
		||||
  console.log('DrawApp store', store);
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      {id ? (
 | 
			
		||||
        <Draw
 | 
			
		||||
          id={id}
 | 
			
		||||
          onClose={() => {
 | 
			
		||||
            setId('');
 | 
			
		||||
            setHistoryState({
 | 
			
		||||
              id: '',
 | 
			
		||||
            });
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      ) : (
 | 
			
		||||
        <div className='flex items-center justify-center h-full text-gray-500'> {store.currentMarkId ? '' : '请先选择一个画布'} </div>
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										21
									
								
								src/apps/draw/Bootstrap.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/apps/draw/Bootstrap.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
			
		||||
import { createRoot } from 'react-dom/client';
 | 
			
		||||
import { App } from './App.tsx';
 | 
			
		||||
import { ToastContainer } from 'react-toastify';
 | 
			
		||||
// import { I18NextProvider, initI18n } from '@kevisual/components/translate/index.tsx';
 | 
			
		||||
 | 
			
		||||
export const Bootstrap = (element: HTMLElement) => {
 | 
			
		||||
  createRoot(element).render(
 | 
			
		||||
    <>
 | 
			
		||||
      {/* <I18NextProvider basename={'/root/locales'} noUse={false}> */}
 | 
			
		||||
      <App />
 | 
			
		||||
      {/* </I18NextProvider> */}
 | 
			
		||||
      <ToastContainer />
 | 
			
		||||
    </>,
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
type RenderProps = {
 | 
			
		||||
  renderRoot: HTMLElement;
 | 
			
		||||
};
 | 
			
		||||
export const render = ({ renderRoot }: RenderProps) => {
 | 
			
		||||
  Bootstrap(renderRoot);
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										5902
									
								
								src/apps/draw/assets/excalidraw.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5902
									
								
								src/apps/draw/assets/excalidraw.css
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										4
									
								
								src/apps/draw/index.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/apps/draw/index.css
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,4 @@
 | 
			
		||||
@import 'tailwindcss';
 | 
			
		||||
/* @import '@excalidraw/excalidraw/index.css'; */
 | 
			
		||||
/* @import './assets/excalidraw.css'; */
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										8
									
								
								src/apps/draw/libs.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src/apps/draw/libs.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
			
		||||
import { Bootstrap } from './Bootstrap';
 | 
			
		||||
 | 
			
		||||
type RenderProps = {
 | 
			
		||||
  renderRoot: HTMLElement;
 | 
			
		||||
};
 | 
			
		||||
export const render = ({ renderRoot }: RenderProps) => {
 | 
			
		||||
  Bootstrap(renderRoot);
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										4
									
								
								src/apps/draw/main.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/apps/draw/main.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,4 @@
 | 
			
		||||
import { render } from './Bootstrap';
 | 
			
		||||
import './index.css';
 | 
			
		||||
// import '@excalidraw/excalidraw/index.css';
 | 
			
		||||
render({ renderRoot: document.getElementById('root')! });
 | 
			
		||||
							
								
								
									
										32
									
								
								src/apps/draw/modules/hash-file.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/apps/draw/modules/hash-file.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,32 @@
 | 
			
		||||
import MD5 from 'crypto-js/md5';
 | 
			
		||||
export const hashFile = (file: File): Promise<string> => {
 | 
			
		||||
  return new Promise((resolve, reject) => {
 | 
			
		||||
    const reader = new FileReader();
 | 
			
		||||
 | 
			
		||||
    reader.onload = async (event) => {
 | 
			
		||||
      try {
 | 
			
		||||
        const content = event.target?.result;
 | 
			
		||||
        if (content instanceof ArrayBuffer) {
 | 
			
		||||
          const contentString = new TextDecoder().decode(content);
 | 
			
		||||
          const hashHex = MD5(contentString).toString();
 | 
			
		||||
          resolve(hashHex);
 | 
			
		||||
        } else if (typeof content === 'string') {
 | 
			
		||||
          const hashHex = MD5(content).toString();
 | 
			
		||||
          resolve(hashHex);
 | 
			
		||||
        } else {
 | 
			
		||||
          throw new Error('Invalid content type');
 | 
			
		||||
        }
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.error('hashFile error', error);
 | 
			
		||||
        reject(error);
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    reader.onerror = (error) => {
 | 
			
		||||
      reject(error);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // 读取文件为 ArrayBuffer
 | 
			
		||||
    reader.readAsArrayBuffer(file);
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										7
									
								
								src/apps/draw/modules/query.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/apps/draw/modules/query.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
			
		||||
import { query } from '@/modules/query';
 | 
			
		||||
import { QueryMark } from '@/query/query-mark/query-mark';
 | 
			
		||||
 | 
			
		||||
export const queryMark = new QueryMark({
 | 
			
		||||
  query: query,
 | 
			
		||||
  markType: 'excalidraw',
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										112
									
								
								src/apps/draw/modules/to-file.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										112
									
								
								src/apps/draw/modules/to-file.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,112 @@
 | 
			
		||||
const getFileExtension = (filename: string) => {
 | 
			
		||||
  return filename.split('.').pop();
 | 
			
		||||
};
 | 
			
		||||
const getFileType = (extension: string) => {
 | 
			
		||||
  switch (extension) {
 | 
			
		||||
    case 'js':
 | 
			
		||||
      return 'text/javascript';
 | 
			
		||||
    case 'css':
 | 
			
		||||
      return 'text/css';
 | 
			
		||||
    case 'html':
 | 
			
		||||
      return 'text/html';
 | 
			
		||||
    case 'json':
 | 
			
		||||
      return 'application/json';
 | 
			
		||||
    case 'png':
 | 
			
		||||
      return 'image/png';
 | 
			
		||||
    case 'jpg':
 | 
			
		||||
      return 'image/jpeg';
 | 
			
		||||
    case 'jpeg':
 | 
			
		||||
      return 'image/jpeg';
 | 
			
		||||
    case 'gif':
 | 
			
		||||
      return 'image/gif';
 | 
			
		||||
    case 'svg':
 | 
			
		||||
      return 'image/svg+xml';
 | 
			
		||||
    case 'webp':
 | 
			
		||||
      return 'image/webp';
 | 
			
		||||
    case 'ico':
 | 
			
		||||
      return 'image/x-icon';
 | 
			
		||||
    default:
 | 
			
		||||
      return 'text/plain';
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
const checkIsBase64 = (content: string) => {
 | 
			
		||||
  return content.startsWith('data:');
 | 
			
		||||
};
 | 
			
		||||
/**
 | 
			
		||||
 * 获取文件的目录和文件名
 | 
			
		||||
 * @param filename 文件名
 | 
			
		||||
 * @returns 目录和文件名
 | 
			
		||||
 */
 | 
			
		||||
export const getDirectoryAndName = (filename: string) => {
 | 
			
		||||
  if (!filename) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
  if (filename.startsWith('.')) {
 | 
			
		||||
    return null;
 | 
			
		||||
  } else {
 | 
			
		||||
    filename = filename.replace(/^\/+/, ''); // Remove all leading slashes
 | 
			
		||||
  }
 | 
			
		||||
  const hasDirectory = filename.includes('/');
 | 
			
		||||
  if (!hasDirectory) {
 | 
			
		||||
    return { directory: '', name: filename };
 | 
			
		||||
  }
 | 
			
		||||
  const parts = filename.split('/');
 | 
			
		||||
  const name = parts.pop()!; // Get the last part as the file name
 | 
			
		||||
  const directory = parts.join('/'); // Join the remaining parts as the directory
 | 
			
		||||
  return { directory, name };
 | 
			
		||||
};
 | 
			
		||||
/**
 | 
			
		||||
 * 把字符串转为文件流,并返回文件流,根据filename的扩展名,自动设置文件类型.
 | 
			
		||||
 * 当不是文本类型,自动需要把base64的字符串转为blob
 | 
			
		||||
 * @param content 字符串
 | 
			
		||||
 * @param filename 文件名
 | 
			
		||||
 * @returns 文件流
 | 
			
		||||
 */
 | 
			
		||||
export const toFile = (content: string, filename: string) => {
 | 
			
		||||
  // 如果文件名是 a/d/a.js 格式的,则需要把d作为目录,a.js作为文件名
 | 
			
		||||
  const directoryAndName = getDirectoryAndName(filename);
 | 
			
		||||
  if (!directoryAndName) {
 | 
			
		||||
    throw new Error('Invalid filename');
 | 
			
		||||
  }
 | 
			
		||||
  const { name } = directoryAndName;
 | 
			
		||||
  const extension = getFileExtension(name);
 | 
			
		||||
  if (!extension) {
 | 
			
		||||
    throw new Error('Invalid filename');
 | 
			
		||||
  }
 | 
			
		||||
  const isBase64 = checkIsBase64(content);
 | 
			
		||||
  const type = getFileType(extension);
 | 
			
		||||
 | 
			
		||||
  if (isBase64) {
 | 
			
		||||
    // Decode base64 string
 | 
			
		||||
    let base64Data = content.split(',')[1]; // Remove the data URL prefix
 | 
			
		||||
    const byteCharacters = atob(base64Data);
 | 
			
		||||
    const byteNumbers = new Array(byteCharacters.length);
 | 
			
		||||
    for (let i = 0; i < byteCharacters.length; i++) {
 | 
			
		||||
      byteNumbers[i] = byteCharacters.charCodeAt(i);
 | 
			
		||||
    }
 | 
			
		||||
    const byteArray = new Uint8Array(byteNumbers);
 | 
			
		||||
    const blob = new Blob([byteArray], { type });
 | 
			
		||||
    return new File([blob], filename, { type });
 | 
			
		||||
  } else {
 | 
			
		||||
    const blob = new Blob([content], { type });
 | 
			
		||||
    return new File([blob], filename, { type });
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 把字符串转为文本文件
 | 
			
		||||
 * @param content 字符串
 | 
			
		||||
 * @param filename 文件名
 | 
			
		||||
 * @returns 文件流
 | 
			
		||||
 */
 | 
			
		||||
export const toTextFile = (content: string = 'keep directory exist', filename: string = 'keep.txt') => {
 | 
			
		||||
  const file = toFile(content, filename);
 | 
			
		||||
  return file;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const downloadFile = (file: File) => {
 | 
			
		||||
  const a = document.createElement('a');
 | 
			
		||||
  a.href = URL.createObjectURL(file);
 | 
			
		||||
  a.download = file.name;
 | 
			
		||||
  a.click();
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										57
									
								
								src/apps/draw/modules/upload.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								src/apps/draw/modules/upload.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,57 @@
 | 
			
		||||
// import NProgress from 'nprogress';
 | 
			
		||||
import { Id, toast } from 'react-toastify';
 | 
			
		||||
import { toastLogin } from '@/modules/toast/ToastLogin';
 | 
			
		||||
import { uploadFileChunked, UploadProgress } from '@/query/query-upload/query-upload';
 | 
			
		||||
 | 
			
		||||
export type ConvertOpts = {
 | 
			
		||||
  appKey?: string;
 | 
			
		||||
  version?: string;
 | 
			
		||||
  username?: string;
 | 
			
		||||
  directory?: string;
 | 
			
		||||
  isPublic?: boolean;
 | 
			
		||||
  filename?: string;
 | 
			
		||||
  /**
 | 
			
		||||
   * 是否不检查应用文件, 默认 true,默认不检测
 | 
			
		||||
   */
 | 
			
		||||
  noCheckAppFiles?: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const uploadChunkV2 = async (file: File, opts: ConvertOpts) => {
 | 
			
		||||
  const filename = opts.filename || file.name;
 | 
			
		||||
  const token = localStorage.getItem('token');
 | 
			
		||||
  if (!token) {
 | 
			
		||||
    console.log('uploadChunk token', token);
 | 
			
		||||
    toastLogin();
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
  let loaded: Id;
 | 
			
		||||
  const uploadProgress = new UploadProgress({
 | 
			
		||||
    onStart: function () {
 | 
			
		||||
      // NProgress.start();
 | 
			
		||||
      loaded = toast.loading(`${filename} 上传中...`);
 | 
			
		||||
    },
 | 
			
		||||
    onDone: () => {
 | 
			
		||||
      // NProgress.done();
 | 
			
		||||
      toast.dismiss(loaded);
 | 
			
		||||
    },
 | 
			
		||||
    onProgress: (progress, data) => {
 | 
			
		||||
      // NProgress.set(progress);
 | 
			
		||||
      // console.log('uploadChunk progress', progress, data);
 | 
			
		||||
      toast.update(loaded, {
 | 
			
		||||
        render: `${filename} 上传中... ${progress.toFixed(2)}%`,
 | 
			
		||||
        isLoading: true,
 | 
			
		||||
        autoClose: false,
 | 
			
		||||
      });
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const result = await uploadFileChunked(file, opts, {
 | 
			
		||||
    uploadProgress,
 | 
			
		||||
    token,
 | 
			
		||||
    createEventSource: (url: string, searchParams: URLSearchParams) => {
 | 
			
		||||
      return new EventSource(url + '?' + searchParams.toString());
 | 
			
		||||
    },
 | 
			
		||||
    FormDataFn: FormData,
 | 
			
		||||
  });
 | 
			
		||||
  return result;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										41
									
								
								src/apps/draw/pages/Draw.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								src/apps/draw/pages/Draw.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,41 @@
 | 
			
		||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
 | 
			
		||||
import { createMarkStore, store, useMarkStore } from '../store';
 | 
			
		||||
console.log('store', store);
 | 
			
		||||
import { StoreContextProvider } from '@kevisual/store/react';
 | 
			
		||||
import { LineChart } from 'lucide-react';
 | 
			
		||||
import { useShallow } from 'zustand/shallow';
 | 
			
		||||
import { Core } from './core/Excalidraw';
 | 
			
		||||
 | 
			
		||||
export const Draw = ({ id, onClose }: { id: string; onClose: () => void }) => {
 | 
			
		||||
  useLayoutEffect(() => {
 | 
			
		||||
    // @ts-ignore
 | 
			
		||||
    window.EXCALIDRAW_ASSET_PATH = 'https://esm.sh/@excalidraw/excalidraw@0.18.0/dist/prod/';
 | 
			
		||||
    // window.EXCALIDRAW_ASSET_PATH = '/';
 | 
			
		||||
  }, []);
 | 
			
		||||
  return (
 | 
			
		||||
    <StoreContextProvider id={id} stateCreator={createMarkStore}>
 | 
			
		||||
      <ExcaliDrawComponent id={id} onClose={onClose} />
 | 
			
		||||
    </StoreContextProvider>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type ExcaliDrawComponentProps = {
 | 
			
		||||
  /** 修改的id */
 | 
			
		||||
  id: string;
 | 
			
		||||
  /** 关闭 */
 | 
			
		||||
  onClose: () => void;
 | 
			
		||||
};
 | 
			
		||||
export const ExcaliDrawComponent = ({ id, onClose }: ExcaliDrawComponentProps) => {
 | 
			
		||||
  const store = useMarkStore(
 | 
			
		||||
    useShallow((state) => {
 | 
			
		||||
      return {
 | 
			
		||||
        id: state.id,
 | 
			
		||||
        setId: state.setId,
 | 
			
		||||
        getMark: state.getMark,
 | 
			
		||||
      };
 | 
			
		||||
    }),
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const memo = useMemo(() => <Core onClose={onClose} id={id} />, [id, onClose]);
 | 
			
		||||
  return <>{memo}</>;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										284
									
								
								src/apps/draw/pages/core/Excalidraw.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										284
									
								
								src/apps/draw/pages/core/Excalidraw.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,284 @@
 | 
			
		||||
import { Excalidraw } from '@excalidraw/excalidraw';
 | 
			
		||||
 | 
			
		||||
import { useEffect, useRef, useState } from 'react';
 | 
			
		||||
import { BinaryFileData, ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types';
 | 
			
		||||
import { Languages, LogOut, Save } from 'lucide-react';
 | 
			
		||||
import { MainMenu, Sidebar, Footer } from '@excalidraw/excalidraw';
 | 
			
		||||
import { throttle } from 'lodash-es';
 | 
			
		||||
import { useMarkStore, store as StoreManager } from '../../store';
 | 
			
		||||
import { useShallow } from 'zustand/shallow';
 | 
			
		||||
import { useListenLang } from './hooks/listen-lang';
 | 
			
		||||
import { toast } from 'react-toastify';
 | 
			
		||||
import { hashFile } from '../../modules/hash-file';
 | 
			
		||||
import { uploadChunkV2 } from '../../modules/upload';
 | 
			
		||||
import { downloadFile, toFile } from '../../modules/to-file';
 | 
			
		||||
 | 
			
		||||
type ImageResource = {};
 | 
			
		||||
export const ImagesResources = (props: ImageResource) => {
 | 
			
		||||
  const [images, setImages] = useState<string[]>([]);
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    initImages();
 | 
			
		||||
  }, []);
 | 
			
		||||
  const refFiles = useRef<any>({});
 | 
			
		||||
  const { api } = useMarkStore(
 | 
			
		||||
    useShallow((state) => {
 | 
			
		||||
      return {
 | 
			
		||||
        api: state.api,
 | 
			
		||||
      };
 | 
			
		||||
    }),
 | 
			
		||||
  );
 | 
			
		||||
  const initImages = async () => {
 | 
			
		||||
    if (!api) return;
 | 
			
		||||
    const res = api.getFiles();
 | 
			
		||||
    console.log('res from ImageResource', res);
 | 
			
		||||
    setImages(Object.keys(res));
 | 
			
		||||
    refFiles.current = res;
 | 
			
		||||
  };
 | 
			
		||||
  return (
 | 
			
		||||
    <div className='w-full h-full'>
 | 
			
		||||
      {images.map((image) => {
 | 
			
		||||
        const isUrl = refFiles.current[image]?.dataURL?.startsWith?.('http');
 | 
			
		||||
        const dataURL = refFiles.current[image]?.dataURL;
 | 
			
		||||
        return (
 | 
			
		||||
          <div className='flex items-center gap-2 w-full overflow-hidden p-2 m-2 border border-gray-200 rounded-md shadow ' key={image}>
 | 
			
		||||
            <img className='w-10 h-10 m-4' src={dataURL} alt='image' />
 | 
			
		||||
            <div className='flex flex-col gap-1'>
 | 
			
		||||
              {isUrl && <div className='text-xs  line-clamp-4 break-all'> {dataURL}</div>}
 | 
			
		||||
              <div className='text-xs'>{refFiles.current[image]?.name}</div>
 | 
			
		||||
              <div className='text-xs'>{refFiles.current[image]?.type}</div>
 | 
			
		||||
              <div className='text-xs'>{refFiles.current[image]?.id}</div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        );
 | 
			
		||||
      })}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const ExcalidrawExpand = (props: { onClose: () => void }) => {
 | 
			
		||||
  const { onClose } = props;
 | 
			
		||||
  const [docked, setDocked] = useState(false);
 | 
			
		||||
  const store = useMarkStore(
 | 
			
		||||
    useShallow((state) => {
 | 
			
		||||
      return {
 | 
			
		||||
        loading: state.loading,
 | 
			
		||||
        updateMark: state.updateMark,
 | 
			
		||||
        api: state.api,
 | 
			
		||||
      };
 | 
			
		||||
    }),
 | 
			
		||||
  );
 | 
			
		||||
  const { lang, setLang, isZh } = useListenLang();
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      {store.loading && (
 | 
			
		||||
        <div className='w-full h-full flex items-center justify-center absolute top-0 left-0 z-10'>
 | 
			
		||||
          <div className='w-full h-full bg-black opacity-10 absolute top-0 left-0 z-1'></div>
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
      <MainMenu>
 | 
			
		||||
        <MainMenu.Item
 | 
			
		||||
          onSelect={() => {
 | 
			
		||||
            store.updateMark();
 | 
			
		||||
          }}>
 | 
			
		||||
          <div className='flex items-center gap-2'>
 | 
			
		||||
            <Save />
 | 
			
		||||
            <span>{isZh ? '保存' : 'Save'}</span>
 | 
			
		||||
          </div>
 | 
			
		||||
        </MainMenu.Item>
 | 
			
		||||
        <MainMenu.DefaultItems.LoadScene />
 | 
			
		||||
        <MainMenu.DefaultItems.Export />
 | 
			
		||||
        <MainMenu.DefaultItems.SaveToActiveFile />
 | 
			
		||||
        <MainMenu.DefaultItems.SaveAsImage />
 | 
			
		||||
        {/* <MainMenu.DefaultItems.LiveCollaborationTrigger onSelect={() => {}} isCollaborating={false} /> */}
 | 
			
		||||
        {/* <MainMenu.DefaultItems.CommandPalette /> */}
 | 
			
		||||
        <MainMenu.DefaultItems.SearchMenu />
 | 
			
		||||
        <MainMenu.DefaultItems.Help />
 | 
			
		||||
        <MainMenu.DefaultItems.ClearCanvas />
 | 
			
		||||
        <MainMenu.Separator />
 | 
			
		||||
        <MainMenu.DefaultItems.ChangeCanvasBackground />
 | 
			
		||||
        <MainMenu.Separator />
 | 
			
		||||
        <MainMenu.DefaultItems.ToggleTheme />
 | 
			
		||||
        <MainMenu.Separator />
 | 
			
		||||
 | 
			
		||||
        <MainMenu.Item
 | 
			
		||||
          onSelect={(e) => {
 | 
			
		||||
            const newLang = lang === 'zh-CN' ? 'en' : 'zh-CN';
 | 
			
		||||
            setLang(newLang);
 | 
			
		||||
          }}>
 | 
			
		||||
          <div className='flex items-center gap-2'>
 | 
			
		||||
            <Languages />
 | 
			
		||||
            <span>{isZh ? 'English' : '中文'}</span>
 | 
			
		||||
          </div>
 | 
			
		||||
        </MainMenu.Item>
 | 
			
		||||
        {onClose && (
 | 
			
		||||
          <>
 | 
			
		||||
            <MainMenu.Separator />
 | 
			
		||||
            <MainMenu.Item onSelect={onClose}>
 | 
			
		||||
              <div className='flex items-center gap-2'>
 | 
			
		||||
                <LogOut />
 | 
			
		||||
                <span>{isZh ? '退出当前画布' : 'Exit current canvas'}</span>
 | 
			
		||||
              </div>
 | 
			
		||||
            </MainMenu.Item>
 | 
			
		||||
          </>
 | 
			
		||||
        )}
 | 
			
		||||
      </MainMenu>
 | 
			
		||||
      <Sidebar name='custom' docked={docked} onDock={setDocked}>
 | 
			
		||||
        <Sidebar.Header />
 | 
			
		||||
        <Sidebar.Tabs>
 | 
			
		||||
          <Sidebar.Tab tab='one'>{<ImagesResources />}</Sidebar.Tab>
 | 
			
		||||
          <Sidebar.Tab tab='two'>Tab two!</Sidebar.Tab>
 | 
			
		||||
          <Sidebar.TabTriggers>
 | 
			
		||||
            <Sidebar.TabTrigger tab='one'>Image Resources</Sidebar.TabTrigger>
 | 
			
		||||
            <Sidebar.TabTrigger tab='two'>Two</Sidebar.TabTrigger>
 | 
			
		||||
          </Sidebar.TabTriggers>
 | 
			
		||||
        </Sidebar.Tabs>
 | 
			
		||||
      </Sidebar>
 | 
			
		||||
 | 
			
		||||
      <Footer>
 | 
			
		||||
        <Sidebar.Trigger
 | 
			
		||||
          name='custom'
 | 
			
		||||
          tab='one'
 | 
			
		||||
          style={{
 | 
			
		||||
            marginLeft: '0.5rem',
 | 
			
		||||
            background: '#70b1ec',
 | 
			
		||||
            color: 'white',
 | 
			
		||||
          }}>
 | 
			
		||||
          {isZh ? '图片资源' : 'Image Resources'}
 | 
			
		||||
        </Sidebar.Trigger>
 | 
			
		||||
      </Footer>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
type CoreProps = {
 | 
			
		||||
  onClose: () => void;
 | 
			
		||||
  id: string;
 | 
			
		||||
};
 | 
			
		||||
export const Core = ({ onClose, id }: CoreProps) => {
 | 
			
		||||
  const ref = useRef<ExcalidrawImperativeAPI>(null);
 | 
			
		||||
  const { lang } = useListenLang();
 | 
			
		||||
  const uploadLoadingRef = useRef<boolean>(false);
 | 
			
		||||
  const store = useMarkStore(
 | 
			
		||||
    useShallow((state) => {
 | 
			
		||||
      return {
 | 
			
		||||
        id: state.id,
 | 
			
		||||
        loading: state.loading,
 | 
			
		||||
        getMark: state.getMark,
 | 
			
		||||
        api: state.api,
 | 
			
		||||
        setApi: state.setApi,
 | 
			
		||||
        getCache: state.getCache,
 | 
			
		||||
        updateMark: state.updateMark,
 | 
			
		||||
      };
 | 
			
		||||
    }),
 | 
			
		||||
  );
 | 
			
		||||
  const cacheDataRef = useRef<{
 | 
			
		||||
    elements: { [key: string]: number };
 | 
			
		||||
    filesObject: Record<string, any>;
 | 
			
		||||
  }>({
 | 
			
		||||
    elements: {},
 | 
			
		||||
    filesObject: {},
 | 
			
		||||
  });
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    id && store.getMark(id);
 | 
			
		||||
  }, [id]);
 | 
			
		||||
  const onSave = throttle(async (elements, appState, filesObject) => {
 | 
			
		||||
    const { elements: cacheElements, filesObject: cacheFiles } = cacheDataRef.current;
 | 
			
		||||
    const _store = StoreManager.getStore(id);
 | 
			
		||||
    if (!_store) {
 | 
			
		||||
      console.error('Store not found for id:', id);
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    const { setCache, loading } = _store.getState();
 | 
			
		||||
    if (loading) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    if (uploadLoadingRef.current) return;
 | 
			
		||||
    let isChange = false;
 | 
			
		||||
    const elementsObj = elements.reduce((acc, e) => {
 | 
			
		||||
      acc[e.id] = e.version;
 | 
			
		||||
      return acc;
 | 
			
		||||
    }, {});
 | 
			
		||||
    if (JSON.stringify(elementsObj) !== JSON.stringify(cacheElements)) {
 | 
			
		||||
      isChange = true;
 | 
			
		||||
    }
 | 
			
		||||
    if (JSON.stringify(cacheFiles) !== JSON.stringify(filesObject)) {
 | 
			
		||||
      isChange = true;
 | 
			
		||||
      const files = Object.values(filesObject) as any as BinaryFileData[];
 | 
			
		||||
      uploadLoadingRef.current = true;
 | 
			
		||||
      for (const file of files) {
 | 
			
		||||
        if (file.dataURL.startsWith('data')) {
 | 
			
		||||
          const _file = toFile(file.dataURL, file.id);
 | 
			
		||||
          const res = (await uploadChunkV2(_file, {
 | 
			
		||||
            filename: file.id,
 | 
			
		||||
            directory: id,
 | 
			
		||||
          })) as any;
 | 
			
		||||
          if (res.code === 200) {
 | 
			
		||||
            toast.success('上传图片成功');
 | 
			
		||||
          } else {
 | 
			
		||||
            toast.error('上传图片失败');
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          const [upload] = res.data?.upload || [];
 | 
			
		||||
          if (upload) {
 | 
			
		||||
            filesObject[file.id] = {
 | 
			
		||||
              ...filesObject[file.id],
 | 
			
		||||
              dataURL: upload.path,
 | 
			
		||||
            };
 | 
			
		||||
          } else {
 | 
			
		||||
            toast.error('上传图片失败');
 | 
			
		||||
          }
 | 
			
		||||
        } else {
 | 
			
		||||
          continue;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      uploadLoadingRef.current = false;
 | 
			
		||||
    }
 | 
			
		||||
    console.log('onSave', elements, appState, filesObject, 'isChange', isChange);
 | 
			
		||||
    if (!isChange) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    cacheDataRef.current = { elements: elementsObj, filesObject };
 | 
			
		||||
    setCache({
 | 
			
		||||
      data: {
 | 
			
		||||
        elements,
 | 
			
		||||
        filesObject,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
  }, 2000);
 | 
			
		||||
  return (
 | 
			
		||||
    <Excalidraw
 | 
			
		||||
      initialData={{}}
 | 
			
		||||
      onChange={(elements, appState, filesObject) => {
 | 
			
		||||
        if (store.loading) return;
 | 
			
		||||
        onSave(elements, appState, filesObject);
 | 
			
		||||
      }}
 | 
			
		||||
      langCode={lang || 'zh-CN'}
 | 
			
		||||
      renderTopRightUI={() => {
 | 
			
		||||
        return <div></div>;
 | 
			
		||||
      }}
 | 
			
		||||
      excalidrawAPI={async (api) => {
 | 
			
		||||
        ref.current = api;
 | 
			
		||||
        store.setApi(api);
 | 
			
		||||
        const cache = await store.getCache(id, true);
 | 
			
		||||
        if (!cache) return;
 | 
			
		||||
        const elementsObj = cache.elements.reduce((acc, e) => {
 | 
			
		||||
          acc[e.id] = e.version;
 | 
			
		||||
          return acc;
 | 
			
		||||
        }, {});
 | 
			
		||||
        cacheDataRef.current = {
 | 
			
		||||
          elements: elementsObj,
 | 
			
		||||
          filesObject: cache.filesObject,
 | 
			
		||||
        };
 | 
			
		||||
      }}
 | 
			
		||||
      generateIdForFile={async (file) => {
 | 
			
		||||
        // return dayjs().format('YYYY-MM-DD-HH-mm-ss') + '.' + file.type.split('/')[1];
 | 
			
		||||
        const hash = await hashFile(file);
 | 
			
		||||
        console.log('hash', hash, 'filetype', file.type);
 | 
			
		||||
        const fileId = hash + '.' + file.type.split('/')[1];
 | 
			
		||||
        console.log('fileId', fileId);
 | 
			
		||||
 | 
			
		||||
        return fileId;
 | 
			
		||||
      }}>
 | 
			
		||||
      <ExcalidrawExpand onClose={onClose} />
 | 
			
		||||
    </Excalidraw>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										29
									
								
								src/apps/draw/pages/core/hooks/listen-lang.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								src/apps/draw/pages/core/hooks/listen-lang.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,29 @@
 | 
			
		||||
import { useEffect, useState } from 'react';
 | 
			
		||||
 | 
			
		||||
export const useListenLang = () => {
 | 
			
		||||
  const [lang, setLang] = useState('zh-CN');
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const lang = localStorage.getItem('excalidrawLang');
 | 
			
		||||
    if (lang) {
 | 
			
		||||
      setLang(lang);
 | 
			
		||||
    }
 | 
			
		||||
    // 监听 localStorage中excalidrawLang的变化
 | 
			
		||||
    const onStorage = (e: StorageEvent) => {
 | 
			
		||||
      if (e.key === 'excalidrawLang') {
 | 
			
		||||
        e.newValue && setLang(e.newValue);
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
    window.addEventListener('storage', onStorage);
 | 
			
		||||
    return () => {
 | 
			
		||||
      window.removeEventListener('storage', onStorage);
 | 
			
		||||
    };
 | 
			
		||||
  }, []);
 | 
			
		||||
  return {
 | 
			
		||||
    lang,
 | 
			
		||||
    setLang: (lang: string) => {
 | 
			
		||||
      // setLang(lang);
 | 
			
		||||
      localStorage.setItem('excalidrawLang', lang);
 | 
			
		||||
    },
 | 
			
		||||
    isZh: lang === 'zh-CN',
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										30
									
								
								src/apps/draw/pages/core/hooks/listen-library.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								src/apps/draw/pages/core/hooks/listen-library.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,30 @@
 | 
			
		||||
import { useEffect } from 'react';
 | 
			
		||||
 | 
			
		||||
export const useListenLibrary = () => {
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    addLibraryItem();
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const addLibraryItem = async () => {
 | 
			
		||||
    const hash = window.location.hash; // 获取哈希值
 | 
			
		||||
    const addLibrary = hash.split('addLibrary=')[1];
 | 
			
		||||
    if (!addLibrary || addLibrary === 'undefined') {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    const token = hash.split('token=')[1];
 | 
			
		||||
    console.log('addLibrary', addLibrary, token);
 | 
			
		||||
    const _fetchURL = decodeURIComponent(addLibrary);
 | 
			
		||||
    const fetchURL = _fetchURL.split('&')[0];
 | 
			
		||||
 | 
			
		||||
    console.log('fetchURL', fetchURL);
 | 
			
		||||
    const res = await fetch(fetchURL, {
 | 
			
		||||
      method: 'GET',
 | 
			
		||||
      mode: 'cors',
 | 
			
		||||
    });
 | 
			
		||||
    const data = await res.json();
 | 
			
		||||
    console.log('data', data);
 | 
			
		||||
  };
 | 
			
		||||
  return {
 | 
			
		||||
    addLibraryItem,
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										215
									
								
								src/apps/draw/store/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										215
									
								
								src/apps/draw/store/index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,215 @@
 | 
			
		||||
import { StoreManager } from '@kevisual/store';
 | 
			
		||||
import { useContextKey } from '@kevisual/store/context';
 | 
			
		||||
import { StateCreator, StoreApi, UseBoundStore } from 'zustand';
 | 
			
		||||
import { queryMark } from '../modules/query';
 | 
			
		||||
import { useStore, BoundStore } from '@kevisual/store/react';
 | 
			
		||||
import { createStore, set as setCache, get as getCache } from 'idb-keyval';
 | 
			
		||||
import { OrderedExcalidrawElement } from '@excalidraw/excalidraw/element/types';
 | 
			
		||||
import { toast } from 'react-toastify';
 | 
			
		||||
import { BinaryFileData, ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types';
 | 
			
		||||
export const cacheStore = createStore('excalidraw-store', 'excalidraw');
 | 
			
		||||
 | 
			
		||||
export const store = useContextKey('store', () => {
 | 
			
		||||
  return new StoreManager();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
type MarkStore = {
 | 
			
		||||
  id: string;
 | 
			
		||||
  setId: (id: string) => void;
 | 
			
		||||
  mark: any;
 | 
			
		||||
  setMark: (mark: any) => void;
 | 
			
		||||
  info: any;
 | 
			
		||||
  setInfo: (info: any) => void;
 | 
			
		||||
  getList: () => Promise<void>;
 | 
			
		||||
  list: any[];
 | 
			
		||||
  setList: (list: any[]) => void;
 | 
			
		||||
  getMark: (markId: string) => Promise<void>;
 | 
			
		||||
  updateMark: () => Promise<void>;
 | 
			
		||||
  getCache: (
 | 
			
		||||
    id: string,
 | 
			
		||||
    updateApiData?: boolean,
 | 
			
		||||
  ) => Promise<
 | 
			
		||||
    | {
 | 
			
		||||
        elements: OrderedExcalidrawElement[];
 | 
			
		||||
        filesObject: Record<string, any>;
 | 
			
		||||
      }
 | 
			
		||||
    | undefined
 | 
			
		||||
  >;
 | 
			
		||||
  setCache: (cache: any, version?: number) => Promise<void>;
 | 
			
		||||
  loading: boolean;
 | 
			
		||||
  setLoading: (loading: boolean) => void;
 | 
			
		||||
  // excalidraw
 | 
			
		||||
 | 
			
		||||
  api: ExcalidrawImperativeAPI | null;
 | 
			
		||||
  setApi: (api: ExcalidrawImperativeAPI) => void;
 | 
			
		||||
};
 | 
			
		||||
export const createMarkStore: StateCreator<MarkStore, [], [], MarkStore> = (set, get, store) => {
 | 
			
		||||
  return {
 | 
			
		||||
    id: '',
 | 
			
		||||
    setId: (id: string) => set(() => ({ id })),
 | 
			
		||||
    mark: null,
 | 
			
		||||
    setMark: (mark: any) => set(() => ({ mark })),
 | 
			
		||||
    loading: true,
 | 
			
		||||
    setLoading: (loading: boolean) => set(() => ({ loading })),
 | 
			
		||||
    info: null,
 | 
			
		||||
    setCache: async (cache: any, version?: number) => {
 | 
			
		||||
      const { id, mark } = get();
 | 
			
		||||
      console.log('cacheData setCache ,id', cache, id);
 | 
			
		||||
      if (!id) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      const cacheData = (await getCache(`${id}`, cacheStore)) || {};
 | 
			
		||||
      await setCache(
 | 
			
		||||
        `${id}`,
 | 
			
		||||
        {
 | 
			
		||||
          ...cacheData,
 | 
			
		||||
          ...cache,
 | 
			
		||||
          data: {
 | 
			
		||||
            ...cacheData?.data,
 | 
			
		||||
            ...cache?.data,
 | 
			
		||||
          },
 | 
			
		||||
          version: version || mark?.version || 0,
 | 
			
		||||
        },
 | 
			
		||||
        cacheStore,
 | 
			
		||||
      );
 | 
			
		||||
    },
 | 
			
		||||
    updateMark: async () => {
 | 
			
		||||
      const { id } = get();
 | 
			
		||||
      if (!id) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const cacheData = await getCache(id, cacheStore);
 | 
			
		||||
      let mark = cacheData || {};
 | 
			
		||||
      if (!mark) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      const { data } = mark;
 | 
			
		||||
      const { elements, filesObject } = data;
 | 
			
		||||
      console.log('updateMark', elements, filesObject);
 | 
			
		||||
      const res = await queryMark.updateMark({ id, data });
 | 
			
		||||
      if (res.code === 200) {
 | 
			
		||||
        set(() => ({ mark: res.data }));
 | 
			
		||||
        toast.success('更新成功');
 | 
			
		||||
        get().setCache({}, res.data!.version);
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    getCache: async (id: string, updateApiData?: boolean) => {
 | 
			
		||||
      if (!id) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      // 获取缓存
 | 
			
		||||
      let cacheData = (await getCache(`${id}`, cacheStore)) || { data: { elements: [], filesObject: {} } };
 | 
			
		||||
      console.log('getCache', id, cacheData);
 | 
			
		||||
      if (cacheData) {
 | 
			
		||||
        if (updateApiData) {
 | 
			
		||||
          const api = get().api;
 | 
			
		||||
          if (api) {
 | 
			
		||||
            const files = Object.values(cacheData.data.filesObject || {}) as BinaryFileData[];
 | 
			
		||||
            api.addFiles(files || []);
 | 
			
		||||
            api.updateScene({
 | 
			
		||||
              elements: [...(cacheData.data?.elements || [])],
 | 
			
		||||
              appState: {},
 | 
			
		||||
            });
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
        elements: cacheData.data.elements || [],
 | 
			
		||||
        filesObject: cacheData.data.filesObject || {},
 | 
			
		||||
      };
 | 
			
		||||
    },
 | 
			
		||||
    setInfo: (info: any) => set(() => ({ info })),
 | 
			
		||||
    getList: async () => {
 | 
			
		||||
      const res = await queryMark.getMarkList({ page: 1, pageSize: 10 });
 | 
			
		||||
      console.log(res);
 | 
			
		||||
    },
 | 
			
		||||
    list: [],
 | 
			
		||||
    setList: (list: any[]) => set(() => ({ list })),
 | 
			
		||||
    getMark: async (markId: string) => {
 | 
			
		||||
      set(() => ({ loading: true, id: markId }));
 | 
			
		||||
      const toastId = toast.loading(`获取数据中...`);
 | 
			
		||||
      const now = new Date().getTime();
 | 
			
		||||
      const cacheData = await getCache(markId, cacheStore);
 | 
			
		||||
      const checkVersion = await queryMark.checkVersion(markId, cacheData?.version);
 | 
			
		||||
      if (checkVersion) {
 | 
			
		||||
        const res = await queryMark.getMark(markId);
 | 
			
		||||
        if (res.code === 200) {
 | 
			
		||||
          set(() => ({
 | 
			
		||||
            mark: res.data,
 | 
			
		||||
            id: markId,
 | 
			
		||||
          }));
 | 
			
		||||
          const mark = res.data!;
 | 
			
		||||
          const excalidrawData = mark.data || {};
 | 
			
		||||
          await get().setCache({
 | 
			
		||||
            data: {
 | 
			
		||||
              elements: excalidrawData.elements,
 | 
			
		||||
              filesObject: excalidrawData.filesObject,
 | 
			
		||||
            },
 | 
			
		||||
            version: mark.version,
 | 
			
		||||
          });
 | 
			
		||||
          get().getCache(markId, true);
 | 
			
		||||
        } else {
 | 
			
		||||
          toast.error(res.message || '获取数据失败');
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const end = new Date().getTime();
 | 
			
		||||
      const getTime = end - now;
 | 
			
		||||
      if (getTime < 2 * 1000) {
 | 
			
		||||
        await new Promise((resolve) => setTimeout(resolve, 2 * 1000 - getTime));
 | 
			
		||||
      }
 | 
			
		||||
      toast.dismiss(toastId);
 | 
			
		||||
      set(() => ({ loading: false }));
 | 
			
		||||
    },
 | 
			
		||||
    api: null,
 | 
			
		||||
    setApi: (api: ExcalidrawImperativeAPI) => set(() => ({ api })),
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useMarkStore = useStore as BoundStore<MarkStore>;
 | 
			
		||||
 | 
			
		||||
export const fileDemo = {
 | 
			
		||||
  abc: {
 | 
			
		||||
    dataURL: 'https://kevisual.xiongxiao.me/root/center/panda.png' as any,
 | 
			
		||||
    // @ts-ignore
 | 
			
		||||
    id: 'abc',
 | 
			
		||||
    name: 'test2.png',
 | 
			
		||||
    type: 'image/png',
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
export const demoElements: OrderedExcalidrawElement[] = [
 | 
			
		||||
  {
 | 
			
		||||
    id: '1',
 | 
			
		||||
    type: 'image',
 | 
			
		||||
    x: 100,
 | 
			
		||||
    y: 100,
 | 
			
		||||
    width: 100,
 | 
			
		||||
    height: 100,
 | 
			
		||||
    fileId: 'abc' as any,
 | 
			
		||||
    version: 2,
 | 
			
		||||
    versionNonce: 28180243,
 | 
			
		||||
    index: 'a0' as any,
 | 
			
		||||
    isDeleted: false,
 | 
			
		||||
    fillStyle: 'solid',
 | 
			
		||||
    strokeWidth: 2,
 | 
			
		||||
    strokeStyle: 'solid',
 | 
			
		||||
    roughness: 1,
 | 
			
		||||
    opacity: 100,
 | 
			
		||||
    angle: 0,
 | 
			
		||||
    strokeColor: '#1e1e1e',
 | 
			
		||||
    backgroundColor: 'transparent',
 | 
			
		||||
    seed: 1,
 | 
			
		||||
    groupIds: [],
 | 
			
		||||
    frameId: null,
 | 
			
		||||
    roundness: null,
 | 
			
		||||
    boundElements: [],
 | 
			
		||||
    updated: 1743219351869,
 | 
			
		||||
    link: null,
 | 
			
		||||
    locked: false,
 | 
			
		||||
    status: 'pending',
 | 
			
		||||
    scale: [1, 1],
 | 
			
		||||
    crop: null,
 | 
			
		||||
  },
 | 
			
		||||
];
 | 
			
		||||
							
								
								
									
										6
									
								
								src/apps/draw/vite-env.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								src/apps/draw/vite-env.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,6 @@
 | 
			
		||||
/// <reference types="vite/client" />
 | 
			
		||||
type SimpleObject = {
 | 
			
		||||
  [key: string | number]: any;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
declare let BASE_NAME: string;
 | 
			
		||||
							
								
								
									
										331
									
								
								src/apps/mark/manager/Manager.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										331
									
								
								src/apps/mark/manager/Manager.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,331 @@
 | 
			
		||||
import { useManagerStore } from './store';
 | 
			
		||||
import { useEffect, useMemo, useState } from 'react';
 | 
			
		||||
import { useShallow } from 'zustand/shallow';
 | 
			
		||||
import { ManagerProvider } from './Provider';
 | 
			
		||||
import { ChevronDown, X, Edit, Plus, Search, Trash, Menu as MenuIcon, MenuSquare } from 'lucide-react';
 | 
			
		||||
import dayjs from 'dayjs';
 | 
			
		||||
import { EditMark as EditMarkComponent } from './edit/Edit';
 | 
			
		||||
import { toast } from 'react-toastify';
 | 
			
		||||
import clsx from 'clsx';
 | 
			
		||||
import { Controller, useForm } from 'react-hook-form';
 | 
			
		||||
import { IconButton } from '@/components/a/button';
 | 
			
		||||
import { MarkType } from '@/query/query-mark/query-mark';
 | 
			
		||||
import { Menu } from '@/components/a/menu';
 | 
			
		||||
import { MarkTypes } from './constant';
 | 
			
		||||
type ManagerProps = {
 | 
			
		||||
  showSearch?: boolean;
 | 
			
		||||
  showAdd?: boolean;
 | 
			
		||||
  onClick?: (data?: any, e?: Event) => void;
 | 
			
		||||
  markType?: MarkType;
 | 
			
		||||
  showSelect?: boolean;
 | 
			
		||||
};
 | 
			
		||||
export { useManagerStore };
 | 
			
		||||
export const Manager = (props: ManagerProps) => {
 | 
			
		||||
  const { showSearch = true, showAdd = false, onClick, showSelect = true } = props;
 | 
			
		||||
 | 
			
		||||
  const { control } = useForm({ defaultValues: { search: '' } });
 | 
			
		||||
  const { list, init, setCurrentMarkId, currentMarkId, markData, deleteMark, getMark, setMarkData, pagination, setPagination, getList, search, setSearch } =
 | 
			
		||||
    useManagerStore(
 | 
			
		||||
      useShallow((state) => {
 | 
			
		||||
        return {
 | 
			
		||||
          list: state.list,
 | 
			
		||||
          init: state.init,
 | 
			
		||||
          markData: state.markData,
 | 
			
		||||
          currentMarkId: state.currentMarkId,
 | 
			
		||||
          setCurrentMarkId: state.setCurrentMarkId,
 | 
			
		||||
          deleteMark: state.deleteMark,
 | 
			
		||||
          getMark: state.getMark,
 | 
			
		||||
          setMarkData: state.setMarkData,
 | 
			
		||||
          pagination: state.pagination,
 | 
			
		||||
          setPagination: state.setPagination,
 | 
			
		||||
          search: state.search,
 | 
			
		||||
          setSearch: state.setSearch,
 | 
			
		||||
          getList: state.getList,
 | 
			
		||||
        };
 | 
			
		||||
      }),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
  const handleMenuItemClick = (option: string) => {
 | 
			
		||||
    console.log('option', option);
 | 
			
		||||
    init(option as any);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const url = new URL(window.location.href);
 | 
			
		||||
    let markType = url.searchParams.get('markType') || '';
 | 
			
		||||
    if (!markType && props.markType) {
 | 
			
		||||
      markType = props.markType;
 | 
			
		||||
    }
 | 
			
		||||
    init((markType as any) || 'md');
 | 
			
		||||
  }, []);
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (search) {
 | 
			
		||||
      getList();
 | 
			
		||||
    } else if (pagination.current > 1) {
 | 
			
		||||
      getList();
 | 
			
		||||
    }
 | 
			
		||||
  }, [pagination.current, search]);
 | 
			
		||||
  const onEditMark = async (markId: string) => {
 | 
			
		||||
    setCurrentMarkId(markId);
 | 
			
		||||
    const res = await getMark(markId);
 | 
			
		||||
    console.log('mark', res);
 | 
			
		||||
    if (res.code === 200) {
 | 
			
		||||
      setMarkData(res.data!);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
  const onDeleteMark = async (markId: string) => {
 | 
			
		||||
    const res = await deleteMark(markId);
 | 
			
		||||
    if (res.code === 200) {
 | 
			
		||||
      toast.success('删除成功');
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className='w-full h-full p-4 bg-white border-r border-r-gray-200  relative'>
 | 
			
		||||
      <div className='flex  px-4 mb-4 justify-between items-center absolute top-0 left-0 h-[56px] w-full'>
 | 
			
		||||
        <div className='flex ml-12 items-center space-x-2'>
 | 
			
		||||
          <Controller
 | 
			
		||||
            name='search'
 | 
			
		||||
            control={control}
 | 
			
		||||
            render={({ field }) => (
 | 
			
		||||
              <div className={`relative ${showSearch ? 'block' : 'hidden'}`}>
 | 
			
		||||
                <input
 | 
			
		||||
                  {...field}
 | 
			
		||||
                  type='text'
 | 
			
		||||
                  className='py-2 px-3 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500'
 | 
			
		||||
                  onKeyDown={(event) => {
 | 
			
		||||
                    if (event.key === 'Enter') {
 | 
			
		||||
                      setSearch(field.value);
 | 
			
		||||
                      if (!field.value) {
 | 
			
		||||
                        getList();
 | 
			
		||||
                      }
 | 
			
		||||
                    }
 | 
			
		||||
                  }}
 | 
			
		||||
                />
 | 
			
		||||
                <div className='absolute inset-y-0 right-0 flex items-center pr-3 cursor-pointer'>
 | 
			
		||||
                  <Search className='w-4 h-4' onClick={() => setSearch(field.value)} />
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
            )}
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
        <div className={'flex items-center space-x-2'}>
 | 
			
		||||
          {showSelect && (
 | 
			
		||||
            <>
 | 
			
		||||
              <Menu
 | 
			
		||||
                options={MarkTypes.map((item) => {
 | 
			
		||||
                  return { label: item, value: item };
 | 
			
		||||
                })}
 | 
			
		||||
                onSelect={handleMenuItemClick}>
 | 
			
		||||
                <MenuIcon className='w-4 h-4' />
 | 
			
		||||
              </Menu>
 | 
			
		||||
            </>
 | 
			
		||||
          )}
 | 
			
		||||
          <button
 | 
			
		||||
            className={clsx(
 | 
			
		||||
              'text-blue-500 cursor-pointer hover:underline flex items-center p-2 rounded-md hover:bg-blue-100 transition duration-200',
 | 
			
		||||
              showAdd ? '' : 'hidden',
 | 
			
		||||
            )}>
 | 
			
		||||
            <Plus
 | 
			
		||||
              className={clsx('w-4 h-4 ')}
 | 
			
		||||
              onClick={() => {
 | 
			
		||||
                setCurrentMarkId('');
 | 
			
		||||
 | 
			
		||||
                setMarkData({
 | 
			
		||||
                  id: '',
 | 
			
		||||
                  title: '',
 | 
			
		||||
                  description: '',
 | 
			
		||||
                  markType: props.markType || ('md' as any),
 | 
			
		||||
                  summary: '',
 | 
			
		||||
                  tags: [],
 | 
			
		||||
                  link: '',
 | 
			
		||||
                });
 | 
			
		||||
              }}
 | 
			
		||||
            />
 | 
			
		||||
          </button>
 | 
			
		||||
          {markData && (
 | 
			
		||||
            <button
 | 
			
		||||
              className='text-blue-500 cursor-pointer hover:underline flex items-center p-2 rounded-md hover:bg-blue-100 transition duration-200'
 | 
			
		||||
              onClick={() => {
 | 
			
		||||
                setCurrentMarkId('');
 | 
			
		||||
                setMarkData(undefined);
 | 
			
		||||
              }}>
 | 
			
		||||
              <X className='w-4 h-4 ' />
 | 
			
		||||
            </button>
 | 
			
		||||
          )}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div className='mt-[56px] overflow-auto scrollbar' style={{ height: 'calc(100% - 56px)' }}>
 | 
			
		||||
        {list.map((item, index) => {
 | 
			
		||||
          const isCurrent = item.id === currentMarkId;
 | 
			
		||||
          return (
 | 
			
		||||
            <div
 | 
			
		||||
              key={item.id}
 | 
			
		||||
              className={`border rounded-lg p-4 mb-4 shadow-md bg-white border-gray-200 ${isCurrent ? 'border-blue-500' : ''}`}
 | 
			
		||||
              onClick={(e) => {
 | 
			
		||||
                onClick?.(item, e as any);
 | 
			
		||||
                e.stopPropagation();
 | 
			
		||||
                e.preventDefault();
 | 
			
		||||
              }}>
 | 
			
		||||
              <div className='flex justify-between items-center'>
 | 
			
		||||
                <div className={`text-lg font-bold truncate cursor-pointer ${isCurrent ? 'text-blue-500' : ''}`}>{item.title}</div>
 | 
			
		||||
                <div className='flex space-x-2'>
 | 
			
		||||
                  <button
 | 
			
		||||
                    className='text-blue-500 cursor-pointer hover:underline flex items-center p-2 rounded-md hover:bg-blue-100 transition duration-200'
 | 
			
		||||
                    onClick={(e) => {
 | 
			
		||||
                      e.stopPropagation();
 | 
			
		||||
                      onEditMark(item.id);
 | 
			
		||||
                    }}>
 | 
			
		||||
                    <Edit className='w-4 h-4 ' />
 | 
			
		||||
                  </button>
 | 
			
		||||
                  <button
 | 
			
		||||
                    className='text-red-500 cursor-pointer hover:underline flex items-center p-2 rounded-md hover:bg-red-100 transition duration-200'
 | 
			
		||||
                    onClick={(e) => {
 | 
			
		||||
                      e.stopPropagation();
 | 
			
		||||
                      onDeleteMark(item.id);
 | 
			
		||||
                    }}>
 | 
			
		||||
                    <Trash className='w-4 h-4 ' />
 | 
			
		||||
                  </button>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
              <div className='text-sm text-gray-600'>类型: {item.markType}</div>
 | 
			
		||||
              <div className='text-sm text-gray-600'>概要: {item.summary}</div>
 | 
			
		||||
              <div className='text-sm text-gray-600'>标签: {item.tags?.join?.(', ')}</div>
 | 
			
		||||
              {/* <div className='text-sm text-gray-600 hidden sm:block'>描述: {item.description}</div> */}
 | 
			
		||||
              <div
 | 
			
		||||
                className='text-sm text-gray-600 hidden sm:block truncate'
 | 
			
		||||
                onClick={() => {
 | 
			
		||||
                  window.open(item.link, '_blank');
 | 
			
		||||
                }}>
 | 
			
		||||
                链接: {item.link}
 | 
			
		||||
              </div>
 | 
			
		||||
              <div className='text-sm text-gray-600 hidden sm:block'>创建时间: {dayjs(item.createdAt).format('YYYY-MM-DD HH:mm:ss')}</div>
 | 
			
		||||
              <div className='text-sm text-gray-600 hidden sm:block'>更新时间: {dayjs(item.updatedAt).format('YYYY-MM-DD HH:mm:ss')}</div>
 | 
			
		||||
            </div>
 | 
			
		||||
          );
 | 
			
		||||
        })}
 | 
			
		||||
        <div className='flex justify-center items-center'>
 | 
			
		||||
          {list.length < pagination.total && (
 | 
			
		||||
            <button
 | 
			
		||||
              className='text-blue-500 cursor-pointer hover:underline flex items-center p-2 rounded-md hover:bg-blue-100 transition duration-200'
 | 
			
		||||
              onClick={() => {
 | 
			
		||||
                setPagination({ ...pagination, current: pagination.current + 1 });
 | 
			
		||||
              }}>
 | 
			
		||||
              <ChevronDown className='w-4 h-4 ' />
 | 
			
		||||
            </button>
 | 
			
		||||
          )}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const EditMark = () => {
 | 
			
		||||
  const { markData } = useManagerStore(
 | 
			
		||||
    useShallow((state) => {
 | 
			
		||||
      return {
 | 
			
		||||
        markData: state.markData,
 | 
			
		||||
      };
 | 
			
		||||
    }),
 | 
			
		||||
  );
 | 
			
		||||
  const mark = markData;
 | 
			
		||||
  if (!mark) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
  if (mark) {
 | 
			
		||||
    return <EditMarkComponent />;
 | 
			
		||||
  }
 | 
			
		||||
  return <div className='w-full h-full'></div>;
 | 
			
		||||
};
 | 
			
		||||
export const LayoutMain = (props: { children?: React.ReactNode; expandChildren?: React.ReactNode; open?: boolean }) => {
 | 
			
		||||
  const getDocumentHeight = () => {
 | 
			
		||||
    return document.documentElement.scrollHeight;
 | 
			
		||||
  };
 | 
			
		||||
  const mStore = useManagerStore(
 | 
			
		||||
    useShallow((state) => {
 | 
			
		||||
      return {
 | 
			
		||||
        open: state.open,
 | 
			
		||||
        setOpen: state.setOpen,
 | 
			
		||||
        markData: state.markData,
 | 
			
		||||
      };
 | 
			
		||||
    }),
 | 
			
		||||
  );
 | 
			
		||||
  const markData = mStore.markData;
 | 
			
		||||
  const openMenu = mStore.open;
 | 
			
		||||
  const setOpenMenu = mStore.setOpen;
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (props.open !== undefined) {
 | 
			
		||||
      setOpenMenu!(props.open);
 | 
			
		||||
    }
 | 
			
		||||
  }, []);
 | 
			
		||||
  const isEdit = !!markData;
 | 
			
		||||
  const hasExpandChildren = !!props.expandChildren;
 | 
			
		||||
  const style = useMemo(() => {
 | 
			
		||||
    if (!hasExpandChildren || openMenu) {
 | 
			
		||||
      return {};
 | 
			
		||||
    }
 | 
			
		||||
    return {
 | 
			
		||||
      top: getDocumentHeight() / 2 + 10,
 | 
			
		||||
    };
 | 
			
		||||
  }, [getDocumentHeight, hasExpandChildren, openMenu]);
 | 
			
		||||
  return (
 | 
			
		||||
    <div className='w-full h-full flex'>
 | 
			
		||||
      <div className={clsx('absolute top-4 z-10', openMenu ? 'left-4' : '-left-4')} style={style}>
 | 
			
		||||
        <IconButton
 | 
			
		||||
          color={openMenu ? 'info' : 'primary'}
 | 
			
		||||
          onClick={() => {
 | 
			
		||||
            setOpenMenu(!openMenu);
 | 
			
		||||
          }}>
 | 
			
		||||
          <MenuSquare className='w-4 h-4' />
 | 
			
		||||
        </IconButton>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div className={clsx('h-full w-full sm:w-1/3', openMenu ? '' : 'hidden')}>{props.children}</div>
 | 
			
		||||
      {(!props.expandChildren || isEdit) && (
 | 
			
		||||
        <div className={clsx('h-full hidden sm:block sm:w-2/3', openMenu ? '' : 'hidden')}>
 | 
			
		||||
          <EditMark />
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
      {props.expandChildren && <div className='h-full grow'>{props.expandChildren}</div>}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
export type AppProps = {
 | 
			
		||||
  /**
 | 
			
		||||
   * 标记类型, wallnote md excalidraw
 | 
			
		||||
   */
 | 
			
		||||
  markType?: MarkType;
 | 
			
		||||
  /**
 | 
			
		||||
   * 是否显示搜索框
 | 
			
		||||
   */
 | 
			
		||||
  showSearch?: boolean;
 | 
			
		||||
  /**
 | 
			
		||||
   * 是否显示添加按钮
 | 
			
		||||
   */
 | 
			
		||||
  showAdd?: boolean;
 | 
			
		||||
  /**
 | 
			
		||||
   * 点击事件
 | 
			
		||||
   */
 | 
			
		||||
  onClick?: (data?: any) => void;
 | 
			
		||||
  /**
 | 
			
		||||
   * 管理器id, 存储到store的id
 | 
			
		||||
   */
 | 
			
		||||
  managerId?: string;
 | 
			
		||||
  children?: React.ReactNode;
 | 
			
		||||
  showSelect?: boolean;
 | 
			
		||||
  openMenu?: boolean;
 | 
			
		||||
};
 | 
			
		||||
export const ProviderManagerName = 'mark-manager';
 | 
			
		||||
export const App = (props: AppProps) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <ManagerProvider id={props.managerId}>
 | 
			
		||||
      <LayoutMain expandChildren={props.children} open={props.openMenu}>
 | 
			
		||||
        <Manager
 | 
			
		||||
          markType={props.markType}
 | 
			
		||||
          showSearch={props.showSearch}
 | 
			
		||||
          showAdd={props.showAdd}
 | 
			
		||||
          onClick={props.onClick}
 | 
			
		||||
          showSelect={props.showSelect}></Manager>
 | 
			
		||||
      </LayoutMain>
 | 
			
		||||
    </ManagerProvider>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										9
									
								
								src/apps/mark/manager/Provider.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/apps/mark/manager/Provider.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
			
		||||
import { StoreContextProvider } from '@kevisual/store/react';
 | 
			
		||||
import { createManagerStore } from './store/index';
 | 
			
		||||
export const ManagerProvider = ({ children, id }: { children: React.ReactNode; id?: string }) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <StoreContextProvider id={id || 'mark-manager'} stateCreator={createManagerStore}>
 | 
			
		||||
      {children}
 | 
			
		||||
    </StoreContextProvider>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										27
									
								
								src/apps/mark/manager/components/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/apps/mark/manager/components/index.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,27 @@
 | 
			
		||||
import { Button as aButton } from '@/components/a/button';
 | 
			
		||||
import { Input } from '@/components/a/input';
 | 
			
		||||
export const Button = aButton;
 | 
			
		||||
 | 
			
		||||
export const TextField = Input;
 | 
			
		||||
 | 
			
		||||
export const IconButton = aButton;
 | 
			
		||||
 | 
			
		||||
export const Menu = () => {
 | 
			
		||||
  return <>dev</>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const InputAdornment = () => {
 | 
			
		||||
  return <>dev</>;
 | 
			
		||||
};
 | 
			
		||||
export const MenuItem = () => {
 | 
			
		||||
  return <>dev</>;
 | 
			
		||||
};
 | 
			
		||||
export const Autocomplete = () => {
 | 
			
		||||
  return <>dev</>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const Box = () => {
 | 
			
		||||
  return <>dev</>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										1
									
								
								src/apps/mark/manager/constant.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/apps/mark/manager/constant.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
export const MarkTypes = ['md', 'wallnote', 'excalidraw', 'chat'];
 | 
			
		||||
							
								
								
									
										156
									
								
								src/apps/mark/manager/edit/Edit.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										156
									
								
								src/apps/mark/manager/edit/Edit.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,156 @@
 | 
			
		||||
import { useForm, Controller } from 'react-hook-form';
 | 
			
		||||
import { TextField } from '../components';
 | 
			
		||||
import { Button } from '@/components/a/button';
 | 
			
		||||
import { AutoComplate } from '@/components/a/auto-complate';
 | 
			
		||||
import { TagsInput } from '@/components/a/input';
 | 
			
		||||
import { useManagerStore } from '../store';
 | 
			
		||||
import { useShallow } from 'zustand/shallow';
 | 
			
		||||
import { useEffect } from 'react';
 | 
			
		||||
import { useTranslation } from 'react-i18next';
 | 
			
		||||
import { pick } from 'lodash-es';
 | 
			
		||||
import { toast } from 'react-toastify';
 | 
			
		||||
import { Select } from '@/components/a/select';
 | 
			
		||||
import { MarkTypes } from '../constant';
 | 
			
		||||
 | 
			
		||||
export const EditMark = () => {
 | 
			
		||||
  const { control, handleSubmit, reset } = useForm();
 | 
			
		||||
  const { updateMark, markData, setCurrentMarkId, setMarkData } = useManagerStore(
 | 
			
		||||
    useShallow((state) => {
 | 
			
		||||
      return {
 | 
			
		||||
        updateMark: state.updateMark,
 | 
			
		||||
        markData: state.markData,
 | 
			
		||||
        setCurrentMarkId: state.setCurrentMarkId,
 | 
			
		||||
        currentMarkId: state.currentMarkId,
 | 
			
		||||
        setMarkData: state.setMarkData,
 | 
			
		||||
      };
 | 
			
		||||
    }),
 | 
			
		||||
  );
 | 
			
		||||
  // const [mark, setMark] = useState<Mark | undefined>(markData);
 | 
			
		||||
  const mark = pick(markData, ['id', 'title', 'description', 'markType', 'summary', 'tags', 'link', 'thumbnail']);
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    reset(mark);
 | 
			
		||||
    console.log('markData', markData);
 | 
			
		||||
  }, [markData?.id]);
 | 
			
		||||
  const onSubmit = async (data: any) => {
 | 
			
		||||
    const res = await updateMark({ ...mark, ...data });
 | 
			
		||||
    if (res.code === 200) {
 | 
			
		||||
      toast.success('编辑成功');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // setCurrentMarkId('');
 | 
			
		||||
    // setMarkData(undefined);
 | 
			
		||||
  };
 | 
			
		||||
  return (
 | 
			
		||||
    <form onSubmit={handleSubmit(onSubmit)} noValidate autoComplete='off' className='w-full h-full overflow-auto px-2 py-1'>
 | 
			
		||||
      <Controller
 | 
			
		||||
        name='title'
 | 
			
		||||
        control={control}
 | 
			
		||||
        defaultValue={mark?.title || ''}
 | 
			
		||||
        render={({ field }) => (
 | 
			
		||||
          <div className='mb-4'>
 | 
			
		||||
            <label className='block text-sm font-medium mb-1'>标题</label>
 | 
			
		||||
            <input {...field} className='w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500' type='text' />
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
      />
 | 
			
		||||
      <Controller
 | 
			
		||||
        name='description'
 | 
			
		||||
        control={control}
 | 
			
		||||
        defaultValue={mark?.description || ''}
 | 
			
		||||
        render={({ field }) => (
 | 
			
		||||
          <div className='mb-4'>
 | 
			
		||||
            <label className='block text-sm font-medium mb-1'>描述</label>
 | 
			
		||||
            <textarea {...field} className='w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500' rows={3} />
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
      />
 | 
			
		||||
      <Controller
 | 
			
		||||
        name='markType'
 | 
			
		||||
        control={control}
 | 
			
		||||
        defaultValue={mark?.markType || ''}
 | 
			
		||||
        render={({ field }) => {
 | 
			
		||||
          return (
 | 
			
		||||
            <div className='mb-4'>
 | 
			
		||||
              <label className='block text-sm font-medium mb-1'>类型</label>
 | 
			
		||||
              <AutoComplate
 | 
			
		||||
                {...field}
 | 
			
		||||
                options={MarkTypes.map((item) => {
 | 
			
		||||
                  return { label: item, value: item };
 | 
			
		||||
                })}
 | 
			
		||||
                onChange={(value) => field.onChange(value)}
 | 
			
		||||
              />
 | 
			
		||||
            </div>
 | 
			
		||||
          );
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
      <Controller
 | 
			
		||||
        name='summary'
 | 
			
		||||
        control={control}
 | 
			
		||||
        defaultValue={mark?.summary || ''}
 | 
			
		||||
        render={({ field }) => (
 | 
			
		||||
          <div className='mb-4'>
 | 
			
		||||
            <label className='block text-sm font-medium mb-1'>概要</label>
 | 
			
		||||
            <textarea {...field} className='w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500' rows={2} />
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
      />
 | 
			
		||||
      <Controller
 | 
			
		||||
        name='tags'
 | 
			
		||||
        control={control}
 | 
			
		||||
        defaultValue={mark?.tags || ''}
 | 
			
		||||
        render={({ field }) => {
 | 
			
		||||
          const label = '标签';
 | 
			
		||||
          return (
 | 
			
		||||
            <div className='mb-4'>
 | 
			
		||||
              <label className='block text-sm font-medium mb-1'>标签</label>
 | 
			
		||||
              <TagsInput
 | 
			
		||||
                {...field}
 | 
			
		||||
                options={field.value?.map((tag: string) => ({ label: tag, value: tag })) || []}
 | 
			
		||||
                placeholder={label}
 | 
			
		||||
                onChange={(value) => {
 | 
			
		||||
                  field.onChange(value);
 | 
			
		||||
                  console.log('tags', value);
 | 
			
		||||
                }}
 | 
			
		||||
              />
 | 
			
		||||
            </div>
 | 
			
		||||
          );
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
      <Controller
 | 
			
		||||
        name='link'
 | 
			
		||||
        control={control}
 | 
			
		||||
        defaultValue={mark?.link || ''}
 | 
			
		||||
        render={({ field }) => (
 | 
			
		||||
          <div className='mb-4'>
 | 
			
		||||
            <label className='block text-sm font-medium mb-1'>链接</label>
 | 
			
		||||
            <input {...field} className='w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500' type='text' />
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
      />
 | 
			
		||||
      <Controller
 | 
			
		||||
        name='thumbnail'
 | 
			
		||||
        control={control}
 | 
			
		||||
        defaultValue={mark?.thumbnail || ''}
 | 
			
		||||
        render={({ field }) => (
 | 
			
		||||
          <div className='mb-4'>
 | 
			
		||||
            <label className='block text-sm font-medium mb-1'>缩略图</label>
 | 
			
		||||
            <input {...field} className='w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500' type='text' />
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
      />
 | 
			
		||||
      <div className='flex gap-2'>
 | 
			
		||||
        <Button type='submit' color='primary'>
 | 
			
		||||
          保存
 | 
			
		||||
        </Button>
 | 
			
		||||
        <Button
 | 
			
		||||
          color='secondary'
 | 
			
		||||
          onClick={() => {
 | 
			
		||||
            setCurrentMarkId('');
 | 
			
		||||
            setMarkData(undefined);
 | 
			
		||||
          }}>
 | 
			
		||||
          取消
 | 
			
		||||
        </Button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </form>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										137
									
								
								src/apps/mark/manager/store/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								src/apps/mark/manager/store/index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,137 @@
 | 
			
		||||
import { StateCreator, StoreManager } from '@kevisual/store';
 | 
			
		||||
import { useContextKey } from '@kevisual/store/context';
 | 
			
		||||
// import { StateCreator, StoreApi, UseBoundStore } from 'zustand';
 | 
			
		||||
import { query as queryClient } from '@/modules/query';
 | 
			
		||||
import { Result } from '@kevisual/query/query';
 | 
			
		||||
import { QueryMark, Mark, MarkType } from '@/query/query-mark/query-mark';
 | 
			
		||||
import { useStore, BoundStore } from '@kevisual/store/react';
 | 
			
		||||
import { uniqBy } from 'lodash-es';
 | 
			
		||||
export const store = useContextKey('store', () => {
 | 
			
		||||
  return new StoreManager();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
type ManagerStore = {
 | 
			
		||||
  /** 当前选中的Mark */
 | 
			
		||||
  currentMarkId: string;
 | 
			
		||||
  setCurrentMarkId: (markId: string) => void;
 | 
			
		||||
  markData: Mark | undefined;
 | 
			
		||||
  setMarkData: (mark?: Partial<Mark>) => void;
 | 
			
		||||
  /** 获取Mark列表 */
 | 
			
		||||
  getList: () => Promise<any>;
 | 
			
		||||
  getMarkFromList: (markId: string) => Mark | undefined;
 | 
			
		||||
  updateMark: (mark: Mark) => Promise<any>;
 | 
			
		||||
  getMark: (markId: string) => Promise<Result<Mark>>;
 | 
			
		||||
  deleteMark: (markId: string) => Promise<any>;
 | 
			
		||||
  /** Mark列表 */
 | 
			
		||||
  list: Mark[];
 | 
			
		||||
  setList: (list: Mark[]) => void;
 | 
			
		||||
  pagination: {
 | 
			
		||||
    current: number;
 | 
			
		||||
    pageSize: number;
 | 
			
		||||
    total: number;
 | 
			
		||||
  };
 | 
			
		||||
  setPagination: (pagination: { current: number; pageSize: number; total: number }) => void;
 | 
			
		||||
  /** 搜索 */
 | 
			
		||||
  search: string;
 | 
			
		||||
  setSearch: (search: string) => void;
 | 
			
		||||
  /** 初始化 */
 | 
			
		||||
  init: (markType: MarkType) => Promise<void>;
 | 
			
		||||
  queryMark: QueryMark;
 | 
			
		||||
  markType: MarkType;
 | 
			
		||||
  open: boolean;
 | 
			
		||||
  setOpen: (open?: boolean) => void;
 | 
			
		||||
};
 | 
			
		||||
export const createManagerStore: StateCreator<ManagerStore, [], [], any> = (set, get, store) => {
 | 
			
		||||
  return {
 | 
			
		||||
    currentMarkId: '',
 | 
			
		||||
    setCurrentMarkId: (markId: string) => set(() => ({ currentMarkId: markId })),
 | 
			
		||||
    open: false,
 | 
			
		||||
    setOpen: (open: boolean) => set(() => ({ open })),
 | 
			
		||||
    getList: async () => {
 | 
			
		||||
      const queryMark = get().queryMark;
 | 
			
		||||
      const { search, pagination } = get();
 | 
			
		||||
      const res = await queryMark.getMarkList({ page: pagination.current, pageSize: pagination.pageSize, search });
 | 
			
		||||
      const oldList = get().list;
 | 
			
		||||
      if (res.code === 200) {
 | 
			
		||||
        const { pagination, list } = res.data || {};
 | 
			
		||||
        const newList = [...oldList, ...list!];
 | 
			
		||||
        const uniqueList = uniqBy(newList, 'id');
 | 
			
		||||
        set(() => ({ list: uniqueList }));
 | 
			
		||||
        set(() => ({ pagination: { current: pagination!.current, pageSize: pagination!.pageSize, total: pagination!.total } }));
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    getMarkFromList: (markId: string) => {
 | 
			
		||||
      return get().list.find((item) => item.id === markId);
 | 
			
		||||
    },
 | 
			
		||||
    updateMark: async (mark: Mark) => {
 | 
			
		||||
      const queryMark = get().queryMark;
 | 
			
		||||
      const res = await queryMark.updateMark(mark);
 | 
			
		||||
      if (res.code === 200) {
 | 
			
		||||
        set((state) => {
 | 
			
		||||
          const oldList = state.list;
 | 
			
		||||
          const resMark = res.data!;
 | 
			
		||||
          const newList = oldList.map((item) => (item.id === mark.id ? mark : item));
 | 
			
		||||
          if (!mark.id) {
 | 
			
		||||
            newList.unshift(resMark);
 | 
			
		||||
          }
 | 
			
		||||
          return {
 | 
			
		||||
            list: newList,
 | 
			
		||||
          };
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
      return res;
 | 
			
		||||
    },
 | 
			
		||||
    getMark: async (markId: string) => {
 | 
			
		||||
      const queryMark = get().queryMark;
 | 
			
		||||
      const res = await queryMark.getMark(markId);
 | 
			
		||||
      return res;
 | 
			
		||||
    },
 | 
			
		||||
    list: [],
 | 
			
		||||
    setList: (list: any[]) => set(() => ({ list })),
 | 
			
		||||
    init: async (markType: MarkType = 'wallnote') => {
 | 
			
		||||
      // await get().getList();
 | 
			
		||||
      console.log('init', set, get);
 | 
			
		||||
      const queryMark = new QueryMark({
 | 
			
		||||
        query: queryClient as any,
 | 
			
		||||
        markType,
 | 
			
		||||
      });
 | 
			
		||||
      const url = new URL(window.location.href);
 | 
			
		||||
      const pageSize = url.searchParams.get('pageSize') || '10';
 | 
			
		||||
      set({ queryMark, markType, list: [], pagination: { current: 1, pageSize: parseInt(pageSize), total: 0 }, currentMarkId: '', markData: undefined });
 | 
			
		||||
      setTimeout(async () => {
 | 
			
		||||
        console.log('get', get);
 | 
			
		||||
        get().getList();
 | 
			
		||||
      }, 1000);
 | 
			
		||||
    },
 | 
			
		||||
    deleteMark: async (markId: string) => {
 | 
			
		||||
      const queryMark = get().queryMark;
 | 
			
		||||
      const res = await queryMark.deleteMark(markId);
 | 
			
		||||
      const currentMarkId = get().currentMarkId;
 | 
			
		||||
      if (res.code === 200) {
 | 
			
		||||
        // get().getList();
 | 
			
		||||
        set((state) => ({
 | 
			
		||||
          list: state.list.filter((item) => item.id !== markId),
 | 
			
		||||
        }));
 | 
			
		||||
        if (currentMarkId === markId) {
 | 
			
		||||
          set(() => ({ currentMarkId: '', markData: undefined }));
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      return res;
 | 
			
		||||
    },
 | 
			
		||||
    queryMark: undefined,
 | 
			
		||||
    markType: 'simple',
 | 
			
		||||
    markData: undefined,
 | 
			
		||||
    setMarkData: (mark: Mark) => set(() => ({ markData: mark })),
 | 
			
		||||
    pagination: {
 | 
			
		||||
      current: 1,
 | 
			
		||||
      pageSize: 10,
 | 
			
		||||
      total: 0,
 | 
			
		||||
    },
 | 
			
		||||
    setPagination: (pagination: { current: number; pageSize: number; total: number }) => set(() => ({ pagination })),
 | 
			
		||||
    /** 搜索 */
 | 
			
		||||
    search: '',
 | 
			
		||||
    setSearch: (search: string) => set(() => ({ search, list: [], pagination: { current: 1, pageSize: 10, total: 0 } })),
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useManagerStore = useStore as BoundStore<ManagerStore>;
 | 
			
		||||
@@ -1,6 +1,92 @@
 | 
			
		||||
import { Input as UIInput } from '@/components/ui/input';
 | 
			
		||||
import React, { useState, useRef, useEffect } from 'react';
 | 
			
		||||
 | 
			
		||||
export type InputProps = { label?: string } & React.ComponentProps<'input'>;
 | 
			
		||||
export const Input = (props: InputProps) => {
 | 
			
		||||
  return <UIInput {...props} />;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type TagsInputProps = {
 | 
			
		||||
  value: string[];
 | 
			
		||||
  onChange: (value: string[]) => void;
 | 
			
		||||
  placeholder?: string;
 | 
			
		||||
  label?: React.ReactNode;
 | 
			
		||||
  showLabel?: boolean;
 | 
			
		||||
  options?: string[]; // 可选,暂未实现自动补全
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const TagsInput = ({
 | 
			
		||||
  value,
 | 
			
		||||
  onChange,
 | 
			
		||||
  placeholder = '',
 | 
			
		||||
  label = '',
 | 
			
		||||
  showLabel = false,
 | 
			
		||||
}: TagsInputProps) => {
 | 
			
		||||
  const [input, setInput] = useState('');
 | 
			
		||||
  const inputRef = useRef<HTMLInputElement>(null);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    setInput('');
 | 
			
		||||
  }, [value]);
 | 
			
		||||
 | 
			
		||||
  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
 | 
			
		||||
    setInput(e.target.value);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
 | 
			
		||||
    if (
 | 
			
		||||
      (e.key === 'Enter' || e.key === ',' || e.key === 'Tab') &&
 | 
			
		||||
      input.trim()
 | 
			
		||||
    ) {
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      const newTag = input.trim();
 | 
			
		||||
      if (!value.includes(newTag)) {
 | 
			
		||||
        onChange([...value, newTag]);
 | 
			
		||||
      }
 | 
			
		||||
      setInput('');
 | 
			
		||||
    } else if (e.key === 'Backspace' && !input && value.length) {
 | 
			
		||||
      onChange(value.slice(0, -1));
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleRemoveTag = (idx: number) => {
 | 
			
		||||
    onChange(value.filter((_, i) => i !== idx));
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div>
 | 
			
		||||
      {showLabel && label && (
 | 
			
		||||
        <label className="block mb-1 text-sm font-medium text-gray-700">{label}</label>
 | 
			
		||||
      )}
 | 
			
		||||
      <div
 | 
			
		||||
        className="flex flex-wrap items-center gap-2 border rounded px-2 py-1 min-h-[40px] focus-within:ring-2 focus-within:ring-blue-500 bg-white"
 | 
			
		||||
        onClick={() => inputRef.current?.focus()}
 | 
			
		||||
      >
 | 
			
		||||
        {value.map((tag, idx) => (
 | 
			
		||||
          <span
 | 
			
		||||
            key={tag + idx}
 | 
			
		||||
            className="flex items-center bg-blue-100 text-blue-800 rounded px-2 py-0.5 text-sm mr-1 mb-1"
 | 
			
		||||
          >
 | 
			
		||||
            {tag}
 | 
			
		||||
            <button
 | 
			
		||||
              type="button"
 | 
			
		||||
              className="ml-1 text-blue-500 hover:text-blue-700 focus:outline-none"
 | 
			
		||||
              onClick={() => handleRemoveTag(idx)}
 | 
			
		||||
              aria-label="Remove tag"
 | 
			
		||||
            >
 | 
			
		||||
              ×
 | 
			
		||||
            </button>
 | 
			
		||||
          </span>
 | 
			
		||||
        ))}
 | 
			
		||||
        <input
 | 
			
		||||
          ref={inputRef}
 | 
			
		||||
          className="flex-1 min-w-[80px] border-none outline-none bg-transparent py-1 text-sm"
 | 
			
		||||
          value={input}
 | 
			
		||||
          onChange={handleInputChange}
 | 
			
		||||
          onKeyDown={handleInputKeyDown}
 | 
			
		||||
          placeholder={placeholder}
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										39
									
								
								src/components/a/menu.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								src/components/a/menu.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,39 @@
 | 
			
		||||
import {
 | 
			
		||||
  DropdownMenu,
 | 
			
		||||
  DropdownMenuTrigger,
 | 
			
		||||
  DropdownMenuContent,
 | 
			
		||||
  DropdownMenuLabel,
 | 
			
		||||
  DropdownMenuSeparator,
 | 
			
		||||
  DropdownMenuItem,
 | 
			
		||||
} from '@/components/ui/dropdown-menu';
 | 
			
		||||
import { useEffect, useState } from 'react';
 | 
			
		||||
type Props = {
 | 
			
		||||
  children?: React.ReactNode;
 | 
			
		||||
  className?: string;
 | 
			
		||||
  options?: { label: string; value: string }[];
 | 
			
		||||
  onSelect?: (value: string) => void;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const Menu = (props: Props) => {
 | 
			
		||||
  const [open, setOpen] = useState(false);
 | 
			
		||||
  const [selectedValue, setSelectedValue] = useState('');
 | 
			
		||||
 | 
			
		||||
  const handleSelect = (value: string) => {
 | 
			
		||||
    setSelectedValue(value);
 | 
			
		||||
    props.onSelect?.(value);
 | 
			
		||||
    setOpen(false);
 | 
			
		||||
  };
 | 
			
		||||
  const showSelectedValue = selectedValue || 'Select an option';
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenu open={open} onOpenChange={setOpen}>
 | 
			
		||||
      <DropdownMenuTrigger className={props.className}>{props.children ? props.children : showSelectedValue}</DropdownMenuTrigger>
 | 
			
		||||
      <DropdownMenuContent>
 | 
			
		||||
        {props.options?.map((option) => (
 | 
			
		||||
          <DropdownMenuItem key={option.value} onSelect={() => handleSelect(option.value)}>
 | 
			
		||||
            {option.label}
 | 
			
		||||
          </DropdownMenuItem>
 | 
			
		||||
        ))}
 | 
			
		||||
      </DropdownMenuContent>
 | 
			
		||||
    </DropdownMenu>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
@@ -1,12 +1,12 @@
 | 
			
		||||
import { Tooltip as UITooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
 | 
			
		||||
import React from 'react';
 | 
			
		||||
 | 
			
		||||
export const Tooltip = (props: { children?: React.ReactNode; title?: React.ReactNode }) => {
 | 
			
		||||
export const Tooltip = (props: { children?: React.ReactNode; title?: React.ReactNode; placement?: 'top' | 'bottom' | 'left' | 'right' }) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <TooltipProvider>
 | 
			
		||||
      <UITooltip>
 | 
			
		||||
        <TooltipTrigger asChild>{props.children}</TooltipTrigger>
 | 
			
		||||
        <TooltipContent>
 | 
			
		||||
        <TooltipContent className='bg-gray-800 text-white' side={props.placement || 'top'}>
 | 
			
		||||
          <p>{props.title}</p>
 | 
			
		||||
        </TooltipContent>
 | 
			
		||||
      </UITooltip>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										255
									
								
								src/components/ui/dropdown-menu.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										255
									
								
								src/components/ui/dropdown-menu.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,255 @@
 | 
			
		||||
import * as React from "react"
 | 
			
		||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
 | 
			
		||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils"
 | 
			
		||||
 | 
			
		||||
function DropdownMenu({
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
 | 
			
		||||
  return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuPortal({
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuTrigger({
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.Trigger
 | 
			
		||||
      data-slot="dropdown-menu-trigger"
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuContent({
 | 
			
		||||
  className,
 | 
			
		||||
  sideOffset = 4,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.Portal>
 | 
			
		||||
      <DropdownMenuPrimitive.Content
 | 
			
		||||
        data-slot="dropdown-menu-content"
 | 
			
		||||
        sideOffset={sideOffset}
 | 
			
		||||
        className={cn(
 | 
			
		||||
          "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
 | 
			
		||||
          className
 | 
			
		||||
        )}
 | 
			
		||||
        {...props}
 | 
			
		||||
      />
 | 
			
		||||
    </DropdownMenuPrimitive.Portal>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuGroup({
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuItem({
 | 
			
		||||
  className,
 | 
			
		||||
  inset,
 | 
			
		||||
  variant = "default",
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
 | 
			
		||||
  inset?: boolean
 | 
			
		||||
  variant?: "default" | "destructive"
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.Item
 | 
			
		||||
      data-slot="dropdown-menu-item"
 | 
			
		||||
      data-inset={inset}
 | 
			
		||||
      data-variant={variant}
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuCheckboxItem({
 | 
			
		||||
  className,
 | 
			
		||||
  children,
 | 
			
		||||
  checked,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.CheckboxItem
 | 
			
		||||
      data-slot="dropdown-menu-checkbox-item"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      checked={checked}
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
 | 
			
		||||
        <DropdownMenuPrimitive.ItemIndicator>
 | 
			
		||||
          <CheckIcon className="size-4" />
 | 
			
		||||
        </DropdownMenuPrimitive.ItemIndicator>
 | 
			
		||||
      </span>
 | 
			
		||||
      {children}
 | 
			
		||||
    </DropdownMenuPrimitive.CheckboxItem>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuRadioGroup({
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.RadioGroup
 | 
			
		||||
      data-slot="dropdown-menu-radio-group"
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuRadioItem({
 | 
			
		||||
  className,
 | 
			
		||||
  children,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.RadioItem
 | 
			
		||||
      data-slot="dropdown-menu-radio-item"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
 | 
			
		||||
        <DropdownMenuPrimitive.ItemIndicator>
 | 
			
		||||
          <CircleIcon className="size-2 fill-current" />
 | 
			
		||||
        </DropdownMenuPrimitive.ItemIndicator>
 | 
			
		||||
      </span>
 | 
			
		||||
      {children}
 | 
			
		||||
    </DropdownMenuPrimitive.RadioItem>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuLabel({
 | 
			
		||||
  className,
 | 
			
		||||
  inset,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
 | 
			
		||||
  inset?: boolean
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.Label
 | 
			
		||||
      data-slot="dropdown-menu-label"
 | 
			
		||||
      data-inset={inset}
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuSeparator({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.Separator
 | 
			
		||||
      data-slot="dropdown-menu-separator"
 | 
			
		||||
      className={cn("bg-border -mx-1 my-1 h-px", className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuShortcut({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<"span">) {
 | 
			
		||||
  return (
 | 
			
		||||
    <span
 | 
			
		||||
      data-slot="dropdown-menu-shortcut"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "text-muted-foreground ml-auto text-xs tracking-widest",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuSub({
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
 | 
			
		||||
  return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuSubTrigger({
 | 
			
		||||
  className,
 | 
			
		||||
  inset,
 | 
			
		||||
  children,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
 | 
			
		||||
  inset?: boolean
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.SubTrigger
 | 
			
		||||
      data-slot="dropdown-menu-sub-trigger"
 | 
			
		||||
      data-inset={inset}
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      {children}
 | 
			
		||||
      <ChevronRightIcon className="ml-auto size-4" />
 | 
			
		||||
    </DropdownMenuPrimitive.SubTrigger>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuSubContent({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.SubContent
 | 
			
		||||
      data-slot="dropdown-menu-sub-content"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export {
 | 
			
		||||
  DropdownMenu,
 | 
			
		||||
  DropdownMenuPortal,
 | 
			
		||||
  DropdownMenuTrigger,
 | 
			
		||||
  DropdownMenuContent,
 | 
			
		||||
  DropdownMenuGroup,
 | 
			
		||||
  DropdownMenuLabel,
 | 
			
		||||
  DropdownMenuItem,
 | 
			
		||||
  DropdownMenuCheckboxItem,
 | 
			
		||||
  DropdownMenuRadioGroup,
 | 
			
		||||
  DropdownMenuRadioItem,
 | 
			
		||||
  DropdownMenuSeparator,
 | 
			
		||||
  DropdownMenuShortcut,
 | 
			
		||||
  DropdownMenuSub,
 | 
			
		||||
  DropdownMenuSubTrigger,
 | 
			
		||||
  DropdownMenuSubContent,
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										10
									
								
								src/pages/apps/html.astro
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/pages/apps/html.astro
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
			
		||||
---
 | 
			
		||||
import '@/styles/theme.css';
 | 
			
		||||
import '@/styles/global.css';
 | 
			
		||||
import Blank from '@/components/html/blank.astro';
 | 
			
		||||
import { App } from '@/apps/ai-html';
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<Blank>
 | 
			
		||||
  <App client:only />
 | 
			
		||||
</Blank>
 | 
			
		||||
@@ -1,7 +1,6 @@
 | 
			
		||||
---
 | 
			
		||||
import { getCollection } from 'astro:content';
 | 
			
		||||
const posts = await getCollection('kevisual');
 | 
			
		||||
console.log('post', posts);
 | 
			
		||||
import { basename } from '@/modules/basename';
 | 
			
		||||
import Blank from '@/components/html/blank.astro';
 | 
			
		||||
---
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										11
									
								
								src/pages/mark/draw.astro
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/pages/mark/draw.astro
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
			
		||||
---
 | 
			
		||||
import '@/styles/theme.css';
 | 
			
		||||
import '@/styles/global.css';
 | 
			
		||||
import Blank from '@/components/html/blank.astro';
 | 
			
		||||
import { App } from '@/apps/draw/App';
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<link rel="stylesheet" href="https://esm.sh/@excalidraw/excalidraw@0.18.0/dist/prod/index.css" />
 | 
			
		||||
<Blank>
 | 
			
		||||
  <App client:only />
 | 
			
		||||
</Blank>
 | 
			
		||||
							
								
								
									
										10
									
								
								src/pages/mark/index.astro
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/pages/mark/index.astro
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
			
		||||
---
 | 
			
		||||
import '@/styles/theme.css';
 | 
			
		||||
import '@/styles/global.css';
 | 
			
		||||
import Blank from '@/components/html/blank.astro';
 | 
			
		||||
import { App } from '@/apps/mark/manager/Manager';
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<Blank>
 | 
			
		||||
  <App client:only openMenu={true} />
 | 
			
		||||
</Blank>
 | 
			
		||||
@@ -15,4 +15,7 @@ export class QueryApp extends BaseQuery {
 | 
			
		||||
  getList(data: any, opts?: DataOpts) {
 | 
			
		||||
    return this.appDefine.queryChain('listApps').post(data, opts);
 | 
			
		||||
  }
 | 
			
		||||
  publishVersion(data: any, opts?: DataOpts) {
 | 
			
		||||
    return this.appDefine.queryChain('publishApp').post(data, opts);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1 @@
 | 
			
		||||
// console.log('upload)
 | 
			
		||||
		Reference in New Issue
	
	Block a user