基本完成消息通知

main
wuyize 2023-01-08 16:23:37 +08:00
parent e71ca15397
commit 06a283390e
6 changed files with 201 additions and 68 deletions

26
package-lock.json generated
View File

@ -21,6 +21,7 @@
"axios": "^1.2.1",
"echarts": "^5.4.1",
"echarts-for-react": "^3.0.2",
"event-source-polyfill": "^1.0.31",
"history": "^5.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@ -30,6 +31,9 @@
"redux-persist": "^6.0.0",
"typescript": "^4.9.4",
"web-vitals": "^2.1.4"
},
"devDependencies": {
"@types/event-source-polyfill": "^1.0.0"
}
},
"node_modules/@adobe/css-tools": {
@ -4033,6 +4037,12 @@
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.0.tgz",
"integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ=="
},
"node_modules/@types/event-source-polyfill": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/@types/event-source-polyfill/-/event-source-polyfill-1.0.0.tgz",
"integrity": "sha512-b8O8/rg7NIW0iJ8i9MNDBZqPljHA+b7AjC3QFqH3dSyW6vgrl3oBgyIv5dw2fibh5enHHDkkPZG5PHza7U4NRw==",
"dev": true
},
"node_modules/@types/express": {
"version": "4.17.15",
"resolved": "https://registry.npmmirror.com/@types/express/-/express-4.17.15.tgz",
@ -7679,6 +7689,11 @@
"node": ">= 0.6"
}
},
"node_modules/event-source-polyfill": {
"version": "1.0.31",
"resolved": "https://registry.npmmirror.com/event-source-polyfill/-/event-source-polyfill-1.0.31.tgz",
"integrity": "sha512-4IJSItgS/41IxN5UVAVuAyczwZF7ZIEsM1XAoUzIHA6A+xzusEZUutdXz2Nr+MQPLxfTiCvqE79/C8HT8fKFvA=="
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-4.0.7.tgz",
@ -20152,6 +20167,12 @@
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.0.tgz",
"integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ=="
},
"@types/event-source-polyfill": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/@types/event-source-polyfill/-/event-source-polyfill-1.0.0.tgz",
"integrity": "sha512-b8O8/rg7NIW0iJ8i9MNDBZqPljHA+b7AjC3QFqH3dSyW6vgrl3oBgyIv5dw2fibh5enHHDkkPZG5PHza7U4NRw==",
"dev": true
},
"@types/express": {
"version": "4.17.15",
"resolved": "https://registry.npmmirror.com/@types/express/-/express-4.17.15.tgz",
@ -23080,6 +23101,11 @@
"resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="
},
"event-source-polyfill": {
"version": "1.0.31",
"resolved": "https://registry.npmmirror.com/event-source-polyfill/-/event-source-polyfill-1.0.31.tgz",
"integrity": "sha512-4IJSItgS/41IxN5UVAVuAyczwZF7ZIEsM1XAoUzIHA6A+xzusEZUutdXz2Nr+MQPLxfTiCvqE79/C8HT8fKFvA=="
},
"eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-4.0.7.tgz",

View File

@ -16,6 +16,7 @@
"axios": "^1.2.1",
"echarts": "^5.4.1",
"echarts-for-react": "^3.0.2",
"event-source-polyfill": "^1.0.31",
"history": "^5.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@ -49,5 +50,8 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@types/event-source-polyfill": "^1.0.0"
}
}

View File

@ -17,6 +17,7 @@ export interface Notice {
noticeId: string;
noticeTargetId: string;
noticeTime: string;
alreadyRead: boolean;
}

View File

