fix ui
All checks were successful
Build and Deploy Web / build-and-deploy (push) Successful in 1m38s

This commit is contained in:
zyc 2026-02-10 16:02:02 +08:00
parent 463b731da3
commit b4108961fa
25 changed files with 3215 additions and 206 deletions

View File

@ -1,2 +1,3 @@
# 环境变量配置 # 环境变量配置
VITE_API_BASE_URL=http://localhost:8001 VITE_API_BASE_URL=http://localhost:8001
VITE_LOG_CENTER_URL=http://localhost:8002

View File

@ -1,2 +1,3 @@
# 生产环境配置 # 生产环境配置
VITE_API_BASE_URL=https://qiyuan-rtc-api.airlabs.art VITE_API_BASE_URL=https://qiyuan-rtc-api.airlabs.art
VITE_LOG_CENTER_URL=https://qiyuan-log-center-api.airlabs.art

647
DESIGN_SPEC.md Normal file
View File

@ -0,0 +1,647 @@
# RTC Web 前端设计规范
> 本文档定义 rtc_web 管理后台的 UI 设计规范。所有新页面和组件必须遵循此规范,确保视觉一致性。
---
## 1. 技术栈
| 技术 | 版本 | 用途 |
|------|------|------|
| React | 19.x | UI 框架 |
| TypeScript | 5.9+ | 类型安全 |
| Ant Design | 5.x | 组件库 |
| @ant-design/pro-components | 2.x | ProTable 等高级组件 |
| @ant-design/charts | 2.x | 图表 (Pie, Column 等) |
| Zustand | 5.x | 状态管理 |
| Vite | 7.x | 构建工具 |
---
## 2. 色彩体系
### 2.1 主题色
主题色通过 `src/theme/tokens.ts` 统一配置,由 Ant Design 的 `ConfigProvider` 全局应用。
| 语义 | 色值 | 用途 |
|------|------|------|
| **Primary** | `#6366f1` | 主操作按钮、链接、选中态、品牌色 |
| Success | `#10b981` | 成功状态、已绑定、完成 |
| Warning | `#f59e0b` | 警告、未导入、待处理 |
| Error | `#ef4444` | 错误、删除、禁用 |
| Info | `#3b82f6` | 信息提示、只读状态 |
### 2.2 统计卡片专用色(`statColors`
`src/theme/tokens.ts` 中导入 `statColors`
```typescript
import { statColors } from '../../theme/tokens';
// 可用颜色:
statColors.primary // #6366f1 - 紫蓝
statColors.success // #10b981 - 绿
statColors.warning // #f59e0b - 橙
statColors.error // #ef4444 - 红
statColors.info // #3b82f6 - 蓝
statColors.purple // #8b5cf6 - 紫
statColors.cyan // #06b6d4 - 青
statColors.pink // #ec4899 - 粉
```
### 2.3 中性色
| 用途 | 色值 |
|------|------|
| 正文标题 | `#1f2937` |
| 正文内容 | `#374151` |
| 辅助文字 | `#6b7280` |
| 占位/禁用 | `#9ca3af` |
| 边框 | `#e5e7eb` |
| 浅背景 | `#f5f5f7` (Layout 背景) |
| 白色容器 | `#ffffff` |
### 2.4 CSS 变量
全局 CSS 变量定义在 `src/index.css``:root` 中,可在任何 CSS 文件中使用:
```css
var(--color-primary) /* #6366f1 */
var(--color-primary-light) /* #818cf8 */
var(--color-primary-dark) /* #4f46e5 */
var(--color-bg-base) /* #f5f5f7 */
var(--color-bg-elevated) /* #ffffff */
var(--color-border) /* #e5e7eb */
var(--shadow-sm) /* 微弱阴影 */
var(--shadow-md) /* 中等阴影 */
var(--shadow-lg) /* 强阴影 */
var(--transition-fast) /* 150ms */
var(--transition-base) /* 200ms */
var(--transition-slow) /* 300ms */
```
---
## 3. 圆角规范
| 场景 | 值 | 说明 |
|------|------|------|
| 小组件 (Tag) | `6px` | `borderRadiusSM` |
| 标准组件 (Button, Input) | `8px` | `borderRadius` |
| 大容器 (Card, Modal) | `12px` | `borderRadiusLG` |
| 特殊场景 (登录卡片) | `16-20px` | 仅限全屏独立页面 |
| 图标背景 | `12px` | 统计卡片图标容器 |
---
## 4. 间距规范
基于 **8px** 网格系统:
| 场景 | 值 |
|------|------|
| 紧凑间距 | `8px` |
| 小间距 | `12px` |
| 标准间距 | `16px` |
| 中等间距 | `24px` (内容区 padding、页面级 margin) |
| 宽松间距 | `32px` |
**常见用法:**
```tsx
// 页面标题与内容
<Title level={4} style={{ marginBottom: 24 }}>页面标题</Title>
// Row 间距
<Row gutter={[16, 16]}>
// Card body padding
<Card styles={{ body: { padding: '20px' } }}>
// 区块间距
<Card style={{ marginBottom: 16 }}>
```
---
## 5. 阴影规范
```
轻微: 0 1px 2px rgba(0,0,0,0.03) → 内容区、Card 默认
中等: 0 1px 3px rgba(0,0,0,0.06) → Header
悬停: var(--shadow-md) → Card:hover (全局自动)
强调: 0 25px 50px rgba(99,102,241,0.25) → 登录卡片
```
---
## 6. 页面结构模板
### 6.1 列表页ProTable 页面)
这是最常见的页面类型,用于设备类型、批次、用户、管理员等管理页面。
```tsx
import React, { useState, useRef } from 'react';
import { Button, message, Modal, Form, Input, Space, Tag, Popconfirm } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { ProTable } from '@ant-design/pro-components';
import type { ProColumns, ActionType } from '@ant-design/pro-components';
import { getItems, createItem, updateItem, deleteItem } from '../../api/xxx';
import type { Item } from '../../api/xxx';
const XxxPage: React.FC = () => {
const actionRef = useRef<ActionType>(null);
const [modalVisible, setModalVisible] = useState(false);
const [editingRecord, setEditingRecord] = useState<Item | null>(null);
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
// --- CRUD handlers ---
const handleAdd = () => {
setEditingRecord(null);
form.resetFields();
setModalVisible(true);
};
const handleEdit = (record: Item) => {
setEditingRecord(record);
form.setFieldsValue(record);
setModalVisible(true);
};
const handleDelete = async (id: number) => {
try {
await deleteItem(id);
message.success('删除成功');
actionRef.current?.reload();
} catch (error) {
message.error(error instanceof Error ? error.message : '删除失败');
}
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setLoading(true);
if (editingRecord) {
await updateItem(editingRecord.id, values);
message.success('更新成功');
} else {
await createItem(values);
message.success('创建成功');
}
setModalVisible(false);
form.resetFields();
actionRef.current?.reload();
} catch (error) {
if (error instanceof Error) {
message.error(error.message);
}
} finally {
setLoading(false);
}
};
// --- Column definitions ---
const columns: ProColumns<Item>[] = [
{
title: 'ID',
dataIndex: 'id',
width: 80,
search: false,
},
{
title: '名称',
dataIndex: 'name',
ellipsis: true,
},
{
title: '状态',
dataIndex: 'is_active',
width: 80,
search: false,
render: (_, record) => (
<Tag color={record.is_active ? 'green' : 'red'}>
{record.is_active ? '正常' : '禁用'}
</Tag>
),
},
{
title: '创建时间',
dataIndex: 'created_at',
valueType: 'dateTime',
width: 180,
search: false,
},
{
title: '操作',
width: 150,
search: false,
render: (_, record) => (
<Space>
<Button type="link" size="small" icon={<EditOutlined />}
onClick={() => handleEdit(record)}>
编辑
</Button>
<Popconfirm title="确定删除吗?" onConfirm={() => handleDelete(record.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
删除
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
{/* ✅ 必须加 cardBordered */}
<ProTable<Item>
headerTitle="Xxx 管理"
rowKey="id"
actionRef={actionRef}
columns={columns}
cardBordered
request={async (params) => {
try {
const res = await getItems({
page: params.current,
page_size: params.pageSize,
});
return {
data: res.data.items,
total: res.data.total,
success: true,
};
} catch (error) {
message.error('获取数据失败');
return { data: [], total: 0, success: false };
}
}}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
新增
</Button>,
]}
pagination={{
defaultPageSize: 10,
showSizeChanger: true,
}}
/>
{/* Modal - 统一 layout="vertical" + marginTop 24 */}
<Modal
title={editingRecord ? '编辑' : '新增'}
open={modalVisible}
onOk={handleSubmit}
onCancel={() => setModalVisible(false)}
confirmLoading={loading}
destroyOnClose
>
<Form form={form} layout="vertical" style={{ marginTop: 24 }}>
<Form.Item name="name" label="名称"
rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="请输入名称" />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default XxxPage;
```
**关键规则:**
1. ProTable 必须添加 `cardBordered` 属性
2. Modal 内 Form 统一 `layout="vertical"` + `style={{ marginTop: 24 }}`
3. 删除操作必须用 `Popconfirm` 确认
4. ID 列:`width: 80, search: false`
5. 时间列:`valueType: 'dateTime', width: 180, search: false`
6. 操作列:`search: false`,按钮用 `type="link" size="small"`
7. 分页统一:`defaultPageSize: 10, showSizeChanger: true`
### 6.2 详情页
```tsx
import { statColors } from '../../theme/tokens';
// 页头:返回按钮 + 标题
<Space style={{ marginBottom: 24 }} align="center">
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/xxx')}
type="text" style={{ color: '#6b7280' }}>
返回
</Button>
<Title level={4} style={{ margin: 0, color: '#1f2937' }}>
详情 - {record.name}
</Title>
</Space>
// 信息卡片
<Card style={{ marginBottom: 16 }}>
<Descriptions column={{ xs: 1, sm: 2, md: 3 }}>
<Descriptions.Item label="字段名"></Descriptions.Item>
</Descriptions>
</Card>
// 统计数据
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={12} sm={6}>
<Card styles={{ body: { padding: '16px 20px' } }}>
<Statistic title="标题" value={100}
valueStyle={{ color: statColors.primary, fontWeight: 600 }} />
</Card>
</Col>
</Row>
// 子表格
<ProTable cardBordered search={false} ... />
```
### 6.3 统计卡片
Dashboard 风格的统计卡片模板:
```tsx
import { statColors } from '../../theme/tokens';
<Card hoverable styles={{ body: { padding: '20px' } }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
{/* 图标容器 */}
<div style={{
width: 44, height: 44,
borderRadius: 12,
background: 'rgba(99, 102, 241, 0.08)', // 主色 8% 透明度
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20,
color: statColors.primary,
flexShrink: 0,
}}>
<UserOutlined />
</div>
{/* 数值区 */}
<div>
<div style={{ fontSize: 12, color: '#6b7280', marginBottom: 2 }}>
标题
</div>
<div style={{ fontSize: 24, fontWeight: 700, color: '#1f2937', lineHeight: 1.2 }}>
{value.toLocaleString()}
</div>
</div>
</div>
</Card>
```
**图标背景色规则:** 使用对应 statColor 的 8% 透明度作为背景。
```
rgba(99, 102, 241, 0.08) → primary 背景
rgba(16, 185, 129, 0.08) → success 背景
rgba(139, 92, 246, 0.08) → purple 背景
rgba(245, 158, 11, 0.08) → warning 背景
rgba(6, 182, 212, 0.08) → cyan 背景
rgba(236, 72, 153, 0.08) → pink 背景
```
---
## 7. 图表规范
使用 `@ant-design/charts`,导入方式:
```typescript
import { Pie, Column, Line, Area } from '@ant-design/charts';
```
### 7.1 饼图
```tsx
const pieConfig = {
data: [{ type: '类别', value: 100 }],
angleField: 'value',
colorField: 'type',
radius: 0.85,
innerRadius: 0.6, // 环形图
color: [statColors.primary, statColors.success, statColors.warning],
label: {
text: (d) => `${d.type}\n${d.value}`,
style: { fontSize: 12, fontWeight: 500 },
},
legend: {
color: {
position: 'bottom' as const,
layout: { justifyContent: 'center' as const },
},
},
};
<Pie {...pieConfig} />
```
### 7.2 柱状图
```tsx
const columnConfig = {
data: [{ name: '类别', value: 100 }],
xField: 'name',
yField: 'value',
style: {
radiusTopLeft: 6,
radiusTopRight: 6,
fill: statColors.primary,
fillOpacity: 0.85,
},
label: {
text: (d) => `${d.value}`,
textBaseline: 'bottom' as const,
style: { dy: -4, fontSize: 12 },
},
axis: {
y: { title: false },
x: { title: false },
},
};
<Column {...columnConfig} />
```
### 7.3 图表容器
```tsx
<Card title="图表标题" styles={{ body: { padding: '16px 24px' } }}>
<div style={{ height: 320 }}>
<ChartComponent {...config} />
</div>
</Card>
```
- 图表容器高度统一 `320px`
- 空数据时显示居中灰色提示文字
- 图表颜色优先使用 `statColors` 中的色值
---
## 8. Tag 状态映射
### 8.1 通用状态
```tsx
// 启用/禁用
<Tag color={record.is_active ? 'green' : 'red'}>
{record.is_active ? '正常' : '禁用'}
</Tag>
// 设备状态
const statusMap = {
in_stock: { color: 'default', text: '库存中' },
bound: { color: 'green', text: '已绑定' },
offline: { color: 'red', text: '离线' },
};
// 布尔属性
<Tag color={value ? 'purple' : 'default'}>{value ? '是' : '否'}</Tag>
// 缺失数据
record.field || <Tag color="orange">未导入</Tag>
```
### 8.2 角色
```tsx
const roleMap = {
super_admin: { text: '超级管理员', color: 'red' },
admin: { text: '管理员', color: 'blue' },
operator: { text: '操作员', color: 'default' },
};
```
---
## 9. 响应式断点
基于 Ant Design Grid 的断点系统:
```tsx
// 统计卡片 (6列网格)
<Col xs={24} sm={12} lg={8} xl={4}>
// 图表区域 (2列)
<Col xs={24} lg={12}>
// 详情页统计 (4列)
<Col xs={12} sm={6}>
// Descriptions 响应式列数
<Descriptions column={{ xs: 1, sm: 2, md: 3 }}>
```
---
## 10. 样式规则
### 10.1 使用优先级
1. **Ant Design Theme Token** → 优先使用 `theme.useToken()` 获取的值
2. **statColors** → 统计、图表颜色用 `statColors`
3. **CSS 变量** → 阴影、过渡动画用 `var(--xxx)`
4. **内联 style** → 布局、间距等用内联样式
5. **全局 CSS** → 仅用于 Ant Design 组件覆盖(已有的 CSS 文件)
### 10.2 禁止事项
- **不要** 硬编码 `#1890ff`(旧主题色),使用 `themeToken.colorPrimary``statColors.primary`
- **不要** 创建 `.module.css` 文件,保持现有内联样式模式
- **不要** 在页面组件中直接引用 CSS 文件,全局 CSS 已在 `App.tsx` 统一导入
- **不要** 使用 `style={{ color: '#999' }}`,用 `#6b7280``#9ca3af` 代替
- **不要** 遗漏 ProTable 的 `cardBordered` 属性
### 10.3 获取主题 Token
```tsx
import { theme } from 'antd';
const MyComponent: React.FC = () => {
const { token: themeToken } = theme.useToken();
return (
<div style={{
background: themeToken.colorBgContainer,
borderRadius: themeToken.borderRadiusLG,
color: themeToken.colorPrimary,
}}>
...
</div>
);
};
```
---
## 11. 文件结构
```
src/
├── theme/
│ ├── tokens.ts # 主题配置 (themeConfig + statColors)
│ └── sidebar.css # 侧边栏样式
├── styles/
│ └── protable-theme.css # ProTable 全局样式
├── components/
│ └── Layout/
│ └── index.tsx # 主布局 (侧边栏 + 头部 + Content)
├── pages/
│ ├── Login/index.tsx # 登录页 (独立布局)
│ ├── Dashboard/index.tsx
│ ├── [Module]/
│ │ ├── index.tsx # 列表页
│ │ └── Detail.tsx # 详情页 (如有)
├── api/ # API 层
├── store/ # Zustand 状态
├── routes/index.tsx # 路由配置
├── App.tsx # 根组件 (ConfigProvider + 主题)
└── index.css # CSS 变量 + 全局基础样式
```
---
## 12. 新增页面检查清单
新开发一个页面时,逐项检查:
- [ ] ProTable 添加了 `cardBordered`
- [ ] 使用 `statColors` 而非硬编码颜色
- [ ] Tag 状态映射与已有页面一致
- [ ] Modal Form 使用 `layout="vertical"` + `style={{ marginTop: 24 }}`
- [ ] 删除操作使用 `Popconfirm`
- [ ] 时间列使用 `valueType: 'dateTime'`
- [ ] 页面标题 `<Title level={4}>` + `marginBottom: 24`
- [ ] 响应式布局使用 `Row/Col` + 正确断点
- [ ] 详情页有返回按钮 (`type="text"`)
- [ ] 空状态文字颜色使用 `#9ca3af`
- [ ] 图表容器高度 `320px`
- [ ] 分页配置 `defaultPageSize: 10, showSizeChanger: true`
---
## 13. 新增路由步骤
1. 在 `src/pages/` 下创建页面组件
2. 在 `src/routes/index.tsx``children` 数组中添加路由
3. 在 `src/components/Layout/index.tsx``menuItems` 中添加菜单项
4. 在 `src/api/` 下创建对应的 API 文件
```tsx
// routes/index.tsx
{
path: 'new-page',
element: <NewPage />,
}
// Layout/index.tsx menuItems
{
key: '/new-page',
icon: <SomeOutlined />,
label: '新页面',
}
```

