feat: v0.9.0 新增记录日志、设备更换记录及IMC服务集成
- 新增 RecordsLog.vue 操作记录日志页面 - 新增 ReplacementRecords.vue 设备更换记录页面 - 新增 imc_service.py IMC网管系统集成服务 - 新增 datetime.js 前端日期时间工具函数 - 新增 OLT时间同步.md 文档 - 扩展 devices.py API:设备更换记录、批量操作等 - 扩展 ssh_service.py:OLT时间同步功能 - 扩展 olt.py:新增时间同步相关接口 - 更新 DeviceList.vue:增强设备列表功能 - 更新路由和导航菜单 - 将 .claude/ 和 .mcp.json 加入 .gitignore Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -35,3 +35,9 @@ logs/
|
||||
# Temp
|
||||
/tmp/
|
||||
*.tmp
|
||||
# Added by code-review-graph
|
||||
.code-review-graph/
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
.mcp.json
|
||||
|
||||
@@ -490,3 +490,42 @@ alembic downgrade -1
|
||||
- [Casdoor 文档](https://casdoor.org/)
|
||||
- [项目详细设计](./系统设计文档.md)
|
||||
- [开发计划](./开发计划.md)
|
||||
|
||||
<!-- code-review-graph MCP tools -->
|
||||
## MCP Tools: code-review-graph
|
||||
|
||||
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
|
||||
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
|
||||
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
|
||||
you structural context (callers, dependents, test coverage) that file
|
||||
scanning cannot.
|
||||
|
||||
### When to use graph tools FIRST
|
||||
|
||||
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
|
||||
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
|
||||
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
|
||||
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
|
||||
- **Architecture questions**: `get_architecture_overview` + `list_communities`
|
||||
|
||||
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
|
||||
|
||||
### Key Tools
|
||||
|
||||
| Tool | Use when |
|
||||
| ------ | ---------- |
|
||||
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
|
||||
| `get_review_context` | Need source snippets for review — token-efficient |
|
||||
| `get_impact_radius` | Understanding blast radius of a change |
|
||||
| `get_affected_flows` | Finding which execution paths are impacted |
|
||||
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
|
||||
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
|
||||
| `get_architecture_overview` | Understanding high-level codebase structure |
|
||||
| `refactor_tool` | Planning renames, finding dead code |
|
||||
|
||||
### Workflow
|
||||
|
||||
1. The graph auto-updates on file changes (via hooks).
|
||||
2. Use `detect_changes` for code review.
|
||||
3. Use `get_affected_flows` to understand impact.
|
||||
4. Use `query_graph` pattern="tests_for" to check coverage.
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
H3C OLT 批量配置脚本(直接复制执行)
|
||||
```
|
||||
system-view
|
||||
undo ntp-service unicast-server 172.16.0.254
|
||||
ntp-service unicast-server 172.16.1.252
|
||||
clock timezone Beijing add 08:00:00
|
||||
quit
|
||||
save force
|
||||
```
|
||||
作用说明:
|
||||
第2行:删掉旧的内网 NTP(172.16.0.254,因为它用的是本地假时间)。
|
||||
第3行:指向新配好的 Windows 时间服务器(172.16.1.252,它直连国家授时中心)。
|
||||
第4行:把设备显示时区改成北京时间(东八区),解决时间少8小时的问题。
|
||||
第6行:强制保存配置,防止重启丢失(加 force 是为了跳过确认提示,方便批量执行)。
|
||||
验证方法:
|
||||
全部刷完等大概 1到2分钟后,执行以下两条命令看结果:
|
||||
display clock
|
||||
display ntp-service sessions
|
||||
display clock 必须看到带有 Beijing 字样,且时间与当前实际北京时间一致。
|
||||
display ntp-service sessions 必须看到 172.16.1.252 前面带有 [12345] 标记,且 offset(偏差)在几毫秒以内。
|
||||
注意:如果某些 OLT 之前没有配过 172.16.0.254,执行第2行时可能会报错提示“找不到该配置”,直接忽略该报错即可,不影响后续命令执行。
|
||||
@@ -1,5 +1,5 @@
|
||||
"""审计日志 API"""
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
@@ -113,8 +113,9 @@ def export_audit_logs(
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(["时间", "用户", "角色", "操作类型", "子类型", "路径", "状态码", "状态", "IP", "描述"])
|
||||
for r in items:
|
||||
t_cst = (r.action_time + timedelta(hours=8)).strftime("%Y-%m-%d %H:%M:%S") if r.action_time else ""
|
||||
writer.writerow([
|
||||
r.action_time.strftime("%Y-%m-%d %H:%M:%S") if r.action_time else "",
|
||||
t_cst,
|
||||
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,
|
||||
@@ -132,7 +133,7 @@ def export_audit_logs(
|
||||
def _fmt(r: AuditLog, detail: bool = False) -> dict:
|
||||
base = {
|
||||
"id": r.id,
|
||||
"action_time": r.action_time.isoformat() if r.action_time else None,
|
||||
"action_time": (r.action_time.isoformat() + "Z") if r.action_time else None,
|
||||
"user_id": r.user_id,
|
||||
"username": r.username,
|
||||
"user_role": r.user_role,
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Optional
|
||||
from app.core.database import get_db
|
||||
from app.middleware.permission_middleware import require_permission
|
||||
from app.models.device import ONUDevice, DeviceStatusHistory, OLTDevice, DeviceReplacement
|
||||
from app.schemas.device import DeviceListResponse, ONUDeviceResponse
|
||||
from app.schemas.device import DeviceListResponse, ONUDeviceResponse, RebootResponse, OpticalPowerResponse
|
||||
|
||||
router = APIRouter(prefix="/api/devices", tags=["设备管理"])
|
||||
|
||||
@@ -204,6 +204,134 @@ def get_schools(
|
||||
return [r[0] for r in query.order_by(ONUDevice.school_name).all()]
|
||||
|
||||
|
||||
@router.get("/replacements")
|
||||
def get_all_replacements(
|
||||
region: Optional[str] = Query(None),
|
||||
start_date: Optional[str] = Query(None),
|
||||
end_date: Optional[str] = Query(None),
|
||||
keyword: Optional[str] = Query(None),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(200, ge=1, le=1000),
|
||||
db: Session = Depends(get_db),
|
||||
current: dict = Depends(require_permission('device.view')),
|
||||
):
|
||||
"""获取全量设备更换记录(带位置信息),支持筛选"""
|
||||
from datetime import datetime
|
||||
query = (
|
||||
db.query(DeviceReplacement, ONUDevice)
|
||||
.join(ONUDevice, DeviceReplacement.onu_device_id == ONUDevice.id)
|
||||
)
|
||||
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(ONUDevice.region.in_(areas))
|
||||
else:
|
||||
return {"total": 0, "items": []}
|
||||
if region:
|
||||
query = query.filter(ONUDevice.region == region)
|
||||
if start_date:
|
||||
query = query.filter(DeviceReplacement.replaced_at >= datetime.fromisoformat(start_date))
|
||||
if end_date:
|
||||
query = query.filter(DeviceReplacement.replaced_at <= datetime.fromisoformat(end_date + 'T23:59:59'))
|
||||
if keyword:
|
||||
kw = f'%{keyword}%'
|
||||
query = query.filter(or_(
|
||||
ONUDevice.school_name.ilike(kw),
|
||||
ONUDevice.region.ilike(kw),
|
||||
DeviceReplacement.old_mac.ilike(kw),
|
||||
DeviceReplacement.new_mac.ilike(kw),
|
||||
DeviceReplacement.operator_name.ilike(kw),
|
||||
))
|
||||
total = query.count()
|
||||
rows = query.order_by(DeviceReplacement.replaced_at.desc()).offset(skip).limit(limit).all()
|
||||
return {
|
||||
"total": total,
|
||||
"items": [
|
||||
{
|
||||
"id": r.id,
|
||||
"replaced_at": r.replaced_at,
|
||||
"old_mac": r.old_mac,
|
||||
"new_mac": r.new_mac,
|
||||
"reason": r.reason,
|
||||
"operator_name": r.operator_name,
|
||||
"region": d.region,
|
||||
"school_name": d.school_name,
|
||||
"building": d.building,
|
||||
"room_number": d.room_number,
|
||||
"onu_device_id": r.onu_device_id,
|
||||
}
|
||||
for r, d in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/replacements/export")
|
||||
def export_replacements(
|
||||
region: Optional[str] = Query(None),
|
||||
start_date: Optional[str] = Query(None),
|
||||
end_date: Optional[str] = Query(None),
|
||||
keyword: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current: dict = Depends(require_permission('device.view')),
|
||||
):
|
||||
"""导出更换记录为 CSV"""
|
||||
import csv, io
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
query = (
|
||||
db.query(DeviceReplacement, ONUDevice)
|
||||
.join(ONUDevice, DeviceReplacement.onu_device_id == ONUDevice.id)
|
||||
)
|
||||
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(ONUDevice.region.in_(areas))
|
||||
else:
|
||||
query = query.filter(False)
|
||||
if region:
|
||||
query = query.filter(ONUDevice.region == region)
|
||||
if start_date:
|
||||
query = query.filter(DeviceReplacement.replaced_at >= datetime.fromisoformat(start_date))
|
||||
if end_date:
|
||||
query = query.filter(DeviceReplacement.replaced_at <= datetime.fromisoformat(end_date + 'T23:59:59'))
|
||||
if keyword:
|
||||
kw = f'%{keyword}%'
|
||||
query = query.filter(or_(
|
||||
ONUDevice.school_name.ilike(kw),
|
||||
ONUDevice.region.ilike(kw),
|
||||
DeviceReplacement.old_mac.ilike(kw),
|
||||
DeviceReplacement.new_mac.ilike(kw),
|
||||
DeviceReplacement.operator_name.ilike(kw),
|
||||
))
|
||||
rows = query.order_by(DeviceReplacement.replaced_at.desc()).all()
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(['更换时间(北京)', '区域', '学校', '楼宇', '房间', '旧MAC', '新MAC', '更换原因', '操作人'])
|
||||
for r, d in rows:
|
||||
bj_time = (r.replaced_at + timedelta(hours=8)).strftime('%Y-%m-%d %H:%M:%S') if r.replaced_at else ''
|
||||
writer.writerow([
|
||||
bj_time,
|
||||
d.region or '',
|
||||
d.school_name or '',
|
||||
d.building or '',
|
||||
d.room_number or '',
|
||||
r.old_mac,
|
||||
r.new_mac,
|
||||
r.reason or '',
|
||||
r.operator_name or '',
|
||||
])
|
||||
|
||||
output.seek(0)
|
||||
filename = f"replacement_records_{datetime.now().strftime('%Y%m%d%H%M%S')}.csv"
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue().encode('utf-8-sig')]),
|
||||
media_type='text/csv',
|
||||
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{device_id}", response_model=ONUDeviceResponse)
|
||||
def get_device(
|
||||
device_id: int,
|
||||
@@ -359,10 +487,16 @@ def replace_device(
|
||||
conflict_new_onu_ids = {nd.onu_device_id for nd in conflict_news}
|
||||
for nd in conflict_news:
|
||||
db.delete(nd)
|
||||
# 先 flush,让 NewDevice 的 ORM 删除落库,解除对 onu_devices 的外键引用
|
||||
db.flush()
|
||||
# 同时删除对应的空白 ONU 记录,避免设备列表出现重复 MAC
|
||||
if conflict_new_onu_ids:
|
||||
from app.models.device import DeviceStatusHistory
|
||||
db.query(DeviceStatusHistory).filter(
|
||||
DeviceStatusHistory.onu_device_id.in_(conflict_new_onu_ids)
|
||||
).delete(synchronize_session=False)
|
||||
db.query(ONUDevice).filter(ONUDevice.id.in_(conflict_new_onu_ids)).delete(synchronize_session=False)
|
||||
db.flush()
|
||||
db.flush()
|
||||
|
||||
# 检查新 MAC 是否已被其他 ONU 设备使用(排除刚刚从 new_devices 删除的临时 ONU)
|
||||
existing = db.query(ONUDevice).filter(
|
||||
@@ -422,3 +556,108 @@ def get_device_replacements(
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{device_id}/reboot", response_model=RebootResponse)
|
||||
def reboot_device(
|
||||
device_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current: dict = Depends(require_permission('device.check')),
|
||||
):
|
||||
"""远程重启 ONU 设备(通过 iMC REST API)"""
|
||||
device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="设备不存在")
|
||||
|
||||
role = current.get('role', 'user')
|
||||
if role == 'area_admin':
|
||||
areas = [a.strip() for a in (current.get('assigned_area') or '').split(',') if a.strip()]
|
||||
if device.region not in areas:
|
||||
raise HTTPException(status_code=403, detail="无权限操作此区域的设备")
|
||||
elif role == 'school_admin':
|
||||
schools = [s.strip() for s in (current.get('assigned_school') or '').split(',') if s.strip()]
|
||||
if device.school_name not in schools:
|
||||
raise HTTPException(status_code=403, detail="无权限操作此学校的设备")
|
||||
|
||||
try:
|
||||
from app.services.imc_service import IMCService
|
||||
result = IMCService().reboot_onu(device.mac_address)
|
||||
return RebootResponse(**result)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"重启失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/{device_id}/optical-power", response_model=OpticalPowerResponse)
|
||||
def get_device_optical_power(
|
||||
device_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current: dict = Depends(require_permission('device.view')),
|
||||
):
|
||||
"""获取 ONU 设备光功率信息(通过 iMC REST API)"""
|
||||
device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="设备不存在")
|
||||
|
||||
role = current.get('role', 'user')
|
||||
if role == 'area_admin':
|
||||
areas = [a.strip() for a in (current.get('assigned_area') or '').split(',') if a.strip()]
|
||||
if device.region not in areas:
|
||||
raise HTTPException(status_code=403, detail="无权限操作此区域的设备")
|
||||
elif role == 'school_admin':
|
||||
schools = [s.strip() for s in (current.get('assigned_school') or '').split(',') if s.strip()]
|
||||
if device.school_name not in schools:
|
||||
raise HTTPException(status_code=403, detail="无权限操作此学校的设备")
|
||||
|
||||
try:
|
||||
from app.services.imc_service import IMCService
|
||||
data = IMCService().get_optical_power(device.mac_address)
|
||||
if data is None:
|
||||
raise HTTPException(status_code=502, detail="获取光功率失败,iMC 接口无响应")
|
||||
return OpticalPowerResponse(
|
||||
power_in=data.get("powerIn"),
|
||||
power_out=data.get("powerOut"),
|
||||
bind_mac=data.get("bindMac"),
|
||||
dev_id=data.get("devId"),
|
||||
epon_dev_name=data.get("eponDevName"),
|
||||
olt_if_name=data.get("oltIfName"),
|
||||
onu_if_desc=data.get("onuIfDesc"),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"获取光功率失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/{device_id}/onu-events")
|
||||
def get_onu_events(
|
||||
device_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: dict = Depends(require_permission('device.view')),
|
||||
):
|
||||
"""查询 ONU 上下线事件记录(SSH 到所属 OLT 执行命令)"""
|
||||
device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="设备不存在")
|
||||
if not device.olt_id:
|
||||
raise HTTPException(status_code=400, detail="该设备未关联 OLT,无法查询")
|
||||
if not device.port_id:
|
||||
raise HTTPException(status_code=400, detail="端口信息缺失,请先更新设备状态")
|
||||
|
||||
olt = db.query(OLTDevice).filter(OLTDevice.id == device.olt_id).first()
|
||||
if not olt:
|
||||
raise HTTPException(status_code=404, detail="关联的 OLT 不存在")
|
||||
|
||||
from app.services.ssh_service import SSHService
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
events = ssh.get_onu_events(device.port_id)
|
||||
return {
|
||||
"interface": f"Onu{device.port_id}",
|
||||
"olt_location": olt.location,
|
||||
"events": events,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}")
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
@@ -67,13 +67,16 @@ async def upload_excel(
|
||||
records = service.parse_excel(file_path)
|
||||
validation = service.validate_data(records)
|
||||
|
||||
success_count = 0
|
||||
created = updated = 0
|
||||
if validation['valid']:
|
||||
result = service.import_devices(validation['valid'], olt_id)
|
||||
success_count = result['success']
|
||||
created = result['created']
|
||||
updated = result['updated']
|
||||
|
||||
return {
|
||||
"success": success_count,
|
||||
"success": created + updated,
|
||||
"created": created,
|
||||
"updated": updated,
|
||||
"failed": validation['invalid']
|
||||
}
|
||||
|
||||
|
||||
@@ -500,6 +500,64 @@ def loopback_detection(
|
||||
return [results_map[olt.id] for olt in olts]
|
||||
|
||||
|
||||
class SyncNTPRequest(BaseModel):
|
||||
old_server: str = "172.16.0.254"
|
||||
new_server: str = "172.16.1.252"
|
||||
|
||||
|
||||
@router.post("/sync-ntp")
|
||||
def sync_ntp(
|
||||
body: SyncNTPRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current: dict = Depends(require_permission('olt.manage')),
|
||||
):
|
||||
"""对所有 OLT 并发执行 NTP 时间服务器同步"""
|
||||
from app.services.ssh_service import SSHService
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
olts = db.query(OLTDevice).all()
|
||||
if current.get('role') == 'area_admin' and current.get('assigned_area'):
|
||||
areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()]
|
||||
olts = [o for o in olts if o.region in areas] if areas else []
|
||||
|
||||
def sync_one(olt):
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
ssh.sync_ntp(body.old_server, body.new_server)
|
||||
return {
|
||||
"olt_ip": olt.ip_address,
|
||||
"olt_location": olt.location or olt.ip_address,
|
||||
"success": True,
|
||||
"error": None,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"olt_ip": olt.ip_address,
|
||||
"olt_location": olt.location or olt.ip_address,
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
}
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
results_map = {}
|
||||
with ThreadPoolExecutor(max_workers=len(olts) or 1) as executor:
|
||||
futures = {executor.submit(sync_one, olt): olt.id for olt in olts}
|
||||
for future in as_completed(futures):
|
||||
r = future.result()
|
||||
results_map[r["olt_ip"]] = r
|
||||
|
||||
results = [results_map[olt.ip_address] for olt in olts]
|
||||
success_count = sum(1 for r in results if r["success"])
|
||||
return {
|
||||
"total": len(results),
|
||||
"success": success_count,
|
||||
"failed": len(results) - success_count,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
|
||||
class TogglePortRequest(BaseModel):
|
||||
action: str # "shutdown" 或 "undo shutdown"
|
||||
|
||||
|
||||
@@ -27,6 +27,14 @@ class Settings(BaseSettings):
|
||||
CHECK_INTERVAL: int = 1800
|
||||
MANUAL_COOLDOWN: int = 300
|
||||
|
||||
# iMC API 配置(用于 ONU 远程重启和光功率查询)
|
||||
IMC_API_URL: str = ""
|
||||
IMC_API_USERNAME: str = ""
|
||||
IMC_API_PASSWORD: str = ""
|
||||
IMC_API_VERIFY_SSL: bool = False
|
||||
IMC_CONNECT_TIMEOUT: float = 5.0
|
||||
IMC_READ_TIMEOUT: float = 20.0
|
||||
|
||||
class Config:
|
||||
env_file = str(PROJECT_ROOT / ".env")
|
||||
|
||||
|
||||
@@ -34,3 +34,18 @@ class DeviceListResponse(BaseModel):
|
||||
total: int
|
||||
items: list[ONUDeviceResponse]
|
||||
|
||||
|
||||
class RebootResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
class OpticalPowerResponse(BaseModel):
|
||||
power_in: Optional[str] = None
|
||||
power_out: Optional[str] = None
|
||||
bind_mac: Optional[str] = None
|
||||
dev_id: Optional[int] = None
|
||||
epon_dev_name: Optional[str] = None
|
||||
olt_if_name: Optional[str] = None
|
||||
onu_if_desc: Optional[str] = None
|
||||
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
iMC REST API 服务
|
||||
- 使用 HTTP Digest Access Authentication (RFC 2617)
|
||||
- 支持 nonce 过期自动续约(401 时自动重新握手)
|
||||
- 功能:ONU 远程重启、光功率查询
|
||||
"""
|
||||
import hashlib
|
||||
import re
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
import logging
|
||||
import requests
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# iMC 重启错误码映射
|
||||
REBOOT_ERROR_CODES = {
|
||||
'103': 'ONU不存在',
|
||||
'119': 'SNMP连接超时',
|
||||
'120': '业务割接失败',
|
||||
'121': 'ONU未运行',
|
||||
'122': '重启失败',
|
||||
}
|
||||
|
||||
# 并发控制:防止同一设备被重复重启
|
||||
_reboot_locks: dict = {}
|
||||
_reboot_lock = threading.Lock()
|
||||
|
||||
|
||||
def _normalize_mac(mac: str) -> str:
|
||||
"""标准化 MAC 为 iMC 要求的格式:1484-7790-4840(大写,4位分组)"""
|
||||
clean = mac.replace(':', '').replace('-', '').replace('.', '').upper()
|
||||
return f"{clean[0:4]}-{clean[4:8]}-{clean[8:12]}"
|
||||
|
||||
|
||||
class IMCService:
|
||||
"""iMC REST API 服务封装"""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = settings.IMC_API_URL.rstrip('/')
|
||||
self.username = settings.IMC_API_USERNAME
|
||||
self.password = settings.IMC_API_PASSWORD
|
||||
self.verify_ssl = settings.IMC_API_VERIFY_SSL
|
||||
self.connect_timeout = settings.IMC_CONNECT_TIMEOUT
|
||||
self.read_timeout = settings.IMC_READ_TIMEOUT
|
||||
self.session = requests.Session()
|
||||
self.realm = "iMC RESTful Web Services"
|
||||
self.nonce = None
|
||||
self.nc = 1
|
||||
|
||||
# ── Digest 认证 ──────────────────────────────────────────────────────────
|
||||
|
||||
def _get_digest_auth_header(self, method: str, uri: str) -> str | None:
|
||||
"""构建 HTTP Digest 认证头,首次调用自动握手获取 nonce"""
|
||||
if not self.nonce:
|
||||
try:
|
||||
resp = self.session.get(
|
||||
f"{self.base_url}{uri}",
|
||||
verify=self.verify_ssl,
|
||||
headers={"Accept": "application/json"},
|
||||
timeout=(self.connect_timeout, self.read_timeout),
|
||||
)
|
||||
if resp.status_code == 401 and 'WWW-Authenticate' in resp.headers:
|
||||
auth_parts = {}
|
||||
for part in resp.headers['WWW-Authenticate'].split(','):
|
||||
if '=' in part:
|
||||
k, v = part.split('=', 1)
|
||||
auth_parts[k.strip()] = v.strip(' "')
|
||||
self.nonce = auth_parts.get('nonce', '')
|
||||
self.realm = auth_parts.get('realm', self.realm)
|
||||
else:
|
||||
logger.error(f"获取 nonce 失败,状态码: {resp.status_code}")
|
||||
return None
|
||||
except requests.Timeout:
|
||||
raise TimeoutError("iMC 认证超时")
|
||||
except Exception as e:
|
||||
logger.error(f"获取 nonce 异常: {e}")
|
||||
return None
|
||||
|
||||
cnonce = hashlib.md5(str(time.time()).encode()).hexdigest()[:16]
|
||||
ha1 = hashlib.md5(f"{self.username}:{self.realm}:{self.password}".encode()).hexdigest()
|
||||
ha2 = hashlib.md5(f"{method}:{uri}".encode()).hexdigest()
|
||||
response_hash = hashlib.md5(
|
||||
f"{ha1}:{self.nonce}:{self.nc:08d}:{cnonce}:auth:{ha2}".encode()
|
||||
).hexdigest()
|
||||
|
||||
auth_value = (
|
||||
f'Digest username="{self.username}", realm="{self.realm}", '
|
||||
f'nonce="{self.nonce}", uri="{uri}", response="{response_hash}", '
|
||||
f'qop=auth, nc={self.nc:08d}, cnonce="{cnonce}"'
|
||||
)
|
||||
self.nc += 1
|
||||
return auth_value
|
||||
|
||||
def _clear_auth(self):
|
||||
"""清除认证状态(nonce 过期时调用,下次请求自动重新握手)"""
|
||||
self.nonce = None
|
||||
self.nc = 1
|
||||
|
||||
# ── 重启 ONU ─────────────────────────────────────────────────────────────
|
||||
|
||||
def reboot_onu(self, mac: str) -> dict:
|
||||
"""
|
||||
远程重启 ONU 设备。
|
||||
使用 per-MAC 锁防止同一设备并发重启。
|
||||
"""
|
||||
imc_mac = _normalize_mac(mac)
|
||||
|
||||
# 并发锁
|
||||
with _reboot_lock:
|
||||
if imc_mac not in _reboot_locks:
|
||||
_reboot_locks[imc_mac] = threading.Lock()
|
||||
lock = _reboot_locks[imc_mac]
|
||||
|
||||
if not lock.acquire(blocking=False):
|
||||
return {"success": False, "message": "该设备正在重启中,请稍后再试"}
|
||||
|
||||
try:
|
||||
for retry in range(2):
|
||||
try:
|
||||
uri = f"/imcrs/epon/onu/reboot?mac={imc_mac}"
|
||||
auth = self._get_digest_auth_header("POST", uri)
|
||||
if not auth:
|
||||
return {"success": False, "message": "认证失败,无法发送重启请求"}
|
||||
|
||||
resp = self.session.post(
|
||||
f"{self.base_url}{uri}",
|
||||
headers={
|
||||
"Accept": "application/xml",
|
||||
"Content-Type": "application/xml",
|
||||
"Content-Length": "0",
|
||||
"Authorization": auth,
|
||||
},
|
||||
verify=self.verify_ssl,
|
||||
timeout=(self.connect_timeout, self.read_timeout),
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
m = re.search(r"<errorCode>(\d+)</errorCode>", resp.text)
|
||||
if m:
|
||||
code = m.group(1)
|
||||
msg = REBOOT_ERROR_CODES.get(code, f"未知错误(代码: {code})")
|
||||
return {"success": False, "message": f"重启失败: {msg}"}
|
||||
return {"success": True, "message": "设备正在重启,请稍后..."}
|
||||
|
||||
elif resp.status_code == 401:
|
||||
self._clear_auth()
|
||||
continue
|
||||
|
||||
return {"success": False, "message": f"重启请求失败(HTTP {resp.status_code})"}
|
||||
|
||||
except TimeoutError:
|
||||
return {"success": False, "message": "iMC 接口超时,请稍后重试"}
|
||||
except Exception as e:
|
||||
logger.error(f"重启异常 (retry={retry}): {e}")
|
||||
if retry == 0:
|
||||
time.sleep(2)
|
||||
continue
|
||||
return {"success": False, "message": f"重启异常: {e}"}
|
||||
|
||||
return {"success": False, "message": "重启失败,已达最大重试次数"}
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
# ── 光功率查询 ────────────────────────────────────────────────────────────
|
||||
|
||||
def get_optical_power(self, mac: str) -> dict | None:
|
||||
"""
|
||||
获取 ONU 设备光功率信息。
|
||||
返回 dict 或 None(失败时)。
|
||||
"""
|
||||
imc_mac = _normalize_mac(mac)
|
||||
|
||||
for retry in range(2):
|
||||
try:
|
||||
uri = f"/imcrs/epon/onu/onuLightWaneInfo?mac={imc_mac}"
|
||||
auth = self._get_digest_auth_header("GET", uri)
|
||||
if not auth:
|
||||
logger.error("生成认证头失败,无法获取光功率")
|
||||
return None
|
||||
|
||||
resp = self.session.get(
|
||||
f"{self.base_url}{uri}",
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": auth,
|
||||
},
|
||||
verify=self.verify_ssl,
|
||||
timeout=(self.connect_timeout, self.read_timeout),
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
try:
|
||||
data = resp.json()
|
||||
return {
|
||||
"powerIn": data.get("powerIn"),
|
||||
"powerOut": data.get("powerOut"),
|
||||
"bindMac": data.get("bindMac"),
|
||||
"devId": data.get("devId"),
|
||||
"eponDevName": data.get("eponDevName"),
|
||||
"oltIfName": data.get("oltIfName"),
|
||||
"onuIfDesc": data.get("onuIfDesc"),
|
||||
}
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"解析光功率 JSON 失败: {e}, 内容: {resp.text}")
|
||||
return None
|
||||
|
||||
elif resp.status_code == 401:
|
||||
self._clear_auth()
|
||||
continue
|
||||
|
||||
logger.error(f"光功率 API 失败,状态码: {resp.status_code}")
|
||||
return None
|
||||
|
||||
except requests.Timeout:
|
||||
raise TimeoutError("iMC 光功率接口请求超时")
|
||||
except Exception as e:
|
||||
logger.error(f"获取光功率异常 (retry={retry}): {e}")
|
||||
if retry == 0:
|
||||
time.sleep(2)
|
||||
continue
|
||||
return None
|
||||
|
||||
return None
|
||||
@@ -75,28 +75,26 @@ class ImportService:
|
||||
|
||||
def import_devices(self, records: List[Dict], olt_id: int = None) -> Dict:
|
||||
"""批量导入设备,存在则更新,不存在则新增"""
|
||||
success_count = 0
|
||||
skip_count = 0
|
||||
invalid_count = 0
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
|
||||
for record in records:
|
||||
mac = record.get('mac_address', '')
|
||||
if not mac:
|
||||
skip_count += 1
|
||||
continue
|
||||
|
||||
# 查询是否已存在该 MAC 地址
|
||||
existing = self.db.query(ONUDevice).filter(ONUDevice.mac_address == mac).first()
|
||||
|
||||
if existing:
|
||||
# 更新现有记录
|
||||
# 更新现有记录(MAC 地址不变)
|
||||
existing.region = record.get('region', '')
|
||||
existing.school_name = record.get('school_name', '')
|
||||
existing.building = record.get('building') or None
|
||||
existing.place_type = record.get('place_type') or None
|
||||
existing.room_number = record.get('room_number') or None
|
||||
existing.notes = record.get('notes') or None
|
||||
success_count += 1
|
||||
updated_count += 1
|
||||
else:
|
||||
# 新增记录
|
||||
device = ONUDevice(
|
||||
@@ -110,8 +108,8 @@ class ImportService:
|
||||
notes=record.get('notes') or None
|
||||
)
|
||||
self.db.add(device)
|
||||
success_count += 1
|
||||
created_count += 1
|
||||
|
||||
self.db.commit()
|
||||
return {'success': success_count}
|
||||
return {'success': created_count + updated_count, 'created': created_count, 'updated': updated_count}
|
||||
|
||||
|
||||
@@ -348,6 +348,85 @@ class SSHService:
|
||||
send_and_wait("quit", ">", timeout=5)
|
||||
return True
|
||||
|
||||
def sync_ntp(self, old_server: str, new_server: str) -> bool:
|
||||
"""同步 NTP 时间服务器配置
|
||||
流程: system-view -> undo ntp old -> ntp new -> clock timezone -> quit -> save force
|
||||
old_server 若不存在会报错,直接忽略继续执行。
|
||||
"""
|
||||
if not self.shell:
|
||||
raise Exception("SSH 未连接")
|
||||
|
||||
def send_and_wait(cmd: str, expect: str, timeout: int = 15) -> str:
|
||||
self.shell.send(cmd + "\n")
|
||||
buf = ""
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if self.shell.recv_ready():
|
||||
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
|
||||
if expect in buf:
|
||||
return buf
|
||||
else:
|
||||
time.sleep(0.2)
|
||||
return buf
|
||||
|
||||
out = send_and_wait("system-view", "]")
|
||||
if "]" not in out:
|
||||
raise Exception("进入 system-view 失败")
|
||||
|
||||
# 删除旧 NTP 服务器(若不存在会报错,忽略即可)
|
||||
send_and_wait(f"undo ntp-service unicast-server {old_server}", "]")
|
||||
|
||||
# 添加新 NTP 服务器
|
||||
out = send_and_wait(f"ntp-service unicast-server {new_server}", "]")
|
||||
if "]" not in out:
|
||||
raise Exception(f"配置 NTP 服务器 {new_server} 失败")
|
||||
|
||||
# 设置时区为北京时间
|
||||
out = send_and_wait("clock timezone Beijing add 08:00:00", "]")
|
||||
if "]" not in out:
|
||||
raise Exception("配置时区失败")
|
||||
|
||||
# 退出系统视图
|
||||
send_and_wait("quit", ">")
|
||||
|
||||
# 强制保存配置
|
||||
send_and_wait("save force", ">", timeout=30)
|
||||
|
||||
return True
|
||||
|
||||
def get_onu_events(self, port_id: str) -> list:
|
||||
"""
|
||||
查询 ONU 上下线事件记录
|
||||
命令: display epon onu-event interface Onu{port_id}
|
||||
返回: [{'date', 'time', 'event', 'status', 'datetime_str'}, ...]
|
||||
时间按倒序(最新在前)返回
|
||||
"""
|
||||
output = self.execute_command(
|
||||
f"display epon onu-event interface Onu{port_id}"
|
||||
)
|
||||
output = self._clean_output(output)
|
||||
events = []
|
||||
for line in output.splitlines():
|
||||
line = line.strip()
|
||||
m = re.match(r'(\d{4}/\d{2}/\d{2})\s+(\d{2}:\d{2}:\d{2})\s+(.+)', line)
|
||||
if not m:
|
||||
continue
|
||||
date_str, time_str, rest = m.group(1), m.group(2), m.group(3).strip()
|
||||
# 最后一个单词是 ONU Status(Up/Offline),前面整体是 Event 名称
|
||||
parts = rest.rsplit(None, 1)
|
||||
if len(parts) == 2:
|
||||
event, status = parts[0].strip(), parts[1].strip()
|
||||
else:
|
||||
event, status = rest, ''
|
||||
events.append({
|
||||
'date': date_str,
|
||||
'time': time_str,
|
||||
'event': event,
|
||||
'status': status,
|
||||
'datetime_str': f"{date_str} {time_str}",
|
||||
})
|
||||
return events
|
||||
|
||||
def close(self):
|
||||
"""关闭 SSH 连接"""
|
||||
if self.client:
|
||||
|
||||
@@ -292,7 +292,7 @@ const allNavItems = [
|
||||
},
|
||||
{
|
||||
path: '/audit',
|
||||
label: '审计日志',
|
||||
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"/>
|
||||
@@ -322,7 +322,7 @@ const mobileNavItems = computed(() => navItems.value.slice(0, 3))
|
||||
const extraNavItems = computed(() =>
|
||||
navItems.value.slice(3).map((item, i) => ({
|
||||
...item,
|
||||
desc: ['7天趋势与区域分布', '物料出入库台账', '管理系统用户', '配置角色权限', '定时检查等系统配置', '查看操作审计记录', '系统信息与说明'][i] || ''
|
||||
desc: ['7天趋势与区域分布', '物料出入库台账', '管理系统用户', '配置角色权限', '定时检查等系统配置', '更换台账与审计日志', '系统信息与说明'][i] || ''
|
||||
}))
|
||||
)
|
||||
|
||||
@@ -337,7 +337,8 @@ const pageNameMap = {
|
||||
'/users': '用户管理',
|
||||
'/roles': '角色权限',
|
||||
'/settings': '系统设置',
|
||||
'/audit': '审计日志',
|
||||
'/replacements': '记录查询',
|
||||
'/audit': '记录查询',
|
||||
}
|
||||
const currentPageName = computed(() => pageNameMap[route.path] || '页面')
|
||||
|
||||
|
||||
@@ -64,8 +64,8 @@ const routes = [
|
||||
},
|
||||
{
|
||||
path: '/audit',
|
||||
name: 'AuditLog',
|
||||
component: () => import('../views/AuditLog.vue'),
|
||||
name: 'RecordsLog',
|
||||
component: () => import('../views/RecordsLog.vue'),
|
||||
meta: { requiresRole: 'admin' }
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 格式化后端返回的 UTC 时间戳为北京时间(UTC+8)
|
||||
*
|
||||
* 后端用 datetime.utcnow() 存储,JSON 序列化无时区后缀,
|
||||
* JavaScript 把无后缀字符串当本地时间处理,导致少 8 小时。
|
||||
* 补上 'Z' 告知 JS 这是 UTC,浏览器会自动转成本地时间(北京 = UTC+8)。
|
||||
*/
|
||||
export function fmtTime(t, { slice } = {}) {
|
||||
if (!t) return '—'
|
||||
const s = String(t)
|
||||
const iso = /[Z+]/.test(s) ? s : s + 'Z'
|
||||
const result = new Date(iso).toLocaleString('zh-CN', { hour12: false })
|
||||
return slice ? result.slice(0, slice) : result
|
||||
}
|
||||
@@ -113,6 +113,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from '../utils/message'
|
||||
import { getAuditLogs, getAuditLogDetail, exportAuditLogs } from '../api/audit'
|
||||
import { fmtTime } from '../utils/datetime'
|
||||
|
||||
const logs = ref([])
|
||||
const total = ref(0)
|
||||
@@ -137,7 +138,7 @@ const actionTypes = [
|
||||
|
||||
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 }) : '—'
|
||||
// fmtTime 从 utils/datetime 导入,已在文件顶部
|
||||
|
||||
const buildParams = () => {
|
||||
const p = { page: page.value, page_size: pageSize.value }
|
||||
|
||||
@@ -185,6 +185,24 @@
|
||||
</svg>
|
||||
更新
|
||||
</button>
|
||||
<button v-if="can('device.view')" class="card-action-btn" @click.stop="openOpticalPowerFromCard(device)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/>
|
||||
</svg>
|
||||
光功率
|
||||
</button>
|
||||
<button v-if="can('device.view')" class="card-action-btn" @click.stop="openONUEventsFromCard(device)" :disabled="!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>
|
||||
<button v-if="can('device.check')" class="card-action-btn card-action-btn--danger" @click.stop="confirmRebootFromCard(device)">
|
||||
<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"/>
|
||||
@@ -344,13 +362,20 @@
|
||||
<el-descriptions-item label="备注" :span="2">{{ selectedDevice.notes || '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer>
|
||||
<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 && !(selectedDevice.slot_number && selectedDevice.port_number))">
|
||||
业务下发
|
||||
</el-button>
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
<div class="detail-footer">
|
||||
<div class="detail-footer-left">
|
||||
<el-button v-if="can('device.check')" type="info" size="small" :loading="deviceRefreshing" @click="doRefreshDevice" :disabled="!selectedDevice.olt_id">更新状态</el-button>
|
||||
<el-button v-if="can('device.view')" size="small" :loading="opticalLoading" @click="openOpticalPower">光功率</el-button>
|
||||
<el-button v-if="can('device.view')" size="small" :loading="eventsLoading" @click="openONUEvents" :disabled="!selectedDevice.port_id">上下线记录</el-button>
|
||||
<el-button v-if="can('device.check')" type="danger" plain size="small" :loading="rebooting" @click="confirmReboot">重启</el-button>
|
||||
</div>
|
||||
<div class="detail-footer-right">
|
||||
<el-button v-if="can('device.edit')" size="small" @click="openReplace">更换</el-button>
|
||||
<el-button v-if="can('device.edit')" type="primary" size="small" @click="openEdit">编辑</el-button>
|
||||
<el-button v-if="can('device.edit')" type="success" size="small" @click="openProvision" :disabled="selectedDevice.status !== 'online' || (!selectedDevice.port_id && !(selectedDevice.slot_number && selectedDevice.port_number))">业务下发</el-button>
|
||||
<el-button size="small" @click="detailVisible = false">关闭</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -457,8 +482,89 @@ save force</pre>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<!-- 上下线记录弹窗 -->
|
||||
<el-dialog v-model="eventsVisible" title="上下线事件记录" width="min(92vw, 620px)" destroy-on-close>
|
||||
<div v-if="eventsLoading" class="dialog-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<span>正在从 OLT 获取记录,请稍候…</span>
|
||||
</div>
|
||||
<template v-else-if="eventsData">
|
||||
<div class="events-meta">
|
||||
<span class="events-intf">接口:<em>{{ eventsData.interface }}</em></span>
|
||||
<span class="events-olt" v-if="eventsData.olt_location">OLT:{{ eventsData.olt_location }}</span>
|
||||
<el-tooltip content="时间来自 OLT 设备时钟,可能与实际时间有偏差" placement="top">
|
||||
<span class="events-hint">⚠ OLT 时钟</span>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div v-if="!eventsData.events.length" class="events-empty">暂无事件记录</div>
|
||||
<div v-else class="events-list">
|
||||
<div
|
||||
v-for="(ev, idx) in eventsData.events"
|
||||
:key="idx"
|
||||
class="event-row"
|
||||
:class="ev.status.toLowerCase() === 'up' ? 'ev-up' : 'ev-down'"
|
||||
>
|
||||
<div class="ev-indicator"></div>
|
||||
<div class="ev-body">
|
||||
<div class="ev-top">
|
||||
<span class="ev-event">{{ fmtONUEvent(ev.event) }}</span>
|
||||
<span class="ev-status" :class="ev.status.toLowerCase() === 'up' ? 'status-up' : 'status-down'">
|
||||
{{ fmtONUStatus(ev.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ev-time">{{ ev.datetime_str }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<el-button :loading="eventsLoading" @click="doFetchEvents">刷新</el-button>
|
||||
<el-button @click="eventsVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 光功率查询对话框 -->
|
||||
<el-dialog v-model="opticalVisible" title="光功率查询" width="min(90vw, 440px)" destroy-on-close>
|
||||
<div v-if="opticalLoading" class="dialog-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<span>正在查询光功率,请稍候…</span>
|
||||
</div>
|
||||
<template v-else-if="opticalResult">
|
||||
<div class="optical-grid">
|
||||
<div class="optical-card rx">
|
||||
<span class="optical-label">接收光功率</span>
|
||||
<span class="optical-val">{{ fmtPower(opticalResult.power_in) }}</span>
|
||||
<span class="optical-unit">dBm</span>
|
||||
</div>
|
||||
<div class="optical-card tx">
|
||||
<span class="optical-label">发送光功率</span>
|
||||
<span class="optical-val">{{ fmtPower(opticalResult.power_out) }}</span>
|
||||
<span class="optical-unit">dBm</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="optical-meta">
|
||||
<div class="optical-meta-row" v-if="opticalResult.epon_dev_name">
|
||||
<span class="optical-meta-label">OLT 设备</span>
|
||||
<span class="optical-meta-val mono">{{ opticalResult.epon_dev_name }}</span>
|
||||
</div>
|
||||
<div class="optical-meta-row" v-if="opticalResult.olt_if_name">
|
||||
<span class="optical-meta-label">端口</span>
|
||||
<span class="optical-meta-val mono">{{ opticalResult.olt_if_name }}</span>
|
||||
</div>
|
||||
<div class="optical-meta-row" v-if="opticalResult.onu_if_desc">
|
||||
<span class="optical-meta-label">ONU 描述</span>
|
||||
<span class="optical-meta-val">{{ opticalResult.onu_if_desc }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<el-button :loading="opticalLoading" @click="doFetchOpticalPower">刷新</el-button>
|
||||
<el-button @click="opticalVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 数据导入对话框 -->
|
||||
<el-dialog v-model="importDialogVisible" title="数据导入" width="480px" destroy-on-close>
|
||||
<el-dialog v-model="importDialogVisible" title="数据导入" width="min(90vw, 560px)" destroy-on-close>
|
||||
<div class="import-hint">
|
||||
请先下载模板,按格式填写后上传。导入只更新设备信息,不会删除已有设备。
|
||||
</div>
|
||||
@@ -509,10 +615,12 @@ save force</pre>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from '../utils/message'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import * as deviceApi from '../api/device'
|
||||
import request from '../utils/request'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
import { usePermission } from '../composables/usePermission'
|
||||
import { fmtTime as fmtReplaceTime } from '../utils/datetime'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
const { can } = usePermission()
|
||||
@@ -562,6 +670,85 @@ const importing = ref(false)
|
||||
const importResult = ref(null)
|
||||
const uploadRef = ref(null)
|
||||
|
||||
// 光功率
|
||||
const opticalVisible = ref(false)
|
||||
const opticalLoading = ref(false)
|
||||
const opticalResult = ref(null)
|
||||
|
||||
// 重启
|
||||
const rebooting = ref(false)
|
||||
|
||||
// 上下线记录
|
||||
const eventsVisible = ref(false)
|
||||
const eventsLoading = ref(false)
|
||||
const eventsData = ref(null)
|
||||
|
||||
const ONU_EVENT_MAP = {
|
||||
'Registration': '注册上线',
|
||||
'Deregistration': '注销离线',
|
||||
'Power Failure': '断电',
|
||||
'Power Recovery': '供电恢复',
|
||||
'Link Down': '链路断开',
|
||||
'Link Up': '链路恢复',
|
||||
'TFTP Start': '固件升级开始',
|
||||
'TFTP Success': '固件升级成功',
|
||||
'TFTP Failure': '固件升级失败',
|
||||
'Reset': '重置',
|
||||
'Dying Gasp': '掉电告警',
|
||||
}
|
||||
const ONU_STATUS_MAP = { 'Up': '上线', 'Offline': '离线', 'Down': '离线' }
|
||||
|
||||
const fmtONUEvent = (e) => ONU_EVENT_MAP[e] || e
|
||||
const fmtONUStatus = (s) => ONU_STATUS_MAP[s] || s
|
||||
|
||||
const fmtPower = (v) => {
|
||||
if (!v || v.trim() === '--' || v.trim() === '') return '—'
|
||||
return v
|
||||
}
|
||||
|
||||
const openOpticalPower = () => {
|
||||
opticalResult.value = null
|
||||
opticalVisible.value = true
|
||||
doFetchOpticalPower()
|
||||
}
|
||||
|
||||
const doFetchOpticalPower = async () => {
|
||||
opticalLoading.value = true
|
||||
try {
|
||||
const { data } = await request.get(`/devices/${selectedDevice.value.id}/optical-power`)
|
||||
opticalResult.value = data
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '获取光功率失败')
|
||||
opticalVisible.value = false
|
||||
} finally {
|
||||
opticalLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const confirmReboot = () => {
|
||||
ElMessageBox.confirm(
|
||||
`确认重启设备 ${formatMac(selectedDevice.value.mac_address)}?重启过程约需 30-60 秒,期间设备将短暂离线。`,
|
||||
'重启确认',
|
||||
{ confirmButtonText: '确认重启', cancelButtonText: '取消', type: 'warning' }
|
||||
).then(doReboot).catch(() => {})
|
||||
}
|
||||
|
||||
const doReboot = async () => {
|
||||
rebooting.value = true
|
||||
try {
|
||||
const { data } = await request.post(`/devices/${selectedDevice.value.id}/reboot`)
|
||||
if (data.success) {
|
||||
ElMessage.success(data.message)
|
||||
} else {
|
||||
ElMessage.error(data.message)
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '重启失败')
|
||||
} finally {
|
||||
rebooting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onImportFileChange = (file) => {
|
||||
importFile.value = file.raw
|
||||
}
|
||||
@@ -634,8 +821,41 @@ const openReplaceFromCard = async (device) => {
|
||||
replaceVisible.value = true
|
||||
}
|
||||
|
||||
const openProvisionFromCard = (device) => {
|
||||
const openOpticalPowerFromCard = (device) => {
|
||||
selectedDevice.value = { ...device }
|
||||
openOpticalPower()
|
||||
}
|
||||
|
||||
const openONUEvents = () => {
|
||||
eventsData.value = null
|
||||
eventsVisible.value = true
|
||||
doFetchEvents()
|
||||
}
|
||||
|
||||
const doFetchEvents = async () => {
|
||||
eventsLoading.value = true
|
||||
try {
|
||||
const { data } = await request.get(`/devices/${selectedDevice.value.id}/onu-events`)
|
||||
eventsData.value = data
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '获取事件记录失败')
|
||||
eventsVisible.value = false
|
||||
} finally {
|
||||
eventsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openONUEventsFromCard = (device) => {
|
||||
selectedDevice.value = { ...device }
|
||||
openONUEvents()
|
||||
}
|
||||
|
||||
const confirmRebootFromCard = (device) => {
|
||||
selectedDevice.value = { ...device }
|
||||
confirmReboot()
|
||||
}
|
||||
|
||||
const openProvisionFromCard = (device) => { selectedDevice.value = { ...device }
|
||||
provisionResult.value = null
|
||||
provisionVisible.value = true
|
||||
}
|
||||
@@ -773,7 +993,7 @@ const doReplace = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const fmtReplaceTime = (t) => t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '—'
|
||||
// fmtReplaceTime 从 utils/datetime 导入,已在文件顶部
|
||||
|
||||
const openEdit = () => {
|
||||
editForm.value = {
|
||||
@@ -1250,29 +1470,30 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 7px;
|
||||
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;
|
||||
padding: 9px 4px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
min-height: 44px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-action-btn:active {
|
||||
@@ -1294,6 +1515,16 @@ onMounted(() => {
|
||||
background: rgba(0,210,180,0.25);
|
||||
}
|
||||
|
||||
.card-action-btn--danger {
|
||||
background: var(--danger-dim);
|
||||
border-color: rgba(239,68,68,0.3);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.card-action-btn--danger:active {
|
||||
background: rgba(239,68,68,0.2);
|
||||
}
|
||||
|
||||
.card-action-btn--warn {
|
||||
background: rgba(245,158,11,0.1);
|
||||
border-color: rgba(245,158,11,0.3);
|
||||
@@ -1407,6 +1638,23 @@ onMounted(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 设备详情弹窗 footer 两端布局 */
|
||||
.detail-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.detail-footer-left,
|
||||
.detail-footer-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.import-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
@@ -1418,6 +1666,195 @@ onMounted(() => {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* 上下线事件记录弹窗 */
|
||||
.events-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 0 0 14px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.events-intf em {
|
||||
font-style: normal;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.events-olt { color: var(--text-secondary); }
|
||||
|
||||
.events-hint {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--warning, #f59e0b);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.events-empty {
|
||||
text-align: center;
|
||||
padding: 32px 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.events-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.event-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.event-row:last-child { border-bottom: none; }
|
||||
|
||||
.ev-indicator {
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
align-self: stretch;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.ev-up .ev-indicator { background: var(--success); }
|
||||
.ev-down .ev-indicator { background: var(--danger); }
|
||||
|
||||
.ev-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.ev-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ev-event {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ev-status {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.status-up {
|
||||
background: var(--success-dim);
|
||||
color: var(--success);
|
||||
border: 1px solid rgba(34,197,94,0.25);
|
||||
}
|
||||
|
||||
.status-down {
|
||||
background: var(--danger-dim);
|
||||
color: var(--danger);
|
||||
border: 1px solid rgba(239,68,68,0.25);
|
||||
}
|
||||
|
||||
.ev-time {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* 光功率弹窗 */
|
||||
.optical-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.optical-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 20px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
.optical-card.rx {
|
||||
background: var(--success-dim);
|
||||
border-color: rgba(34,197,94,0.25);
|
||||
}
|
||||
|
||||
.optical-card.tx {
|
||||
background: var(--info-dim);
|
||||
border-color: rgba(59,130,246,0.25);
|
||||
}
|
||||
|
||||
.optical-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.optical-val {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.optical-card.rx .optical-val { color: var(--success); }
|
||||
.optical-card.tx .optical-val { color: var(--info); }
|
||||
|
||||
.optical-unit {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.optical-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.optical-meta-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.optical-meta-row:last-child { border-bottom: none; }
|
||||
|
||||
.optical-meta-label {
|
||||
width: 64px;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.optical-meta-val {
|
||||
flex: 1;
|
||||
color: var(--text-secondary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.optical-meta-val.mono { font-family: var(--font-mono); }
|
||||
|
||||
.upload-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -88,8 +88,12 @@
|
||||
<div class="panel-body">
|
||||
<div class="result-summary">
|
||||
<div class="rs-card success">
|
||||
<span class="rs-num">{{ result.success }}</span>
|
||||
<span class="rs-label">成功导入</span>
|
||||
<span class="rs-num">{{ result.created || 0 }}</span>
|
||||
<span class="rs-label">新增设备</span>
|
||||
</div>
|
||||
<div class="rs-card updated">
|
||||
<span class="rs-num">{{ result.updated || 0 }}</span>
|
||||
<span class="rs-label">更新信息</span>
|
||||
</div>
|
||||
<div class="rs-card" :class="result.failed?.length ? 'failed' : 'empty'">
|
||||
<span class="rs-num">{{ result.failed?.length || 0 }}</span>
|
||||
@@ -350,6 +354,11 @@ const handleDownloadTemplate = async () => {
|
||||
border-color: rgba(34,197,94,0.25);
|
||||
}
|
||||
|
||||
.rs-card.updated {
|
||||
background: var(--info-dim);
|
||||
border-color: rgba(59,130,246,0.25);
|
||||
}
|
||||
|
||||
.rs-card.failed {
|
||||
background: var(--danger-dim);
|
||||
border-color: rgba(239,68,68,0.25);
|
||||
@@ -369,6 +378,7 @@ const handleDownloadTemplate = async () => {
|
||||
}
|
||||
|
||||
.rs-card.success .rs-num { color: var(--success); }
|
||||
.rs-card.updated .rs-num { color: var(--info); }
|
||||
.rs-card.failed .rs-num { color: var(--danger); }
|
||||
.rs-card.empty .rs-num { color: var(--text-muted); }
|
||||
|
||||
|
||||
@@ -617,6 +617,7 @@ import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { inventoryApi } from '../api/inventory'
|
||||
import { ElMessage } from '../utils/message'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { fmtTime as fmtTimeRaw } from '../utils/datetime'
|
||||
|
||||
const activeTab = ref('materials')
|
||||
const tabs = [
|
||||
@@ -1016,7 +1017,7 @@ const txnTypeLabel = (t) => ({ purchase_in: '采购入库', allocate_out: '领
|
||||
const txnTypeClass = (t) => ({ purchase_in: 'badge-success', allocate_out: 'badge-info', return_in: 'badge-warn', scrap_out: 'badge-danger', adjust: 'badge-default' }[t] || '')
|
||||
const statusLabel = (s) => ({ in_stock: '库存中', allocated: '已分配', installed: '已安装', in_use: '使用中', returned: '已退库', repairing: '维修中', scrapped: '已报废' }[s] || s)
|
||||
const statusClass = (s) => ({ in_stock: 'badge-success', allocated: 'badge-info', installed: 'badge-info', in_use: 'badge-info', returned: 'badge-default', repairing: 'badge-warn', scrapped: 'badge-danger' }[s] || '')
|
||||
const formatTime = (t) => t ? new Date(t).toLocaleString('zh-CN', { hour12: false }).slice(0, 16) : '-'
|
||||
const formatTime = (t) => fmtTimeRaw(t, { slice: 16 })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
新增设备
|
||||
<el-badge v-if="newDeviceCount > 0" :value="newDeviceCount" style="margin-left: 4px" />
|
||||
</el-button>
|
||||
<el-button v-if="can('olt.manage')" type="info" plain size="small" @click="openNtpSync" :loading="ntpSyncing">
|
||||
同步NTP
|
||||
</el-button>
|
||||
<el-button v-if="can('olt.loopback')" type="danger" plain size="small" @click="runLoopbackDetection" :loading="loopDetecting">
|
||||
环路检测
|
||||
</el-button>
|
||||
@@ -257,6 +260,67 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- NTP 时间同步 -->
|
||||
<el-dialog v-model="ntpSyncVisible" title="NTP 时间服务器同步" width="560px" destroy-on-close>
|
||||
<template v-if="ntpPhase === 'config'">
|
||||
<p style="margin: 0 0 16px; color: var(--text-muted); font-size: 13px">
|
||||
批量对所有 OLT 执行 NTP 时间服务器配置,并强制保存。旧服务器若不存在会自动忽略。
|
||||
</p>
|
||||
<el-form label-width="110px">
|
||||
<el-form-item label="旧 NTP 服务器">
|
||||
<el-input v-model="ntpForm.old_server" class="mono-val" placeholder="172.16.0.254" />
|
||||
</el-form-item>
|
||||
<el-form-item label="新 NTP 服务器">
|
||||
<el-input v-model="ntpForm.new_server" class="mono-val" placeholder="172.16.1.252" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div style="background: var(--bg-subtle); border: 1px solid var(--border-subtle); border-radius: 6px; padding: 10px 14px; font-size: 12px; color: var(--text-muted); font-family: monospace; line-height: 1.7">
|
||||
<div>system-view</div>
|
||||
<div>undo ntp-service unicast-server {{ ntpForm.old_server }}</div>
|
||||
<div>ntp-service unicast-server {{ ntpForm.new_server }}</div>
|
||||
<div>clock timezone Beijing add 08:00:00</div>
|
||||
<div>quit</div>
|
||||
<div>save force</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="result-stats" style="margin-bottom: 16px">
|
||||
<div class="result-stat">
|
||||
<span class="rs-val" style="color: var(--success)">{{ ntpSyncResult.success || 0 }}</span>
|
||||
<span class="rs-label">成功</span>
|
||||
</div>
|
||||
<div class="result-stat">
|
||||
<span class="rs-val offline">{{ ntpSyncResult.failed || 0 }}</span>
|
||||
<span class="rs-label">失败</span>
|
||||
</div>
|
||||
<div class="result-stat">
|
||||
<span class="rs-val muted">{{ ntpSyncResult.total || 0 }}</span>
|
||||
<span class="rs-label">共计</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-table :data="ntpSyncResult.results || []" border size="small" max-height="320">
|
||||
<el-table-column prop="olt_location" label="OLT" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="olt_ip" label="IP" width="140">
|
||||
<template #default="{ row }"><span class="mono-val">{{ row.olt_ip }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="72" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.success ? 'success' : 'danger'" size="small">{{ row.success ? '成功' : '失败' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="error" label="错误信息" min-width="130" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span style="color: var(--danger); font-size: 12px">{{ row.error || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<template #footer>
|
||||
<el-button @click="ntpSyncVisible = false">关闭</el-button>
|
||||
<el-button v-if="ntpPhase === 'config'" type="primary" @click="runNtpSync" :loading="ntpSyncing">开始同步</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 添加/编辑 OLT -->
|
||||
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑 OLT 设备' : '添加 OLT 设备'" width="480px" destroy-on-close>
|
||||
<el-form :model="form" label-width="90px">
|
||||
@@ -443,7 +507,7 @@
|
||||
<el-table-column label="最后发现" width="155">
|
||||
<template #default="{ row }">
|
||||
<span style="font-size: 12px; color: var(--text-muted)">
|
||||
{{ row.last_seen_at ? new Date(row.last_seen_at).toLocaleString() : '—' }}
|
||||
{{ fmtTime(row.last_seen_at) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -508,6 +572,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from '../utils/message'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { fmtTime } from '../utils/datetime'
|
||||
import request from '../utils/request'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
import { usePermission } from '../composables/usePermission'
|
||||
@@ -941,6 +1006,31 @@ const openPortManagerFromCard = async (device) => {
|
||||
await openPortManager()
|
||||
}
|
||||
|
||||
const ntpSyncing = ref(false)
|
||||
const ntpSyncVisible = ref(false)
|
||||
const ntpPhase = ref('config') // 'config' | 'results'
|
||||
const ntpForm = ref({ old_server: '172.16.0.254', new_server: '172.16.1.252' })
|
||||
const ntpSyncResult = ref({ total: 0, success: 0, failed: 0, results: [] })
|
||||
|
||||
const openNtpSync = () => {
|
||||
ntpPhase.value = 'config'
|
||||
ntpSyncResult.value = { total: 0, success: 0, failed: 0, results: [] }
|
||||
ntpSyncVisible.value = true
|
||||
}
|
||||
|
||||
const runNtpSync = async () => {
|
||||
ntpSyncing.value = true
|
||||
try {
|
||||
const { data } = await request.post('/olt/sync-ntp', ntpForm.value)
|
||||
ntpSyncResult.value = data
|
||||
ntpPhase.value = 'results'
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || 'NTP 同步失败')
|
||||
} finally {
|
||||
ntpSyncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchRegions()
|
||||
fetchDevices()
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
<template>
|
||||
<div class="records-page">
|
||||
|
||||
<!-- 页头 -->
|
||||
<div class="page-header">
|
||||
<div class="page-title-group">
|
||||
<h1 class="page-title">记录查询</h1>
|
||||
<span class="page-subtitle">{{ activeTab === 'replacements' ? '设备 MAC 更换台账,支持筛选与导出' : '全量用户操作审计,支持追溯与问责' }}</span>
|
||||
</div>
|
||||
<el-button type="success" size="small" :loading="activeTab === 'replacements' ? repExporting : auditExporting" @click="handleExport">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" 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"/><line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
导出 CSV
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Tab 切换 -->
|
||||
<div class="tab-bar">
|
||||
<button
|
||||
:class="['tab-btn', activeTab === 'replacements' && 'active']"
|
||||
@click="switchTab('replacements')"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<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="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="12" y2="17"/>
|
||||
</svg>
|
||||
更换台账
|
||||
<span v-if="repTotal > 0" class="tab-count">{{ repTotal }}</span>
|
||||
</button>
|
||||
<button
|
||||
:class="['tab-btn', activeTab === 'audit' && 'active']"
|
||||
@click="switchTab('audit')"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<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>
|
||||
审计日志
|
||||
<span v-if="auditTotal > 0" class="tab-count">{{ auditTotal }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── 更换台账 ── -->
|
||||
<template v-if="activeTab === 'replacements'">
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="repFilters.region" placeholder="全部区域" clearable size="small" style="width:140px" @change="repLoad">
|
||||
<el-option v-for="r in regions" :key="r" :label="r" :value="r" />
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-model="repDateRange"
|
||||
type="daterange"
|
||||
size="small"
|
||||
range-separator="—"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width:240px"
|
||||
@change="repLoad"
|
||||
/>
|
||||
<el-input
|
||||
v-model="repFilters.keyword"
|
||||
placeholder="学校 / MAC / 操作人"
|
||||
clearable size="small" style="width:200px"
|
||||
@input="repDebounce" @clear="repLoad"
|
||||
/>
|
||||
<button class="reset-btn" @click="repReset">重置</button>
|
||||
<span class="total-hint">共 {{ repTotal }} 条</span>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<el-table :data="repItems" v-loading="repLoading" size="small" style="width:100%" border>
|
||||
<el-table-column type="index" label="#" width="52" align="center">
|
||||
<template #default="{ $index }">
|
||||
<span class="row-index">{{ (repPage - 1) * repPageSize + $index + 1 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="更换时间" width="165">
|
||||
<template #default="{ row }">
|
||||
<span class="mono-val small">{{ fmtTime(row.replaced_at) }}</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="school_name" label="学校" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="楼宇/房间" width="130" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="muted">{{ [row.building, row.room_number].filter(Boolean).join(' / ') || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="旧 MAC" width="145">
|
||||
<template #default="{ row }">
|
||||
<span class="mono-val mac old-mac">{{ row.old_mac }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="新 MAC" width="145">
|
||||
<template #default="{ row }">
|
||||
<span class="mono-val mac new-mac">{{ row.new_mac }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="更换原因" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.reason" style="font-size:12px">{{ row.reason }}</span>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="operator_name" label="操作人" width="110">
|
||||
<template #default="{ row }">
|
||||
<span style="font-size:12px">{{ row.operator_name || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="repPage"
|
||||
v-model:page-size="repPageSize"
|
||||
:total="repTotal"
|
||||
:page-sizes="[50, 100, 200]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
small
|
||||
@size-change="repLoad"
|
||||
@current-change="repLoad"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── 审计日志 ── -->
|
||||
<template v-if="activeTab === 'audit'">
|
||||
<div class="filter-bar">
|
||||
<el-date-picker
|
||||
v-model="auditDateRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
size="small"
|
||||
style="width:340px"
|
||||
@change="auditFilter"
|
||||
/>
|
||||
<el-input v-model="auditFilters.username" placeholder="用户名" size="small" style="width:130px" clearable @change="auditFilter" />
|
||||
<el-select v-model="auditFilters.action_type" placeholder="操作类型" size="small" style="width:120px" clearable @change="auditFilter">
|
||||
<el-option v-for="t in actionTypes" :key="t.value" :label="t.label" :value="t.value" />
|
||||
</el-select>
|
||||
<el-select v-model="auditFilters.status" placeholder="状态" size="small" style="width:100px" clearable @change="auditFilter">
|
||||
<el-option label="成功" value="success" />
|
||||
<el-option label="失败" value="failed" />
|
||||
<el-option label="错误" value="error" />
|
||||
</el-select>
|
||||
<button class="reset-btn" @click="auditReset">重置</button>
|
||||
<span class="total-hint">共 {{ auditTotal }} 条</span>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<el-table :data="auditLogs" size="small" style="width:100%" v-loading="auditLoading" @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="auditPage"
|
||||
v-model:page-size="auditPageSize"
|
||||
:total="auditTotal"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
small
|
||||
@change="fetchAudit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 审计详情抽屉 -->
|
||||
<el-drawer v-model="drawerVisible" title="日志详情" size="480px" direction="rtl">
|
||||
<div v-if="auditDetail" 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="auditDetail.request_params">
|
||||
<div class="detail-section">请求参数</div>
|
||||
<pre class="json-block">{{ JSON.stringify(auditDetail.request_params, null, 2) }}</pre>
|
||||
</template>
|
||||
<template v-if="auditDetail.error_message">
|
||||
<div class="detail-section">错误信息</div>
|
||||
<pre class="json-block error">{{ auditDetail.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'
|
||||
import request from '../utils/request'
|
||||
import { fmtTime } from '../utils/datetime'
|
||||
|
||||
// ── 当前 Tab ──────────────────────────────────────
|
||||
const activeTab = ref('replacements')
|
||||
|
||||
const switchTab = (tab) => {
|
||||
activeTab.value = tab
|
||||
if (tab === 'replacements' && repItems.value.length === 0) repLoad()
|
||||
if (tab === 'audit' && auditLogs.value.length === 0) fetchAudit()
|
||||
}
|
||||
|
||||
// ── 公共 ──────────────────────────────────────────
|
||||
const regions = ref([])
|
||||
|
||||
// ── 更换台账 ──────────────────────────────────────
|
||||
const repItems = ref([])
|
||||
const repTotal = ref(0)
|
||||
const repPage = ref(1)
|
||||
const repPageSize = ref(100)
|
||||
const repLoading = ref(false)
|
||||
const repExporting = ref(false)
|
||||
const repDateRange = ref(null)
|
||||
const repFilters = ref({ region: '', keyword: '' })
|
||||
|
||||
let repTimer = null
|
||||
const repDebounce = () => { clearTimeout(repTimer); repTimer = setTimeout(repLoad, 400) }
|
||||
const repReset = () => { repFilters.value = { region: '', keyword: '' }; repDateRange.value = null; repPage.value = 1; repLoad() }
|
||||
|
||||
const repParams = () => {
|
||||
const p = { skip: (repPage.value - 1) * repPageSize.value, limit: repPageSize.value }
|
||||
if (repFilters.value.region) p.region = repFilters.value.region
|
||||
if (repFilters.value.keyword) p.keyword = repFilters.value.keyword
|
||||
if (repDateRange.value?.[0]) p.start_date = repDateRange.value[0]
|
||||
if (repDateRange.value?.[1]) p.end_date = repDateRange.value[1]
|
||||
return p
|
||||
}
|
||||
|
||||
const repLoad = async () => {
|
||||
repLoading.value = true
|
||||
try {
|
||||
const { data } = await request.get('/devices/replacements', { params: repParams() })
|
||||
repItems.value = data.items
|
||||
repTotal.value = data.total
|
||||
} catch { ElMessage.error('更换台账加载失败') }
|
||||
finally { repLoading.value = false }
|
||||
}
|
||||
|
||||
const repExport = async () => {
|
||||
repExporting.value = true
|
||||
try {
|
||||
const params = {}
|
||||
if (repFilters.value.region) params.region = repFilters.value.region
|
||||
if (repFilters.value.keyword) params.keyword = repFilters.value.keyword
|
||||
if (repDateRange.value?.[0]) params.start_date = repDateRange.value[0]
|
||||
if (repDateRange.value?.[1]) params.end_date = repDateRange.value[1]
|
||||
const { data } = await request.get('/devices/replacements/export', { params, responseType: 'blob' })
|
||||
const url = URL.createObjectURL(new Blob([data]))
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `replacement_records_${Date.now()}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { ElMessage.error('导出失败') }
|
||||
finally { repExporting.value = false }
|
||||
}
|
||||
|
||||
// ── 审计日志 ──────────────────────────────────────
|
||||
const auditLogs = ref([])
|
||||
const auditTotal = ref(0)
|
||||
const auditPage = ref(1)
|
||||
const auditPageSize = ref(50)
|
||||
const auditLoading = ref(false)
|
||||
const auditExporting = ref(false)
|
||||
const drawerVisible = ref(false)
|
||||
const auditDetail = ref(null)
|
||||
const auditDateRange = ref(null)
|
||||
const auditFilters = 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 auditBuildParams = () => {
|
||||
const p = { page: auditPage.value, page_size: auditPageSize.value }
|
||||
if (auditDateRange.value?.[0]) p.start_time = auditDateRange.value[0].toISOString()
|
||||
if (auditDateRange.value?.[1]) p.end_time = auditDateRange.value[1].toISOString()
|
||||
if (auditFilters.value.username) p.username = auditFilters.value.username
|
||||
if (auditFilters.value.action_type) p.action_type = auditFilters.value.action_type
|
||||
if (auditFilters.value.status) p.status = auditFilters.value.status
|
||||
return p
|
||||
}
|
||||
|
||||
const fetchAudit = async () => {
|
||||
auditLoading.value = true
|
||||
try {
|
||||
const { data } = await getAuditLogs(auditBuildParams())
|
||||
auditLogs.value = data.items
|
||||
auditTotal.value = data.total
|
||||
} catch { ElMessage.error('审计日志加载失败') }
|
||||
finally { auditLoading.value = false }
|
||||
}
|
||||
|
||||
const auditFilter = () => { auditPage.value = 1; fetchAudit() }
|
||||
const auditReset = () => {
|
||||
auditFilters.value = { username: '', action_type: '', status: '' }
|
||||
auditDateRange.value = null
|
||||
auditPage.value = 1
|
||||
fetchAudit()
|
||||
}
|
||||
|
||||
const openDetail = async (row) => {
|
||||
try {
|
||||
const { data } = await getAuditLogDetail(row.id)
|
||||
auditDetail.value = data
|
||||
drawerVisible.value = true
|
||||
} catch { ElMessage.error('加载详情失败') }
|
||||
}
|
||||
|
||||
const detailFields = computed(() => {
|
||||
if (!auditDetail.value) return {}
|
||||
const d = auditDetail.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 auditExport = async () => {
|
||||
auditExporting.value = true
|
||||
try {
|
||||
const { data } = await exportAuditLogs(auditBuildParams())
|
||||
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 { auditExporting.value = false }
|
||||
}
|
||||
|
||||
// ── 导出分发 ──────────────────────────────────────
|
||||
const handleExport = () => activeTab.value === 'replacements' ? repExport() : auditExport()
|
||||
|
||||
// ── 初始化 ────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await request.get('/devices/regions')
|
||||
regions.value = data
|
||||
} catch {}
|
||||
repLoad()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.records-page { padding: 24px; }
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.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); }
|
||||
|
||||
/* Tab 条 */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 2px solid var(--border-default);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.tab-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 18px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: color .15s, border-color .15s;
|
||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||
}
|
||||
.tab-btn:hover { color: var(--text-primary); }
|
||||
.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
.tab-count {
|
||||
font-size: 10px;
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
border-radius: 8px;
|
||||
padding: 1px 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 筛选栏 */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.total-hint { font-size: 12px; color: var(--text-muted); margin-left: 4px; }
|
||||
.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);
|
||||
}
|
||||
|
||||
/* 通用样式 */
|
||||
.row-index { color: var(--text-muted); font-size: 12px; }
|
||||
.mono-val { font-family: var(--font-mono); font-size: 12px; }
|
||||
.small { font-size: 11px; }
|
||||
.muted { color: var(--text-muted); font-size: 12px; }
|
||||
|
||||
.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;
|
||||
}
|
||||
.mac.old-mac { color: var(--danger); }
|
||||
.mac.new-mac { color: var(--success); }
|
||||
|
||||
.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>
|
||||
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<div class="replacement-page">
|
||||
<div class="page-header">
|
||||
<div class="page-title-group">
|
||||
<h1 class="page-title">更换记录台账</h1>
|
||||
<span class="page-subtitle">设备 MAC 地址更换历史,支持筛选与导出</span>
|
||||
</div>
|
||||
<el-button type="success" size="small" :loading="exporting" @click="exportCsv">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" 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"/><line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
导出 CSV
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="filters.region" placeholder="全部区域" clearable size="small" style="width:140px" @change="load">
|
||||
<el-option v-for="r in regions" :key="r" :label="r" :value="r" />
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
size="small"
|
||||
range-separator="—"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width:240px"
|
||||
@change="load"
|
||||
/>
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
placeholder="学校 / MAC / 操作人"
|
||||
clearable
|
||||
size="small"
|
||||
style="width:200px"
|
||||
@input="onKeyword"
|
||||
@clear="load"
|
||||
/>
|
||||
<span class="total-hint">共 {{ total }} 条</span>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<div class="data-panel">
|
||||
<el-table :data="items" v-loading="loading" size="small" style="width:100%" border>
|
||||
<el-table-column type="index" label="#" width="52" align="center">
|
||||
<template #default="{ $index }"><span class="row-index">{{ filters.skip + $index + 1 }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="更换时间" width="165">
|
||||
<template #default="{ row }">
|
||||
<span class="mono-val small">{{ fmtTime(row.replaced_at) }}</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="school_name" label="学校" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="楼宇/房间" width="130" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span style="font-size:12px;color:var(--text-muted)">
|
||||
{{ [row.building, row.room_number].filter(Boolean).join(' / ') || '—' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="旧 MAC" width="145">
|
||||
<template #default="{ row }">
|
||||
<span class="mono-val mac old-mac">{{ row.old_mac }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="新 MAC" width="145">
|
||||
<template #default="{ row }">
|
||||
<span class="mono-val mac new-mac">{{ row.new_mac }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="更换原因" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.reason" style="font-size:12px">{{ row.reason }}</span>
|
||||
<span v-else style="color:var(--text-muted);font-size:12px">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="operator_name" label="操作人" width="110">
|
||||
<template #default="{ row }">
|
||||
<span style="font-size:12px">{{ row.operator_name || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-bar">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[50, 100, 200]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
size="small"
|
||||
@size-change="load"
|
||||
@current-change="load"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ElMessage } from '../utils/message'
|
||||
import request from '../utils/request'
|
||||
import { fmtTime } from '../utils/datetime'
|
||||
|
||||
const regions = ref([])
|
||||
const items = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const exporting = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(100)
|
||||
const dateRange = ref(null)
|
||||
const filters = ref({ region: '', keyword: '' })
|
||||
|
||||
let keywordTimer = null
|
||||
const onKeyword = () => {
|
||||
clearTimeout(keywordTimer)
|
||||
keywordTimer = setTimeout(load, 400)
|
||||
}
|
||||
|
||||
const buildParams = () => {
|
||||
const p = {
|
||||
skip: (currentPage.value - 1) * pageSize.value,
|
||||
limit: pageSize.value,
|
||||
}
|
||||
if (filters.value.region) p.region = filters.value.region
|
||||
if (filters.value.keyword) p.keyword = filters.value.keyword
|
||||
if (dateRange.value?.[0]) p.start_date = dateRange.value[0]
|
||||
if (dateRange.value?.[1]) p.end_date = dateRange.value[1]
|
||||
return p
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await request.get('/devices/replacements', { params: buildParams() })
|
||||
items.value = data.items
|
||||
total.value = data.total
|
||||
} catch {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const exportCsv = async () => {
|
||||
exporting.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (filters.value.region) params.set('region', filters.value.region)
|
||||
if (filters.value.keyword) params.set('keyword', filters.value.keyword)
|
||||
if (dateRange.value?.[0]) params.set('start_date', dateRange.value[0])
|
||||
if (dateRange.value?.[1]) params.set('end_date', dateRange.value[1])
|
||||
const url = `/api/devices/replacements/export?${params}`
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = ''
|
||||
a.click()
|
||||
} catch {
|
||||
ElMessage.error('导出失败')
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await request.get('/devices/regions')
|
||||
regions.value = data
|
||||
} catch {}
|
||||
load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.replacement-page {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.total-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.data-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.row-index {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.mono-val {
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mono-val.small {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mac.old-mac {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.mac.new-mac {
|
||||
color: var(--success);
|
||||
}
|
||||
</style>
|
||||
@@ -160,6 +160,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { getUsers, updateUserRole, toggleUser } from '../api/user'
|
||||
import request from '../utils/request'
|
||||
import { fmtTime as formatTime } from '../utils/datetime'
|
||||
|
||||
const users = ref([])
|
||||
const total = ref(0)
|
||||
@@ -226,10 +227,7 @@ const roleOptions = [
|
||||
|
||||
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 })
|
||||
}
|
||||
// formatTime 从 utils/datetime 导入,已在文件顶部
|
||||
|
||||
const loadRegions = async () => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,726 @@
|
||||
# 为 H3ConuMS2 添加 ONU 远程重启 & 光功率查询功能 — 后端开发指南
|
||||
|
||||
> 目标:将 H3C iMC 平台的 ONU 远程重启和光功率查询功能集成到 H3ConuMS2 项目中
|
||||
> 技术栈:FastAPI + SQLAlchemy + requests (HTTP Digest Auth)
|
||||
> 源项目参考:`/home/v6ole/pyproject/H3ConuMS`
|
||||
|
||||
---
|
||||
|
||||
## 1. 整体架构
|
||||
|
||||
```
|
||||
┌──────────────────┐ REST API 调用 ┌──────────────────┐
|
||||
│ H3ConuMS2 后端 │ ──────────────────────► │ H3C iMC 平台 │
|
||||
│ (FastAPI) │ ◄────────────────────── │ │
|
||||
│ IMCService │ │ /imcrs/epon/... │
|
||||
└──────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
**后端提供的 API 端点:**
|
||||
| 端点 | 方法 | 说明 | iMC 后端接口 |
|
||||
|------|------|------|-------------|
|
||||
| `/api/devices/{id}/reboot` | POST | 远程重启 ONU | `/imcrs/epon/onu/reboot?mac={mac}` (POST) |
|
||||
| `/api/devices/{id}/optical-power` | GET | 获取 ONU 光功率 | `/imcrs/epon/onu/onuLightWaneInfo?mac={mac}` (GET) |
|
||||
|
||||
**数据流(以重启为例):**
|
||||
1. 客户端 POST `/api/devices/{id}/reboot`
|
||||
2. 后端控制器验证设备存在 + 权限 → 调用 `IMCService.reboot_onu()`
|
||||
3. `IMCService` 构建 HTTP Digest 认证头 → POST 到 iMC REST API
|
||||
4. iMC 返回结果 → 后端解析错误码 → 返回 JSON
|
||||
|
||||
---
|
||||
|
||||
## 2. Digest 认证原理解析
|
||||
|
||||
iMC 的 REST API 使用 **HTTP Digest Access Authentication**(RFC 2617),不是普通的 Cookie/Session 登录。
|
||||
|
||||
### 认证流程
|
||||
|
||||
```
|
||||
客户端 iMC 服务器
|
||||
│ │
|
||||
│──── GET /imcrs/... (无认证) ────│
|
||||
│ │──── 401 + WWW-Authenticate header
|
||||
│ │ (包含 nonce, realm, qop)
|
||||
│ │
|
||||
│ ── 解析 WWW-Authenticate ──► │
|
||||
│ 提取 nonce 和 realm │
|
||||
│ │
|
||||
│ ── 计算 Digest 响应 ────────► │
|
||||
│ HA1 = MD5(user:realm:pass) │
|
||||
│ HA2 = MD5(method:uri) │
|
||||
│ response = MD5(HA1:nonce:nc:cnonce:qop:HA2) │
|
||||
│ │
|
||||
│──── POST /imcrs/... ──────────►│
|
||||
│ Authorization: Digest ... │
|
||||
│ │──── 200 OK (成功)
|
||||
```
|
||||
|
||||
### 核心 MD5 计算
|
||||
|
||||
```python
|
||||
cnonce = md5(str(time.time())).hexdigest()[:16]
|
||||
ha1 = md5(f"{username}:{realm}:{password}").hexdigest()
|
||||
ha2 = md5(f"{method}:{uri}").hexdigest()
|
||||
response = md5(f"{ha1}:{nonce}:{nc:08d}:{cnonce}:auth:{ha2}").hexdigest()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 需要修改/新增的文件清单
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `backend/app/services/imc_service.py` | **新增** | iMC API 服务(Digest 认证 + 重启 + 光功率) |
|
||||
| `backend/app/services/__init__.py` | 修改 | 导出 IMCService |
|
||||
| `backend/app/schemas/device.py` | 修改 | 添加重启/光功率响应 Schema |
|
||||
| `backend/app/api/v1/devices.py` | 修改 | 添加重启和光功率 API 路由 |
|
||||
| `backend/app/core/config.py` | 修改 | 添加 iMC 配置项 |
|
||||
| `.env` 或 `backend/.env` | 修改 | 添加 iMC 环境变量 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 后端实现
|
||||
|
||||
### 4.1 配置项 — `backend/app/core/config.py`
|
||||
|
||||
在 `Settings` 类中添加 iMC 相关配置:
|
||||
|
||||
```python
|
||||
# ===== iMC API 配置(用于 ONU 远程重启和光功率查询)=====
|
||||
IMC_API_URL: str = "" # 例如 https://172.16.1.252:8443
|
||||
IMC_API_USERNAME: str = "" # iMC 用户名
|
||||
IMC_API_PASSWORD: str = "" # iMC 密码(明文,Digest认证需要原始密码)
|
||||
IMC_API_VERIFY_SSL: bool = False # 是否验证 SSL 证书
|
||||
IMC_CONNECT_TIMEOUT: float = 5.0
|
||||
IMC_READ_TIMEOUT: float = 20.0
|
||||
```
|
||||
|
||||
### 4.2 .env 配置
|
||||
|
||||
在 `backend/.env`(或项目根目录 `.env`)中添加:
|
||||
|
||||
```env
|
||||
# iMC API 配置(用于 ONU 远程重启和光功率查询)
|
||||
IMC_API_URL=https://172.16.1.252:8443
|
||||
IMC_API_USERNAME=admin
|
||||
IMC_API_PASSWORD=Pwd@12345
|
||||
IMC_API_VERIFY_SSL=false
|
||||
IMC_CONNECT_TIMEOUT=5
|
||||
IMC_READ_TIMEOUT=20
|
||||
```
|
||||
|
||||
### 4.3 IMCService — `backend/app/services/imc_service.py`
|
||||
|
||||
完整代码,包含 Digest 认证 + 重启 ONU + 光功率查询三大功能:
|
||||
|
||||
```python
|
||||
"""
|
||||
iMC REST API 服务
|
||||
- 使用 HTTP Digest Access Authentication (RFC 2617)
|
||||
- 支持 nonce 过期自动续约(401 时自动重新握手)
|
||||
- 功能:ONU 远程重启、光功率查询
|
||||
"""
|
||||
import hashlib
|
||||
import re
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import requests
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# iMC 重启错误码映射
|
||||
REBOOT_ERROR_CODES = {
|
||||
'103': 'ONU不存在',
|
||||
'119': 'SNMP连接超时',
|
||||
'120': '业务割接失败',
|
||||
'121': 'ONU未运行',
|
||||
'122': '重启失败',
|
||||
}
|
||||
|
||||
|
||||
class IMCService:
|
||||
"""iMC REST API 服务封装"""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = settings.IMC_API_URL.rstrip('/')
|
||||
self.username = settings.IMC_API_USERNAME
|
||||
self.password = settings.IMC_API_PASSWORD
|
||||
self.verify_ssl = settings.IMC_API_VERIFY_SSL
|
||||
self.session = requests.Session()
|
||||
self.realm = "iMC RESTful Web Services"
|
||||
self.connect_timeout = getattr(settings, 'IMC_CONNECT_TIMEOUT', 5)
|
||||
self.read_timeout = getattr(settings, 'IMC_READ_TIMEOUT', 20)
|
||||
# Digest 认证状态(每次重新初始化时清空,让首次请求自动获取 nonce)
|
||||
self.nonce = None
|
||||
self.nc = 1
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 内部:Digest 认证
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _get_digest_auth_header(self, method: str, uri: str) -> str | None:
|
||||
"""
|
||||
构建 HTTP Digest 认证头
|
||||
|
||||
首次调用时会自动发一个请求获取 nonce(服务器返回 401 + WWW-Authenticate),
|
||||
后续复用 nonce 并递增 nc 值。
|
||||
nonce 过期时调用方捕获 401 后清空 self.nonce,下次自动重新握手。
|
||||
"""
|
||||
if not self.nonce:
|
||||
try:
|
||||
resp = self.session.get(
|
||||
f"{self.base_url}{uri}",
|
||||
verify=self.verify_ssl,
|
||||
headers={"Accept": "application/json"},
|
||||
timeout=(self.connect_timeout, self.read_timeout),
|
||||
)
|
||||
if resp.status_code == 401 and 'WWW-Authenticate' in resp.headers:
|
||||
auth_header = resp.headers['WWW-Authenticate']
|
||||
auth_parts = {}
|
||||
for part in auth_header.split(','):
|
||||
if '=' in part:
|
||||
key, value = part.split('=', 1)
|
||||
auth_parts[key.strip()] = value.strip(' "')
|
||||
self.nonce = auth_parts.get('nonce', '')
|
||||
self.realm = auth_parts.get('realm', self.realm)
|
||||
logger.info(f"获取 nonce 成功: {self.nonce}")
|
||||
else:
|
||||
logger.error(f"获取 nonce 失败, 状态码: {resp.status_code}")
|
||||
return None
|
||||
except requests.Timeout:
|
||||
logger.error("获取 nonce 超时")
|
||||
raise TimeoutError("iMC 认证超时")
|
||||
except Exception as e:
|
||||
logger.error(f"获取 nonce 异常: {e}")
|
||||
return None
|
||||
|
||||
# 计算 Digest 响应
|
||||
cnonce = hashlib.md5(str(time.time()).encode()).hexdigest()[:16]
|
||||
ha1 = hashlib.md5(
|
||||
f"{self.username}:{self.realm}:{self.password}".encode()
|
||||
).hexdigest()
|
||||
ha2 = hashlib.md5(f"{method}:{uri}".encode()).hexdigest()
|
||||
response_hash = hashlib.md5(
|
||||
f"{ha1}:{self.nonce}:{self.nc:08d}:{cnonce}:auth:{ha2}".encode()
|
||||
).hexdigest()
|
||||
|
||||
auth_value = (
|
||||
f'Digest username="{self.username}", '
|
||||
f'realm="{self.realm}", '
|
||||
f'nonce="{self.nonce}", '
|
||||
f'uri="{uri}", '
|
||||
f'response="{response_hash}", '
|
||||
f'qop=auth, '
|
||||
f'nc={self.nc:08d}, '
|
||||
f'cnonce="{cnonce}"'
|
||||
)
|
||||
self.nc += 1
|
||||
return auth_value
|
||||
|
||||
def _clear_auth(self):
|
||||
"""清除认证状态(nonce 过期时调用)"""
|
||||
self.nonce = None
|
||||
self.nc = 1
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 公共:重启 ONU
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def reboot_onu(self, mac: str) -> dict:
|
||||
"""
|
||||
远程重启 ONU 设备
|
||||
|
||||
Args:
|
||||
mac: MAC 地址,格式如 "1484-7790-4840"
|
||||
|
||||
Returns:
|
||||
{"success": True, "message": "设备正在重启,请稍后..."}
|
||||
或 {"success": False, "message": "重启失败: ..."}
|
||||
"""
|
||||
max_retries = 1
|
||||
for retry in range(max_retries + 1):
|
||||
try:
|
||||
uri = f"/imcrs/epon/onu/reboot?mac={mac}"
|
||||
auth = self._get_digest_auth_header("POST", uri)
|
||||
if not auth:
|
||||
return {"success": False, "message": "认证失败,无法发送重启请求"}
|
||||
|
||||
headers = {
|
||||
"Accept": "application/xml",
|
||||
"Content-Type": "application/xml",
|
||||
"Content-Length": "0",
|
||||
"Authorization": auth,
|
||||
}
|
||||
|
||||
resp = self.session.post(
|
||||
f"{self.base_url}{uri}",
|
||||
headers=headers,
|
||||
verify=self.verify_ssl,
|
||||
timeout=(self.connect_timeout, self.read_timeout),
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
# 检查 XML 响应中是否有错误码
|
||||
if "<errorCode>" in resp.text:
|
||||
m = re.search(r"<errorCode>(\d+)</errorCode>", resp.text)
|
||||
if m:
|
||||
code = m.group(1)
|
||||
msg = REBOOT_ERROR_CODES.get(
|
||||
code, f"未知错误(代码: {code})"
|
||||
)
|
||||
return {"success": False, "message": f"重启失败: {msg}"}
|
||||
return {"success": True, "message": "设备正在重启,请稍后..."}
|
||||
|
||||
elif resp.status_code == 401:
|
||||
# nonce 过期,清空后重试
|
||||
self._clear_auth()
|
||||
continue
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"重启请求失败(HTTP {resp.status_code})",
|
||||
}
|
||||
|
||||
except TimeoutError:
|
||||
return {"success": False, "message": "iMC 接口超时,请稍后重试"}
|
||||
except Exception as e:
|
||||
logger.error(f"重启异常: {e}")
|
||||
if retry < max_retries:
|
||||
time.sleep(3)
|
||||
continue
|
||||
return {"success": False, "message": f"重启异常: {e}"}
|
||||
|
||||
return {"success": False, "message": "重启失败,已达最大重试次数"}
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 公共:获取光功率
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def get_optical_power(self, mac: str) -> dict | None:
|
||||
"""
|
||||
获取 ONU 设备光功率信息
|
||||
|
||||
接口: /imcrs/epon/onu/onuLightWaneInfo?mac={mac}
|
||||
响应 JSON 字段:powerIn(接收光功率), powerOut(发送光功率),
|
||||
bindMac, devId, eponDevName, oltIfName, onuIfDesc
|
||||
|
||||
Args:
|
||||
mac: MAC 地址,格式如 "1484-7790-4840"
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
"powerIn": "-18.5", # dBm,接收光功率
|
||||
"powerOut": "2.3", # dBm,发送光功率
|
||||
"bindMac": "...",
|
||||
"devId": ...,
|
||||
"eponDevName": "...",
|
||||
"oltIfName": "...",
|
||||
"onuIfDesc": "..."
|
||||
}
|
||||
或 None(失败时)
|
||||
"""
|
||||
max_retries = 1
|
||||
for retry in range(max_retries + 1):
|
||||
try:
|
||||
uri = f"/imcrs/epon/onu/onuLightWaneInfo?mac={mac}"
|
||||
auth = self._get_digest_auth_header("GET", uri)
|
||||
if not auth:
|
||||
logger.error("生成认证头失败,无法获取光功率")
|
||||
return None
|
||||
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": auth,
|
||||
}
|
||||
|
||||
logger.info(f"获取光功率: {self.base_url}{uri}")
|
||||
resp = self.session.get(
|
||||
f"{self.base_url}{uri}",
|
||||
headers=headers,
|
||||
verify=self.verify_ssl,
|
||||
timeout=(self.connect_timeout, self.read_timeout),
|
||||
)
|
||||
logger.info(f"光功率API响应状态码: {resp.status_code}")
|
||||
|
||||
if resp.status_code == 200:
|
||||
try:
|
||||
data = resp.json()
|
||||
logger.info(
|
||||
f"光功率响应: {json.dumps(data, ensure_ascii=False)}"
|
||||
)
|
||||
return {
|
||||
"powerIn": data.get("powerIn"),
|
||||
"powerOut": data.get("powerOut"),
|
||||
"bindMac": data.get("bindMac"),
|
||||
"devId": data.get("devId"),
|
||||
"eponDevName": data.get("eponDevName"),
|
||||
"oltIfName": data.get("oltIfName"),
|
||||
"onuIfDesc": data.get("onuIfDesc"),
|
||||
}
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"解析光功率 JSON 失败: {e}, 内容: {resp.text}")
|
||||
|
||||
elif resp.status_code == 401:
|
||||
self._clear_auth()
|
||||
continue
|
||||
else:
|
||||
logger.error(
|
||||
f"光功率API请求失败, 状态码: {resp.status_code}, "
|
||||
f"内容: {resp.text}"
|
||||
)
|
||||
break # 非401不重试
|
||||
|
||||
except TimeoutError:
|
||||
logger.error("获取光功率超时")
|
||||
raise
|
||||
except requests.Timeout:
|
||||
logger.error("光功率接口请求超时")
|
||||
raise TimeoutError("iMC 光功率接口请求超时,请稍后重试")
|
||||
except Exception as e:
|
||||
logger.error(f"获取光功率异常: {e}")
|
||||
if retry < max_retries:
|
||||
time.sleep(3)
|
||||
continue
|
||||
return None
|
||||
```
|
||||
|
||||
### 4.4 Schema — `backend/app/schemas/device.py`
|
||||
|
||||
添加重启和光功率的响应模型:
|
||||
|
||||
```python
|
||||
class RebootResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
class OpticalPowerResponse(BaseModel):
|
||||
power_in: Optional[str] = None # 接收光功率 (dBm)
|
||||
power_out: Optional[str] = None # 发送光功率 (dBm)
|
||||
bind_mac: Optional[str] = None
|
||||
dev_id: Optional[int] = None
|
||||
epon_dev_name: Optional[str] = None
|
||||
olt_if_name: Optional[str] = None
|
||||
onu_if_desc: Optional[str] = None
|
||||
```
|
||||
|
||||
### 4.5 API 路由 — `backend/app/api/v1/devices.py`
|
||||
|
||||
在文件顶部导入新 Schema:
|
||||
|
||||
```python
|
||||
from app.schemas.device import (
|
||||
DeviceListResponse,
|
||||
ONUDeviceResponse,
|
||||
RebootResponse,
|
||||
OpticalPowerResponse,
|
||||
)
|
||||
```
|
||||
|
||||
在文件末尾添加两个新端点:
|
||||
|
||||
```python
|
||||
# ═══════════════════════════════════════════════
|
||||
# 重启 ONU
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
@router.post("/{device_id}/reboot", response_model=RebootResponse)
|
||||
def reboot_device(
|
||||
device_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current: dict = Depends(require_permission('device.check')),
|
||||
):
|
||||
"""
|
||||
远程重启 ONU 设备(通过 iMC REST API)
|
||||
|
||||
权限要求:device.check
|
||||
区域/学校管理员只能操作自己范围内的设备。
|
||||
"""
|
||||
device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="设备不存在")
|
||||
|
||||
# 数据范围权限过滤
|
||||
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 device.region not in areas:
|
||||
raise HTTPException(status_code=403, detail="无权限操作此区域的设备")
|
||||
elif role == 'school_admin':
|
||||
assigned = current.get('assigned_school') or ''
|
||||
schools = [s.strip() for s in assigned.split(',') if s.strip()]
|
||||
if device.school_name not in schools:
|
||||
raise HTTPException(status_code=403, detail="无权限操作此学校的设备")
|
||||
|
||||
try:
|
||||
from app.services.imc_service import IMCService
|
||||
service = IMCService()
|
||||
mac = device.mac_address
|
||||
result = service.reboot_onu(mac)
|
||||
return RebootResponse(**result)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"重启失败: {str(e)}")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 获取光功率
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
@router.get("/{device_id}/optical-power", response_model=OpticalPowerResponse)
|
||||
def get_device_optical_power(
|
||||
device_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current: dict = Depends(require_permission('device.view')),
|
||||
):
|
||||
"""
|
||||
获取 ONU 设备光功率信息(通过 iMC REST API)
|
||||
|
||||
返回接收光功率(power_in)和发送光功率(power_out),单位 dBm。
|
||||
权限要求:device.view(只读操作)
|
||||
"""
|
||||
device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="设备不存在")
|
||||
|
||||
# 数据范围权限过滤(同上)
|
||||
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 device.region not in areas:
|
||||
raise HTTPException(status_code=403, detail="无权限操作此区域的设备")
|
||||
elif role == 'school_admin':
|
||||
assigned = current.get('assigned_school') or ''
|
||||
schools = [s.strip() for s in assigned.split(',') if s.strip()]
|
||||
if device.school_name not in schools:
|
||||
raise HTTPException(status_code=403, detail="无权限操作此学校的设备")
|
||||
|
||||
try:
|
||||
from app.services.imc_service import IMCService
|
||||
service = IMCService()
|
||||
mac = device.mac_address
|
||||
result = service.get_optical_power(mac)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=502, detail="获取光功率失败,iMC 接口无响应")
|
||||
|
||||
# 字段名转换:下划线转驼峰前先映射
|
||||
from app.schemas.device import OpticalPowerResponse
|
||||
return OpticalPowerResponse(
|
||||
power_in=result.get("powerIn"),
|
||||
power_out=result.get("powerOut"),
|
||||
bind_mac=result.get("bindMac"),
|
||||
dev_id=result.get("devId"),
|
||||
epon_dev_name=result.get("eponDevName"),
|
||||
olt_if_name=result.get("oltIfName"),
|
||||
onu_if_desc=result.get("onuIfDesc"),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"获取光功率失败: {str(e)}")
|
||||
```
|
||||
|
||||
### 4.6 注册 Service — `backend/app/services/__init__.py`
|
||||
|
||||
```python
|
||||
from .imc_service import IMCService
|
||||
|
||||
__all__ = ["IMCService"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键陷阱与注意事项
|
||||
|
||||
### ⚠️ Digest 认证的 nonce 过期问题
|
||||
|
||||
iMC 的 nonce 有有效期(通常 5-10 分钟)。过期后服务器返回 **401**。
|
||||
- 代码中 `_clear_auth()` 清空 nonce,下次请求自动重新握手
|
||||
- 重启和光功率方法都在 for 循环中捕获 401 并 `continue` 重试
|
||||
|
||||
### ⚠️ MAC 地址格式
|
||||
|
||||
iMC REST API 要求 MAC 地址格式为 **1484-7790-4840**(连字符分隔,大写十六进制)。
|
||||
如果数据库存储格式不同,需要做格式转换:
|
||||
|
||||
```python
|
||||
def normalize_mac(mac: str) -> str:
|
||||
"""标准化 MAC 为 iMC 要求的格式:1484-7790-4840"""
|
||||
clean = mac.replace(':', '').replace('-', '').replace('.', '').upper()
|
||||
return f"{clean[0:4]}-{clean[4:8]}-{clean[8:12]}"
|
||||
```
|
||||
|
||||
### ⚠️ 重启接口需要 Content-Length: 0
|
||||
|
||||
即使请求体为空,也必须显式设置 `Content-Length: 0` 头,否则 iMC 会报错。
|
||||
|
||||
### ⚠️ 光功率接口返回空数据的情况
|
||||
|
||||
当 ONU 离线或光模块故障时,iMC 返回的 `powerIn` / `powerOut` 可能是 `" --"`(两个空格+两个横线)或 `None`。前端需做占位符处理。
|
||||
|
||||
### ⚠️ 重启操作较慢
|
||||
|
||||
从发起请求到设备实际重启完成约需 **30-60 秒**(取决于 SNMP 响应)。建议:
|
||||
- 前端按钮显示 loading 状态
|
||||
- 后端设置合理超时(connect=5s, read=20s)
|
||||
- 不要在短时间内对同一设备重复操作
|
||||
|
||||
### ⚠️ 并发控制
|
||||
|
||||
建议对重启操作添加简单的并发控制,避免同一设备被多次重启:
|
||||
|
||||
```python
|
||||
import threading
|
||||
|
||||
_reboot_locks = {}
|
||||
_reboot_lock = threading.Lock()
|
||||
|
||||
def reboot_onu(self, mac):
|
||||
with _reboot_lock:
|
||||
if mac not in _reboot_locks:
|
||||
_reboot_locks[mac] = threading.Lock()
|
||||
lock = _reboot_locks[mac]
|
||||
|
||||
if not lock.acquire(blocking=False):
|
||||
return {"success": False, "message": "该设备正在重启中,请稍后"}
|
||||
try:
|
||||
# ... 重启逻辑 ...
|
||||
finally:
|
||||
lock.release()
|
||||
```
|
||||
|
||||
### ⚠️ Docker 部署注意
|
||||
|
||||
- 在 `docker-compose.yml` 的 `backend` 服务中新增环境变量:
|
||||
```yaml
|
||||
environment:
|
||||
- IMC_API_URL=https://172.16.1.252:8443
|
||||
- IMC_API_USERNAME=admin
|
||||
- IMC_API_PASSWORD=Pwd@12345
|
||||
- IMC_API_VERIFY_SSL=false
|
||||
```
|
||||
- `backend` 和 `celery-worker` 容器都需要这些变量
|
||||
- 修改后必须重新构建镜像:
|
||||
```bash
|
||||
docker compose build --no-cache backend
|
||||
docker compose rm -f backend && docker compose up -d backend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 测试验证
|
||||
|
||||
### 手动测试
|
||||
|
||||
```bash
|
||||
# 1. 重启设备
|
||||
curl -X POST "http://localhost:8000/api/devices/1/reboot" \
|
||||
-H "Authorization: Bearer <token>"
|
||||
|
||||
# 2. 获取光功率
|
||||
curl "http://localhost:8000/api/devices/1/optical-power" \
|
||||
-H "Authorization: Bearer <token>"
|
||||
|
||||
# 3. 查看后端日志
|
||||
docker compose logs backend | grep IMCService
|
||||
|
||||
# 4. 直接测试 iMC API(验证认证是否工作)
|
||||
curl -k -v "https://172.16.1.252:8443/imcrs/epon/onu/onuLightWaneInfo?mac=1484-7790-4840"
|
||||
```
|
||||
|
||||
### 测试响应示例
|
||||
|
||||
**重启成功:**
|
||||
```json
|
||||
{"success": true, "message": "设备正在重启,请稍后..."}
|
||||
```
|
||||
|
||||
**重启失败(ONU不存在):**
|
||||
```json
|
||||
{"success": false, "message": "重启失败: ONU不存在"}
|
||||
```
|
||||
|
||||
**光功率获取成功:**
|
||||
```json
|
||||
{
|
||||
"power_in": "-18.5",
|
||||
"power_out": "2.3",
|
||||
"bind_mac": "1484-7790-4840",
|
||||
"dev_id": 123,
|
||||
"epon_dev_name": "OLT-1-1",
|
||||
"olt_if_name": "1/0/2",
|
||||
"onu_if_desc": "ONU-学校A"
|
||||
}
|
||||
```
|
||||
|
||||
**光功率获取失败(设备离线):**
|
||||
```json
|
||||
{
|
||||
"power_in": null,
|
||||
"power_out": null,
|
||||
"bind_mac": null,
|
||||
"dev_id": null,
|
||||
"epon_dev_name": null,
|
||||
"olt_if_name": null,
|
||||
"onu_if_desc": null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 完整调用时序图
|
||||
|
||||
```
|
||||
客户端 后端 FastAPI iMC 平台
|
||||
│ │ │
|
||||
│ POST /api/devices/1/reboot │ │
|
||||
│ ────────────────────────────► │ │
|
||||
│ │ ── 查数据库:设备存在?──► │
|
||||
│ │ ◄── 返回设备信息 ────────── │
|
||||
│ │ ── 权限检查 ───────────── │
|
||||
│ │ │
|
||||
│ │ ── GET /imcrs/epon/onu/reboot │
|
||||
│ │ (无认证,获取 nonce) │
|
||||
│ │ ────────────────────────────► │
|
||||
│ │ ◄── 401 + WWW-Authenticate ──│
|
||||
│ │ nonce=xxx, realm=... │
|
||||
│ │ │
|
||||
│ │ ── POST 同 URI + Digest ────► │
|
||||
│ │ Authorization: Digest ... │
|
||||
│ │ ◄── 200 OK (XML) ────────────│
|
||||
│ │ │
|
||||
│ ◄── {success: true, │ │
|
||||
│ message: "设备重启中"} │ │
|
||||
│ │ │
|
||||
│ ── 或 ── │ │
|
||||
│ │ │
|
||||
│ GET /api/devices/1/optical-power │
|
||||
│ ────────────────────────────► │ │
|
||||
│ │ ── 查 + 权限 (同上) ──── │
|
||||
│ │ │
|
||||
│ │ ── GET /imcrs/epon/onu/ │
|
||||
│ │ onuLightWaneInfo?mac=... │
|
||||
│ │ (+ Digest Auth) │
|
||||
│ │ ────────────────────────────► │
|
||||
│ │ ◄── 200 OK (JSON) ───────────│
|
||||
│ │ {powerIn, powerOut, ...} │
|
||||
│ │ │
|
||||
│ ◄── {power_in: "-18.5", │ │
|
||||
│ power_out: "2.3", ...} │ │
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 附录:源项目参考文件位置
|
||||
|
||||
| 内容 | 路径 |
|
||||
|------|------|
|
||||
| IMCService 完整实现 | `/home/v6ole/pyproject/H3ConuMS/app/services/imc_service.py` |
|
||||
| 重启控制器 | `/home/v6ole/pyproject/H3ConuMS/app/controllers/device.py` (第1059行) |
|
||||
| 优化版控制器 | `/home/v6ole/pyproject/H3ConuMS/app/controllers/optimized_device.py` (第157行) |
|
||||
| iMC 配置项 | `/home/v6ole/pyproject/H3ConuMS/app/config.py` (第61-67行) |
|
||||
Reference in New Issue
Block a user