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