| @@ -9,8 +9,7 @@ const Settings: ProLayoutProps & { | |||
| } = { | |||
| locale: 'zh-CN', | |||
| navTheme: 'light', | |||
| // 拂晓蓝 | |||
| colorPrimary: '#1664ff', | |||
| colorPrimary: '#514cf9', | |||
| // layout: 'mix', | |||
| contentWidth: 'Fluid', | |||
| fixedHeader: false, | |||
| @@ -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', | |||
| }, | |||
| ], | |||
| }, | |||
| { | |||
| @@ -60,14 +60,17 @@ | |||
| "not ie <= 10" | |||
| ], | |||
| "dependencies": { | |||
| "@ant-design/colors": "~7.2.1", | |||
| "@ant-design/icons": "^5.0.0", | |||
| "@ant-design/pro-components": "^2.4.4", | |||
| "@ant-design/use-emotion-css": "1.0.4", | |||
| "@antv/g6": "^4.8.24", | |||
| "@antv/hierarchy": "^0.6.12", | |||
| "@ctrl/tinycolor": "~4.1.0", | |||
| "@types/crypto-js": "^4.2.2", | |||
| "@umijs/route-utils": "^4.0.1", | |||
| "antd": "~5.21.4", | |||
| "antd-style": "~3.7.1", | |||
| "caniuse-lite": "~1.0.30001707", | |||
| "classnames": "^2.3.2", | |||
| "crypto-js": "^4.2.0", | |||
| @@ -190,11 +190,18 @@ export const antd: RuntimeAntdConfig = (memo) => { | |||
| memo.theme ??= {}; | |||
| memo.theme.token = { | |||
| colorPrimary: themes['primaryColor'], | |||
| colorPrimaryHover: themes['primaryHoverColor'], | |||
| colorPrimaryActive: themes['primaryActiveColor'], | |||
| colorPrimaryText: themes['primaryColor'], | |||
| colorPrimaryTextHover: themes['primaryHoverColor'], | |||
| colorPrimaryTextActive: themes['primaryActiveColor'], | |||
| // colorPrimaryBg: 'rgba(81, 76, 249, 0.07)', | |||
| colorSuccess: themes['successColor'], | |||
| colorError: themes['errorColor'], | |||
| colorWarning: themes['warningColor'], | |||
| colorLink: themes['primaryColor'], | |||
| colorText: themes['textColor'], | |||
| fontSize: parseInt(themes['fontSize']), | |||
| controlHeightLG: 46, | |||
| }; | |||
| memo.theme.components ??= {}; | |||
| @@ -0,0 +1,25 @@ | |||
| body { | |||
| .kf-primary-button.ant-btn-color-primary.ant-btn-variant-link { | |||
| background-color: .addAlpha(@primary-color, 0.07) [] !important; | |||
| } | |||
| .kf-default-button.ant-btn-color-default.ant-btn-variant-link { | |||
| background-color: .addAlpha(@text-color-secondary, 0.07) [] !important; | |||
| } | |||
| .kf-danger-button.ant-btn-color-danger.ant-btn-variant-link { | |||
| background-color: .addAlpha(@error-color, 0.07) [] !important; | |||
| } | |||
| .ant-btn-color-default.ant-btn-variant-link.kf-default-button:not(:disabled):not( | |||
| .ant-btn-disabled | |||
| ):hover { | |||
| color: .addAlpha(@text-color-secondary, 0.5) []; | |||
| } | |||
| .ant-btn-color-default.ant-btn-variant-link.kf-default-button:not(:disabled):not( | |||
| .ant-btn-disabled | |||
| ):active { | |||
| color: @text-color-secondary; | |||
| } | |||
| } | |||
| @@ -0,0 +1,75 @@ | |||
| import themes from '@/styles/theme.less'; | |||
| import { addAlpha, derivePrimaryStates } from '@/utils/color'; | |||
| import { Button, ButtonProps } from 'antd'; | |||
| import { createStyles } from 'antd-style'; | |||
| import './index.less'; | |||
| type KFColor = 'primary' | 'default' | 'danger'; | |||
| export interface KFButtonProps extends ButtonProps { | |||
| kfColor?: KFColor; | |||
| } | |||
| const useStyles = createStyles(({ token, css }) => ({ | |||
| primary: css` | |||
| color: ${token.colorPrimary} !important; | |||
| background-color: ${addAlpha(themes['primaryColor'], 0.07)} !important; | |||
| &:hover { | |||
| color: ${token.colorPrimaryHover} !important; | |||
| } | |||
| &:active { | |||
| color: ${token.colorPrimaryActive} !important; | |||
| } | |||
| `, | |||
| default: css` | |||
| color: ${themes['textColorSecondary']} !important; | |||
| background-color: ${addAlpha(themes['textColorSecondary'], 0.07)} !important; | |||
| &:hover { | |||
| color: ${derivePrimaryStates(themes['textColorSecondary']).colorPrimaryHover} !important; | |||
| } | |||
| &:active { | |||
| color: ${derivePrimaryStates(themes['textColorSecondary']).colorPrimaryActive} !important; | |||
| } | |||
| `, | |||
| danger: css` | |||
| color: ${themes['errorColor']} !important; | |||
| background-color: ${addAlpha(themes['errorColor'], 0.07)} !important; | |||
| &:hover { | |||
| color: ${derivePrimaryStates(themes['errorColor']).colorPrimaryHover} !important; | |||
| } | |||
| &:active { | |||
| color: ${derivePrimaryStates(themes['errorColor']).colorPrimaryActive} !important; | |||
| } | |||
| `, | |||
| })); | |||
| function KFButton({ kfColor = 'default', className, ...rest }: KFButtonProps) { | |||
| const { styles, cx } = useStyles(); | |||
| let style = ''; | |||
| switch (kfColor) { | |||
| case 'primary': | |||
| style = styles.primary; | |||
| break; | |||
| case 'default': | |||
| style = styles.default; | |||
| break; | |||
| case 'danger': | |||
| style = styles.danger; | |||
| break; | |||
| default: | |||
| break; | |||
| } | |||
| return ( | |||
| <Button {...rest} className={cx(className, style)} color={kfColor} variant="link"></Button> | |||
| ); | |||
| } | |||
| export default KFButton; | |||
| @@ -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; | |||
| } | |||
| } | |||
| @@ -0,0 +1,47 @@ | |||
| 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 () => { | |||
| if (!userId) return; | |||
| 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; | |||
| @@ -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> | |||
| ); | |||
| @@ -171,3 +171,29 @@ export enum ComponentType { | |||
| Map = 'map', | |||
| Str = 'str', | |||
| } | |||
| // 消息类型 | |||
| export enum MessageType { | |||
| System = 1, | |||
| Mine = 2, | |||
| } | |||
| // 消息状态 | |||
| export enum MessageStatus { | |||
| All = -1, | |||
| UnRead = 1, | |||
| Readed = 2, | |||
| } | |||
| // 审核状态 | |||
| export enum ApprovalStatus { | |||
| Pending = 0, | |||
| Agree = 1, | |||
| Reject = 2, | |||
| } | |||
| export const approvalStatusOptions = [ | |||
| { label: '待审核', value: ApprovalStatus.Pending }, | |||
| { label: '通过', value: ApprovalStatus.Agree }, | |||
| { label: '拒绝', value: ApprovalStatus.Reject }, | |||
| ]; | |||
| @@ -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)); | |||
| }; | |||
| @@ -64,7 +64,7 @@ const ResourceInfo = ({ resourceType }: ResourceInfoProps) => { | |||
| // 获取详情 | |||
| const getResourceDetail = useCallback( | |||
| async (version: string | undefined) => { | |||
| async (version?: string) => { | |||
| const params = { | |||
| id: resourceId, | |||
| owner, | |||
| @@ -223,6 +223,21 @@ const ResourceInfo = ({ resourceType }: ResourceInfoProps) => { | |||
| } | |||
| }; | |||
| // 处理发布 | |||
| const handlePublish = async () => { | |||
| const request = config.publish; | |||
| const params = { | |||
| id: resourceId, | |||
| owner, | |||
| name, | |||
| identifier, | |||
| }; | |||
| const [res] = await to(request(params)); | |||
| if (res) { | |||
| message.success('操作成功'); | |||
| } | |||
| }; | |||
| const items = [ | |||
| { | |||
| key: ResourceInfoTabKeys.Introduction, | |||
| @@ -282,6 +297,7 @@ const ResourceInfo = ({ resourceType }: ResourceInfoProps) => { | |||
| {(info[tagPropertyName] as string) || '--'} | |||
| </div> | |||
| )} | |||
| <div | |||
| className={classNames(styles['resource-info__top__praise'], { | |||
| [styles['resource-info__top__praise--praised']]: info.praised, | |||
| @@ -295,6 +311,9 @@ const ResourceInfo = ({ resourceType }: ResourceInfoProps) => { | |||
| /> | |||
| <span>{info.praises_count}</span> | |||
| </div> | |||
| <Button type="default" onClick={handlePublish}> | |||
| 发布 | |||
| </Button> | |||
| </Flex> | |||
| {version ? ( | |||
| <Flex align="center"> | |||
| @@ -34,6 +34,7 @@ function ResourceVersion({ resourceType, info }: ResourceVersionProps) { | |||
| id: info.id, | |||
| version: info.version, | |||
| identifier: info.identifier, | |||
| owner: info.owner, | |||
| is_public: info.is_public, | |||
| }); | |||
| }; | |||
| @@ -19,6 +19,8 @@ import { | |||
| getModelList, | |||
| getModelNextVersionReq, | |||
| getModelVersionList, | |||
| publishDatasetReq, | |||
| publishModelReq, | |||
| } from '@/services/dataset/index.js'; | |||
| import { limitUploadFileType } from '@/utils/ui'; | |||
| import type { TabsProps, UploadFile } from 'antd'; | |||
| @@ -45,6 +47,7 @@ type ResourceTypeInfo = { | |||
| getInfo: (params: any) => Promise<any>; // 获取详情 | |||
| compareVersion: (params: any) => Promise<any>; // 版本对比 | |||
| getNextVersion: (params: any) => Promise<any>; // 获取下一个版本 | |||
| publish: (params: any) => Promise<any>; // 发布 | |||
| name: string; // 名称 | |||
| typeParamKey: 'data_type' | 'model_type'; // 类型参数名称,获取资源列表接口使用 | |||
| tagParamKey: 'data_tag' | 'model_tag'; // 标签参数名称,获取资源列表接口使用 | |||
| @@ -76,6 +79,7 @@ export const resourceConfig: Record<ResourceType, ResourceTypeInfo> = { | |||
| getInfo: getDatasetInfo, | |||
| compareVersion: compareDatasetVersion, | |||
| getNextVersion: getDatasetNextVersionReq, | |||
| publish: publishDatasetReq, | |||
| name: '数据集', | |||
| typeParamKey: 'data_type', | |||
| tagParamKey: 'data_tag', | |||
| @@ -116,6 +120,7 @@ export const resourceConfig: Record<ResourceType, ResourceTypeInfo> = { | |||
| getInfo: getModelInfo, | |||
| compareVersion: compareModelVersion, | |||
| getNextVersion: getModelNextVersionReq, | |||
| publish: publishModelReq, | |||
| name: '模型', | |||
| typeParamKey: 'model_type', | |||
| tagParamKey: 'model_tag', | |||
| @@ -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> | |||
| @@ -30,7 +30,7 @@ | |||
| &:hover { | |||
| color: white; | |||
| .backgroundFullImage(url(@/assets/img/home/model-item-bg-hover.png)); | |||
| .backgroundFullImage(url(@/assets/img/home/model-item-bg-hover2.png)); | |||
| } | |||
| &__hot { | |||
| @@ -3,34 +3,42 @@ import { getPublicModelsReq } from '@/services/home'; | |||
| import { to } from '@/utils/promise'; | |||
| import { gotoPageIfLogin } from '@/utils/ui'; | |||
| import { Divider, Flex } from 'antd'; | |||
| import { motion, type Variants } from 'motion/react'; | |||
| import { motion, useMotionValueEvent, useScroll, type Variants } from 'motion/react'; | |||
| import { useEffect, useState } from 'react'; | |||
| import BlockTitle from '../BlockTitle'; | |||
| import styles from './index.less'; | |||
| const modelVariants: Variants = { | |||
| offscreen: (index: number) => ({ | |||
| y: 0, | |||
| opacity: 1, | |||
| offscreen: (down: boolean) => ({ | |||
| y: 100, | |||
| opacity: 0, | |||
| transition: { | |||
| ease: 'linear', | |||
| duration: 0.1, | |||
| duration: 0.5, | |||
| }, | |||
| }), | |||
| onscreen: { | |||
| y: [0, 200, 0], | |||
| opacity: [0, 0, 1], | |||
| y: 0, | |||
| opacity: 1, | |||
| transition: { | |||
| ease: 'easeOut', | |||
| duration: 0.3, | |||
| times: [0, 0, 1], | |||
| delay: 0.5, | |||
| duration: 0.5, | |||
| // times: [0, 0, 1], | |||
| }, | |||
| }, | |||
| }; | |||
| function ModelBlock() { | |||
| const [modelData, setModelData] = useState<ModelData[]>([]); | |||
| const [isDowning, setIsDowning] = useState(true); | |||
| const { scrollYProgress } = useScroll(); | |||
| useMotionValueEvent(scrollYProgress, 'change', (value) => { | |||
| setIsDowning((scrollYProgress.getPrevious() ?? 0) - value < 0); | |||
| }); | |||
| useEffect(() => { | |||
| console.log(isDowning); | |||
| }, [isDowning]); | |||
| useEffect(() => { | |||
| const getPublicModels = async () => { | |||
| @@ -58,9 +66,9 @@ function ModelBlock() { | |||
| variants={modelVariants} | |||
| initial={'offscreen'} | |||
| whileInView={'onscreen'} | |||
| custom={index} | |||
| className={styles['model__item']} | |||
| key={item.id} | |||
| custom={isDowning} | |||
| onClick={() => { | |||
| gotoPageIfLogin( | |||
| `/dataset/model/info/${item.id}?name=${item.name}&owner=${item.owner}&identifier=${item.identifier}&is_public=${item.is_public}`, | |||
| @@ -1,25 +0,0 @@ | |||
| import { motion, useInView, Variants } from 'motion/react'; | |||
| import { ReactNode, useRef } from 'react'; | |||
| type ScrollRevealProps = { | |||
| children: ReactNode; | |||
| variants: Variants; | |||
| }; | |||
| function ScrollReveal({ children, variants }: ScrollRevealProps) { | |||
| const ref = useRef<HTMLDivElement>(null); | |||
| const isInView = useInView(ref, { amount: 'all' }); | |||
| return ( | |||
| <motion.div | |||
| variants={variants} | |||
| ref={ref} | |||
| initial="offscreen" | |||
| animate={isInView ? 'onscreen' : 'offscreen'} | |||
| > | |||
| {children} | |||
| </motion.div> | |||
| ); | |||
| } | |||
| export default ScrollReveal; | |||
| @@ -15,22 +15,21 @@ import styles from './index.less'; | |||
| const serviceVariants: Variants = { | |||
| offscreen: { | |||
| y: -100, | |||
| y: -200, | |||
| opacity: 0, | |||
| transition: { | |||
| ease: 'linear', | |||
| duration: 0, | |||
| }, | |||
| }, | |||
| onscreen: (index: number) => ({ | |||
| onscreen: { | |||
| y: 0, | |||
| opacity: 1, | |||
| transition: { | |||
| type: 'spring', | |||
| duration: 1, | |||
| delay: index * 0.3, | |||
| }, | |||
| }), | |||
| }, | |||
| }; | |||
| function ServiceBlock() { | |||
| @@ -9,6 +9,7 @@ | |||
| &__item { | |||
| display: flex; | |||
| align-items: center; | |||
| width: 9rem; | |||
| &__icon { | |||
| width: 3.75rem; | |||
| @@ -0,0 +1,138 @@ | |||
| .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; | |||
| flex: none; | |||
| 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; | |||
| font-weight: 500; | |||
| } | |||
| } | |||
| :global { | |||
| .ant-btn:first-of-type { | |||
| margin-right: 10px; | |||
| margin-left: auto; | |||
| } | |||
| } | |||
| } | |||
| &__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 { | |||
| flex: 1; | |||
| 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); | |||
| } | |||
| } | |||
| &__content { | |||
| flex: 1; | |||
| margin-right: 10px; | |||
| } | |||
| &__time { | |||
| display: block; | |||
| flex: none; | |||
| 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; | |||
| } | |||
| } | |||
| @@ -0,0 +1,374 @@ | |||
| import KFButton from '@/components/KFButton'; | |||
| import KFEmpty, { EmptyType } from '@/components/KFEmpty'; | |||
| import { MessageStatus, MessageType } from '@/enums'; | |||
| import { useCacheState } from '@/hooks/useCacheState'; | |||
| import { useCheck } from '@/hooks/useCheck'; | |||
| import { Message, MessageResponse } from '@/pages/Message'; | |||
| import { deleteMessagesReq, getMessageListReq, readMessagesReq } from '@/services/message'; | |||
| import { ago } from '@/utils/date'; | |||
| import { to } from '@/utils/promise'; | |||
| import { modalConfirm } from '@/utils/ui'; | |||
| import { useModel, useNavigate } from '@umijs/max'; | |||
| import { | |||
| Button, | |||
| Checkbox, | |||
| Pagination, | |||
| PaginationProps, | |||
| Typography, | |||
| message, | |||
| type TablePaginationConfig, | |||
| } from 'antd'; | |||
| import classNames from 'classnames'; | |||
| import { useCallback, useEffect, useMemo, useState } from 'react'; | |||
| import styles from './index.less'; | |||
| export type MessageContentProps = { | |||
| messageType: MessageType; | |||
| messageStatus: MessageStatus; | |||
| pagination: TablePaginationConfig; | |||
| onStatusChange: (status: MessageStatus) => void; | |||
| onPaginationChange: (pagination: TablePaginationConfig) => void; | |||
| }; | |||
| function MessageContent({ | |||
| messageType, | |||
| messageStatus, | |||
| pagination, | |||
| onStatusChange, | |||
| onPaginationChange, | |||
| }: MessageContentProps) { | |||
| const { initialState } = useModel('@@initialState'); | |||
| const { currentUser } = initialState || {}; | |||
| const { userId } = currentUser || {}; | |||
| const setCacheState = useCacheState()[1]; | |||
| const [messages, setMessages] = useState<Message[] | undefined>(undefined); | |||
| const [allTotal, setAllTotal] = useState<number>(0); | |||
| const [unreadTotal, setUnreadTotal] = useState<number | undefined>(undefined); | |||
| 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 navigate = useNavigate(); | |||
| const tabs = useMemo( | |||
| () => [ | |||
| { | |||
| title: '未读', | |||
| status: MessageStatus.UnRead, | |||
| total: unreadTotal, | |||
| }, | |||
| { | |||
| title: '全部', | |||
| status: MessageStatus.All, | |||
| }, | |||
| ], | |||
| [unreadTotal], | |||
| ); | |||
| const getMessages = useCallback(async () => { | |||
| if (!userId) return; | |||
| const params: Record<string, any> = { | |||
| receiver: userId, | |||
| status: messageStatus, | |||
| type: messageType, | |||
| page: pagination.current, | |||
| 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 ( | |||
| message?: Message, | |||
| skipLoading: boolean = false, | |||
| skipResult: boolean = false, | |||
| ) => { | |||
| const params: Record<string, any> = message | |||
| ? { | |||
| notificationIds: message.id, | |||
| status: MessageStatus.Readed, | |||
| receiver: message.receiver, | |||
| type: message.type, | |||
| } | |||
| : { | |||
| notificationIds: -1, | |||
| status: MessageStatus.Readed, | |||
| receiver: userId, | |||
| type: messageType, | |||
| }; | |||
| const [res] = await to(readMessagesReq(params, skipLoading)); | |||
| // 点击消息置为已读时,不需要修改数据 | |||
| if (!skipResult && res) { | |||
| // 如果当前是【未读】状态 | |||
| // 【一键已读】后,设置分页为第一页 | |||
| // 如果是一页的唯一数据,设置为前一页 | |||
| if (messageStatus === MessageStatus.UnRead) { | |||
| onPaginationChange({ | |||
| ...pagination, | |||
| current: message | |||
| ? messages?.length === 1 | |||
| ? Math.max(1, pagination.current! - 1) | |||
| : pagination.current | |||
| : 1, | |||
| }); | |||
| } | |||
| } else { | |||
| getMessages(); | |||
| } | |||
| }; | |||
| // 删除 | |||
| const deleteMessages = async (ids: number[]) => { | |||
| if (ids.length <= 0) { | |||
| message.error('请选择要删除的消息'); | |||
| return; | |||
| } | |||
| const params: Record<string, any> = { | |||
| notificationIds: ids.join(','), | |||
| receiver: userId, | |||
| type: messageType, | |||
| }; | |||
| const [res] = await to(deleteMessagesReq(params)); | |||
| if (res) { | |||
| cancelBatchDelete(); | |||
| // 如果是一页的唯一数据,删除后,请求前一页的数据 | |||
| // 否则直接刷新这一页的数据 | |||
| onPaginationChange({ | |||
| ...pagination, | |||
| current: | |||
| ids.length === messages?.length | |||
| ? Math.max(1, pagination.current! - 1) | |||
| : pagination.current, | |||
| }); | |||
| } | |||
| }; | |||
| // 取消批量删除 | |||
| const cancelBatchDelete = useCallback(() => { | |||
| setIsDelete(false); | |||
| setSelectedMessages([]); | |||
| }, [setSelectedMessages]); | |||
| useEffect(() => { | |||
| getMessages(); | |||
| }, [getMessages]); | |||
| // 重置批量删除状态、分页 | |||
| useEffect(() => { | |||
| cancelBatchDelete(); | |||
| }, [messageType, messageStatus, cancelBatchDelete]); | |||
| // 批量删除 | |||
| const handleBatchDelete = () => { | |||
| if (selectedMessages.length <= 0) { | |||
| message.error('请选择要删除的消息'); | |||
| return; | |||
| } | |||
| modalConfirm({ | |||
| title: '删除后,消息不可恢复', | |||
| content: '是否确认删除?', | |||
| onOk: () => { | |||
| deleteMessages(selectedMessages); | |||
| }, | |||
| }); | |||
| }; | |||
| // 点击消息 | |||
| const hanldeMessageClick = (message: Message) => { | |||
| if (message.status === MessageStatus.UnRead) { | |||
| readMessages(message, true, true); | |||
| } | |||
| if (message.notification_url) { | |||
| navigate(message.notification_url); | |||
| setCacheState({ | |||
| messageType, | |||
| pagination, | |||
| messageStatus, | |||
| }); | |||
| } | |||
| }; | |||
| // 分页切换 | |||
| const handlePageChange: PaginationProps['onChange'] = (page, pageSize) => { | |||
| onPaginationChange({ | |||
| 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={() => { | |||
| onStatusChange(item.status); | |||
| }} | |||
| > | |||
| <span>{item.title + (item.total !== undefined ? `(${item.total})` : '')}</span> | |||
| </div> | |||
| ))} | |||
| {isDelete ? ( | |||
| <> | |||
| <KFButton kfColor="default" variant="link" onClick={cancelBatchDelete}> | |||
| 取消 | |||
| </KFButton> | |||
| <KFButton kfColor="danger" variant="link" onClick={handleBatchDelete}> | |||
| 删除 | |||
| </KFButton> | |||
| </> | |||
| ) : ( | |||
| <> | |||
| {messageType === MessageType.Mine && allTotal > 0 && ( | |||
| <KFButton kfColor="primary" variant="link" onClick={() => setIsDelete(true)}> | |||
| 批量删除 | |||
| </KFButton> | |||
| )} | |||
| {allTotal > 0 && ( | |||
| <KFButton kfColor="primary" variant="link" onClick={() => readMessages()}> | |||
| 一键已读 | |||
| </KFButton> | |||
| )} | |||
| </> | |||
| )} | |||
| </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} | |||
| onClick={() => hanldeMessageClick(message)} | |||
| > | |||
| {messageType === MessageType.Mine && isDelete && ( | |||
| <Checkbox | |||
| style={{ marginRight: 10 }} | |||
| checked={isSingleMessagesChecked(message.id)} | |||
| onChange={() => { | |||
| checkSingleMessages(message.id); | |||
| }} | |||
| onClick={(e) => e.stopPropagation()} | |||
| ></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 | |||
| className={styles['message-content__list__item__content']} | |||
| ellipsis={{ tooltip: message.content.replace(/<\/?b>/g, '') }} | |||
| > | |||
| <span dangerouslySetInnerHTML={{ __html: message.content }}></span> | |||
| </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={(e) => { | |||
| e.stopPropagation(); | |||
| readMessages(message); | |||
| }} | |||
| > | |||
| 标记为已读 | |||
| </Button> | |||
| )} | |||
| {messageType === MessageType.Mine && ( | |||
| <Button | |||
| type="link" | |||
| className={styles['message-content__list__item__button']} | |||
| onClick={(e) => { | |||
| e.stopPropagation(); | |||
| 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={`没有${messageStatus === MessageStatus.UnRead ? '未读' : ''}消息`} | |||
| hasFooter | |||
| onButtonClick={getMessages} | |||
| /> | |||
| )} | |||
| </div> | |||
| ); | |||
| } | |||
| export default MessageContent; | |||
| @@ -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; | |||
| } | |||
| } | |||
| } | |||
| @@ -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; | |||
| onTypeChange: (type: MessageType) => void; | |||
| }; | |||
| function MessageMenu({ messageType: currentType, onTypeChange }: 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={() => onTypeChange(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; | |||
| @@ -0,0 +1,8 @@ | |||
| .message { | |||
| display: flex; | |||
| flex-direction: row; | |||
| gap: 0 20px; | |||
| height: 100%; | |||
| padding: 30px 60px 30px; | |||
| .backgroundFullImage(url(@/assets/img/message/message-bg.png)); | |||
| } | |||
| @@ -0,0 +1,81 @@ | |||
| import { MessageStatus, MessageType } from '@/enums'; | |||
| import { useCacheState } from '@/hooks/useCacheState'; | |||
| import { type TablePaginationConfig } from 'antd'; | |||
| import { useState } from 'react'; | |||
| import MessageContent from './components/Content'; | |||
| import MessageMenu from './components/Menu'; | |||
| import styles from './index.less'; | |||
| // 消息列表接口返回类型 | |||
| 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; | |||
| } | |||
| function MessagePage() { | |||
| const [cacheState] = useCacheState(); | |||
| const [messageType, setMessageType] = useState(cacheState?.messageType ?? MessageType.System); | |||
| const [pagination, setPagination] = useState<TablePaginationConfig>( | |||
| cacheState?.pagination ?? { | |||
| current: 1, | |||
| pageSize: 20, | |||
| }, | |||
| ); | |||
| const [messageStatus, setMessageStatus] = useState( | |||
| cacheState?.messageStatus ?? MessageStatus.UnRead, | |||
| ); | |||
| // 重置页面为第一页 | |||
| const resetToFirstPage = () => { | |||
| setPagination((prev) => ({ | |||
| ...prev, | |||
| current: 1, | |||
| })); | |||
| }; | |||
| return ( | |||
| <div className={styles['message']}> | |||
| <MessageMenu | |||
| onTypeChange={(type) => { | |||
| setMessageType(type); | |||
| resetToFirstPage(); | |||
| }} | |||
| messageType={messageType} | |||
| ></MessageMenu> | |||
| <MessageContent | |||
| messageType={messageType} | |||
| messageStatus={messageStatus} | |||
| onStatusChange={(status) => { | |||
| setMessageStatus(status); | |||
| resetToFirstPage(); | |||
| }} | |||
| pagination={pagination} | |||
| onPaginationChange={setPagination} | |||
| ></MessageContent> | |||
| </div> | |||
| ); | |||
| } | |||
| export default MessagePage; | |||
| @@ -0,0 +1,9 @@ | |||
| .approval-info { | |||
| margin-bottom: 20px; | |||
| &__item { | |||
| display: flex; | |||
| align-items: center; | |||
| margin-bottom: 10px; | |||
| } | |||
| } | |||
| @@ -0,0 +1,166 @@ | |||
| import BasicInfo from '@/components/BasicInfo'; | |||
| import KFModal from '@/components/KFModal'; | |||
| import SubAreaTitle from '@/components/SubAreaTitle'; | |||
| import { ApprovalData, ApprovalType } from '@/pages/System/Approval'; | |||
| import { agreeApprovalReq, rejectApprovalReq } from '@/services/message'; | |||
| import { parseJsonText } from '@/utils'; | |||
| import { to } from '@/utils/promise'; | |||
| import { modalConfirm } from '@/utils/ui'; | |||
| import { Button, Form, Input, message, type ModalProps } from 'antd'; | |||
| interface ApprovalModalProps extends Omit<ModalProps, 'onOk'> { | |||
| record: ApprovalData; | |||
| onOk: () => void; | |||
| } | |||
| function ApprovalModal({ record, onOk, ...rest }: ApprovalModalProps) { | |||
| const [form] = Form.useForm(); | |||
| const content = parseJsonText(record.content); | |||
| const recordTypeName = record.type === ApprovalType.Dataset ? '数据集' : '模型'; | |||
| const items = | |||
| record.type === ApprovalType.Dataset | |||
| ? [ | |||
| { | |||
| label: '数据集名称', | |||
| value: content.name, | |||
| }, | |||
| { | |||
| label: '数据集分类', | |||
| value: content.dataType, | |||
| }, | |||
| { | |||
| label: '研究方向', | |||
| value: content.dataTag, | |||
| }, | |||
| { | |||
| label: '数据集描述', | |||
| value: content.description, | |||
| }, | |||
| ] | |||
| : [ | |||
| { | |||
| label: '模型名称', | |||
| value: content.name, | |||
| }, | |||
| { | |||
| label: '模型框架', | |||
| value: content.model_type, | |||
| }, | |||
| { | |||
| label: '模型能力', | |||
| value: content.model_tag, | |||
| }, | |||
| { | |||
| label: '模型描述', | |||
| value: content.description, | |||
| }, | |||
| ]; | |||
| // 审批通过 | |||
| const agreeApproval = async (remark?: string) => { | |||
| const [res] = await to( | |||
| agreeApprovalReq({ | |||
| id: record.id, | |||
| result: remark, | |||
| }), | |||
| ); | |||
| if (res) { | |||
| onOk?.(); | |||
| } | |||
| }; | |||
| // 审批拒绝 | |||
| const rejectApproval = async (remark: string) => { | |||
| const [res] = await to( | |||
| rejectApprovalReq({ | |||
| id: record.id, | |||
| result: remark, | |||
| }), | |||
| ); | |||
| if (res) { | |||
| onOk?.(); | |||
| } | |||
| }; | |||
| const handleAgree = () => { | |||
| const remark = form.getFieldValue('remark') as string | undefined; | |||
| const remarkTrim = remark?.trim(); | |||
| modalConfirm({ | |||
| isDelete: false, | |||
| title: `审批通过后,将发布该${recordTypeName}`, | |||
| content: '是否确认通过?', | |||
| onOk: () => { | |||
| agreeApproval(remarkTrim); | |||
| }, | |||
| }); | |||
| }; | |||
| const handleReject = () => { | |||
| const remark = form.getFieldValue('remark') as string | undefined; | |||
| const remarkTrim = remark?.trim(); | |||
| if (!remarkTrim) { | |||
| message.error('请输入审批意见'); | |||
| return; | |||
| } | |||
| modalConfirm({ | |||
| isDelete: false, | |||
| title: `审批拒绝后,将不发布该${recordTypeName}`, | |||
| content: '是否确认拒绝?', | |||
| onOk: () => { | |||
| rejectApproval(remarkTrim); | |||
| }, | |||
| }); | |||
| }; | |||
| return ( | |||
| <KFModal | |||
| {...rest} | |||
| title="审核" | |||
| width={825} | |||
| footer={[ | |||
| <Button key="agree" type="primary" onClick={handleAgree}> | |||
| 审批通过 | |||
| </Button>, | |||
| <Button key="reject" type="primary" danger onClick={handleReject}> | |||
| 审批拒绝 | |||
| </Button>, | |||
| ]} | |||
| > | |||
| <SubAreaTitle | |||
| title="基本信息" | |||
| image={require('@/assets/img/mirror-basic.png')} | |||
| style={{ marginBottom: '15px' }} | |||
| ></SubAreaTitle> | |||
| <BasicInfo | |||
| datas={items} | |||
| labelWidth={80} | |||
| labelAlign="justify" | |||
| style={{ marginBottom: 20, width: '100%' }} | |||
| ></BasicInfo> | |||
| <Form name="form" layout="vertical" form={form} autoComplete="off"> | |||
| <Form.Item | |||
| label="审核意见" | |||
| name="remark" | |||
| rules={[ | |||
| { | |||
| required: false, | |||
| message: '请输入审核意见', | |||
| }, | |||
| ]} | |||
| > | |||
| <Input.TextArea | |||
| placeholder="请输入审核意见" | |||
| autoSize={{ minRows: 3, maxRows: 6 }} | |||
| maxLength={128} | |||
| showCount | |||
| allowClear | |||
| /> | |||
| </Form.Item> | |||
| </Form> | |||
| </KFModal> | |||
| ); | |||
| } | |||
| export default ApprovalModal; | |||
| @@ -0,0 +1,15 @@ | |||
| .status-cell { | |||
| color: @text-color; | |||
| &--agree { | |||
| color: @success-color; | |||
| } | |||
| &--reject { | |||
| color: @error-color; | |||
| } | |||
| &--pending { | |||
| color: @text-color; | |||
| } | |||
| } | |||
| @@ -0,0 +1,36 @@ | |||
| /* | |||
| * @Author: 赵伟 | |||
| * @Date: 2024-04-18 18:35:41 | |||
| * @Description: 编辑器状态组件 | |||
| */ | |||
| import { ApprovalStatus } from '@/enums'; | |||
| import styles from './index.less'; | |||
| export type DevEditorStatusInfo = { | |||
| text: string; | |||
| classname: string; | |||
| }; | |||
| export const statusInfo: Record<ApprovalStatus, DevEditorStatusInfo> = { | |||
| [ApprovalStatus.Pending]: { | |||
| classname: styles['status-cell--pending'], | |||
| text: '待审核', | |||
| }, | |||
| [ApprovalStatus.Agree]: { | |||
| classname: styles['status-cell--agree'], | |||
| text: '通过', | |||
| }, | |||
| [ApprovalStatus.Reject]: { | |||
| classname: styles['status-cell--reject'], | |||
| text: '已拒绝', | |||
| }, | |||
| }; | |||
| function StatusCell(status?: ApprovalStatus | null) { | |||
| if (status === null || status === undefined || !statusInfo[status]) { | |||
| return <span>--</span>; | |||
| } | |||
| return <span className={statusInfo[status].classname}>{statusInfo[status].text}</span>; | |||
| } | |||
| export default StatusCell; | |||
| @@ -0,0 +1,29 @@ | |||
| .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%; | |||
| } | |||
| &__content { | |||
| height: calc(100% - 60px); | |||
| padding: 20px 30px 0; | |||
| background-color: white; | |||
| border-radius: 10px; | |||
| } | |||
| &__table { | |||
| height: calc(100% - 32px - 28px); | |||
| margin-top: 28px; | |||
| background-color: white; | |||
| border-radius: 10px; | |||
| } | |||
| } | |||
| @@ -0,0 +1,210 @@ | |||
| /* | |||
| * @Author: 赵伟 | |||
| * @Date: 2024-04-16 13:58:08 | |||
| * @Description: 审核列表 | |||
| */ | |||
| import { ApprovalStatus, approvalStatusOptions } from '@/enums'; | |||
| import { getApprovalListReq } from '@/services/message'; | |||
| import { openAntdModal } from '@/utils/modal'; | |||
| import { to } from '@/utils/promise'; | |||
| import tableCellRender, { TableCellValueType } from '@/utils/table'; | |||
| import { | |||
| Button, | |||
| Select, | |||
| Table, | |||
| Typography, | |||
| type TablePaginationConfig, | |||
| type TableProps, | |||
| } from 'antd'; | |||
| import classNames from 'classnames'; | |||
| import { useCallback, useEffect, useState } from 'react'; | |||
| import ApprovalModal from './components/ApprovalModal'; | |||
| import StatusCell from './components/StatusCell'; | |||
| import styles from './index.less'; | |||
| export interface ApprovalData { | |||
| id: number; | |||
| status: number; | |||
| result: null; | |||
| content: string; | |||
| applicant_id: number; | |||
| applicant_name: null; | |||
| applicant_time: Date; | |||
| approver_id: number; | |||
| approver_time: Date; | |||
| title: string; | |||
| type: ApprovalType; | |||
| url: string; | |||
| } | |||
| export enum ApprovalType { | |||
| Dataset = 'DATASET', | |||
| Model = 'MODEL', | |||
| } | |||
| const approvalTypeOptions = [ | |||
| { label: '数据集', value: ApprovalType.Dataset }, | |||
| { label: '模型', value: ApprovalType.Model }, | |||
| ]; | |||
| const statusOptions = [{ label: '全部', value: '' }, ...approvalStatusOptions]; | |||
| function ApprovalList() { | |||
| const [tableData, setTableData] = useState<ApprovalData[]>([]); | |||
| const [total, setTotal] = useState(0); | |||
| const [status, setStatus] = useState(''); | |||
| const [pagination, setPagination] = useState<TablePaginationConfig>({ | |||
| current: 1, | |||
| pageSize: 10, | |||
| }); | |||
| // 获取审核列表 | |||
| const getApprovalList = useCallback(async () => { | |||
| const params: Record<string, any> = { | |||
| current: pagination.current, | |||
| pageSize: pagination.pageSize, | |||
| status: status, | |||
| }; | |||
| const [res] = await to(getApprovalListReq(params)); | |||
| if (res) { | |||
| const { rows = [], total = 0 } = res; | |||
| setTableData(rows); | |||
| setTotal(total); | |||
| } | |||
| }, [pagination, status]); | |||
| useEffect(() => { | |||
| getApprovalList(); | |||
| }, [getApprovalList]); | |||
| // 审核 | |||
| const approval = (record: ApprovalData) => { | |||
| const { close } = openAntdModal(ApprovalModal, { | |||
| record: record, | |||
| onOk: () => { | |||
| close(); | |||
| getApprovalList(); | |||
| }, | |||
| }); | |||
| }; | |||
| // 分页切换 | |||
| const handleTableChange: TableProps<ApprovalData>['onChange'] = ( | |||
| pagination, | |||
| _filters, | |||
| _sorter, | |||
| { action }, | |||
| ) => { | |||
| if (action === 'paginate') { | |||
| setPagination(pagination); | |||
| } | |||
| }; | |||
| const columns: TableProps<ApprovalData>['columns'] = [ | |||
| { | |||
| title: '内容', | |||
| dataIndex: 'title', | |||
| key: 'title', | |||
| render: (title) => ( | |||
| <Typography.Text | |||
| style={{ width: '100%' }} | |||
| ellipsis={{ tooltip: title.replace(/<\/?b>/g, '') }} | |||
| > | |||
| <span dangerouslySetInnerHTML={{ __html: title }}></span> | |||
| </Typography.Text> | |||
| ), | |||
| }, | |||
| { | |||
| title: '类型', | |||
| dataIndex: 'type', | |||
| key: 'type', | |||
| width: 100, | |||
| render: tableCellRender(true, TableCellValueType.Enum, { | |||
| options: approvalTypeOptions, | |||
| }), | |||
| }, | |||
| { | |||
| title: '申请者', | |||
| dataIndex: 'applicant_name', | |||
| key: 'applicant_name', | |||
| width: 180, | |||
| render: tableCellRender(true), | |||
| }, | |||
| { | |||
| title: '申请时间', | |||
| dataIndex: 'applicant_time', | |||
| key: 'applicant_time', | |||
| width: 180, | |||
| render: tableCellRender(true, TableCellValueType.Date), | |||
| }, | |||
| { | |||
| title: '审核意见', | |||
| dataIndex: 'result', | |||
| key: 'result', | |||
| width: 200, | |||
| render: tableCellRender(true), | |||
| }, | |||
| { | |||
| title: '状态', | |||
| dataIndex: 'status', | |||
| key: 'status', | |||
| width: 100, | |||
| render: StatusCell, | |||
| }, | |||
| { | |||
| title: '操作', | |||
| dataIndex: 'operation', | |||
| width: 150, | |||
| key: 'operation', | |||
| render: (_: any, record: ApprovalData) => ( | |||
| <div> | |||
| {record.status === ApprovalStatus.Pending ? ( | |||
| <Button type="link" size="small" key="stop" onClick={() => approval(record)}> | |||
| 审核 | |||
| </Button> | |||
| ) : null} | |||
| </div> | |||
| ), | |||
| }, | |||
| ]; | |||
| return ( | |||
| <div className={styles['approval-list']}> | |||
| <div className={styles['approval-list__header']}> | |||
| <div>审核管理</div> | |||
| </div> | |||
| <div className={styles['approval-list__content']}> | |||
| <div className={styles['approval-list__filter']}> | |||
| <span>状态:</span> | |||
| <Select | |||
| style={{ width: 100 }} | |||
| placeholder="请选择" | |||
| onChange={(value) => setStatus(value ?? '')} | |||
| options={statusOptions} | |||
| value={status} | |||
| allowClear | |||
| ></Select> | |||
| </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> | |||
| </div> | |||
| ); | |||
| } | |||
| export default ApprovalList; | |||
| @@ -254,7 +254,7 @@ const RoleTableList: React.FC = () => { | |||
| { | |||
| title: <FormattedMessage id="pages.searchTable.titleOption" defaultMessage="操作" />, | |||
| dataIndex: 'option', | |||
| width: '220px', | |||
| width: '240px', | |||
| valueType: 'option', | |||
| render: (_, record) => [ | |||
| <Button | |||
| @@ -281,7 +281,7 @@ const UserTableList: React.FC = () => { | |||
| { | |||
| title: <FormattedMessage id="pages.searchTable.titleOption" defaultMessage="操作" />, | |||
| dataIndex: 'option', | |||
| width: '220px', | |||
| width: '240px', | |||
| valueType: 'option', | |||
| render: (_, record) => [ | |||
| <Button | |||
| @@ -28,7 +28,7 @@ | |||
| margin-top: 12px; | |||
| margin-bottom: 30px; | |||
| padding: 2px 7px; | |||
| color: @primary-color-secondary; | |||
| color: @primary-color; | |||
| font-size: 13px; | |||
| background-color: rgba(187, 210, 255, 0.29); | |||
| border-radius: 2px; | |||
| @@ -40,14 +40,14 @@ | |||
| width: 40%; | |||
| text-align: left; | |||
| &:nth-child(3n+2) { | |||
| &:nth-child(3n + 2) { | |||
| width: 30%; | |||
| text-align: center; | |||
| width: 30%; | |||
| } | |||
| &:nth-child(3n) { | |||
| width: 30%; | |||
| text-align: right; | |||
| width: 30%; | |||
| } | |||
| &__title { | |||
| @@ -33,7 +33,7 @@ | |||
| &__role { | |||
| display: inline-block; | |||
| padding: 1px 7px; | |||
| color: @primary-color-secondary; | |||
| color: @primary-color; | |||
| font-size: 13px; | |||
| background-color: rgba(187, 210, 255, 0.29); | |||
| border-radius: 2px; | |||
| @@ -98,6 +98,14 @@ export function getDatasetNextVersionReq(data) { | |||
| }); | |||
| } | |||
| // 发布数据集 | |||
| export function publishDatasetReq(data) { | |||
| return request(`/api/mmp/newdataset/publish`, { | |||
| method: 'POST', | |||
| data, | |||
| }); | |||
| } | |||
| // ----------------------------模型--------------------------------- | |||
| @@ -237,3 +245,12 @@ export function unpraiseResourceReq(id) { | |||
| }); | |||
| } | |||
| // 发布模型 | |||
| export function publishModelReq(data) { | |||
| return request(`/api/mmp/newmodel/publish`, { | |||
| method: 'POST', | |||
| data, | |||
| }); | |||
| } | |||
| @@ -0,0 +1,66 @@ | |||
| /* | |||
| * @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, | |||
| skipErrorHandler: true, | |||
| }); | |||
| } | |||
| // 单条消息标记为已读 | |||
| export function readMessagesReq(data: any, skipLoading: boolean) { | |||
| return request(`/api/writer/gns/notification/gitlink`, { | |||
| method: 'PUT', | |||
| data, | |||
| skipLoading, | |||
| }); | |||
| } | |||
| // 删除消息 | |||
| export function deleteMessagesReq(data: any) { | |||
| return request(`/api/writer/gns/notification/gitlink`, { | |||
| method: 'DELETE', | |||
| data, | |||
| }); | |||
| } | |||
| // 获取审核列表 | |||
| export function getApprovalListReq(params: any) { | |||
| return request(`/api/mmp/sysApproval/my-approve`, { | |||
| method: 'GET', | |||
| params, | |||
| }); | |||
| } | |||
| // 审核通过 | |||
| export function agreeApprovalReq(data: any) { | |||
| return request(`/api/mmp/sysApproval/approve`, { | |||
| method: 'POST', | |||
| data, | |||
| }); | |||
| } | |||
| // 审核拒绝 | |||
| export function rejectApprovalReq(data: any) { | |||
| return request(`/api/mmp/sysApproval/reject`, { | |||
| method: 'POST', | |||
| data, | |||
| }); | |||
| } | |||
| @@ -5,13 +5,13 @@ | |||
| */ | |||
| // 颜色 | |||
| @primary-color: #1664ff; // 主色调 | |||
| @primary-color-secondary: #4e89ff; | |||
| @primary-color-hover: #69b1ff; | |||
| @primary-color: #514cf9; // 主色调 | |||
| @primary-color-hover: #7c79ff; | |||
| @primary-color-active: #2735fe; | |||
| @sider-background-color: #f2f5f7; // 侧边栏背景颜色 | |||
| @background-color: #f9fafb; // 页面背景颜色 | |||
| @text-color: #1d1d20; | |||
| @text-color-secondary: #575757; | |||
| @text-color: #0c0d0e; | |||
| @text-color-secondary: #494a6f; | |||
| @text-color-tertiary: #8a8a8a; | |||
| @text-placeholder-color: rgba(0, 0, 0, 0.25); | |||
| @text-disabled-color: rgba(0, 0, 0, 0.25); | |||
| @@ -20,10 +20,10 @@ | |||
| @warning-color: #f98e1b; | |||
| @abort-color: #8a8a8a; | |||
| @pending-color: #ecb934; | |||
| @underline-color: #5d93ff; | |||
| @underline-color: #514cf9; | |||
| @border-color: #eaeaea; | |||
| @link-hover-color: #69b1ff; | |||
| @link-hover-color: #514cf9; | |||
| @heading-color: rgba(0, 0, 0, 0.85); | |||
| @input-icon-hover-color: rgba(0, 0, 0, 0.85); | |||
| @@ -92,6 +92,8 @@ | |||
| // 导出变量 | |||
| :export { | |||
| primaryColor: @primary-color; | |||
| primaryHoverColor: @primary-color-hover; | |||
| primaryActiveColor: @primary-color-active; | |||
| successColor: @success-color; | |||
| errorColor: @error-color; | |||
| warningColor: @warning-color; | |||
| @@ -0,0 +1,97 @@ | |||
| /* | |||
| * @Author: 赵伟 | |||
| * @Date: 2025-09-02 09:52:50 | |||
| * @Description: 兼容 Antd 颜色设置 | |||
| */ | |||
| // 安装依赖:npm i @ant-design/colors | |||
| import { generate } from '@ant-design/colors'; | |||
| import { TinyColor } from '@ctrl/tinycolor'; | |||
| type Options = { | |||
| /** 是否使用暗色算法(需传背景色,默认按 AntD 暗色背景) */ | |||
| dark?: boolean; | |||
| backgroundColor?: string; // 仅 dark 为 true 时生效 | |||
| }; | |||
| export function derivePrimaryStates(seed: string, opts: Options = {}) { | |||
| const { dark = false, backgroundColor = '#141414' } = opts; | |||
| const palette = generate(seed, dark ? { theme: 'dark', backgroundColor } : undefined); | |||
| return { | |||
| // 基础主色 | |||
| colorPrimary: palette[5], // 主色(第 6 阶) | |||
| // 状态 | |||
| colorPrimaryHover: palette[4], // hover(第 5 阶) | |||
| colorPrimaryActive: palette[6], // active(第 7 阶) | |||
| colorPrimaryTextHover: palette[4], // 文本 hover | |||
| colorPrimaryTextActive: palette[6], // 文本 active | |||
| // 背景 | |||
| colorPrimaryBg: palette[0], // 浅背景(第 1 阶) | |||
| colorPrimaryBgHover: palette[1], // 背景 hover(第 2 阶) | |||
| // 边框 | |||
| colorPrimaryBorder: palette[2], // 普通边框(第 3 阶) | |||
| colorPrimaryBorderHover: palette[3], // 边框 hover(第 4 阶) | |||
| // 文本 | |||
| colorPrimaryText: palette[5], // 主文本(等同于 colorPrimary) | |||
| // 备用(有时用于选中态阴影/描边) | |||
| colorPrimaryShadow: palette[6], | |||
| }; | |||
| } | |||
| export function derivePrimaryStates2(color: string) { | |||
| const base = new TinyColor(color); | |||
| return { | |||
| colorPrimaryHover: base.lighten(10).toHexString(), | |||
| colorPrimaryActive: base.darken(10).toHexString(), | |||
| }; | |||
| } | |||
| /** | |||
| * 给颜色值添加透明度. | |||
| * | |||
| * @param color - 颜色值, #ff0000、#888 或者 rgb(255, 0, 0) | |||
| * @param alpha - 透明的,0~1 | |||
| * @return 颜色值,rgba | |||
| */ | |||
| export function addAlpha(color: string, alpha: number): string { | |||
| const alphaFixed = Math.max(0, Math.min(1, alpha)); // 保证在 [0,1] | |||
| // #rrggbb 或 #rgb | |||
| if (color.startsWith('#')) { | |||
| let r: number, g: number, b: number; | |||
| if (color.length === 4) { | |||
| // #rgb | |||
| r = parseInt(color[1] + color[1], 16); | |||
| g = parseInt(color[2] + color[2], 16); | |||
| b = parseInt(color[3] + color[3], 16); | |||
| } else if (color.length === 7) { | |||
| // #rrggbb | |||
| r = parseInt(color.slice(1, 3), 16); | |||
| g = parseInt(color.slice(3, 5), 16); | |||
| b = parseInt(color.slice(5, 7), 16); | |||
| } else { | |||
| throw new Error('Invalid hex color format'); | |||
| } | |||
| return `rgba(${r}, ${g}, ${b}, ${alphaFixed})`; | |||
| } | |||
| // rgb(r,g,b) | |||
| const rgbMatch = color.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/i); | |||
| if (rgbMatch) { | |||
| const r = parseInt(rgbMatch[1], 10); | |||
| const g = parseInt(rgbMatch[2], 10); | |||
| const b = parseInt(rgbMatch[3], 10); | |||
| return `rgba(${r}, ${g}, ${b}, ${alphaFixed})`; | |||
| } | |||
| throw new Error('Unsupported color format'); | |||
| } | |||
| @@ -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] + '前'; | |||
| }; | |||
| /** | |||
| * 是否是有效的日期 | |||
| * | |||