commit
6522d2b5ab
52 changed files with 15686 additions and 0 deletions
-
3.eslintrc.js
-
13.gitignore
-
1.husky/commit-msg
-
1.husky/pre-commit
-
8.idea/.gitignore
-
12.idea/chat-v1.iml
-
8.idea/modules.xml
-
6.idea/vcs.xml
-
17.lintstagedrc
-
2.npmrc
-
3.prettierignore
-
8.prettierrc
-
3.stylelintrc.js
-
40.umirc.ts
-
3README.md
-
20mock/userAPI.ts
-
31package.json
-
13581pnpm-lock.yaml
-
10src/access.ts
-
16src/app.ts
-
0src/assets/.gitkeep
-
4src/components/Guide/Guide.less
-
23src/components/Guide/Guide.tsx
-
2src/components/Guide/index.ts
-
1src/constants/index.ts
-
13src/models/global.ts
-
21src/pages/Access/index.tsx
-
141src/pages/Chat/components/ChatWindow.less
-
201src/pages/Chat/components/ChatWindow.tsx
-
71src/pages/Chat/components/ContactList.less
-
126src/pages/Chat/components/ContactList.tsx
-
36src/pages/Chat/components/InfoPanel.less
-
123src/pages/Chat/components/InfoPanel.tsx
-
14src/pages/Chat/index.less
-
47src/pages/Chat/index.tsx
-
239src/pages/Chat/wss/WebSocketClient.ts
-
34src/pages/Chat/wss/config.ts
-
5src/pages/Chat/wss/index.ts
-
42src/pages/Chat/wss/types.ts
-
89src/pages/Chat/wss/useWebSocket.ts
-
34src/pages/Chat/wss/utils.ts
-
3src/pages/Home/index.less
-
18src/pages/Home/index.tsx
-
26src/pages/Table/components/CreateForm.tsx
-
138src/pages/Table/components/UpdateForm.tsx
-
270src/pages/Table/index.tsx
-
96src/services/demo/UserController.ts
-
7src/services/demo/index.ts
-
68src/services/demo/typings.d.ts
-
4src/utils/format.ts
-
3tsconfig.json
-
1typings.d.ts
@ -0,0 +1,3 @@ |
|||||
|
module.exports = { |
||||
|
extends: require.resolve('@umijs/max/eslint'), |
||||
|
}; |
@ -0,0 +1,13 @@ |
|||||
|
/node_modules |
||||
|
/.env.local |
||||
|
/.umirc.local.ts |
||||
|
/config/config.local.ts |
||||
|
/src/.umi |
||||
|
/src/.umi-production |
||||
|
/src/.umi-test |
||||
|
/.umi |
||||
|
/.umi-production |
||||
|
/.umi-test |
||||
|
/dist |
||||
|
/.mfsu |
||||
|
.swc |
@ -0,0 +1 @@ |
|||||
|
npx --no-install max verify-commit $1 |
@ -0,0 +1 @@ |
|||||
|
npx --no-install lint-staged --quiet |
@ -0,0 +1,8 @@ |
|||||
|
# 默认忽略的文件 |
||||
|
/shelf/ |
||||
|
/workspace.xml |
||||
|
# 基于编辑器的 HTTP 客户端请求 |
||||
|
/httpRequests/ |
||||
|
# Datasource local storage ignored files |
||||
|
/dataSources/ |
||||
|
/dataSources.local.xml |
@ -0,0 +1,12 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<module type="WEB_MODULE" version="4"> |
||||
|
<component name="NewModuleRootManager"> |
||||
|
<content url="file://$MODULE_DIR$"> |
||||
|
<excludeFolder url="file://$MODULE_DIR$/.tmp" /> |
||||
|
<excludeFolder url="file://$MODULE_DIR$/temp" /> |
||||
|
<excludeFolder url="file://$MODULE_DIR$/tmp" /> |
||||
|
</content> |
||||
|
<orderEntry type="inheritedJdk" /> |
||||
|
<orderEntry type="sourceFolder" forTests="false" /> |
||||
|
</component> |
||||
|
</module> |
@ -0,0 +1,8 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<project version="4"> |
||||
|
<component name="ProjectModuleManager"> |
||||
|
<modules> |
||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/chat-v1.iml" filepath="$PROJECT_DIR$/.idea/chat-v1.iml" /> |
||||
|
</modules> |
||||
|
</component> |
||||
|
</project> |
@ -0,0 +1,6 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<project version="4"> |
||||
|
<component name="VcsDirectoryMappings"> |
||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" /> |
||||
|
</component> |
||||
|
</project> |
@ -0,0 +1,17 @@ |
|||||
|
{ |
||||
|
"*.{md,json}": [ |
||||
|
"prettier --cache --write" |
||||
|
], |
||||
|
"*.{js,jsx}": [ |
||||
|
"max lint --fix --eslint-only", |
||||
|
"prettier --cache --write" |
||||
|
], |
||||
|
"*.{css,less}": [ |
||||
|
"max lint --fix --stylelint-only", |
||||
|
"prettier --cache --write" |
||||
|
], |
||||
|
"*.ts?(x)": [ |
||||
|
"max lint --fix --eslint-only", |
||||
|
"prettier --cache --parser=typescript --write" |
||||
|
] |
||||
|
} |
@ -0,0 +1,2 @@ |
|||||
|
registry=https://registry.npmjs.com/ |
||||
|
|
@ -0,0 +1,3 @@ |
|||||
|
node_modules |
||||
|
.umi |
||||
|
.umi-production |
@ -0,0 +1,8 @@ |
|||||
|
{ |
||||
|
"printWidth": 80, |
||||
|
"singleQuote": true, |
||||
|
"trailingComma": "all", |
||||
|
"proseWrap": "never", |
||||
|
"overrides": [{ "files": ".prettierrc", "options": { "parser": "json" } }], |
||||
|
"plugins": ["prettier-plugin-organize-imports", "prettier-plugin-packagejson"] |
||||
|
} |
@ -0,0 +1,3 @@ |
|||||
|
module.exports = { |
||||
|
extends: require.resolve('@umijs/max/stylelint'), |
||||
|
}; |
@ -0,0 +1,40 @@ |
|||||
|
import { defineConfig } from '@umijs/max'; |
||||
|
|
||||
|
export default defineConfig({ |
||||
|
antd: {}, |
||||
|
access: {}, |
||||
|
model: {}, |
||||
|
initialState: {}, |
||||
|
request: {}, |
||||
|
layout: { |
||||
|
title: '@umijs/max', |
||||
|
}, |
||||
|
routes: [ |
||||
|
{ |
||||
|
path: '/', |
||||
|
redirect: '/home', |
||||
|
}, |
||||
|
{ |
||||
|
name: '首页', |
||||
|
path: '/home', |
||||
|
component: './Home', |
||||
|
}, |
||||
|
{ |
||||
|
name: '权限演示', |
||||
|
path: '/access', |
||||
|
component: './Access', |
||||
|
}, |
||||
|
{ |
||||
|
name: ' CRUD 示例', |
||||
|
path: '/table', |
||||
|
component: './Table', |
||||
|
}, |
||||
|
{ |
||||
|
name: '客服聊天', |
||||
|
path: '/chat', |
||||
|
component: './Chat', |
||||
|
}, |
||||
|
], |
||||
|
npmClient: 'pnpm', |
||||
|
}); |
||||
|
|
@ -0,0 +1,3 @@ |
|||||
|
# README |
||||
|
|
||||
|
`@umijs/max` 模板项目,更多功能参考 [Umi Max 简介](https://umijs.org/docs/max/introduce) |
@ -0,0 +1,20 @@ |
|||||
|
const users = [ |
||||
|
{ id: 0, name: 'Umi', nickName: 'U', gender: 'MALE' }, |
||||
|
{ id: 1, name: 'Fish', nickName: 'B', gender: 'FEMALE' }, |
||||
|
]; |
||||
|
|
||||
|
export default { |
||||
|
'GET /api/v1/queryUserList': (req: any, res: any) => { |
||||
|
res.json({ |
||||
|
success: true, |
||||
|
data: { list: users }, |
||||
|
errorCode: 0, |
||||
|
}); |
||||
|
}, |
||||
|
'PUT /api/v1/user/': (req: any, res: any) => { |
||||
|
res.json({ |
||||
|
success: true, |
||||
|
errorCode: 0, |
||||
|
}); |
||||
|
}, |
||||
|
}; |
@ -0,0 +1,31 @@ |
|||||
|
{ |
||||
|
"private": true, |
||||
|
"author": "mm <RicartApache585@gmail.com>", |
||||
|
"scripts": { |
||||
|
"build": "max build", |
||||
|
"dev": "max dev", |
||||
|
"format": "prettier --cache --write .", |
||||
|
"postinstall": "max setup", |
||||
|
"prepare": "husky", |
||||
|
"setup": "max setup", |
||||
|
"start": "npm run dev" |
||||
|
}, |
||||
|
"dependencies": { |
||||
|
"@ant-design/icons": "^5.0.1", |
||||
|
"@ant-design/pro-components": "^2.8.2", |
||||
|
"@umijs/max": "^4.4.1", |
||||
|
"antd": "^5.22.5", |
||||
|
"axios": "^1.7.9" |
||||
|
}, |
||||
|
"devDependencies": { |
||||
|
"@types/react": "^18.0.33", |
||||
|
"@types/react-dom": "^18.0.11", |
||||
|
"@umijs/plugins": "^4.4.1", |
||||
|
"husky": "^9", |
||||
|
"lint-staged": "^13.2.0", |
||||
|
"prettier": "^2.8.7", |
||||
|
"prettier-plugin-organize-imports": "^3.2.2", |
||||
|
"prettier-plugin-packagejson": "^2.4.3", |
||||
|
"typescript": "^5.0.3" |
||||
|
} |
||||
|
} |
13581
pnpm-lock.yaml
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,10 @@ |
|||||
|
export default (initialState: API.UserInfo) => { |
||||
|
// 在这里按照初始化数据定义项目中的权限,统一管理
|
||||
|
// 参考文档 https://umijs.org/docs/max/access
|
||||
|
const canSeeAdmin = !!( |
||||
|
initialState && initialState.name !== 'dontHaveAccess' |
||||
|
); |
||||
|
return { |
||||
|
canSeeAdmin, |
||||
|
}; |
||||
|
}; |
@ -0,0 +1,16 @@ |
|||||
|
// 运行时配置
|
||||
|
|
||||
|
// 全局初始化数据配置,用于 Layout 用户信息和权限初始化
|
||||
|
// 更多信息见文档:https://umijs.org/docs/api/runtime-config#getinitialstate
|
||||
|
export async function getInitialState(): Promise<{ name: string }> { |
||||
|
return { name: '@umijs/max' }; |
||||
|
} |
||||
|
|
||||
|
export const layout = () => { |
||||
|
return { |
||||
|
logo: 'https://img.alicdn.com/tfs/TB1YHEpwUT1gK0jSZFhXXaAtVXa-28-27.svg', |
||||
|
menu: { |
||||
|
locale: false, |
||||
|
}, |
||||
|
}; |
||||
|
}; |
@ -0,0 +1,4 @@ |
|||||
|
.title { |
||||
|
margin: 0 auto; |
||||
|
font-weight: 200; |
||||
|
} |
@ -0,0 +1,23 @@ |
|||||
|
import { Layout, Row, Typography } from 'antd'; |
||||
|
import React from 'react'; |
||||
|
import styles from './Guide.less'; |
||||
|
|
||||
|
interface Props { |
||||
|
name: string; |
||||
|
} |
||||
|
|
||||
|
// 脚手架示例组件
|
||||
|
const Guide: React.FC<Props> = (props) => { |
||||
|
const { name } = props; |
||||
|
return ( |
||||
|
<Layout> |
||||
|
<Row> |
||||
|
<Typography.Title level={3} className={styles.title}> |
||||
|
欢迎使用 <strong>{name}</strong> ! |
||||
|
</Typography.Title> |
||||
|
</Row> |
||||
|
</Layout> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default Guide; |
@ -0,0 +1,2 @@ |
|||||
|
import Guide from './Guide'; |
||||
|
export default Guide; |
@ -0,0 +1 @@ |
|||||
|
export const DEFAULT_NAME = 'Umi Max'; |
@ -0,0 +1,13 @@ |
|||||
|
// 全局共享数据示例
|
||||
|
import { DEFAULT_NAME } from '@/constants'; |
||||
|
import { useState } from 'react'; |
||||
|
|
||||
|
const useUser = () => { |
||||
|
const [name, setName] = useState<string>(DEFAULT_NAME); |
||||
|
return { |
||||
|
name, |
||||
|
setName, |
||||
|
}; |
||||
|
}; |
||||
|
|
||||
|
export default useUser; |
@ -0,0 +1,21 @@ |
|||||
|
import { PageContainer } from '@ant-design/pro-components'; |
||||
|
import { Access, useAccess } from '@umijs/max'; |
||||
|
import { Button } from 'antd'; |
||||
|
|
||||
|
const AccessPage: React.FC = () => { |
||||
|
const access = useAccess(); |
||||
|
return ( |
||||
|
<PageContainer |
||||
|
ghost |
||||
|
header={{ |
||||
|
title: '权限示例', |
||||
|
}} |
||||
|
> |
||||
|
<Access accessible={access.canSeeAdmin}> |
||||
|
<Button>只有 Admin 可以看到这个按钮</Button> |
||||
|
</Access> |
||||
|
</PageContainer> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default AccessPage; |
@ -0,0 +1,141 @@ |
|||||
|
.chatWindow { |
||||
|
height: 100%; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
background: #f5f5f5; |
||||
|
|
||||
|
.chatHeader { |
||||
|
padding: 16px; |
||||
|
background: white; |
||||
|
border-bottom: 1px solid #f0f0f0; |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
align-items: center; |
||||
|
|
||||
|
.typingIndicator { |
||||
|
font-size: 12px; |
||||
|
color: #8c8c8c; |
||||
|
font-style: italic; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.noChat { |
||||
|
flex: 1; |
||||
|
display: flex; |
||||
|
justify-content: center; |
||||
|
align-items: center; |
||||
|
color: #8c8c8c; |
||||
|
font-size: 16px; |
||||
|
} |
||||
|
|
||||
|
.messageList { |
||||
|
flex: 1; |
||||
|
overflow-y: auto; |
||||
|
padding: 20px; |
||||
|
} |
||||
|
|
||||
|
.messageItem { |
||||
|
margin-bottom: 16px; |
||||
|
display: flex; |
||||
|
|
||||
|
&.sent { |
||||
|
justify-content: flex-end; |
||||
|
|
||||
|
.messageContent { |
||||
|
background-color: #1890ff; |
||||
|
color: white; |
||||
|
|
||||
|
.messageTime { |
||||
|
color: rgba(255, 255, 255, 70%); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
&.received { |
||||
|
justify-content: flex-start; |
||||
|
|
||||
|
.messageContent { |
||||
|
background-color: white; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.messageContent { |
||||
|
max-width: 70%; |
||||
|
padding: 12px 16px; |
||||
|
border-radius: 8px; |
||||
|
word-wrap: break-word; |
||||
|
position: relative; |
||||
|
} |
||||
|
|
||||
|
.messageTime { |
||||
|
font-size: 12px; |
||||
|
margin-top: 4px; |
||||
|
opacity: 0.7; |
||||
|
display: flex; |
||||
|
justify-content: flex-end; |
||||
|
gap: 8px; |
||||
|
} |
||||
|
|
||||
|
.messageStatus { |
||||
|
font-size: 12px; |
||||
|
} |
||||
|
|
||||
|
.inputArea { |
||||
|
background: white; |
||||
|
padding: 16px; |
||||
|
border-top: 1px solid #f0f0f0; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
} |
||||
|
|
||||
|
.toolbar { |
||||
|
margin-bottom: 8px; |
||||
|
display: flex; |
||||
|
gap: 16px; |
||||
|
} |
||||
|
|
||||
|
.toolbarIcon { |
||||
|
font-size: 20px; |
||||
|
color: #8c8c8c; |
||||
|
cursor: pointer; |
||||
|
|
||||
|
&:hover { |
||||
|
color: #1890ff; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.sendButton { |
||||
|
margin-top: 8px; |
||||
|
align-self: flex-end; |
||||
|
} |
||||
|
|
||||
|
// 表情选择器样式 |
||||
|
.emojiPicker { |
||||
|
display: grid; |
||||
|
grid-template-columns: repeat(6, 1fr); |
||||
|
gap: 8px; |
||||
|
padding: 8px; |
||||
|
} |
||||
|
|
||||
|
.emojiItem { |
||||
|
font-size: 24px; |
||||
|
cursor: pointer; |
||||
|
text-align: center; |
||||
|
padding: 4px; |
||||
|
border-radius: 4px; |
||||
|
transition: background-color 0.3s; |
||||
|
|
||||
|
&:hover { |
||||
|
background-color: #f0f0f0; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
:global { |
||||
|
.ant-popover.emojiPopover { |
||||
|
.ant-popover-inner-content { |
||||
|
padding: 8px; |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,201 @@ |
|||||
|
import React from 'react'; |
||||
|
import { Input, Button, Upload, message, Popover } from 'antd'; |
||||
|
import { SendOutlined, SmileOutlined, PaperClipOutlined } from '@ant-design/icons'; |
||||
|
import { useWebSocket } from '../wss'; |
||||
|
import styles from './ChatWindow.less'; |
||||
|
|
||||
|
// 聊天窗口组件的属性接口
|
||||
|
interface ChatWindowProps { |
||||
|
userId: string; // 当前用户ID
|
||||
|
currentChat: { // 当前聊天对象
|
||||
|
id: string; // 聊天对象ID
|
||||
|
name: string; // 聊天对象名称
|
||||
|
} | null; |
||||
|
} |
||||
|
|
||||
|
// 聊天消息接口
|
||||
|
interface ChatMessage { |
||||
|
id: string; // 消息ID
|
||||
|
sender: string; // 发送者ID
|
||||
|
receiver: string; // 接收者ID
|
||||
|
content: string; // 消息内容
|
||||
|
timestamp: number; // 时间戳
|
||||
|
status: string; // 消息状态
|
||||
|
} |
||||
|
|
||||
|
// WebSocket状态接口
|
||||
|
interface WebSocketStatus { |
||||
|
status: 'CONNECTED' | 'DISCONNECTED'; // 连接状态
|
||||
|
messages: ChatMessage[]; // 消息列表
|
||||
|
typingUsers: string[]; // 正在输入的用户列表
|
||||
|
sendMessage: (message: string, receiver: string) => void; // 发送消息方法
|
||||
|
sendTyping: () => void; // 发送正在输入状态
|
||||
|
sendRead: (messageIds: string[]) => void; // 发送已读状态
|
||||
|
} |
||||
|
|
||||
|
// 聊天窗口组件
|
||||
|
const ChatWindow: React.FC<ChatWindowProps> = ({ userId, currentChat }) => { |
||||
|
const [inputValue, setInputValue] = React.useState(''); |
||||
|
const messagesEndRef = React.useRef<HTMLDivElement>(null); |
||||
|
|
||||
|
const { |
||||
|
messages, |
||||
|
typingUsers, |
||||
|
sendMessage, |
||||
|
sendTyping, |
||||
|
sendRead, |
||||
|
status |
||||
|
} = useWebSocket(userId) as WebSocketStatus; |
||||
|
|
||||
|
// 过滤当前聊天的消息
|
||||
|
const currentMessages = messages.filter( |
||||
|
msg => |
||||
|
(msg.sender === userId && msg.receiver === currentChat?.id) || |
||||
|
(msg.receiver === userId && msg.sender === currentChat?.id) |
||||
|
); |
||||
|
|
||||
|
// 滚动到底部方法
|
||||
|
const scrollToBottom = () => { |
||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); |
||||
|
}; |
||||
|
|
||||
|
// 消息更新时滚动到底部
|
||||
|
React.useEffect(() => { |
||||
|
scrollToBottom(); |
||||
|
}, [currentMessages]); |
||||
|
|
||||
|
// 处理发送消息
|
||||
|
const handleSend = () => { |
||||
|
if (!inputValue.trim() || !currentChat) return; |
||||
|
|
||||
|
sendMessage(inputValue.trim(), currentChat.id); |
||||
|
setInputValue(''); |
||||
|
}; |
||||
|
|
||||
|
// 处理输入变化
|
||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { |
||||
|
setInputValue(e.target.value); |
||||
|
sendTyping(); |
||||
|
}; |
||||
|
|
||||
|
// 处理键盘事件
|
||||
|
const handleKeyPress = (e: React.KeyboardEvent) => { |
||||
|
if (e.key === 'Enter' && !e.shiftKey) { |
||||
|
e.preventDefault(); |
||||
|
handleSend(); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
// 处理表情选择
|
||||
|
const handleEmojiSelect = (emoji: string) => { |
||||
|
setInputValue(prev => prev + emoji); |
||||
|
}; |
||||
|
|
||||
|
// 处理消息已读状态
|
||||
|
React.useEffect(() => { |
||||
|
if (!currentChat) return; |
||||
|
|
||||
|
const unreadMessages = currentMessages |
||||
|
.filter(msg => |
||||
|
msg.sender === currentChat.id && |
||||
|
msg.status !== 'read' |
||||
|
) |
||||
|
.map(msg => msg.id); |
||||
|
|
||||
|
if (unreadMessages.length > 0) { |
||||
|
sendRead(unreadMessages); |
||||
|
} |
||||
|
}, [currentMessages, currentChat]); |
||||
|
|
||||
|
const emojis = ['😊', '😂', '🤔', '👍', '❤️', '😍', '🎉', '👋', '🙏', '💪', '✨', '🌟']; |
||||
|
|
||||
|
const emojiContent = ( |
||||
|
<div className={styles.emojiPicker}> |
||||
|
{emojis.map((emoji) => ( |
||||
|
<span |
||||
|
key={emoji} |
||||
|
className={styles.emojiItem} |
||||
|
onClick={() => handleEmojiSelect(emoji)} |
||||
|
> |
||||
|
{emoji} |
||||
|
</span> |
||||
|
))} |
||||
|
</div> |
||||
|
); |
||||
|
|
||||
|
if (!currentChat) { |
||||
|
return ( |
||||
|
<div className={styles.chatWindow}> |
||||
|
<div className={styles.noChat}> |
||||
|
请选择一个联系人开始聊天 |
||||
|
</div> |
||||
|
</div> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return ( |
||||
|
<div className={styles.chatWindow}> |
||||
|
<div className={styles.chatHeader}> |
||||
|
<span>{currentChat.name}</span> |
||||
|
{typingUsers.includes(currentChat.id) && ( |
||||
|
<span className={styles.typingIndicator}>正在输入...</span> |
||||
|
)} |
||||
|
</div> |
||||
|
|
||||
|
<div className={styles.messageList}> |
||||
|
{currentMessages.map((msg) => ( |
||||
|
<div |
||||
|
key={msg.id} |
||||
|
className={`${styles.messageItem} ${ |
||||
|
msg.sender === userId ? styles.sent : styles.received |
||||
|
}`}
|
||||
|
> |
||||
|
<div className={styles.messageContent}> |
||||
|
{msg.content} |
||||
|
<div className={styles.messageTime}> |
||||
|
{new Date(msg.timestamp).toLocaleTimeString()} |
||||
|
{msg.sender === userId && ( |
||||
|
<span className={styles.messageStatus}>{msg.status}</span> |
||||
|
)} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
))} |
||||
|
<div ref={messagesEndRef} /> |
||||
|
</div> |
||||
|
|
||||
|
<div className={styles.inputArea}> |
||||
|
<div className={styles.toolbar}> |
||||
|
<Popover |
||||
|
content={emojiContent} |
||||
|
trigger="click" |
||||
|
placement="topLeft" |
||||
|
overlayClassName={styles.emojiPopover} |
||||
|
> |
||||
|
<SmileOutlined className={styles.toolbarIcon} /> |
||||
|
</Popover> |
||||
|
<Upload> |
||||
|
<PaperClipOutlined className={styles.toolbarIcon} /> |
||||
|
</Upload> |
||||
|
</div> |
||||
|
<Input.TextArea |
||||
|
value={inputValue} |
||||
|
onChange={handleInputChange} |
||||
|
onKeyPress={handleKeyPress} |
||||
|
placeholder="输入消息..." |
||||
|
autoSize={{ minRows: 2, maxRows: 6 }} |
||||
|
disabled={status !== 'CONNECTED'} |
||||
|
/> |
||||
|
<Button |
||||
|
type="primary" |
||||
|
icon={<SendOutlined />} |
||||
|
onClick={handleSend} |
||||
|
className={styles.sendButton} |
||||
|
disabled={status !== 'CONNECTED'} |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default ChatWindow; |
@ -0,0 +1,71 @@ |
|||||
|
.contactList { |
||||
|
height: 100%; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
|
||||
|
.searchBar { |
||||
|
padding: 16px; |
||||
|
border-bottom: 1px solid #f0f0f0; |
||||
|
} |
||||
|
|
||||
|
.list { |
||||
|
flex: 1; |
||||
|
overflow: auto; |
||||
|
} |
||||
|
|
||||
|
.contactItem { |
||||
|
padding: 12px 16px; |
||||
|
cursor: pointer; |
||||
|
transition: all 0.3s; |
||||
|
|
||||
|
&:hover { |
||||
|
background-color: #f5f5f5; |
||||
|
} |
||||
|
|
||||
|
&.selected { |
||||
|
background-color: #e6f7ff; |
||||
|
border-right: 3px solid #1890ff; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.contactTitle { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
align-items: center; |
||||
|
} |
||||
|
|
||||
|
.unreadBadge { |
||||
|
background-color: #1890ff; |
||||
|
color: white; |
||||
|
border-radius: 10px; |
||||
|
padding: 0 6px; |
||||
|
font-size: 12px; |
||||
|
min-width: 18px; |
||||
|
height: 18px; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
} |
||||
|
|
||||
|
.lastMessage { |
||||
|
color: #8c8c8c; |
||||
|
font-size: 12px; |
||||
|
margin-bottom: 4px; |
||||
|
overflow: hidden; |
||||
|
text-overflow: ellipsis; |
||||
|
white-space: nowrap; |
||||
|
} |
||||
|
|
||||
|
.tags { |
||||
|
margin-top: 4px; |
||||
|
|
||||
|
:global { |
||||
|
.ant-tag { |
||||
|
margin-right: 4px; |
||||
|
font-size: 12px; |
||||
|
line-height: 16px; |
||||
|
padding: 0 4px; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,126 @@ |
|||||
|
import React from 'react'; |
||||
|
import { Input, List, Tag, Avatar } from 'antd'; |
||||
|
import { SearchOutlined, UserOutlined } from '@ant-design/icons'; |
||||
|
import { useWebSocket } from '../wss'; |
||||
|
import styles from './ContactList.less'; |
||||
|
|
||||
|
interface Contact { |
||||
|
id: string; |
||||
|
name: string; |
||||
|
avatar?: string; |
||||
|
lastMessage: string; |
||||
|
tags: string[]; |
||||
|
unread?: number; |
||||
|
} |
||||
|
|
||||
|
interface ContactListProps { |
||||
|
currentUserId: string; |
||||
|
selectedContactId?: string; |
||||
|
onSelectContact: (contact: { id: string; name: string }) => void; |
||||
|
} |
||||
|
|
||||
|
const ContactList: React.FC<ContactListProps> = ({ |
||||
|
currentUserId, |
||||
|
selectedContactId, |
||||
|
onSelectContact, |
||||
|
}) => { |
||||
|
const [searchText, setSearchText] = React.useState(''); |
||||
|
const { messages } = useWebSocket(currentUserId); |
||||
|
|
||||
|
// 模拟联系人数据
|
||||
|
const contacts: Contact[] = [ |
||||
|
{ |
||||
|
id: 'user_002', |
||||
|
name: '张三', |
||||
|
lastMessage: '好的,我知道了', |
||||
|
tags: ['售前咨询'], |
||||
|
unread: 2, |
||||
|
}, |
||||
|
{ |
||||
|
id: 'user_003', |
||||
|
name: '李四', |
||||
|
lastMessage: '请问产品什么时候发货?', |
||||
|
tags: ['售后服务'], |
||||
|
}, |
||||
|
// 更多联系人...
|
||||
|
]; |
||||
|
|
||||
|
// 获取每个联系人的最后一条消息
|
||||
|
const getLastMessage = (contactId: string) => { |
||||
|
const contactMessages = messages.filter( |
||||
|
msg => msg.sender === contactId || msg.receiver === contactId |
||||
|
); |
||||
|
return contactMessages[contactMessages.length - 1]?.content || ''; |
||||
|
}; |
||||
|
|
||||
|
// 获取未读消息数
|
||||
|
const getUnreadCount = (contactId: string) => { |
||||
|
return messages.filter( |
||||
|
msg => |
||||
|
msg.sender === contactId && |
||||
|
msg.receiver === currentUserId && |
||||
|
msg.status !== 'read' |
||||
|
).length; |
||||
|
}; |
||||
|
|
||||
|
const filteredContacts = contacts |
||||
|
.map(contact => ({ |
||||
|
...contact, |
||||
|
lastMessage: getLastMessage(contact.id) || contact.lastMessage, |
||||
|
unread: getUnreadCount(contact.id), |
||||
|
})) |
||||
|
.filter(contact => |
||||
|
contact.name.toLowerCase().includes(searchText.toLowerCase()) |
||||
|
); |
||||
|
|
||||
|
return ( |
||||
|
<div className={styles.contactList}> |
||||
|
<div className={styles.searchBar}> |
||||
|
<Input |
||||
|
prefix={<SearchOutlined />} |
||||
|
placeholder="搜索联系人" |
||||
|
onChange={(e) => setSearchText(e.target.value)} |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<List |
||||
|
className={styles.list} |
||||
|
dataSource={filteredContacts} |
||||
|
renderItem={(contact) => ( |
||||
|
<List.Item |
||||
|
className={`${styles.contactItem} ${ |
||||
|
selectedContactId === contact.id ? styles.selected : '' |
||||
|
}`}
|
||||
|
onClick={() => onSelectContact({ id: contact.id, name: contact.name })} |
||||
|
> |
||||
|
<List.Item.Meta |
||||
|
avatar={ |
||||
|
<Avatar icon={<UserOutlined />} src={contact.avatar} /> |
||||
|
} |
||||
|
title={ |
||||
|
<div className={styles.contactTitle}> |
||||
|
<span>{contact.name}</span> |
||||
|
{contact.unread > 0 && ( |
||||
|
<span className={styles.unreadBadge}>{contact.unread}</span> |
||||
|
)} |
||||
|
</div> |
||||
|
} |
||||
|
description={ |
||||
|
<div> |
||||
|
<div className={styles.lastMessage}>{contact.lastMessage}</div> |
||||
|
<div className={styles.tags}> |
||||
|
{contact.tags.map((tag) => ( |
||||
|
<Tag key={tag} color="blue">{tag}</Tag> |
||||
|
))} |
||||
|
</div> |
||||
|
</div> |
||||
|
} |
||||
|
/> |
||||
|
</List.Item> |
||||
|
)} |
||||
|
/> |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default ContactList; |
@ -0,0 +1,36 @@ |
|||||
|
.infoPanel { |
||||
|
height: 100%; |
||||
|
overflow-y: auto; |
||||
|
padding: 16px; |
||||
|
|
||||
|
:global { |
||||
|
.ant-card { |
||||
|
margin-bottom: 16px; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.customerInfo { |
||||
|
.infoItem { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 8px; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.ordersCard { |
||||
|
.orderItem { |
||||
|
width: 100%; |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
align-items: center; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.quickReplyBtn { |
||||
|
width: 100%; |
||||
|
text-align: left; |
||||
|
height: auto; |
||||
|
white-space: normal; |
||||
|
word-wrap: break-word; |
||||
|
} |
||||
|
} |
@ -0,0 +1,123 @@ |
|||||
|
import React from 'react'; |
||||
|
import { Card, Typography, Button, List, Divider } from 'antd'; |
||||
|
import { UserOutlined, PhoneOutlined, ShoppingOutlined } from '@ant-design/icons'; |
||||
|
import styles from './InfoPanel.less'; |
||||
|
|
||||
|
const { Title, Text } = Typography; |
||||
|
|
||||
|
interface CustomerInfo { |
||||
|
name: string; |
||||
|
phone: string; |
||||
|
email: string; |
||||
|
orders: { |
||||
|
id: string; |
||||
|
date: string; |
||||
|
amount: number; |
||||
|
}[]; |
||||
|
} |
||||
|
|
||||
|
interface QuickReply { |
||||
|
id: string; |
||||
|
content: string; |
||||
|
} |
||||
|
|
||||
|
interface InfoPanelProps { |
||||
|
currentChat: { |
||||
|
id: string; |
||||
|
name: string; |
||||
|
} | null; |
||||
|
} |
||||
|
|
||||
|
const InfoPanel: React.FC<InfoPanelProps> = ({ currentChat }) => { |
||||
|
// 模拟客户信息
|
||||
|
const customerInfo: CustomerInfo = { |
||||
|
name: '张三', |
||||
|
phone: '13800138000', |
||||
|
email: 'zhangsan@example.com', |
||||
|
orders: [ |
||||
|
{ |
||||
|
id: 'ORDER001', |
||||
|
date: '2024-02-15', |
||||
|
amount: 299, |
||||
|
}, |
||||
|
{ |
||||
|
id: 'ORDER002', |
||||
|
date: '2024-01-20', |
||||
|
amount: 599, |
||||
|
}, |
||||
|
], |
||||
|
}; |
||||
|
|
||||
|
// 模拟快速回复模板
|
||||
|
const quickReplies: QuickReply[] = [ |
||||
|
{ |
||||
|
id: '1', |
||||
|
content: '您好,很高兴为您服务!', |
||||
|
}, |
||||
|
{ |
||||
|
id: '2', |
||||
|
content: '请问还有什么可以帮您的吗?', |
||||
|
}, |
||||
|
{ |
||||
|
id: '3', |
||||
|
content: '感谢您的咨询,祝您生活愉快!', |
||||
|
}, |
||||
|
]; |
||||
|
|
||||
|
return ( |
||||
|
<div className={styles.infoPanel}> |
||||
|
<Card title="客户信息" bordered={false}> |
||||
|
<div className={styles.customerInfo}> |
||||
|
<div className={styles.infoItem}> |
||||
|
<UserOutlined /> <Text>{customerInfo.name}</Text> |
||||
|
</div> |
||||
|
<div className={styles.infoItem}> |
||||
|
<PhoneOutlined /> <Text>{customerInfo.phone}</Text> |
||||
|
</div> |
||||
|
<div className={styles.infoItem}> |
||||
|
<Text type="secondary">{customerInfo.email}</Text> |
||||
|
</div> |
||||
|
</div> |
||||
|
</Card> |
||||
|
|
||||
|
<Card title="历史订单" bordered={false} className={styles.ordersCard}> |
||||
|
<List |
||||
|
dataSource={customerInfo.orders} |
||||
|
renderItem={(order) => ( |
||||
|
<List.Item> |
||||
|
<div className={styles.orderItem}> |
||||
|
<div> |
||||
|
<Text strong>{order.id}</Text> |
||||
|
<br /> |
||||
|
<Text type="secondary">{order.date}</Text> |
||||
|
</div> |
||||
|
<Text type="success">¥{order.amount}</Text> |
||||
|
</div> |
||||
|
</List.Item> |
||||
|
)} |
||||
|
/> |
||||
|
</Card> |
||||
|
|
||||
|
<Card title="快速回复" bordered={false}> |
||||
|
<List |
||||
|
dataSource={quickReplies} |
||||
|
renderItem={(reply) => ( |
||||
|
<List.Item> |
||||
|
<Button |
||||
|
type="text" |
||||
|
className={styles.quickReplyBtn} |
||||
|
onClick={() => { |
||||
|
// 实现快速回复功能
|
||||
|
}} |
||||
|
> |
||||
|
{reply.content} |
||||
|
</Button> |
||||
|
</List.Item> |
||||
|
)} |
||||
|
/> |
||||
|
</Card> |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default InfoPanel; |
@ -0,0 +1,14 @@ |
|||||
|
.chatLayout { |
||||
|
height: 90vh; |
||||
|
|
||||
|
.sider { |
||||
|
border-right: 1px solid #f0f0f0; |
||||
|
overflow: auto; |
||||
|
} |
||||
|
|
||||
|
.content { |
||||
|
padding: 0; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
} |
||||
|
} |
@ -0,0 +1,47 @@ |
|||||
|
import React from 'react'; |
||||
|
import { Layout } from 'antd'; |
||||
|
import ContactList from './components/ContactList'; |
||||
|
import ChatWindow from './components/ChatWindow'; |
||||
|
import InfoPanel from './components/InfoPanel'; |
||||
|
import styles from './index.less'; |
||||
|
|
||||
|
const { Sider, Content } = Layout; |
||||
|
|
||||
|
// 模拟用户ID,实际应该从登录状态获取
|
||||
|
const CURRENT_USER_ID = 'user_001'; |
||||
|
|
||||
|
const ChatPage: React.FC = () => { |
||||
|
const [currentChat, setCurrentChat] = React.useState<{ |
||||
|
id: string; |
||||
|
name: string; |
||||
|
} | null>(null); |
||||
|
|
||||
|
const handleContactSelect = (contact: { id: string; name: string }) => { |
||||
|
setCurrentChat(contact); |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<Layout className={styles.chatLayout}> |
||||
|
<Sider width={300} theme="light" className={styles.sider}> |
||||
|
<ContactList |
||||
|
currentUserId={CURRENT_USER_ID} |
||||
|
selectedContactId={currentChat?.id} |
||||
|
onSelectContact={handleContactSelect} |
||||
|
/> |
||||
|
</Sider> |
||||
|
<Content className={styles.content}> |
||||
|
<ChatWindow |
||||
|
userId={CURRENT_USER_ID} |
||||
|
currentChat={currentChat} |
||||
|
/> |
||||
|
</Content> |
||||
|
<Sider width={300} theme="light" className={styles.sider}> |
||||
|
<InfoPanel |
||||
|
currentChat={currentChat} |
||||
|
/> |
||||
|
</Sider> |
||||
|
</Layout> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default ChatPage; |
@ -0,0 +1,239 @@ |
|||||
|
import { WS_CONFIG } from './config'; |
||||
|
import { |
||||
|
WSMessageType, |
||||
|
WSMessage, |
||||
|
ChatMessage, |
||||
|
WSConnectionStatus, |
||||
|
WSEventHandlers, |
||||
|
} from './types'; |
||||
|
|
||||
|
class WebSocketClient { |
||||
|
private ws: WebSocket | null = null; |
||||
|
private reconnectAttempts = 0; |
||||
|
private heartbeatInterval: NodeJS.Timeout | null = null; |
||||
|
private heartbeatTimeout: NodeJS.Timeout | null = null; |
||||
|
private messageQueue: WSMessage[] = []; |
||||
|
private status: WSConnectionStatus = WSConnectionStatus.DISCONNECTED; |
||||
|
private handlers: WSEventHandlers = {}; |
||||
|
|
||||
|
constructor(private userId: string) { |
||||
|
this.connect(); |
||||
|
} |
||||
|
|
||||
|
// 设置事件处理器
|
||||
|
setEventHandlers(handlers: WSEventHandlers) { |
||||
|
this.handlers = handlers; |
||||
|
} |
||||
|
|
||||
|
// 建立连接
|
||||
|
private connect() { |
||||
|
if (this.ws?.readyState === WebSocket.OPEN) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.updateStatus(WSConnectionStatus.CONNECTING); |
||||
|
|
||||
|
try { |
||||
|
this.ws = new WebSocket(`${WS_CONFIG.SERVER_URL}?userId=${this.userId}`); |
||||
|
this.setupWebSocketListeners(); |
||||
|
} catch (error) { |
||||
|
this.handleError(error as Error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 设置WebSocket监听器
|
||||
|
private setupWebSocketListeners() { |
||||
|
if (!this.ws) return; |
||||
|
|
||||
|
this.ws.onopen = () => { |
||||
|
this.onConnectionEstablished(); |
||||
|
}; |
||||
|
|
||||
|
this.ws.onmessage = (event) => { |
||||
|
this.handleMessage(event); |
||||
|
}; |
||||
|
|
||||
|
this.ws.onclose = () => { |
||||
|
this.onConnectionClosed(); |
||||
|
}; |
||||
|
|
||||
|
this.ws.onerror = (error) => { |
||||
|
this.handleError(error as Error); |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
// 连接建立后的处理
|
||||
|
private onConnectionEstablished() { |
||||
|
this.updateStatus(WSConnectionStatus.CONNECTED); |
||||
|
this.reconnectAttempts = 0; |
||||
|
this.startHeartbeat(); |
||||
|
this.processMessageQueue(); |
||||
|
} |
||||
|
|
||||
|
// 连接关闭后的处理
|
||||
|
private onConnectionClosed() { |
||||
|
this.updateStatus(WSConnectionStatus.DISCONNECTED); |
||||
|
this.stopHeartbeat(); |
||||
|
|
||||
|
if (WS_CONFIG.RECONNECT.ENABLED && |
||||
|
this.reconnectAttempts < WS_CONFIG.RECONNECT.MAX_ATTEMPTS) { |
||||
|
this.scheduleReconnect(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 安排重连
|
||||
|
private scheduleReconnect() { |
||||
|
const delay = WS_CONFIG.RECONNECT.DELAY * |
||||
|
Math.pow(WS_CONFIG.RECONNECT.BACKOFF_FACTOR, this.reconnectAttempts); |
||||
|
|
||||
|
setTimeout(() => { |
||||
|
this.reconnectAttempts++; |
||||
|
this.connect(); |
||||
|
}, delay); |
||||
|
} |
||||
|
|
||||
|
// 开始心跳
|
||||
|
private startHeartbeat() { |
||||
|
if (!WS_CONFIG.HEARTBEAT.ENABLED) return; |
||||
|
|
||||
|
this.heartbeatInterval = setInterval(() => { |
||||
|
this.sendHeartbeat(); |
||||
|
}, WS_CONFIG.HEARTBEAT.INTERVAL); |
||||
|
} |
||||
|
|
||||
|
// 停止心跳
|
||||
|
private stopHeartbeat() { |
||||
|
if (this.heartbeatInterval) { |
||||
|
clearInterval(this.heartbeatInterval); |
||||
|
this.heartbeatInterval = null; |
||||
|
} |
||||
|
if (this.heartbeatTimeout) { |
||||
|
clearTimeout(this.heartbeatTimeout); |
||||
|
this.heartbeatTimeout = null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 发送心跳
|
||||
|
private sendHeartbeat() { |
||||
|
this.send({ |
||||
|
type: WSMessageType.CONNECT, |
||||
|
payload: { type: 'ping' }, |
||||
|
timestamp: Date.now(), |
||||
|
}); |
||||
|
|
||||
|
this.heartbeatTimeout = setTimeout(() => { |
||||
|
this.handleError(new Error('Heartbeat timeout')); |
||||
|
this.reconnect(); |
||||
|
}, WS_CONFIG.HEARTBEAT.TIMEOUT); |
||||
|
} |
||||
|
|
||||
|
// 重新连接
|
||||
|
private reconnect() { |
||||
|
if (this.ws) { |
||||
|
this.ws.close(); |
||||
|
} |
||||
|
this.connect(); |
||||
|
} |
||||
|
|
||||
|
// 处理消息队列
|
||||
|
private processMessageQueue() { |
||||
|
while (this.messageQueue.length > 0) { |
||||
|
const message = this.messageQueue.shift(); |
||||
|
if (message) { |
||||
|
this.send(message); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 处理接收到的消息
|
||||
|
private handleMessage(event: MessageEvent) { |
||||
|
try { |
||||
|
const message: WSMessage = JSON.parse(event.data); |
||||
|
|
||||
|
switch (message.type) { |
||||
|
case WSMessageType.MESSAGE: |
||||
|
this.handlers.onMessage?.(message.payload as ChatMessage); |
||||
|
break; |
||||
|
case WSMessageType.TYPING: |
||||
|
this.handlers.onTyping?.(message.payload.userId); |
||||
|
break; |
||||
|
case WSMessageType.READ: |
||||
|
this.handlers.onRead?.(message.payload.messageIds); |
||||
|
break; |
||||
|
// 处理心跳响应
|
||||
|
case WSMessageType.CONNECT: |
||||
|
if (this.heartbeatTimeout) { |
||||
|
clearTimeout(this.heartbeatTimeout); |
||||
|
this.heartbeatTimeout = null; |
||||
|
} |
||||
|
break; |
||||
|
} |
||||
|
} catch (error) { |
||||
|
this.handleError(error as Error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 发送消息
|
||||
|
send(message: WSMessage) { |
||||
|
if (this.ws?.readyState === WebSocket.OPEN) { |
||||
|
this.ws.send(JSON.stringify(message)); |
||||
|
} else { |
||||
|
this.messageQueue.push(message); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 发送聊天消息
|
||||
|
sendMessage(message: ChatMessage) { |
||||
|
this.send({ |
||||
|
type: WSMessageType.MESSAGE, |
||||
|
payload: message, |
||||
|
timestamp: Date.now(), |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// 发送正在输入状态
|
||||
|
sendTyping() { |
||||
|
this.send({ |
||||
|
type: WSMessageType.TYPING, |
||||
|
payload: { userId: this.userId }, |
||||
|
timestamp: Date.now(), |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// 发送消息已读状态
|
||||
|
sendRead(messageIds: string[]) { |
||||
|
this.send({ |
||||
|
type: WSMessageType.READ, |
||||
|
payload: { messageIds }, |
||||
|
timestamp: Date.now(), |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// 更新连接状态
|
||||
|
private updateStatus(status: WSConnectionStatus) { |
||||
|
this.status = status; |
||||
|
this.handlers.onStatusChange?.(status); |
||||
|
} |
||||
|
|
||||
|
// 处理错误
|
||||
|
private handleError(error: Error) { |
||||
|
this.updateStatus(WSConnectionStatus.ERROR); |
||||
|
this.handlers.onError?.(error); |
||||
|
} |
||||
|
|
||||
|
// 关闭连接
|
||||
|
disconnect() { |
||||
|
this.stopHeartbeat(); |
||||
|
if (this.ws) { |
||||
|
this.ws.close(); |
||||
|
this.ws = null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 获取当前连接状态
|
||||
|
getStatus(): WSConnectionStatus { |
||||
|
return this.status; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export default WebSocketClient; |
@ -0,0 +1,34 @@ |
|||||
|
export const WS_CONFIG = { |
||||
|
// WebSocket服务器地址
|
||||
|
SERVER_URL: process.env.WS_SERVER_URL || 'ws://localhost:8080', |
||||
|
|
||||
|
// 重连配置
|
||||
|
RECONNECT: { |
||||
|
// 是否启用自动重连
|
||||
|
ENABLED: true, |
||||
|
// 最大重连次数
|
||||
|
MAX_ATTEMPTS: 5, |
||||
|
// 重连延迟(毫秒)
|
||||
|
DELAY: 3000, |
||||
|
// 重连延迟增长倍数
|
||||
|
BACKOFF_FACTOR: 1.5, |
||||
|
}, |
||||
|
|
||||
|
// 心跳配置
|
||||
|
HEARTBEAT: { |
||||
|
// 是否启用心跳
|
||||
|
ENABLED: true, |
||||
|
// 心跳间隔(毫秒)
|
||||
|
INTERVAL: 30000, |
||||
|
// 心跳超时时间(毫秒)
|
||||
|
TIMEOUT: 5000, |
||||
|
}, |
||||
|
|
||||
|
// 消息配置
|
||||
|
MESSAGE: { |
||||
|
// 消息重发最大次数
|
||||
|
MAX_RETRY: 3, |
||||
|
// 消息重发延迟(毫秒)
|
||||
|
RETRY_DELAY: 1000, |
||||
|
}, |
||||
|
}; |
@ -0,0 +1,5 @@ |
|||||
|
export { default as WebSocketClient } from './WebSocketClient'; |
||||
|
export { default as useWebSocket } from './useWebSocket'; |
||||
|
export * from './types'; |
||||
|
export * from './utils'; |
||||
|
export { WS_CONFIG } from './config'; |
@ -0,0 +1,42 @@ |
|||||
|
// WebSocket消息类型
|
||||
|
export enum WSMessageType { |
||||
|
CONNECT = 'CONNECT', |
||||
|
MESSAGE = 'MESSAGE', |
||||
|
READ = 'READ', |
||||
|
TYPING = 'TYPING', |
||||
|
ERROR = 'ERROR', |
||||
|
} |
||||
|
|
||||
|
// WebSocket消息接口
|
||||
|
export interface WSMessage { |
||||
|
type: WSMessageType; |
||||
|
payload: any; |
||||
|
timestamp: number; |
||||
|
} |
||||
|
|
||||
|
// 聊天消息接口
|
||||
|
export interface ChatMessage { |
||||
|
id: string; |
||||
|
content: string; |
||||
|
sender: string; |
||||
|
receiver: string; |
||||
|
timestamp: number; |
||||
|
status: 'sending' | 'sent' | 'delivered' | 'read'; |
||||
|
} |
||||
|
|
||||
|
// WebSocket连接状态
|
||||
|
export enum WSConnectionStatus { |
||||
|
CONNECTING = 'CONNECTING', |
||||
|
CONNECTED = 'CONNECTED', |
||||
|
DISCONNECTED = 'DISCONNECTED', |
||||
|
ERROR = 'ERROR', |
||||
|
} |
||||
|
|
||||
|
// WebSocket事件处理器接口
|
||||
|
export interface WSEventHandlers { |
||||
|
onMessage?: (message: ChatMessage) => void; |
||||
|
onStatusChange?: (status: WSConnectionStatus) => void; |
||||
|
onError?: (error: Error) => void; |
||||
|
onTyping?: (userId: string) => void; |
||||
|
onRead?: (messageIds: string[]) => void; |
||||
|
} |
@ -0,0 +1,89 @@ |
|||||
|
import { useEffect, useRef, useState, useCallback } from 'react'; |
||||
|
import WebSocketClient from './WebSocketClient'; |
||||
|
import { ChatMessage, WSConnectionStatus } from './types'; |
||||
|
|
||||
|
export const useWebSocket = (userId: string) => { |
||||
|
const wsClient = useRef<WebSocketClient | null>(null); |
||||
|
const [status, setStatus] = useState<WSConnectionStatus>(WSConnectionStatus.DISCONNECTED); |
||||
|
const [messages, setMessages] = useState<ChatMessage[]>([]); |
||||
|
const [typingUsers, setTypingUsers] = useState<Set<string>>(new Set()); |
||||
|
|
||||
|
// 初始化WebSocket客户端
|
||||
|
useEffect(() => { |
||||
|
wsClient.current = new WebSocketClient(userId); |
||||
|
|
||||
|
wsClient.current.setEventHandlers({ |
||||
|
onMessage: (message: ChatMessage) => { |
||||
|
setMessages(prev => [...prev, message]); |
||||
|
}, |
||||
|
onStatusChange: (newStatus: WSConnectionStatus) => { |
||||
|
setStatus(newStatus); |
||||
|
}, |
||||
|
onTyping: (typingUserId: string) => { |
||||
|
setTypingUsers(prev => new Set(prev).add(typingUserId)); |
||||
|
// 3秒后清除输入状态
|
||||
|
setTimeout(() => { |
||||
|
setTypingUsers(prev => { |
||||
|
const newSet = new Set(prev); |
||||
|
newSet.delete(typingUserId); |
||||
|
return newSet; |
||||
|
}); |
||||
|
}, 3000); |
||||
|
}, |
||||
|
onRead: (messageIds: string[]) => { |
||||
|
setMessages(prev => |
||||
|
prev.map(msg => |
||||
|
messageIds.includes(msg.id) |
||||
|
? { ...msg, status: 'read' } |
||||
|
: msg |
||||
|
) |
||||
|
); |
||||
|
}, |
||||
|
onError: (error: Error) => { |
||||
|
console.error('WebSocket error:', error); |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
return () => { |
||||
|
wsClient.current?.disconnect(); |
||||
|
}; |
||||
|
}, [userId]); |
||||
|
|
||||
|
// 发送消息
|
||||
|
const sendMessage = useCallback((content: string, receiver: string) => { |
||||
|
if (!wsClient.current) return; |
||||
|
|
||||
|
const message: ChatMessage = { |
||||
|
id: Date.now().toString(), // 临时ID,实际应该由服务器生成
|
||||
|
content, |
||||
|
sender: userId, |
||||
|
receiver, |
||||
|
timestamp: Date.now(), |
||||
|
status: 'sending', |
||||
|
}; |
||||
|
|
||||
|
setMessages(prev => [...prev, message]); |
||||
|
wsClient.current.sendMessage(message); |
||||
|
}, [userId]); |
||||
|
|
||||
|
// 发送正在输入状态
|
||||
|
const sendTyping = useCallback(() => { |
||||
|
wsClient.current?.sendTyping(); |
||||
|
}, []); |
||||
|
|
||||
|
// 发送消息已读状态
|
||||
|
const sendRead = useCallback((messageIds: string[]) => { |
||||
|
wsClient.current?.sendRead(messageIds); |
||||
|
}, []); |
||||
|
|
||||
|
return { |
||||
|
status, |
||||
|
messages, |
||||
|
typingUsers: Array.from(typingUsers), |
||||
|
sendMessage, |
||||
|
sendTyping, |
||||
|
sendRead, |
||||
|
}; |
||||
|
}; |
||||
|
|
||||
|
export default useWebSocket; |
@ -0,0 +1,34 @@ |
|||||
|
import { WSMessage, WSMessageType } from './types'; |
||||
|
|
||||
|
// 创建WebSocket消息
|
||||
|
export const createWSMessage = (type: WSMessageType, payload: any): WSMessage => ({ |
||||
|
type, |
||||
|
payload, |
||||
|
timestamp: Date.now(), |
||||
|
}); |
||||
|
|
||||
|
// 解析WebSocket消息
|
||||
|
export const parseWSMessage = (data: string): WSMessage | null => { |
||||
|
try { |
||||
|
return JSON.parse(data); |
||||
|
} catch (error) { |
||||
|
console.error('Error parsing WebSocket message:', error); |
||||
|
return null; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
// 格式化时间戳
|
||||
|
export const formatMessageTime = (timestamp: number): string => { |
||||
|
const date = new Date(timestamp); |
||||
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); |
||||
|
}; |
||||
|
|
||||
|
// 检查消息是否过期
|
||||
|
export const isMessageExpired = (timestamp: number, expirationTime: number = 5 * 60 * 1000): boolean => { |
||||
|
return Date.now() - timestamp > expirationTime; |
||||
|
}; |
||||
|
|
||||
|
// 生成唯一消息ID
|
||||
|
export const generateMessageId = (): string => { |
||||
|
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; |
||||
|
}; |
@ -0,0 +1,3 @@ |
|||||
|
.container { |
||||
|
padding-top: 80px; |
||||
|
} |
@ -0,0 +1,18 @@ |
|||||
|
import Guide from '@/components/Guide'; |
||||
|
import { trim } from '@/utils/format'; |
||||
|
import { PageContainer } from '@ant-design/pro-components'; |
||||
|
import { useModel } from '@umijs/max'; |
||||
|
import styles from './index.less'; |
||||
|
|
||||
|
const HomePage: React.FC = () => { |
||||
|
const { name } = useModel('global'); |
||||
|
return ( |
||||
|
<PageContainer ghost> |
||||
|
<div className={styles.container}> |
||||
|
<Guide name={trim(name)} /> |
||||
|
</div> |
||||
|
</PageContainer> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default HomePage; |
@ -0,0 +1,26 @@ |
|||||
|
import { Modal } from 'antd'; |
||||
|
import React, { PropsWithChildren } from 'react'; |
||||
|
|
||||
|
interface CreateFormProps { |
||||
|
modalVisible: boolean; |
||||
|
onCancel: () => void; |
||||
|
} |
||||
|
|
||||
|
const CreateForm: React.FC<PropsWithChildren<CreateFormProps>> = (props) => { |
||||
|
const { modalVisible, onCancel } = props; |
||||
|
|
||||
|
return ( |
||||
|
<Modal |
||||
|
destroyOnClose |
||||
|
title="新建" |
||||
|
width={420} |
||||
|
open={modalVisible} |
||||
|
onCancel={() => onCancel()} |
||||
|
footer={null} |
||||
|
> |
||||
|
{props.children} |
||||
|
</Modal> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default CreateForm; |
@ -0,0 +1,138 @@ |
|||||
|
import { |
||||
|
ProFormDateTimePicker, |
||||
|
ProFormRadio, |
||||
|
ProFormSelect, |
||||
|
ProFormText, |
||||
|
ProFormTextArea, |
||||
|
StepsForm, |
||||
|
} from '@ant-design/pro-components'; |
||||
|
import { Modal } from 'antd'; |
||||
|
import React from 'react'; |
||||
|
|
||||
|
export interface FormValueType extends Partial<API.UserInfo> { |
||||
|
target?: string; |
||||
|
template?: string; |
||||
|
type?: string; |
||||
|
time?: string; |
||||
|
frequency?: string; |
||||
|
} |
||||
|
|
||||
|
export interface UpdateFormProps { |
||||
|
onCancel: (flag?: boolean, formVals?: FormValueType) => void; |
||||
|
onSubmit: (values: FormValueType) => Promise<void>; |
||||
|
updateModalVisible: boolean; |
||||
|
values: Partial<API.UserInfo>; |
||||
|
} |
||||
|
|
||||
|
const UpdateForm: React.FC<UpdateFormProps> = (props) => ( |
||||
|
<StepsForm |
||||
|
stepsProps={{ |
||||
|
size: 'small', |
||||
|
}} |
||||
|
stepsFormRender={(dom, submitter) => { |
||||
|
return ( |
||||
|
<Modal |
||||
|
width={640} |
||||
|
bodyStyle={{ padding: '32px 40px 48px' }} |
||||
|
destroyOnClose |
||||
|
title="规则配置" |
||||
|
open={props.updateModalVisible} |
||||
|
footer={submitter} |
||||
|
onCancel={() => props.onCancel()} |
||||
|
> |
||||
|
{dom} |
||||
|
</Modal> |
||||
|
); |
||||
|
}} |
||||
|
onFinish={props.onSubmit} |
||||
|
> |
||||
|
<StepsForm.StepForm |
||||
|
initialValues={{ |
||||
|
name: props.values.name, |
||||
|
nickName: props.values.nickName, |
||||
|
}} |
||||
|
title="基本信息" |
||||
|
> |
||||
|
<ProFormText |
||||
|
width="md" |
||||
|
name="name" |
||||
|
label="规则名称" |
||||
|
rules={[{ required: true, message: '请输入规则名称!' }]} |
||||
|
/> |
||||
|
<ProFormTextArea |
||||
|
name="desc" |
||||
|
width="md" |
||||
|
label="规则描述" |
||||
|
placeholder="请输入至少五个字符" |
||||
|
rules={[ |
||||
|
{ required: true, message: '请输入至少五个字符的规则描述!', min: 5 }, |
||||
|
]} |
||||
|
/> |
||||
|
</StepsForm.StepForm> |
||||
|
<StepsForm.StepForm |
||||
|
initialValues={{ |
||||
|
target: '0', |
||||
|
template: '0', |
||||
|
}} |
||||
|
title="配置规则属性" |
||||
|
> |
||||
|
<ProFormSelect |
||||
|
width="md" |
||||
|
name="target" |
||||
|
label="监控对象" |
||||
|
valueEnum={{ |
||||
|
0: '表一', |
||||
|
1: '表二', |
||||
|
}} |
||||
|
/> |
||||
|
<ProFormSelect |
||||
|
width="md" |
||||
|
name="template" |
||||
|
label="规则模板" |
||||
|
valueEnum={{ |
||||
|
0: '规则模板一', |
||||
|
1: '规则模板二', |
||||
|
}} |
||||
|
/> |
||||
|
<ProFormRadio.Group |
||||
|
name="type" |
||||
|
width="md" |
||||
|
label="规则类型" |
||||
|
options={[ |
||||
|
{ |
||||
|
value: '0', |
||||
|
label: '强', |
||||
|
}, |
||||
|
{ |
||||
|
value: '1', |
||||
|
label: '弱', |
||||
|
}, |
||||
|
]} |
||||
|
/> |
||||
|
</StepsForm.StepForm> |
||||
|
<StepsForm.StepForm |
||||
|
initialValues={{ |
||||
|
type: '1', |
||||
|
frequency: 'month', |
||||
|
}} |
||||
|
title="设定调度周期" |
||||
|
> |
||||
|
<ProFormDateTimePicker |
||||
|
name="time" |
||||
|
label="开始时间" |
||||
|
rules={[{ required: true, message: '请选择开始时间!' }]} |
||||
|
/> |
||||
|
<ProFormSelect |
||||
|
name="frequency" |
||||
|
label="监控对象" |
||||
|
width="xs" |
||||
|
valueEnum={{ |
||||
|
month: '月', |
||||
|
week: '周', |
||||
|
}} |
||||
|
/> |
||||
|
</StepsForm.StepForm> |
||||
|
</StepsForm> |
||||
|
); |
||||
|
|
||||
|
export default UpdateForm; |
@ -0,0 +1,270 @@ |
|||||
|
import services from '@/services/demo'; |
||||
|
import { |
||||
|
ActionType, |
||||
|
FooterToolbar, |
||||
|
PageContainer, |
||||
|
ProDescriptions, |
||||
|
ProDescriptionsItemProps, |
||||
|
ProTable, |
||||
|
} from '@ant-design/pro-components'; |
||||
|
import { Button, Divider, Drawer, message } from 'antd'; |
||||
|
import React, { useRef, useState } from 'react'; |
||||
|
import CreateForm from './components/CreateForm'; |
||||
|
import UpdateForm, { FormValueType } from './components/UpdateForm'; |
||||
|
|
||||
|
const { addUser, queryUserList, deleteUser, modifyUser } = |
||||
|
services.UserController; |
||||
|
|
||||
|
/** |
||||
|
* 添加节点 |
||||
|
* @param fields |
||||
|
*/ |
||||
|
const handleAdd = async (fields: API.UserInfo) => { |
||||
|
const hide = message.loading('正在添加'); |
||||
|
try { |
||||
|
await addUser({ ...fields }); |
||||
|
hide(); |
||||
|
message.success('添加成功'); |
||||
|
return true; |
||||
|
} catch (error) { |
||||
|
hide(); |
||||
|
message.error('添加失败请重试!'); |
||||
|
return false; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
/** |
||||
|
* 更新节点 |
||||
|
* @param fields |
||||
|
*/ |
||||
|
const handleUpdate = async (fields: FormValueType) => { |
||||
|
const hide = message.loading('正在配置'); |
||||
|
try { |
||||
|
await modifyUser( |
||||
|
{ |
||||
|
userId: fields.id || '', |
||||
|
}, |
||||
|
{ |
||||
|
name: fields.name || '', |
||||
|
nickName: fields.nickName || '', |
||||
|
email: fields.email || '', |
||||
|
}, |
||||
|
); |
||||
|
hide(); |
||||
|
|
||||
|
message.success('配置成功'); |
||||
|
return true; |
||||
|
} catch (error) { |
||||
|
hide(); |
||||
|
message.error('配置失败请重试!'); |
||||
|
return false; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
/** |
||||
|
* 删除节点 |
||||
|
* @param selectedRows |
||||
|
*/ |
||||
|
const handleRemove = async (selectedRows: API.UserInfo[]) => { |
||||
|
const hide = message.loading('正在删除'); |
||||
|
if (!selectedRows) return true; |
||||
|
try { |
||||
|
await deleteUser({ |
||||
|
userId: selectedRows.find((row) => row.id)?.id || '', |
||||
|
}); |
||||
|
hide(); |
||||
|
message.success('删除成功,即将刷新'); |
||||
|
return true; |
||||
|
} catch (error) { |
||||
|
hide(); |
||||
|
message.error('删除失败,请重试'); |
||||
|
return false; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const TableList: React.FC<unknown> = () => { |
||||
|
const [createModalVisible, handleModalVisible] = useState<boolean>(false); |
||||
|
const [updateModalVisible, handleUpdateModalVisible] = |
||||
|
useState<boolean>(false); |
||||
|
const [stepFormValues, setStepFormValues] = useState({}); |
||||
|
const actionRef = useRef<ActionType>(); |
||||
|
const [row, setRow] = useState<API.UserInfo>(); |
||||
|
const [selectedRowsState, setSelectedRows] = useState<API.UserInfo[]>([]); |
||||
|
const columns: ProDescriptionsItemProps<API.UserInfo>[] = [ |
||||
|
{ |
||||
|
title: '名称', |
||||
|
dataIndex: 'name', |
||||
|
tip: '名称是唯一的 key', |
||||
|
formItemProps: { |
||||
|
rules: [ |
||||
|
{ |
||||
|
required: true, |
||||
|
message: '名称为必填项', |
||||
|
}, |
||||
|
], |
||||
|
}, |
||||
|
}, |
||||
|
{ |
||||
|
title: '昵称', |
||||
|
dataIndex: 'nickName', |
||||
|
valueType: 'text', |
||||
|
}, |
||||
|
{ |
||||
|
title: '性别', |
||||
|
dataIndex: 'gender', |
||||
|
hideInForm: true, |
||||
|
valueEnum: { |
||||
|
0: { text: '男', status: 'MALE' }, |
||||
|
1: { text: '女', status: 'FEMALE' }, |
||||
|
}, |
||||
|
}, |
||||
|
{ |
||||
|
title: '操作', |
||||
|
dataIndex: 'option', |
||||
|
valueType: 'option', |
||||
|
render: (_, record) => ( |
||||
|
<> |
||||
|
<a |
||||
|
onClick={() => { |
||||
|
handleUpdateModalVisible(true); |
||||
|
setStepFormValues(record); |
||||
|
}} |
||||
|
> |
||||
|
配置 |
||||
|
</a> |
||||
|
<Divider type="vertical" /> |
||||
|
<a href="">订阅警报</a> |
||||
|
</> |
||||
|
), |
||||
|
}, |
||||
|
]; |
||||
|
|
||||
|
return ( |
||||
|
<PageContainer |
||||
|
header={{ |
||||
|
title: 'CRUD 示例', |
||||
|
}} |
||||
|
> |
||||
|
<ProTable<API.UserInfo> |
||||
|
headerTitle="查询表格" |
||||
|
actionRef={actionRef} |
||||
|
rowKey="id" |
||||
|
search={{ |
||||
|
labelWidth: 120, |
||||
|
}} |
||||
|
toolBarRender={() => [ |
||||
|
<Button |
||||
|
key="1" |
||||
|
type="primary" |
||||
|
onClick={() => handleModalVisible(true)} |
||||
|
> |
||||
|
新建 |
||||
|
</Button>, |
||||
|
]} |
||||
|
request={async (params, sorter, filter) => { |
||||
|
const { data, success } = await queryUserList({ |
||||
|
...params, |
||||
|
// FIXME: remove @ts-ignore
|
||||
|
// @ts-ignore
|
||||
|
sorter, |
||||
|
filter, |
||||
|
}); |
||||
|
return { |
||||
|
data: data?.list || [], |
||||
|
success, |
||||
|
}; |
||||
|
}} |
||||
|
columns={columns} |
||||
|
rowSelection={{ |
||||
|
onChange: (_, selectedRows) => setSelectedRows(selectedRows), |
||||
|
}} |
||||
|
/> |
||||
|
{selectedRowsState?.length > 0 && ( |
||||
|
<FooterToolbar |
||||
|
extra={ |
||||
|
<div> |
||||
|
已选择{' '} |
||||
|
<a style={{ fontWeight: 600 }}>{selectedRowsState.length}</a>{' '} |
||||
|
项 |
||||
|
</div> |
||||
|
} |
||||
|
> |
||||
|
<Button |
||||
|
onClick={async () => { |
||||
|
await handleRemove(selectedRowsState); |
||||
|
setSelectedRows([]); |
||||
|
actionRef.current?.reloadAndRest?.(); |
||||
|
}} |
||||
|
> |
||||
|
批量删除 |
||||
|
</Button> |
||||
|
<Button type="primary">批量审批</Button> |
||||
|
</FooterToolbar> |
||||
|
)} |
||||
|
<CreateForm |
||||
|
onCancel={() => handleModalVisible(false)} |
||||
|
modalVisible={createModalVisible} |
||||
|
> |
||||
|
<ProTable<API.UserInfo, API.UserInfo> |
||||
|
onSubmit={async (value) => { |
||||
|
const success = await handleAdd(value); |
||||
|
if (success) { |
||||
|
handleModalVisible(false); |
||||
|
if (actionRef.current) { |
||||
|
actionRef.current.reload(); |
||||
|
} |
||||
|
} |
||||
|
}} |
||||
|
rowKey="id" |
||||
|
type="form" |
||||
|
columns={columns} |
||||
|
/> |
||||
|
</CreateForm> |
||||
|
{stepFormValues && Object.keys(stepFormValues).length ? ( |
||||
|
<UpdateForm |
||||
|
onSubmit={async (value) => { |
||||
|
const success = await handleUpdate(value); |
||||
|
if (success) { |
||||
|
handleUpdateModalVisible(false); |
||||
|
setStepFormValues({}); |
||||
|
if (actionRef.current) { |
||||
|
actionRef.current.reload(); |
||||
|
} |
||||
|
} |
||||
|
}} |
||||
|
onCancel={() => { |
||||
|
handleUpdateModalVisible(false); |
||||
|
setStepFormValues({}); |
||||
|
}} |
||||
|
updateModalVisible={updateModalVisible} |
||||
|
values={stepFormValues} |
||||
|
/> |
||||
|
) : null} |
||||
|
|
||||
|
<Drawer |
||||
|
width={600} |
||||
|
open={!!row} |
||||
|
onClose={() => { |
||||
|
setRow(undefined); |
||||
|
}} |
||||
|
closable={false} |
||||
|
> |
||||
|
{row?.name && ( |
||||
|
<ProDescriptions<API.UserInfo> |
||||
|
column={2} |
||||
|
title={row?.name} |
||||
|
request={async () => ({ |
||||
|
data: row || {}, |
||||
|
})} |
||||
|
params={{ |
||||
|
id: row?.name, |
||||
|
}} |
||||
|
columns={columns} |
||||
|
/> |
||||
|
)} |
||||
|
</Drawer> |
||||
|
</PageContainer> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default TableList; |
@ -0,0 +1,96 @@ |
|||||
|
/* eslint-disable */ |
||||
|
// 该文件由 OneAPI 自动生成,请勿手动修改!
|
||||
|
import { request } from '@umijs/max'; |
||||
|
|
||||
|
/** 此处后端没有提供注释 GET /api/v1/queryUserList */ |
||||
|
export async function queryUserList( |
||||
|
params: { |
||||
|
// query
|
||||
|
/** keyword */ |
||||
|
keyword?: string; |
||||
|
/** current */ |
||||
|
current?: number; |
||||
|
/** pageSize */ |
||||
|
pageSize?: number; |
||||
|
}, |
||||
|
options?: { [key: string]: any }, |
||||
|
) { |
||||
|
return request<API.Result_PageInfo_UserInfo__>('/api/v1/queryUserList', { |
||||
|
method: 'GET', |
||||
|
params: { |
||||
|
...params, |
||||
|
}, |
||||
|
...(options || {}), |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/** 此处后端没有提供注释 POST /api/v1/user */ |
||||
|
export async function addUser( |
||||
|
body?: API.UserInfoVO, |
||||
|
options?: { [key: string]: any }, |
||||
|
) { |
||||
|
return request<API.Result_UserInfo_>('/api/v1/user', { |
||||
|
method: 'POST', |
||||
|
headers: { |
||||
|
'Content-Type': 'application/json', |
||||
|
}, |
||||
|
data: body, |
||||
|
...(options || {}), |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/** 此处后端没有提供注释 GET /api/v1/user/${param0} */ |
||||
|
export async function getUserDetail( |
||||
|
params: { |
||||
|
// path
|
||||
|
/** userId */ |
||||
|
userId?: string; |
||||
|
}, |
||||
|
options?: { [key: string]: any }, |
||||
|
) { |
||||
|
const { userId: param0 } = params; |
||||
|
return request<API.Result_UserInfo_>(`/api/v1/user/${param0}`, { |
||||
|
method: 'GET', |
||||
|
params: { ...params }, |
||||
|
...(options || {}), |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/** 此处后端没有提供注释 PUT /api/v1/user/${param0} */ |
||||
|
export async function modifyUser( |
||||
|
params: { |
||||
|
// path
|
||||
|
/** userId */ |
||||
|
userId?: string; |
||||
|
}, |
||||
|
body?: API.UserInfoVO, |
||||
|
options?: { [key: string]: any }, |
||||
|
) { |
||||
|
const { userId: param0 } = params; |
||||
|
return request<API.Result_UserInfo_>(`/api/v1/user/${param0}`, { |
||||
|
method: 'PUT', |
||||
|
headers: { |
||||
|
'Content-Type': 'application/json', |
||||
|
}, |
||||
|
params: { ...params }, |
||||
|
data: body, |
||||
|
...(options || {}), |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/** 此处后端没有提供注释 DELETE /api/v1/user/${param0} */ |
||||
|
export async function deleteUser( |
||||
|
params: { |
||||
|
// path
|
||||
|
/** userId */ |
||||
|
userId?: string; |
||||
|
}, |
||||
|
options?: { [key: string]: any }, |
||||
|
) { |
||||
|
const { userId: param0 } = params; |
||||
|
return request<API.Result_string_>(`/api/v1/user/${param0}`, { |
||||
|
method: 'DELETE', |
||||
|
params: { ...params }, |
||||
|
...(options || {}), |
||||
|
}); |
||||
|
} |
@ -0,0 +1,7 @@ |
|||||
|
/* eslint-disable */ |
||||
|
// 该文件由 OneAPI 自动生成,请勿手动修改!
|
||||
|
|
||||
|
import * as UserController from './UserController'; |
||||
|
export default { |
||||
|
UserController, |
||||
|
}; |
@ -0,0 +1,68 @@ |
|||||
|
/* eslint-disable */ |
||||
|
// 该文件由 OneAPI 自动生成,请勿手动修改!
|
||||
|
|
||||
|
declare namespace API { |
||||
|
interface PageInfo { |
||||
|
/** |
||||
|
1 */ |
||||
|
current?: number; |
||||
|
pageSize?: number; |
||||
|
total?: number; |
||||
|
list?: Array<Record<string, any>>; |
||||
|
} |
||||
|
|
||||
|
interface PageInfo_UserInfo_ { |
||||
|
/** |
||||
|
1 */ |
||||
|
current?: number; |
||||
|
pageSize?: number; |
||||
|
total?: number; |
||||
|
list?: Array<UserInfo>; |
||||
|
} |
||||
|
|
||||
|
interface Result { |
||||
|
success?: boolean; |
||||
|
errorMessage?: string; |
||||
|
data?: Record<string, any>; |
||||
|
} |
||||
|
|
||||
|
interface Result_PageInfo_UserInfo__ { |
||||
|
success?: boolean; |
||||
|
errorMessage?: string; |
||||
|
data?: PageInfo_UserInfo_; |
||||
|
} |
||||
|
|
||||
|
interface Result_UserInfo_ { |
||||
|
success?: boolean; |
||||
|
errorMessage?: string; |
||||
|
data?: UserInfo; |
||||
|
} |
||||
|
|
||||
|
interface Result_string_ { |
||||
|
success?: boolean; |
||||
|
errorMessage?: string; |
||||
|
data?: string; |
||||
|
} |
||||
|
|
||||
|
type UserGenderEnum = 'MALE' | 'FEMALE'; |
||||
|
|
||||
|
interface UserInfo { |
||||
|
id?: string; |
||||
|
name?: string; |
||||
|
/** nick */ |
||||
|
nickName?: string; |
||||
|
/** email */ |
||||
|
email?: string; |
||||
|
gender?: UserGenderEnum; |
||||
|
} |
||||
|
|
||||
|
interface UserInfoVO { |
||||
|
name?: string; |
||||
|
/** nick */ |
||||
|
nickName?: string; |
||||
|
/** email */ |
||||
|
email?: string; |
||||
|
} |
||||
|
|
||||
|
type definitions_0 = null; |
||||
|
} |
@ -0,0 +1,4 @@ |
|||||
|
// 示例方法,没有实际意义
|
||||
|
export function trim(str: string) { |
||||
|
return str.trim(); |
||||
|
} |
@ -0,0 +1,3 @@ |
|||||
|
{ |
||||
|
"extends": "./src/.umi/tsconfig.json" |
||||
|
} |
@ -0,0 +1 @@ |
|||||
|
import '@umijs/max/typings'; |
Write
Preview
Loading…
Cancel
Save
Reference in new issue