Initial commit
This commit is contained in:
46
.gitignore
vendored
Normal file
46
.gitignore
vendored
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Production builds
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
107
README.md
Normal file
107
README.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# 授权管理系统 - 前端应用
|
||||||
|
|
||||||
|
浙江贝凡企业授权管理系统的前端应用,基于 React + TypeScript + Ant Design。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **React 19**: UI 框
|
||||||
|
- **TypeScript**: 类型系统
|
||||||
|
- **Ant Design 6**: UI 组件库
|
||||||
|
- **Vite 7**: 构建工具
|
||||||
|
- **React Router v7**: 路由管理
|
||||||
|
- **Axios**: HTTP 客户端
|
||||||
|
- **QRCode**: 二维码生成
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── components/ # 通用组件
|
||||||
|
│ │ └── AdminLayout.tsx
|
||||||
|
│ ├── pages/ # 页面组件
|
||||||
|
│ │ ├── Login.tsx
|
||||||
|
│ │ ├── PublicQuery.tsx
|
||||||
|
│ │ ├── Dashboard.tsx
|
||||||
|
│ │ ├── Generate.tsx
|
||||||
|
│ │ ├── Manage.tsx
|
||||||
|
│ │ ├── AdminQuery.tsx
|
||||||
|
│ │ └── Profile.tsx
|
||||||
|
│ ├── services/ # API 服务层
|
||||||
|
│ │ └── api.ts
|
||||||
|
│ ├── types/ # TypeScript 类型定义
|
||||||
|
│ │ └── index.ts
|
||||||
|
│ ├── assets/ # 静态资源
|
||||||
|
│ ├── styles/ # 样式文件
|
||||||
|
│ ├── App.tsx # 主应用组件
|
||||||
|
│ └── main.tsx # 应用入口
|
||||||
|
├── package.json # 项目配置
|
||||||
|
├── tsconfig.json # TypeScript 配置
|
||||||
|
├── vite.config.ts # Vite 配置
|
||||||
|
└── index.html # HTML 入口
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
启动开发服务器:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
开发服务器将在 http://localhost:5173 运行
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
构建生产版本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 预览
|
||||||
|
|
||||||
|
预览生产构建:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm preview
|
||||||
|
```
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
可以在 `.env` 文件中配置:
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_API_BASE_URL=/api
|
||||||
|
```
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
### 公开页面
|
||||||
|
|
||||||
|
- 用户登录
|
||||||
|
- 公开查询序列号(支持二维码扫描)
|
||||||
|
|
||||||
|
### 管理后台
|
||||||
|
|
||||||
|
- 控制台(数据统计)
|
||||||
|
- 生成二维码和序列号
|
||||||
|
- 支持自动生成和自定义前缀
|
||||||
|
- 支持自定义二维码颜色
|
||||||
|
- 企业管理
|
||||||
|
- 查看企业详情
|
||||||
|
- 查看序列号列表
|
||||||
|
- 吊销企业/序列号
|
||||||
|
- 查看序列号二维码
|
||||||
|
- 序列号查询
|
||||||
|
- 用户资料管理
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>溯源管理系统</title>
|
||||||
|
<link rel="icon" type="image/x-icon" href="/src/assets/img/favicon.ico">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
29
package.json
Normal file
29
package.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "trace-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "浙江贝凡企业授权管理系统 - 前端",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^6.1.0",
|
||||||
|
"antd": "^6.2.3",
|
||||||
|
"axios": "^1.13.4",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"react-router-dom": "^7.13.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"@types/react": "^19.2.11",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.3",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^7.3.1"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
2444
pnpm-lock.yaml
generated
Normal file
2444
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
src/App.tsx
Normal file
51
src/App.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom';
|
||||||
|
import { authApi } from './services/api';
|
||||||
|
import LoginPage from './pages/Login';
|
||||||
|
import AdminLayout from './components/AdminLayout';
|
||||||
|
import PublicQueryPage from './pages/PublicQuery';
|
||||||
|
import DashboardPage from './pages/Dashboard';
|
||||||
|
import GeneratePage from './pages/Generate';
|
||||||
|
import ManagePage from './pages/Manage';
|
||||||
|
import AdminQueryPage from './pages/AdminQuery';
|
||||||
|
import ProfilePage from './pages/Profile';
|
||||||
|
|
||||||
|
const PrivateRoute = () => {
|
||||||
|
const user = authApi.getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
return <Outlet />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AdminRoutes = () => {
|
||||||
|
return (
|
||||||
|
<AdminLayout>
|
||||||
|
<Outlet />
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/query" element={<PublicQueryPage />} />
|
||||||
|
|
||||||
|
<Route element={<PrivateRoute />}>
|
||||||
|
<Route element={<AdminRoutes />}>
|
||||||
|
<Route path="/admin" element={<Navigate to="dashboard" replace />} />
|
||||||
|
<Route path="/admin/dashboard" element={<DashboardPage />} />
|
||||||
|
<Route path="/admin/generate" element={<GeneratePage />} />
|
||||||
|
<Route path="/admin/manage" element={<ManagePage />} />
|
||||||
|
<Route path="/admin/query" element={<AdminQueryPage />} />
|
||||||
|
<Route path="/admin/profile" element={<ProfilePage />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
BIN
src/assets/img/beian.png
Normal file
BIN
src/assets/img/beian.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/assets/img/favicon.ico
Normal file
BIN
src/assets/img/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
BIN
src/assets/img/logo.png
Normal file
BIN
src/assets/img/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
148
src/components/AdminLayout.tsx
Normal file
148
src/components/AdminLayout.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { Layout, Menu, Dropdown, Avatar, message, Modal } from 'antd';
|
||||||
|
import {
|
||||||
|
DashboardOutlined,
|
||||||
|
QrcodeOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
|
LockOutlined,
|
||||||
|
ExclamationCircleOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { authApi } from '@/services/api';
|
||||||
|
import './styles/AdminLayout.css';
|
||||||
|
import logo from '@/assets/img/logo.png?url';
|
||||||
|
|
||||||
|
const { Header, Sider, Content } = Layout;
|
||||||
|
|
||||||
|
function AdminLayout() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const user = authApi.getCurrentUser();
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{
|
||||||
|
key: 'dashboard',
|
||||||
|
icon: <DashboardOutlined />,
|
||||||
|
label: '控制台',
|
||||||
|
onClick: () => navigate('/admin/dashboard'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'generate',
|
||||||
|
icon: <QrcodeOutlined />,
|
||||||
|
label: '生成二维码',
|
||||||
|
onClick: () => navigate('/admin/generate'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'manage',
|
||||||
|
icon: <TeamOutlined />,
|
||||||
|
label: '企业管理',
|
||||||
|
onClick: () => navigate('/admin/manage'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'query',
|
||||||
|
icon: <SearchOutlined />,
|
||||||
|
label: '序列号查询',
|
||||||
|
onClick: () => navigate('/admin/query'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认退出',
|
||||||
|
icon: <ExclamationCircleOutlined />,
|
||||||
|
content: '您确定要退出登录吗?',
|
||||||
|
okText: '确定',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
await authApi.logout();
|
||||||
|
message.success('已退出登录');
|
||||||
|
navigate('/login');
|
||||||
|
} catch (error) {
|
||||||
|
message.error('退出失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const userMenuItems = [
|
||||||
|
{
|
||||||
|
key: 'profile',
|
||||||
|
icon: <UserOutlined />,
|
||||||
|
label: '用户资料',
|
||||||
|
onClick: () => navigate('/admin/profile'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'changePassword',
|
||||||
|
icon: <LockOutlined />,
|
||||||
|
label: '修改密码',
|
||||||
|
onClick: () => {
|
||||||
|
message.info('修改密码功能即将上线');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'divider' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'logout',
|
||||||
|
icon: <LogoutOutlined />,
|
||||||
|
label: '退出登录',
|
||||||
|
onClick: handleLogout,
|
||||||
|
danger: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const getSelectedKey = () => {
|
||||||
|
const path = location.pathname;
|
||||||
|
if (path.includes('/dashboard')) return 'dashboard';
|
||||||
|
if (path.includes('/generate')) return 'generate';
|
||||||
|
if (path.includes('/manage')) return 'manage';
|
||||||
|
if (path.includes('/query')) return 'query';
|
||||||
|
if (path.includes('/profile')) return 'profile';
|
||||||
|
return 'dashboard';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout className="admin-layout">
|
||||||
|
<Sider
|
||||||
|
className="admin-sider"
|
||||||
|
width={256}
|
||||||
|
>
|
||||||
|
<div className="logo">
|
||||||
|
<img src={logo} alt="Logo" style={{ height: '40px' }} />
|
||||||
|
</div>
|
||||||
|
<Menu
|
||||||
|
theme="light"
|
||||||
|
mode="inline"
|
||||||
|
selectedKeys={[getSelectedKey()]}
|
||||||
|
items={menuItems}
|
||||||
|
className="admin-menu"
|
||||||
|
/>
|
||||||
|
</Sider>
|
||||||
|
|
||||||
|
<Layout>
|
||||||
|
<Header className="admin-header">
|
||||||
|
<div className="header-title">
|
||||||
|
{menuItems.find((item) => item.key === getSelectedKey())?.label}
|
||||||
|
</div>
|
||||||
|
<div className="header-right">
|
||||||
|
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||||
|
<div className="user-info">
|
||||||
|
<Avatar icon={<UserOutlined />} />
|
||||||
|
<span className="username">{user?.name || user?.username}</span>
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<Content className="admin-content">
|
||||||
|
<Outlet />
|
||||||
|
</Content>
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminLayout;
|
||||||
85
src/components/styles/AdminLayout.css
Normal file
85
src/components/styles/AdminLayout.css
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
.admin-layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sider {
|
||||||
|
position: fixed;
|
||||||
|
height: 100vh;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
background: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 64px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-bottom: 1px solid #e8e8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-text {
|
||||||
|
color: #165dff;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-menu {
|
||||||
|
border-right: 0;
|
||||||
|
background: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header {
|
||||||
|
background: white;
|
||||||
|
padding: 0 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 999;
|
||||||
|
margin-left: 256px;
|
||||||
|
width: calc(100% - 256px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
color: #000000;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-content {
|
||||||
|
margin: 24px;
|
||||||
|
padding: 24px;
|
||||||
|
background: #f0f2f5;
|
||||||
|
min-height: calc(100vh - 112px);
|
||||||
|
margin-left: 280px;
|
||||||
|
}
|
||||||
14
src/main.tsx
Normal file
14
src/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { ConfigProvider } from 'antd';
|
||||||
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
|
import App from './App';
|
||||||
|
import './styles/global.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ConfigProvider locale={zhCN}>
|
||||||
|
<App />
|
||||||
|
</ConfigProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
100
src/pages/AdminQuery.tsx
Normal file
100
src/pages/AdminQuery.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Form, Input, Button, Card, message, Result, Alert } from 'antd';
|
||||||
|
import { SearchOutlined } from '@ant-design/icons';
|
||||||
|
import { serialApi } from '@/services/api';
|
||||||
|
import type { Serial } from '@/types';
|
||||||
|
|
||||||
|
function AdminQueryPage() {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [result, setResult] = useState<Serial | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleQuery = async (values: { serialNumber: string }) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await serialApi.query(values.serialNumber.trim());
|
||||||
|
setResult(data);
|
||||||
|
message.success('查询成功!');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || '查询失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: '600px', margin: '0 auto' }}>
|
||||||
|
<Card title="序列号查询" bordered={false}>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
onFinish={handleQuery}
|
||||||
|
layout="vertical"
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label="授权序列号"
|
||||||
|
name="serialNumber"
|
||||||
|
rules={[{ required: true, message: '请输入授权序列号' }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="请输入授权序列号(如:BF20260001)"
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
loading={loading}
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
icon={<SearchOutlined />}
|
||||||
|
>
|
||||||
|
立即查询
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<Card style={{ marginTop: '24px' }}>
|
||||||
|
<Result
|
||||||
|
status="success"
|
||||||
|
title="授权有效"
|
||||||
|
subTitle="您的序列号已验证通过"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
message={
|
||||||
|
<div>
|
||||||
|
<p><strong>序列号:</strong> {result.serialNumber}</p>
|
||||||
|
<p><strong>企业名称:</strong> {result.companyName}</p>
|
||||||
|
<p><strong>授权状态:</strong> <span style={{ color: '#52c41a', fontWeight: 'bold' }}>有效</span></p>
|
||||||
|
<p><strong>有效期至:</strong> {new Date(result.validUntil).toLocaleString('zh-CN')}</p>
|
||||||
|
<p><strong>创建时间:</strong> {new Date(result.createdAt).toLocaleString('zh-CN')}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
type="success"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Card style={{ marginTop: '24px' }}>
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title="无效序列号"
|
||||||
|
subTitle={error}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminQueryPage;
|
||||||
111
src/pages/Dashboard.tsx
Normal file
111
src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Card, Row, Col, Statistic, Table, Spin, message } from 'antd';
|
||||||
|
import { TeamOutlined, KeyOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||||
|
import { dashboardApi } from '@/services/api';
|
||||||
|
import type { DashboardStats } from '@/types';
|
||||||
|
|
||||||
|
function DashboardPage() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadStats = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await dashboardApi.getStats();
|
||||||
|
setStats(data);
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '加载数据失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '400px' }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '0' }}>
|
||||||
|
<Row gutter={[16, 16]} style={{ marginBottom: '24px' }}>
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="总企业数"
|
||||||
|
value={stats?.totalCompanies || 0}
|
||||||
|
prefix={<TeamOutlined />}
|
||||||
|
valueStyle={{ color: '#1890ff' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="总序列号"
|
||||||
|
value={stats?.totalSerials || 0}
|
||||||
|
prefix={<KeyOutlined />}
|
||||||
|
valueStyle={{ color: '#52c41a' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="有效序列号"
|
||||||
|
value={stats?.activeSerials || 0}
|
||||||
|
prefix={<CheckCircleOutlined />}
|
||||||
|
valueStyle={{ color: '#52c41a' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="无效序列号"
|
||||||
|
value={stats?.inactiveSerials || 0}
|
||||||
|
prefix={<CloseCircleOutlined />}
|
||||||
|
valueStyle={{ color: '#ff4d4f' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Card title="最近生成的序列号" style={{ marginBottom: '24px' }}>
|
||||||
|
<Table
|
||||||
|
columns={[
|
||||||
|
{ title: '序列号', dataIndex: 'serialNumber', key: 'serialNumber' },
|
||||||
|
{ title: '企业名称', dataIndex: 'companyName', key: 'companyName' },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
render: (status: string) => (
|
||||||
|
<span style={{ color: status === 'active' ? '#52c41a' : '#ff4d4f' }}>
|
||||||
|
{status === 'active' ? '有效' : '无效'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
key: 'createdAt',
|
||||||
|
render: (date: string) => new Date(date).toLocaleString('zh-CN'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
dataSource={stats?.recentSerials || []}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DashboardPage;
|
||||||
274
src/pages/Generate.tsx
Normal file
274
src/pages/Generate.tsx
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Form, Input, Button, Card, Radio, InputNumber, DatePicker, message, Modal, Space, ColorPicker, Divider } 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';
|
||||||
|
|
||||||
|
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 style={{ maxWidth: '800px', margin: '0 auto' }}>
|
||||||
|
<Card title="生成二维码" bordered={false}>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
onFinish={handleGenerate}
|
||||||
|
layout="vertical"
|
||||||
|
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="quantity"
|
||||||
|
rules={[{ required: true, message: '请输入序列号数量' }]}
|
||||||
|
>
|
||||||
|
<InputNumber min={1} max={100} style={{ width: '100%' }} />
|
||||||
|
</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="qrColor"
|
||||||
|
initialValue="#000000"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: '12px', 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>
|
||||||
|
<Divider style={{ margin: '12px 0' }} />
|
||||||
|
<ColorPicker
|
||||||
|
value={qrColor}
|
||||||
|
onChange={(color: Color) => {
|
||||||
|
const hexColor = color.toHexString();
|
||||||
|
setQrColor(hexColor);
|
||||||
|
}}
|
||||||
|
showText
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<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: '8px', fontFamily: 'monospace' }}>{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;
|
||||||
126
src/pages/Login.tsx
Normal file
126
src/pages/Login.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Form, Input, Button, Card, message, Checkbox } from 'antd';
|
||||||
|
import { UserOutlined, LockOutlined, LoginOutlined } from '@ant-design/icons';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { authApi } from '@/services/api';
|
||||||
|
import './styles/Login.css';
|
||||||
|
import logo from '@/assets/img/logo.png?url';
|
||||||
|
import beian from '@/assets/img/beian.png?url';
|
||||||
|
|
||||||
|
function LoginPage() {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleLogin = async (values: { username: string; password: string; remember?: boolean }) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await authApi.login(values.username, values.password);
|
||||||
|
|
||||||
|
if (values.remember) {
|
||||||
|
localStorage.setItem('rememberedUsername', values.username);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('rememberedUsername');
|
||||||
|
}
|
||||||
|
|
||||||
|
message.success('登录成功!');
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate('/admin/dashboard');
|
||||||
|
}, 500);
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '登录失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-container">
|
||||||
|
<Card className="login-card" bordered={false}>
|
||||||
|
<div className="login-header">
|
||||||
|
<div className="login-logo">
|
||||||
|
<img src={logo} alt="Logo" style={{ height: '24px' }} />
|
||||||
|
</div>
|
||||||
|
<h1 className="login-title">
|
||||||
|
<LoginOutlined /> 用户登录
|
||||||
|
</h1>
|
||||||
|
<p className="login-subtitle">请输入您的账户信息</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
name="login"
|
||||||
|
onFinish={handleLogin}
|
||||||
|
autoComplete="off"
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{
|
||||||
|
remember: !!localStorage.getItem('rememberedUsername'),
|
||||||
|
username: localStorage.getItem('rememberedUsername') || '',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label="用户名"
|
||||||
|
name="username"
|
||||||
|
rules={[{ required: true, message: '请输入您的用户名' }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
prefix={<UserOutlined />}
|
||||||
|
placeholder="请输入您的用户名"
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="密码"
|
||||||
|
name="password"
|
||||||
|
rules={[{ required: true, message: '请输入您的密码' }]}
|
||||||
|
>
|
||||||
|
<Input.Password
|
||||||
|
prefix={<LockOutlined />}
|
||||||
|
placeholder="请输入您的密码"
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="remember" valuePropName="checked">
|
||||||
|
<Checkbox>记住我</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
loading={loading}
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
icon={<LoginOutlined />}
|
||||||
|
>
|
||||||
|
登录系统
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<div className="login-footer">
|
||||||
|
浙江贝凡网络科技提供云和AI服务
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="copyright">
|
||||||
|
<p>
|
||||||
|
Copyright © 2026 浙江贝凡网络科技有限公司. All Rights Reserved. |
|
||||||
|
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">
|
||||||
|
浙ICP备2025170226号-4
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="https://beian.mps.gov.cn/#/query/webSearch?code=33011002018371" target="_blank" rel="noopener noreferrer">
|
||||||
|
<img src={beian} alt="备案图标" style={{ height: '20px', marginRight: '5px', verticalAlign: 'middle' }} />
|
||||||
|
浙公网安备33011002018371号
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginPage;
|
||||||
427
src/pages/Manage.tsx
Normal file
427
src/pages/Manage.tsx
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Card, Table, Input, Select, Button, Space, message, Modal, Tag, Spin } from 'antd';
|
||||||
|
import { TeamOutlined, DeleteOutlined, EyeOutlined, StopOutlined } from '@ant-design/icons';
|
||||||
|
import type { Company } from '@/types';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface CompanyData {
|
||||||
|
companyName: string;
|
||||||
|
serialCount: number;
|
||||||
|
firstCreated: string;
|
||||||
|
lastCreated: string;
|
||||||
|
activeCount: number;
|
||||||
|
status: 'active' | 'disabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
function ManagePage() {
|
||||||
|
const [companies, setCompanies] = useState<CompanyData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [selectedCompany, setSelectedCompany] = useState<any>(null);
|
||||||
|
const [detailModalVisible, setDetailModalVisible] = useState(false);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [companyDetail, setCompanyDetail] = useState<any>(null);
|
||||||
|
const [qrCodeModalVisible, setQrCodeModalVisible] = useState(false);
|
||||||
|
const [qrCodeDataUrl, setQrCodeDataUrl] = useState<string>('');
|
||||||
|
const [selectedSerial, setSelectedSerial] = useState<string>('');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCompanies();
|
||||||
|
}, [searchTerm]);
|
||||||
|
|
||||||
|
const loadCompanies = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// 直接使用 apiClient 来调用后端接口
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
const headers: any = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
if (token) {
|
||||||
|
headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = '/api/companies';
|
||||||
|
if (searchTerm) {
|
||||||
|
url += `?search=${encodeURIComponent(searchTerm)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, { headers });
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.data) {
|
||||||
|
setCompanies(data.data);
|
||||||
|
} else if (data.message) {
|
||||||
|
setCompanies([]);
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || '获取企业列表失败');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Load companies error:', error);
|
||||||
|
message.error(error.message || '加载企业列表失败');
|
||||||
|
setCompanies([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewDetail = async (company: CompanyData) => {
|
||||||
|
setSelectedCompany(company);
|
||||||
|
setDetailModalVisible(true);
|
||||||
|
setDetailLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
const headers: any = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
if (token) {
|
||||||
|
headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/companies/${encodeURIComponent(company.companyName)}`, { headers });
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.data) {
|
||||||
|
setCompanyDetail(data.data);
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || '获取企业详情失败');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '获取企业详情失败');
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (company: CompanyData) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认删除',
|
||||||
|
content: `确定要删除企业 "${company.companyName}" 吗?这将删除该企业的所有序列号!`,
|
||||||
|
okText: '确定',
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
const headers: any = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
if (token) {
|
||||||
|
headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/companies/${encodeURIComponent(company.companyName)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.message) {
|
||||||
|
message.success('删除成功');
|
||||||
|
loadCompanies();
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || '删除失败');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '删除失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevoke = async (company: CompanyData) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认吊销',
|
||||||
|
content: `确定要吊销企业 "${company.companyName}" 吗?这将使该企业的所有序列号失效!`,
|
||||||
|
okText: '确定',
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
const headers: any = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
if (token) {
|
||||||
|
headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/companies/${encodeURIComponent(company.companyName)}/revoke`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.message) {
|
||||||
|
message.success('吊销成功');
|
||||||
|
loadCompanies();
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || '吊销失败');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '吊销失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewQRCode = async (serialNumber: string) => {
|
||||||
|
try {
|
||||||
|
const baseUrl = window.location.origin;
|
||||||
|
const queryUrl = `${baseUrl}/query?serial=${serialNumber}`;
|
||||||
|
const qrCode = await QRCode.toDataURL(queryUrl);
|
||||||
|
setQrCodeDataUrl(qrCode);
|
||||||
|
setSelectedSerial(serialNumber);
|
||||||
|
setQrCodeModalVisible(true);
|
||||||
|
} catch (error) {
|
||||||
|
message.error('生成二维码失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuerySerial = (serialNumber: string) => {
|
||||||
|
setQrCodeModalVisible(false);
|
||||||
|
navigate(`/query?serial=${serialNumber}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevokeSerial = async (serialNumber: string) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认吊销',
|
||||||
|
content: `确定要吊销序列号 "${serialNumber}" 吗?`,
|
||||||
|
okText: '确定',
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
const headers: any = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
if (token) {
|
||||||
|
headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/serials/${encodeURIComponent(serialNumber)}/revoke`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.message) {
|
||||||
|
message.success('吊销成功');
|
||||||
|
if (selectedCompany) {
|
||||||
|
handleViewDetail(selectedCompany);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || '吊销失败');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '吊销失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '企业名称',
|
||||||
|
dataIndex: 'companyName',
|
||||||
|
key: 'companyName',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '序列号数量',
|
||||||
|
dataIndex: 'serialCount',
|
||||||
|
key: 'serialCount',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
render: (status: string) => (
|
||||||
|
<Tag color={status === 'active' ? 'green' : 'red'}>
|
||||||
|
{status === 'active' ? '正常' : '已吊销'}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'firstCreated',
|
||||||
|
key: 'firstCreated',
|
||||||
|
render: (date: string) => new Date(date).toLocaleString('zh-CN'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最后更新',
|
||||||
|
dataIndex: 'lastCreated',
|
||||||
|
key: 'lastCreated',
|
||||||
|
render: (date: string) => new Date(date).toLocaleString('zh-CN'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
render: (_: any, record: CompanyData) => (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
onClick={() => handleViewDetail(record)}
|
||||||
|
>
|
||||||
|
查看
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
danger
|
||||||
|
icon={<StopOutlined />}
|
||||||
|
onClick={() => handleRevoke(record)}
|
||||||
|
>
|
||||||
|
吊销
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => handleDelete(record)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<TeamOutlined />
|
||||||
|
<span>企业管理</span>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Input.Search
|
||||||
|
placeholder="搜索企业"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 200 }}
|
||||||
|
onSearch={setSearchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
value={searchTerm}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={companies}
|
||||||
|
rowKey="companyName"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{
|
||||||
|
pageSize: 10,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (total) => `共 ${total} 家企业`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="企业详情"
|
||||||
|
open={detailModalVisible}
|
||||||
|
onCancel={() => setDetailModalVisible(false)}
|
||||||
|
footer={null}
|
||||||
|
width={800}
|
||||||
|
>
|
||||||
|
{detailLoading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
) : companyDetail ? (
|
||||||
|
<div>
|
||||||
|
<p><strong>企业名称:</strong> {companyDetail.companyName}</p>
|
||||||
|
<p><strong>序列号总数:</strong> {companyDetail.serialCount}</p>
|
||||||
|
<p><strong>活跃序列号:</strong> {companyDetail.activeCount}</p>
|
||||||
|
<p><strong>已吊销序列号:</strong> {companyDetail.disabledCount || 0}</p>
|
||||||
|
<p><strong>已过期序列号:</strong> {companyDetail.expiredCount || 0}</p>
|
||||||
|
|
||||||
|
{companyDetail.serials && companyDetail.serials.length > 0 && (
|
||||||
|
<div style={{ marginTop: '16px' }}>
|
||||||
|
<h4>序列号列表</h4>
|
||||||
|
<Table
|
||||||
|
columns={[
|
||||||
|
{ title: '序列号', dataIndex: 'serialNumber', key: 'serialNumber' },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'isActive',
|
||||||
|
key: 'isActive',
|
||||||
|
render: (isActive: boolean) => (
|
||||||
|
<Tag color={isActive ? 'green' : 'red'}>
|
||||||
|
{isActive ? '有效' : '已吊销'}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '有效期至',
|
||||||
|
dataIndex: 'validUntil',
|
||||||
|
key: 'validUntil',
|
||||||
|
render: (date: string) => new Date(date).toLocaleString('zh-CN'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
render: (_: any, record: any) => (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
onClick={() => handleViewQRCode(record.serialNumber)}
|
||||||
|
>
|
||||||
|
查看二维码
|
||||||
|
</Button>
|
||||||
|
{record.isActive && (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<StopOutlined />}
|
||||||
|
onClick={() => handleRevokeSerial(record.serialNumber)}
|
||||||
|
>
|
||||||
|
吊销
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
dataSource={companyDetail.serials}
|
||||||
|
rowKey="serialNumber"
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</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', cursor: 'pointer' }} onClick={() => handleQuerySerial(selectedSerial)} />
|
||||||
|
<p style={{ marginTop: '12px', fontFamily: 'monospace', fontSize: '16px', fontWeight: 'bold', color: '#165DFF' }}>{selectedSerial}</p>
|
||||||
|
<p style={{ marginTop: '8px', fontSize: '12px', color: '#999' }}>点击二维码可查询序列号</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ManagePage;
|
||||||
202
src/pages/Profile.tsx
Normal file
202
src/pages/Profile.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Form, Input, Button, Card, message, Avatar, Descriptions, Space, Modal } from 'antd';
|
||||||
|
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||||
|
import { authApi } from '@/services/api';
|
||||||
|
import type { User } from '@/types';
|
||||||
|
|
||||||
|
function ProfilePage() {
|
||||||
|
const [profileForm] = Form.useForm();
|
||||||
|
const [passwordForm] = Form.useForm();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [passwordModalVisible, setPasswordModalVisible] = useState(false);
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUserProfile();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadUserProfile = () => {
|
||||||
|
const currentUser = authApi.getCurrentUser();
|
||||||
|
if (currentUser) {
|
||||||
|
setUser(currentUser);
|
||||||
|
profileForm.setFieldsValue({
|
||||||
|
username: currentUser.username,
|
||||||
|
name: currentUser.name,
|
||||||
|
email: currentUser.email,
|
||||||
|
role: currentUser.role,
|
||||||
|
createdAt: new Date(currentUser.createdAt).toLocaleString('zh-CN'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateProfile = async (values: any) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const updatedUser = await authApi.updateProfile({
|
||||||
|
name: values.name,
|
||||||
|
email: values.email,
|
||||||
|
});
|
||||||
|
setUser(updatedUser);
|
||||||
|
message.success('更新资料成功!');
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '更新资料失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangePassword = async (values: any) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await authApi.changePassword(values.currentPassword, values.newPassword);
|
||||||
|
message.success('修改密码成功!');
|
||||||
|
setPasswordModalVisible(false);
|
||||||
|
passwordForm.resetFields();
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '修改密码失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: '800px', margin: '0 auto' }}>
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
<UserOutlined /> 用户资料
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
style={{ marginBottom: '24px' }}
|
||||||
|
>
|
||||||
|
{user && (
|
||||||
|
<Descriptions column={1} bordered style={{ marginBottom: '24px' }}>
|
||||||
|
<Descriptions.Item label="头像">
|
||||||
|
<Avatar icon={<UserOutlined />} size={64} />
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="用户名">{user.username}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="姓名">{user.name}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="邮箱">{user.email || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="角色">{user.role}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="创建时间">
|
||||||
|
{new Date(user.createdAt).toLocaleString('zh-CN')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form
|
||||||
|
form={profileForm}
|
||||||
|
onFinish={handleUpdateProfile}
|
||||||
|
layout="vertical"
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label="姓名"
|
||||||
|
name="name"
|
||||||
|
rules={[{ required: true, message: '请输入姓名' }]}
|
||||||
|
>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="邮箱"
|
||||||
|
name="email"
|
||||||
|
rules={[{ type: 'email', message: '请输入有效的邮箱地址' }]}
|
||||||
|
>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading}>
|
||||||
|
保存资料
|
||||||
|
</Button>
|
||||||
|
<Button onClick={loadUserProfile}>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
<LockOutlined /> 修改密码
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Button type="primary" onClick={() => setPasswordModalVisible(true)}>
|
||||||
|
修改密码
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="修改密码"
|
||||||
|
open={passwordModalVisible}
|
||||||
|
onCancel={() => setPasswordModalVisible(false)}
|
||||||
|
footer={null}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={passwordForm}
|
||||||
|
onFinish={handleChangePassword}
|
||||||
|
layout="vertical"
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label="当前密码"
|
||||||
|
name="currentPassword"
|
||||||
|
rules={[{ required: true, message: '请输入当前密码' }]}
|
||||||
|
>
|
||||||
|
<Input.Password />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="新密码"
|
||||||
|
name="newPassword"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入新密码' },
|
||||||
|
{ min: 6, message: '密码至少6位' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input.Password />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="确认新密码"
|
||||||
|
name="confirmPassword"
|
||||||
|
dependencies={['newPassword']}
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请确认新密码' },
|
||||||
|
({ getFieldValue }) => ({
|
||||||
|
validator(_, value) {
|
||||||
|
if (!value || getFieldValue('newPassword') === value) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error('两次输入的密码不一致'));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input.Password />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => {
|
||||||
|
setPasswordModalVisible(false);
|
||||||
|
passwordForm.resetFields();
|
||||||
|
}}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProfilePage;
|
||||||
181
src/pages/PublicQuery.tsx
Normal file
181
src/pages/PublicQuery.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Input, Button, Card, message, Spin, Result } from 'antd';
|
||||||
|
import { QrcodeOutlined, SearchOutlined, ArrowLeftOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||||
|
import { serialApi } from '@/services/api';
|
||||||
|
import type { Serial } from '@/types';
|
||||||
|
import './styles/PublicQuery.css';
|
||||||
|
import logo from '@/assets/img/logo.png?url';
|
||||||
|
import beian from '@/assets/img/beian.png?url';
|
||||||
|
|
||||||
|
function PublicQueryPage() {
|
||||||
|
const [serialNumber, setSerialNumber] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [result, setResult] = useState<Serial | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showResult, setShowResult] = useState(false);
|
||||||
|
|
||||||
|
const performQuery = async (serialToQuery: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await serialApi.query(serialToQuery);
|
||||||
|
setResult(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || '查询失败');
|
||||||
|
setResult(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const serialFromUrl = urlParams.get('serial');
|
||||||
|
if (serialFromUrl) {
|
||||||
|
setSerialNumber(serialFromUrl);
|
||||||
|
setShowResult(true);
|
||||||
|
performQuery(serialFromUrl);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleQuery = async () => {
|
||||||
|
if (!serialNumber.trim()) {
|
||||||
|
message.error('请输入授权序列号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowResult(true);
|
||||||
|
performQuery(serialNumber.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setShowResult(false);
|
||||||
|
setSerialNumber('');
|
||||||
|
setResult(null);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="public-query-container">
|
||||||
|
{!showResult ? (
|
||||||
|
<Card className="query-card" bordered={false}>
|
||||||
|
<div className="query-header">
|
||||||
|
<div className="query-logo">
|
||||||
|
<img src={logo} alt="Logo" style={{ height: '24px' }} />
|
||||||
|
</div>
|
||||||
|
<h1 className="query-title">
|
||||||
|
<QrcodeOutlined /> 授权查询
|
||||||
|
</h1>
|
||||||
|
<p className="query-subtitle">请输入您的授权序列号进行查询</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="query-form">
|
||||||
|
<Input
|
||||||
|
size="large"
|
||||||
|
placeholder="请输入或扫描您的授权序列号"
|
||||||
|
value={serialNumber}
|
||||||
|
onChange={(e) => setSerialNumber(e.target.value)}
|
||||||
|
onPressEnter={handleQuery}
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
icon={<SearchOutlined />}
|
||||||
|
onClick={handleQuery}
|
||||||
|
>
|
||||||
|
立即查询
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card className={`result-card show`} bordered={false}>
|
||||||
|
<div className="result-header">
|
||||||
|
<div className="query-logo">
|
||||||
|
<img src={logo} alt="Logo" style={{ height: '24px' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="loading-container">
|
||||||
|
<Spin size="large" />
|
||||||
|
<p>正在查询授权信息...</p>
|
||||||
|
</div>
|
||||||
|
) : result ? (
|
||||||
|
<div className="success-container">
|
||||||
|
{result.status !== 'active' ? (
|
||||||
|
<Result
|
||||||
|
icon={<CloseCircleOutlined style={{ color: '#ff4d4f', fontSize: '72px' }} />}
|
||||||
|
title="授权已吊销"
|
||||||
|
subTitle={`序列号验证通过,但已被吊销。企业:${result.companyName}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Result
|
||||||
|
icon={<CheckCircleOutlined style={{ color: '#52c41a', fontSize: '72px' }} />}
|
||||||
|
title="授权有效"
|
||||||
|
subTitle="您的序列号已验证通过"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="result-details">
|
||||||
|
<div className="detail-item">
|
||||||
|
<span className="label">序列号</span>
|
||||||
|
<span className="value serial">{result.serialNumber}</span>
|
||||||
|
</div>
|
||||||
|
<div className="detail-item">
|
||||||
|
<span className="label">企业名称</span>
|
||||||
|
<span className="value">{result.companyName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="detail-item">
|
||||||
|
<span className="label">有效期至</span>
|
||||||
|
<span className="value">{new Date(result.validUntil).toLocaleString('zh-CN')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="detail-item">
|
||||||
|
<span className="label">授权状态</span>
|
||||||
|
<span className="value status">
|
||||||
|
{result.status === 'active' ? '有效' : '已吊销'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="error-container">
|
||||||
|
<Result
|
||||||
|
icon={<CloseCircleOutlined style={{ color: '#ff4d4f', fontSize: '72px' }} />}
|
||||||
|
title="无效序列号"
|
||||||
|
subTitle={error}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
onClick={handleReset}
|
||||||
|
>
|
||||||
|
重新查询
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="copyright">
|
||||||
|
<p>
|
||||||
|
Copyright © 2026 浙江贝凡网络科技有限公司. All Rights Reserved. |
|
||||||
|
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">
|
||||||
|
浙ICP备2025170226号-4
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="https://beian.mps.gov.cn/#/query/webSearch?code=33011002018371" target="_blank" rel="noopener noreferrer">
|
||||||
|
<img src={beian} alt="备案图标" style={{ height: '20px', marginRight: '5px', verticalAlign: 'middle' }} />
|
||||||
|
浙公网安备33011002018371号
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PublicQueryPage;
|
||||||
88
src/pages/styles/Login.css
Normal file
88
src/pages/styles/Login.css
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
.login-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: linear-gradient(135deg, #dbeafe 0%, #e0e7ff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
padding: 24px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
|
border-radius: 24px;
|
||||||
|
animation: slideIn 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-50px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo {
|
||||||
|
text-align: left;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
font-size: clamp(1.5rem, 4vw, 2rem);
|
||||||
|
font-weight: bold;
|
||||||
|
color: #165DFF;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-subtitle {
|
||||||
|
color: #6B7280;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 16px;
|
||||||
|
color: #6B7280;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright {
|
||||||
|
margin-top: 32px;
|
||||||
|
text-align: center;
|
||||||
|
color: #6B7280;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright p {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright a {
|
||||||
|
color: #6B7280;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright a:hover {
|
||||||
|
color: #165DFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright img {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
149
src/pages/styles/PublicQuery.css
Normal file
149
src/pages/styles/PublicQuery.css
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
.public-query-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 32px 20px;
|
||||||
|
background: linear-gradient(135deg, #dbeafe 0%, #e0e7ff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
padding: 24px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-header {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-logo {
|
||||||
|
text-align: left;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-title {
|
||||||
|
font-size: clamp(1.5rem, 4vw, 2rem);
|
||||||
|
font-weight: bold;
|
||||||
|
color: #165DFF;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-subtitle {
|
||||||
|
color: #6B7280;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
padding: 16px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
|
border-radius: 24px;
|
||||||
|
margin-top: 16px;
|
||||||
|
transform: scale(0);
|
||||||
|
transition: transform 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-card.show {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-header {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container,
|
||||||
|
.success-container,
|
||||||
|
.error-container {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container p {
|
||||||
|
margin-top: 16px;
|
||||||
|
color: #6B7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-details {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #F8FAFC;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item .label {
|
||||||
|
color: #374151;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item .value {
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item .value.serial {
|
||||||
|
color: #165DFF;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item .value.status {
|
||||||
|
color: #52c41a;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright {
|
||||||
|
margin-top: 32px;
|
||||||
|
text-align: center;
|
||||||
|
color: #6B7280;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright p {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright a {
|
||||||
|
color: #6B7280;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright a:hover {
|
||||||
|
color: #165DFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright img {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
207
src/services/api.ts
Normal file
207
src/services/api.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import type { ApiResponse, AuthResponse, User } from '@/types';
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||||
|
|
||||||
|
const apiClient = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
apiClient.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('authToken');
|
||||||
|
localStorage.removeItem('currentUser');
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
const url = error.config?.url || '';
|
||||||
|
if (url.includes('/query')) {
|
||||||
|
const customError = new Error('未找到该序列号,请检查输入是否正确');
|
||||||
|
customError.name = 'NotFoundError';
|
||||||
|
return Promise.reject(customError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
login: async (username: string, password: string) => {
|
||||||
|
const response = await apiClient.post('/auth/login', { username, password });
|
||||||
|
// 后端返回的是 accessToken,前端期望的是 token
|
||||||
|
const token = response.data.accessToken || response.data.token;
|
||||||
|
const user = response.data.user;
|
||||||
|
|
||||||
|
if (token && user) {
|
||||||
|
localStorage.setItem('authToken', token);
|
||||||
|
localStorage.setItem('currentUser', JSON.stringify(user));
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
throw new Error('登录失败');
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: async () => {
|
||||||
|
try {
|
||||||
|
await apiClient.post('/auth/logout');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error);
|
||||||
|
}
|
||||||
|
localStorage.removeItem('authToken');
|
||||||
|
localStorage.removeItem('currentUser');
|
||||||
|
},
|
||||||
|
|
||||||
|
getCurrentUser: (): User | null => {
|
||||||
|
const user = localStorage.getItem('currentUser');
|
||||||
|
return user ? JSON.parse(user) : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateProfile: async (data: { name?: string; email?: string }) => {
|
||||||
|
const response = await apiClient.put('/auth/profile', data);
|
||||||
|
const user = response.data;
|
||||||
|
if (user) {
|
||||||
|
localStorage.setItem('currentUser', JSON.stringify(user));
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
throw new Error('更新资料失败');
|
||||||
|
},
|
||||||
|
|
||||||
|
changePassword: async (currentPassword: string, newPassword: string) => {
|
||||||
|
const response = await apiClient.post('/auth/change-password', { currentPassword, newPassword });
|
||||||
|
if (response.data.message) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
throw new Error(response.data.error || '修改密码失败');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serialApi = {
|
||||||
|
generate: async (data: {
|
||||||
|
companyName: string;
|
||||||
|
serialOption: 'auto' | 'custom';
|
||||||
|
serialPrefix?: string;
|
||||||
|
quantity: number;
|
||||||
|
validOption: 'days' | 'date';
|
||||||
|
validDays?: number;
|
||||||
|
validUntil?: string;
|
||||||
|
}) => {
|
||||||
|
// 根据后端接口调整参数
|
||||||
|
const payload = {
|
||||||
|
companyName: data.companyName,
|
||||||
|
quantity: data.quantity,
|
||||||
|
validDays: data.validOption === 'days' ? data.validDays : undefined,
|
||||||
|
serialPrefix: data.serialOption === 'custom' ? data.serialPrefix : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await apiClient.post('/serials/generate', payload);
|
||||||
|
if (response.data.serials) {
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
throw new Error(response.data.error || '生成序列号失败');
|
||||||
|
},
|
||||||
|
|
||||||
|
query: async (serialNumber: string) => {
|
||||||
|
// 后端路径是正确的: /api/serials/:serialNumber/query
|
||||||
|
const response = await apiClient.get(`/serials/${encodeURIComponent(serialNumber)}/query`);
|
||||||
|
if (response.data.serial) {
|
||||||
|
return response.data.serial;
|
||||||
|
}
|
||||||
|
throw new Error(response.data.error || '查询序列号失败');
|
||||||
|
},
|
||||||
|
|
||||||
|
list: async (companyId?: number) => {
|
||||||
|
let url = '/serials';
|
||||||
|
if (companyId) url += `?companyId=${companyId}`;
|
||||||
|
const response = await apiClient.get(url);
|
||||||
|
if (response.data.data) {
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
throw new Error('获取序列号列表失败');
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: number) => {
|
||||||
|
// 后端没有单个删除接口,需要使用企业接口下的删除
|
||||||
|
const response = await apiClient.delete(`/serials/${id}`);
|
||||||
|
if (response.data) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
throw new Error(response.data.error || '删除序列号失败');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const companyApi = {
|
||||||
|
list: async (filter?: { search?: string; status?: 'all' | 'active' | 'expired' }) => {
|
||||||
|
let url = '/companies';
|
||||||
|
if (filter?.search || filter?.status) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filter.search) params.append('search', filter.search);
|
||||||
|
if (filter.status && filter.status !== 'all') params.append('status', filter.status);
|
||||||
|
url += `?${params.toString()}`;
|
||||||
|
}
|
||||||
|
const response = await apiClient.get(url);
|
||||||
|
if (response.data.data) {
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
throw new Error('获取企业列表失败');
|
||||||
|
},
|
||||||
|
|
||||||
|
get: async (companyName: string) => {
|
||||||
|
const response = await apiClient.get(`/companies/${encodeURIComponent(companyName)}`);
|
||||||
|
if (response.data.data) {
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
throw new Error('获取企业详情失败');
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (companyName: string) => {
|
||||||
|
const response = await apiClient.delete(`/companies/${encodeURIComponent(companyName)}`);
|
||||||
|
if (response.data) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
throw new Error(response.data.error || '删除企业失败');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dashboardApi = {
|
||||||
|
getStats: async () => {
|
||||||
|
// 后端路径是 /api/companies/stats/overview
|
||||||
|
const response = await apiClient.get('/companies/stats/overview');
|
||||||
|
if (response.data.data) {
|
||||||
|
const data = response.data.data;
|
||||||
|
// 转换数据格式以匹配前端期望
|
||||||
|
return {
|
||||||
|
totalCompanies: data.overview?.totalCompanies || 0,
|
||||||
|
totalSerials: data.overview?.totalSerials || 0,
|
||||||
|
activeSerials: data.overview?.activeSerials || 0,
|
||||||
|
inactiveSerials: data.overview?.inactiveSerials || 0,
|
||||||
|
monthlyData: data.monthlyStats || [],
|
||||||
|
recentCompanies: data.recentCompanies?.map((c: any) => ({
|
||||||
|
id: c.companyName,
|
||||||
|
name: c.companyName,
|
||||||
|
status: 'active' as const,
|
||||||
|
createdAt: c.lastCreated,
|
||||||
|
})) || [],
|
||||||
|
recentSerials: data.recentSerials?.map((s: any) => ({
|
||||||
|
id: s.serialNumber,
|
||||||
|
serialNumber: s.serialNumber,
|
||||||
|
companyName: s.companyName,
|
||||||
|
status: s.isActive ? 'active' : 'inactive',
|
||||||
|
createdAt: s.createdAt,
|
||||||
|
})) || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error('获取统计数据失败');
|
||||||
|
},
|
||||||
|
};
|
||||||
25
src/styles/global.css
Normal file
25
src/styles/global.css
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||||
|
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||||
|
'Noto Color Emoji';
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
91
src/types/index.ts
Normal file
91
src/types/index.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
role: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Company {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
status: 'active' | 'disabled';
|
||||||
|
createdAt: string;
|
||||||
|
serials?: Serial[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Serial {
|
||||||
|
id: number;
|
||||||
|
serialNumber: string;
|
||||||
|
companyId: number;
|
||||||
|
companyName: string;
|
||||||
|
status: 'active' | 'disabled';
|
||||||
|
validUntil: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateSerialRequest {
|
||||||
|
companyName: string;
|
||||||
|
serialOption: 'auto' | 'custom';
|
||||||
|
serialPrefix?: string;
|
||||||
|
quantity: number;
|
||||||
|
validOption: 'days' | 'date';
|
||||||
|
validDays?: number;
|
||||||
|
validUntil?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateSerialResponse {
|
||||||
|
companyName: string;
|
||||||
|
serials: Array<{
|
||||||
|
serialNumber: string;
|
||||||
|
validUntil: string;
|
||||||
|
}>;
|
||||||
|
qrCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
token: string;
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
remember?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProfileRequest {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChangePasswordRequest {
|
||||||
|
currentPassword: string;
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
totalCompanies: number;
|
||||||
|
totalSerials: number;
|
||||||
|
activeSerials: number;
|
||||||
|
inactiveSerials: number;
|
||||||
|
monthlyData: Array<{
|
||||||
|
month: string;
|
||||||
|
companies: number;
|
||||||
|
serials: number;
|
||||||
|
}>;
|
||||||
|
recentCompanies: Company[];
|
||||||
|
recentSerials: Serial[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanyFilter {
|
||||||
|
search?: string;
|
||||||
|
status?: 'all' | 'active' | 'expired';
|
||||||
|
}
|
||||||
26
src/vite-env.d.ts
vendored
Normal file
26
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.png' {
|
||||||
|
const value: string;
|
||||||
|
export default value;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.jpg' {
|
||||||
|
const value: string;
|
||||||
|
export default value;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.jpeg' {
|
||||||
|
const value: string;
|
||||||
|
export default value;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.gif' {
|
||||||
|
const value: string;
|
||||||
|
export default value;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.svg' {
|
||||||
|
const value: string;
|
||||||
|
export default value;
|
||||||
|
}
|
||||||
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
10
tsconfig.node.json
Normal file
10
tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
25
vite.config.ts
Normal file
25
vite.config.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user