基本完成消息通知

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", "axios": "^1.2.1",
"echarts": "^5.4.1", "echarts": "^5.4.1",
"echarts-for-react": "^3.0.2", "echarts-for-react": "^3.0.2",
"event-source-polyfill": "^1.0.31",
"history": "^5.3.0", "history": "^5.3.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
@ -30,6 +31,9 @@
"redux-persist": "^6.0.0", "redux-persist": "^6.0.0",
"typescript": "^4.9.4", "typescript": "^4.9.4",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"
},
"devDependencies": {
"@types/event-source-polyfill": "^1.0.0"
} }
}, },
"node_modules/@adobe/css-tools": { "node_modules/@adobe/css-tools": {
@ -4033,6 +4037,12 @@
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.0.tgz", "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.0.tgz",
"integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==" "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": { "node_modules/@types/express": {
"version": "4.17.15", "version": "4.17.15",
"resolved": "https://registry.npmmirror.com/@types/express/-/express-4.17.15.tgz", "resolved": "https://registry.npmmirror.com/@types/express/-/express-4.17.15.tgz",
@ -7679,6 +7689,11 @@
"node": ">= 0.6" "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": { "node_modules/eventemitter3": {
"version": "4.0.7", "version": "4.0.7",
"resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-4.0.7.tgz", "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", "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.0.tgz",
"integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==" "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": { "@types/express": {
"version": "4.17.15", "version": "4.17.15",
"resolved": "https://registry.npmmirror.com/@types/express/-/express-4.17.15.tgz", "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", "resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" "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": { "eventemitter3": {
"version": "4.0.7", "version": "4.0.7",
"resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-4.0.7.tgz", "resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-4.0.7.tgz",

View File

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

View File

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

View File

@ -1,20 +1,23 @@
import React, {useState, useEffect} from 'react'; import React, {useState, useEffect} from 'react';
import {UploadOutlined, UserOutlined, BellOutlined, CloseOutlined} from '@ant-design/icons'; 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 {useAppDispatch, useAppSelector} from "../models/hooks";
import {getStaff, getToken, setStaff, setToken, store} from "../models/store"; import {getStaff, getToken, setStaff, setToken, store} from "../models/store";
import {Link, Outlet, useLocation, useNavigate} from "react-router-dom"; import {Link, Outlet, useLocation, useNavigate} from "react-router-dom";
import Icon from '@ant-design/icons'; import Icon from '@ant-design/icons';
import {Logo} from "../assets/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 {Department, Staff, Token} from "../models/Staff";
import MessageList from "./MessageList"; 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 {Title, Text, Paragraph} = Typography;
const {Header, Content, Footer, Sider} = Layout; const {Header, Content, Footer, Sider} = Layout;
function HeaderBar(props: any) { function HeaderBar(props: { messageCount: number, onMessageCountChange: (count: number) => void }) {
const { const {
token: {colorBgContainer, colorPrimary}, token: {colorBgContainer, colorPrimary},
} = theme.useToken(); } = theme.useToken();
@ -90,7 +93,7 @@ function HeaderBar(props: any) {
boxShadow: '0px 6px 16px 0px rgba(0, 0, 0, 0.08)' boxShadow: '0px 6px 16px 0px rgba(0, 0, 0, 0.08)'
}}> }}>
<div style={{marginLeft: 20}}> <div style={{marginLeft: 20}}>
<MessageList/> <MessageList count={props.messageCount} onCountChange={props.onMessageCountChange}/>
</div> </div>
<div style={{ <div style={{
height: '100%', height: '100%',
@ -121,17 +124,20 @@ function HeaderBar(props: any) {
} }
function MainMenu(props: any) { function MainMenu(props: any) {
console.log(props) const [key, setKey] = useState(1)
if (props.defaultSelectedKeys[0] === '') useEffect(() => {
return null setKey(key + 1)
else }, [props]);
return ( return (
<div key={key} style={{width: '100%'}}>
<Menu <Menu
style={{borderInlineEnd: 'unset'}} style={{width: '100%', borderInlineEnd: 'unset'}}
mode="inline" mode="inline"
selectedKeys={props.defaultSelectedKeys} selectedKeys={props.defaultSelectedKeys}
items={props.items} items={props.items}
/> />
</div>
) )
} }
@ -141,54 +147,54 @@ function HomeView() {
const location = useLocation() const location = useLocation()
const dispatch = useAppDispatch() 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 = () => { 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) => { source.addEventListener('open', (e) => {
console.log("打开连接 onopen==>", e) console.log("open==>", e)
}) })
// 监听消息事件 source.addEventListener("init", (e) => {
source.addEventListener("message", (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) => { source.addEventListener("error", (e) => {
console.log("error==>", e)
}) })
// 关闭连接 // 关闭连接
source.close = () => { source.close = () => {
console.log("source.close") console.log("source.close")
} }
setEventSource(source)
} else {
alert("该浏览器不支持SSE")
}
} }
// 获取系统消息
const getSystemMessage = () => {
// 发送网络请求
axiosInstance.post(`http://localhost:3000/api1/sse/broadcast`).then(
response => {
},
error => {
}
)
} }
const items = [{
let items = [{
key: "/invoice/mine", key: "/invoice/mine",
//icon: React.createElement(UserOutlined), //icon: React.createElement(UserOutlined),
label: <Link to="/invoice/mine"></Link> label: <Link to="/invoice/mine"></Link>
@ -203,7 +209,7 @@ function HomeView() {
}, { }, {
key: "/reimbursement/approval", key: "/reimbursement/approval",
//icon: React.createElement(UserOutlined), //icon: React.createElement(UserOutlined),
label: <Link to="/reimbursement/approval"></Link>, label: <Link to="/reimbursement/approval"></Link>
}, { }, {
key: "/stat", key: "/stat",
//icon: React.createElement(UserOutlined), //icon: React.createElement(UserOutlined),
@ -236,21 +242,59 @@ function HomeView() {
navigate('/invoice/mine') navigate('/invoice/mine')
} }
let reimbursementStatus: number[] = []
if (token.staffId === 'manager') { if (token.staffId === 'manager') {
setBadgeTop(66 + 0 * 44)
setMenuItems(items.slice(3, 6)) setMenuItems(items.slice(3, 6))
reimbursementStatus.push(4)
} else if (staff.managingDepartment) { } else if (staff.managingDepartment) {
setBadgeTop(66 + 3 * 44)
if (staff.managingDepartment.departmentId === 1) { if (staff.managingDepartment.departmentId === 1) {
setMenuItems(items.slice(0, 5)) setMenuItems(items.slice(0, 5))
reimbursementStatus.push(3)
} else { } else {
setMenuItems(items.slice(0, 4)) setMenuItems(items.slice(0, 4))
reimbursementStatus.push(1)
} }
} else { } 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)) setMenuItems(items.slice(0, 5))
else reimbursementStatus.push(2)
} else
setMenuItems(items.slice(0, 2)) 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) { }).catch(function (error) {
console.log(error) console.log(error)
}) })
@ -261,7 +305,7 @@ function HomeView() {
navigate("/login") navigate("/login")
} }
getStaffInfo() getStaffInfo()
createSseConnect()
}, []); }, []);
useEffect(() => { useEffect(() => {
setDefaultSelectedKeys([location.pathname]) setDefaultSelectedKeys([location.pathname])
@ -299,10 +343,26 @@ function HomeView() {
marginLeft: '4px' marginLeft: '4px'
}}></span> }}></span>
</div> </div>
<div style={{width: '100%', display: "flex"}}>
<MainMenu items={menuItems} defaultSelectedKeys={defaultSelectedKeys}/> <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> </Sider>
<Layout> <Layout>
<HeaderBar/> <HeaderBar messageCount={messageCount} onMessageCountChange={(count) => setMessageCount(count)}/>
<Content style={{margin: '0', overflowY: "auto"}}> <Content style={{margin: '0', overflowY: "auto"}}>
<Outlet/> <Outlet/>
</Content> </Content>

View File

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

View File

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