Initial commit

This commit is contained in:
2026-02-06 14:06:49 +08:00
commit 5fc7b33b3b
28 changed files with 5004 additions and 0 deletions
+100
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}