Compare commits
9 Commits
b76ef06c62
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
11f3eda668
|
|||
|
76d5cdf542
|
|||
|
54d6b31da6
|
|||
|
76ea5a2e06
|
|||
|
d2dac6091e
|
|||
|
ff767837e7
|
|||
|
2a3399d436
|
|||
|
a93a90ff2d
|
|||
|
bef7416ecb
|
187
AGENTS.md
Normal file
187
AGENTS.md
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
# Agent Instructions for Trace Frontend
|
||||||
|
|
||||||
|
## Build & Development Commands
|
||||||
|
|
||||||
|
### Development
|
||||||
|
```bash
|
||||||
|
pnpm dev # Start dev server at http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build
|
||||||
|
```bash
|
||||||
|
pnpm build # Build for production (outputs to dist/)
|
||||||
|
pnpm preview # Preview production build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing & Linting
|
||||||
|
No test or lint commands are currently configured. When adding tests, use Vitest or Jest.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
This is a React 19 + TypeScript frontend for the Zhejiang Beifan Trace Management Platform (溯源管理平台). It provides:
|
||||||
|
- Public query interface for serial number verification
|
||||||
|
- Admin dashboard for QR code generation and company management
|
||||||
|
- User authentication and profile management
|
||||||
|
|
||||||
|
**Tech Stack**: React 19, TypeScript, Vite 7, Ant Design 6, React Router v7, Axios
|
||||||
|
|
||||||
|
## Code Style Guidelines
|
||||||
|
|
||||||
|
### File Organization
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/ # Reusable UI components (AdminLayout, etc.)
|
||||||
|
├── pages/ # Page components (Login, Dashboard, etc.)
|
||||||
|
├── services/ # API service layer (api.ts with domain-specific APIs)
|
||||||
|
├── types/ # TypeScript type definitions (index.ts)
|
||||||
|
├── hooks/ # Custom React hooks
|
||||||
|
├── utils/ # Utility functions
|
||||||
|
├── styles/ # Global CSS files
|
||||||
|
├── assets/ # Static assets (images, etc.)
|
||||||
|
├── App.tsx # Main app with routes
|
||||||
|
└── main.tsx # Application entry point
|
||||||
|
```
|
||||||
|
|
||||||
|
### Imports
|
||||||
|
- Use `@/` alias for src directory (configured in vite.config.ts and tsconfig.json)
|
||||||
|
- Group imports: React/hooks first, then third-party libraries, then internal imports
|
||||||
|
- Example:
|
||||||
|
```tsx
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Form, Input, Button } from 'antd';
|
||||||
|
import { UserOutlined } from '@ant-design/icons';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { authApi } from '@/services/api';
|
||||||
|
import type { User } from '@/types';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
- **Components**: PascalCase (e.g., `LoginPage`, `AdminLayout`)
|
||||||
|
- **Functions/Methods**: camelCase (e.g., `handleLogin`, `loadStats`)
|
||||||
|
- **Constants**: UPPER_SNAKE_CASE (e.g., `API_BASE_URL`)
|
||||||
|
- **Types/Interfaces**: PascalCase (e.g., `User`, `Company`, `ApiResponse<T>`)
|
||||||
|
- **Files**: PascalCase for components/pages, lowercase for services/utils
|
||||||
|
|
||||||
|
### TypeScript
|
||||||
|
- All new code must be TypeScript
|
||||||
|
- Use `interface` for object shapes, `type` for unions/aliases
|
||||||
|
- Explicit return types for API functions and handlers
|
||||||
|
- Use `any` only as last resort - prefer `unknown` with type guards
|
||||||
|
- Strict mode enabled: no unused locals, no unused parameters
|
||||||
|
|
||||||
|
### Component Patterns
|
||||||
|
- Use functional components with hooks
|
||||||
|
- For pages: use `function PageName()` (not arrow functions)
|
||||||
|
- For layout/reusable components: use `function ComponentName()`
|
||||||
|
- Export with `export default ComponentName;`
|
||||||
|
- Use Ant Design components via destructuring: `const { Button, Form } = antd;`
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
- Use try-catch blocks for async operations
|
||||||
|
- Display errors with Ant Design `message.error()`
|
||||||
|
- API services throw errors with descriptive messages
|
||||||
|
- Global axios interceptor handles 401 (auth) and 404 (not found) automatically
|
||||||
|
|
||||||
|
### API Services
|
||||||
|
- API calls organized by domain in `src/services/api.ts`:
|
||||||
|
- `authApi` - Authentication (login, logout, profile)
|
||||||
|
- `serialApi` - Serial number management
|
||||||
|
- `companyApi` - Company management
|
||||||
|
- `dashboardApi` - Dashboard statistics
|
||||||
|
- Auth token automatically added via axios interceptor
|
||||||
|
- All API calls return typed responses based on `src/types/index.ts`
|
||||||
|
|
||||||
|
### Routing
|
||||||
|
- Use React Router v7 with `<Routes>` and `<Route>` components
|
||||||
|
- Protected routes wrapped with `<PrivateRoute>` component
|
||||||
|
- Admin pages wrapped with `<AdminRoutes>` layout component
|
||||||
|
- Use `useNavigate()` for programmatic navigation
|
||||||
|
- Use `useLocation()` to get current path
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
- Use React hooks (`useState`, `useEffect`) for local component state
|
||||||
|
- Authentication state persisted in localStorage via authApi
|
||||||
|
- No global state management library currently used
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
- Global styles in `src/styles/global.css`
|
||||||
|
- Component-specific CSS in co-located styles directory (e.g., `pages/styles/Login.css`)
|
||||||
|
- Import component styles with relative path: `import './styles/PageName.css';`
|
||||||
|
- Use Ant Design components for primary UI, custom CSS for layout specifics
|
||||||
|
|
||||||
|
### Locale & Internationalization
|
||||||
|
- Chinese (zh_CN) locale configured globally via `ConfigProvider` in main.tsx
|
||||||
|
- All UI text should be in Chinese
|
||||||
|
- Ant Design components automatically use Chinese locale
|
||||||
|
|
||||||
|
## Key Patterns & Conventions
|
||||||
|
|
||||||
|
### Page Component Structure
|
||||||
|
```tsx
|
||||||
|
function PageName() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [data, setData] = useState<DataType | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await apiMethod();
|
||||||
|
setData(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '加载数据失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Spin size="large" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div>{/* JSX content */}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageName;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Route Guards
|
||||||
|
- `<PrivateRoute />` - Redirects to /login if not authenticated
|
||||||
|
- `<PublicRoute children={...} />` - Redirects to /admin/dashboard if authenticated
|
||||||
|
- Use `<Outlet />` for nested routes
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
- Use Vite's `import.meta.env.VITE_*` pattern
|
||||||
|
- Example: `const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';`
|
||||||
|
- Define in `.env` file (not committed to git)
|
||||||
|
|
||||||
|
## Notes for Agents
|
||||||
|
|
||||||
|
1. **No tests configured**: When adding test frameworks, update AGENTS.md accordingly
|
||||||
|
2. **No linter configured**: Consider adding ESLint and Prettier
|
||||||
|
3. **Authentication flow**: Tokens stored in localStorage, added to requests via interceptor
|
||||||
|
4. **API proxy**: /api requests proxied to http://localhost:3000 during dev (see vite.config.ts)
|
||||||
|
5. **QR Code generation**: Uses `qrcode` library for generating QR codes for serial numbers
|
||||||
|
6. **Admin Layout**: Uses Ant Design Layout with Sider navigation and Header
|
||||||
|
7. **Type safety**: Always add types for new props, state, and API responses
|
||||||
|
|
||||||
|
## Common Tasks
|
||||||
|
|
||||||
|
### Add a new API endpoint
|
||||||
|
1. Add interface to `src/types/index.ts`
|
||||||
|
2. Add method to appropriate API object in `src/services/api.ts`
|
||||||
|
3. Handle errors appropriately with descriptive messages
|
||||||
|
|
||||||
|
### Add a new page
|
||||||
|
1. Create component in `src/pages/PageName.tsx`
|
||||||
|
2. Add CSS file in `src/pages/styles/PageName.css`
|
||||||
|
3. Add route in `src/App.tsx`
|
||||||
|
4. If admin page, wrap in `<AdminRoutes />` and add menu item in `AdminLayout.tsx`
|
||||||
|
|
||||||
|
### Add a new component
|
||||||
|
1. Create in `src/components/ComponentName.tsx`
|
||||||
|
2. Add styles in `src/components/styles/ComponentName.css` if needed
|
||||||
|
3. Import using `@/components/ComponentName`
|
||||||
17
package.json
17
package.json
@@ -2,6 +2,9 @@
|
|||||||
"name": "trace-frontend",
|
"name": "trace-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "浙江贝凡溯源管理平台 - 前端",
|
"description": "浙江贝凡溯源管理平台 - 前端",
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"packageManager": "pnpm@10.27.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
@@ -9,21 +12,19 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^6.1.0",
|
"@ant-design/icons": "^6.1.0",
|
||||||
"antd": "^6.2.3",
|
"antd": "^6.3.1",
|
||||||
"axios": "^1.13.4",
|
"axios": "^1.13.6",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-router-dom": "^7.13.0"
|
"react-router-dom": "^7.13.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.2.11",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.3",
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
},
|
}
|
||||||
"author": "",
|
|
||||||
"license": "MIT"
|
|
||||||
}
|
}
|
||||||
|
|||||||
539
pnpm-lock.yaml
generated
539
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -4,9 +4,9 @@ import LoginPage from './pages/Login';
|
|||||||
import AdminLayout from './components/AdminLayout';
|
import AdminLayout from './components/AdminLayout';
|
||||||
import PublicQueryPage from './pages/PublicQuery';
|
import PublicQueryPage from './pages/PublicQuery';
|
||||||
import DashboardPage from './pages/Dashboard';
|
import DashboardPage from './pages/Dashboard';
|
||||||
import GeneratePage from './pages/Generate';
|
|
||||||
import ManagePage from './pages/Manage';
|
import ManagePage from './pages/Manage';
|
||||||
import ProfilePage from './pages/Profile';
|
import ProfilePage from './pages/Profile';
|
||||||
|
import EmployeeSerialsPage from './pages/EmployeeSerials';
|
||||||
|
|
||||||
const PrivateRoute = () => {
|
const PrivateRoute = () => {
|
||||||
const user = authApi.getCurrentUser();
|
const user = authApi.getCurrentUser();
|
||||||
@@ -48,8 +48,8 @@ function App() {
|
|||||||
<Route element={<AdminRoutes />}>
|
<Route element={<AdminRoutes />}>
|
||||||
<Route path="/admin" element={<Navigate to="dashboard" replace />} />
|
<Route path="/admin" element={<Navigate to="dashboard" replace />} />
|
||||||
<Route path="/admin/dashboard" element={<DashboardPage />} />
|
<Route path="/admin/dashboard" element={<DashboardPage />} />
|
||||||
<Route path="/admin/generate" element={<GeneratePage />} />
|
|
||||||
<Route path="/admin/manage" element={<ManagePage />} />
|
<Route path="/admin/manage" element={<ManagePage />} />
|
||||||
|
<Route path="/admin/employee-serials" element={<EmployeeSerialsPage />} />
|
||||||
<Route path="/admin/profile" element={<ProfilePage />} />
|
<Route path="/admin/profile" element={<ProfilePage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
|||||||
import { Layout, Menu, Dropdown, Avatar, message, Modal } from 'antd';
|
import { Layout, Menu, Dropdown, Avatar, message, Modal } from 'antd';
|
||||||
import {
|
import {
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
QrcodeOutlined,
|
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
LockOutlined,
|
|
||||||
ExclamationCircleOutlined,
|
ExclamationCircleOutlined,
|
||||||
|
IdcardOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { authApi } from '@/services/api';
|
import { authApi } from '@/services/api';
|
||||||
import './styles/AdminLayout.css';
|
import './styles/AdminLayout.css';
|
||||||
@@ -27,18 +26,18 @@ function AdminLayout() {
|
|||||||
label: '控制台',
|
label: '控制台',
|
||||||
onClick: () => navigate('/admin/dashboard'),
|
onClick: () => navigate('/admin/dashboard'),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'generate',
|
|
||||||
icon: <QrcodeOutlined />,
|
|
||||||
label: '生成二维码',
|
|
||||||
onClick: () => navigate('/admin/generate'),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'manage',
|
key: 'manage',
|
||||||
icon: <TeamOutlined />,
|
icon: <TeamOutlined />,
|
||||||
label: '企业管理',
|
label: '企业管理',
|
||||||
onClick: () => navigate('/admin/manage'),
|
onClick: () => navigate('/admin/manage'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'employee-serials',
|
||||||
|
icon: <IdcardOutlined />,
|
||||||
|
label: '员工管理',
|
||||||
|
onClick: () => navigate('/admin/employee-serials'),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
@@ -82,8 +81,8 @@ function AdminLayout() {
|
|||||||
const getSelectedKey = () => {
|
const getSelectedKey = () => {
|
||||||
const path = location.pathname;
|
const path = location.pathname;
|
||||||
if (path.includes('/dashboard')) return 'dashboard';
|
if (path.includes('/dashboard')) return 'dashboard';
|
||||||
if (path.includes('/generate')) return 'generate';
|
|
||||||
if (path.includes('/manage')) return 'manage';
|
if (path.includes('/manage')) return 'manage';
|
||||||
|
if (path.includes('/employee-serials')) return 'employee-serials';
|
||||||
if (path.includes('/profile')) return 'profile';
|
if (path.includes('/profile')) return 'profile';
|
||||||
return 'dashboard';
|
return 'dashboard';
|
||||||
};
|
};
|
||||||
@@ -91,8 +90,8 @@ function AdminLayout() {
|
|||||||
const getTitle = () => {
|
const getTitle = () => {
|
||||||
const path = location.pathname;
|
const path = location.pathname;
|
||||||
if (path.includes('/dashboard')) return '控制台';
|
if (path.includes('/dashboard')) return '控制台';
|
||||||
if (path.includes('/generate')) return '生成二维码';
|
|
||||||
if (path.includes('/manage')) return '企业管理';
|
if (path.includes('/manage')) return '企业管理';
|
||||||
|
if (path.includes('/employee-serials')) return '员工管理';
|
||||||
if (path.includes('/profile')) return '用户资料';
|
if (path.includes('/profile')) return '用户资料';
|
||||||
return '控制台';
|
return '控制台';
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Card, Row, Col, Statistic, Table, Spin, message } from 'antd';
|
import { Card, Row, Col, Statistic, Table, Spin, message, Tag } from 'antd';
|
||||||
import { TeamOutlined, KeyOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
import { TeamOutlined, KeyOutlined, CheckCircleOutlined, UserOutlined } from '@ant-design/icons';
|
||||||
import { dashboardApi } from '@/services/api';
|
import { dashboardApi } from '@/services/api';
|
||||||
import type { DashboardStats } from '@/types';
|
import type { DashboardStats } from '@/types';
|
||||||
|
|
||||||
@@ -45,6 +45,16 @@ function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="总员工数"
|
||||||
|
value={stats?.totalEmployeeSerials || 0}
|
||||||
|
prefix={<UserOutlined />}
|
||||||
|
valueStyle={{ color: '#722ed1' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<Col xs={24} sm={12} lg={6}>
|
||||||
<Card>
|
<Card>
|
||||||
<Statistic
|
<Statistic
|
||||||
@@ -65,16 +75,6 @@ function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
|
||||||
<Card>
|
|
||||||
<Statistic
|
|
||||||
title="无效序列号"
|
|
||||||
value={stats?.inactiveSerials || 0}
|
|
||||||
prefix={<CloseCircleOutlined />}
|
|
||||||
valueStyle={{ color: '#ff4d4f' }}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Card title="最近生成的序列号" style={{ marginBottom: '24px' }}>
|
<Card title="最近生成的序列号" style={{ marginBottom: '24px' }}>
|
||||||
@@ -82,6 +82,16 @@ function DashboardPage() {
|
|||||||
columns={[
|
columns={[
|
||||||
{ title: '序列号', dataIndex: 'serialNumber', key: 'serialNumber' },
|
{ title: '序列号', dataIndex: 'serialNumber', key: 'serialNumber' },
|
||||||
{ title: '企业名称', dataIndex: 'companyName', key: 'companyName' },
|
{ title: '企业名称', dataIndex: 'companyName', key: 'companyName' },
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
dataIndex: 'type',
|
||||||
|
key: 'type',
|
||||||
|
render: (type: string) => (
|
||||||
|
<Tag color={type === 'employee' ? 'purple' : 'blue'}>
|
||||||
|
{type === 'employee' ? '员工' : '企业'}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
|
|||||||
539
src/pages/EmployeeSerials.tsx
Normal file
539
src/pages/EmployeeSerials.tsx
Normal file
@@ -0,0 +1,539 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Card, Table, Input, Button, Space, message, Modal, Tag, Form, Select, InputNumber, Pagination, ColorPicker } from 'antd';
|
||||||
|
import { UserOutlined, PlusOutlined, StopOutlined, EditOutlined, QrcodeOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||||
|
import { employeeSerialApi } from '@/services/api';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import type { Color } from 'antd/es/color-picker';
|
||||||
|
import type { EmployeeSerial } from '@/types';
|
||||||
|
|
||||||
|
function EmployeeSerialsPage() {
|
||||||
|
const [serials, setSerials] = useState<EmployeeSerial[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [limit, setLimit] = useState(10);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [generateModalVisible, setGenerateModalVisible] = useState(false);
|
||||||
|
const [generateLoading, setGenerateLoading] = useState(false);
|
||||||
|
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||||
|
const [editLoading, setEditLoading] = useState(false);
|
||||||
|
const [selectedSerial, setSelectedSerial] = useState<EmployeeSerial | null>(null);
|
||||||
|
const [qrCodeModalVisible, setQrCodeModalVisible] = useState(false);
|
||||||
|
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('');
|
||||||
|
const [generateForm] = Form.useForm();
|
||||||
|
const [editForm] = Form.useForm();
|
||||||
|
const [qrColor, setQrColor] = useState('#000000');
|
||||||
|
const [generatedData, setGeneratedData] = useState<any>(null);
|
||||||
|
const [generateSuccessModalVisible, setGenerateSuccessModalVisible] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const colorPresets = [
|
||||||
|
'#000000',
|
||||||
|
'#165DFF',
|
||||||
|
'#52C41A',
|
||||||
|
'#FAAD14',
|
||||||
|
'#FF4D4F',
|
||||||
|
'#722ED1',
|
||||||
|
'#EB2F96',
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSerials();
|
||||||
|
}, [page, limit, searchTerm]);
|
||||||
|
|
||||||
|
const handlePageChange = (newPage: number, newLimit: number) => {
|
||||||
|
setPage(newPage);
|
||||||
|
setLimit(newLimit);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSerials = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await employeeSerialApi.list({ page, limit, search: searchTerm || undefined });
|
||||||
|
setSerials(result.data);
|
||||||
|
setTotal(result.pagination.total);
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '加载员工序列号列表失败');
|
||||||
|
setSerials([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerate = async (values: { companyName: string; position: string; employeeName: string; quantity: number }) => {
|
||||||
|
setGenerateLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await employeeSerialApi.generate(values);
|
||||||
|
|
||||||
|
if (result.serials && result.serials.length > 0) {
|
||||||
|
const baseUrl = window.location.origin;
|
||||||
|
const queryUrl = `${baseUrl}/query?serial=${result.serials[0].serialNumber}`;
|
||||||
|
const qrCode = await QRCode.toDataURL(queryUrl, {
|
||||||
|
color: {
|
||||||
|
dark: qrColor,
|
||||||
|
light: '#ffffff',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setQrCodeDataUrl(qrCode);
|
||||||
|
setGeneratedData(result);
|
||||||
|
setGenerateSuccessModalVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
message.success(result.message || '生成成功');
|
||||||
|
setGenerateModalVisible(false);
|
||||||
|
generateForm.resetFields();
|
||||||
|
loadSerials();
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '生成失败');
|
||||||
|
} finally {
|
||||||
|
setGenerateLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadQR = () => {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `qrcode-${generatedData?.serials?.[0]?.serialNumber}.png`;
|
||||||
|
link.href = qrCodeDataUrl;
|
||||||
|
link.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewQuery = () => {
|
||||||
|
if (generatedData?.serials?.[0]?.serialNumber) {
|
||||||
|
navigate(`/query?serial=${generatedData.serials[0].serialNumber}`);
|
||||||
|
setGenerateSuccessModalVisible(false);
|
||||||
|
generateForm.resetFields();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (serial: EmployeeSerial) => {
|
||||||
|
setSelectedSerial(serial);
|
||||||
|
editForm.setFieldsValue({
|
||||||
|
companyName: serial.companyName,
|
||||||
|
position: serial.position, // 映射 position 到 position
|
||||||
|
employeeName: serial.employeeName,
|
||||||
|
isActive: serial.isActive,
|
||||||
|
});
|
||||||
|
setEditModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = async (values: { companyName?: string; position?: string; employeeName?: string; isActive?: boolean }) => {
|
||||||
|
if (!selectedSerial) return;
|
||||||
|
setEditLoading(true);
|
||||||
|
try {
|
||||||
|
await employeeSerialApi.update(selectedSerial.serialNumber, {
|
||||||
|
companyName: values.companyName,
|
||||||
|
position: values.position, // 映射 position 到 position
|
||||||
|
employeeName: values.employeeName,
|
||||||
|
isActive: values.isActive,
|
||||||
|
});
|
||||||
|
message.success('更新成功');
|
||||||
|
setEditModalVisible(false);
|
||||||
|
loadSerials();
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '更新失败');
|
||||||
|
} finally {
|
||||||
|
setEditLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevoke = async (serial: EmployeeSerial) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认吊销',
|
||||||
|
content: `确定要吊销序列号 "${serial.serialNumber}" 吗?`,
|
||||||
|
okText: '确定',
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
await employeeSerialApi.revoke(serial.serialNumber);
|
||||||
|
message.success('吊销成功');
|
||||||
|
loadSerials();
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '吊销失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (serial: EmployeeSerial) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认删除',
|
||||||
|
content: `确定要删除序列号 "${serial.serialNumber}" 吗?此操作不可恢复!`,
|
||||||
|
okText: '确定',
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
await employeeSerialApi.delete(serial.serialNumber);
|
||||||
|
message.success('删除成功');
|
||||||
|
loadSerials();
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '删除失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewQrCode = async (serial: EmployeeSerial) => {
|
||||||
|
setSelectedSerial(serial);
|
||||||
|
try {
|
||||||
|
const baseUrl = window.location.origin;
|
||||||
|
const result = await employeeSerialApi.generateQrCode(serial.serialNumber, `${baseUrl}/query`);
|
||||||
|
if (result.qrCodeData) {
|
||||||
|
const qrDataUrl = result.qrCodeData.startsWith('data:')
|
||||||
|
? result.qrCodeData
|
||||||
|
: `data:image/png;base64,${result.qrCodeData}`;
|
||||||
|
setQrCodeDataUrl(qrDataUrl);
|
||||||
|
setQrCodeModalVisible(true);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '生成二维码失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (value: string) => {
|
||||||
|
setSearchTerm(value);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '序列号',
|
||||||
|
dataIndex: 'serialNumber',
|
||||||
|
key: 'serialNumber',
|
||||||
|
width: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '企业名称',
|
||||||
|
dataIndex: 'companyName',
|
||||||
|
key: 'companyName',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '职位',
|
||||||
|
dataIndex: 'position',
|
||||||
|
key: 'position',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '员工姓名',
|
||||||
|
dataIndex: 'employeeName',
|
||||||
|
key: 'employeeName',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'isActive',
|
||||||
|
key: 'isActive',
|
||||||
|
render: (isActive: boolean) => (
|
||||||
|
<Tag color={isActive ? 'green' : 'red'}>
|
||||||
|
{isActive ? '有效' : '已吊销'}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
key: 'createdAt',
|
||||||
|
render: (date: string) => new Date(date).toLocaleString('zh-CN'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
render: (_: any, record: EmployeeSerial) => (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<QrcodeOutlined />}
|
||||||
|
onClick={() => handleViewQrCode(record)}
|
||||||
|
>
|
||||||
|
二维码
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => handleEdit(record)}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
{record.isActive && (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<StopOutlined />}
|
||||||
|
onClick={() => handleRevoke(record)}
|
||||||
|
>
|
||||||
|
吊销
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => handleDelete(record)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<UserOutlined />
|
||||||
|
<span>员工管理</span>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Input.Search
|
||||||
|
placeholder="搜索序列号/企业/职位/员工"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 250 }}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (!e.target.value) {
|
||||||
|
handleSearch('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => setGenerateModalVisible(true)}
|
||||||
|
>
|
||||||
|
生成序列号
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={serials}
|
||||||
|
rowKey="serialNumber"
|
||||||
|
loading={loading}
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Pagination
|
||||||
|
current={page}
|
||||||
|
pageSize={limit}
|
||||||
|
total={total}
|
||||||
|
onChange={handlePageChange}
|
||||||
|
showSizeChanger={true}
|
||||||
|
showTotal={(t) => `共计 ${t} 条记录`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="生成员工序列号"
|
||||||
|
open={generateModalVisible}
|
||||||
|
onCancel={() => {
|
||||||
|
setGenerateModalVisible(false);
|
||||||
|
generateForm.resetFields();
|
||||||
|
}}
|
||||||
|
footer={null}
|
||||||
|
width={500}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={generateForm}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleGenerate}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="companyName"
|
||||||
|
label="企业名称"
|
||||||
|
rules={[{ required: true, message: '请输入企业名称' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入企业名称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="position"
|
||||||
|
label="职位"
|
||||||
|
rules={[{ required: true, message: '请输入职位' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入职位" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="employeeName"
|
||||||
|
label="员工姓名"
|
||||||
|
rules={[{ required: true, message: '请输入员工姓名' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入员工姓名" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="quantity"
|
||||||
|
label="生成数量"
|
||||||
|
rules={[{ required: true, message: '请输入生成数量' }]}
|
||||||
|
initialValue={1}
|
||||||
|
>
|
||||||
|
<InputNumber min={1} max={1000} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="二维码颜色"
|
||||||
|
name="qrColor"
|
||||||
|
initialValue="#000000"
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||||
|
{colorPresets.map((color) => (
|
||||||
|
<div
|
||||||
|
key={color}
|
||||||
|
onClick={() => setQrColor(color)}
|
||||||
|
style={{
|
||||||
|
width: '28px',
|
||||||
|
height: '28px',
|
||||||
|
backgroundColor: color,
|
||||||
|
border: qrColor === color ? '2px solid #165DFF' : '2px solid #d9d9d9',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ColorPicker
|
||||||
|
value={qrColor}
|
||||||
|
onChange={(color: Color) => {
|
||||||
|
const hexColor = color.toHexString();
|
||||||
|
setQrColor(hexColor);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||||
|
<Button onClick={() => setGenerateModalVisible(false)}>取消</Button>
|
||||||
|
<Button type="primary" htmlType="submit" loading={generateLoading}>
|
||||||
|
生成
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="编辑员工序列号"
|
||||||
|
open={editModalVisible}
|
||||||
|
onCancel={() => {
|
||||||
|
setEditModalVisible(false);
|
||||||
|
editForm.resetFields();
|
||||||
|
}}
|
||||||
|
footer={null}
|
||||||
|
width={500}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={editForm}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleUpdate}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="companyName"
|
||||||
|
label="企业名称"
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入企业名称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="position"
|
||||||
|
label="职位"
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入职位" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="employeeName"
|
||||||
|
label="员工姓名"
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入员工姓名" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="isActive"
|
||||||
|
label="状态"
|
||||||
|
>
|
||||||
|
<Select placeholder="请选择状态">
|
||||||
|
<Select.Option value={true}>有效</Select.Option>
|
||||||
|
<Select.Option value={false}>吊销</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||||
|
<Button onClick={() => setEditModalVisible(false)}>取消</Button>
|
||||||
|
<Button type="primary" htmlType="submit" loading={editLoading}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="生成成功"
|
||||||
|
open={generateSuccessModalVisible}
|
||||||
|
onCancel={() => setGenerateSuccessModalVisible(false)}
|
||||||
|
footer={null}
|
||||||
|
width={600}
|
||||||
|
>
|
||||||
|
{generatedData && (
|
||||||
|
<div>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<div>
|
||||||
|
<p><strong>企业名称:</strong> {generatedData.serials?.[0]?.companyName}</p>
|
||||||
|
<p><strong>职位:</strong> {generatedData.serials?.[0]?.position}</p>
|
||||||
|
<p><strong>员工姓名:</strong> {generatedData.serials?.[0]?.employeeName}</p>
|
||||||
|
<p><strong>生成数量:</strong> {generatedData.serials?.length || 0}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{qrCodeDataUrl && (
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<img src={qrCodeDataUrl} alt="QR Code" style={{ width: '200px', height: '200px' }} />
|
||||||
|
{generatedData.serials && generatedData.serials.length > 0 && (
|
||||||
|
<p style={{ marginTop: '12px', fontFamily: 'monospace', fontSize: '16px', fontWeight: 'bold', color: '#165DFF' }}>{generatedData.serials[0].serialNumber}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" onClick={handleViewQuery}>查询序列号</Button>
|
||||||
|
<Button onClick={handleDownloadQR}>下载二维码</Button>
|
||||||
|
<Button onClick={() => {
|
||||||
|
setGenerateSuccessModalVisible(false);
|
||||||
|
generateForm.resetFields();
|
||||||
|
}}>生成新的序列号</Button>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="员工二维码"
|
||||||
|
open={qrCodeModalVisible}
|
||||||
|
onCancel={() => setQrCodeModalVisible(false)}
|
||||||
|
footer={null}
|
||||||
|
width={400}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
{qrCodeDataUrl && (
|
||||||
|
<>
|
||||||
|
<img src={qrCodeDataUrl} alt="QR Code" style={{ width: '200px', height: '200px' }} />
|
||||||
|
<p style={{ marginTop: '12px', fontFamily: 'monospace', fontSize: '16px', fontWeight: 'bold', color: '#165DFF' }}>
|
||||||
|
{selectedSerial?.serialNumber}
|
||||||
|
</p>
|
||||||
|
<p style={{ marginTop: '8px', fontSize: '12px', color: '#999' }}>
|
||||||
|
{selectedSerial?.companyName} - {selectedSerial?.position} - {selectedSerial?.employeeName}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EmployeeSerialsPage;
|
||||||
@@ -1,288 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Form, Input, Button, Card, Radio, InputNumber, DatePicker, message, Modal, Space, ColorPicker, Divider, Row, Col } from 'antd';
|
|
||||||
import { QrcodeOutlined } from '@ant-design/icons';
|
|
||||||
import QRCode from 'qrcode';
|
|
||||||
import type { Color } from 'antd/es/color-picker';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import './styles/Generate.css';
|
|
||||||
|
|
||||||
function GeneratePage() {
|
|
||||||
const [form] = Form.useForm();
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [generatedData, setGeneratedData] = useState<any>(null);
|
|
||||||
const [qrCodeDataUrl, setQrCodeDataUrl] = useState<string>('');
|
|
||||||
const [modalVisible, setModalVisible] = useState(false);
|
|
||||||
const [qrColor, setQrColor] = useState<string>('#000000');
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const colorPresets = [
|
|
||||||
'#000000',
|
|
||||||
'#165DFF',
|
|
||||||
'#52C41A',
|
|
||||||
'#FAAD14',
|
|
||||||
'#FF4D4F',
|
|
||||||
'#722ED1',
|
|
||||||
'#EB2F96',
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleGenerate = async (values: any) => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('authToken');
|
|
||||||
const headers: any = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
};
|
|
||||||
if (token) {
|
|
||||||
headers.Authorization = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
companyName: values.companyName,
|
|
||||||
quantity: values.quantity,
|
|
||||||
validDays: values.validOption === 'days' ? values.validDays : undefined,
|
|
||||||
serialPrefix: values.serialOption === 'custom' ? values.serialPrefix : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch('/api/serials/generate', {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.serials) {
|
|
||||||
setGeneratedData(data);
|
|
||||||
|
|
||||||
if (data.serials && data.serials.length > 0) {
|
|
||||||
const baseUrl = window.location.origin;
|
|
||||||
const queryUrl = `${baseUrl}/query?serial=${data.serials[0].serialNumber}`;
|
|
||||||
const qrCode = await QRCode.toDataURL(queryUrl, {
|
|
||||||
color: {
|
|
||||||
dark: qrColor,
|
|
||||||
light: '#ffffff',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
setQrCodeDataUrl(qrCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
setModalVisible(true);
|
|
||||||
message.success('生成成功!');
|
|
||||||
} else {
|
|
||||||
throw new Error(data.error || '生成失败');
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
message.error(error.message || '生成失败');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownloadQR = () => {
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.download = `qrcode-${generatedData?.serials?.[0]?.serialNumber}.png`;
|
|
||||||
link.href = qrCodeDataUrl;
|
|
||||||
link.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleViewQuery = () => {
|
|
||||||
if (generatedData?.serials?.[0]?.serialNumber) {
|
|
||||||
navigate(`/query?serial=${generatedData.serials[0].serialNumber}`);
|
|
||||||
setModalVisible(false);
|
|
||||||
form.resetFields();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Card
|
|
||||||
title={
|
|
||||||
<Space>
|
|
||||||
<QrcodeOutlined />
|
|
||||||
<span>生成二维码</span>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
bordered={false}
|
|
||||||
>
|
|
||||||
<Form
|
|
||||||
form={form}
|
|
||||||
onFinish={handleGenerate}
|
|
||||||
layout="vertical"
|
|
||||||
initialValues={{
|
|
||||||
serialOption: 'auto',
|
|
||||||
quantity: 1,
|
|
||||||
validOption: 'days',
|
|
||||||
validDays: 365,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Row gutter={24}>
|
|
||||||
<Col span={12}>
|
|
||||||
<Form.Item
|
|
||||||
label="企业名称"
|
|
||||||
name="companyName"
|
|
||||||
rules={[{ required: true, message: '请输入企业名称' }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="输入企业名称(如:浙江贝凡)" style={{ width: '80%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item label="序列号设置" name="serialOption">
|
|
||||||
<Radio.Group>
|
|
||||||
<Radio value="auto">自动生成</Radio>
|
|
||||||
<Radio value="custom">自定义前缀</Radio>
|
|
||||||
</Radio.Group>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
noStyle
|
|
||||||
shouldUpdate={(prevValues, currentValues) => prevValues.serialOption !== currentValues.serialOption}
|
|
||||||
>
|
|
||||||
{({ getFieldValue }) =>
|
|
||||||
getFieldValue('serialOption') === 'custom' ? (
|
|
||||||
<Form.Item
|
|
||||||
label="自定义前缀"
|
|
||||||
name="serialPrefix"
|
|
||||||
rules={[{ max: 10, message: '前缀不能超过10个字符' }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="输入自定义前缀(如:MYCOMPANY)" maxLength={10} />
|
|
||||||
</Form.Item>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
label="序列号数量"
|
|
||||||
name="quantity"
|
|
||||||
rules={[{ required: true, message: '请输入序列号数量' }]}
|
|
||||||
>
|
|
||||||
<InputNumber min={1} max={100} style={{ width: '80%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
<Col span={12}>
|
|
||||||
<Form.Item label="有效期设置" name="validOption">
|
|
||||||
<Radio.Group>
|
|
||||||
<Radio value="days">按天数</Radio>
|
|
||||||
<Radio value="date">按日期</Radio>
|
|
||||||
</Radio.Group>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
noStyle
|
|
||||||
shouldUpdate={(prevValues, currentValues) => prevValues.validOption !== currentValues.validOption}
|
|
||||||
>
|
|
||||||
{({ getFieldValue }) =>
|
|
||||||
getFieldValue('validOption') === 'days' ? (
|
|
||||||
<Form.Item
|
|
||||||
label="有效天数"
|
|
||||||
name="validDays"
|
|
||||||
rules={[{ required: true, message: '请输入有效天数' }]}
|
|
||||||
>
|
|
||||||
<InputNumber min={1} max={3650} style={{ width: '80%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
) : (
|
|
||||||
<Form.Item
|
|
||||||
label="有效期至"
|
|
||||||
name="validUntil"
|
|
||||||
rules={[{ required: true, message: '请选择有效期' }]}
|
|
||||||
>
|
|
||||||
<DatePicker showTime style={{ width: '80%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
label="二维码颜色"
|
|
||||||
name="qrColor"
|
|
||||||
initialValue="#000000"
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
|
||||||
{colorPresets.map((color) => (
|
|
||||||
<div
|
|
||||||
key={color}
|
|
||||||
onClick={() => setQrColor(color)}
|
|
||||||
style={{
|
|
||||||
width: '32px',
|
|
||||||
height: '32px',
|
|
||||||
backgroundColor: color,
|
|
||||||
border: qrColor === color ? '2px solid #165DFF' : '2px solid #d9d9d9',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<ColorPicker
|
|
||||||
value={qrColor}
|
|
||||||
onChange={(color: Color) => {
|
|
||||||
const hexColor = color.toHexString();
|
|
||||||
setQrColor(hexColor);
|
|
||||||
}}
|
|
||||||
showText
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Form.Item>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
htmlType="submit"
|
|
||||||
loading={loading}
|
|
||||||
size="large"
|
|
||||||
block
|
|
||||||
icon={<QrcodeOutlined />}
|
|
||||||
>
|
|
||||||
生成序列号和二维码
|
|
||||||
</Button>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
title="生成成功"
|
|
||||||
open={modalVisible}
|
|
||||||
onCancel={() => setModalVisible(false)}
|
|
||||||
footer={null}
|
|
||||||
width={600}
|
|
||||||
>
|
|
||||||
{generatedData && (
|
|
||||||
<div>
|
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
|
||||||
<div>
|
|
||||||
<p><strong>企业名称:</strong> {generatedData.companyName || generatedData.serials?.[0]?.companyName}</p>
|
|
||||||
<p><strong>生成数量:</strong> {generatedData.serials?.length || 0}</p>
|
|
||||||
{generatedData.serials && generatedData.serials.length > 0 && (
|
|
||||||
<p><strong>有效期至:</strong> {new Date(generatedData.serials[0].validUntil).toLocaleString('zh-CN')}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{qrCodeDataUrl && (
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
|
||||||
<img src={qrCodeDataUrl} alt="QR Code" style={{ width: '200px', height: '200px' }} />
|
|
||||||
{generatedData.serials && generatedData.serials.length > 0 && (
|
|
||||||
<p style={{ marginTop: '12px', fontFamily: 'monospace', fontSize: '16px', fontWeight: 'bold', color: '#165DFF' }}>{generatedData.serials[0].serialNumber}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Space>
|
|
||||||
<Button type="primary" onClick={handleViewQuery}>查询序列号</Button>
|
|
||||||
<Button onClick={handleDownloadQR}>下载二维码</Button>
|
|
||||||
<Button onClick={() => {
|
|
||||||
setModalVisible(false);
|
|
||||||
form.resetFields();
|
|
||||||
}}>生成新的二维码</Button>
|
|
||||||
</Space>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default GeneratePage;
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Card, Table, Input, Select, Button, Space, message, Modal, Tag, Spin } from 'antd';
|
import { Card, Table, Input, Button, Space, message, Modal, Tag, Spin, Form, Radio, InputNumber, DatePicker, ColorPicker, Pagination } from 'antd';
|
||||||
import { TeamOutlined, DeleteOutlined, EyeOutlined, StopOutlined } from '@ant-design/icons';
|
import { TeamOutlined, DeleteOutlined, EyeOutlined, StopOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
import type { Company } from '@/types';
|
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import type { Color } from 'antd/es/color-picker';
|
||||||
|
|
||||||
interface CompanyData {
|
interface CompanyData {
|
||||||
companyName: string;
|
companyName: string;
|
||||||
@@ -25,16 +25,23 @@ function ManagePage() {
|
|||||||
const [qrCodeModalVisible, setQrCodeModalVisible] = useState(false);
|
const [qrCodeModalVisible, setQrCodeModalVisible] = useState(false);
|
||||||
const [qrCodeDataUrl, setQrCodeDataUrl] = useState<string>('');
|
const [qrCodeDataUrl, setQrCodeDataUrl] = useState<string>('');
|
||||||
const [selectedSerial, setSelectedSerial] = useState<string>('');
|
const [selectedSerial, setSelectedSerial] = useState<string>('');
|
||||||
|
const [generateModalVisible, setGenerateModalVisible] = useState(false);
|
||||||
|
const [generateLoading, setGenerateLoading] = useState(false);
|
||||||
|
const [generateForm] = Form.useForm();
|
||||||
|
const [qrColor, setQrColor] = useState<string>('#000000');
|
||||||
|
const [generatedData, setGeneratedData] = useState<any>(null);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadCompanies();
|
loadCompanies();
|
||||||
}, [searchTerm]);
|
}, [searchTerm, page, pageSize]);
|
||||||
|
|
||||||
const loadCompanies = async () => {
|
const loadCompanies = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// 直接使用 apiClient 来调用后端接口
|
|
||||||
const token = localStorage.getItem('authToken');
|
const token = localStorage.getItem('authToken');
|
||||||
const headers: any = {
|
const headers: any = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -44,17 +51,26 @@ function ManagePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let url = '/api/companies';
|
let url = '/api/companies';
|
||||||
if (searchTerm) {
|
const params = new URLSearchParams();
|
||||||
url += `?search=${encodeURIComponent(searchTerm)}`;
|
if (page > 1) params.append('page', String(page));
|
||||||
}
|
if (pageSize !== 10) params.append('limit', String(pageSize));
|
||||||
|
if (searchTerm) params.append('search', searchTerm);
|
||||||
|
if (params.toString()) url += `?${params.toString()}`;
|
||||||
|
|
||||||
const response = await fetch(url, { headers });
|
const response = await fetch(url, { headers });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`请求失败: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.data) {
|
if (data.data) {
|
||||||
setCompanies(data.data);
|
setCompanies(data.data);
|
||||||
|
setTotal(data.pagination?.total || data.data.length);
|
||||||
} else if (data.message) {
|
} else if (data.message) {
|
||||||
setCompanies([]);
|
setCompanies([]);
|
||||||
|
setTotal(0);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(data.error || '获取企业列表失败');
|
throw new Error(data.error || '获取企业列表失败');
|
||||||
}
|
}
|
||||||
@@ -62,6 +78,7 @@ function ManagePage() {
|
|||||||
console.error('Load companies error:', error);
|
console.error('Load companies error:', error);
|
||||||
message.error(error.message || '加载企业列表失败');
|
message.error(error.message || '加载企业列表失败');
|
||||||
setCompanies([]);
|
setCompanies([]);
|
||||||
|
setTotal(0);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -224,6 +241,92 @@ function ManagePage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGenerate = async (values: any) => {
|
||||||
|
setGenerateLoading(true);
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
const headers: any = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
if (token) {
|
||||||
|
headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
companyName: values.companyName,
|
||||||
|
quantity: values.quantity,
|
||||||
|
validDays: values.validOption === 'days' ? values.validDays : undefined,
|
||||||
|
serialPrefix: values.serialOption === 'custom' ? values.serialPrefix : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch('/api/serials/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.serials) {
|
||||||
|
setGeneratedData(data);
|
||||||
|
|
||||||
|
if (data.serials && data.serials.length > 0) {
|
||||||
|
const baseUrl = window.location.origin;
|
||||||
|
const queryUrl = `${baseUrl}/query?serial=${data.serials[0].serialNumber}`;
|
||||||
|
const qrCode = await QRCode.toDataURL(queryUrl, {
|
||||||
|
color: {
|
||||||
|
dark: qrColor,
|
||||||
|
light: '#ffffff',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setQrCodeDataUrl(qrCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
message.success('生成成功!');
|
||||||
|
setGenerateSuccessModalVisible(true);
|
||||||
|
loadCompanies();
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || '生成失败');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '生成失败');
|
||||||
|
} finally {
|
||||||
|
setGenerateLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadQR = () => {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `qrcode-${generatedData?.serials?.[0]?.serialNumber}.png`;
|
||||||
|
link.href = qrCodeDataUrl;
|
||||||
|
link.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewQuery = () => {
|
||||||
|
if (generatedData?.serials?.[0]?.serialNumber) {
|
||||||
|
navigate(`/query?serial=${generatedData.serials[0].serialNumber}`);
|
||||||
|
setGenerateSuccessModalVisible(false);
|
||||||
|
generateForm.resetFields();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [generateSuccessModalVisible, setGenerateSuccessModalVisible] = useState(false);
|
||||||
|
|
||||||
|
const colorPresets = [
|
||||||
|
'#000000',
|
||||||
|
'#165DFF',
|
||||||
|
'#52C41A',
|
||||||
|
'#FAAD14',
|
||||||
|
'#FF4D4F',
|
||||||
|
'#722ED1',
|
||||||
|
'#EB2F96',
|
||||||
|
];
|
||||||
|
|
||||||
|
const handlePageChange = (newPage: number, newPageSize: number) => {
|
||||||
|
setPage(newPage);
|
||||||
|
setPageSize(newPageSize);
|
||||||
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: '企业名称',
|
title: '企业名称',
|
||||||
@@ -300,6 +403,7 @@ function ManagePage() {
|
|||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
extra={
|
extra={
|
||||||
|
<Space>
|
||||||
<Input.Search
|
<Input.Search
|
||||||
placeholder="搜索企业"
|
placeholder="搜索企业"
|
||||||
allowClear
|
allowClear
|
||||||
@@ -308,6 +412,10 @@ function ManagePage() {
|
|||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
/>
|
/>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => setGenerateModalVisible(true)}>
|
||||||
|
生成序列号
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Table
|
<Table
|
||||||
@@ -315,12 +423,18 @@ function ManagePage() {
|
|||||||
dataSource={companies}
|
dataSource={companies}
|
||||||
rowKey="companyName"
|
rowKey="companyName"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={{
|
pagination={false}
|
||||||
pageSize: 10,
|
|
||||||
showSizeChanger: true,
|
|
||||||
showTotal: (total) => `共 ${total} 家企业`,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Pagination
|
||||||
|
current={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={total}
|
||||||
|
onChange={handlePageChange}
|
||||||
|
showSizeChanger={true}
|
||||||
|
showTotal={() => `共计 ${total} 条记录`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
@@ -420,6 +534,183 @@ function ManagePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="生成企业序列号"
|
||||||
|
open={generateModalVisible}
|
||||||
|
onCancel={() => {
|
||||||
|
setGenerateModalVisible(false);
|
||||||
|
generateForm.resetFields();
|
||||||
|
}}
|
||||||
|
footer={null}
|
||||||
|
width={600}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={generateForm}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleGenerate}
|
||||||
|
initialValues={{
|
||||||
|
serialOption: 'auto',
|
||||||
|
quantity: 1,
|
||||||
|
validOption: 'days',
|
||||||
|
validDays: 365,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label="企业名称"
|
||||||
|
name="companyName"
|
||||||
|
rules={[{ required: true, message: '请输入企业名称' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="输入企业名称(如:浙江贝凡)" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="序列号设置" name="serialOption">
|
||||||
|
<Radio.Group>
|
||||||
|
<Radio value="auto">自动生成</Radio>
|
||||||
|
<Radio value="custom">自定义前缀</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
noStyle
|
||||||
|
shouldUpdate={(prevValues, currentValues) => prevValues.serialOption !== currentValues.serialOption}
|
||||||
|
>
|
||||||
|
{({ getFieldValue }) =>
|
||||||
|
getFieldValue('serialOption') === 'custom' ? (
|
||||||
|
<Form.Item
|
||||||
|
label="自定义前缀"
|
||||||
|
name="serialPrefix"
|
||||||
|
rules={[{ max: 10, message: '前缀不能超过10个字符' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="输入自定义前缀(如:MYCOMPANY)" maxLength={10} />
|
||||||
|
</Form.Item>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="有效期设置" name="validOption">
|
||||||
|
<Radio.Group>
|
||||||
|
<Radio value="days">按天数</Radio>
|
||||||
|
<Radio value="date">按日期</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
noStyle
|
||||||
|
shouldUpdate={(prevValues, currentValues) => prevValues.validOption !== currentValues.validOption}
|
||||||
|
>
|
||||||
|
{({ getFieldValue }) =>
|
||||||
|
getFieldValue('validOption') === 'days' ? (
|
||||||
|
<Form.Item
|
||||||
|
label="有效天数"
|
||||||
|
name="validDays"
|
||||||
|
rules={[{ required: true, message: '请输入有效天数' }]}
|
||||||
|
>
|
||||||
|
<InputNumber min={1} max={3650} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
) : (
|
||||||
|
<Form.Item
|
||||||
|
label="有效期至"
|
||||||
|
name="validUntil"
|
||||||
|
rules={[{ required: true, message: '请选择有效期' }]}
|
||||||
|
>
|
||||||
|
<DatePicker showTime style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="生成数量"
|
||||||
|
name="quantity"
|
||||||
|
rules={[{ required: true, message: '请输入生成数量' }]}
|
||||||
|
>
|
||||||
|
<InputNumber min={1} max={100} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="二维码颜色"
|
||||||
|
name="qrColor"
|
||||||
|
initialValue="#000000"
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||||
|
{colorPresets.map((color) => (
|
||||||
|
<div
|
||||||
|
key={color}
|
||||||
|
onClick={() => setQrColor(color)}
|
||||||
|
style={{
|
||||||
|
width: '28px',
|
||||||
|
height: '28px',
|
||||||
|
backgroundColor: color,
|
||||||
|
border: qrColor === color ? '2px solid #165DFF' : '2px solid #d9d9d9',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ColorPicker
|
||||||
|
value={qrColor}
|
||||||
|
onChange={(color: Color) => {
|
||||||
|
const hexColor = color.toHexString();
|
||||||
|
setQrColor(hexColor);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item>
|
||||||
|
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||||
|
<Button onClick={() => setGenerateModalVisible(false)}>取消</Button>
|
||||||
|
<Button type="primary" htmlType="submit" loading={generateLoading}>
|
||||||
|
生成
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="生成成功"
|
||||||
|
open={generateSuccessModalVisible}
|
||||||
|
onCancel={() => setGenerateSuccessModalVisible(false)}
|
||||||
|
footer={null}
|
||||||
|
width={600}
|
||||||
|
>
|
||||||
|
{generatedData && (
|
||||||
|
<div>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<div>
|
||||||
|
<p><strong>企业名称:</strong> {generatedData.companyName || generatedData.serials?.[0]?.companyName}</p>
|
||||||
|
<p><strong>生成数量:</strong> {generatedData.serials?.length || 0}</p>
|
||||||
|
{generatedData.serials && generatedData.serials.length > 0 && (
|
||||||
|
<p><strong>有效期至:</strong> {new Date(generatedData.serials[0].validUntil).toLocaleString('zh-CN')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{qrCodeDataUrl && (
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<img src={qrCodeDataUrl} alt="QR Code" style={{ width: '200px', height: '200px' }} />
|
||||||
|
{generatedData.serials && generatedData.serials.length > 0 && (
|
||||||
|
<p style={{ marginTop: '12px', fontFamily: 'monospace', fontSize: '16px', fontWeight: 'bold', color: '#165DFF' }}>{generatedData.serials[0].serialNumber}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" onClick={handleViewQuery}>查询序列号</Button>
|
||||||
|
<Button onClick={handleDownloadQR}>下载二维码</Button>
|
||||||
|
<Button onClick={() => {
|
||||||
|
setGenerateSuccessModalVisible(false);
|
||||||
|
generateForm.resetFields();
|
||||||
|
}}>生成新的序列号</Button>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ function ProfilePage() {
|
|||||||
email: values.email,
|
email: values.email,
|
||||||
});
|
});
|
||||||
setUser(updatedUser);
|
setUser(updatedUser);
|
||||||
|
localStorage.setItem('currentUser', JSON.stringify(updatedUser));
|
||||||
|
profileForm.setFieldsValue({
|
||||||
|
username: updatedUser.username,
|
||||||
|
name: updatedUser.name,
|
||||||
|
email: updatedUser.email,
|
||||||
|
role: updatedUser.role,
|
||||||
|
createdAt: new Date(updatedUser.createdAt).toLocaleString('zh-CN'),
|
||||||
|
});
|
||||||
message.success('更新资料成功!');
|
message.success('更新资料成功!');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error.message || '更新资料失败');
|
message.error(error.message || '更新资料失败');
|
||||||
|
|||||||
@@ -1,18 +1,28 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Input, Button, Card, message, Spin, Result } from 'antd';
|
import { Input, Button, Card, message, Spin, Result, Tag } from 'antd';
|
||||||
import { QrcodeOutlined, SearchOutlined, ArrowLeftOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
import { QrcodeOutlined, SearchOutlined, ArrowLeftOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||||
import { serialApi } from '@/services/api';
|
import { employeeSerialApi } from '@/services/api';
|
||||||
import type { Serial } from '@/types';
|
import type { Serial } from '@/types';
|
||||||
import './styles/PublicQuery.css';
|
import './styles/PublicQuery.css';
|
||||||
import logo from '@/assets/img/logo.png?url';
|
import logo from '@/assets/img/logo.png?url';
|
||||||
import beian from '@/assets/img/beian.png?url';
|
import beian from '@/assets/img/beian.png?url';
|
||||||
|
|
||||||
|
interface EmployeeSerialResult {
|
||||||
|
serialNumber: string;
|
||||||
|
companyName: string;
|
||||||
|
department: string;
|
||||||
|
employeeName: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
function PublicQueryPage() {
|
function PublicQueryPage() {
|
||||||
const [serialNumber, setSerialNumber] = useState('');
|
const [serialNumber, setSerialNumber] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [result, setResult] = useState<Serial | null>(null);
|
const [result, setResult] = useState<Serial | EmployeeSerialResult | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showResult, setShowResult] = useState(false);
|
const [showResult, setShowResult] = useState(false);
|
||||||
|
const [serialType, setSerialType] = useState<'company' | 'employee'>('company');
|
||||||
|
|
||||||
const performQuery = async (serialToQuery: string) => {
|
const performQuery = async (serialToQuery: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -20,8 +30,14 @@ function PublicQueryPage() {
|
|||||||
setResult(null);
|
setResult(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await serialApi.query(serialToQuery);
|
const response = await employeeSerialApi.queryAll(serialToQuery);
|
||||||
setResult(data);
|
if (response.type === 'employee') {
|
||||||
|
setSerialType('employee');
|
||||||
|
setResult(response.data as EmployeeSerialResult);
|
||||||
|
} else {
|
||||||
|
setSerialType('company');
|
||||||
|
setResult(response.data as Serial);
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || '查询失败');
|
setError(err.message || '查询失败');
|
||||||
setResult(null);
|
setResult(null);
|
||||||
@@ -104,16 +120,19 @@ function PublicQueryPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : result ? (
|
) : result ? (
|
||||||
<div className="success-container">
|
<div className="success-container">
|
||||||
{result.status !== 'active' ? (
|
{(result as any).isActive === false || (result as any).status === 'inactive' ? (
|
||||||
<Result
|
<Result
|
||||||
icon={<CloseCircleOutlined style={{ color: '#ff4d4f', fontSize: '72px' }} />}
|
icon={<CloseCircleOutlined style={{ color: '#ff4d4f', fontSize: '72px' }} />}
|
||||||
title="授权已吊销"
|
title={serialType === 'employee' ? "员工身份已吊销" : "授权已吊销"}
|
||||||
subTitle={`序列号验证通过,但已被吊销。企业:${result.companyName}`}
|
subTitle={serialType === 'employee'
|
||||||
|
? `序列号验证通过,但已被吊销。企业:${(result as EmployeeSerialResult).companyName}`
|
||||||
|
: `序列号验证通过,但已被吊销。企业:${(result as Serial).companyName}`
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Result
|
<Result
|
||||||
icon={<CheckCircleOutlined style={{ color: '#52c41a', fontSize: '72px' }} />}
|
icon={<CheckCircleOutlined style={{ color: '#52c41a', fontSize: '72px' }} />}
|
||||||
title="授权有效"
|
title={serialType === 'employee' ? "员工身份有效" : "授权有效"}
|
||||||
subTitle="您的序列号已验证通过"
|
subTitle="您的序列号已验证通过"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -127,14 +146,30 @@ function PublicQueryPage() {
|
|||||||
<span className="label">企业名称</span>
|
<span className="label">企业名称</span>
|
||||||
<span className="value">{result.companyName}</span>
|
<span className="value">{result.companyName}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{serialType === 'employee' && (result as EmployeeSerialResult).department && (
|
||||||
|
<>
|
||||||
|
<div className="detail-item">
|
||||||
|
<span className="label">职位</span>
|
||||||
|
<span className="value">{(result as EmployeeSerialResult).department}</span>
|
||||||
|
</div>
|
||||||
|
<div className="detail-item">
|
||||||
|
<span className="label">员工姓名</span>
|
||||||
|
<span className="value">{(result as EmployeeSerialResult).employeeName}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{serialType !== 'employee' && (result as Serial).validUntil && (
|
||||||
<div className="detail-item">
|
<div className="detail-item">
|
||||||
<span className="label">有效期至</span>
|
<span className="label">有效期至</span>
|
||||||
<span className="value">{new Date(result.validUntil).toLocaleString('zh-CN')}</span>
|
<span className="value">{new Date((result as Serial).validUntil).toLocaleString('zh-CN')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="detail-item">
|
<div className="detail-item">
|
||||||
<span className="label">授权状态</span>
|
<span className="label">授权状态</span>
|
||||||
<span className="value status">
|
<span className="value status">
|
||||||
{result.status === 'active' ? '有效' : '已吊销'}
|
<Tag color={(result as any).isActive === false || (result as any).status === 'inactive' ? 'red' : 'green'}>
|
||||||
|
{(result as any).isActive === false || (result as any).status === 'inactive' ? '已吊销' : '有效'}
|
||||||
|
</Tag>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
.generate-page {
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { ApiResponse, AuthResponse, User } from '@/types';
|
import type { ApiResponse, AuthResponse, User, EmployeeSerial, EmployeeSerialResponse } from '@/types';
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||||
|
|
||||||
@@ -78,10 +78,9 @@ export const authApi = {
|
|||||||
|
|
||||||
updateProfile: async (data: { name?: string; email?: string }) => {
|
updateProfile: async (data: { name?: string; email?: string }) => {
|
||||||
const response = await apiClient.put('/auth/profile', data);
|
const response = await apiClient.put('/auth/profile', data);
|
||||||
const user = response.data;
|
if (response.data.user) {
|
||||||
if (user) {
|
localStorage.setItem('currentUser', JSON.stringify(response.data.user));
|
||||||
localStorage.setItem('currentUser', JSON.stringify(user));
|
return response.data.user as User;
|
||||||
return user;
|
|
||||||
}
|
}
|
||||||
throw new Error('更新资料失败');
|
throw new Error('更新资料失败');
|
||||||
},
|
},
|
||||||
@@ -91,7 +90,10 @@ export const authApi = {
|
|||||||
if (response.data.message) {
|
if (response.data.message) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
throw new Error(response.data.error || '修改密码失败');
|
if (response.data.error) {
|
||||||
|
throw new Error(response.data.error);
|
||||||
|
}
|
||||||
|
throw new Error('修改密码失败');
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -192,6 +194,7 @@ export const dashboardApi = {
|
|||||||
return {
|
return {
|
||||||
totalCompanies: data.overview?.totalCompanies || 0,
|
totalCompanies: data.overview?.totalCompanies || 0,
|
||||||
totalSerials: data.overview?.totalSerials || 0,
|
totalSerials: data.overview?.totalSerials || 0,
|
||||||
|
totalEmployeeSerials: data.overview?.totalEmployeeSerials || 0,
|
||||||
activeSerials: data.overview?.activeSerials || 0,
|
activeSerials: data.overview?.activeSerials || 0,
|
||||||
inactiveSerials: data.overview?.inactiveSerials || 0,
|
inactiveSerials: data.overview?.inactiveSerials || 0,
|
||||||
monthlyData: data.monthlyStats || [],
|
monthlyData: data.monthlyStats || [],
|
||||||
@@ -207,9 +210,110 @@ export const dashboardApi = {
|
|||||||
companyName: s.companyName,
|
companyName: s.companyName,
|
||||||
status: s.isActive ? 'active' : 'inactive',
|
status: s.isActive ? 'active' : 'inactive',
|
||||||
createdAt: s.createdAt,
|
createdAt: s.createdAt,
|
||||||
|
type: s.type,
|
||||||
})) || [],
|
})) || [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw new Error('获取统计数据失败');
|
throw new Error('获取统计数据失败');
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const employeeSerialApi = {
|
||||||
|
generate: async (data: {
|
||||||
|
companyName: string;
|
||||||
|
position: string;
|
||||||
|
employeeName: string;
|
||||||
|
quantity: number;
|
||||||
|
serialPrefix?: string;
|
||||||
|
}) => {
|
||||||
|
const response = await apiClient.post('/employee-serials/generate', data);
|
||||||
|
if (response.data.serials) {
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
throw new Error(response.data.error || '生成员工序列号失败');
|
||||||
|
},
|
||||||
|
|
||||||
|
list: async (filter?: { page?: number; limit?: number; search?: string }) => {
|
||||||
|
let url = '/employee-serials';
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filter?.page && filter.page > 1) params.append('page', String(filter.page));
|
||||||
|
if (filter?.limit && filter.limit !== 20) params.append('limit', String(filter.limit));
|
||||||
|
if (filter?.search) params.append('search', filter.search);
|
||||||
|
if (params.toString()) url += `?${params.toString()}`;
|
||||||
|
|
||||||
|
const response = await apiClient.get(url);
|
||||||
|
if (response.data) {
|
||||||
|
return response.data as EmployeeSerialResponse;
|
||||||
|
}
|
||||||
|
throw new Error('获取员工序列号列表失败');
|
||||||
|
},
|
||||||
|
|
||||||
|
query: async (serialNumber: string) => {
|
||||||
|
const response = await apiClient.get(`/employee-serials/${encodeURIComponent(serialNumber)}/query`);
|
||||||
|
if (response.data.serial) {
|
||||||
|
return response.data.serial as EmployeeSerial;
|
||||||
|
}
|
||||||
|
throw new Error(response.data.error || '查询员工序列号失败');
|
||||||
|
},
|
||||||
|
|
||||||
|
queryAll: async (serialNumber: string) => {
|
||||||
|
// 先查企业序列号
|
||||||
|
try {
|
||||||
|
const companyResponse = await apiClient.get(`/serials/${encodeURIComponent(serialNumber)}/query`);
|
||||||
|
if (companyResponse.data.serial) {
|
||||||
|
return { type: 'company', data: companyResponse.data.serial };
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
// 企业序列号不存在,继续查员工序列号
|
||||||
|
}
|
||||||
|
// 再查员工序列号
|
||||||
|
try {
|
||||||
|
const employeeResponse = await apiClient.get(`/employee-serials/${encodeURIComponent(serialNumber)}/query`);
|
||||||
|
if (employeeResponse.data.serial) {
|
||||||
|
return { type: 'employee', data: employeeResponse.data.serial };
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
throw new Error('序列号不存在');
|
||||||
|
}
|
||||||
|
throw new Error('序列号不存在');
|
||||||
|
},
|
||||||
|
|
||||||
|
generateQrCode: async (serialNumber: string, baseUrl?: string) => {
|
||||||
|
const response = await apiClient.post(`/employee-serials/${encodeURIComponent(serialNumber)}/qrcode`, {
|
||||||
|
baseUrl,
|
||||||
|
});
|
||||||
|
if (response.data.qrCodeData) {
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
throw new Error(response.data.error || '生成二维码失败');
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (serialNumber: string, data: {
|
||||||
|
companyName?: string;
|
||||||
|
position?: string;
|
||||||
|
employeeName?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
}) => {
|
||||||
|
const response = await apiClient.put(`/employee-serials/${encodeURIComponent(serialNumber)}`, data);
|
||||||
|
if (response.data.serial) {
|
||||||
|
return response.data.serial as EmployeeSerial;
|
||||||
|
}
|
||||||
|
throw new Error(response.data.error || '更新员工序列号失败');
|
||||||
|
},
|
||||||
|
|
||||||
|
revoke: async (serialNumber: string) => {
|
||||||
|
const response = await apiClient.post(`/employee-serials/${encodeURIComponent(serialNumber)}/revoke`);
|
||||||
|
if (response.data.message) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
throw new Error(response.data.error || '吊销员工序列号失败');
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (serialNumber: string) => {
|
||||||
|
const response = await apiClient.delete(`/employee-serials/${encodeURIComponent(serialNumber)}`);
|
||||||
|
if (response.data.message) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
throw new Error(response.data.error || '删除员工序列号失败');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -74,6 +74,7 @@ export interface ApiResponse<T> {
|
|||||||
export interface DashboardStats {
|
export interface DashboardStats {
|
||||||
totalCompanies: number;
|
totalCompanies: number;
|
||||||
totalSerials: number;
|
totalSerials: number;
|
||||||
|
totalEmployeeSerials: number;
|
||||||
activeSerials: number;
|
activeSerials: number;
|
||||||
inactiveSerials: number;
|
inactiveSerials: number;
|
||||||
monthlyData: Array<{
|
monthlyData: Array<{
|
||||||
@@ -89,3 +90,31 @@ export interface CompanyFilter {
|
|||||||
search?: string;
|
search?: string;
|
||||||
status?: 'all' | 'active' | 'expired';
|
status?: 'all' | 'active' | 'expired';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EmployeeSerial {
|
||||||
|
serialNumber: string;
|
||||||
|
companyName: string;
|
||||||
|
position: string;
|
||||||
|
employeeName: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmployeeSerialFilter {
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmployeeSerialPagination {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmployeeSerialResponse {
|
||||||
|
data: EmployeeSerial[];
|
||||||
|
pagination: EmployeeSerialPagination;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user