chore: initial import
This commit is contained in:
+37
@@ -0,0 +1,37 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
client/node_modules/
|
||||||
|
server/node_modules/
|
||||||
|
server/server/node_modules/
|
||||||
|
server/server/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
client/build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
server/uploads/
|
||||||
|
server/logs/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
server/.env
|
||||||
|
!server/.env.example
|
||||||
|
client/.env
|
||||||
|
client/.env.*
|
||||||
|
|
||||||
|
# Local caches
|
||||||
|
client/.npm-cache/
|
||||||
|
|
||||||
|
# IDE / OS
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Local release artifacts
|
||||||
|
_release/
|
||||||
|
|
||||||
|
# Large local installers
|
||||||
|
配置包/
|
||||||
+487
@@ -0,0 +1,487 @@
|
|||||||
|
# 科技成果转化管理系统 — PPT 提纲(草案)
|
||||||
|
|
||||||
|
> 用途:领导汇报型讲解 PPT,包含系统开发逻辑、开发历程、开发心得,并强调“小切口解决问题”的实践。包含安全专题与关键代码摘录。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 封面
|
||||||
|
- 项目名称:科技成果转化管理系统
|
||||||
|
- 汇报人 / 团队 / 日期
|
||||||
|
|
||||||
|
## 汇报时长与页数建议(30 分钟)
|
||||||
|
- 建议页数:18–22 页(含 2 张图:系统架构图 + 核心流程图)
|
||||||
|
- 时间分配:导入 2–3 分钟|主体 22–24 分钟|总结 3–4 分钟
|
||||||
|
|
||||||
|
## 30 分钟版(20 页)结构分配
|
||||||
|
- 业务与管理:8 页
|
||||||
|
- 技术与实现:12 页(含安全专题与代码亮点)
|
||||||
|
|
||||||
|
### 20 页目录建议(可直接用作 PPT 页标题)
|
||||||
|
1. 封面
|
||||||
|
2. 背景与目标(业务痛点 + 目标)
|
||||||
|
3. 业务价值与管理收益(闭环、合规、可追溯)
|
||||||
|
4. 系统开发逻辑(业务链路)
|
||||||
|
5. 核心流程图(从申报到归档)
|
||||||
|
6. 业务模块总览(功能地图)
|
||||||
|
7. 权限与角色体系(管理视角)
|
||||||
|
8. 统计分析与决策支持(管理视角)
|
||||||
|
9. 系统总体架构(技术总览)
|
||||||
|
10. 系统架构图(Nginx/Server/DB/文件)
|
||||||
|
11. 数据模型与权限策略(RBAC + 状态约束)
|
||||||
|
12. 上传与文件安全(白名单 + Magic Number)
|
||||||
|
13. 审计与日志体系(可追溯)
|
||||||
|
14. 认证与单点登录(JWT + token_version)
|
||||||
|
15. 接口防护与限流(分级限流 + 验证码)
|
||||||
|
16. 运维保障(磁盘监控 + 告警)
|
||||||
|
17. 开发历程(里程碑)
|
||||||
|
18. 小切口解决问题(工程细节)
|
||||||
|
19. 安全专题(集中总结)
|
||||||
|
20. 价值总结与下一步
|
||||||
|
|
||||||
|
### 页内时间建议(总计 30 分钟)
|
||||||
|
- 1–2 页:每页 1 分钟
|
||||||
|
- 3–8 页(业务/管理 6 页):每页 1–1.5 分钟
|
||||||
|
- 9–16 页(技术 8 页):每页 1.5–2 分钟
|
||||||
|
- 17–20 页(收束 4 页):每页 1 分钟
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 20 页逐页内容(标题 + 关键点 + 口播稿 + 配图建议)
|
||||||
|
|
||||||
|
## 1. 封面
|
||||||
|
关键点
|
||||||
|
- 项目名称、汇报人、单位、日期
|
||||||
|
口播稿
|
||||||
|
- “今天汇报科技成果转化管理系统,重点讲业务闭环、管理价值与技术亮点。”
|
||||||
|
配图建议
|
||||||
|
- 项目 Logo 或简洁科技感背景图
|
||||||
|
|
||||||
|
## 2. 背景与目标
|
||||||
|
关键点
|
||||||
|
- 痛点:成果类型多、流程长、数据分散、统计难
|
||||||
|
- 目标:全生命周期管理,统一流程与口径
|
||||||
|
口播稿
|
||||||
|
- “核心目标是把成果从申报到归档形成标准化闭环,并保证数据口径一致。”
|
||||||
|
配图建议
|
||||||
|
- 痛点对比图或“问题→目标”对照图
|
||||||
|
|
||||||
|
## 3. 业务价值与管理收益
|
||||||
|
关键点
|
||||||
|
- 闭环流程:可控、可追溯
|
||||||
|
- 合规与审计:有据可查
|
||||||
|
- 统计可视化:辅助决策
|
||||||
|
口播稿
|
||||||
|
- “系统不仅解决业务效率,还能把过程做成可追溯的管理证据链。”
|
||||||
|
配图建议
|
||||||
|
- 价值三角图(效率/合规/决策)
|
||||||
|
|
||||||
|
## 4. 系统开发逻辑(业务链路)
|
||||||
|
关键点
|
||||||
|
- 申报→审核→统计→归档→运维
|
||||||
|
- 以业务路径驱动技术设计
|
||||||
|
口播稿
|
||||||
|
- “先确定业务主链路,再围绕关键节点补齐权限、安全与运维。”
|
||||||
|
配图建议
|
||||||
|
- 业务链路流程条
|
||||||
|
|
||||||
|
## 5. 核心流程图(从申报到归档)
|
||||||
|
关键点
|
||||||
|
- 查重拦截、审核分流、通过入库
|
||||||
|
- 统计分析与导出归档
|
||||||
|
口播稿
|
||||||
|
- “每个关键节点都定义了自动校验和权限控制,确保流程闭环。”
|
||||||
|
配图建议
|
||||||
|
- 使用下方 Mermaid 流程图渲染
|
||||||
|
|
||||||
|
## 6. 业务模块总览
|
||||||
|
关键点
|
||||||
|
- 账号权限、成果管理、审核、统计、导出、公告
|
||||||
|
- 模块之间的依赖关系
|
||||||
|
口播稿
|
||||||
|
- “业务模块围绕成果流转展开,管理侧在审核与统计两端发力。”
|
||||||
|
配图建议
|
||||||
|
- 功能模块矩阵或系统功能地图
|
||||||
|
|
||||||
|
## 7. 权限与角色体系(管理视角)
|
||||||
|
关键点
|
||||||
|
- RBAC 角色分级:普通/中级/高级/管理员/维护员/超管
|
||||||
|
- 关键场景:提交人与完成人权限分离、审核回避
|
||||||
|
口播稿
|
||||||
|
- “权限模型保证数据边界清晰,避免同人审核、越权查看。”
|
||||||
|
配图建议
|
||||||
|
- 角色金字塔或权限矩阵简图
|
||||||
|
|
||||||
|
## 8. 统计分析与决策支持
|
||||||
|
关键点
|
||||||
|
- 多维度统计(部门/年度/类型)
|
||||||
|
- 趋势图、雷达图、堆叠图
|
||||||
|
口播稿
|
||||||
|
- “统计不是展示数量,而是提供结构与趋势,支撑管理决策。”
|
||||||
|
配图建议
|
||||||
|
- 统计图示例(柱状/雷达/趋势)
|
||||||
|
|
||||||
|
## 9. 系统总体架构(技术总览)
|
||||||
|
关键点
|
||||||
|
- 前后端分离,RESTful API
|
||||||
|
- Nginx 统一入口,后端服务化
|
||||||
|
口播稿
|
||||||
|
- “技术架构选择以可扩展与可运维为优先。”
|
||||||
|
配图建议
|
||||||
|
- 架构分层示意图
|
||||||
|
|
||||||
|
## 10. 系统架构图(细化)
|
||||||
|
关键点
|
||||||
|
- SPA → Nginx → API → DB/文件
|
||||||
|
- 日志与审计并行
|
||||||
|
口播稿
|
||||||
|
- “数据流与文件流分开管理,方便安全控制与归档。”
|
||||||
|
配图建议
|
||||||
|
- 使用下方 Mermaid 架构图渲染
|
||||||
|
|
||||||
|
## 11. 数据模型与权限策略
|
||||||
|
关键点
|
||||||
|
- JSONB 处理完成人列表
|
||||||
|
- 状态驱动权限(pending/rejected/approved)
|
||||||
|
口播稿
|
||||||
|
- “用 JSONB 保证结构灵活,用状态机控制访问边界。”
|
||||||
|
配图建议
|
||||||
|
- 简化 ER 图或“状态×角色”矩阵
|
||||||
|
|
||||||
|
## 12. 上传与文件安全
|
||||||
|
关键点
|
||||||
|
- 扩展名白名单
|
||||||
|
- Magic Number 校验真实类型
|
||||||
|
- 下载强制 attachment
|
||||||
|
口播稿
|
||||||
|
- “文件安全是最大的攻击入口,因此做了三层防护。”
|
||||||
|
配图建议
|
||||||
|
- 三层防护示意图(白名单/校验/下载控制)
|
||||||
|
|
||||||
|
## 13. 审计与日志体系
|
||||||
|
关键点
|
||||||
|
- 全局审计日志拦截
|
||||||
|
- 语义化动作映射
|
||||||
|
- 入库可追溯
|
||||||
|
口播稿
|
||||||
|
- “任何关键操作都可追溯到人、时间与结果。”
|
||||||
|
配图建议
|
||||||
|
- 日志链路图或“审计追溯”示意
|
||||||
|
|
||||||
|
## 14. 认证与单点登录
|
||||||
|
关键点
|
||||||
|
- JWT + 过期控制
|
||||||
|
- token_version 实现强制下线
|
||||||
|
口播稿
|
||||||
|
- “不依赖复杂系统,用一个版本号实现单点登录与强制失效。”
|
||||||
|
配图建议
|
||||||
|
- Token 版本演进示意图
|
||||||
|
|
||||||
|
## 15. 接口防护与限流
|
||||||
|
关键点
|
||||||
|
- 全局限流 + 认证接口更严限流
|
||||||
|
- 验证码与短信验证机制
|
||||||
|
口播稿
|
||||||
|
- “对高风险入口进行更严格的限流,防暴力破解。”
|
||||||
|
配图建议
|
||||||
|
- “分级限流”示意图
|
||||||
|
|
||||||
|
## 16. 运维保障(监控告警)
|
||||||
|
关键点
|
||||||
|
- 磁盘空间监控
|
||||||
|
- 自动告警通知
|
||||||
|
口播稿
|
||||||
|
- “提前发现问题比处理事故更重要,系统自动告警避免资源耗尽。”
|
||||||
|
配图建议
|
||||||
|
- 监控告警流程图
|
||||||
|
|
||||||
|
## 17. 开发历程(里程碑)
|
||||||
|
关键点
|
||||||
|
- 需求澄清 → 权限模型 → 联调闭环 → 安全加固
|
||||||
|
口播稿
|
||||||
|
- “按关键链路推进,先保证主流程可用,再做安全与运维加固。”
|
||||||
|
配图建议
|
||||||
|
- 时间线图
|
||||||
|
|
||||||
|
## 18. 小切口解决问题
|
||||||
|
关键点
|
||||||
|
- token_version 单点登录
|
||||||
|
- URL 脱敏日志
|
||||||
|
- Magic Number 校验
|
||||||
|
- 磁盘告警
|
||||||
|
口播稿
|
||||||
|
- “这些小机制代价低,但显著降低了安全与运维风险。”
|
||||||
|
配图建议
|
||||||
|
- 四象限小结卡片
|
||||||
|
|
||||||
|
## 19. 安全专题(集中总结)
|
||||||
|
关键点
|
||||||
|
- 认证、密码、传输、接口、文件、日志六层防护
|
||||||
|
口播稿
|
||||||
|
- “安全不是单点技术,是全链路策略组合。”
|
||||||
|
配图建议
|
||||||
|
- 六层安全防护环
|
||||||
|
|
||||||
|
## 20. 价值总结与下一步
|
||||||
|
关键点
|
||||||
|
- 价值:闭环、合规、可追溯、可扩展
|
||||||
|
- 下一步:流程可配置、多租户、统计深化
|
||||||
|
口播稿
|
||||||
|
- “本期完成的是基础闭环,下一步聚焦可配置与更深的管理洞察。”
|
||||||
|
配图建议
|
||||||
|
- “现在/下一步”对比图
|
||||||
|
|
||||||
|
## 1. 项目背景与目标
|
||||||
|
- 业务痛点:成果类型多、流程长、数据分散、审查流程不统一、统计与归档难
|
||||||
|
- 目标:实现成果全生命周期管理(录入—审核—统计—归档/导出),保障合规与安全
|
||||||
|
|
||||||
|
## 2. 系统开发逻辑(从需求到架构)
|
||||||
|
- 业务逻辑主线:成果申报 → 审核流转 → 统计分析 → 归档导出 → 运维保障
|
||||||
|
- 技术逻辑主线:前后端分离 → API 标准化 → RBAC + 状态权限 → 审计与安全 → 可运维
|
||||||
|
|
||||||
|
## 3. 系统总体架构(概览)
|
||||||
|
- SPA 前端 + Node.js 后端 + PostgreSQL + Nginx
|
||||||
|
- 架构图(可引用文档中的 mermaid 图)
|
||||||
|
- 核心数据流:前端请求 → 反向代理 → API → DB/文件存储
|
||||||
|
|
||||||
|
## 3.1 系统架构图(素材)
|
||||||
|
用途:用于 PPT 的“总体架构”页,可直接粘贴成图或用 Mermaid 生成。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
Client[Web 前端 SPA<br/>React] -->|HTTPS/JSON| Nginx[Nginx 反向代理]
|
||||||
|
Nginx -->|Proxy Pass| Server[Node.js 后端 API]
|
||||||
|
Nginx -->|Static Files| Uploads[文件存储 Uploads]
|
||||||
|
Server -->|SQL| DB[(PostgreSQL)]
|
||||||
|
Server -->|审计日志| Logs[日志系统/审计库]
|
||||||
|
```
|
||||||
|
|
||||||
|
讲解要点(口播要点)
|
||||||
|
- 前后端分离,接口统一,安全与运维可控
|
||||||
|
- 文件与数据库分层存储,方便归档与扩展
|
||||||
|
- Nginx 统一入口,支持 HTTPS 与静态资源托管
|
||||||
|
|
||||||
|
## 4. 业务模块拆解
|
||||||
|
- 账号与权限(注册/登录/角色/权限矩阵)
|
||||||
|
- 成果管理(10 种类型、动态表单、附件、查重机制)
|
||||||
|
- 审核管理(多角色分工、回避机制、状态约束)
|
||||||
|
- 统计分析(多维度图表、趋势与贡献)
|
||||||
|
- 归档与导出(CSV、ZIP、电子档案)
|
||||||
|
- 通知公告(富文本、附件、系统告警)
|
||||||
|
|
||||||
|
## 5. 核心数据与权限模型(亮点)
|
||||||
|
- 角色体系:user / intermediate_user / senior_user / admin / maintainer / super_admin
|
||||||
|
- 权限控制:RBAC + 成果状态细粒度限制(pending / rejected / approved)
|
||||||
|
- 关键场景:提交人 vs 完成人权限区分、管理员回避审查
|
||||||
|
|
||||||
|
## 6. 关键技术实现(亮点聚焦)
|
||||||
|
- 文件上传安全:白名单 + Magic Number 校验 + 下载强制 attachment
|
||||||
|
- 审计日志:全局拦截、语义化动作映射、入库审计
|
||||||
|
- 单点登录:JWT + token_version 控制同账号并发
|
||||||
|
- 运维守护:磁盘空间监控 + 自动告警
|
||||||
|
- 限流与防暴力:全局与认证接口分级限流
|
||||||
|
|
||||||
|
## 7. 开发历程(里程碑)
|
||||||
|
- 需求澄清 → 权限模型定稿
|
||||||
|
- 数据结构与接口标准 → 后端骨架
|
||||||
|
- 前后端联调 → 上传/审核/统计闭环
|
||||||
|
- 安全与运维加固 → 上线前合规检查
|
||||||
|
|
||||||
|
## 7.1 核心流程图(素材)
|
||||||
|
用途:用于 PPT 的“业务流程”页,可直接粘贴成图或用 Mermaid 生成。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[用户登录/注册] --> B[成果申报]
|
||||||
|
B --> C{查重拦截}
|
||||||
|
C -- 通过 --> D[进入审核队列]
|
||||||
|
C -- 重复/无效 --> B
|
||||||
|
D --> E{审核结果}
|
||||||
|
E -- 通过 --> F[入库统计/归档]
|
||||||
|
E -- 驳回 --> B
|
||||||
|
F --> G[统计分析]
|
||||||
|
F --> H[导出/归档]
|
||||||
|
G --> I[管理决策支持]
|
||||||
|
```
|
||||||
|
|
||||||
|
讲解要点(口播要点)
|
||||||
|
- 全流程闭环,关键节点有自动校验与权限控制
|
||||||
|
- 审核环节是质量门槛,统计与导出为管理输出
|
||||||
|
|
||||||
|
## 8. 开发过程中的关键抉择(为什么这样做)
|
||||||
|
- 为什么选择前后端分离:迭代快、职责清晰、部署灵活
|
||||||
|
- 为什么权限细分:保障科研数据边界与合规
|
||||||
|
- 为什么强调审计与日志:管理系统必须可追溯
|
||||||
|
|
||||||
|
## 9. 小切口解决问题(可讲的“工程细节”)
|
||||||
|
- Token 版本号:用最小改动实现“单点登录”和强制下线
|
||||||
|
- URL 脱敏日志:一行正则规避日志泄密风险
|
||||||
|
- Magic Number 校验:阻断伪装文件上载
|
||||||
|
- 磁盘空间监控:运维问题提前暴露,避免事故
|
||||||
|
|
||||||
|
## 10. 安全专题(单独章节)
|
||||||
|
- 身份认证:JWT + 过期控制 + token_version
|
||||||
|
- 密码安全:bcrypt 加盐哈希
|
||||||
|
- 传输安全:HTTPS + Nginx 反向代理
|
||||||
|
- 接口防护:Rate Limit + 验证码
|
||||||
|
- 数据与日志:敏感参数脱敏、审计日志入库
|
||||||
|
- 文件安全:白名单、Magic Number、下载强制 attachment
|
||||||
|
|
||||||
|
## 11. 开发心得(可复用的方法)
|
||||||
|
- 先稳定关键路径,再扩展边缘能力
|
||||||
|
- 面向“审计/合规”做系统设计,而不是事后补丁
|
||||||
|
- 把运维与安全作为功能的一部分
|
||||||
|
- 小机制大收益:用最小成本带来可见提升
|
||||||
|
|
||||||
|
## 12. 价值总结与下一步
|
||||||
|
- 业务价值:流程闭环、统计可视化、管理可追溯
|
||||||
|
- 技术价值:统一权限模型、安全与运维体系化
|
||||||
|
- 下一步(可选):多租户、流程可配置、更多统计维度
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 关键代码摘录(可用作“技术亮点/安全章节”附页)
|
||||||
|
|
||||||
|
> 提示:以下代码块可直接截图进 PPT。路径已标注。
|
||||||
|
|
||||||
|
## A. 限流 + 移动端拦截 + 日志脱敏
|
||||||
|
|
||||||
|
路径:`server/index.js`
|
||||||
|
|
||||||
|
```js
|
||||||
|
morgan.token('safe-url', (req) => {
|
||||||
|
let url = req.originalUrl || req.url;
|
||||||
|
url = url.replace(/([?&])(token|password|code)=([^&]+)/gi, '$1$2=***');
|
||||||
|
return url;
|
||||||
|
});
|
||||||
|
|
||||||
|
const globalLimiter = rateLimit({
|
||||||
|
windowMs: 1 * 60 * 1000,
|
||||||
|
max: 100,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const authLimiter = rateLimit({
|
||||||
|
windowMs: 1 * 60 * 1000,
|
||||||
|
max: 10,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const ua = req.headers['user-agent'] || '';
|
||||||
|
const isMobile = /mobile|android|iphone|ipad|phone/i.test(ua);
|
||||||
|
if (isMobile) return res.status(403).send('移动端访问被禁止');
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## B. JWT 校验 + token_version 单点登录
|
||||||
|
|
||||||
|
路径:`server/middleware/auth.js`
|
||||||
|
|
||||||
|
```js
|
||||||
|
const token = req.headers['authorization']?.split(' ')[1] || req.query.token;
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
|
||||||
|
const tokenVersion = decoded.token_version || 0;
|
||||||
|
const result = await db.query('SELECT token_version FROM users WHERE id = $1', [decoded.id]);
|
||||||
|
const dbVersion = result.rows[0].token_version || 0;
|
||||||
|
|
||||||
|
if (tokenVersion !== dbVersion) {
|
||||||
|
return res.status(401).json({ message: '账号在其他设备登录,请重新登录' });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## C. 密码加盐哈希(bcrypt)
|
||||||
|
|
||||||
|
路径:`server/routes/auth.js`
|
||||||
|
|
||||||
|
```js
|
||||||
|
const salt = await bcrypt.genSalt(10);
|
||||||
|
const passwordHash = await bcrypt.hash(newPassword, salt);
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
'UPDATE users SET password_hash = $1, token_version = CASE WHEN token_version >= 2000000000 THEN 1 ELSE token_version + 1 END WHERE username = $2',
|
||||||
|
[passwordHash, phoneNumber]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## D. 文件上传白名单 + Magic Number 校验
|
||||||
|
|
||||||
|
路径:`server/routes/achievements.js`
|
||||||
|
|
||||||
|
```js
|
||||||
|
const allowedExtensions = [
|
||||||
|
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
||||||
|
'.txt', '.jpg', '.jpeg', '.png', '.zip', '.rar', '.7z'
|
||||||
|
];
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage,
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
|
if (allowedExtensions.includes(ext)) cb(null, true);
|
||||||
|
else cb(new Error(`不支持的文件类型 (${ext})`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileType = await FileType.fromFile(file.path);
|
||||||
|
const allowedMimes = [
|
||||||
|
'image/', 'application/pdf', 'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument',
|
||||||
|
'application/vnd.ms-excel', 'application/vnd.ms-powerpoint',
|
||||||
|
'application/zip', 'application/x-zip-compressed',
|
||||||
|
'application/x-rar-compressed', 'application/x-7z-compressed'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (fileType && !allowedMimes.some(m => fileType.mime.startsWith(m))) {
|
||||||
|
fs.unlinkSync(file.path);
|
||||||
|
return res.status(400).json({ message: '文件真实类型不被允许' });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## E. 审计日志(语义映射 + 入库)
|
||||||
|
|
||||||
|
路径:`server/middleware/auditLogger.js`
|
||||||
|
|
||||||
|
```js
|
||||||
|
const getActionDescription = (method, url) => {
|
||||||
|
if (path === '/api/auth/login' && method === 'POST') return '用户登录';
|
||||||
|
if (path === '/api/achievements' && method === 'POST') return '上传成果';
|
||||||
|
// ... 更多动作映射
|
||||||
|
};
|
||||||
|
|
||||||
|
const logMessage = `[AUDIT] IP:${clientIp} | User:${userInfo} | ${method} ${safeUrl} | Action:${description || 'Unknown'} | Status:${status} | Time:${duration}ms`;
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
'INSERT INTO audit_logs (user_id, username, real_name, ip_address, method, url, description, status, duration) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)',
|
||||||
|
[userId, username, realName, clientIp, method, safeUrl, description, status, duration]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## F. 运营级监控:磁盘空间告警
|
||||||
|
|
||||||
|
路径:`server/utils/diskMonitor.js`
|
||||||
|
|
||||||
|
```js
|
||||||
|
const THRESHOLD_BYTES = 10 * 1024 * 1024 * 1024; // 10GB
|
||||||
|
|
||||||
|
if (FreeSpace < THRESHOLD_BYTES) {
|
||||||
|
const title = `【系统警告】磁盘空间不足(${root})`;
|
||||||
|
const content = `剩余空间 ${freeGB} GB,低于阈值 10GB`;
|
||||||
|
await db.query(
|
||||||
|
'INSERT INTO notifications (title, content, publisher_id, publisher_name, is_top, created_at) VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)',
|
||||||
|
[title, content, publisherId, '系统监控', true]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附:引用的项目文档范围
|
||||||
|
- `PROJECT_DOCUMENTATION.md` 的“项目概述、权限体系、业务流程、技术架构、安全防御体系”章节
|
||||||
|
- 代码路径:`server/index.js`, `server/middleware/auth.js`, `server/middleware/auditLogger.js`, `server/routes/auth.js`, `server/routes/achievements.js`, `server/utils/diskMonitor.js`
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
# 科技成果转化管理系统 - 项目综合文档
|
||||||
|
|
||||||
|
本文档整合了系统设计、用户手册、权限矩阵、部署指南及安全策略,是项目的唯一核心文档。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
1. [项目概述](#1-项目概述)
|
||||||
|
2. [角色与权限体系(核心)](#2-角色与权限体系核心)
|
||||||
|
3. [业务流程与功能指南](#3-业务流程与功能指南)
|
||||||
|
4. [技术架构与开发规范](#4-技术架构与开发规范)
|
||||||
|
5. [运维部署与灾难恢复](#5-运维部署与灾难恢复)
|
||||||
|
6. [安全防御体系](#6-安全防御体系)
|
||||||
|
7. [API 接口规范](#7-api-接口规范)
|
||||||
|
8. [数据库设计](#8-数据库设计)
|
||||||
|
9. [常见问题 (FAQ)](#9-常见问题-faq)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 项目概述
|
||||||
|
|
||||||
|
本项目是一个现代化的科技成果转化管理系统,旨在解决企事业单位及科研机构在成果管理中的痛点。系统支持多种科技成果类型的全生命周期管理,包括录入、审核、统计分析及归档导出。
|
||||||
|
|
||||||
|
### 核心特性
|
||||||
|
- **全流程管理**:从成果提交、审核到归档的闭环管理。
|
||||||
|
- **多维度统计**:提供饼图、雷达图、堆叠柱状图、面积图等丰富的可视化分析。
|
||||||
|
- **严格权限控制**:基于角色的访问控制 (RBAC) + 细粒度的状态权限判断。
|
||||||
|
- **安全合规**:移动端拦截、HTTPS 加密、操作日志审计、敏感数据脱敏。
|
||||||
|
- **智能化处理**:自动归属部门、查重拦截、附件生命周期管理。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 角色与权限体系(核心)
|
||||||
|
|
||||||
|
系统设计了精细的权限模型,确保数据安全与业务流转的规范性。
|
||||||
|
|
||||||
|
### 2.1 角色定义
|
||||||
|
|
||||||
|
| 角色 | 代码 | 职责描述 |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **普通用户** | `user` | 业务人员。负责成果数据的录入,查看自己参与的和自己所在科室的已通过成果。 |
|
||||||
|
| **中级用户** | `intermediate_user` | 科室主任。可查看本科室及本科室成员参与的所有已通过成果。 |
|
||||||
|
| **高级用户** | `senior_user` | 单位领导。可查看全单位所有已通过的成果,用于全局浏览和决策支持。 |
|
||||||
|
| **管理员** | `admin` | 管理员。负责审核成果。 |
|
||||||
|
| **系统维护员** | `maintainer` | 技术支持人员。负责字典、部门等基础数据维护,查看系统日志,重置用户密码。**无权访问任何成果数据**。 |
|
||||||
|
| **超级管理员** | `super_admin` | 系统管理。 |
|
||||||
|
|
||||||
|
### 2.2 全量权限操作矩阵
|
||||||
|
|
||||||
|
下表详细列出了不同角色在不同场景下的操作权限区别。
|
||||||
|
|
||||||
|
**图例**:✅ 允许 | ❌ 禁止 | ⚠️ 有限制
|
||||||
|
|
||||||
|
| 功能模块 | 操作行为 | 普通用户<br>(无关人员) | 普通用户<br>(提交人) | 普通用户<br>(完成人) | 中级用户 | 高级用户 | 管理员 | 超级管理员 | 系统维护员 | 权限说明 |
|
||||||
|
| :--- | :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :--- |
|
||||||
|
| **基础访问** | 登录/注册 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | |
|
||||||
|
| | 移动端访问 | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | 全局禁止非PC设备 |
|
||||||
|
| **成果查看** | 查看列表 | ⚠️ | ⚠️ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | 普通/中级/高级按范围过滤;管理员/超管可见全部状态 |
|
||||||
|
| | 查看详情 | ⚠️ | ⚠️ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | 详情权限与列表一致 |
|
||||||
|
| | **查看范围** | 本部门已通过 | 本人待审/驳回 + 本部门已通过 | 个人相关(任意状态) + 本部门已通过 | 本科室已通过(含本科室成员参与) + 个人相关 + 本人待审/驳回 | 全单位已通过 + 个人相关 + 本人待审/驳回 | 全量(含待审/驳回/已通过) | 全量(含待审/驳回/已通过) | 无 | 完成人通过 contributors.phone 精确匹配 |
|
||||||
|
| **成果录入** | 新增成果 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 当前实现未限制维护员 |
|
||||||
|
| **成果修改** | 修改(待审核) | ❌ | ✅ | ❌ | ⚠️ | ⚠️ | ⚠️ | ✅ | ⚠️ | 仅提交人可改;超管可改任意成果 |
|
||||||
|
| | 修改(已驳回) | ❌ | ✅ | ❌ | ⚠️ | ⚠️ | ⚠️ | ✅ | ⚠️ | 仅提交人可改;超管可改任意成果 |
|
||||||
|
| | 修改(已通过) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | 仅超管可修改 |
|
||||||
|
| **成果删除** | 删除(待审核) | ❌ | ✅ | ❌ | ⚠️ | ⚠️ | ⚠️ | ✅ | ⚠️ | 仅提交人可删;超管可删任意成果 |
|
||||||
|
| | 删除(已驳回) | ❌ | ✅ | ❌ | ⚠️ | ⚠️ | ⚠️ | ✅ | ⚠️ | 仅提交人可删;超管可删任意成果 |
|
||||||
|
| | 删除(已通过) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | 仅超管可删 |
|
||||||
|
| **业务审核** | 审核通过/驳回 | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | 管理员回避:不可审自己参与的成果 |
|
||||||
|
| **数据分析** | 查看统计图表 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 统计图默认仅已通过;状态分布仅超管/维护员全量 |
|
||||||
|
| | 导出数据/附件 | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | 仅超管 |
|
||||||
|
| **成果附件** | 下载单个附件 | ❌ | ⚠️ | ✅ | ⚠️ | ⚠️ | ✅ | ✅ | ❌ | 完成人可下;提交人仅待审/驳回可下 |
|
||||||
|
| | 下载成果附件包 | ❌ | ⚠️ | ✅ | ⚠️ | ⚠️ | ✅ | ✅ | ❌ | 与单附件规则一致 |
|
||||||
|
| **系统管理** | 用户管理 | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ⚠️ | 超管全权;维护员仅列表/重置密码 |
|
||||||
|
| | 部门/字典维护 | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | 仅超管/维护员 |
|
||||||
|
| | 查看系统日志 | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | 仅超管/维护员 |
|
||||||
|
| | 发布系统公告 | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | 仅超管/维护员 |
|
||||||
|
| | 全量数据校验 | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | 仅超管 |
|
||||||
|
| **通知公告** | 查看公告列表/详情 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 所有登录用户可看 |
|
||||||
|
| | 下载公告附件 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 所有登录用户可下 |
|
||||||
|
| | 删除公告 | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | 仅超管/维护员可删 |
|
||||||
|
|
||||||
|
### 2.3 特殊场景说明
|
||||||
|
|
||||||
|
#### 场景 A:我是提交人,但不是完成人
|
||||||
|
> **例子**:管理员帮项目组录入成果。
|
||||||
|
- **待审核/已驳回**:拥有完全控制权(修改/删除)。
|
||||||
|
- **已通过**:权限降级为只读。
|
||||||
|
|
||||||
|
#### 场景 B:我是完成人,但不是提交人
|
||||||
|
> **例子**:项目负责人录入,我是成员。
|
||||||
|
- **待审核/已驳回**:**不可见**。为了隐私和准确性,草稿状态仅提交人可见。
|
||||||
|
- **已通过**:可见,但不可修改。
|
||||||
|
|
||||||
|
#### 场景 C:管理员的回避制度
|
||||||
|
- 如果管理员是某项成果的完成人之一,该成果在审核列表中对其**自动隐藏**。
|
||||||
|
- 必须由其他管理员或超级管理员进行审核。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 业务流程与功能指南
|
||||||
|
|
||||||
|
### 3.1 成果申报全流程
|
||||||
|
|
||||||
|
#### 3.1.1 成果类型详解
|
||||||
|
系统支持以下 10 种科技成果类型的申报:
|
||||||
|
1. **论文 (Paper)**: 已发表的学术论文 (SCI/EI/核心等)。
|
||||||
|
2. **专利 (Patent)**: 发明专利、实用新型、外观设计。
|
||||||
|
3. **项目 (Project)**: 纵向课题、横向课题、自选项目。
|
||||||
|
4. **奖励 (Award)**: 国家级、省部级、行业协会奖励。
|
||||||
|
5. **标准 (Standard)**: 国际标准、国家标准、行业标准、地方标准。
|
||||||
|
6. **专著 (Book)**: 出版发行的学术著作、译著。
|
||||||
|
7. **技术报告 (Report)**: 提交给政府或企业的咨询报告、技术方案。
|
||||||
|
8. **规划 (Plan)**: 发展规划、战略研究报告。
|
||||||
|
9. **成果转化 (Transfer)**: 技术转让、许可、作价入股。
|
||||||
|
10. **软著 (Software)**: 计算机软件著作权。
|
||||||
|
|
||||||
|
#### 3.1.2 智能表单填写指南
|
||||||
|
* **动态字段联动**: 不同类型的成果需要填写的字段不同(如论文需填“期刊名称”,专利需填“专利号”)。
|
||||||
|
* **完成人添加与排序**:
|
||||||
|
* 点击“添加完成人”按钮,输入姓名和手机号。
|
||||||
|
* 列表中的**第一位**默认为“主要完成人”(负责人)。
|
||||||
|
* 系统会自动根据**主要完成人**的手机号,查找其在系统中的注册信息,并将该成果自动归属到其所在部门。
|
||||||
|
* **附件上传规范**: 支持 PDF, Office, 图片, 压缩包。单个文件最大 **500MB**。
|
||||||
|
|
||||||
|
#### 3.1.3 查重拦截机制
|
||||||
|
为了避免重复统计,系统内置了查重机制。当您点击提交时,系统会检查:
|
||||||
|
1. **唯一标识符**: 如专利号、论文DOI、软著登记号等是否已存在。
|
||||||
|
2. **名称+日期**: 如果同一天内提交了完全相同的成果名称。
|
||||||
|
如果检测到重复,系统将拦截提交。
|
||||||
|
|
||||||
|
### 3.2 审核与管理 (管理员视角)
|
||||||
|
|
||||||
|
#### 3.2.1 审核流程
|
||||||
|
1. **查看详情**: 点击成果名称,进入详情页。
|
||||||
|
2. **核对信息**: 检查填写的字段是否完整、准确,附件是否清晰、合规。
|
||||||
|
3. **给出结论**:
|
||||||
|
* **通过**: 成果状态变为“已通过”,正式计入统计数据。
|
||||||
|
* **驳回**: 需填写驳回理由。成果状态变为“已驳回”,提交人可修改后重新提交。
|
||||||
|
|
||||||
|
#### 3.2.2 附件清理
|
||||||
|
当成果被“驳回”或“删除”时,为了节省服务器空间,系统会自动清理关联的附件文件。
|
||||||
|
|
||||||
|
### 3.3 数据可视化与决策支持
|
||||||
|
|
||||||
|
进入“统计分析”页面,系统提供多维度的可视化报表。
|
||||||
|
* **部门贡献度分析**:
|
||||||
|
* **堆叠柱状图**: 展示各部门在不同年份的成果产出数量。
|
||||||
|
* **雷达图**: 展示各部门在不同成果类型上的分布,分析“偏科”现象。
|
||||||
|
* **历史趋势分析 (累计增长)**:
|
||||||
|
* **堆叠面积图**: 展示全单位及各部门的**累计**成果贡献趋势。
|
||||||
|
* **双轴组合图**: 左轴展示全单位总量,右轴展示各部门独立增长曲线。
|
||||||
|
|
||||||
|
#### 3.3.1 批量导出与表单生成
|
||||||
|
系统支持将成果数据以“电子档案”的形式批量导出,便于线下归档或上报。
|
||||||
|
* **批量生成表单**: 在用户管理或成果列表中,可选择多条成果,系统会自动将每条成果的详情页渲染为图片。
|
||||||
|
* **打包下载**: 系统将生成的表单图片及相关附件打包为 ZIP 文件,一键下载。
|
||||||
|
|
||||||
|
### 3.4 系统公告与通知
|
||||||
|
系统内置了全员通知系统,支持富文本编辑和附件分发。
|
||||||
|
* **发布公告**: 高级用户及以上角色可发布公告,支持置顶、富文本排版及多附件上传。
|
||||||
|
* **自动报警**: 系统集成了监控机制,当服务器磁盘空间不足(默认阈值 10GB)时,会自动以“系统监控”身份发布置顶报警公告,提醒管理员处理。
|
||||||
|
|
||||||
|
### 3.5 数据一致性校验
|
||||||
|
为了应对历史数据迁移或意外操作可能导致的数据脏读,系统提供了**全量数据校验工具**(仅超级管理员可用)。
|
||||||
|
* **校验范围**:
|
||||||
|
* **完成人结构**: 检查 JSONB 数据格式是否符合规范。
|
||||||
|
* **归属部门**: 检查成果归属部门是否存在于当前部门字典中。
|
||||||
|
* **字典约束**: 检查成果类型、等级等字段是否在合法值范围内。
|
||||||
|
* **一键修复**: 对于部分逻辑错误,系统提供指引或自动修复建议。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 技术架构与开发规范
|
||||||
|
|
||||||
|
### 4.1 总体架构
|
||||||
|
系统采用经典的 **前后端分离 (SPA)** 架构,通过 RESTful API 进行通信。
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
Client[React 前端 SPA] -->|HTTPS/JSON| Nginx[Nginx 反向代理]
|
||||||
|
Nginx -->|Proxy Pass| Server[Node.js 后端 API]
|
||||||
|
Nginx -->|Static Files| Uploads[文件存储]
|
||||||
|
Server -->|SQL| DB[(PostgreSQL 17)]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 目录结构
|
||||||
|
#### 后端 (`server/`)
|
||||||
|
* `index.js`: 应用入口,负责中间件装载、路由分发、HTTPS 启动。
|
||||||
|
* `routes/`: 路由定义层,按业务模块拆分 (auth, achievements, users...)。
|
||||||
|
* `middleware/`: 中间件层 (auth.js 鉴权, auditLogger.js 审计)。
|
||||||
|
* `utils/`: 工具库 (logger.js 日志, diskMonitor.js 监控)。
|
||||||
|
* `scripts/`: 运维脚本 (setup.js 初始化, seed_data.js 种子数据)。
|
||||||
|
|
||||||
|
#### 前端 (`client/src/`)
|
||||||
|
* `pages/`: 页面组件,对应路由 (Dashboard, Upload, Audit...)。
|
||||||
|
* `components/`: 通用业务组件 (MainLayout 布局, UserSearchSelect 用户搜索)。
|
||||||
|
* `constants/`: 常量定义 (achievements.js 定义了10种成果类型的元数据)。
|
||||||
|
|
||||||
|
### 4.3 后端核心机制实现
|
||||||
|
|
||||||
|
#### 4.3.1 安全防护中间件链
|
||||||
|
1. **CORS**: 跨域资源共享控制,严格限制 `origin` 来源,防止恶意跨域请求。
|
||||||
|
2. **RateLimit (限流)**: 全局 100 req/min,认证接口 10 req/min。
|
||||||
|
3. **MobileInterceptor (移动端拦截)**: 通过 User-Agent 识别并拦截移动设备。
|
||||||
|
4. **Morgan (日志脱敏)**: 正则替换 URL 中的 `token`, `password` 为 `***`。
|
||||||
|
|
||||||
|
#### 4.3.2 审计日志系统 (AuditLogger)
|
||||||
|
* **机制**: 拦截所有非 GET 请求(修改性操作)。
|
||||||
|
* **语义翻译**: 内置映射表,将 `POST /api/achievements` 自动翻译为“上传成果”。
|
||||||
|
* **记录内容**: 操作人、IP、时间、耗时、操作结果。
|
||||||
|
|
||||||
|
#### 4.3.3 文件存储策略
|
||||||
|
* **写入**: 始终写入 `UPLOAD_DIR` (主目录)。
|
||||||
|
* **读取**: 先在 `UPLOAD_DIR` 查找,若未找到,遍历 `UPLOAD_FALLBACK_DIRS` (历史目录列表)。
|
||||||
|
* **安全增强**:
|
||||||
|
* **Magic Number 检查**: 引入 `file-type` 库,通过文件二进制头校验真实类型,防止伪装文件上传。
|
||||||
|
* **静态资源防护**: 强制设置 `Content-Disposition: attachment`,防止 HTML/JS 文件在浏览器中直接执行。
|
||||||
|
|
||||||
|
#### 4.3.4 稳定性保障机制
|
||||||
|
* **内存泄漏防护**: 针对验证码等临时数据,使用定时任务 (`setInterval`) 自动清理过期条目,防止内存溢出。
|
||||||
|
* **数据库连接保活**: 监听连接池 `error` 事件,在连接断开时安全处理,防止 Node.js 进程崩溃。
|
||||||
|
|
||||||
|
### 4.4 前端工程化实践
|
||||||
|
* **常量驱动开发**: 在 `client/src/constants/achievements.js` 中定义所有成果类型的元数据,新增类型无需改动业务逻辑。
|
||||||
|
* **组件复用**: 如 `UserSearchSelect` 组件,实现带有防抖的远程搜索下拉框。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 运维部署与灾难恢复
|
||||||
|
|
||||||
|
### 5.1 基础设施规划
|
||||||
|
* **操作系统**: Windows Server 2016+ / Ubuntu 20.04 LTS+ / CentOS 7.9+
|
||||||
|
* **运行环境**: Node.js v18.16.0+ (推荐 v20 LTS)。
|
||||||
|
* *兼容性说明*: 系统内置了对 `fs.promises.statfs` 的特性检测,确保在旧版 Node.js 上也能正常运行。
|
||||||
|
* **数据库**: PostgreSQL 17.x (必须支持 JSONB 类型)
|
||||||
|
* **Web 服务器**: Nginx 1.20+
|
||||||
|
|
||||||
|
### 5.2 Windows 环境部署实战
|
||||||
|
1. **安装依赖**: 使用 `配置包` 安装 Node.js 和 PostgreSQL。
|
||||||
|
2. **后端配置**:
|
||||||
|
* 复制 `.env.example` 为 `.env`。
|
||||||
|
* 配置 `DB_PASSWORD`, `JWT_SECRET`, `UPLOAD_DIR`。
|
||||||
|
* *环境变量支持*: 前端支持 `REACT_APP_API_URL` 环境变量,便于灵活配置 API 地址。
|
||||||
|
* 运行 `npm install` 和 `npm run init-db`。
|
||||||
|
3. **前端构建**:
|
||||||
|
* 运行 `npm install` 和 `npm run build`。
|
||||||
|
4. **Nginx 配置**:
|
||||||
|
* 配置 `client_max_body_size 500M` 支持大文件。
|
||||||
|
* 配置 `proxy_pass` 转发 API 请求。
|
||||||
|
* 配置 `alias` 托管上传文件。
|
||||||
|
5. **HTTPS 配置**: 使用 `mkcert` 生成自签名证书。
|
||||||
|
|
||||||
|
### 5.3 Linux 生产环境部署实战
|
||||||
|
1. **环境构建**: 安装 Node.js, PostgreSQL, Nginx。
|
||||||
|
2. **权限管理**: 创建专用用户 `stapp`,严禁使用 root 运行。
|
||||||
|
3. **进程守护**: 使用 PM2 (`pm2 start index.js --name "st-backend" --time`)。
|
||||||
|
4. **防火墙**: 仅开放 22, 80, 443 端口。
|
||||||
|
|
||||||
|
### 5.4 数据安全与灾难恢复
|
||||||
|
#### 5.4.1 自动备份策略
|
||||||
|
建议在 Linux 下使用 `crontab` 设置每日自动备份数据库。
|
||||||
|
|
||||||
|
#### 5.4.2 专用备份工具 (STBackupTool)
|
||||||
|
对于 Windows 用户,提供图形化工具:
|
||||||
|
* **一键备份**: 自动打包数据库 SQL 和上传文件为 ZIP 包。
|
||||||
|
* **一键恢复**: 自动解压、导入数据、修复序列、还原文件。
|
||||||
|
|
||||||
|
#### 5.4.3 灾难恢复演练
|
||||||
|
**场景:服务器硬盘彻底损坏,需迁移到新机器。**
|
||||||
|
1. **搭建新环境**: 安装 Node.js 和 PostgreSQL。
|
||||||
|
2. **创建空数据库**: **绝对不要运行 `npm run init-db`!** 必须保持数据库为空。
|
||||||
|
3. **执行恢复**: 使用 `STBackupTool` 导入备份包。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 安全防御体系
|
||||||
|
|
||||||
|
### 6.1 网络层防御
|
||||||
|
* **反向代理隐藏**: Nginx 统一入口,隐藏后端端口。
|
||||||
|
* **全站 HTTPS**: 强制 SSL 加密,防止中间人攻击。
|
||||||
|
* **安全响应头**: 启用 `X-Frame-Options`, `X-XSS-Protection`。
|
||||||
|
* **CORS 策略**: 严格限制跨域请求来源,防止 CSRF 攻击。
|
||||||
|
|
||||||
|
### 6.2 应用层防御
|
||||||
|
* **SQL 注入防御**: 全面使用参数化查询。
|
||||||
|
* **文件上传安全**:
|
||||||
|
* **白名单过滤**: 仅允许 PDF/Office/图片/压缩包。
|
||||||
|
* **Magic Number 校验**: 检查文件二进制头,防止伪装文件。
|
||||||
|
* **自动重命名**: 防止文件名冲突和路径注入。
|
||||||
|
* **Token 保护**: 附件下载使用 `Authorization` 头传递 Token,避免 URL 泄露。
|
||||||
|
* **防暴力破解**: 认证接口限流 (10次/分钟),验证码机制。
|
||||||
|
|
||||||
|
### 6.3 数据隐私保护
|
||||||
|
* **密码存储**: bcrypt 加盐哈希。
|
||||||
|
* **日志脱敏**: 自动替换日志中的敏感字段。
|
||||||
|
* **真实 IP 追踪**: 配置 Nginx 传递 `X-Forwarded-For`。
|
||||||
|
|
||||||
|
### 6.4 权限安全
|
||||||
|
* **精确匹配**: 使用 PostgreSQL 的 JSONB 操作符进行权限判断,消除因重名导致的权限漏洞。
|
||||||
|
* **状态隔离**: 严格区分草稿、待审核、已通过状态下的操作权限。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. API 接口规范
|
||||||
|
|
||||||
|
### 7.1 统一响应格式
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": { ... } // 业务数据
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 核心接口定义
|
||||||
|
* **成果上传**: `POST /api/achievements` (Multipart)
|
||||||
|
* **统计数据**: `GET /api/statistics`
|
||||||
|
* **认证**: `POST /api/auth/login`
|
||||||
|
* **日志**: `GET /api/logs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 数据库设计
|
||||||
|
|
||||||
|
### 8.1 核心表结构
|
||||||
|
* **users**: 用户表 (id, username, password_hash, role, department...)
|
||||||
|
* **achievements**: 成果表 (id, type, status, contributors, assigned_departments...)
|
||||||
|
* *数据完整性*: 外键约束配置为 `ON DELETE SET NULL`,防止误删用户导致成果数据丢失。
|
||||||
|
* **audit_logs**: 审计日志表 (id, user_id, method, url, description, status...)
|
||||||
|
|
||||||
|
### 8.2 JSONB 的应用
|
||||||
|
为了应对灵活的“完成人”列表,使用 PostgreSQL 的 `JSONB` 类型存储 `contributors` 字段 `[{name, phone, is_main}]`,避免了复杂的关联表,且查询性能极佳。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 常见问题 (FAQ)
|
||||||
|
|
||||||
|
**Q: 为什么我提交了成果,但在列表里找不到?**
|
||||||
|
A: 默认列表只显示“我的成果”。请检查您是否是该成果的完成人。
|
||||||
|
|
||||||
|
**Q: 附件上传失败,提示“Network Error”?**
|
||||||
|
A: 可能是文件过大(超过500MB)或网络不稳定。建议在内网环境下操作。
|
||||||
|
|
||||||
|
**Q: 如何修改已通过审核的成果?**
|
||||||
|
A: 为了数据严谨性,已通过的成果不允许普通用户修改。请联系超级管理员进行“退回”或“修改”操作。
|
||||||
|
|
||||||
|
**Q: 手机无法访问系统?**
|
||||||
|
A: 系统开启了移动端拦截策略,仅允许 PC 浏览器访问,以确保数据安全。
|
||||||
|
|
||||||
|
## 10. 补充
|
||||||
|
### 10.1 系统相关账号和密码
|
||||||
|
| 类型 | 账号 | 密码 |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| 数据库 | postgre | 020416 |
|
||||||
|
| 服务器 | STsystem | STsystem |
|
||||||
|
| 超级管理员 | 18888888888 | admin123 |
|
||||||
|
| 系统维护员 | 19999999999 | maintainer123 |
|
||||||
|
### 10.2 运行服务器相关服务
|
||||||
|
PostgreSQL自启动服务
|
||||||
|
STsystem_local_backend 系统后端nssm注册服务
|
||||||
|
STsystem_network_nginx Nginx网络代理的nssm注册服务
|
||||||
|
### 10.3 阿里云短信认证服务
|
||||||
|
| 分类 | 配置项 | 详细信息 |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| 平台 | 网址 | https://dypns.console.aliyun.com/overview |
|
||||||
|
| 资源 | 使用情况 | 初级购入300条,现剩余超80% |
|
||||||
|
| 签名 | 名称 | 速通互联验证码 |
|
||||||
|
| 签名 | 使用场景 | 发送测试短信 |
|
||||||
|
| 模板 | 名称 | 登录/注册模板 |
|
||||||
|
| 模板 | Code | 100001 |
|
||||||
|
| 模板 | 内容 | 您的验证码为 ${code}。尊敬的客户,以上验证码 ${min}分钟内有效,请注意保密,切勿告知他人。 |
|
||||||
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# STsystem
|
||||||
|
|
||||||
|
科技成果转化管理系统。
|
||||||
|
|
||||||
|
这是一个前后端分离的内部业务系统,用于科技成果的录入、审核、统计分析、公告通知和基础数据维护,适用于科研院所、企事业单位等成果管理场景。
|
||||||
|
|
||||||
|
## 项目概览
|
||||||
|
|
||||||
|
- 前端:React 19 + Ant Design
|
||||||
|
- 后端:Node.js + Express
|
||||||
|
- 数据库:PostgreSQL 17
|
||||||
|
- 部署:Nginx 反向代理,前后端分离部署
|
||||||
|
|
||||||
|
## 核心能力
|
||||||
|
|
||||||
|
- 成果全流程管理:录入、编辑、审核、归档、导出
|
||||||
|
- 角色权限控制:普通用户、中级用户、高级用户、管理员、维护员、超级管理员
|
||||||
|
- 数据统计分析:按部门、成果类型、年份等维度可视化分析
|
||||||
|
- 系统公告与附件分发
|
||||||
|
- 审计日志、限流、移动端拦截、附件类型校验等安全措施
|
||||||
|
- AI 统计分析接口,基于脱敏后的聚合数据生成分析结论
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
STsystem/
|
||||||
|
├─ client/ 前端项目
|
||||||
|
├─ server/ 后端项目
|
||||||
|
├─ database/ 数据库结构文件
|
||||||
|
├─ static/ 静态页面资源
|
||||||
|
├─ 配置包/ Windows 环境安装包与依赖
|
||||||
|
├─ backup_tool.py 备份恢复工具
|
||||||
|
├─ nginx.conf Nginx 配置示例
|
||||||
|
├─ PROJECT_DOCUMENTATION.md 项目综合文档
|
||||||
|
└─ 第一次升级.md 升级与上线说明
|
||||||
|
```
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
### 1. 安装依赖
|
||||||
|
|
||||||
|
根目录仅提供快捷脚本,实际依赖分别安装在 `client/` 和 `server/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
npm install
|
||||||
|
|
||||||
|
cd ../client
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 配置后端环境变量
|
||||||
|
|
||||||
|
复制 `server/.env.example` 为 `server/.env`,按实际环境填写数据库、JWT、上传目录和 AI 配置。
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
|
在项目根目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run server # 启动后端
|
||||||
|
npm run client # 启动前端
|
||||||
|
npm run build # 构建前端
|
||||||
|
```
|
||||||
|
|
||||||
|
后端目录额外提供:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run init-db
|
||||||
|
npm run seed-500k
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:生产环境上线时不要误执行初始化或造数脚本,尤其是 `init-db`、`setup`、`seed` 一类命令。
|
||||||
|
|
||||||
|
## 生产部署要点
|
||||||
|
|
||||||
|
- 前端构建产物位于 `client/build/`
|
||||||
|
- 后端入口文件为 `server/index.js`
|
||||||
|
- 生产环境通常通过 Nginx 托管前端静态资源,并反向代理 `/api/`
|
||||||
|
- 上传目录由 `UPLOAD_DIR` / `UPLOAD_FALLBACK_DIRS` 控制
|
||||||
|
- 建议保留服务器现有 `.env`,不要把本地开发环境配置直接覆盖到生产环境
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
- 详细项目说明见 [PROJECT_DOCUMENTATION.md](./PROJECT_DOCUMENTATION.md)
|
||||||
|
- 升级与上线说明见 [第一次升级.md](./第一次升级.md)
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
本仓库应保存源码、配置示例和文档,不应提交以下运行时内容:
|
||||||
|
|
||||||
|
- `server/.env`
|
||||||
|
- `node_modules`
|
||||||
|
- `server/uploads`
|
||||||
|
- `server/logs`
|
||||||
|
- 前端本地缓存和临时构建产物
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# 安全技术处理(PPT 右侧代码截图 + 左侧要点说明)
|
||||||
|
|
||||||
|
本页定位:左侧“要点说明”,右侧“代码截图”。每一行要点对应一段代码截图(右侧可用 1~2 张拼接)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 版式建议(16:9)
|
||||||
|
- 左侧 40%:要点说明(5~7 条,配小图标)
|
||||||
|
- 右侧 60%:代码截图拼图(建议 3~4 块)
|
||||||
|
- 页脚:一行小字“安全策略均可在代码中追溯”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 左侧要点说明(可直接贴 PPT)
|
||||||
|
|
||||||
|
**1. 认证与单点登录**
|
||||||
|
- JWT 校验 + 过期处理
|
||||||
|
- token_version 版本号校验,旧设备强制下线
|
||||||
|
- Token 支持 Header / Query,兼容下载接口
|
||||||
|
|
||||||
|
**2. 短信验证码认证(阿里云)**
|
||||||
|
- 阿里云短信通道(签名 + 模板)
|
||||||
|
- 验证码 2 分钟有效、60 秒冷却
|
||||||
|
- 注册/重置均需验证码校验
|
||||||
|
|
||||||
|
**3. 接口安全与访问控制**
|
||||||
|
- 全局限流 + 认证接口更严限流
|
||||||
|
- CORS 白名单限制
|
||||||
|
- 移动端访问拦截(仅允许 PC)
|
||||||
|
|
||||||
|
**4. 上传与附件安全**
|
||||||
|
- 扩展名白名单
|
||||||
|
- Magic Number 校验真实类型
|
||||||
|
- 可执行文件拦截 + 强制下载
|
||||||
|
|
||||||
|
**5. 审计与日志脱敏**
|
||||||
|
- 非 GET 操作全量审计入库
|
||||||
|
- URL 脱敏(token/password/code)
|
||||||
|
- 日志分级 + 按天滚动
|
||||||
|
|
||||||
|
**6. 运维安全保障**
|
||||||
|
- 磁盘空间监控
|
||||||
|
- 低于阈值自动公告告警
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 右侧代码截图清单(按块拼图)
|
||||||
|
|
||||||
|
**块 A|认证与单点登录**
|
||||||
|
- 文件:`server/middleware/auth.js`
|
||||||
|
- 建议截图区域:`verifyToken` 函数(JWT 校验 + token_version)
|
||||||
|
|
||||||
|
**块 B|短信验证码认证(阿里云)**
|
||||||
|
- 文件:`server/utils/sms.js`
|
||||||
|
- 建议截图区域:`sendVerifyCode` 调用 + 短信模板参数
|
||||||
|
|
||||||
|
**块 C|登录/注册/重置密码安全**
|
||||||
|
- 文件:`server/routes/auth.js`
|
||||||
|
- 建议截图区域:
|
||||||
|
- 发送验证码接口:冷却 + 过期
|
||||||
|
- 重置密码:bcrypt + token_version 更新
|
||||||
|
|
||||||
|
**块 D|限流 + CORS + 移动端拦截**
|
||||||
|
- 文件:`server/index.js`
|
||||||
|
- 建议截图区域:`rateLimit` + `cors` + Mobile Interceptor
|
||||||
|
|
||||||
|
**块 E|上传与附件安全**
|
||||||
|
- 文件:`server/routes/achievements.js`
|
||||||
|
- 建议截图区域:白名单 + FileType Magic Number 校验 + 拦截可执行
|
||||||
|
|
||||||
|
**块 F|审计日志与脱敏**
|
||||||
|
- 文件:`server/middleware/auditLogger.js`
|
||||||
|
- 建议截图区域:URL 脱敏 + 审计入库
|
||||||
|
|
||||||
|
**块 G|日志落盘**
|
||||||
|
- 文件:`server/utils/logger.js`
|
||||||
|
- 建议截图区域:winston daily rotate 配置
|
||||||
|
|
||||||
|
**块 H|磁盘空间告警**
|
||||||
|
- 文件:`server/utils/diskMonitor.js`
|
||||||
|
- 建议截图区域:阈值判断 + 生成系统公告
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 右侧拼图布局建议
|
||||||
|
- 推荐 3 列 × 2 行小块拼图:A/B/C/D/E/F
|
||||||
|
- 若空间不足:A/D/E/F 四块即可(覆盖最关键安全点)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 页脚小字(可选)
|
||||||
|
“以上安全策略均在代码中可审计、可追踪、可复现。”
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# TODO: 成果归属部门设计评审
|
||||||
|
|
||||||
|
## 1. 文档目的
|
||||||
|
|
||||||
|
本文件用于说明当前系统“成果归属部门(`assigned_departments`)”的实现现状、已暴露的业务问题,以及推荐的改造方向,供管理层评审后再确定最终实施方案。
|
||||||
|
|
||||||
|
当前阶段仅做方案评审,不进行数据库结构变更。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 现有设计(当前线上逻辑)
|
||||||
|
|
||||||
|
### 2.1 数据结构
|
||||||
|
|
||||||
|
- `achievements.assigned_departments`:`TEXT[]`,用于记录成果归属部门数组。
|
||||||
|
- `achievements.contributors`:`JSONB`,记录完成人列表(含 `name/phone/isMain/ranking/...`)。
|
||||||
|
- 统计与权限大量依赖 `assigned_departments`。
|
||||||
|
|
||||||
|
### 2.2 归属部门计算方式(新增/修改)
|
||||||
|
|
||||||
|
当前归属部门是这样算的:
|
||||||
|
|
||||||
|
1. 先确定主要完成人(`isMain=true`)。
|
||||||
|
2. 用主要完成人手机号去 `users` 反查其部门。
|
||||||
|
3. 将查到的部门去重后写入 `assigned_departments`。
|
||||||
|
|
||||||
|
这意味着:**成果归属只看“主要完成人”,不看其他参与完成人**。
|
||||||
|
|
||||||
|
### 2.3 “主要完成人”来源规则
|
||||||
|
|
||||||
|
- 论文:按角色自动推导(第一作者/通讯作者等)。
|
||||||
|
- 项目/成果转化:由前端勾选 `isMain`。
|
||||||
|
- 其他类型:按最小排名推导主要完成人。
|
||||||
|
|
||||||
|
### 2.4 领导部门字典的当前用途
|
||||||
|
|
||||||
|
- `dict_leader_departments` 当前主要用于“签批院领导”用户搜索过滤(如技术报告签批)。
|
||||||
|
- 它不直接参与 `assigned_departments` 的计算。
|
||||||
|
|
||||||
|
### 2.5 依赖场景
|
||||||
|
|
||||||
|
- 列表与可见范围过滤(如按部门可见)。
|
||||||
|
- 统计分析(按部门产出、趋势等)。
|
||||||
|
- 字典删除联动(部门删除时,成果归属中的对应部门会被替换为“待定”)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 已出现的业务问题
|
||||||
|
|
||||||
|
### 问题 A:院领导主完成人导致归属失真
|
||||||
|
|
||||||
|
当业务上院领导必须/经常被标记为主要完成人时,归属部门会集中到“院领导部门”,导致:
|
||||||
|
|
||||||
|
- 业务承接部门贡献被淹没;
|
||||||
|
- 统计图偏向领导部门,失去决策价值;
|
||||||
|
- 跨部门协同成果无法准确反映。
|
||||||
|
|
||||||
|
### 问题 B:多部门参与信息丢失
|
||||||
|
|
||||||
|
一个成果可能有 3-4 个部门参与,但当前仅按主完成人计算,导致:
|
||||||
|
|
||||||
|
- `assigned_departments` 不完整;
|
||||||
|
- 基于部门的权限与统计存在系统性偏差。
|
||||||
|
|
||||||
|
### 问题 C:语义混杂
|
||||||
|
|
||||||
|
当前“归属部门”既被当作统计口径,也隐含承载了“牵头责任”含义,但这两个概念在业务上并不等价。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 推荐改造方案(不改库结构,优先)
|
||||||
|
|
||||||
|
## 4.1 目标
|
||||||
|
|
||||||
|
- 归属部门改为“参与部门口径”,提高统计与查询真实性。
|
||||||
|
- 保持当前表结构和现有 API 兼容,降低上线风险。
|
||||||
|
|
||||||
|
## 4.2 核心规则(推荐)
|
||||||
|
|
||||||
|
`assigned_departments` 改为按以下顺序计算:
|
||||||
|
|
||||||
|
1. 从 `contributors` 中取所有完成人手机号;
|
||||||
|
2. 反查这些用户所属部门并去重;
|
||||||
|
3. 排除无效值(空、null 等);
|
||||||
|
4. 读取 `dict_leader_departments`:
|
||||||
|
- 默认将领导部门从归属集合剔除(防止“院领导部门吞噬”);
|
||||||
|
5. 如果剔除后为空:
|
||||||
|
- 回退到提交人部门;
|
||||||
|
6. 若提交人部门也不可用:
|
||||||
|
- 回退为 `['待定']`。
|
||||||
|
|
||||||
|
> 说明:该方案不需要新增字段,直接替换归属计算逻辑即可。
|
||||||
|
|
||||||
|
## 4.3 同步改造点
|
||||||
|
|
||||||
|
- 新增成果时:使用新规则计算。
|
||||||
|
- 编辑成果时:使用同一套新规则重算。
|
||||||
|
- 封装公共函数 `computeAssignedDepartments(...)`,避免两处逻辑分叉。
|
||||||
|
|
||||||
|
## 4.4 历史数据处理(建议)
|
||||||
|
|
||||||
|
提供一次性回填脚本,重算历史成果 `assigned_departments`,否则新老口径并存会导致统计长期不一致。
|
||||||
|
|
||||||
|
建议执行流程:
|
||||||
|
|
||||||
|
1. 开发机全量跑脚本;
|
||||||
|
2. 抽样对比典型成果(院领导参与、多部门参与、单部门项目);
|
||||||
|
3. 生产低峰执行;
|
||||||
|
4. 保留执行前快照,支持快速回滚。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 备选方案(供比较)
|
||||||
|
|
||||||
|
### 方案 B:保留“主完成人口径”,仅加领导部门排除
|
||||||
|
|
||||||
|
- 优点:改动最小。
|
||||||
|
- 缺点:多部门参与问题仍未解决,仍会有归属缺失。
|
||||||
|
- 结论:不推荐作为长期方案。
|
||||||
|
|
||||||
|
### 方案 C:双口径(归属部门 + 牵头部门)
|
||||||
|
|
||||||
|
- 优点:业务语义最清晰。
|
||||||
|
- 缺点:通常需要新增字段与更多改造(前后端、统计、权限口径梳理)。
|
||||||
|
- 结论:作为二期优化可评估;本期若要求“尽量不动数据库”,不优先。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 待管理决策项(请示时建议重点确认)
|
||||||
|
|
||||||
|
- 是否同意“归属部门 = 参与部门口径”?
|
||||||
|
- 是否同意默认剔除领导部门字典中的部门?
|
||||||
|
- 剔除后为空时,是否同意“提交人部门优先,最后回退待定”?
|
||||||
|
- 是否同意执行历史数据回填(推荐执行)?
|
||||||
|
- 统计报表是否继续沿用 `assigned_departments`(本期建议继续沿用)?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. TODO 清单(待批准后执行)
|
||||||
|
|
||||||
|
- [ ] 确认最终业务口径(本文件第 6 节)
|
||||||
|
- [ ] 设计并评审 `computeAssignedDepartments` 规则细节
|
||||||
|
- [ ] 修改新增成果归属计算逻辑
|
||||||
|
- [ ] 修改编辑成果归属重算逻辑
|
||||||
|
- [ ] 增加回填脚本(仅更新 `assigned_departments`)
|
||||||
|
- [ ] 生成回填前后抽样对比报告
|
||||||
|
- [ ] 验证权限与统计页面关键场景
|
||||||
|
- [ ] 安排生产执行窗口与回滚预案
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 风险与控制
|
||||||
|
|
||||||
|
- 风险:归属口径改变后,统计口径会发生可见变化。
|
||||||
|
- 控制:上线公告中明确“统计口径优化”;保留回填前快照用于追溯。
|
||||||
|
|
||||||
|
- 风险:部分历史成果完成人手机号与用户表不一致,可能回退到“待定”。
|
||||||
|
- 控制:先跑演练报告,输出异常清单再决定是否人工修正。
|
||||||
|
|
||||||
+472
@@ -0,0 +1,472 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import datetime
|
||||||
|
import zipfile
|
||||||
|
import json
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import filedialog, messagebox, scrolledtext
|
||||||
|
import threading
|
||||||
|
|
||||||
|
class ConfigManager:
|
||||||
|
def __init__(self):
|
||||||
|
self.config_file = "backup_config.json"
|
||||||
|
self.local_config = {
|
||||||
|
"project_root": "",
|
||||||
|
"pg_bin_path": "",
|
||||||
|
"last_backup_path": ""
|
||||||
|
}
|
||||||
|
self.env_config = {}
|
||||||
|
self.load_local_config()
|
||||||
|
|
||||||
|
# 如果有项目根目录,尝试加载 .env
|
||||||
|
if self.local_config["project_root"]:
|
||||||
|
self.load_env(os.path.join(self.local_config["project_root"], "server", ".env"))
|
||||||
|
|
||||||
|
def load_local_config(self):
|
||||||
|
"""加载本地工具配置"""
|
||||||
|
if os.path.exists(self.config_file):
|
||||||
|
try:
|
||||||
|
with open(self.config_file, 'r', encoding='utf-8') as f:
|
||||||
|
saved = json.load(f)
|
||||||
|
self.local_config.update(saved)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"加载配置文件失败: {e}")
|
||||||
|
|
||||||
|
def save_local_config(self):
|
||||||
|
"""保存本地工具配置"""
|
||||||
|
try:
|
||||||
|
with open(self.config_file, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(self.local_config, f, indent=2, ensure_ascii=False)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"保存配置文件失败: {e}")
|
||||||
|
|
||||||
|
def load_env(self, env_path):
|
||||||
|
"""解析 .env 文件"""
|
||||||
|
self.env_config = {}
|
||||||
|
if not os.path.exists(env_path):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(env_path, 'r', encoding='utf-8') as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith('#'):
|
||||||
|
continue
|
||||||
|
if '=' in line:
|
||||||
|
key, value = line.split('=', 1)
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip()
|
||||||
|
if (value.startswith('"') and value.endswith('"')) or \
|
||||||
|
(value.startswith("'") and value.endswith("'")):
|
||||||
|
value = value[1:-1]
|
||||||
|
self.env_config[key] = value
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_env(self, key, default=None):
|
||||||
|
return self.env_config.get(key, default)
|
||||||
|
|
||||||
|
def get_upload_paths(self):
|
||||||
|
"""获取所有需要备份的文件路径"""
|
||||||
|
paths = []
|
||||||
|
server_root = os.path.join(self.local_config["project_root"], "server")
|
||||||
|
|
||||||
|
# 1. 主目录
|
||||||
|
main_dir = self.get_env('UPLOAD_DIR', 'uploads')
|
||||||
|
if os.path.isabs(main_dir):
|
||||||
|
paths.append(main_dir)
|
||||||
|
else:
|
||||||
|
paths.append(os.path.abspath(os.path.join(server_root, main_dir)))
|
||||||
|
|
||||||
|
# 2. 备用目录
|
||||||
|
fallback_str = self.get_env('UPLOAD_FALLBACK_DIRS')
|
||||||
|
if fallback_str:
|
||||||
|
fallbacks = [p.strip() for p in fallback_str.split(',') if p.strip()]
|
||||||
|
for p in fallbacks:
|
||||||
|
abs_path = p if os.path.isabs(p) else os.path.abspath(os.path.join(server_root, p))
|
||||||
|
if abs_path not in paths:
|
||||||
|
paths.append(abs_path)
|
||||||
|
|
||||||
|
# 3. 默认 uploads 目录兜底
|
||||||
|
default_uploads = os.path.abspath(os.path.join(server_root, 'uploads'))
|
||||||
|
if default_uploads not in paths:
|
||||||
|
paths.append(default_uploads)
|
||||||
|
|
||||||
|
return paths
|
||||||
|
|
||||||
|
class BackupApp:
|
||||||
|
def __init__(self, root):
|
||||||
|
self.root = root
|
||||||
|
self.root.title("ST系统 备份与恢复工具 (可配置版)")
|
||||||
|
self.root.geometry("700x600")
|
||||||
|
|
||||||
|
self.config_mgr = ConfigManager()
|
||||||
|
|
||||||
|
# 如果没有配置项目路径,尝试自动检测当前目录
|
||||||
|
if not self.config_mgr.local_config["project_root"]:
|
||||||
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
if os.path.exists(os.path.join(current_dir, "server", ".env")):
|
||||||
|
self.config_mgr.local_config["project_root"] = current_dir
|
||||||
|
self.config_mgr.load_env(os.path.join(current_dir, "server", ".env"))
|
||||||
|
self.config_mgr.save_local_config()
|
||||||
|
|
||||||
|
# 如果没有配置 PG 路径,尝试自动检测
|
||||||
|
if not self.config_mgr.local_config["pg_bin_path"]:
|
||||||
|
found_pg = self.find_pg_bin()
|
||||||
|
if found_pg:
|
||||||
|
self.config_mgr.local_config["pg_bin_path"] = found_pg
|
||||||
|
self.config_mgr.save_local_config()
|
||||||
|
|
||||||
|
self.setup_ui()
|
||||||
|
self.check_status()
|
||||||
|
|
||||||
|
def find_pg_bin(self):
|
||||||
|
"""尝试自动查找 PostgreSQL bin 目录"""
|
||||||
|
try:
|
||||||
|
subprocess.run(['pg_dump', '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
return "SYSTEM_PATH" # 表示在系统环境变量中
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
common_paths = [
|
||||||
|
r"C:\Program Files\PostgreSQL",
|
||||||
|
r"C:\Program Files (x86)\PostgreSQL"
|
||||||
|
]
|
||||||
|
for base in common_paths:
|
||||||
|
if os.path.exists(base):
|
||||||
|
for version in os.listdir(base):
|
||||||
|
bin_path = os.path.join(base, version, "bin")
|
||||||
|
if os.path.exists(os.path.join(bin_path, "pg_dump.exe")):
|
||||||
|
return bin_path
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def setup_ui(self):
|
||||||
|
# 1. 设置区域
|
||||||
|
frame_settings = tk.LabelFrame(self.root, text="环境配置", padx=10, pady=10)
|
||||||
|
frame_settings.pack(fill=tk.X, padx=10, pady=5)
|
||||||
|
|
||||||
|
# 项目路径
|
||||||
|
tk.Label(frame_settings, text="项目根目录:").grid(row=0, column=0, sticky="w")
|
||||||
|
self.entry_project = tk.Entry(frame_settings, width=50)
|
||||||
|
self.entry_project.grid(row=0, column=1, padx=5)
|
||||||
|
self.entry_project.insert(0, self.config_mgr.local_config["project_root"])
|
||||||
|
tk.Button(frame_settings, text="浏览...", command=self.browse_project).grid(row=0, column=2)
|
||||||
|
|
||||||
|
# PG 路径
|
||||||
|
tk.Label(frame_settings, text="PostgreSQL bin:").grid(row=1, column=0, sticky="w")
|
||||||
|
self.entry_pg = tk.Entry(frame_settings, width=50)
|
||||||
|
self.entry_pg.grid(row=1, column=1, padx=5)
|
||||||
|
self.entry_pg.insert(0, self.config_mgr.local_config["pg_bin_path"])
|
||||||
|
tk.Button(frame_settings, text="浏览...", command=self.browse_pg).grid(row=1, column=2)
|
||||||
|
|
||||||
|
tk.Button(frame_settings, text="保存并重新加载配置", command=self.save_settings, bg="#FF9800", fg="white").grid(row=2, column=1, pady=10)
|
||||||
|
|
||||||
|
# 2. 备份路径区域
|
||||||
|
frame_path = tk.LabelFrame(self.root, text="备份保存位置", padx=10, pady=5)
|
||||||
|
frame_path.pack(fill=tk.X, padx=10, pady=5)
|
||||||
|
|
||||||
|
default_backup = self.config_mgr.local_config["last_backup_path"] or os.getcwd()
|
||||||
|
self.lbl_backup_path = tk.Label(frame_path, text=default_backup, relief=tk.SUNKEN, anchor="w", bg="white")
|
||||||
|
self.lbl_backup_path.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 10))
|
||||||
|
tk.Button(frame_path, text="更改路径...", command=self.choose_backup_dir).pack(side=tk.RIGHT)
|
||||||
|
|
||||||
|
# 3. 操作按钮
|
||||||
|
frame_actions = tk.Frame(self.root, pady=10)
|
||||||
|
frame_actions.pack(fill=tk.X)
|
||||||
|
|
||||||
|
self.btn_backup = tk.Button(frame_actions, text="一键备份", command=self.start_backup,
|
||||||
|
bg="#4CAF50", fg="white", font=("Microsoft YaHei", 12, "bold"), height=2, width=20)
|
||||||
|
self.btn_backup.pack(side=tk.LEFT, padx=40)
|
||||||
|
|
||||||
|
self.btn_restore = tk.Button(frame_actions, text="一键恢复", command=self.start_restore,
|
||||||
|
bg="#2196F3", fg="white", font=("Microsoft YaHei", 12, "bold"), height=2, width=20)
|
||||||
|
self.btn_restore.pack(side=tk.RIGHT, padx=40)
|
||||||
|
|
||||||
|
# 3.5 提示信息
|
||||||
|
frame_tips = tk.Frame(self.root)
|
||||||
|
frame_tips.pack(fill=tk.X, pady=5)
|
||||||
|
tk.Label(frame_tips, text="⚠️ 恢复注意事项:请确保数据库为空(不要运行 init-db),否则会因表已存在而失败。",
|
||||||
|
fg="red", font=("Microsoft YaHei", 9)).pack()
|
||||||
|
|
||||||
|
# 4. 信息显示
|
||||||
|
self.lbl_status = tk.Label(self.root, text="正在检查配置...", fg="blue")
|
||||||
|
self.lbl_status.pack(pady=5)
|
||||||
|
|
||||||
|
# 5. 日志
|
||||||
|
tk.Label(self.root, text="运行日志:").pack(anchor="w", padx=10)
|
||||||
|
self.log_area = scrolledtext.ScrolledText(self.root, height=12)
|
||||||
|
self.log_area.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 10))
|
||||||
|
|
||||||
|
def log(self, message):
|
||||||
|
timestamp = datetime.datetime.now().strftime("%H:%M:%S")
|
||||||
|
self.log_area.insert(tk.END, f"[{timestamp}] {message}\n")
|
||||||
|
self.log_area.see(tk.END)
|
||||||
|
|
||||||
|
def browse_project(self):
|
||||||
|
path = filedialog.askdirectory(title="选择项目根目录 (包含 server 文件夹)")
|
||||||
|
if path:
|
||||||
|
self.entry_project.delete(0, tk.END)
|
||||||
|
self.entry_project.insert(0, path)
|
||||||
|
|
||||||
|
def browse_pg(self):
|
||||||
|
path = filedialog.askdirectory(title="选择 PostgreSQL bin 目录")
|
||||||
|
if path:
|
||||||
|
self.entry_pg.delete(0, tk.END)
|
||||||
|
self.entry_pg.insert(0, path)
|
||||||
|
|
||||||
|
def save_settings(self):
|
||||||
|
project_root = self.entry_project.get().strip()
|
||||||
|
pg_bin = self.entry_pg.get().strip()
|
||||||
|
|
||||||
|
self.config_mgr.local_config["project_root"] = project_root
|
||||||
|
self.config_mgr.local_config["pg_bin_path"] = pg_bin
|
||||||
|
self.config_mgr.save_local_config()
|
||||||
|
|
||||||
|
# 重新加载 .env
|
||||||
|
env_path = os.path.join(project_root, "server", ".env")
|
||||||
|
if self.config_mgr.load_env(env_path):
|
||||||
|
self.log("配置保存成功,已加载 .env 文件")
|
||||||
|
else:
|
||||||
|
self.log("配置保存成功,但无法找到或解析 .env 文件!")
|
||||||
|
|
||||||
|
self.check_status()
|
||||||
|
|
||||||
|
def check_status(self):
|
||||||
|
ready = True
|
||||||
|
msg = []
|
||||||
|
|
||||||
|
if not self.config_mgr.env_config:
|
||||||
|
ready = False
|
||||||
|
msg.append("未加载 .env 配置")
|
||||||
|
else:
|
||||||
|
db = self.config_mgr.get_env('DB_DATABASE')
|
||||||
|
msg.append(f"数据库: {db}")
|
||||||
|
|
||||||
|
pg_path = self.config_mgr.local_config["pg_bin_path"]
|
||||||
|
if not pg_path and self.find_pg_bin() == "":
|
||||||
|
msg.append("PG工具: 未配置")
|
||||||
|
ready = False
|
||||||
|
elif pg_path == "SYSTEM_PATH":
|
||||||
|
msg.append("PG工具: 系统环境变量")
|
||||||
|
else:
|
||||||
|
msg.append(f"PG工具: {pg_path}")
|
||||||
|
|
||||||
|
if ready:
|
||||||
|
self.lbl_status.config(text=" | ".join(msg), fg="green")
|
||||||
|
self.btn_backup.config(state=tk.NORMAL)
|
||||||
|
self.btn_restore.config(state=tk.NORMAL)
|
||||||
|
else:
|
||||||
|
self.lbl_status.config(text="配置不完整: " + " | ".join(msg), fg="red")
|
||||||
|
self.btn_backup.config(state=tk.DISABLED)
|
||||||
|
self.btn_restore.config(state=tk.DISABLED)
|
||||||
|
|
||||||
|
def choose_backup_dir(self):
|
||||||
|
path = filedialog.askdirectory(initialdir=self.lbl_backup_path.cget("text"), title="选择备份保存位置")
|
||||||
|
if path:
|
||||||
|
self.lbl_backup_path.config(text=path)
|
||||||
|
self.config_mgr.local_config["last_backup_path"] = path
|
||||||
|
self.config_mgr.save_local_config()
|
||||||
|
self.log(f"备份保存路径已更改为: {path}")
|
||||||
|
|
||||||
|
def get_pg_cmd(self, cmd):
|
||||||
|
pg_path = self.config_mgr.local_config["pg_bin_path"]
|
||||||
|
if pg_path and pg_path != "SYSTEM_PATH":
|
||||||
|
return os.path.join(pg_path, cmd)
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
def start_backup(self):
|
||||||
|
if not messagebox.askyesno("确认备份", "确定要开始备份吗?"):
|
||||||
|
return
|
||||||
|
threading.Thread(target=self.run_backup, daemon=True).start()
|
||||||
|
|
||||||
|
def run_backup(self):
|
||||||
|
try:
|
||||||
|
self.log("=== 开始备份 ===")
|
||||||
|
save_path = self.lbl_backup_path.cget("text")
|
||||||
|
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
|
# 临时目录
|
||||||
|
temp_dir = os.path.join(save_path, f"temp_backup_{timestamp}")
|
||||||
|
os.makedirs(temp_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# 1. 数据库
|
||||||
|
self.log("正在导出数据库...")
|
||||||
|
db_file = os.path.join(temp_dir, "database.sql")
|
||||||
|
env = os.environ.copy()
|
||||||
|
env['PGPASSWORD'] = self.config_mgr.get_env('DB_PASSWORD', '')
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
self.get_pg_cmd('pg_dump'),
|
||||||
|
'-h', self.config_mgr.get_env('DB_HOST', 'localhost'),
|
||||||
|
'-p', self.config_mgr.get_env('DB_PORT', '5432'),
|
||||||
|
'-U', self.config_mgr.get_env('DB_USER', 'postgres'),
|
||||||
|
'-F', 'p', '-f', db_file,
|
||||||
|
self.config_mgr.get_env('DB_DATABASE', 'stsystem')
|
||||||
|
]
|
||||||
|
|
||||||
|
startupinfo = subprocess.STARTUPINFO()
|
||||||
|
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||||
|
|
||||||
|
proc = subprocess.Popen(cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, startupinfo=startupinfo)
|
||||||
|
out, err = proc.communicate()
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise Exception(f"数据库备份失败: {err.decode('gbk', errors='ignore')}")
|
||||||
|
|
||||||
|
# 2. 文件
|
||||||
|
self.log("正在备份文件...")
|
||||||
|
files_dir = os.path.join(temp_dir, "files")
|
||||||
|
os.makedirs(files_dir)
|
||||||
|
|
||||||
|
paths = self.config_mgr.get_upload_paths()
|
||||||
|
for src in paths:
|
||||||
|
if os.path.exists(src):
|
||||||
|
dirname = os.path.basename(src)
|
||||||
|
dest = os.path.join(files_dir, dirname)
|
||||||
|
if os.path.exists(dest): dest += f"_{paths.index(src)}"
|
||||||
|
shutil.copytree(src, dest)
|
||||||
|
|
||||||
|
# 3. 打包
|
||||||
|
self.log("正在打包...")
|
||||||
|
zip_name = f"STSystem_Backup_{timestamp}.zip"
|
||||||
|
zip_full_path = os.path.join(save_path, zip_name)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(zip_full_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for root, dirs, files in os.walk(temp_dir):
|
||||||
|
for file in files:
|
||||||
|
fp = os.path.join(root, file)
|
||||||
|
arcname = os.path.relpath(fp, temp_dir)
|
||||||
|
zf.write(fp, arcname)
|
||||||
|
|
||||||
|
shutil.rmtree(temp_dir)
|
||||||
|
self.log(f"备份成功: {zip_full_path}")
|
||||||
|
messagebox.showinfo("成功", f"备份已保存:\n{zip_full_path}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.log(f"错误: {e}")
|
||||||
|
messagebox.showerror("错误", str(e))
|
||||||
|
|
||||||
|
def start_restore(self):
|
||||||
|
zip_path = filedialog.askopenfilename(filetypes=[("Zip", "*.zip")])
|
||||||
|
if not zip_path: return
|
||||||
|
|
||||||
|
msg = (
|
||||||
|
"⚠️ 高风险操作警告 ⚠️\n\n"
|
||||||
|
"1. 此操作将覆盖数据库和文件。\n"
|
||||||
|
"2. 请确保目标数据库是【空数据库】(不要运行 init-db)。\n"
|
||||||
|
" 如果数据库中已有表,恢复将失败!\n"
|
||||||
|
"3. 请确保已停止 Node 后端服务。\n\n"
|
||||||
|
"确定要继续吗?"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not messagebox.askyesno("确认恢复", msg, icon='warning'):
|
||||||
|
return
|
||||||
|
threading.Thread(target=self.run_restore, args=(zip_path,), daemon=True).start()
|
||||||
|
|
||||||
|
def run_restore(self, zip_path):
|
||||||
|
try:
|
||||||
|
self.log("=== 开始恢复 ===")
|
||||||
|
temp_dir = os.path.join(os.path.dirname(zip_path), "temp_restore_extract")
|
||||||
|
if os.path.exists(temp_dir): shutil.rmtree(temp_dir)
|
||||||
|
os.makedirs(temp_dir)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(zip_path, 'r') as zf:
|
||||||
|
zf.extractall(temp_dir)
|
||||||
|
|
||||||
|
# 1. 数据库
|
||||||
|
self.log("恢复数据库...")
|
||||||
|
db_file = os.path.join(temp_dir, "database.sql")
|
||||||
|
if not os.path.exists(db_file): raise Exception("未找到 database.sql")
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env['PGPASSWORD'] = self.config_mgr.get_env('DB_PASSWORD', '')
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
self.get_pg_cmd('psql'),
|
||||||
|
'-h', self.config_mgr.get_env('DB_HOST', 'localhost'),
|
||||||
|
'-p', self.config_mgr.get_env('DB_PORT', '5432'),
|
||||||
|
'-U', self.config_mgr.get_env('DB_USER', 'postgres'),
|
||||||
|
'-d', self.config_mgr.get_env('DB_DATABASE', 'stsystem'),
|
||||||
|
'-v', 'ON_ERROR_STOP=1', # 遇到错误立即停止
|
||||||
|
'-f', db_file
|
||||||
|
]
|
||||||
|
|
||||||
|
startupinfo = subprocess.STARTUPINFO()
|
||||||
|
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||||
|
proc = subprocess.Popen(cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, startupinfo=startupinfo)
|
||||||
|
out, err = proc.communicate()
|
||||||
|
|
||||||
|
# 记录输出以便调试
|
||||||
|
if out: self.log(f"PSQL OUT: {out.decode('gbk', errors='ignore')}")
|
||||||
|
if err: self.log(f"PSQL ERR: {err.decode('gbk', errors='ignore')}")
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
err_msg = err.decode('gbk', errors='ignore')
|
||||||
|
if "already exists" in err_msg or "已经存在" in err_msg:
|
||||||
|
raise Exception(f"恢复失败: 数据库中已存在表或数据。\n请先清空数据库,或使用 scripts 中的脚本重置数据库。\n详细错误: {err_msg}")
|
||||||
|
raise Exception(f"数据库恢复失败: {err_msg}")
|
||||||
|
|
||||||
|
# 1.5 修复序列 (防止主键冲突)
|
||||||
|
self.log("正在修复数据库序列...")
|
||||||
|
fix_seq_sql = """
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
r RECORD;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN
|
||||||
|
SELECT table_name, column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND column_default LIKE 'nextval%'
|
||||||
|
LOOP
|
||||||
|
EXECUTE 'SELECT setval(' || quote_literal(pg_get_serial_sequence(r.table_name, r.column_name)) || ', COALESCE((SELECT MAX(' || quote_ident(r.column_name) || ') FROM ' || quote_ident(r.table_name) || '), 1), false)';
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
"""
|
||||||
|
|
||||||
|
cmd_fix = [
|
||||||
|
self.get_pg_cmd('psql'),
|
||||||
|
'-h', self.config_mgr.get_env('DB_HOST', 'localhost'),
|
||||||
|
'-p', self.config_mgr.get_env('DB_PORT', '5432'),
|
||||||
|
'-U', self.config_mgr.get_env('DB_USER', 'postgres'),
|
||||||
|
'-d', self.config_mgr.get_env('DB_DATABASE', 'stsystem'),
|
||||||
|
'-c', fix_seq_sql
|
||||||
|
]
|
||||||
|
|
||||||
|
proc_fix = subprocess.Popen(cmd_fix, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, startupinfo=startupinfo)
|
||||||
|
out_fix, err_fix = proc_fix.communicate()
|
||||||
|
|
||||||
|
if proc_fix.returncode != 0:
|
||||||
|
self.log(f"警告: 序列修复失败 (但这可能不影响使用): {err_fix.decode('gbk', errors='ignore')}")
|
||||||
|
else:
|
||||||
|
self.log("序列修复完成。")
|
||||||
|
|
||||||
|
# 2. 文件
|
||||||
|
self.log("恢复文件...")
|
||||||
|
# 恢复到主目录
|
||||||
|
server_root = os.path.join(self.config_mgr.local_config["project_root"], "server")
|
||||||
|
target_dir = self.config_mgr.get_env('UPLOAD_DIR', 'uploads')
|
||||||
|
if not os.path.isabs(target_dir):
|
||||||
|
target_dir = os.path.abspath(os.path.join(server_root, target_dir))
|
||||||
|
|
||||||
|
if not os.path.exists(target_dir): os.makedirs(target_dir)
|
||||||
|
|
||||||
|
files_src = os.path.join(temp_dir, "files")
|
||||||
|
if os.path.exists(files_src):
|
||||||
|
for root, dirs, files in os.walk(files_src):
|
||||||
|
for file in files:
|
||||||
|
shutil.copy2(os.path.join(root, file), os.path.join(target_dir, file))
|
||||||
|
|
||||||
|
shutil.rmtree(temp_dir)
|
||||||
|
self.log("恢复成功!")
|
||||||
|
messagebox.showinfo("成功", "恢复完成!请重启服务。")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.log(f"错误: {e}")
|
||||||
|
messagebox.showerror("错误", str(e))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
root = tk.Tk()
|
||||||
|
app = BackupApp(root)
|
||||||
|
root.mainloop()
|
||||||
@@ -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));
|
||||||
|
};
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
-- 科技成果转化系统 数据库设计 (PostgreSQL) - 完整版
|
||||||
|
|
||||||
|
-- 1. 部门表
|
||||||
|
CREATE TABLE IF NOT EXISTS departments (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2. 用户表
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
username VARCHAR(50) UNIQUE NOT NULL, -- 手机号
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
role VARCHAR(20) NOT NULL CHECK (role IN ('super_admin', 'admin', 'user', 'maintainer', 'senior_user', 'intermediate_user')),
|
||||||
|
real_name VARCHAR(100),
|
||||||
|
department VARCHAR(100), -- 所属部门名称
|
||||||
|
dept_status VARCHAR(20) DEFAULT 'verified', -- 部门审核状态
|
||||||
|
token_version INTEGER DEFAULT 0, -- Token 版本号 (用于单点登录)
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3. 成果基础信息表 (所有类型共用)
|
||||||
|
CREATE TABLE IF NOT EXISTS achievements (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
type VARCHAR(50) NOT NULL, -- 'paper', 'project', 'award', 'standard', 'monograph', 'report', 'plan', 'patent', 'transformation', 'software'
|
||||||
|
name TEXT NOT NULL, -- 成果名称
|
||||||
|
main_contributors TEXT, -- 主要完成人 (字符串格式,兼容旧数据)
|
||||||
|
all_contributors TEXT, -- 完成人 (字符串格式,兼容旧数据)
|
||||||
|
contributor_phones TEXT, -- 完成人手机号 (字符串格式)
|
||||||
|
contributors JSONB DEFAULT '[]', -- 结构化完成人列表 [{name, phone, isMain}]
|
||||||
|
assigned_departments TEXT[] DEFAULT '{}', -- 归属部门数组 (自动计算)
|
||||||
|
achievement_date DATE, -- 时间
|
||||||
|
remarks TEXT, -- 备注
|
||||||
|
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')),
|
||||||
|
audit_comment TEXT, -- 审核意见
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 4. 论文详情表
|
||||||
|
CREATE TABLE IF NOT EXISTS achievement_paper (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
achievement_id INTEGER REFERENCES achievements(id) ON DELETE CASCADE,
|
||||||
|
paper_type TEXT,
|
||||||
|
first_unit TEXT,
|
||||||
|
journal_name TEXT,
|
||||||
|
publish_date DATE,
|
||||||
|
doi VARCHAR(100)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 5. 项目详情表
|
||||||
|
CREATE TABLE IF NOT EXISTS achievement_project (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
achievement_id INTEGER REFERENCES achievements(id) ON DELETE CASCADE,
|
||||||
|
project_category TEXT,
|
||||||
|
source TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 6. 奖励详情表
|
||||||
|
CREATE TABLE IF NOT EXISTS achievement_award (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
achievement_id INTEGER REFERENCES achievements(id) ON DELETE CASCADE,
|
||||||
|
award_type TEXT,
|
||||||
|
award_level TEXT,
|
||||||
|
award_unit TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 7. 标准详情表
|
||||||
|
CREATE TABLE IF NOT EXISTS achievement_standard (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
achievement_id INTEGER REFERENCES achievements(id) ON DELETE CASCADE,
|
||||||
|
standard_type TEXT,
|
||||||
|
standard_no VARCHAR(100) NOT NULL,
|
||||||
|
implement_date DATE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 8. 专著详情表
|
||||||
|
CREATE TABLE IF NOT EXISTS achievement_monograph (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
achievement_id INTEGER REFERENCES achievements(id) ON DELETE CASCADE,
|
||||||
|
publisher TEXT,
|
||||||
|
isbn VARCHAR(50)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 9. 技术报告详情表
|
||||||
|
CREATE TABLE IF NOT EXISTS achievement_report (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
achievement_id INTEGER REFERENCES achievements(id) ON DELETE CASCADE,
|
||||||
|
report_type TEXT,
|
||||||
|
recipient TEXT,
|
||||||
|
approver_id INTEGER REFERENCES users(id), -- 签批院领导ID
|
||||||
|
approver_name VARCHAR(100) -- 签批院领导姓名 (冗余存储,方便显示)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 10. 规划详情表
|
||||||
|
CREATE TABLE IF NOT EXISTS achievement_plan (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
achievement_id INTEGER REFERENCES achievements(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 11. 专利详情表
|
||||||
|
CREATE TABLE IF NOT EXISTS achievement_patent (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
achievement_id INTEGER REFERENCES achievements(id) ON DELETE CASCADE,
|
||||||
|
patent_no VARCHAR(100),
|
||||||
|
patent_type TEXT,
|
||||||
|
assignee TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 12. 成果转化详情表
|
||||||
|
CREATE TABLE IF NOT EXISTS achievement_transformation (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
achievement_id INTEGER REFERENCES achievements(id) ON DELETE CASCADE,
|
||||||
|
trans_method TEXT,
|
||||||
|
trans_amount NUMERIC(15, 2),
|
||||||
|
project_category TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 13. 软著详情表
|
||||||
|
CREATE TABLE IF NOT EXISTS achievement_software (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
achievement_id INTEGER REFERENCES achievements(id) ON DELETE CASCADE,
|
||||||
|
reg_no VARCHAR(100),
|
||||||
|
acquisition_method TEXT,
|
||||||
|
scope TEXT,
|
||||||
|
owner_unit TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 14. 附件表
|
||||||
|
CREATE TABLE IF NOT EXISTS achievement_attachments (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
achievement_id INTEGER REFERENCES achievements(id) ON DELETE CASCADE,
|
||||||
|
file_name TEXT NOT NULL,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 15. 字典表 (由超级管理员管理)
|
||||||
|
|
||||||
|
-- 单位名称表
|
||||||
|
CREATE TABLE IF NOT EXISTS dict_organizations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 奖励种类表
|
||||||
|
CREATE TABLE IF NOT EXISTS dict_award_types (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 奖励等级表
|
||||||
|
CREATE TABLE IF NOT EXISTS dict_award_levels (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 论文种类表
|
||||||
|
CREATE TABLE IF NOT EXISTS dict_paper_types (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 标准种类表
|
||||||
|
CREATE TABLE IF NOT EXISTS dict_standard_types (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 项目分类表
|
||||||
|
CREATE TABLE IF NOT EXISTS dict_project_categories (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 领导层部门表 (用于筛选签批领导)
|
||||||
|
CREATE TABLE IF NOT EXISTS dict_leader_departments (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 16. 审计日志表
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER REFERENCES users(id),
|
||||||
|
username VARCHAR(50),
|
||||||
|
real_name VARCHAR(50),
|
||||||
|
ip_address VARCHAR(50),
|
||||||
|
method VARCHAR(10),
|
||||||
|
url TEXT,
|
||||||
|
description VARCHAR(255),
|
||||||
|
status INTEGER,
|
||||||
|
duration INTEGER,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 17. 系统公告表
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
publisher_id INTEGER REFERENCES users(id),
|
||||||
|
publisher_name VARCHAR(100),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_top BOOLEAN DEFAULT FALSE,
|
||||||
|
status VARCHAR(20) DEFAULT 'published' -- published, draft, archived
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 18. 系统公告附件表
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_attachments (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
notification_id INTEGER REFERENCES notifications(id) ON DELETE CASCADE,
|
||||||
|
file_name VARCHAR(255) NOT NULL,
|
||||||
|
file_path VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 索引优化
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_achievements_user ON achievements(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_achievements_type ON achievements(type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_achievements_status ON achievements(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_achievements_assigned_depts ON achievements USING GIN (assigned_departments);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_username ON audit_logs(username);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at DESC);
|
||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
# Nginx 配置文件 for ST-System
|
||||||
|
|
||||||
|
worker_processes 4;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
sendfile on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
|
||||||
|
# 允许大文件上传 (500MB)
|
||||||
|
client_max_body_size 500M;
|
||||||
|
|
||||||
|
# 定义后端服务地址
|
||||||
|
upstream st_backend {
|
||||||
|
server 127.0.0.1:5000;
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTPS 服务器配置
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
# SSL 证书配置
|
||||||
|
ssl_certificate server.cert;
|
||||||
|
ssl_certificate_key server.key;
|
||||||
|
|
||||||
|
ssl_session_cache shared:SSL:1m;
|
||||||
|
ssl_session_timeout 5m;
|
||||||
|
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
|
||||||
|
# 安全响应头
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN";
|
||||||
|
add_header X-XSS-Protection "1; mode=block";
|
||||||
|
add_header X-Content-Type-Options "nosniff";
|
||||||
|
|
||||||
|
# 1. 前端静态资源 (React)
|
||||||
|
# 直接由 Nginx 提供服务,性能更高
|
||||||
|
location / {
|
||||||
|
# 安全策略:拦截移动端访问,重定向到提示页
|
||||||
|
if ($http_user_agent ~* "(mobile|android|iphone|ipad|phone)") {
|
||||||
|
return 302 /mobile_forbidden.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
root c:/Users/16216/Desktop/STsystem/client/build;
|
||||||
|
index index.html index.htm;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 移动端禁止访问提示页
|
||||||
|
location = /mobile_forbidden.html {
|
||||||
|
root c:/Users/16216/Desktop/STsystem/static;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. 后端 API 接口
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://st_backend;
|
||||||
|
|
||||||
|
# 传递真实 IP (配合后端 app.set('trust proxy', 1))
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# 支持 WebSocket
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. 上传文件访问
|
||||||
|
location /uploads/ {
|
||||||
|
alias c:/Users/16216/Desktop/STsystem/server/uploads/;
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, no-transform";
|
||||||
|
|
||||||
|
# 安全加固:禁止目录列表和脚本执行
|
||||||
|
autoindex off;
|
||||||
|
# 确保不解析 PHP 等脚本 (如果服务器安装了 PHP)
|
||||||
|
location ~ \.(php|jsp|asp|aspx|py|pl|sh|cgi)$ {
|
||||||
|
deny all;
|
||||||
|
return 403;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTP 自动跳转 HTTPS
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "stsystem-root",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "科技成果转化管理系统根目录",
|
||||||
|
"scripts": {
|
||||||
|
"start": "cd server && npm start",
|
||||||
|
"server": "cd server && npm start",
|
||||||
|
"client": "cd client && npm start",
|
||||||
|
"build": "cd client && npm run build"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC"
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
PORT=5000
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_NAME=stsystem
|
||||||
|
DB_PASSWORD=postgres
|
||||||
|
DB_PORT=5432
|
||||||
|
JWT_SECRET=st_system_secret_2026
|
||||||
|
|
||||||
|
# 文件存储配置
|
||||||
|
# 主上传目录 (支持绝对路径或相对路径)
|
||||||
|
UPLOAD_DIR=uploads
|
||||||
|
# Disk monitor config (optional)
|
||||||
|
# DISK_MONITOR_ENABLED=true
|
||||||
|
# DISK_ALERT_THRESHOLD_GB=10
|
||||||
|
# DISK_ALERT_COOLDOWN_HOURS=24
|
||||||
|
# DISK_CHECK_INTERVAL_MINUTES=60
|
||||||
|
# 历史归档目录 (可选,多个路径用逗号分隔,用于多盘存储或历史数据迁移)
|
||||||
|
# UPLOAD_FALLBACK_DIRS="D:\old_uploads,E:\archive"
|
||||||
|
|
||||||
|
# AI 分析配置:支持多提供方(OpenAI 兼容接口)
|
||||||
|
AI_DEFAULT_PROVIDER=deepseek
|
||||||
|
AI_PROVIDER_PRIORITY=deepseek
|
||||||
|
|
||||||
|
# 千问(当前已停用,如需恢复可自行启用)
|
||||||
|
# QWEN_API_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||||
|
# QWEN_API_KEY=
|
||||||
|
# QWEN_MODEL=qwen-plus
|
||||||
|
|
||||||
|
# DeepSeek
|
||||||
|
DEEPSEEK_API_BASE_URL=https://api.deepseek.com/v1
|
||||||
|
DEEPSEEK_API_KEY=
|
||||||
|
DEEPSEEK_MODEL=deepseek-chat
|
||||||
|
|
||||||
|
# custom:兼容旧配置,可用于企业内网代理
|
||||||
|
AI_API_BASE_URL=
|
||||||
|
AI_API_KEY=
|
||||||
|
AI_MODEL=
|
||||||
|
AI_TIMEOUT_MS=45000
|
||||||
|
AI_MAX_TOKENS=4096
|
||||||
|
AI_NAME_MAX_ITEMS_DEFAULT=100
|
||||||
|
AI_NAME_MAX_ITEMS_HARD_LIMIT=300
|
||||||
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
const { Pool } = require('pg');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
database: process.env.DB_DATABASE || process.env.DB_NAME,
|
||||||
|
password: String(process.env.DB_PASSWORD || ''),
|
||||||
|
port: process.env.DB_PORT,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听连接池错误,防止空闲客户端错误导致程序崩溃
|
||||||
|
pool.on('error', (err, client) => {
|
||||||
|
console.error('Unexpected error on idle client', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
query: (text, params) => pool.query(text, params),
|
||||||
|
pool
|
||||||
|
};
|
||||||
+183
@@ -0,0 +1,183 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const https = require('https');
|
||||||
|
const cors = require('cors');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const morgan = require('morgan');
|
||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
|
const logger = require('./utils/logger');
|
||||||
|
const auditLogger = require('./middleware/auditLogger');
|
||||||
|
const { checkDiskSpace } = require('./utils/diskMonitor');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
// 信任反向代理 (Nginx)
|
||||||
|
// 必须设置此项,否则在 Nginx 后端运行时,req.ip 永远是 127.0.0.1,导致限流策略对所有用户生效(误伤)
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
|
||||||
|
// 中间件
|
||||||
|
app.use(cors({
|
||||||
|
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
|
||||||
|
credentials: true
|
||||||
|
}));
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// 定义脱敏的 URL token
|
||||||
|
morgan.token('safe-url', (req) => {
|
||||||
|
let url = req.originalUrl || req.url;
|
||||||
|
try {
|
||||||
|
// 隐藏 token, password, code 等敏感参数
|
||||||
|
url = url.replace(/([?&])(token|password|code)=([^&]+)/gi, '$1$2=***');
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略错误
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
});
|
||||||
|
|
||||||
|
// HTTP 请求日志 (使用自定义格式,替换 :url 为 :safe-url)
|
||||||
|
app.use(morgan(':remote-addr - :remote-user [:date[clf]] ":method :safe-url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"', { stream: logger.stream }));
|
||||||
|
|
||||||
|
// 全局审计日志
|
||||||
|
app.use(auditLogger);
|
||||||
|
|
||||||
|
// 限流策略配置
|
||||||
|
// 1. 全局限流:每个IP每分钟最多100次请求
|
||||||
|
const globalLimiter = rateLimit({
|
||||||
|
windowMs: 1 * 60 * 1000, // 1分钟
|
||||||
|
max: 100, // 限制每个IP 100次请求
|
||||||
|
standardHeaders: true, // 返回 `RateLimit-*` 头信息
|
||||||
|
legacyHeaders: false, // 禁用 `X-RateLimit-*` 头信息
|
||||||
|
message: { message: '请求过于频繁,请稍后再试' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 认证接口限流:每个IP每分钟最多10次请求 (防暴力破解)
|
||||||
|
const authLimiter = rateLimit({
|
||||||
|
windowMs: 1 * 60 * 1000, // 1分钟
|
||||||
|
max: 10, // 限制每个IP 10次请求
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { message: '登录/注册尝试过于频繁,请1分钟后再试' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 应用全局限流
|
||||||
|
app.use('/api/', globalLimiter);
|
||||||
|
|
||||||
|
// 安全策略:禁止移动端访问
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const userAgent = req.headers['user-agent'] || '';
|
||||||
|
// 匹配常见的移动设备标识
|
||||||
|
const isMobile = /mobile|android|iphone|ipad|phone/i.test(userAgent);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
res.status(403).send(`
|
||||||
|
<html>
|
||||||
|
<head><title>访问被拒绝</title></head>
|
||||||
|
<body style="display:flex;justify-content:center;align-items:center;height:100vh;font-family:sans-serif;background:#f0f2f5;">
|
||||||
|
<div style="text-align:center;padding:40px;background:white;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,0.15);">
|
||||||
|
<h1 style="color:#ff4d4f;">🚫 访问被拒绝</h1>
|
||||||
|
<p style="font-size:18px;color:#333;">根据本单位安全规定,本系统<strong>禁止使用移动设备</strong>访问。</p>
|
||||||
|
<p style="color:#666;">请使用办公电脑访问。</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 确保上传目录存在
|
||||||
|
const uploadDir = process.env.UPLOAD_DIR || 'uploads';
|
||||||
|
let absoluteUploadDir;
|
||||||
|
if (path.isAbsolute(uploadDir)) {
|
||||||
|
absoluteUploadDir = uploadDir;
|
||||||
|
} else {
|
||||||
|
absoluteUploadDir = path.join(__dirname, uploadDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(absoluteUploadDir)) {
|
||||||
|
fs.mkdirSync(absoluteUploadDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const DISK_MONITOR_ENABLED = String(process.env.DISK_MONITOR_ENABLED || 'true').toLowerCase() !== 'false';
|
||||||
|
const DISK_CHECK_INTERVAL_MINUTES = Number(process.env.DISK_CHECK_INTERVAL_MINUTES || 60);
|
||||||
|
const DISK_CHECK_INTERVAL_MS = Number.isFinite(DISK_CHECK_INTERVAL_MINUTES) && DISK_CHECK_INTERVAL_MINUTES > 0
|
||||||
|
? DISK_CHECK_INTERVAL_MINUTES * 60 * 1000
|
||||||
|
: 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// 启动磁盘监控(默认每 60 分钟检查一次)
|
||||||
|
if (DISK_MONITOR_ENABLED) {
|
||||||
|
setInterval(() => {
|
||||||
|
checkDiskSpace();
|
||||||
|
}, DISK_CHECK_INTERVAL_MS);
|
||||||
|
|
||||||
|
// 启动时立即检查一次
|
||||||
|
checkDiskSpace();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导入路由
|
||||||
|
const authRoutes = require('./routes/auth');
|
||||||
|
const achievementRoutes = require('./routes/achievements');
|
||||||
|
const userRoutes = require('./routes/users');
|
||||||
|
const statisticsRoutes = require('./routes/statistics');
|
||||||
|
const dictionaryRoutes = require('./routes/dictionaries');
|
||||||
|
const logRoutes = require('./routes/logs');
|
||||||
|
const notificationRoutes = require('./routes/notifications');
|
||||||
|
const aiAnalysisRoutes = require('./routes/aiAnalysis');
|
||||||
|
|
||||||
|
// 对认证路由应用更严格的限流
|
||||||
|
app.use('/api/auth', authLimiter, authRoutes);
|
||||||
|
app.use('/api/achievements', achievementRoutes);
|
||||||
|
app.use('/api/users', userRoutes);
|
||||||
|
app.use('/api/statistics', statisticsRoutes);
|
||||||
|
app.use('/api/dictionaries', dictionaryRoutes);
|
||||||
|
app.use('/api/logs', logRoutes);
|
||||||
|
app.use('/api/notifications', notificationRoutes);
|
||||||
|
app.use('/api/ai-analysis', aiAnalysisRoutes);
|
||||||
|
|
||||||
|
// 静态文件服务 (用于开发环境查看附件,生产环境建议用 Nginx)
|
||||||
|
app.use('/uploads', express.static(absoluteUploadDir, {
|
||||||
|
setHeaders: (res, path) => {
|
||||||
|
// 强制下载,防止浏览器直接预览 HTML/SVG 等可能包含脚本的文件
|
||||||
|
res.setHeader('Content-Disposition', 'attachment');
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 托管前端静态文件 (生产环境)
|
||||||
|
const clientBuildPath = path.join(__dirname, '../client/build');
|
||||||
|
if (fs.existsSync(clientBuildPath)) {
|
||||||
|
app.use(express.static(clientBuildPath));
|
||||||
|
// 使用正则表达式排除以 /api/ 开头的路径
|
||||||
|
app.get(/^(?!\/api\/).*$/, (req, res) => {
|
||||||
|
res.sendFile(path.join(clientBuildPath, 'index.html'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试读取 SSL 证书 (用于 HTTPS)
|
||||||
|
const keyPath = path.join(__dirname, 'server.key');
|
||||||
|
const certPath = path.join(__dirname, 'server.cert');
|
||||||
|
|
||||||
|
if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
|
||||||
|
// 如果存在证书,启动 HTTPS 服务器
|
||||||
|
const options = {
|
||||||
|
key: fs.readFileSync(keyPath),
|
||||||
|
cert: fs.readFileSync(certPath)
|
||||||
|
};
|
||||||
|
https.createServer(options, app).listen(PORT, () => {
|
||||||
|
const msg = `HTTPS 安全服务器运行在 https://localhost:${PORT}`;
|
||||||
|
console.log(msg);
|
||||||
|
logger.info(msg);
|
||||||
|
console.log(`局域网访问请使用: https://<你的IP>:${PORT}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 否则启动标准 HTTP 服务器
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
const msg = `HTTP 服务器运行在 http://localhost:${PORT}`;
|
||||||
|
console.log(msg);
|
||||||
|
logger.info(msg);
|
||||||
|
console.log(`提示: 未检测到 server.key 和 server.cert,已降级为 HTTP 模式。`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
const logger = require('../utils/logger');
|
||||||
|
const db = require('../db');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据请求方法和 URL 获取操作说明
|
||||||
|
*/
|
||||||
|
const getActionDescription = (method, url) => {
|
||||||
|
// 移除查询参数
|
||||||
|
const path = url.split('?')[0];
|
||||||
|
|
||||||
|
// --- Auth ---
|
||||||
|
if (path === '/api/auth/login' && method === 'POST') return '用户登录';
|
||||||
|
if (path === '/api/auth/register' && method === 'POST') return '用户注册';
|
||||||
|
if (path === '/api/auth/send-code' && method === 'POST') return '发送验证码';
|
||||||
|
if (path === '/api/auth/reset-password' && method === 'POST') return '重置密码';
|
||||||
|
|
||||||
|
// --- Achievements ---
|
||||||
|
if (path === '/api/achievements' && method === 'POST') return '上传成果';
|
||||||
|
if (path === '/api/achievements' && method === 'GET') {
|
||||||
|
if (url.includes('export_action=true')) return '导出用户成果数据';
|
||||||
|
return '查看成果列表';
|
||||||
|
}
|
||||||
|
if (path === '/api/achievements/export/csv' && method === 'GET') return '导出成果(CSV)';
|
||||||
|
if (path === '/api/achievements/export/zip' && method === 'GET') return '导出成果(ZIP)';
|
||||||
|
if (path === '/api/achievements/validate' && method === 'GET') return '数据校验';
|
||||||
|
|
||||||
|
// 动态路径匹配
|
||||||
|
if (path.match(/^\/api\/achievements\/audit\/\d+$/) && method === 'POST') return '审核成果';
|
||||||
|
if (path.match(/^\/api\/achievements\/\d+\/attachments\/zip$/) && method === 'GET') return '下载成果附件包';
|
||||||
|
if (path.match(/^\/api\/achievements\/attachments\/\d+\/download$/) && method === 'GET') return '下载单个附件';
|
||||||
|
if (path.match(/^\/api\/achievements\/\d+$/)) {
|
||||||
|
if (method === 'GET') return '查看成果详情';
|
||||||
|
if (method === 'PUT') return '修改成果';
|
||||||
|
if (method === 'DELETE') return '删除成果';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Users ---
|
||||||
|
if (path === '/api/users' && method === 'POST') return '创建用户';
|
||||||
|
if (path === '/api/users' && method === 'GET') return '查看用户列表';
|
||||||
|
if (path === '/api/users/search' && method === 'GET') return '搜索用户';
|
||||||
|
if (path === '/api/users/pending-list' && method === 'GET') return '查看待定用户';
|
||||||
|
if (path.match(/^\/api\/users\/\d+$/)) {
|
||||||
|
if (method === 'PUT') return '修改用户';
|
||||||
|
if (method === 'DELETE') return '删除用户';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dictionaries ---
|
||||||
|
if (path === '/api/dictionaries/departments' && method === 'GET') return '查看部门列表(公开)';
|
||||||
|
if (path.match(/^\/api\/dictionaries\/[^/]+$/)) {
|
||||||
|
if (method === 'GET') return '查看字典列表';
|
||||||
|
if (method === 'POST') return '添加字典项';
|
||||||
|
}
|
||||||
|
if (path.match(/^\/api\/dictionaries\/[^/]+\/\d+$/)) {
|
||||||
|
if (method === 'PUT') return '修改字典项';
|
||||||
|
if (method === 'DELETE') return '删除字典项';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Notifications ---
|
||||||
|
if (path === '/api/notifications' && method === 'GET') return '查看通知列表';
|
||||||
|
if (path === '/api/notifications' && method === 'POST') return '发布通知';
|
||||||
|
if (path.match(/^\/api\/notifications\/attachments\/\d+\/download$/) && method === 'GET') return '下载通知附件';
|
||||||
|
if (path.match(/^\/api\/notifications\/\d+$/)) {
|
||||||
|
if (method === 'GET') return '查看通知详情';
|
||||||
|
if (method === 'DELETE') return '删除通知';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Statistics ---
|
||||||
|
if (path === '/api/statistics' && method === 'GET') return '查看统计数据';
|
||||||
|
|
||||||
|
// --- Logs ---
|
||||||
|
if (path === '/api/logs' && method === 'GET') return '查看系统日志';
|
||||||
|
|
||||||
|
// 默认
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局审计日志中间件
|
||||||
|
* 记录所有 API 请求的操作人、IP、方法、URL 和 结果状态
|
||||||
|
*/
|
||||||
|
const auditLogger = (req, res, next) => {
|
||||||
|
// 记录请求开始时间
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
// 监听请求完成事件
|
||||||
|
res.on('finish', async () => {
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
|
||||||
|
const method = req.method;
|
||||||
|
const url = req.originalUrl || req.url;
|
||||||
|
const status = res.statusCode;
|
||||||
|
|
||||||
|
// 忽略非 API 请求 (虽然通常挂载在 /api 下,但双重保险)
|
||||||
|
if (!url.startsWith('/api/')) return;
|
||||||
|
|
||||||
|
// 忽略 OPTIONS 预检请求,避免日志刷屏
|
||||||
|
if (method === 'OPTIONS') return;
|
||||||
|
|
||||||
|
// URL 脱敏处理:隐藏 token, password, code 等敏感参数
|
||||||
|
let safeUrl = url;
|
||||||
|
try {
|
||||||
|
safeUrl = safeUrl.replace(/([?&])(token|password|code)=([^&]+)/gi, '$1$2=***');
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略正则错误,保持原样
|
||||||
|
}
|
||||||
|
|
||||||
|
let userInfo = 'Guest';
|
||||||
|
let userId = null;
|
||||||
|
let username = null;
|
||||||
|
let realName = null;
|
||||||
|
|
||||||
|
// 1. 尝试获取已登录用户信息
|
||||||
|
// 注意:req.user 由 verifyToken 中间件挂载。
|
||||||
|
// 由于我们在 res 'finish' 事件中读取,此时路由处理已完成,req.user 应该已存在(如果通过了认证)。
|
||||||
|
if (req.user) {
|
||||||
|
userInfo = `${req.user.real_name || req.user.username} (ID:${req.user.id})`;
|
||||||
|
userId = req.user.id;
|
||||||
|
username = req.user.username;
|
||||||
|
realName = req.user.real_name;
|
||||||
|
}
|
||||||
|
// 2. 对于未登录的关键接口,尝试从 body 中提取身份信息
|
||||||
|
else if (req.body) {
|
||||||
|
if (url.includes('/login')) {
|
||||||
|
userInfo = `[尝试登录: ${req.body.username || '未知'}]`;
|
||||||
|
username = req.body.username;
|
||||||
|
} else if (url.includes('/register')) {
|
||||||
|
userInfo = `[尝试注册: ${req.body.username || '未知'}]`;
|
||||||
|
username = req.body.username;
|
||||||
|
} else if (url.includes('/send-code')) {
|
||||||
|
userInfo = `[请求验证码: ${req.body.phoneNumber || '未知'}]`;
|
||||||
|
username = req.body.phoneNumber;
|
||||||
|
} else if (url.includes('/reset-password')) {
|
||||||
|
userInfo = `[重置密码: ${req.body.phoneNumber || '未知'}]`;
|
||||||
|
username = req.body.phoneNumber;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取操作说明
|
||||||
|
const description = getActionDescription(method, url);
|
||||||
|
|
||||||
|
// 构建日志消息
|
||||||
|
const logMessage = `[AUDIT] IP:${clientIp} | User:${userInfo} | ${method} ${safeUrl} | Action:${description || 'Unknown'} | Status:${status} | Time:${duration}ms`;
|
||||||
|
|
||||||
|
// 根据状态码记录不同级别的日志
|
||||||
|
if (status >= 500) {
|
||||||
|
logger.error(logMessage);
|
||||||
|
} else if (status >= 400) {
|
||||||
|
logger.warn(logMessage);
|
||||||
|
} else {
|
||||||
|
logger.info(logMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入数据库
|
||||||
|
try {
|
||||||
|
await db.query(
|
||||||
|
'INSERT INTO audit_logs (user_id, username, real_name, ip_address, method, url, description, status, duration) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)',
|
||||||
|
[userId, username, realName, clientIp, method, safeUrl, description, status, duration]
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// 数据库写入失败不应影响主流程,仅记录错误
|
||||||
|
console.error('审计日志写入数据库失败:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = auditLogger;
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const db = require('../db');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const verifyToken = async (req, res, next) => {
|
||||||
|
// 优先从 Header 获取,如果没有则从 Query String 获取(用于下载接口)
|
||||||
|
const token = req.headers['authorization']?.split(' ')[1] || req.query.token;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({ message: '未提供认证令牌' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
|
||||||
|
// 单点登录检查:比对 Token 中的版本号与数据库中的版本号
|
||||||
|
// 如果 Token 中没有 version (旧 Token),视为 0
|
||||||
|
const tokenVersion = decoded.token_version || 0;
|
||||||
|
|
||||||
|
const result = await db.query('SELECT token_version FROM users WHERE id = $1', [decoded.id]);
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(401).json({ message: '用户不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbVersion = result.rows[0].token_version || 0;
|
||||||
|
|
||||||
|
if (tokenVersion !== dbVersion) {
|
||||||
|
return res.status(401).json({ message: '您的账号已在其他设备登录,请重新登录' });
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = decoded;
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'TokenExpiredError') {
|
||||||
|
return res.status(401).json({ message: '登录已过期,请重新登录' });
|
||||||
|
}
|
||||||
|
return res.status(401).json({ message: '无效的令牌' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAdmin = (req, res, next) => {
|
||||||
|
if (req.user && (req.user.role === 'admin' || req.user.role === 'super_admin')) {
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
return res.status(403).json({ message: '需要管理员权限' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSuperAdmin = (req, res, next) => {
|
||||||
|
if (req.user && req.user.role === 'super_admin') {
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
return res.status(403).json({ message: '需要超级管理员权限' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMaintainer = (req, res, next) => {
|
||||||
|
if (req.user && req.user.role === 'maintainer') {
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
return res.status(403).json({ message: '需要系统维护员权限' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 系统管理员权限:超级管理员 OR 系统维护员
|
||||||
|
const isSystemAdmin = (req, res, next) => {
|
||||||
|
if (req.user && (req.user.role === 'super_admin' || req.user.role === 'maintainer')) {
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
return res.status(403).json({ message: '需要系统管理权限' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { verifyToken, isAdmin, isSuperAdmin, isMaintainer, isSystemAdmin };
|
||||||
Generated
+3000
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node index.js",
|
||||||
|
"init-db": "node scripts/create_db.js && node scripts/setup.js",
|
||||||
|
"seed-500k": "node scripts/seed_data_limited_500k.js",
|
||||||
|
"seed-500k:clean": "node scripts/seed_data_limited_500k.js --clean",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"description": "",
|
||||||
|
"dependencies": {
|
||||||
|
"@alicloud/credentials": "^2.4.4",
|
||||||
|
"@alicloud/dypnsapi20170525": "^2.0.0",
|
||||||
|
"@alicloud/openapi-client": "^0.4.15",
|
||||||
|
"@alicloud/tea-util": "^1.4.11",
|
||||||
|
"archiver": "^7.0.1",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"content-disposition": "^1.0.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"express-rate-limit": "^8.2.1",
|
||||||
|
"file-type": "^16.5.4",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"morgan": "^1.10.1",
|
||||||
|
"multer": "^2.0.2",
|
||||||
|
"pg": "^8.16.3",
|
||||||
|
"winston": "^3.19.0",
|
||||||
|
"winston-daily-rotate-file": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,949 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const http = require('http');
|
||||||
|
const https = require('https');
|
||||||
|
const { URL } = require('url');
|
||||||
|
const router = express.Router();
|
||||||
|
const db = require('../db');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const { verifyToken } = require('../middleware/auth');
|
||||||
|
|
||||||
|
const normalizePositiveInt = (value, fallback, min = 1, max = Number.MAX_SAFE_INTEGER) => {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return fallback;
|
||||||
|
const intValue = Math.trunc(n);
|
||||||
|
if (intValue < min) return min;
|
||||||
|
if (intValue > max) return max;
|
||||||
|
return intValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AI_TIMEOUT_MS = Number(process.env.AI_TIMEOUT_MS || 45000);
|
||||||
|
const AI_MAX_TOKENS = normalizePositiveInt(process.env.AI_MAX_TOKENS, 4096, 256, 8192);
|
||||||
|
const AI_DEFAULT_PROVIDER = String(process.env.AI_DEFAULT_PROVIDER || 'deepseek').trim().toLowerCase();
|
||||||
|
const AI_PROVIDER_PRIORITY = String(process.env.AI_PROVIDER_PRIORITY || 'deepseek').trim();
|
||||||
|
const AI_NAME_MAX_ITEMS_DEFAULT = normalizePositiveInt(process.env.AI_NAME_MAX_ITEMS_DEFAULT, 100, 20, 500);
|
||||||
|
const AI_NAME_MAX_ITEMS_HARD_LIMIT = normalizePositiveInt(process.env.AI_NAME_MAX_ITEMS_HARD_LIMIT, 300, 50, 1000);
|
||||||
|
|
||||||
|
const ADMIN_ROLES = new Set(['admin', 'super_admin', 'senior_user', 'intermediate_user']);
|
||||||
|
const SUPPORTED_PROVIDERS = new Set(['auto', 'deepseek']);
|
||||||
|
const SUPPORTED_MODES = new Set(['standard', 'deep']);
|
||||||
|
const SUPPORTED_NAME_STRATEGIES = new Set(['latest', 'balanced']);
|
||||||
|
const TYPE_LABEL_MAP = {
|
||||||
|
paper: 'Paper',
|
||||||
|
project: 'Project',
|
||||||
|
award: 'Award',
|
||||||
|
standard: 'Standard',
|
||||||
|
monograph: 'Monograph',
|
||||||
|
report: 'Technical Report',
|
||||||
|
plan: 'Plan',
|
||||||
|
patent: 'Patent',
|
||||||
|
transformation: 'Transformation',
|
||||||
|
software: 'Software Copyright'
|
||||||
|
};
|
||||||
|
const STATUS_LABEL_MAP = {
|
||||||
|
pending: 'Pending',
|
||||||
|
approved: 'Approved',
|
||||||
|
rejected: 'Rejected'
|
||||||
|
};
|
||||||
|
|
||||||
|
const clampText = (value, maxLen = 600) => {
|
||||||
|
return String(value || '').trim().slice(0, maxLen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeFilter = (value) => {
|
||||||
|
if (value === undefined || value === null) return null;
|
||||||
|
const s = String(value).trim();
|
||||||
|
if (!s || s.toLowerCase() === 'all') return null;
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toSafeInt = (value, fallback = null) => {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return fallback;
|
||||||
|
return Math.trunc(n);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeProvider = (value) => {
|
||||||
|
const provider = String(value || '').trim().toLowerCase();
|
||||||
|
if (!provider) return 'auto';
|
||||||
|
if (!SUPPORTED_PROVIDERS.has(provider)) return 'auto';
|
||||||
|
return provider;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeMode = (value) => {
|
||||||
|
const mode = String(value || '').trim().toLowerCase();
|
||||||
|
if (!mode || !SUPPORTED_MODES.has(mode)) return 'standard';
|
||||||
|
return mode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeNameStrategy = (value) => {
|
||||||
|
const strategy = String(value || '').trim().toLowerCase();
|
||||||
|
if (!strategy || !SUPPORTED_NAME_STRATEGIES.has(strategy)) return 'latest';
|
||||||
|
return strategy;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeNameLimit = (value) => {
|
||||||
|
return normalizePositiveInt(value, AI_NAME_MAX_ITEMS_DEFAULT, 20, AI_NAME_MAX_ITEMS_HARD_LIMIT);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toIsoDate = (value) => {
|
||||||
|
if (!value) return null;
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
if (!Number.isFinite(date.getTime())) return null;
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProviderConfigs = () => {
|
||||||
|
return {
|
||||||
|
deepseek: {
|
||||||
|
baseUrl: String(process.env.DEEPSEEK_API_BASE_URL || '').trim(),
|
||||||
|
apiKey: String(process.env.DEEPSEEK_API_KEY || '').trim(),
|
||||||
|
model: String(process.env.DEEPSEEK_MODEL || 'deepseek-chat').trim() || 'deepseek-chat'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAvailableProviders = (configs) => {
|
||||||
|
return Object.keys(configs).filter((provider) => {
|
||||||
|
const cfg = configs[provider];
|
||||||
|
return !!(cfg && cfg.baseUrl && cfg.apiKey && cfg.model);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsePriority = () => {
|
||||||
|
const tokens = AI_PROVIDER_PRIORITY
|
||||||
|
.split(',')
|
||||||
|
.map((p) => normalizeProvider(p))
|
||||||
|
.filter((p) => p !== 'auto');
|
||||||
|
return Array.from(new Set(tokens));
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveProviderOrder = (requestedProvider, availableProviders) => {
|
||||||
|
if (requestedProvider !== 'auto') {
|
||||||
|
return availableProviders.includes(requestedProvider) ? [requestedProvider] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const priority = parsePriority();
|
||||||
|
const ordered = [
|
||||||
|
...priority.filter((p) => availableProviders.includes(p)),
|
||||||
|
...availableProviders.filter((p) => !priority.includes(p))
|
||||||
|
];
|
||||||
|
|
||||||
|
const defaultProvider = normalizeProvider(AI_DEFAULT_PROVIDER);
|
||||||
|
if (defaultProvider !== 'auto' && ordered.includes(defaultProvider)) {
|
||||||
|
return [defaultProvider, ...ordered.filter((p) => p !== defaultProvider)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ordered;
|
||||||
|
};
|
||||||
|
|
||||||
|
const postJson = (urlString, payload, headers = {}, timeoutMs = 30000) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = new URL(urlString);
|
||||||
|
const body = JSON.stringify(payload);
|
||||||
|
const requestLib = url.protocol === 'http:' ? http : https;
|
||||||
|
const defaultPort = url.protocol === 'http:' ? 80 : 443;
|
||||||
|
|
||||||
|
const req = requestLib.request({
|
||||||
|
protocol: url.protocol,
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port || defaultPort,
|
||||||
|
path: `${url.pathname}${url.search}`,
|
||||||
|
method: 'POST',
|
||||||
|
timeout: timeoutMs,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(body),
|
||||||
|
...headers
|
||||||
|
}
|
||||||
|
}, (res) => {
|
||||||
|
let raw = '';
|
||||||
|
res.setEncoding('utf8');
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
raw += chunk;
|
||||||
|
});
|
||||||
|
res.on('end', () => {
|
||||||
|
const statusCode = res.statusCode || 500;
|
||||||
|
let parsed = {};
|
||||||
|
try {
|
||||||
|
parsed = raw ? JSON.parse(raw) : {};
|
||||||
|
} catch (e) {
|
||||||
|
parsed = { raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusCode >= 200 && statusCode < 300) {
|
||||||
|
resolve(parsed);
|
||||||
|
} else {
|
||||||
|
reject(new Error(parsed?.error?.message || parsed?.message || `AI provider error: ${statusCode}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy(new Error('AI request timeout'));
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.write(body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeSse = (res, event, payload) => {
|
||||||
|
if (res.writableEnded) return;
|
||||||
|
res.write(`event: ${event}\n`);
|
||||||
|
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractChoiceText = (parsed) => {
|
||||||
|
const choice = parsed?.choices?.[0] || {};
|
||||||
|
if (typeof choice?.delta?.content === 'string') return choice.delta.content;
|
||||||
|
if (typeof choice?.message?.content === 'string') return choice.message.content;
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const streamProviderToClient = ({ req, res, provider, config, systemPrompt, userPrompt, maxTokens = AI_MAX_TOKENS, timeoutMs = 30000 }) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const endpoint = `${config.baseUrl.replace(/\/+$/, '')}/chat/completions`;
|
||||||
|
const url = new URL(endpoint);
|
||||||
|
const requestLib = url.protocol === 'http:' ? http : https;
|
||||||
|
const defaultPort = url.protocol === 'http:' ? 80 : 443;
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
model: config.model,
|
||||||
|
temperature: 0.2,
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
stream: true,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: userPrompt }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
let settled = false;
|
||||||
|
let tokenChars = 0;
|
||||||
|
let lineBuffer = '';
|
||||||
|
let rawFallbackBuffer = '';
|
||||||
|
let metaSent = false;
|
||||||
|
const onClientClose = () => {
|
||||||
|
if (!upstreamReq.destroyed) {
|
||||||
|
upstreamReq.destroy(new Error('client closed connection'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const settle = (fn, value) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
req.off('close', onClientClose);
|
||||||
|
fn(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const upstreamReq = requestLib.request({
|
||||||
|
protocol: url.protocol,
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port || defaultPort,
|
||||||
|
path: `${url.pathname}${url.search}`,
|
||||||
|
method: 'POST',
|
||||||
|
timeout: timeoutMs,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(payload),
|
||||||
|
Accept: 'text/event-stream',
|
||||||
|
Authorization: `Bearer ${config.apiKey}`
|
||||||
|
}
|
||||||
|
}, (upstreamRes) => {
|
||||||
|
const statusCode = upstreamRes.statusCode || 500;
|
||||||
|
upstreamRes.setEncoding('utf8');
|
||||||
|
|
||||||
|
if (statusCode < 200 || statusCode >= 300) {
|
||||||
|
let errRaw = '';
|
||||||
|
upstreamRes.on('data', (chunk) => {
|
||||||
|
errRaw += chunk;
|
||||||
|
});
|
||||||
|
upstreamRes.on('end', () => {
|
||||||
|
try {
|
||||||
|
const parsed = errRaw ? JSON.parse(errRaw) : {};
|
||||||
|
settle(reject, new Error(parsed?.error?.message || parsed?.message || `AI provider error: ${statusCode}`));
|
||||||
|
} catch (e) {
|
||||||
|
settle(reject, new Error(`AI provider error: ${statusCode}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!metaSent) {
|
||||||
|
writeSse(res, 'meta', {
|
||||||
|
provider,
|
||||||
|
model: config.model,
|
||||||
|
generatedAt: new Date().toISOString()
|
||||||
|
});
|
||||||
|
metaSent = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flushLine = (line) => {
|
||||||
|
const trimmed = String(line || '').trim();
|
||||||
|
if (!trimmed || trimmed.startsWith(':') || !trimmed.startsWith('data:')) return;
|
||||||
|
const data = trimmed.slice(5).trim();
|
||||||
|
if (!data || data === '[DONE]') return;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
const deltaText = extractChoiceText(parsed);
|
||||||
|
if (deltaText) {
|
||||||
|
tokenChars += deltaText.length;
|
||||||
|
writeSse(res, 'token', { content: deltaText });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore malformed stream chunks.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
upstreamRes.on('data', (chunk) => {
|
||||||
|
rawFallbackBuffer += chunk;
|
||||||
|
lineBuffer += chunk.replace(/\r/g, '');
|
||||||
|
let idx = lineBuffer.indexOf('\n');
|
||||||
|
while (idx !== -1) {
|
||||||
|
const line = lineBuffer.slice(0, idx);
|
||||||
|
lineBuffer = lineBuffer.slice(idx + 1);
|
||||||
|
flushLine(line);
|
||||||
|
idx = lineBuffer.indexOf('\n');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
upstreamRes.on('end', () => {
|
||||||
|
if (lineBuffer) flushLine(lineBuffer);
|
||||||
|
|
||||||
|
if (tokenChars === 0 && rawFallbackBuffer.trim()) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawFallbackBuffer);
|
||||||
|
const wholeText = extractChoiceText(parsed);
|
||||||
|
if (wholeText) {
|
||||||
|
tokenChars += wholeText.length;
|
||||||
|
writeSse(res, 'token', { content: wholeText });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore fallback parse errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenChars === 0) {
|
||||||
|
settle(reject, new Error('provider returned empty analysis'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settle(resolve, { provider, model: config.model, tokenChars });
|
||||||
|
});
|
||||||
|
|
||||||
|
upstreamRes.on('error', (err) => {
|
||||||
|
settle(reject, err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
upstreamReq.on('timeout', () => {
|
||||||
|
upstreamReq.destroy(new Error('AI request timeout'));
|
||||||
|
});
|
||||||
|
|
||||||
|
upstreamReq.on('error', (err) => {
|
||||||
|
settle(reject, err);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.once('close', onClientClose);
|
||||||
|
|
||||||
|
upstreamReq.write(payload);
|
||||||
|
upstreamReq.end();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildWhereClause = (filters = {}) => {
|
||||||
|
const where = ['1=1'];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
const year = toSafeInt(normalizeFilter(filters.year));
|
||||||
|
const type = normalizeFilter(filters.type);
|
||||||
|
const status = normalizeFilter(filters.status);
|
||||||
|
const department = normalizeFilter(filters.department);
|
||||||
|
|
||||||
|
if (year && year >= 2000 && year <= 2100) {
|
||||||
|
where.push(`EXTRACT(YEAR FROM achievement_date) = $${params.length + 1}`);
|
||||||
|
params.push(year);
|
||||||
|
}
|
||||||
|
if (type) {
|
||||||
|
where.push(`type = $${params.length + 1}`);
|
||||||
|
params.push(type);
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
where.push(`status = $${params.length + 1}`);
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { whereSql: where.join(' AND '), params, department };
|
||||||
|
};
|
||||||
|
|
||||||
|
const toNumberRows = (rows = []) => {
|
||||||
|
return rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
count: Number(row.count || 0)
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toPercent = (count, total) => {
|
||||||
|
if (!total) return 0;
|
||||||
|
return Number(((Number(count || 0) / Number(total || 1)) * 100).toFixed(1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFilterValue = (key, value) => {
|
||||||
|
if (!value || value === 'all') return '鍏ㄩ儴';
|
||||||
|
if (key === 'type') return TYPE_LABEL_MAP[value] || value;
|
||||||
|
if (key === 'status') return STATUS_LABEL_MAP[value] || value;
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPromptDataset = (dataset) => {
|
||||||
|
const total = Number(dataset?.overview?.total || 0);
|
||||||
|
const rawFilters = dataset?.filters || {};
|
||||||
|
const mode = dataset?.mode === 'deep' ? 'deep' : 'standard';
|
||||||
|
const rawNameSamples = dataset?.nameSamples || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
filters: {
|
||||||
|
year: formatFilterValue('year', rawFilters.year),
|
||||||
|
type: formatFilterValue('type', rawFilters.type),
|
||||||
|
status: formatFilterValue('status', rawFilters.status),
|
||||||
|
department: formatFilterValue('department', rawFilters.department)
|
||||||
|
},
|
||||||
|
overview: { total },
|
||||||
|
byType: (dataset?.byType || []).slice(0, 8).map((item) => ({
|
||||||
|
type: TYPE_LABEL_MAP[item.type] || item.type,
|
||||||
|
count: Number(item.count || 0),
|
||||||
|
ratio: toPercent(item.count, total)
|
||||||
|
})),
|
||||||
|
byStatus: (dataset?.byStatus || []).map((item) => ({
|
||||||
|
status: STATUS_LABEL_MAP[item.status] || item.status,
|
||||||
|
count: Number(item.count || 0),
|
||||||
|
ratio: toPercent(item.count, total)
|
||||||
|
})),
|
||||||
|
byDepartmentTop: (dataset?.byDepartment || []).slice(0, 10).map((item) => ({
|
||||||
|
department: item.department,
|
||||||
|
count: Number(item.count || 0),
|
||||||
|
ratio: toPercent(item.count, total)
|
||||||
|
})),
|
||||||
|
monthlyRecent: (dataset?.byMonth || []).slice(-24).map((item) => ({
|
||||||
|
month: item.month,
|
||||||
|
count: Number(item.count || 0)
|
||||||
|
})),
|
||||||
|
quality: dataset?.quality || {},
|
||||||
|
nameSamples: {
|
||||||
|
mode,
|
||||||
|
strategy: rawNameSamples?.strategy || 'latest',
|
||||||
|
sent: Number(rawNameSamples?.sent || 0),
|
||||||
|
total: Number(rawNameSamples?.total || 0),
|
||||||
|
truncated: Boolean(rawNameSamples?.truncated),
|
||||||
|
items: (rawNameSamples?.items || []).map((item) => ({
|
||||||
|
name: clampText(item?.name, 120),
|
||||||
|
type: TYPE_LABEL_MAP[item?.type] || item?.type || 'unknown',
|
||||||
|
department: item?.department || 'unknown',
|
||||||
|
date: item?.date || null
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const gatherAnalysisDataset = async (filters = {}, options = {}) => {
|
||||||
|
const { whereSql, params, department } = buildWhereClause(filters);
|
||||||
|
const scopedParams = department ? [...params, department] : params;
|
||||||
|
const scopedWhere = department ? `${whereSql} AND owner_department = $${params.length + 1}` : whereSql;
|
||||||
|
|
||||||
|
const mode = normalizeMode(options?.mode);
|
||||||
|
const nameStrategy = normalizeNameStrategy(options?.nameStrategy);
|
||||||
|
const nameLimit = normalizeNameLimit(options?.nameLimit);
|
||||||
|
|
||||||
|
const ownerAchievementsCte = `
|
||||||
|
WITH leader_depts AS (
|
||||||
|
SELECT name FROM dict_leader_departments
|
||||||
|
),
|
||||||
|
leader_dept_array AS (
|
||||||
|
SELECT COALESCE(array_agg(name::text), ARRAY[]::text[]) AS names FROM leader_depts
|
||||||
|
),
|
||||||
|
owner_achievements AS (
|
||||||
|
SELECT
|
||||||
|
a.id,
|
||||||
|
a.name,
|
||||||
|
a.type,
|
||||||
|
a.status,
|
||||||
|
a.achievement_date,
|
||||||
|
a.contributors,
|
||||||
|
a.assigned_departments,
|
||||||
|
a.remarks,
|
||||||
|
COALESCE(non_leader.dept, first_any.dept) AS owner_department
|
||||||
|
FROM achievements a
|
||||||
|
CROSS JOIN leader_dept_array lda
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT u.dept
|
||||||
|
FROM unnest(COALESCE(a.assigned_departments::text[], ARRAY[]::text[])) WITH ORDINALITY AS u(dept, ord)
|
||||||
|
WHERE u.dept IS NOT NULL
|
||||||
|
AND BTRIM(u.dept) <> ''
|
||||||
|
AND u.dept <> ALL(lda.names)
|
||||||
|
ORDER BY u.ord
|
||||||
|
LIMIT 1
|
||||||
|
) non_leader ON TRUE
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT u.dept
|
||||||
|
FROM unnest(COALESCE(a.assigned_departments::text[], ARRAY[]::text[])) WITH ORDINALITY AS u(dept, ord)
|
||||||
|
WHERE u.dept IS NOT NULL
|
||||||
|
AND BTRIM(u.dept) <> ''
|
||||||
|
ORDER BY u.ord
|
||||||
|
LIMIT 1
|
||||||
|
) first_any ON TRUE
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const totalRes = await db.query(
|
||||||
|
`${ownerAchievementsCte}
|
||||||
|
SELECT COUNT(*)::int AS count
|
||||||
|
FROM owner_achievements
|
||||||
|
WHERE ${scopedWhere}`,
|
||||||
|
scopedParams
|
||||||
|
);
|
||||||
|
const typeRes = await db.query(
|
||||||
|
`${ownerAchievementsCte}
|
||||||
|
SELECT type, COUNT(*)::int AS count
|
||||||
|
FROM owner_achievements
|
||||||
|
WHERE ${scopedWhere}
|
||||||
|
GROUP BY type
|
||||||
|
ORDER BY count DESC`,
|
||||||
|
scopedParams
|
||||||
|
);
|
||||||
|
const statusRes = await db.query(
|
||||||
|
`${ownerAchievementsCte}
|
||||||
|
SELECT status, COUNT(*)::int AS count
|
||||||
|
FROM owner_achievements
|
||||||
|
WHERE ${scopedWhere}
|
||||||
|
GROUP BY status
|
||||||
|
ORDER BY count DESC`,
|
||||||
|
scopedParams
|
||||||
|
);
|
||||||
|
const deptRes = await db.query(
|
||||||
|
`${ownerAchievementsCte},
|
||||||
|
department_scope AS (
|
||||||
|
SELECT d.name AS department
|
||||||
|
FROM departments d, leader_dept_array lda
|
||||||
|
WHERE d.name::text <> ALL(lda.names)
|
||||||
|
)
|
||||||
|
SELECT ds.department, COUNT(oa.id)::int AS count
|
||||||
|
FROM department_scope ds
|
||||||
|
LEFT JOIN owner_achievements oa
|
||||||
|
ON ds.department = oa.owner_department
|
||||||
|
AND (${scopedWhere})
|
||||||
|
GROUP BY ds.department
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT 20`,
|
||||||
|
scopedParams
|
||||||
|
);
|
||||||
|
const monthlyRes = await db.query(
|
||||||
|
`${ownerAchievementsCte}
|
||||||
|
SELECT month, count
|
||||||
|
FROM (
|
||||||
|
SELECT TO_CHAR(achievement_date, 'YYYY-MM') AS month, COUNT(*)::int AS count
|
||||||
|
FROM owner_achievements
|
||||||
|
WHERE ${scopedWhere} AND achievement_date IS NOT NULL
|
||||||
|
GROUP BY month
|
||||||
|
ORDER BY month DESC
|
||||||
|
LIMIT 48
|
||||||
|
) t
|
||||||
|
ORDER BY month`,
|
||||||
|
scopedParams
|
||||||
|
);
|
||||||
|
|
||||||
|
const qualityRes = await db.query(
|
||||||
|
`${ownerAchievementsCte}
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE contributors IS NULL OR jsonb_array_length(contributors) = 0)::int AS empty_contributors,
|
||||||
|
COUNT(*) FILTER (WHERE owner_department IS NULL)::int AS empty_departments,
|
||||||
|
COUNT(*) FILTER (WHERE remarks IS NULL OR LENGTH(BTRIM(remarks)) = 0)::int AS empty_remarks
|
||||||
|
FROM owner_achievements
|
||||||
|
WHERE ${scopedWhere}`,
|
||||||
|
scopedParams
|
||||||
|
);
|
||||||
|
|
||||||
|
const total = Number(totalRes.rows[0]?.count || 0);
|
||||||
|
const byType = toNumberRows(typeRes.rows);
|
||||||
|
const byStatus = toNumberRows(statusRes.rows);
|
||||||
|
const byDepartment = toNumberRows(deptRes.rows);
|
||||||
|
const byMonth = toNumberRows(monthlyRes.rows);
|
||||||
|
|
||||||
|
const quality = {
|
||||||
|
emptyContributors: Number(qualityRes.rows[0]?.empty_contributors || 0),
|
||||||
|
emptyDepartments: Number(qualityRes.rows[0]?.empty_departments || 0),
|
||||||
|
emptyRemarks: Number(qualityRes.rows[0]?.empty_remarks || 0)
|
||||||
|
};
|
||||||
|
|
||||||
|
let nameSamples = {
|
||||||
|
mode,
|
||||||
|
strategy: nameStrategy,
|
||||||
|
sent: 0,
|
||||||
|
total: 0,
|
||||||
|
truncated: false,
|
||||||
|
items: []
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mode === 'deep') {
|
||||||
|
const nameCountRes = await db.query(
|
||||||
|
`${ownerAchievementsCte}
|
||||||
|
SELECT COUNT(*)::int AS count
|
||||||
|
FROM owner_achievements
|
||||||
|
WHERE ${scopedWhere}
|
||||||
|
AND name IS NOT NULL
|
||||||
|
AND LENGTH(BTRIM(name)) > 0`,
|
||||||
|
scopedParams
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalNameCount = Number(nameCountRes.rows[0]?.count || 0);
|
||||||
|
let sampleRows = [];
|
||||||
|
|
||||||
|
if (totalNameCount > 0) {
|
||||||
|
if (nameStrategy === 'balanced') {
|
||||||
|
const distinctTypeCount = Math.max(byType.filter((item) => !!item.type).length, 1);
|
||||||
|
const perTypeCap = Math.max(2, Math.ceil(nameLimit / distinctTypeCount));
|
||||||
|
const balancedParams = [...scopedParams, perTypeCap, nameLimit];
|
||||||
|
const balancedRes = await db.query(
|
||||||
|
`${ownerAchievementsCte}
|
||||||
|
SELECT id, name, type, owner_department AS department, achievement_date
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
owner_department,
|
||||||
|
achievement_date,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY COALESCE(type, 'unknown')
|
||||||
|
ORDER BY achievement_date DESC NULLS LAST, id DESC
|
||||||
|
) AS rn
|
||||||
|
FROM owner_achievements
|
||||||
|
WHERE ${scopedWhere}
|
||||||
|
AND name IS NOT NULL
|
||||||
|
AND LENGTH(BTRIM(name)) > 0
|
||||||
|
) t
|
||||||
|
WHERE rn <= $${scopedParams.length + 1}
|
||||||
|
ORDER BY achievement_date DESC NULLS LAST, id DESC
|
||||||
|
LIMIT $${scopedParams.length + 2}`,
|
||||||
|
balancedParams
|
||||||
|
);
|
||||||
|
sampleRows = balancedRes.rows;
|
||||||
|
} else {
|
||||||
|
const latestParams = [...scopedParams, nameLimit];
|
||||||
|
const latestRes = await db.query(
|
||||||
|
`${ownerAchievementsCte}
|
||||||
|
SELECT id, name, type, owner_department AS department, achievement_date
|
||||||
|
FROM owner_achievements
|
||||||
|
WHERE ${scopedWhere}
|
||||||
|
AND name IS NOT NULL
|
||||||
|
AND LENGTH(BTRIM(name)) > 0
|
||||||
|
ORDER BY achievement_date DESC NULLS LAST, id DESC
|
||||||
|
LIMIT $${scopedParams.length + 1}`,
|
||||||
|
latestParams
|
||||||
|
);
|
||||||
|
sampleRows = latestRes.rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = sampleRows
|
||||||
|
.map((row) => ({
|
||||||
|
name: clampText(row?.name, 120),
|
||||||
|
type: row?.type || null,
|
||||||
|
department: row?.department || null,
|
||||||
|
date: toIsoDate(row?.achievement_date)
|
||||||
|
}))
|
||||||
|
.filter((item) => !!item.name);
|
||||||
|
|
||||||
|
nameSamples = {
|
||||||
|
mode,
|
||||||
|
strategy: nameStrategy,
|
||||||
|
sent: items.length,
|
||||||
|
total: totalNameCount,
|
||||||
|
truncated: items.length < totalNameCount,
|
||||||
|
items
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
filters: {
|
||||||
|
year: normalizeFilter(filters.year) || 'all',
|
||||||
|
type: normalizeFilter(filters.type) || 'all',
|
||||||
|
status: normalizeFilter(filters.status) || 'all',
|
||||||
|
department: normalizeFilter(filters.department) || 'all'
|
||||||
|
},
|
||||||
|
overview: {
|
||||||
|
total
|
||||||
|
},
|
||||||
|
byType,
|
||||||
|
byStatus,
|
||||||
|
byDepartment,
|
||||||
|
byMonth,
|
||||||
|
quality,
|
||||||
|
nameSamples,
|
||||||
|
protection: {
|
||||||
|
attachmentContentSent: false,
|
||||||
|
attachmentPathSent: false,
|
||||||
|
attachmentFilenameSent: false,
|
||||||
|
contributorPhoneSent: false,
|
||||||
|
contributorNameSent: false,
|
||||||
|
achievementNameSent: mode === 'deep',
|
||||||
|
note: mode === 'deep'
|
||||||
|
? 'Deep mode sends aggregated statistics and achievement names only. No personal phone, contributor name, or attachment data is sent.'
|
||||||
|
: 'Standard mode sends aggregated statistics only. No names, phones, or attachment data is sent.'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPrompt = (question, dataset) => {
|
||||||
|
const promptDataset = buildPromptDataset(dataset);
|
||||||
|
const mode = dataset?.mode === 'deep' ? 'deep' : 'standard';
|
||||||
|
const modeNote = mode === 'deep'
|
||||||
|
? '当前为深度分析模式:会额外提供成果名称样本(不含个人姓名与手机号)。'
|
||||||
|
: '当前为标准分析模式:仅提供聚合统计数据,不提供成果名称。';
|
||||||
|
const systemPrompt = `
|
||||||
|
你是科研成果统计分析助手。只能基于输入数据给出结论,不得编造事实。
|
||||||
|
要求:
|
||||||
|
1) 明确统计口径与边界;
|
||||||
|
2) 关键判断尽量给出数量/占比/趋势证据;
|
||||||
|
3) 证据不足时直接说明“数据不足以支持该结论”;
|
||||||
|
4) 建议必须可执行、可落地。
|
||||||
|
请使用中文 Markdown 输出,结构如下:
|
||||||
|
## 执行摘要
|
||||||
|
## 关键发现
|
||||||
|
## 风险与数据质量
|
||||||
|
## 优先行动(表格)
|
||||||
|
## 一句话结论
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const userPrompt = `
|
||||||
|
用户问题:${question || '请给出整体分析、风险和改进建议。'}
|
||||||
|
|
||||||
|
模式说明:${modeNote}
|
||||||
|
|
||||||
|
隐私口径:
|
||||||
|
- 不包含附件内容、文件路径、文件名;
|
||||||
|
- 不包含个人手机号、贡献者姓名;
|
||||||
|
- 部门统计已排除院领导部门,每条成果仅计入一个归属部门。
|
||||||
|
|
||||||
|
请先结论后证据再建议。以下是可用数据 JSON:
|
||||||
|
${JSON.stringify(promptDataset)}
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
systemPrompt,
|
||||||
|
userPrompt
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const callProvider = async (provider, config, systemPrompt, userPrompt, maxTokens = AI_MAX_TOKENS) => {
|
||||||
|
const endpoint = `${config.baseUrl.replace(/\/+$/, '')}/chat/completions`;
|
||||||
|
const aiResp = await postJson(
|
||||||
|
endpoint,
|
||||||
|
{
|
||||||
|
model: config.model,
|
||||||
|
temperature: 0.2,
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: userPrompt }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Authorization: `Bearer ${config.apiKey}`
|
||||||
|
},
|
||||||
|
AI_TIMEOUT_MS
|
||||||
|
);
|
||||||
|
|
||||||
|
const analysis = aiResp?.choices?.[0]?.message?.content || '';
|
||||||
|
if (!analysis) {
|
||||||
|
throw new Error('provider returned empty analysis');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
model: config.model,
|
||||||
|
analysis
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
router.post('/achievements', verifyToken, async (req, res) => {
|
||||||
|
if (!ADMIN_ROLES.has(req.user.role)) {
|
||||||
|
return res.status(403).json({ message: 'No permission to use AI analysis.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedProvider = normalizeProvider(req.body?.provider || 'auto');
|
||||||
|
const providerConfigs = getProviderConfigs();
|
||||||
|
const availableProviders = getAvailableProviders(providerConfigs);
|
||||||
|
if (availableProviders.length === 0) {
|
||||||
|
return res.status(503).json({ message: 'AI service is not configured. Please configure DeepSeek API settings.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerOrder = resolveProviderOrder(requestedProvider, availableProviders);
|
||||||
|
if (providerOrder.length === 0) {
|
||||||
|
return res.status(503).json({
|
||||||
|
message: `Requested provider '${requestedProvider}' is unavailable. Available providers: ${availableProviders.join(', ')}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const question = clampText(req.body?.question, 600);
|
||||||
|
const filters = req.body?.filters || {};
|
||||||
|
const mode = normalizeMode(req.body?.mode);
|
||||||
|
const nameLimit = normalizeNameLimit(req.body?.nameLimit);
|
||||||
|
const nameStrategy = normalizeNameStrategy(req.body?.nameStrategy);
|
||||||
|
const analysisOptions = { mode, nameLimit, nameStrategy };
|
||||||
|
const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dataset = await gatherAnalysisDataset(filters, analysisOptions);
|
||||||
|
const { systemPrompt, userPrompt } = buildPrompt(question, dataset);
|
||||||
|
const triedProviders = [];
|
||||||
|
const errors = [];
|
||||||
|
let aiResult = null;
|
||||||
|
|
||||||
|
for (const provider of providerOrder) {
|
||||||
|
triedProviders.push(provider);
|
||||||
|
try {
|
||||||
|
aiResult = await callProvider(provider, providerConfigs[provider], systemPrompt, userPrompt, AI_MAX_TOKENS);
|
||||||
|
break;
|
||||||
|
} catch (providerErr) {
|
||||||
|
errors.push(`${provider}: ${providerErr.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!aiResult) {
|
||||||
|
throw new Error(`All AI providers failed: ${errors.join(' | ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`AI analysis generated by user ${req.user.username} (role=${req.user.role}, provider=${aiResult.provider}, ip=${clientIp})`
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
provider: aiResult.provider,
|
||||||
|
model: aiResult.model,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
analysis: aiResult.analysis,
|
||||||
|
triedProviders,
|
||||||
|
mode: dataset.mode,
|
||||||
|
nameSamples: dataset.nameSamples,
|
||||||
|
datasetSummary: {
|
||||||
|
filters: dataset.filters,
|
||||||
|
total: dataset.overview.total,
|
||||||
|
byTypeCount: dataset.byType.length,
|
||||||
|
byDepartmentCount: dataset.byDepartment.length,
|
||||||
|
byMonthCount: dataset.byMonth.length
|
||||||
|
},
|
||||||
|
protection: dataset.protection
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`AI analysis failed for user ${req.user.username}: ${err.message}`);
|
||||||
|
res.status(500).json({ message: `AI analysis failed: ${err.message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/achievements/stream', verifyToken, async (req, res) => {
|
||||||
|
if (!ADMIN_ROLES.has(req.user.role)) {
|
||||||
|
return res.status(403).json({ message: 'No permission to use AI analysis.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedProvider = normalizeProvider(req.body?.provider || 'auto');
|
||||||
|
const providerConfigs = getProviderConfigs();
|
||||||
|
const availableProviders = getAvailableProviders(providerConfigs);
|
||||||
|
if (availableProviders.length === 0) {
|
||||||
|
return res.status(503).json({ message: 'AI service is not configured. Please configure DeepSeek API settings.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerOrder = resolveProviderOrder(requestedProvider, availableProviders);
|
||||||
|
if (providerOrder.length === 0) {
|
||||||
|
return res.status(503).json({
|
||||||
|
message: `Requested provider '${requestedProvider}' is unavailable. Available providers: ${availableProviders.join(', ')}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const question = clampText(req.body?.question, 600);
|
||||||
|
const filters = req.body?.filters || {};
|
||||||
|
const mode = normalizeMode(req.body?.mode);
|
||||||
|
const nameLimit = normalizeNameLimit(req.body?.nameLimit);
|
||||||
|
const nameStrategy = normalizeNameStrategy(req.body?.nameStrategy);
|
||||||
|
const analysisOptions = { mode, nameLimit, nameStrategy };
|
||||||
|
const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dataset = await gatherAnalysisDataset(filters, analysisOptions);
|
||||||
|
const { systemPrompt, userPrompt } = buildPrompt(question, dataset);
|
||||||
|
const triedProviders = [];
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
res.setHeader('X-Accel-Buffering', 'no');
|
||||||
|
if (typeof res.flushHeaders === 'function') {
|
||||||
|
res.flushHeaders();
|
||||||
|
}
|
||||||
|
|
||||||
|
let streamResult = null;
|
||||||
|
for (const provider of providerOrder) {
|
||||||
|
triedProviders.push(provider);
|
||||||
|
try {
|
||||||
|
streamResult = await streamProviderToClient({
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
provider,
|
||||||
|
config: providerConfigs[provider],
|
||||||
|
systemPrompt,
|
||||||
|
userPrompt,
|
||||||
|
maxTokens: AI_MAX_TOKENS,
|
||||||
|
timeoutMs: AI_TIMEOUT_MS
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
} catch (providerErr) {
|
||||||
|
errors.push(`${provider}: ${providerErr.message}`);
|
||||||
|
writeSse(res, 'provider_error', {
|
||||||
|
provider,
|
||||||
|
message: providerErr.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!streamResult) {
|
||||||
|
writeSse(res, 'error', { message: `All AI providers failed: ${errors.join(' | ')}` });
|
||||||
|
writeSse(res, 'done', { success: false, triedProviders });
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`AI stream analysis generated by user ${req.user.username} (role=${req.user.role}, provider=${streamResult.provider}, ip=${clientIp})`
|
||||||
|
);
|
||||||
|
|
||||||
|
writeSse(res, 'done', {
|
||||||
|
success: true,
|
||||||
|
provider: streamResult.provider,
|
||||||
|
model: streamResult.model,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
triedProviders,
|
||||||
|
mode: dataset.mode,
|
||||||
|
nameSamples: dataset.nameSamples,
|
||||||
|
datasetSummary: {
|
||||||
|
filters: dataset.filters,
|
||||||
|
total: dataset.overview.total,
|
||||||
|
byTypeCount: dataset.byType.length,
|
||||||
|
byDepartmentCount: dataset.byDepartment.length,
|
||||||
|
byMonthCount: dataset.byMonth.length
|
||||||
|
},
|
||||||
|
protection: dataset.protection
|
||||||
|
});
|
||||||
|
res.end();
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`AI stream analysis failed for user ${req.user.username}: ${err.message}`);
|
||||||
|
if (res.headersSent) {
|
||||||
|
writeSse(res, 'error', { message: `AI analysis failed: ${err.message}` });
|
||||||
|
writeSse(res, 'done', { success: false });
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(500).json({ message: `AI analysis failed: ${err.message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const db = require('../db');
|
||||||
|
const SmsClient = require('../utils/sms');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
// 内存存储验证码 (手机号 -> {code, expire, lastSentTime, ip})
|
||||||
|
const verifyCodes = new Map();
|
||||||
|
|
||||||
|
// 定时清理过期验证码 (每5分钟执行一次)
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [phone, data] of verifyCodes.entries()) {
|
||||||
|
if (now > data.expire) {
|
||||||
|
verifyCodes.delete(phone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 5 * 60 * 1000);
|
||||||
|
|
||||||
|
// 发送验证码
|
||||||
|
router.post('/send-code', async (req, res) => {
|
||||||
|
const { phoneNumber, type } = req.body; // type: 'register' | 'reset'
|
||||||
|
const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
|
||||||
|
|
||||||
|
if (!/^1[3-9]\d{9}$/.test(phoneNumber)) {
|
||||||
|
return res.status(400).json({ message: '手机号格式不正确' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查发送频率 (60s 冷却)
|
||||||
|
const existingRecord = verifyCodes.get(phoneNumber);
|
||||||
|
if (existingRecord) {
|
||||||
|
const timeSinceLastSent = Date.now() - existingRecord.lastSentTime;
|
||||||
|
if (timeSinceLastSent < 60 * 1000) {
|
||||||
|
const remainingSeconds = Math.ceil((60000 - timeSinceLastSent) / 1000);
|
||||||
|
return res.status(429).json({ message: `请等待 ${remainingSeconds} 秒后再试` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 如果是重置密码,检查用户是否存在
|
||||||
|
if (type === 'reset') {
|
||||||
|
const userCheck = await db.query('SELECT id FROM users WHERE username = $1', [phoneNumber]);
|
||||||
|
if (userCheck.rows.length === 0) {
|
||||||
|
return res.status(404).json({ message: '该手机号未注册' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 如果是注册,检查用户是否已存在
|
||||||
|
else if (type === 'register') {
|
||||||
|
const userCheck = await db.query('SELECT id FROM users WHERE username = $1', [phoneNumber]);
|
||||||
|
if (userCheck.rows.length > 0) {
|
||||||
|
return res.status(400).json({ message: '该手机号已注册' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = Math.floor(100000 + Math.random() * 900000).toString();
|
||||||
|
await SmsClient.sendVerifyCode(phoneNumber, code);
|
||||||
|
|
||||||
|
verifyCodes.set(phoneNumber, {
|
||||||
|
code,
|
||||||
|
expire: Date.now() + 2 * 60 * 1000, // 2分钟有效
|
||||||
|
lastSentTime: Date.now(),
|
||||||
|
ip: clientIp
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`验证码已发送至 ${phoneNumber} (类型: ${type}, IP: ${clientIp})`);
|
||||||
|
res.json({ message: '验证码已发送' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`发送验证码失败: ${error.message}`);
|
||||||
|
res.status(500).json({ message: '发送失败,请稍后再试' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 重置密码
|
||||||
|
router.post('/reset-password', async (req, res) => {
|
||||||
|
const { phoneNumber, code, newPassword } = req.body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 校验验证码
|
||||||
|
const record = verifyCodes.get(phoneNumber);
|
||||||
|
if (!record || record.code !== code || Date.now() > record.expire) {
|
||||||
|
return res.status(400).json({ message: '验证码无效或已过期' });
|
||||||
|
}
|
||||||
|
verifyCodes.delete(phoneNumber);
|
||||||
|
|
||||||
|
// 2. 更新密码
|
||||||
|
const salt = await bcrypt.genSalt(10);
|
||||||
|
const passwordHash = await bcrypt.hash(newPassword, salt);
|
||||||
|
|
||||||
|
// 更新密码同时增加 token_version,使所有旧设备下线 (增加溢出保护)
|
||||||
|
await db.query(
|
||||||
|
'UPDATE users SET password_hash = $1, token_version = CASE WHEN token_version >= 2000000000 THEN 1 ELSE token_version + 1 END WHERE username = $2',
|
||||||
|
[passwordHash, phoneNumber]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`用户 ${phoneNumber} 重置密码成功`);
|
||||||
|
res.json({ message: '密码重置成功' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`重置密码失败: ${err.message}`);
|
||||||
|
res.status(500).json({ message: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 登录
|
||||||
|
router.post('/login', async (req, res) => {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return res.status(400).json({ message: '用户名和密码不能为空' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await db.query('SELECT * FROM users WHERE username = $1', [username]);
|
||||||
|
const user = result.rows[0];
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
logger.warn(`登录失败: 用户名 ${username} 不存在 (IP: ${clientIp})`);
|
||||||
|
return res.status(401).json({ message: '用户名或密码错误' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMatch = await bcrypt.compare(password, user.password_hash);
|
||||||
|
if (!isMatch) {
|
||||||
|
logger.warn(`登录失败: 用户 ${username} 密码错误 (IP: ${clientIp})`);
|
||||||
|
return res.status(401).json({ message: '用户名或密码错误' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录成功,更新 token_version 实现单点登录 (增加溢出保护)
|
||||||
|
const updateRes = await db.query(
|
||||||
|
'UPDATE users SET token_version = CASE WHEN token_version >= 2000000000 THEN 1 ELSE token_version + 1 END WHERE id = $1 RETURNING token_version',
|
||||||
|
[user.id]
|
||||||
|
);
|
||||||
|
const tokenVersion = updateRes.rows[0].token_version;
|
||||||
|
|
||||||
|
logger.info(`用户 ${username} 登录成功 (IP: ${clientIp}, Version: ${tokenVersion})`);
|
||||||
|
const token = jwt.sign(
|
||||||
|
{
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
real_name: user.real_name,
|
||||||
|
token_version: tokenVersion // 将版本号放入 Token
|
||||||
|
},
|
||||||
|
process.env.JWT_SECRET,
|
||||||
|
{ expiresIn: '24h' }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
real_name: user.real_name
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ message: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 注册
|
||||||
|
router.post('/register', async (req, res) => {
|
||||||
|
const { username, password, role: requestedRole, real_name, department, code } = req.body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 校验验证码
|
||||||
|
const record = verifyCodes.get(username);
|
||||||
|
if (!record || record.code !== code || Date.now() > record.expire) {
|
||||||
|
return res.status(400).json({ message: '验证码无效或已过期' });
|
||||||
|
}
|
||||||
|
verifyCodes.delete(username); // 校验成功后删除
|
||||||
|
|
||||||
|
// 2. 手机号格式校验
|
||||||
|
if (!/^1[3-9]\d{9}$/.test(username)) {
|
||||||
|
return res.status(400).json({ message: '手机号格式不正确' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userExists = await db.query('SELECT * FROM users WHERE username = $1', [username]);
|
||||||
|
if (userExists.rows.length > 0) {
|
||||||
|
return res.status(400).json({ message: '该手机号已注册' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验部门
|
||||||
|
const deptCheck = await db.query('SELECT * FROM departments WHERE name = $1', [department]);
|
||||||
|
let finalDepartment = department;
|
||||||
|
let deptStatus = 'verified';
|
||||||
|
|
||||||
|
if (deptCheck.rows.length === 0) {
|
||||||
|
finalDepartment = '待定';
|
||||||
|
deptStatus = 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
const salt = await bcrypt.genSalt(10);
|
||||||
|
const passwordHash = await bcrypt.hash(password, salt);
|
||||||
|
|
||||||
|
if (requestedRole && requestedRole !== 'user') {
|
||||||
|
const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
|
||||||
|
logger.warn(`娉ㄥ唽 role 瓒婃潈灏濊瘯: username=${username}, requestedRole=${requestedRole}, IP=${clientIp}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query(
|
||||||
|
'INSERT INTO users (username, password_hash, role, real_name, department, dept_status) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, username, role, real_name, department, dept_status',
|
||||||
|
[username, passwordHash, 'user', real_name, finalDepartment, deptStatus]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
|
||||||
|
logger.info(`新用户注册成功: ${username} (${real_name}) (IP: ${clientIp})`);
|
||||||
|
|
||||||
|
res.status(201).json(result.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ message: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const db = require('../db');
|
||||||
|
const { verifyToken, isSystemAdmin } = require('../middleware/auth');
|
||||||
|
|
||||||
|
// 定义允许管理的字典表映射
|
||||||
|
const tableMap = {
|
||||||
|
'departments': 'departments',
|
||||||
|
'organizations': 'dict_organizations',
|
||||||
|
'award-types': 'dict_award_types',
|
||||||
|
'award-levels': 'dict_award_levels',
|
||||||
|
'paper-types': 'dict_paper_types',
|
||||||
|
'standard-types': 'dict_standard_types',
|
||||||
|
'project-categories': 'dict_project_categories',
|
||||||
|
'leader-departments': 'dict_leader_departments'
|
||||||
|
};
|
||||||
|
|
||||||
|
// 特殊接口:公开获取部门列表 (用于注册页面,无需登录)
|
||||||
|
router.get('/departments', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await db.query('SELECT * FROM departments ORDER BY id ASC');
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ message: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 通用获取接口 (登录用户即可访问,用于下拉框)
|
||||||
|
router.get('/:type', verifyToken, async (req, res) => {
|
||||||
|
const tableName = tableMap[req.params.type];
|
||||||
|
if (!tableName) return res.status(404).json({ message: '未知的字典类型' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await db.query(`SELECT * FROM ${tableName} ORDER BY id ASC`);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ message: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 通用添加接口 (系统管理员:超管或维护员)
|
||||||
|
router.post('/:type', verifyToken, isSystemAdmin, async (req, res) => {
|
||||||
|
const tableName = tableMap[req.params.type];
|
||||||
|
if (!tableName) return res.status(404).json({ message: '未知的字典类型' });
|
||||||
|
|
||||||
|
const { name } = req.body;
|
||||||
|
if (!name) return res.status(400).json({ message: '名称不能为空' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await db.query(
|
||||||
|
`INSERT INTO ${tableName} (name) VALUES ($1) RETURNING *`,
|
||||||
|
[name]
|
||||||
|
);
|
||||||
|
res.status(201).json(result.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === '23505') {
|
||||||
|
return res.status(400).json({ message: '该项已存在' });
|
||||||
|
}
|
||||||
|
res.status(500).json({ message: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 通用修改接口 (系统管理员:超管或维护员)
|
||||||
|
router.put('/:type/:id', verifyToken, isSystemAdmin, async (req, res) => {
|
||||||
|
const tableName = tableMap[req.params.type];
|
||||||
|
if (!tableName) return res.status(404).json({ message: '未知的字典类型' });
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const { name } = req.body;
|
||||||
|
if (!name) return res.status(400).json({ message: '名称不能为空' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await db.query(
|
||||||
|
`UPDATE ${tableName} SET name = $1 WHERE id = $2 RETURNING *`,
|
||||||
|
[name, id]
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) return res.status(404).json({ message: '记录不存在' });
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === '23505') {
|
||||||
|
return res.status(400).json({ message: '名称已存在' });
|
||||||
|
}
|
||||||
|
res.status(500).json({ message: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 通用删除接口 (系统管理员:超管或维护员)
|
||||||
|
router.delete('/:type/:id', verifyToken, isSystemAdmin, async (req, res) => {
|
||||||
|
const type = req.params.type;
|
||||||
|
const tableName = tableMap[type];
|
||||||
|
if (!tableName) return res.status(404).json({ message: '未知的字典类型' });
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取名称用于后续逻辑或检查
|
||||||
|
const checkResult = await db.query(`SELECT name FROM ${tableName} WHERE id = $1`, [id]);
|
||||||
|
if (checkResult.rows.length === 0) return res.status(404).json({ message: '记录不存在' });
|
||||||
|
|
||||||
|
const name = checkResult.rows[0].name;
|
||||||
|
|
||||||
|
// 检查是否是“待定”
|
||||||
|
if (name === '待定') {
|
||||||
|
return res.status(400).json({ message: '系统保留项“待定”不能删除' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开启事务处理级联更新
|
||||||
|
const client = await db.pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// 1. 执行级联更新逻辑
|
||||||
|
if (type === 'departments') {
|
||||||
|
// 更新用户表:部门设为待定,状态设为 pending
|
||||||
|
await client.query(
|
||||||
|
"UPDATE users SET department = '待定', dept_status = 'pending' WHERE department = $1",
|
||||||
|
[name]
|
||||||
|
);
|
||||||
|
// 更新成果表:替换归属部门数组中的项
|
||||||
|
await client.query(
|
||||||
|
"UPDATE achievements SET assigned_departments = array_replace(assigned_departments, $1, '待定') WHERE $1 = ANY(assigned_departments)",
|
||||||
|
[name]
|
||||||
|
);
|
||||||
|
} else if (type === 'organizations') {
|
||||||
|
await client.query("UPDATE achievement_award SET award_unit = '待定' WHERE award_unit = $1", [name]);
|
||||||
|
await client.query("UPDATE achievement_project SET source = '待定' WHERE source = $1", [name]);
|
||||||
|
await client.query("UPDATE achievement_monograph SET publisher = '待定' WHERE publisher = $1", [name]);
|
||||||
|
// achievement_plan 表没有 unit 字段,跳过
|
||||||
|
// achievement_transformation 表没有 receiver_unit 字段,跳过
|
||||||
|
await client.query("UPDATE achievement_software SET owner_unit = '待定' WHERE owner_unit = $1", [name]); // 修正字段名 owner -> owner_unit
|
||||||
|
await client.query("UPDATE achievement_paper SET first_unit = '待定' WHERE first_unit = $1", [name]);
|
||||||
|
} else if (type === 'award-types') {
|
||||||
|
await client.query("UPDATE achievement_award SET award_type = '待定' WHERE award_type = $1", [name]);
|
||||||
|
} else if (type === 'award-levels') {
|
||||||
|
await client.query("UPDATE achievement_award SET award_level = '待定' WHERE award_level = $1", [name]);
|
||||||
|
} else if (type === 'paper-types') {
|
||||||
|
await client.query("UPDATE achievement_paper SET paper_type = '待定' WHERE paper_type = $1", [name]);
|
||||||
|
} else if (type === 'standard-types') {
|
||||||
|
await client.query("UPDATE achievement_standard SET standard_type = '待定' WHERE standard_type = $1", [name]);
|
||||||
|
} else if (type === 'project-categories') {
|
||||||
|
await client.query("UPDATE achievement_project SET project_category = '待定' WHERE project_category = $1", [name]);
|
||||||
|
} else if (type === 'leader-departments') {
|
||||||
|
// 领导层部门字典仅用于筛选,删除时不影响其他表数据
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 执行删除
|
||||||
|
await client.query(`DELETE FROM ${tableName} WHERE id = $1`, [id]);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
res.json({ message: '删除成功,关联数据已同步为“待定”' });
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ message: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const db = require('../db');
|
||||||
|
const { verifyToken, isSystemAdmin } = require('../middleware/auth');
|
||||||
|
|
||||||
|
// 获取所有操作说明列表
|
||||||
|
router.get('/descriptions', verifyToken, isSystemAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await db.query('SELECT DISTINCT description FROM audit_logs WHERE description IS NOT NULL AND description != \'\' ORDER BY description');
|
||||||
|
res.json(result.rows.map(row => row.description));
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ message: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取系统日志 (超管 + 维护员)
|
||||||
|
router.get('/', verifyToken, isSystemAdmin, async (req, res) => {
|
||||||
|
const { page = 1, limit = 20, username, startDate, endDate, description } = req.query;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let query = 'SELECT * FROM audit_logs WHERE 1=1';
|
||||||
|
let countQuery = 'SELECT COUNT(*) FROM audit_logs WHERE 1=1';
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (username) {
|
||||||
|
query += ` AND (username ILIKE $${params.length + 1} OR real_name ILIKE $${params.length + 1})`;
|
||||||
|
countQuery += ` AND (username ILIKE $${params.length + 1} OR real_name ILIKE $${params.length + 1})`;
|
||||||
|
params.push(`%${username}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate) {
|
||||||
|
query += ` AND created_at >= $${params.length + 1}`;
|
||||||
|
countQuery += ` AND created_at >= $${params.length + 1}`;
|
||||||
|
params.push(startDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate) {
|
||||||
|
// 结束日期通常需要加一天或者设为当天的 23:59:59,这里假设前端传的是日期字符串
|
||||||
|
// 简单处理:如果传的是 YYYY-MM-DD,则数据库比较时会自动转为 00:00:00,所以可能需要处理
|
||||||
|
// 这里假设前端传的是完整时间或者后端直接比较
|
||||||
|
query += ` AND created_at <= $${params.length + 1}`;
|
||||||
|
countQuery += ` AND created_at <= $${params.length + 1}`;
|
||||||
|
params.push(endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (description) {
|
||||||
|
query += ` AND description = $${params.length + 1}`;
|
||||||
|
countQuery += ` AND description = $${params.length + 1}`;
|
||||||
|
params.push(description);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` ORDER BY created_at DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const countRes = await db.query(countQuery, params);
|
||||||
|
const total = parseInt(countRes.rows[0].count);
|
||||||
|
|
||||||
|
const result = await db.query(query, [...params, limit, offset]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
logs: result.rows,
|
||||||
|
total,
|
||||||
|
page: parseInt(page),
|
||||||
|
totalPages: Math.ceil(total / limit)
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ message: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const multer = require('multer');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const db = require('../db');
|
||||||
|
const { verifyToken, isSystemAdmin } = require('../middleware/auth');
|
||||||
|
|
||||||
|
// 辅助函数:获取所有搜索路径(主目录 + 备用目录)
|
||||||
|
const getSearchPaths = () => {
|
||||||
|
const paths = [];
|
||||||
|
|
||||||
|
// 1. 主目录
|
||||||
|
const mainDir = process.env.UPLOAD_DIR || 'uploads';
|
||||||
|
paths.push(path.isAbsolute(mainDir) ? mainDir : path.join(__dirname, '..', mainDir));
|
||||||
|
|
||||||
|
// 2. 备用目录
|
||||||
|
if (process.env.UPLOAD_FALLBACK_DIRS) {
|
||||||
|
const fallbacks = process.env.UPLOAD_FALLBACK_DIRS.split(',').map(p => p.trim()).filter(p => p);
|
||||||
|
fallbacks.forEach(p => {
|
||||||
|
const absPath = path.isAbsolute(p) ? p : path.join(__dirname, '..', p);
|
||||||
|
// 避免重复
|
||||||
|
if (!paths.includes(absPath)) {
|
||||||
|
paths.push(absPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 默认 uploads 目录 (自动兜底)
|
||||||
|
// 即使未配置 UPLOAD_FALLBACK_DIRS,也自动查找默认的 uploads 目录,确保旧文件可访问
|
||||||
|
const defaultUploads = path.join(__dirname, '..', 'uploads');
|
||||||
|
const isDefaultIncluded = paths.some(p => path.resolve(p) === path.resolve(defaultUploads));
|
||||||
|
|
||||||
|
if (!isDefaultIncluded) {
|
||||||
|
paths.push(defaultUploads);
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 辅助函数:查找文件(支持多路径回退)
|
||||||
|
const findFile = (relativePath) => {
|
||||||
|
const searchPaths = getSearchPaths();
|
||||||
|
for (const rootDir of searchPaths) {
|
||||||
|
const fullPath = path.join(rootDir, relativePath);
|
||||||
|
if (fs.existsSync(fullPath)) {
|
||||||
|
return fullPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 辅助函数:获取上传根目录的绝对路径 (仅用于写入新文件)
|
||||||
|
const getUploadRootDir = () => {
|
||||||
|
const uploadDir = process.env.UPLOAD_DIR || 'uploads';
|
||||||
|
if (path.isAbsolute(uploadDir)) {
|
||||||
|
return uploadDir;
|
||||||
|
} else {
|
||||||
|
return path.join(__dirname, '..', uploadDir);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanupUploadedFiles = (files = []) => {
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file || !file.path) continue;
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(file.path)) {
|
||||||
|
fs.unlinkSync(file.path);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore cleanup failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeNotificationHtml = (html = '') => {
|
||||||
|
return String(html)
|
||||||
|
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||||
|
.replace(/\son\w+\s*=\s*(['"]).*?\1/gi, '')
|
||||||
|
.replace(/\son\w+\s*=\s*[^\s>]+/gi, '')
|
||||||
|
.replace(/javascript:/gi, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 配置 Multer 用于文件上传
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => {
|
||||||
|
const uploadDir = getUploadRootDir();
|
||||||
|
// 确保目录存在
|
||||||
|
if (!fs.existsSync(uploadDir)) {
|
||||||
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
|
}
|
||||||
|
cb(null, uploadDir);
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||||
|
cb(null, 'notify-' + uniqueSuffix + path.extname(file.originalname));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const allowedExtensions = [
|
||||||
|
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
||||||
|
'.txt', '.jpg', '.jpeg', '.png', '.zip', '.rar', '.7z'
|
||||||
|
];
|
||||||
|
|
||||||
|
const MAX_NOTIFICATION_FILE_SIZE = Number(process.env.NOTIFY_MAX_FILE_SIZE || 20 * 1024 * 1024);
|
||||||
|
const MAX_NOTIFICATION_FILES = Number(process.env.NOTIFY_MAX_FILES || 10);
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage: storage,
|
||||||
|
limits: {
|
||||||
|
fileSize: MAX_NOTIFICATION_FILE_SIZE,
|
||||||
|
files: MAX_NOTIFICATION_FILES
|
||||||
|
},
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
|
if (allowedExtensions.includes(ext)) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error(`Unsupported file type (${ext}).`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取通知列表 (所有登录用户)
|
||||||
|
router.get('/', verifyToken, async (req, res) => {
|
||||||
|
const { page = 1, limit = 10, keyword } = req.query;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let query = 'SELECT n.*, u.real_name as publisher_real_name FROM notifications n LEFT JOIN users u ON n.publisher_id = u.id WHERE 1=1';
|
||||||
|
let countQuery = 'SELECT COUNT(*) FROM notifications WHERE 1=1';
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (keyword) {
|
||||||
|
query += ` AND (title ILIKE $${params.length + 1} OR content ILIKE $${params.length + 1})`;
|
||||||
|
countQuery += ` AND (title ILIKE $${params.length + 1} OR content ILIKE $${params.length + 1})`;
|
||||||
|
params.push(`%${keyword}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` ORDER BY is_top DESC, created_at DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`;
|
||||||
|
|
||||||
|
const countRes = await db.query(countQuery, params);
|
||||||
|
const total = parseInt(countRes.rows[0].count);
|
||||||
|
|
||||||
|
const result = await db.query(query, [...params, limit, offset]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
list: result.rows,
|
||||||
|
total,
|
||||||
|
page: parseInt(page),
|
||||||
|
totalPages: Math.ceil(total / limit)
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ message: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取通知详情 (所有登录用户)
|
||||||
|
router.get('/:id', verifyToken, async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
try {
|
||||||
|
const result = await db.query('SELECT n.*, u.real_name as publisher_real_name FROM notifications n LEFT JOIN users u ON n.publisher_id = u.id WHERE n.id = $1', [id]);
|
||||||
|
if (result.rows.length === 0) return res.status(404).json({ message: '通知不存在' });
|
||||||
|
|
||||||
|
const notification = result.rows[0];
|
||||||
|
|
||||||
|
// 获取附件
|
||||||
|
const attachmentsRes = await db.query('SELECT * FROM notification_attachments WHERE notification_id = $1', [id]);
|
||||||
|
notification.attachments = attachmentsRes.rows;
|
||||||
|
|
||||||
|
res.json(notification);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ message: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 发布通知 (仅超管和维护员)
|
||||||
|
router.post('/', verifyToken, isSystemAdmin, (req, res, next) => {
|
||||||
|
upload.array('files')(req, res, (err) => {
|
||||||
|
if (err) {
|
||||||
|
cleanupUploadedFiles(req.files || []);
|
||||||
|
return res.status(400).json({ message: err.message });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}, async (req, res) => {
|
||||||
|
const { title, content, is_top } = req.body;
|
||||||
|
const files = req.files || [];
|
||||||
|
const publisher_id = req.user.id;
|
||||||
|
const publisher_name = req.user.real_name || req.user.username;
|
||||||
|
const safeContent = sanitizeNotificationHtml(content);
|
||||||
|
|
||||||
|
if (!title || !content || !String(safeContent).trim()) {
|
||||||
|
cleanupUploadedFiles(files);
|
||||||
|
return res.status(400).json({ message: '标题和内容不能为空' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await db.pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
const result = await client.query(
|
||||||
|
'INSERT INTO notifications (title, content, publisher_id, publisher_name, is_top) VALUES ($1, $2, $3, $4, $5) RETURNING id',
|
||||||
|
[title, safeContent, publisher_id, publisher_name, is_top === 'true']
|
||||||
|
);
|
||||||
|
const notificationId = result.rows[0].id;
|
||||||
|
|
||||||
|
// 保存附件
|
||||||
|
for (const file of files) {
|
||||||
|
let originalName = file.originalname;
|
||||||
|
// 简单处理文件名编码问题
|
||||||
|
if (!/[^\u0000-\u00ff]/.test(originalName)) {
|
||||||
|
try {
|
||||||
|
const decodedName = Buffer.from(originalName, 'latin1').toString('utf8');
|
||||||
|
if (/[\u4e00-\u9fa5]/.test(decodedName)) {
|
||||||
|
originalName = decodedName;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
'INSERT INTO notification_attachments (notification_id, file_name, file_path) VALUES ($1, $2, $3)',
|
||||||
|
[notificationId, originalName, file.filename]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
res.status(201).json({ message: '发布成功', id: notificationId });
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
cleanupUploadedFiles(files);
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ message: '服务器错误' });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 删除通知 (仅超管和维护员)
|
||||||
|
router.delete('/:id', verifyToken, isSystemAdmin, async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const client = await db.pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// 获取附件并删除文件
|
||||||
|
const filesRes = await client.query('SELECT file_path FROM notification_attachments WHERE notification_id = $1', [id]);
|
||||||
|
|
||||||
|
for (const row of filesRes.rows) {
|
||||||
|
const filePath = findFile(row.file_path);
|
||||||
|
if (filePath) {
|
||||||
|
try { fs.unlinkSync(filePath); } catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 级联删除会自动删除 notification_attachments 表中的记录
|
||||||
|
await client.query('DELETE FROM notifications WHERE id = $1', [id]);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
res.json({ message: '删除成功' });
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ message: '服务器错误' });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 下载附件
|
||||||
|
router.get('/attachments/:id/download', verifyToken, async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
try {
|
||||||
|
const result = await db.query('SELECT * FROM notification_attachments WHERE id = $1', [id]);
|
||||||
|
if (result.rows.length === 0) return res.status(404).json({ message: '附件不存在' });
|
||||||
|
|
||||||
|
const attachment = result.rows[0];
|
||||||
|
const filePath = findFile(attachment.file_path);
|
||||||
|
|
||||||
|
if (!filePath) return res.status(404).json({ message: '文件不存在' });
|
||||||
|
|
||||||
|
const encodedFileName = encodeURIComponent(attachment.file_name);
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodedFileName}`);
|
||||||
|
res.sendFile(filePath);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ message: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const db = require('../db');
|
||||||
|
const { verifyToken } = require('../middleware/auth');
|
||||||
|
|
||||||
|
// 获取统计数据 (所有登录用户均可访问)
|
||||||
|
router.get('/', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { role } = req.user;
|
||||||
|
|
||||||
|
// 权限控制:
|
||||||
|
// 1. 业务统计图表(雷达图、趋势图、部门分布等):统一只统计“已通过”的数据,反映真实有效的成果。
|
||||||
|
// 2. 状态分布图:管理员可以看到所有状态,普通用户只能看到已通过。
|
||||||
|
|
||||||
|
const approvedCondition = "status = 'approved'";
|
||||||
|
|
||||||
|
let allStatusCondition = "status = 'approved'";
|
||||||
|
if (['super_admin', 'maintainer'].includes(role)) {
|
||||||
|
allStatusCondition = "1=1"; // 允许所有状态
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 按类型统计 (雷达图) - 仅统计已通过
|
||||||
|
const typeStats = await db.query(`
|
||||||
|
SELECT type, COUNT(*) as count
|
||||||
|
FROM achievements
|
||||||
|
WHERE ${approvedCondition}
|
||||||
|
GROUP BY type
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 2. 按状态统计 (饼图) - 管理员看全量,用户看已通过
|
||||||
|
const statusStats = await db.query(`
|
||||||
|
SELECT status, COUNT(*) as count
|
||||||
|
FROM achievements
|
||||||
|
WHERE ${allStatusCondition}
|
||||||
|
GROUP BY status
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 3. 按部门统计成果总数 (部门排名) - 仅统计已通过
|
||||||
|
// 口径:每条成果只归属一个部门(优先首个非院领导部门,否则回退首个部门)
|
||||||
|
const departmentStats = await db.query(`
|
||||||
|
WITH leader_depts AS (
|
||||||
|
SELECT name FROM dict_leader_departments
|
||||||
|
),
|
||||||
|
leader_dept_array AS (
|
||||||
|
SELECT COALESCE(array_agg(name::text), ARRAY[]::text[]) AS names FROM leader_depts
|
||||||
|
),
|
||||||
|
department_scope AS (
|
||||||
|
SELECT d.name AS department
|
||||||
|
FROM departments d, leader_dept_array lda
|
||||||
|
WHERE d.name::text <> ALL(lda.names)
|
||||||
|
),
|
||||||
|
achievement_owner AS (
|
||||||
|
SELECT
|
||||||
|
a.id,
|
||||||
|
COALESCE(non_leader.dept, first_any.dept) AS owner_department
|
||||||
|
FROM achievements a
|
||||||
|
CROSS JOIN leader_dept_array lda
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT u.dept
|
||||||
|
FROM unnest(COALESCE(a.assigned_departments::text[], ARRAY[]::text[])) WITH ORDINALITY AS u(dept, ord)
|
||||||
|
WHERE u.dept IS NOT NULL
|
||||||
|
AND BTRIM(u.dept) <> ''
|
||||||
|
AND u.dept <> ALL(lda.names)
|
||||||
|
ORDER BY u.ord
|
||||||
|
LIMIT 1
|
||||||
|
) non_leader ON TRUE
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT u.dept
|
||||||
|
FROM unnest(COALESCE(a.assigned_departments::text[], ARRAY[]::text[])) WITH ORDINALITY AS u(dept, ord)
|
||||||
|
WHERE u.dept IS NOT NULL
|
||||||
|
AND BTRIM(u.dept) <> ''
|
||||||
|
ORDER BY u.ord
|
||||||
|
LIMIT 1
|
||||||
|
) first_any ON TRUE
|
||||||
|
WHERE ${approvedCondition}
|
||||||
|
)
|
||||||
|
SELECT ds.department, COUNT(ao.id)::int as count
|
||||||
|
FROM department_scope ds
|
||||||
|
LEFT JOIN achievement_owner ao ON ds.department = ao.owner_department
|
||||||
|
GROUP BY ds.department
|
||||||
|
ORDER BY count DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 4. 部门成果种类交叉统计 (堆叠柱状图) - 仅统计已通过
|
||||||
|
const deptTypeStats = await db.query(`
|
||||||
|
WITH leader_depts AS (
|
||||||
|
SELECT name FROM dict_leader_departments
|
||||||
|
),
|
||||||
|
leader_dept_array AS (
|
||||||
|
SELECT COALESCE(array_agg(name::text), ARRAY[]::text[]) AS names FROM leader_depts
|
||||||
|
),
|
||||||
|
department_scope AS (
|
||||||
|
SELECT d.name AS department
|
||||||
|
FROM departments d, leader_dept_array lda
|
||||||
|
WHERE d.name::text <> ALL(lda.names)
|
||||||
|
),
|
||||||
|
achievement_owner AS (
|
||||||
|
SELECT
|
||||||
|
a.id,
|
||||||
|
a.type,
|
||||||
|
a.achievement_date,
|
||||||
|
COALESCE(non_leader.dept, first_any.dept) AS owner_department
|
||||||
|
FROM achievements a
|
||||||
|
CROSS JOIN leader_dept_array lda
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT u.dept
|
||||||
|
FROM unnest(COALESCE(a.assigned_departments::text[], ARRAY[]::text[])) WITH ORDINALITY AS u(dept, ord)
|
||||||
|
WHERE u.dept IS NOT NULL
|
||||||
|
AND BTRIM(u.dept) <> ''
|
||||||
|
AND u.dept <> ALL(lda.names)
|
||||||
|
ORDER BY u.ord
|
||||||
|
LIMIT 1
|
||||||
|
) non_leader ON TRUE
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT u.dept
|
||||||
|
FROM unnest(COALESCE(a.assigned_departments::text[], ARRAY[]::text[])) WITH ORDINALITY AS u(dept, ord)
|
||||||
|
WHERE u.dept IS NOT NULL
|
||||||
|
AND BTRIM(u.dept) <> ''
|
||||||
|
ORDER BY u.ord
|
||||||
|
LIMIT 1
|
||||||
|
) first_any ON TRUE
|
||||||
|
WHERE ${approvedCondition}
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
ds.department as department,
|
||||||
|
ao.type,
|
||||||
|
EXTRACT(YEAR FROM ao.achievement_date) as year,
|
||||||
|
COUNT(*)::int as count
|
||||||
|
FROM department_scope ds
|
||||||
|
JOIN achievement_owner ao ON ds.department = ao.owner_department
|
||||||
|
WHERE ao.achievement_date IS NOT NULL
|
||||||
|
GROUP BY ds.department, ao.type, EXTRACT(YEAR FROM ao.achievement_date)
|
||||||
|
ORDER BY ds.department
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 5. 全单位月度趋势统计 (累计折线图) - 仅统计已通过
|
||||||
|
// 前端会进行累计计算,后端只需返回每月增量
|
||||||
|
const trendStats = await db.query(`
|
||||||
|
SELECT TO_CHAR(achievement_date, 'YYYY-MM') as month, COUNT(*) as count
|
||||||
|
FROM achievements
|
||||||
|
WHERE ${approvedCondition} AND achievement_date IS NOT NULL
|
||||||
|
GROUP BY month
|
||||||
|
ORDER BY month
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 6. 各部门月度趋势统计 (累计面积图) - 仅统计已通过
|
||||||
|
// 前端会进行累计计算,后端只需返回每月增量
|
||||||
|
const deptTrendStats = await db.query(`
|
||||||
|
WITH leader_depts AS (
|
||||||
|
SELECT name FROM dict_leader_departments
|
||||||
|
),
|
||||||
|
leader_dept_array AS (
|
||||||
|
SELECT COALESCE(array_agg(name::text), ARRAY[]::text[]) AS names FROM leader_depts
|
||||||
|
),
|
||||||
|
department_scope AS (
|
||||||
|
SELECT d.name AS department
|
||||||
|
FROM departments d, leader_dept_array lda
|
||||||
|
WHERE d.name::text <> ALL(lda.names)
|
||||||
|
),
|
||||||
|
achievement_owner AS (
|
||||||
|
SELECT
|
||||||
|
a.id,
|
||||||
|
a.achievement_date,
|
||||||
|
COALESCE(non_leader.dept, first_any.dept) AS owner_department
|
||||||
|
FROM achievements a
|
||||||
|
CROSS JOIN leader_dept_array lda
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT u.dept
|
||||||
|
FROM unnest(COALESCE(a.assigned_departments::text[], ARRAY[]::text[])) WITH ORDINALITY AS u(dept, ord)
|
||||||
|
WHERE u.dept IS NOT NULL
|
||||||
|
AND BTRIM(u.dept) <> ''
|
||||||
|
AND u.dept <> ALL(lda.names)
|
||||||
|
ORDER BY u.ord
|
||||||
|
LIMIT 1
|
||||||
|
) non_leader ON TRUE
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT u.dept
|
||||||
|
FROM unnest(COALESCE(a.assigned_departments::text[], ARRAY[]::text[])) WITH ORDINALITY AS u(dept, ord)
|
||||||
|
WHERE u.dept IS NOT NULL
|
||||||
|
AND BTRIM(u.dept) <> ''
|
||||||
|
ORDER BY u.ord
|
||||||
|
LIMIT 1
|
||||||
|
) first_any ON TRUE
|
||||||
|
WHERE ${approvedCondition}
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(ao.achievement_date, 'YYYY-MM') as month,
|
||||||
|
ds.department as department,
|
||||||
|
COUNT(*)::int as count
|
||||||
|
FROM department_scope ds
|
||||||
|
JOIN achievement_owner ao ON ds.department = ao.owner_department
|
||||||
|
WHERE ao.achievement_date IS NOT NULL
|
||||||
|
GROUP BY TO_CHAR(ao.achievement_date, 'YYYY-MM'), ds.department
|
||||||
|
ORDER BY month
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 7. 按部门统计“独立完成 / 合作项目”
|
||||||
|
// 当前口径下每条成果仅归属一个部门,因此合作计数恒为 0。
|
||||||
|
const projectModeStats = await db.query(`
|
||||||
|
WITH leader_depts AS (
|
||||||
|
SELECT name FROM dict_leader_departments
|
||||||
|
),
|
||||||
|
leader_dept_array AS (
|
||||||
|
SELECT COALESCE(array_agg(name::text), ARRAY[]::text[]) AS names FROM leader_depts
|
||||||
|
),
|
||||||
|
department_scope AS (
|
||||||
|
SELECT d.name AS department
|
||||||
|
FROM departments d, leader_dept_array lda
|
||||||
|
WHERE d.name::text <> ALL(lda.names)
|
||||||
|
),
|
||||||
|
achievement_owner AS (
|
||||||
|
SELECT
|
||||||
|
a.id,
|
||||||
|
COALESCE(non_leader.dept, first_any.dept) AS owner_department
|
||||||
|
FROM achievements a
|
||||||
|
CROSS JOIN leader_dept_array lda
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT u.dept
|
||||||
|
FROM unnest(COALESCE(a.assigned_departments::text[], ARRAY[]::text[])) WITH ORDINALITY AS u(dept, ord)
|
||||||
|
WHERE u.dept IS NOT NULL
|
||||||
|
AND BTRIM(u.dept) <> ''
|
||||||
|
AND u.dept <> ALL(lda.names)
|
||||||
|
ORDER BY u.ord
|
||||||
|
LIMIT 1
|
||||||
|
) non_leader ON TRUE
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT u.dept
|
||||||
|
FROM unnest(COALESCE(a.assigned_departments::text[], ARRAY[]::text[])) WITH ORDINALITY AS u(dept, ord)
|
||||||
|
WHERE u.dept IS NOT NULL
|
||||||
|
AND BTRIM(u.dept) <> ''
|
||||||
|
ORDER BY u.ord
|
||||||
|
LIMIT 1
|
||||||
|
) first_any ON TRUE
|
||||||
|
WHERE ${approvedCondition}
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
ds.department,
|
||||||
|
COUNT(ao.id)::int AS independent_count,
|
||||||
|
0::int AS collaboration_count,
|
||||||
|
COUNT(ao.id)::int AS total_count
|
||||||
|
FROM department_scope ds
|
||||||
|
LEFT JOIN achievement_owner ao ON ao.owner_department = ds.department
|
||||||
|
GROUP BY ds.department
|
||||||
|
ORDER BY total_count DESC, ds.department
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
byType: typeStats.rows,
|
||||||
|
byStatus: statusStats.rows,
|
||||||
|
byDepartment: departmentStats.rows,
|
||||||
|
deptTypeDetails: deptTypeStats.rows,
|
||||||
|
byMonth: trendStats.rows,
|
||||||
|
deptTrendDetails: deptTrendStats.rows,
|
||||||
|
projectModeByDepartment: projectModeStats.rows
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ message: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const db = require('../db');
|
||||||
|
const { verifyToken, isSuperAdmin, isSystemAdmin } = require('../middleware/auth');
|
||||||
|
|
||||||
|
// 搜索用户 (所有登录用户可用,用于成果录入选择作者)
|
||||||
|
router.get('/search', verifyToken, async (req, res) => {
|
||||||
|
const { keyword, leaderOnly } = req.query;
|
||||||
|
if (!keyword) return res.json([]);
|
||||||
|
try {
|
||||||
|
let query = `
|
||||||
|
SELECT id, username as phone, real_name, department
|
||||||
|
FROM users
|
||||||
|
WHERE (username ILIKE $1 OR real_name ILIKE $1)
|
||||||
|
`;
|
||||||
|
const params = [`%${keyword}%`];
|
||||||
|
|
||||||
|
if (leaderOnly === 'true') {
|
||||||
|
// 获取所有领导层部门
|
||||||
|
const leaderDeptsRes = await db.query('SELECT name FROM dict_leader_departments');
|
||||||
|
const leaderDepts = leaderDeptsRes.rows.map(r => r.name);
|
||||||
|
|
||||||
|
if (leaderDepts.length > 0) {
|
||||||
|
query += ` AND department = ANY($${params.length + 1})`;
|
||||||
|
params.push(leaderDepts);
|
||||||
|
} else {
|
||||||
|
// 如果没有配置领导层部门,则返回空
|
||||||
|
return res.json([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` LIMIT 20`;
|
||||||
|
const result = await db.query(query, params);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ message: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取所有待定状态用户的手机号列表 (用于前端标红显示)
|
||||||
|
router.get('/pending-list', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await db.query('SELECT username FROM users WHERE dept_status = \'pending\'');
|
||||||
|
const phones = result.rows.map(row => row.username);
|
||||||
|
res.json(phones);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ message: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取所有用户列表 (系统管理员:超管或维护员)
|
||||||
|
router.get('/', verifyToken, isSystemAdmin, async (req, res) => {
|
||||||
|
const { keyword } = req.query;
|
||||||
|
try {
|
||||||
|
let query = 'SELECT id, username, role, real_name, department, dept_status, created_at FROM users WHERE 1=1';
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (keyword) {
|
||||||
|
query += ` AND (username ILIKE $1 OR real_name ILIKE $1 OR department ILIKE $1)`;
|
||||||
|
params.push(`%${keyword}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY created_at DESC';
|
||||||
|
const result = await db.query(query, params);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ message: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建新用户 (仅超管)
|
||||||
|
router.post('/', verifyToken, isSuperAdmin, async (req, res) => {
|
||||||
|
const { username, password, role, real_name, department: inputDepartment } = req.body;
|
||||||
|
let department = inputDepartment;
|
||||||
|
|
||||||
|
if (!username || !password || !role) {
|
||||||
|
return res.status(400).json({ message: '缺少必填字段 (username, password, role)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 手机号格式校验
|
||||||
|
if (!/^1[3-9]\d{9}$/.test(username)) {
|
||||||
|
return res.status(400).json({ message: '手机号格式不正确' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userExists = await db.query('SELECT * FROM users WHERE username = $1', [username]);
|
||||||
|
if (userExists.rows.length > 0) {
|
||||||
|
return res.status(400).json({ message: '该手机号已注册' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验部门
|
||||||
|
let deptStatus = 'verified';
|
||||||
|
if (['admin', 'user'].includes(role)) {
|
||||||
|
if (!department) {
|
||||||
|
return res.status(400).json({ message: '该角色必须指定部门' });
|
||||||
|
}
|
||||||
|
const deptCheck = await db.query('SELECT * FROM departments WHERE name = $1', [department]);
|
||||||
|
deptStatus = deptCheck.rows.length > 0 ? 'verified' : 'pending';
|
||||||
|
} else {
|
||||||
|
// super_admin 或 maintainer 可以没有部门
|
||||||
|
if (!department) department = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const salt = await bcrypt.genSalt(10);
|
||||||
|
const passwordHash = await bcrypt.hash(password, salt);
|
||||||
|
|
||||||
|
const result = await db.query(
|
||||||
|
'INSERT INTO users (username, password_hash, role, real_name, department, dept_status) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, username, role, real_name, department, dept_status',
|
||||||
|
[username, passwordHash, role, real_name, department, deptStatus]
|
||||||
|
);
|
||||||
|
res.status(201).json(result.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ message: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 修改用户信息 (系统管理员:超管或维护员)
|
||||||
|
router.put('/:id', verifyToken, isSystemAdmin, async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
let { username, role, real_name, department, password } = req.body;
|
||||||
|
|
||||||
|
// 如果是维护员,只能修改密码,忽略其他字段
|
||||||
|
if (req.user.role === 'maintainer') {
|
||||||
|
if (!password) {
|
||||||
|
return res.json({ message: '未提供新密码,无任何修改' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const salt = await bcrypt.genSalt(10);
|
||||||
|
const passwordHash = await bcrypt.hash(password, salt);
|
||||||
|
await db.query(
|
||||||
|
'UPDATE users SET password_hash=$1, token_version = CASE WHEN token_version >= 2000000000 THEN 1 ELSE token_version + 1 END WHERE id=$2',
|
||||||
|
[passwordHash, id]
|
||||||
|
);
|
||||||
|
return res.json({ message: '密码重置成功' });
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json({ message: '服务器错误' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 以下逻辑仅适用于超级管理员 (super_admin)
|
||||||
|
try {
|
||||||
|
// 手机号格式校验
|
||||||
|
if (!/^1[3-9]\d{9}$/.test(username)) {
|
||||||
|
return res.status(400).json({ message: '手机号格式不正确' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验部门
|
||||||
|
let deptStatus = 'verified';
|
||||||
|
if (['admin', 'user'].includes(role)) {
|
||||||
|
if (!department) {
|
||||||
|
return res.status(400).json({ message: '该角色必须指定部门' });
|
||||||
|
}
|
||||||
|
const deptCheck = await db.query('SELECT * FROM departments WHERE name = $1', [department]);
|
||||||
|
deptStatus = deptCheck.rows.length > 0 ? 'verified' : 'pending';
|
||||||
|
} else {
|
||||||
|
// super_admin 或 maintainer 可以没有部门
|
||||||
|
if (!department) department = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = 'UPDATE users SET username=$1, role=$2, real_name=$3, department=$4, dept_status=$5';
|
||||||
|
let params = [username, role, real_name, department, deptStatus, id];
|
||||||
|
|
||||||
|
if (password) {
|
||||||
|
const salt = await bcrypt.genSalt(10);
|
||||||
|
const passwordHash = await bcrypt.hash(password, salt);
|
||||||
|
query += ', password_hash=$6, token_version = CASE WHEN token_version >= 2000000000 THEN 1 ELSE token_version + 1 END WHERE id=$7';
|
||||||
|
params = [username, role, real_name, department, deptStatus, passwordHash, id];
|
||||||
|
} else {
|
||||||
|
query += ' WHERE id=$6';
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.query(query, params);
|
||||||
|
res.json({ message: '修改成功' });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ message: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 删除用户 (仅超管)
|
||||||
|
router.delete('/:id', verifyToken, isSuperAdmin, async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
try {
|
||||||
|
// 防止删除自己
|
||||||
|
if (parseInt(id) === req.user.id) {
|
||||||
|
return res.status(400).json({ message: '不能删除当前登录账号' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有关联的成果
|
||||||
|
const achievementCheck = await db.query('SELECT id FROM achievements WHERE user_id = $1 LIMIT 1', [id]);
|
||||||
|
if (achievementCheck.rows.length > 0) {
|
||||||
|
return res.status(400).json({ message: '该用户有关联的成果数据,无法直接删除。请先处理关联数据。' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.query('DELETE FROM users WHERE id = $1', [id]);
|
||||||
|
res.json({ message: '删除成功' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ message: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
const path = require('path');
|
||||||
|
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||||
|
const db = require('../db');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
console.log('正在添加 token_version 字段...');
|
||||||
|
await db.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS token_version INTEGER DEFAULT 0');
|
||||||
|
console.log('成功添加 token_version 字段');
|
||||||
|
process.exit(0);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('执行失败:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
const path = require('path');
|
||||||
|
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||||
|
const db = require('../db');
|
||||||
|
|
||||||
|
async function createAuditTable() {
|
||||||
|
const client = await db.pool.connect();
|
||||||
|
try {
|
||||||
|
console.log('正在创建 audit_logs 表...');
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER REFERENCES users(id),
|
||||||
|
username VARCHAR(50),
|
||||||
|
real_name VARCHAR(50),
|
||||||
|
ip_address VARCHAR(50),
|
||||||
|
method VARCHAR(10),
|
||||||
|
url TEXT,
|
||||||
|
description VARCHAR(255),
|
||||||
|
status INTEGER,
|
||||||
|
duration INTEGER,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('正在创建索引...');
|
||||||
|
await client.query('CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at DESC);');
|
||||||
|
await client.query('CREATE INDEX IF NOT EXISTS idx_audit_logs_username ON audit_logs(username);');
|
||||||
|
|
||||||
|
console.log('audit_logs 表创建成功!');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('创建表失败:', err);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
process.exit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createAuditTable();
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
const { Client } = require('pg');
|
||||||
|
const path = require('path');
|
||||||
|
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||||
|
|
||||||
|
const createDb = async () => {
|
||||||
|
const dbName = process.env.DB_DATABASE || 'stsystem';
|
||||||
|
|
||||||
|
// 连接到默认的 postgres 数据库
|
||||||
|
const client = new Client({
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
database: 'postgres', // 连接到默认数据库
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
port: process.env.DB_PORT,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
console.log(`已连接到 postgres 数据库,正在检查 ${dbName} 是否存在...`);
|
||||||
|
|
||||||
|
const res = await client.query(`SELECT 1 FROM pg_database WHERE datname = $1`, [dbName]);
|
||||||
|
|
||||||
|
if (res.rows.length === 0) {
|
||||||
|
console.log(`数据库 ${dbName} 不存在,正在创建...`);
|
||||||
|
// CREATE DATABASE 不能在事务块中运行,也不能使用参数化查询
|
||||||
|
await client.query(`CREATE DATABASE "${dbName}"`);
|
||||||
|
console.log(`数据库 ${dbName} 创建成功!`);
|
||||||
|
} else {
|
||||||
|
console.log(`数据库 ${dbName} 已存在,跳过创建。`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('创建数据库失败:', err);
|
||||||
|
// 如果是因为 postgres 数据库不存在(极少见)或者密码错误,这里会报错
|
||||||
|
// 但我们不应该阻塞后续流程,也许用户已经手动创建了 stsystem
|
||||||
|
// 不过为了安全起见,如果这一步失败,通常意味着配置错误,还是退出比较好
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
createDb();
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
const db = require('../db');
|
||||||
|
|
||||||
|
async function createTables() {
|
||||||
|
const client = await db.pool.connect();
|
||||||
|
try {
|
||||||
|
console.log('开始创建通知相关表...');
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// 创建 notifications 表
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
publisher_id INTEGER REFERENCES users(id),
|
||||||
|
publisher_name VARCHAR(100),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_top BOOLEAN DEFAULT FALSE,
|
||||||
|
status VARCHAR(20) DEFAULT 'published' -- published, draft, archived
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
console.log('notifications 表创建成功');
|
||||||
|
|
||||||
|
// 创建 notification_attachments 表
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_attachments (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
notification_id INTEGER REFERENCES notifications(id) ON DELETE CASCADE,
|
||||||
|
file_name VARCHAR(255) NOT NULL,
|
||||||
|
file_path VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
console.log('notification_attachments 表创建成功');
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
console.log('所有表创建完成');
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.error('创建表失败:', err);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
process.exit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createTables();
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
const { Pool } = require('pg');
|
||||||
|
const path = require('path');
|
||||||
|
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
database: process.env.DB_DATABASE || process.env.DB_NAME,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
port: process.env.DB_PORT,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function debug() {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
console.log('🔍 开始调试统计数据...');
|
||||||
|
|
||||||
|
// 1. 检查状态分布
|
||||||
|
console.log('\n📊 1. 成果状态分布:');
|
||||||
|
const statusRes = await client.query('SELECT status, COUNT(*) FROM achievements GROUP BY status');
|
||||||
|
console.table(statusRes.rows);
|
||||||
|
|
||||||
|
// 2. 检查部门字段样本
|
||||||
|
console.log('\n📋 2. 成果部门字段样本 (前5条):');
|
||||||
|
const deptRes = await client.query('SELECT id, assigned_departments FROM achievements LIMIT 5');
|
||||||
|
console.table(deptRes.rows);
|
||||||
|
|
||||||
|
// 3. 检查部门表
|
||||||
|
console.log('\n🏢 3. 部门表数据:');
|
||||||
|
const deptsRes = await client.query('SELECT name FROM departments');
|
||||||
|
console.table(deptsRes.rows);
|
||||||
|
|
||||||
|
// 4. 运行统计查询 (模拟 statistics.js 中的查询)
|
||||||
|
console.log('\n📈 4. 运行统计查询 (status=\'approved\'):');
|
||||||
|
const statsQuery = `
|
||||||
|
SELECT
|
||||||
|
d.name as department,
|
||||||
|
a.type,
|
||||||
|
EXTRACT(YEAR FROM a.achievement_date) as year,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM departments d
|
||||||
|
JOIN achievements a ON d.name = ANY(a.assigned_departments)
|
||||||
|
WHERE a.status = 'approved' AND a.achievement_date IS NOT NULL
|
||||||
|
GROUP BY d.name, a.type, year
|
||||||
|
ORDER BY d.name
|
||||||
|
`;
|
||||||
|
const statsRes = await client.query(statsQuery);
|
||||||
|
console.log(`查询返回 ${statsRes.rows.length} 行数据`);
|
||||||
|
if (statsRes.rows.length > 0) {
|
||||||
|
console.table(statsRes.rows.slice(0, 10)); // 只显示前10条
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ 查询结果为空!');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('❌ 调试出错:', e);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug();
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
const path = require('path');
|
||||||
|
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||||
|
const db = require('../db');
|
||||||
|
|
||||||
|
async function listSchema() {
|
||||||
|
try {
|
||||||
|
const res = await db.query(`
|
||||||
|
SELECT
|
||||||
|
table_name,
|
||||||
|
column_name,
|
||||||
|
data_type,
|
||||||
|
is_nullable
|
||||||
|
FROM
|
||||||
|
information_schema.columns
|
||||||
|
WHERE
|
||||||
|
table_schema = 'public'
|
||||||
|
ORDER BY
|
||||||
|
table_name, ordinal_position
|
||||||
|
`);
|
||||||
|
|
||||||
|
const tables = {};
|
||||||
|
res.rows.forEach(row => {
|
||||||
|
if (!tables[row.table_name]) {
|
||||||
|
tables[row.table_name] = [];
|
||||||
|
}
|
||||||
|
tables[row.table_name].push({
|
||||||
|
column: row.column_name,
|
||||||
|
type: row.data_type,
|
||||||
|
nullable: row.is_nullable
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('--- 数据库全量表结构 ---');
|
||||||
|
for (const [tableName, columns] of Object.entries(tables)) {
|
||||||
|
console.log(`\n表名: ${tableName}`);
|
||||||
|
console.table(columns);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('查询失败:', err);
|
||||||
|
} finally {
|
||||||
|
process.exit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listSchema();
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
const { Pool } = require('pg');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
database: process.env.DB_DATABASE || process.env.DB_NAME, // 兼容 .env 中的不同命名
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
port: process.env.DB_PORT,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 随机数据源
|
||||||
|
const DEPARTMENTS = ['研发部', '市场部', '人事部', '财务部', '销售部', '运维部'];
|
||||||
|
const SURNAMES = ['赵', '钱', '孙', '李', '周', '吴', '郑', '王', '冯', '陈', '褚', '卫', '蒋', '沈', '韩', '杨'];
|
||||||
|
const NAMES = ['伟', '芳', '娜', '敏', '静', '强', '磊', '军', '洋', '勇', '艳', '杰', '娟', '涛', '明', '超'];
|
||||||
|
const TITLES_PREFIX = ['基于AI的', '高性能', '分布式', '新一代', '云原生', '跨平台', '智能', '自动化'];
|
||||||
|
const TITLES_SUFFIX = ['系统设计', '算法研究', '平台开发', '应用实践', '优化方案', '技术白皮书', '分析报告'];
|
||||||
|
|
||||||
|
// 字典数据
|
||||||
|
const DICTS = {
|
||||||
|
dict_organizations: ['科技大学', '软件研究所', '创新中心', '数据实验室'],
|
||||||
|
dict_paper_types: ['SCI', 'EI', '核心期刊', '会议论文'],
|
||||||
|
dict_award_types: ['科技进步奖', '技术发明奖', '自然科学奖'],
|
||||||
|
dict_award_levels: ['一等奖', '二等奖', '三等奖', '特等奖'],
|
||||||
|
dict_project_categories: ['国家重点研发计划', '自然科学基金', '省部级项目', '横向课题'],
|
||||||
|
dict_standard_types: ['国家标准', '行业标准', '团体标准', '企业标准']
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACHIEVEMENT_TYPES = [
|
||||||
|
'paper', 'project', 'award', 'standard', 'monograph', 'report', 'plan', 'patent', 'transformation', 'software'
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUSES = ['pending', 'approved', 'rejected'];
|
||||||
|
|
||||||
|
// 辅助函数
|
||||||
|
const randomInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
const randomChoice = (arr) => arr[randomInt(0, arr.length - 1)];
|
||||||
|
const generatePhone = () => `1${randomChoice(['3', '5', '7', '8', '9'])}${randomInt(100000000, 999999999)}`;
|
||||||
|
const generateName = () => `${randomChoice(SURNAMES)}${randomChoice(NAMES)}${Math.random() > 0.5 ? randomChoice(NAMES) : ''}`;
|
||||||
|
const generateTitle = () => `${randomChoice(TITLES_PREFIX)}${randomChoice(TITLES_SUFFIX)}`;
|
||||||
|
const generateDate = (startYear, endYear) => {
|
||||||
|
const year = randomInt(startYear, endYear);
|
||||||
|
const month = randomInt(0, 11);
|
||||||
|
const day = randomInt(1, 28);
|
||||||
|
return new Date(year, month, day);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function seed() {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
console.log('🚀 开始注入测试数据...');
|
||||||
|
|
||||||
|
// 0. 初始化数据库架构
|
||||||
|
console.log('🏗️ 初始化数据库架构...');
|
||||||
|
const schemaPath = path.join(__dirname, '../../database/schema.sql');
|
||||||
|
if (fs.existsSync(schemaPath)) {
|
||||||
|
const schemaSql = fs.readFileSync(schemaPath, 'utf8');
|
||||||
|
await client.query(schemaSql);
|
||||||
|
console.log('✅ 数据库架构已应用');
|
||||||
|
} else {
|
||||||
|
console.warn('⚠️ 未找到 schema.sql,跳过架构初始化');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 清理数据 (保留特定账号)
|
||||||
|
console.log('🧹 清理旧数据...');
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// 级联删除所有成果 (这将自动删除详情表和附件表)
|
||||||
|
await client.query('TRUNCATE achievements CASCADE');
|
||||||
|
|
||||||
|
// 删除除保留账号外的所有用户
|
||||||
|
const keepUsers = ['18888888888', '19999999999'];
|
||||||
|
await client.query('DELETE FROM users WHERE username != $1 AND username != $2', [keepUsers[0], keepUsers[1]]);
|
||||||
|
|
||||||
|
console.log('✅ 旧数据清理完成');
|
||||||
|
|
||||||
|
// 2. 填充字典和部门
|
||||||
|
console.log('📚 填充字典数据...');
|
||||||
|
for (const [table, values] of Object.entries(DICTS)) {
|
||||||
|
for (const val of values) {
|
||||||
|
await client.query(`INSERT INTO ${table} (name) VALUES ($1) ON CONFLICT (name) DO NOTHING`, [val]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dept of DEPARTMENTS) {
|
||||||
|
await client.query(
|
||||||
|
'INSERT INTO departments (name) VALUES ($1) ON CONFLICT (name) DO NOTHING',
|
||||||
|
[dept]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 生成新用户
|
||||||
|
console.log('👥 生成测试用户...');
|
||||||
|
const passwordHash = await bcrypt.hash('123456', 10);
|
||||||
|
const userIds = [];
|
||||||
|
|
||||||
|
// 获取保留用户的ID
|
||||||
|
const existingUsers = await client.query('SELECT id FROM users WHERE username = $1 OR username = $2', [keepUsers[0], keepUsers[1]]);
|
||||||
|
existingUsers.rows.forEach(row => userIds.push(row.id));
|
||||||
|
|
||||||
|
// 生成 20 个新用户
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const username = generatePhone();
|
||||||
|
const realName = generateName();
|
||||||
|
const dept = randomChoice(DEPARTMENTS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await client.query(
|
||||||
|
'INSERT INTO users (username, password_hash, role, real_name, department, dept_status) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id',
|
||||||
|
[username, passwordHash, 'user', realName, dept, 'verified']
|
||||||
|
);
|
||||||
|
userIds.push(res.rows[0].id);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code !== '23505') console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`✅ 已生成 ${userIds.length} 个用户`);
|
||||||
|
|
||||||
|
// 4. 生成成果数据
|
||||||
|
console.log('📊 生成成果数据 (目标 500+ 条)...');
|
||||||
|
const targetCount = 550;
|
||||||
|
let insertedCount = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < targetCount; i++) {
|
||||||
|
const userId = randomChoice(userIds);
|
||||||
|
const type = randomChoice(ACHIEVEMENT_TYPES);
|
||||||
|
const status = randomChoice(STATUSES);
|
||||||
|
const date = generateDate(2023, 2026);
|
||||||
|
const title = generateTitle();
|
||||||
|
const dept = randomChoice(DEPARTMENTS);
|
||||||
|
|
||||||
|
// 构造 contributors JSONB
|
||||||
|
const contributors = [
|
||||||
|
{ name: generateName(), phone: generatePhone(), isMain: true },
|
||||||
|
{ name: generateName(), phone: generatePhone(), isMain: false }
|
||||||
|
];
|
||||||
|
|
||||||
|
// 插入主表
|
||||||
|
const res = await client.query(
|
||||||
|
`INSERT INTO achievements
|
||||||
|
(user_id, type, name, contributors, assigned_departments, achievement_date, status, remarks, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
JSON.stringify(contributors),
|
||||||
|
[dept], // 数组格式
|
||||||
|
date,
|
||||||
|
status,
|
||||||
|
'自动生成的数据',
|
||||||
|
date
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const achievementId = res.rows[0].id;
|
||||||
|
|
||||||
|
// 插入附件
|
||||||
|
await client.query(
|
||||||
|
'INSERT INTO achievement_attachments (achievement_id, file_name, file_path) VALUES ($1, $2, $3)',
|
||||||
|
[achievementId, 'test_report.pdf', 'uploads/mock_file.pdf']
|
||||||
|
);
|
||||||
|
|
||||||
|
// 插入详情表 (根据类型)
|
||||||
|
switch (type) {
|
||||||
|
case 'paper':
|
||||||
|
await client.query(
|
||||||
|
'INSERT INTO achievement_paper (achievement_id, paper_type, journal_name, publish_date) VALUES ($1, $2, $3, $4)',
|
||||||
|
[achievementId, randomChoice(DICTS.dict_paper_types), '计算机学报', date]
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'award':
|
||||||
|
await client.query(
|
||||||
|
'INSERT INTO achievement_award (achievement_id, award_type, award_level, award_unit) VALUES ($1, $2, $3, $4)',
|
||||||
|
[achievementId, randomChoice(DICTS.dict_award_types), randomChoice(DICTS.dict_award_levels), '科技部']
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'project':
|
||||||
|
await client.query(
|
||||||
|
'INSERT INTO achievement_project (achievement_id, project_category, source) VALUES ($1, $2, $3)',
|
||||||
|
[achievementId, randomChoice(DICTS.dict_project_categories), '国家自然科学基金委员会']
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'standard':
|
||||||
|
await client.query(
|
||||||
|
'INSERT INTO achievement_standard (achievement_id, standard_type, standard_no, implement_date) VALUES ($1, $2, $3, $4)',
|
||||||
|
[achievementId, randomChoice(DICTS.dict_standard_types), `GB/T ${randomInt(1000, 9999)}-2025`, date]
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
// 其他类型暂略,或插入空记录
|
||||||
|
}
|
||||||
|
|
||||||
|
insertedCount++;
|
||||||
|
if (insertedCount % 100 === 0) process.stdout.write('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
console.log(`\n✅ 成功注入 ${insertedCount} 条成果数据!`);
|
||||||
|
console.log('🎉 数据注入完成!');
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.error('❌ 数据注入失败:', e);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seed();
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
const { Pool } = require('pg');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const path = require('path');
|
||||||
|
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
database: process.env.DB_DATABASE || process.env.DB_NAME,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
port: process.env.DB_PORT,
|
||||||
|
});
|
||||||
|
|
||||||
|
const USER_COUNT = 20000;
|
||||||
|
const ACHIEVEMENT_COUNT = 500000;
|
||||||
|
const BATCH_SIZE = 1000;
|
||||||
|
const RUN_CLEAN = process.argv.includes('--clean');
|
||||||
|
|
||||||
|
const dictTables = [
|
||||||
|
'dict_organizations',
|
||||||
|
'dict_award_types',
|
||||||
|
'dict_award_levels',
|
||||||
|
'dict_paper_types',
|
||||||
|
'dict_standard_types',
|
||||||
|
'dict_project_categories',
|
||||||
|
];
|
||||||
|
|
||||||
|
const achievementTypes = [
|
||||||
|
'paper',
|
||||||
|
'award',
|
||||||
|
'project',
|
||||||
|
'standard',
|
||||||
|
'monograph',
|
||||||
|
'report',
|
||||||
|
'plan',
|
||||||
|
'patent',
|
||||||
|
'transformation',
|
||||||
|
'software',
|
||||||
|
];
|
||||||
|
const USERNAME_PREFIX = '176';
|
||||||
|
const SEED_REMARK = 'seed_500k';
|
||||||
|
const STATUSES = ['pending', 'approved', 'rejected'];
|
||||||
|
const PATENT_TYPES = ['invention', 'utility', 'design'];
|
||||||
|
const REPORT_TYPES = ['technical', 'inspection', 'analysis', 'feasibility'];
|
||||||
|
const TRANS_METHODS = ['transfer', 'license', 'cooperation', 'self-use'];
|
||||||
|
const ACQUISITION_METHODS = ['independent', 'co-development', 'transfer'];
|
||||||
|
const SOFTWARE_SCOPES = ['national', 'industry', 'department'];
|
||||||
|
|
||||||
|
const randomInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
const randomChoice = (arr) => arr[randomInt(0, arr.length - 1)];
|
||||||
|
const pad = (num, size) => String(num).padStart(size, '0');
|
||||||
|
const randomDateInYears = (years) =>
|
||||||
|
new Date(randomChoice(years), randomInt(0, 11), randomInt(1, 28));
|
||||||
|
|
||||||
|
function buildInsert(table, columns, rows) {
|
||||||
|
const values = [];
|
||||||
|
const params = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const placeholders = [];
|
||||||
|
for (const value of row) {
|
||||||
|
params.push(value);
|
||||||
|
placeholders.push(`$${paramIndex++}`);
|
||||||
|
}
|
||||||
|
values.push(`(${placeholders.join(', ')})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = `INSERT INTO ${table} (${columns.join(', ')}) VALUES ${values.join(', ')}`;
|
||||||
|
return { sql, params };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchNames(client, table) {
|
||||||
|
const res = await client.query(`SELECT name FROM ${table} ORDER BY id`);
|
||||||
|
return res.rows.map((row) => row.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedUsers(client, departments) {
|
||||||
|
const passwordHash = await bcrypt.hash('123456', 10);
|
||||||
|
let created = 0;
|
||||||
|
|
||||||
|
for (let offset = 0; offset < USER_COUNT; offset += BATCH_SIZE) {
|
||||||
|
const batchSize = Math.min(BATCH_SIZE, USER_COUNT - offset);
|
||||||
|
const rows = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < batchSize; i++) {
|
||||||
|
const index = offset + i;
|
||||||
|
const username = `${USERNAME_PREFIX}${pad(index, 8)}`; // 11 digits, deterministic
|
||||||
|
const realName = `Perf User ${index + 1}`;
|
||||||
|
const department = randomChoice(departments);
|
||||||
|
rows.push([username, passwordHash, 'user', realName, department, 'verified']);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sql, params } = buildInsert(
|
||||||
|
'users',
|
||||||
|
['username', 'password_hash', 'role', 'real_name', 'department', 'dept_status'],
|
||||||
|
rows
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`${sql} ON CONFLICT (username) DO UPDATE SET
|
||||||
|
password_hash = EXCLUDED.password_hash,
|
||||||
|
role = EXCLUDED.role,
|
||||||
|
real_name = EXCLUDED.real_name,
|
||||||
|
department = EXCLUDED.department,
|
||||||
|
dept_status = EXCLUDED.dept_status`
|
||||||
|
, params);
|
||||||
|
|
||||||
|
created += batchSize;
|
||||||
|
if (created % 5000 === 0) {
|
||||||
|
console.log(`[seed-data-500k] users upserted: ${created}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedAchievements(client, departments, dicts, userIds) {
|
||||||
|
let inserted = 0;
|
||||||
|
const dateYears = [2023, 2024, 2026];
|
||||||
|
|
||||||
|
for (let offset = 0; offset < ACHIEVEMENT_COUNT; offset += BATCH_SIZE) {
|
||||||
|
const batchSize = Math.min(BATCH_SIZE, ACHIEVEMENT_COUNT - offset);
|
||||||
|
await client.query('BEGIN');
|
||||||
|
try {
|
||||||
|
const rows = [];
|
||||||
|
const achievementDates = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < batchSize; i++) {
|
||||||
|
const index = offset + i;
|
||||||
|
const userId = randomChoice(userIds);
|
||||||
|
const type = randomChoice(achievementTypes);
|
||||||
|
const name = `Perf Achievement ${index + 1}`;
|
||||||
|
const assignedDept = randomChoice(departments);
|
||||||
|
const date = randomDateInYears(dateYears);
|
||||||
|
achievementDates.push(date);
|
||||||
|
const contributors = [
|
||||||
|
{ name: `Contributor ${index + 1}A`, phone: `130${pad(index % 100000000, 8)}`, isMain: true },
|
||||||
|
{ name: `Contributor ${index + 1}B`, phone: `131${pad(index % 100000000, 8)}`, isMain: false },
|
||||||
|
];
|
||||||
|
rows.push([
|
||||||
|
userId,
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
JSON.stringify(contributors),
|
||||||
|
[assignedDept],
|
||||||
|
date,
|
||||||
|
randomChoice(STATUSES),
|
||||||
|
SEED_REMARK,
|
||||||
|
date,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sql, params } = buildInsert(
|
||||||
|
'achievements',
|
||||||
|
[
|
||||||
|
'user_id',
|
||||||
|
'type',
|
||||||
|
'name',
|
||||||
|
'contributors',
|
||||||
|
'assigned_departments',
|
||||||
|
'achievement_date',
|
||||||
|
'status',
|
||||||
|
'remarks',
|
||||||
|
'created_at',
|
||||||
|
],
|
||||||
|
rows
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await client.query(`${sql} RETURNING id, type`, params);
|
||||||
|
|
||||||
|
const attachmentsRows = [];
|
||||||
|
const paperRows = [];
|
||||||
|
const awardRows = [];
|
||||||
|
const projectRows = [];
|
||||||
|
const standardRows = [];
|
||||||
|
const monographRows = [];
|
||||||
|
const reportRows = [];
|
||||||
|
const planRows = [];
|
||||||
|
const patentRows = [];
|
||||||
|
const transformationRows = [];
|
||||||
|
const softwareRows = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < res.rows.length; i++) {
|
||||||
|
const { id, type } = res.rows[i];
|
||||||
|
const date = achievementDates[i];
|
||||||
|
const idx = offset + i + 1;
|
||||||
|
attachmentsRows.push([id, `attachment_${idx}.pdf`, 'uploads/mock_file.pdf']);
|
||||||
|
|
||||||
|
if (type === 'paper') {
|
||||||
|
paperRows.push([id, randomChoice(dicts.dict_paper_types), 'Perf Journal', randomDateInYears(dateYears)]);
|
||||||
|
} else if (type === 'award') {
|
||||||
|
awardRows.push([
|
||||||
|
id,
|
||||||
|
randomChoice(dicts.dict_award_types),
|
||||||
|
randomChoice(dicts.dict_award_levels),
|
||||||
|
randomChoice(dicts.dict_organizations),
|
||||||
|
]);
|
||||||
|
} else if (type === 'project') {
|
||||||
|
projectRows.push([id, randomChoice(dicts.dict_project_categories), 'Perf Project Source']);
|
||||||
|
} else if (type === 'standard') {
|
||||||
|
const standardYear = date.getFullYear();
|
||||||
|
standardRows.push([
|
||||||
|
id,
|
||||||
|
randomChoice(dicts.dict_standard_types),
|
||||||
|
`STD-${standardYear}-${pad(idx, 6)}`,
|
||||||
|
date,
|
||||||
|
]);
|
||||||
|
} else if (type === 'monograph') {
|
||||||
|
monographRows.push([id, 'Perf Press', `ISBN-${pad(idx, 9)}`]);
|
||||||
|
} else if (type === 'report') {
|
||||||
|
reportRows.push([id, randomChoice(REPORT_TYPES), 'Recipient Unit', null, `Approver ${idx}`]);
|
||||||
|
} else if (type === 'plan') {
|
||||||
|
planRows.push([id]);
|
||||||
|
} else if (type === 'patent') {
|
||||||
|
patentRows.push([id, `PAT-${pad(idx, 8)}`, randomChoice(PATENT_TYPES), 'Perf Assignee']);
|
||||||
|
} else if (type === 'transformation') {
|
||||||
|
transformationRows.push([
|
||||||
|
id,
|
||||||
|
randomChoice(TRANS_METHODS),
|
||||||
|
randomInt(50, 5000) * 1000 + randomInt(0, 99) / 100,
|
||||||
|
randomChoice(dicts.dict_project_categories),
|
||||||
|
]);
|
||||||
|
} else if (type === 'software') {
|
||||||
|
softwareRows.push([
|
||||||
|
id,
|
||||||
|
`SW-${pad(idx, 8)}`,
|
||||||
|
randomChoice(ACQUISITION_METHODS),
|
||||||
|
randomChoice(SOFTWARE_SCOPES),
|
||||||
|
'Perf Owner Unit',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachmentsRows.length) {
|
||||||
|
const insert = buildInsert(
|
||||||
|
'achievement_attachments',
|
||||||
|
['achievement_id', 'file_name', 'file_path'],
|
||||||
|
attachmentsRows
|
||||||
|
);
|
||||||
|
await client.query(insert.sql, insert.params);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paperRows.length) {
|
||||||
|
const insert = buildInsert(
|
||||||
|
'achievement_paper',
|
||||||
|
['achievement_id', 'paper_type', 'journal_name', 'publish_date'],
|
||||||
|
paperRows
|
||||||
|
);
|
||||||
|
await client.query(insert.sql, insert.params);
|
||||||
|
}
|
||||||
|
if (awardRows.length) {
|
||||||
|
const insert = buildInsert(
|
||||||
|
'achievement_award',
|
||||||
|
['achievement_id', 'award_type', 'award_level', 'award_unit'],
|
||||||
|
awardRows
|
||||||
|
);
|
||||||
|
await client.query(insert.sql, insert.params);
|
||||||
|
}
|
||||||
|
if (projectRows.length) {
|
||||||
|
const insert = buildInsert(
|
||||||
|
'achievement_project',
|
||||||
|
['achievement_id', 'project_category', 'source'],
|
||||||
|
projectRows
|
||||||
|
);
|
||||||
|
await client.query(insert.sql, insert.params);
|
||||||
|
}
|
||||||
|
if (standardRows.length) {
|
||||||
|
const insert = buildInsert(
|
||||||
|
'achievement_standard',
|
||||||
|
['achievement_id', 'standard_type', 'standard_no', 'implement_date'],
|
||||||
|
standardRows
|
||||||
|
);
|
||||||
|
await client.query(insert.sql, insert.params);
|
||||||
|
}
|
||||||
|
if (monographRows.length) {
|
||||||
|
const insert = buildInsert(
|
||||||
|
'achievement_monograph',
|
||||||
|
['achievement_id', 'publisher', 'isbn'],
|
||||||
|
monographRows
|
||||||
|
);
|
||||||
|
await client.query(insert.sql, insert.params);
|
||||||
|
}
|
||||||
|
if (reportRows.length) {
|
||||||
|
const insert = buildInsert(
|
||||||
|
'achievement_report',
|
||||||
|
['achievement_id', 'report_type', 'recipient', 'approver_id', 'approver_name'],
|
||||||
|
reportRows
|
||||||
|
);
|
||||||
|
await client.query(insert.sql, insert.params);
|
||||||
|
}
|
||||||
|
if (planRows.length) {
|
||||||
|
const insert = buildInsert(
|
||||||
|
'achievement_plan',
|
||||||
|
['achievement_id'],
|
||||||
|
planRows
|
||||||
|
);
|
||||||
|
await client.query(insert.sql, insert.params);
|
||||||
|
}
|
||||||
|
if (patentRows.length) {
|
||||||
|
const insert = buildInsert(
|
||||||
|
'achievement_patent',
|
||||||
|
['achievement_id', 'patent_no', 'patent_type', 'assignee'],
|
||||||
|
patentRows
|
||||||
|
);
|
||||||
|
await client.query(insert.sql, insert.params);
|
||||||
|
}
|
||||||
|
if (transformationRows.length) {
|
||||||
|
const insert = buildInsert(
|
||||||
|
'achievement_transformation',
|
||||||
|
['achievement_id', 'trans_method', 'trans_amount', 'project_category'],
|
||||||
|
transformationRows
|
||||||
|
);
|
||||||
|
await client.query(insert.sql, insert.params);
|
||||||
|
}
|
||||||
|
if (softwareRows.length) {
|
||||||
|
const insert = buildInsert(
|
||||||
|
'achievement_software',
|
||||||
|
['achievement_id', 'reg_no', 'acquisition_method', 'scope', 'owner_unit'],
|
||||||
|
softwareRows
|
||||||
|
);
|
||||||
|
await client.query(insert.sql, insert.params);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
inserted += batchSize;
|
||||||
|
if (inserted % 10000 === 0) {
|
||||||
|
console.log(`[seed-data-500k] achievements inserted: ${inserted}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanSeedData(client) {
|
||||||
|
console.log('[seed-data-500k] cleaning existing seeded data...');
|
||||||
|
await client.query('BEGIN');
|
||||||
|
try {
|
||||||
|
const usersRes = await client.query(
|
||||||
|
'SELECT id FROM users WHERE username LIKE $1',
|
||||||
|
[`${USERNAME_PREFIX}%`]
|
||||||
|
);
|
||||||
|
const userIds = usersRes.rows.map((row) => row.id);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
'DELETE FROM achievements WHERE remarks = $1 OR user_id = ANY($2)',
|
||||||
|
[SEED_REMARK, userIds.length ? userIds : [0]]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
'DELETE FROM users WHERE username LIKE $1',
|
||||||
|
[`${USERNAME_PREFIX}%`]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
console.log('[seed-data-500k] clean done');
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
console.log('[seed-data-500k] start');
|
||||||
|
|
||||||
|
if (RUN_CLEAN) {
|
||||||
|
await cleanSeedData(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
const departments = await fetchNames(client, 'departments');
|
||||||
|
if (departments.length === 0) {
|
||||||
|
throw new Error('No departments found. Run "npm run init-db" first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dicts = {};
|
||||||
|
for (const table of dictTables) {
|
||||||
|
const values = await fetchNames(client, table);
|
||||||
|
if (values.length === 0) {
|
||||||
|
throw new Error(`No data in ${table}. Run "npm run init-db" first.`);
|
||||||
|
}
|
||||||
|
dicts[table] = values;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('BEGIN');
|
||||||
|
await seedUsers(client, departments);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
const userIdsRes = await client.query(
|
||||||
|
'SELECT id FROM users WHERE username LIKE $1 ORDER BY id',
|
||||||
|
[`${USERNAME_PREFIX}%`]
|
||||||
|
);
|
||||||
|
const userIds = userIdsRes.rows.map((row) => row.id);
|
||||||
|
if (userIds.length === 0) {
|
||||||
|
throw new Error('No seeded users found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await seedAchievements(client, departments, dicts, userIds);
|
||||||
|
|
||||||
|
console.log('[seed-data-500k] done');
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.error('[seed-data-500k] failed:', err.message || err);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
const path = require('path');
|
||||||
|
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||||
|
const db = require('../db');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const setup = async () => {
|
||||||
|
try {
|
||||||
|
console.log('--- 科技成果转化系统 部署工具 ---');
|
||||||
|
|
||||||
|
// 1. 执行完整建表语句
|
||||||
|
console.log('正在初始化数据库表结构...');
|
||||||
|
const sqlPath = path.join(__dirname, '../../database/schema.sql');
|
||||||
|
if (!fs.existsSync(sqlPath)) {
|
||||||
|
throw new Error(`找不到 SQL 文件: ${sqlPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = fs.readFileSync(sqlPath, 'utf8');
|
||||||
|
// 简单的 SQL 分割逻辑,按分号分割
|
||||||
|
const statements = sql.split(';').filter(s => s.trim());
|
||||||
|
|
||||||
|
for (let statement of statements) {
|
||||||
|
try {
|
||||||
|
await db.query(statement);
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略“已存在”类的错误
|
||||||
|
if (!e.message.includes('already exists')) {
|
||||||
|
console.log(`执行语句时提示: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 初始化默认部门
|
||||||
|
console.log('正在初始化默认部门...');
|
||||||
|
const defaultDepts = [
|
||||||
|
'院领导', '卫星中心综合部', '卫星中心生产部', '卫星中心研发部', '大数据中心',
|
||||||
|
'生产保障部', '生产技术部', '土地调查监测室', '森林调查监测室', '湿地调查监测室',
|
||||||
|
'专业调查室', '权籍调查室', '碳汇研究中心', '权益研究中心', '科技与档案室',
|
||||||
|
'办公室', '党群工作部', '待定'
|
||||||
|
];
|
||||||
|
for (const dept of defaultDepts) {
|
||||||
|
await db.query('INSERT INTO departments (name) VALUES ($1) ON CONFLICT (name) DO NOTHING', [dept]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 初始化字典表数据
|
||||||
|
console.log('正在初始化字典表数据...');
|
||||||
|
|
||||||
|
const dictData = {
|
||||||
|
dict_organizations: ['黑龙江省自然资源权益调查监测院','黑龙江省自然资源卫星应用技术中心','黑龙江省自然资源权益调查监测院(黑龙江省自然资源卫星应用技术中心)','待定'],
|
||||||
|
dict_award_types: ['国家级', '省部级', '社会力量', '其他', '待定'],
|
||||||
|
dict_award_levels: ['一等奖', '二等奖', '三等奖', '其他', '待定'],
|
||||||
|
dict_paper_types: ['Nature/Science/Cell', 'CCF A类会议','SCI一区期刊','SCI二区期刊', 'CCF B类会议','CCF C类会议','SCI三区期刊','SCI四区期刊','EI源刊', '中文核心期刊', 'EI会议', '普通期刊', '普通会议', '待定'],
|
||||||
|
dict_standard_types: ['国家标准', '国际标准', '行业标准', '地方标准', '待定'],
|
||||||
|
dict_project_categories: ['科技', '财政', '横向', '成果转化', '待定']
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [tableName, values] of Object.entries(dictData)) {
|
||||||
|
for (const val of values) {
|
||||||
|
await db.query(`INSERT INTO ${tableName} (name) VALUES ($1) ON CONFLICT (name) DO NOTHING`, [val]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 创建初始超级管理员
|
||||||
|
const adminPhone = '18888888888';
|
||||||
|
const adminPass = 'admin123';
|
||||||
|
const salt = await bcrypt.genSalt(10);
|
||||||
|
const passwordHash = await bcrypt.hash(adminPass, salt);
|
||||||
|
|
||||||
|
console.log('正在创建初始超级管理员...');
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO users (username, password_hash, role, real_name, department, dept_status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (username) DO UPDATE SET role = 'super_admin'`,
|
||||||
|
[adminPhone, passwordHash, 'super_admin', '系统管理员', null, 'verified']
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. 创建初始系统维护员
|
||||||
|
const maintainerPhone = '19999999999';
|
||||||
|
const maintainerPass = 'maintainer123';
|
||||||
|
const maintainerHash = await bcrypt.hash(maintainerPass, salt);
|
||||||
|
|
||||||
|
console.log('正在创建初始系统维护员...');
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO users (username, password_hash, role, real_name, department, dept_status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (username) DO UPDATE SET role = 'maintainer'`,
|
||||||
|
[maintainerPhone, maintainerHash, 'maintainer', '系统维护员', null, 'verified']
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('\n-----------------------------------');
|
||||||
|
console.log('部署成功!');
|
||||||
|
console.log(`初始超管账号: ${adminPhone} / ${adminPass}`);
|
||||||
|
console.log(`初始维护员账号: ${maintainerPhone} / ${maintainerPass}`);
|
||||||
|
console.log('-----------------------------------');
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('\n部署失败:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setup();
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const db = require('../db');
|
||||||
|
const logger = require('./logger');
|
||||||
|
|
||||||
|
const parsePositiveNumber = (value, fallback) => {
|
||||||
|
const num = Number(value);
|
||||||
|
return Number.isFinite(num) && num > 0 ? num : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 磁盘告警阈值(GB),默认 10 GB
|
||||||
|
const THRESHOLD_GB = parsePositiveNumber(process.env.DISK_ALERT_THRESHOLD_GB, 10);
|
||||||
|
const THRESHOLD_BYTES = THRESHOLD_GB * 1024 * 1024 * 1024;
|
||||||
|
// 告警冷却时间(小时),默认 24 小时
|
||||||
|
const ALERT_COOLDOWN_HOURS = parsePositiveNumber(process.env.DISK_ALERT_COOLDOWN_HOURS, 24);
|
||||||
|
|
||||||
|
const getDiskSpace = async (directoryPath) => {
|
||||||
|
try {
|
||||||
|
// 仅在支持 statfs 的 Node 版本启用
|
||||||
|
if (fs.promises.statfs) {
|
||||||
|
const stats = await fs.promises.statfs(directoryPath);
|
||||||
|
const FreeSpace = stats.bfree * stats.bsize;
|
||||||
|
const Size = stats.blocks * stats.bsize;
|
||||||
|
return { FreeSpace, Size };
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('当前 Node.js 版本不支持 fs.promises.statfs,跳过磁盘检查');
|
||||||
|
return { FreeSpace: Number.MAX_SAFE_INTEGER, Size: Number.MAX_SAFE_INTEGER };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`获取磁盘空间失败 [${directoryPath}]: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkDiskSpace = async () => {
|
||||||
|
try {
|
||||||
|
// 1. 获取上传目录绝对路径
|
||||||
|
const uploadDir = process.env.UPLOAD_DIR || 'uploads';
|
||||||
|
const absolutePath = path.resolve(uploadDir);
|
||||||
|
|
||||||
|
// 目录不存在时,回退到所在盘符根目录进行检测
|
||||||
|
let targetPath = absolutePath;
|
||||||
|
try {
|
||||||
|
await fs.promises.access(targetPath);
|
||||||
|
} catch {
|
||||||
|
targetPath = path.parse(absolutePath).root;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 获取磁盘空间
|
||||||
|
const { FreeSpace } = await getDiskSpace(targetPath);
|
||||||
|
|
||||||
|
// 3. 判断是否低于阈值
|
||||||
|
if (FreeSpace < THRESHOLD_BYTES) {
|
||||||
|
const root = path.parse(absolutePath).root;
|
||||||
|
const freeGB = (FreeSpace / (1024 * 1024 * 1024)).toFixed(2);
|
||||||
|
const title = `【系统警告】磁盘空间不足 (${root})`;
|
||||||
|
|
||||||
|
// 4. 冷却期内同标题只发一次
|
||||||
|
const cooldownTime = new Date(Date.now() - ALERT_COOLDOWN_HOURS * 60 * 60 * 1000);
|
||||||
|
const recentAlert = await db.query(
|
||||||
|
`SELECT id FROM notifications
|
||||||
|
WHERE title = $1
|
||||||
|
AND created_at > $2
|
||||||
|
LIMIT 1`,
|
||||||
|
[title, cooldownTime]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (recentAlert.rows.length === 0) {
|
||||||
|
logger.warn(`磁盘空间不足警告: ${root} 剩余 ${freeGB} GB`);
|
||||||
|
|
||||||
|
const content = `系统检测到存储磁盘 (${root}) 剩余空间仅剩 ${freeGB} GB,低于警告阈值 (${THRESHOLD_GB} GB)。\n\n请系统管理员尽快处理:\n1. 清理磁盘空间。\n2. 或配置新的存储路径(如挂载大容量磁盘),并更新配置文件中的 UPLOAD_DIR 参数。`;
|
||||||
|
|
||||||
|
// 尝试获取 ID 最小的 super_admin 作为发布者
|
||||||
|
let publisherId = 1;
|
||||||
|
const adminRes = await db.query("SELECT id FROM users WHERE role = 'super_admin' ORDER BY id ASC LIMIT 1");
|
||||||
|
if (adminRes.rows.length > 0) {
|
||||||
|
publisherId = adminRes.rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
'INSERT INTO notifications (title, content, publisher_id, publisher_name, is_top, created_at) VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)',
|
||||||
|
[title, content, publisherId, '系统监控', true]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('已发布磁盘空间不足警告通知');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`磁盘空间检查失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { checkDiskSpace };
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
const winston = require('winston');
|
||||||
|
require('winston-daily-rotate-file');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// 定义日志目录
|
||||||
|
const logDir = path.join(__dirname, '../logs');
|
||||||
|
|
||||||
|
// 定义日志格式
|
||||||
|
const logFormat = winston.format.combine(
|
||||||
|
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||||
|
winston.format.printf(({ timestamp, level, message }) => {
|
||||||
|
return `${timestamp} [${level.toUpperCase()}]: ${message}`;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 创建 Logger 实例
|
||||||
|
const logger = winston.createLogger({
|
||||||
|
format: logFormat,
|
||||||
|
transports: [
|
||||||
|
// 1. 错误日志:只记录 error 级别
|
||||||
|
new winston.transports.DailyRotateFile({
|
||||||
|
filename: path.join(logDir, 'error-%DATE%.log'),
|
||||||
|
datePattern: 'YYYY-MM-DD',
|
||||||
|
level: 'error',
|
||||||
|
maxSize: '20m',
|
||||||
|
maxFiles: '14d', // 保留14天
|
||||||
|
}),
|
||||||
|
// 2. 综合日志:记录所有级别
|
||||||
|
new winston.transports.DailyRotateFile({
|
||||||
|
filename: path.join(logDir, 'combined-%DATE%.log'),
|
||||||
|
datePattern: 'YYYY-MM-DD',
|
||||||
|
maxSize: '20m',
|
||||||
|
maxFiles: '14d',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果不是生产环境,也输出到控制台
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
logger.add(new winston.transports.Console({
|
||||||
|
format: winston.format.combine(
|
||||||
|
winston.format.colorize(),
|
||||||
|
winston.format.simple()
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建一个流对象,供 morgan 使用
|
||||||
|
logger.stream = {
|
||||||
|
write: (message) => {
|
||||||
|
logger.info(message.trim());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = logger;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
const Dypnsapi20170525 = require('@alicloud/dypnsapi20170525');
|
||||||
|
const OpenApi = require('@alicloud/openapi-client');
|
||||||
|
const Util = require('@alicloud/tea-util');
|
||||||
|
|
||||||
|
class SmsClient {
|
||||||
|
static createClient() {
|
||||||
|
let config = new OpenApi.Config({
|
||||||
|
accessKeyId: process.env.ALIBABA_CLOUD_ACCESS_KEY_ID,
|
||||||
|
accessKeySecret: process.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET,
|
||||||
|
});
|
||||||
|
config.endpoint = `dypnsapi.aliyuncs.com`;
|
||||||
|
return new Dypnsapi20170525.default(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async sendVerifyCode(phoneNumber, code) {
|
||||||
|
let client = this.createClient();
|
||||||
|
let sendSmsVerifyCodeRequest = new Dypnsapi20170525.SendSmsVerifyCodeRequest({
|
||||||
|
signName: process.env.SMS_SIGN_NAME,
|
||||||
|
templateCode: process.env.SMS_TEMPLATE_CODE,
|
||||||
|
phoneNumber: phoneNumber,
|
||||||
|
templateParam: `{"code":"${code}","min":"5"}`,
|
||||||
|
});
|
||||||
|
let runtime = new Util.RuntimeOptions({});
|
||||||
|
try {
|
||||||
|
return await client.sendSmsVerifyCodeWithOptions(sendSmsVerifyCodeRequest, runtime);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('短信发送失败:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = SmsClient;
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>访问被拒绝</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: #f0f2f5;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
text-align: center;
|
||||||
|
background: white;
|
||||||
|
padding: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
max-width: 90%;
|
||||||
|
width: 400px;
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
font-size: 64px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #ff4d4f;
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: #666;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
margin-top: 30px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="icon">🚫</div>
|
||||||
|
<h1>访问被拒绝</h1>
|
||||||
|
<p>根据本单位安全规定,本系统<strong>禁止使用移动设备</strong>(手机/平板)访问。</p>
|
||||||
|
<p style="margin-top: 10px;">请使用办公电脑访问。</p>
|
||||||
|
<div class="footer">科技成果转化系统安全中心</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# 第一次升级
|
||||||
|
|
||||||
|
## 1. 升级目标
|
||||||
|
- 不改 PostgreSQL 现有数据与表结构。
|
||||||
|
- 不改服务器文件存储路径(`UPLOAD_DIR` / `UPLOAD_FALLBACK_DIRS`)。
|
||||||
|
- 通过“代码 + 前端构建产物”发布完成升级。
|
||||||
|
- 修复现有高优先级缺陷并增强安全性与稳定性。
|
||||||
|
|
||||||
|
## 2. 约束与发布原则
|
||||||
|
- 不执行任何初始化/造数脚本(如 `init-db`、`seed`、`create_db`、`setup`)。
|
||||||
|
- 服务器 `.env` 保持原值(尤其数据库与上传目录相关配置)。
|
||||||
|
- 采用兼容式修改,避免破坏现网接口与数据。
|
||||||
|
|
||||||
|
## 3. 升级计划(逐项完成)
|
||||||
|
|
||||||
|
状态说明:
|
||||||
|
- `[ ]` 待处理
|
||||||
|
- `[-]` 处理中
|
||||||
|
- `[x]` 已完成
|
||||||
|
|
||||||
|
- [x] 01. 建立本升级文档与执行清单
|
||||||
|
- [x] 02. 修复成果编辑接口运行时错误与权限判断
|
||||||
|
- [x] 03. 前端移除 URL token 传递(导出/下载改为 Header 认证)
|
||||||
|
- [x] 04. 上传安全加固(文件大小/数量限制、通知附件类型限制)
|
||||||
|
- [x] 05. 上传失败时清理已落盘文件,避免孤儿文件
|
||||||
|
- [x] 06. 数据库配置兼容修复(`DB_DATABASE`/`DB_NAME`)
|
||||||
|
- [x] 07. 数据库连接池错误策略优化(避免进程直接退出)
|
||||||
|
- [x] 08. 整体回归检查与发布说明整理
|
||||||
|
|
||||||
|
## 4. 变更记录
|
||||||
|
- 2026-02-25:创建文档与首轮升级清单。
|
||||||
|
- 2026-02-25:修复 `GET /api/achievements/:id` 的运行时错误(移除未定义变量 `parsedContributors` 的错误逻辑)。
|
||||||
|
- 2026-02-25:修复 `PUT /api/achievements/:id` 事务提前返回未回滚问题(补充 `ROLLBACK`)。
|
||||||
|
- 2026-02-25:修复 `DELETE /api/achievements/:id` 事务提前返回未回滚问题(补充 `ROLLBACK`)。
|
||||||
|
- 2026-02-25:成果上传接口补充 JSON 解析失败时的落盘文件清理。
|
||||||
|
- 2026-02-25:通知发布接口补充附件限制与异常清理,`content` 改为净化后入库(不改表结构)。
|
||||||
|
- 2026-02-25:前端导出/下载去除 URL token,统一改为 `Authorization` Header + Blob 下载。
|
||||||
|
- 2026-02-25:数据库连接配置兼容 `DB_DATABASE` / `DB_NAME`,连接池 idle error 不再 `process.exit(-1)`。
|
||||||
|
- 2026-02-25:后端语法检查通过;前端 `npm run build` 编译通过。
|
||||||
|
- 2026-02-25:本次新增与更新内容全部按 UTF-8 编码保存。
|
||||||
|
- 2026-02-26:统计页新增“统计口径说明”和 AI 安全说明文案;新增“部门独立完成 / 合作项目统计表”。
|
||||||
|
- 2026-02-26:AI Prompt 优化为结构化管理汇报模板(执行摘要/关键发现/独立合作解读/风险/行动建议/一句话结论)。
|
||||||
|
- 2026-02-26:修复统计接口 500(`text <> varchar[]/text[]` 类型兼容问题),统一在 SQL 中显式转换为 `text[]`,并同步修复 AI 分析同口径查询。
|
||||||
|
- 2026-02-26:AI 分析结果前端改为 Markdown 渲染(支持标题/列表/表格等格式),提升可读性。
|
||||||
|
- 2026-02-26:AI 提示词精简;传给模型的数据改为语义化摘要(类型/状态中文化、TopN 与占比、近 24 个月趋势),降低上下文长度并提升分析速度。
|
||||||
|
- 2026-02-26:AI 分析新增流式输出(边生成边显示);前端新增“清空输出”按钮(生成中可中止并清空)。
|
||||||
|
- 2026-02-26:取消后端 `max_tokens` 人工上限(输出长度不再由项目侧硬限制,受模型侧能力约束)。
|
||||||
|
- 2026-02-26:AI 功能权限扩展至 `senior_user`(高级用户)与 `intermediate_user`(中级用户),前后端权限口径保持一致。
|
||||||
|
- 2026-02-26:成果列表/成果详情/导出接口的 `assigned_departments` 返回口径统一排除院领导部门(与统计页口径一致)。
|
||||||
|
- 2026-02-26:修正部门过滤边界:若某条成果过滤院领导部门后为空,则回退显示原始部门,避免“无归属部门”展示问题。
|
||||||
|
- 2026-02-26:成果归属部门计算改为“全体完成人手机号对应部门去重后统计”,并在写库时过滤院领导部门(不再仅按主要完成人计算)。
|
||||||
|
- 2026-02-26:归属部门规则再次调整为“单归属部门”:
|
||||||
|
- 优先按主要完成人顺序取部门;
|
||||||
|
- 若命中院领导部门则按顺序回退到下一个完成人,取第一个非院领导部门;
|
||||||
|
- 若全部均为院领导部门,则回退首个部门,保证每条成果只有 1 个归属部门。
|
||||||
|
- 2026-02-27:统计页下线“部门独立完成 / 合作项目统计表”(当前每个项目仅归属 1 个部门,该表不再提供有效增量信息)。
|
||||||
|
- 2026-02-27:成果相关全入口口径收敛为单归属部门(成果公示/全部成果/我的成果/详情权限/部门筛选/统计/AI 分析统一),并对历史多部门数据按“首个非院领导,否则首个部门”规则读取时归一。
|
||||||
|
- 2026-02-27:统计页新增“AI 年份筛选与图表年份联动”:在 AI 卡片选择年份后,下方部门分布/类型分布/部门种类统计三图同步切换;年份来源保持动态,无硬编码年份。
|
||||||
|
|
||||||
|
## 5. 服务器端操作(全部完成后填写)
|
||||||
|
- 1. 先备份服务器现有代码目录和前端静态目录(保留可回滚版本)。
|
||||||
|
- 2. 保持服务器 `.env` 不变,尤其是 `DB_*`、`UPLOAD_DIR`、`UPLOAD_FALLBACK_DIRS`。
|
||||||
|
- 3. 只上传本次变更的后端代码与前端构建产物(`client/build` 或你实际部署目录下的 dist)。
|
||||||
|
- 4. 不执行任何数据库初始化、迁移、造数脚本(`init-db`/`seed`/`create_db`/`setup`)。
|
||||||
|
- 5. 重启 Node 服务进程(`pm2 restart ...` 或 `systemctl restart ...`,按你现网方式)。
|
||||||
|
- 6. 发布后烟测:
|
||||||
|
- 成果详情页打开(`GET /api/achievements/:id`)。
|
||||||
|
- 成果修改/删除权限验证(普通用户、超管各测一次)。
|
||||||
|
- 成果附件下载、CSV 导出、ZIP 导出。
|
||||||
|
- 通知发布(带附件与异常附件各测一次)。
|
||||||
|
- 统计分析页:部门统计已排除院领导部门;每条成果仅计入一个归属部门。
|
||||||
|
- 统计分析页:不再显示“部门独立完成 / 合作项目统计表”。
|
||||||
|
- AI 分析:可在 `auto / qwen / deepseek` 切换,返回提供方与尝试顺序。
|
||||||
|
- 7. 若异常,先回滚代码与前端静态文件,不回滚数据库(本次未做数据库结构变更)。
|
||||||
|
|
||||||
|
## 6. 功能扩展:AI 分析(2026-02-25)
|
||||||
|
|
||||||
|
### 6.1 功能目标
|
||||||
|
- 在“统计分析”页面增加 AI 分析入口。
|
||||||
|
- 使用后端调用大模型 API 生成分析结论,前端仅展示结果。
|
||||||
|
- 保持数据库与文件存储路径不变,不做任何表结构变更。
|
||||||
|
|
||||||
|
### 6.2 安全策略(已实现)
|
||||||
|
- 不向大模型发送附件内容。
|
||||||
|
- 不向大模型发送附件路径与文件名。
|
||||||
|
- 不向大模型发送完成人手机号和姓名。
|
||||||
|
- 仅发送脱敏后的聚合统计数据(类型分布、状态分布、部门分布、月度趋势、质量计数)。
|
||||||
|
- 接口权限限制为管理员/超级管理员可用。
|
||||||
|
|
||||||
|
### 6.3 本次新增文件与改动
|
||||||
|
- 新增后端路由:`server/routes/aiAnalysis.js`
|
||||||
|
- 挂载后端路由:`server/index.js` -> `/api/ai-analysis`
|
||||||
|
- 新增 AI 配置示例:`server/.env.example`
|
||||||
|
- 前端入口与交互:`client/src/pages/Statistics.js`
|
||||||
|
- 新增提供方选择:自动 / 千问 / DeepSeek / custom
|
||||||
|
- 新增自动回退:按优先级依次尝试(可配置)
|
||||||
|
|
||||||
|
### 6.4 服务器端新增配置
|
||||||
|
在服务器 `.env` 新增(或确认)以下变量:
|
||||||
|
|
||||||
|
```env
|
||||||
|
AI_DEFAULT_PROVIDER=auto
|
||||||
|
AI_PROVIDER_PRIORITY=qwen,deepseek,custom
|
||||||
|
|
||||||
|
QWEN_API_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||||
|
QWEN_API_KEY=你的千问Key
|
||||||
|
QWEN_MODEL=qwen-plus
|
||||||
|
|
||||||
|
DEEPSEEK_API_BASE_URL=https://api.deepseek.com/v1
|
||||||
|
DEEPSEEK_API_KEY=你的DeepSeekKey
|
||||||
|
DEEPSEEK_MODEL=deepseek-chat
|
||||||
|
|
||||||
|
# 可选:自定义网关/代理
|
||||||
|
AI_API_BASE_URL=
|
||||||
|
AI_API_KEY=
|
||||||
|
AI_MODEL=
|
||||||
|
|
||||||
|
AI_TIMEOUT_MS=45000
|
||||||
|
AI_MAX_TOKENS=1600
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- `AI_DEFAULT_PROVIDER=auto` 时,按 `AI_PROVIDER_PRIORITY` 顺序尝试。
|
||||||
|
- 前端可手动指定单一提供方,也可选“自动”。
|
||||||
|
- 私有网段网关可放在 `AI_API_BASE_URL`(custom)中使用(http/https 均支持)。
|
||||||
|
|
||||||
|
### 6.5 上线后测试点
|
||||||
|
- 1. 管理员/超管登录,进入“统计分析”页面,确认出现“AI 智能分析(脱敏)”卡片。
|
||||||
|
- 2. 选择筛选条件并输入问题,点击“生成 AI 分析”应返回文本结果。
|
||||||
|
- 3. 普通用户账号不应可用该功能(无入口或接口返回 403)。
|
||||||
|
- 4. 服务器日志确认无附件路径、文件名、手机号、姓名外发日志。
|
||||||
|
|
||||||
|
### 6.6 统计口径调整(院领导部门过滤)
|
||||||
|
- 部门相关统计(部门分布、部门类型交叉、部门趋势)统一排除 `dict_leader_departments` 中配置的院领导部门。
|
||||||
|
- 成果归属采用“单归属部门”写库规则(按主要完成人顺序回退到首个非院领导部门;若均为院领导则回退首个部门)。
|
||||||
|
- 统计页已下线“部门独立完成 / 合作项目统计表”。
|
||||||
|
- AI 分析入参与统计页保持同口径(排除院领导部门,不再传递独立/合作统计字段)。
|
||||||
|
|
||||||
|
### 6.7 文案与 Prompt 优化(2026-02-26)
|
||||||
|
- 前端统计页新增口径提示文案,明确“排除院领导部门 + 单归属部门计数”。
|
||||||
|
- AI 卡片新增安全提示文案,明确仅发送脱敏聚合数据,不发送附件信息和个人敏感信息。
|
||||||
|
- AI 系统提示词改为结构化输出,支持直接用于院级管理汇报。
|
||||||
|
- 输出要求强调“先结论、后证据、再建议”,并在数据不足时必须显式提示证据不足。
|
||||||
Reference in New Issue
Block a user