2006
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@ant-design/charts": "^2.6.7",
"@ant-design/icons": "^6.1.0", "@ant-design/icons": "^6.1.0",
"@ant-design/pro-components": "^2.8.10", "@ant-design/pro-components": "^2.8.10",
"antd": "^5.29.3", "antd": "^5.29.3",

View File

@ -2,22 +2,17 @@ import { RouterProvider } from 'react-router-dom';
import { ConfigProvider } from 'antd'; import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN'; import zhCN from 'antd/locale/zh_CN';
import router from './routes'; import router from './routes';
import { themeConfig } from './theme/tokens';
import 'dayjs/locale/zh-cn'; import 'dayjs/locale/zh-cn';
import './theme/sidebar.css';
import './styles/protable-theme.css';
function App() { function App() {
return ( return (
<ConfigProvider <ConfigProvider locale={zhCN} theme={themeConfig}>
locale={zhCN} <RouterProvider router={router} />
theme={{ </ConfigProvider>
token: { );
colorPrimary: '#1890ff',
borderRadius: 6,
},
}}
>
<RouterProvider router={router} />
</ConfigProvider>
);
} }
export default App; export default App;

View File

@ -43,6 +43,7 @@ export const getBatches = (params?: {
page?: number; page?: number;
page_size?: number; page_size?: number;
device_type?: number; device_type?: number;
batch_no?: string;
}) => { }) => {
return request.get<unknown, ApiResponse<PaginatedResponse<DeviceBatch>>>('/api/admin/device-batches/', { params }); return request.get<unknown, ApiResponse<PaginatedResponse<DeviceBatch>>>('/api/admin/device-batches/', { params });
}; };

