Browse Source

feat: 完成消息中心页面

dev-zw-notification
zhaowei 6 months ago
parent
commit
f48a285280
30 changed files with 1253 additions and 10 deletions
  1. +11
    -0
      react-ui/config/routes.ts
  2. BIN
      react-ui/src/assets/img/message/at-hover.png
  3. BIN
      react-ui/src/assets/img/message/at.png
  4. BIN
      react-ui/src/assets/img/message/content-bg.png
  5. BIN
      react-ui/src/assets/img/message/menu-bg.png
  6. BIN
      react-ui/src/assets/img/message/message-bg.png
  7. BIN
      react-ui/src/assets/img/message/red-point.png
  8. BIN
      react-ui/src/assets/img/message/system-hover.png
  9. BIN
      react-ui/src/assets/img/message/system.png
  10. BIN
      react-ui/src/assets/img/message/trumpet-hover.png
  11. BIN
      react-ui/src/assets/img/message/trumpet.png
  12. +0
    -0
      react-ui/src/components/KFButton/index.less
  13. +0
    -0
      react-ui/src/components/KFButton/index.tsx
  14. +19
    -0
      react-ui/src/components/MessageBroadcast/index.less
  15. +46
    -0
      react-ui/src/components/MessageBroadcast/index.tsx
  16. +11
    -2
      react-ui/src/components/RightContent/index.tsx
  17. +13
    -0
      react-ui/src/enums/index.ts
  18. +3
    -1
      react-ui/src/hooks/useServerTime.ts
  19. +2
    -3
      react-ui/src/pages/Home/components/Intro/index.tsx
  20. +4
    -4
      react-ui/src/pages/Home/components/Model/index.tsx
  21. +125
    -0
      react-ui/src/pages/Message/components/Content/index.less
  22. +348
    -0
      react-ui/src/pages/Message/components/Content/index.tsx
  23. +69
    -0
      react-ui/src/pages/Message/components/Menu/index.less
  24. +50
    -0
      react-ui/src/pages/Message/components/Menu/index.tsx
  25. +8
    -0
      react-ui/src/pages/Message/index.less
  26. +20
    -0
      react-ui/src/pages/Message/index.tsx
  27. +22
    -0
      react-ui/src/pages/System/Approval/index.less
  28. +407
    -0
      react-ui/src/pages/System/Approval/index.tsx
  29. +40
    -0
      react-ui/src/services/message/index.ts
  30. +55
    -0
      react-ui/src/utils/date.ts

+ 11
- 0
react-ui/config/routes.ts View File

