mark模块更新

This commit is contained in:
xion 2025-03-28 09:40:14 +08:00
parent 9129dffa4c
commit 508ec96029
14 changed files with 666 additions and 29 deletions

View File

@ -1 +1 @@
# vite-react-template
# mark 管理界面的模块功能

View File

@ -1,13 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mark</title>
<style>
html,
body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
}
#root {
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -1,5 +1,5 @@
{
"name": "vite-react",
"name": "@kevisual/mark",
"private": true,
"version": "0.0.1",
"type": "module",
@ -9,14 +9,17 @@
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview",
"pub": "envision deploy ./dist -k vite-react -v 0.0.1",
"ev": "npm run build && npm run deploy",
"pub": "envision deploy ./dist -k mark -v 0.0.1",
"ev": "npm run build && npm run pub",
"dev:lib": "turbo dev"
},
"author": "abearxiong <xiongxiao@xiongxiao.me>",
"license": "MIT",
"dependencies": {
"@kevisual/query-mark": "workspace:*",
"@kevisual/router": "0.0.9",
"@kevisual/store": "workspace:*",
"@types/lodash-es": "^4.17.12",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"lodash-es": "^4.17.21",
@ -24,20 +27,21 @@
"nanoid": "^5.1.5",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"react-toastify": "^11.0.5",
"zustand": "^5.0.3"
},
"devDependencies": {
"@kevisual/query": "0.0.15",
"@kevisual/types": "^0.0.6",
"@tailwindcss/vite": "^4.0.16",
"@types/node": "^22.13.13",
"@tailwindcss/vite": "^4.0.17",
"@types/node": "^22.13.14",
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.16",
"tailwindcss": "^4.0.17",
"typescript": "^5.8.2",
"vite": "^6.2.3"
},
"packageManager": "pnpm@10.6.5"
"packageManager": "pnpm@10.7.0"
}

View File

@ -0,0 +1,13 @@
{
"markType": "Type",
"summary": "Summary",
"tags": "Tags",
"description": "Description",
"link": "Link",
"createdAt": "Created At",
"updatedAt": "Updated At",
"title": "Title",
"thumbnail": "Thumbnail",
"save": "Save",
"editMarkSuccess": "Edit Mark Success"
}

View File

@ -0,0 +1,13 @@
{
"markType": "类型",
"summary": "摘要",
"tags": "标签",
"description": "描述",
"link": "链接",
"createdAt": "创建时间",
"updatedAt": "更新时间",
"title": "标题",
"thumbnail": "缩略图",
"save": "保存",
"editMarkSuccess": "编辑成功"
}

16
src/MarkProvider.tsx Normal file
View File

@ -0,0 +1,16 @@
import { initI18n, I18NextProvider } from '@kevisual/components/translate/index.tsx';
import { ToastContainer } from 'react-toastify';
import { basename } from './modules/basename';
type Props = {
children: React.ReactNode;
basename?: string;
};
// initI18n('');
export const MarkProvider = (props: Props) => {
return (
<I18NextProvider basename={props.basename || basename} noUse={false}>
<ToastContainer />
{props.children}
</I18NextProvider>
);
};

View File

@ -1 +1,2 @@
@import "tailwindcss";
@import 'tailwindcss';
@import '@kevisual/components/theme/wind-theme.css';

View File

@ -3,4 +3,9 @@ import { App } from './pages/App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(<App />);
import { MarkProvider } from './MarkProvider.tsx';
createRoot(document.getElementById('root')!).render(
<MarkProvider>
<App />
</MarkProvider>,
);

317
src/manager/Manager.tsx Normal file
View File

