优化
This commit is contained in:
2
client/packages/design/.gitignore
vendored
Normal file
2
client/packages/design/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
es
|
||||
lib
|
||||
12
client/packages/design/.storybook/main.js
Normal file
12
client/packages/design/.storybook/main.js
Normal file
@@ -0,0 +1,12 @@
|
||||
module.exports = {
|
||||
stories: [
|
||||
'../components/**/*.stories.mdx',
|
||||
'../components/**/*.stories.@(js|jsx|ts|tsx)',
|
||||
],
|
||||
addons: [
|
||||
'@storybook/addon-links',
|
||||
'@storybook/addon-essentials',
|
||||
'@storybook/addon-interactions',
|
||||
],
|
||||
framework: '@storybook/react',
|
||||
};
|
||||
11
client/packages/design/.storybook/preview.js
Normal file
11
client/packages/design/.storybook/preview.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import 'antd/dist/antd.css';
|
||||
|
||||
export const parameters = {
|
||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
};
|
||||
3
client/packages/design/README.md
Normal file
3
client/packages/design/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
Tailchat 的前端组件
|
||||
|
||||
能进这个包的原则是该组件是一个业务无关的组件(无环境依赖)
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
|
||||
import { AutoFolder } from '.';
|
||||
|
||||
export default {
|
||||
title: 'Tailchat/AutoFolder',
|
||||
component: AutoFolder,
|
||||
argTypes: {},
|
||||
} as ComponentMeta<typeof AutoFolder>;
|
||||
|
||||
const Template: ComponentStory<typeof AutoFolder> = (args) => (
|
||||
<AutoFolder {...args} />
|
||||
);
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {
|
||||
maxHeight: 100,
|
||||
children: (
|
||||
<div style={{ border: '1px solid #999' }}>
|
||||
<div>foooooo</div>
|
||||
<div>foooooo</div>
|
||||
<div>foooooo</div>
|
||||
<div>foooooo</div>
|
||||
<div>foooooo</div>
|
||||
<div>foooooo</div>
|
||||
<div>foooooo</div>
|
||||
<div>foooooo</div>
|
||||
<div>foooooo</div>
|
||||
<div>foooooo</div>
|
||||
<div>foooooo</div>
|
||||
<div>foooooo</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
88
client/packages/design/components/AutoFolder/index.tsx
Normal file
88
client/packages/design/components/AutoFolder/index.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React, {
|
||||
PropsWithChildren,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
|
||||
interface AutoFolderProps extends PropsWithChildren {
|
||||
maxHeight: number;
|
||||
showFullText?: React.ReactNode;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
export const AutoFolder: React.FC<AutoFolderProps> = React.memo((props) => {
|
||||
const { showFullText = 'More', backgroundColor = 'white' } = props;
|
||||
const [isShowFullBtn, setIsShowFullBtn] = useState(false); // 是否显示展示所有内容的按钮
|
||||
const [isShowFull, setIsShowFull] = useState(false); // 是否点击按钮展示所有
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contentRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new window.ResizeObserver((entries) => {
|
||||
if (entries[0]) {
|
||||
const { height } = entries[0].contentRect;
|
||||
|
||||
if (height > maxHeight) {
|
||||
setIsShowFull(false);
|
||||
setIsShowFullBtn(true);
|
||||
|
||||
observer.disconnect(); // 触发一次则解除连接
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(contentRef.current);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const maxHeight = useMemo(() => {
|
||||
if (isShowFull) {
|
||||
return 'none';
|
||||
} else {
|
||||
return props.maxHeight;
|
||||
}
|
||||
}, [isShowFull, props.maxHeight]);
|
||||
|
||||
const handleClickShowFullBtn = useMemoizedFn(() => {
|
||||
setIsShowFullBtn(false);
|
||||
setIsShowFull(true);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
maxHeight,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<div ref={contentRef}>{props.children}</div>
|
||||
|
||||
{isShowFullBtn && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
backgroundImage: `linear-gradient(rgba(0,0,0,0), ${backgroundColor})`,
|
||||
padding: '4px 0',
|
||||
}}
|
||||
onClick={handleClickShowFullBtn}
|
||||
>
|
||||
{showFullText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
AutoFolder.displayName = 'AutoFolder';
|
||||
59
client/packages/design/components/Avatar/combined.css
Normal file
59
client/packages/design/components/Avatar/combined.css
Normal file
@@ -0,0 +1,59 @@
|
||||
.td-combined-avatar {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.td-combined-avatar-2 > .line1 {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
background-color: white;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.td-combined-avatar-3 > .line2 {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 50%;
|
||||
height: 1px;
|
||||
background-color: white;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.td-combined-avatar-3 > .line1 {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
background-color: white;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.td-combined-avatar-4 > .line2 {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: white;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.td-combined-avatar-4 > .line1 {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
background-color: white;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.td-combined-avatar__item {
|
||||
position: absolute;
|
||||
border-radius: 0;
|
||||
}
|
||||
106
client/packages/design/components/Avatar/combined.tsx
Normal file
106
client/packages/design/components/Avatar/combined.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Avatar, AvatarProps } from '.';
|
||||
import React from 'react';
|
||||
import _take from 'lodash/take';
|
||||
import { px2rem } from './utils';
|
||||
import './combined.css';
|
||||
|
||||
interface CombinedAvatarProps {
|
||||
shape?: 'circle' | 'square';
|
||||
size?: number;
|
||||
items: Pick<AvatarProps, 'name' | 'src'>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装式头像
|
||||
*/
|
||||
export const CombinedAvatar: React.FC<CombinedAvatarProps> = React.memo(
|
||||
(props) => {
|
||||
const { size = 32, shape = 'circle' } = props;
|
||||
const items = _take(props.items, 4);
|
||||
|
||||
const length = items.length;
|
||||
const getCellStyle = (i: number): React.CSSProperties => {
|
||||
if (length === 1) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (length === 2) {
|
||||
if (i === 0) {
|
||||
return {
|
||||
width: '50%',
|
||||
overflow: 'hidden',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
};
|
||||
}
|
||||
if (i === 1) {
|
||||
return {
|
||||
width: '50%',
|
||||
overflow: 'hidden',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (length === 3) {
|
||||
if (i === 0) {
|
||||
return {
|
||||
width: '50%',
|
||||
overflow: 'hidden',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
};
|
||||
}
|
||||
if (i === 1) {
|
||||
return { transform: 'scale(50%)', transformOrigin: '100% 0 0' };
|
||||
}
|
||||
if (i === 2) {
|
||||
return { transform: 'scale(50%)', transformOrigin: '100% 100% 0' };
|
||||
}
|
||||
}
|
||||
|
||||
if (length === 4) {
|
||||
if (i === 0) {
|
||||
return { transform: 'scale(50%)', transformOrigin: '0 0 0' };
|
||||
}
|
||||
if (i === 1) {
|
||||
return { transform: 'scale(50%)', transformOrigin: '100% 0 0' };
|
||||
}
|
||||
if (i === 2) {
|
||||
return { transform: 'scale(50%)', transformOrigin: '0 100% 0' };
|
||||
}
|
||||
if (i === 3) {
|
||||
return { transform: 'scale(50%)', transformOrigin: '100% 100% 0' };
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`td-combined-avatar td-combined-avatar-${length}`}
|
||||
style={{
|
||||
width: px2rem(size),
|
||||
height: px2rem(size),
|
||||
borderRadius: shape === 'circle' ? '50%' : 3,
|
||||
}}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<Avatar
|
||||
key={i}
|
||||
className="td-combined-avatar__item"
|
||||
style={getCellStyle(i)}
|
||||
size={size}
|
||||
{...item}
|
||||
/>
|
||||
))}
|
||||
|
||||
{items.length >= 2 && <div className="line1" />}
|
||||
{items.length >= 3 && <div className="line2" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
CombinedAvatar.displayName = 'CombinedAvatar';
|
||||
120
client/packages/design/components/Avatar/index.stories.tsx
Normal file
120
client/packages/design/components/Avatar/index.stories.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
import type { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
import { Avatar } from '.';
|
||||
import { CombinedAvatar } from './combined';
|
||||
|
||||
export default {
|
||||
title: 'Tailchat/Avatar',
|
||||
component: Avatar,
|
||||
argTypes: {
|
||||
name: {
|
||||
description: '显示名称,用于无图片下的展示',
|
||||
},
|
||||
isOnline: {
|
||||
description: '是否在线, 可不传',
|
||||
},
|
||||
size: {
|
||||
description: '图标大小',
|
||||
type: 'number',
|
||||
},
|
||||
src: {
|
||||
description: '头像图片地址',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
} as ComponentMeta<typeof Avatar>;
|
||||
|
||||
const Template: ComponentStory<typeof Avatar> = (args) => <Avatar {...args} />;
|
||||
|
||||
export const normal = Template.bind({});
|
||||
normal.args = {
|
||||
name: 'Anonymous',
|
||||
};
|
||||
|
||||
export const withSize = Template.bind({});
|
||||
withSize.args = {
|
||||
name: 'Anonymous',
|
||||
size: 48,
|
||||
};
|
||||
|
||||
export const withOnline = Template.bind({});
|
||||
withOnline.args = {
|
||||
name: 'Anonymous',
|
||||
isOnline: true,
|
||||
};
|
||||
|
||||
export const withImage = Template.bind({});
|
||||
withImage.args = {
|
||||
name: 'Anonymous',
|
||||
src: 'http://dummyimage.com/50x50',
|
||||
};
|
||||
|
||||
const CombinedTemplate: ComponentStory<typeof CombinedAvatar> = (args) => (
|
||||
<div>
|
||||
<CombinedAvatar {...args} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const combined1 = CombinedTemplate.bind({});
|
||||
combined1.args = {
|
||||
size: 48,
|
||||
items: [
|
||||
{
|
||||
name: 'Anonymous',
|
||||
src: 'http://dummyimage.com/50x50',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const combined2 = CombinedTemplate.bind({});
|
||||
combined2.args = {
|
||||
size: 48,
|
||||
items: [
|
||||
{
|
||||
name: 'Anonymous',
|
||||
src: 'http://dummyimage.com/50x50',
|
||||
},
|
||||
{
|
||||
name: 'Anonymous',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const combined3 = CombinedTemplate.bind({});
|
||||
combined3.args = {
|
||||
size: 48,
|
||||
items: [
|
||||
{
|
||||
name: 'Anonymous',
|
||||
src: 'http://dummyimage.com/50x50',
|
||||
},
|
||||
{
|
||||
name: 'Anonymous',
|
||||
src: 'http://dummyimage.com/50x50',
|
||||
},
|
||||
{
|
||||
name: 'Anonymous',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const combined4 = CombinedTemplate.bind({});
|
||||
combined4.args = {
|
||||
size: 48,
|
||||
items: [
|
||||
{
|
||||
name: 'Anonymous',
|
||||
src: 'http://dummyimage.com/50x50',
|
||||
},
|
||||
{
|
||||
name: 'Anonymous',
|
||||
src: 'http://dummyimage.com/50x50',
|
||||
},
|
||||
{
|
||||
name: 'Anonymous',
|
||||
},
|
||||
{
|
||||
name: 'Anonymous',
|
||||
},
|
||||
],
|
||||
};
|
||||
106
client/packages/design/components/Avatar/index.tsx
Normal file
106
client/packages/design/components/Avatar/index.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React, { ForwardRefExoticComponent, useMemo } from 'react';
|
||||
import { Avatar as AntdAvatar, Badge } from 'antd';
|
||||
import _head from 'lodash/head';
|
||||
import _upperCase from 'lodash/upperCase';
|
||||
import _isNil from 'lodash/isNil';
|
||||
import _isEmpty from 'lodash/isEmpty';
|
||||
import _isNumber from 'lodash/isNumber';
|
||||
import _omit from 'lodash/omit';
|
||||
import type { AvatarProps as AntdAvatarProps } from 'antd/lib/avatar';
|
||||
import { getTextColorHex, px2rem } from './utils';
|
||||
import { isValidStr } from '../utils';
|
||||
import { imageUrlParser } from '../Image';
|
||||
|
||||
export { getTextColorHex };
|
||||
|
||||
export interface AvatarProps extends AntdAvatarProps {
|
||||
name?: string;
|
||||
isOnline?: boolean;
|
||||
}
|
||||
|
||||
const _Avatar: React.FC<AvatarProps> = React.memo((_props) => {
|
||||
const { isOnline, ...props } = _props;
|
||||
const src = isValidStr(props.src) ? imageUrlParser(props.src) : undefined;
|
||||
|
||||
const name = useMemo(() => _upperCase(_head(props.name)), [props.name]);
|
||||
|
||||
const color = useMemo(
|
||||
() =>
|
||||
// 如果src为空 且 icon为空 则给个固定颜色
|
||||
_isEmpty(src) && _isNil(props.icon)
|
||||
? getTextColorHex(props.name)
|
||||
: undefined,
|
||||
[src, props.icon, props.name]
|
||||
);
|
||||
|
||||
const style = useMemo(() => {
|
||||
const _style: React.CSSProperties = {
|
||||
userSelect: 'none',
|
||||
...props.style,
|
||||
backgroundColor: color,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
};
|
||||
|
||||
if (_isNumber(props.size)) {
|
||||
// 为了支持rem统一管理宽度,将size转换为样式宽度(size类型上不支持rem单位)
|
||||
if (!_style.width) {
|
||||
_style.width = px2rem(props.size);
|
||||
}
|
||||
if (!_style.height) {
|
||||
_style.height = px2rem(props.size);
|
||||
}
|
||||
|
||||
if (typeof _style.fontSize === 'undefined') {
|
||||
// 如果props.size是数字且没有指定文字大小
|
||||
// 则自动增加fontSize大小
|
||||
_style.fontSize = px2rem(props.size * 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
return _style;
|
||||
}, [props.style, props.size, color]);
|
||||
|
||||
const inner = (
|
||||
<AntdAvatar
|
||||
draggable={false}
|
||||
{..._omit(props, ['size'])}
|
||||
src={src}
|
||||
style={style}
|
||||
>
|
||||
{name}
|
||||
</AntdAvatar>
|
||||
);
|
||||
|
||||
if (typeof isOnline === 'boolean') {
|
||||
const style = {
|
||||
bottom: 0,
|
||||
top: 'auto',
|
||||
};
|
||||
|
||||
if (isOnline === true) {
|
||||
return (
|
||||
<Badge dot={true} color="green" style={style}>
|
||||
{inner}
|
||||
</Badge>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Badge dot={true} color="#999" style={style}>
|
||||
{inner}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return inner;
|
||||
});
|
||||
_Avatar.displayName = 'Avatar';
|
||||
|
||||
type CompoundedComponent = ForwardRefExoticComponent<AvatarProps> & {
|
||||
Group: typeof AntdAvatar.Group;
|
||||
};
|
||||
|
||||
export const Avatar: CompoundedComponent = _Avatar as any;
|
||||
Avatar.Group = AntdAvatar.Group;
|
||||
41
client/packages/design/components/Avatar/utils.ts
Normal file
41
client/packages/design/components/Avatar/utils.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import _isString from 'lodash/isString';
|
||||
import str2int from 'str2int';
|
||||
|
||||
const colors = [
|
||||
'#333333',
|
||||
'#2c3e50',
|
||||
'#8e44ad',
|
||||
'#2980b9',
|
||||
'#27ae60',
|
||||
'#16a085',
|
||||
'#f39c12',
|
||||
'#d35400',
|
||||
'#c0392b',
|
||||
'#3498db',
|
||||
'#9b59b6',
|
||||
'#2ecc71',
|
||||
'#1abc9c',
|
||||
'#f1c40f',
|
||||
'#e74c3c',
|
||||
'#e67e22',
|
||||
];
|
||||
|
||||
/**
|
||||
* 根据文本内容返回一个内置色卡的颜色
|
||||
* @param text 文本
|
||||
*/
|
||||
export function getTextColorHex(text: unknown): string {
|
||||
if (!text || !_isString(text)) {
|
||||
return '#ffffff'; // 如果获取不到文本,则返回白色
|
||||
}
|
||||
|
||||
const id = str2int(text);
|
||||
return colors[id % colors.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* 将像素转换为rem单位
|
||||
*/
|
||||
export function px2rem(size: number): string {
|
||||
return size * (1 / 16) + 'rem';
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import type { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
import { AvatarWithPreview } from '.';
|
||||
|
||||
export default {
|
||||
title: 'Tailchat/AvatarWithPreview',
|
||||
component: AvatarWithPreview,
|
||||
argTypes: {
|
||||
name: {
|
||||
description: '显示名称,用于无图片下的展示',
|
||||
},
|
||||
isOnline: {
|
||||
description: '是否在线, 可不传',
|
||||
},
|
||||
size: {
|
||||
description: '图标大小',
|
||||
type: 'number',
|
||||
},
|
||||
src: {
|
||||
description: '头像图片地址',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
} as ComponentMeta<typeof AvatarWithPreview>;
|
||||
|
||||
const Template: ComponentStory<typeof AvatarWithPreview> = (args) => (
|
||||
<AvatarWithPreview {...args} />
|
||||
);
|
||||
|
||||
export const NoImage = Template.bind({});
|
||||
NoImage.args = {
|
||||
name: 'Anonymous',
|
||||
};
|
||||
|
||||
export const withImage = Template.bind({});
|
||||
withImage.args = {
|
||||
name: 'Anonymous',
|
||||
src: 'http://dummyimage.com/50x50',
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Avatar, AvatarProps } from '../Avatar';
|
||||
import { Image, imageUrlParser } from '../Image';
|
||||
import { isValidStr } from '../utils';
|
||||
|
||||
export const AvatarWithPreview: React.FC<AvatarProps> = React.memo((props) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const hasImage = isValidStr(props.src);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
cursor: hasImage ? 'pointer' : 'auto',
|
||||
}}
|
||||
onClick={() => setVisible(!visible)}
|
||||
>
|
||||
<Avatar {...props} />
|
||||
</div>
|
||||
|
||||
{hasImage && (
|
||||
<div
|
||||
style={{
|
||||
display: 'none',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
preview={{
|
||||
visible,
|
||||
src: imageUrlParser(String(props.src)),
|
||||
onVisibleChange: (value) => {
|
||||
setVisible(value);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
AvatarWithPreview.displayName = 'AvatarWithPreview';
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import type { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
import { CopyableText } from '.';
|
||||
|
||||
export default {
|
||||
title: 'Tailchat/CopyableText',
|
||||
component: CopyableText,
|
||||
argTypes: {
|
||||
config: {
|
||||
description: '见: https://ant.design/components/typography-cn#copyable',
|
||||
defaultValue: true,
|
||||
},
|
||||
},
|
||||
} as ComponentMeta<typeof CopyableText>;
|
||||
|
||||
const Template: ComponentStory<typeof CopyableText> = (args) => (
|
||||
<div>
|
||||
可复制文本: <CopyableText {...args} />
|
||||
<- 点击此处复制
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {
|
||||
children: 'Foo',
|
||||
};
|
||||
|
||||
export const WithConfig = Template.bind({});
|
||||
WithConfig.args = {
|
||||
children: 'Foo',
|
||||
config: {
|
||||
text: 'Bar',
|
||||
},
|
||||
};
|
||||
25
client/packages/design/components/CopyableText/index.tsx
Normal file
25
client/packages/design/components/CopyableText/index.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { Typography } from 'antd';
|
||||
import type { BlockProps } from 'antd/lib/typography/Base';
|
||||
|
||||
interface CopyableTextProps extends React.PropsWithChildren {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
config?: BlockProps['copyable'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 可复制的文本
|
||||
*/
|
||||
export const CopyableText: React.FC<CopyableTextProps> = React.memo((props) => {
|
||||
return (
|
||||
<Typography.Text
|
||||
className={props.className}
|
||||
style={props.style}
|
||||
copyable={props.config ?? true}
|
||||
>
|
||||
{props.children}
|
||||
</Typography.Text>
|
||||
);
|
||||
});
|
||||
CopyableText.displayName = 'CopyableText';
|
||||
19
client/packages/design/components/DelayTip/index.stories.tsx
Normal file
19
client/packages/design/components/DelayTip/index.stories.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
|
||||
import { DelayTip } from '.';
|
||||
|
||||
export default {
|
||||
title: 'Tailchat/DelayTip',
|
||||
component: DelayTip,
|
||||
argTypes: {},
|
||||
} as ComponentMeta<typeof DelayTip>;
|
||||
|
||||
const Template: ComponentStory<typeof DelayTip> = (args) => (
|
||||
<DelayTip {...args}>鼠标移动到这里等待1s</DelayTip>
|
||||
);
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {
|
||||
title: 'Hello World',
|
||||
};
|
||||
14
client/packages/design/components/DelayTip/index.tsx
Normal file
14
client/packages/design/components/DelayTip/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Tooltip, TooltipProps } from 'antd';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* 延时提示
|
||||
*/
|
||||
export const DelayTip: React.FC<TooltipProps> = React.memo((props) => {
|
||||
return (
|
||||
<Tooltip mouseEnterDelay={1} {...props}>
|
||||
{props.children}
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
DelayTip.displayName = 'DelayTip';
|
||||
@@ -0,0 +1,8 @@
|
||||
.highLight {
|
||||
background-color: rgba(0,0,0,0.2);
|
||||
border-radius: 0.25rem;
|
||||
padding-top: 0.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
7
client/packages/design/components/Highlight/index.tsx
Normal file
7
client/packages/design/components/Highlight/index.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import styles from './index.module.less';
|
||||
|
||||
export const Highlight: React.FC<PropsWithChildren> = React.memo((props) => {
|
||||
return <span className={styles.highLight}>{props.children}</span>;
|
||||
});
|
||||
Highlight.displayName = 'Highlight';
|
||||
31
client/packages/design/components/Icon/index.tsx
Normal file
31
client/packages/design/components/Icon/index.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Icon as Iconify,
|
||||
IconProps,
|
||||
addIcon,
|
||||
addCollection,
|
||||
} from '@iconify/react';
|
||||
|
||||
const placeHolderStyle = { width: '1em', height: '1em' };
|
||||
|
||||
const InternalIcon: React.FC<Omit<IconProps, 'ref'>> = React.memo((props) => {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Iconify {...props} onLoad={() => setLoaded(true)} />
|
||||
{!loaded && <span style={placeHolderStyle} />}
|
||||
</>
|
||||
);
|
||||
});
|
||||
InternalIcon.displayName = 'Icon';
|
||||
|
||||
type CompoundedComponent = React.FC<Omit<IconProps, 'ref'>> & {
|
||||
addIcon: typeof addIcon;
|
||||
addCollection: typeof addCollection;
|
||||
};
|
||||
|
||||
export const Icon = InternalIcon as CompoundedComponent;
|
||||
|
||||
Icon.addIcon = addIcon;
|
||||
Icon.addCollection = addCollection;
|
||||
37
client/packages/design/components/Image/index.stories.tsx
Normal file
37
client/packages/design/components/Image/index.stories.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
|
||||
import { Image } from '.';
|
||||
|
||||
// More on default export: https://storybook.js.org/docs/react/writing-stories/introduction#default-export
|
||||
export default {
|
||||
title: 'Tailchat/Image',
|
||||
component: Image,
|
||||
// More on argTypes: https://storybook.js.org/docs/react/api/argtypes
|
||||
argTypes: {},
|
||||
} as ComponentMeta<typeof Image>;
|
||||
|
||||
// More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args
|
||||
const Template: ComponentStory<typeof Image> = (args) => (
|
||||
<>
|
||||
<Image
|
||||
src={'https://source.unsplash.com/collection/94734566/1920x1080'}
|
||||
{...args}
|
||||
/>
|
||||
<div>一个简单的图片, 自带Fallback机制</div>
|
||||
</>
|
||||
);
|
||||
|
||||
export const Default = Template.bind({});
|
||||
// More on args: https://storybook.js.org/docs/react/writing-stories/args
|
||||
Default.args = {
|
||||
preview: true,
|
||||
width: 640,
|
||||
height: 360,
|
||||
};
|
||||
|
||||
export const Fallback = Template.bind({});
|
||||
// More on args: https://storybook.js.org/docs/react/writing-stories/args
|
||||
Fallback.args = {
|
||||
src: '',
|
||||
};
|
||||
24
client/packages/design/components/Image/index.tsx
Normal file
24
client/packages/design/components/Image/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { Image as AntdImage, ImageProps as AntdImageProps } from 'antd';
|
||||
|
||||
export let imageUrlParser = (url: string) => url;
|
||||
|
||||
const fallback =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMIAAADDCAYAAADQvc6UAAABRWlDQ1BJQ0MgUHJvZmlsZQAAKJFjYGASSSwoyGFhYGDIzSspCnJ3UoiIjFJgf8LAwSDCIMogwMCcmFxc4BgQ4ANUwgCjUcG3awyMIPqyLsis7PPOq3QdDFcvjV3jOD1boQVTPQrgSkktTgbSf4A4LbmgqISBgTEFyFYuLykAsTuAbJEioKOA7DkgdjqEvQHEToKwj4DVhAQ5A9k3gGyB5IxEoBmML4BsnSQk8XQkNtReEOBxcfXxUQg1Mjc0dyHgXNJBSWpFCYh2zi+oLMpMzyhRcASGUqqCZ16yno6CkYGRAQMDKMwhqj/fAIcloxgHQqxAjIHBEugw5sUIsSQpBobtQPdLciLEVJYzMPBHMDBsayhILEqEO4DxG0txmrERhM29nYGBddr//5/DGRjYNRkY/l7////39v///y4Dmn+LgeHANwDrkl1AuO+pmgAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAwqADAAQAAAABAAAAwwAAAAD9b/HnAAAHlklEQVR4Ae3dP3PTWBSGcbGzM6GCKqlIBRV0dHRJFarQ0eUT8LH4BnRU0NHR0UEFVdIlFRV7TzRksomPY8uykTk/zewQfKw/9znv4yvJynLv4uLiV2dBoDiBf4qP3/ARuCRABEFAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghgg0Aj8i0JO4OzsrPv69Wv+hi2qPHr0qNvf39+iI97soRIh4f3z58/u7du3SXX7Xt7Z2enevHmzfQe+oSN2apSAPj09TSrb+XKI/f379+08+A0cNRE2ANkupk+ACNPvkSPcAAEibACyXUyfABGm3yNHuAECRNgAZLuYPgEirKlHu7u7XdyytGwHAd8jjNyng4OD7vnz51dbPT8/7z58+NB9+/bt6jU/TI+AGWHEnrx48eJ/EsSmHzx40L18+fLyzxF3ZVMjEyDCiEDjMYZZS5wiPXnyZFbJaxMhQIQRGzHvWR7XCyOCXsOmiDAi1HmPMMQjDpbpEiDCiL358eNHurW/5SnWdIBbXiDCiA38/Pnzrce2YyZ4//59F3ePLNMl4PbpiL2J0L979+7yDtHDhw8vtzzvdGnEXdvUigSIsCLAWavHp/+qM0BcXMd/q25n1vF57TYBp0a3mUzilePj4+7k5KSLb6gt6ydAhPUzXnoPR0dHl79WGTNCfBnn1uvSCJdegQhLI1vvCk+fPu2ePXt2tZOYEV6/fn31dz+shwAR1sP1cqvLntbEN9MxA9xcYjsxS1jWR4AIa2Ibzx0tc44fYX/16lV6NDFLXH+YL32jwiACRBiEbf5KcXoTIsQSpzXx4N28Ja4BQoK7rgXiydbHjx/P25TaQAJEGAguWy0+2Q8PD6/Ki4R8EVl+bzBOnZY95fq9rj9zAkTI2SxdidBHqG9+skdw43borCXO/ZcJdraPWdv22uIEiLA4q7nvvCug8WTqzQveOH26fodo7g6uFe/a17W3+nFBAkRYENRdb1vkkz1CH9cPsVy/jrhr27PqMYvENYNlHAIesRiBYwRy0V+8iXP8+/fvX11Mr7L7ECueb/r48eMqm7FuI2BGWDEG8cm+7G3NEOfmdcTQw4h9/55lhm7DekRYKQPZF2ArbXTAyu4kDYB2YxUzwg0gi/41ztHnfQG26HbGel/crVrm7tNY+/1btkOEAZ2M05r4FB7r9GbAIdxaZYrHdOsgJ/wCEQY0J74TmOKnbxxT9n3FgGGWWsVdowHtjt9Nnvf7yQM2aZU/TIAIAxrw6dOnAWtZZcoEnBpNuTuObWMEiLAx1HY0ZQJEmHJ3HNvGCBBhY6jtaMoEiJB0Z29vL6ls58vxPcO8/zfrdo5qvKO+d3Fx8Wu8zf1dW4p/cPzLly/dtv9Ts/EbcvGAHhHyfBIhZ6NSiIBTo0LNNtScABFyNiqFCBChULMNNSdAhJyNSiECRCjUbEPNCRAhZ6NSiAARCjXbUHMCRMjZqBQiQIRCzTbUnAARcjYqhQgQoVCzDTUnQIScjUohAkQo1GxDzQkQIWejUogAEQo121BzAkTI2agUIkCEQs021JwAEXI2KoUIEKFQsw01J0CEnI1KIQJEKNRsQ80JECFno1KIABEKNdtQcwJEyNmoFCJAhELNNtScABFyNiqFCBChULMNNSdAhJyNSiECRCjUbEPNCRAhZ6NSiAARCjXbUHMCRMjZqBQiQIRCzTbUnAARcjYqhQgQoVCzDTUnQIScjUohAkQo1GxDzQkQIWejUogAEQo121BzAkTI2agUIkCEQs021JwAEXI2KoUIEKFQsw01J0CEnI1KIQJEKNRsQ80JECFno1KIABEKNdtQcwJEyNmoFCJAhELNNtScABFyNiqFCBChULMNNSdAhJyNSiECRCjUbEPNCRAhZ6NSiAARCjXbUHMCRMjZqBQiQIRCzTbUnAARcjYqhQgQoVCzDTUnQIScjUohAkQo1GxDzQkQIWejUogAEQo121BzAkTI2agUIkCEQs021JwAEXI2KoUIEKFQsw01J0CEnI1KIQJEKNRsQ80JECFno1KIABEKNdtQcwJEyNmoFCJAhELNNtScABFyNiqFCBChULMNNSdAhJyNSiEC/wGgKKC4YMA4TAAAAABJRU5ErkJggg==';
|
||||
|
||||
export const Image: React.FC<AntdImageProps> = React.memo((props) => {
|
||||
return (
|
||||
<AntdImage
|
||||
fallback={fallback}
|
||||
preview={false}
|
||||
loading="lazy"
|
||||
{...props}
|
||||
src={props.src ? imageUrlParser(props.src) : undefined}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Image.displayName = 'Image';
|
||||
|
||||
export function setImageUrlParser(parser: (url: string) => string): void {
|
||||
imageUrlParser = parser;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<Meta title="Tailchat/Introduction" />
|
||||
|
||||
# Tailchat Design
|
||||
|
||||
Tailchat Design 是 msgbyte 专为 Tailchat 打造的一套设计样式,基于 `antd` 之上做了一层封装
|
||||
51
client/packages/design/components/RichEditor/bbcode.ts
Normal file
51
client/packages/design/components/RichEditor/bbcode.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { isArray, ObjectMark, RemirrorJSON } from 'remirror';
|
||||
|
||||
/**
|
||||
* 转换成BBCode
|
||||
*/
|
||||
export function transformToBBCode(json: RemirrorJSON): string {
|
||||
if (json.type === 'doc') {
|
||||
return (json.content ?? []).map(transformToBBCode).join('\n');
|
||||
}
|
||||
|
||||
if (json.type === 'paragraph') {
|
||||
return (json.content ?? []).map(transformToBBCode).join('');
|
||||
}
|
||||
|
||||
if (json.type === 'text') {
|
||||
let text = json.text ?? '';
|
||||
|
||||
if (isArray(json.marks)) {
|
||||
(json.marks ?? []).forEach((mark) => {
|
||||
if (typeof mark === 'string') {
|
||||
mark = { type: mark };
|
||||
}
|
||||
text = applyMarks(mark, text);
|
||||
});
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 为text增加mark包裹
|
||||
*/
|
||||
function applyMarks(mark: ObjectMark, text: string): string {
|
||||
if (mark.type === 'bold') {
|
||||
return `[b]${text}[/b]`;
|
||||
}
|
||||
if (mark.type === 'underline') {
|
||||
return `[u]${text}[/u]`;
|
||||
}
|
||||
if (mark.type === 'italic') {
|
||||
return `[i]${text}[/i]`;
|
||||
}
|
||||
if (mark.type === 'code') {
|
||||
return `[code]${text}[/code]`;
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
12
client/packages/design/components/RichEditor/editor.css
Normal file
12
client/packages/design/components/RichEditor/editor.css
Normal file
@@ -0,0 +1,12 @@
|
||||
.remirror-editor-wrapper {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tailchat-rich-editor {
|
||||
height: 100%;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.tailchat-rich-editor p {
|
||||
margin: 0;
|
||||
}
|
||||
44
client/packages/design/components/RichEditor/editor.tsx
Normal file
44
client/packages/design/components/RichEditor/editor.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Remirror,
|
||||
useRemirror,
|
||||
OnChangeJSON,
|
||||
EditorComponent,
|
||||
} from '@remirror/react';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import type { RemirrorJSON } from 'remirror';
|
||||
import { Toolbar } from './toolbar';
|
||||
import { extensions } from './extensions';
|
||||
import { transformToBBCode } from './bbcode';
|
||||
import './editor.css';
|
||||
|
||||
interface RichEditorProps extends React.PropsWithChildren {
|
||||
initContent: string;
|
||||
onChange: (bbcode: string) => void;
|
||||
}
|
||||
export const RichEditor: React.FC<RichEditorProps> = React.memo((props) => {
|
||||
const { manager, state } = useRemirror({
|
||||
extensions,
|
||||
content: props.initContent,
|
||||
stringHandler: 'html',
|
||||
selection: 'end',
|
||||
});
|
||||
|
||||
const handleChange = useMemoizedFn((json: RemirrorJSON) => {
|
||||
props.onChange(transformToBBCode(json));
|
||||
});
|
||||
|
||||
return (
|
||||
<Remirror
|
||||
classNames={['tailchat-rich-editor']}
|
||||
manager={manager}
|
||||
initialContent={state}
|
||||
>
|
||||
<Toolbar />
|
||||
<EditorComponent />
|
||||
<OnChangeJSON onChange={handleChange} />
|
||||
{props.children}
|
||||
</Remirror>
|
||||
);
|
||||
});
|
||||
RichEditor.displayName = 'RichEditor';
|
||||
@@ -0,0 +1,16 @@
|
||||
import {
|
||||
BoldExtension,
|
||||
CodeExtension,
|
||||
ItalicExtension,
|
||||
UnderlineExtension,
|
||||
} from 'remirror/extensions';
|
||||
|
||||
/**
|
||||
* 富文本编辑器使用的拓展
|
||||
*/
|
||||
export const extensions = () => [
|
||||
new BoldExtension(),
|
||||
new ItalicExtension(),
|
||||
new UnderlineExtension(),
|
||||
new CodeExtension(),
|
||||
];
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
import { RichEditor } from './editor';
|
||||
|
||||
// More on default export: https://storybook.js.org/docs/react/writing-stories/introduction#default-export
|
||||
export default {
|
||||
title: 'Tailchat/RichEditor',
|
||||
component: RichEditor,
|
||||
// More on argTypes: https://storybook.js.org/docs/react/api/argtypes
|
||||
argTypes: {},
|
||||
} as ComponentMeta<typeof RichEditor>;
|
||||
|
||||
// More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args
|
||||
const Template: ComponentStory<typeof RichEditor> = (args) => (
|
||||
<div style={{ height: 1000, width: '100%' }}>
|
||||
<RichEditor {...args} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Default = Template.bind({});
|
||||
// More on args: https://storybook.js.org/docs/react/writing-stories/args
|
||||
Default.args = {
|
||||
initContent: '<p>Hi <strong>Friend</strong></p>',
|
||||
};
|
||||
8
client/packages/design/components/RichEditor/index.tsx
Normal file
8
client/packages/design/components/RichEditor/index.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
/**
|
||||
* 富文本编辑器
|
||||
*/
|
||||
|
||||
export const RichEditor = React.lazy(() =>
|
||||
import('./editor').then((module) => ({ default: module.RichEditor }))
|
||||
);
|
||||
26
client/packages/design/components/RichEditor/toolbar.tsx
Normal file
26
client/packages/design/components/RichEditor/toolbar.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
FloatingToolbar,
|
||||
CommandButtonGroup,
|
||||
ToggleBoldButton,
|
||||
ToggleItalicButton,
|
||||
ToggleUnderlineButton,
|
||||
ToggleCodeButton,
|
||||
} from '@remirror/react';
|
||||
|
||||
/**
|
||||
* 菜单
|
||||
*/
|
||||
export const Toolbar: React.FC = React.memo(() => {
|
||||
return (
|
||||
<FloatingToolbar>
|
||||
<CommandButtonGroup>
|
||||
<ToggleBoldButton />
|
||||
<ToggleItalicButton />
|
||||
<ToggleUnderlineButton />
|
||||
<ToggleCodeButton />
|
||||
</CommandButtonGroup>
|
||||
</FloatingToolbar>
|
||||
);
|
||||
});
|
||||
Toolbar.displayName = 'Toolbar';
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
import { SensitiveText } from '.';
|
||||
|
||||
// More on default export: https://storybook.js.org/docs/react/writing-stories/introduction#default-export
|
||||
export default {
|
||||
title: 'Tailchat/SensitiveText',
|
||||
component: SensitiveText,
|
||||
// More on argTypes: https://storybook.js.org/docs/react/api/argtypes
|
||||
argTypes: {},
|
||||
} as ComponentMeta<typeof SensitiveText>;
|
||||
|
||||
// More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args
|
||||
const Template: ComponentStory<typeof SensitiveText> = (args) => (
|
||||
<SensitiveText {...args} />
|
||||
);
|
||||
|
||||
export const Default = Template.bind({});
|
||||
// More on args: https://storybook.js.org/docs/react/writing-stories/args
|
||||
Default.args = {
|
||||
text: 'fooooo',
|
||||
};
|
||||
40
client/packages/design/components/SensitiveText/index.tsx
Normal file
40
client/packages/design/components/SensitiveText/index.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Icon } from '../Icon';
|
||||
|
||||
interface SensitiveTextProps {
|
||||
className?: string;
|
||||
text: string;
|
||||
}
|
||||
export const SensitiveText: React.FC<SensitiveTextProps> = React.memo(
|
||||
(props) => {
|
||||
const { className, text } = props;
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{ display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
{show ? text : getMaskedText(text)}
|
||||
|
||||
<Icon
|
||||
style={{ cursor: 'pointer', marginLeft: 4 }}
|
||||
icon={show ? 'mdi:eye-off-outline' : 'mdi:eye-outline'}
|
||||
onClick={() => setShow((before) => !before)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
SensitiveText.displayName = 'SensitiveText';
|
||||
|
||||
function getMaskedText(text: string) {
|
||||
const len = text.length;
|
||||
if (len > 2) {
|
||||
return `${text[0]}****${text[len - 1]}`;
|
||||
} else if (len === 2) {
|
||||
return `${text[0]}*`;
|
||||
} else {
|
||||
return '**';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
WIP: Warning
|
||||
|
||||
可变高度的虚拟列表
|
||||
|
||||
专为聊天场景打造的
|
||||
@@ -0,0 +1,61 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
|
||||
type Size = { height: number; width: number };
|
||||
|
||||
interface ResizeWatcherProps extends React.PropsWithChildren {
|
||||
wrapperStyle?: React.CSSProperties;
|
||||
onResize?: (size: Size) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当容器大小发生变化时
|
||||
* 触发回调
|
||||
*/
|
||||
export const ResizeWatcher: React.FC<ResizeWatcherProps> = React.memo(
|
||||
(props) => {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleResize = useMemoizedFn((size: Size) => {
|
||||
if (props.onResize) {
|
||||
props.onResize(size);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!rootRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
const { target, contentRect } = entry;
|
||||
if (!target.parentElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用 contentRect 计算大小以确保不会出现使用clientHeight立即向浏览器请求dom大小导致的性能问题
|
||||
handleResize({
|
||||
width: Math.round(contentRect.width),
|
||||
height: Math.round(contentRect.height),
|
||||
});
|
||||
});
|
||||
});
|
||||
resizeObserver.observe(rootRef.current);
|
||||
|
||||
return () => {
|
||||
if (resizeObserver && rootRef.current) {
|
||||
resizeObserver.unobserve(rootRef.current);
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={props.wrapperStyle} ref={rootRef}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
ResizeWatcher.displayName = 'ResizeWatcher';
|
||||
190
client/packages/design/components/VirtualChatList/Scroller.tsx
Normal file
190
client/packages/design/components/VirtualChatList/Scroller.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import React, {
|
||||
PropsWithChildren,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { ResizeWatcher } from './ResizeWatcher';
|
||||
import { useMemoizedFn, useDebounceFn, usePrevious } from 'ahooks';
|
||||
import type { IsForward, Vector } from './types';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface ScrollerRef {
|
||||
scrollTo: (position: Vector) => void;
|
||||
scrollToBottom: () => void;
|
||||
}
|
||||
|
||||
type ScrollerProps = PropsWithChildren<{
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
innerStyle?: React.CSSProperties;
|
||||
isLock?: boolean;
|
||||
scrollingClassName?: string;
|
||||
scrollBehavior?: ScrollBehavior;
|
||||
onScroll?: (
|
||||
position: Vector,
|
||||
detail: {
|
||||
forward: IsForward;
|
||||
isUserScrolling: boolean;
|
||||
isMouseDown: boolean;
|
||||
}
|
||||
) => void;
|
||||
onScrollEnd?: (position: Vector) => void;
|
||||
onContainerResize?: (info: {
|
||||
containerSize: Vector;
|
||||
position: Vector;
|
||||
}) => void;
|
||||
}>;
|
||||
|
||||
const DEFAULT_POS = { x: 0, y: 0 };
|
||||
|
||||
/**
|
||||
* 滚动状态管理组件
|
||||
*/
|
||||
export const Scroller = React.forwardRef<ScrollerRef, ScrollerProps>(
|
||||
(props, ref) => {
|
||||
const { scrollBehavior = 'auto' } = props;
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
const style = useMemo(() => {
|
||||
if (props.isLock ?? false) {
|
||||
return { ...props.style, overflow: 'hidden' };
|
||||
}
|
||||
|
||||
return { ...props.style, overflow: 'auto' };
|
||||
}, [props.isLock]);
|
||||
|
||||
const getPosition = useMemoizedFn(() => {
|
||||
if (!wrapperRef.current) {
|
||||
return DEFAULT_POS;
|
||||
}
|
||||
return {
|
||||
x: wrapperRef.current.scrollLeft,
|
||||
y: wrapperRef.current.scrollTop,
|
||||
};
|
||||
});
|
||||
|
||||
const getContainerSize = useMemoizedFn(() => {
|
||||
if (!wrapperRef.current) {
|
||||
return DEFAULT_POS;
|
||||
}
|
||||
return {
|
||||
x: wrapperRef.current.clientWidth,
|
||||
y: wrapperRef.current.clientHeight,
|
||||
};
|
||||
});
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollTo: (position) => {
|
||||
wrapperRef.current?.scrollTo({
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
behavior: scrollBehavior,
|
||||
});
|
||||
},
|
||||
scrollToBottom: () => {
|
||||
wrapperRef.current?.scrollTo({
|
||||
left: getPosition().x,
|
||||
top: wrapperRef.current.scrollHeight - getContainerSize().y,
|
||||
behavior: scrollBehavior,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const [isScroll, setIsScroll] = useState(false);
|
||||
const [isMouseDown, setIsMouseDown] = useState(false);
|
||||
const { run: setIsScrollLazy } = useDebounceFn(
|
||||
(val) => {
|
||||
setIsScroll(val);
|
||||
},
|
||||
{
|
||||
leading: false,
|
||||
trailing: true,
|
||||
wait: 300,
|
||||
}
|
||||
);
|
||||
|
||||
const { run: handleEndScrollLazy } = useDebounceFn(
|
||||
() => {
|
||||
setIsScroll(false);
|
||||
if (props.onScrollEnd) {
|
||||
props.onScrollEnd(getPosition());
|
||||
}
|
||||
},
|
||||
{
|
||||
leading: false,
|
||||
trailing: true,
|
||||
wait: 300,
|
||||
}
|
||||
);
|
||||
|
||||
const handleWheel = useMemoizedFn(() => {
|
||||
setIsScroll(true);
|
||||
setIsScrollLazy(false);
|
||||
});
|
||||
|
||||
const handleMouseDown = useMemoizedFn(() => {
|
||||
setIsMouseDown(true);
|
||||
});
|
||||
|
||||
const handleMouseUp = useMemoizedFn(() => {
|
||||
setIsMouseDown(false);
|
||||
});
|
||||
|
||||
const prevPosition = usePrevious(getPosition()) ?? DEFAULT_POS;
|
||||
const handleScroll = useMemoizedFn(() => {
|
||||
const isUserScrolling = isScroll || isMouseDown;
|
||||
const currentPosition = getPosition();
|
||||
const forward = {
|
||||
x: currentPosition.x > prevPosition.x,
|
||||
y: currentPosition.y > prevPosition.y,
|
||||
};
|
||||
setIsScroll(true);
|
||||
handleEndScrollLazy();
|
||||
if (props.onScroll) {
|
||||
props.onScroll(currentPosition, {
|
||||
forward,
|
||||
isUserScrolling,
|
||||
isMouseDown: isMouseDown,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const handleResize = useMemoizedFn(() => {
|
||||
if (props.onContainerResize) {
|
||||
props.onContainerResize({
|
||||
containerSize: getContainerSize(),
|
||||
position: getPosition(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<ResizeWatcher wrapperStyle={{ height: '100%' }} onResize={handleResize}>
|
||||
<div
|
||||
key="scroller"
|
||||
className={clsx(props.className, 'scroller', {
|
||||
[props.scrollingClassName ?? 'scrolling']: isScroll,
|
||||
})}
|
||||
style={style}
|
||||
ref={wrapperRef}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div
|
||||
className="scroller-inner"
|
||||
key="scroller-inner"
|
||||
style={props.innerStyle}
|
||||
ref={innerRef}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
</ResizeWatcher>
|
||||
);
|
||||
}
|
||||
);
|
||||
Scroller.displayName = 'Scroller';
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
import { VirtualChatList } from '.';
|
||||
|
||||
// More on default export: https://storybook.js.org/docs/react/writing-stories/introduction#default-export
|
||||
export default {
|
||||
title: 'Tailchat/VirtualChatList',
|
||||
component: VirtualChatList,
|
||||
// More on argTypes: https://storybook.js.org/docs/react/api/argtypes
|
||||
argTypes: {},
|
||||
} as ComponentMeta<typeof VirtualChatList>;
|
||||
|
||||
// More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args
|
||||
const Template: ComponentStory<typeof VirtualChatList> = (args) => (
|
||||
<VirtualChatList {...args} />
|
||||
);
|
||||
|
||||
export const Default = Template.bind({});
|
||||
// More on args: https://storybook.js.org/docs/react/writing-stories/args
|
||||
Default.args = {
|
||||
text: 'fooooo',
|
||||
};
|
||||
82
client/packages/design/components/VirtualChatList/index.tsx
Normal file
82
client/packages/design/components/VirtualChatList/index.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import { ResizeWatcher } from './ResizeWatcher';
|
||||
import { Scroller, ScrollerRef } from './Scroller';
|
||||
import { useUpdate } from 'ahooks';
|
||||
|
||||
interface VirtualChatListProps<ItemType> {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
innerStyle?: React.CSSProperties;
|
||||
getItemKey?: (item: ItemType) => string;
|
||||
items: ItemType[];
|
||||
itemContent: (item: ItemType, index: number) => React.ReactNode;
|
||||
}
|
||||
|
||||
const defaultContainerStyle: React.CSSProperties = {
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
const defaultInnerStyle: React.CSSProperties = {
|
||||
height: '100%',
|
||||
};
|
||||
|
||||
const scrollerStyle: React.CSSProperties = {
|
||||
height: '100%',
|
||||
};
|
||||
|
||||
const InternalVirtualChatList = <ItemType extends object>(
|
||||
props: VirtualChatListProps<ItemType>
|
||||
) => {
|
||||
const scrollerRef = useRef<ScrollerRef>(null);
|
||||
const itemHeightCache = useMemo(() => new Map<ItemType, number>(), []);
|
||||
const forceUpdate = useUpdate();
|
||||
const style = useMemo(
|
||||
() => ({
|
||||
...defaultContainerStyle,
|
||||
...props.style,
|
||||
}),
|
||||
[props.style]
|
||||
);
|
||||
const innerStyle = useMemo(
|
||||
() => ({
|
||||
...defaultInnerStyle,
|
||||
...props.innerStyle,
|
||||
}),
|
||||
[props.innerStyle]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// 挂载后滚动到底部
|
||||
scrollerRef.current?.scrollToBottom();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="virtual-chat-list" style={style}>
|
||||
<Scroller ref={scrollerRef} style={scrollerStyle} innerStyle={innerStyle}>
|
||||
{props.items.map((item, i) => (
|
||||
<div
|
||||
key={props.getItemKey ? props.getItemKey(item) : i}
|
||||
className="virtual-chat-list__item"
|
||||
style={{ height: itemHeightCache.get(item) }}
|
||||
>
|
||||
<ResizeWatcher
|
||||
onResize={(size) => {
|
||||
itemHeightCache.set(item, size.height);
|
||||
forceUpdate();
|
||||
}}
|
||||
>
|
||||
{props.itemContent(item, i)}
|
||||
</ResizeWatcher>
|
||||
</div>
|
||||
))}
|
||||
</Scroller>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type VirtualChatListInterface = typeof InternalVirtualChatList & React.FC;
|
||||
|
||||
export const VirtualChatList: VirtualChatListInterface = React.memo(
|
||||
InternalVirtualChatList
|
||||
) as any;
|
||||
VirtualChatList.displayName = 'VirtualChatList';
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface Vector {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface IsForward {
|
||||
x: boolean;
|
||||
y: boolean;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getValidateStatus } from '../utils';
|
||||
|
||||
describe('getValidateStatus', () => {
|
||||
test('enter undefined', () => {
|
||||
const status = getValidateStatus(undefined);
|
||||
|
||||
expect(status).toBe('');
|
||||
});
|
||||
|
||||
test('enter empty string', () => {
|
||||
const status = getValidateStatus('');
|
||||
|
||||
expect(status).toBe('');
|
||||
});
|
||||
|
||||
test('enter string', () => {
|
||||
const status = getValidateStatus('any string');
|
||||
|
||||
expect(status).toBe('error');
|
||||
});
|
||||
});
|
||||
87
client/packages/design/components/WebMetaForm/index.tsx
Normal file
87
client/packages/design/components/WebMetaForm/index.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
FastifyForm,
|
||||
regField,
|
||||
FastifyFormContainerComponent,
|
||||
regFormContainer,
|
||||
} from 'react-fastify-form';
|
||||
import { Form, Button } from 'antd';
|
||||
|
||||
import { FastifyFormText } from './types/Text';
|
||||
import { FastifyFormTextArea } from './types/TextArea';
|
||||
import { FastifyFormPassword } from './types/Password';
|
||||
import { FastifyFormSelect } from './types/Select';
|
||||
import { FastifyFormCheckbox } from './types/Checkbox';
|
||||
import { FastifyFormCustom } from './types/Custom';
|
||||
|
||||
regField('text', FastifyFormText);
|
||||
regField('textarea', FastifyFormTextArea);
|
||||
regField('password', FastifyFormPassword);
|
||||
regField('select', FastifyFormSelect);
|
||||
regField('checkbox', FastifyFormCheckbox);
|
||||
regField('custom', FastifyFormCustom);
|
||||
|
||||
let webFastifyFormConfig = {
|
||||
submitLabel: 'Submit',
|
||||
};
|
||||
|
||||
export function setWebFastifyFormConfig(config: typeof webFastifyFormConfig) {
|
||||
webFastifyFormConfig = {
|
||||
...webFastifyFormConfig,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
const WebFastifyFormContainer: FastifyFormContainerComponent = React.memo(
|
||||
(props) => {
|
||||
const layout = props.layout;
|
||||
const suffixElement = props.extraProps?.suffixElement;
|
||||
|
||||
const submitButtonRender = useMemo(() => {
|
||||
return (
|
||||
<Form.Item
|
||||
wrapperCol={
|
||||
layout === 'vertical'
|
||||
? { xs: 24 }
|
||||
: { sm: 24, md: { span: 16, offset: 8 } }
|
||||
}
|
||||
>
|
||||
<Button
|
||||
loading={props.loading}
|
||||
type="primary"
|
||||
size="large"
|
||||
htmlType="button"
|
||||
style={{ width: '100%' }}
|
||||
onClick={() => props.handleSubmit()}
|
||||
disabled={props.canSubmit === false}
|
||||
>
|
||||
{props.submitLabel ?? webFastifyFormConfig.submitLabel}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
);
|
||||
}, [
|
||||
props.loading,
|
||||
props.handleSubmit,
|
||||
props.canSubmit,
|
||||
props.submitLabel,
|
||||
layout,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Form
|
||||
layout={layout}
|
||||
labelCol={layout === 'vertical' ? { xs: 24 } : { sm: 24, md: 8 }}
|
||||
wrapperCol={layout === 'vertical' ? { xs: 24 } : { sm: 24, md: 16 }}
|
||||
>
|
||||
{props.children}
|
||||
{suffixElement}
|
||||
{submitButtonRender}
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
);
|
||||
WebFastifyFormContainer.displayName = 'WebFastifyFormContainer';
|
||||
regFormContainer(WebFastifyFormContainer);
|
||||
|
||||
export const WebMetaForm = FastifyForm;
|
||||
(WebMetaForm as any).displayName = 'WebMetaForm';
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { Form, Checkbox } from 'antd';
|
||||
import type { FastifyFormFieldComponent } from 'react-fastify-form';
|
||||
import { getValidateStatus } from '../utils';
|
||||
|
||||
export const FastifyFormCheckbox: FastifyFormFieldComponent = React.memo(
|
||||
(props) => {
|
||||
const { name, label, value, onChange, error } = props;
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
label={label}
|
||||
validateStatus={getValidateStatus(error)}
|
||||
help={error}
|
||||
>
|
||||
<Checkbox
|
||||
name={name}
|
||||
checked={Boolean(value)}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
>
|
||||
{label}
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
);
|
||||
FastifyFormCheckbox.displayName = 'FastifyFormCheckbox';
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { Form } from 'antd';
|
||||
import type {
|
||||
FastifyFormFieldComponent,
|
||||
FastifyFormFieldProps,
|
||||
} from 'react-fastify-form';
|
||||
import { CustomField } from 'react-fastify-form';
|
||||
|
||||
export const FastifyFormCustom: FastifyFormFieldComponent<{
|
||||
render: (props: FastifyFormFieldProps) => React.ReactNode;
|
||||
}> = React.memo((props) => {
|
||||
const { label } = props;
|
||||
|
||||
return (
|
||||
<Form.Item label={label}>
|
||||
<CustomField {...props} />
|
||||
</Form.Item>
|
||||
);
|
||||
});
|
||||
FastifyFormCustom.displayName = 'FastifyFormCustom';
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { Input, Form } from 'antd';
|
||||
import type { FastifyFormFieldComponent } from 'react-fastify-form';
|
||||
import { getValidateStatus } from '../utils';
|
||||
|
||||
export const FastifyFormPassword: FastifyFormFieldComponent = React.memo(
|
||||
(props) => {
|
||||
const {
|
||||
name,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
error,
|
||||
maxLength,
|
||||
placeholder,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
label={label}
|
||||
validateStatus={getValidateStatus(error)}
|
||||
help={error}
|
||||
>
|
||||
<Input.Password
|
||||
name={name}
|
||||
type="password"
|
||||
size="large"
|
||||
maxLength={maxLength}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
);
|
||||
FastifyFormPassword.displayName = 'FastifyFormPassword';
|
||||
@@ -0,0 +1,43 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Select, Form } from 'antd';
|
||||
import _get from 'lodash/get';
|
||||
import _isNil from 'lodash/isNil';
|
||||
import type { FastifyFormFieldComponent } from 'react-fastify-form';
|
||||
|
||||
const Option = Select.Option;
|
||||
|
||||
interface FastifyFormSelectOptionsItem {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const FastifyFormSelect: FastifyFormFieldComponent<{
|
||||
options: FastifyFormSelectOptionsItem[];
|
||||
}> = React.memo((props) => {
|
||||
const { name, label, value, onChange, onBlur, options } = props;
|
||||
|
||||
useEffect(() => {
|
||||
if (_isNil(value) || value === '') {
|
||||
// 如果没有值的话则自动设置默认值
|
||||
onChange(_get(options, [0, 'value']));
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Form.Item label={label}>
|
||||
<Select
|
||||
size="large"
|
||||
value={value}
|
||||
onChange={(value) => onChange(value)}
|
||||
onBlur={onBlur}
|
||||
>
|
||||
{options.map((option, i) => (
|
||||
<Option key={`${option.value}${i}`} value={option.value}>
|
||||
{option.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
);
|
||||
});
|
||||
FastifyFormSelect.displayName = 'FastifyFormSelect';
|
||||
38
client/packages/design/components/WebMetaForm/types/Text.tsx
Normal file
38
client/packages/design/components/WebMetaForm/types/Text.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { Input, Form } from 'antd';
|
||||
import type { FastifyFormFieldComponent } from 'react-fastify-form';
|
||||
import { getValidateStatus } from '../utils';
|
||||
|
||||
export const FastifyFormText: FastifyFormFieldComponent = React.memo(
|
||||
(props) => {
|
||||
const {
|
||||
name,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
error,
|
||||
maxLength,
|
||||
placeholder,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
label={label}
|
||||
validateStatus={getValidateStatus(error)}
|
||||
help={error}
|
||||
>
|
||||
<Input
|
||||
name={name}
|
||||
size="large"
|
||||
maxLength={maxLength}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
);
|
||||
FastifyFormText.displayName = 'FastifyFormText';
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { Input, Form } from 'antd';
|
||||
import type { FastifyFormFieldComponent } from 'react-fastify-form';
|
||||
import { getValidateStatus } from '../utils';
|
||||
|
||||
export const FastifyFormTextArea: FastifyFormFieldComponent = React.memo(
|
||||
(props) => {
|
||||
const {
|
||||
name,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
error,
|
||||
maxLength,
|
||||
placeholder,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
label={label}
|
||||
validateStatus={getValidateStatus(error)}
|
||||
help={error}
|
||||
>
|
||||
<Input.TextArea
|
||||
name={name}
|
||||
rows={4}
|
||||
maxLength={maxLength}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
);
|
||||
FastifyFormTextArea.displayName = 'FastifyFormTextArea';
|
||||
10
client/packages/design/components/WebMetaForm/utils.ts
Normal file
10
client/packages/design/components/WebMetaForm/utils.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 获取校验状态
|
||||
*/
|
||||
export function getValidateStatus(error: string | undefined): 'error' | '' {
|
||||
if (error === undefined || error === '') {
|
||||
return '';
|
||||
} else {
|
||||
return 'error';
|
||||
}
|
||||
}
|
||||
26
client/packages/design/components/index.ts
Normal file
26
client/packages/design/components/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export { AutoFolder } from './AutoFolder';
|
||||
export { Avatar, getTextColorHex } from './Avatar';
|
||||
export { AvatarWithPreview } from './AvatarWithPreview';
|
||||
export { CopyableText } from './CopyableText';
|
||||
export { CombinedAvatar } from './Avatar/combined';
|
||||
export { DelayTip } from './DelayTip';
|
||||
export { Highlight } from './Highlight';
|
||||
export { Icon } from './Icon';
|
||||
export { Image, setImageUrlParser } from './Image';
|
||||
export { SensitiveText } from './SensitiveText';
|
||||
export { VirtualChatList } from './VirtualChatList';
|
||||
|
||||
export {
|
||||
WebMetaForm,
|
||||
setWebFastifyFormConfig as setWebMetaFormConfig,
|
||||
} from './WebMetaForm';
|
||||
export {
|
||||
createFastifyFormSchema as createMetaFormSchema,
|
||||
fieldSchema as metaFormFieldSchema,
|
||||
useFastifyFormContext as useMetaFormContext,
|
||||
useFastifyFormContext,
|
||||
} from 'react-fastify-form';
|
||||
export type {
|
||||
FastifyFormFieldMeta as MetaFormFieldMeta,
|
||||
FastifyFormFieldProps,
|
||||
} from 'react-fastify-form';
|
||||
7
client/packages/design/components/utils.ts
Normal file
7
client/packages/design/components/utils.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 是否一个可用的字符串
|
||||
* 定义为有长度的字符串
|
||||
*/
|
||||
export function isValidStr(str: unknown): str is string {
|
||||
return typeof str == 'string' && str !== '';
|
||||
}
|
||||
59
client/packages/design/package.json
Normal file
59
client/packages/design/package.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "tailchat-design",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"description": "Tailchat frontend UI library",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"dev": "pnpm storybook",
|
||||
"storybook": "start-storybook -p 6006",
|
||||
"build-storybook": "build-storybook"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/msgbyte/tailchat.git"
|
||||
},
|
||||
"keywords": [
|
||||
"design",
|
||||
"tailchat"
|
||||
],
|
||||
"author": "moonrailgun <moonrailgun@gmail.com>",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/msgbyte/tailchat/issues"
|
||||
},
|
||||
"homepage": "https://github.com/msgbyte/tailchat#readme",
|
||||
"dependencies": {
|
||||
"@iconify/react": "^3.2.1",
|
||||
"@remirror/pm": "^2.0.0",
|
||||
"@remirror/react": "^2.0.9",
|
||||
"ahooks": "^3.7.4",
|
||||
"antd": "^4.24.13",
|
||||
"clsx": "^1.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"react-fastify-form": "1.0.13",
|
||||
"remirror": "^2.0.9",
|
||||
"str2int": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.17.9",
|
||||
"@storybook/addon-actions": "^6.4.22",
|
||||
"@storybook/addon-essentials": "^6.4.22",
|
||||
"@storybook/addon-interactions": "^6.4.22",
|
||||
"@storybook/addon-links": "^6.4.22",
|
||||
"@storybook/react": "^6.4.22",
|
||||
"@storybook/testing-library": "^0.0.11",
|
||||
"@types/lodash": "^4.14.170",
|
||||
"@types/react": "18.0.20",
|
||||
"@types/react-dom": "18.0.6",
|
||||
"babel-loader": "^8.2.5",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"typescript": "^4.5.2",
|
||||
"webpack": "^5.72.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
}
|
||||
}
|
||||
8
client/packages/design/tsconfig.json
Normal file
8
client/packages/design/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationDir": ".",
|
||||
},
|
||||
"include": ["components/**/*"]
|
||||
}
|
||||
1
client/packages/design/types/index.d.ts
vendored
Normal file
1
client/packages/design/types/index.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module '*.module.less';
|
||||
1
client/packages/plugin-declaration-generator/.gitignore
vendored
Normal file
1
client/packages/plugin-declaration-generator/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
lib
|
||||
1
client/packages/plugin-declaration-generator/README.md
Normal file
1
client/packages/plugin-declaration-generator/README.md
Normal file
@@ -0,0 +1 @@
|
||||
WIP
|
||||
40
client/packages/plugin-declaration-generator/package.json
Normal file
40
client/packages/plugin-declaration-generator/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "tailchat-plugin-declaration-generator",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "lib/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"watch": "tsc --watch",
|
||||
"prepare": "tsc",
|
||||
"test": "ts-node ./test/index.ts",
|
||||
"test:parser": "ts-node ./test/parser.ts",
|
||||
"test:parser:debug": "node -r ts-node/register --inspect-brk ./test/parser.ts"
|
||||
},
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/generator": "^7.20.5",
|
||||
"@babel/parser": "^7.20.5",
|
||||
"@babel/template": "^7.18.10",
|
||||
"@babel/traverse": "^7.20.5",
|
||||
"fs-extra": "^10.1.0",
|
||||
"glob": "^7.2.3",
|
||||
"lodash": "^4.17.21",
|
||||
"mkdirp": "^1.0.4",
|
||||
"ts-morph": "^16.0.0",
|
||||
"typescript": "^4.9.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^7.20.5",
|
||||
"@types/babel__generator": "^7.6.4",
|
||||
"@types/babel__template": "^7.4.1",
|
||||
"@types/babel__traverse": "^7.18.3",
|
||||
"@types/fs-extra": "^9.0.13",
|
||||
"@types/lodash": "^4.14.191",
|
||||
"@types/mkdirp": "^1.0.2",
|
||||
"@types/node": "^18.11.16",
|
||||
"ts-node": "^10.9.1"
|
||||
}
|
||||
}
|
||||
90
client/packages/plugin-declaration-generator/src/index.ts
Normal file
90
client/packages/plugin-declaration-generator/src/index.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { parse, ParserPlugin } from '@babel/parser';
|
||||
import traverse from '@babel/traverse';
|
||||
import generate from '@babel/generator';
|
||||
import template from '@babel/template';
|
||||
import type { Comment } from '@babel/types';
|
||||
import { program, isFunctionDeclaration } from '@babel/types';
|
||||
import fs from 'fs-extra';
|
||||
import _ from 'lodash';
|
||||
|
||||
export * from './tsgenerator';
|
||||
export * from './parser';
|
||||
|
||||
const babelPlugins: ParserPlugin[] = ['jsx', 'typescript'];
|
||||
const buildNamedExport = template('export function %%name%%(): any', {
|
||||
plugins: babelPlugins,
|
||||
});
|
||||
|
||||
interface Options {
|
||||
entryPath: string;
|
||||
// targetPath: string; // TODO
|
||||
}
|
||||
export async function generateFunctionDeclare(options: Options) {
|
||||
const sourcecode = await fs.readFile(options.entryPath, 'utf8');
|
||||
|
||||
const exported = getSourceCodeExportedFunction(sourcecode);
|
||||
|
||||
const astList = exported.map((e) => {
|
||||
return buildNamedExport({
|
||||
name: e.name,
|
||||
});
|
||||
});
|
||||
|
||||
const code = generate(program(_.flatten(astList))).code;
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
interface ExportedItem {
|
||||
name: string;
|
||||
comments?: string;
|
||||
}
|
||||
function getSourceCodeExportedFunction(sourcecode: string): ExportedItem[] {
|
||||
const ast = parse(sourcecode, {
|
||||
sourceType: 'module',
|
||||
plugins: babelPlugins,
|
||||
});
|
||||
|
||||
const exported: ExportedItem[] = [];
|
||||
|
||||
traverse(ast, {
|
||||
ExportNamedDeclaration({ node }) {
|
||||
if (node.declaration) {
|
||||
if (isFunctionDeclaration(node.declaration)) {
|
||||
const name = node.declaration.id?.name;
|
||||
if (typeof name === 'string') {
|
||||
exported.push({
|
||||
name,
|
||||
comments: getCommentStr(node.leadingComments),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const names = node.specifiers.map((s) => {
|
||||
const exported = s.exported;
|
||||
if (exported.type === 'Identifier') {
|
||||
return {
|
||||
name: exported.name,
|
||||
comments: getCommentStr(node.leadingComments),
|
||||
};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
exported.push(...names.filter((n): n is any => !!n));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return exported;
|
||||
}
|
||||
|
||||
function getCommentStr(
|
||||
comments: Comment[] | null | undefined
|
||||
): string | undefined {
|
||||
if (!comments) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return comments.map((c) => c.value).join('\n');
|
||||
}
|
||||
112
client/packages/plugin-declaration-generator/src/parser.ts
Normal file
112
client/packages/plugin-declaration-generator/src/parser.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
Project,
|
||||
ProjectOptions,
|
||||
Symbol,
|
||||
SyntaxKind,
|
||||
ts,
|
||||
Type,
|
||||
} from 'ts-morph';
|
||||
|
||||
interface Options {
|
||||
entryPath: string;
|
||||
project?: ProjectOptions;
|
||||
hardcodeExportType?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function parseDeclarationEntry(options: Options) {
|
||||
const project = new Project(options.project);
|
||||
const sourceFile = project.getSourceFileOrThrow(options.entryPath);
|
||||
const hardcodeExportType = options.hardcodeExportType ?? {};
|
||||
|
||||
const exportDefs: { name: string; type: string }[] = [];
|
||||
for (const [name, declarations] of sourceFile.getExportedDeclarations()) {
|
||||
if (hardcodeExportType[name]) {
|
||||
exportDefs.push({
|
||||
name,
|
||||
type: hardcodeExportType[name],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log('parsing:', name);
|
||||
|
||||
const typeDef = declarations
|
||||
.map((d) => {
|
||||
if (d.isKind(SyntaxKind.FunctionDeclaration)) {
|
||||
// 如果是方法导出
|
||||
return d
|
||||
.getType()
|
||||
.getCallSignatures()
|
||||
.map((s) => {
|
||||
let fnText = '';
|
||||
const typeParameters = s.getTypeParameters();
|
||||
if (typeParameters.length > 0) {
|
||||
fnText += `<${typeParameters
|
||||
.map((tp) => tp.getText())
|
||||
.join(', ')}>`;
|
||||
}
|
||||
|
||||
fnText += `(${s
|
||||
.getParameters()
|
||||
.map((p) => {
|
||||
return parseFunctionParameter(p);
|
||||
})
|
||||
.join(', ')}) => ${s.getReturnType().getText()}`;
|
||||
|
||||
return fnText;
|
||||
})
|
||||
.join(' | ');
|
||||
} else {
|
||||
// 其他
|
||||
return d.getType().getText();
|
||||
}
|
||||
})
|
||||
.join(' | ');
|
||||
|
||||
exportDefs.push({
|
||||
name,
|
||||
type: typeDef,
|
||||
});
|
||||
}
|
||||
|
||||
return { exportDefs, project };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析函数参数为字符串
|
||||
*/
|
||||
function parseFunctionParameter(parameter: Symbol): string {
|
||||
const name = parameter.getName();
|
||||
const isOptional = parameter.isOptional();
|
||||
const type = parseType(parameter.getDeclarations()[0].getType());
|
||||
|
||||
if (isOptional) {
|
||||
return `${name}?: ${type}`;
|
||||
} else {
|
||||
return `${name}: ${type}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析函数
|
||||
*/
|
||||
function parseType(type: Type<ts.Type>): string {
|
||||
if (type.isAnonymous()) {
|
||||
return type.getText();
|
||||
}
|
||||
|
||||
if (type.isObject()) {
|
||||
const properties = type.getApparentProperties();
|
||||
debugger;
|
||||
|
||||
return `{ ${properties
|
||||
.map((p) => {
|
||||
const t = p.getDeclarations()[0].getType();
|
||||
const text = parseType(t);
|
||||
return `${p.getName()}: ${text}`;
|
||||
})
|
||||
.join(', ')} }`;
|
||||
}
|
||||
|
||||
return type.getText();
|
||||
}
|
||||
191
client/packages/plugin-declaration-generator/src/tsgenerator.ts
Normal file
191
client/packages/plugin-declaration-generator/src/tsgenerator.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import ts, { isVariableStatement } from 'typescript';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
/**
|
||||
* Tools: https://ts-ast-viewer.com/
|
||||
*/
|
||||
|
||||
export interface ExportModuleItem {
|
||||
name: string;
|
||||
comment?: string;
|
||||
pos: number;
|
||||
}
|
||||
|
||||
export interface DeclarationModuleItem {
|
||||
name: string;
|
||||
text: string;
|
||||
comment?: string;
|
||||
pos?: number;
|
||||
}
|
||||
|
||||
export function parseModuleDeclaration(
|
||||
filePath: string,
|
||||
options: ts.CompilerOptions
|
||||
) {
|
||||
const { program } = parseFile(filePath, options);
|
||||
const modules: Record<string, DeclarationModuleItem[]> = {};
|
||||
|
||||
const sourceFile = program?.getSourceFile(filePath);
|
||||
sourceFile?.forEachChild((node) => {
|
||||
if (
|
||||
ts.isModuleDeclaration(node) &&
|
||||
node.body &&
|
||||
ts.isModuleBlock(node.body)
|
||||
) {
|
||||
const moduleName = node.name.text;
|
||||
if (!modules[moduleName]) {
|
||||
modules[moduleName] = [];
|
||||
}
|
||||
|
||||
node.body.forEachChild((item) => {
|
||||
if (ts.isVariableStatement(item)) {
|
||||
let comment: string | undefined = undefined;
|
||||
const commentRange = ts.getLeadingCommentRanges(
|
||||
sourceFile.getFullText(),
|
||||
item.pos
|
||||
);
|
||||
if (Array.isArray(commentRange) && commentRange.length > 0) {
|
||||
comment = '';
|
||||
commentRange.map(({ pos, end }) => {
|
||||
comment += sourceFile.text.substring(pos, end);
|
||||
});
|
||||
}
|
||||
|
||||
item.declarationList.declarations.forEach((declaration) => {
|
||||
const name = declaration.name.getText();
|
||||
const pos = declaration.pos;
|
||||
modules[moduleName].push({
|
||||
name,
|
||||
text: declaration.getText(),
|
||||
comment,
|
||||
pos,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { modules };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析导出文件
|
||||
*/
|
||||
export function parseExports(filePath: string, options: ts.CompilerOptions) {
|
||||
const { program, service } = parseFile(filePath, options);
|
||||
|
||||
const exportModules: ExportModuleItem[] = [];
|
||||
const sourceFile = program?.getSourceFile(filePath);
|
||||
sourceFile?.forEachChild((node) => {
|
||||
if (ts.isExportDeclaration(node)) {
|
||||
// 如果为导出声明: export { foo } from 'foo'
|
||||
node.exportClause?.forEachChild((exportSpec) => {
|
||||
if (ts.isExportSpecifier(exportSpec)) {
|
||||
exportModules.push({
|
||||
name: exportSpec.name.text,
|
||||
// comment:
|
||||
pos: exportSpec.pos,
|
||||
});
|
||||
}
|
||||
});
|
||||
} else if (isExportFunc(node)) {
|
||||
// 如果是方法导出: export function foo() {}
|
||||
if (node.name) {
|
||||
exportModules.push({
|
||||
name: node.name.text,
|
||||
comment: getNodeComments(node),
|
||||
pos: node.pos,
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
isVariableStatement(node) &&
|
||||
node.modifiers?.some((v) => v.kind === ts.SyntaxKind.ExportKeyword)
|
||||
) {
|
||||
// 如果为导出变量
|
||||
// export const foo = ''
|
||||
node.declarationList.declarations.forEach((d) => {
|
||||
if (ts.isIdentifier(d.name)) {
|
||||
exportModules.push({
|
||||
name: d.name.getText(),
|
||||
pos: d.pos,
|
||||
});
|
||||
} else {
|
||||
d.name.elements.forEach((n) => {
|
||||
exportModules.push({
|
||||
name: n.getText(),
|
||||
pos: d.pos,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { exportModules };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析文件
|
||||
*/
|
||||
export function parseFile(filePath: string, options: ts.CompilerOptions) {
|
||||
const host = new FileServiceHost(filePath, options);
|
||||
|
||||
const service = ts.createLanguageService(host, ts.createDocumentRegistry());
|
||||
const program = service.getProgram();
|
||||
|
||||
return { service, program };
|
||||
}
|
||||
|
||||
function isExportFunc(node: ts.Node): node is ts.FunctionDeclaration {
|
||||
if (ts.isFunctionDeclaration(node)) {
|
||||
if (node.modifiers) {
|
||||
return node.modifiers.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function getNodeComments(node: ts.Node): string | undefined {
|
||||
const comments = ts.getSyntheticLeadingComments(node);
|
||||
|
||||
if (!comments) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return comments.map((c) => c.text).join('\n');
|
||||
}
|
||||
|
||||
class FileServiceHost implements ts.LanguageServiceHost {
|
||||
constructor(public filePath: string, private options: ts.CompilerOptions) {}
|
||||
|
||||
getCompilationSettings = () => this.options;
|
||||
getScriptFileNames = () => [
|
||||
this.filePath,
|
||||
// For test
|
||||
'/Users/moonrailgun/inventory/tailchat/packages/plugin-declaration-generator/test/demo/foo.ts',
|
||||
'/Users/moonrailgun/inventory/tailchat/packages/plugin-declaration-generator/test/demo/bar.ts',
|
||||
];
|
||||
getScriptVersion = () => '1';
|
||||
getScriptSnapshot = (fileName: string) => {
|
||||
if (!fs.existsSync(fileName)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return ts.ScriptSnapshot.fromString(fs.readFileSync(fileName).toString());
|
||||
};
|
||||
// getCurrentDirectory = () => process.cwd();
|
||||
getCurrentDirectory = () =>
|
||||
// For test
|
||||
'/Users/moonrailgun/inventory/tailchat/packages/plugin-declaration-generator/test/demo/';
|
||||
getDefaultLibFileName = (options: ts.CompilerOptions) =>
|
||||
ts.getDefaultLibFilePath(options);
|
||||
|
||||
readFile(path: string): string | undefined {
|
||||
return fs.readFileSync(path).toString();
|
||||
}
|
||||
fileExists(path: string): boolean {
|
||||
return fs.existsSync(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* This is bar
|
||||
*/
|
||||
export function bar() {
|
||||
console.log('Anything else');
|
||||
}
|
||||
|
||||
interface E {
|
||||
f: symbol;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
a: number;
|
||||
b: string;
|
||||
c: {
|
||||
d: string;
|
||||
e: E;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* This is bar with complex input
|
||||
*/
|
||||
export function complexBar(input: Options) {
|
||||
console.log('Anything else', input);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import * as mkdirp from 'mkdirp';
|
||||
|
||||
/**
|
||||
* This is foo
|
||||
*/
|
||||
export function foo(input: string) {
|
||||
console.log('Anything', input);
|
||||
mkdirp('./foo/foo/foo/foo/foo/foo/foo');
|
||||
|
||||
return input + 1;
|
||||
}
|
||||
|
||||
export const fooVar = 'fooVar' as string;
|
||||
@@ -0,0 +1,9 @@
|
||||
export { foo, fooVar } from '@/foo';
|
||||
export { bar, complexBar } from '@/bar';
|
||||
|
||||
/**
|
||||
* Root export
|
||||
*/
|
||||
export function main() {
|
||||
console.log('main');
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./*"],
|
||||
}
|
||||
}
|
||||
}
|
||||
4
client/packages/plugin-declaration-generator/test/index.d.ts
vendored
Normal file
4
client/packages/plugin-declaration-generator/test/index.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module '@capital/foo' {
|
||||
export const a: any;
|
||||
export const b: string;
|
||||
}
|
||||
20
client/packages/plugin-declaration-generator/test/index.ts
Normal file
20
client/packages/plugin-declaration-generator/test/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { parseExports, parseModuleDeclaration } from '../src/tsgenerator';
|
||||
import path from 'path';
|
||||
|
||||
const { exportModules } = parseExports(
|
||||
path.resolve(__dirname, './demo/index.ts'),
|
||||
{
|
||||
paths: { '@/*': ['./*'] },
|
||||
}
|
||||
);
|
||||
|
||||
console.log('exportModules', exportModules);
|
||||
|
||||
const { modules } = parseModuleDeclaration(
|
||||
path.resolve(__dirname, './index.d.ts'),
|
||||
{
|
||||
paths: { '@/*': ['./*'] },
|
||||
}
|
||||
);
|
||||
|
||||
console.log('modules', modules);
|
||||
14
client/packages/plugin-declaration-generator/test/parser.ts
Normal file
14
client/packages/plugin-declaration-generator/test/parser.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { parseDeclarationEntry } from '../src/parser';
|
||||
import path from 'path';
|
||||
|
||||
const project = parseDeclarationEntry({
|
||||
entryPath: path.resolve(__dirname, './demo/index.ts'),
|
||||
project: {
|
||||
tsConfigFilePath: path.resolve(__dirname, './demo/tsconfig.json'),
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
'sourceFile',
|
||||
project.getSourceFiles().map((item) => item.getFilePath())
|
||||
);
|
||||
15
client/packages/plugin-declaration-generator/tsconfig.json
Normal file
15
client/packages/plugin-declaration-generator/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"lib": ["ESNext"],
|
||||
"outDir": "lib",
|
||||
"declaration": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"importsNotUsedAsValues": "error",
|
||||
},
|
||||
"include": ["./src/*"]
|
||||
}
|
||||
1
client/packages/tailchat-client-sdk/.gitignore
vendored
Normal file
1
client/packages/tailchat-client-sdk/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
lib
|
||||
3
client/packages/tailchat-client-sdk/README.md
Normal file
3
client/packages/tailchat-client-sdk/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
## Document
|
||||
|
||||
visit website to learn more: [https://tailchat.msgbyte.com/docs/advanced-usage/openapp/about](https://tailchat.msgbyte.com/docs/advanced-usage/openapp/about)
|
||||
5
client/packages/tailchat-client-sdk/jest.config.js
Normal file
5
client/packages/tailchat-client-sdk/jest.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
};
|
||||
27
client/packages/tailchat-client-sdk/package.json
Normal file
27
client/packages/tailchat-client-sdk/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "tailchat-client-sdk",
|
||||
"version": "1.0.9",
|
||||
"description": "",
|
||||
"main": "lib/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc",
|
||||
"release": "npm publish --registry https://registry.npmjs.com/",
|
||||
"test": "jest"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "moonrailgun <moonrailgun@gmail.com>",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.1",
|
||||
"@types/node": "^18.16.1",
|
||||
"jest": "27.5.1",
|
||||
"ts-jest": "27.1.4",
|
||||
"typescript": "^4.9.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.3.2",
|
||||
"tailchat-types": "workspace:*",
|
||||
"socket.io-client": "^4.7.1",
|
||||
"socket.io-msgpack-parser": "^3.0.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { stripMentionTag } from '../utils';
|
||||
|
||||
describe('stripMentionTag', () => {
|
||||
test('simple', () => {
|
||||
expect(
|
||||
stripMentionTag('[at=6448e822834c12425646f473]Robot[/at] Hello')
|
||||
).toBe('Hello');
|
||||
});
|
||||
|
||||
test('not remove other message', () => {
|
||||
expect(
|
||||
stripMentionTag(
|
||||
'[at=6448e822834c12425646f473]Robot[/at] Hello [at=6448e822834c12425646f4732]Robot[/at]'
|
||||
)
|
||||
).toBe('Hello [at=6448e822834c12425646f4732]Robot[/at]');
|
||||
});
|
||||
|
||||
test('also can remove mention ', () => {
|
||||
expect(stripMentionTag('@Robot Hello')).toBe('Hello');
|
||||
});
|
||||
});
|
||||
3
client/packages/tailchat-client-sdk/src/index.ts
Normal file
3
client/packages/tailchat-client-sdk/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './openapi';
|
||||
export * from './plugins/simplenotify';
|
||||
export * from './utils';
|
||||
162
client/packages/tailchat-client-sdk/src/openapi/client/base.ts
Normal file
162
client/packages/tailchat-client-sdk/src/openapi/client/base.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import crypto from 'crypto';
|
||||
|
||||
export class TailchatBaseClient {
|
||||
request: AxiosInstance;
|
||||
jwt: string | null = null;
|
||||
userId: string | null = null;
|
||||
loginP: Promise<void>;
|
||||
|
||||
constructor(
|
||||
public url: string,
|
||||
public appId: string,
|
||||
public appSecret: string
|
||||
) {
|
||||
if (!url || !appId || !appSecret) {
|
||||
throw new Error(
|
||||
'Require params: apiUrl, appId, appSecret. You can set it with env'
|
||||
);
|
||||
}
|
||||
|
||||
this.request = axios.create({
|
||||
baseURL: url,
|
||||
});
|
||||
this.request.interceptors.request.use(async (val) => {
|
||||
if (
|
||||
this.jwt &&
|
||||
['post', 'get'].includes(String(val.method).toLowerCase()) &&
|
||||
!val.headers['X-Token']
|
||||
) {
|
||||
// 任何请求都尝试增加token
|
||||
val.headers['X-Token'] = this.jwt;
|
||||
}
|
||||
|
||||
return val;
|
||||
});
|
||||
this.loginP = this.login();
|
||||
}
|
||||
|
||||
async login() {
|
||||
try {
|
||||
console.log('Login...');
|
||||
const { data } = await this.request.post('/api/openapi/bot/login', {
|
||||
appId: this.appId,
|
||||
token: this.getBotToken(),
|
||||
});
|
||||
|
||||
// NOTICE: 注意,有30天过期时间,需要定期重新登录以换取新的token
|
||||
// 这里先不换
|
||||
this.jwt = data.data?.jwt;
|
||||
this.userId = data.data?.userId;
|
||||
|
||||
console.log('tailchat openapp login success!');
|
||||
|
||||
// 尝试调用函数
|
||||
// this.whoami().then(console.log);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw new Error(
|
||||
`Login failed, please check application credentials or network(Error: ${String(
|
||||
err
|
||||
)})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async waitingForLogin(): Promise<void> {
|
||||
await Promise.resolve(this.loginP);
|
||||
}
|
||||
|
||||
async call(action: string, params = {}) {
|
||||
try {
|
||||
await this.waitingForLogin();
|
||||
console.log('Calling:', action);
|
||||
const { data } = await this.request.post(
|
||||
'/api/' + action.replace(/\./g, '/'),
|
||||
params
|
||||
);
|
||||
|
||||
return data.data;
|
||||
} catch (err: any) {
|
||||
console.error('Service Call Failed:', err);
|
||||
const data: string = err?.response?.data;
|
||||
if (data) {
|
||||
throw new Error(
|
||||
JSON.stringify({
|
||||
action,
|
||||
data,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async whoami(): Promise<{
|
||||
userAgent: string;
|
||||
language: string;
|
||||
user: {
|
||||
_id: string;
|
||||
nickname: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
};
|
||||
token: string;
|
||||
userId: string;
|
||||
}> {
|
||||
return this.call('user.whoami');
|
||||
}
|
||||
|
||||
getBotToken() {
|
||||
return crypto
|
||||
.createHash('md5')
|
||||
.update(this.appId + this.appSecret)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send normal message to tailchat
|
||||
*/
|
||||
async sendMessage(payload: {
|
||||
converseId: string;
|
||||
groupId?: string;
|
||||
content: string;
|
||||
plain?: string;
|
||||
meta?: object;
|
||||
}) {
|
||||
return this.call('chat.message.sendMessage', payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reply message
|
||||
*/
|
||||
async replyMessage(
|
||||
replyInfo: {
|
||||
messageId: string;
|
||||
author: string;
|
||||
content: string;
|
||||
},
|
||||
payload: {
|
||||
converseId: string;
|
||||
groupId?: string;
|
||||
content: string;
|
||||
plain?: string;
|
||||
meta?: object;
|
||||
}
|
||||
) {
|
||||
return this.sendMessage({
|
||||
...payload,
|
||||
meta: {
|
||||
...payload.meta,
|
||||
mentions: [replyInfo.author],
|
||||
reply: {
|
||||
_id: replyInfo.messageId,
|
||||
author: replyInfo.author,
|
||||
content: replyInfo.content,
|
||||
},
|
||||
},
|
||||
content: `[at=${replyInfo.author}][/at] ${payload.content}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { TailchatBaseClient } from './base';
|
||||
|
||||
export class TailchatHTTPClient extends TailchatBaseClient {}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
/**
|
||||
* @deprecated please rename to TailchatHTTPClient
|
||||
*/
|
||||
TailchatHTTPClient as TailchatClient,
|
||||
TailchatHTTPClient,
|
||||
} from './http';
|
||||
export { TailchatWsClient } from './ws';
|
||||
111
client/packages/tailchat-client-sdk/src/openapi/client/ws.ts
Normal file
111
client/packages/tailchat-client-sdk/src/openapi/client/ws.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { TailchatBaseClient } from './base';
|
||||
import io, { Socket } from 'socket.io-client';
|
||||
import * as msgpackParser from 'socket.io-msgpack-parser';
|
||||
import type { ChatMessage } from 'tailchat-types';
|
||||
|
||||
export class TailchatWsClient extends TailchatBaseClient {
|
||||
public socket: Socket | null = null;
|
||||
|
||||
constructor(
|
||||
public url: string,
|
||||
public appId: string,
|
||||
public appSecret: string,
|
||||
public disableMsgpack: boolean = false
|
||||
) {
|
||||
super(url, appId, appSecret);
|
||||
}
|
||||
|
||||
connect(): Promise<Socket> {
|
||||
return new Promise<Socket>(async (resolve, reject) => {
|
||||
await this.waitingForLogin();
|
||||
|
||||
const token = this.jwt;
|
||||
const socket = (this.socket = io(this.url, {
|
||||
transports: ['websocket'],
|
||||
auth: {
|
||||
token,
|
||||
},
|
||||
forceNew: true,
|
||||
parser: this.disableMsgpack ? undefined : msgpackParser,
|
||||
}));
|
||||
|
||||
socket.once('connect', () => {
|
||||
// 连接成功
|
||||
this.emit('chat.converse.findAndJoinRoom')
|
||||
.then((res) => {
|
||||
console.log('Joined rooms', res.data);
|
||||
resolve(socket);
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
socket.once('error', () => {
|
||||
reject();
|
||||
});
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
console.log(`disconnect due to ${reason}`);
|
||||
this.socket = null;
|
||||
});
|
||||
|
||||
socket.onAny((ev) => {
|
||||
console.log('onAny', ev);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (!this.socket) {
|
||||
console.warn('You should call it after connect');
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.disconnect();
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
emit(eventName: string, eventData: any = {}) {
|
||||
if (!this.socket) {
|
||||
console.warn('You should call it after connect');
|
||||
throw new Error('You should call it after connect');
|
||||
}
|
||||
|
||||
return this.socket.emitWithAck(eventName, eventData);
|
||||
}
|
||||
|
||||
on(eventName: string, callback: (payload: any) => void) {
|
||||
if (!this.socket) {
|
||||
console.warn('You should call it after connect');
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.on(eventName, callback);
|
||||
}
|
||||
|
||||
once(eventName: string, callback: (payload: any) => void) {
|
||||
if (!this.socket) {
|
||||
console.warn('You should call it after connect');
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.once(eventName, callback);
|
||||
}
|
||||
|
||||
off(eventName: string, callback: (payload: any) => void) {
|
||||
if (!this.socket) {
|
||||
console.warn('You should call it after connect');
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.off(eventName, callback);
|
||||
}
|
||||
|
||||
onMessage(callback: (messagePayload: ChatMessage) => void) {
|
||||
this.on('notify:chat.message.add', callback);
|
||||
}
|
||||
|
||||
onMessageUpdate(callback: (messagePayload: ChatMessage) => void) {
|
||||
this.on('notify:chat.message.update', callback);
|
||||
}
|
||||
}
|
||||
1
client/packages/tailchat-client-sdk/src/openapi/index.ts
Normal file
1
client/packages/tailchat-client-sdk/src/openapi/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './client';
|
||||
@@ -0,0 +1,24 @@
|
||||
import axios from 'axios';
|
||||
|
||||
/**
|
||||
* 基于简易推送插件的消息通知服务
|
||||
*
|
||||
* @param hostUrl 实例地址url
|
||||
* @param subscribeId 订阅id
|
||||
* @param text 发送的文本,默认支持bbcode
|
||||
*/
|
||||
export async function sendSimpleNotify(
|
||||
hostUrl: string,
|
||||
subscribeId: string,
|
||||
text: string
|
||||
) {
|
||||
await axios({
|
||||
method: 'post',
|
||||
baseURL: hostUrl,
|
||||
url: '/api/plugin:com.msgbyte.simplenotify/webhook/callback',
|
||||
data: {
|
||||
subscribeId,
|
||||
text,
|
||||
},
|
||||
});
|
||||
}
|
||||
10
client/packages/tailchat-client-sdk/src/utils.ts
Normal file
10
client/packages/tailchat-client-sdk/src/utils.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* remove first [at=xxx]xxx[/at] in message first
|
||||
*/
|
||||
export function stripMentionTag(message: string): string {
|
||||
return message
|
||||
.trim()
|
||||
.replace(/^\[at=.*?\[\/at\]/, '')
|
||||
.replace(/^@\S*\s?/, '')
|
||||
.trimStart();
|
||||
}
|
||||
20
client/packages/tailchat-client-sdk/test/index.ts
Normal file
20
client/packages/tailchat-client-sdk/test/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { TailchatWsClient } from '../src';
|
||||
|
||||
const HOST = process.env.HOST;
|
||||
const APPID = process.env.APPID;
|
||||
const APPSECRET = process.env.APPSECRET;
|
||||
|
||||
if (!HOST || !APPID || !APPSECRET) {
|
||||
console.log('require env: HOST, APPID, APPSECRET');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new TailchatWsClient(HOST, APPID, APPSECRET);
|
||||
|
||||
client.connect().then(async () => {
|
||||
console.log('Login Success!');
|
||||
|
||||
client.onMessage((message) => {
|
||||
console.log('Receive message', message);
|
||||
});
|
||||
});
|
||||
18
client/packages/tailchat-client-sdk/tsconfig.json
Normal file
18
client/packages/tailchat-client-sdk/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"lib": ["ESNext"],
|
||||
"skipLibCheck": true,
|
||||
"outDir": "lib",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"importsNotUsedAsValues": "error",
|
||||
"typeRoots": ["./node_modules/@types"]
|
||||
},
|
||||
"include": ["./src/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user