@@ -38,6 +38,12 @@ export default [
key: 'workspace',
component: './Workspace/index',
},
{
name: '消息中心',
path: 'message',
key: 'message',
component: './Message/index',
},
],
},
{
@@ -582,6 +588,11 @@ export default [
},
],
},
{
name: '审核',
path: 'approval',
component: './System/Approval',
},
],
},
{


BIN
react-ui/src/assets/img/message/at-hover.png View File

Before After
Width: 36  |  Height: 36  |  Size: 2.0 kB

BIN
react-ui/src/assets/img/message/at.png View File

Before After
Width: 36  |  Height: 36  |  Size: 1.9 kB

BIN
react-ui/src/assets/img/message/content-bg.png View File

Before After
Width: 2368  |  Height: 1830  |  Size: 514 kB

BIN
react-ui/src/assets/img/message/menu-bg.png View File

Before After
Width: 392  |  Height: 1830  |  Size: 107 kB

BIN
react-ui/src/assets/img/message/message-bg.png View File

Before After
Width: 3840  |  Height: 2040  |  Size: 1.6 MB

BIN
react-ui/src/assets/img/message/red-point.png View File

Before After
Width: 11  |  Height: 11  |  Size: 343 B

BIN
react-ui/src/assets/img/message/system-hover.png View File

Before After
Width: 36  |  Height: 36  |  Size: 1.2 kB

BIN
react-ui/src/assets/img/message/system.png View File

Before After
Width: 36  |  Height: 36  |  Size: 1.3 kB

BIN
react-ui/src/assets/img/message/trumpet-hover.png View File

Before After
Width: 66  |  Height: 66  |  Size: 1.8 kB

BIN
react-ui/src/assets/img/message/trumpet.png View File

Before After
Width: 66  |  Height: 66  |  Size: 1.7 kB

+ 0
- 0
react-ui/src/components/KFButton/index.less View File


+ 0
- 0
react-ui/src/components/KFButton/index.tsx View File


+ 19
- 0
react-ui/src/components/MessageBroadcast/index.less View File

@@ -0,0 +1,19 @@
.message-broadcast {
position: relative;
width: 32px;
height: 32px;
margin-right: 8px;
cursor: pointer;
.backgroundFullImage(url(@/assets/img/message/trumpet.png));

&:hover {
background-image: url(@/assets/img/message/trumpet-hover.png);
}

&__red-point {
position: absolute;
top: 8px;
left: 18px;
width: 6px;
}
}

+ 46
- 0
react-ui/src/components/MessageBroadcast/index.tsx View File

@@ -0,0 +1,46 @@
import RedPointImg from '@/assets/img/message/red-point.png';
import { getMessageCountReq } from '@/services/message';
import { to } from '@/utils/promise';
import { useModel, useNavigate } from '@umijs/max';
import { useCallback, useEffect, useState } from 'react';
import styles from './index.less';

function MessageBroadcast() {
const { initialState } = useModel('@@initialState');
const { currentUser } = initialState || {};
const { userId } = currentUser || {};
const [total, setTotal] = useState<number>(0);
const navigate = useNavigate();

const getMessageCount = useCallback(async () => {
const params: Record<string, any> = {
receiver: userId,
type: -1,
};
const [res] = await to(getMessageCountReq(params));
if (res && res.data) {
const { unread_total } = res.data;
setTotal(unread_total);
}
}, [userId]);

useEffect(() => {
const interval = setInterval(() => {
getMessageCount();
}, 60 * 1000);
getMessageCount();
return () => {
clearInterval(interval);
};
}, [getMessageCount]);

return (
<div className={styles['message-broadcast']} onClick={() => navigate('/workspace/message')}>
{total > 0 && (
<img className={styles['message-broadcast__red-point']} src={RedPointImg}></img>
)}
</div>
);
}

export default MessageBroadcast;

+ 11
- 2
react-ui/src/components/RightContent/index.tsx View File

@@ -1,8 +1,9 @@
import KFIcon from '@/components/KFIcon';
import { ProBreadcrumb } from '@ant-design/pro-components';
import { useModel } from '@umijs/max';
import { Button } from 'antd';
import { Button, Flex } from 'antd';
import React from 'react';
import MessageBroadcast from '../MessageBroadcast';
import Avatar from './AvatarDropdown';
import styles from './index.less';
// import { SelectLang } from '@umijs/max';
@@ -43,7 +44,15 @@ const GlobalHeaderRight: React.FC = () => {

<ProBreadcrumb></ProBreadcrumb>

<Avatar menu={true} />
<Flex
align="center"
style={{ marginLeft: 'auto', marginRight: 0, background: '#3a3da5', padding: '0 30px' }}
>
<MessageBroadcast />

<Avatar menu={true} />
</Flex>

{/* <SelectLang className={actionClassName} /> */}
</div>
);


+ 13
- 0
react-ui/src/enums/index.ts View File

@@ -171,3 +171,16 @@ export enum ComponentType {
Map = 'map',
Str = 'str',
}

// 消息类型
export enum MessageType {
System = 1,
Mine = 2,
}

// 消息状态
export enum MessageStatus {
All = -1,
UnRead = 1,
Readed = 2,
}

+ 3
- 1
react-ui/src/hooks/useServerTime.ts View File

@@ -10,6 +10,7 @@ import { useCallback, useEffect, useState } from 'react';

let globalTimeOffset: number | undefined = undefined;

// 获取服务器时间偏移
export const globalGetSeverTime = async () => {
const requestStartTime = Date.now();
const [res] = await to(getSeverTimeReq());
@@ -23,7 +24,8 @@ export const globalGetSeverTime = async () => {
}
};

export const now = () => {
// 服务器的当前时间
export const serverNow = () => {
return new Date(Date.now() + (globalTimeOffset ?? 0));
};



+ 2
- 3
react-ui/src/pages/Home/components/Intro/index.tsx View File

@@ -1,7 +1,7 @@
import miniHeaderImage from '@/assets/img/home/header-bg-mini.png';
import headerImage from '@/assets/img/home/header-bg.png';
import { convertRemToPx } from '@/utils';
import { useNavigate } from '@umijs/max';
import { gotoPageIfLogin } from '@/utils/ui';
import {
motion,
useMotionTemplate,
@@ -18,7 +18,6 @@ import styles from './index.less';
function IntroBlock() {
const [backgroundImage1, setBackgroundImage1] = useState(undefined);
const [backgroundImage2, setBackgroundImage2] = useState(headerImage);
const navigate = useNavigate();
const { scrollY } = useScroll();
const springValue = useSpring(scrollY, {
stiffness: 100,
@@ -70,7 +69,7 @@ function IntroBlock() {
智能材料科研平台是用于材料研究和开发的技术平台,它旨在提供实验数据收集、分析和可视化等功能,
以支持材料工程师、科学家和研究人员在材料设计、性能评估和工艺优化方面的工作。
</div>
<div className={styles['intro__button']} onClick={() => navigate('/workspace')}>
<div className={styles['intro__button']} onClick={() => gotoPageIfLogin('/workspace')}>
开始使用
</div>
<StatisticsBlock></StatisticsBlock>


+ 4
- 4
react-ui/src/pages/Home/components/Model/index.tsx View File

@@ -9,14 +9,14 @@ import BlockTitle from '../BlockTitle';
import styles from './index.less';

const modelVariants: Variants = {
offscreen: (index: number) => ({
offscreen: {
y: 0,
opacity: 1,
transition: {
ease: 'linear',
duration: 0.1,
duration: 0.3,
},
}),
},
onscreen: {
y: [0, 200, 0],
opacity: [0, 0, 1],
@@ -56,7 +56,7 @@ function ModelBlock() {
return (
<motion.div
variants={modelVariants}
initial={'offscreen'}
initial={false}
whileInView={'onscreen'}
custom={index}
className={styles['model__item']}


+ 125
- 0
react-ui/src/pages/Message/components/Content/index.less View File

@@ -0,0 +1,125 @@
.message-content {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
height: 100%;
.backgroundFullImage(url(@/assets/img/message/content-bg.png));

&__tabs {
display: flex;
align-items: center;
height: 76px;
padding: 0 30px;
border-bottom: 1px dashed rgba(130, 132, 164, 0.18);

&__item {
margin-right: 20px;
color: @text-color-secondary;
font-size: @font-size;

&--selected,
&:hover {
color: @text-color;
}
}
}

&__check-container {
display: flex;
align-items: center;
margin: 16px 0 10px;
padding-left: 30px;
color: @text-color-secondary;
font-size: @font-size;

&__count {
margin: 0 2px;
color: @primary-color;
}
}

&__list {
display: flex;
flex: 1;
flex-direction: column;
width: 100%;
overflow-y: auto;

&__item {
display: flex;
align-items: center;
width: 100%;
height: 56px;
padding: 0 30px;
color: @text-color;
font-size: @font-size;

&__status {
flex: none;
margin-right: 10px;
padding: 2px 4px;
font-size: 12px;
border-radius: 4px;

&--unread {
color: #d7312a;
background-color: rgba(215, 49, 42, 0.07);
}

&--readed {
color: #2a814b;
background-color: rgba(42, 129, 75, 0.07);
}
}

&__time {
display: block;
margin-left: auto;
color: @text-color-secondary;
}

&__button {
display: none;
flex: none;
padding-right: 0;
padding-left: 0;
color: @primary-color-hover;
font-size: @font-size;

&:hover {
color: @primary-color !important;
}

&:first-of-type {
margin-right: 10px;
margin-left: auto;
}
}

&:hover {
color: @primary-color;
background-color: .addAlpha(@primary-color, 0.05) [];
}

&:hover &__button {
display: block;
}

&:hover &__time {
display: none;
}
}
}

:global {
.ant-pagination {
margin-right: 30px;
margin-bottom: 40px;
}
}

&__empty {
flex: 1;
}
}

+ 348
- 0
react-ui/src/pages/Message/components/Content/index.tsx View File

@@ -0,0 +1,348 @@
import KFEmpty, { EmptyType } from '@/components/KFEmpty';
import { MessageStatus, MessageType } from '@/enums';
import { useCheck } from '@/hooks/useCheck';
import { deleteMessagesReq, getMessageListReq, readMessagesReq } from '@/services/message';
import { ago } from '@/utils/date';
import { to } from '@/utils/promise';
import { modalConfirm } from '@/utils/ui';
import { useModel } from '@umijs/max';
import {
Button,
Checkbox,
Pagination,
PaginationProps,
type TablePaginationConfig,
Typography,
} from 'antd';
import classNames from 'classnames';
import { useCallback, useEffect, useMemo, useState } from 'react';
import styles from './index.less';

// {
// "id": 673396,
// "status": 1,
// "content": "<b>陈志航</b> 已加入项目 <b>复杂智能软件系统研究/模型训练项目UI设计</b>",
// "notification_url": "https://www.gitlink.org.cn/ci4s/UIdesign",
// "source": "ProjectMemberJoined",
// "created_at": "2025-08-06 08:54:54",
// "time_ago": "23天前",
// "type": "notification"
// }

// receiver:消息接收者ID (必传)
// status:值未-1时,获取全部信息;值为1时,只获取未读消息;值为2时,获取已读消息
// type:值为-1时,获取全部信息;值为1时,获取系统消息;值为2时,获取@我消息
// sources:消息来源
// page:页码:值为-1时(默认值),不开启分页;
// size:页大小页码,默认20

// 统计数量的接口
// receiver:消息接收者ID (必传)
// type:值为-1时,获取全部信息;值为1时,获取系统消息;值为2时,获取@我消息

// 消息列表接口返回类型
export interface MessageResponse {
receiver: number;
type: MessageType;
unread_total: number;
unread_notification: number;
unread_atme: number;
records: Message[];
records_count: number;
page_num: number;
total_page_count: number;
page_size: number;
}

// 消息数据
export interface Message {
id: number;
sender: number;
receiver: number;
content: string;
status: MessageStatus;
type: MessageType;
source: string;
extra: string;
notification_url: string;
created_at: Date;
}

export type MessageContentProps = {
messageType: MessageType;
};

function MessageContent({ messageType }: MessageContentProps) {
const { initialState } = useModel('@@initialState');
const { currentUser } = initialState || {};
const { userId } = currentUser || {};
const [pagination, setPagination] = useState<TablePaginationConfig>({
current: 1,
pageSize: 20,
});
const [messages, setMessages] = useState<Message[] | undefined>(undefined);
const [allTotal, setAllTotal] = useState<number>(0);
const [unreadTotal, setUnreadTotal] = useState<number | undefined>(undefined);
const [messageStatus, setMessageStatus] = useState(MessageStatus.UnRead);
const [isDelete, setIsDelete] = useState(false);
const messageIds = useMemo(() => messages?.map((v) => v.id), [messages]);
const [
selectedMessages,
setSelectedMessages,
messagesAllChecked,
messagesIndeterminate,
checkAllMessages,
isSingleMessagesChecked,
checkSingleMessages,
] = useCheck(messageIds ?? []);

const tabs = useMemo(
() => [
{
title: '未读',
status: MessageStatus.UnRead,
total: unreadTotal,
},
{
title: '全部',
status: MessageStatus.All,
},
],
[unreadTotal],
);

const getMessages = useCallback(async () => {
const params: Record<string, any> = {
receiver: userId,
status: messageStatus,
type: messageType,
page: pagination.current! - 1,
size: pagination.pageSize,
};
const [res] = await to(getMessageListReq(params));
if (res && res.data) {
const { records, records_count, unread_notification, unread_atme } =
res.data as MessageResponse;
setMessages(records);
setAllTotal(records_count);
setUnreadTotal(messageType === MessageType.System ? unread_notification : unread_atme);
}
}, [pagination, userId, messageStatus, messageType]);

// 标记已读
const readMessages = async (ids?: number[]) => {
const params: Record<string, any> = {
ids: ids,
};
const [res] = await to(readMessagesReq(params));
if (res && res.data) {
getMessages();
}
};

// 删除
const deleteMessages = async (ids?: number[]) => {
const params: Record<string, any> = {
ids: ids,
};
const [res] = await to(deleteMessagesReq(params));
if (res && res.data) {
getMessages();
}
};

// 取消批量删除
const cancelBatchDelete = useCallback(() => {
setIsDelete(false);
setSelectedMessages([]);
}, [setSelectedMessages]);

useEffect(() => {
getMessages();
}, [getMessages]);

useEffect(() => {
cancelBatchDelete();
}, [messageType, messageStatus, cancelBatchDelete]);

// 批量删除
const handleBatchDelete = () => {
modalConfirm({
title: '删除后,消息不可恢复',
content: '是否确认删除?',
onOk: () => {
setIsDelete(false);
setSelectedMessages([]);
},
});
};

// 分页切换
const handlePageChange: PaginationProps['onChange'] = (page, pageSize) => {
setPagination({
current: page,
pageSize: pageSize,
});
};

return (
<div className={styles['message-content']}>
<div className={styles['message-content__tabs']}>
{tabs.map((item) => (
<div
key={item.status}
className={classNames(styles['message-content__tabs__item'], {
[styles['message-content__tabs__item--selected']]: item.status === messageStatus,
})}
onClick={() => {
setMessageStatus(item.status);
}}
>
<span>{item.title + (item.total ? `(${item.total})` : '')}</span>
</div>
))}

{isDelete ? (
<>
<Button
color="default"
variant="link"
style={{ marginLeft: 'auto', marginRight: 10 }}
onClick={cancelBatchDelete}
>
取消
</Button>
<Button
color="danger"
variant="link"
style={{ marginLeft: messageType === MessageType.Mine ? 0 : 'auto', marginRight: 0 }}
onClick={handleBatchDelete}
>
删除
</Button>
</>
) : (
<>
{messageType === MessageType.Mine && allTotal > 0 && (
<Button
color="primary"
variant="link"
style={{ marginLeft: 'auto', marginRight: 10 }}
onClick={() => setIsDelete(true)}
>
批量删除
</Button>
)}
<Button
color="primary"
variant="link"
style={{ marginLeft: messageType === MessageType.Mine ? 0 : 'auto', marginRight: 0 }}
onClick={() => readMessages()}
>
一键已读
</Button>
</>
)}
</div>

{isDelete && (
<div className={styles['message-content__check-container']}>
<Checkbox
style={{ marginRight: 10 }}
indeterminate={messagesIndeterminate}
checked={messagesAllChecked}
onChange={checkAllMessages}
>
全选
</Checkbox>
<span>
已选
<span className={styles['message-content__check-container__count']}>
{selectedMessages.length}
</span>
</span>
</div>
)}

{messages && messages.length > 0 && (
<>
<div className={styles['message-content__list']}>
{messages.map((message) => (
<div className={styles['message-content__list__item']} key={message.id}>
{messageType === MessageType.Mine && isDelete && (
<Checkbox
style={{ marginRight: 10 }}
checked={isSingleMessagesChecked(message.id)}
onChange={(e) => {
e.stopPropagation();
checkSingleMessages(message.id);
}}
></Checkbox>
)}
{messageStatus === MessageStatus.All && (
<div
className={classNames(
styles['message-content__list__item__status'],
message.status === MessageStatus.UnRead
? styles['message-content__list__item__status--unread']
: styles['message-content__list__item__status--readed'],
)}
>
{message.status === MessageStatus.UnRead ? '未读' : '已读'}
</div>
)}
<Typography.Text ellipsis={{ tooltip: message.content }}>
{message.content}
</Typography.Text>
<div className={styles['message-content__list__item__time']}>
{ago(message.created_at)}
</div>
{message.status === MessageStatus.UnRead && (
<Button
className={styles['message-content__list__item__button']}
type="link"
onClick={() => readMessages([message.id])}
>
标记已读
</Button>
)}
{messageType === MessageType.Mine && (
<Button
type="link"
className={styles['message-content__list__item__button']}
onClick={() => deleteMessages([message.id])}
>
删除
</Button>
)}
</div>
))}
</div>
<Pagination
align="end"
total={allTotal}
showSizeChanger
defaultPageSize={20}
pageSizeOptions={[20, 40, 60, 80, 100]}
showQuickJumper
onChange={handlePageChange}
{...pagination}
/>
</>
)}
{messages && messages.length === 0 && (
<KFEmpty
className={styles['message-content__empty']}
type={EmptyType.NoData}
title="暂无数据"
content={'很抱歉,没有搜索到您想要的内容\n建议刷新试试'}
hasFooter={true}
onButtonClick={getMessages}
/>
)}
</div>
);
}

export default MessageContent;

+ 69
- 0
react-ui/src/pages/Message/components/Menu/index.less View File

@@ -0,0 +1,69 @@
.message-menu {
flex: none;
width: 196px;
height: 100%;
.backgroundFullImage(url(@/assets/img/message/menu-bg.png));

&__title {
position: relative;
margin-bottom: 25px;
padding: 20px 20px 10px;
color: @text-color;
font-size: @font-size;

&::after {
position: absolute;
right: 20px;
bottom: 0;
left: 20px;
border-bottom: 1px dashed rgba(130, 132, 164, 0.18);
content: '';
}
}

&__item {
display: flex;
align-items: center;
margin-bottom: 4px;
padding: 10px 0 10px 18px;
color: @text-color-secondary;
font-size: @font-size;
border-left: 2px solid transparent;

&--selected,
&:hover {
color: @primary-color;
background-image: linear-gradient(
101.08deg,
rgba(81, 76, 249, 0.09) 0%,
rgba(255, 255, 255, 0) 100%
);
border-left: 2px solid @primary-color;
}

&__icon,
&__icon--hover {
width: 18px;
height: 18px;
margin-right: 10px;
}

&__icon {
display: block;
}

&__icon--hover {
display: none;
}

&--selected &__icon,
&__item:hover &__icon {
display: none;
}

&--selected &__icon--hover,
&__item:hover &__icon--hover {
display: block;
}
}
}

+ 50
- 0
react-ui/src/pages/Message/components/Menu/index.tsx View File

@@ -0,0 +1,50 @@
import AtHoverIcon from '@/assets/img/message/at-hover.png';
import AtIcon from '@/assets/img/message/at.png';
import SystemHoverIcon from '@/assets/img/message/system-hover.png';
import SystemIcon from '@/assets/img/message/system.png';
import { MessageType } from '@/enums';
import classNames from 'classnames';
import styles from './index.less';

const menus = [
{
title: '系统消息',
icon: SystemIcon,
hoverIcon: SystemHoverIcon,
type: MessageType.System,
},
{
title: '@我的',
icon: AtIcon,
hoverIcon: AtHoverIcon,
type: MessageType.Mine,
},
];

export type MessageMenuProps = {
messageType: MessageType;
onChange: (type: MessageType) => void;
};

function MessageMenu({ messageType: currentType, onChange }: MessageMenuProps) {
return (
<div className={styles['message-menu']}>
<div className={styles['message-menu__title']}>消息列表</div>
{menus.map((item) => (
<div
key={item.type}
className={classNames(styles['message-menu__item'], {
[styles['message-menu__item--selected']]: item.type === currentType,
})}
onClick={() => onChange(item.type)}
>
<img className={styles['message-menu__item__icon']} src={item.icon} />
<img className={styles['message-menu__item__icon--hover']} src={item.hoverIcon} />
<span>{item.title}</span>
</div>
))}
</div>
);
}

export default MessageMenu;

+ 8
- 0
react-ui/src/pages/Message/index.less View File

@@ -0,0 +1,8 @@
.message {
display: flex;
flex-direction: row;
gap: 0 20px;
height: 100%;
padding: 75px 260px 30px;
.backgroundFullImage(url(@/assets/img/message/message-bg.png));
}

+ 20
- 0
react-ui/src/pages/Message/index.tsx View File

@@ -0,0 +1,20 @@
import { MessageType } from '@/enums';
import { useState } from 'react';
import MessageContent from './components/Content';
import MessageMenu from './components/Menu';
import styles from './index.less';

function MessagePage() {
const [messageType, setMessageType] = useState(MessageType.System);
return (
<div className={styles['message']}>
<MessageMenu
onChange={(type) => setMessageType(type)}
messageType={messageType}
></MessageMenu>
<MessageContent messageType={messageType}></MessageContent>
</div>
);
}

export default MessagePage;

+ 22
- 0
react-ui/src/pages/System/Approval/index.less View File

@@ -0,0 +1,22 @@
.approval-list {
height: 100%;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
height: 50px;
margin-bottom: 10px;
padding: 0 30px;
background-image: url(@/assets/img/page-title-bg.png);
background-repeat: no-repeat;
background-position: top center;
background-size: 100% 100%;
}

&__table {
height: calc(100% - 60px);
padding: 20px 30px 0;
background-color: white;
border-radius: 10px;
}
}

+ 407
- 0
react-ui/src/pages/System/Approval/index.tsx View File

@@ -0,0 +1,407 @@
/*
* @Author: 赵伟
* @Date: 2024-04-16 13:58:08
* @Description: 开发环境列表
*/

import { CodeConfigData } from '@/components/CodeSelectorModal';
import KFIcon from '@/components/KFIcon';
import { DevEditorStatus } from '@/enums';
import { useCacheState } from '@/hooks/useCacheState';
import { useSystemResource } from '@/hooks/useComputingResource';
import { DatasetData, ModelData } from '@/pages/Dataset/config';
import {
deleteEditorReq,
getEditorListReq,
startEditorReq,
stopEditorReq,
} from '@/services/developmentEnvironment';
import themes from '@/styles/theme.less';
import { parseJsonText } from '@/utils';
import { formatCodeConfig, formatDataset, formatModel } from '@/utils/format';
import { to } from '@/utils/promise';
import SessionStorage from '@/utils/sessionStorage';
import tableCellRender, { TableCellValueType } from '@/utils/table';
import { modalConfirm } from '@/utils/ui';
import { useNavigate } from '@umijs/max';
import {
App,
Button,
ConfigProvider,
Table,
type TablePaginationConfig,
type TableProps,
} from 'antd';
import classNames from 'classnames';
import { useCallback, useState } from 'react';
// import CreateMirrorModal from '../components/CreateMirrorModal';
// import EditorStatusCell from '../components/EditorStatusCell';
import styles from './index.less';

export type EditorData = {
id: number;
name: string;
status: string;
computing_resource: string;
update_by: string;
create_time: string;
url: string;
computing_resource_id: number;
dataset?: string | DatasetData;
model?: string | ModelData;
image?: string;
code_config?: string | CodeConfigData;
};

function ApprovalList() {
const navigate = useNavigate();
const [cacheState, setCacheState] = useCacheState();
const { message } = App.useApp();
const [tableData, setTableData] = useState<EditorData[]>([]);
const [total, setTotal] = useState(0);
const [pagination, setPagination] = useState<TablePaginationConfig>(
cacheState?.pagination ?? {
current: 1,
pageSize: 10,
},
);
const getResourceDescription = useSystemResource();

// 获取编辑器列表
const getEditorList = useCallback(async () => {
const params: Record<string, any> = {
page: pagination.current! - 1,
size: pagination.pageSize,
};
const [res] = await to(getEditorListReq(params));
if (res && res.data) {
const { content = [], totalElements = 0 } = res.data;
content.forEach((item: EditorData) => {
item.dataset = typeof item.dataset === 'string' ? parseJsonText(item.dataset) : null;
item.model = typeof item.model === 'string' ? parseJsonText(item.model) : null;
item.image = typeof item.image === 'string' ? parseJsonText(item.image) : null;
item.code_config =
typeof item.code_config === 'string' ? parseJsonText(item.code_config) : null;
});
setTableData(content);
setTotal(totalElements);
}
}, [pagination]);

// useEffect(() => {
// getEditorList();
// }, [getEditorList]);

// 删除编辑器
const deleteEditor = async (id: number) => {
const [res] = await to(deleteEditorReq(id));
if (res) {
message.success('删除成功');
// 如果是一页的唯一数据,删除后,请求第一页的数据
// 否则直接刷新这一页的数据
setPagination((prev) => {
return {
...prev,
current: tableData.length === 1 ? Math.max(1, prev.current! - 1) : prev.current,
};
});
}
};

// 启动编辑器
const startEditor = async (id: number) => {
const [res] = await to(startEditorReq(id));
if (res) {
message.success('操作成功');
getEditorList();
}
};

// 停止编辑器
const stopEditor = async (id: number) => {
modalConfirm({
title: '停止后,该编辑器将不可使用',
content: '是否确认停止?',
isDelete: false,
onOk: async () => {
const [res] = await to(stopEditorReq(id));
if (res) {
message.success('操作成功');
getEditorList();
}
},
});
};

// 制作镜像
// const createMirror = (id: number) => {
// const { close } = openAntdModal(CreateMirrorModal, {
// envId: id,
// onOk: () => {
// close();
// },
// });
// };

// 处理删除
const handleEditorDelete = (record: EditorData) => {
modalConfirm({
title: '删除后,该编辑器将不可恢复',
content: '是否确认删除?',
onOk: () => {
deleteEditor(record.id);
},
});
};

// 创建编辑器
const createEditor = () => {
navigate(`/developmentEnvironment/create`);
setCacheState({
pagination,
});
};

// 跳转编辑器页面
const gotoEditorPage = (record: EditorData, e: React.MouseEvent) => {
e.stopPropagation();

setCacheState({
pagination,
});

SessionStorage.setItem(SessionStorage.editorUrlKey, record.url);
navigate(`/developmentEnvironment/editor`);
};

// 去数据集
const gotoDataset = (record: EditorData, e: React.MouseEvent) => {
e.stopPropagation();

const dataset = record.dataset as DatasetData;
const link = formatDataset(dataset)?.link;
if (link) {
setCacheState({
pagination,
});
navigate(link);
}
};

// 去模型
const gotoModel = (record: EditorData, e: React.MouseEvent) => {
e.stopPropagation();

const model = record.model as ModelData;
const link = formatModel(model)?.link;
if (link) {
setCacheState({
pagination,
});
navigate(link);
}
};

// 打开代码配置仓库
const gotoCodeConfig = (record: EditorData, e: React.MouseEvent) => {
e.stopPropagation();

const codeConfig = record.code_config as CodeConfigData;
const url = formatCodeConfig(codeConfig)?.url;
if (url) {
window.open(url, '_blank');
}
};

// 分页切换
const handleTableChange: TableProps<EditorData>['onChange'] = (
pagination,
_filters,
_sorter,
{ action },
) => {
if (action === 'paginate') {
setPagination(pagination);
}
};

const columns: TableProps<EditorData>['columns'] = [
{
title: '编辑器名称',
dataIndex: 'name',
key: 'name',
width: '12%',
render: (text, record, index) =>
record.url && record.status === DevEditorStatus.Running
? tableCellRender<EditorData>(true, TableCellValueType.Link, {
onClick: gotoEditorPage,
})(text, record, index)
: tableCellRender<EditorData>(true, TableCellValueType.Text)(text, record, index),
},
{
title: '计算资源',
dataIndex: 'computing_resource',
key: 'computing_resource',
width: '11%',
render: tableCellRender(),
},
{
title: '资源规格',
dataIndex: 'computing_resource_id',
key: 'computing_resource_id',
width: '11%',
render: tableCellRender(true, TableCellValueType.Custom, {
format: getResourceDescription,
}),
},
{
title: '数据集',
dataIndex: ['dataset', 'showValue'],
key: 'dataset',
width: '11%',
render: tableCellRender(true, TableCellValueType.Link, {
onClick: gotoDataset,
}),
},
{
title: '模型',
dataIndex: ['model', 'showValue'],
key: 'model',
width: '11%',
render: tableCellRender(true, TableCellValueType.Link, {
onClick: gotoModel,
}),
},
{
title: '代码配置',
dataIndex: ['code_config', 'showValue'],
key: 'code_config',
width: '11%',
render: tableCellRender(true, TableCellValueType.Link, {
onClick: gotoCodeConfig,
}),
},
{
title: '镜像',
dataIndex: ['image', 'showValue'],
key: 'image',
width: '11%',
render: tableCellRender(true),
},
{
title: '创建者',
dataIndex: 'update_by',
key: 'update_by',
width: '11%',
render: tableCellRender(true),
},
{
title: '创建时间',
dataIndex: 'create_time',
key: 'create_time',
width: '11%',
render: tableCellRender(true, TableCellValueType.Date),
},
// {
// title: '状态',
// dataIndex: 'status',
// key: 'status',
// width: 100,
// render: EditorStatusCell,
// },
{
title: '操作',
dataIndex: 'operation',
width: 270,
key: 'operation',
render: (_: any, record: EditorData) => (
<div>
{record.status === DevEditorStatus.Pending ||
record.status === DevEditorStatus.Running ? (
<Button
type="link"
size="small"
key="stop"
icon={<KFIcon type="icon-tingzhi" />}
onClick={() => stopEditor(record.id)}
>
停止
</Button>
) : (
<Button
type="link"
size="small"
key="debug"
icon={<KFIcon type="icon-tiaoshi" />}
onClick={() => startEditor(record.id)}
>
启动
</Button>
)}
{/* {record.status !== DevEditorStatus.Running ? (
<Button
type="link"
size="small"
key="jingxiang"
icon={<KFIcon type="icon-jingxiang" />}
onClick={() => createMirror(record.id)}
>
制作镜像
</Button>
) : null} */}
<ConfigProvider
theme={{
token: {
colorLink: themes['warningColor'],
},
}}
>
<Button
type="link"
size="small"
key="remove"
icon={<KFIcon type="icon-shanchu" />}
onClick={() => handleEditorDelete(record)}
>
删除
</Button>
</ConfigProvider>
</div>
),
},
];

return (
<div className={styles['approval-list']}>
<div className={styles['approval-list__header']}>
<div>审核</div>
<Button
style={{ marginLeft: '20px' }}
type="default"
onClick={getEditorList}
icon={<KFIcon type="icon-shuaxin" />}
>
刷新
</Button>
</div>
<div className={classNames('vertical-scroll-table', styles['approval-list__table'])}>
<Table
dataSource={tableData}
columns={columns}
scroll={{ y: 'calc(100% - 55px)' }}
pagination={{
...pagination,
total: total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: () => `共${total}条`,
}}
onChange={handleTableChange}
rowKey="id"
/>
</div>
</div>
);
}

export default ApprovalList;

+ 40
- 0
react-ui/src/services/message/index.ts View File

@@ -0,0 +1,40 @@
/*
* @Author: 赵伟
* @Date: 2025-08-28 10:18:27
* @Description: 消息
*/

import { request } from '@umijs/max';

// 获取消息列表
export function getMessageListReq(params: any) {
return request(`/api/reader/gns/notification/gitlink/list`, {
method: 'GET',
params,
});
}

// 获取消息数量
export function getMessageCountReq(params: any) {
return request(`/api/reader/gns/notification/gitlink/count`, {
method: 'GET',
params,
skipLoading: true,
});
}

// 标记已读
export function readMessagesReq(data: any) {
return request(`/api/reader/gns/notification/gitlink/count`, {
method: 'POST',
data,
});
}

// 删除消息
export function deleteMessagesReq(data: any) {
return request(`/api/reader/gns/notification/gitlink/count`, {
method: 'POST',
data,
});
}

+ 55
- 0
react-ui/src/utils/date.ts View File

@@ -1,3 +1,4 @@
import { serverNow } from '@/hooks/useServerTime';
import dayjs from 'dayjs';

/**
@@ -59,6 +60,60 @@ export const elapsedTime = (begin?: string | Date | null, end?: string | Date |
return elspsedArray.slice(0, 2).join('');
};

/**
* 计算相比现在的时间过去了多久
*
* @param {string | null | undefined} date - The starting date.
* @return {string} The formatted elapsed time string.
*/
export const ago = (date?: string | Date | null): string => {
if (date === undefined || date === null) {
return '--';
}

const beginDate = dayjs(date);
if (!beginDate.isValid()) {
return '--';
}

const endDate = dayjs(serverNow());
const timestamp = endDate.valueOf() - beginDate.valueOf();
if (timestamp <= 0) {
return '刚刚';
}
const duration = dayjs.duration(timestamp);
const years = duration.years();
const months = duration.months();
const days = duration.days();
const hours = duration.hours();
const minutes = duration.minutes();
const seconds = duration.seconds();
const elspsedArray = [];
if (years !== 0) {
elspsedArray.push(`${years}年`);
}
if (months !== 0) {
elspsedArray.push(`${months}个月`);
}
if (days !== 0) {
elspsedArray.push(`${days}天`);
}
if (hours !== 0) {
elspsedArray.push(`${hours}小时`);
}
if (minutes !== 0) {
elspsedArray.push(`${minutes}分`);
}
if (seconds !== 0) {
elspsedArray.push(`${seconds}秒`);
}

if (elspsedArray.length === 0) {
return '刚刚';
}
return elspsedArray[0] + '前';
};

/**
* 是否是有效的日期
*


Loading…
Cancel
Save