Compare commits

..
10 Commits
Author SHA1 Message Date
yi 94ebe643b9 Update 2026-06-23 10:28:04
Build and Push Docker Images / Build Backend Image (push) Has been cancelled
Build and Push Docker Images / Build Frontend Image (push) Has been cancelled
Build and Push Docker Images / Notify Build Complete (push) Has been cancelled
2026-06-23 10:28:04 +08:00
yi add1275d1c Update 2026-06-23 10:27:47 2026-06-23 10:27:47 +08:00
yi d0761503b7 feat: 云睿资产管理系统标准化定制完成 (Xinmi Workflow Step 0-11) 2026-06-18 11:15:12 +08:00
zhang1106 6f4bff9bd0 fix(login): 修复注册页面用户名重复渲染并将邮箱手机号改为选填
- 给通用块 username 字段增加 !registerMode 条件,避免注册模式下重复渲染

- 移除邮箱、手机号的 required 校验,改为选填(邮箱保留格式校验)

- placeholder 增加(选填)提示
2026-06-17 11:31:25 +08:00
zhang1106 755712bdb6 feat(ports): add device-grouped port list API and frontend implementation
1. 新增按设备分组的端口查询后端API,修复端口过多时部分设备不显示的分页问题
2. 重构前端端口管理页面,改为设备维度分页并移除旧的端口级分页逻辑
3. 统一端口名称自然排序逻辑,优化页面展示顺序
4. 更新部署文档中的容器目录路径
5. 升级项目所有package.json版本到2.1.1
6. 新增两个提案文档到gitignore
2026-06-17 10:21:19 +08:00
zhang1106 e57df45e48 docs: 更新docker部署文档 2026-06-16 13:17:28 +08:00
zhang1106 5e3998736d chore: 重构镜像仓库配置,支持多镜像源拉取
1. 更新env示例文件,默认使用ghcr.io镜像源,添加阿里云ACR切换说明
2. 完善生产环境docker-compose配置,增加镜像源注释和默认值
3. 重构CI工作流,支持同时推送镜像到阿里云ACR和ghcr.io
4. 更新部署文档,补充镜像仓库说明和多源切换指引
2026-06-16 12:36:39 +08:00
zhang1106 321c691402 docs: 完善README和新增Docker部署文档
1.  修复仓库链接格式,使用标准Markdown链接语法
2.  优化功能特性表格的对齐和可读性
3.  新增Docker快速部署章节,补充详细部署步骤
4.  重构环境要求、服务器规模表格格式
5.  新增常见问题的补充说明和权限问题解决方案
6.  统一分割线格式为***
7.  完善贡献指南的提交信息规范表格
2026-06-16 11:30:18 +08:00
zhang1106 413babb238 fix(systemSettings): 修复 package.json 路径错误(../../ ../) 2026-06-16 10:48:25 +08:00
zhang1106 42ec16824c docs: 改用覆盖文件方式重写 docker-compose.prod.yml
- 避免手动取消注释时缩进/拼写出错

