Initial commit: ERP system with advance verification fixes

This commit is contained in:
System Administrator
2026-03-25 23:55:36 +07:00
commit 563ca12d76
5920 changed files with 828689 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+174
View File
@@ -0,0 +1,174 @@
# 问题清单 - 进销存系统
**创建日期**: 2026-03-09
**最后更新**: 2026-03-09 19:10 UTC
---
## 🔴 高优先级(阻塞)
### ISSUE-001: 缺失页面组件导致运行时错误
**状态**: 🟐 待修复
**分配给**: @frontend-team
**影响范围**: 所有非仪表盘页面
**问题描述**:
App.tsx 导入了 5 个不存在的页面组件,用户点击菜单项会导致应用崩溃。
**缺失文件**:
- [ ] `src/pages/projects/ProjectsPage.tsx`
- [ ] `src/pages/advances/AdvancesPage.tsx`
- [ ] `src/pages/reimbursements/ReimbursementsPage.tsx`
- [ ] `src/pages/finance/FinancePage.tsx`
- [ ] `src/pages/reports/ReportsPage.tsx`
**修复方案**:
1. 创建空壳组件(临时方案)
2. 逐步实现业务逻辑
**验收标准**:
- [ ] 所有菜单项点击不报错
- [ ] 页面显示"开发中"提示
- [ ] 控制台无错误
---
### ISSUE-002: API 服务层缺失
**状态**: 🟐 待修复
**分配给**: @frontend-team + @backend-team
**影响范围**: 所有数据交互功能
**问题描述**:
`src/api/` 目录为空,无法与后端通信。
**需要创建**:
- [ ] `src/api/index.ts` - Axios 实例配置
- [ ] `src/api/auth.ts` - 认证接口
- [ ] `src/api/projects.ts` - 项目管理接口
- [ ] `src/api/advances.ts` - 预支管理接口
- [ ] `src/api/reimbursements.ts` - 报销管理接口
- [ ] `src/api/finance.ts` - 财务管理接口
- [ ] `src/api/reports.ts` - 报表分析接口
**验收标准**:
- [ ] API 客户端可正常调用
- [ ] 错误处理完善
- [ ] 支持请求/响应拦截
---
## 🟡 中优先级(体验)
### ISSUE-003: PWA 图标文件缺失
**状态**: 🟐 待修复
**分配给**: @frontend-team
**影响范围**: 移动端用户体验
**问题描述**:
vite.config.ts 配置了 PWA,但 public 目录缺少必要的图标文件。
**缺失文件**:
- [ ] `public/pwa-192x192.png`
- [ ] `public/pwa-512x512.png`
- [ ] `public/apple-touch-icon.png`
- [ ] `public/favicon.ico`
**修复方案**:
使用 PWA Asset Generator 生成图标:
```bash
npx pwa-asset-generator src/assets/logo.svg public
```
**验收标准**:
- [ ] 所有图标文件存在
- [ ] 移动端可添加到主屏幕
- [ ] 图标显示正常
---
### ISSUE-004: 移动端适配待完善
**状态**: 🟐 待测试
**分配给**: @frontend-team
**影响范围**: 移动端用户体验
**需要测试**:
- [ ] 侧边栏在移动端自动折叠
- [ ] 表格支持横向滚动
- [ ] 按钮和表单元素触摸友好
- [ ] 字体大小适配小屏幕
**验收标准**:
- [ ] iPhone SE (375px) 显示正常
- [ ] iPad (768px) 显示正常
- [ ] 无横向滚动条(除表格外)
---
## 🟢 低优先级(优化)
### ISSUE-005: 公网访问未开放
**状态**: ️ 预期行为
**分配给**: @devops-team
**影响范围**: 无(使用 Tailscale
**说明**:
安全组未开放 3001 端口,这是**正确的安全配置**。生产环境应仅允许 Tailscale 内网访问。
**建议**:
- [ ] 文档中明确说明访问方式
- [ ] 无需修复
---
### ISSUE-006: 使用开发服务器
**状态**: 🟐 待优化
**分配给**: @devops-team
**影响范围**: 生产环境部署
**问题描述**:
当前使用 Vite 开发服务器 (`vite --host`),不适合生产环境。
**修复方案**:
1. 执行 `npm run build` 构建生产版本
2. 使用 Nginx 或其他 Web 服务器托管 dist 目录
3. 配置反向代理到后端 API
**验收标准**:
- [ ] 构建无错误
- [ ] 生产版本可正常访问
- [ ] 性能优化(压缩、缓存等)
---
## 📝 问题状态图例
| 状态 | 图标 | 说明 |
|------|------|------|
| 待修复 | 🟐 | 尚未开始处理 |
| 进行中 | 🔵 | 正在修复 |
| 待验证 | 🟣 | 修复完成,等待测试 |
| 已解决 | ✅ | 测试通过 |
| 预期行为 | ℹ️ | 无需修复 |
| 已关闭 | ⚫ | 已归档 |
---
## 📊 统计
- **高优先级**: 2 个(阻塞功能)
- **中优先级**: 2 个(影响体验)
- **低优先级**: 2 个(优化建议)
- **总计**: 6 个
---
**维护说明**:
- 修复问题后更新状态
- 新问题按格式添加
- 每周回顾问题清单
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+287
View File
@@ -0,0 +1,287 @@
# 进销存系统测试报告
**测试日期**: 2026-03-09 18:56 UTC
**测试人员**: AI 测试助手
**系统版本**: v0.0.0 (开发中)
**测试环境**: 云服务器 (43.129.27.210) + Tailscale VPN
---
## 📋 测试概览
| 测试类别 | 通过 | 失败 | 警告 | 总计 |
|---------|------|------|------|------|
| 服务状态检查 | 3 | 0 | 0 | 3 |
| 本地访问测试 | 2 | 0 | 0 | 2 |
| Windows 访问测试 | 2 | 0 | 0 | 2 |
| 移动端访问测试 | 2 | 1 | 0 | 3 |
| 功能测试 | 2 | 5 | 2 | 9 |
| **总计** | **11** | **6** | **2** | **19** |
**测试通过率**: 57.9%
---
## ✅ 通过的测试项
### 1. 服务状态检查
| 测试项 | 状态 | 详情 |
|--------|------|------|
| Vite 开发服务 | ✅ 通过 | 进程 ID: 149327,运行正常 |
| 端口 3001 监听 | ✅ 通过 | `0.0.0.0:3001 LISTEN 149327/node` |
| 服务进程 | ✅ 通过 | node + esbuild 服务正常运行 |
### 2. 本地访问测试
| 测试项 | 状态 | 详情 |
|--------|------|------|
| curl localhost:3001 | ✅ 通过 | HTTP/1.1 200 OK |
| 响应头检查 | ✅ 通过 | Content-Type: text/html, Cache-Control: no-cache |
**响应示例**:
```
HTTP/1.1 200 OK
Vary: Origin
Content-Type: text/html
Cache-Control: no-cache
Etag: W/"284-7bU+x0Fr2X4vAzjym6yXRvsGtyk"
```
### 3. Windows 电脑访问测试
| 测试项 | 状态 | 详情 |
|--------|------|------|
| SSH 远程测试 | ✅ 通过 | 从 100.77.135.1 访问成功 |
| PowerShell 请求 | ✅ 通过 | StatusCode: 200, StatusDescription: OK |
**测试结果**:
```
StatusCode StatusDescription
---------- -----------------
200 OK
```
### 4. 移动端访问测试
| 测试项 | 状态 | 详情 |
|--------|------|------|
| Tailscale VPN 访问 | ✅ 通过 | http://100.85.119.13:3001/ 返回 200 |
| 移动端 User-Agent | ✅ 通过 | iPhone Safari UA 测试通过 |
| viewport 配置 | ✅ 通过 | `<meta name="viewport" content="width=device-width, initial-scale=1.0" />` |
### 5. 功能测试 - 已实现
| 测试项 | 状态 | 详情 |
|--------|------|------|
| 首页加载 | ✅ 通过 | HTML 正常返回,Vite HMR 正常 |
| 登录页面 | ✅ 通过 | LoginPage.tsx 完整实现,支持 4 种测试账户 |
---
## ❌ 失败的测试项
### 1. 公网访问测试
| 测试项 | 状态 | 错误信息 |
|--------|------|----------|
| 公网 IP 访问 | ❌ 失败 | `curl: (28) Failed to connect to 43.129.27.210 port 3001 after 5002 ms: Timeout was reached` |
**原因分析**: 云服务器安全组未开放 3001 端口
**建议**: 这是**预期行为**,生产环境应仅允许 Tailscale 内网访问
---
### 2. 缺失页面组件(严重)
App.tsx 导入了以下不存在的页面组件,会导致**运行时错误**:
| 缺失文件 | 路由 | 优先级 |
|---------|------|--------|
| `src/pages/projects/ProjectsPage.tsx` | `/projects` | 🔴 高 |
| `src/pages/advances/AdvancesPage.tsx` | `/advances` | 🔴 高 |
| `src/pages/reimbursements/ReimbursementsPage.tsx` | `/reimbursements` | 🔴 高 |
| `src/pages/finance/FinancePage.tsx` | `/finance` | 🔴 高 |
| `src/pages/reports/ReportsPage.tsx` | `/reports` | 🔴 高 |
**当前项目文件统计**:
```
src/
├── App.tsx ✅
├── main.tsx ✅
├── components/
│ └── layout/MainLayout.tsx ✅
├── pages/
│ ├── auth/LoginPage.tsx ✅
│ └── dashboard/DashboardPage.tsx ✅
├── store/
│ └── authStore.ts ✅
└── api/ (空目录) ❌
```
---
### 3. PWA 移动端支持(警告)
vite.config.ts 配置了 PWA,但缺少必要的图标文件:
| 缺失文件 | 用途 | 优先级 |
|---------|------|--------|
| `public/pwa-192x192.png` | PWA 图标 (192x192) | 🟡 中 |
| `public/pwa-512x512.png` | PWA 图标 (512x512) | 🟡 中 |
| `public/apple-touch-icon.png` | iOS 主屏幕图标 | 🟡 中 |
| `public/favicon.ico` | 浏览器图标 | 🟡 中 |
**影响**: 移动端无法将应用添加到主屏幕
---
## 🔧 修复建议
### 高优先级(阻塞功能)
#### 1. 创建缺失的页面组件
**分配给**: Frontend 专家
需要创建以下 5 个页面组件(可先创建空壳组件避免崩溃):
```bash
# 建议的组件结构
src/pages/projects/ProjectsPage.tsx
src/pages/advances/AdvancesPage.tsx
src/pages/reimbursements/ReimbursementsPage.tsx
src/pages/finance/FinancePage.tsx
src/pages/reports/ReportsPage.tsx
```
**临时解决方案**(避免崩溃):
```tsx
// 示例:ProjectsPage.tsx
import React from 'react'
import { Typography } from 'antd'
const { Title } = Typography
const ProjectsPage: React.FC = () => {
return (
<div style={{ padding: '24px' }}>
<Title level={2}>📁 </Title>
<p>...</p>
</div>
)
}
export default ProjectsPage
```
#### 2. 创建 API 服务层
**分配给**: Frontend 专家 + Backend 专家
```bash
src/api/
├── index.ts (API 客户端配置)
├── auth.ts (认证 API)
├── projects.ts (项目 API)
├── advances.ts (预支 API)
├── reimbursements.ts (报销 API)
├── finance.ts (财务 API)
└── reports.ts (报表 API)
```
---
### 中优先级(用户体验)
#### 3. 添加 PWA 图标
**分配给**: Frontend 专家
生成所需图标文件:
- pwa-192x192.png
- pwa-512x512.png
- apple-touch-icon.png
- favicon.ico
**工具推荐**: 使用 [PWA Asset Generator](https://github.com/elegantapp/pwa-asset-generator)
#### 4. 完善移动端适配
**分配给**: Frontend 专家
- [ ] 测试侧边栏在移动端的折叠行为
- [ ] 确保表格在移动端可横向滚动
- [ ] 添加移动端触摸优化
---
### 低优先级(优化)
#### 5. 安全配置
**分配给**: DevOps 专家
- [ ] 确认安全组仅开放必要端口(SSH + Tailscale
- [ ] 生产环境禁用 Vite 开发服务器
- [ ] 配置 HTTPS(可选,Tailscale 已加密)
---
## 📱 移动端访问说明
### 访问方式
1. **Tailscale VPN** (推荐)
- 连接 Tailscale VPN
- 访问:`http://100.85.119.13:3001/`
2. **Windows 电脑**
- 直接访问:`http://100.85.119.13:3001/`
### 测试账户
| 用户名 | 密码 | 角色 |
|--------|------|------|
| admin | 123456 | 系统管理员 |
| finance | 123456 | 财务专员 |
| manager | 123456 | 项目经理 |
| employee | 123456 | 普通员工 |
---
## 🎯 下一步行动
### 立即执行(本周)
1. [ ] 创建 5 个缺失的页面组件(空壳即可)
2. [ ] 测试登录流程和仪表盘导航
### 短期计划(2 周内)
3. [ ] 实现核心业务页面功能
4. [ ] 添加 PWA 图标支持
5. [ ] 完善移动端适配
### 长期计划(1 个月内)
6. [ ] 对接后端 API
7. [ ] 性能优化和测试
8. [ ] 生产环境部署
---
## 📊 测试环境信息
| 项目 | 值 |
|------|-----|
| 云服务器 IP | 43.129.27.210 |
| Tailscale IP | 100.85.119.13 |
| Windows 电脑 IP | 100.77.135.1 |
| 服务端口 | 3001 |
| 项目路径 | `/root/.openclaw/workspace/company-finance-frontend/` |
| Vite 版本 | ^5.0.8 |
| React 版本 | ^18.2.0 |
| Ant Design 版本 | ^5.16.0 |
---
**报告生成时间**: 2026-03-09 19:10 UTC
**下次测试建议**: 修复高优先级问题后重新测试
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>轻远电力老挝ERP - Qingyuan Power Laos</title>
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "company-finance-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^5.3.0",
"antd": "^5.16.0",
"axios": "^1.6.0",
"dayjs": "^1.11.10",
"i18next": "^25.8.18",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^16.5.8",
"react-query": "^3.39.3",
"react-router-dom": "^6.21.0",
"zustand": "^4.4.7"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"typescript": "^5.2.2",
"vite": "^5.0.8",
"vite-plugin-pwa": "^0.17.4"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+110
View File
@@ -0,0 +1,110 @@
/* 全局样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root {
width: 100%;
height: 100%;
overflow-x: hidden;
}
body {
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;
}
/* 响应式布局优化 */
/* 桌面端(> 768px */
@media screen and (min-width: 769px) {
.ant-layout {
min-height: 100vh;
}
.ant-layout-sider {
overflow: auto;
height: 100vh;
position: sticky;
top: 0;
left: 0;
}
/* 登录页面卡片 */
.login-card {
max-width: 480px;
margin: 0 auto;
}
}
/* 移动端(<= 768px */
@media screen and (max-width: 768px) {
/* 隐藏桌面侧边栏 */
.ant-layout-sider {
display: none;
}
/* 登录页面优化 */
.login-card {
max-width: 100%;
margin: 10px;
border-radius: 8px;
}
/* 调整表单元素 */
.ant-form-item-label {
padding-bottom: 4px;
}
.ant-input,
.ant-btn {
font-size: 16px; /* 防止iOS自动缩放 */
}
/* 标题调整 */
.ant-typography h2 {
font-size: 24px;
}
/* 测试账户卡片 */
.ant-card-body {
padding: 12px;
}
}
/* 超小屏幕(<= 480px */
@media screen and (max-width: 480px) {
.login-card {
margin: 5px;
}
.ant-card-body {
padding: 16px;
}
.ant-typography h2 {
font-size: 20px;
}
.ant-space-vertical {
width: 100%;
}
}
/* 确保移动端菜单正常显示 */
.ant-drawer-body {
padding: 0;
}
/* 移动端头部按钮 */
.mobile-header-button {
position: fixed;
top: 16px;
left: 16px;
z-index: 1000;
}
+156
View File
@@ -0,0 +1,156 @@
import React from 'react'
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
import { ConfigProvider } from 'antd'
import dayjs from 'dayjs'
// 样式导入
import './App.css'
// 页面组件
import LoginPage from './pages/auth/LoginPage'
import DashboardPage from './pages/dashboard/DashboardPage'
import ProjectsPage from './pages/projects/ProjectsPage'
import ProjectDetail from './pages/projects/ProjectDetail'
import AdvancesPage from './pages/advances/AdvancesPage'
import ReimbursementsPage from './pages/reimbursements/ReimbursementsPage'
import FinancePage from './pages/finance/FinancePage'
import PaymentRequestsPage from './pages/PaymentRequestsPage'
import VerificationPage from './pages/VerificationPage'
import LayoutShowcase from './pages/LayoutShowcase'
import ProcurementPage from './pages/ProcurementPage'
import ExchangeRatePage from './pages/ExchangeRatePage'
import SuppliersPage from './pages/SuppliersPage'
import SupplierDetail from './pages/SupplierDetail'
import ProductPage from './pages/ProductPage'
import SubcontractorsPage from './pages/SubcontractorsPage'
import SubcontractorDetail from './pages/SubcontractorDetail'
import CustomersPage from './pages/CustomersPage'
import CustomerDetail from './pages/CustomerDetail'
import UsersPage from './pages/UsersPage'
import RolesPage from './pages/RolesPage'
import SystemLogsPage from './pages/SystemLogsPage'
import ApprovalManagement from './pages/approval/ApprovalManagement'
import ExecutionManagement from './pages/approval/ExecutionManagement'
import ReportsPage from './pages/reports/ReportsPage'
// 预算报价页面
import BudgetProjectList from './pages/budget/BudgetProjectList'
import BudgetProjectCreate from './pages/budget/BudgetProjectCreate'
import BudgetProjectDetail from './pages/budget/BudgetProjectDetail'
// 施工管理页面
import ConstructionList from './pages/construction'
import ConstructionLog from './pages/construction/ConstructionLog'
import ConstructionMilestones from './pages/construction/ConstructionMilestones'
// 后台管理
import AdminLayout from './layouts/AdminLayout'
import BackupPage from './pages/admin/BackupPage'
import ProcessManagement from './pages/admin/ProcessManagement'
import AboutPage from './pages/admin/AboutPage'
// 布局组件
import MainLayout from './components/layout/MainLayout'
// 状态管理
import { useAuthStore } from './store/authStore'
import { useLanguageStore } from './store/languageStore'
// 路由守卫组件
const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated } = useAuthStore()
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
return <>{children}</>
}
function App() {
const { currentLanguage, getLanguageInfo } = useLanguageStore()
const languageInfo = getLanguageInfo()
const localeMap: Record<string, string> = {
'zh-CN': 'zh-cn',
'th-TH': 'th',
'lo-LA': 'en',
'en-US': 'en'
}
dayjs.locale(localeMap[currentLanguage] || 'zh-cn')
return (
<ConfigProvider
locale={languageInfo.antdLocale}
theme={{
token: {
colorPrimary: '#1890ff',
borderRadius: 6,
colorLink: '#1890ff',
},
components: {
Layout: {
headerBg: '#fff',
headerPadding: '0 24px',
},
Menu: {},
Card: {
margin: 16,
},
},
}}
>
<Router>
<Routes>
<Route path="/login" element={<LoginPage />} />
{/* 前台路由 */}
<Route path="/" element={<PrivateRoute><MainLayout /></PrivateRoute>}>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="projects/:id" element={<ProjectDetail />} />
<Route path="budget-projects" element={<BudgetProjectList />} />
<Route path="budget-projects/create" element={<BudgetProjectCreate />} />
<Route path="budget-projects/:id" element={<BudgetProjectDetail />} />
<Route path="construction" element={<ConstructionList />} />
<Route path="construction/:id/logs" element={<ConstructionLog />} />
<Route path="construction/:id/milestones" element={<ConstructionMilestones />} />
<Route path="approval" element={<ApprovalManagement />} />
<Route path="execution" element={<ExecutionManagement />} />
<Route path="advances" element={<AdvancesPage />} />
<Route path="reimbursements" element={<ReimbursementsPage />} />
<Route path="finance" element={<FinancePage />} />
<Route path="exchange-rates" element={<ExchangeRatePage />} />
<Route path="reports" element={<ReportsPage />} />
<Route path="payment-requests" element={<PaymentRequestsPage />} />
<Route path="verification" element={<VerificationPage />} />
<Route path="layout-showcase" element={<LayoutShowcase />} />
<Route path="procurement" element={<ProcurementPage />} />
<Route path="products" element={<ProductPage />} />
<Route path="suppliers" element={<SuppliersPage />} />
<Route path="suppliers/:id" element={<SupplierDetail />} />
<Route path="subcontractors" element={<SubcontractorsPage />} />
<Route path="subcontractors/:id" element={<SubcontractorDetail />} />
<Route path="customers" element={<CustomersPage />} />
<Route path="customers/:id" element={<CustomerDetail />} />
</Route>
{/* 后台管理路由 */}
<Route path="/admin" element={<PrivateRoute><AdminLayout /></PrivateRoute>}>
<Route index element={<Navigate to="/admin/users" replace />} />
<Route path="users" element={<UsersPage />} />
<Route path="roles" element={<RolesPage />} />
<Route path="process" element={<ProcessManagement />} />
<Route path="logs" element={<SystemLogsPage />} />
<Route path="backup" element={<BackupPage />} />
<Route path="about" element={<AboutPage />} />
</Route>
</Routes>
</Router>
</ConfigProvider>
)
}
export default App
+88
View File
@@ -0,0 +1,88 @@
import React from 'react'
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
import { ConfigProvider } from 'antd'
import dayjs from 'dayjs'
// 样式导入
import './App.css'
// 页面组件
import LoginPage from './pages/auth/LoginPage'
import DashboardPage from './pages/dashboard/DashboardPage'
import ProjectsPage from './pages/projects/ProjectsPage'
import AdvancesPage from './pages/advances/AdvancesPage'
import ReimbursementsPage from './pages/reimbursements/ReimbursementsPage'
import FinancePage from './pages/finance/FinancePage'
import ReportsPage from './pages/reports/ReportsPage'
// 布局组件
import MainLayout from './components/layout/MainLayout'
// 状态管理
import { useAuthStore } from './store/authStore'
import { useLanguageStore } from './store/languageStore'
// 路由守卫组件
const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated } = useAuthStore()
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
return <>{children}</>
}
function App() {
const { currentLanguage, getLanguageInfo } = useLanguageStore()
const languageInfo = getLanguageInfo()
// 设置 dayjs 本地化
const localeMap: Record<string, string> = {
'zh-CN': 'zh-cn',
'th-TH': 'th',
'lo-LA': 'en',
'en-US': 'en'
}
dayjs.locale(localeMap[currentLanguage] || 'zh-cn')
return (
<ConfigProvider
locale={languageInfo.antdLocale}
theme={{
token: {
colorPrimary: '#1890ff',
borderRadius: 6,
colorLink: '#1890ff',
},
components: {
Layout: {
headerBg: '#fff',
headerPadding: '0 24px',
},
Menu: {},
Card: {
margin: 16,
},
},
}}
>
<Router>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<PrivateRoute><MainLayout /></PrivateRoute>}>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="advances" element={<AdvancesPage />} />
<Route path="reimbursements" element={<ReimbursementsPage />} />
<Route path="finance" element={<FinancePage />} />
<Route path="reports" element={<ReportsPage />} />
</Route>
</Routes>
</Router>
</ConfigProvider>
)
}
export default App
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,209 @@
import React, { useState, useEffect } from 'react'
import { Table, Button, Modal, Form, Input, Switch, message, Space, Tag, Popconfirm } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, PhoneOutlined, UserOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import { useTranslation } from 'react-i18next'
interface Contact {
id: number
name: string
name_zh?: string
position?: string
department?: string
is_primary: boolean
phone?: string
mobile?: string
wechat?: string
whatsapp?: string
line_id?: string
notes?: string
}
interface ContactManagerProps {
companyType: 'customer' | 'supplier' | 'subcontractor'
companyId: number
companyName: string
onContactsUpdated?: () => void
}
const ContactManager: React.FC<ContactManagerProps> = ({
companyType,
companyId,
companyName,
onContactsUpdated
}) => {
const { t } = useTranslation()
const [contacts, setContacts] = useState<Contact[]>([])
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
const [editingContact, setEditingContact] = useState<Contact | null>(null)
const [form] = Form.useForm()
const fetchContacts = async () => {
setLoading(true)
try {
const response = await fetch(`/api/${companyType}s/${companyId}/contacts`)
const data = await response.json()
setContacts(data.contacts || [])
} catch (error) {
console.error('获取联系人失败:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
if (companyId) {
fetchContacts()
}
}, [companyId, companyType])
const columns: ColumnsType<Contact> = [
{
title: t('contact.name'),
dataIndex: 'name',
key: 'name',
render: (text, record) => (
<div>
<div style={{ fontWeight: 'bold' }}>{text}</div>
{record.position && <div style={{ fontSize: '12px', color: '#666' }}>{record.position}</div>}
</div>
)
},
{
title: t('contact.contactInfo'),
key: 'contact',
render: (_, record) => (
<Space direction="vertical" size={2}>
{record.mobile && <div><PhoneOutlined style={{ marginRight: 4 }} />{record.mobile}</div>}
{record.phone && <div style={{ fontSize: '12px', color: '#666' }}>: {record.phone}</div>}
{record.wechat && <div style={{ fontSize: '12px', color: '#666' }}>: {record.wechat}</div>}
</Space>
)
},
{
title: t('contact.status'),
dataIndex: 'is_primary',
key: 'is_primary',
width: 100,
render: (isPrimary) => (
<Tag color={isPrimary ? 'green' : 'blue'}>
{isPrimary ? t('contact.primary') : t('contact.secondary')}
</Tag>
)
},
{
title: t('common.actions'),
key: 'actions',
width: 120,
render: (_, record) => (
<Space>
<Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} size="small" />
<Popconfirm title={t('common.confirmDelete')} onConfirm={() => handleDelete(record.id)} okText={t('common.yes')} cancelText={t('common.no')}>
<Button type="text" danger icon={<DeleteOutlined />} size="small" />
</Popconfirm>
</Space>
)
}
]
const handleSubmit = async (values: any) => {
try {
const url = editingContact ? `/api/${companyType}s/${companyId}/contacts/${editingContact.id}` : `/api/${companyType}s/${companyId}/contacts`
const method = editingContact ? 'PUT' : 'POST'
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...values, company_type: companyType, company_id: companyId })
})
if (response.ok) {
message.success(editingContact ? t('common.updateSuccess') : t('common.createSuccess'))
setModalVisible(false)
form.resetFields()
setEditingContact(null)
fetchContacts()
onContactsUpdated?.()
}
} catch (error) {
message.error(t('common.operationFailed'))
}
}
const handleEdit = (contact: Contact) => {
setEditingContact(contact)
form.setFieldsValue(contact)
setModalVisible(true)
}
const handleDelete = async (contactId: number) => {
try {
await fetch(`/api/${companyType}s/${companyId}/contacts/${contactId}`, { method: 'DELETE' })
message.success(t('common.deleteSuccess'))
fetchContacts()
onContactsUpdated?.()
} catch (error) {
message.error(t('common.deleteFailed'))
}
}
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<div>
<h3>{t('contact.management')}</h3>
<p style={{ color: '#666' }}>{companyName} - {t(`company.${companyType}`)}</p>
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={() => { setEditingContact(null); form.resetFields(); setModalVisible(true) }}>
{t('contact.addContact')}
</Button>
</div>
<Table columns={columns} dataSource={contacts} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} size="middle" />
<Modal
title={editingContact ? t('contact.editContact') : t('contact.addContact')}
open={modalVisible}
onCancel={() => { setModalVisible(false); form.resetFields(); setEditingContact(null) }}
onOk={() => form.submit()}
width={600}
destroyOnClose
>
<Form form={form} layout="vertical" onFinish={handleSubmit} initialValues={{ is_primary: false }}>
<Form.Item name="name" label={t('contact.name')} rules={[{ required: true }]}>
<Input placeholder={t('contact.namePlaceholder')} />
</Form.Item>
<Form.Item name="position" label={t('contact.position')}>
<Input placeholder={t('contact.positionPlaceholder')} />
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<Form.Item name="phone" label={t('contact.phone')}>
<Input placeholder={t('contact.phonePlaceholder')} />
</Form.Item>
<Form.Item name="mobile" label={t('contact.mobile')}>
<Input placeholder={t('contact.mobilePlaceholder')} />
</Form.Item>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<Form.Item name="wechat" label={t('contact.wechat')}>
<Input placeholder={t('contact.wechatPlaceholder')} />
</Form.Item>
<Form.Item name="line_id" label={t('contact.lineId')}>
<Input placeholder={t('contact.lineIdPlaceholder')} />
</Form.Item>
</div>
<Form.Item name="whatsapp" label="WhatsApp">
<Input placeholder="输入WhatsApp号码" />
</Form.Item>
<Form.Item name="is_primary" label={t('contact.primaryContact')} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="notes" label={t('contact.notes')}>
<Input.TextArea rows={3} placeholder={t('contact.notesPlaceholder')} />
</Form.Item>
</Form>
</Modal>
</div>
)
}
export default ContactManager
@@ -0,0 +1,175 @@
import React, { useState, useEffect } from 'react';
import { Upload, Modal, Image, message, Spin, Progress } from 'antd';
import { PlusOutlined, FileOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons';
import type { UploadFile, UploadProps } from 'antd/es/upload/interface';
interface FileUploadProps {
value?: string[];
onChange?: (urls: string[]) => void;
maxCount?: number;
accept?: string;
}
// 支持的图片格式
const imageFormats = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'];
const isImage = (url: string) => {
const ext = url.split('.').pop()?.toLowerCase();
return imageFormats.includes(ext || '');
};
const FileUpload: React.FC<FileUploadProps> = ({
value = [],
onChange,
maxCount = 9,
accept = 'image/*'
}) => {
const [previewOpen, setPreviewOpen] = useState(false);
const [previewImage, setPreviewImage] = useState('');
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [uploading, setUploading] = useState(false);
// 当 value 变化时,更新 fileList
useEffect(() => {
// 只有当 value 是数组且长度大于 0 时才更新 fileList
// 这样可以避免在上传过程中被重置
if (Array.isArray(value) && value.length > 0) {
const newFileList = value.map((url, index) => ({
uid: `-${index}`,
name: url.split('/').pop() || `file-${index}`,
status: 'done',
url,
thumbUrl: isImage(url) ? url : undefined
}));
setFileList(newFileList);
}
}, [value]);
const handlePreview = async (file: UploadFile) => {
if (isImage(file.url || '')) {
setPreviewImage(file.url || '');
setPreviewOpen(true);
} else {
// 非图片文件,新窗口打开
window.open(file.url, '_blank');
}
};
const handleChange: UploadProps['onChange'] = (info) => {
const { fileList } = info;
setFileList(fileList);
// 提取已上传成功的URL
console.log('FileUpload info:', info);
console.log('FileUpload fileList:', fileList);
const urls = fileList
.filter(file => {
console.log('FileUpload file:', file);
return file.status === 'done';
})
.map(file => {
// 处理不同格式的文件对象
if (file.url) {
return file.url;
} else if (file.response && file.response.url) {
return file.response.url;
} else if (file.response && typeof file.response === 'string') {
return file.response;
}
return '';
})
.filter(url => url); // 过滤空字符串
console.log('FileUpload onChange:', urls);
onChange?.(urls);
};
const customRequest = async (options: any) => {
const { file, onSuccess, onError, onProgress } = options;
setUploading(true);
const formData = new FormData();
formData.append('file', file);
try {
console.log('开始上传文件:', file.name);
const res = await fetch('/api/upload/single', {
method: 'POST',
body: formData
});
console.log('上传响应状态:', res.status);
const data = await res.json();
console.log('上传响应数据:', data);
if (data.success) {
onProgress({ percent: 100 });
// 传递URL字符串,这是Ant Design Upload组件在customRequest中期望的格式
onSuccess(data.data.url, file);
message.success('上传成功');
} else {
onError(new Error(data.error));
message.error(data.error || '上传失败');
}
} catch (error) {
console.error('上传错误:', error);
onError(error);
message.error('上传失败');
} finally {
setUploading(false);
}
};
const uploadButton = (
<div>
<PlusOutlined />
<div style={{ marginTop: 8 }}></div>
</div>
);
return (
<>
<Upload
listType="picture-card"
fileList={fileList}
onPreview={handlePreview}
onChange={handleChange}
customRequest={customRequest}
accept={accept}
maxCount={maxCount}
multiple
>
{fileList.length >= maxCount ? null : uploadButton}
</Upload>
{/* 图片预览弹窗 */}
<Modal
open={previewOpen}
title="图片预览"
footer={null}
onCancel={() => setPreviewOpen(false)}
width="80%"
centered
>
<div style={{ textAlign: 'center' }}>
<Image
src={previewImage}
style={{ maxWidth: '100%', maxHeight: '80vh' }}
preview={false}
/>
</div>
</Modal>
{uploading && (
<div style={{ marginTop: 8 }}>
<Spin size="small" /> ...
</div>
)}
</>
);
};
export default FileUpload;
@@ -0,0 +1,62 @@
import React from 'react'
import { Space, Typography } from 'antd'
import { ThunderboltOutlined } from '@ant-design/icons'
const { Text, Title } = Typography
interface CompanyLogoProps {
showText?: boolean
size?: 'small' | 'medium' | 'large'
}
const CompanyLogo: React.FC<CompanyLogoProps> = ({ showText = true, size = 'medium' }) => {
const sizeMap = {
small: { fontSize: 14, iconSize: 20 },
medium: { fontSize: 16, iconSize: 28 },
large: { fontSize: 20, iconSize: 36 }
}
const { fontSize, iconSize } = sizeMap[size]
return (
<Space align="center" style={{ cursor: 'pointer' }}>
{/* 图标 */}
<ThunderboltOutlined
style={{
fontSize: iconSize,
color: '#1890ff',
fontWeight: 'bold'
}}
/>
{/* 公司名称 */}
{showText && (
<div>
<Title
level={5}
style={{
margin: 0,
fontSize: fontSize,
color: '#262626',
fontWeight: 600
}}
>
ERP
</Title>
<Text
type="secondary"
style={{
fontSize: fontSize - 4,
display: 'block',
marginTop: -2
}}
>
Qingyuan Power Laos
</Text>
</div>
)}
</Space>
)
}
export default CompanyLogo
@@ -0,0 +1,42 @@
import React from 'react'
import { Select, Space } from 'antd'
import { GlobalOutlined } from '@ant-design/icons'
import { useLanguageStore } from '../../store/languageStore'
import { languages } from '../../locales'
const { Option } = Select
interface LanguageSelectorProps {
size?: 'small' | 'middle' | 'large'
showIcon?: boolean
style?: React.CSSProperties
}
const LanguageSelector: React.FC<LanguageSelectorProps> = ({
size = 'middle',
showIcon = true,
style
}) => {
const { currentLanguage, setLanguage } = useLanguageStore()
return (
<Select
value={currentLanguage}
onChange={setLanguage}
size={size}
style={{ minWidth: 140, ...style }}
suffixIcon={showIcon ? <GlobalOutlined /> : undefined}
>
{languages.map(lang => (
<Option key={lang.code} value={lang.code}>
<Space size={4}>
<span>{lang.flag}</span>
<span>{lang.nativeName}</span>
</Space>
</Option>
))}
</Select>
)
}
export default LanguageSelector
@@ -0,0 +1,414 @@
import React, { useState, useEffect } from 'react'
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
import {
Layout,
Menu,
Button,
Avatar,
Dropdown,
Typography,
Space,
Badge,
Drawer,
Modal,
theme
} from 'antd'
import {
DashboardOutlined,
ProjectOutlined,
DollarOutlined,
FileTextOutlined,
BarChartOutlined,
UserOutlined,
LogoutOutlined,
SettingOutlined,
BellOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
CalculatorOutlined,
ToolOutlined,
WalletOutlined,
MoneyCollectOutlined,
AuditOutlined,
FileSearchOutlined,
ShoppingCartOutlined,
TeamOutlined,
ShopOutlined,
SolutionOutlined,
HomeOutlined,
SafetyOutlined,
FileDoneOutlined,
AppstoreOutlined,
CheckCircleOutlined
} from '@ant-design/icons'
import { useAuthStore } from '../../store/authStore'
import { useLanguageStore } from '../../store/languageStore'
import CompanyLogo from '../common/CompanyLogo'
import LanguageSelector from '../common/LanguageSelector'
const { Header, Sider, Content } = Layout
const { Text } = Typography
const MainLayout: React.FC = () => {
const navigate = useNavigate()
const location = useLocation()
const [collapsed, setCollapsed] = useState(false)
const [isMobile, setIsMobile] = useState(false)
const [mobileMenuVisible, setMobileMenuVisible] = useState(false)
const [settingsVisible, setSettingsVisible] = useState(false)
const { user, logout } = useAuthStore()
const { t } = useLanguageStore()
const {
token: { colorBgContainer, borderRadiusLG },
} = theme.useToken()
// 检测屏幕尺寸
useEffect(() => {
const checkMobile = () => {
const mobile = window.innerWidth <= 768
setIsMobile(mobile)
if (mobile) {
setCollapsed(true)
}
}
checkMobile()
window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile)
}, [])
// 完整菜单项
const menuItems = [
{
key: '/dashboard',
icon: <DashboardOutlined />,
label: '工作台'
},
{
key: '/projects',
icon: <ProjectOutlined />,
label: '项目管理'
},
{
key: '/budget-projects',
icon: <CalculatorOutlined />,
label: '预算报价'
},
{
key: '/construction',
icon: <ToolOutlined />,
label: '施工管理'
},
{
key: 'approval',
icon: <SolutionOutlined />,
label: '审批管理',
children: [
{
key: '/approval',
icon: <CheckCircleOutlined />,
label: '待审批'
},
{
key: '/execution',
icon: <DollarOutlined />,
label: '待执行'
}
]
},
{
key: 'finance-docs',
icon: <FileDoneOutlined />,
label: '财务申请',
children: [
{
key: '/advances',
icon: <WalletOutlined />,
label: '预支申请'
},
{
key: '/reimbursements',
icon: <FileTextOutlined />,
label: '报销申请'
},
{
key: '/payment-requests',
icon: <MoneyCollectOutlined />,
label: '付款申请'
},
{
key: '/verification',
icon: <AuditOutlined />,
label: '核销申请'
}
]
},
{
key: 'finance-group',
icon: <BarChartOutlined />,
label: '财务管理',
children: [
{
key: '/finance',
label: '财务概览'
},
{
key: '/exchange-rates',
icon: <DollarOutlined />,
label: '汇率管理'
}
]
},
{
key: '/reports',
icon: <FileSearchOutlined />,
label: '报表分析'
},
{
key: 'procurement',
icon: <ShoppingCartOutlined />,
label: '采购管理',
children: [
{
key: '/products',
icon: <AppstoreOutlined />,
label: '商品管理'
}
]
},
{
key: 'partners',
icon: <TeamOutlined />,
label: '合作伙伴',
children: [
{
key: '/suppliers',
icon: <ShopOutlined />,
label: '供应商管理'
},
{
key: '/subcontractors',
icon: <SolutionOutlined />,
label: '分包商管理'
},
{
key: '/customers',
icon: <HomeOutlined />,
label: '客户管理'
}
]
}
]
// 用户下拉菜单
const userMenuItems = [
{
key: 'profile',
icon: <UserOutlined />,
label: '个人信息'
},
{
key: 'settings',
icon: <SettingOutlined />,
label: '系统设置'
},
{
type: 'divider' as const
},
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录'
}
]
// 处理菜单点击
const handleMenuClick = ({ key }: { key: string }) => {
if (key === 'logout') {
logout()
navigate('/login')
} else if (key === 'settings') {
setSettingsVisible(true)
} else if (key.startsWith('/')) {
navigate(key)
if (isMobile) {
setMobileMenuVisible(false)
}
}
}
// 获取当前选中的菜单项
const getSelectedKey = () => {
return location.pathname
}
// 获取当前展开的菜单项
const getOpenKeys = () => {
const path = location.pathname
if (path.startsWith('/suppliers') ||
path.startsWith('/subcontractors') ||
path.startsWith('/customers')) {
return ['partners']
}
if (path.startsWith('/advances') ||
path.startsWith('/reimbursements') ||
path.startsWith('/payment-requests') ||
path.startsWith('/verification')) {
return ['finance-docs']
}
if (path.startsWith('/approval') || path.startsWith('/execution')) {
return ['approval']
}
if (path.startsWith('/products')) {
return ['procurement']
}
return []
}
return (
<Layout style={{ minHeight: '100vh' }}>
{/* 桌面端侧边栏 */}
{!isMobile && (
<Sider
trigger={null}
collapsible
collapsed={collapsed}
style={{
overflow: 'auto',
height: '100vh',
position: 'fixed',
left: 0,
top: 0,
bottom: 0,
background: colorBgContainer,
borderRight: '1px solid #f0f0f0'
}}
width={220}
collapsedWidth={80}
>
{/* Logo */}
<div style={{
height: 64,
display: 'flex',
alignItems: 'center',
justifyContent: collapsed ? 'center' : 'flex-start',
padding: collapsed ? 0 : '0 20px',
borderBottom: '1px solid #f0f0f0'
}}>
<CompanyLogo collapsed={collapsed} />
</div>
{/* 菜单 */}
<Menu
mode="inline"
selectedKeys={[getSelectedKey()]}
defaultOpenKeys={getOpenKeys()}
items={menuItems}
onClick={handleMenuClick}
style={{ borderRight: 0 }}
/>
{/* 折叠按钮 */}
<div style={{
position: 'absolute',
bottom: 0,
width: '100%',
padding: 16,
borderTop: '1px solid #f0f0f0'
}}>
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)}
style={{ width: '100%' }}
>
{!collapsed && '收起菜单'}
</Button>
</div>
</Sider>
)}
{/* 移动端抽屉菜单 */}
{isMobile && (
<Drawer
placement="left"
onClose={() => setMobileMenuVisible(false)}
open={mobileMenuVisible}
width={280}
styles={{ body: { padding: 0 } }}
>
<div style={{ height: 64, padding: '0 20px', display: 'flex', alignItems: 'center', borderBottom: '1px solid #f0f0f0' }}>
<CompanyLogo collapsed={false} />
</div>
<Menu
mode="inline"
selectedKeys={[getSelectedKey()]}
defaultOpenKeys={getOpenKeys()}
items={menuItems}
onClick={handleMenuClick}
style={{ borderRight: 0 }}
/>
</Drawer>
)}
<Layout style={{ marginLeft: isMobile ? 0 : (collapsed ? 80 : 220), transition: 'margin-left 0.2s' }}>
{/* 顶部导航 */}
<Header style={{
padding: '0 24px',
background: colorBgContainer,
position: 'sticky',
top: 0,
zIndex: 1,
borderBottom: '1px solid #f0f0f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
{/* 移动端菜单按钮 */}
{isMobile && (
<Button
type="text"
icon={<MenuFoldOutlined />}
onClick={() => setMobileMenuVisible(true)}
/>
)}
<div style={{ flex: 1 }} />
<Space size="middle">
<LanguageSelector />
<Dropdown menu={{ items: userMenuItems, onClick: handleMenuClick }} placement="bottomRight">
<Space style={{ cursor: 'pointer' }}>
<Avatar icon={<UserOutlined />} style={{ backgroundColor: '#1890ff' }} />
{!isMobile && <Text>{user?.name || user?.username || '用户'}</Text>}
</Space>
</Dropdown>
</Space>
</Header>
{/* 内容区域 */}
<Content style={{
margin: 0,
minHeight: 280,
background: '#f5f5f5'
}}>
<Outlet />
</Content>
</Layout>
{/* 设置弹窗 */}
<Modal
title="系统设置"
open={settingsVisible}
onCancel={() => setSettingsVisible(false)}
footer={null}
>
<p>...</p>
</Modal>
</Layout>
)
}
export default MainLayout
+27
View File
@@ -0,0 +1,27 @@
// API配置
export const API_CONFIG = {
baseURL: '/api',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
}
// API端点
export const API_ENDPOINTS = {
auth: {
login: '/auth/login',
logout: '/auth/logout',
me: '/auth/me',
},
products: '/products',
customers: '/customers',
suppliers: '/suppliers',
advances: '/advances',
reimbursements: '/reimbursements',
projects: '/projects',
paymentNodes: '/payment-nodes',
paymentRecords: '/payment-records',
exchangeRates: '/exchange-rates',
financeStats: '/finance-stats',
}
+45
View File
@@ -0,0 +1,45 @@
/* 公司财务系统 - 全局样式 */
:root {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color: #333;
background-color: #f0f2f5;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
overflow-x: hidden;
}
#root {
width: 100%;
min-height: 100vh;
}
/* 移动端适配 */
@media (max-width: 768px) {
body {
font-size: 14px;
}
.ant-layout {
min-height: 100vh;
}
.ant-menu {
font-size: 14px;
}
}
@@ -0,0 +1,117 @@
import React from 'react';
import { Outlet, Navigate, useLocation } from 'react-router-dom';
import { Layout, Menu } from 'antd';
import {
UserOutlined,
SafetyOutlined,
FileTextOutlined,
DatabaseOutlined,
InfoCircleOutlined,
ArrowLeftOutlined,
SettingOutlined
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
const { Sider, Content } = Layout;
const AdminLayout: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const menuItems = [
{
key: '/admin/users',
icon: <UserOutlined />,
label: '用户管理'
},
{
key: '/admin/roles',
icon: <SafetyOutlined />,
label: '角色权限'
},
{
key: '/admin/process',
icon: <SettingOutlined />,
label: '流程管理'
},
{
key: '/admin/logs',
icon: <FileTextOutlined />,
label: '系统日志'
},
{
key: '/admin/backup',
icon: <DatabaseOutlined />,
label: '数据备份'
},
{
key: '/admin/about',
icon: <InfoCircleOutlined />,
label: '关于系统'
}
];
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider
width={220}
theme="light"
style={{
borderRight: '1px solid #f0f0f0',
background: '#fff'
}}
>
<div style={{
height: 64,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderBottom: '1px solid #f0f0f0',
background: '#1890ff',
color: '#fff',
fontWeight: 'bold',
fontSize: 16
}}>
</div>
<Menu
mode="inline"
selectedKeys={[location.pathname]}
items={menuItems}
onClick={({ key }) => navigate(key)}
style={{ borderRight: 0 }}
/>
<div style={{
position: 'absolute',
bottom: 20,
width: '100%',
padding: '0 16px'
}}>
<div
onClick={() => navigate('/dashboard')}
style={{
cursor: 'pointer',
color: '#1890ff',
display: 'flex',
alignItems: 'center',
gap: 8
}}
>
<ArrowLeftOutlined />
</div>
</div>
</Sider>
<Layout>
<Content style={{
margin: 0,
background: '#f5f5f5',
minHeight: '100vh'
}}>
<Outlet />
</Content>
</Layout>
</Layout>
);
};
export default AdminLayout;
+69
View File
@@ -0,0 +1,69 @@
export default {
// Common
common: {
confirm: 'Confirm',
cancel: 'Cancel',
save: 'Save',
delete: 'Delete',
edit: 'Edit',
add: 'Add',
search: 'Search',
reset: 'Reset',
submit: 'Submit',
back: 'Back',
loading: 'Loading...',
success: 'Operation successful',
failed: 'Operation failed',
required: 'This field is required'
},
// Login
login: {
title: 'Qingyuan Power Laos ERP',
subtitle: 'Project Management and Finance Platform',
username: 'Username',
password: 'Password',
loginButton: 'Login',
usernamePlaceholder: 'Please enter username',
passwordPlaceholder: 'Please enter password',
usernameRequired: 'Please enter username',
passwordRequired: 'Please enter password',
usernameMin: 'Username must be at least 3 characters',
passwordMin: 'Password must be at least 6 characters',
loginFailed: 'Login failed, please try again',
testAccounts: 'Test Accounts',
techSupport: 'Technical Support: OpenClaw AI + React + Node.js',
selectLanguage: 'Select Language'
},
// Menu
menu: {
dashboard: 'Dashboard',
projects: 'Project Management',
advances: 'Advance Management',
reimbursements: 'Reimbursement Management',
finance: 'Finance Management',
reports: 'Reports',
settings: 'System Settings'
},
// User
user: {
profile: 'Profile',
settings: 'System Settings',
logout: 'Logout',
admin: 'System Administrator',
finance: 'Finance Specialist',
manager: 'Project Manager',
employee: 'Employee'
},
// Features
features: {
projectManage: 'Project Management: Create, track, and analyze project progress',
advanceManage: 'Advance Management: Application and approval process',
reimburseManage: 'Reimbursement Management: Expense claim process',
financeReport: 'Financial Reports: Project cost and profit analysis',
mobileSupport: 'Mobile Support: PWA technology, add to home screen'
}
}
+65
View File
@@ -0,0 +1,65 @@
import zhCN from 'antd/locale/zh_CN'
import thTH from 'antd/locale/th_TH'
import enUS from 'antd/locale/en_US'
export type LanguageCode = 'zh-CN' | 'th-TH' | 'lo-LA' | 'en-US'
export interface Language {
code: LanguageCode
name: string
nativeName: string
flag: string
antdLocale: any
}
export const languages: Language[] = [
{
code: 'zh-CN',
name: '中文简体',
nativeName: '中文简体',
flag: '🇨🇳',
antdLocale: zhCN
},
{
code: 'th-TH',
name: '泰语',
nativeName: 'ไทย',
flag: '🇹🇭',
antdLocale: thTH
},
{
code: 'lo-LA',
name: '老挝语',
nativeName: 'ລາວ',
flag: '🇱🇦',
antdLocale: enUS // Antd没有老挝语,用英语fallback
},
{
code: 'en-US',
name: '英语',
nativeName: 'English',
flag: '🇺🇸',
antdLocale: enUS
}
]
export const translations = {
'zh-CN': zhCNTranslation,
'th-TH': thTHTranslation,
'lo-LA': loLATranslation,
'en-US': enUSTranslation
}
export const getLanguage = (code: LanguageCode): Language => {
return languages.find(lang => lang.code === code) || languages[0]
}
export const getTranslation = (code: LanguageCode) => {
return translations[code] || translations['zh-CN']
}
// 导入翻译文件
import zhCNTranslation from './zh-CN'
import thTHTranslation from './th-TH'
import loLATranslation from './lo-LA'
import enUSTranslation from './en-US'
+69
View File
@@ -0,0 +1,69 @@
export default {
// ທົ່ວໄປ
common: {
confirm: 'ຢືນຢັນ',
cancel: 'ຍົກເລີກ',
save: 'ບັນທຶກ',
delete: 'ລຶບ',
edit: 'ແກ້ໄຂ',
add: 'ເພີ່ມ',
search: 'ຄົ້ນຫາ',
reset: 'ຣີເຊັດ',
submit: 'ສົ່ງ',
back: 'ກັບຄືນ',
loading: 'ກຳລັງໂຫລດ...',
success: 'ດຳເນີນການສຳເລັດ',
failed: 'ດຳເນີນການລົ້ມເຫລວ',
required: 'ຈຳເປັນຕ້ອງປ້ອນ'
},
// ໜ້າລັອກອິນ
login: {
title: 'Qingyuan Power Laos ERP',
subtitle: 'ແພລດຟອມຈັດການໂຄງການ ແລະ ການເງິນ',
username: 'ຊື່ຜູ້ໃຊ້',
password: 'ລະຫັດຜ່ານ',
loginButton: 'ເຂົ້າສູ່ລະບົບ',
usernamePlaceholder: 'ກະລຸນາປ້ອນຊື່ຜູ້ໃຊ້',
passwordPlaceholder: 'ກະລຸນາປ້ອນລະຫັດຜ່ານ',
usernameRequired: 'ກະລຸນາປ້ອນຊື່ຜູ້ໃຊ້',
passwordRequired: 'ກະລຸນາປ້ອນລະຫັດຜ່ານ',
usernameMin: 'ຊື່ຜູ້ໃຊ້ຕ້ອງມີຢ່າງໜ້ອຍ 3 ຕົວອັກສອນ',
passwordMin: 'ລະຫັດຜ່ານຕ້ອງມີຢ່າງໜ້ອຍ 6 ຕົວອັກສອນ',
loginFailed: 'ການເຂົ້າສູ່ລະບົບລົ້ມເຫລວ ກະລຸນາລອງອີກຄັ້ງ',
testAccounts: 'ບັນຊີທົດສອບ',
techSupport: 'ການສະໜັບສະໜູນເຕັກນິກ: OpenClaw AI + React + Node.js',
selectLanguage: 'ເລືອກພາສາ'
},
// ເມນູ
menu: {
dashboard: 'ແດຊບອດ',
projects: 'ຈັດການໂຄງການ',
advances: 'ຈັດການເງິນທືນ',
reimbursements: 'ຈັດການເບີກຈ່າຍ',
finance: 'ຈັດການການເງິນ',
reports: 'ລາຍງານ',
settings: 'ຕັ້ງຄ່າລະບົບ'
},
// ຜູ້ໃຊ້
user: {
profile: 'ຂໍ້ມູນສ່ວນຕົວ',
settings: 'ຕັ້ງຄ່າລະບົບ',
logout: 'ອອກຈາກລະບົບ',
admin: 'ຜູ້ບໍລິຫານລະບົບ',
finance: 'ເຈົ້າໜ້າທີ່ການເງິນ',
manager: 'ຜູ້ຈັດການໂຄງການ',
employee: 'ພະນັກງານ'
},
// ຄຸນສົມບັດລະບົບ
features: {
projectManage: 'ຈັດການໂຄງການ: ສ້າງ ຕິດຕາມ ແລະ ວິເຄາະຄວາມຄືບໜ້າ',
advanceManage: 'ຈັດການເງິນທືນ: ຂະບວນການຂໍ ແລະ ອະນຸມັດ',
reimburseManage: 'ຈັດການເບີກຈ່າຍ: ຂະບວນການເບີກຄ່າໃຊ້ຈ່າຍ',
financeReport: 'ລາຍງານການເງິນ: ວິເຄາະຕົ້ນທຶນ ແລະ ກຳໄລໂຄງການ',
mobileSupport: 'ຮອງຮັບມືຖື: ເຕັກໂນໂລຊີ PWA ສາມາດເພີ່ມໃສ່ໜ້າຈໍຫຼັກ'
}
}
+69
View File
@@ -0,0 +1,69 @@
export default {
// Common
common: {
confirm: 'ยืนยัน',
cancel: 'ยกเลิก',
save: 'บันทึก',
delete: 'ลบ',
edit: 'แก้ไข',
add: 'เพิ่ม',
search: 'ค้นหา',
reset: 'รีเซ็ต',
submit: 'ส่ง',
back: 'กลับ',
loading: 'กำลังโหลด...',
success: 'ดำเนินการสำเร็จ',
failed: 'ดำเนินการล้มเหลว',
required: 'จำเป็นต้องกรอก'
},
// Login
login: {
title: 'Qingyuan Power Laos ERP',
subtitle: 'แพลตฟอร์มการจัดการโครงการและการเงิน',
username: 'ชื่อผู้ใช้',
password: 'รหัสผ่าน',
loginButton: 'เข้าสู่ระบบ',
usernamePlaceholder: 'กรุณากรอกชื่อผู้ใช้',
passwordPlaceholder: 'กรุณากรอกรหัสผ่าน',
usernameRequired: 'กรุณากรอกชื่อผู้ใช้',
passwordRequired: 'กรุณากรอกรหัสผ่าน',
usernameMin: 'ชื่อผู้ใช้ต้องมีอย่างน้อย 3 ตัวอักษร',
passwordMin: 'รหัสผ่านต้องมีอย่างน้อย 6 ตัวอักษร',
loginFailed: 'การเข้าสู่ระบบล้มเหลว กรุณาลองอีกครั้ง',
testAccounts: 'บัญชีทดสอบ',
techSupport: 'การสนับสนุนด้านเทคนิค: OpenClaw AI + React + Node.js',
selectLanguage: 'เลือกภาษา'
},
// Menu
menu: {
dashboard: 'แดชบอร์ด',
projects: 'การจัดการโครงการ',
advances: 'การจัดการเงินทดรอง',
reimbursements: 'การจัดการเบิกเงิน',
finance: 'การจัดการการเงิน',
reports: 'รายงาน',
settings: 'การตั้งค่าระบบ'
},
// User
user: {
profile: 'ข้อมูลส่วนตัว',
settings: 'การตั้งค่าระบบ',
logout: 'ออกจากระบบ',
admin: 'ผู้ดูแลระบบ',
finance: 'เจ้าหน้าที่การเงิน',
manager: 'ผู้จัดการโครงการ',
employee: 'พนักงาน'
},
// Features
features: {
projectManage: 'การจัดการโครงการ: สร้าง ติดตาม และวิเคราะห์ความคืบหน้า',
advanceManage: 'การจัดการเงินทดรอง: กระบวนการขอและอนุมัติ',
reimburseManage: 'การจัดการเบิกเงิน: กระบวนการเบิกค่าใช้จ่าย',
financeReport: 'รายงานการเงิน: วิเคราะห์ต้นทุนและกำไรโครงการ',
mobileSupport: 'รองรับมือถือ: เทคโนโลยี PWA สามารถเพิ่มในหน้าจอหลัก'
}
}
+69
View File
@@ -0,0 +1,69 @@
export default {
// 通用
common: {
confirm: '确认',
cancel: '取消',
save: '保存',
delete: '删除',
edit: '编辑',
add: '添加',
search: '搜索',
reset: '重置',
submit: '提交',
back: '返回',
loading: '加载中...',
success: '操作成功',
failed: '操作失败',
required: '此项为必填'
},
// 登录页
login: {
title: '轻远电力老挝ERP',
subtitle: '项目管理与财务报销一体化平台',
username: '用户名',
password: '密码',
loginButton: '登录',
usernamePlaceholder: '请输入用户名',
passwordPlaceholder: '请输入密码',
usernameRequired: '请输入用户名',
passwordRequired: '请输入密码',
usernameMin: '用户名至少3个字符',
passwordMin: '密码至少6个字符',
loginFailed: '登录失败,请重试',
testAccounts: '测试账户',
techSupport: '技术支持:OpenClaw AI助手 + React + Node.js',
selectLanguage: '选择语言'
},
// 菜单
menu: {
dashboard: '仪表板',
projects: '项目管理',
advances: '预支管理',
reimbursements: '报销管理',
finance: '财务管理',
reports: '报表分析',
settings: '系统设置'
},
// 用户
user: {
profile: '个人资料',
settings: '系统设置',
logout: '退出登录',
admin: '系统管理员',
finance: '财务专员',
manager: '项目经理',
employee: '普通员工'
},
// 系统功能
features: {
projectManage: '项目管理:创建、跟踪、分析项目进度',
advanceManage: '预支管理:员工预支申请与审批流程',
reimburseManage: '报销管理:费用报销与核销流程',
financeReport: '财务报表:项目成本利润分析',
mobileSupport: '移动端支持:PWA技术,可添加到主屏幕'
}
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
@@ -0,0 +1,283 @@
import React, { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
Card, Descriptions, Tag, Spin, Empty, Row, Col, Statistic, Table, Button, Divider, Typography, Badge
} from 'antd'
import {
ArrowLeftOutlined, HomeOutlined, FileTextOutlined, DollarOutlined, UserOutlined, PhoneOutlined
} from '@ant-design/icons'
import axios from 'axios'
const { Title, Text } = Typography
interface Contact {
name: string
position: string
phone: string
is_primary?: boolean
}
interface Customer {
id: number
code: string
name: string
address: string
contacts: Contact[]
remark: string
total_contract_amount: number
total_received: number
total_receivable: number
created_at: string
}
interface Project {
id: number
project_code: string
name: string
contract_amount: string
status: string
customer_id: number
}
interface PaymentNode {
id: number
project_id: number
amount: number
paid_amount: number
}
interface Quotation {
id: number
version: number
quotation_date: string
amount: number
currency: string
status: string
file_url?: string
remark?: string
created_at: string
}
interface BudgetProject {
id: number
name: string
customer_id: number
customer_name: string
manager_id: number
manager_name: string
location?: string
survey_date?: string
intermediary?: string
intermediary_fee_type?: string
intermediary_fee_value?: number
customer_requirements?: string
project_overview?: string
attachments?: string[]
survey_photos?: string[]
status: string
days_in_status: number
created_at: string
quotations: Quotation[]
}
const CustomerDetail: React.FC = () => {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [customer, setCustomer] = useState<Customer | null>(null)
const [projects, setProjects] = useState<Project[]>([])
const [paymentNodes, setPaymentNodes] = useState<PaymentNode[]>([])
const [budgetProjects, setBudgetProjects] = useState<BudgetProject[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchCustomerDetail()
fetchRelatedProjects()
fetchRelatedBudgetProjects()
}, [id])
const fetchCustomerDetail = async () => {
try {
const res = await fetch(`/api/customers/${id}`)
const data = await res.json()
if (data.success) setCustomer(data.data)
} catch (error) {
console.error('获取客户详情失败:', error)
} finally {
setLoading(false)
}
}
const fetchRelatedProjects = async () => {
try {
// 获取所有项目,筛选关联到此客户的
const res = await fetch('/api/projects')
const data = await res.json()
if (data.success) {
const customerProjects = (data.data || []).filter((p: Project) => p.customer_id === parseInt(id))
setProjects(customerProjects)
// 获取所有付款节点
const nodesRes = await fetch('/api/payment-nodes')
const nodesData = await nodesRes.json()
if (nodesData.success) {
setPaymentNodes(nodesData.data || [])
}
}
} catch (error) {
console.error('获取项目失败:', error)
}
}
const fetchRelatedBudgetProjects = async () => {
try {
// 获取与当前客户关联的预算项目
const res = await axios.get('/api/budget-projects', {
params: { customer_id: id }
})
if (res.data.success) {
setBudgetProjects(res.data.data || [])
}
} catch (error) {
console.error('获取预算项目失败:', error)
}
}
if (loading) return <Spin style={{ display: 'flex', justifyContent: 'center', padding: 50 }} />
if (!customer) return <Empty description="客户不存在" style={{ marginTop: 100 }} />
// 计算财务数据
const totalContract = projects.reduce((sum, p) => sum + (parseFloat(p.contract_amount) || 0), 0)
// 从付款节点计算已收金额
const projectIds = projects.map(p => p.id)
const relatedNodes = paymentNodes.filter(n => projectIds.includes(n.project_id))
const totalReceived = relatedNodes.reduce((sum, n) => sum + (n.paid_amount || 0), 0)
const totalReceivable = relatedNodes.reduce((sum, n) => sum + ((n.amount || 0) - (n.paid_amount || 0)), 0)
const projectColumns = [
{ title: '项目编号', dataIndex: 'project_code', key: 'project_code', width: 120 },
{ title: '项目名称', dataIndex: 'name', key: 'name', render: (v: string) => <Text strong>{v}</Text> },
{ title: '合同金额', dataIndex: 'contract_amount', key: 'contract_amount', align: 'right' as const, render: (v: string) => `¥${(parseFloat(v) || 0).toLocaleString()}` },
{ title: '状态', dataIndex: 'status', key: 'status', align: 'center' as const, render: (v: string) => <Badge status={v === 'completed' ? 'success' : 'processing'} text={v === 'completed' ? '已完成' : v === 'planning' ? '规划中' : v === 'in_progress' ? '进行中' : v} /> }
]
const budgetProjectColumns = [
{ title: '项目名称', dataIndex: 'name', key: 'name', render: (v: string, record: BudgetProject) => (
<Text strong onClick={() => navigate(`/budget-projects/${record.id}`)} style={{ cursor: 'pointer', color: '#1890ff' }}>
{v}
</Text>
) },
{ title: '业务经理', dataIndex: 'manager_name', key: 'manager_name' },
{ title: '状态', dataIndex: 'status', key: 'status', align: 'center' as const, render: (v: string) => {
const statusMap: Record<string, { status: 'success' | 'processing' | 'error' | 'default'; text: string }> = {
negotiating: { status: 'processing', text: '商谈中' },
signed: { status: 'success', text: '已签约' },
unsigned: { status: 'error', text: '未签约' }
}
const config = statusMap[v] || { status: 'default', text: v }
return <Badge status={config.status} text={config.text} />
} },
{ title: '报价版本数', dataIndex: 'quotations', key: 'quotations', align: 'center' as const, render: (quotations: Quotation[]) => (quotations || []).length },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', render: (v: string) => v.split('T')[0] }
]
return (
<div style={{ padding: '16px', maxWidth: 1200, margin: '0 auto' }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/customers')} style={{ marginBottom: 16 }} type="text">
</Button>
<Title level={4} style={{ marginBottom: 24 }}>
<HomeOutlined style={{ marginRight: 8, color: '#52c41a' }} />
{customer.name}
</Title>
{/* ========== 卡片1:基本信息 ========== */}
<Card title={<><UserOutlined /> </>} style={{ marginBottom: 24, borderRadius: 8 }} headStyle={{ background: '#f5f5f5', fontWeight: 600 }}>
<Descriptions bordered column={{ xs: 1, sm: 2 }} size="small">
<Descriptions.Item label="编号">{customer.code}</Descriptions.Item>
<Descriptions.Item label="地址">{customer.address || '-'}</Descriptions.Item>
</Descriptions>
{customer.remark && (
<>
<Divider style={{ margin: '16px 0' }} />
<div><Text type="secondary"></Text><div style={{ marginTop: 8, padding: 12, background: '#fafafa', borderRadius: 4 }}>{customer.remark}</div></div>
</>
)}
<Divider style={{ margin: '16px 0' }} />
<div style={{ marginBottom: 8 }}><Text type="secondary"><PhoneOutlined style={{ marginRight: 4 }} /></Text></div>
<Row gutter={[16, 16]}>
{(customer.contacts || []).map((contact, i) => (
<Col key={i} xs={24} sm={12} lg={8}>
<Card size="small" style={{ borderLeft: contact.is_primary ? '3px solid #52c41a' : '3px solid #d9d9d9', background: contact.is_primary ? '#f6ffed' : '#fff' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Text strong>{contact.name || '未命名'}</Text>
{contact.is_primary && <Tag color="green" size="small"></Tag>}
</div>
<div style={{ color: '#666', fontSize: 13 }}>
{contact.position && <div>{contact.position}</div>}
{contact.phone && <div>{contact.phone}</div>}
</div>
</Card>
</Col>
))}
</Row>
{(customer.contacts || []).length === 0 && <Empty description="暂无联系人" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
</Card>
{/* ========== 卡片2:关联项目 ========== */}
<Card title={<><FileTextOutlined /> ({projects.length})</>} style={{ marginBottom: 24, borderRadius: 8 }} headStyle={{ background: '#f5f5f5', fontWeight: 600 }}>
{projects.length > 0 ? (
<Table columns={projectColumns} dataSource={projects} rowKey="id" size="small" pagination={false} bordered />
) : (
<Empty description="暂无关联项目(在项目管理中选择此客户后会自动显示)" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Card>
{/* ========== 卡片4:关联预算项目 ========== */}
<Card title={<><DollarOutlined /> ({budgetProjects.length})</>} style={{ marginBottom: 24, borderRadius: 8 }} headStyle={{ background: '#f5f5f5', fontWeight: 600 }}>
{budgetProjects.length > 0 ? (
<Table columns={budgetProjectColumns} dataSource={budgetProjects} rowKey="id" size="small" pagination={false} bordered />
) : (
<Empty description="暂无关联预算项目(在预算报价管理中选择此客户后会自动显示)" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Card>
{/* ========== 卡片3:财务信息 ========== */}
<Card title={<><DollarOutlined /> </>} style={{ borderRadius: 8 }} headStyle={{ background: '#f5f5f5', fontWeight: 600 }}>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#e6f7ff', border: '1px solid #91d5ff' }}>
<Statistic title="合同总金额" value={totalContract} prefix="¥" valueStyle={{ color: '#1890ff', fontSize: 20 }} />
</Card>
</Col>
<Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#f6ffed', border: '1px solid #b7eb8f' }}>
<Statistic title="已收总金额" value={totalReceived} prefix="¥" valueStyle={{ color: '#52c41a', fontSize: 20 }} />
</Card>
</Col>
<Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#fff2f0', border: '1px solid #ffccc7' }}>
<Statistic title="应收总金额" value={totalReceivable} prefix="¥" valueStyle={{ color: '#ff4d4f', fontSize: 20 }} />
</Card>
</Col>
<Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#fffbe6', border: '1px solid #ffe58f' }}>
<Statistic title="未结金额" value={totalReceivable} prefix="¥" valueStyle={{ color: '#faad14', fontSize: 20 }} />
</Card>
</Col>
</Row>
<Divider style={{ margin: '16px 0' }} />
<div style={{ marginBottom: 16 }}><Text type="secondary"></Text></div>
{projects.length > 0 ? (
<Table columns={projectColumns} dataSource={projects} rowKey="id" size="small" pagination={false} bordered />
) : (
<Empty description="暂无财务数据" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Card>
</div>
)
}
export default CustomerDetail
@@ -0,0 +1,207 @@
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Table, Button, Modal, Form, Input, message, Space, Tag, Card, Row, Col, Statistic } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, HomeOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
interface Contact {
name: string
position: string
phone: string
is_primary?: boolean
}
interface Customer {
id: number
code: string
name: string
address: string
contacts: Contact[]
remark: string
total_contract_amount: number
total_received: number
total_receivable: number
created_at: string
}
const CustomerPage: React.FC = () => {
const navigate = useNavigate()
const [customers, setCustomers] = useState<Customer[]>([])
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null)
const [searchText, setSearchText] = useState('')
const [form] = Form.useForm()
const fetchCustomers = async () => {
setLoading(true)
try {
const response = await fetch('/api/customers')
const data = await response.json()
if (data.success) setCustomers(data.data || [])
} catch (error) {
message.error('获取客户列表失败')
} finally {
setLoading(false)
}
}
useEffect(() => { fetchCustomers() }, [])
const stats = {
total: customers.length,
totalContract: customers.reduce((sum, c) => sum + (c.total_contract_amount || 0), 0),
totalReceivable: customers.reduce((sum, c) => sum + (c.total_receivable || 0), 0)
}
const getPrimaryContact = (contacts: Contact[]) => {
const primary = contacts?.find(c => c.is_primary)
return primary?.name || '-'
}
const columns: ColumnsType<Customer> = [
{ title: '编号', dataIndex: 'code', key: 'code', width: 120 },
{
title: '名称', dataIndex: 'name', key: 'name',
render: (text, record) => (
<Button type="link" style={{ padding: 0, fontWeight: 'bold' }} onClick={() => navigate(`/customers/${record.id}`)}>{text}</Button>
)
},
{ title: '地址', dataIndex: 'address', key: 'address', width: 150, render: (t) => t || '-' },
{ title: '主联系人', key: 'primary_contact', width: 100, render: (_, record) => getPrimaryContact(record.contacts || []) },
{ title: '合同金额', dataIndex: 'total_contract_amount', key: 'total_contract_amount', width: 100, render: (v) => `¥${(v || 0).toLocaleString()}` },
{ title: '应收金额', dataIndex: 'total_receivable', key: 'total_receivable', width: 100, render: (v) => <span style={{ color: v > 0 ? '#ff4d4f' : '#52c41a' }}>¥{(v || 0).toLocaleString()}</span> },
{ title: '操作', key: 'actions', width: 100, render: (_, record) => (
<Space>
<Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} size="small" />
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)} size="small" />
</Space>
)}
]
const filteredCustomers = customers.filter(c =>
c.code?.toLowerCase().includes(searchText.toLowerCase()) ||
c.name?.toLowerCase().includes(searchText.toLowerCase()) ||
c.address?.toLowerCase().includes(searchText.toLowerCase())
)
const handleContactChange = (index: number, field: string, value: any) => {
form.setFieldsValue({
contacts: form.getFieldValue('contacts').map((contact: any, i: number) => {
if (field === 'is_primary' && value) {
// 如果勾选了主联系人,取消其他联系人的主联系人选项
return i === index ? { ...contact, [field]: value } : { ...contact, is_primary: false }
}
return i === index ? { ...contact, [field]: value } : contact
})
})
}
const handleSubmit = async (values: any) => {
try {
let contacts = values.contacts || [{ name: '', position: '', phone: '', is_primary: true }]
const hasPrimary = contacts.some(c => c.is_primary)
if (!hasPrimary && contacts[0].name) contacts[0].is_primary = true
const url = editingCustomer ? `/api/customers/${editingCustomer.id}` : '/api/customers'
const method = editingCustomer ? 'PUT' : 'POST'
const response = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...values, contacts }) })
const data = await response.json()
if (data.success) {
message.success(editingCustomer ? '更新成功' : '创建成功')
setModalVisible(false)
form.resetFields()
setEditingCustomer(null)
fetchCustomers()
} else {
message.error(data.message || '操作失败')
}
} catch (error) {
message.error('操作失败')
}
}
const handleEdit = (customer: Customer) => {
setEditingCustomer(customer)
form.setFieldsValue({
name: customer.name, address: customer.address, remark: customer.remark,
contacts: customer.contacts?.length ? customer.contacts : [{ name: '', position: '', phone: '', is_primary: true }]
})
setModalVisible(true)
}
const handleDelete = async (id: number) => {
Modal.confirm({
title: '确认删除', content: '确定要删除此客户吗?', okText: '确定', cancelText: '取消',
onOk: async () => {
try {
const response = await fetch(`/api/customers/${id}`, { method: 'DELETE' })
const data = await response.json()
if (data.success) { message.success('删除成功'); fetchCustomers() }
else message.error(data.message || '删除失败')
} catch (error) { message.error('删除失败') }
}
})
}
const handleAdd = () => {
setEditingCustomer(null)
form.resetFields()
form.setFieldsValue({ contacts: [{ name: '', position: '', phone: '', is_primary: true }] })
setModalVisible(true)
}
return (
<div style={{ padding: 24 }}>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={8}><Card><Statistic title="客户总数" value={stats.total} prefix={<HomeOutlined />} /></Card></Col>
<Col span={8}><Card><Statistic title="合同总金额" value={stats.totalContract} prefix="¥" valueStyle={{ color: '#1890ff' }} /></Card></Col>
<Col span={8}><Card><Statistic title="应收总金额" value={stats.totalReceivable} prefix="¥" valueStyle={{ color: stats.totalReceivable > 0 ? '#ff4d4f' : '#52c41a' }} /></Card></Col>
</Row>
<Card style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Input placeholder="搜索客户编号、名称或地址" prefix={<SearchOutlined />} value={searchText} onChange={(e) => setSearchText(e.target.value)} allowClear style={{ width: 350 }} />
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}></Button>
</div>
</Card>
<Card>
<Table columns={columns} dataSource={filteredCustomers} rowKey="id" loading={loading} pagination={{ pageSize: 10, showTotal: (total) => `${total}` }} scroll={{ x: 900 }} />
</Card>
<Modal title={editingCustomer ? '编辑客户' : '新增客户'} open={modalVisible} onCancel={() => { setModalVisible(false); form.resetFields(); setEditingCustomer(null) }} onOk={() => form.submit()} width={700}>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}><Input placeholder="客户名称" /></Form.Item>
<Form.Item name="address" label="地址"><Input placeholder="客户地址" /></Form.Item>
<Form.Item name="remark" label="备注"><Input.TextArea rows={2} placeholder="备注信息" /></Form.Item>
<h4></h4>
<Form.List name="contacts" initialValue={[{ name: '', position: '', phone: '', is_primary: true }]}>
{(fields, { add, remove }) => (
<div>
{fields.map(({ key, name, ...restField }) => (
<div key={key} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<Form.Item {...restField} name={[name, 'name']} style={{ marginBottom: 0, flex: 1 }}><Input placeholder="姓名" /></Form.Item>
<Form.Item {...restField} name={[name, 'position']} style={{ marginBottom: 0, flex: 1 }}><Input placeholder="职位" /></Form.Item>
<Form.Item {...restField} name={[name, 'phone']} style={{ marginBottom: 0, flex: 1 }}><Input placeholder="电话" /></Form.Item>
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0 }}>
<input
type="checkbox"
onChange={(e) => handleContactChange(name, 'is_primary', e.target.checked)}
/>
</Form.Item>
{fields.length > 1 && <Button type="link" danger onClick={() => remove(name)}></Button>}
</div>
))}
<Button type="dashed" onClick={() => add()} style={{ width: '100%' }}>+ </Button>
</div>
)}
</Form.List>
</Form>
</Modal>
</div>
)
}
export default CustomerPage
@@ -0,0 +1,380 @@
import React, { useState, useEffect } from 'react';
import { Card, Row, Col, InputNumber, message, Typography, Divider, Spin, Button, Table, Space, Tag } from 'antd';
import { CheckOutlined, HistoryOutlined } from '@ant-design/icons';
import axios from 'axios';
import dayjs from 'dayjs';
const { Text, Title } = Typography;
const RATE_PAIRS = [
{ key: 'CNY_LAK', label: '中老汇率', from: 'CNY', to: 'LAK', fromLabel: '人民币', toLabel: '老挝基普' },
{ key: 'CNY_USD', label: '中美汇率', from: 'CNY', to: 'USD', fromLabel: '人民币', toLabel: '美元' },
{ key: 'CNY_THB', label: '中泰汇率', from: 'CNY', to: 'THB', fromLabel: '人民币', toLabel: '泰铢' },
{ key: 'USD_LAK', label: '美老汇率', from: 'USD', to: 'LAK', fromLabel: '美元', toLabel: '老挝基普' },
{ key: 'THB_LAK', label: '泰老汇率', from: 'THB', to: 'LAK', fromLabel: '泰铢', toLabel: '老挝基普' },
];
interface RateItem {
leftValue: number;
rightValue: number;
actualRate: number;
}
interface HistoryRate {
id: number;
pair_key: string;
rate: number;
effective_date: string;
created_at: string;
created_by_name?: string;
}
const ExchangeRatePage: React.FC = () => {
const [rates, setRates] = useState<Record<string, RateItem>>({});
const [initialRates, setInitialRates] = useState<Record<string, number>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [historyRates, setHistoryRates] = useState<HistoryRate[]>([]);
const [lastUpdateTime, setLastUpdateTime] = useState<string>('');
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
useEffect(() => {
fetchRates();
fetchHistory();
}, []);
const fetchRates = async () => {
setLoading(true);
try {
const res = await axios.get('/api/exchange-rates/latest');
if (res.data.success) {
const data = res.data.data;
const newRates: Record<string, RateItem> = {};
const newInitialRates: Record<string, number> = {};
RATE_PAIRS.forEach(pair => {
const rate = parseFloat(data[pair.key]) || 1;
newRates[pair.key] = { leftValue: 1, rightValue: rate, actualRate: rate };
newInitialRates[pair.key] = rate;
});
setRates(newRates);
setInitialRates(newInitialRates);
if (res.data.updated_at) {
setLastUpdateTime(res.data.updated_at);
}
}
} catch (error) {
message.error('获取汇率失败');
const defaultRates: Record<string, RateItem> = {};
const defaultInitialRates: Record<string, number> = {};
RATE_PAIRS.forEach(pair => {
const defaultRate = pair.key === 'CNY_LAK' ? 3000 : pair.key === 'CNY_USD' ? 0.14 : pair.key === 'CNY_THB' ? 4.5 : pair.key === 'USD_LAK' ? 21000 : 670;
defaultRates[pair.key] = { leftValue: 1, rightValue: defaultRate, actualRate: defaultRate };
defaultInitialRates[pair.key] = defaultRate;
});
setRates(defaultRates);
setInitialRates(defaultInitialRates);
} finally {
setLoading(false);
}
};
const fetchHistory = async () => {
try {
const res = await axios.get('/api/exchange-rates/history?limit=20');
if (res.data.success) {
setHistoryRates(res.data.data);
}
} catch (error) {
console.error('获取历史汇率失败:', error);
}
};
// 左侧输入 - 右侧自动变为1,重新计算汇率
const handleLeftChange = (key: string, value: number | null) => {
if (value === null || value <= 0) return;
const pair = RATE_PAIRS.find(p => p.key === key);
if (!pair) return;
// 当左侧输入值时,右侧变为1,计算新的汇率
const newRate = 1 / value;
setRates(prev => ({
...prev,
[key]: {
leftValue: value,
rightValue: 1,
actualRate: newRate
}
}));
};
// 右侧输入 - 左侧自动变为1,重新计算汇率
const handleRightChange = (key: string, value: number | null) => {
if (value === null || value <= 0) return;
const pair = RATE_PAIRS.find(p => p.key === key);
if (!pair) return;
// 当右侧输入值时,左侧变为1,计算新的汇率
const newRate = value;
setRates(prev => ({
...prev,
[key]: {
leftValue: 1,
rightValue: value,
actualRate: newRate
}
}));
};
// 计算实际汇率显示
const getActualRateDisplay = (key: string) => {
const item = rates[key];
if (!item) return '1 : 1.00';
const pair = RATE_PAIRS.find(p => p.key === key);
const actualRate = item.actualRate;
// 根据汇率对选择合适的小数位数
const decimalPlaces = pair?.key === 'CNY_USD' ? 5 : 2;
return `1 ${pair?.from} = ${actualRate.toFixed(decimalPlaces)} ${pair?.to}`;
};
// 确认保存
const handleConfirm = async () => {
setSaving(true);
try {
const savePromises = RATE_PAIRS.map(pair => {
const item = rates[pair.key];
if (!item) return null;
const actualRate = item.rightValue / item.leftValue;
const initialRate = initialRates[pair.key];
// 只保存有变化的汇率
if (Math.abs(actualRate - initialRate) < 0.0001) {
return null;
}
return axios.post('/api/exchange-rates', {
pair_key: pair.key,
rate: actualRate,
effective_date: dayjs().format('YYYY-MM-DD')
});
});
const validPromises = savePromises.filter(Boolean) as Promise<any>[];
if (validPromises.length === 0) {
message.info('没有汇率发生变化');
setSaving(false);
return;
}
await Promise.all(validPromises);
message.success('汇率保存成功');
setLastUpdateTime(dayjs().format('YYYY-MM-DD HH:mm:ss'));
fetchHistory();
// 更新初始汇率为当前汇率
const newInitialRates: Record<string, number> = {};
RATE_PAIRS.forEach(pair => {
const item = rates[pair.key];
if (item) {
newInitialRates[pair.key] = item.rightValue / item.leftValue;
}
});
setInitialRates(newInitialRates);
} catch (error) {
message.error('保存汇率失败');
} finally {
setSaving(false);
}
};
// 历史汇率表格列
const historyColumns = [
{
title: '汇率对',
dataIndex: 'from_currency',
key: 'from_currency',
render: (_: string, record: HistoryRate) => {
const pairKey = `${record.from_currency}_${record.to_currency}`;
const pair = RATE_PAIRS.find(p => p.key === pairKey);
return pair?.label || pairKey;
}
},
{
title: '汇率',
dataIndex: 'rate',
key: 'rate',
render: (rate: number, record: HistoryRate) => {
const pairKey = `${record.from_currency}_${record.to_currency}`;
const pair = RATE_PAIRS.find(p => p.key === pairKey);
return `1 ${record.from_currency} = ${parseFloat(rate).toFixed(pair?.key === 'CNY_USD' ? 4 : 2)} ${record.to_currency}`;
}
},
{
title: '生效日期',
dataIndex: 'effective_date',
key: 'effective_date',
render: (date: string) => dayjs(date).format('YYYY-MM-DD')
},
{
title: '设置时间',
dataIndex: 'created_at',
key: 'created_at',
render: (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm')
},
{
title: '设置人',
dataIndex: 'created_by_name',
key: 'created_by_name',
render: (name: string) => name || '-'
}
];
if (loading) {
return <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}><Spin size="large" /></div>;
}
return (
<div style={{ padding: isMobile ? 8 : 24 }}>
<div style={{ marginBottom: isMobile ? 12 : 24 }}>
<Title level={2} style={{ marginBottom: 8 }}></Title>
<Space>
<Text type="secondary"></Text>
{lastUpdateTime && (
<Tag color="blue">: {lastUpdateTime}</Tag>
)}
</Space>
</div>
<Row gutter={[16, 16]}>
{RATE_PAIRS.map(pair => {
const item = rates[pair.key];
if (!item) return null;
return (
<Col xs={24} sm={12} lg={8} key={pair.key}>
<Card title={pair.label} size="small" style={{ background: '#fafafa' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ marginBottom: 4, fontSize: 12, color: '#888' }}>{pair.fromLabel}</div>
<InputNumber
style={{
width: '100%',
borderColor: '#d9d9d9',
'&:hover': {
borderColor: '#1890ff',
},
'&:focus': {
borderColor: '#1890ff',
boxShadow: '0 0 0 2px rgba(24, 144, 255, 0.2)',
}
}}
value={item.leftValue}
onChange={(v) => handleLeftChange(pair.key, v)}
precision={6}
size="large"
min={0.000001}
onFocus={(e) => {
if (e.target && e.target.select) {
e.target.select();
}
}}
placeholder={`输入${pair.fromLabel}金额`}
/>
</div>
<div style={{ padding: '20px 8px 0', fontSize: 18, color: '#1890ff', fontWeight: 'bold' }}>=</div>
<div style={{ flex: 1 }}>
<div style={{ marginBottom: 4, fontSize: 12, color: '#888' }}>{pair.toLabel}</div>
<InputNumber
style={{
width: '100%',
borderColor: '#d9d9d9',
'&:hover': {
borderColor: '#1890ff',
},
'&:focus': {
borderColor: '#1890ff',
boxShadow: '0 0 0 2px rgba(24, 144, 255, 0.2)',
}
}}
value={item.rightValue}
onChange={(v) => handleRightChange(pair.key, v)}
precision={pair.key === 'CNY_USD' ? 4 : 2}
size="large"
min={0.000001}
onFocus={(e) => {
if (e.target && e.target.select) {
e.target.select();
}
}}
placeholder={`输入${pair.toLabel}金额`}
/>
</div>
</div>
<Divider style={{ margin: '12px 0' }} />
<div style={{ textAlign: 'center' }}>
<Text type="secondary" style={{ fontSize: 13 }}>
: {getActualRateDisplay(pair.key)}
</Text>
</div>
</Card>
</Col>
);
})}
</Row>
{/* 确认按钮 */}
<div style={{ marginTop: 24, textAlign: 'center' }}>
<Button
type="primary"
size="large"
icon={<CheckOutlined />}
onClick={handleConfirm}
loading={saving}
style={{ minWidth: 200 }}
>
</Button>
</div>
{/* 历史汇率表 */}
<Card
title={
<Space>
<HistoryOutlined />
<span></span>
</Space>
}
style={{ marginTop: 24 }}
>
<Table
dataSource={historyRates}
columns={historyColumns}
rowKey="id"
pagination={{ pageSize: 10 }}
size="small"
/>
</Card>
<Card style={{ marginTop: 16, background: '#fffbe6', borderColor: '#ffe58f' }}>
<Text type="warning">
1 = X右侧币种"确认保存汇率"
</Text>
</Card>
</div>
);
};
export default ExchangeRatePage;
@@ -0,0 +1,438 @@
import React, { useState } from 'react';
import {
Card, Typography, Button, Space, Tag, Table, List, Avatar,
Row, Col, Divider, Tabs, Progress, Badge, Rate, Timeline,
Statistic, Switch, Alert, Empty
} from 'antd';
import {
UserOutlined, StarOutlined, LikeOutlined, MessageOutlined,
EyeOutlined, HeartOutlined, ShoppingCartOutlined,
CalendarOutlined, ClockCircleOutlined, CheckCircleOutlined
} from '@ant-design/icons';
const { Title, Paragraph, Text } = Typography;
const { TabPane } = Tabs;
/**
* 布局样式预览页面
* 展示各种常见UI布局类型及其适用场景
*/
const LayoutShowcase: React.FC = () => {
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
// 模拟数据
const listData = [
{ id: 1, title: '项目A - 博纳斯线路改造', status: 'active', progress: 75, manager: '张三' },
{ id: 2, title: '项目B - 变压器安装工程', status: 'pending', progress: 0, manager: '李四' },
{ id: 3, title: '项目C - 电缆敷设施工', status: 'completed', progress: 100, manager: '王五' },
];
const tableColumns = [
{ title: '项目名称', dataIndex: 'title', key: 'title' },
{ title: '负责人', dataIndex: 'manager', key: 'manager' },
{ title: '进度', dataIndex: 'progress', key: 'progress', render: (v: number) => `${v}%` },
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (v: string) => {
const colors: Record<string, string> = { active: 'processing', pending: 'default', completed: 'success' };
const texts: Record<string, string> = { active: '进行中', pending: '待开始', completed: '已完成' };
return <Tag color={colors[v]}>{texts[v]}</Tag>;
}
},
];
// ============ 布局类型1: 卡片列表 ============
const CardListDemo = () => (
<div>
<Alert
message="卡片列表布局"
description="适用于:项目列表、任务列表、产品展示。特点:信息层次清晰、视觉分隔明确、适合移动端"
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
{listData.map(item => (
<Card
key={item.id}
style={{ marginBottom: 16, borderRadius: 12 }}
hoverable
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ flex: 1 }}>
<Text strong style={{ fontSize: 16 }}>{item.title}</Text>
<br />
<Text type="secondary">: {item.manager}</Text>
</div>
<Tag color={item.status === 'active' ? 'processing' : item.status === 'completed' ? 'success' : 'default'}>
{item.status === 'active' ? '进行中' : item.status === 'completed' ? '已完成' : '待开始'}
</Tag>
</div>
<Divider style={{ margin: '12px 0' }} />
<Progress percent={item.progress} showInfo={false} />
<div style={{ marginTop: 8, display: 'flex', gap: 8 }}>
<Button size="small" type="primary"></Button>
<Button size="small"></Button>
</div>
</Card>
))}
</div>
);
// ============ 布局类型2: 表格布局 ============
const TableDemo = () => (
<div>
<Alert
message="表格布局"
description="适用于:数据管理、批量操作、对比分析。特点:信息密集、支持排序筛选、适合桌面端大量数据"
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<Table
dataSource={listData}
columns={tableColumns}
rowKey="id"
pagination={false}
/>
</div>
);
// ============ 布局类型3: 网格卡片 ============
const GridCardDemo = () => (
<div>
<Alert
message="网格卡片布局"
description="适用于:仪表板、快捷入口、统计展示。特点:空间利用率高、视觉均衡、适合展示统计信息"
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} md={8} lg={6}>
<Card hoverable style={{ borderRadius: 12, textAlign: 'center' }}>
<Statistic title="进行中项目" value={12} suffix="个" />
<Progress percent={60} showInfo={false} style={{ marginTop: 8 }} />
</Card>
</Col>
<Col xs={24} sm={12} md={8} lg={6}>
<Card hoverable style={{ borderRadius: 12, textAlign: 'center' }}>
<Statistic title="待处理任务" value={5} suffix="项" valueStyle={{ color: '#cf1322' }} />
<Progress percent={25} showInfo={false} strokeColor="#cf1322" style={{ marginTop: 8 }} />
</Card>
</Col>
<Col xs={24} sm={12} md={8} lg={6}>
<Card hoverable style={{ borderRadius: 12, textAlign: 'center' }}>
<Statistic title="本月完成" value={28} suffix="个" valueStyle={{ color: '#3f8600' }} />
<Progress percent={85} showInfo={false} strokeColor="#3f8600" style={{ marginTop: 8 }} />
</Card>
</Col>
<Col xs={24} sm={12} md={8} lg={6}>
<Card hoverable style={{ borderRadius: 12, textAlign: 'center' }}>
<Statistic title="团队成员" value={8} suffix="人" />
<Progress percent={100} showInfo={false} style={{ marginTop: 8 }} />
</Card>
</Col>
</Row>
</div>
);
// ============ 布局类型4: 时间线布局 ============
const TimelineDemo = () => (
<div>
<Alert
message="时间线布局"
description="适用于:审批流程、施工进度、操作日志。特点:顺序清晰、时间节点明确、适合流程展示"
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<Timeline
items={[
{
color: 'green',
children: (
<>
<Text strong></Text>
<br />
<Text type="secondary">2026-03-01 - </Text>
</>
),
},
{
color: 'blue',
children: (
<>
<Text strong></Text>
<br />
<Text type="secondary">2026-03-05 - </Text>
</>
),
},
{
color: 'blue',
children: (
<>
<Text strong></Text>
<br />
<Text type="secondary">2026-03-10 - </Text>
</>
),
},
{
color: 'gray',
children: (
<>
<Text strong></Text>
<br />
<Text type="secondary"> 2026-04-01</Text>
</>
),
},
]}
/>
</div>
);
// ============ 布局类型5: 瀑布流/Feed布局 ============
const FeedDemo = () => (
<div>
<Alert
message="Feed流布局"
description="适用于:动态消息、施工日志、社交媒体风格。特点:沉浸式阅读、时间倒序、适合移动端滑动"
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<List
itemLayout="vertical"
dataSource={[
{
title: '今日施工进展',
description: '完成了3号杆塔的基础浇筑工作,混凝土养护中。',
author: '张三',
date: '今天 14:30',
avatar: '👨‍🔧',
},
{
title: '材料到货通知',
description: '电缆材料已到货,存放在仓库A区,请施工组负责人安排领取。',
author: '李四',
date: '今天 10:15',
avatar: '📦',
},
{
title: '安全检查完成',
description: '本周安全检查已完成,未发现重大隐患。',
author: '王五',
date: '昨天 16:00',
avatar: '✅',
},
]}
renderItem={(item: any) => (
<List.Item>
<Card style={{ width: '100%', marginBottom: 12, borderRadius: 12 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div style={{ fontSize: 32 }}>{item.avatar}</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Text strong>{item.author}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{item.date}</Text>
</div>
<Text strong style={{ fontSize: 15, display: 'block', marginTop: 4 }}>{item.title}</Text>
<Text type="secondary">{item.description}</Text>
<div style={{ marginTop: 12, display: 'flex', gap: 16 }}>
<Space>
<LikeOutlined />
</Space>
<Space>
<MessageOutlined />
</Space>
</div>
</div>
</div>
</Card>
</List.Item>
)}
/>
</div>
);
// ============ 布局类型6: 详情页布局 ============
const DetailDemo = () => (
<div>
<Alert
message="详情页布局"
description="适用于:项目详情、订单详情、用户档案。特点:信息分组明确、主次分明、适合深度阅读"
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<Card style={{ borderRadius: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Title level={4} style={{ margin: 0 }}></Title>
<Tag color="processing"></Tag>
</div>
<Row gutter={[24, 16]}>
<Col xs={24} sm={12} md={8}>
<Text type="secondary"></Text>
<br />
<Text strong>线</Text>
</Col>
<Col xs={24} sm={12} md={8}>
<Text type="secondary"></Text>
<br />
<Text strong></Text>
</Col>
<Col xs={24} sm={12} md={8}>
<Text type="secondary"></Text>
<br />
<Text strong></Text>
</Col>
<Col xs={24} sm={12} md={8}>
<Text type="secondary"></Text>
<br />
<Text strong>¥1,250,000</Text>
</Col>
<Col xs={24} sm={12} md={8}>
<Text type="secondary"></Text>
<br />
<Text strong>2026-03-01</Text>
</Col>
<Col xs={24} sm={12} md={8}>
<Text type="secondary"></Text>
<br />
<Text strong>2026-05-30</Text>
</Col>
</Row>
<Divider />
<Text type="secondary"></Text>
<Paragraph>
722kV高压线路改造1250kVA变压器安装工程
</Paragraph>
<Divider />
<div style={{ marginBottom: 8 }}>
<Text type="secondary"></Text>
</div>
<Progress percent={65} status="active" />
</Card>
</div>
);
// ============ 布局对比总结 ============
const ComparisonTable = () => (
<Card title="布局类型对比" style={{ marginTop: 24, borderRadius: 12 }}>
<Table
dataSource={[
{
key: '1',
layout: '卡片列表',
bestFor: '项目/任务列表',
mobile: '⭐⭐⭐⭐⭐',
desktop: '⭐⭐⭐⭐',
dataDensity: '中',
},
{
key: '2',
layout: '表格',
bestFor: '数据管理/分析',
mobile: '⭐⭐',
desktop: '⭐⭐⭐⭐⭐',
dataDensity: '高',
},
{
key: '3',
layout: '网格卡片',
bestFor: '仪表板/统计',
mobile: '⭐⭐⭐⭐',
desktop: '⭐⭐⭐⭐⭐',
dataDensity: '中',
},
{
key: '4',
layout: '时间线',
bestFor: '流程/进度',
mobile: '⭐⭐⭐⭐',
desktop: '⭐⭐⭐',
dataDensity: '低',
},
{
key: '5',
layout: 'Feed流',
bestFor: '动态/日志',
mobile: '⭐⭐⭐⭐⭐',
desktop: '⭐⭐⭐',
dataDensity: '低',
},
{
key: '6',
layout: '详情页',
bestFor: '深度信息',
mobile: '⭐⭐⭐',
desktop: '⭐⭐⭐⭐⭐',
dataDensity: '中',
},
]}
columns={[
{ title: '布局类型', dataIndex: 'layout', key: 'layout' },
{ title: '适用场景', dataIndex: 'bestFor', key: 'bestFor' },
{ title: '移动端', dataIndex: 'mobile', key: 'mobile' },
{ title: '桌面端', dataIndex: 'desktop', key: 'desktop' },
{ title: '数据密度', dataIndex: 'dataDensity', key: 'dataDensity' },
]}
pagination={false}
size="small"
/>
</Card>
);
return (
<div style={{ padding: isMobile ? 12 : 24, maxWidth: 1200, margin: '0 auto' }}>
<Title level={2}></Title>
<Paragraph type="secondary">
UI布局类型
</Paragraph>
<Divider />
<Tabs defaultActiveKey="1" tabPosition="top">
<TabPane tab="📋 卡片列表" key="1">
<CardListDemo />
</TabPane>
<TabPane tab="📊 表格布局" key="2">
<TableDemo />
</TabPane>
<TabPane tab="🔲 网格卡片" key="3">
<GridCardDemo />
</TabPane>
<TabPane tab="⏱️ 时间线" key="4">
<TimelineDemo />
</TabPane>
<TabPane tab="📝 Feed流" key="5">
<FeedDemo />
</TabPane>
<TabPane tab="📄 详情页" key="6">
<DetailDemo />
</TabPane>
</Tabs>
<ComparisonTable />
</div>
);
};
export default LayoutShowcase;
@@ -0,0 +1,367 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Card, Space, Tag, Modal, Form, Input, InputNumber, Select, DatePicker, message, Descriptions, Image, Divider } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined, UndoOutlined, PlusCircleOutlined, MinusCircleOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { useAuthStore } from '../store/authStore';
import FileUpload from '../components/FileUpload';
const { Option } = Select;
const { TextArea } = Input;
interface DetailItem {
id?: string;
description: string;
amount: number;
attachments?: string[];
}
const PaymentRequestsPage: React.FC = () => {
const { user } = useAuthStore();
const [requests, setRequests] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [selectedRecord, setSelectedRecord] = useState<any>(null);
const [form] = Form.useForm();
const [detailItems, setDetailItems] = useState<DetailItem[]>([]);
const [exchangeRates, setExchangeRates] = useState<Record<string, number>>({});
useEffect(() => {
fetchRequests();
}, []);
const fetchRequests = async () => {
setLoading(true);
try {
const res = await fetch('/api/payment-requests');
const data = await res.json();
if (data.success) {
// 解析JSON字符串字段
const parsedRequests = data.data.map((request: any) => ({
...request,
detail_items: request.detail_items ? JSON.parse(request.detail_items) : [],
attachments: request.attachments ? JSON.parse(request.attachments) : []
}));
setRequests(parsedRequests);
}
} catch (error) {
console.error('获取付款申请列表失败:', error);
message.error('获取付款申请列表失败');
} finally {
setLoading(false);
}
};
const handleCreate = () => {
setEditingId(null);
setDetailItems([]);
form.resetFields();
form.setFieldsValue({
payment_date: dayjs(),
currency: 'CNY',
applicant: user?.name || user?.username || '当前用户',
attachments: []
});
setModalVisible(true);
};
const handleEdit = (record: any) => {
setEditingId(record.id);
setDetailItems(record.detail_items || []);
form.setFieldsValue({
...record,
payment_date: record.payment_date ? dayjs(record.payment_date) : null,
attachments: record.attachments || []
});
setModalVisible(true);
};
const handleView = (record: any) => {
setSelectedRecord(record);
setDetailModalVisible(true);
};
const handleDelete = async (id: number) => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这条付款申请吗?',
onOk: async () => {
try {
await fetch('/api/payment-requests/' + id, { method: 'DELETE' });
message.success('删除成功');
fetchRequests();
} catch (error) {
message.error('删除失败');
}
}
});
};
const handleWithdraw = async (id: number) => {
Modal.confirm({
title: '确认撤回',
content: '撤回后可重新编辑提交,确认撤回吗?',
onOk: async () => {
try {
await fetch('/api/payment-requests/' + id + '/withdraw', { method: 'POST' });
message.success('已撤回,可重新编辑');
fetchRequests();
} catch (error) {
message.error('撤回失败');
}
}
});
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
const data = {
...values,
payment_date: values.payment_date?.format('YYYY-MM-DD'),
detail_items: detailItems,
amount: detailItems.reduce((sum, item) => sum + (item.amount || 0), 0),
applicant: user?.name || user?.username
};
const url = editingId ? '/api/payment-requests/' + editingId : '/api/payment-requests';
const method = editingId ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await res.json();
if (result.success) {
message.success(editingId ? '更新成功' : '创建成功');
setModalVisible(false);
fetchRequests();
} else {
message.error(result.error || '操作失败');
}
} catch (error) {
message.error('操作失败');
}
};
const addDetailItem = () => setDetailItems([...detailItems, { description: '', amount: 0, attachments: [] }]);
const updateDetailItem = (index: number, field: keyof DetailItem, value: any) => {
const newItems = [...detailItems];
newItems[index] = { ...newItems[index], [field]: value };
setDetailItems(newItems);
};
const convertToCNY = (amount: number, curr: string): number => {
if (curr === "CNY") return amount;
const rateKey = curr + "_CNY";
const rate = exchangeRates[rateKey] || 1;
return amount * rate;
};
const removeDetailItem = (index: number) => setDetailItems(detailItems.filter((_, i) => i !== index));
const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
pending: { color: 'processing', text: '待审批' },
approved: { color: 'success', text: '已批准' },
rejected: { color: 'error', text: '已退回' },
withdrawn: { color: 'default', text: '已撤回' },
paid: { color: 'blue', text: '已付款' },
};
const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>;
};
const formatAmount = (amount: number, currency: string = 'CNY') => {
const symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' };
return (symbols[currency] || '¥') + (amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
// Format number with thousand separator for input display
const formatNumberWithSeparator = (value: number | undefined, currency: string): string => {
if (value === undefined || value === null) return '';
const symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' };
const symbol = symbols[currency] || '¥';
return symbol + value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
// Parse formatted string back to number
const parseFormattedNumber = (value: string): number => {
// Remove currency symbols and thousand separators
const cleaned = value.replace(/[¥$₭฿,]/g, '');
return parseFloat(cleaned) || 0;
};
const columns = [
{ title: '事由', dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleView(r)}>{v}</a> },
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
{ title: '收款单位', dataIndex: 'payee', key: 'payee', width: 150, ellipsis: true },
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => (
<>
<div>{formatAmount(v, r.currency)}</div>
{r.currency !== 'CNY' && r.amount_cny && <div style={{ color: '#999', fontSize: 12 }}> ¥{r.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</div>}
</>
) },
{ title: '付款日期', dataIndex: 'payment_date', key: 'payment_date', width: 100 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (status: string) => getStatusTag(status) },
{ title: '编号', dataIndex: 'request_code', key: 'request_code', width: 120 },
{
title: '操作', key: 'action', width: 250,
render: (_: any, record: any) => (
<Space wrap>
<Button size="small" icon={<EyeOutlined />} onClick={() => handleView(record)}></Button>
{record.status === 'pending' && (
<>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}></Button>
<Button size="small" icon={<UndoOutlined />} onClick={() => handleWithdraw(record.id)}></Button>
</>
)}
{(record.status === 'rejected' || record.status === 'withdrawn') && (
<Button size="small" type="primary" icon={<EditOutlined />} onClick={() => handleEdit(record)}></Button>
)}
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}></Button>
</Space>
)
}
];
const currency = Form.useWatch('currency', form);
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}>
<h2 style={{ marginBottom: 8 }}></h2>
<p style={{ color: '#888', marginBottom: 0 }}></p>
</div>
<Card extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}></Button>}>
<Table dataSource={requests} columns={columns} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} size="middle" scroll={{ x: 1200 }} />
</Card>
<Modal title={editingId ? '编辑付款申请' : '新建付款申请'} open={modalVisible} onOk={handleSubmit} onCancel={() => setModalVisible(false)} width={900}>
<Form form={form} layout="vertical">
<Form.Item name="applicant" label="申请人">
<Input disabled style={{ color: 'rgba(0,0,0,0.85)', backgroundColor: '#f5f5f5' }} />
</Form.Item>
<Form.Item name="payment_date" label="付款日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="payee" label="收款单位" rules={[{ required: true }]}>
<Input placeholder="收款单位/供应商名称" />
</Form.Item>
<Form.Item name="bank_account" label="银行账号">
<Input placeholder="收款银行账号" />
</Form.Item>
<Form.Item name="bank_name" label="开户银行">
<Input placeholder="开户银行名称" />
</Form.Item>
<Form.Item name="currency" label="币种" rules={[{ required: true }]}>
<Select style={{ width: 200 }}>
<Option value="CNY"> (CNY)</Option>
<Option value="USD"> (USD)</Option>
<Option value="LAK"> (LAK)</Option>
<Option value="THB"> (THB)</Option>
</Select>
</Form.Item>
<Form.Item name="reason" label="付款事由" rules={[{ required: true }]}>
<TextArea rows={2} placeholder="付款原因" />
</Form.Item>
<Divider></Divider>
<div style={{ marginBottom: 16 }}>
<Button type="dashed" icon={<PlusCircleOutlined />} onClick={addDetailItem}></Button>
<span style={{ marginLeft: 16, color: '#888' }}>
: {formatAmount(detailItems.reduce((sum, item) => sum + (item.amount || 0), 0), currency)}
</span>
</div>
{detailItems.map((item, index) => (
<Card key={index} size="small" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'flex-start' }}>
<div style={{ flex: 1, minWidth: 200 }}>
<label style={{ display: 'block', marginBottom: 4, color: '#666' }}></label>
<Input value={item.description} onChange={(e) => updateDetailItem(index, 'description', e.target.value)} placeholder="款项说明" />
</div>
<div style={{ width: 150 }}>
<label style={{ display: 'block', marginBottom: 4, color: '#666' }}></label>
<InputNumber value={item.amount} onChange={(v) => updateDetailItem(index, 'amount', v)} min={0} precision={2} style={{ width: '100%' }} placeholder="金额" />
</div>
<div style={{ flex: 2, minWidth: 300 }}>
<label style={{ display: 'block', marginBottom: 4, color: '#666' }}></label>
<FileUpload value={item.attachments || []} onChange={(urls) => updateDetailItem(index, 'attachments', urls)} maxCount={3} accept="image/*" />
</div>
<Button type="text" danger icon={<MinusCircleOutlined />} onClick={() => removeDetailItem(index)} style={{ marginTop: 24 }} />
</div>
</Card>
))}
<Divider></Divider>
<Form.Item name="attachments" label="整体凭证附件">
<FileUpload maxCount={9} accept="image/*" />
</Form.Item>
</Form>
</Modal>
<Modal title="付款申请详情" open={detailModalVisible} onCancel={() => setDetailModalVisible(false)} footer={null} width={900}>
{selectedRecord && (
<>
<Descriptions bordered column={2} size="small">
<Descriptions.Item label="申请编号">{selectedRecord.request_code}</Descriptions.Item>
<Descriptions.Item label="状态">{getStatusTag(selectedRecord.status)}</Descriptions.Item>
<Descriptions.Item label="申请人">{selectedRecord.applicant}</Descriptions.Item>
<Descriptions.Item label="付款日期">{selectedRecord.payment_date}</Descriptions.Item>
<Descriptions.Item label="收款单位">{selectedRecord.payee}</Descriptions.Item>
<Descriptions.Item label="银行账号">{selectedRecord.bank_account}</Descriptions.Item>
<Descriptions.Item label="开户银行">{selectedRecord.bank_name}</Descriptions.Item>
<Descriptions.Item label="金额">
{formatAmount(selectedRecord.amount, selectedRecord.currency)}
{selectedRecord.currency !== 'CNY' && selectedRecord.amount_cny && (
<span style={{ color: '#999', marginLeft: 8 }}> ¥{selectedRecord.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span>
)}
</Descriptions.Item>
<Descriptions.Item label="付款事由" span={2}>{selectedRecord.reason}</Descriptions.Item>
</Descriptions>
{selectedRecord.detail_items && selectedRecord.detail_items.length > 0 && (
<>
<Divider></Divider>
<Table
dataSource={selectedRecord.detail_items}
rowKey="id"
size="small"
pagination={false}
columns={[
{ title: '款项说明', dataIndex: 'description', key: 'description' },
{ title: '金额', dataIndex: 'amount', key: 'amount', render: (v: number) => formatAmount(v, selectedRecord.currency) },
{ title: '附件', dataIndex: 'attachments', key: 'attachments', render: (v: string[]) => v?.length ? `${v.length}` : '-' }
]}
/>
</>
)}
{selectedRecord.attachments && selectedRecord.attachments.length > 0 && (
<>
<Divider></Divider>
<Image.PreviewGroup>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{selectedRecord.attachments.map((url: string, index: number) => (
<Image key={index} src={url} width={100} height={100} style={{ objectFit: 'cover', borderRadius: 4 }} />
))}
</div>
</Image.PreviewGroup>
</>
)}
</>
)}
</Modal>
</div>
);
};
export default PaymentRequestsPage;
@@ -0,0 +1,148 @@
import React from 'react';
import { Card, Typography, Button, Table, Tag, Space, Modal, Form, Input, Select, DatePicker, InputNumber, message, Row, Col, Statistic } from 'antd';
import { PlusOutlined, SearchOutlined, ShoppingOutlined } from '@ant-design/icons';
const { Title, Paragraph } = Typography;
const { RangePicker } = DatePicker;
const ProcurementPage: React.FC = () => {
const [loading, setLoading] = React.useState(false);
const [modalVisible, setModalVisible] = React.useState(false);
const [form] = Form.useForm();
const columns = [
{ title: '采购单号', dataIndex: 'code', key: 'code', width: 140 },
{ title: '采购日期', dataIndex: 'date', key: 'date', width: 120 },
{ title: '供应商', dataIndex: 'supplier', key: 'supplier' },
{ title: '物料名称', dataIndex: 'material', key: 'material' },
{ title: '数量', dataIndex: 'quantity', key: 'quantity', width: 80 },
{ title: '单价', dataIndex: 'unitPrice', key: 'unitPrice', width: 100, render: (v: number) => `¥${v}` },
{ title: '总金额', dataIndex: 'amount', key: 'amount', width: 120, render: (v: number) => `¥${v?.toLocaleString()}` },
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (v: string) => {
const colors: Record<string, string> = {
pending: 'default',
approved: 'processing',
received: 'success',
rejected: 'error'
};
const texts: Record<string, string> = {
pending: '待审批',
approved: '已批准',
received: '已入库',
rejected: '已拒绝'
};
return <Tag color={colors[v]}>{texts[v]}</Tag>;
}
},
{
title: '操作',
key: 'action',
width: 150,
render: () => (
<Space>
<Button size="small" type="link"></Button>
<Button size="small" type="link"></Button>
</Space>
)
}
];
const data = [
{ key: '1', code: 'PO20260318001', date: '2026-03-18', supplier: '老挝电力设备公司', material: '电缆 3x120', quantity: 1000, unitPrice: 45, amount: 45000, status: 'pending' },
{ key: '2', code: 'PO20260317002', date: '2026-03-17', supplier: '万象建材供应商', material: '钢管 DN50', quantity: 200, unitPrice: 120, amount: 24000, status: 'approved' },
{ key: '3', code: 'PO20260316003', date: '2026-03-16', supplier: '沙湾五金店', material: '螺栓 M12', quantity: 500, unitPrice: 5, amount: 2500, status: 'received' },
];
const handleSubmit = () => {
message.success('采购申请已提交');
setModalVisible(false);
};
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Title level={3} style={{ marginBottom: 0 }}></Title>
<Paragraph type="secondary"></Paragraph>
</div>
<Space>
<RangePicker placeholder={['开始日期', '结束日期']} />
<Input.Search placeholder="搜索采购单号" style={{ width: 200 }} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalVisible(true)}>
</Button>
</Space>
</div>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} md={6}>
<Card>
<Statistic title="待审批" value={5} prefix={<ShoppingOutlined />} />
</Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Card>
<Statistic title="已批准" value={12} valueStyle={{ color: '#1890ff' }} />
</Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Card>
<Statistic title="已入库" value={28} valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Card>
<Statistic title="本月采购额" value={156000} prefix="¥" />
</Card>
</Col>
</Row>
<Card>
<Table columns={columns} dataSource={data} loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1200 }} />
</Card>
<Modal
title="新建采购申请"
open={modalVisible}
onCancel={() => setModalVisible(false)}
onOk={handleSubmit}
width={600}
>
<Form form={form} layout="vertical">
<Form.Item label="供应商" name="supplier" rules={[{ required: true }]}>
<Select placeholder="选择供应商" options={[
{ value: 'supplier1', label: '老挝电力设备公司' },
{ value: 'supplier2', label: '万象建材供应商' },
{ value: 'supplier3', label: '沙湾五金店' }
]} />
</Form.Item>
<Form.Item label="物料名称" name="material" rules={[{ required: true }]}>
<Input placeholder="请输入物料名称" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="数量" name="quantity" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={1} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="单价" name="unitPrice" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={0} precision={2} prefix="¥" />
</Form.Item>
</Col>
</Row>
<Form.Item label="备注" name="remark">
<Input.TextArea rows={3} placeholder="请输入备注说明" />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default ProcurementPage;
File diff suppressed because it is too large Load Diff
+138
View File
@@ -0,0 +1,138 @@
import React from 'react';
import { Card, Typography, Button, Table, Tag, Space, Modal, Form, Input, Checkbox, message, Tree } from 'antd';
import { PlusOutlined, SearchOutlined, SafetyOutlined } from '@ant-design/icons';
const { Title, Paragraph } = Typography;
const RolesPage: React.FC = () => {
const [loading, setLoading] = React.useState(false);
const [modalVisible, setModalVisible] = React.useState(false);
const [form] = Form.useForm();
const permissionTree = [
{
title: '项目管理',
key: 'project',
children: [
{ title: '查看项目', key: 'project:view' },
{ title: '创建项目', key: 'project:create' },
{ title: '编辑项目', key: 'project:edit' },
{ title: '删除项目', key: 'project:delete' },
],
},
{
title: '财务管理',
key: 'finance',
children: [
{ title: '查看财务', key: 'finance:view' },
{ title: '预支审批', key: 'finance:advance' },
{ title: '报销审批', key: 'finance:reimburse' },
{ title: '付款审批', key: 'finance:payment' },
],
},
{
title: '采购管理',
key: 'procurement',
children: [
{ title: '查看采购', key: 'procurement:view' },
{ title: '创建采购', key: 'procurement:create' },
{ title: '审批采购', key: 'procurement:approve' },
],
},
{
title: '系统设置',
key: 'system',
children: [
{ title: '用户管理', key: 'system:users' },
{ title: '角色管理', key: 'system:roles' },
{ title: '系统配置', key: 'system:config' },
],
},
];
const columns = [
{ title: '角色ID', dataIndex: 'id', key: 'id', width: 100 },
{ title: '角色名称', dataIndex: 'name', key: 'name', width: 150 },
{ title: '角色描述', dataIndex: 'description', key: 'description' },
{
title: '权限数量',
dataIndex: 'permissionCount',
key: 'permissionCount',
width: 100,
render: (v: number) => <Tag color="blue">{v} </Tag>
},
{ title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', width: 150 },
{ title: '创建人', dataIndex: 'creator', key: 'creator', width: 120 },
{
title: '操作',
key: 'action',
width: 180,
render: () => (
<Space>
<Button size="small" type="link"></Button>
<Button size="small" type="link"></Button>
<Button size="small" type="link" danger></Button>
</Space>
)
}
];
const data = [
{ key: '1', id: 'R001', name: '超级管理员', description: '拥有系统所有权限', permissionCount: 50, createdAt: '2026-01-01', creator: '系统' },
{ key: '2', id: 'R002', name: '项目经理', description: '项目管理、施工管理权限', permissionCount: 25, createdAt: '2026-01-15', creator: 'admin' },
{ key: '3', id: 'R003', name: '财务经理', description: '财务管理、审批权限', permissionCount: 18, createdAt: '2026-02-01', creator: 'admin' },
{ key: '4', id: 'R004', name: '普通员工', description: '查看和申请权限', permissionCount: 10, createdAt: '2026-02-15', creator: 'admin' },
];
const handleSubmit = () => {
message.success('角色已创建');
setModalVisible(false);
};
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Title level={3} style={{ marginBottom: 0 }}></Title>
<Paragraph type="secondary"></Paragraph>
</div>
<Space>
<Input.Search placeholder="搜索角色" style={{ width: 200 }} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalVisible(true)}>
</Button>
</Space>
</div>
<Card>
<Table columns={columns} dataSource={data} loading={loading} pagination={{ pageSize: 10 }} />
</Card>
<Modal
title="新增角色"
open={modalVisible}
onCancel={() => setModalVisible(false)}
onOk={handleSubmit}
width={600}
>
<Form form={form} layout="vertical">
<Form.Item label="角色名称" name="name" rules={[{ required: true }]}>
<Input placeholder="请输入角色名称" prefix={<SafetyOutlined />} />
</Form.Item>
<Form.Item label="角色描述" name="description">
<Input placeholder="请输入角色描述" />
</Form.Item>
<Form.Item label="权限配置" name="permissions">
<Tree
checkable
defaultExpandedKeys={['project', 'finance']}
treeData={permissionTree}
/>
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default RolesPage;
@@ -0,0 +1,203 @@
import React, { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
Card, Descriptions, Tag, Spin, Empty, Row, Col, Statistic, Table, Button, Divider, Typography, Badge
} from 'antd'
import {
ArrowLeftOutlined, SolutionOutlined, FileTextOutlined, DollarOutlined, UserOutlined, PhoneOutlined
} from '@ant-design/icons'
const { Title, Text } = Typography
interface Contact {
name: string
position: string
phone: string
is_primary?: boolean
}
interface Subcontractor {
id: number
code: string
name: string
scope: string
features: string
country: string
contacts: Contact[]
remark: string
total_contract_amount: number
total_paid: number
total_payable: number
created_at: string
}
interface Project {
id: number
project_code: string
name: string
contract_amount: string
status: string
subcontractor_id: number
}
const SubcontractorDetail: React.FC = () => {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [subcontractor, setSubcontractor] = useState<Subcontractor | null>(null)
const [projects, setProjects] = useState<Project[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchSubcontractorDetail()
fetchRelatedProjects()
}, [id])
const fetchSubcontractorDetail = async () => {
try {
const res = await fetch(`/api/subcontractors/${id}`)
const data = await res.json()
if (data.success) setSubcontractor(data.data)
} catch (error) {
console.error('获取分包商详情失败:', error)
} finally {
setLoading(false)
}
}
const fetchRelatedProjects = async () => {
try {
// 获取所有项目,筛选关联到此分包商的
// 注意:需要后端在projects表中添加subcontractor_id字段
// 或者建立project_subcontractors关联表
const res = await fetch('/api/projects')
const data = await res.json()
if (data.success) {
// 暂时通过subcontractor_id筛选(后端需要添加此字段)
const subcontractorProjects = (data.data || []).filter((p: Project) => p.subcontractor_id === parseInt(id))
setProjects(subcontractorProjects)
}
} catch (error) {
console.error('获取项目失败:', error)
}
}
if (loading) return <Spin style={{ display: 'flex', justifyContent: 'center', padding: 50 }} />
if (!subcontractor) return <Empty description="分包商不存在" style={{ marginTop: 100 }} />
const totalContract = projects.reduce((sum, p) => sum + (parseFloat(p.contract_amount) || 0), 0)
const totalPaid = 0 // 从付款节点计算
const totalPayable = 0
const projectColumns = [
{ title: '项目编号', dataIndex: 'project_code', key: 'project_code', width: 120 },
{ title: '项目名称', dataIndex: 'name', key: 'name', render: (v: string) => <Text strong>{v}</Text> },
{ title: '合同金额', dataIndex: 'contract_amount', key: 'contract_amount', align: 'right' as const, render: (v: string) => `¥${(parseFloat(v) || 0).toLocaleString()}` },
{ title: '状态', dataIndex: 'status', key: 'status', align: 'center' as const, render: (v: string) => <Badge status={v === 'completed' ? 'success' : 'processing'} text={v === 'completed' ? '已完成' : v === 'planning' ? '规划中' : v === 'in_progress' ? '进行中' : v} /> }
]
return (
<div style={{ padding: '16px', maxWidth: 1200, margin: '0 auto' }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/subcontractors')} style={{ marginBottom: 16 }} type="text">
</Button>
<Title level={4} style={{ marginBottom: 24 }}>
<SolutionOutlined style={{ marginRight: 8, color: '#722ed1' }} />
{subcontractor.name}
</Title>
{/* ========== 卡片1:基本信息 ========== */}
<Card title={<><UserOutlined /> </>} style={{ marginBottom: 24, borderRadius: 8 }} headStyle={{ background: '#f5f5f5', fontWeight: 600 }}>
<Descriptions bordered column={{ xs: 1, sm: 2, md: 3 }} size="small">
<Descriptions.Item label="编号">{subcontractor.code}</Descriptions.Item>
<Descriptions.Item label="承包范围">{subcontractor.scope || '-'}</Descriptions.Item>
<Descriptions.Item label="国家"><Tag color="purple">{subcontractor.country || '-'}</Tag></Descriptions.Item>
</Descriptions>
{(subcontractor.features || subcontractor.remark) && (
<>
<Divider style={{ margin: '16px 0' }} />
<Row gutter={16}>
{subcontractor.features && (
<Col span={24}>
<div style={{ marginBottom: 8 }}><Text type="secondary"></Text></div>
<div style={{ padding: 12, background: '#f9f0ff', borderRadius: 4, border: '1px solid #d3adf7' }}>{subcontractor.features}</div>
</Col>
)}
</Row>
{subcontractor.remark && (
<>
<Divider style={{ margin: '16px 0' }} />
<div><Text type="secondary"></Text><div style={{ marginTop: 8, padding: 12, background: '#fafafa', borderRadius: 4 }}>{subcontractor.remark}</div></div>
</>
)}
</>
)}
<Divider style={{ margin: '16px 0' }} />
<div style={{ marginBottom: 8 }}><Text type="secondary"><PhoneOutlined style={{ marginRight: 4 }} /></Text></div>
<Row gutter={[16, 16]}>
{(subcontractor.contacts || []).map((contact, i) => (
<Col key={i} xs={24} sm={12} lg={8}>
<Card size="small" style={{ borderLeft: contact.is_primary ? '3px solid #722ed1' : '3px solid #d9d9d9', background: contact.is_primary ? '#f9f0ff' : '#fff' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Text strong>{contact.name || '未命名'}</Text>
{contact.is_primary && <Tag color="purple" size="small"></Tag>}
</div>
<div style={{ color: '#666', fontSize: 13 }}>
{contact.position && <div>{contact.position}</div>}
{contact.phone && <div>{contact.phone}</div>}
</div>
</Card>
</Col>
))}
</Row>
{(subcontractor.contacts || []).length === 0 && <Empty description="暂无联系人" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
</Card>
{/* ========== 卡片2:关联项目 ========== */}
<Card title={<><FileTextOutlined /> ({projects.length})</>} style={{ marginBottom: 24, borderRadius: 8 }} headStyle={{ background: '#f5f5f5', fontWeight: 600 }}>
{projects.length > 0 ? (
<Table columns={projectColumns} dataSource={projects} rowKey="id" size="small" pagination={false} bordered />
) : (
<Empty description="暂无关联项目(在项目管理中选择此分包商后会自动显示)" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Card>
{/* ========== 卡片3:财务信息 ========== */}
<Card title={<><DollarOutlined /> </>} style={{ borderRadius: 8 }} headStyle={{ background: '#f5f5f5', fontWeight: 600 }}>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#e6f7ff', border: '1px solid #91d5ff' }}>
<Statistic title="合同总金额" value={totalContract} prefix="¥" valueStyle={{ color: '#1890ff', fontSize: 20 }} />
</Card>
</Col>
<Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#f6ffed', border: '1px solid #b7eb8f' }}>
<Statistic title="已付总金额" value={totalPaid} prefix="¥" valueStyle={{ color: '#52c41a', fontSize: 20 }} />
</Card>
</Col>
<Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#fff2f0', border: '1px solid #ffccc7' }}>
<Statistic title="应付总金额" value={totalPayable} prefix="¥" valueStyle={{ color: '#ff4d4f', fontSize: 20 }} />
</Card>
</Col>
<Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#fffbe6', border: '1px solid #ffe58f' }}>
<Statistic title="未结金额" value={totalPayable} prefix="¥" valueStyle={{ color: '#faad14', fontSize: 20 }} />
</Card>
</Col>
</Row>
<Divider style={{ margin: '16px 0' }} />
<div style={{ marginBottom: 16 }}><Text type="secondary"></Text></div>
{projects.length > 0 ? (
<Table columns={projectColumns} dataSource={projects} rowKey="id" size="small" pagination={false} bordered />
) : (
<Empty description="暂无财务数据" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Card>
</div>
)
}
export default SubcontractorDetail
@@ -0,0 +1,223 @@
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Table, Button, Modal, Form, Input, Select, message, Space, Tag, Card, Row, Col, Statistic } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, SolutionOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
interface Contact {
name: string
position: string
phone: string
is_primary?: boolean
}
interface Subcontractor {
id: number
code: string
name: string
scope: string
features: string
country: string
contacts: Contact[]
remark: string
total_contract_amount: number
total_paid: number
total_payable: number
created_at: string
}
const SubcontractorPage: React.FC = () => {
const navigate = useNavigate()
const [subcontractors, setSubcontractors] = useState<Subcontractor[]>([])
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
const [editingSubcontractor, setEditingSubcontractor] = useState<Subcontractor | null>(null)
const [searchText, setSearchText] = useState('')
const [form] = Form.useForm()
const fetchSubcontractors = async () => {
setLoading(true)
try {
const response = await fetch('/api/subcontractors')
const data = await response.json()
if (data.success) setSubcontractors(data.data || [])
} catch (error) {
message.error('获取分包商列表失败')
} finally {
setLoading(false)
}
}
useEffect(() => { fetchSubcontractors() }, [])
const stats = {
total: subcontractors.length,
totalContract: subcontractors.reduce((sum, s) => sum + (s.total_contract_amount || 0), 0),
totalPayable: subcontractors.reduce((sum, s) => sum + (s.total_payable || 0), 0)
}
const getPrimaryContact = (contacts: Contact[]) => {
const primary = contacts?.find(c => c.is_primary)
return primary?.name || '-'
}
const columns: ColumnsType<Subcontractor> = [
{ title: '编号', dataIndex: 'code', key: 'code', width: 120 },
{
title: '名称', dataIndex: 'name', key: 'name',
render: (text, record) => (
<Button type="link" style={{ padding: 0, fontWeight: 'bold' }} onClick={() => navigate(`/subcontractors/${record.id}`)}>{text}</Button>
)
},
{ title: '承包范围', dataIndex: 'scope', key: 'scope', width: 120 },
{ title: '主联系人', key: 'primary_contact', width: 100, render: (_, record) => getPrimaryContact(record.contacts || []) },
{ title: '国家', dataIndex: 'country', key: 'country', width: 80, render: (c) => <Tag>{c || '-'}</Tag> },
{ title: '合同金额', dataIndex: 'total_contract_amount', key: 'total_contract_amount', width: 100, render: (v) => `¥${(v || 0).toLocaleString()}` },
{ title: '应付金额', dataIndex: 'total_payable', key: 'total_payable', width: 100, render: (v) => <span style={{ color: v > 0 ? '#ff4d4f' : '#52c41a' }}>¥{(v || 0).toLocaleString()}</span> },
{ title: '操作', key: 'actions', width: 100, render: (_, record) => (
<Space>
<Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} size="small" />
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)} size="small" />
</Space>
)}
]
const filteredSubcontractors = subcontractors.filter(s =>
s.code?.toLowerCase().includes(searchText.toLowerCase()) ||
s.name?.toLowerCase().includes(searchText.toLowerCase()) ||
s.scope?.toLowerCase().includes(searchText.toLowerCase())
)
const handleContactChange = (index: number, field: string, value: any) => {
form.setFieldsValue({
contacts: form.getFieldValue('contacts').map((contact: any, i: number) => {
if (field === 'is_primary' && value) {
// 如果勾选了主联系人,取消其他联系人的主联系人选项
return i === index ? { ...contact, [field]: value } : { ...contact, is_primary: false }
}
return i === index ? { ...contact, [field]: value } : contact
})
})
}
const handleSubmit = async (values: any) => {
try {
let contacts = values.contacts || [{ name: '', position: '', phone: '', is_primary: true }]
const hasPrimary = contacts.some(c => c.is_primary)
if (!hasPrimary && contacts[0].name) contacts[0].is_primary = true
const url = editingSubcontractor ? `/api/subcontractors/${editingSubcontractor.id}` : '/api/subcontractors'
const method = editingSubcontractor ? 'PUT' : 'POST'
const response = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...values, contacts }) })
const data = await response.json()
if (data.success) {
message.success(editingSubcontractor ? '更新成功' : '创建成功')
setModalVisible(false)
form.resetFields()
setEditingSubcontractor(null)
fetchSubcontractors()
} else {
message.error(data.message || '操作失败')
}
} catch (error) {
message.error('操作失败')
}
}
const handleEdit = (subcontractor: Subcontractor) => {
setEditingSubcontractor(subcontractor)
form.setFieldsValue({
name: subcontractor.name, scope: subcontractor.scope, features: subcontractor.features, country: subcontractor.country, remark: subcontractor.remark,
contacts: subcontractor.contacts?.length ? subcontractor.contacts : [{ name: '', position: '', phone: '', is_primary: true }]
})
setModalVisible(true)
}
const handleDelete = async (id: number) => {
Modal.confirm({
title: '确认删除', content: '确定要删除此分包商吗?', okText: '确定', cancelText: '取消',
onOk: async () => {
try {
const response = await fetch(`/api/subcontractors/${id}`, { method: 'DELETE' })
const data = await response.json()
if (data.success) { message.success('删除成功'); fetchSubcontractors() }
else message.error(data.message || '删除失败')
} catch (error) { message.error('删除失败') }
}
})
}
const handleAdd = () => {
setEditingSubcontractor(null)
form.resetFields()
form.setFieldsValue({ country: 'Laos', contacts: [{ name: '', position: '', phone: '', is_primary: true }] })
setModalVisible(true)
}
return (
<div style={{ padding: 24 }}>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={8}><Card><Statistic title="分包商总数" value={stats.total} prefix={<SolutionOutlined />} /></Card></Col>
<Col span={8}><Card><Statistic title="合同总金额" value={stats.totalContract} prefix="¥" valueStyle={{ color: '#1890ff' }} /></Card></Col>
<Col span={8}><Card><Statistic title="应付总金额" value={stats.totalPayable} prefix="¥" valueStyle={{ color: stats.totalPayable > 0 ? '#ff4d4f' : '#52c41a' }} /></Card></Col>
</Row>
<Card style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Input placeholder="搜索分包商编号、名称或承包范围" prefix={<SearchOutlined />} value={searchText} onChange={(e) => setSearchText(e.target.value)} allowClear style={{ width: 350 }} />
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}></Button>
</div>
</Card>
<Card>
<Table columns={columns} dataSource={filteredSubcontractors} rowKey="id" loading={loading} pagination={{ pageSize: 10, showTotal: (total) => `${total}` }} scroll={{ x: 900 }} />
</Card>
<Modal title={editingSubcontractor ? '编辑分包商' : '新增分包商'} open={modalVisible} onCancel={() => { setModalVisible(false); form.resetFields(); setEditingSubcontractor(null) }} onOk={() => form.submit()} width={700}>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}><Input placeholder="分包商名称" /></Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="scope" label="承包范围"><Input placeholder="手填:如电力安装、土建工程" /></Form.Item>
</Col>
<Col span={12}>
<Form.Item name="country" label="国家" initialValue="Laos">
<Select>
<Select.Option value="China"></Select.Option>
<Select.Option value="Laos"></Select.Option>
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item name="features" label="特点"><Input.TextArea rows={2} placeholder="手填:如专业团队、设备齐全、价格合理等" /></Form.Item>
<Form.Item name="remark" label="备注"><Input.TextArea rows={2} placeholder="备注信息" /></Form.Item>
<h4></h4>
<Form.List name="contacts" initialValue={[{ name: '', position: '', phone: '', is_primary: true }]}>
{(fields, { add, remove }) => (
<div>
{fields.map(({ key, name, ...restField }) => (
<div key={key} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<Form.Item {...restField} name={[name, 'name']} style={{ marginBottom: 0, flex: 1 }}><Input placeholder="姓名" /></Form.Item>
<Form.Item {...restField} name={[name, 'position']} style={{ marginBottom: 0, flex: 1 }}><Input placeholder="职位" /></Form.Item>
<Form.Item {...restField} name={[name, 'phone']} style={{ marginBottom: 0, flex: 1 }}><Input placeholder="电话" /></Form.Item>
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0 }}>
<input
type="checkbox"
onChange={(e) => handleContactChange(name, 'is_primary', e.target.checked)}
/>
</Form.Item>
{fields.length > 1 && <Button type="link" danger onClick={() => remove(name)}></Button>}
</div>
))}
<Button type="dashed" onClick={() => add()} style={{ width: '100%' }}>+ </Button>
</div>
)}
</Form.List>
</Form>
</Modal>
</div>
)
}
export default SubcontractorPage
@@ -0,0 +1,190 @@
import React, { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
Card, Descriptions, Tag, Spin, Empty, Row, Col, Statistic, Table, Button, Divider, Typography, Badge
} from 'antd'
import {
ArrowLeftOutlined, ShopOutlined, FileTextOutlined, DollarOutlined, UserOutlined, PhoneOutlined
} from '@ant-design/icons'
const { Title, Text } = Typography
interface Contact {
name: string
position: string
phone: string
is_primary?: boolean
}
interface Supplier {
id: number
code: string
name: string
supply_category: string
country: string
contacts: Contact[]
remark: string
total_purchase_amount: number
total_paid: number
total_payable: number
created_at: string
}
interface Project {
id: number
project_code: string
name: string
contract_amount: number
status: string
customer_id: number
supplier_id: number
}
const SupplierDetail: React.FC = () => {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [supplier, setSupplier] = useState<Supplier | null>(null)
const [projects, setProjects] = useState<Project[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchSupplierDetail()
fetchRelatedProjects()
}, [id])
const fetchSupplierDetail = async () => {
try {
const res = await fetch(`/api/suppliers/${id}`)
const data = await res.json()
if (data.success) setSupplier(data.data)
} catch (error) {
console.error('获取供应商详情失败:', error)
} finally {
setLoading(false)
}
}
const fetchRelatedProjects = async () => {
try {
// 获取所有项目,筛选关联到此供应商的
const res = await fetch('/api/projects')
const data = await res.json()
if (data.success) {
// 供应商暂无supplier_id关联,先显示空
// 后续可以在项目中添加供应商关联字段
const supplierProjects = (data.data || []).filter((p: Project) => p.supplier_id === parseInt(id))
setProjects(supplierProjects)
}
} catch (error) {
console.error('获取项目失败:', error)
}
}
if (loading) return <Spin style={{ display: 'flex', justifyContent: 'center', padding: 50 }} />
if (!supplier) return <Empty description="供应商不存在" style={{ marginTop: 100 }} />
const totalContract = projects.reduce((sum, p) => sum + (parseFloat(p.contract_amount) || 0), 0)
// 供应商暂无已付/应付数据,暂时显示0
const totalPaid = 0
const totalPayable = 0
const projectColumns = [
{ title: '项目编号', dataIndex: 'project_code', key: 'project_code', width: 120 },
{ title: '项目名称', dataIndex: 'name', key: 'name', render: (v: string) => <Text strong>{v}</Text> },
{ title: '合同金额', dataIndex: 'contract_amount', key: 'contract_amount', align: 'right' as const, render: (v: string) => `¥${(parseFloat(v) || 0).toLocaleString()}` },
{ title: '状态', dataIndex: 'status', key: 'status', align: 'center' as const, render: (v: string) => <Badge status={v === 'completed' ? 'success' : 'processing'} text={v === 'completed' ? '已完成' : v === 'planning' ? '规划中' : v === 'in_progress' ? '进行中' : v} /> }
]
return (
<div style={{ padding: '16px', maxWidth: 1200, margin: '0 auto' }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/suppliers')} style={{ marginBottom: 16 }} type="text">
</Button>
<Title level={4} style={{ marginBottom: 24 }}>
<ShopOutlined style={{ marginRight: 8, color: '#1890ff' }} />
{supplier.name}
</Title>
{/* ========== 卡片1:基本信息 ========== */}
<Card title={<><UserOutlined /> </>} style={{ marginBottom: 24, borderRadius: 8 }} headStyle={{ background: '#f5f5f5', fontWeight: 600 }}>
<Descriptions bordered column={{ xs: 1, sm: 2, md: 3 }} size="small">
<Descriptions.Item label="编号">{supplier.code}</Descriptions.Item>
<Descriptions.Item label="供应类别">{supplier.supply_category || '-'}</Descriptions.Item>
<Descriptions.Item label="国家"><Tag color="blue">{supplier.country || '-'}</Tag></Descriptions.Item>
</Descriptions>
{supplier.remark && (
<>
<Divider style={{ margin: '16px 0' }} />
<div><Text type="secondary"></Text><div style={{ marginTop: 8, padding: 12, background: '#fafafa', borderRadius: 4 }}>{supplier.remark}</div></div>
</>
)}
<Divider style={{ margin: '16px 0' }} />
<div style={{ marginBottom: 8 }}><Text type="secondary"><PhoneOutlined style={{ marginRight: 4 }} /></Text></div>
<Row gutter={[16, 16]}>
{(supplier.contacts || []).map((contact, i) => (
<Col key={i} xs={24} sm={12} lg={8}>
<Card size="small" style={{ borderLeft: contact.is_primary ? '3px solid #1890ff' : '3px solid #d9d9d9', background: contact.is_primary ? '#f0f5ff' : '#fff' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Text strong>{contact.name || '未命名'}</Text>
{contact.is_primary && <Tag color="blue" size="small"></Tag>}
</div>
<div style={{ color: '#666', fontSize: 13 }}>
{contact.position && <div>{contact.position}</div>}
{contact.phone && <div>{contact.phone}</div>}
</div>
</Card>
</Col>
))}
</Row>
{(supplier.contacts || []).length === 0 && <Empty description="暂无联系人" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
</Card>
{/* ========== 卡片2:关联项目 ========== */}
<Card title={<><FileTextOutlined /> </>} style={{ marginBottom: 24, borderRadius: 8 }} headStyle={{ background: '#f5f5f5', fontWeight: 600 }}>
{projects.length > 0 ? (
<Table columns={projectColumns} dataSource={projects} rowKey="id" size="small" pagination={false} bordered />
) : (
<Empty description="暂无关联项目(在项目管理中添加供应商关联后会自动显示)" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Card>
{/* ========== 卡片3:财务信息 ========== */}
<Card title={<><DollarOutlined /> </>} style={{ borderRadius: 8 }} headStyle={{ background: '#f5f5f5', fontWeight: 600 }}>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#e6f7ff', border: '1px solid #91d5ff' }}>
<Statistic title="合同总金额" value={totalContract} prefix="¥" valueStyle={{ color: '#1890ff', fontSize: 20 }} />
</Card>
</Col>
<Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#f6ffed', border: '1px solid #b7eb8f' }}>
<Statistic title="已付总金额" value={totalPaid} prefix="¥" valueStyle={{ color: '#52c41a', fontSize: 20 }} />
</Card>
</Col>
<Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#fff2f0', border: '1px solid #ffccc7' }}>
<Statistic title="应付总金额" value={totalPayable} prefix="¥" valueStyle={{ color: '#ff4d4f', fontSize: 20 }} />
</Card>
</Col>
<Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#fffbe6', border: '1px solid #ffe58f' }}>
<Statistic title="未结金额" value={totalPayable} prefix="¥" valueStyle={{ color: '#faad14', fontSize: 20 }} />
</Card>
</Col>
</Row>
<Divider style={{ margin: '16px 0' }} />
<div style={{ marginBottom: 16 }}><Text type="secondary"></Text></div>
{projects.length > 0 ? (
<Table columns={projectColumns} dataSource={projects} rowKey="id" size="small" pagination={false} bordered />
) : (
<Empty description="暂无财务数据" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Card>
</div>
)
}
export default SupplierDetail
@@ -0,0 +1,313 @@
import React, { useState, useEffect } from 'react'
import { Table, Button, Modal, Form, Input, Select, message, Space, Tag, Card, Row, Col, Statistic } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ShopOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
interface Supplier {
id: number
code: string
name: string
type: string
country: string
total_purchase_amount: number
total_paid: number
total_payable: number
rating: number
created_at: string
}
const SupplierPage: React.FC = () => {
const [suppliers, setSuppliers] = useState<Supplier[]>([])
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
const [editingSupplier, setEditingSupplier] = useState<Supplier | null>(null)
const [searchText, setSearchText] = useState('')
const [form] = Form.useForm()
// 统计数据
const stats = {
total: suppliers.length,
totalPurchase: suppliers.reduce((sum, s) => sum + s.total_purchase_amount, 0),
totalPayable: suppliers.reduce((sum, s) => sum + s.total_payable, 0),
avgRating: suppliers.length > 0
? suppliers.reduce((sum, s) => sum + s.rating, 0) / suppliers.length
: 0
}
// 获取供应商列表
const fetchSuppliers = async () => {
setLoading(true)
try {
const response = await fetch('/api/suppliers')
const data = await response.json()
if (data.success) {
setSuppliers(data.data || [])
} else {
message.error('获取供应商列表失败')
}
} catch (error) {
console.error('获取供应商失败:', error)
message.error('网络错误')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchSuppliers()
}, [])
// 表格列定义
const columns: ColumnsType<Supplier> = [
{
title: '编号',
dataIndex: 'code',
key: 'code',
width: 120,
sorter: (a, b) => a.code.localeCompare(b.code)
},
{
title: '名称',
dataIndex: 'name',
key: 'name',
render: (text) => <span style={{ fontWeight: 'bold' }}>{text}</span>
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
width: 100,
render: (type) => {
const typeMap: Record<string, { color: string, text: string }> = {
'china': { color: 'red', text: '中国供应商' },
'local': { color: 'green', text: '本地供应商' },
'international': { color: 'blue', text: '国际供应商' }
}
const info = typeMap[type] || { color: 'default', text: type }
return <Tag color={info.color}>{info.text}</Tag>
}
},
{
title: '国家',
dataIndex: 'country',
key: 'country',
width: 100,
render: (country) => (
<Tag color={country === 'China' ? 'red' : country === 'Thailand' ? 'purple' : 'blue'}>
{country}
</Tag>
)
},
{
title: '评分',
dataIndex: 'rating',
key: 'rating',
width: 100,
render: (rating) => {
const stars = '★'.repeat(rating) + '☆'.repeat(5 - rating)
return (
<div style={{ color: rating >= 4 ? '#52c41a' : rating >= 3 ? '#faad14' : '#ff4d4f' }}>
{stars}
</div>
)
},
sorter: (a, b) => a.rating - b.rating
},
{
title: '采购金额',
dataIndex: 'total_purchase_amount',
key: 'total_purchase_amount',
width: 150,
render: (amount) => `¥${amount.toLocaleString()}`,
sorter: (a, b) => a.total_purchase_amount - b.total_purchase_amount
},
{
title: '应付金额',
dataIndex: 'total_payable',
key: 'total_payable',
width: 150,
render: (amount) => (
<span style={{ color: amount > 0 ? '#ff4d4f' : '#52c41a' }}>
¥{amount.toLocaleString()}
</span>
),
sorter: (a, b) => a.total_payable - b.total_payable
},
{
title: '操作',
key: 'actions',
width: 120,
render: (_, record) => (
<Space>
<Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} size="small" />
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)} size="small" />
</Space>
)
}
]
// 处理搜索
const filteredSuppliers = suppliers.filter(supplier =>
supplier.code.toLowerCase().includes(searchText.toLowerCase()) ||
supplier.name.toLowerCase().includes(searchText.toLowerCase())
)
// 处理提交
const handleSubmit = async (values: any) => {
try {
const url = editingSupplier ? `/api/suppliers/${editingSupplier.id}` : '/api/suppliers'
const method = editingSupplier ? 'PUT' : 'POST'
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values)
})
const data = await response.json()
if (data.success) {
message.success(editingSupplier ? '更新成功' : '创建成功')
setModalVisible(false)
form.resetFields()
setEditingSupplier(null)
fetchSuppliers()
} else {
message.error(data.message || '操作失败')
}
} catch (error) {
console.error('保存供应商失败:', error)
message.error('操作失败')
}
}
const handleEdit = (supplier: Supplier) => {
setEditingSupplier(supplier)
form.setFieldsValue(supplier)
setModalVisible(true)
}
const handleDelete = async (id: number) => {
Modal.confirm({
title: '确认删除',
content: '确定要删除此供应商吗?',
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
const response = await fetch(`/api/suppliers/${id}`, { method: 'DELETE' })
const data = await response.json()
if (data.success) {
message.success('删除成功')
fetchSuppliers()
} else {
message.error(data.message || '删除失败')
}
} catch (error) {
message.error('删除失败')
}
}
})
}
const handleAdd = () => {
setEditingSupplier(null)
form.resetFields()
setModalVisible(true)
}
return (
<div style={{ padding: 24 }}>
{/* 统计卡片 */}
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card><Statistic title="供应商总数" value={stats.total} prefix={<ShopOutlined />} /></Card>
</Col>
<Col span={6}>
<Card><Statistic title="采购总金额" value={stats.totalPurchase} prefix="¥" valueStyle={{ color: '#1890ff' }} /></Card>
</Col>
<Col span={6}>
<Card><Statistic title="应付总金额" value={stats.totalPayable} prefix="¥" valueStyle={{ color: stats.totalPayable > 0 ? '#ff4d4f' : '#52c41a' }} /></Card>
</Col>
<Col span={6}>
<Card><Statistic title="平均评分" value={stats.avgRating} precision={1} prefix="★" suffix="/5" /></Card>
</Col>
</Row>
{/* 操作栏 */}
<Card style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Input
placeholder="搜索供应商编号或名称"
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
style={{ width: 300 }}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}></Button>
</div>
</Card>
{/* 表格 */}
<Card>
<Table
columns={columns}
dataSource={filteredSuppliers}
rowKey="id"
loading={loading}
pagination={{ pageSize: 10, showSizeChanger: true, showTotal: (total) => `${total}` }}
scroll={{ x: 1000 }}
/>
</Card>
{/* 模态框 */}
<Modal
title={editingSupplier ? '编辑供应商' : '新增供应商'}
open={modalVisible}
onCancel={() => { setModalVisible(false); form.resetFields(); setEditingSupplier(null) }}
onOk={() => form.submit()}
width={600}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item name="code" label="编号" rules={[{ required: true, message: '请输入编号' }]}>
<Input placeholder="如:SUP-001" />
</Form.Item>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="供应商名称" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="type" label="类型" initialValue="local">
<Select>
<Select.Option value="china"></Select.Option>
<Select.Option value="local"></Select.Option>
<Select.Option value="international"></Select.Option>
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="country" label="国家" initialValue="Laos">
<Select>
<Select.Option value="China"></Select.Option>
<Select.Option value="Thailand"></Select.Option>
<Select.Option value="Laos"></Select.Option>
<Select.Option value="Vietnam"></Select.Option>
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item name="rating" label="评分" initialValue={5}>
<Select>
<Select.Option value={5}> (5)</Select.Option>
<Select.Option value={4}> (4)</Select.Option>
<Select.Option value={3}> (3)</Select.Option>
</Select>
</Form.Item>
</Form>
</Modal>
</div>
)
}
export default SupplierPage
@@ -0,0 +1,249 @@
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Table, Button, Modal, Form, Input, Select, message, Space, Tag, Card, Row, Col, Statistic } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ShopOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
interface Contact {
name: string
position: string
phone: string
is_primary?: boolean
}
interface Supplier {
id: number
code: string
name: string
supply_category: string
country: string
contacts: Contact[]
remark: string
total_purchase_amount: number
total_paid: number
total_payable: number
created_at: string
}
const SupplierPage: React.FC = () => {
const navigate = useNavigate()
const [suppliers, setSuppliers] = useState<Supplier[]>([])
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
const [editingSupplier, setEditingSupplier] = useState<Supplier | null>(null)
const [searchText, setSearchText] = useState('')
const [form] = Form.useForm()
const fetchSuppliers = async () => {
setLoading(true)
try {
const response = await fetch('/api/suppliers')
const data = await response.json()
if (data.success) setSuppliers(data.data || [])
} catch (error) {
message.error('获取供应商列表失败')
} finally {
setLoading(false)
}
}
useEffect(() => { fetchSuppliers() }, [])
const stats = {
total: suppliers.length,
totalPurchase: suppliers.reduce((sum, s) => sum + (s.total_purchase_amount || 0), 0),
totalPayable: suppliers.reduce((sum, s) => sum + (s.total_payable || 0), 0)
}
const getPrimaryContact = (contacts: Contact[]) => {
const primary = contacts?.find(c => c.is_primary)
return primary?.name || '-'
}
const columns: ColumnsType<Supplier> = [
{ title: '编号', dataIndex: 'code', key: 'code', width: 120 },
{
title: '名称',
dataIndex: 'name',
key: 'name',
render: (text, record) => (
<Button type="link" style={{ padding: 0, fontWeight: 'bold' }} onClick={() => navigate(`/suppliers/${record.id}`)}>
{text}
</Button>
)
},
{ title: '供应类别', dataIndex: 'supply_category', key: 'supply_category', width: 120 },
{ title: '主联系人', key: 'primary_contact', width: 100, render: (_, record) => getPrimaryContact(record.contacts || []) },
{ title: '国家', dataIndex: 'country', key: 'country', width: 80, render: (country) => <Tag>{country || '-'}</Tag> },
{ title: '采购金额', dataIndex: 'total_purchase_amount', key: 'total_purchase_amount', width: 100, render: (amount) => `¥${(amount || 0).toLocaleString()}` },
{ title: '应付金额', dataIndex: 'total_payable', key: 'total_payable', width: 100, render: (amount) => <span style={{ color: amount > 0 ? '#ff4d4f' : '#52c41a' }}>¥{(amount || 0).toLocaleString()}</span> },
{
title: '操作',
key: 'actions',
width: 100,
render: (_, record) => (
<Space>
<Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} size="small" />
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)} size="small" />
</Space>
)
}
]
const filteredSuppliers = suppliers.filter(s =>
s.code?.toLowerCase().includes(searchText.toLowerCase()) ||
s.name?.toLowerCase().includes(searchText.toLowerCase()) ||
s.supply_category?.toLowerCase().includes(searchText.toLowerCase())
)
const handleContactChange = (index: number, field: string, value: any) => {
form.setFieldsValue({
contacts: form.getFieldValue('contacts').map((contact: any, i: number) => {
if (field === 'is_primary' && value) {
// 如果勾选了主联系人,取消其他联系人的主联系人选项
return i === index ? { ...contact, [field]: value } : { ...contact, is_primary: false }
}
return i === index ? { ...contact, [field]: value } : contact
})
})
}
const handleSubmit = async (values: any) => {
try {
let contacts = values.contacts || [{ name: '', position: '', phone: '', is_primary: true }]
const hasPrimary = contacts.some(c => c.is_primary)
if (!hasPrimary && contacts[0].name) contacts[0].is_primary = true
const url = editingSupplier ? `/api/suppliers/${editingSupplier.id}` : '/api/suppliers'
const method = editingSupplier ? 'PUT' : 'POST'
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...values, contacts })
})
const data = await response.json()
if (data.success) {
message.success(editingSupplier ? '更新成功' : '创建成功')
setModalVisible(false)
form.resetFields()
setEditingSupplier(null)
fetchSuppliers()
} else {
message.error(data.message || '操作失败')
}
} catch (error) {
message.error('操作失败')
}
}
const handleEdit = (supplier: Supplier) => {
setEditingSupplier(supplier)
form.setFieldsValue({
name: supplier.name,
supply_category: supplier.supply_category,
country: supplier.country,
remark: supplier.remark,
contacts: supplier.contacts?.length ? supplier.contacts : [{ name: '', position: '', phone: '', is_primary: true }]
})
setModalVisible(true)
}
const handleDelete = async (id: number) => {
Modal.confirm({
title: '确认删除',
content: '确定要删除此供应商吗?',
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
const response = await fetch(`/api/suppliers/${id}`, { method: 'DELETE' })
const data = await response.json()
if (data.success) { message.success('删除成功'); fetchSuppliers() }
else message.error(data.message || '删除失败')
} catch (error) {
message.error('删除失败')
}
}
})
}
const handleAdd = () => {
setEditingSupplier(null)
form.resetFields()
form.setFieldsValue({ country: 'Laos', contacts: [{ name: '', position: '', phone: '', is_primary: true }] })
setModalVisible(true)
}
return (
<div style={{ padding: 24 }}>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={8}><Card><Statistic title="供应商总数" value={stats.total} prefix={<ShopOutlined />} /></Card></Col>
<Col span={8}><Card><Statistic title="采购总金额" value={stats.totalPurchase} prefix="¥" valueStyle={{ color: '#1890ff' }} /></Card></Col>
<Col span={8}><Card><Statistic title="应付总金额" value={stats.totalPayable} prefix="¥" valueStyle={{ color: stats.totalPayable > 0 ? '#ff4d4f' : '#52c41a' }} /></Card></Col>
</Row>
<Card style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Input placeholder="搜索供应商编号、名称或供应类别" prefix={<SearchOutlined />} value={searchText} onChange={(e) => setSearchText(e.target.value)} allowClear style={{ width: 350 }} />
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}></Button>
</div>
</Card>
<Card>
<Table columns={columns} dataSource={filteredSuppliers} rowKey="id" loading={loading} pagination={{ pageSize: 10, showTotal: (total) => `${total}` }} scroll={{ x: 900 }} />
</Card>
<Modal title={editingSupplier ? '编辑供应商' : '新增供应商'} open={modalVisible} onCancel={() => { setModalVisible(false); form.resetFields(); setEditingSupplier(null) }} onOk={() => form.submit()} width={700}>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="供应商名称" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="supply_category" label="供应类别">
<Input placeholder="手填:如电力设备、建筑材料" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="country" label="国家" initialValue="Laos">
<Select>
<Select.Option value="China"></Select.Option>
<Select.Option value="Laos"></Select.Option>
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} placeholder="备注信息" />
</Form.Item>
<h4></h4>
<Form.List name="contacts" initialValue={[{ name: '', position: '', phone: '', is_primary: true }]}>
{(fields, { add, remove }) => (
<div>
{fields.map(({ key, name, ...restField }) => (
<div key={key} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<Form.Item {...restField} name={[name, 'name']} style={{ marginBottom: 0, flex: 1 }}><Input placeholder="姓名" /></Form.Item>
<Form.Item {...restField} name={[name, 'position']} style={{ marginBottom: 0, flex: 1 }}><Input placeholder="职位" /></Form.Item>
<Form.Item {...restField} name={[name, 'phone']} style={{ marginBottom: 0, flex: 1 }}><Input placeholder="电话" /></Form.Item>
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0 }}>
<input
type="checkbox"
onChange={(e) => handleContactChange(name, 'is_primary', e.target.checked)}
/>
</Form.Item>
{fields.length > 1 && <Button type="link" danger onClick={() => remove(name)}></Button>}
</div>
))}
<Button type="dashed" onClick={() => add()} style={{ width: '100%' }}>+ </Button>
</div>
)}
</Form.List>
</Form>
</Modal>
</div>
)
}
export default SupplierPage
@@ -0,0 +1,75 @@
import React from 'react';
import { Card, Typography, Button, Table, Tag, Space, Select, DatePicker, Input } from 'antd';
import { SearchOutlined, DownloadOutlined, DeleteOutlined } from '@ant-design/icons';
const { Title, Paragraph } = Typography;
const { RangePicker } = DatePicker;
const SystemLogsPage: React.FC = () => {
const [loading, setLoading] = React.useState(false);
const columns = [
{ title: '日志ID', dataIndex: 'id', key: 'id', width: 80 },
{ title: '时间', dataIndex: 'timestamp', key: 'timestamp', width: 180 },
{
title: '级别',
dataIndex: 'level',
key: 'level',
width: 100,
render: (v: string) => {
const colors: Record<string, string> = { 'info': 'blue', 'warning': 'orange', 'error': 'red', 'success': 'green' };
return <Tag color={colors[v]}>{v.toUpperCase()}</Tag>;
}
},
{ title: '模块', dataIndex: 'module', key: 'module', width: 120 },
{ title: '操作人', dataIndex: 'operator', key: 'operator', width: 120 },
{ title: '操作', dataIndex: 'action', key: 'action' },
{ title: 'IP地址', dataIndex: 'ip', key: 'ip', width: 130 },
{ title: '详情', dataIndex: 'detail', key: 'detail', ellipsis: true },
];
const data = [
{ key: '1', id: 1001, timestamp: '2026-03-18 17:15:30', level: 'info', module: '用户管理', operator: 'admin', action: '用户登录', ip: '192.168.1.100', detail: '用户 admin 成功登录系统' },
{ key: '2', id: 1002, timestamp: '2026-03-18 17:14:25', level: 'info', module: '项目管理', operator: 'manager', action: '创建项目', ip: '192.168.1.101', detail: '创建新项目: 博纳斯线路改造' },
{ key: '3', id: 1003, timestamp: '2026-03-18 17:13:10', level: 'warning', module: '财务管理', operator: 'admin', action: '审批预支', ip: '192.168.1.100', detail: '预支申请单 ADV20260318001 审批通过' },
{ key: '4', id: 1004, timestamp: '2026-03-18 17:12:05', level: 'success', module: '系统', operator: 'system', action: '数据备份', ip: '127.0.0.1', detail: '自动备份完成,耗时 45 秒' },
{ key: '5', id: 1005, timestamp: '2026-03-18 17:10:00', level: 'error', module: 'API', operator: 'anonymous', action: '接口访问', ip: '10.0.0.55', detail: '无效的 API Token 访问尝试' },
{ key: '6', id: 1006, timestamp: '2026-03-18 17:09:30', level: 'info', module: '采购管理', operator: 'pm1', action: '创建采购', ip: '192.168.1.102', detail: '创建采购申请: PO20260318002' },
{ key: '7', id: 1007, timestamp: '2026-03-18 17:08:15', level: 'info', module: '用户管理', operator: 'admin', action: '修改角色', ip: '192.168.1.100', detail: '修改用户 zhang 的角色为项目经理' },
];
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Title level={3} style={{ marginBottom: 0 }}></Title>
<Paragraph type="secondary"></Paragraph>
</div>
<Space>
<Select placeholder="日志级别" style={{ width: 120 }} allowClear options={[
{ value: 'info', label: 'Info' },
{ value: 'warning', label: 'Warning' },
{ value: 'error', label: 'Error' },
{ value: 'success', label: 'Success' }
]} />
<Select placeholder="模块" style={{ width: 150 }} allowClear options={[
{ value: 'user', label: '用户管理' },
{ value: 'project', label: '项目管理' },
{ value: 'finance', label: '财务管理' },
{ value: 'system', label: '系统' }
]} />
<RangePicker placeholder={['开始日期', '结束日期']} />
<Input.Search placeholder="搜索日志内容" style={{ width: 200 }} />
<Button icon={<DownloadOutlined />}></Button>
<Button icon={<DeleteOutlined />} danger></Button>
</Space>
</div>
<Card>
<Table columns={columns} dataSource={data} loading={loading} pagination={{ pageSize: 15 }} scroll={{ x: 1400 }} />
</Card>
</div>
);
};
export default SystemLogsPage;
+147
View File
@@ -0,0 +1,147 @@
import React from 'react';
import { Card, Typography, Button, Table, Tag, Space, Modal, Form, Input, Select, message, Row, Col, Avatar, Switch } from 'antd';
import { PlusOutlined, SearchOutlined, UserOutlined, LockOutlined } from '@ant-design/icons';
const { Title, Paragraph } = Typography;
const UsersPage: React.FC = () => {
const [loading, setLoading] = React.useState(false);
const [modalVisible, setModalVisible] = React.useState(false);
const [form] = Form.useForm();
const columns = [
{ title: '用户ID', dataIndex: 'id', key: 'id', width: 100 },
{
title: '头像',
dataIndex: 'avatar',
key: 'avatar',
width: 80,
render: () => <Avatar icon={<UserOutlined />} />
},
{ title: '用户名', dataIndex: 'username', key: 'username', width: 120 },
{ title: '姓名', dataIndex: 'name', key: 'name', width: 120 },
{ title: '邮箱', dataIndex: 'email', key: 'email' },
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 130 },
{
title: '角色',
dataIndex: 'role',
key: 'role',
width: 120,
render: (v: string) => {
const colors: Record<string, string> = { 'admin': 'red', 'manager': 'blue', 'user': 'green' };
const texts: Record<string, string> = { 'admin': '管理员', 'manager': '经理', 'user': '普通用户' };
return <Tag color={colors[v]}>{texts[v]}</Tag>;
}
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (v: boolean) => <Switch checked={v} onChange={() => {}} />
},
{ title: '最后登录', dataIndex: 'lastLogin', key: 'lastLogin', width: 150 },
{
title: '操作',
key: 'action',
width: 180,
render: () => (
<Space>
<Button size="small" type="link"></Button>
<Button size="small" type="link"></Button>
<Button size="small" type="link" danger></Button>
</Space>
)
}
];
const data = [
{ key: '1', id: 'U001', username: 'admin', name: '系统管理员', email: 'admin@qingyuan.com', phone: '+856 20 0000 0001', role: 'admin', status: true, lastLogin: '2026-03-18 15:30' },
{ key: '2', id: 'U002', username: 'manager', name: '罗仕林', email: 'luo@qingyuan.com', phone: '+856 20 0000 0002', role: 'manager', status: true, lastLogin: '2026-03-18 14:20' },
{ key: '3', id: 'U003', username: 'pm1', name: '张三', email: 'zhang@qingyuan.com', phone: '+856 20 0000 0003', role: 'user', status: true, lastLogin: '2026-03-17 10:15' },
];
const handleSubmit = () => {
message.success('用户已添加');
setModalVisible(false);
};
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Title level={3} style={{ marginBottom: 0 }}></Title>
<Paragraph type="secondary"></Paragraph>
</div>
<Space>
<Select placeholder="选择角色" style={{ width: 150 }} allowClear options={[
{ value: 'admin', label: '管理员' },
{ value: 'manager', label: '经理' },
{ value: 'user', label: '普通用户' }
]} />
<Input.Search placeholder="搜索用户" style={{ width: 200 }} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalVisible(true)}>
</Button>
</Space>
</div>
<Card>
<Table columns={columns} dataSource={data} loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1400 }} />
</Card>
<Modal
title="新增用户"
open={modalVisible}
onCancel={() => setModalVisible(false)}
onOk={handleSubmit}
width={600}
>
<Form form={form} layout="vertical">
<Row gutter={16}>
<Col span={12}>
<Form.Item label="用户名" name="username" rules={[{ required: true }]}>
<Input placeholder="请输入用户名" prefix={<UserOutlined />} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="姓名" name="name" rules={[{ required: true }]}>
<Input placeholder="请输入姓名" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="邮箱" name="email" rules={[{ required: true, type: 'email' }]}>
<Input placeholder="email@example.com" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="手机号" name="phone">
<Input placeholder="+856 20 xxxx xxxx" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="角色" name="role" rules={[{ required: true }]}>
<Select placeholder="选择角色" options={[
{ value: 'admin', label: '管理员' },
{ value: 'manager', label: '经理' },
{ value: 'user', label: '普通用户' }
]} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="初始密码" name="password" rules={[{ required: true }]}>
<Input.Password placeholder="请输入初始密码" prefix={<LockOutlined />} />
</Form.Item>
</Col>
</Row>
</Form>
</Modal>
</div>
);
};
export default UsersPage;
@@ -0,0 +1,399 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Card, Space, Tag, Modal, Form, Input, InputNumber, Select, DatePicker, message, Descriptions, Image, Divider, AutoComplete } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined, UndoOutlined, PlusCircleOutlined, MinusCircleOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { useAuthStore } from '../store/authStore';
import FileUpload from '../components/FileUpload';
const { Option } = Select;
const { TextArea } = Input;
interface DetailItem {
id?: string;
description: string;
amount: number;
attachments?: string[];
}
const VerificationPage: React.FC = () => {
const { user } = useAuthStore();
const [records, setRecords] = useState<any[]>([]);
const [advances, setAdvances] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [selectedRecord, setSelectedRecord] = useState<any>(null);
const [form] = Form.useForm();
const [detailItems, setDetailItems] = useState<DetailItem[]>([]);
const [exchangeRates, setExchangeRates] = useState<Record<string, number>>({});
useEffect(() => {
fetchExchangeRates();
fetchRecords(); fetchAdvances(); }, []);
const fetchExchangeRates = async () => {
try {
const res = await fetch("/api/exchange-rates/latest");
const data = await res.json();
if (data.success) {
const rates: Record<string, number> = {};
Object.keys(data.data).forEach(key => {
rates[key] = parseFloat(data.data[key]) || 1;
});
setExchangeRates(rates);
}
} catch (error) {}
};
const fetchRecords = async () => {
setLoading(true);
try {
const res = await fetch('/api/verifications');
const data = await res.json();
if (data.success) {
// 解析JSON字符串字段
const parsedRecords = data.data.map((record: any) => ({
...record,
detail_items: record.detail_items ? JSON.parse(record.detail_items) : [],
attachments: record.attachments ? JSON.parse(record.attachments) : []
}));
setRecords(parsedRecords);
}
} catch (error) {
console.error('获取核销列表失败:', error);
message.error('获取核销列表失败');
} finally {
setLoading(false);
}
};
const fetchAdvances = async () => {
try {
const res = await fetch('/api/advances?status=approved');
const data = await res.json();
if (data.success) setAdvances(data.data);
} catch (error) {}
};
const handleCreate = () => {
setEditingId(null);
setDetailItems([]);
form.resetFields();
form.setFieldsValue({
verification_date: dayjs(),
currency: 'CNY',
applicant: user?.name || user?.username || '当前用户',
attachments: []
});
setModalVisible(true);
};
const handleEdit = (record: any) => {
setEditingId(record.id);
setDetailItems(record.detail_items || []);
form.setFieldsValue({
...record,
verification_date: record.verification_date ? dayjs(record.verification_date) : null,
attachments: record.attachments || []
});
setModalVisible(true);
};
const handleView = (record: any) => {
setSelectedRecord(record);
setDetailModalVisible(true);
};
const handleDelete = async (id: number) => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这条核销记录吗?',
onOk: async () => {
try {
await fetch('/api/verifications/' + id, { method: 'DELETE' });
message.success('删除成功');
fetchRecords();
} catch (error) {
message.error('删除失败');
}
}
});
};
const handleWithdraw = async (id: number) => {
Modal.confirm({
title: '确认撤回',
content: '撤回后可重新编辑提交,确认撤回吗?',
onOk: async () => {
try {
await fetch('/api/verifications/' + id + '/withdraw', { method: 'POST' });
message.success('已撤回,可重新编辑');
fetchRecords();
} catch (error) {
message.error('撤回失败');
}
}
});
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
const data = {
...values,
verification_date: values.verification_date?.format('YYYY-MM-DD'),
detail_items: detailItems,
amount: detailItems.reduce((sum, item) => sum + (item.amount || 0), 0),
applicant: user?.name || user?.username
};
const url = editingId ? '/api/verifications/' + editingId : '/api/verifications';
const method = editingId ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await res.json();
if (result.success) {
message.success(editingId ? '更新成功' : '创建成功');
setModalVisible(false);
fetchRecords();
} else {
message.error(result.error || '操作失败');
}
} catch (error) {
message.error('操作失败');
}
};
const addDetailItem = () => setDetailItems([...detailItems, { description: '', amount: 0, attachments: [] }]);
const updateDetailItem = (index: number, field: keyof DetailItem, value: any) => {
const newItems = [...detailItems];
newItems[index] = { ...newItems[index], [field]: value };
setDetailItems(newItems);
};
const convertToCNY = (amount: number, curr: string): number => {
if (curr === "CNY") return amount;
const rateKey = curr + "_CNY";
const rate = exchangeRates[rateKey] || 1;
return amount * rate;
};
const removeDetailItem = (index: number) => setDetailItems(detailItems.filter((_, i) => i !== index));
const handleAdvanceSelect = (advanceCode: string) => {
const advance = advances.find((a: any) => a.advance_code === advanceCode);
if (advance) {
form.setFieldsValue({
advance_code: advance.advance_code,
advance_amount: advance.amount,
currency: advance.currency
});
}
};
const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
pending: { color: 'processing', text: '待审批' },
approved: { color: 'success', text: '已批准' },
rejected: { color: 'error', text: '已退回' },
withdrawn: { color: 'default', text: '已撤回' },
completed: { color: 'blue', text: '已完成' },
};
const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>;
};
const formatAmount = (amount: number, currency: string = 'CNY') => {
const symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' };
return (symbols[currency] || '¥') + (amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
// Format number with thousand separator for input display
const formatNumberWithSeparator = (value: number | undefined, currency: string): string => {
if (value === undefined || value === null) return '';
const symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' };
const symbol = symbols[currency] || '¥';
return symbol + value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
// Parse formatted string back to number
const parseFormattedNumber = (value: string): number => {
// Remove currency symbols and thousand separators
const cleaned = value.replace(/[¥$₭฿,]/g, '');
return parseFloat(cleaned) || 0;
};
const columns = [
{ title: '事由', dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleView(r)}>{v}</a> },
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
{ title: '关联预支', dataIndex: 'advance_code', key: 'advance_code', width: 120 },
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => (
<>
<div>{formatAmount(v, r.currency)}</div>
{r.currency !== 'CNY' && r.amount_cny && <div style={{ color: '#999', fontSize: 12 }}> ¥{r.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</div>}
</>
) },
{ title: '核销日期', dataIndex: 'verification_date', key: 'verification_date', width: 100 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (status: string) => getStatusTag(status) },
{ title: '编号', dataIndex: 'verification_code', key: 'verification_code', width: 120 },
{
title: '操作', key: 'action', width: 250,
render: (_: any, record: any) => (
<Space wrap>
<Button size="small" icon={<EyeOutlined />} onClick={() => handleView(record)}></Button>
{record.status === 'pending' && (
<>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}></Button>
<Button size="small" icon={<UndoOutlined />} onClick={() => handleWithdraw(record.id)}></Button>
</>
)}
{(record.status === 'rejected' || record.status === 'withdrawn') && (
<Button size="small" type="primary" icon={<EditOutlined />} onClick={() => handleEdit(record)}></Button>
)}
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}></Button>
</Space>
)
}
];
const currency = Form.useWatch('currency', form);
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}>
<h2 style={{ marginBottom: 8 }}></h2>
<p style={{ color: '#888', marginBottom: 0 }}></p>
</div>
<Card extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}></Button>}>
<Table dataSource={records} columns={columns} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} size="middle" scroll={{ x: 1200 }} />
</Card>
<Modal title={editingId ? '编辑核销' : '新建核销'} open={modalVisible} onOk={handleSubmit} onCancel={() => setModalVisible(false)} width={900}>
<Form form={form} layout="vertical">
<Form.Item name="applicant" label="申请人">
<Input disabled style={{ color: 'rgba(0,0,0,0.85)', backgroundColor: '#f5f5f5' }} />
</Form.Item>
<Form.Item name="advance_code" label="关联预支单">
<AutoComplete
options={advances.map((a: any) => ({ value: a.advance_code, label: `${a.advance_code} - ${a.applicant} - ${formatAmount(a.amount, a.currency)}` }))}
onSelect={handleAdvanceSelect}
placeholder="选择或输入预支单编号"
/>
</Form.Item>
<Form.Item name="advance_amount" label="预支金额">
<InputNumber disabled style={{ width: 200 }} />
</Form.Item>
<Form.Item name="verification_date" label="核销日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="currency" label="币种" rules={[{ required: true }]}>
<Select style={{ width: 200 }}>
<Option value="CNY"> (CNY)</Option>
<Option value="USD"> (USD)</Option>
<Option value="LAK"> (LAK)</Option>
<Option value="THB"> (THB)</Option>
</Select>
</Form.Item>
<Form.Item name="reason" label="核销事由" rules={[{ required: true }]}>
<TextArea rows={2} placeholder="核销原因说明" />
</Form.Item>
<Divider></Divider>
<div style={{ marginBottom: 16 }}>
<Button type="dashed" icon={<PlusCircleOutlined />} onClick={addDetailItem}></Button>
<span style={{ marginLeft: 16, color: '#888' }}>
: {formatAmount(detailItems.reduce((sum, item) => sum + (item.amount || 0), 0), currency)}
</span>
</div>
{detailItems.map((item, index) => (
<Card key={index} size="small" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'flex-start' }}>
<div style={{ flex: 1, minWidth: 200 }}>
<label style={{ display: 'block', marginBottom: 4, color: '#666' }}></label>
<Input value={item.description} onChange={(e) => updateDetailItem(index, 'description', e.target.value)} placeholder="费用说明" />
</div>
<div style={{ width: 150 }}>
<label style={{ display: 'block', marginBottom: 4, color: '#666' }}></label>
<InputNumber value={item.amount} onChange={(v) => updateDetailItem(index, 'amount', v)} min={0} precision={2} style={{ width: '100%' }} placeholder="金额" />
</div>
<div style={{ flex: 2, minWidth: 300 }}>
<label style={{ display: 'block', marginBottom: 4, color: '#666' }}></label>
<FileUpload value={item.attachments || []} onChange={(urls) => updateDetailItem(index, 'attachments', urls)} maxCount={3} accept="image/*" />
</div>
<Button type="text" danger icon={<MinusCircleOutlined />} onClick={() => removeDetailItem(index)} style={{ marginTop: 24 }} />
</div>
</Card>
))}
<Divider></Divider>
<Form.Item name="attachments" label="整体凭证附件">
<FileUpload maxCount={9} accept="image/*" />
</Form.Item>
</Form>
</Modal>
<Modal title="核销详情" open={detailModalVisible} onCancel={() => setDetailModalVisible(false)} footer={null} width={900}>
{selectedRecord && (
<>
<Descriptions bordered column={2} size="small">
<Descriptions.Item label="核销编号">{selectedRecord.verification_code}</Descriptions.Item>
<Descriptions.Item label="状态">{getStatusTag(selectedRecord.status)}</Descriptions.Item>
<Descriptions.Item label="关联预支">{selectedRecord.advance_code}</Descriptions.Item>
<Descriptions.Item label="申请人">{selectedRecord.applicant}</Descriptions.Item>
<Descriptions.Item label="核销日期">{selectedRecord.verification_date}</Descriptions.Item>
<Descriptions.Item label="金额">
{formatAmount(selectedRecord.amount, selectedRecord.currency)}
{selectedRecord.currency !== 'CNY' && selectedRecord.amount_cny && (
<span style={{ color: '#999', marginLeft: 8 }}> ¥{selectedRecord.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span>
)}
</Descriptions.Item>
<Descriptions.Item label="核销事由" span={2}>{selectedRecord.reason}</Descriptions.Item>
</Descriptions>
{selectedRecord.detail_items && selectedRecord.detail_items.length > 0 && (
<>
<Divider></Divider>
<Table
dataSource={selectedRecord.detail_items}
rowKey="id"
size="small"
pagination={false}
columns={[
{ title: '费用说明', dataIndex: 'description', key: 'description' },
{ title: '金额', dataIndex: 'amount', key: 'amount', render: (v: number) => formatAmount(v, selectedRecord.currency) },
{ title: '附件', dataIndex: 'attachments', key: 'attachments', render: (v: string[]) => v?.length ? `${v.length}` : '-' }
]}
/>
</>
)}
{selectedRecord.attachments && selectedRecord.attachments.length > 0 && (
<>
<Divider></Divider>
<Image.PreviewGroup>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{selectedRecord.attachments.map((url: string, index: number) => (
<Image key={index} src={url} width={100} height={100} style={{ objectFit: 'cover', borderRadius: 4 }} />
))}
</div>
</Image.PreviewGroup>
</>
)}
</>
)}
</Modal>
</div>
);
};
export default VerificationPage;
@@ -0,0 +1,130 @@
import React from 'react';
import { Card, Typography, Descriptions, Tag, Row, Col, Progress, Divider } from 'antd';
import {
CloudServerOutlined,
DatabaseOutlined,
NodeIndexOutlined,
CheckCircleOutlined,
InfoCircleOutlined
} from '@ant-design/icons';
const { Title, Paragraph, Text } = Typography;
const AboutPage: React.FC = () => {
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}>
<Title level={3} style={{ marginBottom: 8 }}></Title>
<Paragraph type="secondary"></Paragraph>
</div>
<Row gutter={24}>
<Col span={16}>
<Card title={<><InfoCircleOutlined /> </>}>
<Descriptions bordered column={2}>
<Descriptions.Item label="系统名称">ERP</Descriptions.Item>
<Descriptions.Item label="系统版本">V1.0.0</Descriptions.Item>
<Descriptions.Item label="开发团队"></Descriptions.Item>
<Descriptions.Item label="上线日期">20263</Descriptions.Item>
<Descriptions.Item label="技术架构">
<Tag color="blue">React 18</Tag>
<Tag color="green">Ant Design 5</Tag>
<Tag color="purple">Node.js</Tag>
<Tag color="orange">PostgreSQL</Tag>
</Descriptions.Item>
<Descriptions.Item label="部署环境">
<Tag color="cyan"></Tag>
</Descriptions.Item>
<Descriptions.Item label="前端框架">Vite + React + TypeScript</Descriptions.Item>
<Descriptions.Item label="后端框架">Express.js + PostgreSQL</Descriptions.Item>
</Descriptions>
</Card>
<Card title={<><CheckCircleOutlined /> </>} style={{ marginTop: 24 }}>
<Row gutter={[16, 16]}>
<Col span={8}>
<Card size="small" style={{ textAlign: 'center' }}>
<Title level={5}></Title>
<Text type="secondary"></Text>
</Card>
</Col>
<Col span={8}>
<Card size="small" style={{ textAlign: 'center' }}>
<Title level={5}></Title>
<Text type="secondary"></Text>
</Card>
</Col>
<Col span={8}>
<Card size="small" style={{ textAlign: 'center' }}>
<Title level={5}></Title>
<Text type="secondary"></Text>
</Card>
</Col>
<Col span={8}>
<Card size="small" style={{ textAlign: 'center' }}>
<Title level={5}></Title>
<Text type="secondary"></Text>
</Card>
</Col>
<Col span={8}>
<Card size="small" style={{ textAlign: 'center' }}>
<Title level={5}></Title>
<Text type="secondary"></Text>
</Card>
</Col>
<Col span={8}>
<Card size="small" style={{ textAlign: 'center' }}>
<Title level={5}></Title>
<Text type="secondary"></Text>
</Card>
</Col>
</Row>
</Card>
</Col>
<Col span={8}>
<Card title={<><CloudServerOutlined /> </>}>
<div style={{ marginBottom: 16 }}>
<Text type="secondary">CPU使用率</Text>
<Progress percent={45} status="active" />
</div>
<div style={{ marginBottom: 16 }}>
<Text type="secondary">使</Text>
<Progress percent={60} strokeColor="#52c41a" />
</div>
<div style={{ marginBottom: 16 }}>
<Text type="secondary"></Text>
<Progress percent={35} strokeColor="#1890ff" />
</div>
<Divider />
<Descriptions column={1} size="small">
<Descriptions.Item label="服务器IP">43.161.248.209</Descriptions.Item>
<Descriptions.Item label="操作系统">OpenCloudOS 9</Descriptions.Item>
<Descriptions.Item label="Node版本">v22.22.1</Descriptions.Item>
</Descriptions>
</Card>
<Card title={<><DatabaseOutlined /> </>} style={{ marginTop: 24 }}>
<div style={{ textAlign: 'center', padding: 20 }}>
<CheckCircleOutlined style={{ fontSize: 48, color: '#52c41a' }} />
<Title level={4} style={{ margin: '16px 0 8px' }}></Title>
<Text type="secondary">PostgreSQL 15</Text>
</div>
<Divider />
<Descriptions column={1} size="small">
<Descriptions.Item label="数据库名">company_finance_db</Descriptions.Item>
<Descriptions.Item label="连接状态"></Descriptions.Item>
<Descriptions.Item label="最近备份">2026-03-19 00:00</Descriptions.Item>
</Descriptions>
</Card>
</Col>
</Row>
<Card style={{ marginTop: 24, background: '#f6ffed', borderColor: '#b7eb8f' }}>
<Text>© 2026 ERP系统 - V1.0.0</Text>
</Card>
</div>
);
};
export default AboutPage;
@@ -0,0 +1,97 @@
import React from 'react';
import { Card, Typography, Button, Table, Tag, Space, Modal, Form, Input, DatePicker, message, Row, Col, Progress } from 'antd';
import { DownloadOutlined, UploadOutlined, DeleteOutlined, ClockCircleOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
const { Title, Paragraph, Text } = Typography;
const BackupPage: React.FC = () => {
const [loading, setLoading] = React.useState(false);
const [backuping, setBackuping] = React.useState(false);
const columns = [
{ title: '备份名称', dataIndex: 'name', key: 'name' },
{ title: '备份时间', dataIndex: 'time', key: 'time' },
{ title: '文件大小', dataIndex: 'size', key: 'size' },
{ title: '备份类型', dataIndex: 'type', key: 'type', render: (v: string) => <Tag color={v === 'auto' ? 'blue' : 'green'}>{v === 'auto' ? '自动' : '手动'}</Tag> },
{ title: '状态', dataIndex: 'status', key: 'status', render: (v: string) => <Tag color={v === 'success' ? 'success' : 'error'}>{v === 'success' ? '成功' : '失败'}</Tag> },
{
title: '操作',
key: 'action',
render: () => (
<Space>
<Button size="small" type="link" icon={<DownloadOutlined />}></Button>
<Button size="small" type="link" icon={<UploadOutlined />}></Button>
<Button size="small" danger type="link" icon={<DeleteOutlined />}></Button>
</Space>
)
}
];
const data = [
{ key: '1', name: 'backup-20260319.sql', time: '2026-03-19 00:00', size: '15.2 MB', type: 'auto', status: 'success' },
{ key: '2', name: 'backup-20260318.sql', time: '2026-03-18 00:00', size: '14.8 MB', type: 'auto', status: 'success' },
{ key: '3', name: 'backup-manual-20260317.sql', time: '2026-03-17 15:30', size: '14.5 MB', type: 'manual', status: 'success' },
];
const handleBackup = () => {
setBackuping(true);
setTimeout(() => {
message.success('备份创建成功');
setBackuping(false);
}, 2000);
};
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}>
<Title level={3} style={{ marginBottom: 8 }}></Title>
<Paragraph type="secondary"></Paragraph>
</div>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card>
<Text type="secondary"></Text>
<Title level={2} style={{ margin: '8px 0 0' }}>3</Title>
</Card>
</Col>
<Col span={6}>
<Card>
<Text type="secondary"></Text>
<Title level={2} style={{ margin: '8px 0 0' }}>44.5 MB</Title>
</Card>
</Col>
<Col span={6}>
<Card>
<Text type="secondary"></Text>
<Title level={4} style={{ margin: '8px 0 0' }}>2026-03-19 00:00</Title>
</Card>
</Col>
<Col span={6}>
<Card>
<Text type="secondary"></Text>
<Progress percent={30} size="small" style={{ marginTop: 8 }} />
<Text type="secondary">300 MB / 1 GB</Text>
</Card>
</Col>
</Row>
<Card
title="备份列表"
extra={
<Space>
<Button icon={<ClockCircleOutlined />}></Button>
<Button type="primary" icon={<DownloadOutlined />} loading={backuping} onClick={handleBackup}>
</Button>
</Space>
}
>
<Table columns={columns} dataSource={data} loading={loading} pagination={{ pageSize: 10 }} />
</Card>
</div>
);
};
export default BackupPage;
@@ -0,0 +1,221 @@
import React, { useState } from 'react';
import { Card, Typography, Table, Button, Space, Modal, Form, Select, Input, message, Tag, Steps, Divider, Switch, Badge } from 'antd';
import { EditOutlined, PlusOutlined, SettingOutlined, CheckCircleOutlined, ClockCircleOutlined, SyncOutlined } from '@ant-design/icons';
const { Title, Paragraph, Text } = Typography;
const { Option } = Select;
interface ProcessNode {
id: string;
name: string;
role: string;
roleName: string;
order: number;
enabled: boolean;
}
const ProcessManagement: React.FC = () => {
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [editingNode, setEditingNode] = useState<ProcessNode | null>(null);
const [form] = Form.useForm();
// 流程节点数据
const [nodes, setNodes] = useState<ProcessNode[]>([
{ id: '1', name: '发起申请', role: 'applicant', roleName: '申请人(任意角色)', order: 1, enabled: true },
{ id: '2', name: '审批', role: 'admin', roleName: '管理员', order: 2, enabled: true },
{ id: '3', name: '执行付款', role: 'admin', roleName: '管理员', order: 3, enabled: true },
]);
// 角色选项
const roleOptions = [
{ value: 'applicant', label: '申请人(任意角色)' },
{ value: 'admin', label: '管理员' },
{ value: 'finance', label: '财务专员' },
{ value: 'manager', label: '项目经理' },
];
// 流程类型
const processTypes = [
{ key: 'advance', name: '预支申请', description: '员工预支款项申请流程' },
{ key: 'reimbursement', name: '报销申请', description: '费用报销申请流程' },
{ key: 'payment', name: '付款申请', description: '供应商付款申请流程' },
{ key: 'verification', name: '核销申请', description: '单据核销申请流程' },
];
const handleEdit = (node: ProcessNode) => {
setEditingNode(node);
form.setFieldsValue({
role: node.role
});
setModalVisible(true);
};
const handleSave = () => {
form.validateFields().then(values => {
if (editingNode) {
const updatedNodes = nodes.map(n => {
if (n.id === editingNode.id) {
const roleOption = roleOptions.find(r => r.value === values.role);
return { ...n, role: values.role, roleName: roleOption?.label || values.role };
}
return n;
});
setNodes(updatedNodes);
message.success('节点配置已保存');
}
setModalVisible(false);
});
};
const getStatusTag = (enabled: boolean) => {
return enabled ? <Tag color="success"></Tag> : <Tag color="default"></Tag>;
};
const getStepStatus = (order: number) => {
if (order === 1) return 'finish';
if (order === 2) return 'process';
return 'wait';
};
const columns = [
{
title: '顺序',
dataIndex: 'order',
key: 'order',
width: 80,
render: (v: number) => <Badge count={v} style={{ backgroundColor: '#1890ff' }} />
},
{ title: '节点名称', dataIndex: 'name', key: 'name', width: 150 },
{
title: '执行角色',
dataIndex: 'roleName',
key: 'roleName',
render: (v: string, r: ProcessNode) => (
<Space>
<Tag color={r.role === 'admin' ? 'blue' : r.role === 'finance' ? 'green' : 'default'}>
{v}
</Tag>
</Space>
)
},
{
title: '状态',
dataIndex: 'enabled',
key: 'enabled',
width: 100,
render: (v: boolean) => getStatusTag(v)
},
{
title: '操作',
key: 'action',
width: 120,
render: (_: any, record: ProcessNode) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>
</Button>
</Space>
)
}
];
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}>
<Title level={3} style={{ marginBottom: 8 }}></Title>
<Paragraph type="secondary">
</Paragraph>
</div>
{/* 流程图示 */}
<Card title="当前流程图" style={{ marginBottom: 24 }}>
<Steps current={1} style={{ marginTop: 16 }}>
{nodes.filter(n => n.enabled).map((node, index) => (
<Steps.Step
key={node.id}
title={node.name}
description={node.roleName}
status={getStepStatus(node.order)}
icon={
node.order === 1 ? <PlusOutlined /> :
node.order === 2 ? <CheckCircleOutlined /> :
<SyncOutlined />
}
/>
))}
</Steps>
<Divider />
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
<Text strong></Text>
</Paragraph>
</Card>
{/* 节点配置表 */}
<Card title="节点配置">
<Table
columns={columns}
dataSource={nodes}
rowKey="id"
pagination={false}
size="middle"
/>
</Card>
{/* 流程类型说明 */}
<Card title="适用流程" style={{ marginTop: 24 }}>
<Table
columns={[
{ title: '流程类型', dataIndex: 'name', key: 'name', width: 150 },
{ title: '说明', dataIndex: 'description', key: 'description' },
{
title: '状态',
key: 'status',
width: 100,
render: () => <Tag color="success"></Tag>
}
]}
dataSource={processTypes}
rowKey="key"
pagination={false}
size="middle"
/>
</Card>
{/* 编辑节点弹窗 */}
<Modal
title={`编辑节点:${editingNode?.name}`}
open={modalVisible}
onCancel={() => setModalVisible(false)}
onOk={handleSave}
width={500}
>
<Form form={form} layout="vertical">
<Form.Item label="节点名称">
<Input value={editingNode?.name} disabled />
</Form.Item>
<Form.Item
name="role"
label="执行角色"
rules={[{ required: true, message: '请选择执行角色' }]}
>
<Select placeholder="选择执行角色">
{roleOptions.map(opt => (
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
))}
</Select>
</Form.Item>
</Form>
<div style={{ padding: 12, background: '#fffbe6', borderRadius: 6, marginTop: 16 }}>
<Text type="warning">
使
</Text>
</div>
</Modal>
</div>
);
};
export default ProcessManagement;
@@ -0,0 +1,342 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Card, Space, Tag, Modal, Form, Input, InputNumber, Select, DatePicker, message, Descriptions, Image, Divider } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined, UndoOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { useAuthStore } from '../../store/authStore';
import FileUpload from '../../components/FileUpload';
const { Option } = Select;
const { TextArea } = Input;
const AdvancesPage: React.FC = () => {
const { user } = useAuthStore();
const [advances, setAdvances] = useState<any[]>([]);
const [projects, setProjects] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [selectedRecord, setSelectedRecord] = useState<any>(null);
const [form] = Form.useForm();
const [exchangeRates, setExchangeRates] = useState<Record<string, number>>({});
useEffect(() => {
fetchAdvances();
fetchProjects();
fetchExchangeRates();
}, []);
const fetchAdvances = async () => {
setLoading(true);
try {
const res = await fetch('/api/advances');
const data = await res.json();
if (data.success) setAdvances(data.data);
} catch (error) {
message.error('获取预支列表失败');
} finally {
setLoading(false);
}
};
const fetchProjects = async () => {
try {
const res = await fetch('/api/projects');
const data = await res.json();
if (data.success) setProjects(data.data);
} catch (error) {}
};
const fetchExchangeRates = async () => {
try {
const res = await fetch('/api/exchange-rates/latest');
const data = await res.json();
if (data.success) {
const rates: Record<string, number> = {};
Object.keys(data.data).forEach(key => {
rates[key] = parseFloat(data.data[key]) || 1;
});
setExchangeRates(rates);
}
} catch (error) {}
};
const handleCreate = () => {
setEditingId(null);
form.resetFields();
form.setFieldsValue({
advance_date: dayjs(),
currency: 'CNY',
expense_type: 'public',
applicant: user?.name || user?.username || '当前用户',
attachments: []
});
setModalVisible(true);
};
const handleEdit = (record: any) => {
setEditingId(record.id);
form.setFieldsValue({
...record,
advance_date: record.advance_date ? dayjs(record.advance_date) : null,
attachments: record.attachments || []
});
setModalVisible(true);
};
const handleView = (record: any) => {
setSelectedRecord(record);
setDetailModalVisible(true);
};
const handleDelete = async (id: number) => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这条预支记录吗?',
onOk: async () => {
try {
await fetch('/api/advances/' + id, { method: 'DELETE' });
message.success('删除成功');
fetchAdvances();
} catch (error) {
message.error('删除失败');
}
}
});
};
const handleWithdraw = async (id: number) => {
Modal.confirm({
title: '确认撤回',
content: '撤回后可重新编辑提交,确认撤回吗?',
onOk: async () => {
try {
await fetch('/api/advances/' + id + '/withdraw', { method: 'POST' });
message.success('已撤回,可重新编辑');
fetchAdvances();
} catch (error) {
message.error('撤回失败');
}
}
});
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
const data = {
...values,
advance_date: values.advance_date?.format('YYYY-MM-DD'),
amount_cny: values.currency === 'CNY' ? values.amount : convertToCNY(values.amount, values.currency),
applicant: user?.name || user?.username
};
const url = editingId ? '/api/advances/' + editingId : '/api/advances';
const method = editingId ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await res.json();
if (result.success) {
message.success(editingId ? '更新成功' : '创建成功');
setModalVisible(false);
fetchAdvances();
} else {
message.error(result.error || '操作失败');
}
} catch (error) {
message.error('操作失败');
}
};
const convertToCNY = (amount: number, currency: string): number => {
if (currency === 'CNY') return amount;
const rateKey = 'CNY_' + currency;
const rate = exchangeRates[rateKey] || 1;
return amount / rate;
};
const amount = Form.useWatch('amount', form);
const currency = Form.useWatch('currency', form);
const expenseType = Form.useWatch('expense_type', form);
const amountCNY = amount && currency ? convertToCNY(amount, currency) : 0;
const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
pending: { color: 'processing', text: '待审批' },
approved: { color: 'success', text: '已批准' },
rejected: { color: 'error', text: '已退回' },
withdrawn: { color: 'default', text: '已撤回' },
settled: { color: 'blue', text: '已核销' },
};
const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>;
};
const formatAmount = (amount: number, currency: string = 'CNY') => {
const symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' };
return (symbols[currency] || '¥') + (amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
// Format number with thousand separator for input display
const formatNumberWithSeparator = (value: number | undefined, currency: string): string => {
if (value === undefined || value === null) return '';
const symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' };
const symbol = symbols[currency] || '¥';
return symbol + value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
// Parse formatted string back to number
const parseFormattedNumber = (value: string): number => {
// Remove currency symbols and thousand separators
const cleaned = value.replace(/[¥$₭฿,]/g, '');
return parseFloat(cleaned) || 0;
};
const columns = [
{ title: '事由', dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleView(r)}>{v}</a> },
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => (
<>
<div>{formatAmount(v, r.currency)}</div>
{r.currency !== 'CNY' && r.amount_cny && <div style={{ color: '#999', fontSize: 12 }}> ¥{r.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</div>}
</>
) },
{ title: '预支日期', dataIndex: 'advance_date', key: 'advance_date', width: 100 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (status: string) => getStatusTag(status) },
{ title: '编号', dataIndex: 'advance_code', key: 'advance_code', width: 120 },
{
title: '操作', key: 'action', width: 250,
render: (_: any, record: any) => (
<Space wrap>
<Button size="small" icon={<EyeOutlined />} onClick={() => handleView(record)}></Button>
{record.status === 'pending' && (
<>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}></Button>
<Button size="small" icon={<UndoOutlined />} onClick={() => handleWithdraw(record.id)}></Button>
</>
)}
{(record.status === 'rejected' || record.status === 'withdrawn') && (
<Button size="small" type="primary" icon={<EditOutlined />} onClick={() => handleEdit(record)}></Button>
)}
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}></Button>
</Space>
)
}
];
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}>
<h2 style={{ marginBottom: 8 }}></h2>
<p style={{ color: '#888', marginBottom: 0 }}></p>
</div>
<Card extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}></Button>}>
<Table dataSource={advances} columns={columns} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} size="middle" scroll={{ x: 1200 }} />
</Card>
{/* 新建/编辑弹窗 */}
<Modal title={editingId ? '编辑预支' : '新建预支'} open={modalVisible} onOk={handleSubmit} onCancel={() => setModalVisible(false)} width={700}>
<Form form={form} layout="vertical">
<Form.Item name="applicant" label="申请人">
<Input disabled style={{ color: 'rgba(0,0,0,0.85)', backgroundColor: '#f5f5f5' }} />
</Form.Item>
<Form.Item name="advance_date" label="预支日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="expense_type" label="支出类型" rules={[{ required: true }]}>
<Select placeholder="选择支出类型" onChange={() => form.setFieldsValue({ project_id: undefined })}>
<Option value="public"></Option>
<Option value="project"></Option>
</Select>
</Form.Item>
{expenseType === 'project' && (
<Form.Item name="project_id" label="选择项目" rules={[{ required: true, message: '请选择项目' }]}>
<Select placeholder="选择项目" showSearch optionFilterProp="children">
{projects.map((p: any) => <Option key={p.id} value={p.id}>{p.name}</Option>)}
</Select>
</Form.Item>
)}
<Form.Item label="金额" required>
<Space>
<Form.Item name="currency" noStyle initialValue="CNY">
<Select style={{ width: 140 }}>
<Option value="CNY"> (CNY)</Option>
<Option value="USD"> (USD)</Option>
<Option value="LAK"> (LAK)</Option>
<Option value="THB"> (THB)</Option>
</Select>
</Form.Item>
<Form.Item name="amount" noStyle rules={[{ required: true, message: '请输入金额' }]}>
<InputNumber
style={{ width: 200 }}
min={0}
precision={2}
placeholder="输入金额"
formatter={(value) => formatNumberWithSeparator(value as number, currency || 'CNY')}
parser={(value) => parseFormattedNumber(value || '0')}
/>
</Form.Item>
</Space>
{amountCNY > 0 && (
<div style={{ marginTop: 8, color: '#888', fontSize: 13 }}>
¥ {amountCNY.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
)}
</Form.Item>
<Form.Item name="reason" label="事由" rules={[{ required: true }]}>
<TextArea rows={3} placeholder="请输入预支事由" />
</Form.Item>
<Form.Item name="attachments" label="凭证附件">
<FileUpload maxCount={9} accept="image/*" />
</Form.Item>
</Form>
</Modal>
{/* 详情弹窗 */}
<Modal title="预支详情" open={detailModalVisible} onCancel={() => setDetailModalVisible(false)} footer={null} width={800}>
{selectedRecord && (
<>
<Descriptions bordered column={2} size="small">
<Descriptions.Item label="预支编号">{selectedRecord.advance_code}</Descriptions.Item>
<Descriptions.Item label="状态">{getStatusTag(selectedRecord.status)}</Descriptions.Item>
<Descriptions.Item label="申请人">{selectedRecord.applicant}</Descriptions.Item>
<Descriptions.Item label="预支日期">{selectedRecord.advance_date}</Descriptions.Item>
<Descriptions.Item label="支出类型">{selectedRecord.expense_type === 'project' ? '项目支出' : '公用支出'}</Descriptions.Item>
<Descriptions.Item label="金额">
{formatAmount(selectedRecord.amount, selectedRecord.currency)}
{selectedRecord.currency !== 'CNY' && selectedRecord.amount_cny && (
<span style={{ color: '#999', marginLeft: 8 }}> ¥{selectedRecord.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span>
)}
</Descriptions.Item>
<Descriptions.Item label="事由" span={2}>{selectedRecord.reason}</Descriptions.Item>
</Descriptions>
{selectedRecord.attachments && selectedRecord.attachments.length > 0 && (
<>
<Divider></Divider>
<Image.PreviewGroup>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{selectedRecord.attachments.map((url: string, index: number) => (
<Image key={index} src={url} width={100} height={100} style={{ objectFit: 'cover', borderRadius: 4 }} />
))}
</div>
</Image.PreviewGroup>
</>
)}
</>
)}
</Modal>
</div>
);
};
export default AdvancesPage;
@@ -0,0 +1,297 @@
import React, { useState, useEffect } from 'react';
import { Card, Table, Tag, Button, Space, Modal, Form, Input, Select, message, Tabs, Badge, Timeline, Typography } from 'antd';
import { CheckOutlined, CloseOutlined, EyeOutlined, EditOutlined, UndoOutlined, ClockCircleOutlined, UserOutlined, DollarOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
const { TextArea } = Input;
const { Text } = Typography;
// 审批记录类型
interface ApprovalRecord {
id: string;
applyCode: string;
applyType: string;
applicant: string;
amount: number;
currency: string;
action: 'approve' | 'reject' | 'withdraw' | 'submit';
operator: string;
operatorRole: string;
timestamp: string;
remark?: string;
reason?: string;
}
const ApprovalManagement: React.FC = () => {
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [editModalVisible, setEditModalVisible] = useState(false);
const [historyModalVisible, setHistoryModalVisible] = useState(false);
const [selectedRecord, setSelectedRecord] = useState<any>(null);
const [approvalType, setApprovalType] = useState<'approve' | 'reject'>('approve');
const [form] = Form.useForm();
const [editForm] = Form.useForm();
// 审批记录
const [approvalHistory, setApprovalHistory] = useState<ApprovalRecord[]>([
{ id: '1', applyCode: 'ADV20260317001', applyType: '预支申请', applicant: '张三', amount: 3000, currency: 'CNY', action: 'approve', operator: '系统管理员', operatorRole: '管理员', timestamp: '2026-03-17 15:30', remark: '同意' },
{ id: '2', applyCode: 'REIM20260316001', applyType: '报销申请', applicant: '李四', amount: 2100, currency: 'CNY', action: 'reject', operator: '系统管理员', operatorRole: '管理员', timestamp: '2026-03-16 17:20', reason: '票据不完整' },
{ id: '3', applyCode: 'ADV20260315001', applyType: '预支申请', applicant: '王五', amount: 5000, currency: 'CNY', action: 'submit', operator: '王五', operatorRole: '申请人', timestamp: '2026-03-15 10:00' },
{ id: '4', applyCode: 'PAY20260314001', applyType: '付款申请', applicant: '赵六', amount: 80000, currency: 'CNY', action: 'approve', operator: '系统管理员', operatorRole: '管理员', timestamp: '2026-03-14 14:20', remark: '同意付款' },
]);
// 待审批数据
const [pendingData, setPendingData] = useState([
{ key: '1', type: '预支申请', code: 'ADV20260319001', applicant: '张三', applicantId: 'user1', amount: 5000, currency: 'CNY', date: '2026-03-19', reason: '项目差旅费用', status: 'pending', detail_items: [] },
{ key: '2', type: '报销申请', code: 'REIM20260319001', applicant: '李四', applicantId: 'user2', amount: 3500, currency: 'CNY', date: '2026-03-19', reason: '办公用品采购', status: 'pending', detail_items: [] },
{ key: '3', type: '付款申请', code: 'PAY20260319001', applicant: '王五', applicantId: 'user3', amount: 50000, currency: 'CNY', date: '2026-03-18', payee: '老挝电力设备公司', reason: '设备采购款', status: 'pending', detail_items: [] },
]);
// 已审批数据
const approvedData = [
{ key: '1', type: '预支申请', code: 'ADV20260317001', applicant: '张三', applicantId: 'user1', amount: 3000, currency: 'CNY', date: '2026-03-17', status: 'approved', approver: '系统管理员', approveTime: '2026-03-17 15:30' },
];
const formatAmount = (amount: number, currency: string = 'CNY') => {
const symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' };
return (symbols[currency] || '¥') + (amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
const formatNumberWithSeparator = (value: number | undefined, currency: string): string => {
if (value === undefined || value === null) return '';
const symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' };
return (symbols[currency] || '¥') + value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
const parseFormattedNumber = (value: string): number => {
const cleaned = value.replace(/[¥$₭฿,]/g, '');
return parseFloat(cleaned) || 0;
};
const getTypeTag = (type: string) => {
const colors: Record<string, string> = { '预支申请': 'blue', '报销申请': 'green', '付款申请': 'orange', '核销申请': 'purple' };
return <Tag color={colors[type] || 'default'}>{type}</Tag>;
};
const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
pending: { color: 'processing', text: '待审批' }, approved: { color: 'success', text: '已通过' },
rejected: { color: 'error', text: '已退回' }, withdrawn: { color: 'default', text: '已撤回' }, editing: { color: 'warning', text: '编辑中' },
};
const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>;
};
// 查看审批记录
const handleView = (record: any) => {
setSelectedRecord(record);
setHistoryModalVisible(true);
};
const handleViewHistory = (record: any) => {
setSelectedRecord(record);
setHistoryModalVisible(true);
};
// 添加审批记录
const addApprovalRecord = (record: any, action: 'approve' | 'reject' | 'withdraw', operator: string, operatorRole: string, remark?: string, reason?: string) => {
const newRecord: ApprovalRecord = {
id: Date.now().toString(),
applyCode: record.code,
applyType: record.type,
applicant: record.applicant,
amount: record.amount,
currency: record.currency,
action,
operator,
operatorRole,
timestamp: dayjs().format('YYYY-MM-DD HH:mm'),
remark,
reason
};
setApprovalHistory([newRecord, ...approvalHistory]);
};
const handleApprove = (record: any) => {
setSelectedRecord(record);
setApprovalType('approve');
form.resetFields();
setModalVisible(true);
};
const handleReject = (record: any) => {
setSelectedRecord(record);
setApprovalType('reject');
form.resetFields();
setModalVisible(true);
};
const handleWithdraw = (record: any) => {
Modal.confirm({
title: '撤回申请',
content: `确认撤回申请 ${record.code} 吗?撤回后可重新编辑提交。`,
okText: '确认撤回',
cancelText: '取消',
onOk: () => {
addApprovalRecord(record, 'withdraw', record.applicant, '申请人');
setPendingData(pendingData.filter(item => item.key !== record.key));
message.success('申请已撤回,可重新编辑提交');
}
});
};
const handleEdit = (record: any) => {
setSelectedRecord(record);
editForm.setFieldsValue({ amount: record.amount, reason: record.reason });
setEditModalVisible(true);
};
const handleSubmit = () => {
form.validateFields().then(values => {
const operator = '系统管理员';
const operatorRole = '管理员';
if (approvalType === 'approve') {
addApprovalRecord(selectedRecord, 'approve', operator, operatorRole, values.remark);
setPendingData(pendingData.filter(item => item.key !== selectedRecord.key));
message.success(`审批通过:${selectedRecord.code}`);
} else {
addApprovalRecord(selectedRecord, 'reject', operator, operatorRole, undefined, values.rejectReason);
setPendingData(pendingData.filter(item => item.key !== selectedRecord.key));
message.success(`已退回:${selectedRecord.code},申请人可编辑后重新提交`);
}
setModalVisible(false);
});
};
const handleEditSubmit = () => {
editForm.validateFields().then(values => {
addApprovalRecord(selectedRecord, 'submit', selectedRecord.applicant, '申请人');
message.success('修改成功,已重新提交审批');
setEditModalVisible(false);
});
};
// 审批记录相关列
const historyColumns = [
{ title: '时间', dataIndex: 'timestamp', key: 'timestamp', width: 140 },
{ title: '操作', dataIndex: 'action', key: 'action', width: 100, render: (v: string) => {
const map: Record<string, { color: string; icon: any; text: string }> = {
submit: { color: 'blue', icon: <ClockCircleOutlined />, text: '提交' },
approve: { color: 'green', icon: <CheckOutlined />, text: '通过' },
reject: { color: 'red', icon: <CloseOutlined />, text: '退回' },
withdraw: { color: 'default', icon: <UndoOutlined />, text: '撤回' }
};
const m = map[v] || { color: 'default', icon: null, text: v };
return <Tag color={m.color} icon={m.icon}>{m.text}</Tag>;
}},
{ title: '申请编号', dataIndex: 'applyCode', key: 'applyCode', width: 140 },
{ title: '类型', dataIndex: 'applyType', key: 'applyType', width: 100, render: (v: string) => getTypeTag(v) },
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: ApprovalRecord) => (
<>
<div>{formatAmount(v, r.currency)}</div>
{r.currency !== 'CNY' && r.amount_cny && <div style={{ color: '#999', fontSize: 12 }}> ¥{r.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</div>}
</>
) },
{ title: '操作人', dataIndex: 'operator', key: 'operator', width: 100 },
{ title: '角色', dataIndex: 'operatorRole', key: 'operatorRole', width: 80 },
{ title: '备注/原因', dataIndex: 'remark', key: 'remark', ellipsis: true, render: (_: any, r: ApprovalRecord) => r.remark || r.reason || '-' },
];
const pendingColumns = [
{ title: '事由', dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleView(r)}>{v}</a> },
{ title: '类型', dataIndex: 'type', key: 'type', width: 100, render: (v: string) => getTypeTag(v) },
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => (
<>
<div>{formatAmount(v, r.currency)}</div>
{r.currency !== 'CNY' && r.amount_cny && <div style={{ color: '#999', fontSize: 12 }}> ¥{r.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</div>}
</>
) },
{ title: '申请日期', dataIndex: 'date', key: 'date', width: 100 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (v: string) => getStatusTag(v) },
{ title: '编号', dataIndex: 'code', key: 'code', width: 140 },
{
title: '操作', key: 'action', width: 280,
render: (_: any, record: any) => (
<Space wrap>
<Button size="small" type="primary" icon={<CheckOutlined />} onClick={() => handleApprove(record)}></Button>
<Button size="small" danger icon={<CloseOutlined />} onClick={() => handleReject(record)}>退</Button>
<Button size="small" icon={<UndoOutlined />} onClick={() => handleWithdraw(record)}></Button>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}></Button>
</Space>
)
}
];
const approvedColumns = [
{ title: '申请类型', dataIndex: 'type', key: 'type', width: 100, render: (v: string) => getTypeTag(v) },
{ title: '申请编号', dataIndex: 'code', key: 'code', width: 140 },
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => (
<>
<div>{formatAmount(v, r.currency)}</div>
{r.currency !== 'CNY' && r.amount_cny && <div style={{ color: '#999', fontSize: 12 }}> ¥{r.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</div>}
</>
) },
{ title: '审批时间', dataIndex: 'approveTime', key: 'approveTime', width: 140 },
{ title: '审批人', dataIndex: 'approver', key: 'approver', width: 100 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (v: string) => getStatusTag(v) },
{
title: '操作', key: 'action', width: 100,
render: (_: any, record: any) => (
<Button size="small" icon={<EyeOutlined />} onClick={() => handleViewHistory(record)}></Button>
)
}
];
const tabItems = [
{ key: 'pending', label: <span> <Badge count={pendingData.length} style={{ marginLeft: 8 }} /></span>, children: <Table columns={pendingColumns} dataSource={pendingData} loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1200 }} /> },
{ key: 'approved', label: '已审批', children: <Table columns={approvedColumns} dataSource={approvedData} loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1200 }} /> },
{ key: 'history', label: <span> <Badge count={approvalHistory.length} style={{ marginLeft: 8 }} /></span>, children: <Table columns={historyColumns} dataSource={approvalHistory} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1400 }} /> },
];
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}><h2 style={{ marginBottom: 8 }}></h2><p style={{ color: '#888', marginBottom: 0 }}></p></div>
<Card><Tabs items={tabItems} /></Card>
<Modal title={approvalType === 'approve' ? '审批通过' : '退回申请'} open={modalVisible} onCancel={() => setModalVisible(false)} onOk={handleSubmit} width={500}>
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 6 }}>
<p><strong></strong>{selectedRecord?.code}</p>
<p><strong></strong>{selectedRecord?.applicant}</p>
<p><strong></strong>{selectedRecord && formatAmount(selectedRecord.amount, selectedRecord.currency)}</p>
</div>
<Form form={form} layout="vertical">
{approvalType === 'approve' ? (
<Form.Item name="remark" label="审批备注"><TextArea rows={3} placeholder="可选:填写审批备注" /></Form.Item>
) : (
<Form.Item name="rejectReason" label="退回原因" rules={[{ required: true, message: '请填写退回原因' }]}><TextArea rows={3} placeholder="请填写退回原因" /></Form.Item>
)}
</Form>
</Modal>
<Modal title={`编辑申请:${selectedRecord?.code}`} open={editModalVisible} onCancel={() => setEditModalVisible(false)} onOk={handleEditSubmit} width={600}>
<Form form={editForm} layout="vertical">
<Form.Item label="申请类型"><Input value={selectedRecord?.type} disabled /></Form.Item>
<Form.Item label="申请人"><Input value={selectedRecord?.applicant} disabled /></Form.Item>
<Form.Item name="amount" label="金额" rules={[{ required: true }]}><Input type="number" style={{ width: '100%' }} /></Form.Item>
<Form.Item name="reason" label="事由" rules={[{ required: true }]}><TextArea rows={3} /></Form.Item>
</Form>
</Modal>
<Modal title={`审批记录:${selectedRecord?.code}`} open={historyModalVisible} onCancel={() => setHistoryModalVisible(false)} footer={null} width={800}>
<Table
columns={historyColumns}
dataSource={approvalHistory.filter(r => r.applyCode === selectedRecord?.code)}
rowKey="id"
pagination={false}
size="small"
/>
</Modal>
</div>
);
};
export default ApprovalManagement;
@@ -0,0 +1,311 @@
import React, { useState, useEffect } from 'react';
import { Card, Table, Tag, Button, Space, Modal, Form, Input, Select, DatePicker, message, Tabs, Badge, Descriptions } from 'antd';
import { CheckOutlined, CloseOutlined, EyeOutlined, DollarOutlined, EditOutlined, UndoOutlined, ClockCircleOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
const { TextArea } = Input;
// 执行记录类型
interface ExecutionRecord {
id: string;
applyCode: string;
applyType: string;
applicant: string;
amount: number;
currency: string;
action: 'execute' | 'reject';
operator: string;
operatorRole: string;
timestamp: string;
executeMethod?: string;
voucherNo?: string;
rejectReason?: string;
remark?: string;
}
const ExecutionManagement: React.FC = () => {
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [editModalVisible, setEditModalVisible] = useState(false);
const [historyModalVisible, setHistoryModalVisible] = useState(false);
const [selectedRecord, setSelectedRecord] = useState<any>(null);
const [executionType, setExecutionType] = useState<'execute' | 'reject'>('execute');
const [form] = Form.useForm();
const [editForm] = Form.useForm();
// 执行记录
const [executionHistory, setExecutionHistory] = useState<ExecutionRecord[]>([
{ id: '1', applyCode: 'ADV20260310001', applyType: '预支申请', applicant: '赵六', amount: 5000, currency: 'CNY', action: 'execute', operator: '财务专员', operatorRole: '财务', timestamp: '2026-03-11 10:30', executeMethod: '银行转账', voucherNo: 'VCH20260311001' },
{ id: '2', applyCode: 'PAY20260308001', applyType: '付款申请', applicant: '张三', amount: 30000, currency: 'CNY', action: 'execute', operator: '财务专员', operatorRole: '财务', timestamp: '2026-03-09 14:20', executeMethod: '银行转账', voucherNo: 'VCH20260309001' },
{ id: '3', applyCode: 'REIM20260312001', applyType: '报销申请', applicant: '李四', amount: 1500, currency: 'CNY', action: 'reject', operator: '财务专员', operatorRole: '财务', timestamp: '2026-03-13 09:15', rejectReason: '凭证不完整,请补充' },
]);
// 待执行数据
const [pendingData, setPendingData] = useState([
{ key: '1', type: '预支申请', code: 'ADV20260317001', applicant: '张三', applicantId: 'user1', amount: 3000, currency: 'CNY', applyDate: '2026-03-17', approveDate: '2026-03-17', approver: '系统管理员', reason: '项目差旅费用', status: 'pending' },
{ key: '2', type: '报销申请', code: 'REIM20260315001', applicant: '李四', applicantId: 'user2', amount: 2100, currency: 'CNY', applyDate: '2026-03-15', approveDate: '2026-03-16', approver: '系统管理员', reason: '办公用品采购报销', status: 'pending' },
{ key: '3', type: '付款申请', code: 'PAY20260314001', applicant: '王五', applicantId: 'user3', amount: 50000, currency: 'CNY', applyDate: '2026-03-14', approveDate: '2026-03-15', approver: '系统管理员', payee: '老挝电力设备公司', reason: '设备采购款', status: 'pending' },
]);
// 已执行数据
const executedData = [
{ key: '1', type: '预支申请', code: 'ADV20260310001', applicant: '赵六', applicantId: 'user4', amount: 5000, currency: 'CNY', applyDate: '2026-03-10', executeDate: '2026-03-11', executor: '财务专员', executeMethod: '银行转账', voucherNo: 'VCH20260311001', status: 'executed' },
{ key: '2', type: '付款申请', code: 'PAY20260308001', applicant: '张三', applicantId: 'user1', amount: 30000, currency: 'CNY', applyDate: '2026-03-08', executeDate: '2026-03-09', executor: '财务专员', executeMethod: '银行转账', voucherNo: 'VCH20260309001', status: 'executed' },
];
const formatAmount = (amount: number, currency: string = 'CNY') => {
const symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' };
return (symbols[currency] || '¥') + (amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
const formatNumberWithSeparator = (value: number | undefined, currency: string): string => {
if (value === undefined || value === null) return '';
const symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' };
return (symbols[currency] || '¥') + value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
const parseFormattedNumber = (value: string): number => {
const cleaned = value.replace(/[¥$₭฿,]/g, '');
return parseFloat(cleaned) || 0;
};
const getTypeTag = (type: string) => {
const colors: Record<string, string> = { '预支申请': 'blue', '报销申请': 'green', '付款申请': 'orange', '核销申请': 'purple' };
return <Tag color={colors[type] || 'default'}>{type}</Tag>;
};
const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
pending: { color: 'processing', text: '待执行' },
executed: { color: 'success', text: '已执行' },
rejected: { color: 'error', text: '已退回' },
};
const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>;
};
// 添加执行记录
const addExecutionRecord = (record: any, action: 'execute' | 'reject', operator: string, operatorRole: string, data?: any) => {
const newRecord: ExecutionRecord = {
id: Date.now().toString(),
applyCode: record.code,
applyType: record.type,
applicant: record.applicant,
amount: record.amount,
currency: record.currency,
action,
operator,
operatorRole,
timestamp: dayjs().format('YYYY-MM-DD HH:mm'),
executeMethod: data?.executeMethod,
voucherNo: data?.voucherNo,
rejectReason: data?.rejectReason,
remark: data?.remark
};
setExecutionHistory([newRecord, ...executionHistory]);
};
const handleExecute = (record: any) => {
setSelectedRecord(record);
setExecutionType('execute');
form.resetFields();
form.setFieldsValue({ execute_date: dayjs(), execute_method: 'bank' });
setModalVisible(true);
};
const handleReject = (record: any) => {
setSelectedRecord(record);
setExecutionType('reject');
form.resetFields();
setModalVisible(true);
};
const handleViewDetail = (record: any) => {
setSelectedRecord(record);
setDetailModalVisible(true);
};
const handleViewHistory = (record: any) => {
setSelectedRecord(record);
setHistoryModalVisible(true);
};
const handleEdit = (record: any) => {
setSelectedRecord(record);
editForm.setFieldsValue({ amount: record.amount, reason: record.reason });
setEditModalVisible(true);
};
const handleSubmit = () => {
form.validateFields().then(values => {
const operator = '系统管理员';
const operatorRole = '管理员';
if (executionType === 'execute') {
addExecutionRecord(selectedRecord, 'execute', operator, operatorRole, {
executeMethod: values.execute_method === 'bank' ? '银行转账' : values.execute_method === 'cash' ? '现金' : '其他',
voucherNo: values.voucher_no,
remark: values.remark
});
setPendingData(pendingData.filter(item => item.key !== selectedRecord.key));
message.success(`执行成功:${selectedRecord.code}`);
} else {
addExecutionRecord(selectedRecord, 'reject', operator, operatorRole, { rejectReason: values.rejectReason });
setPendingData(pendingData.filter(item => item.key !== selectedRecord.key));
message.success(`已退回:${selectedRecord.code},申请人可编辑后重新提交`);
}
setModalVisible(false);
});
};
const handleEditSubmit = () => {
editForm.validateFields().then(values => {
message.success('修改成功,已重新提交审批');
setEditModalVisible(false);
});
};
// 执行记录列
const historyColumns = [
{ title: '时间', dataIndex: 'timestamp', key: 'timestamp', width: 140 },
{ title: '操作', dataIndex: 'action', key: 'action', width: 100, render: (v: string) => {
const map: Record<string, { color: string; icon: any; text: string }> = {
execute: { color: 'green', icon: <CheckOutlined />, text: '执行' },
reject: { color: 'red', icon: <CloseOutlined />, text: '退回' }
};
const m = map[v] || { color: 'default', icon: null, text: v };
return <Tag color={m.color} icon={m.icon}>{m.text}</Tag>;
}},
{ title: '申请编号', dataIndex: 'applyCode', key: 'applyCode', width: 140 },
{ title: '类型', dataIndex: 'applyType', key: 'applyType', width: 100, render: (v: string) => getTypeTag(v) },
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: ExecutionRecord) => (
<>
<div>{formatAmount(v, r.currency)}</div>
{r.currency !== 'CNY' && r.amount_cny && <div style={{ color: '#999', fontSize: 12 }}> ¥{r.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</div>}
</>
) },
{ title: '执行方式', dataIndex: 'executeMethod', key: 'executeMethod', width: 100 },
{ title: '凭证号', dataIndex: 'voucherNo', key: 'voucherNo', width: 140 },
{ title: '操作人', dataIndex: 'operator', key: 'operator', width: 100 },
{ title: '角色', dataIndex: 'operatorRole', key: 'operatorRole', width: 80 },
{ title: '退回原因', dataIndex: 'rejectReason', key: 'rejectReason', ellipsis: true },
];
const pendingColumns = [
{ title: '事由', dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleViewDetail(r)}>{v}</a> },
{ title: '类型', dataIndex: 'type', key: 'type', width: 100, render: (v: string) => getTypeTag(v) },
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 120, render: (v: number, r: any) => <span style={{ fontWeight: 'bold', color: '#1890ff' }}>{formatAmount(v, r.currency)}</span> },
{ title: '收款方', dataIndex: 'payee', key: 'payee', ellipsis: true, render: (v: string, r: any) => v || r.applicant },
{ title: '审批日期', dataIndex: 'approveDate', key: 'approveDate', width: 100 },
{ title: '编号', dataIndex: 'code', key: 'code', width: 140 },
{
title: '操作', key: 'action', width: 280,
render: (_: any, record: any) => (
<Space wrap>
<Button size="small" type="primary" icon={<DollarOutlined />} onClick={() => handleExecute(record)}></Button>
<Button size="small" danger icon={<CloseOutlined />} onClick={() => handleReject(record)}>退</Button>
<Button size="small" icon={<EyeOutlined />} onClick={() => handleViewDetail(record)}></Button>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}></Button>
</Space>
)
}
];
const executedColumns = [
{ title: '事由', dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleViewDetail(r)}>{v || '-'}</a> },
{ title: '类型', dataIndex: 'type', key: 'type', width: 100, render: (v: string) => getTypeTag(v) },
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => (
<>
<div>{formatAmount(v, r.currency)}</div>
{r.currency !== 'CNY' && r.amount_cny && <div style={{ color: '#999', fontSize: 12 }}> ¥{r.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</div>}
</>
) },
{ title: '执行日期', dataIndex: 'executeDate', key: 'executeDate', width: 100 },
{ title: '执行方式', dataIndex: 'executeMethod', key: 'executeMethod', width: 100 },
{ title: '凭证号', dataIndex: 'voucherNo', key: 'voucherNo', width: 140 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (v: string) => getStatusTag(v) },
{ title: '编号', dataIndex: 'code', key: 'code', width: 140 },
{
title: '操作', key: 'action', width: 100,
render: (_: any, record: any) => (
<Button size="small" icon={<EyeOutlined />} onClick={() => handleViewHistory(record)}></Button>
)
}
];
const tabItems = [
{ key: 'pending', label: <span> <Badge count={pendingData.length} style={{ marginLeft: 8 }} /></span>, children: <Table columns={pendingColumns} dataSource={pendingData} loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1300 }} /> },
{ key: 'executed', label: '已执行', children: <Table columns={executedColumns} dataSource={executedData} loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1400 }} /> },
{ key: 'history', label: <span> <Badge count={executionHistory.length} style={{ marginLeft: 8 }} /></span>, children: <Table columns={historyColumns} dataSource={executionHistory} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1500 }} /> },
];
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}><h2 style={{ marginBottom: 8 }}></h2><p style={{ color: '#888', marginBottom: 0 }}></p></div>
<Card><Tabs items={tabItems} /></Card>
<Modal title={executionType === 'execute' ? '执行付款' : '退回申请'} open={modalVisible} onCancel={() => setModalVisible(false)} onOk={handleSubmit} width={600}>
{executionType === 'execute' ? (
<>
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 6 }}>
<Descriptions column={2} size="small">
<Descriptions.Item label="申请编号">{selectedRecord?.code}</Descriptions.Item>
<Descriptions.Item label="金额">{selectedRecord && formatAmount(selectedRecord.amount, selectedRecord.currency)}</Descriptions.Item>
<Descriptions.Item label="收款方" span={2}>{selectedRecord?.payee || selectedRecord?.applicant}</Descriptions.Item>
</Descriptions>
</div>
<Form form={form} layout="vertical">
<Form.Item name="execute_date" label="执行日期" rules={[{ required: true }]}><DatePicker style={{ width: '100%' }} /></Form.Item>
<Form.Item name="execute_method" label="执行方式" rules={[{ required: true }]}>
<Select options={[{ value: 'bank', label: '银行转账' }, { value: 'cash', label: '现金' }, { value: 'check', label: '支票' }, { value: 'other', label: '其他' }]} />
</Form.Item>
<Form.Item name="voucher_no" label="凭证号" rules={[{ required: true, message: '请输入凭证号' }]}><Input placeholder="请输入付款凭证号" /></Form.Item>
<Form.Item name="remark" label="备注"><TextArea rows={2} placeholder="可选:填写执行备注" /></Form.Item>
</Form>
</>
) : (
<>
<div style={{ marginBottom: 16, padding: 12, background: '#fff2f0', borderRadius: 6 }}>
<p><strong></strong>{selectedRecord?.code}</p>
<p><strong></strong>{selectedRecord && formatAmount(selectedRecord.amount, selectedRecord.currency)}</p>
</div>
<Form form={form} layout="vertical">
<Form.Item name="rejectReason" label="退回原因" rules={[{ required: true, message: '请填写退回原因' }]}><TextArea rows={3} placeholder="请填写退回原因" /></Form.Item>
</Form>
</>
)}
</Modal>
<Modal title="申请详情" open={detailModalVisible} onCancel={() => setDetailModalVisible(false)} footer={null} width={700}>
{selectedRecord && (
<Descriptions bordered column={2} size="small">
<Descriptions.Item label="申请类型">{getTypeTag(selectedRecord.type)}</Descriptions.Item>
<Descriptions.Item label="申请编号">{selectedRecord.code}</Descriptions.Item>
<Descriptions.Item label="申请人">{selectedRecord.applicant}</Descriptions.Item>
<Descriptions.Item label="金额">{formatAmount(selectedRecord.amount, selectedRecord.currency)}</Descriptions.Item>
<Descriptions.Item label="事由" span={2}>{selectedRecord.reason}</Descriptions.Item>
</Descriptions>
)}
</Modal>
<Modal title={`编辑申请:${selectedRecord?.code}`} open={editModalVisible} onCancel={() => setEditModalVisible(false)} onOk={handleEditSubmit} width={600}>
<Form form={editForm} layout="vertical">
<Form.Item label="申请类型"><Input value={selectedRecord?.type} disabled /></Form.Item>
<Form.Item label="申请人"><Input value={selectedRecord?.applicant} disabled /></Form.Item>
<Form.Item name="amount" label="金额" rules={[{ required: true }]}><Input type="number" style={{ width: '100%' }} /></Form.Item>
<Form.Item name="reason" label="事由" rules={[{ required: true }]}><TextArea rows={3} /></Form.Item>
</Form>
</Modal>
<Modal title={`执行记录:${selectedRecord?.code}`} open={historyModalVisible} onCancel={() => setHistoryModalVisible(false)} footer={null} width={1000}>
<Table columns={historyColumns} dataSource={executionHistory.filter(r => r.applyCode === selectedRecord?.code)} rowKey="id" pagination={false} size="small" />
</Modal>
</div>
);
};
export default ExecutionManagement;
@@ -0,0 +1,220 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Card,
Form,
Input,
Button,
Typography,
Space,
Alert,
Flex,
Divider
} from 'antd'
import {
UserOutlined,
LockOutlined,
DashboardOutlined,
DollarOutlined,
ProjectOutlined,
TeamOutlined
} from '@ant-design/icons'
import { useAuthStore } from '../../store/authStore'
import { useLanguageStore } from '../../store/languageStore'
import LanguageSelector from '../../components/common/LanguageSelector'
const { Title, Text } = Typography
const LoginPage: React.FC = () => {
const navigate = useNavigate()
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const { login } = useAuthStore()
const { t } = useLanguageStore()
const handleSubmit = async (values: { username: string; password: string }) => {
setLoading(true)
setError(null)
try {
await login(values.username, values.password)
navigate('/dashboard')
} catch (err) {
setError(err instanceof Error ? err.message : t('login.loginFailed'))
} finally {
setLoading(false)
}
}
// 测试账户
const testAccounts = [
{ username: 'admin', password: 'X123c321@', role: t('user.admin') },
{ username: 'finance', password: 'X123c321@', role: t('user.finance') },
{ username: 'manager', password: 'X123c321@', role: t('user.manager') },
{ username: 'employee', password: 'X123c321@', role: t('user.employee') }
]
const handleTestLogin = (username: string, password: string) => {
form.setFieldsValue({ username, password })
form.submit()
}
return (
<div style={{
minHeight: '100vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px'
}}>
<Card
className="login-card"
style={{
width: '100%',
maxWidth: 480,
borderRadius: 16,
boxShadow: '0 20px 60px rgba(0,0,0,0.3)'
}}
bodyStyle={{ padding: 40 }}
>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
{/* 标题 */}
<div style={{ textAlign: 'center' }}>
<Title level={2} style={{ marginBottom: 8 }}>
<DashboardOutlined style={{ marginRight: 12, color: '#1890ff' }} />
{t('login.title')}
</Title>
<Text type="secondary">{t('login.subtitle')}</Text>
</div>
{/* 语言选择器 V2.0 */}
<div style={{
textAlign: 'center',
padding: '12px',
background: '#f0f2f5',
borderRadius: '8px',
border: '2px solid #1890ff'
}}>
<div style={{ marginBottom: 8, color: '#1890ff', fontWeight: 'bold' }}>
🌍 / Select Language
</div>
<LanguageSelector size="large" style={{ width: '200px' }} />
</div>
{/* 错误提示 */}
{error && (
<Alert
message={error}
type="error"
showIcon
closable
onClose={() => setError(null)}
/>
)}
{/* 登录表单 */}
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
autoComplete="off"
>
<Form.Item
name="username"
label={t('login.username')}
rules={[
{ required: true, message: t('login.usernameRequired') },
{ min: 3, message: t('login.usernameMin') }
]}
>
<Input
prefix={<UserOutlined />}
placeholder={t('login.usernamePlaceholder')}
size="large"
/>
</Form.Item>
<Form.Item
name="password"
label={t('login.password')}
rules={[
{ required: true, message: t('login.passwordRequired') },
{ min: 6, message: t('login.passwordMin') }
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder={t('login.passwordPlaceholder')}
size="large"
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={loading}
size="large"
block
>
{t('login.loginButton')}
</Button>
</Form.Item>
</Form>
<Divider>{t('login.testAccounts')}</Divider>
{/* 测试账户 */}
<Space direction="vertical" style={{ width: '100%' }}>
{testAccounts.map((account, index) => (
<Card
key={index}
size="small"
hoverable
onClick={() => handleTestLogin(account.username, account.password)}
style={{ cursor: 'pointer' }}
>
<Flex justify="space-between" align="center">
<Space>
{account.role === t('user.admin') && <DashboardOutlined style={{ color: '#1890ff' }} />}
{account.role === t('user.finance') && <DollarOutlined style={{ color: '#52c41a' }} />}
{account.role === t('user.manager') && <ProjectOutlined style={{ color: '#fa8c16' }} />}
{account.role === t('user.employee') && <TeamOutlined style={{ color: '#722ed1' }} />}
<Text strong>{account.role}</Text>
</Space>
<Text type="secondary">
{t('login.username')}: {account.username} / {t('login.password')}: {account.password}
</Text>
</Flex>
</Card>
))}
</Space>
{/* 功能说明 */}
<Card size="small" type="inner">
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<Text strong>{t('menu.dashboard')}:</Text>
<Text type="secondary"> {t('features.projectManage')}</Text>
<Text type="secondary"> {t('features.advanceManage')}</Text>
<Text type="secondary"> {t('features.reimburseManage')}</Text>
<Text type="secondary"> {t('features.financeReport')}</Text>
<Text type="secondary"> {t('features.mobileSupport')}</Text>
</Space>
</Card>
{/* 技术支持 */}
<div style={{ textAlign: 'center', marginTop: 20 }}>
<Text type="secondary">
{t('login.techSupport')}
</Text>
</div>
</Space>
</Card>
</div>
)
}
export default LoginPage
@@ -0,0 +1,295 @@
import React, { useState, useEffect } from 'react';
import { Card, Typography, Button, Form, Input, Select, DatePicker, InputNumber, Radio, Space, message, Divider, Row, Col } from 'antd';
import { SaveOutlined, ArrowLeftOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import axios from 'axios';
import dayjs from 'dayjs';
import FileUpload from '../../components/FileUpload';
import { useAuthStore } from '../../store/authStore';
const { Title, Paragraph } = Typography;
const { Option } = Select;
const { TextArea } = Input;
interface Customer {
id: number;
name: string;
}
interface User {
id: number;
name: string;
department?: string;
}
const BudgetProjectCreate: React.FC = () => {
const [isMobile, setIsMobile] = useState(false);
const [loading, setLoading] = useState(false);
const [customers, setCustomers] = useState<Customer[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [form] = Form.useForm();
const [attachments, setAttachments] = useState<string[]>([]);
const [surveyPhotos, setSurveyPhotos] = useState<string[]>([]);
const navigate = useNavigate();
const { user: currentUser } = useAuthStore();
const isAdmin = currentUser?.role === 'admin';
// 检查权限,如果不是管理员,重定向到列表页面
useEffect(() => {
if (!isAdmin) {
message.error('您没有权限访问此页面');
navigate('/budget-projects');
}
}, [isAdmin, navigate]);
// const { user: currentUser } = useAuthStore();
// 表单监听值
const intermediaryFeeType = Form.useWatch('intermediary_fee_type', form);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
useEffect(() => {
fetchCustomers();
fetchUsers();
}, []);
const fetchCustomers = async () => {
try {
const res = await axios.get('/api/customers');
if (res.data.success) setCustomers(res.data.data);
} catch (error) {
console.error('获取客户列表失败:', error);
}
};
const fetchUsers = async () => {
try {
const res = await axios.get('/api/users');
if (res.data.success) setUsers(res.data.data);
} catch (error) {
console.error('获取用户列表失败:', error);
}
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setLoading(true);
const projectData = {
...values,
attachments,
survey_photos: surveyPhotos,
survey_date: values.survey_date?.format('YYYY-MM-DD'),
status: 'negotiating',
};
const res = await axios.post('/api/budget-projects', projectData, {
headers: {
'x-user-role': 'admin' // 创建预算项目需要管理员权限
}
});
if (res.data.success) {
message.success('创建成功');
navigate('/budget-projects');
}
} catch (error: any) {
if (error.response?.data?.error) {
message.error(error.response.data.error);
} else {
message.error('创建失败');
}
} finally {
setLoading(false);
}
};
return (
<div style={{ padding: isMobile ? 8 : 24 }}>
<div style={{ marginBottom: isMobile ? 12 : 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 8 }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/budget-projects')}
>
</Button>
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 0 }}></Title>
</div>
<Paragraph type="secondary"></Paragraph>
</div>
<Card>
<Form
form={form}
layout="vertical"
initialValues={{
intermediary_fee_type: 'fixed',
survey_date: dayjs(), // 勘察日期默认为当天
attachments: [],
survey_photos: []
}}
>
{/* 基本信息 */}
<Divider orientation="left"></Divider>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item
name="name"
label="项目名称"
rules={[{ required: true, message: '请输入项目名称' }]}
>
<Input placeholder="请输入项目名称" size="large" />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item
name="customer_id"
label="客户"
rules={[{ required: true, message: '请选择客户' }]}
>
<Select
placeholder="请选择客户"
showSearch
optionFilterProp="children"
size="large"
>
{customers.map((c) => (
<Option key={c.id} value={c.id}>{c.name}</Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item
name="manager_id"
label="业务经理"
rules={[{ required: true, message: '请选择业务经理' }]}
>
<Select
placeholder="请选择业务经理"
showSearch
optionFilterProp="children"
size="large"
>
{users.map((u) => (
<Option key={u.id} value={u.id}>{u.name} ({u.department || '未知部门'})</Option>
))}
</Select>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="location" label="项目地点">
<Input placeholder="请输入项目地点" size="large" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item name="survey_date" label="勘察日期">
<DatePicker style={{ width: '100%' }} size="large" />
</Form.Item>
</Col>
</Row>
{/* 居间人信息 */}
<Divider orientation="left"></Divider>
<Row gutter={16}>
<Col xs={24} md={8}>
<Form.Item name="intermediary" label="居间人">
<Input placeholder="请输入居间人姓名" size="large" />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="intermediary_fee_type" label="居间费类型">
<Radio.Group>
<Radio value="fixed"></Radio>
<Radio value="percentage"></Radio>
</Radio.Group>
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item
name="intermediary_fee_value"
label={intermediaryFeeType === 'percentage' ? '居间费比例(%)' : '居间费金额'}
>
<InputNumber
style={{ width: '100%' }}
size="large"
min={0}
precision={intermediaryFeeType === 'percentage' ? 2 : 0}
placeholder={intermediaryFeeType === 'percentage' ? '输入比例,如:5' : '输入金额'}
/>
</Form.Item>
</Col>
</Row>
{/* 项目详情 */}
<Divider orientation="left"></Divider>
<Form.Item name="customer_requirements" label="客户要求">
<TextArea rows={4} placeholder="请输入客户的具体要求" />
</Form.Item>
<Form.Item name="project_overview" label="工程概况">
<TextArea rows={4} placeholder="请输入工程概况描述" />
</Form.Item>
{/* 附件上传 */}
<Divider orientation="left"></Divider>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item label="附件上传">
<FileUpload
value={attachments}
onChange={setAttachments}
accept=".pdf,.doc,.docx,.jpg,.jpeg,.png,.xlsx,.xls"
/>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item label="勘察照片">
<FileUpload
value={surveyPhotos}
onChange={setSurveyPhotos}
accept="image/*"
/>
</Form.Item>
</Col>
</Row>
{/* 提交按钮 */}
<div style={{ marginTop: 24, textAlign: 'right' }}>
<Space>
<Button onClick={() => navigate('/budget-projects')}></Button>
<Button
type="primary"
icon={<SaveOutlined />}
loading={loading}
onClick={handleSubmit}
>
</Button>
</Space>
</div>
</Form>
</Card>
</div>
);
};
export default BudgetProjectCreate;
@@ -0,0 +1,550 @@
import React, { useState, useEffect } from 'react';
import { Card, Typography, Button, Space, Tag, message, Empty, Divider, Row, Col, List, Descriptions, Avatar, Badge, Modal, Input } from 'antd';
import { ArrowLeftOutlined, EyeOutlined, FileAddOutlined, FileOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import { useNavigate, useParams } from 'react-router-dom';
import axios from 'axios';
import dayjs from 'dayjs';
import QuotationCreateModal from './QuotationCreateModal';
import ContractCreateModal from './ContractCreateModal';
import { useAuthStore } from '../../store/authStore';
const { Title, Paragraph, Text } = Typography;
interface Quotation {
id: number;
version: number;
quotation_date: string;
amount: number;
currency: string;
status: 'draft' | 'sent' | 'approved' | 'rejected';
file_url?: string;
remark?: string;
created_at: string;
}
interface BudgetProject {
id: number;
name: string;
customer_id: number;
customer_name: string;
manager_id: number;
manager_name: string;
location?: string;
survey_date?: string;
intermediary?: string;
intermediary_fee_type?: 'fixed' | 'percentage';
intermediary_fee_value?: number;
customer_requirements?: string;
project_overview?: string;
attachments?: string[];
survey_photos?: string[];
status: 'negotiating' | 'signed' | 'unsigned';
days_in_status: number;
created_at: string;
quotations: Quotation[];
}
const CURRENCIES: Record<string, { label: string; symbol: string }> = {
CNY: { label: '人民币', symbol: '¥' },
USD: { label: '美元', symbol: '$' },
LAK: { label: '老挝基普', symbol: '₭' },
THB: { label: '泰铢', symbol: '฿' },
};
const BudgetProjectDetail: React.FC = () => {
const [project, setProject] = useState<BudgetProject | null>(null);
const [loading, setLoading] = useState(true);
const [quotationModalVisible, setQuotationModalVisible] = useState(false);
const [contractModalVisible, setContractModalVisible] = useState(false);
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [deletePassword, setDeletePassword] = useState('');
const [deleteLoading, setDeleteLoading] = useState(false);
const [quotationDeleteModalVisible, setQuotationDeleteModalVisible] = useState(false);
const [quotationDeleteId, setQuotationDeleteId] = useState<number | null>(null);
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const { user: currentUser } = useAuthStore();
const isAdmin = currentUser?.role === 'admin' || false;
useEffect(() => {
if (id) {
fetchProjectDetail();
}
}, [id]);
const fetchProjectDetail = async () => {
if (!id) return;
setLoading(true);
try {
const res = await axios.get(`/api/budget-projects/${id}`);
if (res.data.success) {
const projectData = res.data.data;
// 后端已经解析了数据,直接使用
projectData.quotations = Array.isArray(projectData.quotations) ? projectData.quotations : [];
projectData.attachments = Array.isArray(projectData.attachments) ? projectData.attachments : [];
projectData.survey_photos = Array.isArray(projectData.survey_photos) ? projectData.survey_photos : [];
setProject(projectData);
}
} catch (error) {
console.error('获取项目详情失败:', error);
message.error('获取数据失败');
} finally {
setLoading(false);
}
};
const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
negotiating: { color: 'processing', text: '商谈中' },
signed: { color: 'success', text: '已签约' },
unsigned: { color: 'error', text: '未签约' },
};
const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>;
};
const getQuotationStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
draft: { color: 'default', text: '草稿' },
sent: { color: 'processing', text: '已发送' },
approved: { color: 'success', text: '已通过' },
rejected: { color: 'error', text: '已拒绝' },
};
const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>;
};
const formatAmount = (amount: number, currency: string = 'CNY') => {
const c = CURRENCIES[currency];
const symbol = c?.symbol || '¥';
return `${symbol}${amount.toLocaleString('zh-CN')}`;
};
const handleSign = () => {
if (!project) return;
setContractModalVisible(true);
};
const handleContractSuccess = () => {
setContractModalVisible(false);
fetchProjectDetail();
};
const handleUnsigned = async () => {
if (!project) return;
try {
const res = await axios.put(`/api/budget-projects/${project.id}/unsigned`, {}, {
headers: {
'x-user-role': currentUser?.role || 'employee'
}
});
if (res.data.success) {
message.success('标记未签约成功');
fetchProjectDetail();
}
} catch (error) {
message.error('操作失败');
}
};
const handleDeleteQuotation = (quotationId: number) => {
setQuotationDeleteId(quotationId);
setDeletePassword('');
setQuotationDeleteModalVisible(true);
};
const handleQuotationDeleteConfirm = async () => {
if (!project || !quotationDeleteId) return;
// 验证密码(这里简单验证,实际项目中应该使用更安全的验证方式)
if (deletePassword !== 'X123c321@') {
message.error('密码错误');
return;
}
setDeleteLoading(true);
try {
const res = await axios.delete(`/api/budget-projects/${project.id}/quotations/${quotationDeleteId}`, {
headers: {
'x-user-role': currentUser?.role || 'employee'
}
});
if (res.data.success) {
message.success('删除成功');
setQuotationDeleteModalVisible(false);
fetchProjectDetail();
}
} catch (error) {
message.error('删除失败');
} finally {
setDeleteLoading(false);
}
};
const openQuotationModal = () => {
if (project) {
setQuotationModalVisible(true);
}
};
const handleQuotationSuccess = () => {
setQuotationModalVisible(false);
fetchProjectDetail();
};
const goToProjectManagement = () => {
if (project) {
navigate(`/projects/${project.id}`);
}
};
const handleDeleteProject = () => {
if (!project) return;
setDeletePassword('');
setDeleteModalVisible(true);
};
const handleProjectDeleteConfirm = async () => {
if (!project) return;
// 验证密码(这里简单验证,实际项目中应该使用更安全的验证方式)
if (deletePassword !== 'X123c321@') {
message.error('密码错误');
return;
}
setDeleteLoading(true);
try {
const res = await axios.delete(`/api/budget-projects/${project.id}`, {
headers: {
'x-user-role': currentUser?.role || 'employee'
}
});
if (res.data.success) {
message.success('删除成功');
setDeleteModalVisible(false);
navigate('/budget-projects');
}
} catch (error) {
message.error('删除失败');
} finally {
setDeleteLoading(false);
}
};
if (loading) {
return (
<div style={{ padding: 24 }}>
<Card loading />
</div>
);
}
if (!project) {
return (
<div style={{ padding: 24 }}>
<Card>
<Empty description="项目不存在" />
<Button type="primary" onClick={() => navigate('/budget-projects')} style={{ marginTop: 16 }}>
</Button>
</Card>
</div>
);
}
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 8 }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/budget-projects')}
>
</Button>
<Title level={2} style={{ marginBottom: 0 }}></Title>
</div>
<Paragraph type="secondary"></Paragraph>
</div>
{/* 项目基本信息 */}
<Card style={{ marginBottom: 24 }}>
<Title level={4}></Title>
<Divider />
<Row gutter={16}>
<Col xs={24} md={12}>
<Descriptions column={1} bordered>
<Descriptions.Item label="项目名称">{project.name}</Descriptions.Item>
<Descriptions.Item label="客户">{project.customer_name}</Descriptions.Item>
<Descriptions.Item label="业务经理">{project.manager_name}</Descriptions.Item>
<Descriptions.Item label="项目地点">{project.location || '-'}</Descriptions.Item>
<Descriptions.Item label="勘察日期">{project.survey_date || '-'}</Descriptions.Item>
<Descriptions.Item label="状态">{getStatusTag(project.status)}</Descriptions.Item>
<Descriptions.Item label="创建时间">{dayjs(project.created_at).format('YYYY-MM-DD HH:mm:ss')}</Descriptions.Item>
</Descriptions>
</Col>
<Col xs={24} md={12}>
<Descriptions column={1} bordered>
<Descriptions.Item label="居间人">{project.intermediary || '-'}</Descriptions.Item>
<Descriptions.Item label="居间费类型">
{project.intermediary_fee_type === 'fixed' ? '固定金额' : project.intermediary_fee_type === 'percentage' ? '百分比' : '-'}
</Descriptions.Item>
<Descriptions.Item label="居间费">
{project.intermediary_fee_value ?
project.intermediary_fee_type === 'percentage' ?
`${project.intermediary_fee_value}%` :
formatAmount(project.intermediary_fee_value, 'CNY')
: '-'}
</Descriptions.Item>
<Descriptions.Item label="客户要求">{project.customer_requirements || '-'}</Descriptions.Item>
<Descriptions.Item label="工程概况">{project.project_overview || '-'}</Descriptions.Item>
</Descriptions>
</Col>
</Row>
</Card>
{/* 附件和照片 */}
<Card style={{ marginBottom: 24 }}>
<Title level={4}></Title>
<Divider />
<Row gutter={16}>
<Col xs={24} md={12}>
<div style={{ marginBottom: 16 }}>
<Text strong>:</Text>
{project.attachments && project.attachments.length > 0 ? (
<List
style={{ marginTop: 8 }}
dataSource={project.attachments}
renderItem={(url, index) => (
<List.Item key={index}>
<Space>
<FileOutlined />
<Text ellipsis>{url.split('/').pop() || `file-${index}`}</Text>
<Button
size="small"
icon={<EyeOutlined />}
onClick={() => window.open(url, '_blank')}
>
</Button>
</Space>
</List.Item>
)}
/>
) : (
<Text type="secondary" style={{ display: 'block', marginTop: 8 }}></Text>
)}
</div>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 16 }}>
<Text strong>:</Text>
{project.survey_photos && project.survey_photos.length > 0 ? (
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{project.survey_photos.map((url, index) => (
<div key={index} style={{ position: 'relative', width: 100, height: 100, border: '1px solid #f0f0f0', borderRadius: 4, overflow: 'hidden' }}>
<img
src={url}
alt={`survey-${index}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onClick={() => window.open(url, '_blank')}
/>
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(0, 0, 0, 0.5)', color: '#fff', padding: 4, fontSize: 12, textAlign: 'center' }}>
{index + 1}
</div>
</div>
))}
</div>
) : (
<Text type="secondary" style={{ display: 'block', marginTop: 8 }}></Text>
)}
</div>
</Col>
</Row>
</Card>
{/* 报价版本列表 */}
<Card style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Title level={4}></Title>
{isAdmin && project.status === 'negotiating' && (
<Button
type="primary"
icon={<FileAddOutlined />}
onClick={openQuotationModal}
>
</Button>
)}
</div>
<Divider />
{Array.isArray(project.quotations) && project.quotations.length > 0 ? (
<List
itemLayout="horizontal"
dataSource={project.quotations}
renderItem={(quotation, index) => (
<List.Item
key={quotation.id}
actions={[
<Button
size="small"
icon={<EyeOutlined />}
onClick={() => window.open(quotation.file_url, '_blank')}
disabled={!quotation.file_url}
>
</Button>,
isAdmin && (
<Button
size="small"
danger
onClick={() => handleDeleteQuotation(quotation.id)}
>
</Button>
)
].filter(Boolean)}
>
<List.Item.Meta
avatar={<Avatar style={{ backgroundColor: '#1890ff' }}>V{quotation.version}</Avatar>}
title={
<Space>
<Text strong>V{quotation.version}</Text>
{getQuotationStatusTag(quotation.status)}
</Space>
}
description={
<Space direction="vertical">
<Text>: {dayjs(quotation.quotation_date).format('YYYY-MM-DD')}</Text>
<Text>: {formatAmount(quotation.amount, quotation.currency)}</Text>
{quotation.remark && <Text>: {quotation.remark}</Text>}
</Space>
}
/>
</List.Item>
)}
/>
) : (
<Empty description="暂无报价版本" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Card>
{/* 操作按钮 */}
<Card>
<Title level={4}></Title>
<Divider />
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{isAdmin && project.status === 'negotiating' && (
<>
<Button
type="primary"
icon={<CheckCircleOutlined />}
onClick={handleSign}
>
</Button>
<Button
danger
icon={<CloseCircleOutlined />}
onClick={handleUnsigned}
>
</Button>
</>
)}
{project.status === 'signed' && (
<Button
type="primary"
onClick={goToProjectManagement}
>
</Button>
)}
{isAdmin && (
<Button
danger
onClick={handleDeleteProject}
>
</Button>
)}
</div>
</Card>
{/* 新增报价版本弹窗 */}
<QuotationCreateModal
visible={quotationModalVisible}
project={project}
onCancel={() => setQuotationModalVisible(false)}
onSuccess={handleQuotationSuccess}
/>
{/* 合同信息录入弹窗 */}
<ContractCreateModal
visible={contractModalVisible}
projectId={project?.id || 0}
projectName={project?.name || ''}
onCancel={() => setContractModalVisible(false)}
onSuccess={handleContractSuccess}
/>
{/* 删除项目确认模态框 */}
<Modal
title="删除确认"
open={deleteModalVisible}
onOk={handleProjectDeleteConfirm}
onCancel={() => setDeleteModalVisible(false)}
confirmLoading={deleteLoading}
okText="确认删除"
cancelText="取消"
>
<div style={{ marginBottom: 16 }}>
<p></p>
<p></p>
</div>
<Input.Password
placeholder="请输入管理员密码"
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
size="large"
/>
</Modal>
{/* 删除报价版本确认模态框 */}
<Modal
title="删除确认"
open={quotationDeleteModalVisible}
onOk={handleQuotationDeleteConfirm}
onCancel={() => setQuotationDeleteModalVisible(false)}
confirmLoading={deleteLoading}
okText="确认删除"
cancelText="取消"
>
<div style={{ marginBottom: 16 }}>
<p></p>
<p></p>
</div>
<Input.Password
placeholder="请输入管理员密码"
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
size="large"
/>
</Modal>
</div>
);
};
export default BudgetProjectDetail;
@@ -0,0 +1,303 @@
import React, { useState, useEffect } from 'react';
import { Card, Typography, Button, Space, Tag, message, Empty, Radio, Modal, Input } from 'antd';
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import axios from 'axios';
import dayjs from 'dayjs';
import { useAuthStore } from '../../store/authStore';
const { Title, Paragraph, Text } = Typography;
interface Quotation {
id: number;
version: number;
quotation_date: string;
amount: number;
currency: string;
status: 'draft' | 'sent' | 'approved' | 'rejected';
file_url?: string;
remark?: string;
created_at: string;
}
interface BudgetProject {
id: number;
name: string;
customer_id: number;
customer_name: string;
manager_id: number;
manager_name: string;
location?: string;
survey_date?: string;
intermediary?: string;
intermediary_fee_type?: 'fixed' | 'percentage';
intermediary_fee_value?: number;
customer_requirements?: string;
project_overview?: string;
attachments?: string[];
survey_photos?: string[];
status: 'negotiating' | 'signed' | 'unsigned';
days_in_status: number;
created_at: string;
quotations: Quotation[];
}
type StatusFilter = 'all' | 'negotiating' | 'signed' | 'unsigned';
const CURRENCIES: Record<string, { label: string; symbol: string }> = {
CNY: { label: '人民币', symbol: '¥' },
USD: { label: '美元', symbol: '$' },
LAK: { label: '老挝基普', symbol: '₭' },
THB: { label: '泰铢', symbol: '฿' },
};
const BudgetProjectList: React.FC = () => {
const [isMobile, setIsMobile] = useState(false);
const [projects, setProjects] = useState<BudgetProject[]>([]);
const [loading, setLoading] = useState(false);
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [deleteProjectId, setDeleteProjectId] = useState<number | null>(null);
const [deletePassword, setDeletePassword] = useState('');
const [deleteLoading, setDeleteLoading] = useState(false);
const navigate = useNavigate();
const { user: currentUser } = useAuthStore();
const isAdmin = currentUser?.role === 'admin' || false;
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
useEffect(() => {
fetchProjects();
}, []);
const fetchProjects = async () => {
setLoading(true);
try {
const res = await axios.get('/api/budget-projects');
if (res.data.success) {
// 后端已经解析了数据,直接使用
const projectsWithParsedData = res.data.data.map((project: any) => {
return {
...project,
quotations: Array.isArray(project.quotations) ? project.quotations : [],
attachments: Array.isArray(project.attachments) ? project.attachments : [],
survey_photos: Array.isArray(project.survey_photos) ? project.survey_photos : []
};
});
setProjects(projectsWithParsedData);
}
} catch (error) {
console.error('获取预算项目失败:', error);
message.error('获取数据失败');
} finally {
setLoading(false);
}
};
const filteredProjects = projects.filter(p =>
statusFilter === 'all' || p.status === statusFilter
);
const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
negotiating: { color: 'processing', text: '商谈中' },
signed: { color: 'success', text: '已签约' },
unsigned: { color: 'error', text: '未签约' },
};
const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>;
};
const getQuotationStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
draft: { color: 'default', text: '草稿' },
sent: { color: 'processing', text: '已发送' },
approved: { color: 'success', text: '已通过' },
rejected: { color: 'error', text: '已拒绝' },
};
const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>;
};
const formatAmount = (amount: number, currency: string = 'CNY') => {
const c = CURRENCIES[currency];
const symbol = c?.symbol || '¥';
return `${symbol}${amount.toLocaleString('zh-CN')}`;
};
const handleDeleteProject = (projectId: number) => {
setDeleteProjectId(projectId);
setDeletePassword('');
setDeleteModalVisible(true);
};
const handleDeleteConfirm = async () => {
if (!deleteProjectId) return;
// 验证密码(这里简单验证,实际项目中应该使用更安全的验证方式)
if (deletePassword !== 'X123c321@') {
message.error('密码错误');
return;
}
setDeleteLoading(true);
try {
const res = await axios.delete(`/api/budget-projects/${deleteProjectId}`, {
headers: {
'x-user-role': currentUser?.role || 'employee'
}
});
if (res.data.success) {
message.success('删除成功');
setDeleteModalVisible(false);
fetchProjects();
}
} catch (error) {
message.error('删除失败');
} finally {
setDeleteLoading(false);
}
};
return (
<div style={{ padding: isMobile ? 8 : 24 }}>
<div style={{ marginBottom: isMobile ? 12 : 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
<div>
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}></Title>
<Paragraph type="secondary" style={{ marginBottom: 0 }}></Paragraph>
</div>
{isAdmin && (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => navigate('/budget-projects/create')}
size={isMobile ? 'middle' : 'large'}
>
</Button>
)}
</div>
</div>
{/* 状态筛选 */}
<Card style={{ marginBottom: 16 }}>
<Space>
<Text strong>:</Text>
<Radio.Group
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
optionType="button"
buttonStyle="solid"
>
<Radio.Button value="all"></Radio.Button>
<Radio.Button value="negotiating"></Radio.Button>
<Radio.Button value="signed"></Radio.Button>
<Radio.Button value="unsigned"></Radio.Button>
</Radio.Group>
</Space>
</Card>
{/* 项目列表 */}
<Card loading={loading}>
{filteredProjects.length === 0 ? (
<Empty description="暂无数据" />
) : (
<div>
{filteredProjects.map((project) => (
<div
key={project.id}
style={{
border: '1px solid #f0f0f0',
borderRadius: 8,
marginBottom: 16,
overflow: 'hidden'
}}
>
{/* 项目头部 */}
<div
style={{
padding: '16px 20px',
background: '#fafafa',
borderBottom: '1px solid #f0f0f0',
cursor: 'pointer'
}}
onClick={() => navigate(`/budget-projects/${project.id}`)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: 12 }}>
<Space size="middle">
<Text strong style={{ fontSize: 16 }}>{project.name}</Text>
</Space>
<Space>
{getStatusTag(project.status)}
<Text type="secondary">{project.days_in_status}</Text>
{isAdmin && (
<Button
danger
size="small"
onClick={(e) => {
e.stopPropagation();
handleDeleteProject(project.id);
}}
>
</Button>
)}
</Space>
</div>
<div style={{ marginTop: 12 }}>
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<Text type="secondary">: {project.customer_name}</Text>
<Text type="secondary">: {project.manager_name}</Text>
{project.intermediary && (
<Text type="secondary">
: {project.intermediary}
{project.intermediary_fee_value && (
<span> : {formatAmount(project.intermediary_fee_value, 'CNY')}</span>
)}
</Text>
)}
</Space>
</div>
</div>
</div>
))}
</div>
)}
</Card>
{/* 删除确认模态框 */}
<Modal
title="删除确认"
open={deleteModalVisible}
onOk={handleDeleteConfirm}
onCancel={() => setDeleteModalVisible(false)}
confirmLoading={deleteLoading}
okText="确认删除"
cancelText="取消"
>
<div style={{ marginBottom: 16 }}>
<p></p>
<p></p>
</div>
<Input.Password
placeholder="请输入管理员密码"
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
size="large"
/>
</Modal>
</div>
);
};
export default BudgetProjectList;
@@ -0,0 +1,201 @@
import React, { useState, useEffect } from 'react';
import { Modal, Form, Input, DatePicker, InputNumber, Select, Space, message } from 'antd';
import dayjs from 'dayjs';
import axios from 'axios';
interface ContractCreateModalProps {
visible: boolean;
projectId: number;
projectName: string;
onCancel: () => void;
onSuccess: () => void;
}
const ContractCreateModal: React.FC<ContractCreateModalProps> = ({
visible,
projectId,
projectName,
onCancel,
onSuccess
}) => {
const [form] = Form.useForm();
const [contractAmount, setContractAmount] = useState(0);
// 生成默认的合同编号(包含时间戳确保唯一性)
const today = dayjs();
const dateStr = today.format('YYYYMMDD');
const timeStr = today.format('HHmmss');
const defaultContractCode = `CONTRACT-${dateStr}-${timeStr}`;
useEffect(() => {
if (visible) {
form.setFieldsValue({
contract_code: defaultContractCode,
project_name: projectName,
contract_method: 'lump_sum',
currency: 'CNY',
contract_amount: 0,
contract_period: 180
});
setContractAmount(0);
}
}, [visible, form, projectName]);
// 处理工期变化
const handlePeriodChange = (value: number) => {
// 只需要设置工期天数,不需要计算开始和结束日期
};
// 提交表单
const handleSubmit = async (values: any) => {
// 构建提交数据(简化版)
const submitData = {
contract_code: values.contract_code,
project_name: values.project_name,
contract_method: values.contract_method || 'lump_sum',
currency: values.currency || 'CNY',
contract_amount: values.contract_amount || 0,
contract_period: values.contract_period || 180,
warranty_deposit_percentage: 5, // 默认5%
warranty_period: 12, // 默认12个月
// 其他字段留空,后续在项目管理中补充
project_overview: '',
other_requirements: '',
contract_file: null,
payment_nodes: [],
unit_price_items: []
};
console.log('提交的合同信息:', submitData);
try {
const res = await axios.put(`/api/budget-projects/${projectId}/sign`, submitData, {
headers: {
'x-user-role': 'admin' // 签约操作需要管理员权限
}
});
console.log('API响应:', res);
if (res.data.success) {
message.success('签约成功,项目已自动创建');
onSuccess();
onCancel();
} else {
message.error(res.data.message || '操作失败');
}
} catch (error: any) {
console.error('签约失败:', error);
console.error('错误响应:', error.response);
const errorMessage = error.response?.data?.message || error.message || '操作失败';
message.error(errorMessage);
}
};
return (
<Modal
title="快速签约"
open={visible}
onOk={() => form.submit()}
onCancel={onCancel}
width={600}
okText="确认签约"
cancelText="取消"
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
>
{/* 基本信息 */}
<Form.Item
name="contract_code"
label="合同编号"
rules={[{ required: true, message: '请输入合同编号' }]}
>
<Input placeholder="请输入合同编号" />
</Form.Item>
<Form.Item
name="project_name"
label="项目名称"
rules={[{ required: true, message: '请输入项目名称' }]}
>
<Input placeholder="请输入项目名称" />
</Form.Item>
<Form.Item
name="contract_method"
label="承包方式"
rules={[{ required: true, message: '请选择承包方式' }]}
>
<Select
placeholder="请选择承包方式"
options={[
{ value: 'lump_sum', label: '总价包干' },
{ value: 'unit_price', label: '单价结算' }
]}
/>
</Form.Item>
<Form.Item
name="currency"
label="币种"
rules={[{ required: true, message: '请选择币种' }]}
>
<Select
placeholder="请选择币种"
options={[
{ value: 'CNY', label: '人民币' },
{ value: 'USD', label: '美元' },
{ value: 'LAK', label: '老挝基普' },
{ value: 'THB', label: '泰铢' }
]}
/>
</Form.Item>
<Form.Item
name="contract_amount"
label="总价"
rules={[
{
required: true,
message: '请输入总价'
}
]}
>
<InputNumber
style={{ width: '100%' }}
min={0}
placeholder="请输入总价"
formatter={(value) => `¥ ${value}`}
parser={(value) => value.replace(/¥\s?|(,*)/g, '')}
onChange={(value) => setContractAmount(value || 0)}
/>
</Form.Item>
{/* 工期 */}
<Form.Item
name="contract_period"
label="工期(天)"
rules={[{ required: true, message: '请输入工期' }]}
>
<InputNumber
style={{ width: '100%' }}
min={1}
placeholder="请输入工期(天)"
onChange={handlePeriodChange}
/>
</Form.Item>
<div style={{ marginTop: 16, padding: 16, background: '#f5f5f5', borderRadius: 8 }}>
<p style={{ margin: 0, fontSize: 14, color: '#666' }}>
</p>
</div>
</Form>
</Modal>
);
};
export default ContractCreateModal;
@@ -0,0 +1,257 @@
import React, { useState, useEffect } from 'react';
import { Modal, Form, Input, DatePicker, InputNumber, Select, Upload, Button, message } from 'antd';
import { UploadOutlined, DeleteOutlined, FileOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import axios from 'axios';
const { Option } = Select;
interface Quotation {
id: number;
version: number;
quotation_date: string;
amount: number;
currency: string;
status: 'draft' | 'sent' | 'approved' | 'rejected';
file_url?: string;
remark?: string;
}
interface BudgetProject {
id: number;
name: string;
quotations: Quotation[];
}
interface QuotationCreateModalProps {
visible: boolean;
project: BudgetProject | null;
onCancel: () => void;
onSuccess: () => void;
}
const CURRENCIES = [
{ value: 'CNY', label: '人民币', symbol: '¥' },
{ value: 'USD', label: '美元', symbol: '$' },
{ value: 'LAK', label: '老挝基普', symbol: '₭' },
{ value: 'THB', label: '泰铢', symbol: '฿' },
];
const QuotationCreateModal: React.FC<QuotationCreateModalProps> = ({
visible,
project,
onCancel,
onSuccess,
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [uploadedFile, setUploadedFile] = useState<{ url: string; name: string } | null>(null);
// 计算下一个版本号
const nextVersion = project?.quotations && Array.isArray(project.quotations) && project.quotations.length > 0
? Math.max(...project.quotations.map(q => q.version || 0)) + 1
: 1;
useEffect(() => {
if (visible) {
form.resetFields();
form.setFieldsValue({
quotation_date: dayjs(),
currency: 'CNY',
version: nextVersion,
});
setUploadedFile(null);
}
}, [visible, nextVersion, form]);
const handleUpload = async (options: any) => {
const { file, onSuccess: onUploadSuccess, onError } = options;
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/upload/single', {
method: 'POST',
body: formData,
});
const result = await response.json();
if (result.success) {
message.success('上传成功');
setUploadedFile({ url: result.data.url, name: file.name });
onUploadSuccess(result.data, file);
} else {
message.error(result.error || '上传失败');
onError?.(new Error(result.error));
}
} catch (error: any) {
message.error('上传失败');
onError?.(error);
}
};
const handleRemoveFile = () => {
setUploadedFile(null);
};
const handleSubmit = async () => {
if (!project) return;
try {
const values = await form.validateFields();
setLoading(true);
const quotationData = {
...values,
quotation_date: values.quotation_date.format('YYYY-MM-DD'),
file_url: uploadedFile?.url,
version: nextVersion,
};
const res = await axios.post(`/api/budget-projects/${project.id}/quotations`, quotationData, {
headers: {
'x-user-role': 'admin' // 创建报价版本需要管理员权限
}
});
if (res.data.success) {
message.success('新增报价版本成功');
onSuccess();
}
} catch (error: any) {
if (error.response?.data?.error) {
message.error(error.response.data.error);
} else {
message.error('创建失败');
}
} finally {
setLoading(false);
}
};
const getFileIcon = () => (
<div
style={{
width: 60,
height: 60,
border: '1px solid #d9d9d9',
borderRadius: 4,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#fafafa',
}}
>
<FileOutlined style={{ fontSize: 24, color: '#1890ff' }} />
</div>
);
return (
<Modal
title="新增报价版本"
open={visible}
onOk={handleSubmit}
onCancel={onCancel}
width={600}
confirmLoading={loading}
okText="保存"
cancelText="取消"
>
<Form form={form} layout="vertical">
{/* 项目信息展示 */}
<div style={{
padding: 16,
background: '#f5f5f5',
borderRadius: 8,
marginBottom: 24
}}>
<div style={{ marginBottom: 8 }}>
<span style={{ color: '#666' }}>: </span>
<span style={{ fontWeight: 500 }}>{project?.name}</span>
</div>
<div>
<span style={{ color: '#666' }}>: </span>
<span style={{ fontWeight: 500 }}>V{nextVersion - 1}</span>
<span style={{ color: '#999', marginLeft: 8 }}>
( V{nextVersion})
</span>
</div>
</div>
<Form.Item
name="quotation_date"
label="报价日期"
rules={[{ required: true, message: '请选择报价日期' }]}
>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="amount"
label="报价金额"
rules={[{ required: true, message: '请输入报价金额' }]}
>
<InputNumber
style={{ width: '100%' }}
min={0}
precision={2}
placeholder="请输入报价金额"
addonAfter="元"
/>
</Form.Item>
<Form.Item
name="currency"
label="币种"
rules={[{ required: true, message: '请选择币种' }]}
>
<Select placeholder="请选择币种">
{CURRENCIES.map((c) => (
<Option key={c.value} value={c.value}>
{c.label} ({c.symbol})
</Option>
))}
</Select>
</Form.Item>
<Form.Item label="报价文件">
{uploadedFile ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{getFileIcon()}
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500 }}>{uploadedFile.name}</div>
<a href={uploadedFile.url} target="_blank" rel="noopener noreferrer">
</a>
</div>
<Button
danger
icon={<DeleteOutlined />}
onClick={handleRemoveFile}
size="small"
>
</Button>
</div>
) : (
<Upload
accept=".pdf,.doc,.docx,.xlsx,.xls,.jpg,.jpeg,.png"
customRequest={handleUpload}
showUploadList={false}
maxCount={1}
>
<Button icon={<UploadOutlined />}></Button>
</Upload>
)}
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={3} placeholder="请输入备注信息" />
</Form.Item>
</Form>
</Modal>
);
};
export default QuotationCreateModal;
@@ -0,0 +1,4 @@
export { default as BudgetProjectList } from "./BudgetProjectList";
export { default as BudgetProjectCreate } from "./BudgetProjectCreate";
export { default as QuotationCreateModal } from "./QuotationCreateModal";
export { default } from "./BudgetProjectList";
@@ -0,0 +1,264 @@
import React, { useState, useEffect } from 'react';
import { Card, Typography, Button, Space, Tag, Progress, Empty, Spin, message, Row, Col, Divider } from 'antd';
import { FileTextOutlined, CameraOutlined, ScheduleOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import axios from 'axios';
import dayjs from 'dayjs';
import { useAuthStore } from '../../store/authStore';
const { Title, Paragraph, Text } = Typography;
// 天气图标映射
const WEATHER_ICONS: Record<string, string> = {
sunny: '☀️ 晴',
cloudy: '⛅ 多云',
rainy: '🌧️ 雨',
stormy: '⛈️ 雷暴',
windy: '💨 大风',
};
// 项目状态映射
const STATUS_CONFIG: Record<string, { color: string; text: string }> = {
pending: { color: 'default', text: '待开始' },
active: { color: 'processing', text: '施工中' },
completed: { color: 'success', text: '完工' },
suspended: { color: 'warning', text: '暂停' },
cancelled: { color: 'error', text: '已取消' },
};
interface Project {
id: number;
project_code: string;
name: string;
customer_name: string;
status: string;
start_date: string;
expected_end_date: string;
contract_amount: number;
currency: string;
manager_name: string;
progress_percentage: number;
latest_log?: {
id: number;
log_date: string;
weather: string;
work_content: string;
};
}
const ConstructionList: React.FC = () => {
const [isMobile, setIsMobile] = useState(false);
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const navigate = useNavigate();
const { user } = useAuthStore();
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
useEffect(() => {
fetchProjects();
}, []);
const fetchProjects = async () => {
setLoading(true);
try {
const res = await axios.get('/api/construction/my-projects');
if (res.data.success) {
setProjects(res.data.data);
}
} catch (error) {
console.error('获取项目列表失败:', error);
message.error('获取项目列表失败');
} finally {
setLoading(false);
}
};
const formatCurrency = (amount: number, currency: string = 'CNY') => {
const symbols: Record<string, string> = {
CNY: '¥',
USD: '$',
LAK: '₭',
THB: '฿',
};
const symbol = symbols[currency] || '¥';
return `${symbol}${(amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 0 })}`;
};
const isToday = (dateStr: string) => {
return dayjs(dateStr).isSame(dayjs(), 'day');
};
const renderProjectCard = (project: Project) => {
const statusConfig = STATUS_CONFIG[project.status] || STATUS_CONFIG.pending;
const hasTodayLog = project.latest_log && isToday(project.latest_log.log_date);
return (
<Card
key={project.id}
style={{
marginBottom: isMobile ? 12 : 16,
borderRadius: 12,
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
}}
styles={{ body: { padding: isMobile ? 16 : 20 } }}
>
{/* 项目头部 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span style={{ fontSize: 20 }}>🎯</span>
<Text strong style={{ fontSize: isMobile ? 15 : 16 }}>{project.name}</Text>
</div>
<Text type="secondary" style={{ fontSize: 13 }}>
: {project.customer_name || '未指定'}
</Text>
</div>
<Tag color={statusConfig.color} style={{ marginLeft: 8 }}>
{statusConfig.text}
</Tag>
</div>
{/* 进度条 */}
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
<Text strong style={{ fontSize: 12 }}>{Math.round((project.progress_percentage || 0))}%</Text>
</div>
<Progress
percent={Math.round((project.progress_percentage || 0))}
showInfo={false}
strokeColor={{
'0%': '#108ee9',
'100%': '#87d068',
}}
trailColor="#f0f0f0"
/>
</div>
{/* 最新日志状态 */}
{project.status === 'active' && (
<div style={{
padding: '8px 12px',
background: hasTodayLog ? '#f6ffed' : '#fff7e6',
borderRadius: 8,
marginBottom: 12,
display: 'flex',
alignItems: 'center',
gap: 8
}}>
{hasTodayLog ? (
<>
<span></span>
<Text style={{ fontSize: 13 }}>
: {project.latest_log?.work_content?.substring(0, 30)}...
</Text>
</>
) : (
<>
<span></span>
<Text type="warning" style={{ fontSize: 13 }}>今日日志: 未填写</Text>
</>
)}
</div>
)}
<Divider style={{ margin: '12px 0' }} />
{/* 操作按钮 */}
<Row gutter={[8, 8]}>
<Col xs={24} sm={8}>
<Button
type={project.status === 'active' && !hasTodayLog ? 'primary' : 'default'}
icon={<FileTextOutlined />}
onClick={() => navigate(`/construction/${project.id}/logs`)}
block
size={isMobile ? 'large' : 'middle'}
style={{ borderRadius: 8 }}
>
{project.status === 'active' && !hasTodayLog ? '📝 写今日日志' : '📝 施工日志'}
</Button>
</Col>
<Col xs={24} sm={8}>
<Button
icon={<CameraOutlined />}
onClick={() => navigate(`/construction/${project.id}/logs`)}
block
size={isMobile ? 'large' : 'middle'}
style={{ borderRadius: 8 }}
>
📷
</Button>
</Col>
<Col xs={24} sm={8}>
<Button
icon={<ScheduleOutlined />}
onClick={() => navigate(`/construction/${project.id}/milestones`)}
block
size={isMobile ? 'large' : 'middle'}
style={{ borderRadius: 8 }}
>
📋
</Button>
</Col>
</Row>
</Card>
);
};
return (
<div style={{
padding: isMobile ? 12 : 24,
maxWidth: 1200,
margin: '0 auto'
}}>
{/* 页面标题 */}
<div style={{ marginBottom: isMobile ? 16 : 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Title level={isMobile ? 4 : 3} style={{ marginBottom: 0 }}></Title>
<Button
icon={<ReloadOutlined />}
onClick={fetchProjects}
loading={loading}
>
</Button>
</div>
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
</Paragraph>
</div>
{/* 项目列表 */}
{loading ? (
<div style={{ textAlign: 'center', padding: 60 }}>
<Spin size="large" />
<Paragraph type="secondary" style={{ marginTop: 16 }}>...</Paragraph>
</div>
) : projects.length === 0 ? (
<Card style={{ borderRadius: 12 }}>
<Empty
description="暂无施工项目"
image={Empty.PRESENTED_IMAGE_SIMPLE}
>
<Text type="secondary"></Text>
</Empty>
</Card>
) : (
<div>
<Text type="secondary" style={{ marginBottom: 12, display: 'block' }}>
({projects.length})
</Text>
{projects.map(project => renderProjectCard(project))}
</div>
)}
</div>
);
};
export default ConstructionList;
@@ -0,0 +1,441 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Card, Typography, Button, Space, Modal, Form, Input, DatePicker, Select,
Upload, message, Spin, Empty, Image, Tag, Divider, Popconfirm, Row, Col
} from 'antd';
import {
PlusOutlined, ArrowLeftOutlined, DeleteOutlined,
CameraOutlined, CalendarOutlined, CloudOutlined
} from '@ant-design/icons';
import axios from 'axios';
import dayjs from 'dayjs';
import { useAuthStore } from '../../store/authStore';
const { Title, Paragraph, Text } = Typography;
const { TextArea } = Input;
const { Option } = Select;
// 天气选项
const WEATHER_OPTIONS = [
{ value: 'sunny', label: '☀️ 晴', icon: '☀️' },
{ value: 'cloudy', label: '⛅ 多云', icon: '⛅' },
{ value: 'rainy', label: '🌧️ 雨', icon: '🌧️' },
{ value: 'stormy', label: '⛈️ 雷暴', icon: '⛈️' },
{ value: 'windy', label: '💨 大风', icon: '💨' },
];
interface Log {
id: number;
log_date: string;
weather: string;
work_content: string;
next_plan: string;
issues: string;
recorder_name: string;
photos: Photo[];
created_at: string;
}
interface Photo {
id: number;
photo_url: string;
photo_name: string;
photo_type: string;
file_size: number;
created_at: string;
}
const ConstructionLog: React.FC = () => {
const { id: projectId } = useParams<{ id: string }>();
const navigate = useNavigate();
const [isMobile, setIsMobile] = useState(false);
const [logs, setLogs] = useState<Log[]>([]);
const [loading, setLoading] = useState(true);
const [modalVisible, setModalVisible] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [projectInfo, setProjectInfo] = useState<any>(null);
const [form] = Form.useForm();
const { user } = useAuthStore();
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
useEffect(() => {
if (projectId) {
fetchLogs();
fetchProjectInfo();
}
}, [projectId]);
const fetchLogs = async () => {
setLoading(true);
try {
const res = await axios.get(`/api/projects/${projectId}/construction-logs`);
if (res.data.success) {
setLogs(res.data.data);
}
} catch (error) {
console.error('获取日志列表失败:', error);
message.error('获取日志列表失败');
} finally {
setLoading(false);
}
};
const fetchProjectInfo = async () => {
try {
const res = await axios.get(`/api/projects/${projectId}`);
if (res.data.success) {
setProjectInfo(res.data.data);
}
} catch (error) {
console.error('获取项目信息失败:', error);
}
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setSubmitting(true);
const res = await axios.post(`/api/projects/${projectId}/construction-logs`, {
log_date: values.log_date.format('YYYY-MM-DD'),
weather: values.weather,
work_content: values.work_content,
photos: '', // 暂时为空,后续添加照片上传功能
});
if (res.data.success) {
message.success('日志添加成功');
setModalVisible(false);
form.resetFields();
fetchLogs();
}
} catch (error) {
console.error('添加日志失败:', error);
message.error('添加日志失败');
} finally {
setSubmitting(false);
}
};
const handleDeleteLog = async (logId: number) => {
try {
const res = await axios.delete(`/api/construction-logs/${logId}`);
if (res.data.success) {
message.success('日志删除成功');
fetchLogs();
}
} catch (error) {
console.error('删除日志失败:', error);
message.error('删除日志失败');
}
};
// 按日期分组
const groupedLogs = logs.reduce((acc, log) => {
const month = dayjs(log.log_date).format('YYYY年MM月');
if (!acc[month]) {
acc[month] = [];
}
acc[month].push(log);
return acc;
}, {} as Record<string, Log[]>);
const getWeatherLabel = (value: string) => {
const option = WEATHER_OPTIONS.find(o => o.value === value);
return option ? option.label : value;
};
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};
const renderLogCard = (log: Log) => (
<Card
key={log.id}
style={{
marginBottom: 16,
borderRadius: 12,
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
}}
styles={{ body: { padding: isMobile ? 16 : 20 } }}
>
{/* 日志头部 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Space>
<CalendarOutlined style={{ color: '#1890ff' }} />
<Text strong style={{ fontSize: 15 }}>{dayjs(log.log_date).format('MM月DD日')}</Text>
<Tag color="blue">{getWeatherLabel(log.weather)}</Tag>
</Space>
<Space>
<Text type="secondary" style={{ fontSize: 12 }}>: {log.recorder_name || '未知'}</Text>
<Popconfirm
title="确定删除此日志?"
description="删除后无法恢复"
onConfirm={() => handleDeleteLog(log.id)}
okText="确定"
cancelText="取消"
>
<Button type="text" danger size="small" icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
</div>
{/* 工作内容 */}
{log.work_content && (
<div style={{ marginBottom: 12 }}>
<Text type="secondary" style={{ fontSize: 12 }}>:</Text>
<Paragraph style={{ margin: '4px 0 0', whiteSpace: 'pre-wrap' }}>
{log.work_content}
</Paragraph>
</div>
)}
{/* 明日计划 */}
{log.next_plan && (
<div style={{ marginBottom: 12 }}>
<Text type="secondary" style={{ fontSize: 12 }}>:</Text>
<Paragraph style={{ margin: '4px 0 0', whiteSpace: 'pre-wrap' }}>
{log.next_plan}
</Paragraph>
</div>
)}
{/* 问题记录 */}
{log.issues && (
<div style={{ marginBottom: 12 }}>
<Text type="secondary" style={{ fontSize: 12 }}>:</Text>
<Paragraph style={{ margin: '4px 0 0', color: '#fa8c16', whiteSpace: 'pre-wrap' }}>
{log.issues}
</Paragraph>
</div>
)}
{/* 照片展示 */}
{log.photos && log.photos.length > 0 && (
<div style={{ marginTop: 12 }}>
<Text type="secondary" style={{ fontSize: 12, marginBottom: 8, display: 'block' }}>
({log.photos.length}):
</Text>
<Image.PreviewGroup>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{log.photos.map(photo => (
<Image
key={photo.id}
src={photo.photo_url}
width={isMobile ? 80 : 100}
height={isMobile ? 80 : 100}
style={{
borderRadius: 8,
objectFit: 'cover',
cursor: 'pointer'
}}
placeholder={
<div style={{
width: isMobile ? 80 : 100,
height: isMobile ? 80 : 100,
background: '#f0f0f0',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<CameraOutlined style={{ fontSize: 24, color: '#bfbfbf' }} />
</div>
}
/>
))}
</div>
</Image.PreviewGroup>
</div>
)}
</Card>
);
return (
<div style={{
padding: isMobile ? 12 : 24,
maxWidth: 800,
margin: '0 auto'
}}>
{/* 页面头部 */}
<div style={{ marginBottom: isMobile ? 16 : 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/construction')}
/>
<div>
<Title level={isMobile ? 4 : 3} style={{ margin: 0 }}>
</Title>
{projectInfo && (
<Text type="secondary" style={{ fontSize: 13 }}>
{projectInfo.name}
</Text>
)}
</div>
</div>
</div>
{/* 日志列表 */}
{loading ? (
<div style={{ textAlign: 'center', padding: 60 }}>
<Spin size="large" />
</div>
) : logs.length === 0 ? (
<Card style={{ borderRadius: 12 }}>
<Empty description="暂无施工日志">
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalVisible(true)}>
</Button>
</Empty>
</Card>
) : (
<div>
{Object.entries(groupedLogs).map(([month, monthLogs]) => (
<div key={month}>
<Divider orientation="left" style={{ margin: '16px 0' }}>
<Text strong style={{ fontSize: 14 }}>{month}</Text>
</Divider>
{monthLogs.map(log => renderLogCard(log))}
</div>
))}
</div>
)}
{/* 底部添加按钮 */}
<div style={{
position: 'fixed',
bottom: 24,
right: 24,
zIndex: 100
}}>
<Button
type="primary"
icon={<PlusOutlined />}
size="large"
onClick={() => setModalVisible(true)}
style={{
borderRadius: 24,
height: 48,
paddingLeft: 24,
paddingRight: 24,
boxShadow: '0 4px 12px rgba(24, 144, 255, 0.4)'
}}
>
</Button>
</div>
{/* 新增日志弹窗 */}
<Modal
title="新增施工日志"
open={modalVisible}
onOk={handleSubmit}
onCancel={() => setModalVisible(false)}
confirmLoading={submitting}
okText="提交"
cancelText="取消"
width={isMobile ? '95%' : 500}
style={{ top: 20 }}
>
<Form
form={form}
layout="vertical"
initialValues={{
log_date: dayjs(),
weather: 'sunny'
}}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="log_date"
label="日期"
rules={[{ required: true, message: '请选择日期' }]}
>
<DatePicker
style={{ width: '100%' }}
size="large"
disabledDate={(current) => current && current > dayjs().endOf('day')}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="weather"
label="天气"
rules={[{ required: true, message: '请选择天气' }]}
>
<Select size="large">
{WEATHER_OPTIONS.map(opt => (
<Option key={opt.value} value={opt.value}>
{opt.label}
</Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item
name="work_content"
label="今日工作"
rules={[{ required: true, message: '请填写今日工作内容' }]}
>
<TextArea
rows={3}
placeholder="描述今日完成的施工工作..."
size="large"
/>
</Form.Item>
<Form.Item name="next_plan" label="明日计划">
<TextArea
rows={2}
placeholder="明日工作计划..."
size="large"
/>
</Form.Item>
<Form.Item name="issues" label="问题记录">
<TextArea
rows={2}
placeholder="遇到的问题或需要协调的事项..."
size="large"
/>
</Form.Item>
<Form.Item label="上传照片">
<Upload
listType="picture-card"
multiple
maxCount={9}
accept="image/*"
beforeUpload={() => false}
>
<div>
<CameraOutlined style={{ fontSize: 20 }} />
<div style={{ marginTop: 4, fontSize: 12 }}></div>
</div>
</Upload>
<Text type="secondary" style={{ fontSize: 12 }}>
9
</Text>
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default ConstructionLog;
@@ -0,0 +1,240 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Card, Typography, Button, Space, Tag, Spin, Empty, Timeline, Progress, Divider
} from 'antd';
import {
ArrowLeftOutlined, CheckCircleOutlined, ClockCircleOutlined,
SyncOutlined, CloseCircleOutlined
} from '@ant-design/icons';
import axios from 'axios';
import dayjs from 'dayjs';
const { Title, Paragraph, Text } = Typography;
// 节点状态配置
const STATUS_CONFIG: Record<string, {
color: string;
text: string;
icon: React.ReactNode;
timelineColor: string;
}> = {
pending: {
color: 'default',
text: '待开始',
icon: <ClockCircleOutlined />,
timelineColor: 'gray'
},
in_progress: {
color: 'processing',
text: '进行中',
icon: <SyncOutlined spin />,
timelineColor: 'blue'
},
completed: {
color: 'success',
text: '已完成',
icon: <CheckCircleOutlined />,
timelineColor: 'green'
},
cancelled: {
color: 'error',
text: '已取消',
icon: <CloseCircleOutlined />,
timelineColor: 'red'
},
};
interface Milestone {
id: number;
node_name: string;
node_type: string;
status: string;
due_date: string;
trigger_condition: string;
created_at: string;
}
const ConstructionMilestones: React.FC = () => {
const { id: projectId } = useParams<{ id: string }>();
const navigate = useNavigate();
const [isMobile, setIsMobile] = useState(false);
const [milestones, setMilestones] = useState<Milestone[]>([]);
const [projectInfo, setProjectInfo] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
useEffect(() => {
if (projectId) {
fetchMilestones();
fetchProjectInfo();
}
}, [projectId]);
const fetchMilestones = async () => {
setLoading(true);
try {
const res = await axios.get(`/api/construction/projects/${projectId}/milestones`);
if (res.data.success) {
setMilestones(res.data.data);
}
} catch (error) {
console.error('获取节点列表失败:', error);
} finally {
setLoading(false);
}
};
const fetchProjectInfo = async () => {
try {
const res = await axios.get(`/api/projects/${projectId}`);
if (res.data.success) {
setProjectInfo(res.data.data);
}
} catch (error) {
console.error('获取项目信息失败:', error);
}
};
// 计算进度
const completedCount = milestones.filter(m => m.status === 'completed').length;
const totalCount = milestones.length;
const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
const renderTimelineItem = (milestone: Milestone, index: number) => {
const statusConfig = STATUS_CONFIG[milestone.status] || STATUS_CONFIG.pending;
return (
<Timeline.Item
key={milestone.id}
color={statusConfig.timelineColor}
dot={
<span style={{ fontSize: 16 }}>
{statusConfig.icon}
</span>
}
>
<Card
size="small"
style={{
marginBottom: 8,
borderRadius: 8,
background: milestone.status === 'completed' ? '#f6ffed' : '#fff',
}}
styles={{ body: { padding: 12 } }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Text strong style={{ fontSize: 14 }}>{milestone.node_name}</Text>
{milestone.trigger_condition && (
<Paragraph
type="secondary"
style={{ margin: '4px 0 0', fontSize: 12 }}
>
{milestone.trigger_condition}
</Paragraph>
)}
</div>
<Tag color={statusConfig.color} icon={statusConfig.icon}>
{statusConfig.text}
</Tag>
</div>
{milestone.due_date && (
<Text type="secondary" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
: {dayjs(milestone.due_date).format('YYYY-MM-DD')}
</Text>
)}
</Card>
</Timeline.Item>
);
};
return (
<div style={{
padding: isMobile ? 12 : 24,
maxWidth: 800,
margin: '0 auto'
}}>
{/* 页面头部 */}
<div style={{ marginBottom: isMobile ? 16 : 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/construction')}
/>
<div>
<Title level={isMobile ? 4 : 3} style={{ margin: 0 }}>
</Title>
{projectInfo && (
<Text type="secondary" style={{ fontSize: 13 }}>
{projectInfo.name}
</Text>
)}
</div>
</div>
</div>
{/* 进度概览 */}
{!loading && milestones.length > 0 && (
<Card style={{ marginBottom: 16, borderRadius: 12 }}>
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<Text type="secondary"></Text>
<Title level={2} style={{ margin: '8px 0 0' }}>{progressPercent}%</Title>
</div>
<Progress
percent={progressPercent}
strokeColor={{
'0%': '#108ee9',
'100%': '#87d068',
}}
/>
<div style={{ display: 'flex', justifyContent: 'center', gap: 24, marginTop: 16 }}>
<div style={{ textAlign: 'center' }}>
<Text strong style={{ fontSize: 20 }}>{completedCount}</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}></Text>
</div>
<div style={{ textAlign: 'center' }}>
<Text strong style={{ fontSize: 20 }}>{totalCount - completedCount}</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}></Text>
</div>
<div style={{ textAlign: 'center' }}>
<Text strong style={{ fontSize: 20 }}>{totalCount}</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}></Text>
</div>
</div>
</Card>
)}
{/* 节点时间线 */}
{loading ? (
<div style={{ textAlign: 'center', padding: 60 }}>
<Spin size="large" />
</div>
) : milestones.length === 0 ? (
<Card style={{ borderRadius: 12 }}>
<Empty description="暂无施工节点">
<Text type="secondary"></Text>
</Empty>
</Card>
) : (
<Card style={{ borderRadius: 12 }}>
<Timeline style={{ marginTop: 16 }}>
{milestones.map((milestone, index) => renderTimelineItem(milestone, index))}
</Timeline>
</Card>
)}
</div>
);
};
export default ConstructionMilestones;
@@ -0,0 +1,4 @@
export { default as ConstructionList } from "./ConstructionList";
export { default as ConstructionLog } from "./ConstructionLog";
export { default as ConstructionMilestones } from "./ConstructionMilestones";
export { default } from "./ConstructionList";
@@ -0,0 +1,156 @@
import React, { useState, useEffect } from 'react';
import { Card, Col, Row, Statistic, Table, Typography, Tag } from 'antd';
import {
ProjectOutlined,
DollarOutlined,
FileTextOutlined,
TeamOutlined
} from '@ant-design/icons';
const { Title } = Typography;
// 模拟数据
const projectData = [
{ key: '1', name: '项目 A', status: '进行中', budget: 500000, spent: 250000 },
{ key: '2', name: '项目 B', status: '已完成', budget: 300000, spent: 280000 },
{ key: '3', name: '项目 C', status: '规划中', budget: 800000, spent: 0 },
];
const DashboardPage: React.FC = () => {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth <= 768);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
// 桌面端表格列
const desktopColumns = [
{ title: '项目名称', dataIndex: 'name', key: 'name' },
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: string) => {
const colorMap: Record<string, string> = {
'进行中': 'blue',
'已完成': 'green',
'规划中': 'orange',
};
return <Tag color={colorMap[status] || 'default'}>{status}</Tag>;
}
},
{
title: '预算',
dataIndex: 'budget',
key: 'budget',
render: (value: number) => `¥${value.toLocaleString()}`
},
{
title: '已花费',
dataIndex: 'spent',
key: 'spent',
render: (value: number) => `¥${value.toLocaleString()}`
}
];
// 移动端简化表格列
const mobileColumns = [
{ title: '项目', dataIndex: 'name', key: 'name', ellipsis: true },
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: string) => (
<Tag color={status === '已完成' ? 'green' : 'blue'} style={{ fontSize: 10 }}>
{status}
</Tag>
)
},
{
title: '预算/花费',
key: 'budget_spent',
render: (_: any, record: any) => (
<div style={{ fontSize: 12 }}>
<div>¥{(record.budget / 10000).toFixed(0)}</div>
<div style={{ color: '#888' }}>¥{(record.spent / 10000).toFixed(0)}</div>
</div>
)
}
];
return (
<div style={{ padding: isMobile ? 8 : 24 }}>
<Title level={isMobile ? 3 : 2} style={{ marginBottom: isMobile ? 12 : 24 }}>
📊
</Title>
{/* 统计卡片 - 移动端优化 */}
<Row gutter={[8, 8]} style={{ marginBottom: isMobile ? 12 : 24 }}>
<Col xs={12} sm={12} md={6}>
<Card size="small">
<Statistic
title={<span style={{ fontSize: 12 }}></span>}
value={12}
prefix={<ProjectOutlined />}
valueStyle={{ color: '#1890ff', fontSize: isMobile ? 18 : undefined }}
/>
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card size="small">
<Statistic
title={<span style={{ fontSize: 12 }}></span>}
value={85600}
prefix={<DollarOutlined />}
valueStyle={{ color: '#52c41a', fontSize: isMobile ? 18 : undefined }}
suffix="元"
/>
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card size="small">
<Statistic
title={<span style={{ fontSize: 12 }}></span>}
value={5}
prefix={<FileTextOutlined />}
valueStyle={{ color: '#faad14', fontSize: isMobile ? 18 : undefined }}
/>
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card size="small">
<Statistic
title={<span style={{ fontSize: 12 }}></span>}
value={28}
prefix={<TeamOutlined />}
valueStyle={{ color: '#722ed1', fontSize: isMobile ? 18 : undefined }}
/>
</Card>
</Col>
</Row>
{/* 项目列表 */}
<Card
title="最近项目"
size="small"
bodyStyle={{ padding: isMobile ? 8 : 24 }}
>
<Table
columns={isMobile ? mobileColumns : desktopColumns}
dataSource={projectData}
pagination={false}
scroll={isMobile ? { x: 400 } : undefined}
size={isMobile ? 'small' : 'middle'}
/>
</Card>
</div>
);
};
export default DashboardPage;
@@ -0,0 +1,227 @@
import React from 'react';
import { Card, Typography, Table, Statistic, Row, Col, Tag } from 'antd';
import { DollarOutlined, FileTextOutlined, CheckCircleOutlined } from '@ant-design/icons';
const { Title, Paragraph } = Typography;
const FinancePage: React.FC = () => {
// 统计数据
const stats = [
{
title: '本月总收入',
value: 125000,
prefix: '¥',
icon: <DollarOutlined />,
trend: '+12%',
color: '#3f8600',
},
{
title: '本月总支出',
value: 68000,
prefix: '¥',
icon: <FileTextOutlined />,
trend: '-5%',
color: '#cf1322',
},
{
title: '待审批报销',
value: 15000,
prefix: '¥',
icon: <CheckCircleOutlined />,
trend: '+3%',
color: '#1890ff',
},
];
// 财务记录数据
const dataSource = [
{
key: '1',
date: '2026-03-10',
type: '收入',
category: '项目回款',
project: '项目 A',
amount: 50000,
status: '已入账',
},
{
key: '2',
date: '2026-03-09',
type: '支出',
category: '报销',
project: '项目 B',
amount: 8000,
status: '已付款',
},
{
key: '3',
date: '2026-03-08',
type: '支出',
category: '预支',
project: '项目 C',
amount: 5000,
status: '已付款',
},
{
key: '4',
date: '2026-03-07',
type: '收入',
category: '项目回款',
project: '项目 D',
amount: 75000,
status: '已入账',
},
];
// 桌面端表格列
const desktopColumns = [
{
title: '日期',
dataIndex: 'date',
key: 'date',
sorter: (a: any, b: any) => a.date.localeCompare(b.date),
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
render: (type: string) => (
<span style={{ color: type === '收入' ? 'green' : 'red' }}>
{type === '收入' ? '↑ 收入' : '↓ 支出'}
</span>
),
},
{
title: '类别',
dataIndex: 'category',
key: 'category',
},
{
title: '项目',
dataIndex: 'project',
key: 'project',
},
{
title: '金额',
dataIndex: 'amount',
key: 'amount',
render: (amount: number) => `¥${amount.toLocaleString()}`,
sorter: (a: any, b: any) => a.amount - b.amount,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: string) => {
const colorMap: Record<string, string> = {
'已入账': 'green',
'已付款': 'blue',
'处理中': 'orange',
};
return <Tag color={colorMap[status] || 'default'}>{status}</Tag>;
},
},
];
// 移动端简化表格列
const mobileColumns = [
{
title: '日期',
dataIndex: 'date',
key: 'date',
width: 100,
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
width: 60,
render: (type: string) => (
<span style={{ color: type === '收入' ? 'green' : 'red', fontSize: 12 }}>
{type === '收入' ? '↑' : '↓'}
</span>
),
},
{
title: '金额',
dataIndex: 'amount',
key: 'amount',
render: (amount: number) => (
<div style={{ fontWeight: 'bold' }}>¥{amount.toLocaleString()}</div>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: string) => (
<Tag color={status === '已入账' ? 'green' : 'blue'} style={{ fontSize: 10 }}>
{status}
</Tag>
),
},
];
const [isMobile, setIsMobile] = React.useState(false);
React.useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth <= 768);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
return (
<div>
<div style={{ marginBottom: 16 }}>
<Title level={3} style={{ marginBottom: 8 }}></Title>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
</Paragraph>
</div>
{/* 统计卡片 - 移动端优化 */}
<Row gutter={[8, 8]} style={{ marginBottom: 16 }}>
{stats.map((stat, index) => (
<Col xs={24} sm={8} key={index}>
<Card size="small" style={{ textAlign: 'center' }}>
<Statistic
title={<span style={{ fontSize: 12 }}>{stat.title}</span>}
value={stat.value}
prefix={stat.prefix}
suffix={stat.trend}
valueStyle={{
color: stat.color,
fontSize: isMobile ? 18 : undefined
}}
/>
</Card>
</Col>
))}
</Row>
{/* 财务明细表 */}
<Card
title="财务明细"
size="small"
bodyStyle={{ padding: isMobile ? 8 : 24 }}
>
<Table
dataSource={dataSource}
columns={isMobile ? mobileColumns : desktopColumns}
pagination={{
pageSize: 5,
size: isMobile ? 'small' : 'default'
}}
scroll={isMobile ? { x: 500 } : undefined}
size={isMobile ? 'small' : 'middle'}
/>
</Card>
</div>
);
};
export default FinancePage;
@@ -0,0 +1,828 @@
import React, { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { Tabs, Card, Descriptions, Button, Table, Tag, Progress, Space, message, Spin, Select, Menu, Dropdown, Modal, Form, Input, InputNumber, Switch, Upload } from 'antd'
import { DownOutlined, UploadOutlined } from '@ant-design/icons'
import {
ArrowLeftOutlined,
EditOutlined,
InfoCircleOutlined,
FileTextOutlined,
TeamOutlined,
DatabaseOutlined,
CheckCircleOutlined,
FileSearchOutlined,
DollarOutlined,
SafetyOutlined
} from '@ant-design/icons'
import axios from 'axios'
const { TabPane } = Tabs
interface Project {
id: number
project_code: string
name: string
customer_id: number
customer_name: string
status: string
budget: string
spent: string
start_date: string
end_date: string
description: string
contract_type: string
contract_amount: string
currency: string
contract_days: number
project_manager_id: number
manager_name: string
location: string
work_quantity: string
project_situation: string
settlement_type: string
has_warranty: boolean
warranty_amount: string
warranty_percent: string
warranty_months: number
warranty_start_date: string
warranty_end_date: string
warranty_status: string
}
const ProjectDetail: React.FC = () => {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [project, setProject] = useState<Project | null>(null)
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState('basic')
const [isMobile, setIsMobile] = useState(false)
const [contractEditModalVisible, setContractEditModalVisible] = useState(false)
const [contractForm] = Form.useForm()
const [unitPriceItems, setUnitPriceItems] = useState([
{ key: '1', name: '项目1', unit: '个', quantity: 10, price: 100, total: 1000 },
{ key: '2', name: '项目2', unit: '米', quantity: 50, price: 20, total: 1000 }
])
const [contractTotal, setContractTotal] = useState(0)
const [settlementType, setSettlementType] = useState('lump_sum')
const [paymentNodes, setPaymentNodes] = useState([
{ key: '1', name: '预付款', percentage: 30, amount: 0, status: 'pending' },
{ key: '2', name: '进度款', percentage: 60, amount: 0, status: 'pending' },
{ key: '3', name: '质保金', percentage: 10, amount: 0, status: 'pending' }
])
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth <= 768)
}
checkMobile()
window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile)
}, [])
useEffect(() => {
fetchProject()
}, [id])
// 当project加载完成后,设置合同相关的默认值
useEffect(() => {
if (project) {
setSettlementType(project.settlement_type || 'lump_sum')
setContractTotal(parseFloat(project.contract_amount || '0'))
// 初始化付款节点金额
const initialNodes = paymentNodes.map(node => ({
...node,
amount: Math.round((parseFloat(project.contract_amount || '0') * node.percentage) / 100)
}))
setPaymentNodes(initialNodes)
// 设置表单默认值
contractForm.setFieldsValue({
project_overview: project.project_situation || project.description || '',
settlement_type: project.settlement_type || 'lump_sum',
contract_total: parseFloat(project.contract_amount || '0'),
tax_included: false
})
}
}, [project, contractForm])
const [contracts, setContracts] = useState<any[]>([])
const [subcontracts, setSubcontracts] = useState<any[]>([])
const [materials, setMaterials] = useState<any[]>([])
const [milestones, setMilestones] = useState<any[]>([])
const [finances, setFinances] = useState<any[]>([])
const [warrantyDeposits, setWarrantyDeposits] = useState<any[]>([])
const [constructionLogs, setConstructionLogs] = useState<any[]>([])
const fetchProject = async () => {
try {
const response = await axios.get(`/api/projects/${id}`)
if (response.data.success) {
setProject(response.data.data)
}
} catch (error) {
message.error('获取项目信息失败')
} finally {
setLoading(false)
}
}
const fetchProjectData = async () => {
try {
// 获取合同信息
const contractsResponse = await axios.get(`/api/projects/${id}/contracts`)
if (contractsResponse.data.success) {
setContracts(contractsResponse.data.data)
}
// 获取分包信息
const subcontractsResponse = await axios.get(`/api/projects/${id}/subcontracts`)
if (subcontractsResponse.data.success) {
setSubcontracts(subcontractsResponse.data.data)
}
// 获取材料信息
const materialsResponse = await axios.get(`/api/projects/${id}/materials`)
if (materialsResponse.data.success) {
setMaterials(materialsResponse.data.data)
}
// 获取施工节点
const milestonesResponse = await axios.get(`/api/projects/${id}/milestones`)
if (milestonesResponse.data.success) {
setMilestones(milestonesResponse.data.data)
}
// 获取财务信息
const financesResponse = await axios.get(`/api/projects/${id}/finances`)
if (financesResponse.data.success) {
setFinances(financesResponse.data.data)
}
// 获取质保金信息
const warrantyDepositsResponse = await axios.get(`/api/projects/${id}/warranty-deposits`)
if (warrantyDepositsResponse.data.success) {
setWarrantyDeposits(warrantyDepositsResponse.data.data)
}
// 获取施工日志
const constructionLogsResponse = await axios.get(`/api/projects/${id}/construction-logs`)
if (constructionLogsResponse.data.success) {
setConstructionLogs(constructionLogsResponse.data.data)
}
} catch (error) {
console.error('获取项目数据失败:', error)
}
}
useEffect(() => {
if (id) {
fetchProjectData()
}
}, [id])
// 监听单价项目变更,更新合同总价
useEffect(() => {
calculateContractTotal()
contractForm.setFieldsValue({ contract_total: contractTotal })
}, [unitPriceItems])
// 监听结算方式变更,更新合同总价字段状态
useEffect(() => {
if (settlementType === 'unit_price') {
calculateContractTotal()
contractForm.setFieldsValue({ contract_total: contractTotal })
}
}, [settlementType])
const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
planning: { color: 'blue', text: '规划中' },
in_progress: { color: 'processing', text: '进行中' },
completed: { color: 'success', text: '已完成' },
suspended: { color: 'warning', text: '已暂停' },
}
const config = statusMap[status] || { color: 'default', text: status }
return <Tag color={config.color}>{config.text}</Tag>
}
// 计算合同总价
const calculateContractTotal = () => {
const total = unitPriceItems.reduce((sum, item) => sum + (item.total || 0), 0)
setContractTotal(total)
}
// 处理单价项目变更
const handleUnitPriceItemChange = (index: number, field: string, value: any) => {
const newItems = [...unitPriceItems]
newItems[index] = { ...newItems[index], [field]: value }
// 计算单项总价
if (field === 'quantity' || field === 'price') {
newItems[index].total = (newItems[index].quantity || 0) * (newItems[index].price || 0)
}
setUnitPriceItems(newItems)
calculateContractTotal()
}
// 处理结算方式变更
const handleSettlementTypeChange = (value: string) => {
setSettlementType(value)
contractForm.setFieldsValue({ settlement_type: value })
}
// 处理付款节点比例变化
const handlePaymentNodePercentageChange = (index: number, value: number) => {
const newNodes = [...paymentNodes]
newNodes[index].percentage = value
newNodes[index].amount = Math.round((contractTotal * value) / 100)
setPaymentNodes(newNodes)
}
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}>
<Spin size="large" />
</div>
)
}
if (!project) {
return (
<Card>
<div style={{ textAlign: 'center', padding: 40 }}>
<Button type="link" onClick={() => navigate('/projects')}></Button>
</div>
</Card>
)
}
// 基本信息 Tab
const BasicInfoTab = () => (
<Card title="项目基本信息" extra={<Button icon={<EditOutlined />}></Button>}>
<Descriptions column={2} bordered>
<Descriptions.Item label="项目编号">{project.project_code}</Descriptions.Item>
<Descriptions.Item label="项目名称">{project.name}</Descriptions.Item>
<Descriptions.Item label="客户">{project.customer_name}</Descriptions.Item>
<Descriptions.Item label="项目经理">{project.manager_name}</Descriptions.Item>
<Descriptions.Item label="项目地点">{project.location || '-'}</Descriptions.Item>
<Descriptions.Item label="项目状态">{getStatusTag(project.status)}</Descriptions.Item>
<Descriptions.Item label="开工日期">{project.start_date?.split('T')[0] || '-'}</Descriptions.Item>
<Descriptions.Item label="完工日期">{project.end_date?.split('T')[0] || '-'}</Descriptions.Item>
<Descriptions.Item label="合同工期">{project.contract_days} </Descriptions.Item>
<Descriptions.Item label="结算方式">
{project.settlement_type === 'lump_sum' ? '总价包干' : '单价结算'}
</Descriptions.Item>
<Descriptions.Item label="工程概况" span={2}>
{project.project_situation || project.description || '-'}
</Descriptions.Item>
<Descriptions.Item label="创建时间" span={2}>
{new Date().toLocaleDateString()}
</Descriptions.Item>
</Descriptions>
</Card>
)
// 合同详情 Tab
const ContractTab = () => (
<Card title="合同详情" extra={<Button icon={<EditOutlined />} onClick={() => setContractEditModalVisible(true)}></Button>}>
<Descriptions column={2} bordered style={{ marginBottom: 24 }}>
<Descriptions.Item label="合同金额">
{project.currency} {parseFloat(project.contract_amount || '0').toLocaleString()}
</Descriptions.Item>
<Descriptions.Item label="币种">{project.currency}</Descriptions.Item>
<Descriptions.Item label="合同类型">
{project.contract_type === 'lump_sum' ? '总价包干' : '单价合同'}
</Descriptions.Item>
<Descriptions.Item label="结算方式">
{project.settlement_type === 'lump_sum' ? '总价包干' : '单价结算'}
</Descriptions.Item>
</Descriptions>
<Card type="inner" title="付款节点" style={{ marginBottom: 24 }}>
<Table
dataSource={milestones.map(item => ({
...item,
key: item.id
}))}
columns={[
{ title: '节点名称', dataIndex: 'milestone_name', key: 'milestone_name' },
{ title: '比例', dataIndex: 'percentage', key: 'percentage', render: (v: number) => `${v}%` },
{ title: '金额', dataIndex: 'amount', key: 'amount', render: (v: number) => `¥${v.toLocaleString()}` },
{ title: '完成进度', dataIndex: 'completion_progress', key: 'completion_progress', render: (v: number) => <Progress percent={v} size="small" /> },
{ title: '状态', dataIndex: 'status', key: 'status', render: (v: string) => <Tag color={v === 'completed' ? 'success' : v === 'in_progress' ? 'processing' : 'default'}>{v === 'completed' ? '已完成' : v === 'in_progress' ? '进行中' : '待开始'}</Tag> },
]}
locale={{ emptyText: '暂无节点记录' }}
/>
</Card>
<Card type="inner" title="质保金设置">
<Descriptions column={2}>
<Descriptions.Item label="是否有质保金">{project.has_warranty ? '是' : '否'}</Descriptions.Item>
<Descriptions.Item label="质保金比例">{project.warranty_percent || 5}%</Descriptions.Item>
<Descriptions.Item label="质保金金额">¥{parseFloat(project.warranty_amount || '0').toLocaleString()}</Descriptions.Item>
<Descriptions.Item label="质保期限">{project.warranty_months} </Descriptions.Item>
<Descriptions.Item label="到期日期">{project.warranty_end_date?.split('T')[0] || '-'}</Descriptions.Item>
<Descriptions.Item label="质保金状态">
<Tag color={project.warranty_status === 'released' ? 'success' : 'default'}>
{project.warranty_status === 'released' ? '已释放' : '待释放'}
</Tag>
</Descriptions.Item>
</Descriptions>
</Card>
</Card>
)
// 分包管理 Tab
const SubcontractTab = () => (
<Card title="分包管理" extra={<Button type="primary"></Button>}>
<Table
dataSource={subcontracts.map(item => ({
...item,
key: item.id
}))}
columns={[
{ title: '分包商', dataIndex: 'subcontractor_name', key: 'subcontractor_name' },
{ title: '合同金额', dataIndex: 'contract_amount', key: 'contract_amount', render: (v: number) => `¥${v.toLocaleString()}` },
{ title: '已付款', dataIndex: 'paid_amount', key: 'paid_amount', render: (v: number) => `¥${v.toLocaleString()}` },
{ title: '状态', dataIndex: 'status', key: 'status', render: (v: string) => <Tag color={v === 'completed' ? 'success' : 'default'}>{v === 'completed' ? '已完成' : '进行中'}</Tag> },
{ title: '操作', key: 'action', render: () => <Button size="small"></Button> },
]}
locale={{ emptyText: '暂无分包记录' }}
/>
</Card>
)
// 材料管理 Tab
const MaterialTab = () => (
<Card title="材料管理">
<Table
dataSource={materials.map(item => ({
...item,
key: item.id
}))}
columns={[
{ title: '材料名称', dataIndex: 'product_name', key: 'product_name' },
{ title: '单位', dataIndex: 'unit', key: 'unit' },
{ title: '预算量', dataIndex: 'budget_quantity', key: 'budget_quantity' },
{ title: '采购量', dataIndex: 'purchase_quantity', key: 'purchase_quantity' },
{ title: '使用量', dataIndex: 'used_quantity', key: 'used_quantity' },
{ title: '均价', dataIndex: 'average_price', key: 'average_price', render: (v: number) => `¥${v.toLocaleString()}` },
{ title: '总价', dataIndex: 'total_amount', key: 'total_amount', render: (v: number) => `¥${v.toLocaleString()}` },
]}
locale={{ emptyText: '暂无材料记录' }}
/>
</Card>
)
// 施工节点 Tab
const MilestoneTab = () => (
<Card title="施工节点">
<Card type="inner" title="合同付款节点" style={{ marginBottom: 16 }}>
<Table
dataSource={milestones.map(item => ({
...item,
key: item.id
}))}
columns={[
{ title: '节点名称', dataIndex: 'milestone_name', key: 'milestone_name' },
{ title: '比例', dataIndex: 'percentage', key: 'percentage', render: (v: number) => `${v}%` },
{ title: '金额', dataIndex: 'amount', key: 'amount', render: (v: number) => `¥${v.toLocaleString()}` },
{ title: '完成进度', dataIndex: 'completion_progress', key: 'completion_progress', render: (v: number) => <Progress percent={v} size="small" /> },
{ title: '状态', dataIndex: 'status', key: 'status', render: (v: string) => <Tag color={v === 'completed' ? 'success' : v === 'in_progress' ? 'processing' : 'default'}>{v === 'completed' ? '已完成' : v === 'in_progress' ? '进行中' : '待开始'}</Tag> },
]}
locale={{ emptyText: '暂无节点记录' }}
/>
</Card>
<Card type="inner" title="重要节点完成情况">
<Table
dataSource={milestones.map(item => ({
...item,
key: item.id
}))}
columns={[
{ title: '节点名称', dataIndex: 'milestone_name', key: 'milestone_name' },
{ title: '计划日期', dataIndex: 'expected_date', key: 'expected_date' },
{ title: '实际日期', dataIndex: 'actual_date', key: 'actual_date' },
{ title: '状态', dataIndex: 'status', key: 'status', render: (v: string) => <Tag color={v === 'completed' ? 'success' : v === 'in_progress' ? 'processing' : 'default'}>{v === 'completed' ? '已完成' : v === 'in_progress' ? '进行中' : '待开始'}</Tag> },
{ title: '操作', key: 'action', render: () => <Button size="small"></Button> },
]}
locale={{ emptyText: '暂无记录' }}
/>
</Card>
</Card>
)
// 施工日志 Tab
const LogTab = () => (
<Card title="施工日志" extra={<Button type="primary"></Button>}>
<Table
dataSource={constructionLogs.map(item => ({
...item,
key: item.id
}))}
columns={[
{ title: '日期', dataIndex: 'log_date', key: 'log_date' },
{ title: '天气', dataIndex: 'weather', key: 'weather' },
{ title: '记录人', dataIndex: 'recorder', key: 'recorder', render: () => '系统管理员' },
{ title: '今日工作', dataIndex: 'work_content', key: 'work_content' },
{ title: '照片', dataIndex: 'photos', key: 'photos', render: (v: string) => v ? '查看照片' : '-' },
]}
locale={{ emptyText: '暂无日志记录' }}
/>
</Card>
)
// 财务信息 Tab
const FinanceTab = () => {
const contractAmount = parseFloat(project?.contract_amount || '0')
const totalIncome = finances.filter(f => f.payment_type === 'income').reduce((sum, f) => sum + (f.amount || 0), 0)
const totalExpense = finances.filter(f => f.payment_type === 'expense').reduce((sum, f) => sum + (f.amount || 0), 0)
const grossProfit = totalIncome - totalExpense
return (
<Card title="财务信息">
<Descriptions column={2} bordered>
<Descriptions.Item label="合同金额">
¥{contractAmount.toLocaleString()}
</Descriptions.Item>
<Descriptions.Item label="已收款">¥{totalIncome.toLocaleString()}</Descriptions.Item>
<Descriptions.Item label="预支总额">¥0</Descriptions.Item>
<Descriptions.Item label="报销总额">¥0</Descriptions.Item>
<Descriptions.Item label="分包付款">¥0</Descriptions.Item>
<Descriptions.Item label="支出合计">¥{totalExpense.toLocaleString()}</Descriptions.Item>
<Descriptions.Item label="毛利润" span={2}>
<span style={{ color: grossProfit >= 0 ? '#52c41a' : '#ff4d4f', fontWeight: 'bold' }}>
¥{grossProfit.toLocaleString()}
</span>
</Descriptions.Item>
</Descriptions>
</Card>
)
}
// 质保金 Tab
const WarrantyTab = () => {
const warrantyDeposit = warrantyDeposits[0]
return (
<Card title="质保金管理">
<Descriptions column={2} bordered>
<Descriptions.Item label="质保金金额">
¥{parseFloat(warrantyDeposit?.amount || project?.warranty_amount || '0').toLocaleString()}
</Descriptions.Item>
<Descriptions.Item label="质保比例">{warrantyDeposit?.warranty_period || project?.warranty_percent || 5}%</Descriptions.Item>
<Descriptions.Item label="质保期限">{warrantyDeposit?.warranty_period || project?.warranty_months} </Descriptions.Item>
<Descriptions.Item label="起算日期">{warrantyDeposit?.start_date || project?.warranty_start_date?.split('T')[0] || '-'}</Descriptions.Item>
<Descriptions.Item label="到期日期">{warrantyDeposit?.end_date || project?.warranty_end_date?.split('T')[0] || '-'}</Descriptions.Item>
<Descriptions.Item label="质保金状态">
<Tag color={warrantyDeposit?.status === 'released' || project?.warranty_status === 'released' ? 'success' : 'default'}>
{warrantyDeposit?.status === 'released' || project?.warranty_status === 'released' ? '已释放' : '待释放'}
</Tag>
</Descriptions.Item>
</Descriptions>
<div style={{ marginTop: 16, textAlign: 'center' }}>
<Space>
<Button type="primary"></Button>
<Button></Button>
</Space>
</div>
</Card>
)
}
return (
<div>
<div style={{ marginBottom: 16 }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/projects')}>
</Button>
</div>
<Card style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h2 style={{ margin: 0 }}>{project.name}</h2>
<Space style={{ marginTop: 8 }}>
{getStatusTag(project.status)}
<span>进度: 60%</span>
<span>: ¥{parseFloat(project.warranty_amount || '0').toLocaleString()}</span>
</Space>
</div>
<Button icon={<EditOutlined />}></Button>
</div>
</Card>
{isMobile ? (
<div style={{ marginBottom: 16 }}>
<Select
style={{ width: '100%' }}
value={activeTab}
onChange={setActiveTab}
options={[
{ value: 'basic', label: '基本信息' },
{ value: 'contract', label: '合同详情' },
{ value: 'subcontract', label: '分包管理' },
{ value: 'material', label: '材料管理' },
{ value: 'milestone', label: '施工节点' },
{ value: 'log', label: '施工日志' },
{ value: 'finance', label: '财务信息' },
{ value: 'warranty', label: '质保金' }
]}
/>
<div style={{ marginTop: 16 }}>
{activeTab === 'basic' && <BasicInfoTab />}
{activeTab === 'contract' && <ContractTab />}
{activeTab === 'subcontract' && <SubcontractTab />}
{activeTab === 'material' && <MaterialTab />}
{activeTab === 'milestone' && <MilestoneTab />}
{activeTab === 'log' && <LogTab />}
{activeTab === 'finance' && <FinanceTab />}
{activeTab === 'warranty' && <WarrantyTab />}
</div>
</div>
) : (
<Tabs activeKey={activeTab} onChange={setActiveTab} type="card">
<TabPane
tab={<span><InfoCircleOutlined /> </span>}
key="basic"
>
<BasicInfoTab />
</TabPane>
<TabPane
tab={<span><FileTextOutlined /> </span>}
key="contract"
>
<ContractTab />
</TabPane>
<TabPane
tab={<span><TeamOutlined /> </span>}
key="subcontract"
>
<SubcontractTab />
</TabPane>
<TabPane
tab={<span><DatabaseOutlined /> </span>}
key="material"
>
<MaterialTab />
</TabPane>
<TabPane
tab={<span><CheckCircleOutlined /> </span>}
key="milestone"
>
<MilestoneTab />
</TabPane>
<TabPane
tab={<span><FileSearchOutlined /> </span>}
key="log"
>
<LogTab />
</TabPane>
<TabPane
tab={<span><DollarOutlined /> </span>}
key="finance"
>
<FinanceTab />
</TabPane>
<TabPane
tab={<span><SafetyOutlined /> </span>}
key="warranty"
>
<WarrantyTab />
</TabPane>
</Tabs>
)}
{/* 合同编辑模态框 */}
<Modal
title="合同细节录入"
open={contractEditModalVisible}
onCancel={() => setContractEditModalVisible(false)}
onOk={() => setContractEditModalVisible(false)}
width={900}
okText="保存"
cancelText="取消"
>
<div style={{ padding: 20 }}>
<Form layout="vertical" form={contractForm}>
{/* 工程概况 */}
<Form.Item
label="工程概况"
name="project_overview"
rules={[{ required: true, message: '请输入工程概况' }]}
>
<Input.TextArea rows={4} placeholder="请输入工程概况" />
</Form.Item>
{/* 结算方式 */}
<Form.Item
label="结算方式"
name="settlement_type"
rules={[{ required: true, message: '请选择结算方式' }]}
>
<Select
placeholder="请选择结算方式"
options={[
{ value: 'lump_sum', label: '总价包干' },
{ value: 'unit_price', label: '单价结算' }
]}
disabled
/>
</Form.Item>
{/* 合同总价 - 只在总价包干时显示 */}
{settlementType === 'lump_sum' && (
<Form.Item
label="合同总价"
name="contract_total"
rules={[{ required: true, message: '请输入合同总价' }]}
>
<InputNumber
min={0}
placeholder="请输入合同总价"
formatter={(value) => `¥ ${value}`}
parser={(value) => value?.replace(/¥\s?/, '')}
disabled
/>
</Form.Item>
)}
{/* 合同是否含税 */}
<Form.Item
label="合同是否含税"
name="tax_included"
valuePropName="checked"
>
<Switch checkedChildren="是" unCheckedChildren="否" />
</Form.Item>
{/* 单价结算项目 - 只在单价结算时显示 */}
{settlementType === 'unit_price' && (
<Form.Item label="项目单项价">
<Table
dataSource={unitPriceItems}
columns={[
{
title: '项目名称',
dataIndex: 'name',
key: 'name',
render: (_, record, index) => (
<Input
placeholder="请输入项目名称"
value={unitPriceItems[index].name}
onChange={(e) => handleUnitPriceItemChange(index, 'name', e.target.value)}
/>
)
},
{
title: '单位',
dataIndex: 'unit',
key: 'unit',
render: (_, record, index) => (
<Input
placeholder="请输入单位"
value={unitPriceItems[index].unit}
onChange={(e) => handleUnitPriceItemChange(index, 'unit', e.target.value)}
/>
)
},
{
title: '数量',
dataIndex: 'quantity',
key: 'quantity',
render: (_, record, index) => (
<InputNumber
min={0}
placeholder="请输入数量"
value={unitPriceItems[index].quantity}
onChange={(value) => handleUnitPriceItemChange(index, 'quantity', value)}
/>
)
},
{
title: '单价',
dataIndex: 'price',
key: 'price',
render: (_, record, index) => (
<InputNumber
min={0}
placeholder="请输入单价"
value={unitPriceItems[index].price}
onChange={(value) => handleUnitPriceItemChange(index, 'price', value)}
/>
)
},
{
title: '总价',
dataIndex: 'total',
key: 'total',
render: (_, record, index) => (
<InputNumber
min={0}
disabled
value={unitPriceItems[index].total}
/>
)
},
{ title: '操作', key: 'action', render: () => <Button danger></Button> }
]}
pagination={false}
locale={{ emptyText: '暂无项目单项' }}
/>
<Button type="dashed" style={{ marginTop: 16 }}></Button>
</Form.Item>
)}
{/* 付款节点 */}
<Form.Item label="付款节点">
<Table
dataSource={paymentNodes}
columns={[
{
title: '节点名称',
dataIndex: 'name',
key: 'name',
render: (_, record, index) => (
<Input
placeholder="请输入节点名称"
value={paymentNodes[index].name}
onChange={(e) => {
const newNodes = [...paymentNodes]
newNodes[index].name = e.target.value
setPaymentNodes(newNodes)
}}
/>
)
},
{
title: '比例(%)',
dataIndex: 'percentage',
key: 'percentage',
render: (_, record, index) => (
<InputNumber
min={0}
max={100}
placeholder="请输入比例"
value={paymentNodes[index].percentage}
onChange={(value) => handlePaymentNodePercentageChange(index, value || 0)}
/>
)
},
{
title: '金额',
dataIndex: 'amount',
key: 'amount',
render: (_, record, index) => (
<InputNumber
min={0}
placeholder="请输入金额"
value={paymentNodes[index].amount}
disabled
/>
)
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: () => <Tag color="default"></Tag>
},
{ title: '操作', key: 'action', render: () => <Button danger></Button> }
]}
pagination={false}
locale={{ emptyText: '暂无付款节点' }}
/>
<Button type="dashed" style={{ marginTop: 16 }}></Button>
</Form.Item>
{/* 合同附件 */}
<Form.Item label="合同附件">
<Upload
name="file"
action="/api/upload"
listType="file"
maxCount={5}
>
<Button icon={<UploadOutlined />}></Button>
</Upload>
</Form.Item>
{/* 其他信息 */}
<Form.Item
label="其他合同信息"
name="other_info"
>
<Input.TextArea rows={4} placeholder="请输入其他合同相关信息" />
</Form.Item>
</Form>
</div>
</Modal>
</div>
)
}
export default ProjectDetail
@@ -0,0 +1,316 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, Typography, Button, Space, Table, Tag, message, Spin, Modal, Input } from 'antd';
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
import axios from 'axios';
import { useAuthStore } from '../../store/authStore';
const { Title, Paragraph } = Typography;
interface Project {
id: number
project_code: string
name: string
customer_id: number
customer_name?: string
status: string
budget: string
spent: string
start_date: string
end_date: string
description: string
manager_name?: string
progress?: number
}
const ProjectsPage: React.FC = () => {
const navigate = useNavigate();
const [isMobile, setIsMobile] = useState(false);
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [deleteProjectId, setDeleteProjectId] = useState<number | null>(null);
const [deletePassword, setDeletePassword] = useState('');
const [deleteLoading, setDeleteLoading] = useState(false);
const { user: currentUser } = useAuthStore();
const isAdmin = currentUser?.role === 'admin' || false;
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth <= 768);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
useEffect(() => {
fetchProjects();
}, []);
const fetchProjects = async () => {
try {
const response = await axios.get('/api/projects');
if (response.data.success) {
setProjects(response.data.data.map((p: Project) => ({
...p,
key: p.id.toString(),
progress: Math.floor(Math.random() * 100), // 临时模拟进度
manager_name: p.manager_name || '未分配'
})));
}
} catch (error) {
message.error('获取项目列表失败');
} finally {
setLoading(false);
}
};
// 处理删除项目
const handleDeleteProject = (projectId: number) => {
setDeleteProjectId(projectId);
setDeletePassword('');
setDeleteModalVisible(true);
};
// 确认删除项目
const handleDeleteConfirm = async () => {
if (!deleteProjectId) return;
// 验证密码
if (deletePassword !== 'X123c321@') {
message.error('密码错误');
return;
}
setDeleteLoading(true);
try {
const response = await axios.delete(`/api/projects/${deleteProjectId}`, {
headers: {
'x-user-role': 'admin'
}
});
if (response.data.success) {
message.success('项目删除成功');
setDeleteModalVisible(false);
fetchProjects();
} else {
message.error(response.data.message || '删除失败');
}
} catch (error) {
message.error('删除项目失败');
} finally {
setDeleteLoading(false);
}
};
// 桌面端表格列
const desktopColumns = [
{
title: '项目名称',
dataIndex: 'name',
key: 'name',
width: 250,
ellipsis: true,
render: (text: string, record: Project) => (
<a onClick={() => navigate(`/projects/${record.id}`)} style={{ cursor: 'pointer' }}>
{text}
</a>
),
},
{
title: '项目经理',
dataIndex: 'manager_name',
key: 'manager_name',
width: 100,
},
{
title: '预算',
dataIndex: 'budget',
key: 'budget',
width: 120,
render: (amount: string) => {
const val = parseFloat(amount || '0');
return val > 0 ? `¥${(val / 10000).toFixed(1)}` : '-';
},
},
{
title: '进度',
dataIndex: 'progress',
key: 'progress',
width: 120,
render: (progress: number) => (
<div style={{ width: 100 }}>
<div style={{ background: '#f0f0f0', borderRadius: 10, height: 8 }}>
<div
style={{
background: progress > 80 ? '#52c41a' : progress > 50 ? '#1890ff' : '#faad14',
borderRadius: 10,
height: 8,
width: `${progress}%`,
}}
/>
</div>
<span style={{ fontSize: 12, color: '#888' }}>{progress}%</span>
</div>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
planning: { color: 'blue', text: '规划中' },
in_progress: { color: 'processing', text: '进行中' },
completed: { color: 'success', text: '已完成' },
suspended: { color: 'warning', text: '已暂停' },
};
const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>;
},
},
{
title: '操作',
key: 'action',
width: 140,
render: (_: unknown, record: Project) => (
<Space>
<Button size="small" onClick={() => navigate(`/projects/${record.id}`)}></Button>
<Button size="small"></Button>
{isAdmin && (
<Button
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => handleDeleteProject(record.id)}
>
</Button>
)}
</Space>
),
},
];
// 移动端简化表格列
const mobileColumns = [
{
title: '项目',
dataIndex: 'name',
key: 'name',
ellipsis: true,
render: (text: string, record: Project) => (
<a onClick={() => navigate(`/projects/${record.id}`)} style={{ cursor: 'pointer' }}>
{text}
</a>
),
},
{
title: '进度',
dataIndex: 'progress',
key: 'progress',
width: 80,
render: (progress: number) => (
<div style={{ width: 60 }}>
<div style={{ background: '#f0f0f0', borderRadius: 4, height: 6 }}>
<div
style={{
background: progress > 80 ? '#52c41a' : progress > 50 ? '#1890ff' : '#faad14',
borderRadius: 4,
height: 6,
width: `${progress}%`,
}}
/>
</div>
<span style={{ fontSize: 10, color: '#888' }}>{progress}%</span>
</div>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 70,
render: (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
planning: { color: 'blue', text: '规划' },
in_progress: { color: 'processing', text: '进行中' },
completed: { color: 'success', text: '完成' },
suspended: { color: 'warning', text: '暂停' },
};
const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color} style={{ fontSize: 10 }}>{config.text}</Tag>;
},
},
{
title: '操作',
key: 'action',
width: 60,
render: (_: unknown, record: Project) => (
<Button size="small" onClick={() => navigate(`/projects/${record.id}`)}></Button>
),
},
];
return (
<div style={{ padding: isMobile ? 8 : 24 }}>
<div style={{ marginBottom: isMobile ? 12 : 24 }}>
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}></Title>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
</Paragraph>
</div>
<Card
title="项目列表"
size="small"
bodyStyle={{ padding: isMobile ? 8 : 24 }}
>
{loading ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin />
</div>
) : (
<Table
dataSource={projects}
columns={isMobile ? mobileColumns : desktopColumns}
pagination={{
pageSize: isMobile ? 5 : 10,
size: isMobile ? 'small' : 'default'
}}
scroll={isMobile ? { x: 350 } : undefined}
size={isMobile ? 'small' : 'middle'}
/>
)}
</Card>
{/* 删除确认模态框 */}
<Modal
title="删除确认"
open={deleteModalVisible}
onOk={handleDeleteConfirm}
onCancel={() => setDeleteModalVisible(false)}
confirmLoading={deleteLoading}
okText="确认删除"
cancelText="取消"
>
<div style={{ marginBottom: 16 }}>
<p></p>
<p></p>
</div>
<Input.Password
placeholder="请输入管理员密码"
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
size="large"
/>
</Modal>
</div>
);
};
export default ProjectsPage;
@@ -0,0 +1,341 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Card, Space, Tag, Modal, Form, Input, InputNumber, Select, DatePicker, message, Descriptions, Image, Divider } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined, UndoOutlined, PlusCircleOutlined, MinusCircleOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { useAuthStore } from '../../store/authStore';
import FileUpload from '../../components/FileUpload';
const { Option } = Select;
const { TextArea } = Input;
interface DetailItem {
id?: string;
description: string;
amount: number;
attachments?: string[];
}
const ReimbursementsPage: React.FC = () => {
const { user } = useAuthStore();
const [reimbursements, setReimbursements] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [selectedRecord, setSelectedRecord] = useState<any>(null);
const [form] = Form.useForm();
const [detailItems, setDetailItems] = useState<DetailItem[]>([]);
useEffect(() => { fetchReimbursements(); }, []);
const fetchReimbursements = async () => {
setLoading(true);
try {
const res = await fetch('/api/reimbursements');
const data = await res.json();
if (data.success) setReimbursements(data.data);
} catch (error) {
message.error('获取报销列表失败');
} finally {
setLoading(false);
}
};
const handleCreate = () => {
setEditingId(null);
setDetailItems([]);
form.resetFields();
form.setFieldsValue({
reimbursement_date: dayjs(),
currency: 'CNY',
applicant: user?.name || user?.username || '当前用户',
attachments: []
});
setModalVisible(true);
};
const handleEdit = (record: any) => {
setEditingId(record.id);
setDetailItems(record.detail_items || []);
form.setFieldsValue({
...record,
reimbursement_date: record.reimbursement_date ? dayjs(record.reimbursement_date) : null,
attachments: record.attachments || []
});
setModalVisible(true);
};
const handleView = (record: any) => {
setSelectedRecord(record);
setDetailModalVisible(true);
};
const handleDelete = async (id: number) => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这条报销记录吗?',
onOk: async () => {
try {
await fetch('/api/reimbursements/' + id, { method: 'DELETE' });
message.success('删除成功');
fetchReimbursements();
} catch (error) {
message.error('删除失败');
}
}
});
};
const handleWithdraw = async (id: number) => {
Modal.confirm({
title: '确认撤回',
content: '撤回后可重新编辑提交,确认撤回吗?',
onOk: async () => {
try {
await fetch('/api/reimbursements/' + id + '/withdraw', { method: 'POST' });
message.success('已撤回,可重新编辑');
fetchReimbursements();
} catch (error) {
message.error('撤回失败');
}
}
});
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
const data = {
...values,
reimbursement_date: values.reimbursement_date?.format('YYYY-MM-DD'),
detail_items: detailItems,
amount: detailItems.reduce((sum, item) => sum + (item.amount || 0), 0),
applicant: user?.name || user?.username
};
const url = editingId ? '/api/reimbursements/' + editingId : '/api/reimbursements';
const method = editingId ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await res.json();
if (result.success) {
message.success(editingId ? '更新成功' : '创建成功');
setModalVisible(false);
fetchReimbursements();
} else {
message.error(result.error || '操作失败');
}
} catch (error) {
message.error('操作失败');
}
};
const addDetailItem = () => {
setDetailItems([...detailItems, { description: '', amount: 0, attachments: [] }]);
};
const updateDetailItem = (index: number, field: keyof DetailItem, value: any) => {
const newItems = [...detailItems];
newItems[index] = { ...newItems[index], [field]: value };
setDetailItems(newItems);
};
const removeDetailItem = (index: number) => {
setDetailItems(detailItems.filter((_, i) => i !== index));
};
const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
pending: { color: 'processing', text: '待审批' },
approved: { color: 'success', text: '已批准' },
rejected: { color: 'error', text: '已退回' },
withdrawn: { color: 'default', text: '已撤回' },
paid: { color: 'blue', text: '已付款' },
};
const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>;
};
const formatAmount = (amount: number, currency: string = 'CNY') => {
const symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' };
return (symbols[currency] || '¥') + (amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
const columns = [
{ title: '事由', dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleView(r)}>{v}</a> },
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => (
<>
<div>{formatAmount(v, r.currency)}</div>
{r.currency !== 'CNY' && r.amount_cny && <div style={{ color: '#999', fontSize: 12 }}> ¥{r.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</div>}
</>
) },
{ title: '报销日期', dataIndex: 'reimbursement_date', key: 'reimbursement_date', width: 100 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (status: string) => getStatusTag(status) },
{ title: '编号', dataIndex: 'reimbursement_code', key: 'reimbursement_code', width: 120 },
{
title: '操作', key: 'action', width: 250,
render: (_: any, record: any) => (
<Space wrap>
<Button size="small" icon={<EyeOutlined />} onClick={() => handleView(record)}></Button>
{record.status === 'pending' && (
<>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}></Button>
<Button size="small" icon={<UndoOutlined />} onClick={() => handleWithdraw(record.id)}></Button>
</>
)}
{(record.status === 'rejected' || record.status === 'withdrawn') && (
<Button size="small" type="primary" icon={<EditOutlined />} onClick={() => handleEdit(record)}></Button>
)}
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}></Button>
</Space>
)
}
];
const currency = Form.useWatch('currency', form);
const totalAmount = detailItems.reduce((sum, item) => sum + (item.amount || 0), 0);
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}>
<h2 style={{ marginBottom: 8 }}></h2>
<p style={{ color: '#888', marginBottom: 0 }}></p>
</div>
<Card extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}></Button>}>
<Table dataSource={reimbursements} columns={columns} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} size="middle" scroll={{ x: 1100 }} />
</Card>
<Modal title={editingId ? '编辑报销' : '新建报销'} open={modalVisible} onOk={handleSubmit} onCancel={() => setModalVisible(false)} width={900}>
<Form form={form} layout="vertical">
<Form.Item name="applicant" label="申请人">
<Input disabled style={{ color: 'rgba(0,0,0,0.85)', backgroundColor: '#f5f5f5' }} />
</Form.Item>
<Form.Item name="reimbursement_date" label="报销日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="currency" label="币种" rules={[{ required: true }]}>
<Select style={{ width: 200 }}>
<Option value="CNY"> (CNY)</Option>
<Option value="USD"> (USD)</Option>
<Option value="LAK"> (LAK)</Option>
<Option value="THB"> (THB)</Option>
</Select>
</Form.Item>
<Form.Item name="reason" label="事由" rules={[{ required: true }]}>
<TextArea rows={2} placeholder="请输入报销事由" />
</Form.Item>
<Divider></Divider>
<div style={{ marginBottom: 16 }}>
<Button type="dashed" icon={<PlusCircleOutlined />} onClick={addDetailItem}></Button>
<span style={{ marginLeft: 16, color: '#888' }}>
: {formatAmount(totalAmount, currency)}
</span>
</div>
{detailItems.map((item, index) => (
<Card key={index} size="small" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'flex-start' }}>
<div style={{ flex: 1, minWidth: 200 }}>
<label style={{ display: 'block', marginBottom: 4, color: '#666' }}></label>
<Input
value={item.description}
onChange={(e) => updateDetailItem(index, 'description', e.target.value)}
placeholder="费用说明"
/>
</div>
<div style={{ width: 150 }}>
<label style={{ display: 'block', marginBottom: 4, color: '#666' }}></label>
<InputNumber
value={item.amount}
onChange={(v) => updateDetailItem(index, 'amount', v)}
min={0}
precision={2}
style={{ width: '100%' }}
placeholder="金额"
/>
</div>
<div style={{ flex: 2, minWidth: 300 }}>
<label style={{ display: 'block', marginBottom: 4, color: '#666' }}></label>
<FileUpload
value={item.attachments || []}
onChange={(urls) => updateDetailItem(index, 'attachments', urls)}
maxCount={3}
accept="image/*"
/>
</div>
<Button type="text" danger icon={<MinusCircleOutlined />} onClick={() => removeDetailItem(index)} style={{ marginTop: 24 }} />
</div>
</Card>
))}
<Divider></Divider>
<Form.Item name="attachments" label="整体凭证附件">
<FileUpload maxCount={9} accept="image/*" />
</Form.Item>
</Form>
</Modal>
<Modal title="报销详情" open={detailModalVisible} onCancel={() => setDetailModalVisible(false)} footer={null} width={900}>
{selectedRecord && (
<>
<Descriptions bordered column={2} size="small">
<Descriptions.Item label="报销编号">{selectedRecord.reimbursement_code}</Descriptions.Item>
<Descriptions.Item label="状态">{getStatusTag(selectedRecord.status)}</Descriptions.Item>
<Descriptions.Item label="申请人">{selectedRecord.applicant}</Descriptions.Item>
<Descriptions.Item label="报销日期">{selectedRecord.reimbursement_date}</Descriptions.Item>
<Descriptions.Item label="币种">{selectedRecord.currency}</Descriptions.Item>
<Descriptions.Item label="金额">
{formatAmount(selectedRecord.amount, selectedRecord.currency)}
{selectedRecord.currency !== 'CNY' && selectedRecord.amount_cny && (
<span style={{ color: '#999', marginLeft: 8 }}> ¥{selectedRecord.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span>
)}
</Descriptions.Item>
<Descriptions.Item label="事由" span={2}>{selectedRecord.reason}</Descriptions.Item>
</Descriptions>
{selectedRecord.detail_items && selectedRecord.detail_items.length > 0 && (
<>
<Divider></Divider>
<Table
dataSource={selectedRecord.detail_items}
rowKey="id"
size="small"
pagination={false}
columns={[
{ title: '费用说明', dataIndex: 'description', key: 'description' },
{ title: '金额', dataIndex: 'amount', key: 'amount', render: (v: number) => formatAmount(v, selectedRecord.currency) },
{ title: '附件', dataIndex: 'attachments', key: 'attachments', render: (v: string[]) => v?.length ? `${v.length}` : '-' }
]}
/>
</>
)}
{selectedRecord.attachments && selectedRecord.attachments.length > 0 && (
<>
<Divider></Divider>
<Image.PreviewGroup>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{selectedRecord.attachments.map((url: string, index: number) => (
<Image key={index} src={url} width={100} height={100} style={{ objectFit: 'cover', borderRadius: 4 }} />
))}
</div>
</Image.PreviewGroup>
</>
)}
</>
)}
</Modal>
</div>
);
};
export default ReimbursementsPage;
@@ -0,0 +1,202 @@
import React, { useState, useEffect } from 'react';
import { Card, Typography, Table, DatePicker, Button, Row, Col, Tag } from 'antd';
import { DownloadOutlined } from '@ant-design/icons';
const { Title, Paragraph } = Typography;
const ReportsPage: React.FC = () => {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth <= 768);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
// 报表数据
const dataSource = [
{
key: '1',
month: '2026-02',
income: 250000,
expense: 180000,
profit: 70000,
projects: 5,
},
{
key: '2',
month: '2026-01',
income: 220000,
expense: 165000,
profit: 55000,
projects: 4,
},
{
key: '3',
month: '2025-12',
income: 280000,
expense: 195000,
profit: 85000,
projects: 6,
},
];
// 桌面端表格列
const desktopColumns = [
{
title: '月份',
dataIndex: 'month',
key: 'month',
},
{
title: '总收入',
dataIndex: 'income',
key: 'income',
render: (amount: number) => `¥${amount.toLocaleString()}`,
},
{
title: '总支出',
dataIndex: 'expense',
key: 'expense',
render: (amount: number) => `¥${amount.toLocaleString()}`,
},
{
title: '净利润',
dataIndex: 'profit',
key: 'profit',
render: (amount: number) => (
<span style={{ color: amount > 0 ? 'green' : 'red' }}>
¥{amount.toLocaleString()}
</span>
),
},
{
title: '项目数量',
dataIndex: 'projects',
key: 'projects',
},
{
title: '操作',
key: 'action',
render: () => (
<Button size="small" icon={<DownloadOutlined />}></Button>
),
},
];
// 移动端简化表格列
const mobileColumns = [
{
title: '月份',
dataIndex: 'month',
key: 'month',
render: (month: string) => month.replace('-', '/'),
},
{
title: '收入',
dataIndex: 'income',
key: 'income',
render: (amount: number) => (
<div style={{ color: 'green' }}>¥{(amount / 10000).toFixed(0)}</div>
),
},
{
title: '支出',
dataIndex: 'expense',
key: 'expense',
render: (amount: number) => (
<div style={{ color: 'red' }}>¥{(amount / 10000).toFixed(0)}</div>
),
},
{
title: '利润',
dataIndex: 'profit',
key: 'profit',
render: (amount: number) => (
<div style={{ fontWeight: 'bold', color: amount > 0 ? 'green' : 'red' }}>
¥{(amount / 10000).toFixed(0)}
</div>
),
},
];
return (
<div style={{ padding: isMobile ? 8 : 24 }}>
<div style={{ marginBottom: isMobile ? 12 : 24 }}>
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}></Title>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
</Paragraph>
</div>
<Card
title="月度财务报表"
size="small"
bodyStyle={{ padding: isMobile ? 8 : 24 }}
extra={
isMobile ? (
<Button size="small" icon={<DownloadOutlined />} />
) : (
<DatePicker picker="month" style={{ marginRight: 8 }} />
)
}
>
<Table
dataSource={dataSource}
columns={isMobile ? mobileColumns : desktopColumns}
pagination={false}
scroll={isMobile ? { x: 350 } : undefined}
size={isMobile ? 'small' : 'middle'}
summary={(pageData) => {
let totalIncome = 0;
let totalExpense = 0;
let totalProfit = 0;
let totalProjects = 0;
pageData.forEach(({ income, expense, profit, projects }) => {
totalIncome += income;
totalExpense += expense;
totalProfit += profit;
totalProjects += projects;
});
return (
<Table.Summary fixed>
<Table.Summary.Row>
<Table.Summary.Cell index={0}>
<strong></strong>
</Table.Summary.Cell>
<Table.Summary.Cell index={1}>
<strong>¥{(totalIncome / 10000).toFixed(0)}</strong>
</Table.Summary.Cell>
<Table.Summary.Cell index={2}>
<strong>¥{(totalExpense / 10000).toFixed(0)}</strong>
</Table.Summary.Cell>
<Table.Summary.Cell index={3}>
<strong style={{ color: totalProfit > 0 ? 'green' : 'red' }}>
¥{(totalProfit / 10000).toFixed(0)}
</strong>
</Table.Summary.Cell>
{!isMobile && (
<>
<Table.Summary.Cell index={4}>
<strong>{totalProjects}</strong>
</Table.Summary.Cell>
<Table.Summary.Cell index={5} />
</>
)}
</Table.Summary.Row>
</Table.Summary>
);
}}
/>
</Card>
</div>
);
};
export default ReportsPage;
@@ -0,0 +1,96 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { API_CONFIG, API_ENDPOINTS } from '../config/api'
export interface User {
id: number
username: string
name: string
email?: string
role: 'admin' | 'manager' | 'user' | 'finance'
department?: string
avatar?: string
}
export interface AuthState {
user: User | null
token: string | null
isAuthenticated: boolean
isLoading: boolean
// Actions
login: (username: string, password: string) => Promise<void>
logout: () => void
setUser: (user: User) => void
setToken: (token: string) => void
clearAuth: () => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
login: async (username: string, password: string) => {
set({ isLoading: true })
try {
// 调用真实后端API
const response = await fetch(`${API_CONFIG.baseURL}${API_ENDPOINTS.auth.login}`, {
method: 'POST',
headers: API_CONFIG.headers,
body: JSON.stringify({ username, password }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.message || '登录失败')
}
const data = await response.json()
set({
user: data.data,
token: 'mock-token', // 后端没有返回token,使用模拟值
isAuthenticated: true,
isLoading: false
})
} catch (error) {
set({ isLoading: false })
throw error
}
},
logout: () => {
set({
user: null,
token: null,
isAuthenticated: false
})
},
setUser: (user: User) => {
set({ user })
},
setToken: (token: string) => {
set({ token })
},
clearAuth: () => {
set({
user: null,
token: null,
isAuthenticated: false
})
}
}),
{
name: 'auth-storage',
}
)
)
@@ -0,0 +1,56 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import dayjs from 'dayjs'
import { type LanguageCode, getLanguage, getTranslation } from '../locales'
interface LanguageState {
currentLanguage: LanguageCode
setLanguage: (code: LanguageCode) => void
getLanguageInfo: () => any
t: (key: string) => string
}
export const useLanguageStore = create<LanguageState>()(
persist(
(set, get) => ({
currentLanguage: 'zh-CN',
setLanguage: (code: LanguageCode) => {
set({ currentLanguage: code })
// 更新dayjs语言
import('dayjs/locale/zh-cn')
import('dayjs/locale/th')
const localeMap: Record<LanguageCode, string> = {
'zh-CN': 'zh-cn',
'th-TH': 'th',
'lo-LA': 'en',
'en-US': 'en'
}
dayjs.locale(localeMap[code])
},
getLanguageInfo: () => {
return getLanguage(get().currentLanguage)
},
t: (key: string): string => {
const translation = getTranslation(get().currentLanguage)
const keys = key.split('.')
let result: any = translation
for (const k of keys) {
if (result && typeof result === 'object') {
result = result[k]
} else {
return key // 找不到翻译,返回key
}
}
return typeof result === 'string' ? result : key
}
}),
{
name: 'language-storage',
}
)
)
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+105
View File
@@ -0,0 +1,105 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'masked-icon.svg'],
manifest: {
name: '公司财务管理系统',
short_name: '财务系统',
description: '项目管理与财务报销一体化系统',
theme_color: '#1890ff',
background_color: '#ffffff',
display: 'standalone',
orientation: 'portrait',
scope: '/',
start_url: '/',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable'
}
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365
}
}
},
{
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'gstatic-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365
}
}
},
{
urlPattern: /\/api\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
networkTimeoutSeconds: 10,
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24
}
}
}
]
}
})
],
server: {
port: 3000,
host: true,
proxy: {
'/api': {
target: 'http://localhost:3004',
changeOrigin: true
}
}
},
build: {
outDir: 'dist',
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom', 'react-router-dom'],
ui: ['antd', '@ant-design/icons'],
utils: ['axios', 'dayjs', 'zustand']
}
}
}
}
})
File diff suppressed because it is too large Load Diff