View File

@ -13,7 +13,13 @@ export interface DeviceType {
} }
// 获取设备类型列表 // 获取设备类型列表
export const getDeviceTypes = (params?: { page?: number; page_size?: number }) => { export const getDeviceTypes = (params?: {
page?: number;
page_size?: number;
brand?: string;
product_code?: string;
name?: string;
}) => {
return request.get<unknown, ApiResponse<PaginatedResponse<DeviceType>>>('/api/admin/device-types/', { params }); return request.get<unknown, ApiResponse<PaginatedResponse<DeviceType>>>('/api/admin/device-types/', { params });
}; };

View File

@ -41,16 +41,18 @@ function reportToLogCenter(error: Error, context?: Record<string, unknown>) {
}; };
// 使用 sendBeacon 确保页面关闭时也能发送 // 使用 sendBeacon 确保页面关闭时也能发送
const body = JSON.stringify(payload);
if (navigator.sendBeacon) { if (navigator.sendBeacon) {
const blob = new Blob([body], { type: 'application/json' });
navigator.sendBeacon( navigator.sendBeacon(
`${LOG_CENTER_URL}/api/v1/logs/report`, `${LOG_CENTER_URL}/api/v1/logs/report`,
JSON.stringify(payload) blob
); );
} else { } else {
fetch(`${LOG_CENTER_URL}/api/v1/logs/report`, { fetch(`${LOG_CENTER_URL}/api/v1/logs/report`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body,
keepalive: true, keepalive: true,
}).catch(() => { }); // 静默失败 }).catch(() => { }); // 静默失败
} }
@ -85,6 +87,10 @@ request.interceptors.request.use(
// 响应拦截器 - 统一处理响应 // 响应拦截器 - 统一处理响应
request.interceptors.response.use( request.interceptors.response.use(
(response) => { (response) => {
// 文件下载blob直接返回完整 response
if (response.config.responseType === 'blob') {
return response;
}
const data = response.data; const data = response.data;
// 业务错误处理 // 业务错误处理
if (data.code !== 0) { if (data.code !== 0) {

View File

@ -48,7 +48,7 @@ export const toggleAppUserStatus = (id: number) => {
}; };
// 获取管理员列表 // 获取管理员列表
export const getAdminUsers = (params?: { page?: number; page_size?: number }) => { export const getAdminUsers = (params?: { page?: number; page_size?: number; username?: string }) => {
return request.get<unknown, ApiResponse<PaginatedResponse<AdminUser>>>('/api/admin/admins/', { params }); return request.get<unknown, ApiResponse<PaginatedResponse<AdminUser>>>('/api/admin/admins/', { params });
}; };

View File

@ -8,6 +8,7 @@ import {
theme, theme,
Button, Button,
Space, Space,
Tag,
} from 'antd'; } from 'antd';
import { import {
DashboardOutlined, DashboardOutlined,
@ -92,21 +93,23 @@ const MainLayout: React.FC = () => {
]; ];
const getRoleLabel = (role?: string) => { const getRoleLabel = (role?: string) => {
const roleMap: Record<string, string> = { const roleMap: Record<string, { label: string; color: string }> = {
super_admin: '超级管理员', super_admin: { label: '超级管理员', color: 'purple' },
admin: '管理员', admin: { label: '管理员', color: 'blue' },
operator: '操作员', operator: { label: '操作员', color: 'default' },
}; };
return roleMap[role || ''] || role; return roleMap[role || ''] || { label: role, color: 'default' };
}; };
const roleInfo = getRoleLabel(adminInfo?.role);
return ( return (
<Layout style={{ minHeight: '100vh' }}> <Layout style={{ minHeight: '100vh' }}>
<Sider <Sider
trigger={null} trigger={null}
collapsible collapsible
collapsed={collapsed} collapsed={collapsed}
theme="dark" className="rtc-sidebar"
style={{ style={{
overflow: 'auto', overflow: 'auto',
height: '100vh', height: '100vh',
@ -116,19 +119,9 @@ const MainLayout: React.FC = () => {
bottom: 0, bottom: 0,
}} }}
> >
<div <div className="rtc-sidebar-logo">
style={{ <div className="rtc-sidebar-logo-icon">R</div>
height: 64, {!collapsed && <span>RTC </span>}
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: collapsed ? 16 : 20,
fontWeight: 'bold',
borderBottom: '1px solid rgba(255,255,255,0.1)',
}}
>
{collapsed ? 'RTC' : 'RTC 管理后台'}
</div> </div>
<Menu <Menu
theme="dark" theme="dark"
@ -136,9 +129,15 @@ const MainLayout: React.FC = () => {
selectedKeys={[location.pathname]} selectedKeys={[location.pathname]}
items={menuItems} items={menuItems}
onClick={handleMenuClick} onClick={handleMenuClick}
style={{ borderRight: 0 }}
/> />
</Sider> </Sider>
<Layout style={{ marginLeft: collapsed ? 80 : 200, transition: 'margin-left 0.2s' }}> <Layout
style={{
marginLeft: collapsed ? 80 : 200,
transition: 'margin-left 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
}}
>
<Header <Header
style={{ style={{
padding: '0 24px', padding: '0 24px',
@ -146,26 +145,40 @@ const MainLayout: React.FC = () => {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
boxShadow: '0 1px 4px rgba(0,0,0,0.08)', boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
position: 'sticky', position: 'sticky',
top: 0, top: 0,
zIndex: 10, zIndex: 10,
borderBottom: '1px solid rgba(0,0,0,0.04)',
}} }}
> >
<Button <Button
type="text" type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />} icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)} onClick={() => setCollapsed(!collapsed)}
style={{ fontSize: 16 }} style={{ fontSize: 16, color: '#6b7280' }}
/> />
<Space> <Space size={12}>
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight"> <Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<Space style={{ cursor: 'pointer' }}> <Space style={{ cursor: 'pointer', padding: '4px 8px', borderRadius: 8 }}>
<Avatar icon={<UserOutlined />} style={{ backgroundColor: themeToken.colorPrimary }} /> <Avatar
<span>{adminInfo?.name || adminInfo?.username}</span> size={34}
<span style={{ color: themeToken.colorTextSecondary, fontSize: 12 }}> icon={<UserOutlined />}
({getRoleLabel(adminInfo?.role)}) style={{
</span> background: `linear-gradient(135deg, ${themeToken.colorPrimary}, #8b5cf6)`,
}}
/>
<div style={{ lineHeight: 1.3 }}>
<div style={{ fontSize: 14, fontWeight: 500, color: '#1f2937' }}>
{adminInfo?.name || adminInfo?.username}
</div>
<Tag
color={roleInfo.color}
style={{ fontSize: 11, lineHeight: '18px', margin: 0, padding: '0 6px' }}
>
{roleInfo.label}
</Tag>
</div>
</Space> </Space>
</Dropdown> </Dropdown>
</Space> </Space>
@ -177,6 +190,7 @@ const MainLayout: React.FC = () => {
background: themeToken.colorBgContainer, background: themeToken.colorBgContainer,
borderRadius: themeToken.borderRadiusLG, borderRadius: themeToken.borderRadiusLG,
minHeight: 280, minHeight: 280,
boxShadow: '0 1px 2px rgba(0,0,0,0.03)',
}} }}
> >
<Outlet /> <Outlet />

View File

@ -1,41 +1,62 @@
:root {
--color-primary: #6366f1;
--color-primary-light: #818cf8;
--color-primary-dark: #4f46e5;
--color-bg-base: #f5f5f7;
--color-bg-elevated: #ffffff;
--color-border: #e5e7eb;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.04);
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
} }
html, body, #root { html, body, #root {
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Inter', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
background: var(--color-bg-base);
} }
/* 滚动条样式 */ /* Scrollbar */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 6px;
height: 8px; height: 6px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: #f1f1f1; background: transparent;
border-radius: 4px;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #c1c1c1; background: #d1d5db;
border-radius: 4px; border-radius: 3px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: #a1a1a1; background: #9ca3af;
} }
/* ProTable 调整 */ /* Smooth transitions */
.ant-pro-table-list-toolbar-title { a, button, .ant-btn, .ant-card, .ant-tag {
font-weight: 600; transition: all var(--transition-fast);
}
/* Card hover */
.ant-card.ant-card-hoverable:hover {
box-shadow: var(--shadow-md);
transform: translateY(-1px);
} }

View File

@ -2,6 +2,22 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import App from './App.tsx' import App from './App.tsx'
import './index.css' import './index.css'
import { reportToLogCenter } from './api/request.ts'
// 全局未捕获错误上报
window.onerror = (_message, source, lineno, _colno, error) => {
if (error) {
reportToLogCenter(error, { source, lineno })
}
}
// 全局未处理 Promise 拒绝上报
window.onunhandledrejection = (event) => {
const error = event.reason instanceof Error
? event.reason
: new Error(String(event.reason))
reportToLogCenter(error, { type: 'unhandledrejection' })
}
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>

View File

@ -172,7 +172,7 @@ const AdminPage: React.FC = () => {
if (!isSuperAdmin) { if (!isSuperAdmin) {
return ( return (
<div style={{ textAlign: 'center', padding: 100, color: '#999' }}> <div style={{ textAlign: 'center', padding: 100, color: '#9ca3af' }}>
访 访
</div> </div>
); );
@ -185,11 +185,13 @@ const AdminPage: React.FC = () => {
rowKey="id" rowKey="id"
actionRef={actionRef} actionRef={actionRef}
columns={columns} columns={columns}
cardBordered
request={async (params) => { request={async (params) => {
try { try {
const res = await getAdminUsers({ const res = await getAdminUsers({
page: params.current, page: params.current,
page_size: params.pageSize, page_size: params.pageSize,
username: params.username,
}); });
return { return {
data: res.data.items, data: res.data.items,

View File

@ -21,6 +21,7 @@ import { ProTable } from '@ant-design/pro-components';
import type { ProColumns } from '@ant-design/pro-components'; import type { ProColumns } from '@ant-design/pro-components';
import { getBatch, getBatchDevices, exportBatchExcel } from '../../api/batch'; import { getBatch, getBatchDevices, exportBatchExcel } from '../../api/batch';
import type { Device, DeviceBatch } from '../../api/batch'; import type { Device, DeviceBatch } from '../../api/batch';
import { statColors } from '../../theme/tokens';
const { Title } = Typography; const { Title } = Typography;
@ -107,22 +108,34 @@ const BatchDetail: React.FC = () => {
} }
if (!batch) { if (!batch) {
return <div></div>; return <div style={{ textAlign: 'center', padding: 100, color: '#9ca3af' }}></div>;
} }
const statItems = batch.statistics ? [
{ title: '总数量', value: batch.statistics.total, color: statColors.primary },
{ title: '已导入MAC', value: batch.statistics.with_mac, color: statColors.success },
{ title: '库存中', value: batch.statistics.in_stock, color: statColors.warning },
{ title: '已绑定', value: batch.statistics.bound, color: statColors.info },
] : [];
return ( return (
<div> <div>
<Space style={{ marginBottom: 24 }}> <Space style={{ marginBottom: 24 }} align="center">
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/batches')}> <Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/batches')}
type="text"
style={{ color: '#6b7280' }}
>
</Button> </Button>
<Title level={4} style={{ margin: 0 }}> <Title level={4} style={{ margin: 0, color: '#1f2937' }}>
- {batch.batch_no} - {batch.batch_no}
</Title> </Title>
</Space> </Space>
<Card style={{ marginBottom: 24 }}> <Card style={{ marginBottom: 16 }}>
<Descriptions column={3}> <Descriptions column={{ xs: 1, sm: 2, md: 3 }}>
<Descriptions.Item label="设备类型"> <Descriptions.Item label="设备类型">
{batch.device_type_info?.name} {batch.device_type_info?.name}
</Descriptions.Item> </Descriptions.Item>
@ -138,39 +151,19 @@ const BatchDetail: React.FC = () => {
</Descriptions> </Descriptions>
</Card> </Card>
{batch.statistics && ( {statItems.length > 0 && (
<Row gutter={16} style={{ marginBottom: 24 }}> <Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col span={6}> {statItems.map((item, index) => (
<Card> <Col xs={12} sm={6} key={index}>
<Statistic title="总数量" value={batch.statistics.total} /> <Card styles={{ body: { padding: '16px 20px' } }}>
</Card> <Statistic
</Col> title={item.title}
<Col span={6}> value={item.value}
<Card> valueStyle={{ color: item.color, fontWeight: 600 }}
<Statistic />
title="已导入MAC" </Card>
value={batch.statistics.with_mac} </Col>
valueStyle={{ color: '#52c41a' }} ))}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="库存中"
value={batch.statistics.in_stock}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="已绑定"
value={batch.statistics.bound}
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
</Row> </Row>
)} )}
@ -179,6 +172,7 @@ const BatchDetail: React.FC = () => {
rowKey="id" rowKey="id"
columns={deviceColumns} columns={deviceColumns}
search={false} search={false}
cardBordered
request={async (params) => { request={async (params) => {
try { try {
const res = await getBatchDevices(parseInt(id!), { const res = await getBatchDevices(parseInt(id!), {

View File

@ -85,13 +85,19 @@ const BatchPage: React.FC = () => {
const handleExport = async (batchId: number) => { const handleExport = async (batchId: number) => {
try { try {
const res = await exportBatchExcel(batchId); const res = await exportBatchExcel(batchId);
const blob = new Blob([res as unknown as BlobPart], { // res 是完整的 AxiosResponsedata 已经是 Blob
const blob = new Blob([res.data], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
}); });
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement('a');
link.href = url; link.href = url;
link.download = `batch_${batchId}_sn_codes.xlsx`; // 尝试从 Content-Disposition 获取文件名
const disposition = res.headers?.['content-disposition'];
const filename = disposition
? decodeURIComponent(disposition.split('filename=')[1]?.replace(/"/g, '') || `batch_${batchId}.xlsx`)
: `batch_${batchId}_sn_codes.xlsx`;
link.download = filename;
link.click(); link.click();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
message.success('导出成功'); message.success('导出成功');
@ -262,11 +268,13 @@ const BatchPage: React.FC = () => {
rowKey="id" rowKey="id"
actionRef={actionRef} actionRef={actionRef}
columns={columns} columns={columns}
cardBordered
request={async (params) => { request={async (params) => {
try { try {
const res = await getBatches({ const res = await getBatches({
page: params.current, page: params.current,
page_size: params.pageSize, page_size: params.pageSize,
batch_no: params.batch_no,
}); });
return { return {
data: res.data.items, data: res.data.items,

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Row, Col, Card, Statistic, Typography, Spin } from 'antd'; import { Row, Col, Card, Typography, Spin } from 'antd';
import { import {
UserOutlined, UserOutlined,
MobileOutlined, MobileOutlined,
@ -8,7 +8,9 @@ import {
CheckCircleOutlined, CheckCircleOutlined,
DatabaseOutlined, DatabaseOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { Pie, Column } from '@ant-design/charts';
import request from '../../api/request'; import request from '../../api/request';
import { statColors } from '../../theme/tokens';
const { Title } = Typography; const { Title } = Typography;
@ -21,7 +23,6 @@ interface DashboardStats {
in_stock_device_count: number; in_stock_device_count: number;
} }
// 调用真实后端API
const getDashboardStats = async (): Promise<DashboardStats> => { const getDashboardStats = async (): Promise<DashboardStats> => {
const res = await request.get<unknown, { data: DashboardStats }>('/api/admin/dashboard/stats/'); const res = await request.get<unknown, { data: DashboardStats }>('/api/admin/dashboard/stats/');
return res.data; return res.data;
@ -29,7 +30,7 @@ const getDashboardStats = async (): Promise<DashboardStats> => {
const Dashboard: React.FC = () => { const Dashboard: React.FC = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [stats, setStats] = useState({ const [stats, setStats] = useState<DashboardStats>({
user_count: 0, user_count: 0,
device_count: 0, device_count: 0,
device_type_count: 0, device_type_count: 0,
@ -58,99 +59,190 @@ const Dashboard: React.FC = () => {
title: '用户总数', title: '用户总数',
value: stats.user_count, value: stats.user_count,
icon: <UserOutlined />, icon: <UserOutlined />,
color: '#1890ff', color: statColors.primary,
bgColor: 'rgba(99, 102, 241, 0.08)',
}, },
{ {
title: '设备总数', title: '设备总数',
value: stats.device_count, value: stats.device_count,
icon: <MobileOutlined />, icon: <MobileOutlined />,
color: '#52c41a', color: statColors.success,
bgColor: 'rgba(16, 185, 129, 0.08)',
}, },
{ {
title: '设备类型', title: '设备类型',
value: stats.device_type_count, value: stats.device_type_count,
icon: <AppstoreOutlined />, icon: <AppstoreOutlined />,
color: '#722ed1', color: statColors.purple,
bgColor: 'rgba(139, 92, 246, 0.08)',
}, },
{ {
title: '入库批次', title: '入库批次',
value: stats.batch_count, value: stats.batch_count,
icon: <InboxOutlined />, icon: <InboxOutlined />,
color: '#fa8c16', color: statColors.warning,
bgColor: 'rgba(245, 158, 11, 0.08)',
}, },
{ {
title: '已绑定设备', title: '已绑定设备',
value: stats.bound_device_count, value: stats.bound_device_count,
icon: <CheckCircleOutlined />, icon: <CheckCircleOutlined />,
color: '#13c2c2', color: statColors.cyan,
bgColor: 'rgba(6, 182, 212, 0.08)',
}, },
{ {
title: '库存设备', title: '库存设备',
value: stats.in_stock_device_count, value: stats.in_stock_device_count,
icon: <DatabaseOutlined />, icon: <DatabaseOutlined />,
color: '#eb2f96', color: statColors.pink,
bgColor: 'rgba(236, 72, 153, 0.08)',
}, },
]; ];
// 设备状态分布数据
const otherCount = Math.max(0, stats.device_count - stats.in_stock_device_count - stats.bound_device_count);
const pieData = [
{ type: '库存中', value: stats.in_stock_device_count },
{ type: '已绑定', value: stats.bound_device_count },
...(otherCount > 0 ? [{ type: '其他', value: otherCount }] : []),
];
const pieConfig = {
data: pieData,
angleField: 'value',
colorField: 'type',
radius: 0.85,
innerRadius: 0.6,
color: [statColors.primary, statColors.success, statColors.warning],
label: {
text: (d: { type: string; value: number }) => `${d.type}\n${d.value}`,
style: { fontSize: 12, fontWeight: 500 },
},
legend: {
color: {
position: 'bottom' as const,
layout: { justifyContent: 'center' as const },
},
},
interaction: {
elementHighlight: true,
},
};
// 数据概览柱状图
const columnData = [
{ name: '用户', value: stats.user_count, type: '数量' },
{ name: '设备', value: stats.device_count, type: '数量' },
{ name: '已绑定', value: stats.bound_device_count, type: '数量' },
{ name: '库存', value: stats.in_stock_device_count, type: '数量' },
{ name: '批次', value: stats.batch_count, type: '数量' },
{ name: '类型', value: stats.device_type_count, type: '数量' },
];
const columnConfig = {
data: columnData,
xField: 'name',
yField: 'value',
color: statColors.primary,
style: {
radiusTopLeft: 6,
radiusTopRight: 6,
fill: statColors.primary,
fillOpacity: 0.85,
},
label: {
text: (d: { value: number }) => `${d.value}`,
textBaseline: 'bottom' as const,
style: { dy: -4, fontSize: 12 },
},
axis: {
y: { title: false },
x: { title: false },
},
};
return ( return (
<div> <div>
<Title level={4} style={{ marginBottom: 24 }}> <Title level={4} style={{ marginBottom: 24, color: '#1f2937' }}>
</Title> </Title>
<Spin spinning={loading}> <Spin spinning={loading}>
<Row gutter={[24, 24]}> {/* Stat Cards */}
<Row gutter={[16, 16]}>
{statCards.map((stat, index) => ( {statCards.map((stat, index) => (
<Col xs={24} sm={12} lg={8} xl={4} key={index}> <Col xs={24} sm={12} lg={8} xl={4} key={index}>
<Card hoverable> <Card
<Statistic hoverable
title={stat.title} styles={{ body: { padding: '20px' } }}
value={stat.value} >
prefix={ <div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<span style={{ color: stat.color, fontSize: 24, marginRight: 8 }}> <div
{stat.icon} style={{
</span> width: 44,
} height: 44,
valueStyle={{ color: stat.color }} borderRadius: 12,
/> background: stat.bgColor,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 20,
color: stat.color,
flexShrink: 0,
}}
>
{stat.icon}
</div>
<div>
<div style={{ fontSize: 12, color: '#6b7280', marginBottom: 2 }}>
{stat.title}
</div>
<div style={{ fontSize: 24, fontWeight: 700, color: '#1f2937', lineHeight: 1.2 }}>
{stat.value.toLocaleString()}
</div>
</div>
</div>
</Card> </Card>
</Col> </Col>
))} ))}
</Row> </Row>
</Spin>
<Row gutter={[24, 24]} style={{ marginTop: 24 }}> {/* Charts */}
<Col xs={24} lg={12}> <Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Card title="设备状态分布" style={{ height: 300 }}> <Col xs={24} lg={12}>
<div style={{ <Card
display: 'flex', title="设备状态分布"
justifyContent: 'center', styles={{ body: { padding: '16px 24px' } }}
alignItems: 'center', >
height: 200, <div style={{ height: 320 }}>
color: '#999', {stats.device_count > 0 ? (
flexDirection: 'column', <Pie {...pieConfig} />
}}> ) : (
<div style={{ fontSize: 48, marginBottom: 16 }}>📊</div> <div style={{
<div>...</div> display: 'flex',
</div> alignItems: 'center',
</Card> justifyContent: 'center',
</Col> height: '100%',
<Col xs={24} lg={12}> color: '#9ca3af',
<Card title="快捷操作" style={{ height: 300 }}> }}>
<div style={{
display: 'flex', </div>
justifyContent: 'center', )}
alignItems: 'center', </div>
height: 200, </Card>
color: '#999', </Col>
flexDirection: 'column', <Col xs={24} lg={12}>
}}> <Card
<div style={{ fontSize: 48, marginBottom: 16 }}>🚀</div> title="数据概览"
<div>使</div> styles={{ body: { padding: '16px 24px' } }}
</div> >
</Card> <div style={{ height: 320 }}>
</Col> <Column {...columnConfig} />
</Row> </div>
</Card>
</Col>
</Row>
</Spin>
</div> </div>
); );
}; };

View File

@ -51,6 +51,7 @@ const DevicePage: React.FC = () => {
headerTitle="设备列表" headerTitle="设备列表"
rowKey="id" rowKey="id"
columns={columns} columns={columns}
cardBordered
request={async (params) => { request={async (params) => {
try { try {
// 先获取所有批次,再获取设备 // 先获取所有批次,再获取设备

View File

@ -90,7 +90,7 @@ const DeviceTypePage: React.FC = () => {
width: 100, width: 100,
search: false, search: false,
render: (_, record) => ( render: (_, record) => (
<Tag color={record.is_network_required ? 'blue' : 'default'}> <Tag color={record.is_network_required ? 'purple' : 'default'}>
{record.is_network_required ? '联网' : '离线'} {record.is_network_required ? '联网' : '离线'}
</Tag> </Tag>
), ),
@ -135,11 +135,15 @@ const DeviceTypePage: React.FC = () => {
headerTitle="设备类型管理" headerTitle="设备类型管理"
rowKey="id" rowKey="id"
columns={columns} columns={columns}
cardBordered
request={async (params) => { request={async (params) => {
try { try {
const res = await getDeviceTypes({ const res = await getDeviceTypes({
page: params.current, page: params.current,
page_size: params.pageSize, page_size: params.pageSize,
brand: params.brand,
product_code: params.product_code,
name: params.name,
}); });
return { return {
data: res.data.items, data: res.data.items,

View File

@ -1,11 +1,11 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Form, Input, Button, Card, message, Typography } from 'antd'; import { Form, Input, Button, message, Typography } from 'antd';
import { UserOutlined, LockOutlined } from '@ant-design/icons'; import { UserOutlined, LockOutlined } from '@ant-design/icons';
import { login } from '../../api/auth'; import { login } from '../../api/auth';
import { useAuthStore } from '../../store/useAuthStore'; import { useAuthStore } from '../../store/useAuthStore';
const { Title } = Typography; const { Title, Text } = Typography;
const LoginPage: React.FC = () => { const LoginPage: React.FC = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -33,22 +33,69 @@ const LoginPage: React.FC = () => {
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', background: 'linear-gradient(135deg, #312e81 0%, #6366f1 50%, #8b5cf6 100%)',
position: 'relative',
overflow: 'hidden',
}} }}
> >
<Card {/* Decorative elements */}
<div
style={{ style={{
position: 'absolute',
width: 400, width: 400,
boxShadow: '0 20px 60px rgba(0,0,0,0.3)', height: 400,
borderRadius: 16, borderRadius: '50%',
background: 'rgba(255,255,255,0.05)',
top: -100,
right: -100,
}}
/>
<div
style={{
position: 'absolute',
width: 300,
height: 300,
borderRadius: '50%',
background: 'rgba(255,255,255,0.03)',
bottom: -80,
left: -80,
}}
/>
<div
style={{
width: 420,
padding: 40,
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(20px)',
borderRadius: 20,
boxShadow: '0 25px 50px rgba(99, 102, 241, 0.25), 0 0 0 1px rgba(255,255,255,0.2)',
position: 'relative',
zIndex: 1,
}} }}
bordered={false}
> >
<div style={{ textAlign: 'center', marginBottom: 32 }}> <div style={{ textAlign: 'center', marginBottom: 36 }}>
<Title level={2} style={{ marginBottom: 8, color: '#1890ff' }}> <div
style={{
width: 56,
height: 56,
borderRadius: 16,
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
boxShadow: '0 8px 20px rgba(99, 102, 241, 0.3)',
}}
>
<span style={{ fontSize: 24, fontWeight: 800, color: '#fff' }}>R</span>
</div>
<Title level={3} style={{ marginBottom: 4, color: '#1f2937' }}>
RTC RTC
</Title> </Title>
<Typography.Text type="secondary"> · · </Typography.Text> <Text type="secondary" style={{ fontSize: 14 }}>
· ·
</Text>
</div> </div>
<Form <Form
@ -62,8 +109,9 @@ const LoginPage: React.FC = () => {
rules={[{ required: true, message: '请输入用户名' }]} rules={[{ required: true, message: '请输入用户名' }]}
> >
<Input <Input
prefix={<UserOutlined style={{ color: '#bfbfbf' }} />} prefix={<UserOutlined style={{ color: '#9ca3af' }} />}
placeholder="用户名" placeholder="用户名"
style={{ height: 48, borderRadius: 10 }}
/> />
</Form.Item> </Form.Item>
@ -72,24 +120,31 @@ const LoginPage: React.FC = () => {
rules={[{ required: true, message: '请输入密码' }]} rules={[{ required: true, message: '请输入密码' }]}
> >
<Input.Password <Input.Password
prefix={<LockOutlined style={{ color: '#bfbfbf' }} />} prefix={<LockOutlined style={{ color: '#9ca3af' }} />}
placeholder="密码" placeholder="密码"
style={{ height: 48, borderRadius: 10 }}
/> />
</Form.Item> </Form.Item>
<Form.Item style={{ marginBottom: 0 }}> <Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
<Button <Button
type="primary" type="primary"
htmlType="submit" htmlType="submit"
loading={loading} loading={loading}
block block
style={{ height: 48, fontSize: 16 }} style={{
height: 48,
fontSize: 16,
fontWeight: 600,
borderRadius: 10,
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.4)',
}}
> >
</Button> </Button>
</Form.Item> </Form.Item>
</Form> </Form>
</Card> </div>
</div> </div>
); );
}; };

View File

@ -1,15 +1,18 @@
import React from 'react'; import React, { useRef } from 'react';
import { Button, message, Tag, Space, Popconfirm } from 'antd'; import { Button, message, Tag, Space, Popconfirm } from 'antd';
import { ProTable } from '@ant-design/pro-components'; import { ProTable } from '@ant-design/pro-components';
import type { ProColumns } from '@ant-design/pro-components'; import type { ProColumns, ActionType } from '@ant-design/pro-components';
import { getAppUsers, toggleAppUserStatus } from '../../api/user'; import { getAppUsers, toggleAppUserStatus } from '../../api/user';
import type { AppUser } from '../../api/user'; import type { AppUser } from '../../api/user';
const UserPage: React.FC = () => { const UserPage: React.FC = () => {
const actionRef = useRef<ActionType>(null);
const handleToggleStatus = async (id: number, currentStatus: boolean) => { const handleToggleStatus = async (id: number, currentStatus: boolean) => {
try { try {
await toggleAppUserStatus(id); await toggleAppUserStatus(id);
message.success(currentStatus ? '已禁用' : '已启用'); message.success(currentStatus ? '已禁用' : '已启用');
actionRef.current?.reload();
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : '操作失败'); message.error(error instanceof Error ? error.message : '操作失败');
} }
@ -90,7 +93,9 @@ const UserPage: React.FC = () => {
<ProTable<AppUser> <ProTable<AppUser>
headerTitle="用户管理" headerTitle="用户管理"
rowKey="id" rowKey="id"
actionRef={actionRef}
columns={columns} columns={columns}
cardBordered
request={async (params) => { request={async (params) => {
try { try {
const res = await getAppUsers({ const res = await getAppUsers({

View File

@ -0,0 +1,45 @@
/* ProTable global enhancements */
.ant-pro-table-list-toolbar-title {
font-weight: 600;
font-size: 16px;
color: #1f2937;
}
/* Table header */
.ant-pro-table .ant-table-thead > tr > th,
.ant-pro-table .ant-table-thead > tr > td {
font-weight: 600;
color: #374151;
font-size: 13px;
}
/* Row hover */
.ant-pro-table .ant-table-tbody > tr:hover > td {
background: #f9fafb !important;
}
/* Link buttons in tables */
.ant-pro-table .ant-btn-link {
padding: 2px 6px;
height: auto;
font-size: 13px;
}
/* Search form */
.ant-pro-table-search {
border-radius: 8px;
margin-bottom: 12px;
}
/* Pagination */
.ant-pro-table .ant-pagination {
margin-top: 8px;
}
/* Tag styles in tables */
.ant-pro-table .ant-tag {
border-radius: 6px;
font-size: 12px;
padding: 1px 8px;
}

72
src/theme/sidebar.css Normal file
View File

@ -0,0 +1,72 @@
/* Modern dark sidebar with gradient */
.rtc-sidebar {
background: linear-gradient(180deg, #1e1b4b 0%, #312e81 100%) !important;
box-shadow: 2px 0 12px rgba(0, 0, 0, 0.15);
}
.rtc-sidebar::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(
ellipse at top left,
rgba(99, 102, 241, 0.12) 0%,
transparent 60%
);
pointer-events: none;
}
/* Logo area */
.rtc-sidebar-logo {
height: 64px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
color: #e0e7ff;
font-weight: 700;
font-size: 17px;
letter-spacing: 0.3px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
position: relative;
z-index: 1;
}
.rtc-sidebar-logo-icon {
width: 32px;
height: 32px;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 800;
color: #fff;
flex-shrink: 0;
}
/* Menu selected item with left accent */
.rtc-sidebar .ant-menu-item-selected {
position: relative;
}
.rtc-sidebar .ant-menu-item-selected::before {
content: '';
position: absolute;
left: 0;
top: 25%;
bottom: 25%;
width: 3px;
background: #818cf8;
border-radius: 0 2px 2px 0;
}
/* Menu items hover */
.rtc-sidebar .ant-menu-item {
margin-block: 2px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}

66
src/theme/tokens.ts Normal file
View File

@ -0,0 +1,66 @@
import type { ThemeConfig } from 'antd';
export const themeConfig: ThemeConfig = {
token: {
colorPrimary: '#6366f1',
colorSuccess: '#10b981',
colorWarning: '#f59e0b',
colorError: '#ef4444',
colorInfo: '#3b82f6',
colorBgLayout: '#f5f5f7',
borderRadius: 8,
borderRadiusLG: 12,
borderRadiusSM: 6,
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Inter", "Helvetica Neue", sans-serif',
},
components: {
Layout: {
headerBg: '#ffffff',
headerHeight: 64,
headerPadding: '0 24px',
siderBg: '#1e1b4b',
},
Card: {
borderRadiusLG: 12,
},
Table: {
headerBg: '#fafafb',
borderRadius: 8,
},
Button: {
borderRadius: 8,
controlHeight: 36,
},
Input: {
borderRadius: 8,
},
Select: {
borderRadius: 8,
},
Modal: {
borderRadiusLG: 12,
},
Menu: {
darkItemBg: 'transparent',
darkSubMenuItemBg: 'transparent',
darkItemSelectedBg: 'rgba(99, 102, 241, 0.2)',
darkItemHoverBg: 'rgba(255, 255, 255, 0.06)',
darkItemSelectedColor: '#c7d2fe',
itemBorderRadius: 8,
itemMarginInline: 8,
},
},
};
// 统计卡片颜色配置
export const statColors = {
primary: '#6366f1',
success: '#10b981',
warning: '#f59e0b',
error: '#ef4444',
info: '#3b82f6',
purple: '#8b5cf6',
cyan: '#06b6d4',
pink: '#ec4899',
};

View File

@ -8,7 +8,7 @@ export default defineConfig({
port: 5174, port: 5174,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:8001', target: 'http://192.168.124.24:8000/',
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
}, },