@ -0,0 +1,317 @@
import { useManagerStore } from './store';
import { useEffect, useMemo, useState } from 'react';
import { useShallow } from 'zustand/shallow';
import { ManagerProvider } from './Provider';
import { ChevronDown, ChevronLeft, Edit, Plus, Search, Trash, Menu as MenuIcon, MenuSquare } from 'lucide-react';
import dayjs from 'dayjs';
import { useTranslation } from 'react-i18next';
import { EditMark as EditMarkComponent } from './edit/Edit';
import { toast } from 'react-toastify';
import clsx from 'clsx';
import { Controller, useForm } from 'react-hook-form';
import { Button, TextField, InputAdornment, IconButton, Menu, MenuItem } from '@mui/material';
import { MarkType } from '@kevisual/query-mark';
type ManagerProps = {
showSearch?: boolean;
showAdd?: boolean;
onClick?: (data?: any) => void;
markType?: MarkType;
};
export const Manager = (props: ManagerProps) => {
const { showSearch = true, showAdd = false, onClick } = props;
const { control } = useForm({ defaultValues: { search: '' } });
const { list, init, setCurrentMarkId, currentMarkId, deleteMark, getMark, setMarkData, pagination, setPagination, getList, search, setSearch } =
useManagerStore(
useShallow((state) => {
return {
list: state.list,
init: state.init,
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 { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleMenuItemClick = (option: string) => {
handleClose();
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(t('deleteMarkSuccess'));
}
};
console.log('list', list.length, pagination.total);
return (
<div className='w-full h-full p-4 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 }) => (
<TextField
{...field}
variant='outlined'
margin='normal'
sx={{
display: showSearch ? 'block' : 'none',
}}
size='small'
slotProps={{
input: {
endAdornment: (
<InputAdornment position='end'>
<Search className='w-4 h-4' onClick={() => setSearch(field.value)} />
</InputAdornment>
),
onKeyDown: (event) => {
if (event.key === 'Enter') {
setSearch(field.value);
}
},
},
}}
/>
)}
/>
</div>
<div className={'flex items-center space-x-2'}>
<IconButton onClick={handleClick}>
<MenuIcon className='w-4 h-4' />
</IconButton>
<Menu
anchorEl={anchorEl}
open={open}
onClose={handleClose}
onClick={(e) => {
console.log('e', e);
}}>
{['md', 'mdx', 'wallnote', 'excalidraw'].map((option) => (
<MenuItem
key={option}
value={option}
onClick={() => {
handleMenuItemClick(option);
}}>
{option}
</MenuItem>
))}
</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 ', currentMarkId ? 'rotate-12' : 'rotate-0')}
onClick={() => {
setCurrentMarkId('');
setMarkData({
id: '',
title: '',
description: '',
markType: 'md' as any,
summary: '',
tags: [],
link: '',
});
}}
/>
</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={() => {
onClick?.(item);
}}>
<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'>
{t('markType')}: {item.markType}
</div>
<div className='text-sm text-gray-600'>
{t('summary')}: {item.summary}
</div>
<div className='text-sm text-gray-600'>
{t('tags')}: {item.tags?.join?.(', ')}
</div>
<div className='text-sm text-gray-600 hidden sm:block'>
{t('description')}: {item.description}
</div>
<div
className='text-sm text-gray-600 hidden sm:block truncate'
onClick={() => {
window.open(item.link, '_blank');
}}>
{t('link')}: {item.link}
</div>
<div className='text-sm text-gray-600 hidden sm:block'>
{t('createdAt')}: {dayjs(item.createdAt).format('YYYY-MM-DD HH:mm:ss')}
</div>
<div className='text-sm text-gray-600 hidden sm:block'>
{t('updatedAt')}: {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 }) => {
const [openMenu, setOpenMenu] = useState(false);
return (
<div className='w-full h-full flex'>
<div className='absolute top-4 left-4 z-10'>
<Button
variant='contained'
color={openMenu ? 'info' : 'primary'}
sx={{
minWidth: '0px',
padding: '8px',
}}
onClick={() => {
setOpenMenu(!openMenu);
}}>
<MenuSquare className='w-4 h-4' />
</Button>
</div>
<div className={clsx('h-full w-full sm:w-1/3', openMenu ? '' : 'hidden')}>{props.children}</div>
<div className={clsx('h-full hidden sm:block sm:w-2/3', openMenu ? '' : 'hidden')}>
<EditMark />
</div>
</div>
);
};
export type AppProps = {
/**
* wallnote md excalidraw
*/
markType?: MarkType;
/**
*
*/
showSearch?: boolean;
/**
*
*/
showAdd?: boolean;
/**
*
*/
onClick?: (data?: any) => void;
/**
* id, store的id
*/
managerId?: string;
};
export const App = (props: AppProps) => {
return (
<ManagerProvider id={props.managerId}>
<LayoutMain>
<Manager markType={props.markType} showSearch={props.showSearch} showAdd={props.showAdd} onClick={props.onClick} />
</LayoutMain>
</ManagerProvider>
);
};

9
src/manager/Provider.tsx Normal file
View 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>
);
};

99
src/manager/edit/Edit.tsx Normal file
View File

@ -0,0 +1,99 @@
import { useForm, Controller } from 'react-hook-form';
import { TextField, Button, Box, MenuItem, Autocomplete, FormControlLabel } from '@mui/material';
import { useManagerStore } from '../store';
import { useShallow } from 'zustand/shallow';
import { useEffect, useState } from 'react';
import { Mark } from '@kevisual/query-mark';
import { useTranslation } from 'react-i18next';
import { pick } from 'lodash-es';
import { toast } from 'react-toastify';
import { TagsInput } from '@kevisual/components/select/TagsInput.tsx';
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(t('editMarkSuccess'));
}
// setCurrentMarkId('');
// setMarkData(undefined);
};
const { t } = useTranslation();
return (
<Box component='form' sx={{ px: 2, py: 1 }} onSubmit={handleSubmit(onSubmit)} noValidate autoComplete='off' className='w-full h-full overflow-auto'>
<Controller
name='title'
control={control}
defaultValue={mark?.title || ''}
render={({ field }) => <TextField {...field} label={t('title')} variant='outlined' fullWidth margin='normal' />}
/>
<Controller
name='description'
control={control}
defaultValue={mark?.description || ''}
render={({ field }) => <TextField {...field} label={t('description')} variant='outlined' fullWidth margin='normal' multiline />}
/>
<Controller
name='markType'
control={control}
defaultValue={mark?.markType || ''}
render={({ field }) => (
<Autocomplete
{...field}
options={['md', 'mdx', 'wallnote', 'excalidraw']}
freeSolo
renderInput={(params) => <TextField {...params} label={t('markType')} variant='outlined' fullWidth margin='normal' />}
onChange={(_, value) => field.onChange(value)}
/>
)}
/>
<Controller
name='summary'
control={control}
defaultValue={mark?.summary || ''}
render={({ field }) => <TextField {...field} label={t('summary')} variant='outlined' fullWidth margin='normal' multiline />}
/>
<Controller
name='tags'
control={control}
defaultValue={mark?.tags || ''}
render={({ field }) => {
return <TagsInput {...field} label={t('tags')} showLabel={true} />;
}}
/>
<Controller
name='link'
control={control}
defaultValue={mark?.link || ''}
render={({ field }) => <TextField {...field} label={t('link')} variant='outlined' fullWidth margin='normal' />}
/>
<Controller
name='thumbnail'
control={control}
defaultValue={mark?.thumbnail || ''}
render={({ field }) => <TextField {...field} label={t('thumbnail')} variant='outlined' fullWidth margin='normal' />}
/>
<Button type='submit' variant='contained' color='primary'>
{t('save')}
</Button>
</Box>
);
};

