Files
tech-achievement-management…/client/src/pages/Upload.js
T
2026-04-19 14:05:40 +08:00

639 lines
23 KiB
JavaScript

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;