@ -1,20 +1,23 @@
import React, {useState, useEffect} from 'react';
import {UploadOutlined, UserOutlined, BellOutlined, CloseOutlined} from '@ant-design/icons';
import {Layout, Menu, theme, Typography, Button, Dropdown, MenuProps, Popover} from 'antd';
import {Layout, Menu, theme, Typography, Button, Dropdown, MenuProps, Popover, Badge} from 'antd';
import {useAppDispatch, useAppSelector} from "../models/hooks";
import {getStaff, getToken, setStaff, setToken, store} from "../models/store";
import {Link, Outlet, useLocation, useNavigate} from "react-router-dom";
import Icon from '@ant-design/icons';
import {Logo} from "../assets/icons";
import axiosInstance from "../utils/axiosInstance";
import axiosInstance, {baseUrl} from "../utils/axiosInstance";
import {Department, Staff, Token} from "../models/Staff";
import MessageList from "./MessageList";
import {NativeEventSource, EventSourcePolyfill} from 'event-source-polyfill';
import {NoticeResponse} from "../models/Notice";
import qs from "qs";
const {Title, Text, Paragraph} = Typography;
const {Header, Content, Footer, Sider} = Layout;
function HeaderBar(props: any) {
function HeaderBar(props: { messageCount: number, onMessageCountChange: (count: number) => void }) {
const {
token: {colorBgContainer, colorPrimary},
} = theme.useToken();
@ -90,7 +93,7 @@ function HeaderBar(props: any) {
boxShadow: '0px 6px 16px 0px rgba(0, 0, 0, 0.08)'
}}>
<div style={{marginLeft: 20}}>
<MessageList/>
<MessageList count={props.messageCount} onCountChange={props.onMessageCountChange}/>
</div>
<div style={{
height: '100%',
@ -121,17 +124,20 @@ function HeaderBar(props: any) {
}
function MainMenu(props: any) {
console.log(props)
if (props.defaultSelectedKeys[0] === '')
return null
else
const [key, setKey] = useState(1)
useEffect(() => {
setKey(key + 1)
}, [props]);
return (
<div key={key} style={{width: '100%'}}>
<Menu
style={{borderInlineEnd: 'unset'}}
style={{width: '100%', borderInlineEnd: 'unset'}}
mode="inline"
selectedKeys={props.defaultSelectedKeys}
items={props.items}
/>
</div>
)
}
@ -141,54 +147,54 @@ function HomeView() {
const location = useLocation()
const dispatch = useAppDispatch()
const [messageCount, setMessageCount] = useState([])
const [messageCount, setMessageCount] = useState(0)
const [reimbursementCount, setReimbursementCount] = useState(0)
const [badgeTop, setBadgeTop] = useState(66 + 3 * 44)
const [eventSource, setEventSource] = useState<EventSourcePolyfill | null>(null)
// 新建Sse连接
const createSseConnect = () => {
if (window.EventSource) {
if (eventSource === null) {
let source = new EventSourcePolyfill(baseUrl + 'common/notice/listen', {
headers: {
Authorization: "Bearer " + store.getState().token.accessToken
},
heartbeatTimeout: 21600000
})
let source = new EventSource('http://localhost:3000/api1/sse/createConnect?clientId=001')
// 监听打开事件
source.addEventListener('open', (e) => {
console.log("打开连接 onopen==>", e)
console.log("open==>", e)
})
// 监听消息事件
source.addEventListener("message", (e) => {
source.addEventListener("init", (e) => {
console.log("init==>", e)
})
source.addEventListener("reimbursement", (e) => {
console.log("reimbursement==>", e)
// @ts-ignore
setReimbursementCount(reimbursementCount + Number(e.data))
})
source.addEventListener("notice", (e) => {
console.log("notice==>", e)
// @ts-ignore
setMessageCount(messageCount + Number(e.data))
})
// 监听错误事件
source.addEventListener("error", (e) => {
console.log("error==>", e)
})
// 关闭连接
source.close = () => {
console.log("source.close")
}
} else {
alert("该浏览器不支持SSE")
}
setEventSource(source)
}
// 获取系统消息
const getSystemMessage = () => {
// 发送网络请求
axiosInstance.post(`http://localhost:3000/api1/sse/broadcast`).then(
response => {
},
error => {
}
)
}
let items = [{
const items = [{
key: "/invoice/mine",
//icon: React.createElement(UserOutlined),
label: <Link to="/invoice/mine"></Link>
@ -203,7 +209,7 @@ function HomeView() {
}, {
key: "/reimbursement/approval",
//icon: React.createElement(UserOutlined),
label: <Link to="/reimbursement/approval"></Link>,
label: <Link to="/reimbursement/approval"></Link>
}, {
key: "/stat",
//icon: React.createElement(UserOutlined),
@ -236,21 +242,59 @@ function HomeView() {
navigate('/invoice/mine')
}
let reimbursementStatus: number[] = []
if (token.staffId === 'manager') {
setBadgeTop(66 + 0 * 44)
setMenuItems(items.slice(3, 6))
reimbursementStatus.push(4)
} else if (staff.managingDepartment) {
setBadgeTop(66 + 3 * 44)
if (staff.managingDepartment.departmentId === 1) {
setMenuItems(items.slice(0, 5))
reimbursementStatus.push(3)
} else {
setMenuItems(items.slice(0, 4))
reimbursementStatus.push(1)
}
} else {
if (staff.staffDepartments && staff.staffDepartments.find((value, index, obj) => value.departmentId === 1))
setBadgeTop(66 + 3 * 44)
if (staff.staffDepartments && staff.staffDepartments.find((value, index, obj) => value.departmentId === 1)) {
setMenuItems(items.slice(0, 5))
else
reimbursementStatus.push(2)
} else
setMenuItems(items.slice(0, 2))
}
axiosInstance({
url: 'common/notice',
method: 'get',
params: {
pageNum: 0,
pageSize: 100
}
}).then(response => {
const data: NoticeResponse = response.data
let unreadCount = 0
for (const notice of data.records)
if (!notice.alreadyRead) unreadCount++
setMessageCount(unreadCount)
}).catch(function (error) {
console.log(error)
})
axiosInstance({
url: 'approval/reimbursement?'+ qs.stringify({
pageNum: 0,
pageSize: 1,
reimbursementStatus: reimbursementStatus
}, {skipNulls: true, arrayFormat: 'indices'}),
method: 'get'
}).then(response => {
setReimbursementCount(response.data.total)
}).catch(function (error) {
console.log(error)
})
}).catch(function (error) {
console.log(error)
})
@ -261,7 +305,7 @@ function HomeView() {
navigate("/login")
}
getStaffInfo()
createSseConnect()
}, []);
useEffect(() => {
setDefaultSelectedKeys([location.pathname])
@ -299,10 +343,26 @@ function HomeView() {
marginLeft: '4px'
}}></span>
</div>
<div style={{width: '100%', display: "flex"}}>
<MainMenu items={menuItems} defaultSelectedKeys={defaultSelectedKeys}/>
<div style={{
position: "absolute",
right: 14,
top: badgeTop,
height: 44,
display: "flex",
alignItems: "center"
}}>
<Badge count={reimbursementCount}/>
</div>
</div>
</Sider>
<Layout>
<HeaderBar/>
<HeaderBar messageCount={messageCount} onMessageCountChange={(count) => setMessageCount(count)}/>
<Content style={{margin: '0', overflowY: "auto"}}>
<Outlet/>
</Content>

View File

@ -7,14 +7,16 @@ import 'dayjs/locale/zh-cn'
import axiosInstance from "../utils/axiosInstance";
import {setStaff, setToken} from "../models/store";
import {Staff} from "../models/Staff";
import {invoiceTypeNameMap} from "../models/Invoice";
const {Text, Title, Paragraph} = Typography
const relativeTime = require('dayjs/plugin/relativeTime')
dayjs.locale('zh-cn')
dayjs.extend(relativeTime)
function MessageList() {
function MessageList(props: { count: number, onCountChange: (count: number) => void}) {
const [list, setList] = useState<Notice[]>([])
const [loading, setLoading] = useState(false)
const getTitle = (notice: Notice) => {
switch (notice.data.noticeType) {
@ -27,7 +29,7 @@ function MessageList() {
return '报销单状态更新'
break
case 1:
return '发票被回'
return '发票被回'
break
}
}
@ -54,22 +56,29 @@ function MessageList() {
switch (notice.data.noticeType) {
case 0:
if (notice.data.approvalResult === 5)
return '报销单审批未通过'
return <Paragraph>{
notice.data.reimbursement?.reimbursementId + '号报销单(' + notice.data.reimbursement?.reimbursementDepartureName
+ (notice.data.reimbursement?.roundTrip ? " ⇌ " : " → ") + notice.data.reimbursement?.reimbursementDestinationName + ''
+ '『' + notice.data.approvalStaff?.staffName + '』审批不通过'
}<br/>{'审批意见:' + notice.data.approvalOpinion}</Paragraph>
else
return <Paragraph>{
notice.data.reimbursement?.reimbursementId + '号报销单(' + notice.data.reimbursement?.reimbursementDepartureName
+ (notice.data.reimbursement?.roundTrip ? " ⇌ " : " → ") + notice.data.reimbursement?.reimbursementDestinationName+''
+ getApprovalStep(notice.data.approvalResult)+'『'+notice.data.approvalStaff?.staffName+'』审批通过'
}<br/>{'审批意见:'+notice.data.approvalOpinion}</Paragraph>
+ (notice.data.reimbursement?.roundTrip ? " ⇌ " : " → ") + notice.data.reimbursement?.reimbursementDestinationName + ''
+ getApprovalStep(notice.data.approvalResult) + '『' + notice.data.approvalStaff?.staffName + '』审批通过'
}<br/>{'审批意见:' + notice.data.approvalOpinion}</Paragraph>
break
case 1:
return '发票被打回'
if (notice.data.invoice)
return <Paragraph>{
'一张' + invoiceTypeNameMap.get(notice.data.invoice?.invoiceKind) + '(¥' + notice.data.invoice?.invoiceAmount / 100. + ')被『' + notice.data.rejectStaff?.staffName + '』撤回'
}<br/>{'撤回原因:' + notice.data.rejectOpinion}</Paragraph>
break
}
}
const onMessageListOpen = () => {
setLoading(true)
axiosInstance({
url: 'common/notice',
method: 'get',
@ -81,6 +90,7 @@ function MessageList() {
console.log(response.data)
const data: NoticeResponse = response.data
setList(data.records)
setLoading(false)
}).catch(function (error) {
console.log(error)
})
@ -163,6 +173,7 @@ function MessageList() {
<List
style={{width: 400}}
itemLayout="horizontal"
loading={loading}
dataSource={list}
renderItem={(item) => (
<div style={{
@ -180,22 +191,36 @@ function MessageList() {
whiteSpace: "unset",
textAlign: "unset"
}}>
<Dropdown menu={{
<Dropdown disabled={item.alreadyRead} menu={{
items: [
{
key: '1',
label: (<span></span>),
}],
onClick: ({key}) => {
axiosInstance({
url: 'common/notice/' + item.noticeId + '/read',
method: 'put',
}).then(response => {
props.onCountChange(props.count-1)
setList(list.map((value, index, array) => {
if (value === item)
value.alreadyRead = true
return value
}))
}).catch(function (error) {
console.log(error)
})
}
}} placement="top" arrow>
<div>
<Badge status="processing"/>
<Badge status="processing" style={{
display: item.alreadyRead ? 'none' : 'unset',
marginRight: 10
}}/>
<span style={{
fontSize: 16,
fontWeight: "bold",
marginLeft: 10
}}>{getTitle(item)} </span>
<Divider type="vertical"/>
<Text>{
@ -210,15 +235,30 @@ function MessageList() {
<Button style={{
marginLeft: -30
}} shape='circle'
type='text'><CloseOutlined/></Button>
type='text' onClick={() => {
axiosInstance({
url: 'common/notice/' + item.noticeId,
method: 'delete',
}).then(response => {
setList(list.filter(value => value !== item))
if(!item.alreadyRead) props.onCountChange(props.count-1)
}).catch(function (error) {
console.log(error)
})
}}>
<CloseOutlined/>
</Button>
</div>
)}
/>
} title="我的消息" arrowPointAtCenter onOpenChange={onMessageListOpen}>
<Button type="text" shape="circle">
<Badge count={props.count} size="small" offset={[-10, 10]}>
<Button type="text" shape="circle" size="large">
<BellOutlined/>
</Button>
</Badge>
</Popover>
)
}

View File

@ -27,8 +27,10 @@ function LoginView() {
refreshToken: response.data.refreshToken,
clientSecret: response.data.clientSecret
}))
//models.commit('setStaff', response.data.data)
navigate('/')
if (values.staffId === 'manager')
navigate('/reimbursement/approval')
else
navigate('/invoice/mine')
}).catch(function (error) {
console.log(error);
setAlertMessage(error.response.data.msg)
@ -40,7 +42,7 @@ function LoginView() {
console.log('Failed:', errorInfo);
};
const afterClose = () =>{
const afterClose = () => {
setShowAlert(false)
}
@ -98,7 +100,7 @@ function LoginView() {
<Input.Password prefix={<LockOutlined/>}
placeholder="密码"/>
</Form.Item>
{showAlert&&
{showAlert &&
<Alert
style={{marginBottom: 20, height: 32}}
message={alertMessage}