133
src/manager/store/index.ts Normal file
View File

@ -0,0 +1,133 @@
import { StateCreator, StoreManager } from '@kevisual/store';
import { useContextKey } from '@kevisual/store/context';
// import { StateCreator, StoreApi, UseBoundStore } from 'zustand';
import { query as queryClient } from '../../modules/query';
import { Result } from '@kevisual/query/query';
import { QueryMark, Mark, MarkType } from '@kevisual/query-mark';
import { useStore, BoundStore } from '@kevisual/store/react';
import { uniqBy } from 'lodash-es';
export const store = useContextKey('store', () => {
return new StoreManager();
});
type ManagerStore = {
/** 当前选中的Mark */
currentMarkId: string;
setCurrentMarkId: (markId: string) => void;
markData: Mark | undefined;
setMarkData: (mark?: Partial<Mark>) => void;
/** 获取Mark列表 */
getList: () => Promise<any>;
getMarkFromList: (markId: string) => Mark | undefined;
updateMark: (mark: Mark) => Promise<any>;
getMark: (markId: string) => Promise<Result<Mark>>;
deleteMark: (markId: string) => Promise<any>;
/** Mark列表 */
list: Mark[];
setList: (list: Mark[]) => void;
pagination: {
current: number;
pageSize: number;
total: number;
};
setPagination: (pagination: { current: number; pageSize: number; total: number }) => void;
/** 搜索 */
search: string;
setSearch: (search: string) => void;
/** 初始化 */
init: (markType: MarkType) => Promise<void>;
queryMark: QueryMark;
markType: MarkType;
};
export const createManagerStore: StateCreator<ManagerStore, [], [], any> = (set, get, store) => {
return {
currentMarkId: '',
setCurrentMarkId: (markId: string) => set(() => ({ currentMarkId: markId })),
getList: async () => {
const queryMark = get().queryMark;
const { search, pagination } = get();
const res = await queryMark.getMarkList({ page: pagination.current, pageSize: pagination.pageSize, search });
const oldList = get().list;
if (res.code === 200) {
const { pagination, list } = res.data || {};
const newList = [...oldList, ...list];
const uniqueList = uniqBy(newList, 'id');
set(() => ({ list: uniqueList }));
set(() => ({ pagination: { current: pagination.current, pageSize: pagination.pageSize, total: pagination.total } }));
}
},
getMarkFromList: (markId: string) => {
return get().list.find((item) => item.id === markId);
},
updateMark: async (mark: Mark) => {
const queryMark = get().queryMark;
const res = await queryMark.updateMark(mark.id, mark);
if (res.code === 200) {
set((state) => {
const oldList = state.list;
const resMark = res.data!;
const newList = oldList.map((item) => (item.id === mark.id ? mark : item));
if (!mark.id) {
newList.unshift(resMark);
}
return {
list: newList,
};
});
}
return res;
},
getMark: async (markId: string) => {
const queryMark = get().queryMark;
const res = await queryMark.getMark(markId);
return res;
},
list: [],
setList: (list: any[]) => set(() => ({ list })),
init: async (markType: MarkType = 'wallnote') => {
// await get().getList();
console.log('init', set, get);
const queryMark = new QueryMark({
query: queryClient as any,
markType,
});
const url = new URL(window.location.href);
const pageSize = url.searchParams.get('pageSize') || '10';
set({ queryMark, markType, list: [], pagination: { current: 1, pageSize: parseInt(pageSize), total: 0 }, currentMarkId: '', markData: undefined });
setTimeout(async () => {
console.log('get', get);
get().getList();
}, 1000);
},
deleteMark: async (markId: string) => {
const queryMark = get().queryMark;
const res = await queryMark.deleteMark(markId);
const currentMarkId = get().currentMarkId;
if (res.code === 200) {
// get().getList();
set((state) => ({
list: state.list.filter((item) => item.id !== markId),
}));
if (currentMarkId === markId) {
set(() => ({ currentMarkId: '', markData: undefined }));
}
}
return res;
},
queryMark: undefined,
markType: 'simple',
markData: undefined,
setMarkData: (mark: Mark) => set(() => ({ markData: mark })),
pagination: {
current: 1,
pageSize: 10,
total: 0,
},
setPagination: (pagination: { current: number; pageSize: number; total: number }) => set(() => ({ pagination })),
/** 搜索 */
search: '',
setSearch: (search: string) => set(() => ({ search, list: [], pagination: { current: 1, pageSize: 10, total: 0 } })),
};
};
export const useManagerStore = useStore as BoundStore<ManagerStore>;

View File

@ -1,5 +1,10 @@
import { basename } from '../modules/basename';
console.log('basename', basename);
import { App as MarkApp } from '../manager/Manager';
export const App = () => {
return <div className='bg-slate-200 w-full h-full border'></div>;
return (
<div className=' w-full h-full overflow-hidden'>
<MarkApp />
</div>
);
};

View File

@ -10,6 +10,17 @@ const isDev = process.env.NODE_ENV === 'development';
const basename = isDev ? '/' : pkgs?.basename || '/';
let proxy: any = {};
if (isDev) {
proxy = {
'/api': {
target: 'https://kevisual.xiongxiao.me',
changeOrigin: true,
ws: true,
rewriteWsOrigin: true,
},
};
}
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
@ -38,13 +49,7 @@ export default defineConfig({
rewriteWsOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '/api'),
},
'/api/router': {
target: 'ws://localhost:3000',
changeOrigin: true,
ws: true,
rewriteWsOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '/api'),
},
...proxy,
},
},
});