feat(auth): 添加用户权限获取接口并完善JWT令牌角色信息

- 在JWT令牌中添加用户角色信息
- 新增get_my_permissions接口用于获取当前用户权限码列表
- 重构认证回调逻辑,增加错误日志记录
- 更新用户信息获取接口使用Authorization头验证
```
This commit is contained in:
2026-04-06 00:40:08 +08:00
parent dfa8fa62a8
commit f1f8518985
71 changed files with 9402 additions and 191 deletions
Submodule .claude/skills/create-ex added at 54d0dceab6
+89 -7
View File
@@ -4,17 +4,26 @@
这是一个基于 Python FastAPI + Vue 3 的 H3C OLT 设备监控管理系统,用于监控 4000+ ONU 设备的在线状态。
**当前版本**: v0.5.0
**开发状态**: 核心功能已完成
**当前版本**: v0.7.0
**开发状态**: 核心功能已完成,权限与运维功能完善中
**已实现功能**
- ✅ SSH 连接 H3C OLT 设备查询 ONU 状态(支持 More 分页)
- ✅ SSH 连接 H3C OLT 设备查询 ONU 状态(支持 More 分页、终端控制字符清理
- ✅ Excel 数据导入和批量管理
- ✅ 定时自动状态检查(每30分钟)+ 手动触发
- ✅ 定时自动状态检查(可配置间隔,最小5分钟)+ 手动触发
- ✅ Casdoor 统一认证和 JWT 令牌管理
- ✅ 设备管理(列表、详情、筛选、分页)
- ✅ 统计仪表板(总数、在线、离线)
-权限管理基础框架(RBAC
- ✅ 统计仪表板(总数、在线、离线)+ 区域分布饼图 + 7天趋势折线图
-完整 RBAC 权限管理(角色、权限、用户管理页面
- ✅ OLT 管理(增删改查、批量导入、区域动态同步)
- ✅ OLT 端口管理(查看端口状态、开关端口)
- ✅ 重复 MAC 检测与清除
- ✅ 新设备发现与信息补全
- ✅ 快速扫描(多线程并发)
- ✅ 环路检测
- ✅ 系统设置(管理员可配置检查间隔,显示下次扫描时间/扫描中状态)
- ✅ 库存管理模块(物料、序列号设备、出入库、盘点)
- ✅ 每日状态快照(凌晨1点聚合,用于趋势图性能优化)
**技术栈**
- 后端:Python FastAPI + PostgreSQL + Celery + Redis + Paramiko
@@ -173,6 +182,79 @@
---
## 部署陷阱与经验教训
### 修改代码后必须重新构建镜像
**问题**:修改了宿主机上的源码后,直接 `docker compose up -d``docker compose restart` **不会**让容器使用新代码。容器运行的是构建时打包进镜像的旧代码。
**正确流程**
```bash
# 1. 重新构建镜像(必须加 --no-cache 确保不用旧层)
docker compose build --no-cache backend # 或 frontend,或两者
# 2. 重新创建容器(必须用 rm + up,或 up --force-recreate
docker compose rm -f backend
docker compose up -d backend
# 3. 如有数据库模型变更,执行迁移
docker compose exec backend alembic upgrade head
```
**不要用**
- `docker compose restart`:只重启进程,不更新镜像
- `docker compose up -d`(无 build):容器配置变了会重建,但镜像不变
### 端口被占用导致容器启动但无端口映射
**问题**:如果宿主机端口已被其他容器占用,新容器会启动成功(`docker compose up` 不报错),但端口映射为空 `{}`,外部无法访问。
**排查方法**
```bash
# 检查端口映射是否正常
docker inspect <container> --format '{{json .NetworkSettings.Ports}}'
# 查看谁占用了端口
docker ps | grep <port>
```
**解决方法**:先停掉占用端口的旧容器,再 `docker compose rm -f && docker compose up -d`
### frontend 容器必须配置 VITE_API_PROXY_TARGET
**问题**:根目录 `docker-compose.yml` 的 frontend 服务如果没有配置 `VITE_API_PROXY_TARGET`Vite 代理会默认打到 `http://localhost:8001`,导致所有 `/api` 请求 500 或无法到达后端。
**必须在 docker-compose.yml 中配置**
```yaml
frontend:
build: ./frontend
ports:
- "5173:5173"
environment:
- VITE_API_PROXY_TARGET=http://backend:8000
```
### 新增数据库模型后必须执行迁移
**问题**:新增了 SQLAlchemy 模型(如 `DeviceDailySnapshot``SystemSetting`),重建镜像后如果不执行 `alembic upgrade head`,表不存在会导致 500 错误。
**每次新增模型的完整流程**
```bash
# 1. 创建迁移文件(在宿主机或容器内)
docker compose exec backend alembic revision --autogenerate -m "描述"
# 2. 重建镜像
docker compose build --no-cache backend
# 3. 重启容器
docker compose rm -f backend && docker compose up -d backend
# 4. 执行迁移
docker compose exec backend alembic upgrade head
```
---
## 项目结构
```
@@ -281,7 +363,7 @@ docker-compose exec backend python scripts/init_db.py
```bash
# 创建迁移
alembic revision --autogenerate -m "描述"
/home/v6ole/pyproject/H3ConuMS2/CLAUDE.md
# 执行迁移
alembic upgrade head
@@ -0,0 +1,107 @@
"""rebuild users, permissions, role_permissions tables
Revision ID: a1b2c3d4e5f6
Revises: 3e108afa9bba
Create Date: 2026-04-05
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = 'a1b2c3d4e5f6'
down_revision: Union[str, None] = '3e108afa9bba'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
insp = sa.inspect(conn)
existing = insp.get_table_names()
# 创建 permissions 表(如不存在)
if 'permissions' not in existing:
op.create_table(
'permissions',
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('code', sa.String(length=50), nullable=False),
sa.Column('module', sa.String(length=50), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('code'),
)
# 创建 users 表(如不存在)
if 'users' not in existing:
op.create_table(
'users',
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column('casdoor_id', sa.String(length=100), nullable=False),
sa.Column('username', sa.String(length=100), nullable=False),
sa.Column('email', sa.String(length=255), nullable=True),
sa.Column('role', sa.String(length=50), nullable=True, server_default='user'),
sa.Column('assigned_area', sa.String(length=100), nullable=True),
sa.Column('assigned_school', sa.String(length=200), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True, server_default='true'),
sa.Column('last_login', postgresql.TIMESTAMP(), nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('casdoor_id'),
)
op.create_index('ix_users_id', 'users', ['id'], unique=False)
# 创建 role_permissions 关联表(如不存在)
if 'role_permissions' not in existing:
op.create_table(
'role_permissions',
sa.Column('role', sa.String(length=50), nullable=False),
sa.Column('permission_id', sa.BigInteger(), nullable=False),
sa.ForeignKeyConstraint(['permission_id'], ['permissions.id']),
sa.PrimaryKeyConstraint('role', 'permission_id'),
)
# 插入默认权限数据(跳过已存在的)
permissions_table = sa.table(
'permissions',
sa.column('name', sa.String),
sa.column('code', sa.String),
sa.column('module', sa.String),
sa.column('description', sa.Text),
)
op.bulk_insert(permissions_table, [
{'name': '查看设备', 'code': 'device.view', 'module': 'device', 'description': '查看设备列表和详情'},
{'name': '触发检查', 'code': 'device.check', 'module': 'device', 'description': '手动触发设备状态检查'},
{'name': '导入设备', 'code': 'device.import', 'module': 'device', 'description': '通过Excel导入设备数据'},
{'name': '编辑设备', 'code': 'device.edit', 'module': 'device', 'description': '编辑设备信息'},
{'name': '删除设备', 'code': 'device.delete', 'module': 'device', 'description': '删除设备记录'},
{'name': '查看OLT', 'code': 'olt.view', 'module': 'olt', 'description': '查看OLT设备列表'},
{'name': '管理OLT', 'code': 'olt.manage', 'module': 'olt', 'description': '添加、编辑、删除OLT设备'},
{'name': '查看用户', 'code': 'user.view', 'module': 'user', 'description': '查看用户列表'},
{'name': '管理用户', 'code': 'user.manage', 'module': 'user', 'description': '修改用户角色和权限'},
{'name': '系统管理', 'code': 'system.admin', 'module': 'system', 'description': '系统级管理操作'},
])
# 插入默认角色权限
op.execute("""
INSERT INTO role_permissions (role, permission_id)
SELECT 'area_admin', id FROM permissions WHERE code IN ('device.view', 'device.check', 'olt.view')
""")
op.execute("""
INSERT INTO role_permissions (role, permission_id)
SELECT 'school_admin', id FROM permissions WHERE code IN ('device.view')
""")
op.execute("""
INSERT INTO role_permissions (role, permission_id)
SELECT 'user', id FROM permissions WHERE code IN ('device.view')
""")
def downgrade() -> None:
op.drop_table('role_permissions')
op.drop_index('ix_users_id', table_name='users')
op.drop_table('users')
op.drop_table('permissions')
@@ -0,0 +1,28 @@
"""add olt.port_manage, olt.discover, olt.loopback permissions
Revision ID: b2c3d4e5f6a7
Revises: a1b2c3d4e5f6
Create Date: 2026-04-05
"""
from alembic import op
import sqlalchemy as sa
revision = 'b2c3d4e5f6a7'
down_revision = 'a1b2c3d4e5f6'
branch_labels = None
depends_on = None
def upgrade():
op.execute("""
INSERT INTO permissions (name, code, module, description)
VALUES
('端口管理', 'olt.port_manage', 'olt', '查看和切换OLT端口状态'),
('扫描入库', 'olt.discover', 'olt', '扫描OLT并将新设备写入数据库'),
('环路检测', 'olt.loopback', 'olt', '对OLT执行环路检测')
ON CONFLICT (code) DO NOTHING
""")
def downgrade():
op.execute("DELETE FROM permissions WHERE code IN ('olt.port_manage', 'olt.discover', 'olt.loopback')")
@@ -0,0 +1,192 @@
"""add inventory management tables and permissions
Revision ID: c3d4e5f6a7b8
Revises: b2c3d4e5f6a7
Create Date: 2026-04-05
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = 'c3d4e5f6a7b8'
down_revision: Union[str, None] = 'b2c3d4e5f6a7'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 物料分类表
op.create_table(
'material_categories',
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('code', sa.String(length=50), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('code'),
)
# 物料主数据表
op.create_table(
'materials',
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column('category_id', sa.BigInteger(), nullable=True),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('model', sa.String(length=100), nullable=True),
sa.Column('specification', sa.Text(), nullable=True),
sa.Column('brand', sa.String(length=100), nullable=True),
sa.Column('unit', sa.String(length=20), server_default='', nullable=True),
sa.Column('safe_quantity', sa.Integer(), server_default='0', nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['category_id'], ['material_categories.id']),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_materials_id', 'materials', ['id'], unique=False)
# 库存批次表
op.create_table(
'inventory_batches',
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column('material_id', sa.BigInteger(), nullable=True),
sa.Column('batch_no', sa.String(length=50), nullable=False),
sa.Column('quantity', sa.Integer(), nullable=False),
sa.Column('available_quantity', sa.Integer(), nullable=False, server_default='0'),
sa.Column('supplier', sa.String(length=200), nullable=True),
sa.Column('purchase_date', sa.Date(), nullable=True),
sa.Column('purchase_price', sa.Numeric(10, 2), nullable=True),
sa.Column('expiry_date', sa.Date(), nullable=True),
sa.Column('location', sa.String(length=100), nullable=True),
sa.Column('status', sa.String(length=20), server_default='in_stock', nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['material_id'], ['materials.id']),
sa.PrimaryKeyConstraint('id'),
)
# 序列号设备表
op.create_table(
'serial_devices',
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column('material_id', sa.BigInteger(), nullable=True),
sa.Column('batch_id', sa.BigInteger(), nullable=True),
sa.Column('serial_no', sa.String(length=100), nullable=False),
sa.Column('mac_address', sa.String(length=17), nullable=True),
sa.Column('asset_no', sa.String(length=50), nullable=True),
sa.Column('status', sa.String(length=20), server_default='in_stock', nullable=True),
sa.Column('current_location', sa.String(length=200), nullable=True),
sa.Column('installed_info', postgresql.JSONB(), nullable=True),
sa.Column('onu_device_id', sa.BigInteger(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['batch_id'], ['inventory_batches.id']),
sa.ForeignKeyConstraint(['material_id'], ['materials.id']),
sa.ForeignKeyConstraint(['onu_device_id'], ['onu_devices.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('serial_no'),
)
op.create_index('ix_serial_devices_mac_address', 'serial_devices', ['mac_address'], unique=False)
op.create_index('ix_serial_devices_status', 'serial_devices', ['status'], unique=False)
# 出入库记录表
op.create_table(
'inventory_transactions',
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column('transaction_no', sa.String(length=50), nullable=False),
sa.Column('transaction_type', sa.String(length=20), nullable=False),
sa.Column('material_id', sa.BigInteger(), nullable=True),
sa.Column('batch_id', sa.BigInteger(), nullable=True),
sa.Column('serial_device_id', sa.BigInteger(), nullable=True),
sa.Column('quantity', sa.Integer(), nullable=False),
sa.Column('from_status', sa.String(length=20), nullable=True),
sa.Column('to_status', sa.String(length=20), nullable=True),
sa.Column('operator_id', sa.BigInteger(), nullable=True),
sa.Column('project_name', sa.String(length=200), nullable=True),
sa.Column('installation_info', postgresql.JSONB(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['batch_id'], ['inventory_batches.id']),
sa.ForeignKeyConstraint(['material_id'], ['materials.id']),
sa.ForeignKeyConstraint(['operator_id'], ['users.id']),
sa.ForeignKeyConstraint(['serial_device_id'], ['serial_devices.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('transaction_no'),
)
# 盘点记录表
op.create_table(
'inventory_checks',
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column('check_no', sa.String(length=50), nullable=False),
sa.Column('check_date', sa.Date(), nullable=False),
sa.Column('checker_id', sa.BigInteger(), nullable=True),
sa.Column('material_id', sa.BigInteger(), nullable=True),
sa.Column('batch_id', sa.BigInteger(), nullable=True),
sa.Column('book_quantity', sa.Integer(), nullable=True),
sa.Column('actual_quantity', sa.Integer(), nullable=True),
sa.Column('difference', sa.Integer(), nullable=True),
sa.Column('reason', sa.Text(), nullable=True),
sa.Column('adjusted', sa.Boolean(), server_default='false', nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['batch_id'], ['inventory_batches.id']),
sa.ForeignKeyConstraint(['checker_id'], ['users.id']),
sa.ForeignKeyConstraint(['material_id'], ['materials.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('check_no'),
)
# 新增库存管理权限
permissions_table = sa.table(
'permissions',
sa.column('name', sa.String),
sa.column('code', sa.String),
sa.column('module', sa.String),
sa.column('description', sa.Text),
)
op.bulk_insert(permissions_table, [
{'name': '查看库存', 'code': 'inventory.view', 'module': 'inventory', 'description': '查看库存列表和统计'},
{'name': '管理物料', 'code': 'inventory.manage', 'module': 'inventory', 'description': '创建、编辑、删除物料'},
{'name': '出入库操作', 'code': 'inventory.transaction', 'module': 'inventory', 'description': '执行采购入库、领用出库、退库操作'},
{'name': '库存盘点', 'code': 'inventory.check', 'module': 'inventory', 'description': '创建盘点单并执行库存调整'},
{'name': '库存报表', 'code': 'inventory.report', 'module': 'inventory', 'description': '查看库存统计报表'},
])
# 默认给 area_admin 查看权限
op.execute("""
INSERT INTO role_permissions (role, permission_id)
SELECT 'area_admin', id FROM permissions WHERE code = 'inventory.view'
""")
# 插入默认物料分类
op.execute("""
INSERT INTO material_categories (name, code, description) VALUES
('ONU设备', 'ONU', '光网络单元设备'),
('OLT设备', 'OLT', '光线路终端设备'),
('交换机', 'SWITCH', '网络交换机'),
('防火墙', 'FIREWALL', '网络防火墙设备'),
('上网行为管理', 'BEHAVIOR', '上网行为管理设备'),
('光模块及配件', 'ACCESSORY', '光模块、跳线等配件')
""")
def downgrade() -> None:
op.drop_table('inventory_checks')
op.drop_table('inventory_transactions')
op.drop_index('ix_serial_devices_status', table_name='serial_devices')
op.drop_index('ix_serial_devices_mac_address', table_name='serial_devices')
op.drop_table('serial_devices')
op.drop_table('inventory_batches')
op.drop_index('ix_materials_id', table_name='materials')
op.drop_table('materials')
op.drop_table('material_categories')
op.execute("""
DELETE FROM role_permissions WHERE permission_id IN (
SELECT id FROM permissions WHERE module = 'inventory'
)
""")
op.execute("DELETE FROM permissions WHERE module = 'inventory'")
@@ -0,0 +1,26 @@
"""add region to olt_devices
Revision ID: d4e5f6a7b8c9
Revises: c3d4e5f6a7b8
Create Date: 2026-04-05
"""
from alembic import op
import sqlalchemy as sa
revision = 'd4e5f6a7b8c9'
down_revision = 'c3d4e5f6a7b8'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('olt_devices', sa.Column('region', sa.String(100), nullable=True))
op.create_index('ix_olt_devices_region', 'olt_devices', ['region'])
# 默认将现有 OLT 设置为城区
op.execute("UPDATE olt_devices SET region = '城区' WHERE region IS NULL")
def downgrade():
op.drop_index('ix_olt_devices_region', 'olt_devices')
op.drop_column('olt_devices', 'region')
@@ -0,0 +1,32 @@
"""add device_daily_snapshots table
Revision ID: e5f6a7b8c9d0
Revises: d4e5f6a7b8c9
Create Date: 2026-04-05
"""
from alembic import op
import sqlalchemy as sa
revision = 'e5f6a7b8c9d0'
down_revision = 'd4e5f6a7b8c9'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'device_daily_snapshots',
sa.Column('id', sa.BigInteger(), primary_key=True, index=True),
sa.Column('snapshot_date', sa.String(10), nullable=False),
sa.Column('total', sa.Integer(), nullable=False, server_default='0'),
sa.Column('online', sa.Integer(), nullable=False, server_default='0'),
sa.Column('offline', sa.Integer(), nullable=False, server_default='0'),
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('now()')),
)
op.create_index('ix_device_daily_snapshots_snapshot_date', 'device_daily_snapshots', ['snapshot_date'])
def downgrade():
op.drop_index('ix_device_daily_snapshots_snapshot_date', 'device_daily_snapshots')
op.drop_table('device_daily_snapshots')
@@ -0,0 +1,33 @@
"""add system_settings table
Revision ID: f6a7b8c9d0e1
Revises: e5f6a7b8c9d0
Create Date: 2026-04-05
"""
from alembic import op
import sqlalchemy as sa
revision = 'f6a7b8c9d0e1'
down_revision = 'e5f6a7b8c9d0'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'system_settings',
sa.Column('key', sa.String(100), primary_key=True),
sa.Column('value', sa.Text(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('updated_at', sa.TIMESTAMP(), server_default=sa.text('now()')),
)
# 插入默认值
op.execute("""
INSERT INTO system_settings (key, value, description)
VALUES ('check_interval_seconds', '1800', '定时检查间隔(秒),最小 300(5分钟)')
""")
def downgrade():
op.drop_table('system_settings')
@@ -0,0 +1,55 @@
"""add audit_logs table
Revision ID: g7h8i9j0k1l2
Revises: f6a7b8c9d0e1
Create Date: 2026-04-05
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
revision = 'g7h8i9j0k1l2'
down_revision = 'f6a7b8c9d0e1'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'audit_logs',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('user_id', sa.String(100), nullable=False),
sa.Column('username', sa.String(100), nullable=False),
sa.Column('user_role', sa.String(50)),
sa.Column('action_time', sa.TIMESTAMP(), nullable=False, server_default=sa.text('now()')),
sa.Column('action_type', sa.String(50), nullable=False),
sa.Column('action_subtype', sa.String(50)),
sa.Column('ip_address', sa.String(45)),
sa.Column('user_agent', sa.Text()),
sa.Column('request_method', sa.String(10)),
sa.Column('request_path', sa.String(500)),
sa.Column('status', sa.String(20), nullable=False),
sa.Column('status_code', sa.Integer()),
sa.Column('resource_type', sa.String(50)),
sa.Column('resource_id', sa.String(100)),
sa.Column('resource_name', sa.String(200)),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('request_params', JSONB()),
sa.Column('response_data', JSONB()),
sa.Column('error_message', sa.Text()),
sa.Column('created_at', sa.TIMESTAMP(), nullable=False, server_default=sa.text('now()')),
)
op.create_index('idx_audit_logs_action_time', 'audit_logs', ['action_time'])
op.create_index('idx_audit_logs_user_id', 'audit_logs', ['user_id'])
op.create_index('idx_audit_logs_action_type', 'audit_logs', ['action_type'])
op.create_index('idx_audit_logs_resource_type', 'audit_logs', ['resource_type'])
op.create_index('idx_audit_logs_status', 'audit_logs', ['status'])
def downgrade():
op.drop_index('idx_audit_logs_status', 'audit_logs')
op.drop_index('idx_audit_logs_resource_type', 'audit_logs')
op.drop_index('idx_audit_logs_action_type', 'audit_logs')
op.drop_index('idx_audit_logs_user_id', 'audit_logs')
op.drop_index('idx_audit_logs_action_time', 'audit_logs')
op.drop_table('audit_logs')
@@ -0,0 +1,38 @@
"""add device_replacements table
Revision ID: h8i9j0k1l2m3
Revises: g7h8i9j0k1l2
Create Date: 2026-04-05
"""
from alembic import op
import sqlalchemy as sa
revision = 'h8i9j0k1l2m3'
down_revision = 'g7h8i9j0k1l2'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'device_replacements',
sa.Column('id', sa.BigInteger(), primary_key=True),
sa.Column('onu_device_id', sa.BigInteger(), sa.ForeignKey('onu_devices.id'), nullable=False),
sa.Column('old_mac', sa.String(17), nullable=False),
sa.Column('new_mac', sa.String(17), nullable=False),
sa.Column('reason', sa.Text()),
sa.Column('operator_id', sa.String(100)),
sa.Column('operator_name', sa.String(100)),
sa.Column('replaced_at', sa.TIMESTAMP(), nullable=False, server_default=sa.text('now()')),
sa.Column('created_at', sa.TIMESTAMP(), nullable=False, server_default=sa.text('now()')),
)
op.create_index('idx_device_replacements_onu_device_id', 'device_replacements', ['onu_device_id'])
op.create_index('idx_device_replacements_old_mac', 'device_replacements', ['old_mac'])
op.create_index('idx_device_replacements_new_mac', 'device_replacements', ['new_mac'])
def downgrade():
op.drop_index('idx_device_replacements_new_mac', 'device_replacements')
op.drop_index('idx_device_replacements_old_mac', 'device_replacements')
op.drop_index('idx_device_replacements_onu_device_id', 'device_replacements')
op.drop_table('device_replacements')
+156
View File
@@ -0,0 +1,156 @@
"""审计日志 API"""
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.middleware.permission_middleware import require_permission
from app.models.audit_log import AuditLog
from app.services.audit_service import query_logs
import csv
import io
router = APIRouter(prefix="/api/audit", tags=["审计日志"])
@router.get("/logs")
def get_audit_logs(
start_time: Optional[datetime] = Query(None),
end_time: Optional[datetime] = Query(None),
user_id: Optional[str] = Query(None),
username: Optional[str] = Query(None),
action_type: Optional[str] = Query(None),
resource_type: Optional[str] = Query(None),
status: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
_: dict = Depends(require_permission('*')),
):
"""查询审计日志(仅管理员)"""
total, items = query_logs(
db,
start_time=start_time,
end_time=end_time,
user_id=user_id,
username=username,
action_type=action_type,
resource_type=resource_type,
status=status,
page=page,
page_size=page_size,
)
return {
"total": total,
"page": page,
"page_size": page_size,
"items": [_fmt(r) for r in items],
}
@router.get("/logs/{log_id}")
def get_audit_log_detail(
log_id: int,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('*')),
):
"""获取单条审计日志详情"""
log = db.query(AuditLog).filter(AuditLog.id == log_id).first()
if not log:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="日志不存在")
return _fmt(log, detail=True)
@router.get("/stats")
def get_audit_stats(
days: int = Query(7, ge=1, le=90),
db: Session = Depends(get_db),
_: dict = Depends(require_permission('*')),
):
"""审计日志统计(最近N天)"""
from datetime import timedelta
from sqlalchemy import func
since = datetime.utcnow() - timedelta(days=days)
rows = (
db.query(AuditLog.action_type, AuditLog.status, func.count().label("cnt"))
.filter(AuditLog.action_time >= since)
.group_by(AuditLog.action_type, AuditLog.status)
.all()
)
total = db.query(func.count(AuditLog.id)).filter(AuditLog.action_time >= since).scalar()
by_type = {}
for row in rows:
if row.action_type not in by_type:
by_type[row.action_type] = {"success": 0, "failed": 0, "error": 0}
by_type[row.action_type][row.status] = row.cnt
return {"total": total, "days": days, "by_type": by_type}
@router.get("/logs/export/csv")
def export_audit_logs(
start_time: Optional[datetime] = Query(None),
end_time: Optional[datetime] = Query(None),
action_type: Optional[str] = Query(None),
status: Optional[str] = Query(None),
db: Session = Depends(get_db),
_: dict = Depends(require_permission('*')),
):
"""导出审计日志为 CSV"""
_, items = query_logs(
db,
start_time=start_time,
end_time=end_time,
action_type=action_type,
status=status,
page=1,
page_size=5000,
)
def generate():
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(["时间", "用户", "角色", "操作类型", "子类型", "路径", "状态码", "状态", "IP", "描述"])
for r in items:
writer.writerow([
r.action_time.strftime("%Y-%m-%d %H:%M:%S") if r.action_time else "",
r.username, r.user_role, r.action_type, r.action_subtype or "",
f"{r.request_method} {r.request_path}", r.status_code, r.status,
r.ip_address or "", r.description,
])
yield buf.getvalue().encode("utf-8-sig")
filename = f"audit_{datetime.now().strftime('%Y%m%d%H%M%S')}.csv"
return StreamingResponse(
generate(),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
def _fmt(r: AuditLog, detail: bool = False) -> dict:
base = {
"id": r.id,
"action_time": r.action_time.isoformat() if r.action_time else None,
"user_id": r.user_id,
"username": r.username,
"user_role": r.user_role,
"action_type": r.action_type,
"action_subtype": r.action_subtype,
"request_method": r.request_method,
"request_path": r.request_path,
"status": r.status,
"status_code": r.status_code,
"resource_type": r.resource_type,
"resource_id": r.resource_id,
"resource_name": r.resource_name,
"description": r.description,
"ip_address": r.ip_address,
}
if detail:
base["request_params"] = r.request_params
base["response_data"] = r.response_data
base["error_message"] = r.error_message
base["user_agent"] = r.user_agent
return base
+36 -7
View File
@@ -1,12 +1,12 @@
"""认证 API"""
import base64
import json
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Header
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.casdoor import casdoor_sdk
from app.core.security import create_access_token
from app.core.security import create_access_token, verify_token
from app.core.config import settings
from app.models.user import User
from app.schemas.auth import Token, UserInfo
@@ -62,19 +62,48 @@ def callback(body: CallbackRequest, db: Session = Depends(get_db)):
user.last_login = datetime.utcnow()
db.commit()
jwt_token = create_access_token({"sub": str(user.id), "username": user.username})
jwt_token = create_access_token({
"sub": str(user.id),
"username": user.username,
"role": user.role or "user",
})
return {"access_token": jwt_token}
except HTTPException:
raise
except Exception as e:
import traceback
import logging
logging.getLogger(__name__).error("callback error: %s\n%s", e, traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
@router.get("/profile", response_model=UserInfo)
def get_profile(token: str, db: Session = Depends(get_db)):
"""获取当前用户信息"""
from app.core.security import verify_token
@router.get("/permissions")
def get_my_permissions(
authorization: str = Header(..., alias="Authorization"),
db: Session = Depends(get_db)
):
"""获取当前用户的权限码列表"""
from app.middleware.permission_middleware import get_role_permissions
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未授权")
token = authorization[7:]
payload = verify_token(token)
if not payload:
raise HTTPException(status_code=401, detail="无效的令牌")
role = payload.get('role', 'user')
perms = get_role_permissions(role, db)
return {"role": role, "permissions": perms}
@router.get("/profile")
def get_profile(
authorization: str = Header(..., alias="Authorization"),
db: Session = Depends(get_db)
):
"""获取当前用户信息"""
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未授权")
token = authorization[7:]
payload = verify_token(token)
if not payload:
raise HTTPException(status_code=401, detail="无效的令牌")
+16 -4
View File
@@ -10,6 +10,7 @@ from app.tasks.check_tasks import check_all_devices
from app.core.celery_app import celery_app
from app.core.database import get_db
from app.services.check_service import CheckService
from app.middleware.permission_middleware import require_permission
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/check", tags=["状态检查"])
@@ -30,7 +31,7 @@ class CheckError(BaseModel):
@router.post("/status")
def trigger_check():
def trigger_check(_: dict = Depends(require_permission('device.check'))):
"""手动触发状态检查"""
try:
task = check_all_devices.delay()
@@ -41,7 +42,10 @@ def trigger_check():
@router.get("/status/{task_id}")
def get_check_status(task_id: str):
def get_check_status(
task_id: str,
_: dict = Depends(require_permission('device.check')),
):
"""查询状态检查任务进度和结果"""
task_result = AsyncResult(task_id, app=celery_app)
state = task_result.state
@@ -65,7 +69,11 @@ def get_check_status(task_id: str):
@router.post("/scan/{olt_id}")
def scan_olt(olt_id: int, db: Session = Depends(get_db)):
def scan_olt(
olt_id: int,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.check')),
):
"""扫描单台 OLT,预览发现的设备(不写入数据库)"""
try:
service = CheckService(db)
@@ -76,7 +84,11 @@ def scan_olt(olt_id: int, db: Session = Depends(get_db)):
@router.post("/discover/{olt_id}")
def discover_olt(olt_id: int, db: Session = Depends(get_db)):
def discover_olt(
olt_id: int,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('olt.discover')),
):
"""扫描单台 OLT 并将新发现的 MAC 自动入库关联"""
try:
service = CheckService(db)
+163 -12
View File
@@ -5,7 +5,8 @@ from sqlalchemy import asc, desc, distinct, or_
from pydantic import BaseModel
from typing import Optional
from app.core.database import get_db
from app.models.device import ONUDevice, DeviceStatusHistory, OLTDevice
from app.middleware.permission_middleware import require_permission
from app.models.device import ONUDevice, DeviceStatusHistory, OLTDevice, DeviceReplacement
from app.schemas.device import DeviceListResponse, ONUDeviceResponse
router = APIRouter(prefix="/api/devices", tags=["设备管理"])
@@ -18,8 +19,9 @@ def get_devices(
region: str = None,
school_name: str = None,
keyword: str = None,
status: str = None, # online / offline
db: Session = Depends(get_db)
status: str = None,
db: Session = Depends(get_db),
current: dict = Depends(require_permission('device.view')),
):
"""获取设备列表"""
# 子查询:每台设备最新一条状态记录
@@ -44,6 +46,24 @@ def get_devices(
query = db.query(ONUDevice)
# 数据范围过滤:区域管理员只能看自己分配的区域,学校管理员只能看自己分配的学校
role = current.get('role', 'user')
if role == 'area_admin':
assigned = current.get('assigned_area') or ''
areas = [a.strip() for a in assigned.split(',') if a.strip()]
if areas:
query = query.filter(ONUDevice.region.in_(areas))
else:
# 未分配区域则看不到任何设备
query = query.filter(False)
elif role == 'school_admin':
assigned = current.get('assigned_school') or ''
schools = [s.strip() for s in assigned.split(',') if s.strip()]
if schools:
query = query.filter(ONUDevice.school_name.in_(schools))
else:
query = query.filter(False)
if region:
query = query.filter(ONUDevice.region == region)
if school_name:
@@ -141,17 +161,55 @@ def get_devices(
@router.get("/regions")
def get_regions(db: Session = Depends(get_db)):
"""获取所有区域列表"""
regions = db.query(distinct(ONUDevice.region)).filter(
def get_regions(
db: Session = Depends(get_db),
current: dict = Depends(require_permission('device.view')),
):
"""获取所有区域列表(受角色数据范围限制)"""
role = current.get('role', 'user')
query = db.query(distinct(ONUDevice.region)).filter(
ONUDevice.region.isnot(None),
ONUDevice.region != ''
).order_by(ONUDevice.region).all()
return [r[0] for r in regions]
)
if role == 'area_admin':
assigned = current.get('assigned_area') or ''
areas = [a.strip() for a in assigned.split(',') if a.strip()]
if areas:
query = query.filter(ONUDevice.region.in_(areas))
else:
return []
elif role == 'school_admin':
assigned = current.get('assigned_school') or ''
schools = [s.strip() for s in assigned.split(',') if s.strip()]
if schools:
query = query.filter(ONUDevice.school_name.in_(schools))
else:
return []
return [r[0] for r in query.order_by(ONUDevice.region).all()]
@router.get("/schools")
def get_schools(
region: str = None,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.view')),
):
"""获取所有学校列表(可按区域筛选)"""
query = db.query(distinct(ONUDevice.school_name)).filter(
ONUDevice.school_name.isnot(None),
ONUDevice.school_name != ''
)
if region:
query = query.filter(ONUDevice.region == region)
return [r[0] for r in query.order_by(ONUDevice.school_name).all()]
@router.get("/{device_id}", response_model=ONUDeviceResponse)
def get_device(device_id: int, db: Session = Depends(get_db)):
def get_device(
device_id: int,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.view')),
):
"""获取设备详情"""
device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
if not device:
@@ -186,7 +244,11 @@ def get_device(device_id: int, db: Session = Depends(get_db)):
@router.post("/{device_id}/refresh")
def refresh_device_status(device_id: int, db: Session = Depends(get_db)):
def refresh_device_status(
device_id: int,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.check')),
):
"""通过 SSH 单独更新一台设备的状态和距离"""
from app.services.check_service import CheckService
try:
@@ -206,8 +268,16 @@ class DeviceUpdate(BaseModel):
notes: Optional[str] = None
class DeviceReplaceRequest(BaseModel):
new_mac: str
reason: Optional[str] = None
@router.delete("/status/all")
def clear_all_status(db: Session = Depends(get_db)):
def clear_all_status(
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.delete')),
):
"""清空所有设备状态历史记录"""
db.query(DeviceStatusHistory).delete()
db.commit()
@@ -215,7 +285,12 @@ def clear_all_status(db: Session = Depends(get_db)):
@router.put("/{device_id}")
def update_device(device_id: int, body: DeviceUpdate, db: Session = Depends(get_db)):
def update_device(
device_id: int,
body: DeviceUpdate,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.edit')),
):
"""更新设备信息(区域、学校、楼宇、房间号、备注)"""
device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
if not device:
@@ -229,3 +304,79 @@ def update_device(device_id: int, body: DeviceUpdate, db: Session = Depends(get_
return {"message": "更新成功"}
@router.post("/{device_id}/replace")
def replace_device(
device_id: int,
body: DeviceReplaceRequest,
db: Session = Depends(get_db),
current: dict = Depends(require_permission('device.edit')),
):
"""更换设备 MAC 地址,并记录更换历史"""
from datetime import datetime
device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
if not device:
raise HTTPException(status_code=404, detail="设备不存在")
new_mac = body.new_mac.upper().strip()
# 校验 MAC 格式(允许 XX:XX:XX:XX:XX:XX 或 XXXXXXXXXXXX
import re
if not re.match(r'^([0-9A-F]{2}[:-]){5}[0-9A-F]{2}$|^[0-9A-F]{12}$', new_mac):
raise HTTPException(status_code=400, detail="MAC 地址格式不正确")
# 检查新 MAC 是否已被其他设备使用
existing = db.query(ONUDevice).filter(
ONUDevice.mac_address == new_mac,
ONUDevice.id != device_id
).first()
if existing:
raise HTTPException(status_code=400, detail="该 MAC 地址已被其他设备使用")
# 同步更新库存序列号设备的 onu_device_id 关联(如有)
from app.models.inventory import SerialDevice
old_serial = db.query(SerialDevice).filter(SerialDevice.onu_device_id == device_id).first()
if old_serial:
old_serial.onu_device_id = None
old_serial.status = "returned"
new_serial = db.query(SerialDevice).filter(SerialDevice.mac_address == new_mac).first()
if new_serial:
new_serial.onu_device_id = device_id
new_serial.status = "in_use"
record = DeviceReplacement(
onu_device_id=device_id,
old_mac=device.mac_address,
new_mac=new_mac,
reason=body.reason or None,
operator_id=current.get("sub", ""),
operator_name=current.get("username", ""),
replaced_at=datetime.utcnow(),
)
db.add(record)
device.mac_address = new_mac
db.commit()
return {"message": "更换成功", "old_mac": record.old_mac, "new_mac": new_mac}
@router.get("/{device_id}/replacements")
def get_device_replacements(
device_id: int,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.view')),
):
"""获取设备更换历史"""
records = db.query(DeviceReplacement).filter(
DeviceReplacement.onu_device_id == device_id
).order_by(DeviceReplacement.replaced_at.desc()).all()
return [
{
"id": r.id,
"old_mac": r.old_mac,
"new_mac": r.new_mac,
"reason": r.reason,
"operator_name": r.operator_name,
"replaced_at": r.replaced_at,
}
for r in records
]
+4 -2
View File
@@ -2,6 +2,7 @@
from fastapi import APIRouter, UploadFile, File, Depends, Response
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.middleware.permission_middleware import require_permission
from app.services.import_service import ImportService
import shutil
import io
@@ -11,7 +12,7 @@ router = APIRouter(prefix="/api/import", tags=["数据导入"])
@router.get("/template")
def download_template():
def download_template(_: dict = Depends(require_permission('device.import'))):
"""下载导入数据模板"""
wb = Workbook()
ws = wb.active
@@ -54,7 +55,8 @@ def download_template():
async def upload_excel(
file: UploadFile = File(...),
olt_id: int = None,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.import')),
):
"""上传并导入 Excel 文件(仅导入 MAC 信息,不关联 OLT)"""
file_path = f"/tmp/{file.filename}"
+196
View File
@@ -0,0 +1,196 @@
"""库存管理 API"""
from typing import Optional
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.middleware.permission_middleware import require_permission
from app.schemas.inventory import (
CategoryCreate, CategoryResponse,
MaterialCreate, MaterialUpdate, MaterialListResponse,
PurchaseInRequest, AllocateOutRequest, ReturnInRequest,
TransactionListResponse, SerialDeviceListResponse,
CheckCreate, CheckListResponse, InventorySummary,
)
import app.services.inventory_service as svc
router = APIRouter(prefix="/api/inventory", tags=["库存管理"])
# ── 物料分类 ──────────────────────────────────────────────────────────────────
@router.get("/categories")
def list_categories(
db: Session = Depends(get_db),
_: dict = Depends(require_permission("inventory.view")),
):
return svc.get_categories(db)
@router.post("/categories", response_model=CategoryResponse)
def create_category(
body: CategoryCreate,
db: Session = Depends(get_db),
_: dict = Depends(require_permission("inventory.manage")),
):
return svc.create_category(db, body.name, body.code, body.description)
# ── 物料 ──────────────────────────────────────────────────────────────────────
@router.get("/materials", response_model=MaterialListResponse)
def list_materials(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
category_id: Optional[int] = None,
keyword: Optional[str] = None,
db: Session = Depends(get_db),
_: dict = Depends(require_permission("inventory.view")),
):
return svc.get_materials(db, skip, limit, category_id, keyword)
@router.post("/materials")
def create_material(
body: MaterialCreate,
db: Session = Depends(get_db),
_: dict = Depends(require_permission("inventory.manage")),
):
return svc.create_material(db, body.model_dump())
@router.put("/materials/{material_id}")
def update_material(
material_id: int,
body: MaterialUpdate,
db: Session = Depends(get_db),
_: dict = Depends(require_permission("inventory.manage")),
):
return svc.update_material(db, material_id, body.model_dump(exclude_none=True))
@router.delete("/materials/{material_id}")
def delete_material(
material_id: int,
db: Session = Depends(get_db),
_: dict = Depends(require_permission("inventory.manage")),
):
svc.delete_material(db, material_id)
return {"message": "删除成功"}
# ── 出入库操作 ────────────────────────────────────────────────────────────────
@router.post("/transactions/purchase")
def purchase_in(
body: PurchaseInRequest,
db: Session = Depends(get_db),
current: dict = Depends(require_permission("inventory.transaction")),
):
return svc.purchase_in(db, body.model_dump(), int(current.get("sub", 0)))
@router.post("/transactions/allocate")
def allocate_out(
body: AllocateOutRequest,
db: Session = Depends(get_db),
current: dict = Depends(require_permission("inventory.transaction")),
):
return svc.allocate_out(db, body.model_dump(), int(current.get("sub", 0)))
@router.post("/transactions/return")
def return_in(
body: ReturnInRequest,
db: Session = Depends(get_db),
current: dict = Depends(require_permission("inventory.transaction")),
):
return svc.return_in(db, body.serial_device_id, body.return_type, body.notes, int(current.get("sub", 0)))
@router.get("/transactions", response_model=TransactionListResponse)
def list_transactions(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
transaction_type: Optional[str] = None,
material_id: Optional[int] = None,
db: Session = Depends(get_db),
_: dict = Depends(require_permission("inventory.view")),
):
return svc.get_transactions(db, skip, limit, transaction_type, material_id)
@router.get("/transactions/{transaction_id}")
def get_transaction(
transaction_id: int,
db: Session = Depends(get_db),
_: dict = Depends(require_permission("inventory.view")),
):
return svc.get_transaction_detail(db, transaction_id)
@router.get("/batches")
def list_batches(
material_id: int,
db: Session = Depends(get_db),
_: dict = Depends(require_permission("inventory.view")),
):
return svc.get_batches_by_material(db, material_id)
# ── 序列号设备 ────────────────────────────────────────────────────────────────
@router.get("/serial-devices", response_model=SerialDeviceListResponse)
def list_serial_devices(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
material_id: Optional[int] = None,
status: Optional[str] = None,
keyword: Optional[str] = None,
db: Session = Depends(get_db),
_: dict = Depends(require_permission("inventory.view")),
):
return svc.get_serial_devices(db, skip, limit, material_id, status, keyword)
# ── 库存统计 ──────────────────────────────────────────────────────────────────
@router.get("/summary", response_model=InventorySummary)
def get_summary(
db: Session = Depends(get_db),
_: dict = Depends(require_permission("inventory.view")),
):
return svc.get_summary(db)
# ── 盘点 ──────────────────────────────────────────────────────────────────────
@router.get("/checks", response_model=CheckListResponse)
def list_checks(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
material_id: Optional[int] = None,
db: Session = Depends(get_db),
_: dict = Depends(require_permission("inventory.view")),
):
return svc.get_checks(db, skip, limit, material_id)
@router.post("/checks")
def create_check(
body: CheckCreate,
db: Session = Depends(get_db),
current: dict = Depends(require_permission("inventory.check")),
):
return svc.create_check(db, body.model_dump(), int(current.get("sub", 0)))
@router.post("/checks/{check_id}/adjust")
def adjust_check(
check_id: int,
db: Session = Depends(get_db),
current: dict = Depends(require_permission("inventory.check")),
):
return svc.adjust_check(db, check_id, int(current.get("sub", 0)))
+118 -18
View File
@@ -1,8 +1,10 @@
"""OLT 设备管理 API"""
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from sqlalchemy.orm import Session
from sqlalchemy import distinct
from pydantic import BaseModel
from app.core.database import get_db
from app.middleware.permission_middleware import require_permission
from app.models.device import OLTDevice
import pandas as pd
import io
@@ -15,6 +17,7 @@ class OLTCreate(BaseModel):
username: str
password: str
slot_command: str = "display onu slot"
region: str = "城区"
location: str = ""
description: str = ""
@@ -23,17 +26,57 @@ class OLTEdit(BaseModel):
username: str
password: str = None
slot_command: str = "display onu slot"
region: str = "城区"
location: str = ""
description: str = ""
@router.get("/regions")
def get_olt_regions(
db: Session = Depends(get_db),
current: dict = Depends(require_permission('olt.view')),
):
"""获取 OLT 设备的所有区域(受角色数据范围限制)"""
query = db.query(distinct(OLTDevice.region)).filter(
OLTDevice.region.isnot(None),
OLTDevice.region != ''
)
if current.get('role') == 'area_admin' and current.get('assigned_area'):
areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()]
if areas:
query = query.filter(OLTDevice.region.in_(areas))
else:
return []
return sorted([r[0] for r in query.all()])
@router.get("/devices")
def get_devices(db: Session = Depends(get_db)):
return db.query(OLTDevice).all()
def get_devices(
db: Session = Depends(get_db),
current: dict = Depends(require_permission('olt.view')),
):
q = db.query(OLTDevice)
# 区域管理员只能看自己区域的 OLT
if current.get('role') == 'area_admin' and current.get('assigned_area'):
areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()]
if areas:
q = q.filter(OLTDevice.region.in_(areas))
else:
return []
return q.all()
@router.post("/devices")
def create_device(device: OLTCreate, db: Session = Depends(get_db)):
def create_device(
device: OLTCreate,
db: Session = Depends(get_db),
current: dict = Depends(require_permission('olt.manage')),
):
# 区域管理员只能创建自己区域的 OLT
if current.get('role') == 'area_admin' and current.get('assigned_area'):
areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()]
if device.region not in areas:
raise HTTPException(status_code=403, detail="只能管理本区域的 OLT")
db_device = OLTDevice(**device.dict())
db.add(db_device)
db.commit()
@@ -41,15 +84,26 @@ def create_device(device: OLTCreate, db: Session = Depends(get_db)):
@router.put("/devices/{ip_address}")
def update_device(ip_address: str, device: OLTEdit, db: Session = Depends(get_db)):
def update_device(
ip_address: str,
device: OLTEdit,
db: Session = Depends(get_db),
current: dict = Depends(require_permission('olt.manage')),
):
db_device = db.query(OLTDevice).filter(OLTDevice.ip_address == ip_address).first()
if not db_device:
raise HTTPException(status_code=404, detail="设备不存在")
# 区域管理员只能管理自己区域的 OLT
if current.get('role') == 'area_admin' and current.get('assigned_area'):
areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()]
if db_device.region not in areas:
raise HTTPException(status_code=403, detail="只能管理本区域的 OLT")
db_device.username = device.username
if device.password:
db_device.password = device.password
db_device.slot_command = device.slot_command
db_device.region = device.region
db_device.location = device.location
db_device.description = device.description
@@ -58,14 +112,21 @@ def update_device(ip_address: str, device: OLTEdit, db: Session = Depends(get_db
@router.delete("/devices/{ip_address}")
def delete_device(ip_address: str, db: Session = Depends(get_db)):
def delete_device(
ip_address: str,
db: Session = Depends(get_db),
current: dict = Depends(require_permission('olt.manage')),
):
from app.models.device import ONUDevice
device = db.query(OLTDevice).filter(OLTDevice.ip_address == ip_address).first()
if not device:
raise HTTPException(status_code=404, detail="设备不存在")
if current.get('role') == 'area_admin' and current.get('assigned_area'):
areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()]
if device.region not in areas:
raise HTTPException(status_code=403, detail="只能管理本区域的 OLT")
# 检查是否有关联的 ONU 设备
onu_count = db.query(ONUDevice).filter(ONUDevice.olt_id == device.id).count()
if onu_count > 0:
raise HTTPException(status_code=400, detail=f"该 OLT 设备下还有 {onu_count} 个 ONU 设备,无法删除")
@@ -76,7 +137,11 @@ def delete_device(ip_address: str, db: Session = Depends(get_db)):
@router.post("/import")
async def import_devices(file: UploadFile = File(...), db: Session = Depends(get_db)):
async def import_devices(
file: UploadFile = File(...),
db: Session = Depends(get_db),
_: dict = Depends(require_permission('olt.manage')),
):
try:
content = await file.read()
df = pd.read_excel(io.BytesIO(content))
@@ -118,7 +183,7 @@ async def import_devices(file: UploadFile = File(...), db: Session = Depends(get
@router.get("/template")
def download_template():
def download_template(_: dict = Depends(require_permission('olt.manage'))):
from fastapi.responses import FileResponse
return FileResponse(
path="/home/v6ole/pyproject/H3ConuMS2/backend/templates/OLT设备导入模板.xlsx",
@@ -127,7 +192,11 @@ def download_template():
@router.get("/duplicate-macs")
def get_duplicate_macs(olt_id: int = None, db: Session = Depends(get_db)):
def get_duplicate_macs(
olt_id: int = None,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('olt.view')),
):
"""查询重复 MAC 地址记录"""
from app.models.device import DuplicateMac
query = db.query(DuplicateMac)
@@ -148,7 +217,11 @@ def get_duplicate_macs(olt_id: int = None, db: Session = Depends(get_db)):
@router.delete("/duplicate-macs/{record_id}")
def delete_duplicate_mac(record_id: int, db: Session = Depends(get_db)):
def delete_duplicate_mac(
record_id: int,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('olt.manage')),
):
"""删除重复 MAC 记录(已处理后清除)"""
from app.models.device import DuplicateMac
record = db.query(DuplicateMac).filter(DuplicateMac.id == record_id).first()
@@ -164,7 +237,12 @@ class ClearPortRequest(BaseModel):
@router.post("/duplicate-macs/{record_id}/clear-port")
def clear_onu_port(record_id: int, body: ClearPortRequest, db: Session = Depends(get_db)):
def clear_onu_port(
record_id: int,
body: ClearPortRequest,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('olt.manage')),
):
"""通过 SSH 清除指定端口的 ONU 配置,并从 ports 列表中移除该端口"""
from app.models.device import DuplicateMac
from app.services.ssh_service import SSHService
@@ -204,7 +282,10 @@ def clear_onu_port(record_id: int, body: ClearPortRequest, db: Session = Depends
@router.get("/new-devices")
def get_new_devices(db: Session = Depends(get_db)):
def get_new_devices(
db: Session = Depends(get_db),
_: dict = Depends(require_permission('olt.view')),
):
"""查询新发现的设备列表(待补全信息)"""
from app.models.device import NewDevice, ONUDevice
rows = (
@@ -241,7 +322,12 @@ class NewDeviceUpdate(BaseModel):
@router.put("/new-devices/{record_id}")
def update_new_device(record_id: int, body: NewDeviceUpdate, db: Session = Depends(get_db)):
def update_new_device(
record_id: int,
body: NewDeviceUpdate,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('olt.manage')),
):
"""补全新设备信息,完成后从 new_devices 移除"""
from app.models.device import NewDevice, ONUDevice
record = db.query(NewDevice).filter(NewDevice.id == record_id).first()
@@ -265,7 +351,11 @@ def update_new_device(record_id: int, body: NewDeviceUpdate, db: Session = Depen
@router.delete("/new-devices/{record_id}")
def dismiss_new_device(record_id: int, db: Session = Depends(get_db)):
def dismiss_new_device(
record_id: int,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('olt.manage')),
):
"""忽略新设备(不补全信息,仅从待处理列表移除)"""
from app.models.device import NewDevice
record = db.query(NewDevice).filter(NewDevice.id == record_id).first()
@@ -277,7 +367,10 @@ def dismiss_new_device(record_id: int, db: Session = Depends(get_db)):
@router.post("/quick-scan")
def quick_scan(db: Session = Depends(get_db)):
def quick_scan(
db: Session = Depends(get_db),
_: dict = Depends(require_permission('olt.discover')),
):
"""多线程对所有 OLT 同时执行扫描,更新已有设备状态"""
from app.services.check_service import CheckService
from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -339,7 +432,10 @@ def quick_scan(db: Session = Depends(get_db)):
@router.post("/loopback-detection")
def loopback_detection(db: Session = Depends(get_db)):
def loopback_detection(
db: Session = Depends(get_db),
_: dict = Depends(require_permission('olt.loopback')),
):
"""对所有 OLT 并发执行环路检测,返回有环路的端口及对应设备信息"""
from app.models.device import ONUDevice
from app.services.ssh_service import SSHService
@@ -407,7 +503,11 @@ class TogglePortRequest(BaseModel):
@router.get("/devices/{olt_id}/ports")
def get_olt_ports(olt_id: int, db: Session = Depends(get_db)):
def get_olt_ports(
olt_id: int,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('olt.port_manage')),
):
"""获取指定 OLT 的所有 Olt 端口状态"""
from app.services.ssh_service import SSHService
olt = db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
@@ -425,7 +525,7 @@ def get_olt_ports(olt_id: int, db: Session = Depends(get_db)):
@router.post("/devices/{olt_id}/ports/toggle")
def toggle_olt_port(olt_id: int, body: TogglePortRequest, port_name: str, db: Session = Depends(get_db)):
def toggle_olt_port(olt_id: int, body: TogglePortRequest, port_name: str, db: Session = Depends(get_db), _: dict = Depends(require_permission('olt.port_manage'))):
"""开启或关闭指定 OLT 端口"""
from app.services.ssh_service import SSHService
if body.action not in ("shutdown", "undo shutdown"):
+5 -2
View File
@@ -4,6 +4,7 @@ from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import List, Optional
from app.core.database import get_db
from app.middleware.permission_middleware import require_permission
from app.services.provision_service import ProvisionService
router = APIRouter(prefix="/api/provision", tags=["业务下发"])
@@ -29,7 +30,8 @@ class ProvisionResponse(BaseModel):
@router.post("/service", response_model=ProvisionResponse)
def provision_single_device(
request: ProvisionRequest,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.edit')),
):
"""下发业务到单个设备"""
service = ProvisionService(db)
@@ -51,7 +53,8 @@ def provision_single_device(
@router.post("/batch", response_model=dict)
def provision_batch_devices(
request: BatchProvisionRequest,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.edit')),
):
"""批量下发业务"""
service = ProvisionService(db)
+110
View File
@@ -0,0 +1,110 @@
"""角色权限配置 API"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import text
from pydantic import BaseModel
from app.core.database import get_db
from app.middleware.permission_middleware import require_permission, invalidate_role_cache
from app.models.permission import Permission
router = APIRouter(prefix="/api", tags=["角色权限"])
VALID_ROLES = ['admin', 'area_admin', 'school_admin', 'user']
class RolePermissionsUpdate(BaseModel):
permissions: list[str] # 权限码列表
@router.get("/permissions")
def get_all_permissions(
db: Session = Depends(get_db),
_: dict = Depends(require_permission('user.view')),
):
"""获取所有权限码列表(按模块分组)"""
perms = db.query(Permission).order_by(Permission.module, Permission.code).all()
result = {}
for p in perms:
module = p.module or 'other'
if module not in result:
result[module] = []
result[module].append({
"id": p.id,
"name": p.name,
"code": p.code,
"description": p.description,
})
return result
@router.get("/roles")
def get_roles(
db: Session = Depends(get_db),
_: dict = Depends(require_permission('user.view')),
):
"""获取所有角色及其当前权限"""
rows = db.execute(
text("""
SELECT rp.role, p.code
FROM role_permissions rp
JOIN permissions p ON p.id = rp.permission_id
ORDER BY rp.role, p.code
""")
).fetchall()
role_map: dict[str, list[str]] = {r: [] for r in VALID_ROLES}
for role, code in rows:
if role in role_map:
role_map[role].append(code)
# admin 特殊处理
role_map['admin'] = ['*']
return [
{"role": role, "permissions": perms}
for role, perms in role_map.items()
]
@router.put("/roles/{role}/permissions")
def update_role_permissions(
role: str,
body: RolePermissionsUpdate,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('user.manage')),
):
"""更新角色权限(替换全量)"""
if role not in VALID_ROLES:
raise HTTPException(status_code=400, detail=f"无效角色,可选:{', '.join(VALID_ROLES)}")
if role == 'admin':
raise HTTPException(status_code=400, detail="admin 角色权限不可修改")
# 验证权限码是否存在
if body.permissions:
existing = {p.code for p in db.query(Permission).filter(
Permission.code.in_(body.permissions)
).all()}
invalid = set(body.permissions) - existing
if invalid:
raise HTTPException(status_code=400, detail=f"无效权限码:{', '.join(invalid)}")
# 删除旧权限,插入新权限
db.execute(text("DELETE FROM role_permissions WHERE role = :role"), {"role": role})
if body.permissions:
# 查出权限 id 再插入,避免 ANY 语法兼容问题
perm_ids = db.execute(
text("SELECT id FROM permissions WHERE code IN :codes"),
{"codes": tuple(body.permissions)}
).fetchall()
for (pid,) in perm_ids:
db.execute(
text("INSERT INTO role_permissions (role, permission_id) VALUES (:role, :pid)"),
{"role": role, "pid": pid}
)
db.commit()
# 清除 Redis 缓存
invalidate_role_cache(role)
return {"message": "权限更新成功"}
+76
View File
@@ -0,0 +1,76 @@
"""系统设置 API(仅管理员)"""
import time
import redis as redis_lib
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.config import settings
from app.middleware.permission_middleware import require_permission
from app.models.setting import SystemSetting
router = APIRouter(prefix="/api/settings", tags=["系统设置"])
MIN_CHECK_INTERVAL = 300 # 5 分钟
MAX_CHECK_INTERVAL = 86400 # 24 小时
_INTERVAL_REDIS_KEY = "system:check_interval_seconds"
def _get_redis():
return redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
@router.get("")
def get_settings(
db: Session = Depends(get_db),
_: dict = Depends(require_permission('*')),
):
"""获取所有系统设置,附带下次扫描时间"""
rows = db.query(SystemSetting).all()
result = {row.key: {"value": row.value, "description": row.description} for row in rows}
# 计算下次扫描时间
try:
r = _get_redis()
interval_str = r.get(_INTERVAL_REDIS_KEY)
last_run_str = r.get("check_all_devices:last_run")
is_running = bool(r.get("check_all_devices:running"))
interval = int(interval_str) if interval_str else 1800
next_run_ts = (float(last_run_str) + interval) if last_run_str else None
result["next_check_at"] = {
"value": str(int(next_run_ts)) if next_run_ts else None,
"running": is_running,
"description": "下次扫描时间戳(Unix"
}
except Exception:
result["next_check_at"] = {"value": None, "running": False, "description": "下次扫描时间戳(Unix"}
return result
@router.put("/check_interval")
def update_check_interval(
seconds: int,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('*')),
):
"""更新定时检查间隔(秒),范围 300~86400"""
if seconds < MIN_CHECK_INTERVAL:
raise HTTPException(status_code=400, detail=f"间隔不能小于 {MIN_CHECK_INTERVAL} 秒(5分钟)")
if seconds > MAX_CHECK_INTERVAL:
raise HTTPException(status_code=400, detail=f"间隔不能大于 {MAX_CHECK_INTERVAL} 秒(24小时)")
setting = db.query(SystemSetting).filter_by(key='check_interval_seconds').first()
if setting:
setting.value = str(seconds)
else:
db.add(SystemSetting(key='check_interval_seconds', value=str(seconds), description='定时检查间隔(秒)'))
db.commit()
# 同步到 Redis,让 Celery 任务立即生效
_get_redis().set(_INTERVAL_REDIS_KEY, str(seconds))
# 重置上次运行时间,让下次触发时立即按新间隔计算
_get_redis().delete("check_all_devices:last_run")
return {"key": "check_interval_seconds", "value": seconds}
+146 -16
View File
@@ -3,14 +3,18 @@ from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from sqlalchemy import func, case
from app.core.database import get_db
from app.models.device import ONUDevice, DeviceStatusHistory
from datetime import datetime, timedelta
from app.middleware.permission_middleware import require_permission
from app.models.device import ONUDevice, DeviceStatusHistory, DeviceDailySnapshot
from datetime import datetime, timedelta, date
router = APIRouter(prefix="/api/stats", tags=["统计"])
@router.get("/dashboard")
def get_dashboard(db: Session = Depends(get_db)):
def get_dashboard(
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.view')),
):
"""仪表板统计:总体、城区、城郊、乡镇在线率"""
# 每台设备最新状态子查询
latest_subq = (
@@ -92,7 +96,10 @@ def get_dashboard(db: Session = Depends(get_db)):
@router.get("/summary")
def get_summary(db: Session = Depends(get_db)):
def get_summary(
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.view')),
):
"""获取统计摘要"""
total = db.query(ONUDevice).count()
latest_status = db.query(
@@ -103,16 +110,139 @@ def get_summary(db: Session = Depends(get_db)):
return {"total": total, "online": status_dict.get('online', 0), "offline": status_dict.get('offline', 0)}
@router.get("/trend")
def get_trend(days: int = 7, db: Session = Depends(get_db)):
"""获取状态趋势数据"""
start_date = datetime.utcnow() - timedelta(days=days)
history = db.query(
func.date(DeviceStatusHistory.checked_at).label('date'),
func.sum(func.case((DeviceStatusHistory.status == 'online', 1), else_=0)).label('online'),
func.sum(func.case((DeviceStatusHistory.status == 'offline', 1), else_=0)).label('offline')
).filter(DeviceStatusHistory.checked_at >= start_date).group_by(
func.date(DeviceStatusHistory.checked_at)
).all()
return [{"date": str(h.date), "online": h.online, "offline": h.offline} for h in history]
@router.get("/by-region")
def get_by_region(
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.view')),
):
"""各区域设备数量及在线率(用于饼图)"""
latest_subq = (
db.query(
DeviceStatusHistory.onu_device_id,
func.max(DeviceStatusHistory.checked_at).label("max_checked_at")
)
.group_by(DeviceStatusHistory.onu_device_id)
.subquery()
)
latest_status_subq = (
db.query(
DeviceStatusHistory.onu_device_id,
DeviceStatusHistory.status
)
.join(
latest_subq,
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)
)
.subquery()
)
rows = (
db.query(
ONUDevice.region,
func.count().label("total"),
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)).label("online"),
func.sum(case((latest_status_subq.c.status == 'offline', 1), else_=0)).label("offline"),
)
.outerjoin(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id)
.group_by(ONUDevice.region)
.order_by(func.count().desc())
.all()
)
return [
{
"region": row.region or "未知",
"total": int(row.total or 0),
"online": int(row.online or 0),
"offline": int(row.offline or 0),
}
for row in rows
]
@router.get("/trend")
def get_trend(
days: int = 7,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.view')),
):
"""获取状态趋势数据(优先查快照表,不足时实时聚合)"""
today = date.today()
date_range = [(today - timedelta(days=i)).strftime('%Y-%m-%d') for i in range(days - 1, -1, -1)]
# 查快照表(不含今天,今天用实时数据)
snapshots = (
db.query(DeviceDailySnapshot)
.filter(DeviceDailySnapshot.snapshot_date.in_(date_range[:-1]))
.all()
)
snapshot_map = {s.snapshot_date: s for s in snapshots}
# 今天实时聚合
today_str = today.strftime('%Y-%m-%d')
start_of_today = datetime.combine(today, datetime.min.time())
daily_latest_subq = (
db.query(
DeviceStatusHistory.onu_device_id,
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"),
)
.filter(DeviceStatusHistory.checked_at >= start_of_today)
.group_by(DeviceStatusHistory.onu_device_id)
.subquery()
)
today_row = (
db.query(
func.sum(case((DeviceStatusHistory.status == 'online', 1), else_=0)).label("online"),
func.sum(case((DeviceStatusHistory.status == 'offline', 1), else_=0)).label("offline"),
)
.join(
daily_latest_subq,
(DeviceStatusHistory.onu_device_id == daily_latest_subq.c.onu_device_id) &
(DeviceStatusHistory.checked_at == daily_latest_subq.c.max_checked_at)
)
.one()
)
result = []
for d in date_range:
if d == today_str:
result.append({
"date": d,
"online": int(today_row.online or 0),
"offline": int(today_row.offline or 0),
})
elif d in snapshot_map:
s = snapshot_map[d]
result.append({"date": d, "online": s.online, "offline": s.offline})
else:
# 快照缺失时实时聚合该天数据
day = datetime.strptime(d, '%Y-%m-%d').date()
day_start = datetime.combine(day, datetime.min.time())
day_end = datetime.combine(day, datetime.max.time())
subq = (
db.query(
DeviceStatusHistory.onu_device_id,
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"),
)
.filter(DeviceStatusHistory.checked_at.between(day_start, day_end))
.group_by(DeviceStatusHistory.onu_device_id)
.subquery()
)
row = (
db.query(
func.sum(case((DeviceStatusHistory.status == 'online', 1), else_=0)).label("online"),
func.sum(case((DeviceStatusHistory.status == 'offline', 1), else_=0)).label("offline"),
)
.join(
subq,
(DeviceStatusHistory.onu_device_id == subq.c.onu_device_id) &
(DeviceStatusHistory.checked_at == subq.c.max_checked_at)
)
.one()
)
result.append({"date": d, "online": int(row.online or 0), "offline": int(row.offline or 0)})
return result
+81
View File
@@ -0,0 +1,81 @@
"""用户管理 API"""
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import asc
from app.core.database import get_db
from app.middleware.permission_middleware import require_permission
from app.models.user import User
from app.schemas.user import UserListResponse, UserListItem, UserRoleUpdate
router = APIRouter(prefix="/api/users", tags=["用户管理"])
VALID_ROLES = {'admin', 'area_admin', 'school_admin', 'user'}
@router.get("", response_model=UserListResponse)
def get_users(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
role: str = None,
keyword: str = None,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('user.view')),
):
"""获取用户列表"""
query = db.query(User)
if role:
query = query.filter(User.role == role)
if keyword:
query = query.filter(
User.username.contains(keyword) | User.email.contains(keyword)
)
query = query.order_by(asc(User.created_at))
total = query.count()
items = query.offset(skip).limit(limit).all()
return {"total": total, "items": items}
@router.put("/{user_id}/role")
def update_user_role(
user_id: int,
body: UserRoleUpdate,
db: Session = Depends(get_db),
current: dict = Depends(require_permission('user.manage')),
):
"""修改用户角色及分配区域/学校"""
if body.role not in VALID_ROLES:
raise HTTPException(status_code=400, detail=f"无效角色,可选:{', '.join(VALID_ROLES)}")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
# 不允许修改自己的角色
if str(user.id) == current.get('sub'):
raise HTTPException(status_code=400, detail="不能修改自己的角色")
user.role = body.role
user.assigned_area = body.assigned_area
user.assigned_school = body.assigned_school
db.commit()
return {"message": "更新成功"}
@router.put("/{user_id}/toggle")
def toggle_user(
user_id: int,
db: Session = Depends(get_db),
current: dict = Depends(require_permission('user.manage')),
):
"""启用/禁用用户"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if str(user.id) == current.get('sub'):
raise HTTPException(status_code=400, detail="不能禁用自己")
user.is_active = not user.is_active
db.commit()
return {"message": "已禁用" if not user.is_active else "已启用", "is_active": user.is_active}
+18 -3
View File
@@ -1,5 +1,6 @@
"""Celery 配置"""
from celery import Celery
from celery.schedules import crontab
from app.core.config import settings
celery_app = Celery(
@@ -12,12 +13,26 @@ celery_app.conf.update(
task_serializer='json',
result_serializer='json',
accept_content=['json'],
timezone='UTC',
timezone='Asia/Shanghai',
enable_utc=True,
# 使用专属队列,避免与同 Redis 上的其他 Celery 项目抢任务
task_default_queue='h3c_onu_ms',
beat_schedule={
'check-devices-every-30-minutes': {
# 每5分钟触发一次(最小间隔),任务内部根据配置的间隔自行节流
'check-devices-scheduler': {
'task': 'app.tasks.check_tasks.check_all_devices',
'schedule': settings.CHECK_INTERVAL,
'schedule': 300,
'options': {'queue': 'h3c_onu_ms'},
},
'aggregate-daily-snapshot': {
'task': 'app.tasks.check_tasks.aggregate_daily_snapshot',
'schedule': crontab(hour=1, minute=0), # 每天凌晨 1:00
'options': {'queue': 'h3c_onu_ms'},
},
'cleanup-audit-logs': {
'task': 'app.tasks.audit_tasks.cleanup_audit_logs_task',
'schedule': crontab(hour=2, minute=0), # 每天凌晨 2:00
'options': {'queue': 'h3c_onu_ms'},
},
},
)
+8 -1
View File
@@ -2,7 +2,8 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.api.v1 import auth, devices, check, import_data, stats, olt, provision
from app.api.v1 import auth, devices, check, import_data, stats, olt, provision, users, roles, inventory, settings as settings_api, audit
from app.middleware.audit_middleware import AuditMiddleware
app = FastAPI(title=settings.APP_NAME, debug=settings.DEBUG)
@@ -13,6 +14,7 @@ app.add_middleware(
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(AuditMiddleware)
app.include_router(auth.router)
app.include_router(devices.router)
@@ -21,6 +23,11 @@ app.include_router(import_data.router)
app.include_router(stats.router)
app.include_router(olt.router)
app.include_router(provision.router)
app.include_router(users.router)
app.include_router(roles.router)
app.include_router(inventory.router)
app.include_router(settings_api.router)
app.include_router(audit.router)
@app.get("/health")
+109
View File
@@ -0,0 +1,109 @@
"""审计日志中间件:拦截所有 API 请求,异步写入审计日志"""
import json
import time
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from app.core.security import verify_token
# 不记录审计日志的路径前缀
_SKIP_PATHS = {
"/health",
"/docs",
"/redoc",
"/openapi.json",
"/api/auth/login", # 仅获取登录 URL,无用户身份
"/api/auth/permissions", # 高频只读
"/api/stats/",
"/api/olt/regions",
"/api/olt/new-devices",
"/api/olt/duplicate-macs",
}
# 只记录写操作 + 登录回调 + 特定查询(GET 默认跳过,以下 GET 例外)
_ALWAYS_LOG_METHODS = {"POST", "PUT", "DELETE", "PATCH"}
_LOG_GET_PATHS = {
"/api/auth/profile",
}
def _should_log(method: str, path: str) -> bool:
for skip in _SKIP_PATHS:
if path.startswith(skip):
return False
if method in _ALWAYS_LOG_METHODS:
return True
if method == "GET":
return path in _LOG_GET_PATHS
return False
def _extract_token_payload(request: Request) -> dict:
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
payload = verify_token(auth[7:])
if payload:
return payload
return {}
class AuditMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
method = request.method
path = request.url.path
if not _should_log(method, path):
return await call_next(request)
# 读取请求体(只读一次,需要重新构造)
request_params = None
try:
body_bytes = await request.body()
if body_bytes:
try:
request_params = json.loads(body_bytes)
# 脱敏:移除密码字段
if isinstance(request_params, dict):
for k in ("password", "passwd", "secret"):
if k in request_params:
request_params[k] = "***"
except Exception:
pass
except Exception:
pass
start_time = time.time()
response = await call_next(request)
duration_ms = int((time.time() - start_time) * 1000)
# 异步写日志(不等待)
try:
payload = _extract_token_payload(request)
user_id = payload.get("sub", "anonymous")
username = payload.get("username", "anonymous")
user_role = payload.get("role", "")
ip_address = ""
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
ip_address = forwarded.split(",")[0].strip()
elif request.client:
ip_address = request.client.host
from app.tasks.audit_tasks import create_audit_log_task
create_audit_log_task.delay(
user_id=str(user_id),
username=username,
user_role=user_role,
method=method,
path=path,
ip_address=ip_address,
user_agent=request.headers.get("user-agent", "")[:500],
status_code=response.status_code,
request_params=request_params,
response_data=None, # 不捕获响应体(性能考虑)
error_message=None if response.status_code < 400 else f"HTTP {response.status_code}",
)
except Exception:
pass # 中间件异常绝不影响主响应
return response
+85 -16
View File
@@ -1,27 +1,96 @@
"""权限检查中间件"""
from fastapi import HTTPException, Depends
"""权限检查中间件(数据库驱动 + Redis 缓存)"""
import json
from fastapi import HTTPException, Depends, Header
from sqlalchemy.orm import Session
from sqlalchemy import text
import redis
from app.core.database import get_db
from app.core.security import verify_token
from app.core.config import settings
ROLE_PERMISSIONS = {
'admin': ['*'],
'area_admin': ['device.view', 'device.check'],
'school_admin': ['device.view'],
'user': ['device.view']
}
_redis_client = None
def check_permission(required_permission: str):
def permission_checker(token: str):
def get_redis() -> redis.Redis:
global _redis_client
if _redis_client is None:
_redis_client = redis.from_url(settings.REDIS_URL, decode_responses=True)
return _redis_client
def get_role_permissions(role: str, db: Session) -> list:
"""从数据库加载角色权限,结果缓存到 Redis(TTL 5分钟)"""
if role == 'admin':
return ['*']
r = get_redis()
cache_key = f"permissions:role:{role}"
cached = r.get(cache_key)
if cached:
return json.loads(cached)
rows = db.execute(
text("""
SELECT p.code FROM permissions p
JOIN role_permissions rp ON rp.permission_id = p.id
WHERE rp.role = :role
"""),
{"role": role}
).fetchall()
perms = [row[0] for row in rows]
r.setex(cache_key, 300, json.dumps(perms))
return perms
def invalidate_role_cache(role: str) -> None:
"""修改角色权限后清除缓存"""
get_redis().delete(f"permissions:role:{role}")
def require_permission(permission: str):
"""FastAPI Depends 工厂,检查 Bearer token 中的角色是否拥有指定权限"""
def dependency(
authorization: str = Header(..., alias="Authorization"),
db: Session = Depends(get_db),
) -> dict:
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未授权")
token = authorization[7:]
payload = verify_token(token)
if not payload:
raise HTTPException(status_code=401, detail="未授权")
raise HTTPException(status_code=401, detail="令牌无效或已过期")
role = payload.get('role', 'user')
permissions = ROLE_PERMISSIONS.get(role, [])
if '*' in permissions or required_permission in permissions:
return payload
perms = get_role_permissions(role, db)
if '*' not in perms and permission not in perms:
raise HTTPException(status_code=403, detail="权限不足")
return permission_checker
# 附加用户的区域/学校分配信息,供数据范围过滤使用
user_id = payload.get('sub')
if user_id and role in ('area_admin', 'school_admin'):
from app.models.user import User
user = db.query(User).filter(User.id == int(user_id)).first()
if user:
payload['assigned_area'] = user.assigned_area
payload['assigned_school'] = user.assigned_school
return payload
return dependency
def get_current_user(
authorization: str = Header(..., alias="Authorization"),
db: Session = Depends(get_db),
) -> dict:
"""仅验证登录状态,不检查具体权限"""
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未授权")
token = authorization[7:]
payload = verify_token(token)
if not payload:
raise HTTPException(status_code=401, detail="令牌无效或已过期")
return payload
+52
View File
@@ -0,0 +1,52 @@
"""审计日志数据库模型"""
from sqlalchemy import Column, Integer, String, Text, TIMESTAMP, Index
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.sql import func
from app.core.database import Base
class AuditLog(Base):
__tablename__ = "audit_logs"
id = Column(Integer, primary_key=True, index=True)
# 用户信息
user_id = Column(String(100), nullable=False)
username = Column(String(100), nullable=False)
user_role = Column(String(50))
# 操作信息
action_time = Column(TIMESTAMP, nullable=False, server_default=func.now())
action_type = Column(String(50), nullable=False) # auth/device/olt/user/system/inventory
action_subtype = Column(String(50)) # create/update/delete/login/...
# 请求信息
ip_address = Column(String(45))
user_agent = Column(Text)
request_method = Column(String(10))
request_path = Column(String(500))
# 操作结果
status = Column(String(20), nullable=False) # success/failed/error
status_code = Column(Integer)
# 资源信息
resource_type = Column(String(50))
resource_id = Column(String(100))
resource_name = Column(String(200))
# 日志内容
description = Column(Text, nullable=False)
request_params = Column(JSONB)
response_data = Column(JSONB)
error_message = Column(Text)
created_at = Column(TIMESTAMP, nullable=False, server_default=func.now())
__table_args__ = (
Index("idx_audit_logs_action_time", "action_time"),
Index("idx_audit_logs_user_id", "user_id"),
Index("idx_audit_logs_action_type", "action_type"),
Index("idx_audit_logs_resource_type", "resource_type"),
Index("idx_audit_logs_status", "status"),
)
+28
View File
@@ -13,6 +13,7 @@ class OLTDevice(Base):
username = Column(String(100), nullable=False)
password = Column(Text, nullable=False)
slot_command = Column(String(50), nullable=False)
region = Column(String(100), index=True)
location = Column(String(200))
description = Column(Text)
created_at = Column(TIMESTAMP, server_default=func.now())
@@ -72,6 +73,18 @@ class DuplicateMac(Base):
last_seen_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
class DeviceDailySnapshot(Base):
"""设备每日状态快照(用于趋势图,避免全量扫描历史表)"""
__tablename__ = "device_daily_snapshots"
id = Column(BigInteger, primary_key=True, index=True)
snapshot_date = Column(String(10), nullable=False, index=True) # YYYY-MM-DD
total = Column(Integer, nullable=False, default=0)
online = Column(Integer, nullable=False, default=0)
offline = Column(Integer, nullable=False, default=0)
created_at = Column(TIMESTAMP, server_default=func.now())
class NewDevice(Base):
"""新发现设备(OLT 扫描到但尚未补全信息的设备)"""
__tablename__ = "new_devices"
@@ -80,3 +93,18 @@ class NewDevice(Base):
onu_device_id = Column(BigInteger, ForeignKey("onu_devices.id"), nullable=False, unique=True)
olt_id = Column(BigInteger, ForeignKey("olt_devices.id"), nullable=False)
discovered_at = Column(TIMESTAMP, server_default=func.now())
class DeviceReplacement(Base):
"""设备更换记录"""
__tablename__ = "device_replacements"
id = Column(BigInteger, primary_key=True, index=True)
onu_device_id = Column(BigInteger, ForeignKey("onu_devices.id"), nullable=False, index=True)
old_mac = Column(String(17), nullable=False)
new_mac = Column(String(17), nullable=False)
reason = Column(Text)
operator_id = Column(String(100)) # 操作人 user_id
operator_name = Column(String(100))
replaced_at = Column(TIMESTAMP, nullable=False, server_default=func.now())
created_at = Column(TIMESTAMP, server_default=func.now())
+117
View File
@@ -0,0 +1,117 @@
"""库存管理数据模型"""
from sqlalchemy import Column, BigInteger, String, Integer, Text, TIMESTAMP, ForeignKey, Boolean, Numeric, Date
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
class MaterialCategory(Base):
__tablename__ = "material_categories"
id = Column(BigInteger, primary_key=True, index=True)
name = Column(String(100), nullable=False)
code = Column(String(50), unique=True, nullable=False)
description = Column(Text)
created_at = Column(TIMESTAMP, server_default=func.now())
materials = relationship("Material", back_populates="category")
class Material(Base):
__tablename__ = "materials"
id = Column(BigInteger, primary_key=True, index=True)
category_id = Column(BigInteger, ForeignKey("material_categories.id"))
name = Column(String(200), nullable=False)
model = Column(String(100))
specification = Column(Text)
brand = Column(String(100))
unit = Column(String(20), default="")
safe_quantity = Column(Integer, default=0)
notes = Column(Text)
created_at = Column(TIMESTAMP, server_default=func.now())
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
category = relationship("MaterialCategory", back_populates="materials")
batches = relationship("InventoryBatch", back_populates="material")
serial_devices = relationship("SerialDevice", back_populates="material")
class InventoryBatch(Base):
__tablename__ = "inventory_batches"
id = Column(BigInteger, primary_key=True, index=True)
material_id = Column(BigInteger, ForeignKey("materials.id"))
batch_no = Column(String(50), nullable=False)
quantity = Column(Integer, nullable=False)
available_quantity = Column(Integer, nullable=False, default=0)
supplier = Column(String(200))
purchase_date = Column(Date)
purchase_price = Column(Numeric(10, 2))
expiry_date = Column(Date)
location = Column(String(100))
status = Column(String(20), default="in_stock") # in_stock, reserved, out_of_stock
created_at = Column(TIMESTAMP, server_default=func.now())
material = relationship("Material", back_populates="batches")
serial_devices = relationship("SerialDevice", back_populates="batch")
class SerialDevice(Base):
__tablename__ = "serial_devices"
id = Column(BigInteger, primary_key=True, index=True)
material_id = Column(BigInteger, ForeignKey("materials.id"))
batch_id = Column(BigInteger, ForeignKey("inventory_batches.id"))
serial_no = Column(String(100), unique=True, nullable=False)
mac_address = Column(String(17), index=True)
asset_no = Column(String(50))
status = Column(String(20), default="in_stock", index=True)
# in_stock, allocated, installed, in_use, returned, repairing, scrapped
current_location = Column(String(200))
installed_info = Column(JSONB)
onu_device_id = Column(BigInteger, ForeignKey("onu_devices.id"), nullable=True)
notes = Column(Text)
created_at = Column(TIMESTAMP, server_default=func.now())
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
material = relationship("Material", back_populates="serial_devices")
batch = relationship("InventoryBatch", back_populates="serial_devices")
class InventoryTransaction(Base):
__tablename__ = "inventory_transactions"
id = Column(BigInteger, primary_key=True, index=True)
transaction_no = Column(String(50), unique=True, nullable=False)
transaction_type = Column(String(20), nullable=False)
# purchase_in, allocate_out, return_in, scrap_out, adjust
material_id = Column(BigInteger, ForeignKey("materials.id"))
batch_id = Column(BigInteger, ForeignKey("inventory_batches.id"), nullable=True)
serial_device_id = Column(BigInteger, ForeignKey("serial_devices.id"), nullable=True)
quantity = Column(Integer, nullable=False)
from_status = Column(String(20))
to_status = Column(String(20))
operator_id = Column(BigInteger, ForeignKey("users.id"))
project_name = Column(String(200))
installation_info = Column(JSONB)
notes = Column(Text)
created_at = Column(TIMESTAMP, server_default=func.now())
class InventoryCheck(Base):
__tablename__ = "inventory_checks"
id = Column(BigInteger, primary_key=True, index=True)
check_no = Column(String(50), unique=True, nullable=False)
check_date = Column(Date, nullable=False)
checker_id = Column(BigInteger, ForeignKey("users.id"))
material_id = Column(BigInteger, ForeignKey("materials.id"))
batch_id = Column(BigInteger, ForeignKey("inventory_batches.id"), nullable=True)
book_quantity = Column(Integer)
actual_quantity = Column(Integer)
difference = Column(Integer)
reason = Column(Text)
adjusted = Column(Boolean, default=False)
created_at = Column(TIMESTAMP, server_default=func.now())
+13
View File
@@ -0,0 +1,13 @@
"""系统设置模型"""
from sqlalchemy import Column, String, Text, TIMESTAMP
from sqlalchemy.sql import func
from app.core.database import Base
class SystemSetting(Base):
__tablename__ = "system_settings"
key = Column(String(100), primary_key=True)
value = Column(Text, nullable=False)
description = Column(Text)
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
+238
View File
@@ -0,0 +1,238 @@
"""库存管理 Pydantic 模式"""
from typing import Optional, List, Any
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel
# ── 物料分类 ──────────────────────────────────────────────────────────────────
class CategoryCreate(BaseModel):
name: str
code: str
description: Optional[str] = None
class CategoryResponse(BaseModel):
id: int
name: str
code: str
description: Optional[str] = None
created_at: Optional[datetime] = None
class Config:
from_attributes = True
# ── 物料 ──────────────────────────────────────────────────────────────────────
class MaterialCreate(BaseModel):
category_id: int
name: str
model: Optional[str] = None
specification: Optional[str] = None
brand: Optional[str] = None
unit: str = ""
safe_quantity: int = 0
notes: Optional[str] = None
class MaterialUpdate(BaseModel):
name: Optional[str] = None
model: Optional[str] = None
specification: Optional[str] = None
brand: Optional[str] = None
unit: Optional[str] = None
safe_quantity: Optional[int] = None
notes: Optional[str] = None
class MaterialResponse(BaseModel):
id: int
category_id: int
category_name: Optional[str] = None
name: str
model: Optional[str] = None
specification: Optional[str] = None
brand: Optional[str] = None
unit: str
safe_quantity: int
notes: Optional[str] = None
total_quantity: int = 0
available_quantity: int = 0
created_at: Optional[datetime] = None
class Config:
from_attributes = True
class MaterialListResponse(BaseModel):
total: int
items: List[MaterialResponse]
# ── 库存批次 ──────────────────────────────────────────────────────────────────
class BatchCreate(BaseModel):
material_id: int
batch_no: str
quantity: int
supplier: Optional[str] = None
purchase_date: Optional[date] = None
purchase_price: Optional[Decimal] = None
location: Optional[str] = None
notes: Optional[str] = None
class BatchResponse(BaseModel):
id: int
material_id: int
material_name: Optional[str] = None
batch_no: str
quantity: int
available_quantity: int
supplier: Optional[str] = None
purchase_date: Optional[date] = None
purchase_price: Optional[Decimal] = None
location: Optional[str] = None
status: str
created_at: Optional[datetime] = None
class Config:
from_attributes = True
# ── 序列号设备 ────────────────────────────────────────────────────────────────
class SerialDeviceCreate(BaseModel):
material_id: int
batch_id: Optional[int] = None
serial_no: str
mac_address: Optional[str] = None
asset_no: Optional[str] = None
notes: Optional[str] = None
class SerialDeviceResponse(BaseModel):
id: int
material_id: int
material_name: Optional[str] = None
batch_id: Optional[int] = None
serial_no: str
mac_address: Optional[str] = None
asset_no: Optional[str] = None
status: str
current_location: Optional[str] = None
installed_info: Optional[Any] = None
notes: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
class SerialDeviceListResponse(BaseModel):
total: int
items: List[SerialDeviceResponse]
# ── 出入库操作 ────────────────────────────────────────────────────────────────
class PurchaseInRequest(BaseModel):
material_id: int
batch_no: str
quantity: int
supplier: Optional[str] = None
purchase_date: Optional[date] = None
purchase_price: Optional[Decimal] = None
location: Optional[str] = None
serial_nos: Optional[List[str]] = None # 高价值设备的序列号列表
notes: Optional[str] = None
class AllocateOutRequest(BaseModel):
serial_device_id: Optional[int] = None # 序列号设备
batch_id: Optional[int] = None # 批次设备
quantity: int = 1
project_name: Optional[str] = None
installation_info: Optional[Any] = None
# 方案三:领用时补录设备标识(适用于批次出库场景)
mac_address: Optional[str] = None # MAC 地址
serial_no: Optional[str] = None # 序列号
asset_no: Optional[str] = None # 资产编号
notes: Optional[str] = None
class ReturnInRequest(BaseModel):
serial_device_id: int
return_type: str # simple, repair, scrap
notes: Optional[str] = None
class TransactionResponse(BaseModel):
id: int
transaction_no: str
transaction_type: str
material_id: int
material_name: Optional[str] = None
quantity: int
from_status: Optional[str] = None
to_status: Optional[str] = None
operator_id: Optional[int] = None
project_name: Optional[str] = None
installation_info: Optional[Any] = None # 包含补录的设备信息
notes: Optional[str] = None
created_at: Optional[datetime] = None
class Config:
from_attributes = True
class TransactionListResponse(BaseModel):
total: int
items: List[TransactionResponse]
# ── 盘点 ──────────────────────────────────────────────────────────────────────
class CheckCreate(BaseModel):
check_date: date
material_id: int
batch_id: Optional[int] = None
actual_quantity: int
reason: Optional[str] = None
class CheckResponse(BaseModel):
id: int
check_no: str
check_date: date
material_id: int
material_name: Optional[str] = None
batch_id: Optional[int] = None
book_quantity: Optional[int] = None
actual_quantity: Optional[int] = None
difference: Optional[int] = None
reason: Optional[str] = None
adjusted: bool
created_at: Optional[datetime] = None
class Config:
from_attributes = True
class CheckListResponse(BaseModel):
total: int
items: List[CheckResponse]
# ── 统计 ──────────────────────────────────────────────────────────────────────
class InventorySummary(BaseModel):
total_materials: int
total_quantity: int
available_quantity: int
allocated_quantity: int
low_stock_count: int
category_stats: List[dict]
+30
View File
@@ -0,0 +1,30 @@
"""用户相关 Schema"""
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class UserListItem(BaseModel):
id: int
username: str
email: Optional[str] = None
role: str
assigned_area: Optional[str] = None
assigned_school: Optional[str] = None
is_active: bool
last_login: Optional[datetime] = None
created_at: Optional[datetime] = None
class Config:
from_attributes = True
class UserListResponse(BaseModel):
total: int
items: list[UserListItem]
class UserRoleUpdate(BaseModel):
role: str # admin / area_admin / school_admin / user
assigned_area: Optional[str] = None
assigned_school: Optional[str] = None
+183
View File
@@ -0,0 +1,183 @@
"""审计日志服务"""
import os
from datetime import datetime, timedelta
from typing import Optional
from sqlalchemy.orm import Session
from sqlalchemy import and_
from app.models.audit_log import AuditLog
AUDIT_LOG_DIR = os.environ.get("AUDIT_LOG_DIR", "/app/logs/audit")
AUDIT_LOG_RETENTION_DAYS = int(os.environ.get("AUDIT_LOG_RETENTION_DAYS", "90"))
# 路由分类规则:(method, path_pattern, exact, action_type, action_subtype, resource_type, description)
# exact=True 精确匹配路径;exact=False 前缀匹配。
# 精确匹配规则排在前,前缀匹配按从长到短排列,避免短前缀误匹配。
_ROUTE_MAP = [
# ── 认证 ──────────────────────────────────────────────────────
("POST", "/api/auth/callback", True, "auth", "login", "user", "用户登录"),
("GET", "/api/auth/profile", True, "auth", "profile", "user", "查看个人信息"),
# ── OLT:精确路径(不含 ID 段)────────────────────────────────
("GET", "/api/olt/devices", True, "olt", "list", "olt", "查询 OLT 设备列表"),
("POST", "/api/olt/devices", True, "olt", "create", "olt", "新建 OLT 设备"),
("POST", "/api/olt/import", True, "olt", "import", "olt", "批量导入 OLT 设备"),
("POST", "/api/olt/quick-scan", True, "olt", "quick_scan", "olt", "触发快速扫描"),
("POST", "/api/olt/loopback-detection", True, "olt", "loopback", "olt", "触发环路检测"),
# OLT 端口操作(路径含 ID,前缀匹配;/ports/toggle 比 /devices/ 更具体,先列)
("POST", "/api/olt/devices/", False, "olt", "port_toggle", "olt", "切换 OLT 端口状态"),
("GET", "/api/olt/devices/", False, "olt", "port_list", "olt", "查询 OLT 端口列表"),
("PUT", "/api/olt/devices/", False, "olt", "update", "olt", "更新 OLT 设备信息"),
("DELETE", "/api/olt/devices/", False, "olt", "delete", "olt", "删除 OLT 设备"),
# 重复 MAC
("POST", "/api/olt/duplicate-macs/", False, "olt", "port_clear", "olt", "清除重复 MAC 端口占用"),
("DELETE", "/api/olt/duplicate-macs/", False, "olt", "mac_delete", "olt", "删除重复 MAC 记录"),
# 新发现设备
("PUT", "/api/olt/new-devices/", False, "olt", "device_fill", "olt", "补全新发现设备信息"),
("DELETE", "/api/olt/new-devices/", False, "olt", "device_ignore", "olt", "忽略新发现设备"),
# ── ONU 设备 ──────────────────────────────────────────────────
("POST", "/api/check/status", True, "system", "scan_trigger", "device", "手动触发全量状态扫描"),
("POST", "/api/import/upload", True, "device", "import", "device", "批量导入 ONU 设备"),
("DELETE", "/api/devices/status/all", True, "device", "status_clear", "device", "清除所有设备状态"),
("POST", "/api/devices/", False, "device", "refresh", "device", "刷新单台设备在线状态"),
("PUT", "/api/devices/", False, "device", "update", "device", "更新 ONU 设备信息"),
("DELETE", "/api/devices/", False, "device", "delete", "device", "删除 ONU 设备"),
# 设备更换(路径含 /replace,需在 refresh 前匹配,通过 _classify 特殊处理)
("POST", "/replace", False, "device", "replace", "device", "更换设备 MAC 地址"),
# ── 用户管理 ──────────────────────────────────────────────────
("POST", "/api/users", True, "user", "create", "user", "创建用户"),
("PUT", "/api/users/", False, "user", "update", "user", "更新用户信息"),
("DELETE", "/api/users/", False, "user", "delete", "user", "删除用户"),
# ── 角色权限 ──────────────────────────────────────────────────
("PUT", "/api/roles/", False, "user", "role_update", "role", "更新角色权限配置"),
# ── 系统设置 ──────────────────────────────────────────────────
("PUT", "/api/settings/check_interval", True, "system", "config_update", "system", "更新定时扫描间隔"),
("PUT", "/api/settings/", False, "system", "config_update", "system", "更新系统配置"),
# ── 库存管理(精确路径优先)───────────────────────────────────
("POST", "/api/inventory/transactions/purchase", True, "inventory", "purchase", "inventory", "物料采购入库"),
("POST", "/api/inventory/transactions/allocate", True, "inventory", "allocate", "inventory", "物料分配出库"),
("POST", "/api/inventory/transactions/return", True, "inventory", "return", "inventory", "物料退库"),
("POST", "/api/inventory/categories", True, "inventory", "cat_create", "inventory", "新建库存分类"),
("POST", "/api/inventory/materials", True, "inventory", "mat_create", "inventory", "新建物料"),
("PUT", "/api/inventory/materials/", False, "inventory", "mat_update", "inventory", "更新物料信息"),
("DELETE", "/api/inventory/materials/", False, "inventory", "mat_delete", "inventory", "删除物料"),
("POST", "/api/inventory/checks", True, "inventory", "check_create", "inventory", "发起库存盘点"),
("POST", "/api/inventory/checks/", False, "inventory", "check_adjust", "inventory", "盘点差异调整"),
("PUT", "/api/inventory/checks/", False, "inventory", "check_update", "inventory", "更新盘点记录"),
]
def _classify(method: str, path: str):
"""根据请求方法和路径推断操作分类,精确匹配优先于前缀匹配"""
# 第一轮:精确匹配
for m, p, exact, atype, subtype, rtype, desc in _ROUTE_MAP:
if exact and method == m and path == p:
return atype, subtype, rtype, desc
# 第二轮:后缀匹配(path_pattern 以 "/" 开头但不含 "/api",视为后缀)
for m, p, exact, atype, subtype, rtype, desc in _ROUTE_MAP:
if not exact and not p.startswith('/api') and method == m and path.endswith(p):
return atype, subtype, rtype, desc
# 第三轮:前缀匹配(规则列表已按从具体到宽泛排列)
for m, p, exact, atype, subtype, rtype, desc in _ROUTE_MAP:
if not exact and p.startswith('/api') and method == m and path.startswith(p):
return atype, subtype, rtype, desc
return "system", "request", "unknown", f"{method} {path}"
def _get_ip(request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
if request.client:
return request.client.host
return ""
def write_audit_log(
db: Session,
*,
user_id: str,
username: str,
user_role: str,
method: str,
path: str,
ip_address: str = "",
user_agent: str = "",
status_code: int,
request_params: Optional[dict] = None,
response_data: Optional[dict] = None,
error_message: Optional[str] = None,
description: Optional[str] = None,
resource_id: Optional[str] = None,
resource_name: Optional[str] = None,
):
action_type, action_subtype, resource_type, default_desc = _classify(method, path)
status = "success" if status_code < 400 else ("failed" if status_code < 500 else "error")
log = AuditLog(
user_id=user_id,
username=username,
user_role=user_role,
action_type=action_type,
action_subtype=action_subtype,
ip_address=ip_address,
user_agent=user_agent[:500] if user_agent else "",
request_method=method,
request_path=path,
status=status,
status_code=status_code,
resource_type=resource_type,
resource_id=resource_id,
resource_name=resource_name,
description=description or default_desc,
request_params=request_params,
response_data=response_data if status_code < 400 else None,
error_message=error_message,
)
db.add(log)
db.commit()
return log.id
def query_logs(
db: Session,
*,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
user_id: Optional[str] = None,
username: Optional[str] = None,
action_type: Optional[str] = None,
resource_type: Optional[str] = None,
status: Optional[str] = None,
page: int = 1,
page_size: int = 50,
):
q = db.query(AuditLog)
filters = []
if start_time:
filters.append(AuditLog.action_time >= start_time)
if end_time:
filters.append(AuditLog.action_time <= end_time)
if user_id:
filters.append(AuditLog.user_id == user_id)
if username:
filters.append(AuditLog.username.ilike(f"%{username}%"))
if action_type:
filters.append(AuditLog.action_type == action_type)
if resource_type:
filters.append(AuditLog.resource_type == resource_type)
if status:
filters.append(AuditLog.status == status)
if filters:
q = q.filter(and_(*filters))
total = q.count()
items = q.order_by(AuditLog.action_time.desc()).offset((page - 1) * page_size).limit(page_size).all()
return total, items
def cleanup_old_logs(db: Session, retention_days: int = AUDIT_LOG_RETENTION_DAYS):
"""清理超过保留期的日志"""
cutoff = datetime.utcnow() - timedelta(days=retention_days)
deleted = db.query(AuditLog).filter(AuditLog.created_at < cutoff).delete()
db.commit()
return deleted
+543
View File
@@ -0,0 +1,543 @@
"""库存管理业务逻辑"""
import uuid
from datetime import date, datetime
from typing import Optional, List
from sqlalchemy.orm import Session
from sqlalchemy import func, text
from fastapi import HTTPException
from app.models.inventory import (
MaterialCategory, Material, InventoryBatch,
SerialDevice, InventoryTransaction, InventoryCheck
)
def _gen_no(prefix: str) -> str:
return f"{prefix}{datetime.now().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}"
# ── 物料分类 ──────────────────────────────────────────────────────────────────
def get_categories(db: Session) -> List[MaterialCategory]:
return db.query(MaterialCategory).order_by(MaterialCategory.id).all()
def create_category(db: Session, name: str, code: str, description: Optional[str]) -> MaterialCategory:
if db.query(MaterialCategory).filter(MaterialCategory.code == code).first():
raise HTTPException(status_code=400, detail="分类代码已存在")
cat = MaterialCategory(name=name, code=code, description=description)
db.add(cat)
db.commit()
db.refresh(cat)
return cat
# ── 物料 ──────────────────────────────────────────────────────────────────────
def get_materials(db: Session, skip: int, limit: int, category_id: Optional[int], keyword: Optional[str]):
q = db.query(Material)
if category_id:
q = q.filter(Material.category_id == category_id)
if keyword:
q = q.filter(Material.name.contains(keyword) | Material.model.contains(keyword))
total = q.count()
items = q.order_by(Material.id).offset(skip).limit(limit).all()
result = []
for m in items:
total_qty = db.query(func.sum(InventoryBatch.quantity)).filter(
InventoryBatch.material_id == m.id
).scalar() or 0
avail_qty = db.query(func.sum(InventoryBatch.available_quantity)).filter(
InventoryBatch.material_id == m.id
).scalar() or 0
item = {
"id": m.id,
"category_id": m.category_id,
"category_name": m.category.name if m.category else None,
"name": m.name,
"model": m.model,
"specification": m.specification,
"brand": m.brand,
"unit": m.unit,
"safe_quantity": m.safe_quantity,
"notes": m.notes,
"total_quantity": total_qty,
"available_quantity": avail_qty,
"created_at": m.created_at,
}
result.append(item)
return {"total": total, "items": result}
def create_material(db: Session, data: dict) -> Material:
m = Material(**data)
db.add(m)
db.commit()
db.refresh(m)
return m
def update_material(db: Session, material_id: int, data: dict) -> Material:
m = db.query(Material).filter(Material.id == material_id).first()
if not m:
raise HTTPException(status_code=404, detail="物料不存在")
for k, v in data.items():
if v is not None:
setattr(m, k, v)
db.commit()
db.refresh(m)
return m
def delete_material(db: Session, material_id: int):
m = db.query(Material).filter(Material.id == material_id).first()
if not m:
raise HTTPException(status_code=404, detail="物料不存在")
has_stock = db.query(InventoryBatch).filter(
InventoryBatch.material_id == material_id,
InventoryBatch.available_quantity > 0,
).first()
if has_stock:
raise HTTPException(status_code=400, detail="该物料仍有库存,请先出库后再删除")
db.delete(m)
db.commit()
# ── 采购入库 ──────────────────────────────────────────────────────────────────
def purchase_in(db: Session, data: dict, operator_id: int) -> dict:
material_id = data["material_id"]
m = db.query(Material).filter(Material.id == material_id).first()
if not m:
raise HTTPException(status_code=404, detail="物料不存在")
serial_nos = data.pop("serial_nos", None) or []
quantity = data["quantity"]
batch = InventoryBatch(
material_id=material_id,
batch_no=data["batch_no"],
quantity=quantity,
available_quantity=quantity,
supplier=data.get("supplier"),
purchase_date=data.get("purchase_date"),
purchase_price=data.get("purchase_price"),
location=data.get("location"),
status="in_stock",
)
db.add(batch)
db.flush()
# 高价值设备:逐个创建序列号记录
for sn in serial_nos:
sd = SerialDevice(
material_id=material_id,
batch_id=batch.id,
serial_no=sn,
status="in_stock",
)
db.add(sd)
txn = InventoryTransaction(
transaction_no=_gen_no("PI"),
transaction_type="purchase_in",
material_id=material_id,
batch_id=batch.id,
quantity=quantity,
from_status=None,
to_status="in_stock",
operator_id=operator_id,
notes=data.get("notes"),
)
db.add(txn)
db.commit()
return {"message": "入库成功", "batch_id": batch.id, "transaction_no": txn.transaction_no}
# ── 领用出库 ──────────────────────────────────────────────────────────────────
def allocate_out(db: Session, data: dict, operator_id: int) -> dict:
serial_device_id = data.get("serial_device_id")
batch_id = data.get("batch_id")
quantity = data.get("quantity", 1)
if serial_device_id:
sd = db.query(SerialDevice).filter(SerialDevice.id == serial_device_id).first()
if not sd:
raise HTTPException(status_code=404, detail="设备不存在")
if sd.status != "in_stock":
raise HTTPException(status_code=400, detail=f"设备当前状态为 {sd.status},无法领用")
batch = db.query(InventoryBatch).filter(InventoryBatch.id == sd.batch_id).first()
if batch:
batch.available_quantity = max(0, batch.available_quantity - 1)
sd.status = "allocated"
sd.installed_info = data.get("installation_info")
txn = InventoryTransaction(
transaction_no=_gen_no("AO"),
transaction_type="allocate_out",
material_id=sd.material_id,
batch_id=sd.batch_id,
serial_device_id=sd.id,
quantity=1,
from_status="in_stock",
to_status="allocated",
operator_id=operator_id,
project_name=data.get("project_name"),
installation_info=data.get("installation_info"),
notes=data.get("notes"),
)
db.add(txn)
db.commit()
return {"message": "领用成功", "transaction_no": txn.transaction_no}
elif batch_id:
batch = db.query(InventoryBatch).filter(InventoryBatch.id == batch_id).first()
if not batch:
raise HTTPException(status_code=404, detail="批次不存在")
if batch.available_quantity < quantity:
raise HTTPException(status_code=400, detail="库存不足")
batch.available_quantity -= quantity
# 方案三:领用时补录设备标识,存入 installation_info
device_info = data.get("installation_info") or {}
if isinstance(device_info, str):
device_info = {}
mac = data.get("mac_address", "").strip() if data.get("mac_address") else ""
sn = data.get("serial_no", "").strip() if data.get("serial_no") else ""
asset = data.get("asset_no", "").strip() if data.get("asset_no") else ""
if mac or sn or asset:
device_info = {k: v for k, v in {"mac_address": mac, "serial_no": sn, "asset_no": asset}.items() if v}
txn = InventoryTransaction(
transaction_no=_gen_no("AO"),
transaction_type="allocate_out",
material_id=batch.material_id,
batch_id=batch_id,
quantity=quantity,
from_status="in_stock",
to_status="allocated",
operator_id=operator_id,
project_name=data.get("project_name"),
installation_info=device_info if device_info else None,
notes=data.get("notes"),
)
db.add(txn)
db.commit()
return {"message": "领用成功", "transaction_no": txn.transaction_no}
raise HTTPException(status_code=400, detail="需要指定序列号设备或批次")
# ── 退库 ──────────────────────────────────────────────────────────────────────
def return_in(db: Session, serial_device_id: int, return_type: str, notes: Optional[str], operator_id: int) -> dict:
sd = db.query(SerialDevice).filter(SerialDevice.id == serial_device_id).first()
if not sd:
raise HTTPException(status_code=404, detail="设备不存在")
from_status = sd.status
if return_type == "scrap":
to_status = "scrapped"
elif return_type == "repair":
to_status = "repairing"
else:
to_status = "in_stock"
# 归还时恢复批次可用数量
if sd.batch_id:
batch = db.query(InventoryBatch).filter(InventoryBatch.id == sd.batch_id).first()
if batch:
batch.available_quantity += 1
sd.status = to_status
txn = InventoryTransaction(
transaction_no=_gen_no("RI"),
transaction_type="return_in" if return_type != "scrap" else "scrap_out",
material_id=sd.material_id,
batch_id=sd.batch_id,
serial_device_id=sd.id,
quantity=1,
from_status=from_status,
to_status=to_status,
operator_id=operator_id,
notes=notes,
)
db.add(txn)
db.commit()
return {"message": "退库成功", "transaction_no": txn.transaction_no}
# ── 批次查询 ──────────────────────────────────────────────────────────────────
def get_batches_by_material(db: Session, material_id: int) -> list:
batches = db.query(InventoryBatch).filter(
InventoryBatch.material_id == material_id,
InventoryBatch.available_quantity > 0,
).order_by(InventoryBatch.id.desc()).all()
return [
{
"id": b.id,
"batch_no": b.batch_no,
"quantity": b.quantity,
"available_quantity": b.available_quantity,
"supplier": b.supplier,
"location": b.location,
}
for b in batches
]
# ── 序列号设备查询 ────────────────────────────────────────────────────────────
def get_serial_devices(db: Session, skip: int, limit: int, material_id: Optional[int], status: Optional[str], keyword: Optional[str]):
q = db.query(SerialDevice)
if material_id:
q = q.filter(SerialDevice.material_id == material_id)
if status:
q = q.filter(SerialDevice.status == status)
if keyword:
q = q.filter(
SerialDevice.serial_no.contains(keyword) |
SerialDevice.mac_address.contains(keyword) |
SerialDevice.asset_no.contains(keyword)
)
total = q.count()
items = q.order_by(SerialDevice.id.desc()).offset(skip).limit(limit).all()
result = []
for sd in items:
result.append({
"id": sd.id,
"material_id": sd.material_id,
"material_name": sd.material.name if sd.material else None,
"batch_id": sd.batch_id,
"serial_no": sd.serial_no,
"mac_address": sd.mac_address,
"asset_no": sd.asset_no,
"status": sd.status,
"current_location": sd.current_location,
"installed_info": sd.installed_info,
"notes": sd.notes,
"created_at": sd.created_at,
"updated_at": sd.updated_at,
})
return {"total": total, "items": result}
def get_transaction_detail(db: Session, transaction_id: int) -> dict:
t = db.query(InventoryTransaction).filter(InventoryTransaction.id == transaction_id).first()
if not t:
raise HTTPException(status_code=404, detail="记录不存在")
m = db.query(Material).filter(Material.id == t.material_id).first()
batch = db.query(InventoryBatch).filter(InventoryBatch.id == t.batch_id).first() if t.batch_id else None
sd = db.query(SerialDevice).filter(SerialDevice.id == t.serial_device_id).first() if t.serial_device_id else None
return {
"id": t.id,
"transaction_no": t.transaction_no,
"transaction_type": t.transaction_type,
"material_id": t.material_id,
"material_name": m.name if m else None,
"material_model": m.model if m else None,
"material_brand": m.brand if m else None,
"quantity": t.quantity,
"from_status": t.from_status,
"to_status": t.to_status,
"operator_id": t.operator_id,
"project_name": t.project_name,
"installation_info": t.installation_info,
"notes": t.notes,
"created_at": t.created_at,
"batch": {
"id": batch.id,
"batch_no": batch.batch_no,
"supplier": batch.supplier,
"purchase_date": str(batch.purchase_date) if batch.purchase_date else None,
"purchase_price": str(batch.purchase_price) if batch.purchase_price else None,
"location": batch.location,
} if batch else None,
"serial_device": {
"id": sd.id,
"serial_no": sd.serial_no,
"mac_address": sd.mac_address,
"asset_no": sd.asset_no,
} if sd else None,
}
def get_transactions(db: Session, skip: int, limit: int, transaction_type: Optional[str], material_id: Optional[int]):
q = db.query(InventoryTransaction)
if transaction_type:
q = q.filter(InventoryTransaction.transaction_type == transaction_type)
if material_id:
q = q.filter(InventoryTransaction.material_id == material_id)
total = q.count()
items = q.order_by(InventoryTransaction.id.desc()).offset(skip).limit(limit).all()
result = []
for t in items:
m = db.query(Material).filter(Material.id == t.material_id).first()
result.append({
"id": t.id,
"transaction_no": t.transaction_no,
"transaction_type": t.transaction_type,
"material_id": t.material_id,
"material_name": m.name if m else None,
"quantity": t.quantity,
"from_status": t.from_status,
"to_status": t.to_status,
"operator_id": t.operator_id,
"project_name": t.project_name,
"installation_info": t.installation_info,
"notes": t.notes,
"created_at": t.created_at,
})
return {"total": total, "items": result}
# ── 库存总览 ──────────────────────────────────────────────────────────────────
def get_summary(db: Session) -> dict:
total_materials = db.query(Material).count()
total_qty = db.query(func.sum(InventoryBatch.quantity)).scalar() or 0
avail_qty = db.query(func.sum(InventoryBatch.available_quantity)).scalar() or 0
allocated_qty = total_qty - avail_qty
# 低库存物料数
low_stock = db.execute(text("""
SELECT COUNT(*) FROM (
SELECT m.id, m.safe_quantity, COALESCE(SUM(b.available_quantity), 0) AS avail
FROM materials m
LEFT JOIN inventory_batches b ON b.material_id = m.id
GROUP BY m.id, m.safe_quantity
HAVING COALESCE(SUM(b.available_quantity), 0) <= m.safe_quantity AND m.safe_quantity > 0
) t
""")).scalar() or 0
# 按分类统计
rows = db.execute(text("""
SELECT c.name, COUNT(m.id) AS material_count,
COALESCE(SUM(b.available_quantity), 0) AS available
FROM material_categories c
LEFT JOIN materials m ON m.category_id = c.id
LEFT JOIN inventory_batches b ON b.material_id = m.id
GROUP BY c.id, c.name
ORDER BY c.id
""")).fetchall()
category_stats = [{"name": r[0], "material_count": r[1], "available": r[2]} for r in rows]
return {
"total_materials": total_materials,
"total_quantity": total_qty,
"available_quantity": avail_qty,
"allocated_quantity": allocated_qty,
"low_stock_count": low_stock,
"category_stats": category_stats,
}
# ── 盘点 ──────────────────────────────────────────────────────────────────────
def create_check(db: Session, data: dict, checker_id: int) -> InventoryCheck:
material_id = data["material_id"]
batch_id = data.get("batch_id")
if batch_id:
batch = db.query(InventoryBatch).filter(InventoryBatch.id == batch_id).first()
book_qty = batch.available_quantity if batch else 0
else:
book_qty = db.query(func.sum(InventoryBatch.available_quantity)).filter(
InventoryBatch.material_id == material_id
).scalar() or 0
actual_qty = data["actual_quantity"]
diff = actual_qty - book_qty
check = InventoryCheck(
check_no=_gen_no("CK"),
check_date=data["check_date"],
checker_id=checker_id,
material_id=material_id,
batch_id=batch_id,
book_quantity=book_qty,
actual_quantity=actual_qty,
difference=diff,
reason=data.get("reason"),
adjusted=False,
)
db.add(check)
db.commit()
db.refresh(check)
return check
def adjust_check(db: Session, check_id: int, operator_id: int) -> dict:
check = db.query(InventoryCheck).filter(InventoryCheck.id == check_id).first()
if not check:
raise HTTPException(status_code=404, detail="盘点记录不存在")
if check.adjusted:
raise HTTPException(status_code=400, detail="已调整过")
if check.batch_id:
batch = db.query(InventoryBatch).filter(InventoryBatch.id == check.batch_id).first()
if batch:
batch.available_quantity = check.actual_quantity
else:
# 调整第一个批次(简化处理)
batch = db.query(InventoryBatch).filter(
InventoryBatch.material_id == check.material_id
).first()
if batch:
batch.available_quantity = check.actual_quantity
txn = InventoryTransaction(
transaction_no=_gen_no("ADJ"),
transaction_type="adjust",
material_id=check.material_id,
batch_id=check.batch_id,
quantity=abs(check.difference or 0),
from_status="in_stock",
to_status="in_stock",
operator_id=operator_id,
notes=f"盘点调整:{check.check_no},差异 {check.difference}",
)
db.add(txn)
check.adjusted = True
db.commit()
return {"message": "调整成功"}
def get_checks(db: Session, skip: int, limit: int, material_id: Optional[int]):
q = db.query(InventoryCheck)
if material_id:
q = q.filter(InventoryCheck.material_id == material_id)
total = q.count()
items = q.order_by(InventoryCheck.id.desc()).offset(skip).limit(limit).all()
result = []
for c in items:
m = db.query(Material).filter(Material.id == c.material_id).first()
result.append({
"id": c.id,
"check_no": c.check_no,
"check_date": c.check_date,
"material_id": c.material_id,
"material_name": m.name if m else None,
"batch_id": c.batch_id,
"book_quantity": c.book_quantity,
"actual_quantity": c.actual_quantity,
"difference": c.difference,
"reason": c.reason,
"adjusted": c.adjusted,
"created_at": c.created_at,
})
return {"total": total, "items": result}
+17 -5
View File
@@ -165,14 +165,23 @@ class SSHService:
mac_match = re.search(r'([0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4})', line, re.IGNORECASE)
if mac_match:
mac = mac_match.group(1).lower()
# 提取状态字段
if 'Up' in line:
if re.search(r'\b(up|online)\b', line.lower()):
devices[mac] = 'online'
elif 'Offline' in line:
elif re.search(r'\b(offline|down)\b', line.lower()):
devices[mac] = 'offline'
return devices
def _clean_output(self, output: str) -> str:
"""清理终端控制字符和 More 分页标记,避免污染解析"""
# 移除 ANSI 转义序列
output = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', output)
# 移除 ---- More ---- 行(含前后控制字符)
output = re.sub(r'---- More ----[^\n]*', '', output)
# 将独立的 \r(不跟 \n)替换为空,避免覆盖行内容
output = re.sub(r'\r(?!\n)', '', output)
return output
def parse_onu_info(self, output: str) -> Tuple[Dict[str, ONUInfo], Dict[str, List[ONUInfo]]]:
"""增强解析:提取完整 ONU 信息
返回: (unique_devices, duplicate_devices)
@@ -180,6 +189,7 @@ class SSHService:
- duplicate_devices: MAC -> [ONUInfo, ...] (出现在多个端口的 MAC)
"""
all_records: Dict[str, List[ONUInfo]] = {}
output = self._clean_output(output)
lines = output.split('\n')
current_slot = None
@@ -227,9 +237,11 @@ class SSHService:
mac = mac_match.group(1).lower()
# 提取状态
# 提取状态:H3C OLT 不同固件版本可能输出 Up/UP/up/Online/online
status = 'offline'
if 'Up' in line:
line_lower = line.lower()
# 检查行末状态字段(避免误匹配 "Onu" 中的字母)
if re.search(r'\b(up|online)\b', line_lower):
status = 'online'
# 提取端口信息: Onu1/0/2:1 -> slot=2, port=1, port_id="1/0/2:1"
+62
View File
@@ -0,0 +1,62 @@
"""审计日志 Celery 任务"""
import traceback
from app.core.celery_app import celery_app
from app.core.database import SessionLocal
@celery_app.task(queue='h3c_onu_ms', ignore_result=True)
def create_audit_log_task(
user_id: str,
username: str,
user_role: str,
method: str,
path: str,
ip_address: str,
user_agent: str,
status_code: int,
request_params: dict = None,
response_data: dict = None,
error_message: str = None,
description: str = None,
resource_id: str = None,
resource_name: str = None,
):
"""异步写入审计日志,不阻塞主请求流程"""
from app.services.audit_service import write_audit_log
db = SessionLocal()
try:
write_audit_log(
db,
user_id=user_id,
username=username,
user_role=user_role,
method=method,
path=path,
ip_address=ip_address,
user_agent=user_agent,
status_code=status_code,
request_params=request_params,
response_data=response_data,
error_message=error_message,
description=description,
resource_id=resource_id,
resource_name=resource_name,
)
except Exception:
pass # 审计日志失败不影响主业务
finally:
db.close()
@celery_app.task(queue='h3c_onu_ms')
def cleanup_audit_logs_task():
"""清理90天前的审计日志(每天执行)"""
from app.services.audit_service import cleanup_old_logs
db = SessionLocal()
try:
deleted = cleanup_old_logs(db)
return {'success': True, 'deleted': deleted}
except Exception as e:
return {'success': False, 'error': str(e), 'traceback': traceback.format_exc()}
finally:
db.close()
+105 -1
View File
@@ -1,13 +1,56 @@
"""状态检查任务"""
import time
import traceback
import redis as redis_lib
from datetime import datetime, timedelta
from sqlalchemy import func, case
from app.core.celery_app import celery_app
from app.core.database import SessionLocal
from app.core.config import settings
from app.services.check_service import CheckService
_LAST_RUN_KEY = "check_all_devices:last_run"
_RUNNING_KEY = "check_all_devices:running"
_INTERVAL_REDIS_KEY = "system:check_interval_seconds"
def _get_redis():
return redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
def _get_check_interval(r) -> int:
"""从 Redis 读取配置间隔,回退到 DB,再回退到默认值"""
cached = r.get(_INTERVAL_REDIS_KEY)
if cached:
return int(cached)
# 从 DB 读取并缓存
db = SessionLocal()
try:
from app.models.setting import SystemSetting
setting = db.query(SystemSetting).filter_by(key='check_interval_seconds').first()
interval = int(setting.value) if setting else settings.CHECK_INTERVAL
r.set(_INTERVAL_REDIS_KEY, str(interval))
return interval
finally:
db.close()
@celery_app.task(bind=True)
def check_all_devices(self):
"""检查所有设备状态"""
"""检查所有设备状态(支持可配置间隔,最小5分钟)"""
r = _get_redis()
interval = _get_check_interval(r)
# Redis 节流:检查距上次运行是否已超过配置间隔
last_run = r.get(_LAST_RUN_KEY)
now = time.time()
if last_run and (now - float(last_run)) < interval:
remaining = int(interval - (now - float(last_run)))
return {'skipped': True, 'reason': f'间隔未到,还需等待 {remaining}', 'interval': interval}
# 标记正在运行(TTL 10分钟防止异常时永久卡住)
r.set(_RUNNING_KEY, '1', ex=600)
db = SessionLocal()
self.update_state(state='PROGRESS', meta={'current': 0, 'total': 0, 'status': '获取OLT列表...'})
try:
@@ -68,5 +111,66 @@ def check_all_devices(self):
'error': str(e),
'traceback': traceback.format_exc()
}
finally:
# 任务完成后记录时间、清除运行标记
r.set(_LAST_RUN_KEY, str(time.time()))
r.delete(_RUNNING_KEY)
db.close()
@celery_app.task
def aggregate_daily_snapshot():
"""聚合昨日设备状态快照(每天凌晨执行)"""
from app.models.device import DeviceStatusHistory, DeviceDailySnapshot, ONUDevice
db = SessionLocal()
try:
yesterday = (datetime.utcnow() - timedelta(days=1)).date()
date_str = yesterday.strftime('%Y-%m-%d')
# 如果已存在则跳过(幂等)
exists = db.query(DeviceDailySnapshot).filter_by(snapshot_date=date_str).first()
if exists:
return {'skipped': True, 'date': date_str}
# 昨天每台设备的最后一次检查状态
day_start = datetime.combine(yesterday, datetime.min.time())
day_end = datetime.combine(yesterday, datetime.max.time())
daily_latest_subq = (
db.query(
DeviceStatusHistory.onu_device_id,
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"),
)
.filter(DeviceStatusHistory.checked_at.between(day_start, day_end))
.group_by(DeviceStatusHistory.onu_device_id)
.subquery()
)
row = (
db.query(
func.count().label("total"),
func.sum(case((DeviceStatusHistory.status == 'online', 1), else_=0)).label("online"),
func.sum(case((DeviceStatusHistory.status == 'offline', 1), else_=0)).label("offline"),
)
.join(
daily_latest_subq,
(DeviceStatusHistory.onu_device_id == daily_latest_subq.c.onu_device_id) &
(DeviceStatusHistory.checked_at == daily_latest_subq.c.max_checked_at)
)
.one()
)
snapshot = DeviceDailySnapshot(
snapshot_date=date_str,
total=int(row.total or 0),
online=int(row.online or 0),
offline=int(row.offline or 0),
)
db.add(snapshot)
db.commit()
return {'success': True, 'date': date_str, 'total': snapshot.total, 'online': snapshot.online}
except Exception as e:
db.rollback()
return {'success': False, 'error': str(e), 'traceback': traceback.format_exc()}
finally:
db.close()
+1
View File
@@ -1,5 +1,6 @@
"""Celery Worker 入口模块"""
from app.core.celery_app import celery_app
from app.tasks import check_tasks # noqa: F401 - 导入以注册任务
from app.tasks import audit_tasks # noqa: F401 - 导入以注册任务
__all__ = ['celery_app']
+4 -4
View File
@@ -23,7 +23,7 @@ services:
- ../backend/static:/app/static
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 10s
retries: 3
@@ -71,7 +71,6 @@ services:
- SECRET_KEY=${SECRET_KEY}
volumes:
- ../backend/logs:/app/logs
- ../backend/celerybeat-schedule:/app/celerybeat-schedule
depends_on:
- backend
restart: unless-stopped
@@ -83,9 +82,10 @@ services:
dockerfile: Dockerfile
container_name: h3c-onu-ms-frontend
ports:
- "8081:80"
- "5173:5173"
environment:
- VITE_API_BASE_URL=${VITE_API_BASE_URL:-http://localhost:8001}
- VITE_API_PROXY_TARGET=http://backend:8000
- VITE_CASDOOR_ENDPOINT=${VITE_CASDOOR_ENDPOINT}
- VITE_CASDOOR_CLIENT_ID=${VITE_CASDOOR_CLIENT_ID}
- VITE_CASDOOR_ORG_NAME=${VITE_CASDOOR_ORG_NAME}
@@ -94,7 +94,7 @@ services:
- backend
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80"]
test: ["CMD", "wget", "-qO-", "http://localhost:5173"]
interval: 30s
timeout: 10s
retries: 3
+22 -5
View File
@@ -1,5 +1,3 @@
version: '3.8'
services:
backend:
build: ./backend
@@ -10,23 +8,42 @@ services:
volumes:
- ./backend/logs:/app/logs
restart: unless-stopped
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 15s
timeout: 10s
retries: 5
start_period: 40s
celery-worker:
build: ./backend
command: celery -A app.core.celery_app worker --loglevel=info
command: celery -A celery_worker.celery_app worker --loglevel=info -Q h3c_onu_ms
env_file:
- ./backend/.env
volumes:
- ./backend/logs:/app/logs
restart: unless-stopped
depends_on:
backend:
condition: service_healthy
celery-beat:
build: ./backend
command: celery -A app.core.celery_app beat --loglevel=info
command: celery -A celery_worker.celery_app beat --loglevel=info --schedule=/tmp/celerybeat-schedule
env_file:
- ./backend/.env
restart: unless-stopped
depends_on:
backend:
condition: service_healthy
frontend:
build: ./frontend
ports:
- "5173:5173"
- "18002:5173"
environment:
- VITE_API_PROXY_TARGET=http://backend:8000
restart: unless-stopped
depends_on:
backend:
condition: service_healthy
+14 -1
View File
@@ -2,8 +2,21 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>H3C ONU设备管理系统</title>
<!-- PWA / iOS 主屏幕 -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="ONU管理">
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#0a0e1a">
<!-- iOS 图标 -->
<link rel="apple-touch-icon" href="/icon-180.png">
<!-- Web App Manifest -->
<link rel="manifest" href="/manifest.json">
</head>
<body>
<div id="app"></div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

+14
View File
@@ -0,0 +1,14 @@
{
"name": "H3C ONU 设备管理",
"short_name": "ONU管理",
"description": "H3C OLT/ONU 设备监控管理系统",
"start_url": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#0a0e1a",
"theme_color": "#0a0e1a",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}
+6
View File
@@ -0,0 +1,6 @@
import request from '../utils/request'
export const getAuditLogs = (params) => request.get('/audit/logs', { params })
export const getAuditLogDetail = (id) => request.get(`/audit/logs/${id}`)
export const getAuditStats = (days = 7) => request.get('/audit/stats', { params: { days } })
export const exportAuditLogs = (params) => request.get('/audit/logs/export/csv', { params, responseType: 'blob' })
+34
View File
@@ -0,0 +1,34 @@
import request from '../utils/request'
export const inventoryApi = {
// 分类
getCategories: () => request.get('/inventory/categories'),
createCategory: (data) => request.post('/inventory/categories', data),
// 物料
getMaterials: (params) => request.get('/inventory/materials', { params }),
createMaterial: (data) => request.post('/inventory/materials', data),
updateMaterial: (id, data) => request.put(`/inventory/materials/${id}`, data),
deleteMaterial: (id) => request.delete(`/inventory/materials/${id}`),
// 出入库
purchaseIn: (data) => request.post('/inventory/transactions/purchase', data),
allocateOut: (data) => request.post('/inventory/transactions/allocate', data),
returnIn: (data) => request.post('/inventory/transactions/return', data),
getTransactions: (params) => request.get('/inventory/transactions', { params }),
getTransactionDetail: (id) => request.get(`/inventory/transactions/${id}`),
// 批次
getBatches: (material_id) => request.get('/inventory/batches', { params: { material_id } }),
// 序列号设备
getSerialDevices: (params) => request.get('/inventory/serial-devices', { params }),
// 统计
getSummary: () => request.get('/inventory/summary'),
// 盘点
getChecks: (params) => request.get('/inventory/checks', { params }),
createCheck: (data) => request.post('/inventory/checks', data),
adjustCheck: (id) => request.post(`/inventory/checks/${id}/adjust`),
}
+6
View File
@@ -0,0 +1,6 @@
import request from '../utils/request'
export const getRoles = () => request.get('/roles')
export const getPermissions = () => request.get('/permissions')
export const updateRolePermissions = (role, permissions) =>
request.put(`/roles/${role}/permissions`, { permissions })
+4
View File
@@ -0,0 +1,4 @@
import request from '../utils/request'
export const getSettings = () => request.get('/settings')
export const updateCheckInterval = (seconds) => request.put(`/settings/check_interval?seconds=${seconds}`)
+5
View File
@@ -0,0 +1,5 @@
import request from '../utils/request'
export const getUsers = (params) => request.get('/users', { params })
export const updateUserRole = (id, data) => request.put(`/users/${id}/role`, data)
export const toggleUser = (id) => request.put(`/users/${id}/toggle`)
+525 -40
View File
@@ -1,7 +1,44 @@
<template>
<div class="app-shell">
<!-- 侧边 -->
<aside class="sidebar">
<!-- 移动端顶 -->
<header v-if="isMobile" class="mobile-topbar">
<div class="mobile-brand">
<div class="brand-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<rect x="2" y="2" width="8" height="8" rx="1.5" fill="currentColor" opacity="0.9"/>
<rect x="14" y="2" width="8" height="8" rx="1.5" fill="currentColor" opacity="0.5"/>
<rect x="2" y="14" width="8" height="8" rx="1.5" fill="currentColor" opacity="0.5"/>
<rect x="14" y="14" width="8" height="8" rx="1.5" fill="currentColor" opacity="0.9"/>
</svg>
</div>
<span class="mobile-brand-title">H3C ONU</span>
</div>
<div class="mobile-topbar-right">
<div class="mobile-time">{{ currentTime }}</div>
<button class="mobile-theme-btn" @click="themeStore.toggle()">
<svg v-if="themeStore.theme === 'dark'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
</button>
<button class="mobile-theme-btn" @click="handleLogout" title="退出登录">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</button>
</div>
</header>
<!-- 桌面端侧边栏 -->
<aside v-if="!isMobile" class="sidebar">
<div class="sidebar-brand">
<div class="brand-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
@@ -49,9 +86,9 @@
</aside>
<!-- 主内容区 -->
<div class="main-area">
<!-- 顶部栏 -->
<header class="topbar">
<div class="main-area" :class="{ 'main-area--mobile': isMobile }">
<!-- 桌面端顶部栏 -->
<header v-if="!isMobile" class="topbar">
<div class="topbar-left">
<div class="breadcrumb">
<span class="breadcrumb-root">控制台</span>
@@ -61,9 +98,7 @@
</div>
<div class="topbar-right">
<div class="topbar-time">{{ currentTime }}</div>
<!-- 主题切换 -->
<button class="theme-toggle" @click="themeStore.toggle()" :title="themeStore.theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'">
<!-- 深色时显示太阳图标点击切换到浅色 -->
<svg v-if="themeStore.theme === 'dark'" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/>
@@ -71,7 +106,6 @@
<line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
<!-- 浅色时显示月亮图标点击切换到深色 -->
<svg v-else width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
@@ -88,10 +122,70 @@
</header>
<!-- 页面内容 -->
<main class="page-content">
<main class="page-content" :class="{ 'page-content--mobile': isMobile }">
<router-view />
</main>
</div>
<!-- 移动端底部导航 -->
<nav v-if="isMobile" class="bottom-nav">
<router-link
v-for="item in mobileNavItems"
:key="item.path"
:to="item.path"
class="bottom-nav-item"
:class="{ active: isActive(item.path) }"
>
<span class="bottom-nav-icon" v-html="item.icon"></span>
<span class="bottom-nav-label">{{ item.label }}</span>
</router-link>
<!-- 更多菜单 -->
<button class="bottom-nav-item" :class="{ active: moreMenuOpen }" @click="moreMenuOpen = !moreMenuOpen">
<span class="bottom-nav-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/><line x1="2" y1="20" x2="22" y2="20"/>
</svg>
</span>
<span class="bottom-nav-label">功能</span>
</button>
</nav>
<!-- 移动端更多菜单抽屉 -->
<transition name="slide-up">
<div v-if="isMobile && moreMenuOpen" class="more-drawer">
<div class="more-drawer-overlay" @click="moreMenuOpen = false"></div>
<div class="more-drawer-panel">
<div class="more-drawer-header">
<span class="more-drawer-title">功能菜单</span>
<button class="more-drawer-close" @click="moreMenuOpen = false">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="more-drawer-items">
<router-link
v-for="item in extraNavItems"
:key="item.path"
:to="item.path"
class="more-drawer-item"
:class="{ active: isActive(item.path) }"
@click="moreMenuOpen = false"
>
<span class="more-item-icon" v-html="item.icon"></span>
<div class="more-item-text">
<span class="more-item-label">{{ item.label }}</span>
<span class="more-item-desc">{{ item.desc }}</span>
</div>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="more-item-arrow">
<polyline points="9 18 15 12 9 6"/>
</svg>
</router-link>
</div>
</div>
</div>
</transition>
</div>
</template>
@@ -100,23 +194,32 @@ import { computed, ref, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useThemeStore } from '../stores/theme'
import { useMobile } from '../composables/useMobile'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const themeStore = useThemeStore()
const { isMobile } = useMobile()
const currentTime = ref('')
const moreMenuOpen = ref(false)
const updateTime = () => {
const now = new Date()
currentTime.value = now.toLocaleTimeString('zh-CN', { hour12: false })
}
let timer = null
onMounted(() => { updateTime(); timer = setInterval(updateTime, 1000) })
onMounted(async () => {
updateTime()
timer = setInterval(updateTime, 1000)
if (authStore.token && !authStore.user) {
await authStore.fetchProfile()
}
})
onUnmounted(() => clearInterval(timer))
const navItems = [
const allNavItems = [
{
path: '/dashboard',
label: '统计概览',
@@ -135,7 +238,7 @@ const navItems = [
},
{
path: '/olt',
label: 'OLT 管理',
label: 'OLT',
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<circle cx="7" cy="9" r="1.2" fill="currentColor"/><circle cx="11" cy="9" r="1.2" fill="currentColor"/>
@@ -152,15 +255,69 @@ const navItems = [
</svg>`
},
{
path: '/import',
label: '数据导入',
path: '/inventory',
label: '库存管理',
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>`
},
{
path: '/users',
label: '用户管理',
adminOnly: true,
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>`
},
{
path: '/roles',
label: '角色权限',
adminOnly: true,
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>`
},
{
path: '/settings',
label: '系统设置',
adminOnly: true,
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="3"/>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
</svg>`
},
{
path: '/audit',
label: '审计日志',
adminOnly: true,
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>`
},
]
const navItems = computed(() =>
allNavItems.filter(item => !item.adminOnly || authStore.isAdmin)
)
// 移动端底部导航只显示核心3项
const mobileNavItems = computed(() => navItems.value.slice(0, 3))
// 更多菜单中的额外项(非前3项)
const extraNavItems = computed(() =>
navItems.value.slice(3).map((item, i) => ({
...item,
desc: ['7天趋势与区域分布', '物料出入库台账', '管理系统用户', '配置角色权限', '定时检查等系统配置', '查看操作审计记录'][i] || ''
}))
)
const isActive = (path) => route.path === path || route.path.startsWith(path + '/')
const pageNameMap = {
@@ -168,7 +325,11 @@ const pageNameMap = {
'/devices': '设备列表',
'/olt': 'OLT 管理',
'/charts': '统计图表',
'/import': '数据导入',
'/inventory': '库存管理',
'/users': '用户管理',
'/roles': '角色权限',
'/settings': '系统设置',
'/audit': '审计日志',
}
const currentPageName = computed(() => pageNameMap[route.path] || '页面')
@@ -182,11 +343,12 @@ const handleLogout = () => {
.app-shell {
display: flex;
height: 100vh;
height: 100dvh;
overflow: hidden;
background: var(--bg-base);
}
/* ── 侧边栏 ── */
/* ── 侧边栏(桌面端) ── */
.sidebar {
width: 220px;
flex-shrink: 0;
@@ -199,7 +361,6 @@ const handleLogout = () => {
z-index: 10;
}
/* 品牌 */
.sidebar-brand {
display: flex;
align-items: center;
@@ -241,7 +402,6 @@ const handleLogout = () => {
letter-spacing: 0.03em;
}
/* 状态指示 */
.sidebar-status {
display: flex;
align-items: center;
@@ -271,7 +431,6 @@ const handleLogout = () => {
letter-spacing: 0.03em;
}
/* 导航 */
.sidebar-nav {
flex: 1;
padding: 12px 10px;
@@ -315,7 +474,6 @@ const handleLogout = () => {
}
.nav-item.active .nav-icon { opacity: 1; }
.nav-label { flex: 1; }
.nav-indicator {
@@ -329,7 +487,6 @@ const handleLogout = () => {
.nav-item.active .nav-indicator { opacity: 1; }
/* 底部 */
.sidebar-footer {
padding: 14px 10px;
border-top: 1px solid var(--border-subtle);
@@ -366,7 +523,19 @@ const handleLogout = () => {
min-width: 0;
}
/* 顶部栏 */
.main-area--mobile {
padding-top: 52px;
padding-bottom: 64px;
}
@media (display-mode: standalone) {
.main-area--mobile {
padding-top: calc(52px + env(safe-area-inset-top));
padding-bottom: calc(64px + env(safe-area-inset-bottom));
}
}
/* 桌面端顶部栏 */
.topbar {
height: 52px;
flex-shrink: 0;
@@ -391,19 +560,9 @@ const handleLogout = () => {
font-size: 13px;
}
.breadcrumb-root {
color: var(--text-muted);
}
.breadcrumb-sep {
color: var(--border-strong);
font-size: 12px;
}
.breadcrumb-current {
color: var(--text-secondary);
font-weight: 500;
}
.breadcrumb-root { color: var(--text-muted); }
.breadcrumb-sep { color: var(--border-strong); font-size: 12px; }
.breadcrumb-current { color: var(--text-secondary); font-weight: 500; }
.topbar-time {
font-family: var(--font-mono);
@@ -425,14 +584,17 @@ const handleLogout = () => {
color: var(--text-muted);
}
/* 页面内容 */
.page-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
/* 主题切换按钮 */
.page-content--mobile {
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.theme-toggle {
display: flex;
align-items: center;
@@ -455,7 +617,330 @@ const handleLogout = () => {
background: var(--accent-dim);
}
.theme-label {
.theme-label { letter-spacing: 0.02em; }
/* ── 移动端顶栏 ── */
.mobile-topbar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 52px;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border-subtle);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
/* standalone 模式下顶栏需要为刘海让出空间 */
@media (display-mode: standalone) {
.mobile-topbar {
height: calc(52px + env(safe-area-inset-top));
align-items: flex-end;
padding-bottom: 8px;
padding-top: env(safe-area-inset-top);
}
}
.mobile-brand {
display: flex;
align-items: center;
gap: 10px;
}
.mobile-brand .brand-icon {
width: 30px;
height: 30px;
background: var(--accent-dim);
border: 1px solid var(--border-accent);
border-radius: 7px;
display: flex;
align-items: center;
justify-content: center;
color: var(--accent);
}
.mobile-brand-title {
font-family: var(--font-mono);
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
letter-spacing: 0.05em;
}
.mobile-topbar-right {
display: flex;
align-items: center;
gap: 12px;
}
.mobile-time {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-muted);
letter-spacing: 0.04em;
}
.mobile-theme-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-elevated);
border: 1px solid var(--border-default);
border-radius: 50%;
color: var(--text-muted);
cursor: pointer;
transition: all 0.2s;
}
.mobile-theme-btn:active {
background: var(--accent-dim);
border-color: var(--border-accent);
color: var(--accent);
}
/* ── 移动端底部导航 ── */
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 64px;
z-index: 100;
display: flex;
align-items: stretch;
background: var(--bg-surface);
border-top: 1px solid var(--border-subtle);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
@media (display-mode: standalone) {
.bottom-nav {
height: calc(64px + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom);
}
}
.bottom-nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
text-decoration: none;
color: var(--text-muted);
background: transparent;
border: none;
cursor: pointer;
font-family: var(--font-sans);
transition: color 0.18s;
padding: 8px 4px;
min-height: 44px;
position: relative;
}
.bottom-nav-item::before {
content: '';
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 2px;
background: var(--accent);
border-radius: 0 0 2px 2px;
transition: width 0.2s ease;
}
.bottom-nav-item.active {
color: var(--accent);
}
.bottom-nav-item.active::before {
width: 32px;
}
.bottom-nav-icon {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
}
.bottom-nav-label {
font-size: 10px;
font-weight: 500;
letter-spacing: 0.02em;
white-space: nowrap;
}
/* ── 更多菜单抽屉 ── */
.more-drawer {
position: fixed;
inset: 0;
z-index: 200;
}
.more-drawer-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
}
.more-drawer-panel {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: var(--bg-surface);
border-radius: 20px 20px 0 0;
border-top: 1px solid var(--border-default);
padding-bottom: calc(16px + env(safe-area-inset-bottom));
box-shadow: 0 -8px 40px rgba(0, 0, 0, 0.4);
}
.more-drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 20px 12px;
border-bottom: 1px solid var(--border-subtle);
}
.more-drawer-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.more-drawer-close {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-elevated);
border: 1px solid var(--border-default);
border-radius: 50%;
color: var(--text-muted);
cursor: pointer;
}
.more-drawer-items {
padding: 8px 12px;
display: flex;
flex-direction: column;
gap: 2px;
}
.more-drawer-item {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 12px;
border-radius: var(--radius-md);
text-decoration: none;
color: var(--text-secondary);
background: transparent;
border: none;
cursor: pointer;
font-family: var(--font-sans);
width: 100%;
text-align: left;
transition: background 0.15s;
min-height: 56px;
}
.more-drawer-item:active,
.more-drawer-item.active {
background: var(--accent-dim);
color: var(--accent);
}
.more-item-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-elevated);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
color: var(--text-secondary);
flex-shrink: 0;
}
.more-drawer-item.active .more-item-icon {
background: var(--accent-dim);
border-color: var(--border-accent);
color: var(--accent);
}
.more-item-text {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.more-item-label {
font-size: 14px;
font-weight: 500;
color: inherit;
}
.more-item-desc {
font-size: 11px;
color: var(--text-muted);
}
.more-item-arrow {
color: var(--text-muted);
flex-shrink: 0;
}
.more-drawer-logout {
margin-top: 4px;
border-top: 1px solid var(--border-subtle);
padding-top: 16px;
color: var(--danger);
}
.more-drawer-logout .more-item-icon {
color: var(--danger);
background: var(--danger-dim);
border-color: rgba(239,68,68,0.25);
}
/* 抽屉动画 */
.slide-up-enter-active,
.slide-up-leave-active {
transition: opacity 0.25s ease;
}
.slide-up-enter-active .more-drawer-panel,
.slide-up-leave-active .more-drawer-panel {
transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
}
.slide-up-enter-from,
.slide-up-leave-to {
opacity: 0;
}
.slide-up-enter-from .more-drawer-panel,
.slide-up-leave-to .more-drawer-panel {
transform: translateY(100%);
}
</style>
+16
View File
@@ -0,0 +1,16 @@
import { ref, onMounted, onUnmounted } from 'vue'
const MOBILE_BREAKPOINT = 768
export function useMobile() {
const isMobile = ref(window.innerWidth < MOBILE_BREAKPOINT)
const onResize = () => {
isMobile.value = window.innerWidth < MOBILE_BREAKPOINT
}
onMounted(() => window.addEventListener('resize', onResize))
onUnmounted(() => window.removeEventListener('resize', onResize))
return { isMobile }
}
@@ -0,0 +1,8 @@
import { useAuthStore } from '../stores/auth'
export function usePermission() {
const authStore = useAuthStore()
return {
can: (perm) => authStore.hasPermission(perm),
}
}
+37 -1
View File
@@ -1,11 +1,47 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import ElementPlus, { ElMessage } from 'element-plus'
import 'element-plus/dist/index.css'
import './styles/theme.css'
import App from './App.vue'
import router from './router'
// 检测 standalone 模式,将 safe-area-inset-top 写入 CSS 变量供弹窗/消息使用
function getSafeAreaTop() {
const div = document.createElement('div')
div.style.cssText = 'position:fixed;top:env(safe-area-inset-top);left:0;width:0;height:0;visibility:hidden;'
document.body.appendChild(div)
const top = parseInt(getComputedStyle(div).top) || 0
document.body.removeChild(div)
return top
}
const isStandalone = window.navigator.standalone === true ||
window.matchMedia('(display-mode: standalone)').matches
if (isStandalone) {
const sat = getSafeAreaTop()
document.documentElement.style.setProperty('--sat', sat + 'px')
// 全局覆盖 ElMessage,自动加上顶栏 + 刘海的偏移
const MESSAGE_OFFSET = 52 + sat + 8 // 顶栏高度 + 刘海 + 间距
const _origMessage = ElMessage
const wrap = (opts) => {
if (typeof opts === 'string') return _origMessage({ message: opts, offset: MESSAGE_OFFSET })
return _origMessage({ offset: MESSAGE_OFFSET, ...opts })
}
;['success', 'warning', 'info', 'error'].forEach(type => {
wrap[type] = (opts) => {
if (typeof opts === 'string') return _origMessage[type]({ message: opts, offset: MESSAGE_OFFSET })
return _origMessage[type]({ offset: MESSAGE_OFFSET, ...opts })
}
})
wrap.closeAll = _origMessage.closeAll
// 替换全局 ElMessage(各页面通过 import { ElMessage } 使用的不受影响,
// 但通过 app.config.globalProperties.$message 调用的会生效)
window.__elMessageOffset = MESSAGE_OFFSET
}
const app = createApp(App)
app.use(createPinia())
app.use(router)
+54 -12
View File
@@ -29,11 +29,6 @@ const routes = [
name: 'DeviceList',
component: () => import('../views/DeviceList.vue')
},
{
path: '/import',
name: 'ImportData',
component: () => import('../views/ImportData.vue')
},
{
path: '/charts',
name: 'Charts',
@@ -43,7 +38,36 @@ const routes = [
path: '/olt',
name: 'OltManage',
component: () => import('../views/OltManage.vue')
}
},
{
path: '/inventory',
name: 'Inventory',
component: () => import('../views/Inventory.vue')
},
{
path: '/users',
name: 'UserManage',
component: () => import('../views/UserManage.vue'),
meta: { requiresRole: 'admin' }
},
{
path: '/roles',
name: 'RolePermissions',
component: () => import('../views/RolePermissions.vue'),
meta: { requiresRole: 'admin' }
},
{
path: '/settings',
name: 'Settings',
component: () => import('../views/Settings.vue'),
meta: { requiresRole: 'admin' }
},
{
path: '/audit',
name: 'AuditLog',
component: () => import('../views/AuditLog.vue'),
meta: { requiresRole: 'admin' }
},
]
}
]
@@ -53,15 +77,33 @@ const router = createRouter({
routes
})
router.beforeEach((to, from, next) => {
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.token) {
next('/login')
} else if (to.path === '/login' && authStore.token) {
next('/dashboard')
} else {
next()
return next('/login')
}
if (to.path === '/login' && authStore.token) {
return next('/dashboard')
}
// 需要角色检查时,确保 user 信息已加载
if (to.meta.requiresRole && authStore.token) {
if (!authStore.user) {
await authStore.fetchProfile()
}
if (authStore.role !== to.meta.requiresRole) {
return next('/dashboard')
}
}
// 加载权限列表(仅首次)
if (authStore.token && authStore.permissions.length === 0) {
await authStore.fetchPermissions()
}
next()
})
export default router
+50 -2
View File
@@ -1,21 +1,69 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ref, computed } from 'vue'
import * as authApi from '../api/auth'
import axios from 'axios'
function decodeJwtPayload(token) {
try {
const b64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')
return JSON.parse(atob(b64))
} catch {
return {}
}
}
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('token') || '')
const user = ref(null)
const permissions = ref([])
// 直接从 JWT payload 读 role,无需额外请求
const role = computed(() => {
if (user.value?.role) return user.value.role
if (token.value) return decodeJwtPayload(token.value).role || 'user'
return 'user'
})
const isAdmin = computed(() => role.value === 'admin')
const hasPermission = (perm) => {
if (isAdmin.value) return true
return permissions.value.includes(perm)
}
const setToken = (newToken) => {
token.value = newToken
localStorage.setItem('token', newToken)
}
const fetchProfile = async () => {
if (!token.value) return
try {
const res = await authApi.getProfile()
user.value = res.data
} catch {
// token 失效时静默处理
}
}
const fetchPermissions = async () => {
if (!token.value) return
try {
const res = await axios.get('/api/auth/permissions', {
headers: { Authorization: `Bearer ${token.value}` }
})
permissions.value = res.data.permissions || []
} catch {
permissions.value = []
}
}
const logout = () => {
token.value = ''
user.value = null
permissions.value = []
localStorage.removeItem('token')
}
return { token, user, setToken, logout }
return { token, user, role, isAdmin, permissions, hasPermission, setToken, fetchProfile, fetchPermissions, logout }
})
+92
View File
@@ -68,6 +68,9 @@ html, body, #app {
font-family: var(--font-sans);
font-size: 14px;
-webkit-font-smoothing: antialiased;
/* iOS standalone 模式下禁止长按弹出菜单、文字选中 */
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
}
/* ── Element Plus 深色覆盖 ── */
@@ -730,3 +733,92 @@ html {
transition-duration: 0.2s;
transition-timing-function: ease;
}
/* ============================================================
移动端全局适配
============================================================ */
/* 防止 iOS 双击缩放 */
html {
touch-action: manipulation;
}
/* 移动端 Element Plus 对话框全屏适配 */
@media (max-width: 767px) {
/* overlay 容器整体下移,避开固定顶栏(52px)并留出安全间距 */
.el-overlay-dialog {
padding-top: 60px !important;
padding-bottom: 72px !important; /* 底部导航 64px + 8px 间距 */
box-sizing: border-box !important;
}
.el-dialog {
width: calc(100vw - 24px) !important;
max-width: 100% !important;
margin: 0 auto !important;
border-radius: var(--radius-lg) !important;
/* 最大高度 = 视口 - 顶栏 - 底导航 - 上下间距 */
max-height: calc(100vh - 60px - 72px) !important;
display: flex !important;
flex-direction: column !important;
}
.el-dialog__body {
padding: 16px !important;
flex: 1 !important;
overflow-y: auto !important;
-webkit-overflow-scrolling: touch;
/* 不再写死 max-height,由父容器控制 */
max-height: none !important;
}
.el-dialog__footer {
padding: 12px 16px !important;
flex-shrink: 0 !important;
}
/* 表格在移动端允许横向滚动 */
.el-table {
overflow-x: auto !important;
}
/* 分页在移动端居中 */
.el-pagination {
justify-content: center !important;
flex-wrap: wrap !important;
}
/* 按钮最小触摸区域 */
.el-button {
min-height: 36px !important;
}
/* 输入框高度 */
.el-input__wrapper {
min-height: 36px !important;
}
/* 消息提示位置 */
.el-message {
min-width: 240px !important;
max-width: calc(100vw - 32px) !important;
}
}
/* ── iOS 主屏幕 standalone 模式:弹窗和消息额外下移刘海高度 ── */
/* --sat 由 main.js 在 standalone 模式下动态写入,Safari 中不存在默认为 0 */
@media (display-mode: standalone) and (max-width: 767px) {
.el-overlay-dialog {
padding-top: calc(60px + var(--sat, 0px)) !important;
padding-bottom: calc(72px + env(safe-area-inset-bottom)) !important;
}
.el-dialog {
max-height: calc(100vh - 60px - 72px - var(--sat, 0px) - env(safe-area-inset-bottom)) !important;
}
/* 消息提示下移 */
.el-message-list {
top: calc(20px + var(--sat, 0px)) !important;
}
}
+31
View File
@@ -0,0 +1,31 @@
/**
* 封装 ElMessage,在 iOS standalone 模式下自动加上顶栏+刘海偏移
*/
import { ElMessage as _ElMessage } from 'element-plus'
const isStandalone = window.navigator.standalone === true ||
window.matchMedia('(display-mode: standalone)').matches
function getOffset() {
if (!isStandalone) return 20
const sat = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--sat')) || 0
return 52 + sat + 8
}
function message(opts) {
const offset = getOffset()
if (typeof opts === 'string') return _ElMessage({ message: opts, offset })
return _ElMessage({ offset, ...opts })
}
;['success', 'warning', 'info', 'error'].forEach(type => {
message[type] = (opts) => {
const offset = getOffset()
if (typeof opts === 'string') return _ElMessage[type]({ message: opts, offset })
return _ElMessage[type]({ offset, ...opts })
}
})
message.closeAll = _ElMessage.closeAll
export { message as ElMessage }
+248
View File
@@ -0,0 +1,248 @@
<template>
<div class="audit-page">
<div class="page-header">
<div class="page-title-group">
<h1 class="page-title">审计日志</h1>
<span class="page-subtitle">记录所有用户操作支持追溯与问责</span>
</div>
<button class="export-btn" @click="handleExport" :disabled="exporting">
{{ exporting ? '导出中…' : '导出 CSV' }}
</button>
</div>
<!-- 筛选栏 -->
<div class="filter-bar">
<el-date-picker
v-model="dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
size="small"
style="width: 340px"
@change="onFilter"
/>
<el-input v-model="filters.username" placeholder="用户名" size="small" style="width: 130px" clearable @change="onFilter" />
<el-select v-model="filters.action_type" placeholder="操作类型" size="small" style="width: 120px" clearable @change="onFilter">
<el-option v-for="t in actionTypes" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
<el-select v-model="filters.status" placeholder="状态" size="small" style="width: 100px" clearable @change="onFilter">
<el-option label="成功" value="success" />
<el-option label="失败" value="failed" />
<el-option label="错误" value="error" />
</el-select>
<button class="reset-btn" @click="resetFilters">重置</button>
</div>
<!-- 表格 -->
<div class="table-wrap">
<el-table :data="logs" size="small" style="width:100%" v-loading="loading" @row-click="openDetail">
<el-table-column label="时间" width="160">
<template #default="{ row }">
<span class="mono-val">{{ fmtTime(row.action_time) }}</span>
</template>
</el-table-column>
<el-table-column label="用户" width="110">
<template #default="{ row }">
<span>{{ row.username }}</span>
<span class="role-tag">{{ row.user_role }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ row }">
<span :class="['type-tag', `type-${row.action_type}`]">{{ typeLabel(row.action_type) }}</span>
</template>
</el-table-column>
<el-table-column label="子类型" width="80">
<template #default="{ row }">
<span class="muted">{{ row.action_subtype || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="描述" min-width="180" show-overflow-tooltip prop="description" />
<el-table-column label="路径" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span class="mono-val small">{{ row.request_method }} {{ row.request_path }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="72" align="center">
<template #default="{ row }">
<span :class="['status-dot', `status-${row.status}`]">{{ statusLabel(row.status) }}</span>
</template>
</el-table-column>
<el-table-column label="IP" width="130">
<template #default="{ row }">
<span class="mono-val small">{{ row.ip_address || '—' }}</span>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
small
@change="fetchLogs"
/>
</div>
</div>
<!-- 详情抽屉 -->
<el-drawer v-model="drawerVisible" title="日志详情" size="480px" direction="rtl">
<div v-if="detail" class="detail-body">
<div class="detail-row" v-for="(v, k) in detailFields" :key="k">
<span class="detail-label">{{ v.label }}</span>
<span class="detail-value" :class="{ mono: v.mono }">{{ v.val }}</span>
</div>
<template v-if="detail.request_params">
<div class="detail-section">请求参数</div>
<pre class="json-block">{{ JSON.stringify(detail.request_params, null, 2) }}</pre>
</template>
<template v-if="detail.error_message">
<div class="detail-section">错误信息</div>
<pre class="json-block error">{{ detail.error_message }}</pre>
</template>
</div>
</el-drawer>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from '../utils/message'
import { getAuditLogs, getAuditLogDetail, exportAuditLogs } from '../api/audit'
const logs = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(50)
const loading = ref(false)
const exporting = ref(false)
const drawerVisible = ref(false)
const detail = ref(null)
const dateRange = ref(null)
const filters = ref({ username: '', action_type: '', status: '' })
const actionTypes = [
{ label: '认证', value: 'auth' },
{ label: '设备', value: 'device' },
{ label: 'OLT', value: 'olt' },
{ label: '用户', value: 'user' },
{ label: '系统', value: 'system' },
{ label: '库存', value: 'inventory' },
]
const typeLabel = (t) => actionTypes.find(x => x.value === t)?.label || t
const statusLabel = (s) => ({ success: '成功', failed: '失败', error: '错误' }[s] || s)
const fmtTime = (t) => t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '—'
const buildParams = () => {
const p = { page: page.value, page_size: pageSize.value }
if (dateRange.value?.[0]) p.start_time = dateRange.value[0].toISOString()
if (dateRange.value?.[1]) p.end_time = dateRange.value[1].toISOString()
if (filters.value.username) p.username = filters.value.username
if (filters.value.action_type) p.action_type = filters.value.action_type
if (filters.value.status) p.status = filters.value.status
return p
}
const fetchLogs = async () => {
loading.value = true
try {
const { data } = await getAuditLogs(buildParams())
logs.value = data.items
total.value = data.total
} catch {
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
const onFilter = () => { page.value = 1; fetchLogs() }
const resetFilters = () => {
filters.value = { username: '', action_type: '', status: '' }
dateRange.value = null
page.value = 1
fetchLogs()
}
const openDetail = async (row) => {
try {
const { data } = await getAuditLogDetail(row.id)
detail.value = data
drawerVisible.value = true
} catch { ElMessage.error('加载详情失败') }
}
const detailFields = computed(() => {
if (!detail.value) return {}
const d = detail.value
return {
time: { label: '时间', val: fmtTime(d.action_time), mono: true },
user: { label: '用户', val: `${d.username} (${d.user_role})` },
type: { label: '操作', val: `${typeLabel(d.action_type)} / ${d.action_subtype || '—'}` },
path: { label: '路径', val: `${d.request_method} ${d.request_path}`, mono: true },
status: { label: '状态', val: `${statusLabel(d.status)} (${d.status_code})` },
ip: { label: 'IP', val: d.ip_address || '—', mono: true },
desc: { label: '描述', val: d.description },
ua: { label: 'UA', val: d.user_agent || '—' },
}
})
const handleExport = async () => {
exporting.value = true
try {
const { data } = await exportAuditLogs(buildParams())
const url = URL.createObjectURL(new Blob([data]))
const a = document.createElement('a')
a.href = url
a.download = `audit_${Date.now()}.csv`
a.click()
URL.revokeObjectURL(url)
} catch { ElMessage.error('导出失败') } finally { exporting.value = false }
}
onMounted(fetchLogs)
</script>
<style scoped>
.audit-page { padding: 24px; }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 20px; }
.page-title-group { display: flex; flex-direction: column; gap: 4px; }
.page-title { margin: 0; font-size: 22px; font-weight: 700; color: var(--text-primary); letter-spacing: -0.02em; }
.page-subtitle { font-size: 12px; color: var(--text-muted); }
.export-btn { padding: 7px 16px; background: var(--bg-elevated); border: 1px solid var(--border-default); border-radius: var(--radius-sm); color: var(--text-secondary); font-size: 13px; cursor: pointer; }
.export-btn:hover { border-color: var(--accent); color: var(--accent); }
.filter-bar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; align-items: center; }
.reset-btn { padding: 6px 12px; background: transparent; border: 1px solid var(--border-default); border-radius: var(--radius-sm); color: var(--text-muted); font-size: 12px; cursor: pointer; }
.reset-btn:hover { color: var(--text-primary); }
.table-wrap { background: var(--bg-card); border: 1px solid var(--border-default); border-radius: var(--radius-lg); overflow: hidden; }
.pagination { padding: 12px 16px; display: flex; justify-content: flex-end; border-top: 1px solid var(--border-subtle); }
.mono-val { font-family: var(--font-mono); font-size: 12px; }
.small { font-size: 11px; }
.muted { color: var(--text-muted); font-size: 12px; }
.role-tag { margin-left: 4px; font-size: 10px; color: var(--text-muted); background: var(--bg-elevated); padding: 1px 5px; border-radius: 3px; }
.type-tag { font-size: 11px; padding: 2px 7px; border-radius: 3px; font-weight: 500; }
.type-auth { background: #dbeafe; color: #1d4ed8; }
.type-device { background: #dcfce7; color: #15803d; }
.type-olt { background: #fef9c3; color: #854d0e; }
.type-user { background: #fce7f3; color: #9d174d; }
.type-system { background: #f3e8ff; color: #6b21a8; }
.type-inventory{ background: #ffedd5; color: #9a3412; }
.status-dot { font-size: 11px; padding: 2px 7px; border-radius: 3px; }
.status-success { background: #dcfce7; color: #15803d; }
.status-failed { background: #fef9c3; color: #854d0e; }
.status-error { background: #fee2e2; color: #991b1b; }
.detail-body { padding: 4px 0; }
.detail-row { display: flex; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--border-subtle); font-size: 13px; }
.detail-label { width: 60px; flex-shrink: 0; color: var(--text-muted); }
.detail-value { flex: 1; color: var(--text-primary); word-break: break-all; }
.detail-value.mono { font-family: var(--font-mono); font-size: 12px; }
.detail-section { margin: 16px 0 8px; font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
.json-block { background: var(--bg-elevated); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 10px 12px; font-size: 12px; font-family: var(--font-mono); white-space: pre-wrap; word-break: break-all; color: var(--text-secondary); margin: 0; }
.json-block.error { color: #dc2626; }
</style>
+70 -1
View File
@@ -11,7 +11,9 @@
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
{{ loading ? '刷新中…' : '刷新数据' }}
<span v-if="loading">刷新中</span>
<span v-else-if="lastUpdated">{{ lastUpdated }}</span>
<span v-else>刷新数据</span>
</button>
</div>
@@ -198,10 +200,14 @@
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import request from '../utils/request'
import { useMobile } from '../composables/useMobile'
const { isMobile } = useMobile()
const router = useRouter()
const loading = ref(false)
const data = ref({})
const lastUpdated = ref('')
const townVisible = ref(false)
const selectedTown = ref(null)
@@ -238,6 +244,7 @@ const loadData = async () => {
try {
const { data: d } = await request.get('/stats/dashboard')
data.value = d
lastUpdated.value = '更新于 ' + new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
} finally {
loading.value = false
}
@@ -589,4 +596,66 @@ onMounted(loadData)
font-size: 12px;
color: var(--text-muted);
}
/* ── 移动端响应式 ── */
@media (max-width: 767px) {
.dashboard {
padding: 16px 12px;
}
.page-header {
margin-bottom: 16px;
}
.page-title {
font-size: 18px;
}
.refresh-btn {
padding: 8px 12px;
font-size: 12px;
}
/* 统计卡片:2列 */
.stat-grid {
grid-template-columns: repeat(2, 1fr);
gap: 10px;
margin-bottom: 16px;
}
.stat-card {
padding: 14px;
}
.stat-rate {
font-size: 28px;
margin-bottom: 8px;
}
.stat-meta {
margin-bottom: 10px;
}
/* 学校列表:单列堆叠 */
.list-grid {
grid-template-columns: 1fr;
gap: 10px;
}
.school-list {
max-height: 260px;
}
.school-row {
grid-template-columns: 1fr 60px 52px;
gap: 8px;
padding: 10px 8px;
min-height: 44px;
}
.school-name {
font-size: 13px;
}
}
</style>
+629 -10
View File
@@ -7,12 +7,15 @@
<span class="page-subtitle">ONU 终端设备管理</span>
</div>
<div class="header-actions">
<el-button type="danger" plain size="small" @click="clearStatus" :loading="clearing">
<el-button v-if="!isMobile && can('device.delete')" type="danger" plain size="small" @click="clearStatus" :loading="clearing">
清空状态
</el-button>
<el-button type="warning" size="small" @click="refreshAllStatus" :loading="refreshing">
<el-button v-if="can('device.check')" type="warning" size="small" @click="refreshAllStatus" :loading="refreshing">
{{ refreshing ? '更新中' : '全部更新' }}
</el-button>
<el-button v-if="can('device.import')" type="success" size="small" @click="importDialogVisible = true">
数据导入
</el-button>
</div>
</div>
@@ -85,8 +88,9 @@
</div>
</div>
<!-- 表格 -->
<!-- 表格桌面端 -->
<el-table
v-if="!isMobile"
:data="devices"
border
stripe
@@ -120,7 +124,7 @@
</el-table-column>
<el-table-column prop="distance_m" label="距离" width="80" align="right">
<template #default="{ row }">
<span class="mono-val">{{ row.distance_m ? row.distance_m + 'm' : '—' }}</span>
<span class="mono-val">{{ formatDistance(row.distance_m) }}</span>
</template>
</el-table-column>
<el-table-column prop="notes" label="备注" min-width="100" show-overflow-tooltip>
@@ -130,9 +134,114 @@
</el-table-column>
</el-table>
<!-- 移动端卡片列表 -->
<div v-if="isMobile" class="device-cards">
<div
v-for="device in devices"
:key="device.id"
class="device-card"
:class="{ expanded: expandedId === device.id }"
@click="toggleCard(device)"
>
<!-- 卡片头部 -->
<div class="card-head">
<div class="card-head-left">
<span class="card-mac">{{ formatMac(device.mac_address) }}</span>
<span class="card-school">{{ device.school_name }}</span>
</div>
<div class="card-head-right">
<span class="status-badge" :class="device.status">
<span class="status-dot-sm"></span>
{{ device.status === 'online' ? '在线' : device.status === 'offline' ? '离线' : '未知' }}
</span>
<svg class="card-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</div>
</div>
<!-- 基础信息 -->
<div class="card-body">
<div class="card-row">
<span class="card-label">楼宇</span>
<span class="card-val">{{ device.building || '—' }}</span>
</div>
<div class="card-row">
<span class="card-label">场所</span>
<span class="card-val">{{ device.place_type || '—' }}</span>
</div>
<div class="card-row">
<span class="card-label">房间号</span>
<span class="card-val">{{ device.room_number || '—' }}</span>
</div>
</div>
<!-- 展开详情 -->
<div v-if="expandedId === device.id" class="card-expanded">
<div class="card-actions">
<button v-if="can('device.check')" class="card-action-btn" @click.stop="doRefreshDeviceCard(device)" :disabled="!device.olt_id">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
更新
</button>
<button v-if="can('device.edit')" class="card-action-btn" @click.stop="openEditFromCard(device)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
编辑
</button>
<button v-if="can('device.edit')" class="card-action-btn card-action-btn--warn" @click.stop="openReplaceFromCard(device)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/>
<polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>
</svg>
更换
</button>
<button
v-if="can('device.edit')"
class="card-action-btn card-action-btn--primary"
@click.stop="openProvisionFromCard(device)"
:disabled="device.status !== 'online' || !device.port_id"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
业务下发
</button>
</div>
<div class="card-detail-rows">
<div class="card-row">
<span class="card-label">距离</span>
<span class="card-val mono">{{ formatDistance(device.distance_m) }}</span>
</div>
<div class="card-row">
<span class="card-label">端口</span>
<span class="card-val mono">{{ formatPort(device) }}</span>
</div>
<div class="card-row">
<span class="card-label">型号</span>
<span class="card-val">{{ device.model || '—' }}</span>
</div>
<div class="card-row">
<span class="card-label">所属 OLT</span>
<span class="card-val">{{ device.olt_location || '—' }}</span>
</div>
<div v-if="device.notes" class="card-row card-row--notes">
<span class="card-label">备注</span>
<span class="card-val">{{ device.notes }}</span>
</div>
</div>
</div>
</div>
<div v-if="devices.length === 0" class="cards-empty">暂无设备数据</div>
</div>
<!-- 分页 -->
<div class="pagination-wrap">
<el-pagination
v-if="!isMobile"
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[20, 50, 100]"
@@ -141,6 +250,11 @@
@size-change="handleSizeChange"
layout="total, sizes, prev, pager, next"
/>
<div v-else class="mobile-pagination">
<button class="mobile-page-btn" :disabled="page <= 1" @click="page--; loadDevices()">上一页</button>
<span class="mobile-page-info"> {{ page }} / {{ Math.ceil(total / pageSize) }} </span>
<button class="mobile-page-btn" :disabled="page >= Math.ceil(total / pageSize)" @click="page++; loadDevices()">下一页</button>
</div>
</div>
</div>
@@ -220,7 +334,7 @@
</span>
</el-descriptions-item>
<el-descriptions-item label="距离">
<span class="mono-val">{{ selectedDevice.distance_m ? selectedDevice.distance_m + ' 米' : '—' }}</span>
<span class="mono-val">{{ formatDistance(selectedDevice.distance_m) }}</span>
</el-descriptions-item>
<el-descriptions-item label="端口" :span="2">
<span class="mono-val">{{ formatPort(selectedDevice) }}</span>
@@ -230,15 +344,44 @@
<el-descriptions-item label="备注" :span="2">{{ selectedDevice.notes || '—' }}</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button type="info" :loading="deviceRefreshing" @click="doRefreshDevice" :disabled="!selectedDevice.olt_id">更新</el-button>
<el-button type="primary" @click="openEdit">编辑</el-button>
<el-button type="success" @click="openProvision" :disabled="selectedDevice.status !== 'online' || !selectedDevice.port_id">
<el-button v-if="can('device.check')" type="info" :loading="deviceRefreshing" @click="doRefreshDevice" :disabled="!selectedDevice.olt_id">更新</el-button>
<el-button v-if="can('device.edit')" type="warning" @click="openReplace">更换</el-button>
<el-button v-if="can('device.edit')" type="primary" @click="openEdit">编辑</el-button>
<el-button v-if="can('device.edit')" type="success" @click="openProvision" :disabled="selectedDevice.status !== 'online' || !selectedDevice.port_id">
业务下发
</el-button>
<el-button @click="detailVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 更换设备对话框 -->
<el-dialog v-model="replaceVisible" title="更换设备" width="440px" destroy-on-close>
<div style="margin-bottom: 12px; font-size: 13px; color: var(--text-muted)">
当前 MAC<span class="mono-val accent">{{ formatMac(selectedDevice.mac_address) }}</span>
</div>
<el-form :model="replaceForm" label-width="80px">
<el-form-item label="新 MAC 地址" required>
<el-input v-model="replaceForm.new_mac" placeholder="例:AABBCCDDEEFF 或 AA:BB:CC:DD:EE:FF" clearable />
</el-form-item>
<el-form-item label="更换原因">
<el-input v-model="replaceForm.reason" type="textarea" :rows="2" placeholder="可选,填写更换原因" />
</el-form-item>
</el-form>
<div v-if="replacements.length" style="margin-top: 16px">
<div style="font-size: 12px; font-weight: 600; color: var(--text-muted); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.05em">更换历史</div>
<div v-for="r in replacements" :key="r.id" class="replace-history-row">
<span class="mono-val small">{{ r.old_mac }}</span>
<span style="margin: 0 6px; color: var(--text-muted)"></span>
<span class="mono-val small">{{ r.new_mac }}</span>
<span style="margin-left: auto; font-size: 11px; color: var(--text-muted)">{{ fmtReplaceTime(r.replaced_at) }}</span>
</div>
</div>
<template #footer>
<el-button @click="replaceVisible = false">取消</el-button>
<el-button type="warning" :loading="replaceSaving" @click="doReplace">确认更换</el-button>
</template>
</el-dialog>
<!-- 编辑对话框 -->
<el-dialog v-model="editVisible" title="编辑设备信息" width="460px" destroy-on-close>
<el-form :model="editForm" label-width="70px">
@@ -301,14 +444,65 @@ save force</pre>
</el-button>
</template>
</el-dialog>
<!-- 数据导入对话框 -->
<el-dialog v-model="importDialogVisible" title="数据导入" width="480px" destroy-on-close>
<div class="import-hint">
请先下载模板按格式填写后上传导入只更新设备信息不会删除已有设备
</div>
<div class="import-actions">
<el-button size="small" @click="downloadImportTemplate">下载导入模板</el-button>
</div>
<el-upload
ref="uploadRef"
:auto-upload="false"
:show-file-list="true"
:limit="1"
accept=".xlsx,.xls"
:on-change="onImportFileChange"
:on-remove="() => importFile = null"
drag
style="margin-top: 16px"
>
<div class="upload-area">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="color: var(--text-muted); margin-bottom: 8px">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<div style="font-size: 13px; color: var(--text-secondary)">拖拽文件到此处,或 <em style="color: var(--accent)">点击选择</em></div>
<div style="font-size: 11px; color: var(--text-muted); margin-top: 4px">支持 .xlsx / .xls 格式</div>
</div>
</el-upload>
<div v-if="importResult" style="margin-top: 16px">
<el-alert
:type="importResult.failed?.length ? 'warning' : 'success'"
:title="`导入完成:成功 ${importResult.success}${importResult.failed?.length ? ',失败 ' + importResult.failed.length + ' 条' : ''}`"
:closable="false"
/>
<div v-if="importResult.failed?.length" style="margin-top: 10px; max-height: 160px; overflow-y: auto">
<div v-for="f in importResult.failed" :key="f.row" style="font-size: 12px; color: var(--danger); padding: 2px 0">
第 {{ f.row }} 行:{{ f.reason }}
</div>
</div>
</div>
<template #footer>
<el-button @click="importDialogVisible = false; importResult = null; importFile = null">关闭</el-button>
<el-button type="primary" :loading="importing" :disabled="!importFile" @click="doImport">开始导入</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ElMessage } from '../utils/message'
import * as deviceApi from '../api/device'
import request from '../utils/request'
import { useMobile } from '../composables/useMobile'
import { usePermission } from '../composables/usePermission'
const { isMobile } = useMobile()
const { can } = usePermission()
const route = useRoute()
@@ -333,10 +527,101 @@ const editVisible = ref(false)
const editForm = ref({ region: '', school_name: '', building: '', room_number: '', notes: '' })
const editSaving = ref(false)
const replaceVisible = ref(false)
const replaceForm = ref({ new_mac: '', reason: '' })
const replaceSaving = ref(false)
const replacements = ref([])
const checkResultVisible = ref(false)
const checkResult = ref(null)
const checkInProgress = ref(false)
// 移动端卡片展开状态
const expandedId = ref(null)
// 数据导入
const importDialogVisible = ref(false)
const importFile = ref(null)
const importing = ref(false)
const importResult = ref(null)
const uploadRef = ref(null)
const onImportFileChange = (file) => {
importFile.value = file.raw
}
const downloadImportTemplate = () => {
window.open('/api/import/template', '_blank')
}
const doImport = async () => {
if (!importFile.value) return
importing.value = true
importResult.value = null
const formData = new FormData()
formData.append('file', importFile.value)
try {
const { data } = await request.post('/import/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
importResult.value = data
if (!data.failed?.length) {
ElMessage.success(`成功导入 ${data.success} 条记录`)
loadDevices()
}
} catch (error) {
ElMessage.error(error.response?.data?.detail || '导入失败')
} finally {
importing.value = false
}
}
const toggleCard = (device) => {
expandedId.value = expandedId.value === device.id ? null : device.id
}
const doRefreshDeviceCard = async (device) => {
try {
const { data } = await deviceApi.refreshDevice(device.id)
device.status = data.status
device.distance_m = data.distance_m
if (data.model) device.model = data.model
ElMessage.success(`更新完成:${data.status === 'online' ? '在线' : '离线'}`)
} catch (error) {
ElMessage.error(error.response?.data?.detail || '更新失败')
}
}
const openEditFromCard = (device) => {
selectedDevice.value = { ...device }
editForm.value = {
region: device.region || '',
school_name: device.school_name || '',
building: device.building || '',
room_number: device.room_number || '',
notes: device.notes || '',
}
editVisible.value = true
}
const openReplaceFromCard = async (device) => {
selectedDevice.value = { ...device }
replaceForm.value = { new_mac: '', reason: '' }
replacements.value = []
try {
const { data } = await request.get(`/devices/${device.id}/replacements`)
replacements.value = data
} catch {}
replaceVisible.value = true
}
const openProvisionFromCard = (device) => {
selectedDevice.value = { ...device }
provisionResult.value = null
provisionVisible.value = true
}
const clearStatus = async () => {
clearing.value = true
try {
@@ -387,6 +672,12 @@ const formatPort = (device) => {
return `Onu${device.slot_number || '?'}/${device.port_number || '?'}`
}
const formatDistance = (d) => {
if (!d && d !== 0) return '—'
if (d === 1000) return '1000 米内'
return d + 'm'
}
const openDetail = (row) => {
selectedDevice.value = { ...row }
provisionResult.value = null
@@ -400,7 +691,7 @@ const doRefreshDevice = async () => {
selectedDevice.value.status = data.status
selectedDevice.value.distance_m = data.distance_m
if (data.model) selectedDevice.value.model = data.model
ElMessage.success(`更新完成:${data.status === 'online' ? '在线' : '离线'}${data.distance_m ? ',距离 ' + data.distance_m + 'm' : ''}`)
ElMessage.success(`更新完成:${data.status === 'online' ? '在线' : '离线'}${data.distance_m ? ',距离 ' + formatDistance(data.distance_m) : ''}`)
loadDevices()
} catch (error) {
ElMessage.error(error.response?.data?.detail || '更新失败')
@@ -409,6 +700,37 @@ const doRefreshDevice = async () => {
}
}
const openReplace = async () => {
replaceForm.value = { new_mac: '', reason: '' }
replacements.value = []
try {
const { data } = await request.get(`/devices/${selectedDevice.value.id}/replacements`)
replacements.value = data
} catch {}
detailVisible.value = false
replaceVisible.value = true
}
const doReplace = async () => {
if (!replaceForm.value.new_mac.trim()) {
ElMessage.warning('请输入新 MAC 地址')
return
}
replaceSaving.value = true
try {
await request.post(`/devices/${selectedDevice.value.id}/replace`, replaceForm.value)
ElMessage.success('更换成功')
replaceVisible.value = false
loadDevices()
} catch (error) {
ElMessage.error(error.response?.data?.detail || '更换失败')
} finally {
replaceSaving.value = false
}
}
const fmtReplaceTime = (t) => t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '—'
const openEdit = () => {
editForm.value = {
region: selectedDevice.value.region || '',
@@ -674,6 +996,15 @@ onMounted(() => {
.mono-val.accent { color: var(--accent); font-size: 13px; }
.replace-history-row {
display: flex;
align-items: center;
padding: 5px 0;
border-bottom: 1px solid var(--border-subtle);
font-size: 12px;
}
.replace-history-row:last-child { border-bottom: none; }
/* 分页 */
.pagination-wrap {
display: flex;
@@ -752,4 +1083,292 @@ onMounted(() => {
line-height: 1.7;
white-space: pre;
}
/* ── 移动端卡片 ── */
.device-cards {
display: flex;
flex-direction: column;
gap: 0;
}
.device-card {
border-bottom: 1px solid var(--border-subtle);
cursor: pointer;
transition: background 0.15s;
user-select: none;
}
.device-card:active {
background: var(--bg-hover);
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px 8px;
gap: 12px;
}
.card-head-left {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.card-mac {
font-family: var(--font-mono);
font-size: 13px;
font-weight: 600;
color: var(--accent);
letter-spacing: 0.02em;
}
.card-school {
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-head-right {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.card-chevron {
color: var(--text-muted);
transition: transform 0.2s ease;
flex-shrink: 0;
}
.device-card.expanded .card-chevron {
transform: rotate(180deg);
}
.card-body {
padding: 0 16px 12px;
display: flex;
flex-direction: column;
gap: 4px;
}
.card-row {
display: flex;
align-items: baseline;
gap: 8px;
min-height: 22px;
}
.card-label {
font-size: 11px;
color: var(--text-muted);
min-width: 52px;
flex-shrink: 0;
letter-spacing: 0.02em;
}
.card-val {
font-size: 12px;
color: var(--text-secondary);
flex: 1;
min-width: 0;
word-break: break-all;
}
.card-val.mono {
font-family: var(--font-mono);
font-size: 12px;
}
.card-row--notes .card-val {
color: var(--text-muted);
font-size: 11px;
line-height: 1.5;
}
.card-expanded {
border-top: 1px solid var(--border-subtle);
background: var(--bg-elevated);
}
.card-actions {
display: flex;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.card-action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
padding: 9px 8px;
background: var(--bg-card);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
color: var(--text-secondary);
font-size: 12px;
font-family: var(--font-sans);
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
min-height: 44px;
}
.card-action-btn:active {
background: var(--bg-hover);
}
.card-action-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.card-action-btn--primary {
background: var(--accent-dim);
border-color: var(--border-accent);
color: var(--accent);
}
.card-action-btn--primary:active {
background: rgba(0,210,180,0.25);
}
.card-action-btn--warn {
background: rgba(245,158,11,0.1);
border-color: rgba(245,158,11,0.3);
color: #d97706;
}
.card-action-btn--warn:active {
background: rgba(245,158,11,0.2);
}
.card-detail-rows {
padding: 10px 16px 14px;
display: flex;
flex-direction: column;
gap: 6px;
}
.cards-empty {
text-align: center;
padding: 48px 0;
font-size: 13px;
color: var(--text-muted);
}
/* 移动端筛选栏 */
@media (max-width: 767px) {
.device-list {
padding: 12px;
}
.page-header {
margin-bottom: 12px;
}
.page-title {
font-size: 18px;
}
.filter-bar {
padding: 12px;
gap: 10px;
flex-direction: column;
align-items: stretch;
}
.filter-group {
width: 100%;
}
.filter-group .el-select,
.filter-group .el-input {
width: 100% !important;
}
.filter-bar .el-button {
width: 100%;
height: 44px;
}
.data-stats {
padding: 10px 12px;
}
.pagination-wrap {
padding: 12px;
justify-content: center;
}
.pagination-wrap .el-pagination {
flex-wrap: wrap;
justify-content: center;
gap: 4px;
}
}
.mobile-pagination {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 12px;
}
.mobile-page-btn {
padding: 9px 20px;
background: var(--bg-elevated);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
color: var(--text-secondary);
font-size: 13px;
font-family: var(--font-sans);
cursor: pointer;
min-height: 44px;
transition: all 0.15s;
white-space: nowrap;
}
.mobile-page-btn:active {
background: var(--bg-hover);
}
.mobile-page-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.mobile-page-info {
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-mono);
white-space: nowrap;
}
.import-hint {
font-size: 12px;
color: var(--text-muted);
line-height: 1.6;
margin-bottom: 12px;
}
.import-actions {
margin-bottom: 4px;
}
.upload-area {
display: flex;
flex-direction: column;
align-items: center;
padding: 24px 0;
}
</style>
+7 -2
View File
@@ -57,7 +57,7 @@
</el-upload>
<div class="action-row">
<el-button @click="handleDownloadTemplate" size="default">
<el-button v-if="can('device.import')" @click="handleDownloadTemplate" size="default">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 5px">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
@@ -66,6 +66,7 @@
下载模板
</el-button>
<el-button
v-if="can('device.import')"
type="primary"
size="default"
:loading="uploading"
@@ -74,6 +75,7 @@
>
{{ uploading ? '导入中…' : '开始导入' }}
</el-button>
<el-alert v-if="!can('device.import')" type="warning" :closable="false" title="您没有导入权限" show-icon />
</div>
</div>
</div>
@@ -119,8 +121,11 @@
<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { ElMessage } from '../utils/message'
import * as importApi from '../api/import'
import { usePermission } from '../composables/usePermission'
const { can } = usePermission()
const selectedFile = ref(null)
const uploading = ref(false)
File diff suppressed because it is too large Load Diff
+275 -17
View File
@@ -7,13 +7,14 @@
<span class="page-subtitle">光线路终端设备配置与监控</span>
</div>
<div class="header-actions">
<el-button type="primary" size="small" @click="openAddDialog">
<el-button v-if="!isMobile && can('olt.manage')" type="primary" size="small" @click="openAddDialog">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="margin-right: 5px">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
添加 OLT
</el-button>
<el-upload
v-if="!isMobile && can('olt.manage')"
:auto-upload="false"
:show-file-list="false"
:on-change="handleImportFile"
@@ -22,19 +23,19 @@
>
<el-button type="success" size="small" :loading="importing">批量导入</el-button>
</el-upload>
<el-button size="small" @click="downloadTemplate">下载模板</el-button>
<el-button type="danger" plain size="small" @click="openDuplicateMacs">
<el-button v-if="!isMobile && can('olt.manage')" size="small" @click="downloadTemplate">下载模板</el-button>
<el-button v-if="!isMobile && can('olt.manage')" type="danger" plain size="small" @click="openDuplicateMacs">
重复 MAC
<el-badge v-if="duplicateCount > 0" :value="duplicateCount" style="margin-left: 4px" />
</el-button>
<el-button type="warning" plain size="small" @click="openNewDevices">
<el-button v-if="can('olt.manage')" type="warning" plain size="small" @click="openNewDevices">
新增设备
<el-badge v-if="newDeviceCount > 0" :value="newDeviceCount" style="margin-left: 4px" />
</el-button>
<el-button type="danger" plain size="small" @click="runLoopbackDetection" :loading="loopDetecting">
<el-button v-if="can('olt.loopback')" type="danger" plain size="small" @click="runLoopbackDetection" :loading="loopDetecting">
环路检测
</el-button>
<el-button type="primary" plain size="small" @click="runQuickScan" :loading="quickScanning">
<el-button v-if="can('olt.discover')" type="primary" plain size="small" @click="runQuickScan" :loading="quickScanning">
快速扫描
</el-button>
</div>
@@ -42,7 +43,14 @@
<!-- OLT 列表 -->
<div class="data-panel">
<el-table :data="devices" size="small" style="width: 100%">
<!-- 筛选栏 -->
<div class="filter-bar" v-if="!isMobile">
<el-select v-model="filterRegion" placeholder="全部区域" clearable size="small" style="width: 160px" @change="fetchDevices">
<el-option v-for="r in regions" :key="r" :label="r" :value="r" />
</el-select>
</div>
<!-- 桌面端表格 -->
<el-table v-if="!isMobile" :data="filteredDevices" size="small" style="width: 100%">
<el-table-column type="index" label="#" width="52" align="center">
<template #default="{ $index }">
<span class="row-index">{{ $index + 1 }}</span>
@@ -53,6 +61,11 @@
<span class="ip-link" @click="openDetail(row)">{{ row.ip_address }}</span>
</template>
</el-table-column>
<el-table-column prop="region" label="区域" width="90">
<template #default="{ row }">
<span class="region-tag">{{ row.region || '—' }}</span>
</template>
</el-table-column>
<el-table-column prop="username" label="用户名" width="110">
<template #default="{ row }">
<span class="mono-val">{{ row.username }}</span>
@@ -85,6 +98,50 @@
</template>
</el-table-column>
</el-table>
<!-- 移动端 OLT 卡片 -->
<div v-if="isMobile" class="olt-cards">
<div v-for="device in devices" :key="device.id" class="olt-card">
<div class="olt-card-head">
<div class="olt-card-head-left">
<span class="olt-ip">{{ device.ip_address }}</span>
<span class="olt-location">{{ device.location || '—' }}</span>
</div>
<div class="olt-card-head-right">
<span
v-if="loopStatusMap[device.id]"
class="loop-badge"
@click="showLoopDetail(device.id)"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
环路 {{ loopStatusMap[device.id].length }}
</span>
<span v-else class="olt-status-ok">
<span class="status-dot-sm online-dot"></span>正常
</span>
</div>
</div>
<div class="olt-card-actions">
<button class="olt-action-btn" @click="openDetail(device)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
快速扫描
</button>
<button v-if="can('olt.port_manage')" class="olt-action-btn" @click="openPortManagerFromCard(device)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<circle cx="7" cy="9" r="1.2" fill="currentColor"/>
</svg>
端口管理
</button>
</div>
</div>
<div v-if="devices.length === 0" class="olt-cards-empty">暂无 OLT 设备</div>
</div>
</div>
<!-- 快速扫描结果 -->
@@ -206,6 +263,11 @@
<el-form-item label="IP 地址"><el-input v-model="form.ip_address" :disabled="isEdit" /></el-form-item>
<el-form-item label="用户名"><el-input v-model="form.username" /></el-form-item>
<el-form-item label="密码"><el-input v-model="form.password" type="password" placeholder="不修改请留空" /></el-form-item>
<el-form-item label="区域">
<el-select v-model="form.region" style="width: 100%">
<el-option v-for="r in regions" :key="r" :label="r" :value="r" />
</el-select>
</el-form-item>
<el-form-item label="安装位置"><el-input v-model="form.location" /></el-form-item>
<el-form-item label="槽位命令"><el-input v-model="form.slot_command" /></el-form-item>
<el-form-item label="描述"><el-input v-model="form.description" type="textarea" :rows="2" /></el-form-item>
@@ -225,6 +287,7 @@
<el-descriptions-item label="用户名">
<span class="mono-val">{{ selectedDevice.username }}</span>
</el-descriptions-item>
<el-descriptions-item label="区域">{{ selectedDevice.region || '—' }}</el-descriptions-item>
<el-descriptions-item label="安装位置">{{ selectedDevice.location || '—' }}</el-descriptions-item>
<el-descriptions-item label="槽位命令" :span="2">
<span class="mono-val cmd">{{ selectedDevice.slot_command || '—' }}</span>
@@ -236,7 +299,7 @@
<div class="detail-actions">
<!-- 第一行设备操作 -->
<div class="action-row">
<el-button type="info" :loading="portsLoading" @click="openPortManager">
<el-button v-if="can('olt.port_manage')" type="info" :loading="portsLoading" @click="openPortManager">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:4px;vertical-align:-2px">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<circle cx="7" cy="9" r="1.2" fill="currentColor"/><circle cx="11" cy="9" r="1.2" fill="currentColor"/>
@@ -250,7 +313,7 @@
</svg>
扫描发现
</el-button>
<el-button type="warning" :loading="discovering" @click="discoverOlt">
<el-button v-if="can('olt.discover')" type="warning" :loading="discovering" @click="discoverOlt">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:4px;vertical-align:-2px">
<path d="M12 5v14M5 12l7 7 7-7"/>
</svg>
@@ -260,8 +323,8 @@
<!-- 第二行管理操作 + 关闭 -->
<div class="action-row action-row--secondary">
<div class="action-left">
<el-button type="primary" @click="openEdit">编辑</el-button>
<el-button type="danger" @click="confirmDelete">删除</el-button>
<el-button v-if="can('olt.manage')" type="primary" @click="openEdit">编辑</el-button>
<el-button v-if="can('olt.manage')" type="danger" @click="confirmDelete">删除</el-button>
</div>
<el-button @click="detailVisible = false">关闭</el-button>
</div>
@@ -298,7 +361,8 @@
<div class="port-status-text">{{ portStatusText(port) }}</div>
</div>
</div>
<div class="port-hint">点击端口可切换开启/关闭状态</div>
<div class="port-hint" v-if="can('olt.port_manage')">点击端口可切换开启/关闭状态</div>
<div class="port-hint" v-else>仅查看端口状态无管理权限</div>
</div>
<template #footer>
<el-button :loading="portsLoading" @click="loadPorts">刷新</el-button>
@@ -342,7 +406,7 @@
</el-table-column>
</el-table>
<template #footer>
<el-button type="warning" :loading="discovering" @click="discoverOlt">确认入库新设备</el-button>
<el-button v-if="can('olt.discover')" type="warning" :loading="discovering" @click="discoverOlt">确认入库新设备</el-button>
<el-button @click="scanVisible = false">关闭</el-button>
</template>
</el-dialog>
@@ -440,11 +504,22 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from '../utils/message'
import { ElMessageBox } from 'element-plus'
import request from '../utils/request'
import { useMobile } from '../composables/useMobile'
import { usePermission } from '../composables/usePermission'
const { isMobile } = useMobile()
const { can } = usePermission()
const devices = ref([])
const filterRegion = ref('')
const regions = ref([])
const filteredDevices = computed(() =>
filterRegion.value ? devices.value.filter(d => d.region === filterRegion.value) : devices.value
)
const dialogVisible = ref(false)
const detailVisible = ref(false)
const scanVisible = ref(false)
@@ -471,7 +546,7 @@ const loopDetailInterfaces = ref([])
const quickScanning = ref(false)
const quickScanVisible = ref(false)
const quickScanResult = ref({ total_online: 0, total_offline: 0, total_new: 0, results: [], errors: [] })
const form = ref({ ip_address: '', username: '', password: '', slot_command: 'display onu slot', location: '', description: '' })
const form = ref({ ip_address: '', username: '', password: '', slot_command: 'display onu slot', region: '城区', location: '', description: '' })
const portManagerVisible = ref(false)
const portsLoading = ref(false)
@@ -508,6 +583,7 @@ const openPortManager = async () => {
}
const togglePort = async (port) => {
if (!can('olt.port_manage')) return
const isUp = port.status === 'up'
const action = isUp ? 'shutdown' : 'undo shutdown'
const actionText = isUp ? '关闭' : '开启'
@@ -535,6 +611,13 @@ const togglePort = async (port) => {
}
}
const fetchRegions = async () => {
try {
const { data } = await request.get('/olt/regions')
regions.value = data
} catch {}
}
const fetchDevices = async () => {
const { data } = await request.get('/olt/devices')
devices.value = data
@@ -549,7 +632,7 @@ const fetchDuplicateCount = async () => {
const openAddDialog = () => {
isEdit.value = false
form.value = { ip_address: '', username: '', password: '', slot_command: 'display onu slot', location: '', description: '' }
form.value = { ip_address: '', username: '', password: '', slot_command: 'display onu slot', region: regions.value[0] || '', location: '', description: '' }
dialogVisible.value = true
}
@@ -566,6 +649,7 @@ const openEdit = () => {
username: selectedDevice.value.username,
password: '',
slot_command: selectedDevice.value.slot_command || 'display onu slot',
region: selectedDevice.value.region || '城区',
location: selectedDevice.value.location || '',
description: selectedDevice.value.description || ''
}
@@ -794,7 +878,19 @@ const downloadTemplate = () => {
window.open('/api/olt/template', '_blank')
}
// 移动端卡片操作
const openEditFromCard = (device) => {
selectedDevice.value = { ...device }
openEdit()
}
const openPortManagerFromCard = async (device) => {
selectedDevice.value = { ...device }
await openPortManager()
}
onMounted(() => {
fetchRegions()
fetchDevices()
fetchDuplicateCount()
fetchNewDeviceCount()
@@ -851,6 +947,25 @@ onMounted(() => {
overflow: hidden;
}
.filter-bar {
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
display: flex;
gap: 12px;
align-items: center;
}
.region-tag {
display: inline-block;
padding: 1px 8px;
background: var(--accent-dim);
border: 1px solid var(--border-accent);
border-radius: 10px;
font-size: 11px;
color: var(--accent);
font-weight: 500;
}
/* 表格内元素 */
.row-index {
font-family: var(--font-mono);
@@ -1148,4 +1263,147 @@ onMounted(() => {
display: flex;
gap: 8px;
}
/* ── 移动端 OLT 卡片 ── */
.olt-cards {
display: flex;
flex-direction: column;
gap: 0;
}
.olt-card {
border-bottom: 1px solid var(--border-subtle);
padding: 14px 16px;
}
.olt-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.olt-card-head-left {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.olt-ip {
font-family: var(--font-mono);
font-size: 15px;
font-weight: 600;
color: var(--accent);
cursor: pointer;
letter-spacing: 0.02em;
}
.olt-location {
font-size: 12px;
color: var(--text-secondary);
}
.olt-card-head-right {
flex-shrink: 0;
display: flex;
align-items: center;
}
.olt-status-ok {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: var(--success);
font-weight: 500;
}
.online-dot {
background: var(--success);
box-shadow: 0 0 5px var(--success);
}
.olt-card-actions {
display: flex;
gap: 8px;
}
.olt-action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
padding: 9px 6px;
background: var(--bg-elevated);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
color: var(--text-secondary);
font-size: 12px;
font-family: var(--font-sans);
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
min-height: 44px;
}
.olt-action-btn:active {
background: var(--bg-hover);
}
.olt-action-btn--edit {
flex: 0 0 auto;
padding: 9px 14px;
}
.olt-cards-empty {
text-align: center;
padding: 48px 0;
font-size: 13px;
color: var(--text-muted);
}
/* 移动端页头 */
@media (max-width: 767px) {
.olt-manage {
padding: 12px;
}
.page-header {
flex-direction: column;
gap: 12px;
margin-bottom: 14px;
}
.page-title {
font-size: 18px;
}
.header-actions {
width: 100%;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.header-actions .el-button,
.header-actions > * {
width: 100%;
min-height: 44px;
}
/* 移动端按钮区只显示2列(移动端只剩新增设备、环路检测、快速扫描) */
.header-actions {
grid-template-columns: repeat(2, 1fr);
}
/* 端口管理对话框:限制高度支持滚动 */
.switch-panel {
max-height: 45vh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
}
</style>
+214
View File
@@ -0,0 +1,214 @@
<template>
<div class="page-wrap">
<div class="page-header">
<h2 class="page-title">角色权限配置</h2>
</div>
<div class="layout" v-if="!loading">
<!-- 左侧角色列表 -->
<div class="role-list">
<div
v-for="r in roles"
:key="r.role"
class="role-item"
:class="{ active: selectedRole === r.role, disabled: r.role === 'admin' }"
@click="selectRole(r)"
>
<span class="role-name">{{ roleLabel(r.role) }}</span>
<span class="role-perm-count">{{ r.role === 'admin' ? '全部' : r.permissions.length + ' 项' }}</span>
</div>
</div>
<!-- 右侧权限矩阵 -->
<div class="perm-panel">
<div v-if="!selectedRole" class="perm-empty">选择左侧角色查看权限</div>
<template v-else>
<div class="perm-panel-header">
<span class="perm-panel-title">{{ roleLabel(selectedRole) }} 的权限</span>
<div class="perm-actions" v-if="selectedRole !== 'admin'">
<button class="btn btn-ghost" @click="resetPerms">重置</button>
<button class="btn btn-primary" :disabled="saving" @click="handleSave">
{{ saving ? '保存中...' : '保存权限' }}
</button>
</div>
</div>
<div v-if="selectedRole === 'admin'" class="admin-notice">
admin 角色拥有全部权限不可修改
</div>
<div v-else class="perm-modules">
<div v-for="(perms, module) in allPermissions" :key="module" class="perm-module">
<div class="module-header">
<span class="module-name">{{ moduleLabel(module) }}</span>
<label class="check-all">
<input
type="checkbox"
:checked="isModuleAllChecked(module, perms)"
:indeterminate="isModuleIndeterminate(module, perms)"
@change="toggleModule(module, perms, $event.target.checked)"
/>
全选
</label>
</div>
<div class="perm-items">
<label v-for="p in perms" :key="p.code" class="perm-item">
<input
type="checkbox"
:value="p.code"
v-model="selectedPerms"
/>
<div class="perm-info">
<span class="perm-name">{{ p.name }}</span>
<span class="perm-code">{{ p.code }}</span>
</div>
</label>
</div>
</div>
</div>
</template>
</div>
</div>
<div v-else class="loading-tip">加载中...</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getRoles, getPermissions, updateRolePermissions } from '../api/role'
const loading = ref(true)
const saving = ref(false)
const roles = ref([])
const allPermissions = ref({}) // { module: [{ code, name, description }] }
const selectedRole = ref('')
const selectedPerms = ref([])
let originalPerms = []
const roleOptions = [
{ value: 'admin', label: '超级管理员' },
{ value: 'area_admin', label: '区域管理员' },
{ value: 'school_admin', label: '学校管理员' },
{ value: 'user', label: '普通用户' },
]
const moduleLabels = {
device: '设备管理',
olt: 'OLT 管理',
user: '用户管理',
system: '系统管理',
}
const roleLabel = (r) => roleOptions.find(o => o.value === r)?.label || r
const moduleLabel = (m) => moduleLabels[m] || m
const selectRole = (r) => {
if (r.role === 'admin') return
selectedRole.value = r.role
selectedPerms.value = [...r.permissions]
originalPerms = [...r.permissions]
}
const resetPerms = () => {
selectedPerms.value = [...originalPerms]
}
const isModuleAllChecked = (module, perms) =>
perms.every(p => selectedPerms.value.includes(p.code))
const isModuleIndeterminate = (module, perms) => {
const checked = perms.filter(p => selectedPerms.value.includes(p.code)).length
return checked > 0 && checked < perms.length
}
const toggleModule = (module, perms, checked) => {
const codes = perms.map(p => p.code)
if (checked) {
selectedPerms.value = [...new Set([...selectedPerms.value, ...codes])]
} else {
selectedPerms.value = selectedPerms.value.filter(c => !codes.includes(c))
}
}
const handleSave = async () => {
saving.value = true
try {
await updateRolePermissions(selectedRole.value, selectedPerms.value)
// 更新本地 roles 数据
const r = roles.value.find(r => r.role === selectedRole.value)
if (r) r.permissions = [...selectedPerms.value]
originalPerms = [...selectedPerms.value]
} finally {
saving.value = false
}
}
onMounted(async () => {
const [rolesRes, permsRes] = await Promise.all([getRoles(), getPermissions()])
roles.value = rolesRes.data
allPermissions.value = permsRes.data
loading.value = false
})
</script>
<style scoped>
.page-wrap { padding: 24px; max-width: 1100px; }
.page-header { margin-bottom: 20px; }
.page-title { font-size: 18px; font-weight: 600; color: var(--text-primary); margin: 0; }
.layout { display: flex; gap: 20px; align-items: flex-start; }
.role-list {
width: 180px; flex-shrink: 0;
border: 1px solid var(--border-subtle); border-radius: var(--radius-lg);
overflow: hidden;
}
.role-item {
display: flex; align-items: center; justify-content: space-between;
padding: 13px 16px; cursor: pointer; border-bottom: 1px solid var(--border-subtle);
transition: background 0.15s; color: var(--text-secondary); font-size: 13px;
}
.role-item:last-child { border-bottom: none; }
.role-item:hover { background: var(--bg-hover); }
.role-item.active { background: var(--accent-dim); color: var(--accent); }
.role-item.disabled { cursor: default; opacity: 0.6; }
.role-name { font-weight: 500; }
.role-perm-count { font-size: 11px; color: var(--text-muted); }
.role-item.active .role-perm-count { color: var(--accent); opacity: 0.7; }
.perm-panel {
flex: 1; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg);
min-height: 300px;
}
.perm-empty { padding: 60px; text-align: center; color: var(--text-muted); font-size: 13px; }
.perm-panel-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px; border-bottom: 1px solid var(--border-subtle);
}
.perm-panel-title { font-size: 14px; font-weight: 600; color: var(--text-primary); }
.perm-actions { display: flex; gap: 8px; }
.admin-notice { padding: 20px; color: var(--text-muted); font-size: 13px; }
.perm-modules { padding: 16px 20px; display: flex; flex-direction: column; gap: 20px; }
.perm-module { }
.module-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--border-subtle);
}
.module-name { font-size: 13px; font-weight: 600; color: var(--text-primary); }
.check-all { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-muted); cursor: pointer; }
.perm-items { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; }
.perm-item {
display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px;
border: 1px solid var(--border-subtle); border-radius: var(--radius-md);
cursor: pointer; transition: border-color 0.15s; background: var(--bg-elevated);
}
.perm-item:has(input:checked) { border-color: var(--border-accent); background: var(--accent-dim); }
.perm-item input { margin-top: 2px; accent-color: var(--accent); cursor: pointer; }
.perm-info { display: flex; flex-direction: column; gap: 2px; }
.perm-name { font-size: 13px; color: var(--text-primary); font-weight: 500; }
.perm-code { font-size: 11px; color: var(--text-muted); font-family: var(--font-mono); }
.loading-tip { padding: 60px; text-align: center; color: var(--text-muted); }
.btn { padding: 7px 16px; border-radius: var(--radius-md); font-size: 13px; cursor: pointer; border: 1px solid transparent; font-family: var(--font-sans); }
.btn-ghost { background: var(--bg-elevated); border-color: var(--border-default); color: var(--text-secondary); }
.btn-ghost:hover { border-color: var(--border-strong); }
.btn-primary { background: var(--accent-dim); border-color: var(--border-accent); color: var(--accent); }
.btn-primary:hover { background: var(--accent); color: #fff; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
</style>
+352
View File
@@ -0,0 +1,352 @@
<template>
<div class="settings-page">
<div class="page-header">
<div class="page-title-group">
<h1 class="page-title">系统设置</h1>
<span class="page-subtitle">管理员专属配置项</span>
</div>
</div>
<div class="settings-card">
<div class="card-header">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: -2px; margin-right: 8px">
<circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
</svg>
定时检查间隔
</div>
<div class="card-body">
<p class="setting-desc">控制系统自动扫描 OLT 设备状态的频率设置后立即生效无需重启服务</p>
<div class="interval-form">
<div class="input-group">
<input
v-model.number="intervalMinutes"
type="number"
:min="5"
:max="1440"
class="interval-input"
:disabled="saving"
/>
<span class="input-unit">分钟</span>
</div>
<div class="preset-btns">
<button
v-for="p in presets"
:key="p.value"
class="preset-btn"
:class="{ active: intervalMinutes === p.value }"
@click="intervalMinutes = p.value"
>{{ p.label }}</button>
</div>
</div>
<div v-if="intervalMinutes < 5" class="warn-tip">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
最小间隔为 5 分钟
</div>
<div class="form-footer">
<span class="current-hint">当前设置{{ currentIntervalLabel }}</span>
<button
class="save-btn"
:disabled="saving || intervalMinutes < 5 || intervalMinutes > 1440"
@click="save"
>
<svg v-if="saving" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spinning">
<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
{{ saving ? '保存中…' : '保存设置' }}
</button>
</div>
<div class="next-run-hint">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
下次扫描{{ nextCheckLabel }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage } from '../utils/message'
import { getSettings, updateCheckInterval } from '../api/settings'
const intervalMinutes = ref(30)
const currentSeconds = ref(1800)
const nextCheckAt = ref(null)
const isScanning = ref(false)
const saving = ref(false)
const presets = [
{ label: '5 分钟', value: 5 },
{ label: '15 分钟', value: 15 },
{ label: '30 分钟', value: 30 },
{ label: '1 小时', value: 60 },
{ label: '2 小时', value: 120 },
]
const currentIntervalLabel = computed(() => {
const m = Math.round(currentSeconds.value / 60)
return m >= 60 ? `${m / 60} 小时` : `${m} 分钟`
})
const nextCheckLabel = computed(() => {
if (isScanning.value) return '扫描中…'
if (!nextCheckAt.value) return '暂无记录(尚未执行过扫描)'
const ts = parseInt(nextCheckAt.value) * 1000
const now = Date.now()
if (ts <= now) return '即将执行'
const diff = Math.round((ts - now) / 1000)
const h = Math.floor(diff / 3600)
const m = Math.floor((diff % 3600) / 60)
const s = diff % 60
const parts = []
if (h > 0) parts.push(`${h} 小时`)
if (m > 0) parts.push(`${m} 分钟`)
if (s > 0 && h === 0) parts.push(`${s}`)
const timeStr = new Date(ts).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
return `${timeStr}${parts.join(' ')} 后)`
})
const load = async () => {
try {
const { data } = await getSettings()
const s = data?.check_interval_seconds
if (s) {
currentSeconds.value = parseInt(s.value)
intervalMinutes.value = Math.round(currentSeconds.value / 60)
}
const n = data?.next_check_at
nextCheckAt.value = n?.value || null
isScanning.value = n?.running || false
} catch {
// 静默处理
}
}
const save = async () => {
if (intervalMinutes.value < 5 || intervalMinutes.value > 1440) return
saving.value = true
try {
const seconds = intervalMinutes.value * 60
await updateCheckInterval(seconds)
currentSeconds.value = seconds
ElMessage.success('设置已保存,下次检查将按新间隔执行')
} catch (e) {
ElMessage.error(e?.response?.data?.detail || '保存失败')
} finally {
saving.value = false
}
}
onMounted(() => {
load()
const timer = setInterval(load, 10000)
onUnmounted(() => clearInterval(timer))
})
</script>
<style scoped>
.settings-page {
padding: 24px;
max-width: 640px;
}
.page-header {
margin-bottom: 24px;
}
.page-title-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.page-title {
margin: 0;
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.02em;
}
.page-subtitle {
font-size: 12px;
color: var(--text-muted);
}
.settings-card {
background: var(--bg-card);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
overflow: hidden;
}
.card-header {
padding: 14px 20px;
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-elevated);
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.card-body {
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.setting-desc {
margin: 0;
font-size: 13px;
color: var(--text-muted);
line-height: 1.6;
}
.interval-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.input-group {
display: flex;
align-items: center;
gap: 10px;
}
.interval-input {
width: 100px;
padding: 8px 12px;
background: var(--bg-elevated);
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 15px;
font-family: var(--font-mono);
font-weight: 600;
text-align: center;
transition: border-color 0.2s;
outline: none;
}
.interval-input:focus {
border-color: var(--accent);
}
.interval-input:disabled {
opacity: 0.5;
}
.input-unit {
font-size: 13px;
color: var(--text-muted);
}
.preset-btns {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.preset-btn {
padding: 5px 12px;
background: var(--bg-elevated);
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 12px;
font-family: var(--font-sans);
cursor: pointer;
transition: all 0.15s;
}
.preset-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.preset-btn.active {
background: var(--accent-dim);
border-color: var(--accent);
color: var(--accent);
}
.warn-tip {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #f59e0b;
}
.form-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 4px;
}
.current-hint {
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-mono);
}
.next-run-hint {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-muted);
padding-top: 4px;
}
.save-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 20px;
background: var(--accent);
border: none;
border-radius: var(--radius-sm);
color: #fff;
font-size: 13px;
font-family: var(--font-sans);
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.save-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.save-btn:not(:disabled):hover {
opacity: 0.85;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinning {
animation: spin 0.8s linear infinite;
}
@media (max-width: 767px) {
.settings-page {
padding: 16px 12px;
}
}
</style>
+454
View File
@@ -0,0 +1,454 @@
<template>
<div class="page-wrap">
<div class="page-header">
<h2 class="page-title">用户管理</h2>
<div class="header-filters">
<input
v-model="keyword"
class="filter-input"
placeholder="搜索用户名/邮箱"
@input="onSearch"
/>
<select v-model="filterRole" class="filter-select" @change="onSearch">
<option value="">全部角色</option>
<option v-for="r in roleOptions" :key="r.value" :value="r.value">{{ r.label }}</option>
</select>
</div>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>用户名</th>
<th>邮箱</th>
<th>角色</th>
<th>区域/学校</th>
<th>状态</th>
<th>最后登录</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="7" class="td-center">加载中...</td>
</tr>
<tr v-else-if="!users.length">
<td colspan="7" class="td-center td-empty">暂无用户</td>
</tr>
<tr v-for="u in users" :key="u.id">
<td class="td-username">{{ u.username }}</td>
<td class="td-muted">{{ u.email || '—' }}</td>
<td>
<span class="role-badge" :class="'role-' + u.role">{{ roleLabel(u.role) }}</span>
</td>
<td class="td-muted">
<span v-if="u.assigned_area">{{ u.assigned_area }}</span>
<span v-if="u.assigned_school"> / {{ u.assigned_school }}</span>
<span v-if="!u.assigned_area && !u.assigned_school"></span>
</td>
<td>
<span class="status-badge" :class="u.is_active ? 'status-active' : 'status-disabled'">
{{ u.is_active ? '启用' : '禁用' }}
</span>
</td>
<td class="td-muted">{{ formatTime(u.last_login) }}</td>
<td>
<div class="action-btns">
<button class="btn-sm btn-primary" @click="openEdit(u)">编辑</button>
<button
class="btn-sm"
:class="u.is_active ? 'btn-danger' : 'btn-success'"
@click="handleToggle(u)"
>
{{ u.is_active ? '禁用' : '启用' }}
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div class="pagination" v-if="total > pageSize">
<button class="page-btn" :disabled="page === 0" @click="page--; loadUsers()">上一页</button>
<span class="page-info">{{ page + 1 }} / {{ Math.ceil(total / pageSize) }}</span>
<button class="page-btn" :disabled="(page + 1) * pageSize >= total" @click="page++; loadUsers()">下一页</button>
</div>
<!-- 编辑弹窗 -->
<div v-if="editUser" class="modal-overlay" @click.self="editUser = null">
<div class="modal">
<div class="modal-header">
<span class="modal-title">编辑用户{{ editUser.username }}</span>
<button class="modal-close" @click="editUser = null"></button>
</div>
<div class="modal-body">
<div class="form-row">
<label class="form-label">角色</label>
<select v-model="editForm.role" class="form-select" @change="onRoleChange">
<option v-for="r in roleOptions" :key="r.value" :value="r.value">{{ r.label }}</option>
</select>
</div>
<!-- 区域管理员多选区域 -->
<div class="form-row" v-if="editForm.role === 'area_admin'">
<label class="form-label">分配区域可多选</label>
<div class="multi-toolbar">
<input v-model="areaSearch" class="multi-search" placeholder="搜索区域…" />
<button class="multi-btn" @click="selectAllAreas">全选</button>
<button class="multi-btn" @click="invertAreas">反选</button>
</div>
<div class="multi-select-wrap">
<div
v-for="r in filteredRegions"
:key="r"
class="multi-option"
:class="{ selected: editForm.selectedAreas.includes(r) }"
@click="toggleArea(r)"
>{{ r }}</div>
<div v-if="!filteredRegions.length" class="multi-empty">暂无匹配区域</div>
</div>
<div class="selected-hint" v-if="editForm.selectedAreas.length">
已选{{ editForm.selectedAreas.join('') }}
</div>
</div>
<!-- 学校管理员先选区域筛选再多选学校 -->
<div class="form-row" v-if="editForm.role === 'school_admin'">
<label class="form-label">按区域筛选学校</label>
<select v-model="schoolFilterRegion" class="form-select" @change="loadSchools">
<option value="">全部区域</option>
<option v-for="r in allRegions" :key="r" :value="r">{{ r }}</option>
</select>
</div>
<div class="form-row" v-if="editForm.role === 'school_admin'">
<label class="form-label">分配学校可多选</label>
<div class="multi-toolbar">
<input v-model="schoolSearch" class="multi-search" placeholder="搜索学校…" />
<button class="multi-btn" @click="selectAllSchools">全选</button>
<button class="multi-btn" @click="invertSchools">反选</button>
</div>
<div class="multi-select-wrap">
<div
v-for="s in filteredSchoolList"
:key="s"
class="multi-option"
:class="{ selected: editForm.selectedSchools.includes(s) }"
@click="toggleSchool(s)"
>{{ s }}</div>
<div v-if="!filteredSchoolList.length" class="multi-empty">暂无匹配学校</div>
</div>
<div class="selected-hint" v-if="editForm.selectedSchools.length">
已选 {{ editForm.selectedSchools.length }} {{ editForm.selectedSchools.join('') }}
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" @click="editUser = null">取消</button>
<button class="btn btn-primary" :disabled="saving" @click="handleSave">
{{ saving ? '保存中...' : '保存' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { getUsers, updateUserRole, toggleUser } from '../api/user'
import request from '../utils/request'
const users = ref([])
const total = ref(0)
const loading = ref(false)
const page = ref(0)
const pageSize = 20
const keyword = ref('')
const filterRole = ref('')
let searchTimer = null
const editUser = ref(null)
const editForm = ref({ role: 'user', selectedAreas: [], selectedSchools: [] })
const saving = ref(false)
const allRegions = ref([])
const allSchools = ref([])
const filteredSchools = ref([])
const schoolFilterRegion = ref('')
// 搜索词
const areaSearch = ref('')
const schoolSearch = ref('')
// 过滤后的区域/学校列表
const filteredRegions = computed(() =>
areaSearch.value
? allRegions.value.filter(r => r.includes(areaSearch.value))
: allRegions.value
)
const filteredSchoolList = computed(() =>
schoolSearch.value
? filteredSchools.value.filter(s => s.includes(schoolSearch.value))
: filteredSchools.value
)
// 全选/反选 - 区域
const selectAllAreas = () => {
editForm.value.selectedAreas = [...filteredRegions.value]
}
const invertAreas = () => {
editForm.value.selectedAreas = filteredRegions.value.filter(
r => !editForm.value.selectedAreas.includes(r)
)
}
// 全选/反选 - 学校
const selectAllSchools = () => {
const toAdd = filteredSchoolList.value.filter(s => !editForm.value.selectedSchools.includes(s))
editForm.value.selectedSchools = [...editForm.value.selectedSchools, ...toAdd]
}
const invertSchools = () => {
editForm.value.selectedSchools = filteredSchoolList.value.filter(
s => !editForm.value.selectedSchools.includes(s)
)
}
const roleOptions = [
{ value: 'admin', label: '超级管理员' },
{ value: 'area_admin', label: '区域管理员' },
{ value: 'school_admin', label: '学校管理员' },
{ value: 'user', label: '普通用户' },
]
const roleLabel = (role) => roleOptions.find(r => r.value === role)?.label || role
const formatTime = (t) => {
if (!t) return '—'
return new Date(t).toLocaleString('zh-CN', { hour12: false })
}
const loadRegions = async () => {
try {
const { data } = await request.get('/devices/regions')
allRegions.value = data
} catch {}
}
const loadSchools = async () => {
try {
const { data } = await request.get('/devices/schools', {
params: schoolFilterRegion.value ? { region: schoolFilterRegion.value } : {}
})
allSchools.value = data
filteredSchools.value = data
} catch {}
}
const loadUsers = async () => {
loading.value = true
try {
const res = await getUsers({
skip: page.value * pageSize,
limit: pageSize,
keyword: keyword.value || undefined,
role: filterRole.value || undefined,
})
users.value = res.data.items
total.value = res.data.total
} finally {
loading.value = false
}
}
const onSearch = () => {
clearTimeout(searchTimer)
searchTimer = setTimeout(() => { page.value = 0; loadUsers() }, 300)
}
const openEdit = (u) => {
editUser.value = u
schoolFilterRegion.value = ''
areaSearch.value = ''
schoolSearch.value = ''
const areas = u.assigned_area ? u.assigned_area.split(',').map(s => s.trim()).filter(Boolean) : []
const schools = u.assigned_school ? u.assigned_school.split(',').map(s => s.trim()).filter(Boolean) : []
editForm.value = { role: u.role, selectedAreas: areas, selectedSchools: schools }
if (u.role === 'school_admin') loadSchools()
}
const onRoleChange = () => {
editForm.value.selectedAreas = []
editForm.value.selectedSchools = []
schoolFilterRegion.value = ''
areaSearch.value = ''
schoolSearch.value = ''
if (editForm.value.role === 'school_admin') loadSchools()
}
const toggleArea = (r) => {
const idx = editForm.value.selectedAreas.indexOf(r)
if (idx >= 0) editForm.value.selectedAreas.splice(idx, 1)
else editForm.value.selectedAreas.push(r)
}
const toggleSchool = (s) => {
const idx = editForm.value.selectedSchools.indexOf(s)
if (idx >= 0) editForm.value.selectedSchools.splice(idx, 1)
else editForm.value.selectedSchools.push(s)
}
const handleSave = async () => {
saving.value = true
try {
const payload = { role: editForm.value.role, assigned_area: null, assigned_school: null }
if (editForm.value.role === 'area_admin' && editForm.value.selectedAreas.length) {
payload.assigned_area = editForm.value.selectedAreas.join(',')
}
if (editForm.value.role === 'school_admin' && editForm.value.selectedSchools.length) {
payload.assigned_school = editForm.value.selectedSchools.join(',')
}
await updateUserRole(editUser.value.id, payload)
editUser.value = null
await loadUsers()
} finally {
saving.value = false
}
}
const handleToggle = async (u) => {
await toggleUser(u.id)
await loadUsers()
}
onMounted(() => {
loadUsers()
loadRegions()
loadSchools()
})
</script>
<style scoped>
.page-wrap { padding: 24px; max-width: 1200px; }
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; flex-wrap: wrap; gap: 12px; }
.page-title { font-size: 18px; font-weight: 600; color: var(--text-primary); margin: 0; }
.header-filters { display: flex; gap: 10px; }
.filter-input, .filter-select {
padding: 7px 12px; border: 1px solid var(--border-default); border-radius: var(--radius-md);
background: var(--bg-elevated); color: var(--text-primary); font-size: 13px; outline: none;
}
.filter-input:focus, .filter-select:focus { border-color: var(--accent); }
.table-wrap { overflow-x: auto; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); }
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.data-table th {
padding: 10px 14px; text-align: left; font-weight: 500; color: var(--text-muted);
background: var(--bg-surface); border-bottom: 1px solid var(--border-subtle); white-space: nowrap;
}
.data-table td { padding: 11px 14px; border-bottom: 1px solid var(--border-subtle); color: var(--text-secondary); }
.data-table tr:last-child td { border-bottom: none; }
.data-table tr:hover td { background: var(--bg-hover); }
.td-center { text-align: center; }
.td-empty { color: var(--text-muted); }
.td-username { font-weight: 500; color: var(--text-primary); }
.td-muted { color: var(--text-muted); font-size: 12px; }
.role-badge {
display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 500;
}
.role-admin { background: rgba(139,92,246,0.15); color: #a78bfa; }
.role-area_admin { background: rgba(59,130,246,0.15); color: #60a5fa; }
.role-school_admin { background: rgba(16,185,129,0.15); color: #34d399; }
.role-user { background: var(--bg-elevated); color: var(--text-muted); }
.status-badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 500; }
.status-active { background: rgba(16,185,129,0.15); color: var(--success); }
.status-disabled { background: rgba(239,68,68,0.1); color: var(--danger); }
.action-btns { display: flex; gap: 6px; }
.btn-sm { padding: 4px 10px; border-radius: var(--radius-sm); font-size: 12px; cursor: pointer; border: 1px solid transparent; font-family: var(--font-sans); }
.btn-primary { background: var(--accent-dim); border-color: var(--border-accent); color: var(--accent); }
.btn-primary:hover { background: var(--accent); color: #fff; }
.btn-danger { background: rgba(239,68,68,0.1); border-color: rgba(239,68,68,0.3); color: var(--danger); }
.btn-danger:hover { background: var(--danger); color: #fff; }
.btn-success { background: rgba(16,185,129,0.1); border-color: rgba(16,185,129,0.3); color: var(--success); }
.btn-success:hover { background: var(--success); color: #fff; }
.pagination { display: flex; align-items: center; gap: 12px; margin-top: 16px; justify-content: flex-end; }
.page-btn { padding: 6px 14px; border: 1px solid var(--border-default); border-radius: var(--radius-md); background: var(--bg-elevated); color: var(--text-secondary); font-size: 13px; cursor: pointer; }
.page-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.page-info { font-size: 13px; color: var(--text-muted); }
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 500; display: flex; align-items: center; justify-content: center; }
.modal { background: var(--bg-surface); border: 1px solid var(--border-default); border-radius: var(--radius-lg); width: 480px; max-width: 92vw; max-height: 85vh; display: flex; flex-direction: column; }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 18px 20px 14px; border-bottom: 1px solid var(--border-subtle); flex-shrink: 0; }
.modal-title { font-size: 15px; font-weight: 600; color: var(--text-primary); }
.modal-close { background: none; border: none; color: var(--text-muted); font-size: 16px; cursor: pointer; padding: 4px; }
.modal-body { padding: 20px; display: flex; flex-direction: column; gap: 16px; overflow-y: auto; }
.form-row { display: flex; flex-direction: column; gap: 6px; }
.form-label { font-size: 12px; color: var(--text-muted); font-weight: 500; }
.form-select {
padding: 8px 12px; border: 1px solid var(--border-default); border-radius: var(--radius-md);
background: var(--bg-elevated); color: var(--text-primary); font-size: 13px; outline: none;
}
.form-select:focus { border-color: var(--accent); }
.modal-footer { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 20px; border-top: 1px solid var(--border-subtle); flex-shrink: 0; }
.btn { padding: 7px 18px; border-radius: var(--radius-md); font-size: 13px; cursor: pointer; border: 1px solid transparent; font-family: var(--font-sans); }
.btn-ghost { background: var(--bg-elevated); border-color: var(--border-default); color: var(--text-secondary); }
.btn-ghost:hover { border-color: var(--border-strong); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* 多选标签区 */
.multi-toolbar {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.multi-search {
flex: 1;
padding: 5px 10px;
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
background: var(--bg-elevated);
color: var(--text-primary);
font-size: 12px;
outline: none;
}
.multi-search:focus { border-color: var(--accent); }
.multi-btn {
padding: 4px 10px;
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
background: var(--bg-surface);
color: var(--text-secondary);
font-size: 11px;
cursor: pointer;
white-space: nowrap;
font-family: var(--font-sans);
}
.multi-btn:hover { border-color: var(--accent); color: var(--accent); }
.multi-select-wrap {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 10px;
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
background: var(--bg-elevated);
max-height: 180px;
overflow-y: auto;
}
.multi-option {
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
cursor: pointer;
border: 1px solid var(--border-default);
color: var(--text-secondary);
background: var(--bg-surface);
transition: all 0.15s;
user-select: none;
}
.multi-option:hover { border-color: var(--accent); color: var(--accent); }
.multi-option.selected { background: var(--accent-dim); border-color: var(--border-accent); color: var(--accent); font-weight: 500; }
.multi-empty { font-size: 12px; color: var(--text-muted); padding: 4px; }
.selected-hint { font-size: 11px; color: var(--text-muted); line-height: 1.5; }
</style>
+1 -1
View File
@@ -8,7 +8,7 @@ export default defineConfig({
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
target: process.env.VITE_API_PROXY_TARGET || 'http://localhost:8001',
changeOrigin: true
}
}
+535
View File
@@ -0,0 +1,535 @@
# H3C ONU设备管理系统 - 审计日志系统设计方案
## 项目概述
本方案为 H3C ONU 设备管理系统设计一套完整的审计日志系统,用于记录所有用户操作(手动和自动触发),支持误操作恢复和问责追溯。
## 设计目标
1. **全面记录**:记录所有用户操作,包括认证、设备管理、OLT端口操作等
2. **详细审计**:记录操作详情(用户、时间、IP、参数、结果等)
3. **快速查询**:提供超级管理员查询界面
4. **长期存储**:日志保留90天,支持自动清理
5. **性能友好**:异步执行,不影响主业务流程
6. **Docker友好**:支持容器化部署,日志映射到宿主机
## 系统架构
### 整体架构图
```
用户操作 → API请求 → 审计日志中间件 → 异步任务队列 →
[数据库] 记录核心信息 + [文件系统] 记录详细日志
查询界面 ← 日志服务 ← 定期清理任务
```
### 技术栈
- **后端框架**FastAPI + SQLAlchemy + Celery
- **数据库**PostgreSQL(核心信息)+ 文件系统(详细日志)
- **消息队列**RedisCelery broker
- **存储**Docker卷映射到宿主机
## 详细设计
### 1. 数据库设计
#### 1.1 审计日志表 (`audit_logs`)
```sql
CREATE TABLE audit_logs (
id SERIAL PRIMARY KEY,
-- 用户信息
user_id VARCHAR(100) NOT NULL,
username VARCHAR(100) NOT NULL,
user_role VARCHAR(50),
-- 操作信息
action_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
action_type VARCHAR(50) NOT NULL, -- 操作类型
action_subtype VARCHAR(50), -- 操作子类型
-- 请求信息
ip_address VARCHAR(45),
user_agent TEXT,
request_method VARCHAR(10),
request_path VARCHAR(500),
-- 操作结果
status VARCHAR(20) NOT NULL, -- success, failed, error
status_code INTEGER,
-- 资源信息
resource_type VARCHAR(50), -- device, olt, port, user, etc.
resource_id VARCHAR(100),
resource_name VARCHAR(200),
-- 简要信息
description TEXT NOT NULL,
-- 详细日志
request_params JSONB,
response_data JSONB,
error_message TEXT,
-- 文件存储
details_file_path VARCHAR(500),
-- 元数据
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- 索引设计
CREATE INDEX idx_audit_logs_action_time ON audit_logs(action_time);
CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id);
CREATE INDEX idx_audit_logs_action_type ON audit_logs(action_type);
CREATE INDEX idx_audit_logs_resource_type ON audit_logs(resource_type);
CREATE INDEX idx_audit_logs_status ON audit_logs(status);
```
#### 1.2 操作类型分类
| 操作类型 | 操作子类型 | 描述 | 示例 |
|---------|-----------|------|------|
| `auth` | `login`, `logout`, `token_refresh` | 认证相关操作 | 用户登录、登出 |
| `device` | `create`, `update`, `delete`, `status_check`, `import` | 设备管理操作 | 创建设备、更新设备信息 |
| `olt` | `create`, `update`, `delete`, `port_enable`, `port_disable` | OLT管理操作 | 启用/关闭OLT端口 |
| `user` | `create`, `update`, `delete`, `role_change` | 用户管理操作 | 创建用户、修改角色 |
| `system` | `config_update`, `task_trigger`, `cleanup` | 系统操作 | 触发状态检查、清理任务 |
| `inventory` | `stock_in`, `stock_out`, `transfer`, `count` | 库存管理操作 | 入库、出库、盘点 |
### 2. 文件存储设计
#### 2.1 目录结构
```
logs/
├── audit/ # 审计日志目录
│ ├── 2025-04-05/ # 按日期分目录
│ │ ├── auth/ # 按操作类型分子目录
│ │ │ ├── login_20250405193500_user123_abc123.json
│ │ │ └── logout_20250405194000_user456_def456.json
│ │ ├── device/
│ │ ├── olt/
│ │ └── ...
│ ├── 2025-04-06/
│ └── ...
├── app/ # 应用日志目录
│ └── app.log
└── celery/ # Celery任务日志目录
└── celery.log
```
#### 2.2 详细日志文件格式(JSON)
```json
{
"log_id": "abc123def456",
"audit_log_id": 12345,
"timestamp": "2025-04-05T19:35:00+08:00",
"user": {
"id": "user123",
"username": "张三",
"role": "admin",
"ip_address": "192.168.1.100"
},
"action": {
"type": "olt_port_enable",
"subtype": "port_operation",
"description": "启用OLT端口",
"resource": {
"type": "olt_port",
"id": "olt-1-port-2",
"name": "OLT-1端口2"
}
},
"request": {
"method": "POST",
"path": "/api/olt/1/port/2/enable",
"headers": {
"user-agent": "Mozilla/5.0...",
"content-type": "application/json"
},
"body": {
"reason": "设备上线",
"operator": "张三"
},
"query_params": {}
},
"response": {
"status_code": 200,
"body": {
"success": true,
"message": "端口启用成功",
"data": {
"port_id": "olt-1-port-2",
"status": "enabled",
"enabled_at": "2025-04-05T19:35:00+08:00"
}
}
},
"execution": {
"duration_ms": 1250,
"start_time": "2025-04-05T19:34:58.750+08:00",
"end_time": "2025-04-05T19:35:00.000+08:00"
},
"system": {
"service": "backend",
"version": "v0.7.0",
"environment": "production"
}
}
```
### 3. 组件设计
#### 3.1 审计日志中间件 (`audit_middleware.py`)
**功能**
- 拦截所有API请求(排除健康检查、文档等)
- 提取请求和响应信息
- 异步发送日志到任务队列
**关键特性**
- 支持请求体读取(JSON格式)
- 支持响应体捕获
- 异常处理,不影响主流程
- 性能监控(记录执行时间)
#### 3.2 审计日志服务 (`audit_service.py`)
**功能**
- 解析日志数据,确定操作类型
- 创建数据库记录
- 保存详细日志到文件系统
- 提供查询接口
**关键方法**
- `create_audit_log()`: 创建日志记录
- `query_logs()`: 查询日志(支持多条件筛选)
- `cleanup_old_logs()`: 清理90天前的日志
- `export_logs()`: 导出日志
#### 3.3 Celery任务 (`audit_tasks.py`)
**功能**
- 异步处理日志记录
- 定期清理任务
- 日志归档任务
**任务列表**
- `create_audit_log_task`: 创建审计日志
- `cleanup_audit_logs_task`: 清理旧日志(每天执行)
- `archive_audit_logs_task`: 归档日志(每月执行)
#### 3.4 API接口 (`audit_api.py`)
**端点设计**
| 端点 | 方法 | 描述 | 权限 |
|------|------|------|------|
| `/api/audit/logs` | GET | 查询审计日志 | 超级管理员 |
| `/api/audit/logs/{id}` | GET | 获取单条日志详情 | 超级管理员 |
| `/api/audit/logs/export` | POST | 导出日志 | 超级管理员 |
| `/api/audit/stats` | GET | 获取日志统计 | 超级管理员 |
**查询参数**
- `start_time`: 开始时间
- `end_time`: 结束时间
- `user_id`: 用户ID
- `action_type`: 操作类型
- `resource_type`: 资源类型
- `status`: 状态(success/failed/error
- `page`: 页码
- `page_size`: 每页数量
### 4. 前端界面设计
#### 4.1 日志查询页面
**功能**
- 时间范围选择器
- 多条件筛选(用户、操作类型、状态等)
- 分页显示
- 导出功能
**界面元素**
- 查询条件表单
- 日志列表表格
- 分页控件
- 导出按钮
#### 4.2 日志详情页面
**功能**
- 显示日志详细信息
- 查看详细日志文件内容
- 操作回放(显示请求和响应)
## 实施步骤
### 阶段一:基础框架搭建(1-2天)
1. **创建数据库模型**
- 创建 `audit_logs`
- 添加数据库迁移
2. **实现基础服务**
- 创建 `AuditService`
- 实现日志创建和存储逻辑
3. **配置Docker**
- 更新 `docker-compose.yml` 日志映射
- 确保目录权限正确
### 阶段二:中间件和异步处理(2-3天)
1. **实现审计中间件**
- 创建 `AuditMiddleware`
- 集成到FastAPI应用
2. **实现Celery任务**
- 创建审计日志任务
- 配置任务队列
3. **测试异步流程**
- 验证日志记录不阻塞主流程
- 测试异常处理
### 阶段三:查询接口和界面(2-3天)
1. **实现API接口**
- 创建审计日志查询端点
- 实现多条件筛选
2. **开发前端界面**
- 创建日志查询页面
- 实现筛选和分页功能
3. **实现导出功能**
- 支持JSON/CSV格式导出
- 批量导出功能
### 阶段四:清理和优化(1-2天)
1. **实现自动清理**
- 创建清理任务
- 测试清理逻辑
2. **性能优化**
- 数据库查询优化
- 文件IO优化
3. **监控和告警**
- 添加日志记录监控
- 设置磁盘空间告警
## 配置要求
### 环境变量
```bash
# 审计日志配置
AUDIT_LOG_ENABLED=true
AUDIT_LOG_RETENTION_DAYS=90
AUDIT_LOG_DIR=/app/logs/audit
AUDIT_LOG_LEVEL=INFO
# Celery配置
CELERY_BROKER_URL=redis://redis:6379/0
CELERY_RESULT_BACKEND=redis://redis:6379/0
```
### Docker配置更新
```yaml
services:
backend:
build: ./backend
volumes:
- ./backend/logs:/app/logs # 应用日志
- ./logs/audit:/app/logs/audit # 审计日志专用目录
environment:
- AUDIT_LOG_ENABLED=true
- AUDIT_LOG_DIR=/app/logs/audit
```
## 测试方案
### 单元测试
1. **服务层测试**
- 测试 `AuditService.create_audit_log()`
- 测试 `AuditService.query_logs()`
- 测试 `AuditService.cleanup_old_logs()`
2. **中间件测试**
- 测试请求拦截
- 测试异常处理
- 测试性能影响
### 集成测试
1. **端到端测试**
- 模拟用户操作,验证日志记录
- 测试查询接口
- 测试导出功能
2. **性能测试**
- 高并发下的日志记录性能
- 大数据量下的查询性能
### 验收测试
1. **功能验收**
- 验证所有操作类型都被记录
- 验证查询功能正常工作
- 验证清理功能按预期工作
2. **非功能验收**
- 性能:日志记录不影响API响应时间(<50ms
- 可靠性:日志不丢失,可追溯
- 安全性:只有超级管理员可访问
## 维护和监控
### 日常维护
1. **磁盘空间监控**
- 监控日志目录大小
- 设置磁盘使用率告警(>80%)
2. **性能监控**
- 监控日志记录延迟
- 监控数据库查询性能
3. **定期检查**
- 每周检查清理任务执行情况
- 每月检查日志完整性
### 故障处理
1. **日志记录失败**
- 检查Celery worker状态
- 检查磁盘空间
- 检查文件权限
2. **查询性能下降**
- 优化数据库索引
- 增加查询缓存
- 考虑分表策略
## 扩展性考虑
### 未来扩展
1. **实时告警**
- 敏感操作实时通知
- 异常模式检测
2. **日志分析**
- 操作趋势分析
- 用户行为分析
3. **审计报告**
- 定期生成审计报告
- 合规性报告
### 性能优化
1. **数据库优化**
- 分区表(按时间分区)
- 读写分离
2. **存储优化**
- 压缩旧日志
- 冷热数据分离
## 风险评估和缓解措施
| 风险 | 影响 | 概率 | 缓解措施 |
|------|------|------|----------|
| 磁盘空间不足 | 日志记录失败 | 中 | 1. 设置磁盘监控告警<br>2. 实现自动清理<br>3. 使用日志轮转 |
| 性能影响 | API响应变慢 | 低 | 1. 异步处理<br>2. 批量写入<br>3. 性能测试 |
| 数据丢失 | 审计追溯失败 | 低 | 1. 双重存储(DB+文件)<br>2. 定期备份<br>3. 监控告警 |
| 安全风险 | 日志泄露 | 中 | 1. 严格权限控制<br>2. 日志加密存储<br>3. 访问审计 |
## 成功标准
1. **功能完整性**
- 所有用户操作都被记录
- 支持多条件查询
- 支持日志导出
2. **性能指标**
- 日志记录延迟 < 100ms
- 查询响应时间 < 2s(1000条记录)
- 系统资源占用 < 5%
3. **可靠性**
- 日志不丢失率 > 99.9%
- 自动清理任务成功率 100%
- 系统可用性 > 99.5%
## 附录
### A. 文件清单
```
backend/
├── app/
│ ├── models/
│ │ └── audit_log.py # 审计日志模型
│ ├── middleware/
│ │ └── audit_middleware.py # 审计中间件
│ ├── services/
│ │ └── audit_service.py # 审计服务
│ ├── tasks/
│ │ └── audit_tasks.py # Celery任务
│ ├── api/
│ │ └── v1/
│ │ └── audit.py # 审计API
│ └── core/
│ └── config.py # 配置更新
├── alembic/
│ └── versions/ # 数据库迁移文件
└── tests/
└── test_audit.py # 测试文件
frontend/
└── src/
├── views/
│ └── AuditLogView.vue # 审计日志页面
├── api/
│ └── audit.js # 审计API调用
└── stores/
└── audit.js # 审计状态管理
```
### B. 依赖更新
**后端依赖**
```txt
# requirements.txt 新增
python-json-logger==2.0.7
celery==5.3.4
redis==5.0.1
```
**前端依赖**
```json
// package.json 新增
"date-fns": "^3.0.0",
"xlsx": "^0.18.5"
```
### C. 部署检查清单
- [ ] 数据库迁移已执行
- [ ] 环境变量已配置
- [ ] Docker卷映射已更新
- [ ] 目录权限已设置
- [ ] Celery worker已启动
- [ ] 清理任务已调度
-
+532
View File
@@ -0,0 +1,532 @@
# H3C ONU设备管理系统 - 库存管理功能设计方案
## 项目背景
基于与项目负责人的详细讨论,为H3C ONU设备管理系统增加库存管理功能,用于管理项目中现有的实体设备(ONU、OLT、交换机、防火墙、上网行为管理等)的出入库台账。
## 设计讨论记录
**讨论时间**: 2026年4月5日
**参与人员**: 阿森、小柚(AI助手)
## 一、需求分析
### 1.1 核心需求
1. **设备全生命周期管理**:从采购入库 → 领用出库 → 安装使用 → 退库归还 → 报废处理
2. **物料分类管理**:支持ONU设备、OLT设备、交换机、防火墙、上网行为管理等
3. **库存台账管理**:记录基本信息、唯一标识、库存数量、位置信息、状态信息
4. **业务流程管理**:批量入库、领用出库、退库归还、报废/报修处理、盘点调整
5. **系统集成需求**:与现有设备监控系统深度集成,设备安装时更新位置信息,报废时从监控系统移除
### 1.2 用户场景
1. **仓库管理员**:管理物料入库、出库、盘点
2. **运维人员**:领用设备进行现场安装
3. **采购人员**:新设备采购入库
4. **管理人员**:查看库存报表,进行决策
### 1.3 功能范围
- 物料分类管理
- 库存台账管理
- 出入库流程管理
- 盘点管理
- 报表统计
- 与现有系统集成
## 二、整体架构设计
### 2.1 架构方案:集成扩展方案
**选择理由**
1. **无缝集成**:与现有设备监控系统深度集成
2. **数据一致**:共享用户、权限、设备基础数据
3. **流程连贯**:设备完整生命周期管理
4. **技术复用**:复用现有的Excel导入、权限控制、UI组件
### 2.2 系统关系图
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 库存管理系统 │◄────►│ 设备监控系统 │◄────►│ 权限管理系统 │
│ │ │ │ │ (Casdoor) │
├─────────────────┤ ├─────────────────┤ └─────────────────┘
│ • 物料管理 │ │ • ONU状态监控 │
│ • 出入库台账 │ │ • OLT管理 │
│ • 库存统计 │ │ • 实时告警 │
│ • 领用审批 │ │ • 历史记录 │
└─────────────────┘ └─────────────────┘
│ │
└─────────────────────────┘
设备生命周期流转
```
### 2.3 技术架构
- **后端**:在现有FastAPI基础上扩展库存管理模块
- **前端**:在现有Vue 3基础上增加库存管理页面
- **数据库**:在现有PostgreSQL中新增库存相关表
- **权限**:集成现有权限系统,新增库存管理权限
- **导入导出**:复用现有Excel导入组件
## 三、详细功能设计
### 3.1 物料分类管理
#### 支持的设备类型:
1. ONU设备(与现有系统深度集成)
2. OLT设备(与现有OLT管理集成)
3. 交换机
4. 防火墙
5. 上网行为管理
6. 光模块等配件
#### 物料属性:
- **基本信息**:名称、型号、规格、品牌、单位
- **唯一标识**
- ONU设备:MAC地址(与现有系统一致)
- OLT设备:IP地址 + 序列号
- 交换机:MAC地址 + 序列号
- 防火墙:序列号 + 资产编号
- 上网行为管理:序列号
- **库存信息**:当前数量、安全库存
- **位置信息**:仓库位置(项目专用仓库,无货架管理)
- **状态信息**:全新、已使用、待维修、报废
- **供应商信息**:供应商、采购日期、采购价格
### 3.2 库存管理方式
采用**混合管理模式**
- **高价值设备**:序列号管理(每个设备单独跟踪)
- **低价值配件**:批次管理(按采购批次跟踪)
- **简单数量管理**:只记录总数(适用于消耗品)
### 3.3 出入库流程设计
#### A. 采购入库流程
```
Excel模板导入 → 批量入库 → 生成采购单 → 审核入库 → 更新库存
```
- 支持现有Excel导入方式
- 自动生成采购单编号
- 支持分批入库
#### B. 领用出库流程
```
选择物料 → 填写领用信息 → 登记安装信息 → 确认出库 → 同步到监控系统
```
- 管理员直接操作,无需审批流程
- 领用时预填安装信息(学校、楼宇、房间等)
- 自动创建监控设备记录
#### C. 退库归还流程
```
选择退库设备 → 选择退库类型 → 更新状态 → 退回库存
```
支持四种退库类型:
1. **简单退库**:状态变回库存,保留历史记录
2. **维修退库**:标记为待维修,进入维修流程
3. **报废退库**:进入报废流程,从监控系统移除
4. **带历史退库**:保留完整的安装和使用历史
### 3.4 库存管理功能
#### A. 库存总览
- 按设备类型统计数量
- 库存价值统计
- 库存状态分布(全新/已使用/待维修)
- 低库存预警
#### B. 定期盘点
- 每月/每季度全盘
- 盘点差异记录
- 盘点报告生成
- 库存数量调整
#### C. 报表统计
1. **库存总览报表**:各类物料数量和价值统计
2. **领用统计报表**:按人员、项目、时间统计
3. **库存预警报表**:低于安全库存的物料提醒
4. **设备生命周期报告**:设备从入库到报废的全过程
### 3.5 状态流转设计
```
库存中 (in_stock)
↓ 领用
已分配 (allocated)
↓ 安装
已安装 (installed)
↓ 投入使用
使用中 (in_use)
├─→ 简单退库 → 库存中
├─→ 维修退库 → 维修中 (repairing) → 维修完成 → 库存中
└─→ 报废退库 → 已报废 (scrapped)
```
## 四、数据库设计
### 4.1 核心数据表
```sql
-- 物料分类表
CREATE TABLE material_categories (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL, -- 分类名称:ONU、OLT、交换机等
code VARCHAR(50) UNIQUE NOT NULL, -- 分类代码
description TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
-- 物料主数据表
CREATE TABLE materials (
id BIGSERIAL PRIMARY KEY,
category_id BIGINT REFERENCES material_categories(id),
name VARCHAR(200) NOT NULL, -- 物料名称
model VARCHAR(100), -- 型号
specification TEXT, -- 规格
brand VARCHAR(100), -- 品牌
unit VARCHAR(20) DEFAULT '', -- 单位
safe_quantity INTEGER DEFAULT 0, -- 安全库存
notes TEXT, -- 备注
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- 库存批次表(批次管理)
CREATE TABLE inventory_batches (
id BIGSERIAL PRIMARY KEY,
material_id BIGINT REFERENCES materials(id),
batch_no VARCHAR(50) NOT NULL, -- 批次号
quantity INTEGER NOT NULL, -- 批次数量
supplier VARCHAR(200), -- 供应商
purchase_date DATE, -- 采购日期
purchase_price DECIMAL(10,2), -- 采购单价
expiry_date DATE, -- 有效期(如有)
location VARCHAR(100), -- 仓库位置
status VARCHAR(20) DEFAULT 'in_stock', -- 状态:in_stock, reserved, out_of_stock
created_at TIMESTAMP DEFAULT NOW()
);
-- 序列号设备表(高价值设备单独跟踪)
CREATE TABLE serial_devices (
id BIGSERIAL PRIMARY KEY,
material_id BIGINT REFERENCES materials(id),
batch_id BIGINT REFERENCES inventory_batches(id),
serial_no VARCHAR(100) NOT NULL UNIQUE, -- 序列号
mac_address VARCHAR(17), -- MAC地址(ONU设备)
asset_no VARCHAR(50), -- 资产编号
status VARCHAR(20) DEFAULT 'in_stock', -- 状态:in_stock, allocated, installed, in_use, returned, repairing, scrapped
current_location VARCHAR(200), -- 当前位置
installed_info JSONB, -- 安装信息:学校、楼宇、房间等
onu_device_id BIGINT REFERENCES onu_devices(id), -- 关联监控设备
notes TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- 出入库记录表
CREATE TABLE inventory_transactions (
id BIGSERIAL PRIMARY KEY,
transaction_no VARCHAR(50) UNIQUE NOT NULL, -- 单据编号
transaction_type VARCHAR(20) NOT NULL, -- 类型:purchase_in, allocate_out, return_in, scrap_out, adjust
material_id BIGINT REFERENCES materials(id),
batch_id BIGINT REFERENCES inventory_batches(id),
serial_device_id BIGINT REFERENCES serial_devices(id),
quantity INTEGER NOT NULL,
from_status VARCHAR(20), -- 操作前状态
to_status VARCHAR(20), -- 操作后状态
operator_id BIGINT REFERENCES users(id), -- 操作人
project_name VARCHAR(200), -- 项目名称
installation_info JSONB, -- 安装信息
notes TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
-- 盘点记录表
CREATE TABLE inventory_checks (
id BIGSERIAL PRIMARY KEY,
check_no VARCHAR(50) UNIQUE NOT NULL, -- 盘点单号
check_date DATE NOT NULL, -- 盘点日期
checker_id BIGINT REFERENCES users(id), -- 盘点人
material_id BIGINT REFERENCES materials(id),
batch_id BIGINT REFERENCES inventory_batches(id),
book_quantity INTEGER, -- 账面数量
actual_quantity INTEGER, -- 实际数量
difference INTEGER, -- 差异数量
reason TEXT, -- 差异原因
adjusted BOOLEAN DEFAULT FALSE, -- 是否已调整
created_at TIMESTAMP DEFAULT NOW()
);
```
### 4.2 数据关系图
```
material_categories
materials ─┬─ inventory_batches
└─ serial_devices ───── onu_devices (现有表)
inventory_transactions
inventory_checks
```
## 五、与现有系统集成设计
### 5.1 设备流转集成
```python
# 领用出库时自动创建监控设备
def allocate_device(serial_device_id, installation_info):
# 1. 更新库存设备状态为 allocated
# 2. 如果设备类型是ONU,自动创建onu_devices记录
# 3. 关联serial_devices.onu_device_id
# 4. 初始化设备状态检查任务
# 安装完成时同步到监控系统
def install_device(serial_device_id, onu_device_id):
# 1. 更新库存设备状态为 installed
# 2. 更新onu_devices的安装位置信息
# 3. 开始定期状态监控
# 退库时处理监控设备
def return_device(serial_device_id, return_type):
if return_type == 'scrap':
# 报废:从监控系统移除,保留历史记录
deactivate_monitoring(serial_device_id)
else:
# 其他退库:暂停监控,设备状态标记为退库
pause_monitoring(serial_device_id)
```
### 5.2 权限集成
- 复用现有Casdoor用户体系
- 新增库存管理相关权限:
- `inventory:view` - 查看库存
- `inventory:manage` - 管理物料
- `inventory:transaction` - 出入库操作
- `inventory:check` - 盘点操作
- `inventory:report` - 查看报表
### 5.3 数据导入集成
- 复用现有的Excel导入组件
- 新增库存导入模板
- 支持批量设备入库
## 六、用户界面设计
### 6.1 桌面端界面布局
```
┌─────────────────────────────────────┐
│ 库存管理 │
├─────────────────────────────────────┤
│ [物料管理] [入库管理] [出库管理] │
│ [退库管理] [盘点管理] [报表统计] │
├─────────────────────────────────────┤
│ 主工作区 │
│ • 库存总览仪表板 │
│ • 物料列表(表格/卡片) │
│ • 出入库流水 │
└─────────────────────────────────────┘
```
### 6.2 移动端适配
- 复用移动端设计思路中的底部导航
- 增加"库存"标签页
- 移动端聚焦功能:库存查询、扫码盘点、快速领用
### 6.3 关键页面设计
1. **库存总览页面**:卡片式数据可视化,关键指标一目了然
2. **物料管理页面**:支持表格和卡片视图,可按分类筛选
3. **领用出库页面**:向导式操作流程,简化用户操作
4. **盘点管理页面**:支持移动端扫码,提高盘点效率
## 七、API接口设计
### 7.1 物料管理接口
```
GET /api/inventory/materials # 获取物料列表
POST /api/inventory/materials # 创建物料
GET /api/inventory/materials/{id} # 获取物料详情
PUT /api/inventory/materials/{id} # 更新物料
DELETE /api/inventory/materials/{id} # 删除物料
POST /api/inventory/materials/import # Excel导入物料
GET /api/inventory/materials/export # 导出物料列表
```
### 7.2 出入库接口
```
POST /api/inventory/transactions/purchase # 采购入库
POST /api/inventory/transactions/allocate # 领用出库
POST /api/inventory/transactions/return # 退库归还
POST /api/inventory/transactions/scrap # 报废处理
GET /api/inventory/transactions # 查询出入库记录
GET /api/inventory/transactions/{id} # 获取单据详情
```
### 7.3 库存查询接口
```
GET /api/inventory/summary # 库存总览
GET /api/inventory/status # 库存状态统计
GET /api/inventory/warning # 库存预警列表
GET /api/inventory/history/{device_id} # 设备流转历史
```
### 7.4 盘点管理接口
```
POST /api/inventory/checks # 创建盘点单
PUT /api/inventory/checks/{id} # 更新盘点结果
POST /api/inventory/checks/{id}/adjust # 执行库存调整
GET /api/inventory/checks # 查询盘点记录
GET /api/inventory/checks/{id}/report # 生成盘点报告
```
## 八、实施计划
### 第一阶段:基础框架(1-2周)
1. 数据库表结构创建和迁移脚本
2. 后端API基础框架搭建
3. 权限系统扩展(新增库存管理权限)
4. 基础物料管理功能实现
**交付物**
- 数据库迁移文件
- 后端基础API
- 权限配置更新
### 第二阶段:核心功能(2-3周)
1. 出入库流程完整实现
2. Excel导入导出功能
3. 库存统计报表
4. 与监控系统集成开发
**交付物**
- 完整的出入库功能
- Excel导入模板
- 库存报表功能
- 设备流转集成
### 第三阶段:高级功能(1-2周)
1. 移动端盘点功能
2. 库存预警系统
3. 设备生命周期报告
4. 性能优化和测试
**交付物**
- 移动端盘点支持
- 预警系统
- 完整测试报告
## 九、技术实现要点
### 9.1 后端实现
- 新增`inventory`模块,目录结构:
```
backend/app/
├── api/
│ └── inventory/ # 库存管理API
├── models/
│ └── inventory.py # 库存数据模型
├── schemas/
│ └── inventory.py # 库存Pydantic模式
├── services/
│ └── inventory_service.py # 库存业务逻辑
└── tasks/
└── inventory_tasks.py # 库存相关异步任务
```
### 9.2 前端实现
- 新增库存管理路由和页面
- 复用现有的Element Plus组件
- 开发专用的库存管理组件
### 9.3 数据一致性保证
- 使用数据库事务保证库存操作的原子性
- 定期数据一致性检查任务
- 完整的操作日志记录
## 十、测试策略
### 10.1 功能测试
1. **单元测试**:每个API接口的独立测试
2. **集成测试**:库存流程与监控系统集成测试
3. **业务流程测试**:完整的出入库流程测试
4. **数据一致性测试**:库存数量与交易记录一致性验证
### 10.2 性能测试
1. **并发测试**:多用户同时操作库存
2. **大数据量测试**:导入大量设备数据
3. **响应时间测试**:关键操作响应时间
### 10.3 兼容性测试
1. **浏览器兼容**:主流浏览器测试
2. **移动端兼容**:手机浏览器测试
3. **Excel兼容**:不同版本Excel导入测试
## 十一、风险与应对
### 11.1 技术风险
1. **数据一致性风险**:采用数据库事务和定期检查
2. **性能风险**:大数据量时使用分页和缓存
3. **集成风险**:分阶段集成,先读后写
### 11.2 业务风险
1. **流程变更风险**:设计灵活的流程配置
2. **用户接受度风险**:提供培训和使用文档
3. **数据迁移风险**:制定详细的数据迁移计划
## 十二、维护与支持
### 12.1 监控指标
1. **库存操作成功率**:出入库操作成功比例
2. **数据一致性指标**:库存数量与实际数量差异
3. **用户使用指标**:各功能使用频率
### 12.2 运维支持
1. **日志记录**:详细的操作日志
2. **数据备份**:定期库存数据备份
3. **故障恢复**:数据不一致时的恢复流程
## 十三、预期效益
### 13.1 业务效益
1. **流程规范化**:设备从采购到报废的全生命周期管理
2. **库存可视化**:实时掌握库存状态,避免缺料和积压
3. **成本控制**:精确的设备资产管理和成本核算
4. **效率提升**:减少人工盘点时间,提高领用效率
### 13.2 管理效益
1. **决策支持**:基于数据的采购和库存优化决策
2. **责任追溯**:完整的设备流转历史记录
3. **合规管理**:符合资产管理规范要求
## 十四、后续扩展考虑
### 14.1 功能扩展
1. **供应商管理**:完整的供应商信息管理
2. **采购管理**:采购申请、比价、合同管理
3. **维修管理**:设备维修流程管理
4. **租赁管理**:设备租赁和归还管理
### 14.2 技术扩展
1. **条码/RFID支持**:自动化库存管理
2. **移动APP**:独立的库存管理APP
3. **API开放**:对外提供库存查询API
4. **数据分析**:基于AI的库存预测
## 十五、总结
本设计方案为H3C ONU设备管理系统增加了完整的库存管理功能,实现了设备从采购入库到报废退库的全生命周期管理。方案具有以下特点:
1. **深度集成**:与现有设备监控系统无缝集成
2. **流程完整**:覆盖采购、领用、安装、退库、报废全流程
3. **灵活配置**:支持不同设备类型的差异化管理
4. **用户友好**:简洁的界面和操作流程
5. **可扩展性强**:为后续功能扩展预留接口
该方案的实施将显著提升项目的设备管理水平和运营效率,为项目的长期发展奠定坚实基础。
---
**文档信息**
- 创建时间:2026年4月5日
- 文档版本:v1.0
- 适用对象:开发团队、测试团队、运维团队
- 保密级别:内部使用
**备注**
1. 本方案基于与项目负责人的详细讨论结果整理
2. 实施过程中可根据实际情况进行调整
3. 建议分阶段实施,降低风险
4. 重要变更需更新本文档并通知相关人员
+317
View File
@@ -0,0 +1,317 @@
# H3C ONU设备管理系统 - 移动端适配设计思路
## 项目背景
当前H3C ONU设备管理系统主要针对桌面端设计,在移动设备上存在以下问题:
1. 布局在小屏幕上显示不全,需要左右滚动
2. 按钮和交互元素太小,难以点击
3. 表格数据在小屏幕上难以阅读
4. 导航菜单在手机上使用不便
5. 响应式断点设置不合理
## 设计目标
为移动端用户提供专用的小屏幕优化布局,聚焦核心功能,提升现场维护、外出监控和应急处理场景下的使用体验。
## 用户场景分析
移动端用户主要使用场景:
1. **现场维护**:快速查看设备状态,执行简单操作
2. **外出监控**:随时了解整体运行情况
3. **应急处理**:进行紧急操作和故障排查
## 核心功能范围
移动端聚焦以下三个核心功能:
1. **统计预览** - 了解整体运行状态
2. **设备列表** - 查找和管理具体设备
3. **OLT管理** - 管理OLT设备和端口
**功能精简**:移动端暂不需要新增、删除设备等复杂管理功能。
## 整体设计方案
### 一、架构方案:混合响应式设计
- **独立移动端布局**:为小屏幕(<768px)设计专用布局
- **组件复用**:复用现有Vue组件和业务逻辑
- **渐进增强**:桌面端功能完整,移动端聚焦核心
- **主题继承**:完全支持现有的深色/浅色主题切换
### 二、技术实现方案
推荐使用**方案A:响应式断点 + 条件渲染**
```vue
<template>
<div class="mobile-layout" v-if="isMobile">
<!-- 移动端专用布局 -->
<BottomNavigation />
<DeviceCards v-if="currentTab === 'devices'" />
</div>
<div class="desktop-layout" v-else>
<!-- 现有桌面布局 -->
<Sidebar />
<DeviceTable />
</div>
</template>
<script>
// 使用CSS媒体查询检测屏幕尺寸
const isMobile = window.matchMedia('(max-width: 768px)').matches
</script>
```
## 详细设计说明
### 1. 导航系统 - 底部导航栏
```
[统计] [设备] [OLT] [我的]
```
**设计要点:**
- 固定底部,符合移动端使用习惯
- 图标+文字,清晰易懂
- 当前选中项高亮显示
- 支持徽章提示(如设备告警数量)
### 2. 设备列表 - 卡片式设计
#### 卡片内容(折叠状态):
```
┌─────────────────────────┐
│ MAC: aabb-ccdd-eeee ● 在线 │
│ 学校: 第一中学 │
│ 楼宇: 教学楼A-301 │
│ 场所:计算机教室 │
│ 房间号:201 │
└─────────────────────────┘
```
#### 点击展开后拓展显示:
```
┌─────────────────────────┐
│ MAC: aabb-ccdd-eeee ● 在线 │
│ 学校: 第一中学 │
│ 楼宇: 教学楼A-301 │
│ 场所:计算机教室 │
│ 房间号:201 │
├─────────────────────────┤
│ [更新] [编辑] [业务下发] │
├─────────────────────────┤
│ 距离: 1235M │
│ 端口: Onu2/0/2:15 │
│ 型号:WA6520H-EGPON/A │
│ 所属 OLT:七百弄乡中心机房 │
│ 备注:由于是微机室,需配置 │
│ 设备1000M带宽限速 │
└─────────────────────────┘
```
**设计优势:**
- 信息层次清晰,重点突出(MAC、在线状态、学校名称、楼宇、场所、房间号)
- 节省屏幕空间
- 操作按钮在展开后显示,避免误触
### 3. 统计预览 - 可折叠面板
```
┌─────────────────────────┐
│ 📊 统计概览 │
│ ▼ 总体状态 │
│ 在线: 3852/4000 (96%) │
│ 城区: 95% 城郊: 92% │
│ 乡镇: 88% │
├─────────────────────────┤
│ ▶ 详细统计(点击展开) │
├─────────────────────────┤
│ ▶ 告警统计(点击展开) │
└─────────────────────────┘
```
**功能特点:**
- 默认显示关键指标
- 详细内容可折叠,按需展开
- 支持按区域/学校筛选
- 支持时间范围选择
### 4. OLT管理 - 卡片+展开式
#### 卡片内容:
```
┌─────────────────────────┐
│ 192.168.1.100 ● 正常 │
│ 安装位置: 岩滩接入网机房 │
│ 环路: 0个 [检测] │
├─────────────────────────┤
│ [快速扫描] [端口管理] [编辑] │
└─────────────────────────┘
```
**功能分层:**
- 卡片显示:IP地址、环路检测状态、快速扫描按钮、端口管理按钮、编辑按钮
## 视觉设计规范
### 1. 主题继承
- 完全支持现有的深色/浅色两种主题风格
- 用户可自由切换主题
- 保持一致的品牌视觉语言
### 2. 设计原则
- **触摸友好**:最小点击区域44×44px
- **信息密度**:适当减少信息密度,避免拥挤
- **操作简化**:复杂操作分步骤引导
- **反馈及时**:操作后提供明确反馈
### 3. 响应式断点
- **移动端**< 768px
- **平板端**768px - 1024px(可考虑适配)
- **桌面端**> 1024px(现有布局)
## 技术实施方案
### 第一阶段:基础框架(1-2天)
1. 添加移动端检测逻辑
2. 创建底部导航组件
3. 设置移动端专用路由
4. 适配现有主题系统
### 第二阶段:核心页面(3-5天)
1. 设备卡片组件开发
2. 统计折叠面板
3. OLT管理卡片
4. 响应式表格重构
### 第三阶段:交互优化(2-3天)
1. 触摸手势支持
2. 加载状态优化
3. 离线能力增强
4. 性能优化
## 组件设计规范
### 1. 设备卡片组件 (DeviceCard.vue)
```vue
<template>
<div class="device-card" @click="toggleExpand">
<div class="card-header">
<span class="device-name">{{ device.name }}</span>
<span class="status-badge" :class="device.status">
{{ device.status === 'online' ? '● 在线' : '○ 离线' }}
</span>
</div>
<div class="card-content">
<div class="info-row">
<span class="label">MAC:</span>
<span class="value">{{ device.mac }}</span>
</div>
<div class="info-row">
<span class="label">学校:</span>
<span class="value">{{ device.school }}</span>
</div>
<!-- 更多信息... -->
</div>
<div v-if="expanded" class="card-expanded">
<!-- 展开后的详细信息和操作按钮 -->
</div>
</div>
</template>
```
### 2. 底部导航组件 (BottomNavigation.vue)
```vue
<template>
<nav class="bottom-nav">
<router-link
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="nav-item"
:class="{ active: $route.path.startsWith(item.path) }"
>
<span class="nav-icon">{{ item.icon }}</span>
<span class="nav-label">{{ item.label }}</span>
</router-link>
</nav>
</template>
```
## 性能优化策略
### 1. 数据加载优化
- 分页加载设备列表
- 虚拟滚动长列表
- 图片和图标懒加载
### 2. 网络优化
- API请求合并
- 数据缓存策略
- 离线模式支持
### 3. 渲染优化
- 组件懒加载
- 避免不必要的重渲染
- 使用CSS动画代替JS动画
## 测试策略
### 1. 设备兼容性测试
- iOS Safari
- Android Chrome
- 微信内置浏览器
- 不同屏幕尺寸
### 2. 功能测试
- 触摸操作测试
- 手势支持测试
- 网络切换测试
- 主题切换测试
### 3. 性能测试
- 页面加载时间
- 内存使用情况
- 滚动流畅度
- 电池消耗
## 后续扩展考虑
### 1. 功能扩展
- 推送通知(设备离线告警)
- 扫码快速定位设备
- 语音搜索和操作
- 离线数据同步
### 2. 体验优化
- PWA支持(添加到主屏幕)
- 深色模式自动切换
- 手势快捷操作
- 自定义快捷功能
### 3. 平台扩展
- 微信小程序版本
- 企业微信集成
- 钉钉工作台集成
- 移动端APP
## 总结
本设计方案通过重新规划移动端专用布局,采用卡片式设计、底部导航、可折叠面板等移动端友好模式,解决了当前系统在移动设备上的可用性问题。方案既保持了与现有系统的功能一致性,又针对移动端使用场景进行了优化,能够显著提升移动端用户体验。
**核心价值:**
1. **专注核心功能**:聚焦统计、设备、OLT三大核心场景
2. **优化交互体验**:卡片式设计更适合触摸操作
3. **保持系统一致性**:复用现有组件和主题
4. **可扩展性强**:为后续功能扩展预留空间
---
*文档创建时间:2026年4月4日*
*基于与项目负责人的详细讨论结果整理*
1. 删除物料报错 inventory.js:12 DELETE http://10.10.10.14:5173/api/inventory/materials/1 400 (Bad Request)
2. 出入库记录点击单据编号应该可以看到出库或者入库单填写时的详细信息
1. 因为“数据导入”功能只导入了设备数据,所以不需要单独做一个页面了,改为在设备列表页面添加一个“数据导入”按钮,点击按钮后打开一个文件选择框,选择文件后进行数据导入(提供导入文件模板)。
2. 给现有的OLT列表也添加一个区域(和设备列表的区域内容同步,默认先选择城区,后续我再自己修改),并且设置区域权限,区域管理员只能管理对应区域的OLT。
1. 我发现现在的30分钟定时检查好像有点问题,还不是触发完了之后所有设备都显示离线,请帮我排查一下
2. 新增一个设置,超级管理员可以设置定时检查时间,默认是30分钟,用户可以自己设置,但是不能小于5分钟。
OLT管理页面的“区域”是需要跟用户管理页面中,区域管理员的“分配区域”是同步的,因为区域管理员应该只能查看到自己区域内的OLT,现在OLT管理页面的区域只有三个选项,这是不对的。