chore: initial import
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
@@ -0,0 +1,70 @@
|
||||
# Getting Started with Create React App
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm start`
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
|
||||
|
||||
The page will reload when you make changes.\
|
||||
You may also see any lint errors in the console.
|
||||
|
||||
### `npm test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `npm run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
|
||||
|
||||
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
|
||||
|
||||
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
|
||||
### Code Splitting
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
|
||||
|
||||
### Analyzing the Bundle Size
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
|
||||
|
||||
### Making a Progressive Web App
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
|
||||
|
||||
### Deployment
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
||||
|
||||
### `npm run build` fails to minify
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
||||
Generated
+20862
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "client",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-react": "^1.0.6",
|
||||
"antd": "^6.1.4",
|
||||
"axios": "^1.13.2",
|
||||
"dayjs": "^1.11.10",
|
||||
"file-saver": "^2.0.5",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jszip": "^3.10.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.12.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"recharts": "^3.6.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="科技成果管理系统"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>科技成果管理系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 200 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"short_name": "科技成果管理",
|
||||
"name": "科技成果管理系统",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -0,0 +1,38 @@
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { ConfigProvider, Spin } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import Layout from './components/MainLayout';
|
||||
|
||||
// 懒加载页面组件
|
||||
const Login = lazy(() => import('./pages/Login'));
|
||||
const Register = lazy(() => import('./pages/Register'));
|
||||
const ResetPassword = lazy(() => import('./pages/ResetPassword'));
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||
const UploadPage = lazy(() => import('./pages/Upload'));
|
||||
const Audit = lazy(() => import('./pages/Audit'));
|
||||
const UserManagement = lazy(() => import('./pages/UserManagement'));
|
||||
const Statistics = lazy(() => import('./pages/Statistics'));
|
||||
const DictionaryManagement = lazy(() => import('./pages/DictionaryManagement'));
|
||||
const DataValidation = lazy(() => import('./pages/DataValidation'));
|
||||
const SystemLogs = lazy(() => import('./pages/SystemLogs'));
|
||||
const Notifications = lazy(() => import('./pages/Notifications'));
|
||||
|
||||
// 加载中组件
|
||||
const PageLoading = () => (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
<Spin size="large" tip="加载中..." />
|
||||
</div>
|
||||
);
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
token: {
|
||||
colorPrimary: '#1677ff',
|
||||
borderRadius: 6,
|
||||
},
|
||||
components: {
|
||||
Layout: {
|
||||
headerBg: '#ffffff',
|
||||
siderBg: '#001529',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Router>
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="notifications" element={<Notifications />} />
|
||||
<Route path="upload" element={<UploadPage />} />
|
||||
<Route path="audit" element={<Audit />} />
|
||||
<Route path="users" element={<UserManagement />} />
|
||||
<Route path="dictionaries" element={<DictionaryManagement />} />
|
||||
<Route path="statistics" element={<Statistics />} />
|
||||
<Route path="validation" element={<DataValidation />} />
|
||||
<Route path="logs" element={<SystemLogs />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Router>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Button, message } from 'antd';
|
||||
import { DownloadOutlined, PictureOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import html2canvas from 'html2canvas';
|
||||
import API_BASE_URL from '../config';
|
||||
import AchievementFormRenderer from './AchievementFormRenderer';
|
||||
|
||||
const AchievementDetails = ({ id, open, onClose }) => {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchDetails = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await axios.get(`${API_BASE_URL}/api/achievements/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setData(response.data);
|
||||
} catch (error) {
|
||||
message.error('获取详情失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && id) {
|
||||
fetchDetails();
|
||||
}
|
||||
}, [open, id, fetchDetails]);
|
||||
|
||||
const handleDownloadAttachment = async () => {
|
||||
if (data?.attachments && data.attachments.length > 0) {
|
||||
const hide = message.loading('正在打包下载...', 0);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await axios.get(`${API_BASE_URL}/api/achievements/${id}/attachments/zip`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
|
||||
let filename = `成果附件_${data.name || id}.zip`;
|
||||
const contentDisposition = response.headers['content-disposition'];
|
||||
if (contentDisposition) {
|
||||
const filenameMatch = contentDisposition.match(/filename\*=UTF-8''(.+)/) || contentDisposition.match(/filename="(.+)"/);
|
||||
if (filenameMatch && filenameMatch[1]) {
|
||||
filename = decodeURIComponent(filenameMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
message.success('下载已开始');
|
||||
} catch (error) {
|
||||
console.error('下载失败:', error);
|
||||
message.error('下载失败,请稍后重试');
|
||||
} finally {
|
||||
hide();
|
||||
}
|
||||
} else {
|
||||
message.warning('该成果没有附件');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadImage = async () => {
|
||||
const element = document.getElementById('achievement-detail-content');
|
||||
if (!element) return;
|
||||
|
||||
const hideMessage = message.loading('正在生成图片...', 0);
|
||||
const EXPORT_WIDTH = 1100;
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.style.position = 'fixed';
|
||||
container.style.top = '0';
|
||||
container.style.left = '-100000px';
|
||||
container.style.zIndex = '-9999';
|
||||
container.style.width = `${EXPORT_WIDTH}px`;
|
||||
container.style.backgroundColor = '#fff';
|
||||
container.style.padding = '40px';
|
||||
container.style.boxSizing = 'border-box';
|
||||
container.style.overflow = 'visible';
|
||||
document.body.appendChild(container);
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.style.textAlign = 'center';
|
||||
header.style.marginBottom = '30px';
|
||||
header.style.borderBottom = '2px solid #1890ff';
|
||||
header.style.paddingBottom = '20px';
|
||||
header.innerHTML = `
|
||||
<div style="display: flex; align-items: center; justify-content: center; margin-bottom: 10px;">
|
||||
<img src="/logo.png" style="height: 50px; margin-right: 15px;" onerror="this.style.display='none'" />
|
||||
<h1 style="margin: 0; font-size: 28px; font-weight: bold; color: #333; white-space: normal; word-break: break-all; overflow-wrap: anywhere;">科技成果管理表单</h1>
|
||||
</div>
|
||||
<div style="font-size: 14px; color: #666;">生成时间:${new Date().toLocaleString()}</div>
|
||||
`;
|
||||
container.appendChild(header);
|
||||
|
||||
const contentClone = element.cloneNode(true);
|
||||
contentClone.style.overflow = 'visible';
|
||||
container.appendChild(contentClone);
|
||||
|
||||
try {
|
||||
const exportWidth = Math.max(container.scrollWidth, container.offsetWidth);
|
||||
const exportHeight = Math.max(container.scrollHeight, container.offsetHeight);
|
||||
|
||||
const canvas = await html2canvas(container, {
|
||||
useCORS: true,
|
||||
scale: 2,
|
||||
backgroundColor: '#ffffff',
|
||||
width: exportWidth,
|
||||
height: exportHeight,
|
||||
windowWidth: exportWidth,
|
||||
windowHeight: exportHeight,
|
||||
scrollX: 0,
|
||||
scrollY: 0
|
||||
});
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.download = `成果表单_${data.name || '未命名'}.png`;
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
link.click();
|
||||
message.success('图片已下载');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
message.error('生成图片失败');
|
||||
} finally {
|
||||
document.body.removeChild(container);
|
||||
hideMessage();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="成果详情"
|
||||
open={open}
|
||||
confirmLoading={loading}
|
||||
onCancel={onClose}
|
||||
footer={[
|
||||
<Button key="close" onClick={onClose}>关闭</Button>,
|
||||
<Button key="form" icon={<PictureOutlined />} onClick={handleDownloadImage}>下载表单</Button>,
|
||||
<Button key="attachment" type="primary" icon={<DownloadOutlined />} onClick={handleDownloadAttachment}>下载附件</Button>
|
||||
]}
|
||||
width={800}
|
||||
>
|
||||
{data ? (
|
||||
<AchievementFormRenderer data={data} />
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>加载中...</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AchievementDetails;
|
||||
@@ -0,0 +1,468 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Modal, Form, Input, DatePicker, InputNumber, message, Spin, Divider, Space, Checkbox, Button, Select, Alert } from 'antd';
|
||||
import { PlusOutlined, MinusCircleOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import API_BASE_URL from '../config';
|
||||
import UserSearchSelect from './UserSearchSelect';
|
||||
import { maskPhone } from '../utils/phoneMask';
|
||||
import {
|
||||
nameLabels,
|
||||
dateLabels,
|
||||
roleOptions,
|
||||
contributorAddButtonLabels,
|
||||
contributorMainLabels,
|
||||
contributorSectionTitles,
|
||||
nameTips,
|
||||
sourceTips
|
||||
} from '../constants/achievements';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
const AchievementEdit = ({ id, open, onClose, onSave }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState(null);
|
||||
const [dicts, setDicts] = useState({
|
||||
organizations: [],
|
||||
'award-types': [],
|
||||
'award-levels': [],
|
||||
'paper-types': [],
|
||||
'standard-types': [],
|
||||
'project-categories': []
|
||||
});
|
||||
|
||||
const fetchDicts = useCallback(async () => {
|
||||
const types = ['organizations', 'award-types', 'award-levels', 'paper-types', 'standard-types', 'project-categories'];
|
||||
const token = localStorage.getItem('token');
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
types.map(t => axios.get(`${API_BASE_URL}/api/dictionaries/${t}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}))
|
||||
);
|
||||
const newDicts = {};
|
||||
types.forEach((t, index) => {
|
||||
newDicts[t] = results[index].data;
|
||||
});
|
||||
setDicts(newDicts);
|
||||
} catch (error) {
|
||||
console.error('获取字典失败', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchDetails = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await axios.get(`${API_BASE_URL}/api/achievements/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
const achievement = response.data;
|
||||
setData(achievement);
|
||||
|
||||
// 处理日期字段
|
||||
const formData = {
|
||||
...achievement.details,
|
||||
name: achievement.name,
|
||||
achievement_date: achievement.achievement_date ? dayjs(achievement.achievement_date) : null,
|
||||
remarks: achievement.remarks
|
||||
};
|
||||
|
||||
// 统一处理完成人数据,适配 UserSearchSelect
|
||||
const contributors = achievement.contributors || [];
|
||||
formData.contributors = contributors.map(c => ({
|
||||
user: JSON.stringify({ real_name: c.name, phone: c.phone }),
|
||||
role: c.role,
|
||||
isMain: c.isMain,
|
||||
ranking: c.ranking
|
||||
}));
|
||||
|
||||
Object.keys(formData).forEach(key => {
|
||||
if (key.includes('date') && formData[key] && key !== 'achievement_date') {
|
||||
formData[key] = dayjs(formData[key]);
|
||||
}
|
||||
});
|
||||
|
||||
form.setFieldsValue(formData);
|
||||
} catch (error) {
|
||||
message.error('获取详情失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id, form]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && id) {
|
||||
fetchDetails();
|
||||
fetchDicts();
|
||||
}
|
||||
}, [open, id, fetchDetails, fetchDicts]);
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
// 前端去重校验
|
||||
const contributors = values.contributors || [];
|
||||
const phoneSet = new Set();
|
||||
for (const c of contributors) {
|
||||
if (c.user) {
|
||||
const u = JSON.parse(c.user);
|
||||
if (phoneSet.has(u.phone)) {
|
||||
message.error(`完成人列表中存在重复的员工:${u.real_name} (${maskPhone(u.phone)})。请删除重复项。`);
|
||||
return;
|
||||
}
|
||||
phoneSet.add(u.phone);
|
||||
}
|
||||
}
|
||||
|
||||
// 排名去重校验
|
||||
if (!['project', 'transformation'].includes(data.type)) {
|
||||
const rankingSet = new Set();
|
||||
for (const c of contributors) {
|
||||
if (c.ranking) {
|
||||
if (rankingSet.has(c.ranking)) {
|
||||
message.error(`完成人排名不能重复:第 ${c.ranking} 名出现了多次。`);
|
||||
return;
|
||||
}
|
||||
rankingSet.add(c.ranking);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
const finalContributors = (values.contributors || []).map(c => {
|
||||
const u = c.user ? JSON.parse(c.user) : {};
|
||||
return {
|
||||
name: u.real_name,
|
||||
phone: u.phone,
|
||||
role: c.role,
|
||||
isMain: c.isMain,
|
||||
ranking: c.ranking
|
||||
};
|
||||
}).filter(c => c.name);
|
||||
|
||||
const { name, achievement_date, remarks, contributors: _ignoredContributors, ...details } = values;
|
||||
|
||||
await axios.put(`${API_BASE_URL}/api/achievements/${id}`, {
|
||||
name,
|
||||
contributors: finalContributors,
|
||||
achievement_date: achievement_date ? achievement_date.format('YYYY-MM-DD') : null,
|
||||
remarks,
|
||||
details
|
||||
}, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
message.success('修改成功');
|
||||
onSave();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
if (error.response) {
|
||||
message.error(error.response.data.message || '修改失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderFields = () => {
|
||||
if (!data) return null;
|
||||
const type = data.type;
|
||||
const hasRanking = !['project', 'transformation'].includes(type);
|
||||
const useCheckbox = ['project', 'transformation'].includes(type);
|
||||
const isPaper = type === 'paper';
|
||||
|
||||
const commonFields = (
|
||||
<>
|
||||
{nameTips[type] && (
|
||||
<Alert
|
||||
message={nameTips[type]}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16, width: 'fit-content' }}
|
||||
/>
|
||||
)}
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={nameLabels[type] || "成果名称"}
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Divider orientation="center" style={{ fontWeight: 'bold', fontSize: '16px', margin: '24px 0', color: '#1890ff' }}>
|
||||
{contributorSectionTitles[type] || contributorSectionTitles.default}
|
||||
</Divider>
|
||||
<Form.List name="contributors">
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.length > 0 && (
|
||||
<Space style={{ display: 'flex', marginBottom: 4 }} align="baseline">
|
||||
<span style={{ display: 'inline-block', width: 250 }}>
|
||||
<span style={{ color: '#ff4d4f', marginRight: 4 }}>*</span>姓名
|
||||
</span>
|
||||
|
||||
{hasRanking && (
|
||||
<span style={{ display: 'inline-block', width: 80 }}>
|
||||
<span style={{ color: '#ff4d4f', marginRight: 4 }}>*</span>排名
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isPaper && (
|
||||
<span style={{ display: 'inline-block', width: 150 }}>
|
||||
<span style={{ color: '#ff4d4f', marginRight: 4 }}>*</span>角色
|
||||
</span>
|
||||
)}
|
||||
|
||||
{useCheckbox && (
|
||||
<span>
|
||||
{contributorMainLabels[type] || contributorMainLabels.default}
|
||||
</span>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
{fields.map(({ key, name, ...restField }) => (
|
||||
<Space key={key} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, 'user']}
|
||||
rules={[{ required: true, message: '请搜索并选择员工' }]}
|
||||
>
|
||||
<UserSearchSelect placeholder="搜索姓名/手机号" style={{ width: 250 }} />
|
||||
</Form.Item>
|
||||
|
||||
{hasRanking && (
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, 'ranking']}
|
||||
rules={[{ required: true, message: '请输入排名' }]}
|
||||
>
|
||||
<InputNumber min={1} placeholder="排名" style={{ width: 80 }} />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{isPaper && (
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, 'role']}
|
||||
rules={[{ required: true, message: '请选择角色' }]}
|
||||
>
|
||||
<Select placeholder="角色" style={{ width: 150 }}>
|
||||
{roleOptions.map(r => <Option key={r} value={r}>{r}</Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{useCheckbox && (
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, 'isMain']}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Checkbox>
|
||||
{contributorMainLabels[type] || contributorMainLabels.default}
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{fields.length > 1 && (
|
||||
<MinusCircleOutlined onClick={() => remove(name)} />
|
||||
)}
|
||||
</Space>
|
||||
))}
|
||||
<Form.Item>
|
||||
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
|
||||
{contributorAddButtonLabels[type] || contributorAddButtonLabels.default}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
<Divider />
|
||||
|
||||
<Form.Item
|
||||
name="achievement_date"
|
||||
label={dateLabels[type] || "成果时间"}
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="remarks" label="备注"><Input.TextArea rows={3} /></Form.Item>
|
||||
</>
|
||||
);
|
||||
|
||||
switch (type) {
|
||||
case 'paper':
|
||||
return (
|
||||
<>
|
||||
{commonFields}
|
||||
<Form.Item name="first_unit" label="第一完成单位" rules={[{ required: true }]}>
|
||||
<Select placeholder="请选择第一完成单位" showSearch>
|
||||
{dicts['organizations'].filter(d => d.name !== '待定').map(d => <Select.Option key={d.id} value={d.name}>{d.name}</Select.Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="paper_type" label="论文水平" rules={[{ required: true }]}>
|
||||
<Select placeholder="请选择论文水平">
|
||||
{dicts['paper-types'].filter(d => d.name !== '待定').map(d => <Select.Option key={d.id} value={d.name}>{d.name}</Select.Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="journal_name" label="期刊名称"><Input /></Form.Item>
|
||||
<Form.Item name="doi" label="DOI"><Input /></Form.Item>
|
||||
</>
|
||||
);
|
||||
case 'project':
|
||||
return (
|
||||
<>
|
||||
{commonFields}
|
||||
<Form.Item name="project_category" label="项目分类">
|
||||
<Select placeholder="请选择项目分类">
|
||||
{dicts['project-categories'].filter(d => d.name !== '待定').map(d => <Select.Option key={d.id} value={d.name}>{d.name}</Select.Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
{sourceTips[type] && (
|
||||
<Alert
|
||||
message={sourceTips[type]}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16, width: 'fit-content' }}
|
||||
/>
|
||||
)}
|
||||
<Form.Item name="source" label="项目来源"><Input /></Form.Item>
|
||||
</>
|
||||
);
|
||||
case 'award':
|
||||
return (
|
||||
<>
|
||||
{commonFields}
|
||||
<Form.Item name="award_type" label="奖励种类">
|
||||
<Select placeholder="请选择奖励种类">
|
||||
{dicts['award-types'].filter(d => d.name !== '待定').map(d => <Select.Option key={d.id} value={d.name}>{d.name}</Select.Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="award_level" label="奖励等级">
|
||||
<Select placeholder="请选择奖励等级">
|
||||
{dicts['award-levels'].filter(d => d.name !== '待定').map(d => <Select.Option key={d.id} value={d.name}>{d.name}</Select.Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="award_unit" label="授奖单位">
|
||||
<Input placeholder="请输入授奖单位" />
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
case 'standard':
|
||||
return (
|
||||
<>
|
||||
{commonFields}
|
||||
<Form.Item name="standard_type" label="标准种类">
|
||||
<Select placeholder="请选择标准种类">
|
||||
{dicts['standard-types'].filter(d => d.name !== '待定').map(d => <Select.Option key={d.id} value={d.name}>{d.name}</Select.Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="standard_no" label="标准编号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="implement_date" label="实施日期"><DatePicker style={{ width: '100%' }} /></Form.Item>
|
||||
</>
|
||||
);
|
||||
case 'monograph':
|
||||
return (
|
||||
<>
|
||||
{commonFields}
|
||||
<Form.Item name="publisher" label="出版社"><Input /></Form.Item>
|
||||
<Form.Item name="isbn" label="ISBN"><Input /></Form.Item>
|
||||
</>
|
||||
);
|
||||
case 'report':
|
||||
return (
|
||||
<>
|
||||
{commonFields}
|
||||
<Form.Item name="report_type" label="技术报告类型" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="recipient" label="发布(提报)对象" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
</>
|
||||
);
|
||||
case 'plan':
|
||||
return (
|
||||
<>
|
||||
{commonFields}
|
||||
</>
|
||||
);
|
||||
case 'patent':
|
||||
return (
|
||||
<>
|
||||
{commonFields}
|
||||
<Form.Item name="patent_no" label="专利号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="assignee" label="权利人" rules={[{ required: true }]}>
|
||||
<Select placeholder="请选择权利人" showSearch>
|
||||
{dicts['organizations'].filter(d => d.name !== '待定').map(d => <Select.Option key={d.id} value={d.name}>{d.name}</Select.Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="patent_type" label="专利类型" rules={[{ required: true }]}>
|
||||
<Select placeholder="请选择专利类型">
|
||||
<Select.Option value="发明专利">发明专利</Select.Option>
|
||||
<Select.Option value="实用新型专利">实用新型专利</Select.Option>
|
||||
<Select.Option value="外观设计专利">外观设计专利</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
case 'transformation':
|
||||
return (
|
||||
<>
|
||||
{commonFields}
|
||||
<Form.Item name="project_category" label="项目分类">
|
||||
<Select placeholder="请选择项目分类">
|
||||
{dicts['project-categories'].filter(d => d.name !== '待定').map(d => <Select.Option key={d.id} value={d.name}>{d.name}</Select.Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="trans_method" label="转化方式"><Input /></Form.Item>
|
||||
<Form.Item name="trans_amount" label="转化金额 (元)"><InputNumber style={{ width: '100%' }} /></Form.Item>
|
||||
</>
|
||||
);
|
||||
case 'software':
|
||||
return (
|
||||
<>
|
||||
{commonFields}
|
||||
<Form.Item name="reg_no" label="登记号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="owner_unit" label="著作权人" rules={[{ required: true }]}>
|
||||
<Select placeholder="请选择著作权人" showSearch>
|
||||
{dicts['organizations'].filter(d => d.name !== '待定').map(d => <Select.Option key={d.id} value={d.name}>{d.name}</Select.Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="acquisition_method" label="权利取得方式" rules={[{ required: true }]}>
|
||||
<Select placeholder="请选择权利取得方式">
|
||||
<Select.Option value="原始取得">原始取得</Select.Option>
|
||||
<Select.Option value="继受取得">继受取得</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="scope" label="权利范围" rules={[{ required: true }]}>
|
||||
<Select placeholder="请选择权利范围">
|
||||
<Select.Option value="全部权利">全部权利</Select.Option>
|
||||
<Select.Option value="部分权利">部分权利</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="修改成果信息"
|
||||
open={open}
|
||||
onOk={handleOk}
|
||||
onCancel={onClose}
|
||||
width={700}
|
||||
destroyOnClose
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Form form={form} layout="vertical">
|
||||
{renderFields()}
|
||||
</Form>
|
||||
</Spin>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AchievementEdit;
|
||||
@@ -0,0 +1,284 @@
|
||||
import React from 'react';
|
||||
import { Descriptions, Tag, Space, Divider, Table, Button, message } from 'antd';
|
||||
import { DownloadOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import API_BASE_URL from '../config';
|
||||
import {
|
||||
achievementTypes,
|
||||
fieldLabels,
|
||||
nameLabels,
|
||||
dateLabels
|
||||
} from '../constants/achievements';
|
||||
|
||||
const AchievementFormRenderer = ({ data }) => {
|
||||
if (!data) return null;
|
||||
|
||||
const SPLIT_CONTRIBUTOR_THRESHOLD = 8;
|
||||
const contributors = Array.isArray(data.contributors) ? data.contributors : [];
|
||||
const shouldSplitContributors = contributors.length > SPLIT_CONTRIBUTOR_THRESHOLD;
|
||||
const splitIndex = Math.ceil(contributors.length / 2);
|
||||
const leftContributors = contributors.slice(0, splitIndex);
|
||||
const rightContributors = contributors.slice(splitIndex);
|
||||
|
||||
const getContributorColumns = (isSplit = false) => [
|
||||
{
|
||||
title: '排名',
|
||||
dataIndex: 'ranking',
|
||||
key: 'ranking',
|
||||
width: isSplit ? 56 : 72,
|
||||
render: (ranking) => ranking || '-'
|
||||
},
|
||||
{ title: '姓名', dataIndex: 'name', key: 'name', ...(isSplit ? {} : { width: 180 }), render: (name) => <div className="export-form-wrap-text">{name || '-'}</div> },
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
key: 'role',
|
||||
...(isSplit ? {} : { width: 260 }),
|
||||
render: (role, record) => (
|
||||
<Space wrap size={[4, 4]}>
|
||||
{role || (record.isMain ? '主要完成人' : '完成人')}
|
||||
{record.isMain && <Tag color="blue">主要</Tag>}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const getTypeName = (typeKey) => {
|
||||
const type = achievementTypes.find((t) => t.key === typeKey);
|
||||
return type ? type.label : typeKey;
|
||||
};
|
||||
|
||||
const formatTwoLineLabel = (label) => {
|
||||
const text = String(label || '').trim();
|
||||
if (!text) return '';
|
||||
|
||||
if (text.length <= 2) {
|
||||
return (
|
||||
<span className="export-form-label-two-line">
|
||||
<span>{text}</span>
|
||||
<span> </span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const breakIndex = Math.ceil(text.length / 2);
|
||||
return (
|
||||
<span className="export-form-label-two-line">
|
||||
<span>{text.slice(0, breakIndex)}</span>
|
||||
<span>{text.slice(breakIndex)}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const handleDownloadAttachment = async (file) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await axios.get(`${API_BASE_URL}/api/achievements/attachments/${file.id}/download`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
let filename = file.file_name || 'attachment';
|
||||
const contentDisposition = response.headers['content-disposition'];
|
||||
if (contentDisposition) {
|
||||
const utf8Name = contentDisposition.match(/filename\\*=UTF-8''(.+)/);
|
||||
const normalName = contentDisposition.match(/filename="(.+)"/);
|
||||
if (utf8Name && utf8Name[1]) {
|
||||
filename = decodeURIComponent(utf8Name[1]);
|
||||
} else if (normalName && normalName[1]) {
|
||||
filename = normalName[1];
|
||||
}
|
||||
}
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
message.error('下载失败,请稍后重试');
|
||||
}
|
||||
};
|
||||
|
||||
const renderContributorTable = (source, keyPrefix, isSplit = false) => (
|
||||
<Table
|
||||
dataSource={source}
|
||||
columns={getContributorColumns(isSplit)}
|
||||
pagination={false}
|
||||
size="small"
|
||||
bordered
|
||||
tableLayout="fixed"
|
||||
className={`export-form-table ${isSplit ? 'export-form-table-split' : 'export-form-table-single'}`}
|
||||
style={{ width: '100%' }}
|
||||
rowKey={(record, index) => `${keyPrefix}-${record?.id || record?.phone || record?.name || 'row'}-${index}`}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div id="achievement-detail-content" className="export-form-root">
|
||||
<Descriptions title="基础信息" bordered column={2} className="export-form-descriptions">
|
||||
<Descriptions.Item label={formatTwoLineLabel(nameLabels[data.type] || '成果名称')} span={2}>
|
||||
<div className="export-form-wrap-text">{data.name}</div>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={formatTwoLineLabel('归属部门')} span={2}>
|
||||
<Space size={[0, 4]} wrap>
|
||||
{(data.assigned_departments || []).map((dept) => (
|
||||
<Tag color="blue" key={dept}>{dept}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={formatTwoLineLabel(dateLabels[data.type] || '成果时间')}>
|
||||
{data.achievement_date ? new Date(data.achievement_date).toLocaleDateString() : '无'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={formatTwoLineLabel('类型')}>{getTypeName(data.type)}</Descriptions.Item>
|
||||
<Descriptions.Item label={formatTwoLineLabel('状态')}>
|
||||
<Tag color={data.status === 'approved' ? 'green' : data.status === 'rejected' ? 'red' : 'gold'}>
|
||||
{data.status === 'approved' ? '已通过' : data.status === 'rejected' ? '已驳回' : '待审核'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={formatTwoLineLabel('提交时间')}>{new Date(data.created_at).toLocaleString()}</Descriptions.Item>
|
||||
<Descriptions.Item label={formatTwoLineLabel('审核意见')}>{data.audit_comment || '无'}</Descriptions.Item>
|
||||
<Descriptions.Item label={formatTwoLineLabel('备注')} span={2}>{data.remarks || '无'}</Descriptions.Item>
|
||||
<Descriptions.Item label={formatTwoLineLabel('附件列表')} span={2}>
|
||||
{data.attachments && data.attachments.length > 0 ? (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{data.attachments.map((file) => (
|
||||
<Button
|
||||
key={file.id}
|
||||
type="link"
|
||||
icon={<DownloadOutlined />}
|
||||
className="export-form-attachment-btn"
|
||||
style={{ width: '100%' }}
|
||||
onClick={() => handleDownloadAttachment(file)}
|
||||
>
|
||||
{file.file_name}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
) : '无'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Divider orientation="center" style={{ fontWeight: 'bold', fontSize: '16px', margin: '24px 0', color: '#1890ff' }}>完成人列表</Divider>
|
||||
|
||||
{shouldSplitContributors ? (
|
||||
<div className="export-form-contributor-grid">
|
||||
<div className="export-form-contributor-col">
|
||||
{renderContributorTable(leftContributors, 'left', true)}
|
||||
</div>
|
||||
<div className="export-form-contributor-col">
|
||||
{renderContributorTable(rightContributors, 'right', true)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
renderContributorTable(contributors, 'single', false)
|
||||
)}
|
||||
|
||||
<Divider orientation="center" style={{ fontWeight: 'bold', fontSize: '16px', margin: '24px 0', color: '#1890ff' }}>详细表单内容</Divider>
|
||||
|
||||
<Descriptions bordered column={2} className="export-form-descriptions">
|
||||
{Object.entries(data.details || {}).map(([key, value]) => {
|
||||
if (key === 'id' || key === 'achievement_id' || key === 'approver_id') return null;
|
||||
const label = fieldLabels[data.type]?.[key] || key;
|
||||
return (
|
||||
<Descriptions.Item key={key} label={label}>
|
||||
<div className="export-form-wrap-text">{value?.toString() || '无'}</div>
|
||||
</Descriptions.Item>
|
||||
);
|
||||
})}
|
||||
</Descriptions>
|
||||
|
||||
<style>{`
|
||||
#achievement-detail-content.export-form-root {
|
||||
width: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
#achievement-detail-content .export-form-wrap-text {
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.5;
|
||||
}
|
||||
#achievement-detail-content .export-form-label-two-line {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 2.2em;
|
||||
line-height: 1.1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#achievement-detail-content .export-form-label-two-line > span {
|
||||
display: block;
|
||||
}
|
||||
#achievement-detail-content .export-form-descriptions.ant-descriptions-bordered .ant-descriptions-view {
|
||||
border: 2px solid #595959;
|
||||
}
|
||||
#achievement-detail-content .export-form-descriptions.ant-descriptions-bordered .ant-descriptions-row > th,
|
||||
#achievement-detail-content .export-form-descriptions.ant-descriptions-bordered .ant-descriptions-row > td {
|
||||
border-inline-end: 1.5px solid #595959;
|
||||
border-bottom: 1.5px solid #595959;
|
||||
vertical-align: top;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
#achievement-detail-content .export-form-table .ant-table-container {
|
||||
border: 2px solid #595959;
|
||||
}
|
||||
#achievement-detail-content .export-form-table .ant-table-container table {
|
||||
width: 100% !important;
|
||||
}
|
||||
#achievement-detail-content .export-form-table .ant-table-thead > tr > th,
|
||||
#achievement-detail-content .export-form-table .ant-table-tbody > tr > td {
|
||||
border-inline-end: 1.5px solid #595959;
|
||||
border-bottom: 1.5px solid #595959;
|
||||
vertical-align: top;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
#achievement-detail-content .export-form-table .ant-tag {
|
||||
white-space: normal;
|
||||
}
|
||||
#achievement-detail-content .export-form-table-split .ant-space {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 4px;
|
||||
}
|
||||
#achievement-detail-content .export-form-contributor-grid {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
#achievement-detail-content .export-form-contributor-col {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
@media (max-width: 680px) {
|
||||
#achievement-detail-content .export-form-contributor-grid {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#achievement-detail-content .export-form-contributor-col {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
#achievement-detail-content .export-form-attachment-btn.ant-btn-link {
|
||||
height: auto;
|
||||
text-align: left;
|
||||
padding: 0;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.5;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AchievementFormRenderer;
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Layout, Menu, Button, theme, Typography } from 'antd';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
UploadOutlined,
|
||||
AuditOutlined,
|
||||
BarChartOutlined,
|
||||
LogoutOutlined,
|
||||
FileTextOutlined,
|
||||
TeamOutlined,
|
||||
SettingOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
HistoryOutlined,
|
||||
NotificationOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { maskPhone } from '../utils/phoneMask';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
const { Title } = Typography;
|
||||
|
||||
const MainLayout = () => {
|
||||
const [collapsed] = useState(false);
|
||||
const [user, setUser] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const {
|
||||
token: { colorBgContainer, borderRadiusLG },
|
||||
} = theme.useToken();
|
||||
|
||||
useEffect(() => {
|
||||
const userData = localStorage.getItem('user');
|
||||
if (!userData) {
|
||||
navigate('/login');
|
||||
} else {
|
||||
setUser(JSON.parse(userData));
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
key: '/dashboard',
|
||||
icon: <DashboardOutlined />,
|
||||
label: '首页概览',
|
||||
},
|
||||
{
|
||||
key: '/notifications',
|
||||
icon: <NotificationOutlined />,
|
||||
label: '系统公告',
|
||||
},
|
||||
{
|
||||
key: '/upload',
|
||||
icon: <UploadOutlined />,
|
||||
label: '成果上传',
|
||||
},
|
||||
...((user?.role === 'admin' || user?.role === 'super_admin') ? [
|
||||
{
|
||||
key: '/audit',
|
||||
icon: <AuditOutlined />,
|
||||
label: '成果审核',
|
||||
}
|
||||
] : []),
|
||||
...((user?.role === 'super_admin' || user?.role === 'maintainer') ? [
|
||||
{
|
||||
key: '/users',
|
||||
icon: <TeamOutlined />,
|
||||
label: '用户管理',
|
||||
},
|
||||
{
|
||||
key: '/dictionaries',
|
||||
icon: <SettingOutlined />,
|
||||
label: '系统字典管理',
|
||||
},
|
||||
{
|
||||
key: '/validation',
|
||||
icon: <SafetyCertificateOutlined />,
|
||||
label: '数据校验',
|
||||
}
|
||||
] : []),
|
||||
...((user?.role === 'super_admin' || user?.role === 'maintainer') ? [
|
||||
{
|
||||
key: '/logs',
|
||||
icon: <HistoryOutlined />,
|
||||
label: '系统日志',
|
||||
}
|
||||
] : []),
|
||||
...((user?.role !== 'maintainer') ? [
|
||||
{
|
||||
key: '/statistics',
|
||||
icon: <BarChartOutlined />,
|
||||
label: '统计分析',
|
||||
},
|
||||
] : []),
|
||||
];
|
||||
|
||||
// 过滤掉维护员不可见的菜单项
|
||||
if (user?.role === 'maintainer') {
|
||||
const hiddenKeys = ['/upload', '/dashboard', '/validation'];
|
||||
for (let i = menuItems.length - 1; i >= 0; i--) {
|
||||
if (hiddenKeys.includes(menuItems[i].key)) {
|
||||
menuItems.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||
<div className="marquee-container">
|
||||
<div className="marquee-content">
|
||||
温馨提示:本系统为内部使用,严禁上传涉密信息!上传内容将接受保密审查,违规行为将严肃处理。
|
||||
</div>
|
||||
</div>
|
||||
<Layout style={{ flex: 1 }}>
|
||||
<Sider
|
||||
trigger={null}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
theme="dark"
|
||||
width={256}
|
||||
>
|
||||
<div style={{
|
||||
height: 80,
|
||||
margin: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
padding: collapsed ? 0 : '0 16px',
|
||||
background: 'rgba(47, 0, 255, 0.05)',
|
||||
borderRadius: '12px',
|
||||
transition: 'all 0.2s',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
background: '#1677ff',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: collapsed ? 0 : 12,
|
||||
flexShrink: 0,
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="Logo"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
e.target.nextSibling.style.display = 'block';
|
||||
}}
|
||||
/>
|
||||
<FileTextOutlined style={{ color: 'white', fontSize: 22, display: 'none' }} />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div style={{ overflow: 'hidden' }}>
|
||||
<Title level={5} style={{ color: 'white', margin: 0, fontSize: '16px', lineHeight: '1.2' }}>
|
||||
科技成果
|
||||
</Title>
|
||||
<div style={{ color: 'rgba(255,255,255,0.45)', fontSize: '12px' }}>管理系统</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
items={menuItems}
|
||||
onClick={({ key }) => navigate(key)}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header style={{ padding: '0 16px', background: colorBgContainer, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Title level={4} style={{ margin: 0 }}>欢迎, {user?.real_name || maskPhone(user?.username)}</Title>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LogoutOutlined />}
|
||||
onClick={handleLogout}
|
||||
>
|
||||
退出登录
|
||||
</Button>
|
||||
</Header>
|
||||
<Content
|
||||
style={{
|
||||
margin: '24px 16px',
|
||||
padding: 24,
|
||||
minHeight: 280,
|
||||
background: colorBgContainer,
|
||||
borderRadius: borderRadiusLG,
|
||||
overflow: 'initial'
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</Content>
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '16px 0',
|
||||
background: colorBgContainer,
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
marginTop: 'auto'
|
||||
}}>
|
||||
<div>
|
||||
科技成果管理系统 V1.0 © 2026 黑龙江省自然资源权益调查监测院(黑龙江省自然资源卫星应用技术中心)保留所有权利
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainLayout;
|
||||
@@ -0,0 +1,70 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Select, Spin, message } from 'antd';
|
||||
import axios from 'axios';
|
||||
import debounce from 'lodash/debounce';
|
||||
import API_BASE_URL from '../config';
|
||||
import { maskPhone } from '../utils/phoneMask';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
const UserSearchSelect = ({ value, onChange, placeholder, style, leaderOnly = false }) => {
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [options, setOptions] = useState([]);
|
||||
|
||||
const fetchUser = useMemo(
|
||||
() => debounce(async (search) => {
|
||||
if (!search || search.trim() === '') return;
|
||||
setFetching(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
message.error('登录已过期,请重新登录');
|
||||
return;
|
||||
}
|
||||
const params = { keyword: search };
|
||||
if (leaderOnly) {
|
||||
params.leaderOnly = 'true';
|
||||
}
|
||||
const response = await axios.get(`${API_BASE_URL}/api/users/search`, {
|
||||
params,
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setOptions(response.data.map(user => ({
|
||||
label: `${user.real_name} (${maskPhone(user.phone)}) - ${user.department}`,
|
||||
value: JSON.stringify(user),
|
||||
key: user.phone
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error('搜索用户失败', error);
|
||||
if (error.code === 'ERR_NETWORK') {
|
||||
message.error('无法连接到后端服务器,请检查后端是否启动(端口5000)');
|
||||
} else {
|
||||
// 仅在非网络错误时显示具体错误信息,避免干扰用户
|
||||
// message.error('搜索失败: ' + (error.response?.data?.message || error.message));
|
||||
}
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}, 500),
|
||||
[leaderOnly]
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onSearch={fetchUser}
|
||||
onChange={onChange}
|
||||
filterOption={false}
|
||||
notFoundContent={fetching ? <Spin size="small" /> : null}
|
||||
style={style}
|
||||
>
|
||||
{options.map(opt => (
|
||||
<Option key={opt.key} value={opt.value}>{opt.label}</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserSearchSelect;
|
||||
@@ -0,0 +1,5 @@
|
||||
const API_BASE_URL = process.env.NODE_ENV === 'production'
|
||||
? window.location.origin
|
||||
: (process.env.REACT_APP_API_URL || 'http://localhost:5000');
|
||||
|
||||
export default API_BASE_URL;
|
||||
@@ -0,0 +1,125 @@
|
||||
export const achievementTypes = [
|
||||
{ label: '项目', key: 'project' },
|
||||
{ label: '论文', key: 'paper' },
|
||||
{ label: '专著', key: 'monograph' },
|
||||
{ label: '软著', key: 'software' },
|
||||
{ label: '专利', key: 'patent' },
|
||||
{ label: '标准', key: 'standard' },
|
||||
{ label: '技术报告', key: 'report' },
|
||||
{ label: '规划', key: 'plan' },
|
||||
{ label: '奖励', key: 'award' },
|
||||
{ label: '成果转化', key: 'transformation' },
|
||||
];
|
||||
|
||||
export const uploadTips = {
|
||||
paper: '应上传论文PDF及索引页(若已被核心数据库收录,请一并上传收录证明)。',
|
||||
project: '应上传立项证明、实施方案、验收总结等证明材料。',
|
||||
award: '应上传获奖证书PDF或PNG、JPG格式文件。',
|
||||
standard: '应上传标准及标准发布、标准有效性相关材料。',
|
||||
monograph: '应至少上传封面、封底及人名页。',
|
||||
report: '应上传技术报告全文及相关材料,附件中需包含院领导签批页。',
|
||||
plan: '应上传规划全文及相关材料。',
|
||||
patent: '应上传专利证书及相关材料。',
|
||||
transformation: '应上传转化合同或证明及相关材料。',
|
||||
software: '应上传软著证书、代码压缩包、运行说明及相关材料。',
|
||||
};
|
||||
|
||||
export const contributorTips = {
|
||||
paper: '应按顺序添加本单位作者,注意标明通讯作者、第一作者。',
|
||||
project: '应添加项目完成人中本单位人员,以项目报告内项目成员名单为准。',
|
||||
award: '应添加获奖证书上本单位人员。',
|
||||
standard: '应添加标准上本单位人员。',
|
||||
monograph: '应添加专著作者中本单位人员。',
|
||||
report: '应添加技术报告作者中本单位人员。',
|
||||
plan: '应添加规划作者中本单位人员。',
|
||||
patent: '应添加专利发明人中本单位人员。',
|
||||
transformation: '应添加项目完成人中本单位人员,以项目报告内项目成员名单为准。',
|
||||
software: '应添加软件编写人员中本单位人员。',
|
||||
};
|
||||
|
||||
export const dateTips = {
|
||||
paper: '请选择论文正式发表的日期(以期刊出版日期为准)。',
|
||||
project: '请选择项目通过验收的日期。',
|
||||
award: '请选择获奖证书上的授奖日期。',
|
||||
standard: '请选择标准正式发布的日期。',
|
||||
monograph: '请选择专著出版日期。',
|
||||
report: '请选择报告提交或发布的日期。',
|
||||
plan: '请选择规划正式发布的日期。',
|
||||
patent: '请选择专利授权公告日。',
|
||||
transformation: '请选择转化合同签订或完成日期。',
|
||||
software: '请选择软著证书上的登记日期。',
|
||||
};
|
||||
|
||||
export const nameLabels = {
|
||||
paper: '论文题目', project: '项目名称', award: '获奖项目名称', standard: '标准名称',
|
||||
monograph: '专著名称', report: '报告名称', plan: '规划名称', patent: '专利名称',
|
||||
transformation: '项目名称', software: '软件名称'
|
||||
};
|
||||
|
||||
export const contributorLabels = {
|
||||
paper: '论文作者', project: '项目完成人', award: '获奖人员', standard: '标准起草人',
|
||||
monograph: '专著作者', report: '报告编写人', plan: '规划编写人', patent: '发明人',
|
||||
transformation: '项目完成人', software: '软件开发人员'
|
||||
};
|
||||
|
||||
export const dateLabels = {
|
||||
paper: '发表日期', project: '验收时间', award: '获奖日期', standard: '发布日期',
|
||||
monograph: '出版时间', report: '报告提交时间', plan: '发布日期', patent: '授权时间',
|
||||
transformation: '验收时间', software: '授权证书时间'
|
||||
};
|
||||
|
||||
export const roleOptions = ['第一作者', '通讯作者', '其他作者'];
|
||||
|
||||
export const fieldLabels = {
|
||||
paper: {
|
||||
paper_type: '论文水平',
|
||||
first_unit: '第一完成单位',
|
||||
journal_name: '期刊名称',
|
||||
publish_date: '发表日期',
|
||||
doi: 'DOI'
|
||||
},
|
||||
project: { project_category: '项目分类', source: '项目来源' },
|
||||
award: { award_type: '奖励种类', award_level: '奖励等级', award_unit: '授奖单位' },
|
||||
standard: { standard_type: '标准种类', standard_no: '标准编号', implement_date: '实施日期' },
|
||||
monograph: { publisher: '出版社', isbn: 'ISBN' },
|
||||
report: { report_type: '技术报告类型', recipient: '发布(提报)对象', approver_name: '签批院领导' },
|
||||
plan: {},
|
||||
patent: { patent_no: '专利号', assignee: '权利人', patent_type: '专利类型' },
|
||||
transformation: { project_category: '项目分类', trans_method: '转化方式', trans_amount: '转化金额' },
|
||||
software: { reg_no: '登记号', owner_unit: '著作权人', acquisition_method: '权利取得方式', scope: '权利范围' }
|
||||
};
|
||||
|
||||
export const contributorAddButtonLabels = {
|
||||
award: '添加获奖人员',
|
||||
monograph: '添加编写作者',
|
||||
report: '添加编写人',
|
||||
patent: '添加发明人',
|
||||
software: '添加著作权人',
|
||||
default: '添加完成人'
|
||||
};
|
||||
|
||||
export const contributorMainLabels = {
|
||||
monograph: '主要编写作者',
|
||||
report: '主要编写人',
|
||||
patent: '主要发明人',
|
||||
software: '主要著作权人',
|
||||
default: '主要完成人'
|
||||
};
|
||||
|
||||
export const contributorSectionTitles = {
|
||||
award: '获奖人员信息',
|
||||
monograph: '编写作者信息',
|
||||
report: '报告编写人信息',
|
||||
patent: '发明人信息',
|
||||
software: '著作权人信息',
|
||||
transformation: '完成人信息',
|
||||
default: '完成人信息'
|
||||
};
|
||||
|
||||
export const nameTips = {
|
||||
transformation: '指已完成成果转化的项目'
|
||||
};
|
||||
|
||||
export const sourceTips = {
|
||||
project: '按照列入本单位经费确定项目'
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* 跑马灯动画 */
|
||||
.marquee-container {
|
||||
background-color: #1677ff; /* 蓝色 */
|
||||
color: #ffffff; /* 白色 */
|
||||
padding: 10px 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
letter-spacing: 2px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||||
z-index: 1000;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.marquee-content {
|
||||
display: inline-block;
|
||||
padding-left: 100%;
|
||||
animation: marquee 20s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
0% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
100% {
|
||||
transform: translate(-100%, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
import axios from 'axios';
|
||||
|
||||
// 添加全局拦截器处理 401 Token 过期
|
||||
axios.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
// 清除本地存储
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
// 如果当前不在登录页,则跳转
|
||||
if (!window.location.pathname.includes('/login')) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<App />
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,248 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Tag, Space, Button, Modal, Form, Input, message, Card, Radio, DatePicker, Select } from 'antd';
|
||||
import { EyeOutlined, CheckCircleOutlined, SearchOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import API_BASE_URL from '../config';
|
||||
import AchievementDetails from '../components/AchievementDetails';
|
||||
import { maskPhone } from '../utils/phoneMask';
|
||||
|
||||
const Audit = () => {
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [currentRecord, setCurrentRecord] = useState(null);
|
||||
const [detailsVisible, setDetailsVisible] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [dateRange, setDateRange] = useState(null);
|
||||
const [filterType, setFilterType] = useState(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filterType]);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const params = {
|
||||
status: 'pending',
|
||||
type: filterType,
|
||||
keyword: keyword,
|
||||
startDate: dateRange && dateRange[0] ? dateRange[0].format('YYYY-MM-DD') : null,
|
||||
endDate: dateRange && dateRange[1] ? dateRange[1].format('YYYY-MM-DD') : null
|
||||
};
|
||||
const response = await axios.get(`${API_BASE_URL}/api/achievements`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
params
|
||||
});
|
||||
setData(response.data);
|
||||
} catch (error) {
|
||||
message.error('获取待审核列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAudit = (record) => {
|
||||
setCurrentRecord(record);
|
||||
setIsModalVisible(true);
|
||||
};
|
||||
|
||||
const submitAudit = async (values) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.post(`${API_BASE_URL}/api/achievements/audit/${currentRecord.id}`, values, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
message.success('审核提交成功');
|
||||
setIsModalVisible(false);
|
||||
form.resetFields();
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
const errorMsg = error.response?.data?.message || '审核提交失败';
|
||||
message.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 80,
|
||||
render: (type) => {
|
||||
const typeMap = {
|
||||
paper: '论文', project: '项目', award: '奖励', standard: '标准',
|
||||
monograph: '专著', report: '技术报告', plan: '规划', patent: '专利',
|
||||
transformation: '成果转化', software: '软著'
|
||||
};
|
||||
return <Tag color="blue">{typeMap[type] || type}</Tag>;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '成果名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
ellipsis: true,
|
||||
render: (text, record) => {
|
||||
const dateLabels = {
|
||||
paper: '发表日期', project: '验收时间', award: '获奖日期', standard: '发布日期',
|
||||
monograph: '出版时间', report: '报告提交时间', plan: '发布日期', patent: '授权时间',
|
||||
transformation: '验收时间', software: '授权证书时间'
|
||||
};
|
||||
const dateLabel = dateLabels[record.type] || '成果时间';
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={0}>
|
||||
<b>{text}</b>
|
||||
<small style={{ color: '#888' }}>
|
||||
{dateLabel}: {record.achievement_date ? new Date(record.achievement_date).toLocaleDateString() : '无'}
|
||||
</small>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '提交人',
|
||||
key: 'submitter',
|
||||
width: 150,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<span><UserOutlined style={{ marginRight: 4 }} />{record.submitter_name || '未知'}</span>
|
||||
<small style={{ color: '#888' }}>{maskPhone(record.username)}</small>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '完成人',
|
||||
dataIndex: 'contributors',
|
||||
key: 'contributors',
|
||||
render: (contributors) => (
|
||||
<Space size={[0, 4]} wrap>
|
||||
{(contributors || []).map((c, idx) => (
|
||||
<Tag key={idx} color={c.isMain ? 'orange' : 'default'} style={{ borderRadius: '10px' }}>
|
||||
{c.name}
|
||||
{c.isMain && <span style={{ marginLeft: 2, fontSize: '10px' }}>★</span>}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '归属部门',
|
||||
dataIndex: 'assigned_departments',
|
||||
key: 'assigned_departments',
|
||||
render: (depts) => (
|
||||
<Space size={[0, 4]} wrap>
|
||||
{(depts || []).map(dept => (
|
||||
<Tag color="cyan" key={dept}>{dept}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 180,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => {
|
||||
setSelectedId(record.id);
|
||||
setDetailsVisible(true);
|
||||
}}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<CheckCircleOutlined />}
|
||||
onClick={() => handleAudit(record)}
|
||||
>
|
||||
审核
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card title="成果审核管理">
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<Input
|
||||
placeholder="搜索名称/完成人/手机号"
|
||||
style={{ width: 200 }}
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
onPressEnter={fetchData}
|
||||
allowClear
|
||||
/>
|
||||
<DatePicker.RangePicker
|
||||
style={{ width: 250 }}
|
||||
onChange={setDateRange}
|
||||
/>
|
||||
<Select
|
||||
placeholder="按类型筛选"
|
||||
style={{ width: 120 }}
|
||||
allowClear
|
||||
onChange={setFilterType}
|
||||
>
|
||||
<Select.Option value="paper">论文</Select.Option>
|
||||
<Select.Option value="project">项目</Select.Option>
|
||||
<Select.Option value="award">奖励</Select.Option>
|
||||
<Select.Option value="standard">标准</Select.Option>
|
||||
<Select.Option value="monograph">专著</Select.Option>
|
||||
<Select.Option value="report">技术报告</Select.Option>
|
||||
<Select.Option value="plan">规划</Select.Option>
|
||||
<Select.Option value="patent">专利</Select.Option>
|
||||
<Select.Option value="transformation">成果转化</Select.Option>
|
||||
<Select.Option value="software">软著</Select.Option>
|
||||
</Select>
|
||||
<Button type="primary" icon={<SearchOutlined />} onClick={fetchData}>查询</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Table columns={columns} dataSource={data} rowKey="id" loading={loading} size="middle" />
|
||||
|
||||
<AchievementDetails
|
||||
id={selectedId}
|
||||
open={detailsVisible}
|
||||
onClose={() => {
|
||||
setDetailsVisible(false);
|
||||
setSelectedId(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="成果审核"
|
||||
open={isModalVisible}
|
||||
onOk={() => form.submit()}
|
||||
onCancel={() => {
|
||||
setIsModalVisible(false);
|
||||
form.resetFields();
|
||||
}}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} onFinish={submitAudit} layout="vertical">
|
||||
<Form.Item name="status" label="审核结果" rules={[{ required: true, message: '请选择审核结果' }]}>
|
||||
<Radio.Group buttonStyle="solid">
|
||||
<Radio.Button value="approved">通过</Radio.Button>
|
||||
<Radio.Button value="rejected">不通过</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item name="audit_comment" label="审核意见">
|
||||
<Input.TextArea rows={4} placeholder="请输入审核意见..." />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default Audit;
|
||||
@@ -0,0 +1,557 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Row, Col, Card, Statistic, Table, Tag, Space, Button, message, Popconfirm, Select, Input, DatePicker, Tabs, Modal, Progress } from 'antd';
|
||||
import { FileTextOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined, EyeOutlined, DeleteOutlined, EditOutlined, SearchOutlined, UserOutlined, GlobalOutlined, UserSwitchOutlined, PictureOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import JSZip from 'jszip';
|
||||
import { saveAs } from 'file-saver';
|
||||
import html2canvas from 'html2canvas';
|
||||
import API_BASE_URL from '../config';
|
||||
import AchievementDetails from '../components/AchievementDetails';
|
||||
import AchievementEdit from '../components/AchievementEdit';
|
||||
import AchievementFormRenderer from '../components/AchievementFormRenderer';
|
||||
import { maskPhone } from '../utils/phoneMask';
|
||||
|
||||
const Dashboard = () => {
|
||||
const [stats, setStats] = useState({ total: 0, pending: 0, approved: 0, rejected: 0 });
|
||||
const [recentData, setRecentData] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const [detailsVisible, setDetailsVisible] = useState(false);
|
||||
const [editVisible, setEditVisible] = useState(false);
|
||||
const [filterType, setFilterType] = useState(null);
|
||||
const [filterStatus, setFilterStatus] = useState(null);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [dateRange, setDateRange] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState('my');
|
||||
const [pendingUsers, setPendingUsers] = useState(new Set());
|
||||
|
||||
// 批量下载相关状态
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0 });
|
||||
const [batchCurrentData, setBatchCurrentData] = useState(null);
|
||||
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
const canDeleteAchievement = (record) => (
|
||||
user.role === 'super_admin' ||
|
||||
(record.user_id === user.id && ['pending', 'rejected'].includes(record.status))
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
fetchPendingUsers();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filterType, filterStatus, activeTab]);
|
||||
|
||||
// 获取待定状态用户列表
|
||||
const fetchPendingUsers = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await axios.get(`${API_BASE_URL}/api/users/pending-list`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setPendingUsers(new Set(response.data));
|
||||
} catch (error) {
|
||||
console.error('获取待定用户列表失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const params = {
|
||||
type: filterType,
|
||||
status: filterStatus,
|
||||
keyword: keyword,
|
||||
startDate: dateRange && dateRange[0] ? dateRange[0].format('YYYY-MM-DD') : null,
|
||||
endDate: dateRange && dateRange[1] ? dateRange[1].format('YYYY-MM-DD') : null
|
||||
};
|
||||
|
||||
// 根据 Tab 决定查询范围
|
||||
if (activeTab === 'my') {
|
||||
params.scope = 'my';
|
||||
} else if (activeTab === 'public') {
|
||||
// 公共库只看已通过的
|
||||
params.status = 'approved';
|
||||
}
|
||||
// activeTab === 'all' 时,不传 scope 也不强制 status,后端默认返回所有(针对管理员)
|
||||
|
||||
const config = {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
params
|
||||
};
|
||||
|
||||
const response = await axios.get(`${API_BASE_URL}/api/achievements`, config);
|
||||
const data = response.data;
|
||||
setRecentData(data); // 显示所有数据,支持分页更好,但这里先全量
|
||||
|
||||
// 更新统计数据:基于当前列表数据计算
|
||||
// my: 显示个人统计
|
||||
// public: 显示公示统计(仅已通过)
|
||||
// all: 显示全系统统计
|
||||
const s = { total: data.length, pending: 0, approved: 0, rejected: 0 };
|
||||
data.forEach(item => {
|
||||
if (item.status === 'pending') s.pending++;
|
||||
else if (item.status === 'approved') s.approved++;
|
||||
else if (item.status === 'rejected') s.rejected++;
|
||||
});
|
||||
setStats(s);
|
||||
} catch (error) {
|
||||
message.error('获取数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 80,
|
||||
render: (type) => {
|
||||
const typeMap = {
|
||||
paper: '论文', project: '项目', award: '奖励', standard: '标准',
|
||||
monograph: '专著', report: '技术报告', plan: '规划', patent: '专利',
|
||||
transformation: '成果转化', software: '软著'
|
||||
};
|
||||
return <Tag color="blue">{typeMap[type] || type}</Tag>;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '成果名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 300,
|
||||
render: (text, record) => {
|
||||
const dateLabels = {
|
||||
paper: '发表日期', project: '验收时间', award: '获奖日期', standard: '发布日期',
|
||||
monograph: '出版时间', report: '报告提交时间', plan: '发布日期', patent: '授权时间',
|
||||
transformation: '验收时间', software: '授权证书时间'
|
||||
};
|
||||
const dateLabel = dateLabels[record.type] || '成果时间';
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={0}>
|
||||
<span style={{ fontSize: '16px', fontWeight: 600, wordBreak: 'break-all', whiteSpace: 'normal' }}>{text}</span>
|
||||
<span style={{ color: '#888', fontSize: '12px' }}>
|
||||
{dateLabel}: {record.achievement_date ? new Date(record.achievement_date).toLocaleDateString() : '无'}
|
||||
</span>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '提交人',
|
||||
key: 'submitter',
|
||||
width: 150,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<span><UserOutlined style={{ marginRight: 4 }} />{record.submitter_name || '未知'}</span>
|
||||
<small style={{ color: '#888' }}>{maskPhone(record.username)}</small>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '完成人',
|
||||
dataIndex: 'contributors',
|
||||
key: 'contributors',
|
||||
render: (contributors) => (
|
||||
<Space size={[0, 4]} wrap>
|
||||
{(contributors || []).map((c, idx) => {
|
||||
const isPending = pendingUsers.has(c.phone);
|
||||
return (
|
||||
<Tag
|
||||
key={idx}
|
||||
color={isPending ? 'red' : (c.isMain ? 'orange' : 'default')}
|
||||
style={{ borderRadius: '10px' }}
|
||||
>
|
||||
{c.name}
|
||||
{c.isMain && <span style={{ marginLeft: 2, fontSize: '10px' }}>★</span>}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '归属部门',
|
||||
dataIndex: 'assigned_departments',
|
||||
key: 'assigned_departments',
|
||||
render: (depts) => (
|
||||
<Space size={[0, 4]} wrap>
|
||||
{(depts || []).map(dept => (
|
||||
<Tag color="cyan" key={dept}>{dept}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 90,
|
||||
render: (status) => {
|
||||
const statusMap = {
|
||||
pending: { color: 'gold', text: '待审核' },
|
||||
approved: { color: 'green', text: '已通过' },
|
||||
rejected: { color: 'red', text: '已驳回' },
|
||||
};
|
||||
const { color, text } = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={color}>{text}</Tag>;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 200,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => { setSelectedId(record.id); setDetailsVisible(true); }}>详情</Button>
|
||||
{(user.role === 'super_admin' || (user.role === 'user' && record.status === 'pending' && record.user_id === user.id)) && (
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => { setSelectedId(record.id); setEditVisible(true); }}>修改</Button>
|
||||
)}
|
||||
{canDeleteAchievement(record) && (
|
||||
<Popconfirm title="确定删除吗?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
}
|
||||
];
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.delete(`${API_BASE_URL}/api/achievements/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
message.success('删除成功');
|
||||
fetchDashboardData();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const buildExportParams = () => ({
|
||||
type: filterType || undefined,
|
||||
status: filterStatus || undefined,
|
||||
keyword: keyword || undefined,
|
||||
startDate: dateRange && dateRange[0] ? dateRange[0].format('YYYY-MM-DD') : undefined,
|
||||
endDate: dateRange && dateRange[1] ? dateRange[1].format('YYYY-MM-DD') : undefined
|
||||
});
|
||||
|
||||
const downloadBlob = (blob, filename) => {
|
||||
const url = window.URL.createObjectURL(new Blob([blob]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const parseFilename = (contentDisposition, fallback) => {
|
||||
if (!contentDisposition) return fallback;
|
||||
const utf8Name = contentDisposition.match(/filename\\*=UTF-8''(.+)/);
|
||||
const normalName = contentDisposition.match(/filename="(.+)"/);
|
||||
if (utf8Name && utf8Name[1]) return decodeURIComponent(utf8Name[1]);
|
||||
if (normalName && normalName[1]) return normalName[1];
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const handleExportCsv = async () => {
|
||||
const hide = message.loading('正在导出 CSV...', 0);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await axios.get(`${API_BASE_URL}/api/achievements/export/csv`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
params: buildExportParams(),
|
||||
responseType: 'blob'
|
||||
});
|
||||
const filename = parseFilename(response.headers['content-disposition'], 'achievements_export.csv');
|
||||
downloadBlob(response.data, filename);
|
||||
message.success('CSV 导出成功');
|
||||
} catch (error) {
|
||||
message.error('CSV 导出失败');
|
||||
} finally {
|
||||
hide();
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportZip = async () => {
|
||||
const hide = message.loading('正在导出 ZIP...', 0);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await axios.get(`${API_BASE_URL}/api/achievements/export/zip`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
params: buildExportParams(),
|
||||
responseType: 'blob'
|
||||
});
|
||||
const filename = parseFilename(response.headers['content-disposition'], 'achievements_export.zip');
|
||||
downloadBlob(response.data, filename);
|
||||
message.success('ZIP 导出成功');
|
||||
} catch (error) {
|
||||
message.error('ZIP 导出失败');
|
||||
} finally {
|
||||
hide();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDownloadForms = async () => {
|
||||
if (recentData.length === 0) {
|
||||
message.warning('当前列表没有数据可下载');
|
||||
return;
|
||||
}
|
||||
|
||||
setBatchLoading(true);
|
||||
setBatchProgress({ current: 0, total: recentData.length });
|
||||
const zip = new JSZip();
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
try {
|
||||
for (let i = 0; i < recentData.length; i++) {
|
||||
const item = recentData[i];
|
||||
setBatchProgress({ current: i + 1, total: recentData.length });
|
||||
|
||||
// 1. 获取详情
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE_URL}/api/achievements/${item.id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
const detailData = response.data;
|
||||
|
||||
// 2. 设置数据以触发渲染
|
||||
setBatchCurrentData(detailData);
|
||||
|
||||
// 3. 等待渲染完成 (给 React 和 DOM 一点时间)
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// 4. 截图
|
||||
const element = document.getElementById('batch-export-container');
|
||||
if (element) {
|
||||
const exportWidth = Math.max(element.scrollWidth, element.offsetWidth);
|
||||
const exportHeight = Math.max(element.scrollHeight, element.offsetHeight);
|
||||
const canvas = await html2canvas(element, {
|
||||
useCORS: true,
|
||||
scale: 2,
|
||||
backgroundColor: '#ffffff',
|
||||
width: exportWidth,
|
||||
height: exportHeight,
|
||||
windowWidth: exportWidth,
|
||||
windowHeight: exportHeight,
|
||||
scrollX: 0,
|
||||
scrollY: 0
|
||||
});
|
||||
|
||||
// 5. 添加到 ZIP
|
||||
const imgData = canvas.toDataURL('image/png').split(',')[1];
|
||||
const fileName = `成果表单_${detailData.name || '未命名'}_${detailData.id}.png`;
|
||||
// 处理文件名中的非法字符
|
||||
const safeFileName = fileName.replace(/[\\/:*?"<>|]/g, '_');
|
||||
zip.file(safeFileName, imgData, { base64: true });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`处理成果 ${item.id} 失败`, err);
|
||||
// 继续处理下一个,不中断
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 生成并下载 ZIP
|
||||
const content = await zip.generateAsync({ type: 'blob' });
|
||||
saveAs(content, `批量导出表单_${new Date().toISOString().slice(0,10)}.zip`);
|
||||
message.success('批量下载完成');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('批量下载失败');
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
setBatchCurrentData(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 批量下载进度遮罩 */}
|
||||
<Modal
|
||||
open={batchLoading}
|
||||
footer={null}
|
||||
closable={false}
|
||||
centered
|
||||
maskClosable={false}
|
||||
title="正在批量生成表单图片"
|
||||
>
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
<Progress
|
||||
type="circle"
|
||||
percent={Math.round((batchProgress.current / batchProgress.total) * 100)}
|
||||
format={() => `${batchProgress.current} / ${batchProgress.total}`}
|
||||
/>
|
||||
<div style={{ marginTop: 20, color: '#666' }}>
|
||||
正在处理中,请勿关闭页面...<br/>
|
||||
此操作可能需要几分钟,请耐心等待。
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* 隐藏的渲染容器 */}
|
||||
<div
|
||||
id="batch-export-container"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: '-100000px',
|
||||
top: 0,
|
||||
width: '1100px',
|
||||
backgroundColor: '#fff',
|
||||
padding: '40px',
|
||||
boxSizing: 'border-box',
|
||||
overflow: 'visible',
|
||||
zIndex: -1
|
||||
}}
|
||||
>
|
||||
{batchCurrentData && (
|
||||
<>
|
||||
<div style={{ textAlign: 'center', marginBottom: '30px', borderBottom: '2px solid #1890ff', paddingBottom: '20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: '10px' }}>
|
||||
<img src="/logo.png" style={{ height: '50px', marginRight: '15px' }} onError={(e) => e.target.style.display='none'} alt="logo" />
|
||||
<h1 style={{ margin: 0, fontSize: '28px', fontWeight: 'bold', color: '#333', whiteSpace: 'normal', wordBreak: 'break-all', overflowWrap: 'anywhere' }}>科技成果管理表单</h1>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>生成时间:{new Date().toLocaleString()}</div>
|
||||
</div>
|
||||
<AchievementFormRenderer data={batchCurrentData} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card style={{ marginBottom: 24 }} bodyStyle={{ padding: '20px' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '16px', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Space wrap size="middle" style={{ flex: 1 }}>
|
||||
<Input
|
||||
prefix={<SearchOutlined style={{ color: '#bfbfbf' }} />}
|
||||
placeholder="搜索名称/完成人/手机号"
|
||||
style={{ width: 240 }}
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
onPressEnter={fetchDashboardData}
|
||||
allowClear
|
||||
/>
|
||||
<DatePicker.RangePicker style={{ width: 260 }} onChange={setDateRange} />
|
||||
<Select placeholder="按类型" style={{ width: 120 }} allowClear onChange={setFilterType}>
|
||||
<Select.Option value="paper">论文</Select.Option>
|
||||
<Select.Option value="project">项目</Select.Option>
|
||||
<Select.Option value="award">奖励</Select.Option>
|
||||
<Select.Option value="standard">标准</Select.Option>
|
||||
<Select.Option value="monograph">专著</Select.Option>
|
||||
<Select.Option value="report">技术报告</Select.Option>
|
||||
<Select.Option value="plan">规划</Select.Option>
|
||||
<Select.Option value="patent">专利</Select.Option>
|
||||
<Select.Option value="transformation">成果转化</Select.Option>
|
||||
<Select.Option value="software">软著</Select.Option>
|
||||
</Select>
|
||||
<Select placeholder="按状态" style={{ width: 120 }} allowClear onChange={setFilterStatus}>
|
||||
<Select.Option value="pending">待审核</Select.Option>
|
||||
<Select.Option value="approved">已通过</Select.Option>
|
||||
<Select.Option value="rejected">已驳回</Select.Option>
|
||||
</Select>
|
||||
<Button type="primary" icon={<SearchOutlined />} onClick={fetchDashboardData}>查询</Button>
|
||||
</Space>
|
||||
|
||||
<Space>
|
||||
<Button icon={<PictureOutlined />} onClick={handleBatchDownloadForms} loading={batchLoading}>批量下载表单</Button>
|
||||
{user.role === 'super_admin' && (
|
||||
<>
|
||||
<Button icon={<FileTextOutlined />} onClick={handleExportCsv}>导出CSV</Button>
|
||||
<Button danger icon={<FileTextOutlined />} onClick={handleExportZip}>导出附件</Button>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={6}><Card><Statistic title="总成果数" value={stats.total} prefix={<FileTextOutlined />} /></Card></Col>
|
||||
<Col span={6}><Card><Statistic title="待审核" value={stats.pending} valueStyle={{ color: '#faad14' }} prefix={<ClockCircleOutlined />} /></Card></Col>
|
||||
<Col span={6}><Card><Statistic title="已通过" value={stats.approved} valueStyle={{ color: '#52c41a' }} prefix={<CheckCircleOutlined />} /></Card></Col>
|
||||
<Col span={6}><Card><Statistic title="已驳回" value={stats.rejected} valueStyle={{ color: '#f5222d' }} prefix={<CloseCircleOutlined />} /></Card></Col>
|
||||
</Row>
|
||||
|
||||
<Card style={{ marginTop: 24 }} bodyStyle={{ padding: '24px' }}>
|
||||
<Tabs
|
||||
type="card"
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
tabBarStyle={{ marginBottom: 24 }}
|
||||
items={[
|
||||
{
|
||||
key: 'my',
|
||||
label: <span style={{ fontSize: '16px', padding: '4px 8px' }}><UserSwitchOutlined />我的成果</span>,
|
||||
children: <Table
|
||||
columns={columns}
|
||||
dataSource={recentData}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 10 }}
|
||||
scroll={{ x: 1200 }}
|
||||
rowClassName={(record) => {
|
||||
const hasPendingContributor = (record.contributors || []).some(c => pendingUsers.has(c.phone));
|
||||
if (record.has_data_issue || hasPendingContributor) return 'row-pending-issue';
|
||||
return '';
|
||||
}}
|
||||
/>
|
||||
},
|
||||
{
|
||||
key: 'public',
|
||||
label: <span style={{ fontSize: '16px', padding: '4px 8px' }}><GlobalOutlined />成果公示</span>,
|
||||
children: <Table
|
||||
columns={columns}
|
||||
dataSource={recentData}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 10 }}
|
||||
scroll={{ x: 1200 }}
|
||||
rowClassName={(record) => {
|
||||
const hasPendingContributor = (record.contributors || []).some(c => pendingUsers.has(c.phone));
|
||||
if (record.has_data_issue || hasPendingContributor) return 'row-pending-issue';
|
||||
return '';
|
||||
}}
|
||||
/>
|
||||
},
|
||||
...(user.role === 'super_admin' ? [{
|
||||
key: 'all',
|
||||
label: <span style={{ fontSize: '16px', padding: '4px 8px' }}><FileTextOutlined />全部成果</span>,
|
||||
children: <Table
|
||||
columns={columns}
|
||||
dataSource={recentData}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 10 }}
|
||||
scroll={{ x: 1200 }}
|
||||
rowClassName={(record) => {
|
||||
const hasPendingContributor = (record.contributors || []).some(c => pendingUsers.has(c.phone));
|
||||
if (record.has_data_issue || hasPendingContributor) return 'row-pending-issue';
|
||||
return '';
|
||||
}}
|
||||
/>
|
||||
}] : [])
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<AchievementDetails id={selectedId} open={detailsVisible} onClose={() => { setDetailsVisible(false); setSelectedId(null); }} />
|
||||
<AchievementEdit id={selectedId} open={editVisible} onClose={() => { setEditVisible(false); setSelectedId(null); }} onSave={fetchDashboardData} />
|
||||
|
||||
<style>{`
|
||||
.row-pending-issue {
|
||||
background-color: #ffccc7;
|
||||
}
|
||||
.row-pending-issue:hover > td {
|
||||
background-color: #ffa39e !important;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
@@ -0,0 +1,157 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, Button, Table, Tag, Space, message, Typography, Alert } from 'antd';
|
||||
import { SafetyCertificateOutlined, EditOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import API_BASE_URL from '../config';
|
||||
import AchievementEdit from '../components/AchievementEdit';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const DataValidation = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState([]);
|
||||
const [editVisible, setEditVisible] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const [hasValidated, setHasValidated] = useState(false);
|
||||
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
|
||||
const handleValidate = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await axios.get(`${API_BASE_URL}/api/achievements/validate`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setData(response.data);
|
||||
setHasValidated(true);
|
||||
if (response.data.length === 0) {
|
||||
message.success('校验完成,未发现异常数据');
|
||||
} else {
|
||||
message.warning(`校验完成,发现 ${response.data.length} 条异常数据`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('校验失败', error);
|
||||
message.error('校验失败,请稍后重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '成果名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 300,
|
||||
render: (text, record) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<span style={{ fontWeight: 600 }}>{text}</span>
|
||||
<Tag color="blue">{record.type}</Tag>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '提交人',
|
||||
dataIndex: 'submitter',
|
||||
key: 'submitter',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '异常详情',
|
||||
dataIndex: 'errors',
|
||||
key: 'errors',
|
||||
render: (errors) => (
|
||||
<ul style={{ margin: 0, paddingLeft: 20, color: '#ff4d4f' }}>
|
||||
{errors.map((err, idx) => (
|
||||
<li key={idx}>{err}</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 100,
|
||||
render: (_, record) => (
|
||||
user.role === 'super_admin' ? (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => { setSelectedId(record.id); setEditVisible(true); }}
|
||||
>
|
||||
修改
|
||||
</Button>
|
||||
) : (
|
||||
<span style={{ color: '#999' }}>无权限</span>
|
||||
)
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
<SafetyCertificateOutlined style={{ marginRight: 8 }} />
|
||||
数据一致性校验
|
||||
</Title>
|
||||
<Text type="secondary">
|
||||
全量扫描数据库,检查成果完成人、归属部门以及各类字典字段(如论文类型、奖励等级等)的数据一致性。
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={loading}
|
||||
onClick={handleValidate}
|
||||
size="large"
|
||||
>
|
||||
开始全量校验
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{hasValidated && (
|
||||
<Card title={`校验结果:发现 ${data.length} 条异常数据`}>
|
||||
{data.length > 0 ? (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 10 }}
|
||||
rowClassName={() => 'validation-error-row'}
|
||||
/>
|
||||
) : (
|
||||
<Alert message="恭喜!所有数据均校验通过,未发现异常。" type="success" showIcon />
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<AchievementEdit
|
||||
id={selectedId}
|
||||
open={editVisible}
|
||||
onClose={() => { setEditVisible(false); setSelectedId(null); }}
|
||||
onSave={() => {
|
||||
setEditVisible(false);
|
||||
setSelectedId(null);
|
||||
handleValidate(); // 修改后重新校验
|
||||
}}
|
||||
/>
|
||||
|
||||
<style>{`
|
||||
.validation-error-row {
|
||||
background-color: #fff1f0;
|
||||
}
|
||||
.validation-error-row:hover > td {
|
||||
background-color: #ffccc7 !important;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataValidation;
|
||||
@@ -0,0 +1,124 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Input, Space, Card, Modal, Form, message, Popconfirm } from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import API_BASE_URL from '../config';
|
||||
|
||||
const DepartmentManagement = () => {
|
||||
const [departments, setDepartments] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
fetchDepartments();
|
||||
}, []);
|
||||
|
||||
const fetchDepartments = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE_URL}/api/departments`);
|
||||
setDepartments(response.data);
|
||||
} catch (error) {
|
||||
message.error('获取部门列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async (values) => {
|
||||
try {
|
||||
await axios.post(`${API_BASE_URL}/api/departments`, values, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
message.success('添加成功');
|
||||
setIsModalVisible(false);
|
||||
form.resetFields();
|
||||
fetchDepartments();
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.message || '添加失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await axios.delete(`${API_BASE_URL}/api/departments/${id}`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
message.success('删除成功');
|
||||
fetchDepartments();
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '部门名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
render: (text) => new Date(text).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_, record) => (
|
||||
<Popconfirm
|
||||
title="确定要删除该部门吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="link" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="部门管理"
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setIsModalVisible(true)}
|
||||
>
|
||||
添加部门
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={departments}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="添加部门"
|
||||
open={isModalVisible}
|
||||
onCancel={() => setIsModalVisible(false)}
|
||||
onOk={() => form.submit()}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} onFinish={handleAdd} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="部门名称"
|
||||
rules={[{ required: true, message: '请输入部门名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入部门名称" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DepartmentManagement;
|
||||
@@ -0,0 +1,209 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Input, Space, Card, Modal, Form, message, Popconfirm, Tabs, Select } from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import API_BASE_URL from '../config';
|
||||
|
||||
const DictionaryManagement = () => {
|
||||
const [data, setData] = useState([]);
|
||||
const [departments, setDepartments] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [editingRecord, setEditingRecord] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState('organizations');
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const tabs = [
|
||||
{ key: 'departments', label: '部门管理' },
|
||||
{ key: 'organizations', label: '单位名称' },
|
||||
{ key: 'award-types', label: '奖励种类' },
|
||||
{ key: 'award-levels', label: '奖励等级' },
|
||||
{ key: 'paper-types', label: '论文种类' },
|
||||
{ key: 'standard-types', label: '标准种类' },
|
||||
{ key: 'project-categories', label: '项目分类' },
|
||||
{ key: 'leader-departments', label: '领导层部门' },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTab]);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
// 部门管理原本有自己的接口,现在统一走字典接口
|
||||
const response = await axios.get(`${API_BASE_URL}/api/dictionaries/${activeTab}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setData(response.data);
|
||||
|
||||
// 如果是领导层部门管理,还需要获取所有部门列表供选择
|
||||
if (activeTab === 'leader-departments') {
|
||||
const deptResponse = await axios.get(`${API_BASE_URL}/api/dictionaries/departments`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setDepartments(deptResponse.data);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddOrEdit = async (values) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (editingRecord) {
|
||||
await axios.put(`${API_BASE_URL}/api/dictionaries/${activeTab}/${editingRecord.id}`, values, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
message.success('修改成功');
|
||||
} else {
|
||||
await axios.post(`${API_BASE_URL}/api/dictionaries/${activeTab}`, values, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
message.success('添加成功');
|
||||
}
|
||||
setIsModalVisible(false);
|
||||
setEditingRecord(null);
|
||||
form.resetFields();
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await axios.delete(`${API_BASE_URL}/api/dictionaries/${activeTab}/${id}`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
message.success('删除成功');
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openEditModal = (record) => {
|
||||
setEditingRecord(record);
|
||||
form.setFieldsValue(record);
|
||||
setIsModalVisible(true);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
render: (text) => new Date(text).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEditModal(record)}
|
||||
disabled={record.name === '待定'}
|
||||
>
|
||||
修改
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
disabled={record.name === '待定'}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={record.name === '待定'}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card title="系统字典管理">
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={tabs}
|
||||
tabBarExtraContent={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditingRecord(null);
|
||||
form.resetFields();
|
||||
setIsModalVisible(true);
|
||||
}}
|
||||
>
|
||||
添加选项
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 10 }}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={`${editingRecord ? '修改' : '添加'}${tabs.find(t => t.key === activeTab)?.label}`}
|
||||
open={isModalVisible}
|
||||
onCancel={() => {
|
||||
setIsModalVisible(false);
|
||||
setEditingRecord(null);
|
||||
}}
|
||||
onOk={() => form.submit()}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} onFinish={handleAddOrEdit} layout="vertical">
|
||||
{activeTab === 'leader-departments' ? (
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="选择部门"
|
||||
rules={[{ required: true, message: '请选择部门' }]}
|
||||
>
|
||||
<Select placeholder="请选择部门" showSearch optionFilterProp="children">
|
||||
{departments.map(dept => (
|
||||
<Select.Option key={dept.id} value={dept.name}>{dept.name}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="名称"
|
||||
rules={[{ required: true, message: '请输入名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入名称" />
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DictionaryManagement;
|
||||
@@ -0,0 +1,92 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Form, Input, Button, Card, message } from 'antd';
|
||||
import { UserOutlined, LockOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import API_BASE_URL from '../config';
|
||||
|
||||
const Login = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onFinish = async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await axios.post(`${API_BASE_URL}/api/auth/login`, values);
|
||||
const { token, user } = response.data;
|
||||
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
|
||||
message.success('登录成功');
|
||||
navigate('/dashboard');
|
||||
} catch (error) {
|
||||
console.error('登录错误详情:', error.response);
|
||||
if (error.response?.status === 401) {
|
||||
message.error('用户名或密码错误,请重新输入');
|
||||
} else {
|
||||
message.error(error.response?.data?.message || '登录失败,请检查网络或联系管理员');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
background: 'linear-gradient(135deg, #1677ff 0%, #003a8c 100%)'
|
||||
}}>
|
||||
<Card style={{ width: 400, borderRadius: '12px', boxShadow: '0 8px 24px rgba(0,0,0,0.15)' }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<div style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
background: '#1677ff',
|
||||
borderRadius: '16px',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 16,
|
||||
boxShadow: '0 4px 12px rgba(22,119,255,0.3)'
|
||||
}}>
|
||||
<FileTextOutlined style={{ color: 'white', fontSize: 32 }} />
|
||||
</div>
|
||||
<h2 style={{ margin: 0, color: '#1f1f1f', fontSize: '24px', fontWeight: 600 }}>科技成果管理系统</h2>
|
||||
<p style={{ color: '#8c8c8c', marginTop: 8 }}>请登录您的账号以继续</p>
|
||||
</div>
|
||||
<Form name="login" onFinish={onFinish}>
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[
|
||||
{ required: true, message: '请输入手机号' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号格式' }
|
||||
]}
|
||||
>
|
||||
<Input prefix={<UserOutlined />} placeholder="手机号" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="密码" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block size="large" loading={loading}>
|
||||
登录
|
||||
</Button>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 16 }}>
|
||||
<Link to="/reset-password">忘记密码?</Link>
|
||||
<span>还没有账号? <Link to="/register">立即注册</Link></span>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
@@ -0,0 +1,364 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Table, Button, Input, Modal, Form, Upload, message, Space, Tag, Popconfirm, Typography, List } from 'antd';
|
||||
import { PlusOutlined, SearchOutlined, DeleteOutlined, PaperClipOutlined, DownloadOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-react';
|
||||
import '@wangeditor/editor/dist/css/style.css';
|
||||
import axios from 'axios';
|
||||
import API_BASE_URL from '../config';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
// 封装 WangEditor 组件以适配 Ant Design Form
|
||||
const MyEditor = ({ value, onChange }) => {
|
||||
const [editor, setEditor] = useState(null);
|
||||
const [html, setHtml] = useState(value || '');
|
||||
|
||||
// 监听 value 变化(例如表单重置时)
|
||||
useEffect(() => {
|
||||
setHtml(value || '');
|
||||
}, [value]);
|
||||
|
||||
const toolbarConfig = {};
|
||||
const editorConfig = {
|
||||
placeholder: '请输入内容...',
|
||||
};
|
||||
|
||||
// 及时销毁 editor
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (editor == null) return;
|
||||
editor.destroy();
|
||||
setEditor(null);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
return (
|
||||
<div style={{ border: '1px solid #ccc', zIndex: 100 }}>
|
||||
<Toolbar
|
||||
editor={editor}
|
||||
defaultConfig={toolbarConfig}
|
||||
mode="default"
|
||||
style={{ borderBottom: '1px solid #ccc' }}
|
||||
/>
|
||||
<Editor
|
||||
defaultConfig={editorConfig}
|
||||
value={html}
|
||||
onCreated={setEditor}
|
||||
onChange={editor => {
|
||||
const newHtml = editor.getHtml();
|
||||
setHtml(newHtml);
|
||||
onChange(newHtml);
|
||||
}}
|
||||
mode="default"
|
||||
style={{ height: '300px', overflowY: 'hidden' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Notifications = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState([]);
|
||||
const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 });
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [isDetailVisible, setIsDetailVisible] = useState(false);
|
||||
const [currentNotification, setCurrentNotification] = useState(null);
|
||||
const [form] = Form.useForm();
|
||||
const [fileList, setFileList] = useState([]);
|
||||
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
const isAdmin = ['super_admin', 'maintainer'].includes(user.role);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pagination.current, pagination.pageSize]);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await axios.get(`${API_BASE_URL}/api/notifications`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
params: {
|
||||
page: pagination.current,
|
||||
limit: pagination.pageSize,
|
||||
keyword
|
||||
}
|
||||
});
|
||||
setData(response.data.list);
|
||||
setPagination({ ...pagination, total: response.data.total });
|
||||
} catch (error) {
|
||||
message.error('获取公告列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setPagination({ ...pagination, current: 1 });
|
||||
fetchData();
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setCurrentNotification(null);
|
||||
form.resetFields();
|
||||
setFileList([]);
|
||||
setIsModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.delete(`${API_BASE_URL}/api/notifications/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
message.success('删除成功');
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleView = async (record) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await axios.get(`${API_BASE_URL}/api/notifications/${record.id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setCurrentNotification(response.data);
|
||||
setIsDetailVisible(true);
|
||||
} catch (error) {
|
||||
message.error('获取详情失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const formData = new FormData();
|
||||
formData.append('title', values.title);
|
||||
formData.append('content', values.content);
|
||||
formData.append('is_top', values.is_top || false);
|
||||
|
||||
fileList.forEach(file => {
|
||||
formData.append('files', file.originFileObj || file);
|
||||
});
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.post(`${API_BASE_URL}/api/notifications`, formData, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
|
||||
message.success('发布成功');
|
||||
setIsModalVisible(false);
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('发布失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (attachment) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await axios.get(`${API_BASE_URL}/api/notifications/attachments/${attachment.id}/download`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
|
||||
// 尝试从 Content-Disposition 获取文件名
|
||||
let filename = attachment.file_name;
|
||||
const contentDisposition = response.headers['content-disposition'];
|
||||
if (contentDisposition) {
|
||||
const filenameMatch = contentDisposition.match(/filename\*=UTF-8''(.+)/);
|
||||
if (filenameMatch && filenameMatch.length === 2) {
|
||||
filename = decodeURIComponent(filenameMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
} catch (error) {
|
||||
message.error('下载失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '标题',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
render: (text, record) => (
|
||||
<Space>
|
||||
{record.is_top && <Tag color="red">置顶</Tag>}
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => handleView(record)}
|
||||
style={{ padding: 0, fontWeight: 500, fontSize: '15px', height: 'auto' }}
|
||||
>
|
||||
{text}
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '发布人',
|
||||
dataIndex: 'publisher_real_name',
|
||||
key: 'publisher',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '发布时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 180,
|
||||
render: (text) => new Date(text).toLocaleString()
|
||||
},
|
||||
...(isAdmin ? [{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 100,
|
||||
render: (_, record) => (
|
||||
<Popconfirm title="确定删除吗?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button type="link" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
)
|
||||
}] : [])
|
||||
];
|
||||
|
||||
const uploadProps = {
|
||||
onRemove: (file) => {
|
||||
const index = fileList.indexOf(file);
|
||||
const newFileList = fileList.slice();
|
||||
newFileList.splice(index, 1);
|
||||
setFileList(newFileList);
|
||||
},
|
||||
beforeUpload: (file) => {
|
||||
setFileList(prev => [...prev, file]);
|
||||
return false;
|
||||
},
|
||||
fileList,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<Card title="系统公告" extra={isAdmin && <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>发布公告</Button>}>
|
||||
<div style={{ marginBottom: 16, display: 'flex', gap: 8 }}>
|
||||
<Input
|
||||
placeholder="搜索公告标题或内容"
|
||||
style={{ width: 300 }}
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
onPressEnter={handleSearch}
|
||||
allowClear
|
||||
/>
|
||||
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>搜索</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
...pagination,
|
||||
onChange: (page, pageSize) => setPagination({ ...pagination, current: page, pageSize })
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 发布公告弹窗 */}
|
||||
<Modal
|
||||
title="发布新公告"
|
||||
open={isModalVisible}
|
||||
onOk={handleOk}
|
||||
onCancel={() => setIsModalVisible(false)}
|
||||
width={800}
|
||||
maskClosable={false}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="title" label="公告标题" rules={[{ required: true, message: '请输入标题' }]}>
|
||||
<Input placeholder="请输入公告标题" />
|
||||
</Form.Item>
|
||||
<Form.Item name="content" label="公告内容" rules={[{ required: true, message: '请输入内容' }]}>
|
||||
<MyEditor />
|
||||
</Form.Item>
|
||||
<Form.Item name="files" label="附件上传">
|
||||
<Upload {...uploadProps} multiple>
|
||||
<Button icon={<PaperClipOutlined />}>选择文件</Button>
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 公告详情弹窗 */}
|
||||
<Modal
|
||||
title={null}
|
||||
open={isDetailVisible}
|
||||
onCancel={() => setIsDetailVisible(false)}
|
||||
footer={[
|
||||
<Button key="close" onClick={() => setIsDetailVisible(false)}>关闭</Button>
|
||||
]}
|
||||
width={800}
|
||||
>
|
||||
{currentNotification && (
|
||||
<div>
|
||||
<div style={{ textAlign: 'center', marginBottom: 24, borderBottom: '1px solid #f0f0f0', paddingBottom: 16 }}>
|
||||
<Title level={3}>{currentNotification.title}</Title>
|
||||
<Space style={{ color: '#888' }}>
|
||||
<span>发布人:{currentNotification.publisher_real_name || '管理员'}</span>
|
||||
<span>发布时间:{new Date(currentNotification.created_at).toLocaleString()}</span>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="wangeditor-content"
|
||||
style={{ minHeight: 200, fontSize: '16px', lineHeight: '1.8' }}
|
||||
dangerouslySetInnerHTML={{ __html: currentNotification.content }}
|
||||
/>
|
||||
|
||||
{currentNotification.attachments && currentNotification.attachments.length > 0 && (
|
||||
<div style={{ marginTop: 32, paddingTop: 16, borderTop: '1px dashed #d9d9d9' }}>
|
||||
<Title level={5}><PaperClipOutlined /> 附件下载</Title>
|
||||
<List
|
||||
size="small"
|
||||
dataSource={currentNotification.attachments}
|
||||
renderItem={item => (
|
||||
<List.Item>
|
||||
<Space>
|
||||
<FileTextOutlined />
|
||||
<Text>{item.file_name}</Text>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => handleDownload(item)}
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
</Space>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Notifications;
|
||||
@@ -0,0 +1,182 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Form, Input, Button, Card, message, Space, Select } from 'antd';
|
||||
import { UserOutlined, LockOutlined, IdcardOutlined, ApartmentOutlined } from '@ant-design/icons';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import API_BASE_URL from '../config';
|
||||
|
||||
const Register = () => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [departments, setDepartments] = useState([]);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
fetchDepartments();
|
||||
}, []);
|
||||
|
||||
const fetchDepartments = async () => {
|
||||
try {
|
||||
// 部门管理已合并进字典管理,使用字典接口获取
|
||||
const response = await axios.get(`${API_BASE_URL}/api/dictionaries/departments`);
|
||||
setDepartments(response.data);
|
||||
} catch (error) {
|
||||
message.error('获取部门列表失败');
|
||||
}
|
||||
};
|
||||
|
||||
const sendCode = async () => {
|
||||
const phoneNumber = form.getFieldValue('username');
|
||||
if (!/^1[3-9]\d{9}$/.test(phoneNumber)) {
|
||||
return message.error('请先输入正确的手机号');
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
await axios.post(`${API_BASE_URL}/api/auth/send-code`, {
|
||||
phoneNumber,
|
||||
type: 'register'
|
||||
});
|
||||
message.success('验证码已发送');
|
||||
setCountdown(60);
|
||||
const timer = setInterval(() => {
|
||||
setCountdown(prev => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.message || '发送失败');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onFinish = async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await axios.post(`${API_BASE_URL}/api/auth/register`, {
|
||||
...values,
|
||||
role: 'user'
|
||||
});
|
||||
message.success('注册成功,请登录');
|
||||
navigate('/login');
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.message || '注册失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
background: '#f0f2f5'
|
||||
}}>
|
||||
<Card title="科技成果转化系统 - 用户注册" style={{ width: 400 }}>
|
||||
<Form form={form} name="register" onFinish={onFinish} layout="vertical">
|
||||
<Form.Item label="手机号" required style={{ marginBottom: 0 }}>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Form.Item
|
||||
name="username"
|
||||
noStyle
|
||||
rules={[
|
||||
{ required: true, message: '请输入手机号' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '格式错误' }
|
||||
]}
|
||||
>
|
||||
<Input prefix={<UserOutlined />} placeholder="手机号" style={{ width: '70%' }} />
|
||||
</Form.Item>
|
||||
<Button
|
||||
onClick={sendCode}
|
||||
disabled={sending || countdown > 0}
|
||||
style={{ width: '30%' }}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s` : '验证码'}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="code"
|
||||
label="验证码"
|
||||
rules={[{ required: true, message: '请输入验证码' }]}
|
||||
>
|
||||
<Input prefix={<LockOutlined />} placeholder="6位数字验证码" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="real_name"
|
||||
label="真实姓名"
|
||||
rules={[{ required: true, message: '请输入真实姓名' }]}
|
||||
>
|
||||
<Input prefix={<IdcardOutlined />} placeholder="真实姓名" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="department"
|
||||
label="所属部门"
|
||||
rules={[{ required: true, message: '请选择所属部门' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择所属部门"
|
||||
prefix={<ApartmentOutlined />}
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
>
|
||||
{departments.map(dept => (
|
||||
<Select.Option key={dept.id} value={dept.name}>{dept.name}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="设置登录密码" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="confirm"
|
||||
label="确认密码"
|
||||
dependencies={['password']}
|
||||
rules={[
|
||||
{ required: true, message: '请再次输入密码' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('password') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('两次输入的密码不一致'));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="再次输入密码" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block size="large" loading={loading}>
|
||||
立即注册
|
||||
</Button>
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
已有账号? <Link to="/login">立即登录</Link>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Register;
|
||||
@@ -0,0 +1,162 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Form, Input, Button, Card, message, Row, Col } from 'antd';
|
||||
import { UserOutlined, LockOutlined, SafetyOutlined, ArrowLeftOutlined } from '@ant-design/icons';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import API_BASE_URL from '../config';
|
||||
|
||||
const ResetPassword = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [form] = Form.useForm();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
let timer;
|
||||
if (countdown > 0) {
|
||||
timer = setInterval(() => {
|
||||
setCountdown((prev) => prev - 1);
|
||||
}, 1000);
|
||||
}
|
||||
return () => clearInterval(timer);
|
||||
}, [countdown]);
|
||||
|
||||
const sendCode = async () => {
|
||||
try {
|
||||
const phoneNumber = form.getFieldValue('phoneNumber');
|
||||
if (!phoneNumber || !/^1[3-9]\d{9}$/.test(phoneNumber)) {
|
||||
message.error('请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
|
||||
setSendingCode(true);
|
||||
await axios.post(`${API_BASE_URL}/api/auth/send-code`, {
|
||||
phoneNumber,
|
||||
type: 'reset'
|
||||
});
|
||||
message.success('验证码已发送');
|
||||
setCountdown(60);
|
||||
} catch (error) {
|
||||
// 如果是 429 错误,尝试从错误消息中解析剩余秒数
|
||||
if (error.response?.status === 429) {
|
||||
const msg = error.response.data.message;
|
||||
const match = msg.match(/(\d+)/);
|
||||
if (match) {
|
||||
setCountdown(parseInt(match[1], 10));
|
||||
}
|
||||
}
|
||||
message.error(error.response?.data?.message || '发送失败');
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onFinish = async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await axios.post(`${API_BASE_URL}/api/auth/reset-password`, {
|
||||
phoneNumber: values.phoneNumber,
|
||||
code: values.code,
|
||||
newPassword: values.newPassword
|
||||
});
|
||||
message.success('密码重置成功,请重新登录');
|
||||
navigate('/login');
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.message || '重置失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
background: 'linear-gradient(135deg, #1677ff 0%, #003a8c 100%)'
|
||||
}}>
|
||||
<Card style={{ width: 400, borderRadius: '12px', boxShadow: '0 8px 24px rgba(0,0,0,0.15)' }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<h2 style={{ margin: 0, color: '#1f1f1f', fontSize: '24px', fontWeight: 600 }}>重置密码</h2>
|
||||
<p style={{ color: '#8c8c8c', marginTop: 8 }}>通过手机验证码重置您的密码</p>
|
||||
</div>
|
||||
<Form form={form} name="reset-password" onFinish={onFinish} layout="vertical">
|
||||
<Form.Item
|
||||
name="phoneNumber"
|
||||
rules={[
|
||||
{ required: true, message: '请输入手机号' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号格式' }
|
||||
]}
|
||||
>
|
||||
<Input prefix={<UserOutlined />} placeholder="手机号" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="验证码" required style={{ marginBottom: 0 }}>
|
||||
<Row gutter={8}>
|
||||
<Col span={14}>
|
||||
<Form.Item
|
||||
name="code"
|
||||
rules={[{ required: true, message: '请输入验证码' }]}
|
||||
>
|
||||
<Input prefix={<SafetyOutlined />} placeholder="验证码" size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={10}>
|
||||
<Button
|
||||
block
|
||||
size="large"
|
||||
onClick={sendCode}
|
||||
disabled={countdown > 0 || sendingCode}
|
||||
loading={sendingCode}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}秒后重试` : '获取验证码'}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="newPassword"
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{ min: 6, message: '密码至少6位' }
|
||||
]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="新密码" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="confirmPassword"
|
||||
dependencies={['newPassword']}
|
||||
rules={[
|
||||
{ required: true, message: '请确认新密码' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('newPassword') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('两次输入的密码不一致'));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="确认新密码" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block size="large" loading={loading}>
|
||||
重置密码
|
||||
</Button>
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<Link to="/login"><ArrowLeftOutlined /> 返回登录</Link>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetPassword;
|
||||
@@ -0,0 +1,703 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Alert, Button, Card, Col, Input, message, Radio, Row, Select, Slider, Space, Spin, Switch, Table, Tag, Typography } from 'antd';
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Area,
|
||||
Bar,
|
||||
BarChart,
|
||||
Brush,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ComposedChart,
|
||||
Legend,
|
||||
Line,
|
||||
Pie,
|
||||
PieChart,
|
||||
PolarAngleAxis,
|
||||
PolarGrid,
|
||||
PolarRadiusAxis,
|
||||
Radar,
|
||||
RadarChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis
|
||||
} from 'recharts';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import axios from 'axios';
|
||||
import API_BASE_URL from '../config';
|
||||
|
||||
const { Title } = Typography;
|
||||
const { Option } = Select;
|
||||
|
||||
const TYPE_MAP = {
|
||||
paper: '论文',
|
||||
project: '项目',
|
||||
award: '奖励',
|
||||
standard: '标准',
|
||||
monograph: '专著',
|
||||
report: '技术报告',
|
||||
plan: '规划',
|
||||
patent: '专利',
|
||||
transformation: '成果转化',
|
||||
software: '软著'
|
||||
};
|
||||
|
||||
const STATUS_MAP = {
|
||||
pending: '待审核',
|
||||
approved: '已通过',
|
||||
rejected: '已驳回'
|
||||
};
|
||||
|
||||
const AI_MODE_MAP = {
|
||||
standard: '标准模式(仅聚合)',
|
||||
deep: '深度模式(含成果名称样本)'
|
||||
};
|
||||
|
||||
const AI_PROVIDER_MAP = {
|
||||
deepseek: 'DeepSeek'
|
||||
};
|
||||
|
||||
const ChartContainer = ({ height, minHeight, children }) => {
|
||||
const containerRef = useRef(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return undefined;
|
||||
|
||||
const refresh = () => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
setReady(rect.width > 0 && rect.height > 0);
|
||||
};
|
||||
refresh();
|
||||
|
||||
let observer = null;
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
observer = new ResizeObserver(refresh);
|
||||
observer.observe(el);
|
||||
} else {
|
||||
const timer = setInterval(refresh, 150);
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observer) observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ width: '100%', height, minHeight }}>
|
||||
{ready ? (
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={minHeight}>
|
||||
{children}
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div style={{ width: '100%', height: '100%' }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Statistics = () => {
|
||||
const currentUser = (() => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('user') || '{}');
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
})();
|
||||
|
||||
const canUseAi = ['admin', 'super_admin', 'senior_user', 'intermediate_user'].includes(currentUser?.role);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState(null);
|
||||
|
||||
const [pieYear, setPieYear] = useState('all');
|
||||
const [radarYear, setRadarYear] = useState('all');
|
||||
const [barYear, setBarYear] = useState('all');
|
||||
const [trendMode, setTrendMode] = useState('month');
|
||||
|
||||
const [viewMode, setViewMode] = useState('chart');
|
||||
const [detailTitle, setDetailTitle] = useState('');
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detailList, setDetailList] = useState([]);
|
||||
|
||||
const [aiLoading, setAiLoading] = useState(false);
|
||||
const [aiMode, setAiMode] = useState('standard');
|
||||
const [aiNameLimit, setAiNameLimit] = useState(100);
|
||||
const [aiNameStrategy, setAiNameStrategy] = useState('latest');
|
||||
const [aiQuestion, setAiQuestion] = useState('请从成果结构、部门差异、风险点和改进优先级四个方面给出分析建议。');
|
||||
const [aiResult, setAiResult] = useState('');
|
||||
const [aiMeta, setAiMeta] = useState(null);
|
||||
const aiAbortRef = useRef(null);
|
||||
const [aiFilters, setAiFilters] = useState({ year: 'all', type: 'all', status: 'all', department: 'all' });
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const resp = await axios.get(`${API_BASE_URL}/api/statistics`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
setData(resp.data);
|
||||
} catch (e) {
|
||||
message.error('获取统计数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const generateColor = (index) => `hsl(${(index * 137.508) % 360}, 65%, 40%)`;
|
||||
|
||||
const years = data
|
||||
? [...new Set((data.deptTypeDetails || []).map((item) => item.year))].filter(Boolean).sort((a, b) => b - a)
|
||||
: [];
|
||||
const departments = data
|
||||
? [...new Set((data.byDepartment || []).map((item) => item.department).filter(Boolean))]
|
||||
: [];
|
||||
|
||||
const inferYearFromQuestion = (text) => {
|
||||
const q = String(text || '').trim();
|
||||
if (!q) return null;
|
||||
const currentYear = new Date().getFullYear();
|
||||
if (/(今年|本年|当年)/.test(q)) return currentYear;
|
||||
if (/(去年|上年|上一年)/.test(q)) return currentYear - 1;
|
||||
if (/(明年|下年|下一年)/.test(q)) return currentYear + 1;
|
||||
|
||||
const yearMatches = Array.from(q.matchAll(/(?:19|20|21)\d{2}/g))
|
||||
.map((m) => Number(m[0]))
|
||||
.filter((y) => y >= 2000 && y <= currentYear + 100);
|
||||
if (yearMatches.length === 0) return null;
|
||||
|
||||
const uniqueYears = Array.from(new Set(yearMatches));
|
||||
if (uniqueYears.length === 1) return uniqueYears[0];
|
||||
if (/[-~至到]/.test(q)) return Math.max(...uniqueYears);
|
||||
return null;
|
||||
};
|
||||
|
||||
const syncChartsByYear = (yearValue) => {
|
||||
const y = String(yearValue || 'all');
|
||||
setPieYear(y);
|
||||
setRadarYear(y);
|
||||
setBarYear(y);
|
||||
};
|
||||
|
||||
const handleAiYearChange = (value) => {
|
||||
const y = String(value || 'all');
|
||||
setAiFilters((prev) => ({ ...prev, year: y }));
|
||||
syncChartsByYear(y);
|
||||
};
|
||||
|
||||
const pieData = (() => {
|
||||
if (!data) return [];
|
||||
const m = {};
|
||||
(data.deptTypeDetails || []).forEach((item) => {
|
||||
if (pieYear !== 'all' && String(item.year) !== String(pieYear)) return;
|
||||
if (!m[item.department]) m[item.department] = { name: item.department, value: 0 };
|
||||
m[item.department].value += Number(item.count) || 0;
|
||||
});
|
||||
return Object.values(m).sort((a, b) => b.value - a.value);
|
||||
})();
|
||||
const pieColors = pieData.map((_, i) => generateColor(i));
|
||||
|
||||
const radarData = (() => {
|
||||
if (!data) return [];
|
||||
const m = {};
|
||||
(data.deptTypeDetails || []).forEach((item) => {
|
||||
if (radarYear !== 'all' && String(item.year) !== String(radarYear)) return;
|
||||
if (!m[item.type]) m[item.type] = { typeKey: item.type, name: TYPE_MAP[item.type] || item.type, value: 0 };
|
||||
m[item.type].value += Number(item.count) || 0;
|
||||
});
|
||||
Object.keys(TYPE_MAP).forEach((k) => {
|
||||
if (!m[k]) m[k] = { typeKey: k, name: TYPE_MAP[k], value: 0 };
|
||||
});
|
||||
return Object.values(m).sort((a, b) => a.value - b.value);
|
||||
})();
|
||||
|
||||
const barData = (() => {
|
||||
if (!data) return [];
|
||||
const m = {};
|
||||
(data.deptTypeDetails || []).forEach((item) => {
|
||||
if (barYear !== 'all' && String(item.year) !== String(barYear)) return;
|
||||
if (!m[item.department]) m[item.department] = { name: item.department, total: 0 };
|
||||
const key = TYPE_MAP[item.type] || item.type;
|
||||
const count = Number(item.count) || 0;
|
||||
m[item.department][key] = (m[item.department][key] || 0) + count;
|
||||
m[item.department].total += count;
|
||||
});
|
||||
return Object.values(m).sort((a, b) => b.total - a.total);
|
||||
})();
|
||||
|
||||
const allTypes = Object.values(TYPE_MAP);
|
||||
const typeColorMap = {};
|
||||
allTypes.forEach((k, i) => { typeColorMap[k] = generateColor(i); });
|
||||
|
||||
const trendData = (() => {
|
||||
if (!data || !data.byMonth || data.byMonth.length === 0) return [];
|
||||
|
||||
const monthSet = new Set();
|
||||
(data.byMonth || []).forEach((item) => monthSet.add(item.month));
|
||||
(data.deptTrendDetails || []).forEach((item) => monthSet.add(item.month));
|
||||
const months = Array.from(monthSet).sort();
|
||||
if (months.length === 0) return [];
|
||||
|
||||
const startMonth = months[0];
|
||||
const endMonth = months[months.length - 1];
|
||||
const fullMonths = [];
|
||||
let [curY, curM] = startMonth.split('-').map(Number);
|
||||
const [endY, endM] = endMonth.split('-').map(Number);
|
||||
while (curY < endY || (curY === endY && curM <= endM)) {
|
||||
fullMonths.push(`${curY}-${String(curM).padStart(2, '0')}`);
|
||||
curM += 1;
|
||||
if (curM > 12) {
|
||||
curM = 1;
|
||||
curY += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const incrementMap = {};
|
||||
fullMonths.forEach((m) => { incrementMap[m] = { 全单位: 0 }; });
|
||||
(data.byMonth || []).forEach((item) => { if (incrementMap[item.month]) incrementMap[item.month].全单位 = Number(item.count) || 0; });
|
||||
(data.deptTrendDetails || []).forEach((item) => { if (incrementMap[item.month]) incrementMap[item.month][item.department] = Number(item.count) || 0; });
|
||||
|
||||
const deptKeys = [...new Set((data.deptTrendDetails || []).map((item) => item.department))];
|
||||
const cumulative = { 全单位: 0 };
|
||||
deptKeys.forEach((d) => { cumulative[d] = 0; });
|
||||
|
||||
let result = fullMonths.map((m) => {
|
||||
const entry = { name: m, rawMonth: m };
|
||||
const increments = incrementMap[m] || {};
|
||||
cumulative.全单位 += increments.全单位 || 0;
|
||||
entry.全单位 = cumulative.全单位;
|
||||
deptKeys.forEach((d) => {
|
||||
cumulative[d] += increments[d] || 0;
|
||||
entry[d] = cumulative[d];
|
||||
});
|
||||
return entry;
|
||||
});
|
||||
|
||||
if (trendMode === 'year') {
|
||||
const yearMap = {};
|
||||
result.forEach((item) => { yearMap[item.name.slice(0, 4)] = { ...item, name: item.name.slice(0, 4) }; });
|
||||
result = Object.values(yearMap).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
return result;
|
||||
})();
|
||||
|
||||
const allDepts = data ? [...new Set((data.deptTrendDetails || []).map((item) => item.department))] : [];
|
||||
const deptColorMap = {};
|
||||
allDepts.forEach((d, i) => { deptColorMap[d] = generateColor(i + 5); });
|
||||
|
||||
const fetchDetailList = async ({ department, type, year }) => {
|
||||
setViewMode('list');
|
||||
setDetailLoading(true);
|
||||
setDetailList([]);
|
||||
|
||||
const params = {};
|
||||
if (department) params.department = department;
|
||||
if (type) params.type = type;
|
||||
if (year && year !== 'all') {
|
||||
params.startDate = `${year}-01-01`;
|
||||
params.endDate = `${year}-12-31`;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const resp = await axios.get(`${API_BASE_URL}/api/achievements`, { params, headers: { Authorization: `Bearer ${token}` } });
|
||||
setDetailList(resp.data);
|
||||
} catch (e) {
|
||||
message.error('获取成果列表失败');
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePieClick = (entry) => {
|
||||
if (!entry) return;
|
||||
setDetailTitle(`${entry.name} 成果列表 ${pieYear !== 'all' ? `(${pieYear}年)` : ''}`);
|
||||
fetchDetailList({ department: entry.name, year: pieYear });
|
||||
};
|
||||
|
||||
const handleRadarClick = (eventData) => {
|
||||
if (!eventData) return;
|
||||
let item = null;
|
||||
if (eventData.payload && eventData.payload.value && !eventData.payload.typeKey) item = radarData.find((d) => d.name === eventData.payload.value);
|
||||
else if (eventData.payload && eventData.payload.typeKey) item = eventData.payload;
|
||||
else if (eventData.typeKey) item = eventData;
|
||||
else if (eventData.activePayload && eventData.activePayload.length > 0) item = eventData.activePayload[0].payload;
|
||||
|
||||
if (item?.typeKey) {
|
||||
setDetailTitle(`${item.name} 列表 ${radarYear !== 'all' ? `(${radarYear}年)` : ''}`);
|
||||
fetchDetailList({ type: item.typeKey, year: radarYear });
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setViewMode('chart');
|
||||
setDetailList([]);
|
||||
};
|
||||
|
||||
const handleAiAnalyze = async () => {
|
||||
if (!canUseAi) {
|
||||
message.warning('当前账号无权使用 AI 分析');
|
||||
return;
|
||||
}
|
||||
if (aiLoading) return;
|
||||
|
||||
let requestFilters = { ...aiFilters };
|
||||
const selectedYear = String(aiFilters?.year || 'all');
|
||||
if (selectedYear !== 'all') {
|
||||
syncChartsByYear(selectedYear);
|
||||
} else {
|
||||
const inferredYear = inferYearFromQuestion(aiQuestion);
|
||||
if (inferredYear) {
|
||||
const y = String(inferredYear);
|
||||
requestFilters = { ...requestFilters, year: y };
|
||||
setAiFilters((prev) => ({ ...prev, year: y }));
|
||||
syncChartsByYear(y);
|
||||
message.info(`已识别提问中的年份:${y}年,图表已自动切换。`);
|
||||
}
|
||||
}
|
||||
|
||||
setAiLoading(true);
|
||||
setAiResult('');
|
||||
setAiMeta(null);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const controller = new AbortController();
|
||||
aiAbortRef.current = controller;
|
||||
|
||||
const resp = await fetch(`${API_BASE_URL}/api/ai-analysis/achievements/stream`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({
|
||||
provider: 'deepseek',
|
||||
question: aiQuestion,
|
||||
filters: requestFilters,
|
||||
mode: aiMode,
|
||||
nameLimit: aiMode === 'deep' ? aiNameLimit : undefined,
|
||||
nameStrategy: aiMode === 'deep' ? aiNameStrategy : undefined
|
||||
}),
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
let errMessage = 'AI 分析请求失败';
|
||||
try {
|
||||
const errJson = await resp.json();
|
||||
errMessage = errJson?.message || errMessage;
|
||||
} catch (e) {
|
||||
// ignore parse failure
|
||||
}
|
||||
throw new Error(errMessage);
|
||||
}
|
||||
|
||||
const reader = resp.body?.getReader();
|
||||
if (!reader) throw new Error('当前环境不支持流式读取');
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
|
||||
const processSseBlock = (block) => {
|
||||
const lines = block.split('\n');
|
||||
let eventName = 'message';
|
||||
const dataLines = [];
|
||||
lines.forEach((line) => {
|
||||
const clean = line.trimEnd();
|
||||
if (clean.startsWith('event:')) eventName = clean.slice(6).trim();
|
||||
else if (clean.startsWith('data:')) dataLines.push(clean.slice(5).trim());
|
||||
});
|
||||
if (dataLines.length === 0) return;
|
||||
|
||||
let payload = {};
|
||||
const dataText = dataLines.join('\n');
|
||||
try { payload = JSON.parse(dataText); } catch (e) { payload = { message: dataText }; }
|
||||
|
||||
if (eventName === 'token') {
|
||||
const delta = String(payload?.content || '');
|
||||
if (delta) setAiResult((prev) => `${prev}${delta}`);
|
||||
return;
|
||||
}
|
||||
if (eventName === 'meta') {
|
||||
setAiMeta((prev) => ({
|
||||
...(prev || {}),
|
||||
provider: payload?.provider || prev?.provider,
|
||||
model: payload?.model || prev?.model,
|
||||
generatedAt: payload?.generatedAt || prev?.generatedAt || new Date().toISOString()
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (eventName === 'done') {
|
||||
setAiMeta((prev) => ({
|
||||
...(prev || {}),
|
||||
provider: payload?.provider || prev?.provider,
|
||||
model: payload?.model || prev?.model,
|
||||
generatedAt: payload?.generatedAt || prev?.generatedAt || new Date().toISOString(),
|
||||
triedProviders: payload?.triedProviders || prev?.triedProviders,
|
||||
summary: payload?.datasetSummary || prev?.summary,
|
||||
protection: payload?.protection || prev?.protection,
|
||||
mode: payload?.mode || prev?.mode,
|
||||
nameSamples: payload?.nameSamples || prev?.nameSamples
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (eventName === 'error') throw new Error(payload?.message || 'AI 分析服务异常');
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true }).replace(/\r/g, '');
|
||||
let splitIndex = buffer.indexOf('\n\n');
|
||||
while (splitIndex !== -1) {
|
||||
processSseBlock(buffer.slice(0, splitIndex));
|
||||
buffer = buffer.slice(splitIndex + 2);
|
||||
splitIndex = buffer.indexOf('\n\n');
|
||||
}
|
||||
}
|
||||
if (buffer.trim()) processSseBlock(buffer);
|
||||
message.success('AI 分析已生成');
|
||||
} catch (e) {
|
||||
if (e?.name === 'AbortError') message.info('已停止并清空输出');
|
||||
else message.error(e?.message || 'AI 分析失败');
|
||||
} finally {
|
||||
aiAbortRef.current = null;
|
||||
setAiLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAiOutput = () => {
|
||||
if (aiAbortRef.current) {
|
||||
aiAbortRef.current.abort();
|
||||
aiAbortRef.current = null;
|
||||
}
|
||||
setAiResult('');
|
||||
setAiMeta(null);
|
||||
setAiMode('standard');
|
||||
setAiNameLimit(100);
|
||||
setAiNameStrategy('latest');
|
||||
setAiFilters({ year: 'all', type: 'all', status: 'all', department: 'all' });
|
||||
syncChartsByYear('all');
|
||||
};
|
||||
|
||||
const detailColumns = [
|
||||
{ title: '成果名称', dataIndex: 'name', key: 'name', ellipsis: true },
|
||||
{ title: '主要完成人', dataIndex: 'main_contributors', key: 'main_contributors', ellipsis: true },
|
||||
{ title: '归属部门', dataIndex: 'assigned_departments', key: 'assigned_departments', render: (v) => (v ? v.join(', ') : '-') },
|
||||
{ title: '日期', dataIndex: 'achievement_date', key: 'achievement_date', width: 120, render: (v) => (v ? new Date(v).toLocaleDateString() : '-') },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (status) => <Tag color={{ pending: 'orange', approved: 'green', rejected: 'red' }[status]}>{STATUS_MAP[status]}</Tag>
|
||||
}
|
||||
];
|
||||
|
||||
if (loading || !data) {
|
||||
return <div style={{ textAlign: 'center', padding: '50px' }}><Spin size="large" /></div>;
|
||||
}
|
||||
|
||||
if (viewMode === 'list') {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24, display: 'flex', alignItems: 'center' }}>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={handleBack} style={{ marginRight: 16 }}>返回</Button>
|
||||
<Title level={2} style={{ margin: 0 }}>{detailTitle}</Title>
|
||||
</div>
|
||||
<Card variant="borderless">
|
||||
<Table columns={detailColumns} dataSource={detailList} rowKey="id" loading={detailLoading} pagination={{ pageSize: 10 }} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Title level={2} style={{ marginBottom: 32 }}>统计分析看板</Title>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="统计口径说明:部门相关统计已排除院领导部门;每条成果仅归属一个部门(优先首个非院领导部门,否则回退首个部门)。"
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<Row gutter={[24, 24]}>
|
||||
{canUseAi && (
|
||||
<Col span={24}>
|
||||
<Card title="AI 智能分析(DeepSeek)" variant="borderless">
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={aiMode === 'deep'
|
||||
? '深度模式:发送聚合统计 + 成果名称样本(不发送个人手机号、贡献者姓名、附件内容/路径/文件名)。'
|
||||
: '标准模式:仅发送脱敏聚合统计数据(不发送成果名称、个人手机号、贡献者姓名、附件内容/路径/文件名)。'}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Select value={aiMode} style={{ width: '100%' }} onChange={setAiMode}>
|
||||
{Object.keys(AI_MODE_MAP).map((key) => <Option key={key} value={key}>{AI_MODE_MAP[key]}</Option>)}
|
||||
</Select>
|
||||
</Col>
|
||||
{aiMode === 'deep' && (
|
||||
<Col xs={24} sm={24} md={8}>
|
||||
<div style={{ color: '#666', fontSize: 12, marginBottom: 6 }}>名称样本数量:{aiNameLimit}</div>
|
||||
<Slider min={20} max={300} step={10} value={aiNameLimit} marks={{ 20: '20', 100: '100', 200: '200', 300: '300' }} onChange={(v) => { if (!Array.isArray(v)) setAiNameLimit(v); }} />
|
||||
</Col>
|
||||
)}
|
||||
{aiMode === 'deep' && (
|
||||
<Col xs={24} sm={12} md={4}>
|
||||
<div style={{ color: '#666', fontSize: 12, marginBottom: 6 }}>类型均衡策略(关=最新优先)</div>
|
||||
<Switch checked={aiNameStrategy === 'balanced'} onChange={(checked) => setAiNameStrategy(checked ? 'balanced' : 'latest')} checkedChildren="开" unCheckedChildren="关" />
|
||||
</Col>
|
||||
)}
|
||||
<Col xs={24} sm={12} md={4}>
|
||||
<Select value={aiFilters.year} style={{ width: '100%' }} onChange={handleAiYearChange}>
|
||||
<Option value="all">全部年份</Option>
|
||||
{years.map((y) => <Option key={y} value={String(y)}>{y}年</Option>)}
|
||||
</Select>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={4}>
|
||||
<Select value={aiFilters.type} style={{ width: '100%' }} onChange={(v) => setAiFilters((prev) => ({ ...prev, type: v }))}>
|
||||
<Option value="all">全部类型</Option>
|
||||
{Object.keys(TYPE_MAP).map((k) => <Option key={k} value={k}>{TYPE_MAP[k]}</Option>)}
|
||||
</Select>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={4}>
|
||||
<Select value={aiFilters.status} style={{ width: '100%' }} onChange={(v) => setAiFilters((prev) => ({ ...prev, status: v }))}>
|
||||
<Option value="all">全部状态</Option>
|
||||
{Object.keys(STATUS_MAP).map((k) => <Option key={k} value={k}>{STATUS_MAP[k]}</Option>)}
|
||||
</Select>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={4}>
|
||||
<Select value={aiFilters.department} style={{ width: '100%' }} onChange={(v) => setAiFilters((prev) => ({ ...prev, department: v }))}>
|
||||
<Option value="all">全部部门</Option>
|
||||
{departments.map((d) => <Option key={d} value={d}>{d}</Option>)}
|
||||
</Select>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Input.TextArea rows={4} maxLength={600} value={aiQuestion} onChange={(e) => setAiQuestion(e.target.value)} placeholder="请描述你要分析的角度,例如:2025年哪个部门成果增长最快,下一步改进重点是什么?" />
|
||||
<Space style={{ marginTop: 12, marginBottom: 12 }}>
|
||||
<Button type="primary" loading={aiLoading} onClick={handleAiAnalyze}>开始 AI 分析</Button>
|
||||
<Button onClick={handleClearAiOutput}>停止并清空</Button>
|
||||
</Space>
|
||||
|
||||
{aiMeta && (
|
||||
<div style={{ color: '#666', marginBottom: 12, fontSize: 12 }}>
|
||||
提供方:{AI_PROVIDER_MAP[aiMeta.provider] || aiMeta.provider || '-'} | 模型:{aiMeta.model || '-'} | 生成时间:{aiMeta.generatedAt ? new Date(aiMeta.generatedAt).toLocaleString() : '-'} | 模式:{aiMeta.mode === 'deep' ? '深度' : '标准'}
|
||||
{aiMeta?.nameSamples?.mode === 'deep' ? ` | 名称样本:${aiMeta?.nameSamples?.sent || 0}/${aiMeta?.nameSamples?.total || 0}${aiMeta?.nameSamples?.truncated ? '(已截断)' : ''}` : ''}
|
||||
{aiMeta?.nameSamples?.mode === 'deep' ? ` | 策略:${aiMeta?.nameSamples?.strategy === 'balanced' ? '类型均衡' : '最新优先'}` : ''}
|
||||
{Array.isArray(aiMeta?.triedProviders) && aiMeta.triedProviders.length > 0 ? ` | 尝试顺序:${aiMeta.triedProviders.join(' -> ')}` : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{aiResult && (
|
||||
<Card size="small" title="AI 分析结果">
|
||||
<div style={{ lineHeight: 1.8, wordBreak: 'break-word' }}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{aiResult}</ReactMarkdown>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
)}
|
||||
|
||||
<Col span={12} style={{ minWidth: 0 }}>
|
||||
<Card
|
||||
title={<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}><span>部门成果占比(饼图)</span><Select value={pieYear} size="small" style={{ width: 100 }} onChange={(v) => setPieYear(String(v))}><Option value="all">全部年份</Option>{years.map((y) => <Option key={y} value={String(y)}>{y}年</Option>)}</Select></div>}
|
||||
variant="borderless"
|
||||
>
|
||||
<ChartContainer height={300} minHeight={240}>
|
||||
<PieChart>
|
||||
<Pie data={pieData} cx="50%" cy="50%" labelLine label={({ name, percent }) => `${name} (${(percent * 100).toFixed(0)}%)`} outerRadius={80} fill="#8884d8" dataKey="value" onClick={handlePieClick} style={{ cursor: 'pointer' }}>
|
||||
{pieData.map((entry, index) => <Cell key={`cell-${index}`} fill={pieColors[index % pieColors.length]} style={{ cursor: 'pointer' }} />)}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={12} style={{ minWidth: 0 }}>
|
||||
<Card
|
||||
title={<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}><span>成果类型分布(雷达图)</span><Select value={radarYear} size="small" style={{ width: 100 }} onChange={(v) => setRadarYear(String(v))}><Option value="all">全部年份</Option>{years.map((y) => <Option key={y} value={String(y)}>{y}年</Option>)}</Select></div>}
|
||||
variant="borderless"
|
||||
>
|
||||
<ChartContainer height={300} minHeight={240}>
|
||||
<RadarChart cx="50%" cy="50%" outerRadius="80%" data={radarData} onClick={handleRadarClick}>
|
||||
<defs>
|
||||
<linearGradient id="radarGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#1890ff" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="#1890ff" stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<PolarGrid stroke="#e8e8e8" />
|
||||
<PolarAngleAxis dataKey="name" onClick={(payload) => handleRadarClick({ payload })} style={{ cursor: 'pointer', fontSize: 12, fontWeight: 'bold' }} tick={{ fill: '#666' }} />
|
||||
<PolarRadiusAxis angle={30} domain={[0, 'auto']} tick={false} axisLine={false} />
|
||||
<Radar name="成果类型数量" dataKey="value" stroke="#1890ff" strokeWidth={2} fill="url(#radarGradient)" fillOpacity={0.6} isAnimationActive={false} onClick={handleRadarClick} style={{ cursor: 'pointer' }} />
|
||||
<Tooltip contentStyle={{ borderRadius: 8, border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)' }} itemStyle={{ color: '#1890ff' }} />
|
||||
</RadarChart>
|
||||
</ChartContainer>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={24} style={{ minWidth: 0 }}>
|
||||
<Card
|
||||
title={<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}><span>各部门成果种类统计</span><Select value={barYear} style={{ width: 120 }} onChange={(v) => setBarYear(String(v))}><Option value="all">全部年份</Option>{years.map((y) => <Option key={y} value={String(y)}>{y}年</Option>)}</Select></div>}
|
||||
variant="borderless"
|
||||
>
|
||||
<div style={{ width: '100%', overflowX: 'auto' }}>
|
||||
<div style={{ minWidth: '100%', width: barData.length > 8 ? barData.length * 100 : '100%' }}>
|
||||
<ChartContainer height={400} minHeight={280}>
|
||||
<BarChart data={barData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
{allTypes.map((name) => <Bar key={name} dataKey={name} fill={typeColorMap[name]} />)}
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={24} style={{ minWidth: 0 }}>
|
||||
<Card
|
||||
title={<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}><span>累计成果与部门趋势(按月/按年)</span><Radio.Group value={trendMode} onChange={(e) => setTrendMode(e.target.value)} buttonStyle="solid"><Radio.Button value="month">按月</Radio.Button><Radio.Button value="year">按年</Radio.Button></Radio.Group></div>}
|
||||
variant="borderless"
|
||||
>
|
||||
<div style={{ width: '100%' }}>
|
||||
{trendData.length > 0 ? (
|
||||
<ChartContainer height={400} minHeight={280}>
|
||||
<ComposedChart data={trendData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis yAxisId="left" label={{ value: '累计成果总量', angle: -90, position: 'insideLeft' }} />
|
||||
<YAxis yAxisId="right" orientation="right" label={{ value: '部门累计数量', angle: 90, position: 'insideRight' }} />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Brush dataKey="name" height={30} stroke="#8884d8" startIndex={trendMode === 'month' && trendData.length > 12 ? trendData.length - 12 : 0} />
|
||||
<Area type="monotone" dataKey="全单位" fill="#f0f0f0" stroke="#8884d8" yAxisId="left" />
|
||||
{allDepts.map((dept) => <Line key={dept} type="monotone" dataKey={dept} stroke={deptColorMap[dept]} strokeWidth={2} dot={false} yAxisId="right" />)}
|
||||
</ComposedChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div style={{ height: 400, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999' }}>暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Statistics;
|
||||
@@ -0,0 +1,207 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Table, Card, Form, Input, DatePicker, Button, Space, Tag, message, Select } from 'antd';
|
||||
import { SearchOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import API_BASE_URL from '../config';
|
||||
import { maskPhone, maskPhonesInText } from '../utils/phoneMask';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
const { Option } = Select;
|
||||
|
||||
const SystemLogs = () => {
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [descriptions, setDescriptions] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
});
|
||||
const [filters, setFilters] = useState({});
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const fetchDescriptions = useCallback(async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await axios.get(`${API_BASE_URL}/api/logs/descriptions`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
setDescriptions(response.data);
|
||||
} catch (error) {
|
||||
console.error('获取操作说明列表失败', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchLogs = useCallback(async (page = 1, pageSize = 20, currentFilters = {}) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const params = {
|
||||
page,
|
||||
limit: pageSize,
|
||||
...currentFilters,
|
||||
};
|
||||
|
||||
// 处理日期范围
|
||||
if (currentFilters.dateRange) {
|
||||
params.startDate = currentFilters.dateRange[0].format('YYYY-MM-DD 00:00:00');
|
||||
params.endDate = currentFilters.dateRange[1].format('YYYY-MM-DD 23:59:59');
|
||||
delete params.dateRange;
|
||||
}
|
||||
|
||||
const response = await axios.get(`${API_BASE_URL}/api/logs`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
params,
|
||||
});
|
||||
|
||||
setLogs(response.data.logs);
|
||||
setPagination({
|
||||
current: response.data.page,
|
||||
pageSize: pageSize,
|
||||
total: response.data.total,
|
||||
});
|
||||
} catch (error) {
|
||||
message.error('获取日志失败');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs(1, 20);
|
||||
fetchDescriptions();
|
||||
}, [fetchLogs, fetchDescriptions]);
|
||||
|
||||
const handleTableChange = (newPagination) => {
|
||||
fetchLogs(newPagination.current, newPagination.pageSize, filters);
|
||||
};
|
||||
|
||||
const onFinish = (values) => {
|
||||
setFilters(values);
|
||||
fetchLogs(1, pagination.pageSize, values);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
form.resetFields();
|
||||
setFilters({});
|
||||
fetchLogs(1, 20, {});
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 180,
|
||||
render: (text) => new Date(text).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '操作人',
|
||||
key: 'user',
|
||||
width: 150,
|
||||
render: (_, record) => (
|
||||
record.real_name ? `${record.real_name} (${maskPhone(record.username)})` : (maskPhone(record.username) || 'Guest')
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'IP地址',
|
||||
dataIndex: 'ip_address',
|
||||
key: 'ip_address',
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: '方法',
|
||||
dataIndex: 'method',
|
||||
key: 'method',
|
||||
width: 80,
|
||||
render: (text) => {
|
||||
let color = 'blue';
|
||||
if (text === 'GET') color = 'green';
|
||||
if (text === 'POST') color = 'blue';
|
||||
if (text === 'PUT') color = 'orange';
|
||||
if (text === 'DELETE') color = 'red';
|
||||
return <Tag color={color}>{text}</Tag>;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作说明',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
width: 150,
|
||||
render: (text) => maskPhonesInText(text) || '-',
|
||||
},
|
||||
{
|
||||
title: '请求路径',
|
||||
dataIndex: 'url',
|
||||
key: 'url',
|
||||
ellipsis: true,
|
||||
render: (text) => maskPhonesInText(text),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 80,
|
||||
render: (status) => {
|
||||
let color = 'green';
|
||||
if (status >= 400) color = 'orange';
|
||||
if (status >= 500) color = 'red';
|
||||
return <Tag color={color}>{status}</Tag>;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '耗时',
|
||||
dataIndex: 'duration',
|
||||
key: 'duration',
|
||||
width: 100,
|
||||
render: (text) => `${text}ms`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card title="系统操作日志">
|
||||
<Form form={form} layout="inline" onFinish={onFinish} style={{ marginBottom: 24 }}>
|
||||
<Form.Item name="username" label="用户">
|
||||
<Input placeholder="姓名或手机号" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="操作说明" style={{ width: 200 }}>
|
||||
<Select placeholder="请选择操作说明" allowClear showSearch>
|
||||
{descriptions.map(desc => (
|
||||
<Option key={desc} value={desc}>{desc}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="dateRange" label="时间范围">
|
||||
<RangePicker showTime />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>
|
||||
查询
|
||||
</Button>
|
||||
<Button onClick={handleReset} icon={<ReloadOutlined />}>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={logs}
|
||||
rowKey="id"
|
||||
pagination={{
|
||||
...pagination,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条记录`,
|
||||
}}
|
||||
loading={loading}
|
||||
onChange={handleTableChange}
|
||||
size="middle"
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemLogs;
|
||||
@@ -0,0 +1,638 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Form, Input, Button, Tabs, DatePicker, InputNumber, Upload, Card, message, Space, Checkbox, Divider, Select, Alert } from 'antd';
|
||||
import { UploadOutlined, PlusOutlined, MinusCircleOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import API_BASE_URL from '../config';
|
||||
import UserSearchSelect from '../components/UserSearchSelect';
|
||||
import { maskPhone } from '../utils/phoneMask';
|
||||
import {
|
||||
achievementTypes,
|
||||
uploadTips,
|
||||
contributorTips,
|
||||
dateTips,
|
||||
nameLabels,
|
||||
contributorLabels,
|
||||
dateLabels,
|
||||
roleOptions,
|
||||
contributorAddButtonLabels,
|
||||
contributorMainLabels,
|
||||
nameTips,
|
||||
sourceTips
|
||||
} from '../constants/achievements';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
const UploadPage = () => {
|
||||
const [form] = Form.useForm();
|
||||
const [type, setType] = useState('project');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fileList, setFileList] = useState([]);
|
||||
// 项目类专用文件列表
|
||||
const [projectProofFiles, setProjectProofFiles] = useState([]); // 立项证明
|
||||
const [projectPlanFiles, setProjectPlanFiles] = useState([]); // 实施方案
|
||||
const [projectSummaryFiles, setProjectSummaryFiles] = useState([]); // 验收总结
|
||||
|
||||
const [dicts, setDicts] = useState({
|
||||
organizations: [],
|
||||
'award-types': [],
|
||||
'award-levels': [],
|
||||
'paper-types': [],
|
||||
'standard-types': [],
|
||||
'project-categories': []
|
||||
});
|
||||
|
||||
const fetchDicts = useMemo(() => async () => {
|
||||
const types = ['organizations', 'award-types', 'award-levels', 'paper-types', 'standard-types', 'project-categories'];
|
||||
const token = localStorage.getItem('token');
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
types.map(t => axios.get(`${API_BASE_URL}/api/dictionaries/${t}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}))
|
||||
);
|
||||
const newDicts = {};
|
||||
types.forEach((t, index) => {
|
||||
newDicts[t] = results[index].data;
|
||||
});
|
||||
setDicts(newDicts);
|
||||
} catch (error) {
|
||||
console.error('获取字典失败', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDicts();
|
||||
}, [fetchDicts]);
|
||||
|
||||
const renderDictSelectWithCustom = (name, label, dictData, rules = []) => {
|
||||
return (
|
||||
<>
|
||||
<Form.Item name={name} label={label} rules={rules}>
|
||||
<Select placeholder={`请选择${label}`}>
|
||||
<Option value="无">无</Option>
|
||||
{dictData.filter(d => d.name !== '待定').map(d => (
|
||||
<Option key={d.id} value={d.name}>{d.name}</Option>
|
||||
))}
|
||||
<Option value="其他">其他</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, current) => prev[name] !== current[name]}
|
||||
>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue(name) === '其他' ? (
|
||||
<Form.Item
|
||||
name={`${name}_custom`}
|
||||
label={`请输入${label}`}
|
||||
rules={[{ required: true, message: `请输入${label}` }]}
|
||||
>
|
||||
<Input placeholder={`请输入${label}`} />
|
||||
</Form.Item>
|
||||
) : null
|
||||
}
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const onFinish = async (values) => {
|
||||
setLoading(true);
|
||||
|
||||
// 处理“其他”选项的自定义输入
|
||||
const customFields = ['first_unit', 'assignee', 'owner_unit'];
|
||||
customFields.forEach(field => {
|
||||
if (values[field] === '其他') {
|
||||
values[field] = values[`${field}_custom`];
|
||||
}
|
||||
delete values[`${field}_custom`];
|
||||
});
|
||||
|
||||
// 前端去重校验
|
||||
const contributors = values.contributors || [];
|
||||
const phoneSet = new Set();
|
||||
for (const c of contributors) {
|
||||
if (c.user) {
|
||||
const u = JSON.parse(c.user);
|
||||
if (phoneSet.has(u.phone)) {
|
||||
message.error(`完成人列表中存在重复的员工:${u.real_name} (${maskPhone(u.phone)})。请删除重复项。`);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
phoneSet.add(u.phone);
|
||||
}
|
||||
}
|
||||
|
||||
// 排名去重校验
|
||||
if (!['project', 'transformation'].includes(type)) {
|
||||
const rankingSet = new Set();
|
||||
for (const c of contributors) {
|
||||
if (c.ranking) {
|
||||
if (rankingSet.has(c.ranking)) {
|
||||
message.error(`完成人排名不能重复:第 ${c.ranking} 名出现了多次。`);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
rankingSet.add(c.ranking);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 附件校验
|
||||
if (type === 'project') {
|
||||
if (projectProofFiles.length === 0) {
|
||||
message.error('请上传立项证明');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (projectPlanFiles.length === 0) {
|
||||
message.error('请上传实施方案');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (projectSummaryFiles.length === 0) {
|
||||
message.error('请上传验收总结');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (fileList.length === 0) {
|
||||
message.error('请至少上传一个附件');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('type', type);
|
||||
|
||||
const finalContributors = (values.contributors || []).map(c => {
|
||||
const u = c.user ? JSON.parse(c.user) : {};
|
||||
return {
|
||||
name: u.real_name,
|
||||
phone: u.phone,
|
||||
role: c.role,
|
||||
isMain: c.isMain,
|
||||
ranking: c.ranking
|
||||
};
|
||||
}).filter(c => c.name);
|
||||
|
||||
const { name, achievement_date, remarks, contributors: _ignoredContributors, approver, ...details } = values;
|
||||
|
||||
formData.append('name', name);
|
||||
formData.append('contributors', JSON.stringify(finalContributors));
|
||||
formData.append('achievement_date', achievement_date ? achievement_date.format('YYYY-MM-DD') : '');
|
||||
formData.append('remarks', remarks || '');
|
||||
|
||||
// 处理签批领导
|
||||
if (type === 'report' && approver) {
|
||||
try {
|
||||
const approverUser = JSON.parse(approver);
|
||||
details.approver_id = approverUser.id;
|
||||
details.approver_name = approverUser.real_name;
|
||||
} catch (e) {
|
||||
console.error('解析签批领导信息失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
formData.append('details', JSON.stringify(details));
|
||||
|
||||
if (type === 'project') {
|
||||
// 项目类附件处理:添加前缀以区分
|
||||
projectProofFiles.forEach(file => {
|
||||
const newFile = new File([file.originFileObj], `[立项证明]-${file.name}`, { type: file.type });
|
||||
formData.append('attachments', newFile);
|
||||
});
|
||||
projectPlanFiles.forEach(file => {
|
||||
const newFile = new File([file.originFileObj], `[实施方案]-${file.name}`, { type: file.type });
|
||||
formData.append('attachments', newFile);
|
||||
});
|
||||
projectSummaryFiles.forEach(file => {
|
||||
const newFile = new File([file.originFileObj], `[验收总结]-${file.name}`, { type: file.type });
|
||||
formData.append('attachments', newFile);
|
||||
});
|
||||
} else {
|
||||
fileList.forEach(file => {
|
||||
formData.append('attachments', file.originFileObj);
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.post(`${API_BASE_URL}/api/achievements`, formData, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
message.success('成果上传成功,请等待管理员审核');
|
||||
form.resetFields();
|
||||
setFileList([]);
|
||||
setProjectProofFiles([]);
|
||||
setProjectPlanFiles([]);
|
||||
setProjectSummaryFiles([]);
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.message || '上传失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderCommonFields = () => {
|
||||
const hasRanking = !['project', 'transformation'].includes(type);
|
||||
const useCheckbox = ['project', 'transformation'].includes(type);
|
||||
const isPaper = type === 'paper';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider orientation="center" style={{ fontWeight: 'bold', fontSize: '16px', margin: '24px 0', color: '#1890ff' }}>成果名称</Divider>
|
||||
{nameTips[type] && (
|
||||
<Alert
|
||||
message={nameTips[type]}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16, width: 'fit-content' }}
|
||||
/>
|
||||
)}
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={nameLabels[type] || "成果名称"}
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Divider orientation="center" style={{ fontWeight: 'bold', fontSize: '16px', margin: '24px 0', color: '#1890ff' }}>完成人信息</Divider>
|
||||
{contributorTips[type] && (
|
||||
<Alert
|
||||
message={contributorTips[type]}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16, width: 'fit-content' }}
|
||||
/>
|
||||
)}
|
||||
<Form.Item label={contributorLabels[type]} style={{ marginBottom: 0 }}>
|
||||
<Form.List
|
||||
name="contributors"
|
||||
initialValue={[{
|
||||
user: undefined,
|
||||
role: isPaper ? '第一作者' : undefined,
|
||||
isMain: useCheckbox,
|
||||
ranking: hasRanking ? 1 : undefined
|
||||
}]}
|
||||
>
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.length > 0 && (
|
||||
<Space style={{ display: 'flex', marginBottom: 4 }} align="baseline">
|
||||
<span style={{ display: 'inline-block', width: 250 }}>
|
||||
<span style={{ color: '#ff4d4f', marginRight: 4 }}>*</span>姓名
|
||||
</span>
|
||||
|
||||
{hasRanking && (
|
||||
<span style={{ display: 'inline-block', width: 80 }}>
|
||||
<span style={{ color: '#ff4d4f', marginRight: 4 }}>*</span>排名
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isPaper && (
|
||||
<span style={{ display: 'inline-block', width: 150 }}>
|
||||
<span style={{ color: '#ff4d4f', marginRight: 4 }}>*</span>角色
|
||||
</span>
|
||||
)}
|
||||
|
||||
{useCheckbox && (
|
||||
<span>
|
||||
{contributorMainLabels[type] || contributorMainLabels.default}
|
||||
</span>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
{fields.map(({ key, name, ...restField }) => (
|
||||
<Space key={key} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, 'user']}
|
||||
rules={[{ required: true, message: '请搜索并选择员工' }]}
|
||||
>
|
||||
<UserSearchSelect placeholder="搜索姓名/手机号" style={{ width: 250 }} />
|
||||
</Form.Item>
|
||||
|
||||
{hasRanking && (
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, 'ranking']}
|
||||
rules={[{ required: true, message: '请输入排名' }]}
|
||||
>
|
||||
<InputNumber min={1} placeholder="排名" style={{ width: 80 }} />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{isPaper && (
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, 'role']}
|
||||
rules={[{ required: true, message: '请选择角色' }]}
|
||||
>
|
||||
<Select placeholder="角色" style={{ width: 150 }}>
|
||||
{roleOptions.map(r => <Option key={r} value={r}>{r}</Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{useCheckbox && (
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, 'isMain']}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Checkbox>
|
||||
{contributorMainLabels[type] || contributorMainLabels.default}
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{fields.length > 1 && (
|
||||
<MinusCircleOutlined onClick={() => remove(name)} />
|
||||
)}
|
||||
</Space>
|
||||
))}
|
||||
<Form.Item>
|
||||
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
|
||||
{contributorAddButtonLabels[type] || contributorAddButtonLabels.default}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form.Item>
|
||||
<Divider orientation="center" style={{ fontWeight: 'bold', fontSize: '16px', margin: '24px 0', color: '#1890ff' }}>成果信息</Divider>
|
||||
|
||||
{dateTips[type] && (
|
||||
<Alert
|
||||
message={dateTips[type]}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12, width: 'fit-content' }}
|
||||
/>
|
||||
)}
|
||||
<Form.Item
|
||||
name="achievement_date"
|
||||
label={dateLabels[type] || "成果时间"}
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="remarks" label="备注"><Input.TextArea rows={3} /></Form.Item>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSpecificFields = () => {
|
||||
switch (type) {
|
||||
case 'paper':
|
||||
return (
|
||||
<>
|
||||
{renderDictSelectWithCustom('first_unit', '完成单位', dicts['organizations'], [{ required: true, message: '请选择完成单位' }])}
|
||||
|
||||
<Form.Item name="paper_type" label="论文水平" rules={[{ required: true, message: '请选择论文水平' }]}>
|
||||
<Select placeholder="请选择论文水平">
|
||||
{dicts['paper-types'].filter(d => d.name !== '待定').map(d => <Select.Option key={d.id} value={d.name}>{d.name}</Select.Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="journal_name" label="期刊名称"><Input /></Form.Item>
|
||||
<Form.Item name="doi" label="DOI"><Input /></Form.Item>
|
||||
</>
|
||||
);
|
||||
case 'project':
|
||||
return (
|
||||
<>
|
||||
<Form.Item name="project_category" label="项目分类">
|
||||
<Select placeholder="请选择项目分类">
|
||||
{dicts['project-categories'].filter(d => d.name !== '待定').map(d => <Select.Option key={d.id} value={d.name}>{d.name}</Select.Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
{sourceTips[type] && (
|
||||
<Alert
|
||||
message={sourceTips[type]}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16, width: 'fit-content' }}
|
||||
/>
|
||||
)}
|
||||
<Form.Item name="source" label="项目来源"><Input /></Form.Item>
|
||||
</>
|
||||
);
|
||||
case 'award':
|
||||
return (
|
||||
<>
|
||||
<Form.Item name="award_type" label="奖励种类">
|
||||
<Select placeholder="请选择奖励种类">
|
||||
{dicts['award-types'].filter(d => d.name !== '待定').map(d => <Select.Option key={d.id} value={d.name}>{d.name}</Select.Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="award_level" label="奖励等级">
|
||||
<Select placeholder="请选择奖励等级">
|
||||
{dicts['award-levels'].filter(d => d.name !== '待定').map(d => <Select.Option key={d.id} value={d.name}>{d.name}</Select.Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="award_unit" label="授奖单位">
|
||||
<Input placeholder="请输入授奖单位" />
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
case 'standard':
|
||||
return (
|
||||
<>
|
||||
<Form.Item name="standard_type" label="标准种类">
|
||||
<Select placeholder="请选择标准种类">
|
||||
{dicts['standard-types'].filter(d => d.name !== '待定').map(d => <Select.Option key={d.id} value={d.name}>{d.name}</Select.Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="standard_no" label="标准编号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="implement_date" label="实施日期"><DatePicker style={{ width: '100%' }} /></Form.Item>
|
||||
</>
|
||||
);
|
||||
case 'monograph':
|
||||
return (
|
||||
<>
|
||||
<Form.Item name="publisher" label="出版社"><Input /></Form.Item>
|
||||
<Form.Item name="isbn" label="ISBN"><Input /></Form.Item>
|
||||
</>
|
||||
);
|
||||
case 'report':
|
||||
return (
|
||||
<>
|
||||
<Form.Item name="report_type" label="技术报告类型" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="recipient" label="发布(提报)对象" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item
|
||||
name="approver"
|
||||
label="签批院领导"
|
||||
rules={[{ required: true, message: '请选择签批院领导' }]}
|
||||
tooltip="仅显示领导层部门的用户"
|
||||
>
|
||||
<UserSearchSelect placeholder="搜索并选择签批领导" leaderOnly={true} />
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
case 'plan':
|
||||
return null;
|
||||
case 'patent':
|
||||
return (
|
||||
<>
|
||||
<Form.Item name="patent_no" label="专利号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
{renderDictSelectWithCustom('assignee', '权利人', dicts['organizations'], [{ required: true, message: '请选择权利人' }])}
|
||||
<Form.Item name="patent_type" label="专利类型" rules={[{ required: true }]}>
|
||||
<Select placeholder="请选择专利类型">
|
||||
<Select.Option value="发明专利">发明专利</Select.Option>
|
||||
<Select.Option value="实用新型专利">实用新型专利</Select.Option>
|
||||
<Select.Option value="外观设计专利">外观设计专利</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
case 'transformation':
|
||||
return (
|
||||
<>
|
||||
<Form.Item name="project_category" label="项目分类">
|
||||
<Select placeholder="请选择项目分类">
|
||||
{dicts['project-categories'].filter(d => d.name !== '待定').map(d => <Select.Option key={d.id} value={d.name}>{d.name}</Select.Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="trans_method" label="转化方式"><Input /></Form.Item>
|
||||
<Form.Item name="trans_amount" label="转化金额 (元)"><InputNumber style={{ width: '100%' }} /></Form.Item>
|
||||
</>
|
||||
);
|
||||
case 'software':
|
||||
return (
|
||||
<>
|
||||
<Form.Item name="reg_no" label="登记号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
{renderDictSelectWithCustom('owner_unit', '著作权人', dicts['organizations'], [{ required: true, message: '请选择著作权人' }])}
|
||||
<Form.Item name="acquisition_method" label="权利取得方式" rules={[{ required: true }]}>
|
||||
<Select placeholder="请选择权利取得方式">
|
||||
<Select.Option value="原始取得">原始取得</Select.Option>
|
||||
<Select.Option value="继受取得">继受取得</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="scope" label="权利范围" rules={[{ required: true }]}>
|
||||
<Select placeholder="请选择权利范围">
|
||||
<Select.Option value="全部权利">全部权利</Select.Option>
|
||||
<Select.Option value="部分权利">部分权利</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return <p>未知类型</p>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title="上传科技成果">
|
||||
<Tabs
|
||||
type="card"
|
||||
size="large"
|
||||
tabBarStyle={{ marginBottom: 24, fontWeight: 'bold' }}
|
||||
activeKey={type}
|
||||
onChange={(key) => {
|
||||
setType(key);
|
||||
form.resetFields();
|
||||
setFileList([]);
|
||||
setProjectProofFiles([]);
|
||||
setProjectPlanFiles([]);
|
||||
setProjectSummaryFiles([]);
|
||||
}}
|
||||
items={achievementTypes.map(t => ({
|
||||
key: t.key,
|
||||
label: t.label,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={onFinish}
|
||||
style={{ marginTop: 24 }}
|
||||
>
|
||||
{renderCommonFields()}
|
||||
{renderSpecificFields()}
|
||||
|
||||
<Divider orientation="center" style={{ fontWeight: 'bold', fontSize: '16px', margin: '24px 0', color: '#1890ff' }}>附件上传</Divider>
|
||||
|
||||
{type === 'project' ? (
|
||||
<>
|
||||
<Alert
|
||||
message="项目类成果必须上传以下三类附件"
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16, width: 'fit-content' }}
|
||||
/>
|
||||
<Form.Item label="立项证明" required>
|
||||
<Upload
|
||||
beforeUpload={() => false}
|
||||
fileList={projectProofFiles}
|
||||
onChange={({ fileList }) => setProjectProofFiles(fileList)}
|
||||
multiple
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>上传立项证明</Button>
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
<Form.Item label="实施方案" required>
|
||||
<Upload
|
||||
beforeUpload={() => false}
|
||||
fileList={projectPlanFiles}
|
||||
onChange={({ fileList }) => setProjectPlanFiles(fileList)}
|
||||
multiple
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>上传实施方案</Button>
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
<Form.Item label="验收总结" required>
|
||||
<Upload
|
||||
beforeUpload={() => false}
|
||||
fileList={projectSummaryFiles}
|
||||
onChange={({ fileList }) => setProjectSummaryFiles(fileList)}
|
||||
multiple
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>上传验收总结</Button>
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<Form.Item>
|
||||
{uploadTips[type] && (
|
||||
<Alert
|
||||
message={uploadTips[type]}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12, width: 'fit-content' }}
|
||||
/>
|
||||
)}
|
||||
<Upload
|
||||
beforeUpload={() => false}
|
||||
fileList={fileList}
|
||||
onChange={({ fileList }) => setFileList(fileList)}
|
||||
multiple
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>选择文件</Button>
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={loading}>
|
||||
提交审核
|
||||
</Button>
|
||||
<Button onClick={() => form.resetFields()}>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadPage;
|
||||
@@ -0,0 +1,480 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Space, Modal, Form, Input, Select, message, Card, Popconfirm, Tag, Progress, DatePicker } from 'antd';
|
||||
import { UserAddOutlined, EditOutlined, DeleteOutlined, SearchOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import JSZip from 'jszip';
|
||||
import { saveAs } from 'file-saver';
|
||||
import html2canvas from 'html2canvas';
|
||||
import API_BASE_URL from '../config';
|
||||
import AchievementFormRenderer from '../components/AchievementFormRenderer';
|
||||
import { maskPhone } from '../utils/phoneMask';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const UserManagement = () => {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState(null);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [exportMonthRange, setExportMonthRange] = useState(null);
|
||||
const [departments, setDepartments] = useState([]);
|
||||
const [form] = Form.useForm();
|
||||
const currentUser = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
|
||||
// 批量导出相关状态
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0 });
|
||||
const [batchCurrentData, setBatchCurrentData] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
fetchDepartments();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const fetchDepartments = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await axios.get(`${API_BASE_URL}/api/dictionaries/departments`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setDepartments(response.data);
|
||||
} catch (error) {
|
||||
message.error('获取部门列表失败');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await axios.get(`${API_BASE_URL}/api/users`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
params: { keyword }
|
||||
});
|
||||
setUsers(response.data);
|
||||
} catch (error) {
|
||||
message.error('获取用户列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingUser(null);
|
||||
form.resetFields();
|
||||
setIsModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (record) => {
|
||||
setEditingUser(record);
|
||||
form.setFieldsValue({
|
||||
username: record.username,
|
||||
role: record.role,
|
||||
real_name: record.real_name,
|
||||
department: record.department,
|
||||
// 批量导出相关状态
|
||||
});
|
||||
setIsModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.delete(`${API_BASE_URL}/api/users/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
message.success('删除成功');
|
||||
fetchUsers();
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (values) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (editingUser) {
|
||||
await axios.put(`${API_BASE_URL}/api/users/${editingUser.id}`, values, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
message.success('修改成功');
|
||||
} else {
|
||||
await axios.post(`${API_BASE_URL}/api/users`, values, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
message.success('创建成功');
|
||||
}
|
||||
setIsModalVisible(false);
|
||||
fetchUsers();
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const hasExportRange = () => (
|
||||
exportMonthRange &&
|
||||
exportMonthRange.length === 2 &&
|
||||
exportMonthRange[0] &&
|
||||
exportMonthRange[1]
|
||||
);
|
||||
|
||||
const getExportDateParams = () => {
|
||||
if (!hasExportRange()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
startDate: exportMonthRange[0].startOf('month').format('YYYY-MM-DD'),
|
||||
endDate: exportMonthRange[1].endOf('month').format('YYYY-MM-DD')
|
||||
};
|
||||
};
|
||||
|
||||
const getExportRangeSuffix = () => {
|
||||
if (!hasExportRange()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${exportMonthRange[0].format('YYYYMM')}_to_${exportMonthRange[1].format('YYYYMM')}`;
|
||||
};
|
||||
|
||||
const triggerExportUserAchievements = (user) => {
|
||||
if (hasExportRange()) {
|
||||
handleExportUserAchievements(user);
|
||||
return;
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: '未选择导出年月范围',
|
||||
content: '当前将导出该用户全部成果。若需按年月导出,请先选择上方“导出年月范围”。',
|
||||
okText: '继续导出',
|
||||
cancelText: '取消',
|
||||
onOk: () => handleExportUserAchievements(user)
|
||||
});
|
||||
};
|
||||
|
||||
const handleExportUserAchievements = async (user) => {
|
||||
setBatchLoading(true);
|
||||
setBatchProgress({ current: 0, total: 0 });
|
||||
const zip = new JSZip();
|
||||
const token = localStorage.getItem('token');
|
||||
const exportDateParams = getExportDateParams();
|
||||
const exportRangeSuffix = getExportRangeSuffix();
|
||||
|
||||
try {
|
||||
// 1. 获取用户的所有成果
|
||||
// 使用 export_action=true 标记,以便后端审计日志记录为“导出用户成果数据”
|
||||
const response = await axios.get(`${API_BASE_URL}/api/achievements`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
params: {
|
||||
keyword: user.username, // 使用手机号作为关键词匹配参与人
|
||||
export_action: 'true',
|
||||
...exportDateParams
|
||||
}
|
||||
});
|
||||
|
||||
const achievements = response.data;
|
||||
if (achievements.length === 0) {
|
||||
if (exportRangeSuffix) {
|
||||
message.warning('该用户在所选年月范围内没有成果数据');
|
||||
setBatchLoading(false);
|
||||
return;
|
||||
}
|
||||
message.warning('该用户没有相关成果数据');
|
||||
setBatchLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setBatchProgress({ current: 0, total: achievements.length });
|
||||
|
||||
// 2. 生成 CSV 清单
|
||||
let csvContent = '\uFEFF'; // BOM for Excel
|
||||
csvContent += "ID,成果名称,类型,状态,提交人,参与人,归属部门,成果时间\n";
|
||||
|
||||
achievements.forEach(item => {
|
||||
const typeMap = {
|
||||
paper: '论文',
|
||||
project: '项目',
|
||||
award: '奖励',
|
||||
standard: '标准',
|
||||
monograph: '专著',
|
||||
report: '技术报告',
|
||||
plan: '规划',
|
||||
patent: '专利',
|
||||
transformation: '成果转化',
|
||||
software: '软著'
|
||||
};
|
||||
const statusMap = {
|
||||
pending: '待审核',
|
||||
approved: '已通过',
|
||||
rejected: '已驳回'
|
||||
};
|
||||
|
||||
const contributors = (item.contributors || []).map(c => c.name).join('; ');
|
||||
const departments = (item.assigned_departments || []).join('; ');
|
||||
const date = item.achievement_date ? new Date(item.achievement_date).toLocaleDateString() : '';
|
||||
|
||||
// 处理字段中的逗号和引号
|
||||
const escape = (field) => `"${String(field || '').replace(/"/g, '""')}"`;
|
||||
|
||||
csvContent += [
|
||||
item.id,
|
||||
escape(item.name),
|
||||
escape(typeMap[item.type] || item.type),
|
||||
escape(statusMap[item.status] || item.status),
|
||||
escape(item.submitter_name),
|
||||
escape(contributors),
|
||||
escape(departments),
|
||||
escape(date)
|
||||
].join(',') + "\n";
|
||||
});
|
||||
|
||||
zip.file(`成果清单_${user.real_name}${exportRangeSuffix ? `_${exportRangeSuffix}` : ''}.csv`, csvContent);
|
||||
|
||||
// 3. 批量生成表单图片
|
||||
for (let i = 0; i < achievements.length; i++) {
|
||||
const item = achievements[i];
|
||||
setBatchProgress({ current: i + 1, total: achievements.length });
|
||||
|
||||
try {
|
||||
// 获取详情
|
||||
const detailRes = await axios.get(`${API_BASE_URL}/api/achievements/${item.id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
const detailData = detailRes.data;
|
||||
|
||||
// 渲染
|
||||
setBatchCurrentData(detailData);
|
||||
await new Promise(resolve => setTimeout(resolve, 500)); // 等待渲染
|
||||
|
||||
// 截图
|
||||
const element = document.getElementById('batch-export-container-user');
|
||||
if (element) {
|
||||
const exportWidth = Math.max(element.scrollWidth, element.offsetWidth);
|
||||
const exportHeight = Math.max(element.scrollHeight, element.offsetHeight);
|
||||
const canvas = await html2canvas(element, {
|
||||
useCORS: true,
|
||||
scale: 2,
|
||||
backgroundColor: '#ffffff',
|
||||
width: exportWidth,
|
||||
height: exportHeight,
|
||||
windowWidth: exportWidth,
|
||||
windowHeight: exportHeight,
|
||||
scrollX: 0,
|
||||
scrollY: 0
|
||||
});
|
||||
|
||||
const imgData = canvas.toDataURL('image/png').split(',')[1];
|
||||
const fileName = `成果表单_${detailData.name || '未命名'}_${detailData.id}.png`;
|
||||
const safeFileName = fileName.replace(/[\\/:*?"<>|]/g, '_');
|
||||
zip.file(safeFileName, imgData, { base64: true });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`处理成果 ${item.id} 失败`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 下载 ZIP
|
||||
const content = await zip.generateAsync({ type: 'blob' });
|
||||
saveAs(content, `用户成果导出_${user.real_name}${exportRangeSuffix ? `_${exportRangeSuffix}` : ''}_${new Date().toISOString().slice(0,10)}.zip`);
|
||||
message.success('导出完成');
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('导出失败');
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
setBatchCurrentData(null);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '手机号', dataIndex: 'username', key: 'username', render: (value) => maskPhone(value) || '-' },
|
||||
{ title: '真实姓名', dataIndex: 'real_name', key: 'real_name' },
|
||||
{
|
||||
title: '部门',
|
||||
dataIndex: 'department',
|
||||
key: 'department',
|
||||
render: (text, record) => (
|
||||
<Space>
|
||||
{text}
|
||||
{record.dept_status === 'pending' && (
|
||||
<Tag color="error">待确认</Tag>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
key: 'role',
|
||||
render: (role) => {
|
||||
const roleMap = {
|
||||
super_admin: { color: 'purple', text: '超级管理员' },
|
||||
admin: { color: 'blue', text: '管理员' },
|
||||
senior_user: { color: 'cyan', text: '高级用户' },
|
||||
intermediate_user: { color: 'geekblue', text: '中级用户' },
|
||||
user: { color: 'green', text: '普通用户' },
|
||||
maintainer: { color: 'orange', text: '系统维护员' }
|
||||
};
|
||||
const { color, text } = roleMap[role] || { color: 'default', text: role };
|
||||
return <Tag color={color}>{text}</Tag>;
|
||||
}
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', render: (t) => new Date(t).toLocaleString() },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 300,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>
|
||||
{(currentUser.role === 'super_admin' || currentUser.role === 'admin') && (
|
||||
<Button icon={<FileTextOutlined />} onClick={() => triggerExportUserAchievements(record)}>导出成果</Button>
|
||||
)}
|
||||
{currentUser.role === 'super_admin' && (
|
||||
<Popconfirm title="确定删除吗?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="用户权限管理"
|
||||
extra={currentUser.role === 'super_admin' && <Button type="primary" icon={<UserAddOutlined />} onClick={handleAdd}>新增用户</Button>}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Input
|
||||
placeholder="搜索手机号/姓名/部门"
|
||||
style={{ width: 250 }}
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
onPressEnter={fetchUsers}
|
||||
allowClear
|
||||
/>
|
||||
<Button type="primary" icon={<SearchOutlined />} onClick={fetchUsers}>查询</Button>
|
||||
{(currentUser.role === 'super_admin' || currentUser.role === 'admin') && (
|
||||
<>
|
||||
<span style={{ color: '#666' }}>导出年月范围:</span>
|
||||
<RangePicker
|
||||
picker="month"
|
||||
allowClear
|
||||
value={exportMonthRange}
|
||||
onChange={(dates) => setExportMonthRange(dates)}
|
||||
placeholder={['导出起始年月', '导出结束年月']}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
<Table columns={columns} dataSource={users} rowKey="id" loading={loading} />
|
||||
|
||||
<Modal
|
||||
title={editingUser ? '编辑用户' : '新增用户'}
|
||||
open={isModalVisible}
|
||||
onOk={() => form.submit()}
|
||||
onCancel={() => setIsModalVisible(false)}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} onFinish={handleSubmit} layout="vertical">
|
||||
<Form.Item
|
||||
name="username"
|
||||
label="手机号"
|
||||
rules={[
|
||||
{ required: true, message: '请输入手机号' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号格式' }
|
||||
]}
|
||||
>
|
||||
<Input disabled={!!editingUser} />
|
||||
</Form.Item>
|
||||
<Form.Item name="real_name" label="真实姓名" rules={[{ required: true, message: '请输入真实姓名' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="department" label="部门" rules={[{ required: true, message: '请选择部门' }]}>
|
||||
<Select showSearch placeholder="请选择部门">
|
||||
{departments.map(dept => (
|
||||
<Select.Option key={dept.id} value={dept.name}>{dept.name}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="role" label="角色" rules={[{ required: true, message: '请选择角色' }]}>
|
||||
<Select disabled={currentUser.role === 'maintainer'}>
|
||||
<Select.Option value="super_admin">超级管理员</Select.Option>
|
||||
<Select.Option value="admin">管理员</Select.Option>
|
||||
<Select.Option value="senior_user">高级用户</Select.Option>
|
||||
<Select.Option value="intermediate_user">中级用户</Select.Option>
|
||||
<Select.Option value="user">普通用户</Select.Option>
|
||||
<Select.Option value="maintainer">系统维护员</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label={editingUser ? "重置密码(留空不修改)" : "初始密码"}
|
||||
rules={[{ required: !editingUser }]}
|
||||
>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 批量导出进度遮罩 */}
|
||||
<Modal
|
||||
open={batchLoading}
|
||||
footer={null}
|
||||
closable={false}
|
||||
centered
|
||||
maskClosable={false}
|
||||
title="正在导出用户成果数据"
|
||||
>
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
<Progress
|
||||
type="circle"
|
||||
percent={batchProgress.total > 0 ? Math.round((batchProgress.current / batchProgress.total) * 100) : 0}
|
||||
format={() => `${batchProgress.current} / ${batchProgress.total}`}
|
||||
/>
|
||||
<div style={{ marginTop: 20, color: '#666' }}>
|
||||
正在生成成果清单和表单图片...<br/>
|
||||
请勿关闭页面,这可能需要几分钟。
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* 隐藏的渲染容器 */}
|
||||
<div
|
||||
id="batch-export-container-user"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: '-100000px',
|
||||
top: 0,
|
||||
width: '1100px',
|
||||
backgroundColor: '#fff',
|
||||
padding: '40px',
|
||||
boxSizing: 'border-box',
|
||||
overflow: 'visible',
|
||||
zIndex: -1
|
||||
}}
|
||||
>
|
||||
{batchCurrentData && (
|
||||
<>
|
||||
<div style={{ textAlign: 'center', marginBottom: '30px', borderBottom: '2px solid #1890ff', paddingBottom: '20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: '10px' }}>
|
||||
<img src="/logo.png" style={{ height: '50px', marginRight: '15px' }} onError={(e) => e.target.style.display='none'} alt="logo" />
|
||||
<h1 style={{ margin: 0, fontSize: '28px', fontWeight: 'bold', color: '#333', whiteSpace: 'normal', wordBreak: 'break-all', overflowWrap: 'anywhere' }}>科技成果管理表单</h1>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>生成时间:{new Date().toLocaleString()}</div>
|
||||
</div>
|
||||
<AchievementFormRenderer data={batchCurrentData} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserManagement;
|
||||
@@ -0,0 +1,13 @@
|
||||
const reportWebVitals = onPerfEntry => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
||||
@@ -0,0 +1,5 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -0,0 +1,17 @@
|
||||
export const maskPhone = (value) => {
|
||||
const text = String(value ?? '').trim();
|
||||
if (!text) return '';
|
||||
|
||||
if (/^1\d{10}$/.test(text)) {
|
||||
return `${text.slice(0, 3)}****${text.slice(7)}`;
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
export const maskPhonesInText = (value) => {
|
||||
const text = String(value ?? '');
|
||||
if (!text) return '';
|
||||
|
||||
return text.replace(/1\d{10}/g, (phone) => maskPhone(phone));
|
||||
};
|
||||
Reference in New Issue
Block a user