generated from kevisual/vite-react-template
temp
This commit is contained in:
12
package.json
12
package.json
@@ -18,20 +18,27 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.2.0",
|
"@base-ui/react": "^1.2.0",
|
||||||
"@kevisual/api": "^0.0.47",
|
"@excalidraw/excalidraw": "^0.18.0",
|
||||||
|
"@kevisual/api": "^0.0.48",
|
||||||
|
"@kevisual/cache": "^0.0.5",
|
||||||
"@kevisual/context": "^0.0.4",
|
"@kevisual/context": "^0.0.4",
|
||||||
"@kevisual/router": "0.0.70",
|
"@kevisual/router": "0.0.70",
|
||||||
|
"@kevisual/store": "^0.0.10",
|
||||||
"@tanstack/react-router": "^1.159.5",
|
"@tanstack/react-router": "^1.159.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"crypto-js": "^4.2.0",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
"es-toolkit": "^1.44.0",
|
"es-toolkit": "^1.44.0",
|
||||||
|
"idb-keyval": "^6.2.2",
|
||||||
"lucide-react": "^0.564.0",
|
"lucide-react": "^0.564.0",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"re-resizable": "^6.11.2",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"react-draggable": "^4.5.0",
|
||||||
"react-hook-form": "^7.71.1",
|
"react-hook-form": "^7.71.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
@@ -45,6 +52,7 @@
|
|||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tanstack/react-router-devtools": "^1.159.5",
|
"@tanstack/react-router-devtools": "^1.159.5",
|
||||||
"@tanstack/router-plugin": "^1.159.5",
|
"@tanstack/router-plugin": "^1.159.5",
|
||||||
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.2.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -54,7 +62,7 @@
|
|||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "v8.0.0-beta.13"
|
"vite": "v7.3.1"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.29.3"
|
"packageManager": "pnpm@10.29.3"
|
||||||
}
|
}
|
||||||
2326
pnpm-lock.yaml
generated
2326
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
80
src/components/a/auto-complate.tsx
Normal file
80
src/components/a/auto-complate.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/a/button';
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
|
||||||
|
type Option = {
|
||||||
|
value?: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AutoComplateProps = {
|
||||||
|
options: Option[];
|
||||||
|
placeholder?: string;
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
width?: string;
|
||||||
|
};
|
||||||
|
export function AutoComplate(props: AutoComplateProps) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [value, _setValue] = React.useState('');
|
||||||
|
const setValue = (value: string) => {
|
||||||
|
props?.onChange?.(value);
|
||||||
|
_setValue(value);
|
||||||
|
};
|
||||||
|
const showLabel = React.useMemo(() => {
|
||||||
|
const option = props.options.find((option) => option.value === value);
|
||||||
|
if (option) {
|
||||||
|
return option?.label;
|
||||||
|
}
|
||||||
|
if (props.value) return props.value;
|
||||||
|
if (value) return value;
|
||||||
|
return 'Select ...';
|
||||||
|
}, [value, props.value]);
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger >
|
||||||
|
<Button variant='outline' role='combobox' aria-expanded={open} className={cn(props.width ? props.width : 'w-[400px]', 'justify-between')}>
|
||||||
|
{showLabel}
|
||||||
|
<ChevronsUpDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className={cn(props.width ? props.width : 'w-[400px]', ' p-0')}>
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
placeholder={props.placeholder ?? 'Search options...'}
|
||||||
|
onKeyDown={(e: any) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
setOpen(false);
|
||||||
|
const value = e.target?.value || '';
|
||||||
|
setValue(value.trim());
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No options found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{props.options.map((framework) => (
|
||||||
|
<CommandItem
|
||||||
|
key={framework.value}
|
||||||
|
value={framework.value}
|
||||||
|
onSelect={(currentValue) => {
|
||||||
|
setValue(currentValue === value ? '' : currentValue);
|
||||||
|
setOpen(false);
|
||||||
|
}}>
|
||||||
|
<Check className={cn('mr-2 h-4 w-4', value === framework.value ? 'opacity-100' : 'opacity-0')} />
|
||||||
|
{framework.label}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/components/a/button.tsx
Normal file
19
src/components/a/button.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Button as UiButton } from '@/components/ui/button';
|
||||||
|
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
export const IconButton: typeof UiButton = (props) => {
|
||||||
|
return <UiButton variant='ghost' size='icon' {...props} className={cn('h-8 w-8 cursor-pointer', props?.className)} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Button = (props: Parameters<typeof UiButton>[0]) => {
|
||||||
|
return <UiButton variant='ghost' {...props} className={cn('cursor-pointer', props?.className)} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ButtonTextIcon = (props: ButtonPrimitive.Props & { icon: React.ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<UiButton variant={'outline'} size='sm' {...props} className={cn('cursor-pointer flex items-center gap-2', props?.className)}>
|
||||||
|
{props.icon}
|
||||||
|
{props.children}
|
||||||
|
</UiButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
102
src/components/a/confirm.tsx
Normal file
102
src/components/a/confirm.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
type useConfirmOptions = {
|
||||||
|
confrimProps: ConfirmProps;
|
||||||
|
};
|
||||||
|
type Fn = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||||
|
export const useConfirm = (opts?: useConfirmOptions) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
type ConfirmOptions = {
|
||||||
|
onOk?: Fn;
|
||||||
|
onCancel?: Fn;
|
||||||
|
};
|
||||||
|
const confirm = (opts?: ConfirmOptions) => {
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
const module = useMemo(() => {
|
||||||
|
return <Confirm {...opts?.confrimProps} hasTrigger={false} open={open} setOpen={setOpen} />;
|
||||||
|
}, [open]);
|
||||||
|
return {
|
||||||
|
module: module,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
confirm,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConfirmProps = {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
tip?: React.ReactNode;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
onOkText?: string;
|
||||||
|
onCancelText?: string;
|
||||||
|
onOk?: Fn;
|
||||||
|
onCancle?: Fn;
|
||||||
|
hasTrigger?: boolean;
|
||||||
|
open?: boolean;
|
||||||
|
setOpen?: (open: boolean) => void;
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
};
|
||||||
|
export const Confirm = (props: ConfirmProps) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(props.open);
|
||||||
|
const hasTrigger = props.hasTrigger ?? true;
|
||||||
|
useEffect(() => {
|
||||||
|
setIsOpen(props.open);
|
||||||
|
}, [props.open]);
|
||||||
|
return (
|
||||||
|
<AlertDialog
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={(v) => {
|
||||||
|
setIsOpen(v);
|
||||||
|
props?.setOpen?.(v);
|
||||||
|
}}>
|
||||||
|
{hasTrigger && (
|
||||||
|
<>
|
||||||
|
{props?.children && <AlertDialogTrigger>{props?.children ?? props?.tip ?? '提示'}</AlertDialogTrigger>}
|
||||||
|
{!props?.children && <AlertDialogTrigger>{props?.tip ?? '提示'}</AlertDialogTrigger>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{props?.title ?? '是否确认删除?'}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{props?.description ?? '此操作无法撤销,是否继续。'}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
{props?.footer && <div className='flex gap-2'>{props?.footer}</div>}
|
||||||
|
{!props?.footer && (
|
||||||
|
<>
|
||||||
|
<AlertDialogCancel
|
||||||
|
className='cursor-pointer'
|
||||||
|
onClick={(e) => {
|
||||||
|
props?.onCancle?.(e);
|
||||||
|
e.stopPropagation();
|
||||||
|
}}>
|
||||||
|
{props?.onCancelText ?? '取消'}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className='cursor-pointer'
|
||||||
|
onClick={(e) => {
|
||||||
|
props?.onOk?.(e);
|
||||||
|
e.stopPropagation();
|
||||||
|
}}>
|
||||||
|
{props?.onOkText ?? '确定'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
21
src/components/a/divider.tsx
Normal file
21
src/components/a/divider.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export const Divider = (props: { orientation?: 'horizontal' | 'vertical' }) => {
|
||||||
|
const { orientation = 'horizontal' } = props;
|
||||||
|
|
||||||
|
const dividerStyle: React.CSSProperties = {
|
||||||
|
display: 'block',
|
||||||
|
backgroundColor: '#e5e7eb', // 淡灰色分割线
|
||||||
|
...(orientation === 'horizontal'
|
||||||
|
? {
|
||||||
|
width: '100%',
|
||||||
|
height: '1px',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
width: '1px',
|
||||||
|
display: 'inline-block',
|
||||||
|
margin: '2px 4px',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return <div style={dividerStyle} role='separator' aria-orientation={orientation} />;
|
||||||
|
};
|
||||||
132
src/components/a/drag-modal/index.tsx
Normal file
132
src/components/a/drag-modal/index.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import Draggable from 'react-draggable';
|
||||||
|
import { cn as clsxMerge } from '@/lib/utils';
|
||||||
|
import { Resizable } from 're-resizable';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
type DragModalProps = {
|
||||||
|
title?: React.ReactNode;
|
||||||
|
content?: React.ReactNode;
|
||||||
|
onClose?: () => void;
|
||||||
|
containerClassName?: string;
|
||||||
|
handleClassName?: string;
|
||||||
|
contentClassName?: string;
|
||||||
|
focus?: boolean;
|
||||||
|
/**
|
||||||
|
* 默认大小, 单位为px
|
||||||
|
* width: defaultSize.width || 320
|
||||||
|
* height: defaultSize.height || 400
|
||||||
|
*/
|
||||||
|
defaultSize?: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
};
|
||||||
|
export const DragModal = (props: DragModalProps) => {
|
||||||
|
const dragRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Draggable
|
||||||
|
nodeRef={dragRef as any}
|
||||||
|
onStop={(e, data) => {
|
||||||
|
// console.log(e, data);
|
||||||
|
}}
|
||||||
|
handle='.handle'
|
||||||
|
grid={[1, 1]}
|
||||||
|
scale={1}
|
||||||
|
bounds='parent'
|
||||||
|
defaultPosition={{
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
}}>
|
||||||
|
<div
|
||||||
|
className={clsxMerge('absolute top-0 left-0 bg-white rounded-md border border-gray-200 shadow-sm pointer-events-auto', props.focus ? 'z-30' : '', props.containerClassName)}
|
||||||
|
ref={dragRef}
|
||||||
|
style={props.style}>
|
||||||
|
<div className={clsxMerge('handle cursor-move border-b border-gray-200 py-2 px-4', props.handleClassName)}>{props.title || 'Move'}</div>
|
||||||
|
<Resizable
|
||||||
|
className={clsxMerge('', props.contentClassName)}
|
||||||
|
defaultSize={{
|
||||||
|
width: props.defaultSize?.width || 600,
|
||||||
|
height: props.defaultSize?.height || 400,
|
||||||
|
}}
|
||||||
|
onResizeStop={(e, direction, ref, d) => {
|
||||||
|
// console.log(e, direction, ref, d);
|
||||||
|
}}
|
||||||
|
enable={{
|
||||||
|
bottom: true,
|
||||||
|
right: true,
|
||||||
|
bottomRight: true,
|
||||||
|
}}>
|
||||||
|
{props.content}
|
||||||
|
</Resizable>
|
||||||
|
</div>
|
||||||
|
</Draggable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type DragModalTitleProps = {
|
||||||
|
title?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
onClose?: () => void;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
export const DragModalTitle = (props: DragModalTitleProps) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsxMerge('flex flex-row items-center justify-between', props.className)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
props.onClick?.();
|
||||||
|
}}>
|
||||||
|
<div className='text-sm font-medium text-gray-700'>
|
||||||
|
{props.title}
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className='text-gray-500 cursor-pointer p-2 hover:bg-gray-100 rounded-md'
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
props.onClose?.();
|
||||||
|
}}>
|
||||||
|
<X className='w-4 h-4 ' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getComputedHeight = () => {
|
||||||
|
const height = window.innerHeight;
|
||||||
|
const width = window.innerWidth;
|
||||||
|
return { height, width };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useComputedHeight = () => {
|
||||||
|
const [computedHeight, setComputedHeight] = useState({
|
||||||
|
height: 0,
|
||||||
|
width: 0,
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
const height = window.innerHeight;
|
||||||
|
const width = window.innerWidth;
|
||||||
|
setComputedHeight({ height, width });
|
||||||
|
}, []);
|
||||||
|
return computedHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDragSize = (width = 600, heigth = 400) => {
|
||||||
|
const computedHeight = getComputedHeight();
|
||||||
|
const isMin = computedHeight.width < width;
|
||||||
|
return {
|
||||||
|
defaultSize: {
|
||||||
|
width: isMin ? computedHeight.width : 600,
|
||||||
|
height: 400,
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
left: isMin ? 0 : computedHeight.width / 2 - width / 2,
|
||||||
|
top: computedHeight.height / 2 - heigth / 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
92
src/components/a/input.tsx
Normal file
92
src/components/a/input.tsx
Normal file
@@ -0,0 +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>
|
||||||
|
);
|
||||||
|
};
|
||||||
24
src/components/a/modal.tsx
Normal file
24
src/components/a/modal.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||||
|
import React, { Dispatch, SetStateAction } from 'react';
|
||||||
|
|
||||||
|
type ModalProps = {
|
||||||
|
open?: boolean;
|
||||||
|
setOpen?: (open: boolean) => any;
|
||||||
|
title?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Modal = (props: ModalProps) => {
|
||||||
|
const { open = false, setOpen, title, children } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{children}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
33
src/components/a/select.tsx
Normal file
33
src/components/a/select.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Select as UISelect, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
|
||||||
|
type Option = {
|
||||||
|
value: string;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
type SelectProps = {
|
||||||
|
className?: string;
|
||||||
|
options?: Option[];
|
||||||
|
value?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
onChange?: (value: any) => any;
|
||||||
|
size?: 'small' | 'medium' | 'large';
|
||||||
|
};
|
||||||
|
export const Select = (props: SelectProps) => {
|
||||||
|
const options = props.options || [];
|
||||||
|
return (
|
||||||
|
<UISelect onValueChange={props.onChange} value={props.value}>
|
||||||
|
<SelectTrigger className='w-[180px]'>
|
||||||
|
<SelectValue placeholder={props.placeholder || '请选择'} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((item, index) => {
|
||||||
|
return (
|
||||||
|
<SelectItem key={index} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</UISelect>
|
||||||
|
);
|
||||||
|
};
|
||||||
15
src/components/a/tooltip.tsx
Normal file
15
src/components/a/tooltip.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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; placement?: 'top' | 'bottom' | 'left' | 'right' }) => {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<UITooltip>
|
||||||
|
<TooltipTrigger>{props.children}</TooltipTrigger>
|
||||||
|
<TooltipContent side={props.placement || 'top'}>
|
||||||
|
<p>{props.title}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</UITooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
175
src/components/ui/alert-dialog.tsx
Normal file
175
src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Backdrop
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Popup.Props & {
|
||||||
|
size?: "default" | "sm"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Popup
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-background ring-foreground/10 gap-4 rounded-xl p-4 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn("grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogMedia({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-media"
|
||||||
|
className={cn("bg-muted mb-2 inline-flex size-10 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn("text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn("text-muted-foreground *:[a]:hover:text-foreground text-sm text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="alert-dialog-action"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Close.Props &
|
||||||
|
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Close
|
||||||
|
data-slot="alert-dialog-cancel"
|
||||||
|
className={cn(className)}
|
||||||
|
render={<Button variant={variant} size={size} />}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogMedia,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
}
|
||||||
88
src/components/ui/popover.tsx
Normal file
88
src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: PopoverPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
PopoverPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Positioner
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<PopoverPrimitive.Popup
|
||||||
|
data-slot="popover-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 ring-foreground/10 flex flex-col gap-2.5 rounded-lg p-2.5 text-sm shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 w-72 origin-(--transform-origin) outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Positioner>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="popover-header"
|
||||||
|
className={cn("flex flex-col gap-0.5 text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Title
|
||||||
|
data-slot="popover-title"
|
||||||
|
className={cn("font-medium", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: PopoverPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Description
|
||||||
|
data-slot="popover-description"
|
||||||
|
className={cn("text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverDescription,
|
||||||
|
PopoverHeader,
|
||||||
|
PopoverTitle,
|
||||||
|
PopoverTrigger,
|
||||||
|
}
|
||||||
34
src/pages/auth/layout.tsx
Normal file
34
src/pages/auth/layout.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { QueryLoginBrowser } from '@kevisual/api/login'
|
||||||
|
|
||||||
|
import { query } from '@/modules/query'
|
||||||
|
import { useLayoutEffect, useState } from 'react';
|
||||||
|
import { queryResources } from '../draw/modules/query';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children?: any;
|
||||||
|
}
|
||||||
|
export const Auth = (props: Props) => {
|
||||||
|
const [mount, setMount] = useState(false);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
// Your layout effect logic here
|
||||||
|
init()
|
||||||
|
}, []);
|
||||||
|
const init = async () => {
|
||||||
|
const login = new QueryLoginBrowser({ query: query })
|
||||||
|
const resMe = await login.getMe()
|
||||||
|
if (resMe) {
|
||||||
|
// User is logged in
|
||||||
|
setMount(true)
|
||||||
|
console.log('res', resMe)
|
||||||
|
const username = resMe.data?.username;
|
||||||
|
queryResources.setUsername(username)
|
||||||
|
} else {
|
||||||
|
// User is not logged in
|
||||||
|
setMount(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!mount) return null;
|
||||||
|
return <>
|
||||||
|
{props.children}
|
||||||
|
</>
|
||||||
|
}
|
||||||
40
src/pages/draw/Draw.tsx
Normal file
40
src/pages/draw/Draw.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { createMarkStore, useMarkStore } from './store';
|
||||||
|
import { StoreContextProvider } from '@kevisual/store/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.kevisual.cn/@excalidraw/excalidraw@0.18.0/dist/prod/';
|
||||||
|
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}</>;
|
||||||
|
};
|
||||||
283
src/pages/draw/core/Excalidraw.tsx
Normal file
283
src/pages/draw/core/Excalidraw.tsx
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import { Excalidraw } from '@excalidraw/excalidraw';
|
||||||
|
import '@excalidraw/excalidraw/index.css'
|
||||||
|
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 'es-toolkit';
|
||||||
|
import { useMarkStore, store as StoreManager } from '../store';
|
||||||
|
import { useShallow } from 'zustand/shallow';
|
||||||
|
import { useListenLang } from './hooks/listen-lang';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { hashFile } from '../modules/hash-file';
|
||||||
|
import { toFile } from '@kevisual/api/query-upload'
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
import { queryResources } from '../modules/query'
|
||||||
|
|
||||||
|
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 day = dayjs().format('YYYY-MM');
|
||||||
|
const directory = `excalidraw/${day}/${id}`;
|
||||||
|
const filePath = `upload/1.0.0/${directory}/${file.id}`;
|
||||||
|
const res = (await queryResources.uploadFile(filePath, _file)) as any;
|
||||||
|
if (res.code === 200) {
|
||||||
|
toast.success('上传图片成功');
|
||||||
|
} else {
|
||||||
|
toast.error('上传图片失败');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const fullFilePath = queryResources.prefix + filePath;
|
||||||
|
filesObject[file.id] = {
|
||||||
|
...filesObject[file.id],
|
||||||
|
dataURL: fullFilePath,
|
||||||
|
};
|
||||||
|
|
||||||
|
} 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/pages/draw/core/hooks/listen-lang.ts
Normal file
29
src/pages/draw/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/pages/draw/core/hooks/listen-library.ts
Normal file
30
src/pages/draw/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,
|
||||||
|
};
|
||||||
|
};
|
||||||
32
src/pages/draw/modules/hash-file.ts
Normal file
32
src/pages/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);
|
||||||
|
});
|
||||||
|
};
|
||||||
9
src/pages/draw/modules/query.ts
Normal file
9
src/pages/draw/modules/query.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { query } from '@/modules/query';
|
||||||
|
import { QueryResources } from '@kevisual/api/query-resources'
|
||||||
|
import { QueryMark } from '@kevisual/api/query-mark';
|
||||||
|
export const queryMark = new QueryMark({
|
||||||
|
query: query,
|
||||||
|
markType: 'excalidraw',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const queryResources = new QueryResources({})
|
||||||
96
src/pages/draw/page.tsx
Normal file
96
src/pages/draw/page.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||||
|
import { getHistoryState, setHistoryState } from '@kevisual/store/web-page.js';
|
||||||
|
import { Draw } from './Draw';
|
||||||
|
import { App as Manager, ProviderManagerName, useManagerStore } from '../mark/manager/Manager';
|
||||||
|
|
||||||
|
import { useShallow } from 'zustand/shallow';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export const App = () => {
|
||||||
|
return <DrawApp />;
|
||||||
|
};
|
||||||
|
export default App;
|
||||||
|
|
||||||
|
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();
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
215
src/pages/draw/store/index.ts
Normal file
215
src/pages/draw/store/index.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import { StoreManager } from '@kevisual/store';
|
||||||
|
import { useContextKey } from '@kevisual/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 'sonner';
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
];
|
||||||
331
src/pages/mark/manager/Manager.tsx
Normal file
331
src/pages/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 { 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 'sonner';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
|
import { IconButton } from '@/components/a/button';
|
||||||
|
import { MarkType } from '@kevisual/api/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; hasTopTitle?: 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(() => {
|
||||||
|
const top = props.hasTopTitle ? 70 : 0; // Adjust top based on whether there's a title
|
||||||
|
if (!hasExpandChildren || openMenu) {
|
||||||
|
return {
|
||||||
|
top,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
top: getDocumentHeight() / 2 + 10 + top,
|
||||||
|
};
|
||||||
|
}, [getDocumentHeight, hasExpandChildren, openMenu, props.hasTopTitle]);
|
||||||
|
return (
|
||||||
|
<div className='w-full h-full flex'>
|
||||||
|
<div className={clsx('absolute top-4 z-10', openMenu ? 'left-4' : '-left-4')} style={style}>
|
||||||
|
<IconButton
|
||||||
|
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;
|
||||||
|
hasTopTitle?: boolean; // 是否有顶部标题
|
||||||
|
};
|
||||||
|
export const ProviderManagerName = 'mark-manager';
|
||||||
|
export const App = (props: AppProps) => {
|
||||||
|
return (
|
||||||
|
<LayoutMain expandChildren={props.children} open={props.openMenu} hasTopTitle={props.hasTopTitle}>
|
||||||
|
<Manager
|
||||||
|
markType={props.markType}
|
||||||
|
showSearch={props.showSearch}
|
||||||
|
showAdd={props.showAdd}
|
||||||
|
onClick={props.onClick}
|
||||||
|
showSelect={props.showSelect}></Manager>
|
||||||
|
</LayoutMain>
|
||||||
|
);
|
||||||
|
};
|
||||||
9
src/pages/mark/manager/Provider.tsx
Normal file
9
src/pages/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/pages/mark/manager/components/index.tsx
Normal file
27
src/pages/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/pages/mark/manager/constant.ts
Normal file
1
src/pages/mark/manager/constant.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const MarkTypes = ['md', 'wallnote', 'excalidraw', 'chat'];
|
||||||
152
src/pages/mark/manager/edit/Edit.tsx
Normal file
152
src/pages/mark/manager/edit/Edit.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
|
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 { pick } from 'es-toolkit';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
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' >
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentMarkId('');
|
||||||
|
setMarkData(undefined);
|
||||||
|
}}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
131
src/pages/mark/manager/store/index.ts
Normal file
131
src/pages/mark/manager/store/index.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { create, StateCreator, StoreApi, UseBoundStore } from 'zustand';
|
||||||
|
import { query as queryClient } from '@/modules/query';
|
||||||
|
import { Result } from '@kevisual/query/query';
|
||||||
|
import { MarkType, Mark, QueryMark } from '@kevisual/api/query-mark';
|
||||||
|
import { uniqBy } from 'es-toolkit';
|
||||||
|
|
||||||
|
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, (item) => item.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 = create<ManagerStore>(createManagerStore);
|
||||||
51
src/pages/mark/page.tsx
Normal file
51
src/pages/mark/page.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { App as Manager, useManagerStore } from './manager/Manager';
|
||||||
|
import { getUrlId } from '../draw/page';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useLayoutEffect, useState } from 'react';
|
||||||
|
import { getHistoryState, setHistoryState } from '@kevisual/store/web-page.js';
|
||||||
|
export const App = () => {
|
||||||
|
const [id, setId] = useState('');
|
||||||
|
const urlId = getUrlId();
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const state = getHistoryState();
|
||||||
|
if (state?.id) {
|
||||||
|
setId(state.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (urlId) {
|
||||||
|
setId(urlId);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
return <>
|
||||||
|
<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();
|
||||||
|
if (_store.markData) {
|
||||||
|
_store.setCurrentMarkId('');
|
||||||
|
// _store.setOpen(false);
|
||||||
|
_store.setMarkData(undefined);
|
||||||
|
}
|
||||||
|
} else if (data.id === id) {
|
||||||
|
toast.success('已选择当前画布');
|
||||||
|
}
|
||||||
|
console.log('onClick', data, id);
|
||||||
|
}}>
|
||||||
|
</Manager>
|
||||||
|
</>
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
const Home = () => {
|
import App from './draw/page'
|
||||||
return <div>Home Page</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Home;
|
export default App;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Link, Outlet, createRootRoute } from '@tanstack/react-router'
|
import { Link, Outlet, createRootRoute } from '@tanstack/react-router'
|
||||||
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
|
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
|
||||||
import { Toaster } from "@/components/ui/sonner"
|
import { Toaster } from "@/components/ui/sonner"
|
||||||
|
import { Auth } from '@/pages/auth/layout'
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
component: RootComponent,
|
component: RootComponent,
|
||||||
@@ -8,7 +9,7 @@ export const Route = createRootRoute({
|
|||||||
|
|
||||||
function RootComponent() {
|
function RootComponent() {
|
||||||
return (
|
return (
|
||||||
<>
|
<Auth>
|
||||||
<div className="p-2 flex gap-2 text-lg">
|
<div className="p-2 flex gap-2 text-lg">
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
@@ -24,6 +25,6 @@ function RootComponent() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
<TanStackRouterDevtools position="bottom-right" />
|
<TanStackRouterDevtools position="bottom-right" />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</>
|
</Auth>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -4,11 +4,11 @@ import path from 'path';
|
|||||||
import pkgs from './package.json';
|
import pkgs from './package.json';
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import { tanstackRouter } from '@tanstack/router-plugin/vite'
|
import { tanstackRouter } from '@tanstack/router-plugin/vite'
|
||||||
|
import 'dotenv/config'
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
const basename = isDev ? '/' : pkgs?.basename || '/';
|
const basename = isDev ? '/' : pkgs?.basename || '/';
|
||||||
|
|
||||||
let target = process.env.VITE_API_URL || 'http://localhost:51515';
|
let target = process.env.VITE_API_URL || 'http://localhost:51515';
|
||||||
|
console.log('target', target)
|
||||||
const apiProxy = { target: target, changeOrigin: true, ws: true, rewriteWsOrigin: true, secure: false, cookieDomainRewrite: 'localhost' };
|
const apiProxy = { target: target, changeOrigin: true, ws: true, rewriteWsOrigin: true, secure: false, cookieDomainRewrite: 'localhost' };
|
||||||
let proxy = {
|
let proxy = {
|
||||||
'/root/': apiProxy,
|
'/root/': apiProxy,
|
||||||
|
|||||||
Reference in New Issue
Block a user