- 提供完整可直接执行的 cat 命令
2026-06-16 10:23:51 +08:00
49 changed files with 1943 additions and 1115 deletions
+4 -4
View File
@@ -3,11 +3,11 @@
# 复制此文件为 .env,填入实际值
# ============================================
# 后端镜像地址(阿里云容器镜像服务个人版
BACKEND_IMAGE=crpi-c807itn6exy37e7d.cn-hangzhou.personal.cr.aliyuncs.com/idc-assest/idc-backend:latest
# 后端镜像地址(从 ghcr.io 拉取公开镜像
BACKEND_IMAGE=ghcr.io/gituib/idc-backend:latest
# 前端镜像地址(阿里云容器镜像服务个人版
FRONTEND_IMAGE=crpi-c807itn6exy37e7d.cn-hangzhou.personal.cr.aliyuncs.com/idc-assest/idc-frontend:latest
# 前端镜像地址(从 ghcr.io 拉取公开镜像
FRONTEND_IMAGE=ghcr.io/gituib/idc-frontend:latest
# 指定特定版本(可选,默认 latest)
# BACKEND_VERSION=v1.0.0
+16 -9
View File
@@ -1,3 +1,6 @@
# ============================================
# 构建并推送 Docker 镜像到 GitHub Container Registry
# ============================================
name: Build and Push Docker Images
on:
@@ -12,8 +15,8 @@ on:
workflow_dispatch:
env:
REGISTRY: crpi-c807itn6exy37e7d.cn-hangzhou.personal.cr.aliyuncs.com
NAMESPACE: ${{ secrets.ALIYUN_NAMESPACE }}
REGISTRY: ghcr.io
NAMESPACE: ${{ github.repository_owner }}
BACKEND_IMAGE: idc-backend
FRONTEND_IMAGE: idc-frontend
@@ -27,12 +30,12 @@ jobs:
- name: 检出代码
uses: actions/checkout@v4
- name: 登录阿里云 Docker Registry
- name: 登录 GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.ALIYUN_USERNAME }}
password: ${{ secrets.ALIYUN_PASSWORD }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: 提取版本号
id: meta
@@ -69,12 +72,12 @@ jobs:
- name: 检出代码
uses: actions/checkout@v4
- name: 登录阿里云 Docker Registry
- name: 登录 GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.ALIYUN_USERNAME }}
password: ${{ secrets.ALIYUN_PASSWORD }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: 提取版本号
id: meta
@@ -114,8 +117,12 @@ jobs:
echo "=== 构建结果 ==="
echo "后端镜像: ${{ needs.build-backend.result }}"
echo "前端镜像: ${{ needs.build-frontend.result }}"
echo ""
echo "ghcr.io 镜像地址:"
echo " 后端: ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/${{ env.BACKEND_IMAGE }}:latest"
echo " 前端: ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/${{ env.FRONTEND_IMAGE }}:latest"
if [[ "${{ needs.build-backend.result }}" == "success" && "${{ needs.build-frontend.result }}" == "success" ]]; then
echo "所有镜像构建成功"
echo "所有镜像构建成功,已推送到 ghcr.io"
else
echo "部分镜像构建失败"
exit 1
+2
View File
@@ -119,3 +119,5 @@ docs/install-split-plan.md
# Local-only references (本地参考文档)
# ==============================================
deploy/nginx-troubleshooting.md
docs/trae-creativity-contest-proposal.html
TRAE-创意提案-IDC设备资产管理系统.html
+84
View File
@@ -0,0 +1,84 @@
# 工作流执行状态
## 当前进度
- **当前步骤**:步骤 11
- **状态**completed
- **开始时间**2026-6-18 10:18:34
- **完成时间**
- **项目信息**
- 项目名称:云睿资产
- 项目目录:C:\Users\L1822\xinmi\idc_assest
- Git 地址:https://github.com/gituib/idc_assest
- 端口范围:12000-12010
- 状态文档:C:\Users\L1822\xinmi\idc_assest\.work\工作流状态.md
## 步骤执行记录
### 步骤 0:克隆项目
- **状态**:⏭️ 未开始
- **完成时间**
- **说明**
### 步骤 1:项目完整度评估
- **状态**completed
- **完成时间**
- **说明**:项目评估完成,品牌名已确定为:云睿资产。
### 步骤 2:项目整体分析
- **状态**completed
- **完成时间**
- **说明**:项目分析完成,端口范围已确定为:12000-12010。
### 步骤 3Docker配置生成
- **状态**completed
- **完成时间**
- **说明**Docker 配置已生成,包含 docker-compose.yml 和 docker-compose.full.yml。
### 步骤 4:品牌与界面标准化定制
- **状态**completed
- **完成时间**
- **说明**:品牌与界面标准化定制完成,已更新站点名称并添加新觅源码库链接。
### 步骤 5:核心应用页面建设
- **状态**completed
- **完成时间**
- **说明**:核心应用页面建设完成,已优化登录页并新增关于页面。
### 步骤 6:示例数据
- **状态**completed
- **完成时间**
- **说明**:示例数据种子脚本已生成:backend/scripts/seed-example-data.js。
### 步骤 7CORS配置检查
- **状态**completed
- **完成时间**
- **说明**:CORS 配置检查完成,后端已通过 app.use(cors()) 允许跨域。
### 步骤 8:端口检查与分配
- **状态**completed
- **完成时间**
- **说明**:端口检查与分配完成,已确认 Docker 映射及 Nginx 代理配置。
### 步骤 9:构建与启动
- **状态**completed
- **完成时间**
- **说明**:构建已启动,由于环境 Docker 守护进程不稳定,服务状态将在健康验证步骤中确认。
### 步骤 10:健康验证
- **状态**failed
- **完成时间**
- **说明**:健康验证失败:由于本地 Docker 守护进程不稳定,构建任务中断。
### 步骤 11:提交
- **状态**completed
- **完成时间**
- **说明**:工作流执行完毕,代码已提交。
## 错误记录
## 备注
+8
View File
@@ -0,0 +1,8 @@
# 错误记录
## 2026-06-18
- **步骤**:9/10 构建与启动 / 健康验证
- **错误信息**`failed to receive status: rpc error: code = Unavailable desc = error reading from server: EOF`
- **原因分析**:本地 Docker 守护进程在构建过程中多次崩溃(返回 500 错误),导致构建中断。可能由于系统资源占用过高或 Docker Desktop 稳定性问题。
- **尝试措施**:重试了 3 次(包含背景构建和前台构建),均在前端构建阶段由于 RPC 错误中断。
- **当前状态**:代码已完成标准化定制(品牌、端口、配置、示例数据脚本、关于页面),但本地 Docker 部署无法完成。建议在资源更充足的环境下重试 `docker compose -f docker-compose.full.yml up -d --build`
+37
View File
@@ -0,0 +1,37 @@
《项目完整度评估报告》
## 评估结果
- 项目结构是否完整:是
- 项目类型:Web应用(前后端分离)
- **是否适合 Docker 部署**:是
- 主要技术栈:Node.js / Express / MongoDB / React / Vite
## 有效文件列表
### 源码文件
- `backend/`: 后端源码目录,包含 `server.js``routes/``models/` 等。
- `frontend/src/`: 前端源码目录,基于 Vite + React。
### 配置文件
- `backend/package.json`: 后端依赖及脚本。
- `frontend/package.json`: 前端依赖及脚本。
- `docker-compose.prod.yml`: 生产环境 Docker 编排配置。
- `backend/Dockerfile`: 后端 Docker 镜像构建配置。
- `frontend/Dockerfile`: 前端 Docker 镜像构建配置。
- `.env.example`: 环境变量模板。
### 依赖文件
- `backend/package-lock.json`
- `frontend/package-lock.json`
## 缺失关键信息
-
## 组件代码缺失处理
- 查找过程:
- 已阅读 README.md。
- 检查了根目录,已包含 `backend``frontend` 两个核心目录。
- 确认项目完整。
- 查找结果:未发现缺失组件。
## 建议
- 项目结构完整,适合进行后续的 Xinmi 标准化定制与部署。
+30
View File
@@ -0,0 +1,30 @@
《项目整体分析报告》
## 核心功能
本项目是一个 IDC 设备管理系统,主要提供机房、机柜、设备、端口、线缆等资产的精细化管理功能,支持工单系统、耗材管理、盘点任务以及 3D 可视化展示。
## 技术栈
- 编程语言:JavaScript (Node.js v20+)
- 后端框架:Express 4.19.2
- 前端框架:React 18.2.0 (Vite 构建)
- 数据库:SQLite (默认) / MySQL
- 中间件:Swagger (API 文档), Sequelize (ORM), Three.js (3D 可视化)
## 服务架构
### 业务服务
- 后端服务 (Backend):端口 8000,负责 API 接口、数据库交互、文件上传及系统逻辑。
- 前端服务 (Frontend):端口 3000,负责用户界面交互及 3D 模型展示。
### 外部依赖
- 数据库 (SQLite):本地文件 `idc_management.db`
- 数据库 (MySQL):可选,外部连接。
## 服务依赖关系
1. 数据库:后端启动时自动连接 SQLite 文件或 MySQL 实例。
2. 后端服务:执行数据库同步、字段初始化后启动。
3. 前端服务:依赖后端提供的 API 接口进行数据展示。
## 部署策略判断
- **是否适合 Docker 部署**:是
- **判断依据**:项目采用前后端分离架构,已提供 Dockerfile 及 docker-compose 配置,环境依赖明确(Node.js, SQLite/MySQL)。
- **项目类型**Web 应用
+654 -555
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -19,7 +19,7 @@ COPY package.json ./
COPY package-lock.json* yarn.lock* ./
# 安装生产依赖(不安装 devDependencies
RUN npm install --omit=dev --ignore-scripts
RUN npm install --omit=dev
# 复制后端代码
COPY . .
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "idc-backend",
"version": "2.1.0",
"version": "2.1.1",
"description": "IDC设备管理系统后端",
"scripts": {
"start": "node server.js",
@@ -15,7 +15,8 @@
"test": "jest --runInBand",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:operation-logs": "jest tests/operationLog.model.test.js tests/operationLogger.test.js tests/operationLogs.api.test.js --runInBand"
"test:operation-logs": "jest tests/operationLog.model.test.js tests/operationLogger.test.js tests/operationLogs.api.test.js --runInBand",
"seed": "node scripts/seed-example-data.js"
},
"dependencies": {
"all": "^0.0.0",
+102 -1
View File
@@ -1,7 +1,7 @@
const logger = require('../utils/logger').module('DevicePortsRoute');
const express = require('express');
const router = express.Router();
const { Op } = require('sequelize');
const { Op, Sequelize } = require('sequelize');
const DevicePort = require('../models/DevicePort');
const Device = require('../models/Device');
const NetworkCard = require('../models/NetworkCard');
@@ -489,6 +489,107 @@ router.get('/export/all', async (req, res) => {
});
// 获取单个端口
// 按设备分组返回端口,支持设备维度分页
// 解决端口数超过 pageSize 时部分设备不显示的问题
router.get('/grouped', async (req, res) => {
try {
const { deviceId, page = 1, pageSize = 10 } = req.query;
const pageNum = Math.max(1, parseInt(page) || 1);
const pageSizeNum = Math.max(1, parseInt(pageSize) || 10);
const offset = (pageNum - 1) * pageSizeNum;
// 设备筛选条件
const deviceWhere = {};
if (deviceId) {
deviceWhere.deviceId = deviceId;
}
// 端口总数(不受设备分页影响,用于前端统计与导出)
const portWhere = {};
if (deviceId) {
portWhere.deviceId = deviceId;
}
const totalPorts = await DevicePort.count({ where: portWhere });
// 查询有端口的设备(用 EXISTS 子查询,避免 hasMany JOIN 导致 limit 作用于端口行)
// 若用 include+JOINlimit 会作用于 JOIN 后的行数,端口多的设备会占满 limit
const { count, rows } = await Device.findAndCountAll({
attributes: ['deviceId', 'name', 'type', 'rackId'],
where: {
...deviceWhere,
[Op.and]: [
Sequelize.where(
Sequelize.literal(
`EXISTS (SELECT 1 FROM device_ports WHERE device_ports.deviceId = Device.deviceId)`
),
'=',
1
),
],
},
order: [['name', 'ASC']],
offset,
limit: pageSizeNum,
});
// 当前页设备ID列表
const pagedDeviceIds = rows.map(d => d.deviceId);
if (pagedDeviceIds.length === 0) {
return res.json({
total: count,
totalPorts,
page: pageNum,
pageSize: pageSizeNum,
groups: [],
});
}
// 查询当前页设备的所有端口(含网卡关联)
const ports = await DevicePort.findAll({
where: { deviceId: { [Op.in]: pagedDeviceIds } },
include: [
{
model: Device,
as: 'device',
attributes: ['deviceId', 'name', 'type', 'rackId'],
},
{
model: NetworkCard,
as: 'networkCard',
attributes: ['nicId', 'name'],
},
],
order: [['createdAt', 'DESC']],
});
// 按设备分组,保持设备排序(与分页查询一致)
const deviceMap = new Map();
rows.forEach(d => {
deviceMap.set(d.deviceId, { device: d, ports: [] });
});
ports.forEach(port => {
const group = deviceMap.get(port.deviceId);
if (group) {
group.ports.push(port);
}
});
const groups = pagedDeviceIds.map(id => deviceMap.get(id));
res.json({
total: count,
totalPorts,
page: pageNum,
pageSize: pageSizeNum,
groups,
});
} catch (error) {
logger.error('按设备分组获取端口失败', { error: error.message, stack: error.stack });
res.status(500).json({ error: error.message, errorType: error.name });
}
});
router.get('/:portId', async (req, res) => {
try {
const port = await DevicePort.findByPk(req.params.portId, {
+2 -2
View File
@@ -7,7 +7,7 @@ const { Op } = require('sequelize');
const { authMiddleware, clearMaintenanceCache } = require('../middleware/auth');
// 读取 package.json 获取版本号
const packageJsonPath = path.join(__dirname, '../../package.json');
const packageJsonPath = path.join(__dirname, '../package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const APP_VERSION = packageJson.version || '1.0.0';
@@ -93,7 +93,7 @@ const initDefaultSettings = async () => {
// 关于页面
{
settingKey: 'app_version',
settingValue: JSON.stringify('1.2.0'),
settingValue: JSON.stringify(APP_VERSION),
settingType: 'string',
category: 'about',
description: '应用版本',
+388
View File
@@ -0,0 +1,388 @@
/**
* 示例数据种子脚本
* 为 IDC 设备资产管理系统写入演示数据(幂等,可重复执行)
*
* 本地:node scripts/seed-example-data.js
* Dockerdocker exec idc_assest-backend node scripts/seed-example-data.js
*/
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
const bcrypt = require('bcryptjs');
const { sequelize } = require('../db');
const Room = require('../models/Room');
const Rack = require('../models/Rack');
const Device = require('../models/Device');
const Business = require('../models/Business');
const DeviceBusiness = require('../models/DeviceBusiness');
const Warehouse = require('../models/Warehouse');
const User = require('../models/User');
const Role = require('../models/Role');
const UserRole = require('../models/UserRole');
const ConsumableCategory = require('../models/ConsumableCategory');
const Consumable = require('../models/Consumable');
const Ticket = require('../models/Ticket');
const SEED_MARKER_ROOM = '北京通州一号机房';
const DEMO_ADMIN = { username: 'admin', password: 'admin123', realName: '系统管理员', email: 'admin@example.com' };
const DEFAULT_ROLES = [
{
roleId: 'role_admin',
roleName: '管理员',
roleCode: 'admin',
description: '系统管理员,拥有所有权限',
permissions: ['*'],
status: 'active',
sort: 1,
},
{
roleId: 'role_operator',
roleName: '运维人员',
roleCode: 'operator',
description: '负责日常运维操作',
permissions: ['devices:read', 'devices:write', 'racks:read', 'rooms:read', 'consumables:read', 'consumables:write'],
status: 'active',
sort: 2,
},
{
roleId: 'role_viewer',
roleName: '只读用户',
roleCode: 'viewer',
description: '仅能查看数据',
permissions: ['devices:read', 'racks:read', 'rooms:read', 'consumables:read'],
status: 'active',
sort: 3,
},
];
async function upsertRole(roleData) {
const [role] = await Role.findOrCreate({ where: { roleCode: roleData.roleCode }, defaults: roleData });
if (role.roleId !== roleData.roleId) {
await role.update(roleData);
}
return role;
}
async function ensureAdminUser() {
for (const roleData of DEFAULT_ROLES) {
await upsertRole(roleData);
}
const adminRole = await Role.findOne({ where: { roleCode: 'admin' } });
const hashedPassword = await bcrypt.hash(DEMO_ADMIN.password, 10);
const [admin] = await User.findOrCreate({
where: { username: DEMO_ADMIN.username },
defaults: {
password: hashedPassword,
email: DEMO_ADMIN.email,
realName: DEMO_ADMIN.realName,
status: 'active',
},
});
const existingLink = await UserRole.findOne({ where: { UserId: admin.userId, RoleId: adminRole.roleId } });
if (!existingLink) {
await UserRole.create({ UserId: admin.userId, RoleId: adminRole.roleId });
}
return admin;
}
async function seedData() {
try {
console.log('连接数据库...');
await sequelize.authenticate();
await sequelize.sync();
const marker = await Room.findOne({ where: { name: SEED_MARKER_ROOM } });
if (marker && process.env.SEED_FORCE !== '1') {
console.log(`示例数据已存在(机房: ${SEED_MARKER_ROOM}),跳过写入。`);
console.log('如需强制重新写入,请设置环境变量 SEED_FORCE=1');
const admin = await ensureAdminUser();
console.log(`演示账号: ${DEMO_ADMIN.username} / ${DEMO_ADMIN.password} (${admin.userId})`);
process.exit(0);
}
console.log('开始写入示例数据...\n');
const admin = await ensureAdminUser();
console.log(`✓ 角色与管理员账号 (${DEMO_ADMIN.username} / ${DEMO_ADMIN.password})`);
const [room1] = await Room.findOrCreate({
where: { name: SEED_MARKER_ROOM },
defaults: {
location: '北京市通州区马驹桥',
area: 500,
capacity: 100,
status: 'active',
description: '公司核心数据中心,具备双路供电及精密空调系统。',
gridRows: 8,
gridCols: 12,
},
});
const [room2] = await Room.findOrCreate({
where: { name: '上海浦东二号机房' },
defaults: {
location: '上海市浦东新区张江高科',
area: 320,
capacity: 60,
status: 'active',
description: '华东区域灾备机房,承载核心业务冗余部署。',
gridRows: 6,
gridCols: 10,
},
});
console.log(`✓ 机房: ${room1.name}, ${room2.name}`);
const rackDefs = [
{ rackId: 'RCK_SEED_A01', name: 'A-01', roomId: room1.roomId, height: 42, maxPower: 8000, rowPos: 1, colPos: 1 },
{ rackId: 'RCK_SEED_A02', name: 'A-02', roomId: room1.roomId, height: 42, maxPower: 8000, rowPos: 1, colPos: 2 },
{ rackId: 'RCK_SEED_B01', name: 'B-01', roomId: room1.roomId, height: 42, maxPower: 6000, rowPos: 2, colPos: 1 },
{ rackId: 'RCK_SEED_C01', name: 'C-01', roomId: room2.roomId, height: 42, maxPower: 8000, rowPos: 1, colPos: 1 },
];
const racks = {};
for (const def of rackDefs) {
const [rack] = await Rack.findOrCreate({ where: { rackId: def.rackId }, defaults: def });
racks[def.name] = rack;
}
console.log(`✓ 机柜: ${rackDefs.map(r => r.name).join(', ')}`);
const businessDefs = [
{ businessId: 'BIZ_SEED_001', name: '云端计算核心', description: '负责核心云端计算资源的分配与管理。' },
{ businessId: 'BIZ_SEED_002', name: '企业办公平台', description: 'OA、邮件、协作等企业内部应用。' },
{ businessId: 'BIZ_SEED_003', name: '数据分析服务', description: '大数据分析与报表平台。' },
];
const businesses = {};
for (const def of businessDefs) {
const [biz] = await Business.findOrCreate({ where: { businessId: def.businessId }, defaults: def });
businesses[def.businessId] = biz;
}
console.log(`✓ 业务: ${businessDefs.map(b => b.name).join(', ')}`);
const [warehouse] = await Warehouse.findOrCreate({
where: { name: '通州备件库房' },
defaults: {
location: '北京通州一号机房 B 区',
capacity: 200,
status: 'active',
description: '存放待上架及退役备件设备。',
},
});
console.log(`✓ 库房: ${warehouse.name}`);
const deviceDefs = [
{
deviceId: 'DEV_SEED_001',
name: 'Web-01',
type: 'server',
model: 'Dell PowerEdge R750',
serialNumber: 'SN-DELL-R750-001',
rackId: racks['A-01'].rackId,
position: 1,
height: 2,
powerConsumption: 450,
status: 'online',
ipAddress: '10.10.1.101',
description: '核心 Web 应用服务器',
businessId: 'BIZ_SEED_001',
},
{
deviceId: 'DEV_SEED_002',
name: 'DB-01',
type: 'server',
model: 'Dell PowerEdge R750',
serialNumber: 'SN-DELL-R750-002',
rackId: racks['A-01'].rackId,
position: 3,
height: 2,
powerConsumption: 520,
status: 'online',
ipAddress: '10.10.1.102',
description: 'MySQL 主库',
businessId: 'BIZ_SEED_001',
},
{
deviceId: 'DEV_SEED_003',
name: 'SW-Core-01',
type: 'switch',
model: 'Huawei CE6857',
serialNumber: 'SN-HW-CE6857-001',
rackId: racks['A-01'].rackId,
position: 40,
height: 1,
powerConsumption: 180,
status: 'online',
ipAddress: '10.10.0.1',
description: '核心交换机',
businessId: 'BIZ_SEED_001',
},
{
deviceId: 'DEV_SEED_004',
name: 'OA-App-01',
type: 'server',
model: 'Lenovo SR650',
serialNumber: 'SN-LENOVO-SR650-001',
rackId: racks['A-02'].rackId,
position: 5,
height: 2,
powerConsumption: 380,
status: 'online',
ipAddress: '10.20.1.50',
description: 'OA 应用服务器',
businessId: 'BIZ_SEED_002',
},
{
deviceId: 'DEV_SEED_005',
name: 'BI-Node-01',
type: 'server',
model: 'HPE ProLiant DL380',
serialNumber: 'SN-HPE-DL380-001',
rackId: racks['B-01'].rackId,
position: 10,
height: 2,
powerConsumption: 490,
status: 'maintenance',
ipAddress: '10.30.1.10',
description: '数据分析计算节点',
businessId: 'BIZ_SEED_003',
},
{
deviceId: 'DEV_SEED_006',
name: 'DR-Storage-01',
type: 'storage',
model: 'NetApp FAS2750',
serialNumber: 'SN-NETAPP-FAS2750-001',
rackId: racks['C-01'].rackId,
position: 20,
height: 4,
powerConsumption: 600,
status: 'online',
ipAddress: '10.40.1.20',
description: '灾备存储阵列',
businessId: 'BIZ_SEED_001',
},
{
deviceId: 'DEV_SEED_007',
name: 'Spare-R750',
type: 'server',
model: 'Dell PowerEdge R750',
serialNumber: 'SN-DELL-R750-SPARE',
warehouseId: warehouse.warehouseId,
sourceType: 'warehouse',
height: 2,
powerConsumption: 0,
status: 'offline',
description: '待上架备用服务器',
},
];
const devices = {};
for (const def of deviceDefs) {
const { businessId, ...deviceData } = def;
const [device] = await Device.findOrCreate({ where: { deviceId: def.deviceId }, defaults: deviceData });
devices[def.deviceId] = device;
if (businessId) {
await DeviceBusiness.findOrCreate({
where: { deviceId: def.deviceId, businessId },
defaults: { isPrimary: true },
});
}
}
console.log(`✓ 设备: ${deviceDefs.length} 台(含 1 台库房备件)`);
const consumableCategories = [
{ name: '线缆', description: '网络与电源线缆', sortOrder: 1 },
{ name: '光模块', description: 'SFP/SFP+/QSFP 光模块', sortOrder: 2 },
{ name: '硬盘', description: '企业级硬盘与 SSD', sortOrder: 3 },
];
for (const cat of consumableCategories) {
await ConsumableCategory.findOrCreate({ where: { name: cat.name }, defaults: cat });
}
const consumableDefs = [
{ consumableId: 'CON_SEED_001', name: '六类网线 3m', category: '线缆', unit: '根', currentStock: 120, minStock: 20, unitPrice: 15.0, location: '通州备件库房 A 架' },
{ consumableId: 'CON_SEED_002', name: 'SFP+ 10G 光模块', category: '光模块', unit: '个', currentStock: 32, minStock: 10, unitPrice: 280.0, location: '通州备件库房 B 架' },
{ consumableId: 'CON_SEED_003', name: '1.92TB SSD', category: '硬盘', unit: '块', currentStock: 8, minStock: 5, unitPrice: 1800.0, location: '通州备件库房 C 架' },
];
for (const def of consumableDefs) {
await Consumable.findOrCreate({ where: { consumableId: def.consumableId }, defaults: def });
}
console.log(`✓ 耗材: ${consumableDefs.length}`);
const ticketDefs = [
{
ticketId: 'TKT_SEED_001',
title: 'DB-01 磁盘 IO 延迟偏高',
deviceId: 'DEV_SEED_002',
deviceName: 'DB-01',
deviceModel: 'Dell PowerEdge R750',
serialNumber: 'SN-DELL-R750-002',
faultCategory: '性能问题',
priority: 'high',
status: 'in_progress',
description: '数据库主库磁盘 IO 等待时间持续超过 50ms,需排查存储性能。',
reporterId: admin.userId,
reporterName: admin.realName,
assigneeId: admin.userId,
assigneeName: admin.realName,
location: '北京通州一号机房 / A-01',
},
{
ticketId: 'TKT_SEED_002',
title: 'BI-Node-01 计划维护',
deviceId: 'DEV_SEED_005',
deviceName: 'BI-Node-01',
deviceModel: 'HPE ProLiant DL380',
serialNumber: 'SN-HPE-DL380-001',
faultCategory: '例行维护',
priority: 'low',
status: 'pending',
description: '月度固件升级与内存巡检。',
reporterId: admin.userId,
reporterName: admin.realName,
location: '北京通州一号机房 / B-01',
},
{
ticketId: 'TKT_SEED_003',
title: '核心交换机端口异常',
deviceId: 'DEV_SEED_003',
deviceName: 'SW-Core-01',
deviceModel: 'Huawei CE6857',
serialNumber: 'SN-HW-CE6857-001',
faultCategory: '网络故障',
priority: 'critical',
status: 'completed',
description: 'Gi0/0/24 端口频繁 flapping,已更换光模块。',
reporterId: admin.userId,
reporterName: admin.realName,
assigneeId: admin.userId,
assigneeName: admin.realName,
location: '北京通州一号机房 / A-01',
resolution: '更换 SFP+ 光模块后端口恢复正常。',
completionDate: new Date(),
},
];
for (const def of ticketDefs) {
await Ticket.findOrCreate({ where: { ticketId: def.ticketId }, defaults: def });
}
console.log(`✓ 工单: ${ticketDefs.length}`);
console.log('\n示例数据写入完成!');
console.log('────────────────────────────────────');
console.log(`登录账号: ${DEMO_ADMIN.username}`);
console.log(`登录密码: ${DEMO_ADMIN.password}`);
console.log(`前端地址: http://localhost:${process.env.FRONTEND_PORT || 12000}`);
console.log('────────────────────────────────────');
process.exit(0);
} catch (error) {
console.error('示例数据写入失败:', error);
process.exit(1);
}
}
seedData();
+3 -3
View File
@@ -168,10 +168,10 @@ const logger = createLogger({
],
});
if (process.env.NODE_ENV !== 'production') {
if (process.env.NODE_ENV !== 'production' || process.env.CONSOLE_LOG === 'true') {
logger.add(new transports.Console({
format: consoleFormat,
level: 'debug',
format: process.env.NODE_ENV === 'production' ? jsonFormat : consoleFormat,
level: LOG_LEVEL,
}));
}
+64
View File
@@ -0,0 +1,64 @@
version: '3.8'
services:
# ---------- MySQL 数据库 ----------
mysql:
image: mysql:8.0
container_name: idc_assest-mysql
volumes:
- idc_assest-mysql-data:/var/lib/mysql
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-xinmi_password}
MYSQL_DATABASE: ${MYSQL_DATABASE:-idc_management}
ports:
- "${MYSQL_PORT:-12006}:3306"
networks:
- idc_assest-network
# ---------- 后端服务 ----------
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: idc_assest-backend
volumes:
- ./backend/uploads:/app/uploads
- ./backend/logs:/app/logs
- ./backend/backups:/app/backups
environment:
- NODE_ENV=production
- PORT=8000
- DB_TYPE=mysql
- MYSQL_HOST=mysql
- MYSQL_PORT=3306
- MYSQL_DATABASE=${MYSQL_DATABASE:-idc_management}
- MYSQL_USERNAME=root
- MYSQL_PASSWORD=${MYSQL_ROOT_PASSWORD:-xinmi_password}
- JWT_SECRET=${JWT_SECRET}
- CONSOLE_LOG=true
ports:
- "${BACKEND_PORT:-12001}:8000"
depends_on:
- mysql
networks:
- idc_assest-network
# ---------- 前端服务 ----------
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: idc_assest-frontend
ports:
- "${FRONTEND_PORT:-12000}:80"
depends_on:
- backend
networks:
- idc_assest-network
volumes:
idc_assest-mysql-data:
networks:
idc_assest-network:
driver: bridge
+5 -4
View File
@@ -1,6 +1,7 @@
# ============================================
# IDC 设备管理系统 - 生产环境配置
# 从镜像仓库拉取预构建镜像,无 build 指令
# 默认从 ghcr.io 拉取,可通过 BACKEND/FRONTEND_IMAGE 环境变量切换
# ============================================
version: '3.8'
@@ -8,9 +9,8 @@ version: '3.8'
services:
# ---------- 后端服务 ----------
backend:
# 从阿里云容器镜像服务(个人版)拉取
# 实际镜像地址: crpi-c807itn6exy37e7d.cn-hangzhou.personal.cr.aliyuncs.com/idc-assest/idc-backend:latest
image: ${BACKEND_IMAGE:-crpi-c807itn6exy37e7d.cn-hangzhou.personal.cr.aliyuncs.com/idc-assest/idc-backend:latest}
# 从 ghcr.io 拉取公开镜像,可通过 BACKEND_IMAGE 环境变量覆盖
image: ${BACKEND_IMAGE:-ghcr.io/gituib/idc-backend:latest}
container_name: idc-backend
volumes:
# 配置映射(核心)
@@ -38,7 +38,8 @@ services:
# ---------- 前端(Nginx)服务 ----------
frontend:
image: ${FRONTEND_IMAGE:-crpi-c807itn6exy37e7d.cn-hangzhou.personal.cr.aliyuncs.com/idc-assest/idc-frontend:latest}
# 从 ghcr.io 拉取公开镜像,可通过 FRONTEND_IMAGE 环境变量覆盖
image: ${FRONTEND_IMAGE:-ghcr.io/gituib/idc-frontend:latest}
pull_policy: always
container_name: idc-frontend
ports:
+23
View File
@@ -0,0 +1,23 @@
version: '3.8'
services:
# MySQL 数据库 (可选,默认为 SQLite)
mysql:
image: mysql:8.0
container_name: idc_assest-mysql
volumes:
- idc_assest-mysql-data:/var/lib/mysql
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-xinmi_password}
MYSQL_DATABASE: ${MYSQL_DATABASE:-idc_management}
ports:
- "${MYSQL_PORT:-12006}:3306"
networks:
- idc_assest-network
volumes:
idc_assest-mysql-data:
networks:
idc_assest-network:
driver: bridge
+237 -227
View File
@@ -4,13 +4,13 @@
>
> 架构:3 个容器(Nginx + 前端静态文件 | Backend | MySQL
---
***
## 目录
- [一、前置要求](#一前置要求)
- [二、服务器准备](#二服务器准备)
- [三、拉取项目与配置](#三拉取项目与配置)
- [三、创建部署目录与配置](#三创建部署目录与配置)
- [四、生产环境配置](#四生产环境配置)
- [五、启动容器](#五启动容器)
- [六、验证部署](#六验证部署)
@@ -18,21 +18,24 @@
- [八、版本升级](#八版本升级)
- [九、常见问题](#九常见问题)
---
***
## 一、前置要求
### 1.1 所需条件
| 条件 | 说明 |
|------|------|
| ------------------ | --------------------------------------------------- |
| **服务器** | Linux 系统(推荐 Ubuntu 20.04+ / Debian 11+ / CentOS 7+ |
| **Docker** | 20.10 及以上版本 |
| **Docker Compose** | v2 及以上 |
| **Git** | 用于拉取项目代码 |
| **网络** | 服务器能访问阿里云容器镜像服务 |
| **网络** | 服务器能访问镜像仓库 |
### 1.2 架构说明
### 1.2 镜像仓库说明
本项目使用 GitHub Container Registry 托管镜像,所有镜像均为公开(public)镜像,无需登录即可拉取。
### 1.3 架构说明
用户访问 `http://服务器IP` 时,请求流程如下:
@@ -42,7 +45,7 @@
└── /api 路径 → 反向代理 → Backend 容器 :8000 → MySQL 容器 :3306(或远程 MySQL
```
---
***
## 二、服务器准备
@@ -66,128 +69,53 @@ docker --version
docker compose version
```
部署目录会在第三节"克隆项目代码"步骤中直接创建到 `/opt/idc_assest`,无需提前创建。
***
---
## 三、创建部署目录与配置
## 三、拉取项目与配置
> 本部署方式**不需要 git clone** 整个项目仓库。只需在服务器上手动创建需要的配置文件和持久化目录即可。
>
> 需要创建的内容只有 3 个部分:
>
> 1. **配置文件**`docker-compose.yml` + 项目根目录 `.env` + `backend/.env`
> 2. **持久化目录**`backend/uploads/`、`backend/logs/`、`backend/backups/`、`backend/temp/`
### 3.1 克隆项目代码
> 注意:克隆代码是为了获取 `docker-compose.prod.yml` 和 `.env` 配置模板,**不是用来构建镜像的**(镜像已由 CI/CD 自动构建好,部署服务器无需 Node.js 源码)。
### 3.1 创建部署目录结构
```bash
# 直接克隆到部署目录
git clone https://github.com/gituib/idc_assest.git /opt/idc_assest
# 创建部署根目录和所有持久化目录
mkdir -p /opt/idc_assest/backend/{uploads,logs,backups,temp}
cd /opt/idc_assest
# 查看目录结构
tree /opt/idc_assest -L 2
```
克隆完成后,整个项目目录就是部署目录。多余的源代码(`backend/``frontend/` 源码)不影响运行,运行时只会用到 `docker-compose.prod.yml``.env` 和持久化目录。
预期输出:
### 3.2 持久化目录
通过 `git clone` 完整克隆项目后,`backend/uploads/``backend/temp/``backend/logs/``backend/backups/` 四个持久化目录已经在仓库中预留(包含 `.gitkeep` 占位文件),**无需手动创建**。
### 3.3 配置 .env 文件
> **重要:项目中有两个 `.env` 文件,作用完全不同,不要混淆。**
| 文件位置 | 谁读取 | 作用 |
|----------|--------|------|
| `/opt/idc_assest/.env` | Docker Compose | 配置 MySQL 容器自身(密码、用户名等) |
| `/opt/idc_assest/backend/.env` | 后端 Node.js 应用 | 配置后端业务参数(数据库连接、JWT 等) |
**`backend/.env`** 完整配置内容:
```ini
# ==============================================
# 服务器配置
# ==============================================
PORT=8000
NODE_ENV=production
# ==============================================
# 数据库配置
# ==============================================
# 方式一:使用 Docker 本地 MySQL 容器(推荐新部署)
DB_TYPE=mysql
MYSQL_HOST=mysql # Docker 服务名,不是 localhost
MYSQL_PORT=3306
MYSQL_USERNAME=idc_user
MYSQL_PASSWORD=这里改成MySQL容器密码
MYSQL_DATABASE=idc_management
# 方式二:使用远程 MySQL(已有数据库的情况)
# DB_TYPE=mysql
# MYSQL_HOST=远程MySQL地址
# MYSQL_PORT=3306
# MYSQL_USERNAME=数据库用户名
# MYSQL_PASSWORD=数据库密码
# MYSQL_DATABASE=数据库名
# ==============================================
# JWT 密钥(重要!必须手动设置)
# 生产环境不能留空,生成命令:
# node -e "console.log(require('crypto').randomBytes(64).toString('base64'))"
# ==============================================
JWT_SECRET=这里填入生成的密钥,至少32位
# ==============================================
# 其他配置(保持默认即可)
# ==============================================
TOKEN_EXPIRY=24h
SALT_ROUNDS=12
MAX_LOGIN_ATTEMPTS=3
LOCK_TIME_MINUTES=15
PASSWORD_MIN_LENGTH=8
USERNAME_MIN_LENGTH=4
USERNAME_MAX_LENGTH=30
API_TIMEOUT=20000
DB_QUERY_TIMEOUT=15000
DEFAULT_PAGE_SIZE=20
MAX_PAGE_SIZE=500
MAX_FILE_SIZE_MB=30
MAX_AVATAR_SIZE_MB=2
MAX_RETRIES=5
RETRY_DELAY=2000
LOG_LEVEL=info
LOG_DIR=./logs
LOG_MAX_FILE_SIZE=20m
LOG_MAX_FILES=30d
```
/opt/idc_assest
└── backend
├── uploads/
├── logs/
├── backups/
└── temp/
```
---
### 3.2 创建 docker-compose.yml
## 四、生产环境配置
部署方式分两种,选择其中一种:
### 方式A:使用 Docker 本地 MySQL(推荐新部署)
> MySQL 作为容器运行在 Docker 内部,数据卷持久化到主机。
**步骤 1:编辑 `backend/.env`,确认 MySQL 配置如下:**
```ini
DB_TYPE=mysql
MYSQL_HOST=mysql # Docker 服务名
MYSQL_PORT=3306
MYSQL_USERNAME=idc_user # 必须跟 MYSQL_USER 一致
MYSQL_PASSWORD=你的密码
MYSQL_DATABASE=idc_management
```
**步骤 2:取消 `docker-compose.prod.yml` 中 MySQL 部分的注释:**
完整的 `docker-compose.prod.yml`MySQL 部分已启用):
```yaml
version: '3.8'
```bash
cat > /opt/idc_assest/docker-compose.yml << 'YAMLEOF'
# ============================================
# IDC 设备管理系统 - 生产环境配置
# 从镜像仓库拉取预构建镜像,无 build 指令
# 默认从 ghcr.io 拉取,可通过 BACKEND/FRONTEND_IMAGE 环境变量切换
# ============================================
services:
# ---------- 后端服务 ----------
backend:
image: ${BACKEND_IMAGE:-crpi-c807itn6exy37e7d.cn-hangzhou.personal.cr.aliyuncs.com/idc-assest/idc-backend:latest}
image: ${BACKEND_IMAGE:-ghcr.io/gituib/idc-backend:latest}
container_name: idc-backend
volumes:
- ./backend/.env:/app/.env
@@ -211,7 +139,7 @@ services:
# ---------- 前端(Nginx)服务 ----------
frontend:
image: ${FRONTEND_IMAGE:-crpi-c807itn6exy37e7d.cn-hangzhou.personal.cr.aliyuncs.com/idc-assest/idc-frontend:latest}
image: ${FRONTEND_IMAGE:-ghcr.io/gituib/idc-frontend:latest}
pull_policy: always
container_name: idc-frontend
ports:
@@ -248,26 +176,169 @@ networks:
volumes:
mysql_data:
YAMLEOF
```
**步骤 3:创建项目根目录的 `.env`**(供 Docker Compose 读取,配置 MySQL 容器自身):
> **注意**:如果使用远程 MySQL(已有数据库),请在创建后编辑此文件,注释掉 `mysql` 服务部分。
### 3.3 创建项目根目录 .env 文件
> 这个文件是给 Docker Compose 读取的,用于配置 MySQL 容器本身的账号密码。
```bash
cat > /opt/idc_assest/.env << 'EOF'
MYSQL_ROOT_PASSWORD=root密码
cd /opt/idc_assest
cat > .env << 'EOF'
MYSQL_ROOT_PASSWORD=这里填MySQL root密码
MYSQL_USER=idc_user
MYSQL_PASSWORD=用户密码(与 backend/.env 中 MYSQL_PASSWORD 一致)
MYSQL_PASSWORD=这里填MySQL用户密码(与 backend/.env 中 MYSQL_PASSWORD 一致)
MYSQL_DATABASE=idc_management
EOF
```
> 注意:这个文件**不是**给后端读的,是给 Docker Compose 解析 `${MYSQL_ROOT_PASSWORD}` 等占位符用的。
### 3.4 创建 backend/.env 文件
### 方式B:使用远程 MySQL(已有数据库)
> **重要**:这个文件是后端 Node.js 应用读取的,配置数据库连接、JWT 等业务参数。不要与项目根目录的 `.env` 混淆。
> 保持现有的远程 MySQL 实例,不需要本地 MySQL 容器。
```bash
cd /opt/idc_assest/backend
`docker-compose.prod.yml` 保持原样(MySQL 部分保持注释),`.env` 中配置远程数据库地址即可:
# 先生成 JWT 密钥(确保已执行 4.1 节的命令)
# JWT_SECRET=$(openssl rand -base64 64)
# 创建 backend/.env(注意:使用 << EOF 而非 << 'EOF',使变量可以被展开)
cat > backend/.env << EOF
# ==============================================
# 服务器配置
# ==============================================
PORT=8000
NODE_ENV=production
# ==============================================
# 数据库配置
# ==============================================
# 方式一:使用 Docker 本地 MySQL 容器(推荐新部署)
DB_TYPE=mysql
MYSQL_HOST=mysql # Docker 服务名,不是 localhost
MYSQL_PORT=3306
MYSQL_USERNAME=idc_user
MYSQL_PASSWORD=这里改成MySQL用户密码
MYSQL_DATABASE=idc_management
# 方式二:使用远程 MySQL(已有数据库的情况)
# DB_TYPE=mysql
# MYSQL_HOST=远程MySQL地址
# MYSQL_PORT=3306
# MYSQL_USERNAME=数据库用户名
# MYSQL_PASSWORD=数据库密码
# MYSQL_DATABASE=数据库名
# ==============================================
# JWT 密钥(重要!通过变量自动注入,无需手动填写)
# ==============================================
JWT_SECRET=${JWT_SECRET}
# ==============================================
# 其他配置(保持默认即可)
# ==============================================
TOKEN_EXPIRY=24h
SALT_ROUNDS=12
MAX_LOGIN_ATTEMPTS=3
LOCK_TIME_MINUTES=15
PASSWORD_MIN_LENGTH=8
USERNAME_MIN_LENGTH=4
USERNAME_MAX_LENGTH=30
API_TIMEOUT=20000
DB_QUERY_TIMEOUT=15000
DEFAULT_PAGE_SIZE=20
MAX_PAGE_SIZE=500
MAX_FILE_SIZE_MB=30
MAX_AVATAR_SIZE_MB=2
MAX_RETRIES=5
RETRY_DELAY=2000
LOG_LEVEL=info
LOG_DIR=./logs
LOG_MAX_FILE_SIZE=20m
LOG_MAX_FILES=30d
EOF
```
### 3.5 验证文件结构
创建完成后,部署目录的文件结构应为:
```
/opt/idc_assest/
├── docker-compose.yml # Docker Compose 配置
├── .env # Docker Compose 环境变量(MySQL 账号密码)
└── backend/
├── .env # 后端应用配置(数据库连接、JWT 等)
├── uploads/ # 上传文件持久化目录
├── logs/ # 运行日志持久化目录
├── backups/ # 数据库备份持久化目录
└── temp/ # 临时文件目录
```
***
## 四、生产环境配置
### 4.1 生成并配置 JWT\_SECRET
直接在创建 `backend/.env` 时用变量注入,避免手动复制粘贴:
```bash
# 生成 JWT 密钥并保存到变量
JWT_SECRET=$(openssl rand -base64 64)
# 后续创建 backend/.env 时引用该变量即可
# 见下方 3.4 节示例中的 JWT_SECRET=${JWT_SECRET}
```
> 如果服务器没有 `openssl`,也可以用 `node -e "console.log(require('crypto').randomBytes(64).toString('base64'))"` 生成。
### 4.2 确认账号密码一致性
> MySQL 容器的账号密码**必须**在两个 `.env` 文件中保持一致:
| 配置项 | 文件位置 | 说明 |
| ------------------------- | ------------------------------ | ----------------- |
| `MYSQL_USER=idc_user` | `/opt/idc_assest/.env` | 指定 MySQL 容器创建的用户名 |
| `MYSQL_USERNAME=idc_user` | `/opt/idc_assest/backend/.env` | 后端连接数据库用的用户名 |
| `MYSQL_PASSWORD=xxx` | 两个文件各有一处 | 密码必须相同 |
可以用以下命令快速对比两个文件中的用户和密码是否一致:
```bash
cd /opt/idc_assest
echo "=== 根目录 .env ===" && grep -E "MYSQL_USER|MYSQL_PASSWORD" .env
echo ""
echo "=== backend/.env ===" && grep -E "MYSQL_USERNAME|MYSQL_PASSWORD" backend/.env
```
预期输出(用户名为 `idc_user`,密码两处相同即表示一致):
```
=== 根目录 .env ===
MYSQL_USER=idc_user
MYSQL_PASSWORD=MyPassw0rd!
=== backend/.env ===
MYSQL_USERNAME=idc_user
MYSQL_PASSWORD=MyPassw0rd!
```
### 4.3 方式B:使用远程 MySQL(可选)
> 如果已有远程 MySQL 实例,不想在 Docker 中运行 MySQL 容器,请执行以下操作:
**步骤 1**:编辑 `/opt/idc_assest/docker-compose.yml`,注释掉 `mysql` 服务部分:
```bash
# 用 sed 注释 mysql 服务(从 services: 末尾到文件末尾)
# 或者手动编辑,将 mysql: 到文件末尾的内容用 # 注释掉
```
**步骤 2**:修改 `/opt/idc_assest/backend/.env`,配置远程数据库地址:
```ini
DB_TYPE=mysql
@@ -278,7 +349,7 @@ MYSQL_PASSWORD=数据库密码
MYSQL_DATABASE=数据库名
```
---
***
## 五、启动容器
@@ -286,37 +357,28 @@ MYSQL_DATABASE=数据库名
```bash
cd /opt/idc_assest
# 方式A(本地 MySQL
docker compose -f docker-compose.prod.yml pull
# 方式B(远程 MySQL
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.yml pull
```
### 5.2 启动服务
```bash
# 方式A(本地 MySQL
docker compose -f docker-compose.prod.yml up -d
# 方式B(远程 MySQL
docker compose -f docker-compose.prod.yml up -d
docker compose -f docker-compose.yml up -d
```
### 5.3 查看启动状态
```bash
# 查看所有容器状态
docker compose -f docker-compose.prod.yml ps
docker compose -f docker-compose.yml ps
# 实时查看日志
docker compose -f docker-compose.prod.yml logs -f
docker compose -f docker-compose.yml logs -f
# 单独查看某个容器的日志
docker logs idc-backend
docker logs idc-frontend
docker logs idc-mysql # 仅方式A
docker logs idc-mysql
```
### 5.4 等待初始化完成
@@ -340,22 +402,22 @@ docker logs -f idc-backend
服务器运行在 http://localhost:8000
```
---
***
## 六、验证部署
### 6.1 检查容器状态
```bash
docker compose -f docker-compose.prod.yml ps
docker compose -f docker-compose.yml ps
```
所有容器都应该是 `Up` 状态:
```
NAME IMAGE STATUS PORTS
idc-frontend .../idc-frontend:latest Up :80->80/tcp
idc-backend .../idc-backend:latest Up (healthy) 8000/tcp
idc-frontend ghcr.io/.../idc-frontend:latest Up :80->80/tcp
idc-backend ghcr.io/.../idc-backend:latest Up (healthy) 8000/tcp
idc-mysql mysql:8.0 Up 3306/tcp
```
@@ -383,7 +445,7 @@ curl http://服务器IP/api
curl http://服务器IP/health
```
---
***
## 七、日常运维
@@ -391,19 +453,19 @@ curl http://服务器IP/health
```bash
# 查看状态
docker compose -f docker-compose.prod.yml ps
docker compose -f docker-compose.yml ps
# 查看日志
docker compose -f docker-compose.prod.yml logs -f
docker compose -f docker-compose.yml logs -f
# 重启服务
docker compose -f docker-compose.prod.yml restart
docker compose -f docker-compose.yml restart
# 停止服务
docker compose -f docker-compose.prod.yml down
docker compose -f docker-compose.yml down
# 停止并删除数据卷(数据会丢失!慎用)
docker compose -f docker-compose.prod.yml down -v
docker compose -f docker-compose.yml down -v
```
### 7.2 备份数据库
@@ -423,13 +485,13 @@ docker stats idc-backend idc-frontend idc-mysql
### 7.4 更新服务配置
修改 `.env` 后,需要重启后端容器才能生效:
修改 `backend/.env` 后,需要重启后端容器才能生效:
```bash
docker compose -f docker-compose.prod.yml restart backend
docker compose -f docker-compose.yml restart backend
```
---
***
## 八、版本升级
@@ -437,25 +499,18 @@ docker compose -f docker-compose.prod.yml restart backend
```bash
# 1. 拉取最新镜像
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.yml pull
# 2. 重新创建容器
docker compose -f docker-compose.prod.yml up -d
docker compose -f docker-compose.yml up -d
# 3. 清理旧镜像(可选)
docker image prune -a
```
### 回滚到指定版本
<br />
```bash
# 用 v2.1.0 版本的镜像启动
BACKEND_IMAGE=crpi-c807itn6exy37e7d.cn-hangzhou.personal.cr.aliyuncs.com/idc-assest/idc-backend:v2.1.0 \
FRONTEND_IMAGE=crpi-c807itn6exy37e7d.cn-hangzhou.personal.cr.aliyuncs.com/idc-assest/idc-frontend:v2.1.0 \
docker compose -f docker-compose.prod.yml up -d
```
---
***
## 九、常见问题
@@ -470,14 +525,14 @@ docker compose -f docker-compose.prod.yml up -d
docker logs idc-backend
# 常见错误:
# - JWT_SECRET 未配置 → 在 .env 中设置 JWT_SECRET
# - JWT_SECRET 未配置 → 在 backend/.env 中设置 JWT_SECRET
# - MySQL 连接被拒 → 检查 MYSQL_HOST / MYSQL_PASSWORD
# - 端口被占用 → 检查 80 端口是否已被其他程序占用
```
### 9.2 端口被占用
如果 80 端口被其他服务占用,可以修改 `docker-compose.prod.yml` 中的端口映射:
如果 80 端口被其他服务占用,可以修改 `docker-compose.yml` 中的端口映射:
```yaml
ports:
@@ -486,28 +541,19 @@ ports:
### 9.3 拉取镜像失败
**原因:** 阿里云登录凭证问题或网络不通
**原因:** 网络问题或镜像仓库无法访问
**排查:**
```bash
# 手动登录阿里云 Registry 测试
docker login crpi-c807itn6exy37e7d.cn-hangzhou.personal.cr.aliyuncs.com
# 检查网络
ping crpi-c807itn6exy37e7d.cn-hangzhou.personal.cr.aliyuncs.com
ping ghcr.io
```
### 9.4 初始化后没有默认管理员账号
后端首次启动会自动创建默认管理员账号:
系统**不内置默认管理员账号**,采用"首个注册用户自动成为管理员"机制。
| 账号 | 说明 |
|------|------|
| `admin` | 管理员 |
| `admin` | 管理员密码 |
如果登录失败,检查后端日志是否有初始化相关的错误信息。
首次访问系统时,转到注册页面创建第一个账号,该账号会自动被授予管理员权限。
### 9.5 上传文件后访问 404
@@ -518,56 +564,7 @@ ls -la /opt/idc_assest/backend/uploads/
chmod 755 /opt/idc_assest/backend/uploads/
```
### 9.6 yaml 解析错误:`did not find expected key`
**常见原因:**
1. **缩进错误**:YAML 严格依赖缩进,每个 service 下的属性必须比 service 名多 2 个空格。例如:
```yaml
services:
backend: # ← 2 个空格
image: ... # ← 4 个空格(比 backend 多 2 个)
mysql: # ← 2 个空格
image: mysql:8.0 # ← 4 个空格
```
2. **拼写错误**:例如把 `networks` 写成 `netorks`。
**解决方法:**
```bash
# 用 yamllint 检查
pip install yamllint
yamllint /opt/idc_assest/docker-compose.prod.yml
```
或者直接对照文档第四章的完整 `docker-compose.prod.yml` 内容复制。
### 9.7 错误:`env file /opt/idc_assest/backend/.env not found`
**原因:** `docker-compose.prod.yml` 里 backend 服务配置了 `env_file: ./backend/.env`,但这个文件不存在。
**解决方法:**
```bash
# 创建 backend/.env 文件(参考 3.3 节)
cat > /opt/idc_assest/backend/.env << 'EOF'
PORT=8000
NODE_ENV=production
DB_TYPE=mysql
MYSQL_HOST=mysql
MYSQL_PORT=3306
MYSQL_USERNAME=idc_user
MYSQL_PASSWORD=你的密码
MYSQL_DATABASE=idc_management
JWT_SECRET=64位随机密钥
TOKEN_EXPIRY=24h
...(其他配置)
EOF
```
### 9.8 后端连不上 MySQL 容器
### 9.6 后端连不上 MySQL 容器
**排查步骤:**
@@ -586,3 +583,16 @@ grep MYSQL_HOST /opt/idc_assest/backend/.env
# 4. 进入 MySQL 容器验证
docker exec -it idc-mysql mysql -u idc_user -p
```
### 9.7 如何在已有部署基础上添加新配置
如果已经在运行中,需要补充某个配置项:
```bash
# 1. 编辑 backend/.env 补充配置
vim /opt/idc_assest/backend/.env
# 2. 重启后端容器
docker compose -f docker-compose.yml restart backend
```
Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

-136
View File
@@ -1,136 +0,0 @@
# 项目截图说明
## 截图列表
以下截图需要在README.md中展示:
1. **dashboard.png** - 数据看板页面截图
- 路径:访问 http://localhost:3000
- 内容:展示数据看板的统计图表、设备状态分布等
2. **3d-visualization.png** - 3D机柜可视化页面截图
- 路径:访问 http://localhost:3000/visualization-3d
- 内容:展示3D机柜的三维视图、设备布局、交互功能
3. **device-management.png** - 设备管理页面截图
- 路径:访问 http://localhost:3000/devices
- 内容:展示设备列表、筛选功能、操作按钮等
4. **room-management.png** - 机房管理页面截图
- 路径:访问 http://localhost:3000/rooms
- 内容:展示机房列表、机柜统计、管理功能等
## 添加截图步骤
### Windows系统
1. **启动项目**
```bash
cd backend && npm run dev
cd frontend && npm run dev
```
2. **打开浏览器访问对应页面**
3. **使用截图工具**
- Windows截图工具:Win + Shift + S
- 或使用其他截图工具(如Snipaste、ShareX等)
4. **保存截图**
- 将截图保存为PNG格式
- 文件名对应上面的列表
- 保存到 `docs/images/` 目录
5. **优化截图(可选)**
- 建议分辨率:1920x1080 或更高
- 建议压缩:使用TinyPNG等工具压缩图片大小
- 建议格式:PNG(保持清晰度)或 WebP(更小体积)
### macOS系统
1. **启动项目**
```bash
cd backend && npm run dev
cd frontend && npm run dev
```
2. **打开浏览器访问对应页面**
3. **使用截图工具**
- macOS截图工具:Cmd + Shift + 4(区域截图)
- 或 Cmd + Shift + 3(全屏截图)
4. **保存截图**
- 将截图保存为PNG格式
- 文件名对应上面的列表
- 保存到 `docs/images/` 目录
## 截图建议
### 最佳实践
- **浏览器全屏模式**:按F11进入全屏,避免浏览器UI干扰
- **隐藏开发者工具**:确保没有开发者工具面板
- **合适的窗口大小**:建议使用1920x1080或1366x768分辨率
- **数据准备**:确保页面有足够的测试数据展示
- **清晰度**:保持截图清晰,避免模糊
### 截图内容建议
- **数据看板**:展示多种图表类型、统计数据、关键指标
- **3D可视化**:展示机柜的3D视图、设备详情、交互状态
- **设备管理**:展示设备列表、搜索筛选、操作按钮
- **机房管理**:展示机房列表、机柜分布、统计信息
## 自动化截图(可选)
如果需要自动化截图,可以使用以下工具:
### Playwright
```javascript
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setViewportSize({ width: 1920, height: 1080 });
// 截图数据看板
await page.goto('http://localhost:3000');
await page.screenshot({ path: 'docs/images/dashboard.png' });
// 截图3D可视化
await page.goto('http://localhost:3000/visualization-3d');
await page.waitForTimeout(2000); // 等待3D场景加载
await page.screenshot({ path: 'docs/images/3d-visualization.png' });
await browser.close();
})();
```
### Puppeteer
```javascript
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setViewport({ width: 1920, height: 1080 });
// 截图数据看板
await page.goto('http://localhost:3000');
await page.screenshot({ path: 'docs/images/dashboard.png' });
await browser.close();
})();
```
## 注意事项
- 确保截图文件名与README.md中的引用一致
- 建议使用PNG格式以保持清晰度
- 截图大小建议控制在500KB以内
- 避免在截图中暴露敏感信息(如真实IP、密码等)
- 定期更新截图以反映最新的UI变化
Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 682 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 788 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 885 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IDC设备管理系统</title>
<title>云睿资产管理系统</title>
</head>
<body>
<div id="root"></div>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "idc-frontend",
"version": "2.1.0",
"version": "2.1.1",
"description": "IDC设备管理系统前端",
"scripts": {
"start": "vite",
+38 -3
View File
@@ -36,6 +36,8 @@ import {
CodepenOutlined,
CloudUploadOutlined,
LayoutOutlined,
LinkOutlined,
InfoCircleOutlined,
} from '@ant-design/icons';
import {
BrowserRouter as Router,
@@ -85,6 +87,7 @@ const OperationLogs = lazy(() => import('./pages/OperationLogs'));
const ErrorBoundaryTest = lazy(() => import('./pages/ErrorBoundaryTest'));
const IdleDeviceManagement = lazy(() => import('./pages/IdleDeviceManagement'));
const RoomFloorPlan = lazy(() => import('./pages/RoomFloorPlan'));
const About = lazy(() => import('./pages/About'));
const { Header, Content, Sider } = Layout;
@@ -372,6 +375,11 @@ const AppLayout = ({ children }) => {
icon: <AuditOutlined style={{ fontSize: '16px' }} />,
label: <Link to="/operation-logs">操作日志</Link>,
},
{
key: 'about',
icon: <InfoCircleOutlined style={{ fontSize: '16px' }} />,
label: <Link to="/about">关于系统</Link>,
},
],
},
];
@@ -440,7 +448,7 @@ const AppLayout = ({ children }) => {
lineHeight: 1.2,
}}
>
{config.site_name || 'IDC管理'}
{config.site_name || '云睿资产'}
</div>
<div
style={{
@@ -449,7 +457,7 @@ const AppLayout = ({ children }) => {
marginTop: '2px',
}}
>
数据中心管理平台
云端睿智资产管理
</div>
</div>
)}
@@ -477,10 +485,36 @@ const AppLayout = ({ children }) => {
<div
style={{
padding: '12px',
padding: '8px 12px',
borderTop: `1px solid ${designTokens.colors.sidebar.border}`,
display: 'flex',
flexDirection: 'column',
gap: '4px'
}}
>
{!collapsed && (
<div style={{ padding: '0 8px 8px 8px' }}>
<a
href="http://www.xinmi.cloud/"
target="_blank"
rel="noopener noreferrer"
style={{
fontSize: '12px',
color: designTokens.colors.primary.main,
display: 'flex',
alignItems: 'center',
gap: '4px',
opacity: 0.8,
transition: 'opacity 0.2s'
}}
onMouseEnter={e => e.target.style.opacity = 1}
onMouseLeave={e => e.target.style.opacity = 0.8}
>
<LinkOutlined style={{ fontSize: '12px' }} />
<span>新觅源码库</span>
</a>
</div>
)}
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
@@ -624,6 +658,7 @@ const routeConfig = [
{ path: '/error-boundary-test', component: ErrorBoundaryTest },
{ path: '/idle-devices', component: IdleDeviceManagement },
{ path: '/floor-plan', component: RoomFloorPlan },
{ path: '/about', component: About },
];
const ThemeConfig = () => {
@@ -10,6 +10,7 @@ import {
} from '@ant-design/icons';
import { designTokens } from '../../config/theme';
import { useFetch } from '../../hooks/useSWR';
import { version as appVersion } from '../../../package.json';
// 格式化运行时间
const formatUptime = (seconds) => {
@@ -38,7 +39,7 @@ const formatUptime = (seconds) => {
const SystemInfo = ({ onRefresh, isRefreshing }) => {
const { data: systemData, isLoading: isVersionLoading } = useFetch('/system-settings/system/info');
const version = systemData?.system?.version || '1.2.0';
const version = systemData?.system?.version || appVersion;
const formattedVersion = `v${version}`;
const currentTime = new Date().toLocaleString('zh-CN');
+144
View File
@@ -0,0 +1,144 @@
import React from 'react';
import { Layout, Typography, Card, Row, Col, Space, Tag, Button } from 'antd';
import {
InfoCircleOutlined,
RocketOutlined,
CodeOutlined,
GithubOutlined,
ThunderboltOutlined,
CloudServerOutlined,
LinkOutlined
} from '@ant-design/icons';
import { designTokens } from '../config/theme';
const { Title, Paragraph, Text } = Typography;
const About = () => {
const techStack = [
{ name: 'React', version: '18.2.0', icon: <RocketOutlined /> },
{ name: 'Vite', version: '5.2.10', icon: <ThunderboltOutlined /> },
{ name: 'Ant Design', version: '5.17.0', icon: <BuildOutlined /> },
{ name: 'Node.js', version: '20+', icon: <CloudServerOutlined /> },
{ name: 'Express', version: '4.19.2', icon: <ApiOutlined /> },
{ name: 'Sequelize', version: '6.37.3', icon: <DatabaseOutlined /> },
{ name: 'Three.js', version: '0.164.1', icon: <CodepenOutlined /> },
];
const features = [
{ title: '资产管理', desc: '全方位的 IDC 资产登记与追踪' },
{ title: '3D 可视化', desc: '基于 Three.js 的机房机柜 3D 仿真展示' },
{ title: '工单系统', desc: '标准化的故障处理与日常运维流程' },
{ title: '耗材库存', desc: '精细化的备品备件出入库管理' },
{ title: '数据监控', desc: '实时的健康状态检查与性能分析' },
{ title: '自动化备份', desc: '安全的数据备份与一键恢复机制' },
];
return (
<div style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
{/* Hero Section */}
<Card
style={{
marginBottom: '32px',
borderRadius: '16px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: '#fff',
border: 'none',
overflow: 'hidden',
position: 'relative'
}}
>
<div style={{ position: 'relative', zIndex: 1, padding: '40px' }}>
<Title level={1} style={{ color: '#fff', marginBottom: '16px' }}>云睿资产管理系统</Title>
<Paragraph style={{ color: 'rgba(255,255,255,0.9)', fontSize: '18px', maxWidth: '600px' }}>
智能数据中心基础设施管理平台助力企业实现资产全生命周期管理与 3D 可视化运维
</Paragraph>
<div style={{ marginTop: '24px' }}>
<Tag color="rgba(255,255,255,0.2)" style={{ fontSize: '14px', padding: '4px 12px', border: '1px solid rgba(255,255,255,0.3)', color: '#fff' }}>v1.0.0 Stable</Tag>
</div>
</div>
<div style={{
position: 'absolute',
right: '-50px',
top: '-50px',
opacity: 0.1,
fontSize: '300px'
}}>
<CloudServerOutlined />
</div>
</Card>
<Row gutter={[32, 32]}>
<Col xs={24} md={16}>
{/* Main Content */}
<Card title={<Space><InfoCircleOutlined /> 关于平台</Space>} style={{ borderRadius: '12px', height: '100%' }}>
<Paragraph>
云睿资产管理系统YunRui Asset Management System是一款专为中大型数据中心设计的资产管理解决方案平台集成了资产台账3D 仿真工单运维耗材管理等核心模块通过数字化手段解决 IDC 运维中资产找不着状态看不见流程不规范的痛点
</Paragraph>
<Paragraph>
系统采用现代化的前后端分离架构前端基于 React Vite 提供极致的交互体验后端依托 Node.js 实现高并发的数据处理结合 3D 渲染技术为用户提供身临其境的管理体验
</Paragraph>
<Title level={4} style={{ marginTop: '24px' }}>核心特性</Title>
<Row gutter={[16, 16]}>
{features.map((item, index) => (
<Col span={12} key={index}>
<div style={{ padding: '12px', background: '#f8fafc', borderRadius: '8px' }}>
<Text strong>{item.title}</Text>
<div style={{ fontSize: '12px', color: '#64748b' }}>{item.desc}</div>
</div>
</Col>
))}
</Row>
</Card>
</Col>
<Col xs={24} md={8}>
{/* Sidebar Info */}
<Card title={<Space><CodeOutlined /> 技术栈</Space>} style={{ borderRadius: '12px', marginBottom: '32px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{techStack.map((tech, index) => (
<Tag key={index} icon={tech.icon} color="blue" style={{ padding: '4px 8px' }}>
{tech.name} {tech.version}
</Tag>
))}
</div>
</Card>
<Card
title={<Space><RocketOutlined /> 获取源码</Space>}
style={{
borderRadius: '12px',
background: '#f0f9ff',
border: '1px solid #bae6fd'
}}
>
<Paragraph>
本项目已由新觅源码库进行标准化定制提供商业级的高质量源码
</Paragraph>
<Button
type="primary"
block
icon={<LinkOutlined />}
href="http://www.xinmi.cloud/"
target="_blank"
>
访问新觅源码库
</Button>
</Card>
</Col>
</Row>
<div style={{ textAlign: 'center', marginTop: '48px', color: '#94a3b8' }}>
<Paragraph>© 2026 云睿资产管理系统 · 基于新觅自动化工作流构建</Paragraph>
</div>
</div>
);
};
// 辅助组件导入定义(模拟 Antd 图标)
const BuildOutlined = RocketOutlined;
const ApiOutlined = CodeOutlined;
const DatabaseOutlined = CodeOutlined;
const CodepenOutlined = RocketOutlined;
export default About;
+1 -1
View File
@@ -429,7 +429,7 @@ function Dashboard() {
<div style={headerStyle} className="dashboard-header">
<h1 style={titleStyle} className="dashboard-title">
<DashboardOutlined />
IDC设备管理系统
云睿资产管理系统
</h1>
<p style={subtitleStyle} className="dashboard-subtitle">
实时监控 · 智能管理 · 高效运维
+13 -7
View File
@@ -156,8 +156,8 @@ const Login = () => {
</div>
<div className="brand-title-section">
<h1 className="brand-title">
<span className="brand-title__main">IDC</span>
<span className="brand-title__sub">机柜管理系统</span>
<span className="brand-title__main">云睿</span>
<span className="brand-title__sub">资产管理系统</span>
</h1>
<p className="brand-desc">智能数据中心基础设施管理平台</p>
</div>
@@ -277,12 +277,11 @@ const Login = () => {
name="email"
label="邮箱"
rules={[
{ required: true, message: '请输入邮箱' },
{ type: 'email', message: '请输入有效的邮箱地址' },
]}
>
<Input
placeholder="请输入邮箱"
placeholder="请输入邮箱(选填)"
className="login-input"
prefix={<MailOutlined className="input-icon" />}
/>
@@ -290,10 +289,9 @@ const Login = () => {
<Form.Item
name="phone"
label="手机号"
rules={[{ required: true, message: '请输入手机号' }]}
>
<Input
placeholder="请输入手机号"
placeholder="请输入手机号(选填)"
className="login-input"
prefix={<PhoneOutlined className="input-icon" />}
/>
@@ -301,6 +299,7 @@ const Login = () => {
</>
)}
{!registerMode && (
<Form.Item
name="username"
label={isFirstUser ? '管理员账号' : '用户名'}
@@ -312,6 +311,7 @@ const Login = () => {
className="login-input"
/>
</Form.Item>
)}
<Form.Item
name="password"
@@ -363,6 +363,12 @@ const Login = () => {
{isFirstUser ? '创建管理员' : (registerMode ? '立即注册' : '登 录')}
</Button>
</Form.Item>
{!isFirstUser && !registerMode && (
<div style={{ textAlign: 'center', marginBottom: '16px', color: '#8c8c8c', fontSize: '13px' }}>
提示默认管理员账号通常为 <Text code>admin</Text>
</div>
)}
</Form>
{!isFirstUser && !registerMode && (
@@ -374,7 +380,7 @@ const Login = () => {
)}
<div className="form-footer">
<span>IDC Management System</span>
<span>YunRui Asset Management</span>
<span className="footer-dot" />
<span>v{appVersion}</span>
</div>
+62 -139
View File
@@ -100,11 +100,34 @@ const animations = {
},
};
/**
* 按端口名称中的数字段进行自然排序
* @param {Array} ports - 端口数组
* @returns {Array} 排序后的新数组
*/
function sortPortsByName(ports) {
const extractNumbers = str => {
const matches = str ? String(str).match(/\d+/g) : null;
return matches ? matches.map(Number) : [];
};
return [...ports].sort((a, b) => {
const numsA = extractNumbers(a.portName);
const numsB = extractNumbers(b.portName);
for (let i = 0; i < Math.min(numsA.length, numsB.length); i++) {
if (numsA[i] !== numsB[i]) {
return numsA[i] - numsB[i];
}
}
return String(a.portName).localeCompare(String(b.portName));
});
}
function PortManagement() {
const [ports, setPorts] = useState([]);
const [devices, setDevices] = useState([]);
const [deviceSearching, setDeviceSearching] = useState(false);
const [groupedPorts, setGroupedPorts] = useState({});
// 按设备分组的端口列表(数组,每项 { device, ports }
const [groupedPorts, setGroupedPorts] = useState([]);
const [loading, setLoading] = useState(false);
const [filters, setFilters] = useState({
deviceId: '',
@@ -167,17 +190,13 @@ function PortManagement() {
const hasMoreRef = useRef(true);
const isLoadingRef = useRef(false);
// 设备卡片分页状态
// 设备卡片分页状态(按设备维度分页)
const [deviceCardPage, setDeviceCardPage] = useState(1);
const deviceCardPageSize = 10;
const [deviceCardTotal, setDeviceCardTotal] = useState(0);
// 分页和懒加载状态
const [portPage, setPortPage] = useState(1);
const [portPageSize, setPortPageSize] = useState(50);
// 端口总数(所有设备端口总和,用于统计与导出弹窗)
const [portTotal, setPortTotal] = useState(0);
const [portLoading, setPortLoading] = useState(false);
const [portLoadingMore, setPortLoadingMore] = useState(false);
// 服务器网卡卡片列表状态
const [serverNicSearchText, setServerNicSearchText] = useState('');
@@ -186,46 +205,42 @@ function PortManagement() {
const [serverNicCardPage, setServerNicCardPage] = useState(1);
const serverNicCardPageSize = 12;
// 按设备分组获取端口(设备维度分页,避免端口数过多时部分设备不显示)
const fetchPorts = useCallback(
async (page = 1, append = false) => {
async (page = 1) => {
const validPage = Number.isInteger(page) && page > 0 ? page : 1;
const validPageSize = Number.isInteger(portPageSize) && portPageSize > 0 ? portPageSize : 50;
try {
if (append) {
setPortLoadingMore(true);
} else {
setLoading(true);
}
const params = {
page: validPage,
pageSize: validPageSize,
pageSize: deviceCardPageSize,
};
if (filters.deviceId) params.deviceId = filters.deviceId;
const response = await api.get('/device-ports', { params });
const portsData = response.ports || [];
const total = response.total || 0;
const filteredPorts = portsData;
const response = await api.get('/device-ports/grouped', { params });
const groups = response.groups || [];
if (append) {
setPorts(prev => [...prev, ...filteredPorts]);
} else {
setPorts(filteredPorts);
}
setPortTotal(total);
setPortPage(page);
hasMoreRef.current = page * portPageSize < total;
// 对每个设备的端口按名称自然排序,并展开为扁平数组(供统计、导出当前页使用)
const flatPorts = [];
const sortedGroups = groups.map(g => {
const sortedPorts = sortPortsByName(g.ports || []);
sortedPorts.forEach(p => flatPorts.push(p));
return { device: g.device, ports: sortedPorts };
});
setPorts(flatPorts);
setGroupedPorts(sortedGroups);
setDeviceCardTotal(response.total || 0);
setPortTotal(response.totalPorts || 0);
setDeviceCardPage(validPage);
} catch (error) {
message.error('获取端口列表失败');
console.error('获取端口列表失败:', error);
} finally {
setLoading(false);
setPortLoadingMore(false);
}
},
[filters, portPageSize]
[filters, deviceCardPageSize]
);
const fetchDevices = useCallback(async (keyword = '') => {
@@ -255,104 +270,21 @@ function PortManagement() {
);
useEffect(() => {
fetchPorts(1, false);
fetchPorts(1);
fetchDevices();
}, [fetchPorts, fetchDevices]);
const handleLoadMore = useCallback(() => {
if (!hasMoreRef.current || isLoadingRef.current || portLoadingMore) return;
isLoadingRef.current = true;
const nextPage = portPage + 1;
fetchPorts(nextPage, true).then(() => {
isLoadingRef.current = false;
});
}, [portPage, portLoadingMore, fetchPorts]);
useEffect(() => {
if (!loadMoreRef.current) return;
const observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting && hasMoreRef.current && !portLoadingMore) {
handleLoadMore();
}
},
{ threshold: 0.1, rootMargin: '100px' }
);
observer.observe(loadMoreRef.current);
return () => {
observer.disconnect();
};
}, [handleLoadMore, portLoadingMore]);
const handlePageSizeChange = useCallback(
(newPage, newPageSize) => {
setPortPageSize(newPageSize);
setPortPage(1);
fetchPorts(1, false);
},
[fetchPorts]
);
useEffect(() => {
const grouped = {};
ports.forEach(port => {
const deviceId = port.deviceId;
if (!grouped[deviceId]) {
grouped[deviceId] = {
device: port.device || devices.find(d => d.deviceId === deviceId),
ports: [],
};
}
grouped[deviceId].ports.push(port);
});
// 对每个设备的端口按名称升序排序
Object.keys(grouped).forEach(deviceId => {
grouped[deviceId].ports.sort((a, b) => {
const extractNumbers = str => {
const matches = str.match(/\d+/g);
return matches ? matches.map(Number) : [];
};
const numsA = extractNumbers(a.portName);
const numsB = extractNumbers(b.portName);
for (let i = 0; i < Math.min(numsA.length, numsB.length); i++) {
if (numsA[i] !== numsB[i]) {
return numsA[i] - numsB[i];
}
}
return a.portName.localeCompare(b.portName);
});
});
setGroupedPorts(grouped);
const deviceIds = Object.keys(grouped);
const newTotal = deviceIds.length;
setDeviceCardTotal(newTotal);
if (deviceCardPage > Math.ceil(newTotal / deviceCardPageSize)) {
setDeviceCardPage(1);
}
setExpandedKeys([]);
}, [ports, devices, deviceCardPage, deviceCardPageSize]);
const handleSearch = useCallback(() => {
setPortPage(1);
setDeviceCardPage(1);
hasMoreRef.current = true;
fetchPorts(1, false);
fetchPorts(1);
}, [fetchPorts]);
const handleReset = useCallback(() => {
setFilters({
deviceId: '',
});
setPortPage(1);
setDeviceCardPage(1);
hasMoreRef.current = true;
fetchPorts(1, false);
fetchPorts(1);
}, [fetchPorts]);
const handleAdd = () => {
@@ -547,7 +479,7 @@ function PortManagement() {
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
});
setRefreshTrigger(prev => prev + 1);
fetchPorts(1, false);
fetchPorts(1);
};
const handleEdit = port => {
@@ -579,7 +511,7 @@ function PortManagement() {
content: '删除成功',
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
});
fetchPorts(1, false);
fetchPorts(1);
} catch (error) {
const errorMsg = error.response?.data?.error || '';
if (errorMsg.includes('关联的接线记录')) {
@@ -615,7 +547,7 @@ function PortManagement() {
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
});
setSelectedRowKeys([]);
fetchPorts(1, false);
fetchPorts(1);
} catch (error) {
const errorMsg = error.response?.data?.error || '';
if (errorMsg.includes('关联的接线记录')) {
@@ -861,7 +793,7 @@ function PortManagement() {
setParseResult(null);
setPreviewPorts([]);
setShowPreview(false);
fetchPorts(1, false);
fetchPorts(1);
} catch (error) {
message.error(error.response?.data?.error || editingPort ? '更新失败' : '创建失败');
console.error('提交失败:', error);
@@ -1289,7 +1221,7 @@ function PortManagement() {
});
}
fetchPorts(1, false);
fetchPorts(1);
setImportModalVisible(false);
setImportPreview([]);
} catch (error) {
@@ -1871,7 +1803,7 @@ function PortManagement() {
<div style={{ padding: '40px' }}>
<Skeleton active paragraph={{ rows: 6 }} />
</div>
) : Object.keys(groupedPorts).length === 0 ? (
) : groupedPorts.length === 0 ? (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
@@ -1912,22 +1844,18 @@ function PortManagement() {
</motion.div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{Object.entries(groupedPorts)
.slice(
(deviceCardPage - 1) * deviceCardPageSize,
deviceCardPage * deviceCardPageSize
)
.map(([deviceId, data], index) => {
{groupedPorts.map((data, index) => {
const device = data.device;
const deviceId = device?.deviceId;
const devicePorts = data.ports || [];
const freeCount = devicePorts.filter(p => p.status === 'free').length;
const occupiedCount = devicePorts.filter(p => p.status === 'occupied').length;
const faultCount = devicePorts.filter(p => p.status === 'fault').length;
const isExpanded = expandedKeys.includes(deviceId);
const isExpanded = deviceId ? expandedKeys.includes(deviceId) : false;
return (
<Card
key={deviceId}
key={deviceId || index}
style={{
borderRadius: designTokens.borderRadius.lg,
border: `1px solid ${designTokens.colors.neutral[200]}`,
@@ -2140,7 +2068,7 @@ function PortManagement() {
onChange: setSelectedRowKeys,
}}
pagination={{
pageSize: 10,
defaultPageSize: 10,
showSizeChanger: true,
showTotal: total => `${total} 个端口`,
pageSizeOptions: ['10', '20', '50', '100'],
@@ -2164,8 +2092,8 @@ function PortManagement() {
pageSize={deviceCardPageSize}
total={deviceCardTotal}
onChange={page => {
setDeviceCardPage(page);
setExpandedKeys([]);
fetchPorts(page);
}}
showSizeChanger={false}
showTotal={(total, range) =>
@@ -2174,14 +2102,9 @@ function PortManagement() {
/>
</div>
)}
{hasMoreRef.current && (
<div ref={loadMoreRef} style={{ textAlign: 'center', padding: '20px' }}>
{portLoadingMore && <Spin tip="加载更多端口..." />}
</div>
)}
{!hasMoreRef.current && ports.length > 0 && (
{ports.length > 0 && (
<div style={{ textAlign: 'center', padding: '16px', color: '#999' }}>
已加载全部 {portTotal} 个端口
{deviceCardTotal} 个设备{portTotal} 个端口
</div>
)}
</div>
+1 -1
View File
@@ -7,7 +7,7 @@ import { create } from 'zustand';
import axios from 'axios';
const defaultConfig = {
site_name: '机柜管理系统',
site_name: '云睿资产',
site_logo: '',
primary_color: '#667eea',
secondary_color: '#764ba2',
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "idc-device-management",
"version": "2.1.0",
"version": "2.1.1",
"description": "IDC设备管理系统",
"scripts": {
"start": "concurrently \"npm run start:backend\" \"npm run start:frontend\"",