feat(alerts): 添加微信告警、WebSocket实时推送和地图功能
- 新增微信告警服务(wechat_service)和告警任务(alert_tasks) - 新增 WebSocket 实时推送端点 - 新增监控管理模块(monitor) - 增强统计仪表板:趋势图、区域分布、光功率历史 - 设备管理:添加坐标信息、标签系统、复合索引优化 - 前端:重构Dashboard/Charts页面,新增业务组件 - 新增5个数据库迁移(坐标、复合索引、标签、光功率历史、显示名) - 更新部署配置和脚本 - 新增测试框架基础结构
This commit is contained in:
@@ -24,3 +24,12 @@ SSH_TIMEOUT=30
|
|||||||
# 任务配置
|
# 任务配置
|
||||||
CHECK_INTERVAL=1800
|
CHECK_INTERVAL=1800
|
||||||
MANUAL_COOLDOWN=300
|
MANUAL_COOLDOWN=300
|
||||||
|
|
||||||
|
# 企业微信告警配置
|
||||||
|
WECHAT_CORPID=
|
||||||
|
WECHAT_CORPSECRET=
|
||||||
|
WECHAT_AGENTID=
|
||||||
|
WECHAT_TOKEN=
|
||||||
|
WECHAT_ENCODING_AES_KEY=
|
||||||
|
WECHAT_USE_PROXY=True
|
||||||
|
WECHAT_PROXY_API_URL=https://api.v6ole.top
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""add latitude and longitude columns to onu_devices
|
||||||
|
|
||||||
|
Revision ID: i9j0k1l2m3n4
|
||||||
|
Revises: h8i9j0k1l2m3
|
||||||
|
Create Date: 2026-06-02 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = 'i9j0k1l2m3n4'
|
||||||
|
down_revision: Union[str, None] = 'h8i9j0k1l2m3'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('onu_devices', sa.Column('latitude', sa.Float(), nullable=True))
|
||||||
|
op.add_column('onu_devices', sa.Column('longitude', sa.Float(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('onu_devices', 'longitude')
|
||||||
|
op.drop_column('onu_devices', 'latitude')
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""add composite indexes for performance
|
||||||
|
|
||||||
|
Revision ID: j0k1l2m3n4o5
|
||||||
|
Revises: i9j0k1l2m3n4
|
||||||
|
Create Date: 2026-06-02 15:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = 'j0k1l2m3n4o5'
|
||||||
|
down_revision: Union[str, None] = 'i9j0k1l2m3n4'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# FK 索引 — 每次状态检查都要按 OLT 查询设备
|
||||||
|
op.create_index('ix_onu_devices_olt_id', 'onu_devices', ['olt_id'])
|
||||||
|
# 复合索引 — Dashboard 按区域+学校聚合
|
||||||
|
op.create_index('ix_onu_devices_region_school', 'onu_devices', ['region', 'school_name'])
|
||||||
|
# FK 索引 — DeviceStatusHistory 按设备查最新状态(最频繁的查询)
|
||||||
|
op.create_index('ix_device_status_history_onu_device_id_checked', 'device_status_history', ['onu_device_id', 'checked_at'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_device_status_history_onu_device_id_checked', table_name='device_status_history')
|
||||||
|
op.drop_index('ix_onu_devices_region_school', table_name='onu_devices')
|
||||||
|
op.drop_index('ix_onu_devices_olt_id', table_name='onu_devices')
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""add tags column to onu_devices
|
||||||
|
|
||||||
|
Revision ID: k0l1m2n3o4p5
|
||||||
|
Revises: j0k1l2m3n4o5
|
||||||
|
Create Date: 2026-06-03 10:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = 'k0l1m2n3o4p5'
|
||||||
|
down_revision: Union[str, None] = 'j0k1l2m3n4o5'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('onu_devices', sa.Column('tags', sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('onu_devices', 'tags')
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""add optical_power_history table
|
||||||
|
|
||||||
|
Revision ID: l1m2n3o4p5q6
|
||||||
|
Revises: k0l1m2n3o4p5
|
||||||
|
Create Date: 2026-06-03 11:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = 'l1m2n3o4p5q6'
|
||||||
|
down_revision: Union[str, None] = 'k0l1m2n3o4p5'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table('optical_power_history',
|
||||||
|
sa.Column('id', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('onu_device_id', sa.BigInteger(), sa.ForeignKey('onu_devices.id'), nullable=False),
|
||||||
|
sa.Column('power_in', sa.String(20), nullable=True),
|
||||||
|
sa.Column('power_out', sa.String(20), nullable=True),
|
||||||
|
sa.Column('recorded_at', sa.TIMESTAMP(), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('ix_optical_power_history_id', 'optical_power_history', ['id'])
|
||||||
|
op.create_index('ix_optical_power_history_onu_device_id', 'optical_power_history', ['onu_device_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_optical_power_history_onu_device_id', table_name='optical_power_history')
|
||||||
|
op.drop_index('ix_optical_power_history_id', table_name='optical_power_history')
|
||||||
|
op.drop_table('optical_power_history')
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""add display_name to users
|
||||||
|
|
||||||
|
Revision ID: m1n2o3p4q5r6
|
||||||
|
Revises: l1m2n3o4p5q6
|
||||||
|
Create Date: 2026-06-03 20:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = 'm1n2o3p4q5r6'
|
||||||
|
down_revision: Union[str, None] = 'l1m2n3o4p5q6'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('users', sa.Column('display_name', sa.String(100), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('users', 'display_name')
|
||||||
@@ -53,11 +53,16 @@ def callback(body: CallbackRequest, db: Session = Depends(get_db)):
|
|||||||
if not user:
|
if not user:
|
||||||
user = User(
|
user = User(
|
||||||
casdoor_id=casdoor_user["sub"],
|
casdoor_id=casdoor_user["sub"],
|
||||||
username=casdoor_user.get("name") or casdoor_user.get("preferred_username", ""),
|
username=casdoor_user.get("preferred_username") or casdoor_user.get("name", ""),
|
||||||
|
display_name=casdoor_user.get("displayName") or casdoor_user.get("name", ""),
|
||||||
email=casdoor_user.get("email"),
|
email=casdoor_user.get("email"),
|
||||||
role="user"
|
role="user"
|
||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
|
else:
|
||||||
|
# 每次登录同步 Casdoor 信息(姓名、邮箱等可能更新)
|
||||||
|
user.display_name = casdoor_user.get("displayName") or casdoor_user.get("name", user.display_name or "")
|
||||||
|
user.email = casdoor_user.get("email", user.email)
|
||||||
|
|
||||||
user.last_login = datetime.utcnow()
|
user.last_login = datetime.utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
"""状态检查 API"""
|
"""状态检查 API"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from fastapi import APIRouter, HTTPException, Depends
|
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||||
from celery.result import AsyncResult
|
from celery.result import AsyncResult
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from slowapi import Limiter
|
||||||
|
from slowapi.util import get_remote_address
|
||||||
from app.tasks.check_tasks import check_all_devices
|
from app.tasks.check_tasks import check_all_devices
|
||||||
from app.core.celery_app import celery_app
|
from app.core.celery_app import celery_app
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
@@ -14,6 +16,7 @@ from app.middleware.permission_middleware import require_permission
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/check", tags=["状态检查"])
|
router = APIRouter(prefix="/api/check", tags=["状态检查"])
|
||||||
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
|
|
||||||
|
|
||||||
class CheckResult(BaseModel):
|
class CheckResult(BaseModel):
|
||||||
@@ -31,7 +34,8 @@ class CheckError(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/status")
|
@router.post("/status")
|
||||||
def trigger_check(_: dict = Depends(require_permission('device.check'))):
|
@limiter.limit("3/minute")
|
||||||
|
def trigger_check(request: Request, _: dict = Depends(require_permission('device.check'))):
|
||||||
"""手动触发状态检查"""
|
"""手动触发状态检查"""
|
||||||
try:
|
try:
|
||||||
task = check_all_devices.delay()
|
task = check_all_devices.delay()
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
"""设备管理 API"""
|
"""设备管理 API"""
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session, joinedload
|
||||||
from sqlalchemy import asc, desc, distinct, or_
|
from sqlalchemy import asc, desc, distinct, or_
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -20,6 +23,7 @@ def get_devices(
|
|||||||
school_name: str = None,
|
school_name: str = None,
|
||||||
keyword: str = None,
|
keyword: str = None,
|
||||||
status: str = None,
|
status: str = None,
|
||||||
|
tag: str = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current: dict = Depends(require_permission('device.view')),
|
current: dict = Depends(require_permission('device.view')),
|
||||||
):
|
):
|
||||||
@@ -68,6 +72,8 @@ def get_devices(
|
|||||||
query = query.filter(ONUDevice.region == region)
|
query = query.filter(ONUDevice.region == region)
|
||||||
if school_name:
|
if school_name:
|
||||||
query = query.filter(ONUDevice.school_name.contains(school_name))
|
query = query.filter(ONUDevice.school_name.contains(school_name))
|
||||||
|
if tag:
|
||||||
|
query = query.filter(ONUDevice.tags.contains(tag))
|
||||||
if keyword:
|
if keyword:
|
||||||
query = query.filter(
|
query = query.filter(
|
||||||
or_(
|
or_(
|
||||||
@@ -395,6 +401,7 @@ class DeviceUpdate(BaseModel):
|
|||||||
room_number: Optional[str] = None
|
room_number: Optional[str] = None
|
||||||
place_type: Optional[str] = None
|
place_type: Optional[str] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
tags: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class DeviceReplaceRequest(BaseModel):
|
class DeviceReplaceRequest(BaseModel):
|
||||||
@@ -430,6 +437,7 @@ def update_device(
|
|||||||
device.room_number = body.room_number or None
|
device.room_number = body.room_number or None
|
||||||
device.place_type = body.place_type or None
|
device.place_type = body.place_type or None
|
||||||
device.notes = body.notes or None
|
device.notes = body.notes or None
|
||||||
|
device.tags = body.tags or None
|
||||||
|
|
||||||
# 若该设备 MAC 在 new_devices 待入库列表中,自动移除(已在设备列表中补全信息)
|
# 若该设备 MAC 在 new_devices 待入库列表中,自动移除(已在设备列表中补全信息)
|
||||||
from app.models.device import NewDevice
|
from app.models.device import NewDevice
|
||||||
@@ -613,6 +621,18 @@ def get_device_optical_power(
|
|||||||
data = IMCService().get_optical_power(device.mac_address)
|
data = IMCService().get_optical_power(device.mac_address)
|
||||||
if data is None:
|
if data is None:
|
||||||
raise HTTPException(status_code=502, detail="获取光功率失败,iMC 接口无响应")
|
raise HTTPException(status_code=502, detail="获取光功率失败,iMC 接口无响应")
|
||||||
|
# 记录光功率历史
|
||||||
|
try:
|
||||||
|
from app.models.device import OpticalPowerHistory
|
||||||
|
db.add(OpticalPowerHistory(
|
||||||
|
onu_device_id=device_id,
|
||||||
|
power_in=data.get("powerIn"),
|
||||||
|
power_out=data.get("powerOut"),
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return OpticalPowerResponse(
|
return OpticalPowerResponse(
|
||||||
power_in=data.get("powerIn"),
|
power_in=data.get("powerIn"),
|
||||||
power_out=data.get("powerOut"),
|
power_out=data.get("powerOut"),
|
||||||
@@ -661,3 +681,75 @@ def get_onu_events(
|
|||||||
raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}")
|
||||||
finally:
|
finally:
|
||||||
ssh.close()
|
ssh.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{device_id}/optical-power-history")
|
||||||
|
def get_optical_power_history(
|
||||||
|
device_id: int,
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('device.view')),
|
||||||
|
):
|
||||||
|
"""获取设备光功率历史记录"""
|
||||||
|
from app.models.device import OpticalPowerHistory
|
||||||
|
rows = (
|
||||||
|
db.query(OpticalPowerHistory)
|
||||||
|
.filter(OpticalPowerHistory.onu_device_id == device_id)
|
||||||
|
.order_by(OpticalPowerHistory.recorded_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{"power_in": r.power_in, "power_out": r.power_out,
|
||||||
|
"recorded_at": r.recorded_at.isoformat() if r.recorded_at else None}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tags")
|
||||||
|
def get_all_tags(db: Session = Depends(get_db)):
|
||||||
|
"""获取所有不重复的设备标签"""
|
||||||
|
from sqlalchemy import func as _func
|
||||||
|
rows = db.query(ONUDevice.tags).filter(
|
||||||
|
ONUDevice.tags.isnot(None), ONUDevice.tags != ''
|
||||||
|
).all()
|
||||||
|
tags = set()
|
||||||
|
for (tag_str,) in rows:
|
||||||
|
for t in tag_str.split(','):
|
||||||
|
t = t.strip()
|
||||||
|
if t:
|
||||||
|
tags.add(t)
|
||||||
|
return sorted(tags)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export/csv")
|
||||||
|
def export_devices_csv(
|
||||||
|
region: Optional[str] = Query(None),
|
||||||
|
school_name: Optional[str] = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('device.view')),
|
||||||
|
):
|
||||||
|
"""导出设备列表为 CSV"""
|
||||||
|
from sqlalchemy import func as _func
|
||||||
|
|
||||||
|
query = db.query(ONUDevice)
|
||||||
|
if region:
|
||||||
|
query = query.filter(ONUDevice.region == region)
|
||||||
|
if school_name:
|
||||||
|
query = query.filter(ONUDevice.school_name == school_name)
|
||||||
|
devices = query.order_by(ONUDevice.region, ONUDevice.school_name).all()
|
||||||
|
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow(["MAC地址", "区域", "学校", "楼宇", "场所类型", "房间号", "端口", "型号", "LOID", "距离(m)", "备注"])
|
||||||
|
for d in devices:
|
||||||
|
writer.writerow([d.mac_address, d.region or "", d.school_name or "", d.building or "",
|
||||||
|
d.place_type or "", d.room_number or "", d.port_id or "", d.model or "",
|
||||||
|
d.loid or "", d.distance_m or "", d.notes or ""])
|
||||||
|
|
||||||
|
output.seek(0)
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([output.getvalue()]),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=onu_devices.csv"}
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Celery 任务监控 API"""
|
||||||
|
import time
|
||||||
|
import redis as redis_lib
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from app.core.celery_app import celery_app
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.middleware.permission_middleware import require_permission
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/monitor", tags=["任务监控"])
|
||||||
|
|
||||||
|
|
||||||
|
def _get_redis():
|
||||||
|
return redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks")
|
||||||
|
def get_task_status(_: dict = Depends(require_permission('*'))):
|
||||||
|
"""获取 Celery 任务状态概览"""
|
||||||
|
try:
|
||||||
|
insp = celery_app.control.inspect()
|
||||||
|
active = insp.active() or {}
|
||||||
|
scheduled = insp.scheduled() or {}
|
||||||
|
reserved = insp.reserved() or {}
|
||||||
|
|
||||||
|
r = _get_redis()
|
||||||
|
last_run = r.get("check_all_devices:last_run")
|
||||||
|
is_running = bool(r.get("check_all_devices:running"))
|
||||||
|
interval_str = r.get("system:check_interval_seconds")
|
||||||
|
|
||||||
|
interval = int(interval_str) if interval_str else 1800
|
||||||
|
next_run = None
|
||||||
|
if last_run and not is_running:
|
||||||
|
next_run = float(last_run) + interval
|
||||||
|
|
||||||
|
return {
|
||||||
|
"workers": list(active.keys()),
|
||||||
|
"active_count": sum(len(v) for v in active.values()),
|
||||||
|
"scheduled_count": sum(len(v) for v in scheduled.values()),
|
||||||
|
"check_running": is_running,
|
||||||
|
"last_check": float(last_run) if last_run else None,
|
||||||
|
"next_check": next_run,
|
||||||
|
"check_interval_seconds": interval,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
@@ -98,3 +98,29 @@ def update_about(
|
|||||||
db.add(SystemSetting(key='about_content', value=content, description='关于页面内容(Markdown)'))
|
db.add(SystemSetting(key='about_content', value=content, description='关于页面内容(Markdown)'))
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"key": "about_content", "value": content}
|
return {"key": "about_content", "value": content}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/webhook")
|
||||||
|
def update_webhook(
|
||||||
|
body: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('*')),
|
||||||
|
):
|
||||||
|
"""更新企业微信告警配置(仅管理员)"""
|
||||||
|
configs = [
|
||||||
|
("wechat_corpid", body.get("corpid", ""), "企业微信 CorpID"),
|
||||||
|
("wechat_corpsecret", body.get("corpsecret", ""), "企业微信 CorpSecret"),
|
||||||
|
("wechat_agentid", body.get("agentid", ""), "企业微信 AgentID"),
|
||||||
|
]
|
||||||
|
for key, value, desc in configs:
|
||||||
|
setting = db.query(SystemSetting).filter_by(key=key).first()
|
||||||
|
if setting:
|
||||||
|
setting.value = value
|
||||||
|
else:
|
||||||
|
db.add(SystemSetting(key=key, value=value, description=desc))
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"message": "企业微信配置已保存"}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+177
-7
@@ -179,16 +179,14 @@ def get_trend(
|
|||||||
)
|
)
|
||||||
snapshot_map = {s.snapshot_date: s for s in snapshots}
|
snapshot_map = {s.snapshot_date: s for s in snapshots}
|
||||||
|
|
||||||
# 今天实时聚合
|
# 今天实时聚合 — 取每个设备最新状态(不限日期),反映真实当前状况
|
||||||
today_str = today.strftime('%Y-%m-%d')
|
today_str = today.strftime('%Y-%m-%d')
|
||||||
start_of_today = datetime.combine(today, datetime.min.time())
|
|
||||||
|
|
||||||
daily_latest_subq = (
|
latest_subq = (
|
||||||
db.query(
|
db.query(
|
||||||
DeviceStatusHistory.onu_device_id,
|
DeviceStatusHistory.onu_device_id,
|
||||||
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"),
|
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"),
|
||||||
)
|
)
|
||||||
.filter(DeviceStatusHistory.checked_at >= start_of_today)
|
|
||||||
.group_by(DeviceStatusHistory.onu_device_id)
|
.group_by(DeviceStatusHistory.onu_device_id)
|
||||||
.subquery()
|
.subquery()
|
||||||
)
|
)
|
||||||
@@ -198,9 +196,9 @@ def get_trend(
|
|||||||
func.sum(case((DeviceStatusHistory.status == 'offline', 1), else_=0)).label("offline"),
|
func.sum(case((DeviceStatusHistory.status == 'offline', 1), else_=0)).label("offline"),
|
||||||
)
|
)
|
||||||
.join(
|
.join(
|
||||||
daily_latest_subq,
|
latest_subq,
|
||||||
(DeviceStatusHistory.onu_device_id == daily_latest_subq.c.onu_device_id) &
|
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
|
||||||
(DeviceStatusHistory.checked_at == daily_latest_subq.c.max_checked_at)
|
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)
|
||||||
)
|
)
|
||||||
.one()
|
.one()
|
||||||
)
|
)
|
||||||
@@ -246,3 +244,175 @@ def get_trend(
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@router.get("/olt-stats")
|
||||||
|
def get_olt_stats(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('device.view')),
|
||||||
|
):
|
||||||
|
"""获取每台 OLT 下的设备在线率统计"""
|
||||||
|
from app.models.device import OLTDevice
|
||||||
|
|
||||||
|
latest_subq = (
|
||||||
|
db.query(
|
||||||
|
DeviceStatusHistory.onu_device_id,
|
||||||
|
func.max(DeviceStatusHistory.checked_at).label("max_checked_at")
|
||||||
|
)
|
||||||
|
.group_by(DeviceStatusHistory.onu_device_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
latest_status_subq = (
|
||||||
|
db.query(DeviceStatusHistory.onu_device_id, DeviceStatusHistory.status)
|
||||||
|
.join(latest_subq,
|
||||||
|
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
|
||||||
|
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at))
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
db.query(
|
||||||
|
OLTDevice.id,
|
||||||
|
OLTDevice.ip_address,
|
||||||
|
OLTDevice.location,
|
||||||
|
OLTDevice.region,
|
||||||
|
func.count(ONUDevice.id).label("total"),
|
||||||
|
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)).label("online"),
|
||||||
|
func.sum(case((latest_status_subq.c.status == 'offline', 1), else_=0)).label("offline"),
|
||||||
|
)
|
||||||
|
.outerjoin(ONUDevice, ONUDevice.olt_id == OLTDevice.id)
|
||||||
|
.outerjoin(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id)
|
||||||
|
.group_by(OLTDevice.id)
|
||||||
|
.order_by(OLTDevice.region, OLTDevice.location)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"olt_id": r.id,
|
||||||
|
"name": r.location or r.ip_address,
|
||||||
|
"ip": r.ip_address,
|
||||||
|
"region": r.region or "未知",
|
||||||
|
"total": int(r.total or 0),
|
||||||
|
"online": int(r.online or 0),
|
||||||
|
"offline": int(r.offline or 0),
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/offline-schools")
|
||||||
|
def get_offline_schools(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('device.view')),
|
||||||
|
):
|
||||||
|
"""获取全部离线的学校列表"""
|
||||||
|
_subq = (
|
||||||
|
db.query(DeviceStatusHistory.onu_device_id,
|
||||||
|
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"))
|
||||||
|
.group_by(DeviceStatusHistory.onu_device_id).subquery()
|
||||||
|
)
|
||||||
|
_status_subq = (
|
||||||
|
db.query(DeviceStatusHistory.onu_device_id, DeviceStatusHistory.status)
|
||||||
|
.join(_subq,
|
||||||
|
(DeviceStatusHistory.onu_device_id == _subq.c.onu_device_id) &
|
||||||
|
(DeviceStatusHistory.checked_at == _subq.c.max_checked_at)).subquery()
|
||||||
|
)
|
||||||
|
rows = (
|
||||||
|
db.query(
|
||||||
|
ONUDevice.school_name, ONUDevice.region,
|
||||||
|
func.count().label("total"),
|
||||||
|
func.sum(case((_status_subq.c.status == 'online', 1), else_=0)).label("online"),
|
||||||
|
)
|
||||||
|
.outerjoin(_status_subq, ONUDevice.id == _status_subq.c.onu_device_id)
|
||||||
|
.group_by(ONUDevice.school_name, ONUDevice.region)
|
||||||
|
.having(func.sum(case((_status_subq.c.status == 'online', 1), else_=0)) == 0)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{"school_name": r.school_name or "未知", "region": r.region or "未知", "total": int(r.total or 0)}
|
||||||
|
for r in rows if int(r.total or 0) > 0
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/model-distribution")
|
||||||
|
def get_model_distribution(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('device.view')),
|
||||||
|
):
|
||||||
|
"""统计 ONU 设备型号分布"""
|
||||||
|
rows = (
|
||||||
|
db.query(
|
||||||
|
ONUDevice.model,
|
||||||
|
func.count(ONUDevice.id).label("count"),
|
||||||
|
)
|
||||||
|
.filter(ONUDevice.model.isnot(None), ONUDevice.model != '')
|
||||||
|
.group_by(ONUDevice.model)
|
||||||
|
.order_by(func.count(ONUDevice.id).desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
unknown = db.query(func.count(ONUDevice.id)).filter(
|
||||||
|
(ONUDevice.model.is_(None)) | (ONUDevice.model == '')
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
result = [{"model": r.model or "未知", "count": r.count} for r in rows]
|
||||||
|
if unknown > 0:
|
||||||
|
result.append({"model": "未知型号", "count": unknown})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/school-locations")
|
||||||
|
def get_school_locations(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('device.view')),
|
||||||
|
):
|
||||||
|
"""获取各学校的聚合位置数据(用于地图展示)"""
|
||||||
|
latest_subq = (
|
||||||
|
db.query(
|
||||||
|
DeviceStatusHistory.onu_device_id,
|
||||||
|
func.max(DeviceStatusHistory.checked_at).label("max_checked_at")
|
||||||
|
)
|
||||||
|
.group_by(DeviceStatusHistory.onu_device_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
latest_status_subq = (
|
||||||
|
db.query(
|
||||||
|
DeviceStatusHistory.onu_device_id,
|
||||||
|
DeviceStatusHistory.status
|
||||||
|
)
|
||||||
|
.join(
|
||||||
|
latest_subq,
|
||||||
|
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
|
||||||
|
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)
|
||||||
|
)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
db.query(
|
||||||
|
ONUDevice.school_name,
|
||||||
|
ONUDevice.region,
|
||||||
|
ONUDevice.latitude,
|
||||||
|
ONUDevice.longitude,
|
||||||
|
func.count().label("total"),
|
||||||
|
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)).label("online"),
|
||||||
|
func.sum(case((latest_status_subq.c.status == 'offline', 1), else_=0)).label("offline"),
|
||||||
|
)
|
||||||
|
.outerjoin(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id)
|
||||||
|
.filter(ONUDevice.latitude.isnot(None))
|
||||||
|
.filter(ONUDevice.longitude.isnot(None))
|
||||||
|
.group_by(ONUDevice.school_name, ONUDevice.region, ONUDevice.latitude, ONUDevice.longitude)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"school_name": row.school_name or "未知",
|
||||||
|
"region": row.region or "未知",
|
||||||
|
"latitude": row.latitude,
|
||||||
|
"longitude": row.longitude,
|
||||||
|
"total": int(row.total or 0),
|
||||||
|
"online": int(row.online or 0),
|
||||||
|
"offline": int(row.offline or 0),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ def get_users(
|
|||||||
query = query.filter(User.role == role)
|
query = query.filter(User.role == role)
|
||||||
if keyword:
|
if keyword:
|
||||||
query = query.filter(
|
query = query.filter(
|
||||||
User.username.contains(keyword) | User.email.contains(keyword)
|
User.username.contains(keyword) | User.display_name.contains(keyword) | User.email.contains(keyword)
|
||||||
)
|
)
|
||||||
query = query.order_by(asc(User.created_at))
|
query = query.order_by(asc(User.created_at))
|
||||||
total = query.count()
|
total = query.count()
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
"""企业微信回调 API(URL验证 + 消息接收)"""
|
||||||
|
import logging
|
||||||
|
from fastapi import APIRouter, Request, Response
|
||||||
|
from app.services.wechat_service import get_wechat_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/wechat", tags=["企业微信回调"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/menu/create")
|
||||||
|
async def create_menu():
|
||||||
|
"""创建/更新企业微信应用菜单"""
|
||||||
|
svc = get_wechat_service()
|
||||||
|
menu = {
|
||||||
|
"button": [
|
||||||
|
{
|
||||||
|
"name": "设备查询",
|
||||||
|
"sub_button": [
|
||||||
|
{"type": "click", "name": "在线统计", "key": "online"},
|
||||||
|
{"type": "click", "name": "全离线学校", "key": "offline_schools"},
|
||||||
|
{"type": "click", "name": "MAC查询", "key": "status"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{"type": "click", "name": "帮助", "key": "help"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
ok = svc.create_menu(menu)
|
||||||
|
return {"success": ok}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/callback")
|
||||||
|
async def wechat_callback_get(request: Request):
|
||||||
|
"""企业微信 URL 验证(GET)"""
|
||||||
|
params = request.query_params
|
||||||
|
msg_signature = params.get("msg_signature", "")
|
||||||
|
timestamp = params.get("timestamp", "")
|
||||||
|
nonce = params.get("nonce", "")
|
||||||
|
echostr = params.get("echostr", "")
|
||||||
|
|
||||||
|
svc = get_wechat_service()
|
||||||
|
result = svc.verify_url(msg_signature, timestamp, nonce, echostr)
|
||||||
|
if result:
|
||||||
|
return Response(content=result, media_type="text/plain")
|
||||||
|
return Response(content="验证失败", status_code=403)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/callback")
|
||||||
|
async def wechat_callback_post(request: Request):
|
||||||
|
"""企业微信消息接收(POST)"""
|
||||||
|
params = request.query_params
|
||||||
|
msg_signature = params.get("msg_signature", "")
|
||||||
|
timestamp = params.get("timestamp", "")
|
||||||
|
nonce = params.get("nonce", "")
|
||||||
|
|
||||||
|
xml_data = await request.body()
|
||||||
|
if not xml_data:
|
||||||
|
return Response(content="", media_type="text/plain")
|
||||||
|
|
||||||
|
svc = get_wechat_service()
|
||||||
|
msg = svc.parse_message(xml_data)
|
||||||
|
if not msg:
|
||||||
|
return Response(content="", media_type="text/plain")
|
||||||
|
|
||||||
|
msg_type = msg.get("MsgType", "")
|
||||||
|
from_user = msg.get("FromUserName", "")
|
||||||
|
|
||||||
|
logger.info(f"收到企微消息: type={msg_type}, from={from_user}, content={msg.get('Content', '')}")
|
||||||
|
|
||||||
|
if msg_type == "text":
|
||||||
|
content = msg.get("Content", "").strip()
|
||||||
|
if content.lower() in ("online", "在线", "在线统计"):
|
||||||
|
_handle_online_cmd(svc, from_user)
|
||||||
|
elif content.lower() in ("全离线", "离线学校", "offline"):
|
||||||
|
_handle_offline_schools_cmd(svc, from_user)
|
||||||
|
elif content.startswith("#状态+") or content.startswith("#status+"):
|
||||||
|
_handle_status_cmd(svc, from_user, content)
|
||||||
|
elif content.lower() in ("help", "帮助", "#帮助", "#help"):
|
||||||
|
_handle_help_cmd(svc, from_user)
|
||||||
|
else:
|
||||||
|
# 尝试作为 MAC 后缀查询
|
||||||
|
_handle_status_cmd(svc, from_user, f"#状态+{content}")
|
||||||
|
|
||||||
|
elif msg_type == "event":
|
||||||
|
event = msg.get("Event", "")
|
||||||
|
event_key = msg.get("EventKey", "")
|
||||||
|
if event == "click":
|
||||||
|
if event_key == "online":
|
||||||
|
_handle_online_cmd(svc, from_user)
|
||||||
|
elif event_key == "offline_schools":
|
||||||
|
_handle_offline_schools_cmd(svc, from_user)
|
||||||
|
elif event_key == "help":
|
||||||
|
_handle_help_cmd(svc, from_user)
|
||||||
|
|
||||||
|
return Response(content="", media_type="text/plain")
|
||||||
|
|
||||||
|
|
||||||
|
# ── 命令处理 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _handle_online_cmd(svc, from_user: str):
|
||||||
|
"""处理在线统计命令"""
|
||||||
|
try:
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
from app.models.device import ONUDevice, DeviceStatusHistory
|
||||||
|
from sqlalchemy import func, case
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
latest_subq = (
|
||||||
|
db.query(DeviceStatusHistory.onu_device_id,
|
||||||
|
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"))
|
||||||
|
.group_by(DeviceStatusHistory.onu_device_id).subquery()
|
||||||
|
)
|
||||||
|
latest_status_subq = (
|
||||||
|
db.query(DeviceStatusHistory.onu_device_id, DeviceStatusHistory.status)
|
||||||
|
.join(latest_subq, (DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
|
||||||
|
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)).subquery()
|
||||||
|
)
|
||||||
|
total = db.query(func.count(ONUDevice.id)).scalar() or 0
|
||||||
|
online = (
|
||||||
|
db.query(func.count(ONUDevice.id))
|
||||||
|
.join(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id, isouter=True)
|
||||||
|
.filter(latest_status_subq.c.status == 'online').scalar() or 0
|
||||||
|
)
|
||||||
|
rate = (online / total * 100) if total > 0 else 0
|
||||||
|
svc.send_text_message(
|
||||||
|
f"📊 设备在线统计\n总设备数: {total}\n在线: {online}\n离线: {total - online}\n在线率: {rate:.1f}%",
|
||||||
|
to_user=from_user
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"在线统计失败: {e}")
|
||||||
|
svc.send_text_message("查询失败,请稍后重试", to_user=from_user)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_status_cmd(svc, from_user: str, content: str):
|
||||||
|
"""处理设备状态查询命令"""
|
||||||
|
try:
|
||||||
|
mac_suffix = content.split('+')[1].strip().upper()
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
from app.models.device import ONUDevice, DeviceStatusHistory
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
devices = db.query(ONUDevice).filter(
|
||||||
|
ONUDevice.mac_address.ilike(f"%{mac_suffix}")
|
||||||
|
).limit(10).all()
|
||||||
|
|
||||||
|
if not devices:
|
||||||
|
svc.send_text_message("未找到匹配的设备", to_user=from_user)
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = [f"🔍 找到 {len(devices)} 个设备(MAC 含 {mac_suffix}):", ""]
|
||||||
|
for d in devices[:8]:
|
||||||
|
# 查最新状态
|
||||||
|
latest = (
|
||||||
|
db.query(DeviceStatusHistory.status,
|
||||||
|
func.max(DeviceStatusHistory.checked_at))
|
||||||
|
.filter(DeviceStatusHistory.onu_device_id == d.id)
|
||||||
|
.group_by(DeviceStatusHistory.status)
|
||||||
|
.order_by(func.max(DeviceStatusHistory.checked_at).desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
status_text = latest[0] if latest else "未知"
|
||||||
|
emoji = "🟢" if status_text == "online" else "🔴"
|
||||||
|
school = d.school_name or "未知"
|
||||||
|
lines.append(f"{emoji} {d.mac_address} | {school} | {d.region or ''}")
|
||||||
|
|
||||||
|
svc.send_text_message("\n".join(lines), to_user=from_user)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"设备查询失败: {e}")
|
||||||
|
svc.send_text_message("查询失败,请稍后重试", to_user=from_user)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_offline_schools_cmd(svc, from_user: str):
|
||||||
|
"""查询全离线学校"""
|
||||||
|
try:
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
from app.models.device import ONUDevice, DeviceStatusHistory
|
||||||
|
from sqlalchemy import func, case
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
latest_subq = (
|
||||||
|
db.query(DeviceStatusHistory.onu_device_id,
|
||||||
|
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"))
|
||||||
|
.group_by(DeviceStatusHistory.onu_device_id).subquery()
|
||||||
|
)
|
||||||
|
latest_status_subq = (
|
||||||
|
db.query(DeviceStatusHistory.onu_device_id, DeviceStatusHistory.status)
|
||||||
|
.join(latest_subq,
|
||||||
|
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
|
||||||
|
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)).subquery()
|
||||||
|
)
|
||||||
|
rows = (
|
||||||
|
db.query(
|
||||||
|
ONUDevice.school_name, ONUDevice.region,
|
||||||
|
func.count().label("total"),
|
||||||
|
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)).label("online"),
|
||||||
|
)
|
||||||
|
.outerjoin(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id)
|
||||||
|
.group_by(ONUDevice.school_name, ONUDevice.region)
|
||||||
|
.having(func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)) == 0)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
svc.send_text_message("✅ 当前没有全离线的学校", to_user=from_user)
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = [f"🔴 全离线学校 ({len(rows)} 所):", ""]
|
||||||
|
for r in rows:
|
||||||
|
school = r.school_name or "未知"
|
||||||
|
region = r.region or "未知"
|
||||||
|
total = int(r.total or 0)
|
||||||
|
if total > 0:
|
||||||
|
lines.append(f"• {school}({region}): {total}台全离线")
|
||||||
|
svc.send_text_message("\n".join(lines), to_user=from_user)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"全离线查询失败: {e}")
|
||||||
|
svc.send_text_message("查询失败,请稍后重试", to_user=from_user)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_help_cmd(svc, from_user: str):
|
||||||
|
"""处理帮助命令"""
|
||||||
|
svc.send_text_message(
|
||||||
|
"📋 H3C ONU 管理助手\n\n"
|
||||||
|
"🔍 设备查询:\n"
|
||||||
|
"• 发送「在线」查看设备在线统计\n"
|
||||||
|
"• 发送「全离线」查看全离线学校\n"
|
||||||
|
"• 发送 MAC 地址后四位查询设备\n\n"
|
||||||
|
"💡 发送「帮助」显示此信息\n"
|
||||||
|
"💻 完整功能: https://onu.dhdx.fun",
|
||||||
|
to_user=from_user
|
||||||
|
)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""WebSocket 实时推送"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
REDIS_CHANNEL = "h3c_onu:status_updates"
|
||||||
|
_connected: set[WebSocket] = set()
|
||||||
|
|
||||||
|
|
||||||
|
async def _redis_listener():
|
||||||
|
"""监听 Redis pub/sub 并广播给所有 WebSocket 客户端"""
|
||||||
|
try:
|
||||||
|
r = aioredis.from_url(settings.REDIS_URL)
|
||||||
|
pubsub = r.pubsub()
|
||||||
|
await pubsub.subscribe(REDIS_CHANNEL)
|
||||||
|
logger.info("WebSocket Redis 监听已启动")
|
||||||
|
async for msg in pubsub.listen():
|
||||||
|
if msg["type"] == "message":
|
||||||
|
dead: set[WebSocket] = set()
|
||||||
|
for ws in _connected:
|
||||||
|
try:
|
||||||
|
await ws.send_text(msg["data"].decode())
|
||||||
|
except Exception:
|
||||||
|
dead.add(ws)
|
||||||
|
_connected -= dead
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Redis 监听异常: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/ws/dashboard")
|
||||||
|
async def dashboard_ws(ws: WebSocket):
|
||||||
|
await ws.accept()
|
||||||
|
_connected.add(ws)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await ws.receive_text() # keep-alive, 忽略客户端消息
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
_connected.discard(ws)
|
||||||
@@ -29,6 +29,15 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# iMC API 配置(用于 ONU 远程重启和光功率查询)
|
# iMC API 配置(用于 ONU 远程重启和光功率查询)
|
||||||
IMC_API_URL: str = ""
|
IMC_API_URL: str = ""
|
||||||
|
|
||||||
|
# 企业微信应用消息 API(用于发送告警)
|
||||||
|
WECHAT_CORPID: str = ""
|
||||||
|
WECHAT_CORPSECRET: str = ""
|
||||||
|
WECHAT_AGENTID: str = ""
|
||||||
|
WECHAT_TOKEN: str = ""
|
||||||
|
WECHAT_ENCODING_AES_KEY: str = ""
|
||||||
|
WECHAT_USE_PROXY: bool = True
|
||||||
|
WECHAT_PROXY_API_URL: str = "https://api.v6ole.top"
|
||||||
IMC_API_USERNAME: str = ""
|
IMC_API_USERNAME: str = ""
|
||||||
IMC_API_PASSWORD: str = ""
|
IMC_API_PASSWORD: str = ""
|
||||||
IMC_API_VERIFY_SSL: bool = False
|
IMC_API_VERIFY_SSL: bool = False
|
||||||
|
|||||||
+69
-5
@@ -1,15 +1,52 @@
|
|||||||
"""FastAPI 主应用"""
|
"""FastAPI 主应用"""
|
||||||
from fastapi import FastAPI
|
import logging
|
||||||
|
from pythonjsonlogger import jsonlogger
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||||
|
from slowapi.util import get_remote_address
|
||||||
|
from slowapi.errors import RateLimitExceeded
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.api.v1 import auth, devices, check, import_data, stats, olt, provision, users, roles, inventory, settings as settings_api, audit
|
from app.api.v1 import auth, devices, check, import_data, stats, olt, provision, users, roles, inventory, settings as settings_api, audit, wechat, ws, monitor
|
||||||
from app.middleware.audit_middleware import AuditMiddleware
|
from app.middleware.audit_middleware import AuditMiddleware
|
||||||
|
|
||||||
app = FastAPI(title=settings.APP_NAME, debug=settings.DEBUG)
|
# 结构化 JSON 日志
|
||||||
|
_handler = logging.StreamHandler()
|
||||||
|
_handler.setFormatter(jsonlogger.JsonFormatter('%(asctime)s %(name)s %(levelname)s %(message)s'))
|
||||||
|
logging.getLogger().handlers = [_handler]
|
||||||
|
logging.getLogger().setLevel(logging.INFO)
|
||||||
|
logging.getLogger('uvicorn.access').handlers = [_handler]
|
||||||
|
|
||||||
|
# 请求体大小限制中间件
|
||||||
|
MAX_BODY_SIZE = 10 * 1024 * 1024 # 10 MB
|
||||||
|
|
||||||
|
class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
if request.headers.get("content-length"):
|
||||||
|
if int(request.headers["content-length"]) > MAX_BODY_SIZE:
|
||||||
|
return JSONResponse({"detail": "请求体过大,最大 10MB"}, status_code=413)
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# CORS 白名单
|
||||||
|
ALLOWED_ORIGINS = [
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://localhost:18002",
|
||||||
|
"https://onu.dhdx.fun",
|
||||||
|
]
|
||||||
|
allowed = [o for o in ALLOWED_ORIGINS if o]
|
||||||
|
|
||||||
|
limiter = Limiter(key_func=get_remote_address, default_limits=["120/minute"])
|
||||||
|
|
||||||
|
app = FastAPI(title=settings.APP_NAME, debug=settings.DEBUG)
|
||||||
|
app.state.limiter = limiter
|
||||||
|
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||||
|
|
||||||
|
app.add_middleware(RequestSizeLimitMiddleware)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=allowed if allowed else ["*"],
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
@@ -28,8 +65,35 @@ app.include_router(roles.router)
|
|||||||
app.include_router(inventory.router)
|
app.include_router(inventory.router)
|
||||||
app.include_router(settings_api.router)
|
app.include_router(settings_api.router)
|
||||||
app.include_router(audit.router)
|
app.include_router(audit.router)
|
||||||
|
app.include_router(wechat.router)
|
||||||
|
app.include_router(ws.router)
|
||||||
|
app.include_router(monitor.router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup():
|
||||||
|
import asyncio
|
||||||
|
asyncio.create_task(ws._redis_listener())
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health_check():
|
def health_check():
|
||||||
return {"status": "ok"}
|
status = {"status": "ok", "db": "ok", "redis": "ok"}
|
||||||
|
try:
|
||||||
|
import redis
|
||||||
|
import psycopg2
|
||||||
|
r = redis.from_url(settings.REDIS_URL, socket_timeout=2)
|
||||||
|
r.ping()
|
||||||
|
except Exception:
|
||||||
|
status["redis"] = "error"
|
||||||
|
status["status"] = "degraded"
|
||||||
|
try:
|
||||||
|
from sqlalchemy import text
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
db = SessionLocal()
|
||||||
|
db.execute(text("SELECT 1"))
|
||||||
|
db.close()
|
||||||
|
except Exception:
|
||||||
|
status["db"] = "error"
|
||||||
|
status["status"] = "degraded"
|
||||||
|
return status
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""设备数据模型"""
|
"""设备数据模型"""
|
||||||
from sqlalchemy import Column, BigInteger, String, Integer, Text, TIMESTAMP, ForeignKey, JSON
|
from sqlalchemy import Column, BigInteger, String, Integer, Float, Text, TIMESTAMP, ForeignKey, JSON
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
@@ -39,6 +39,9 @@ class ONUDevice(Base):
|
|||||||
place_type = Column(String(50)) # 场所类型
|
place_type = Column(String(50)) # 场所类型
|
||||||
room_number = Column(String(50))
|
room_number = Column(String(50))
|
||||||
notes = Column(Text) # 备注
|
notes = Column(Text) # 备注
|
||||||
|
tags = Column(Text) # 标签(逗号分隔),如 "重点设备,考试用"
|
||||||
|
latitude = Column(Float, nullable=True) # 纬度
|
||||||
|
longitude = Column(Float, nullable=True) # 经度
|
||||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||||
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
@@ -95,6 +98,17 @@ class NewDevice(Base):
|
|||||||
discovered_at = Column(TIMESTAMP, server_default=func.now())
|
discovered_at = Column(TIMESTAMP, server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class OpticalPowerHistory(Base):
|
||||||
|
"""光功率历史记录"""
|
||||||
|
__tablename__ = "optical_power_history"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
onu_device_id = Column(BigInteger, ForeignKey("onu_devices.id"), nullable=False, index=True)
|
||||||
|
power_in = Column(String(20)) # 接收光功率 dBm
|
||||||
|
power_out = Column(String(20)) # 发送光功率 dBm
|
||||||
|
recorded_at = Column(TIMESTAMP, nullable=False, server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
class DeviceReplacement(Base):
|
class DeviceReplacement(Base):
|
||||||
"""设备更换记录"""
|
"""设备更换记录"""
|
||||||
__tablename__ = "device_replacements"
|
__tablename__ = "device_replacements"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class User(Base):
|
|||||||
id = Column(BigInteger, primary_key=True, index=True)
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
casdoor_id = Column(String(100), unique=True, nullable=False)
|
casdoor_id = Column(String(100), unique=True, nullable=False)
|
||||||
username = Column(String(100), nullable=False)
|
username = Column(String(100), nullable=False)
|
||||||
|
display_name = Column(String(100)) # 中文姓名
|
||||||
email = Column(String(255))
|
email = Column(String(255))
|
||||||
role = Column(String(50), default="user")
|
role = Column(String(50), default="user")
|
||||||
assigned_area = Column(String(100))
|
assigned_area = Column(String(100))
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from datetime import datetime
|
|||||||
class UserListItem(BaseModel):
|
class UserListItem(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
username: str
|
username: str
|
||||||
|
display_name: Optional[str] = None
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
role: str
|
role: str
|
||||||
assigned_area: Optional[str] = None
|
assigned_area: Optional[str] = None
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""设备状态检查服务"""
|
"""设备状态检查服务"""
|
||||||
|
import time
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from app.services.ssh_service import SSHService
|
from app.services.ssh_service import SSHService
|
||||||
@@ -6,6 +7,26 @@ from app.models.device import OLTDevice, ONUDevice, DeviceStatusHistory, Duplica
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
# SSH 连接池缓存,TTL 5 分钟
|
||||||
|
_conn_pool: Dict[int, tuple[SSHService, float]] = {}
|
||||||
|
_POOL_TTL = 300
|
||||||
|
|
||||||
|
def _get_cached_ssh(olt_ip: str, olt_user: str, olt_pass: str, olt_id: int) -> SSHService:
|
||||||
|
"""获取缓存的 SSH 连接,过期自动重连"""
|
||||||
|
entry = _conn_pool.get(olt_id)
|
||||||
|
if entry:
|
||||||
|
ssh, ts = entry
|
||||||
|
if time.time() - ts < _POOL_TTL:
|
||||||
|
return ssh
|
||||||
|
try:
|
||||||
|
ssh.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
ssh = SSHService(olt_ip, olt_user, olt_pass)
|
||||||
|
ssh.connect()
|
||||||
|
_conn_pool[olt_id] = (ssh, time.time())
|
||||||
|
return ssh
|
||||||
|
|
||||||
|
|
||||||
def parse_distance(distance_str: Optional[str]) -> Optional[int]:
|
def parse_distance(distance_str: Optional[str]) -> Optional[int]:
|
||||||
"""将距离字符串转为整数,如 '<1000' -> 1000, '1234' -> 1234"""
|
"""将距离字符串转为整数,如 '<1000' -> 1000, '1234' -> 1234"""
|
||||||
@@ -29,9 +50,8 @@ class CheckService:
|
|||||||
if not olt:
|
if not olt:
|
||||||
raise Exception(f"OLT 设备不存在: {olt_id}")
|
raise Exception(f"OLT 设备不存在: {olt_id}")
|
||||||
|
|
||||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
ssh = _get_cached_ssh(olt.ip_address, olt.username, olt.password, olt_id)
|
||||||
try:
|
try:
|
||||||
ssh.connect()
|
|
||||||
output = ssh.execute_command(olt.slot_command)
|
output = ssh.execute_command(olt.slot_command)
|
||||||
onu_info_dict, _ = ssh.parse_onu_info(output)
|
onu_info_dict, _ = ssh.parse_onu_info(output)
|
||||||
|
|
||||||
@@ -160,9 +180,8 @@ class CheckService:
|
|||||||
if not olt:
|
if not olt:
|
||||||
raise Exception(f"OLT 设备不存在: {olt_id}")
|
raise Exception(f"OLT 设备不存在: {olt_id}")
|
||||||
|
|
||||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
ssh = _get_cached_ssh(olt.ip_address, olt.username, olt.password, olt_id)
|
||||||
try:
|
try:
|
||||||
ssh.connect()
|
|
||||||
output = ssh.execute_command(olt.slot_command)
|
output = ssh.execute_command(olt.slot_command)
|
||||||
onu_info_dict, duplicate_dict = ssh.parse_onu_info(output)
|
onu_info_dict, duplicate_dict = ssh.parse_onu_info(output)
|
||||||
|
|
||||||
@@ -241,9 +260,8 @@ class CheckService:
|
|||||||
if not olt:
|
if not olt:
|
||||||
raise Exception(f"OLT 设备不存在: {olt_id}")
|
raise Exception(f"OLT 设备不存在: {olt_id}")
|
||||||
|
|
||||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
ssh = _get_cached_ssh(olt.ip_address, olt.username, olt.password, olt_id)
|
||||||
try:
|
try:
|
||||||
ssh.connect()
|
|
||||||
output = ssh.execute_command(olt.slot_command)
|
output = ssh.execute_command(olt.slot_command)
|
||||||
onu_info_dict, duplicate_dict = ssh.parse_onu_info(output)
|
onu_info_dict, duplicate_dict = ssh.parse_onu_info(output)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
"""企业微信 (WeChat Work) 应用消息服务"""
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
import base64
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import urllib.parse
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
from Crypto.Cipher import AES
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_db_setting(key: str, default: str = "") -> str:
|
||||||
|
"""从数据库读取 SystemSetting,失败时返回 default"""
|
||||||
|
try:
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
from app.models.setting import SystemSetting
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
row = db.query(SystemSetting).filter_by(key=key).first()
|
||||||
|
return row.value if row else default
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
class WeChatService:
|
||||||
|
"""企业微信应用消息服务"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# 优先读环境变量,fallback 到数据库配置
|
||||||
|
self.corpid = settings.WECHAT_CORPID or _get_db_setting("wechat_corpid")
|
||||||
|
self.corpsecret = settings.WECHAT_CORPSECRET or _get_db_setting("wechat_corpsecret")
|
||||||
|
self.agentid = settings.WECHAT_AGENTID or _get_db_setting("wechat_agentid")
|
||||||
|
self.token = settings.WECHAT_TOKEN
|
||||||
|
self.encoding_aes_key = settings.WECHAT_ENCODING_AES_KEY
|
||||||
|
self.use_proxy = settings.WECHAT_USE_PROXY
|
||||||
|
self.proxy_api_url = settings.WECHAT_PROXY_API_URL
|
||||||
|
self._access_token = None
|
||||||
|
self._token_expires_at = 0
|
||||||
|
|
||||||
|
# ── access_token 管理 ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_access_token(self) -> str | None:
|
||||||
|
"""获取企业微信 access_token,自动缓存和续期"""
|
||||||
|
now = time.time()
|
||||||
|
if self._access_token and now < self._token_expires_at:
|
||||||
|
return self._access_token
|
||||||
|
|
||||||
|
if not self.corpid or not self.corpsecret:
|
||||||
|
logger.warning("WECHAT_CORPID 或 WECHAT_CORPSECRET 未配置")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.use_proxy:
|
||||||
|
url = f"{self.proxy_api_url}/cgi-bin/gettoken?corpid={self.corpid}&corpsecret={self.corpsecret}"
|
||||||
|
else:
|
||||||
|
url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={self.corpid}&corpsecret={self.corpsecret}"
|
||||||
|
|
||||||
|
resp = requests.get(url, timeout=10)
|
||||||
|
result = resp.json()
|
||||||
|
|
||||||
|
if result.get("errcode") == 0:
|
||||||
|
self._access_token = result.get("access_token")
|
||||||
|
expires_in = result.get("expires_in", 7200) - 300 # 提前5分钟过期
|
||||||
|
self._token_expires_at = now + expires_in
|
||||||
|
logger.info(f"企业微信 access_token 获取成功,过期时间: {time.strftime('%H:%M:%S', time.localtime(self._token_expires_at))}")
|
||||||
|
return self._access_token
|
||||||
|
elif result.get("errcode") == 60020 and not self.use_proxy:
|
||||||
|
logger.info("IP受限,尝试使用代理获取 access_token")
|
||||||
|
self.use_proxy = True
|
||||||
|
return self.get_access_token()
|
||||||
|
else:
|
||||||
|
logger.error(f"获取 access_token 失败: {result}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取 access_token 异常: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── 消息分条 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _split_long_message(content: str, max_chars: int = 1800) -> list[str]:
|
||||||
|
"""将长消息按换行边界拆分为多条,避免企微截断"""
|
||||||
|
if len(content) <= max_chars:
|
||||||
|
return [content]
|
||||||
|
chunks = []
|
||||||
|
lines = content.split('\n')
|
||||||
|
current = ''
|
||||||
|
for line in lines:
|
||||||
|
if len(current) + len(line) + 1 > max_chars and current:
|
||||||
|
chunks.append(current.strip())
|
||||||
|
current = line
|
||||||
|
else:
|
||||||
|
current += ('\n' + line) if current else line
|
||||||
|
if current.strip():
|
||||||
|
chunks.append(current.strip())
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
# ── 发送消息 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _send_markdown_single(self, content: str, to_user: str) -> bool:
|
||||||
|
"""发送单条 Markdown 消息(内部方法)"""
|
||||||
|
access_token = self.get_access_token()
|
||||||
|
if not access_token:
|
||||||
|
return False
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"touser": to_user, "toparty": "", "totag": "",
|
||||||
|
"msgtype": "markdown",
|
||||||
|
"agentid": int(self.agentid),
|
||||||
|
"markdown": {"content": content}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.use_proxy:
|
||||||
|
url = f"{self.proxy_api_url}/cgi-bin/message/send?access_token={access_token}"
|
||||||
|
else:
|
||||||
|
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
|
||||||
|
|
||||||
|
resp = requests.post(url, json=data, timeout=15)
|
||||||
|
result = resp.json()
|
||||||
|
errcode = result.get("errcode", -1)
|
||||||
|
if errcode == 0:
|
||||||
|
return True
|
||||||
|
elif errcode == 40014:
|
||||||
|
self._access_token = None
|
||||||
|
self._token_expires_at = 0
|
||||||
|
raise Exception("token_expired")
|
||||||
|
elif errcode == 60020 and not self.use_proxy:
|
||||||
|
self.use_proxy = True
|
||||||
|
raise Exception("ip_restricted")
|
||||||
|
else:
|
||||||
|
logger.warning(f"企业微信消息发送失败: {result.get('errmsg')} (errcode={errcode})")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_markdown(self, content: str, to_user: str = "@all") -> bool:
|
||||||
|
"""发送 Markdown 消息,自动分条"""
|
||||||
|
if not self.corpid or not self.corpsecret or not self.agentid:
|
||||||
|
logger.warning("企业微信未配置,跳过发送")
|
||||||
|
return False
|
||||||
|
|
||||||
|
chunks = self._split_long_message(content)
|
||||||
|
success = True
|
||||||
|
for i, chunk in enumerate(chunks):
|
||||||
|
prefix = f"({i+1}/{len(chunks)})\n" if len(chunks) > 1 else ""
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
ok = self._send_markdown_single(prefix + chunk, to_user)
|
||||||
|
if ok:
|
||||||
|
break
|
||||||
|
if attempt < 2:
|
||||||
|
time.sleep(1)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"发送分片 {i+1}/{len(chunks)} 异常: {e}")
|
||||||
|
if attempt < 2:
|
||||||
|
time.sleep(1)
|
||||||
|
continue
|
||||||
|
success = False
|
||||||
|
if i < len(chunks) - 1:
|
||||||
|
time.sleep(0.5) # 避免频率限制
|
||||||
|
return success
|
||||||
|
|
||||||
|
|
||||||
|
# ── 发送文本消息 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def send_text_message(self, content: str, to_user: str = "@all") -> bool:
|
||||||
|
"""通过应用消息 API 发送文本消息,自动分条"""
|
||||||
|
if not self.corpid or not self.corpsecret or not self.agentid:
|
||||||
|
return False
|
||||||
|
|
||||||
|
chunks = self._split_long_message(content, max_chars=1800)
|
||||||
|
success = True
|
||||||
|
for i, chunk in enumerate(chunks):
|
||||||
|
prefix = f"({i+1}/{len(chunks)})\n" if len(chunks) > 1 else ""
|
||||||
|
try:
|
||||||
|
access_token = self.get_access_token()
|
||||||
|
if not access_token:
|
||||||
|
return False
|
||||||
|
data = {
|
||||||
|
"touser": to_user, "toparty": "", "totag": "",
|
||||||
|
"msgtype": "text",
|
||||||
|
"agentid": int(self.agentid),
|
||||||
|
"text": {"content": prefix + chunk}
|
||||||
|
}
|
||||||
|
url = f"{self.proxy_api_url}/cgi-bin/message/send?access_token={access_token}" if self.use_proxy else f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
|
||||||
|
resp = requests.post(url, json=data, timeout=15)
|
||||||
|
if resp.json().get("errcode") != 0:
|
||||||
|
success = False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"发送文本消息分片 {i+1}/{len(chunks)} 失败: {e}")
|
||||||
|
success = False
|
||||||
|
if i < len(chunks) - 1:
|
||||||
|
time.sleep(0.5)
|
||||||
|
return success
|
||||||
|
|
||||||
|
# ── 菜单管理 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def create_menu(self, menu_data: dict) -> bool:
|
||||||
|
"""创建/更新企业微信应用菜单"""
|
||||||
|
try:
|
||||||
|
access_token = self.get_access_token()
|
||||||
|
if not access_token:
|
||||||
|
return False
|
||||||
|
if self.use_proxy:
|
||||||
|
url = f"{self.proxy_api_url}/cgi-bin/menu/create?access_token={access_token}&agentid={self.agentid}"
|
||||||
|
else:
|
||||||
|
url = f"https://qyapi.weixin.qq.com/cgi-bin/menu/create?access_token={access_token}&agentid={self.agentid}"
|
||||||
|
resp = requests.post(url, json=menu_data, timeout=15)
|
||||||
|
result = resp.json()
|
||||||
|
if result.get("errcode") == 0:
|
||||||
|
logger.info("企业微信菜单创建成功")
|
||||||
|
return True
|
||||||
|
logger.warning(f"创建菜单失败: {result}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"创建菜单异常: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ── 获取用户信息 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_user_info(self, userid: str) -> dict:
|
||||||
|
"""获取企业微信用户信息"""
|
||||||
|
try:
|
||||||
|
access_token = self.get_access_token()
|
||||||
|
if not access_token:
|
||||||
|
return {"errcode": -1, "errmsg": "无 access_token"}
|
||||||
|
if self.use_proxy:
|
||||||
|
url = f"{self.proxy_api_url}/cgi-bin/user/get?access_token={access_token}&userid={userid}"
|
||||||
|
else:
|
||||||
|
url = f"https://qyapi.weixin.qq.com/cgi-bin/user/get?access_token={access_token}&userid={userid}"
|
||||||
|
resp = requests.get(url, timeout=10)
|
||||||
|
return resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取用户信息失败: {e}")
|
||||||
|
return {"errcode": -1, "errmsg": str(e)}
|
||||||
|
|
||||||
|
# ── URL 验证(GET 回调)──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def verify_url(self, msg_signature: str, timestamp: str, nonce: str, echostr: str) -> str | None:
|
||||||
|
"""验证企业微信回调 URL"""
|
||||||
|
try:
|
||||||
|
echostr = urllib.parse.unquote(echostr)
|
||||||
|
temp_list = [self.token, timestamp, nonce, echostr]
|
||||||
|
temp_list.sort()
|
||||||
|
temp_str = ''.join(temp_list)
|
||||||
|
hash_str = hashlib.sha1(temp_str.encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
|
if hash_str != msg_signature:
|
||||||
|
logger.error(f"URL验证签名不匹配: expected={msg_signature}, got={hash_str}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self.encoding_aes_key:
|
||||||
|
return self._decrypt_echostr(echostr)
|
||||||
|
return echostr
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"URL验证异常: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _decrypt_echostr(self, echostr: str) -> str | None:
|
||||||
|
"""解密 echostr"""
|
||||||
|
try:
|
||||||
|
aes_key = base64.b64decode(self.encoding_aes_key + '=')
|
||||||
|
encrypted = base64.b64decode(echostr)
|
||||||
|
cipher = AES.new(aes_key, AES.MODE_CBC, aes_key[:16])
|
||||||
|
decrypted = cipher.decrypt(encrypted)
|
||||||
|
decrypted = decrypted[:-decrypted[-1]] # PKCS7 unpad
|
||||||
|
content = decrypted[16:]
|
||||||
|
xml_len = socket.ntohl(struct.unpack("I", content[:4])[0])
|
||||||
|
xml_content = content[4:xml_len + 4]
|
||||||
|
received_id = content[xml_len + 4:].decode('utf-8')
|
||||||
|
if received_id != self.corpid:
|
||||||
|
logger.error(f"企业ID验证失败: {received_id} != {self.corpid}")
|
||||||
|
return None
|
||||||
|
return xml_content.decode('utf-8')
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"解密echostr失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── 消息解密(POST 回调)─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def parse_message(self, xml_data: bytes) -> dict | None:
|
||||||
|
"""解析企业微信回调的加密 XML 消息"""
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(xml_data)
|
||||||
|
msg = {child.tag: child.text for child in root}
|
||||||
|
|
||||||
|
if 'Encrypt' in msg:
|
||||||
|
decrypted = self._decrypt_message(msg['Encrypt'])
|
||||||
|
decrypted_root = ET.fromstring(decrypted)
|
||||||
|
msg = {child.tag: child.text for child in decrypted_root}
|
||||||
|
|
||||||
|
return msg
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"解析消息失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _decrypt_message(self, encrypted_msg: str) -> str:
|
||||||
|
"""解密企业微信推送的消息"""
|
||||||
|
aes_key = base64.b64decode(self.encoding_aes_key + '=')
|
||||||
|
encrypted = base64.b64decode(encrypted_msg)
|
||||||
|
cipher = AES.new(aes_key, AES.MODE_CBC, aes_key[:16])
|
||||||
|
decrypted = cipher.decrypt(encrypted)
|
||||||
|
decrypted = decrypted[:-decrypted[-1]] # PKCS7 unpad
|
||||||
|
content = decrypted[16:]
|
||||||
|
xml_len = socket.ntohl(struct.unpack("I", content[:4])[0])
|
||||||
|
xml_content = content[4:xml_len + 4]
|
||||||
|
received_id = content[xml_len + 4:].decode('utf-8')
|
||||||
|
if received_id != self.corpid:
|
||||||
|
raise Exception(f"企业ID验证失败: {received_id} != {self.corpid}")
|
||||||
|
return xml_content.decode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
# 模块级单例和便捷函数
|
||||||
|
_service: WeChatService | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_wechat_service() -> WeChatService:
|
||||||
|
global _service
|
||||||
|
if _service is None:
|
||||||
|
_service = WeChatService()
|
||||||
|
return _service
|
||||||
|
|
||||||
|
|
||||||
|
def send_wechat_markdown(content: str) -> bool:
|
||||||
|
"""便捷函数:发送企业微信 Markdown 消息"""
|
||||||
|
return get_wechat_service().send_markdown(content)
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""告警相关 Celery 任务"""
|
||||||
|
import traceback
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import func, case
|
||||||
|
from app.core.celery_app import celery_app
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(queue='h3c_onu_ms', ignore_result=True)
|
||||||
|
def check_school_offline_alerts():
|
||||||
|
"""
|
||||||
|
检查是否有学校全部离线(在线率为 0%),如有则发送企业微信告警。
|
||||||
|
该任务在每次全量状态检查完成后异步调用。
|
||||||
|
"""
|
||||||
|
from app.models.device import ONUDevice, DeviceStatusHistory
|
||||||
|
from app.services.wechat_service import send_wechat_markdown
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
# 每台设备最新状态的子查询
|
||||||
|
latest_subq = (
|
||||||
|
db.query(
|
||||||
|
DeviceStatusHistory.onu_device_id,
|
||||||
|
func.max(DeviceStatusHistory.checked_at).label("max_checked_at")
|
||||||
|
)
|
||||||
|
.group_by(DeviceStatusHistory.onu_device_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
latest_status_subq = (
|
||||||
|
db.query(
|
||||||
|
DeviceStatusHistory.onu_device_id,
|
||||||
|
DeviceStatusHistory.status
|
||||||
|
)
|
||||||
|
.join(
|
||||||
|
latest_subq,
|
||||||
|
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
|
||||||
|
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)
|
||||||
|
)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
# 按学校聚合在线率,只查询在线数为 0 的学校
|
||||||
|
rows = (
|
||||||
|
db.query(
|
||||||
|
ONUDevice.school_name,
|
||||||
|
ONUDevice.region,
|
||||||
|
func.count().label("total"),
|
||||||
|
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)).label("online"),
|
||||||
|
)
|
||||||
|
.outerjoin(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id)
|
||||||
|
.group_by(ONUDevice.school_name, ONUDevice.region)
|
||||||
|
.having(
|
||||||
|
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)) == 0
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return {"alerted": False, "reason": "没有全离线的学校"}
|
||||||
|
|
||||||
|
# 构造告警消息
|
||||||
|
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
lines = [
|
||||||
|
"## <font color=\"warning\">[告警] 学校全部离线</font>",
|
||||||
|
f"> 检查时间:{now_str}",
|
||||||
|
"> 以下学校所有设备均处于离线状态:",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for row in rows:
|
||||||
|
school = row.school_name or "未知"
|
||||||
|
region = row.region or "未知"
|
||||||
|
total = int(row.total or 0)
|
||||||
|
if total > 0:
|
||||||
|
lines.append(f"- **{school}**({region}): {total} 台设备全离线")
|
||||||
|
|
||||||
|
content = "\n".join(lines)
|
||||||
|
send_wechat_markdown(content)
|
||||||
|
return {"alerted": True, "schools": len(rows)}
|
||||||
|
except Exception as e:
|
||||||
|
return {"alerted": False, "error": str(e), "traceback": traceback.format_exc()}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -97,6 +97,16 @@ def check_all_devices(self):
|
|||||||
|
|
||||||
self.update_state(state='PROGRESS', meta={'current': total, 'total': total, 'status': '检查完成'})
|
self.update_state(state='PROGRESS', meta={'current': total, 'total': total, 'status': '检查完成'})
|
||||||
|
|
||||||
|
# 通知 WebSocket 客户端状态已更新
|
||||||
|
try:
|
||||||
|
import json as _json
|
||||||
|
r.publish("h3c_onu:status_updates", _json.dumps({
|
||||||
|
"type": "check_complete", "total_online": total_online,
|
||||||
|
"total_offline": total_offline, "total_olts": total
|
||||||
|
}))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'success': True,
|
'success': True,
|
||||||
'total_olts': total,
|
'total_olts': total,
|
||||||
|
|||||||
@@ -2,5 +2,6 @@
|
|||||||
from app.core.celery_app import celery_app
|
from app.core.celery_app import celery_app
|
||||||
from app.tasks import check_tasks # noqa: F401 - 导入以注册任务
|
from app.tasks import check_tasks # noqa: F401 - 导入以注册任务
|
||||||
from app.tasks import audit_tasks # noqa: F401 - 导入以注册任务
|
from app.tasks import audit_tasks # noqa: F401 - 导入以注册任务
|
||||||
|
from app.tasks import alert_tasks # noqa: F401 - 导入以注册任务
|
||||||
|
|
||||||
__all__ = ['celery_app']
|
__all__ = ['celery_app']
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ paramiko==3.4.0
|
|||||||
pandas==2.1.4
|
pandas==2.1.4
|
||||||
openpyxl==3.1.2
|
openpyxl==3.1.2
|
||||||
cryptography==42.0.0
|
cryptography==42.0.0
|
||||||
|
pycryptodome==3.20.0
|
||||||
|
slowapi==0.1.9
|
||||||
|
python-json-logger==2.0.7
|
||||||
|
pytest==8.3.4
|
||||||
|
pytest-asyncio==0.25.0
|
||||||
casdoor==1.18.0
|
casdoor==1.18.0
|
||||||
aiohttp>=3.9.0
|
aiohttp>=3.9.0
|
||||||
PyJWT>=2.8.0
|
PyJWT>=2.8.0
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""pytest fixtures"""
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
TEST_DB_URL = "sqlite:///:memory:"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_session():
|
||||||
|
engine = create_engine(TEST_DB_URL, connect_args={"check_same_thread": False})
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
yield session
|
||||||
|
session.close()
|
||||||
|
Base.metadata.drop_all(bind=engine)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""数据导入服务测试"""
|
||||||
|
import io
|
||||||
|
import pytest
|
||||||
|
from app.services.import_service import ImportService
|
||||||
|
|
||||||
|
|
||||||
|
class TestImportService:
|
||||||
|
def test_validate_mac(self, db_session):
|
||||||
|
svc = ImportService(db_session)
|
||||||
|
valid = [{"mac_address": "1484-778f-aa60", "region": "城区", "school_name": "测试学校"}]
|
||||||
|
result = svc.validate_data(valid)
|
||||||
|
assert len(result["valid"]) == 1
|
||||||
|
assert len(result["invalid"]) == 0
|
||||||
|
|
||||||
|
def test_invalid_mac_rejected(self, db_session):
|
||||||
|
svc = ImportService(db_session)
|
||||||
|
data = [{"mac_address": "invalid", "region": "城区", "school_name": "测试学校"}]
|
||||||
|
result = svc.validate_data(data)
|
||||||
|
assert len(result["invalid"]) > 0
|
||||||
|
|
||||||
|
def test_missing_required_fields(self, db_session):
|
||||||
|
svc = ImportService(db_session)
|
||||||
|
# MAC format valid but empty region/school may or may not be rejected
|
||||||
|
# depending on validation rules — just verify it doesn't crash
|
||||||
|
data = [{"mac_address": "1484-778f-aa60", "region": "", "school_name": ""}]
|
||||||
|
result = svc.validate_data(data)
|
||||||
|
assert "valid" in result or "invalid" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestCleanValue:
|
||||||
|
def test_strips_whitespace(self):
|
||||||
|
from app.services.import_service import clean_value
|
||||||
|
assert clean_value(" test ") == "test"
|
||||||
|
|
||||||
|
def test_none_returns_empty(self):
|
||||||
|
from app.services.import_service import clean_value
|
||||||
|
assert clean_value(None) == ''
|
||||||
|
|
||||||
|
def test_nan_returns_empty(self):
|
||||||
|
from app.services.import_service import clean_value
|
||||||
|
import math
|
||||||
|
assert clean_value(float('nan')) == ''
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""SSH 输出解析测试"""
|
||||||
|
import pytest
|
||||||
|
from app.services.ssh_service import SSHService
|
||||||
|
|
||||||
|
|
||||||
|
def _make_svc():
|
||||||
|
return SSHService("10.0.0.1", "admin", "pass")
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseOnuInfo:
|
||||||
|
def test_single_online(self):
|
||||||
|
svc = _make_svc()
|
||||||
|
output = """
|
||||||
|
Flags: S-Switched L-Loopback N-Not exist U-Up D-Down
|
||||||
|
Port MAC Status OAM State LOID Model Distance
|
||||||
|
0/0/1 1484-778f-aa60 Up OAM_Up test_loid H3C_ET704 1234m
|
||||||
|
"""
|
||||||
|
onu_dict, unknown = svc.parse_onu_info(output)
|
||||||
|
assert len(onu_dict) == 1
|
||||||
|
assert "1484-778f-aa60" in onu_dict
|
||||||
|
assert onu_dict["1484-778f-aa60"].status == "online"
|
||||||
|
|
||||||
|
def test_mixed_online_offline(self):
|
||||||
|
svc = _make_svc()
|
||||||
|
output = """
|
||||||
|
Flags: S-Switched L-Loopback N-Not exist U-Up D-Down
|
||||||
|
Port MAC Status OAM State LOID Model Distance
|
||||||
|
0/0/1 1484-778f-aa60 Up OAM_Up loid_a H3C_ET704 500m
|
||||||
|
0/0/2 1484-778f-bb70 Down OAM_Down loid_b Unknown <1000m
|
||||||
|
"""
|
||||||
|
onu_dict, unknown = svc.parse_onu_info(output)
|
||||||
|
mac_a = "1484-778f-aa60"
|
||||||
|
mac_b = "1484-778f-bb70"
|
||||||
|
assert onu_dict[mac_a].status == "online"
|
||||||
|
assert onu_dict[mac_b].status == "offline"
|
||||||
|
|
||||||
|
def test_more_marker_removal(self):
|
||||||
|
svc = _make_svc()
|
||||||
|
output = """
|
||||||
|
Flags: S-Switched L-Loopback N-Not exist U-Up D-Down
|
||||||
|
Port MAC Status OAM State LOID Model Distance
|
||||||
|
---- More ----
|
||||||
|
0/0/1 1484-778f-aa60 Up OAM_Up loid H3C_ET704 500m
|
||||||
|
---- More ----
|
||||||
|
0/0/2 1484-778f-bb70 Up OAM_Up loid2 H3C_ET704 800m
|
||||||
|
"""
|
||||||
|
onu_dict, _ = svc.parse_onu_info(output)
|
||||||
|
assert len(onu_dict) == 2
|
||||||
|
|
||||||
|
def test_more_inline_with_device_line(self):
|
||||||
|
"""More 标记与下一条设备数据同行时,不应丢弃该行"""
|
||||||
|
svc = _make_svc()
|
||||||
|
output = """
|
||||||
|
---- More ---- 1484-778f-aa60 Up OAM_Up loid H3C_ET704 500m
|
||||||
|
"""
|
||||||
|
onu_dict, _ = svc.parse_onu_info(output)
|
||||||
|
mac = "1484-778f-aa60"
|
||||||
|
assert mac in onu_dict
|
||||||
|
|
||||||
|
def test_empty_output(self):
|
||||||
|
svc = _make_svc()
|
||||||
|
onu_dict, unknown = svc.parse_onu_info("")
|
||||||
|
assert len(onu_dict) == 0
|
||||||
|
|
||||||
|
def test_header_only(self):
|
||||||
|
svc = _make_svc()
|
||||||
|
output = " Flags: S-Switched L-Loopback N-Not exist U-Up D-Down\n Port MAC Status"
|
||||||
|
onu_dict, _ = svc.parse_onu_info(output)
|
||||||
|
assert len(onu_dict) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestCleanOutput:
|
||||||
|
def test_strips_ansi_codes(self):
|
||||||
|
svc = _make_svc()
|
||||||
|
cleaned = svc._clean_output("\x1b[37D\x1b[K 1484-778f-aa60 Up")
|
||||||
|
assert "\x1b[37D" not in cleaned
|
||||||
|
assert "\x1b[K" not in cleaned
|
||||||
|
assert "1484-778f-aa60" in cleaned
|
||||||
|
|
||||||
|
def test_removes_more_marker(self):
|
||||||
|
svc = _make_svc()
|
||||||
|
output = "---- More ----\n1484-778f-aa60 Up"
|
||||||
|
cleaned = svc._clean_output(output)
|
||||||
|
assert "---- More ----" not in cleaned
|
||||||
|
assert "1484-778f-aa60" in cleaned
|
||||||
|
|
||||||
|
def test_preserves_device_line_after_more(self):
|
||||||
|
"""More 标记同行后续设备数据不应被删除"""
|
||||||
|
svc = _make_svc()
|
||||||
|
output = "---- More ----\r\r 1484-778f-aa60 Up"
|
||||||
|
cleaned = svc._clean_output(output)
|
||||||
|
assert "1484-778f-aa60" in cleaned
|
||||||
|
assert "---- More ----" not in cleaned
|
||||||
@@ -168,6 +168,15 @@ RATE_LIMIT_PER_HOUR=1000
|
|||||||
# 数据库备份目录
|
# 数据库备份目录
|
||||||
BACKUP_DIR=/app/backups
|
BACKUP_DIR=/app/backups
|
||||||
|
|
||||||
|
# 企业微信告警配置
|
||||||
|
WECHAT_CORPID=
|
||||||
|
WECHAT_CORPSECRET=
|
||||||
|
WECHAT_AGENTID=
|
||||||
|
WECHAT_TOKEN=
|
||||||
|
WECHAT_ENCODING_AES_KEY=
|
||||||
|
WECHAT_USE_PROXY=True
|
||||||
|
WECHAT_PROXY_API_URL=https://api.v6ole.top
|
||||||
|
|
||||||
# 备份保留天数
|
# 备份保留天数
|
||||||
BACKUP_RETENTION=30
|
BACKUP_RETENTION=30
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ services:
|
|||||||
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
|
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
|
||||||
- SECRET_KEY=${SECRET_KEY}
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
- DEBUG=${DEBUG:-false}
|
- DEBUG=${DEBUG:-false}
|
||||||
|
- WECHAT_CORPID=${WECHAT_CORPID:-}
|
||||||
|
- WECHAT_CORPSECRET=${WECHAT_CORPSECRET:-}
|
||||||
|
- WECHAT_AGENTID=${WECHAT_AGENTID:-}
|
||||||
|
- WECHAT_TOKEN=${WECHAT_TOKEN:-}
|
||||||
|
- WECHAT_ENCODING_AES_KEY=${WECHAT_ENCODING_AES_KEY:-}
|
||||||
|
- WECHAT_USE_PROXY=${WECHAT_USE_PROXY:-True}
|
||||||
|
- WECHAT_PROXY_API_URL=${WECHAT_PROXY_API_URL:-https://api.v6ole.top}
|
||||||
volumes:
|
volumes:
|
||||||
- ../backend/logs:/app/logs
|
- ../backend/logs:/app/logs
|
||||||
- ../backend/static:/app/static
|
- ../backend/static:/app/static
|
||||||
@@ -46,6 +53,13 @@ services:
|
|||||||
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
|
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
|
||||||
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
|
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
|
||||||
- SECRET_KEY=${SECRET_KEY}
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
|
- WECHAT_CORPID=${WECHAT_CORPID:-}
|
||||||
|
- WECHAT_CORPSECRET=${WECHAT_CORPSECRET:-}
|
||||||
|
- WECHAT_AGENTID=${WECHAT_AGENTID:-}
|
||||||
|
- WECHAT_TOKEN=${WECHAT_TOKEN:-}
|
||||||
|
- WECHAT_ENCODING_AES_KEY=${WECHAT_ENCODING_AES_KEY:-}
|
||||||
|
- WECHAT_USE_PROXY=${WECHAT_USE_PROXY:-True}
|
||||||
|
- WECHAT_PROXY_API_URL=${WECHAT_PROXY_API_URL:-https://api.v6ole.top}
|
||||||
volumes:
|
volumes:
|
||||||
- ../backend/logs:/app/logs
|
- ../backend/logs:/app/logs
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -69,6 +83,13 @@ services:
|
|||||||
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
|
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
|
||||||
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
|
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
|
||||||
- SECRET_KEY=${SECRET_KEY}
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
|
- WECHAT_CORPID=${WECHAT_CORPID:-}
|
||||||
|
- WECHAT_CORPSECRET=${WECHAT_CORPSECRET:-}
|
||||||
|
- WECHAT_AGENTID=${WECHAT_AGENTID:-}
|
||||||
|
- WECHAT_TOKEN=${WECHAT_TOKEN:-}
|
||||||
|
- WECHAT_ENCODING_AES_KEY=${WECHAT_ENCODING_AES_KEY:-}
|
||||||
|
- WECHAT_USE_PROXY=${WECHAT_USE_PROXY:-True}
|
||||||
|
- WECHAT_PROXY_API_URL=${WECHAT_PROXY_API_URL:-https://api.v6ole.top}
|
||||||
volumes:
|
volumes:
|
||||||
- ../backend/logs:/app/logs
|
- ../backend/logs:/app/logs
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -216,12 +216,31 @@ backup_data() {
|
|||||||
echo "环境: $ENV" >> "$BACKUP_DIR/backup.info"
|
echo "环境: $ENV" >> "$BACKUP_DIR/backup.info"
|
||||||
echo "版本: $(git describe --tags 2>/dev/null || echo '未知')" >> "$BACKUP_DIR/backup.info"
|
echo "版本: $(git describe --tags 2>/dev/null || echo '未知')" >> "$BACKUP_DIR/backup.info"
|
||||||
|
|
||||||
|
# 验证数据库备份
|
||||||
|
echo -e "${BLUE}验证数据库备份...${NC}"
|
||||||
|
if [ -f "$BACKUP_DIR/database.sql" ]; then
|
||||||
|
SQL_SIZE=$(wc -c < "$BACKUP_DIR/database.sql")
|
||||||
|
if [ "$SQL_SIZE" -lt 100 ]; then
|
||||||
|
echo -e "${RED}错误: 数据库备份文件过小 ($SQL_SIZE bytes),可能备份失败${NC}"
|
||||||
|
elif head -1 "$BACKUP_DIR/database.sql" | grep -qiE "^(--|SET|CREATE|COPY|INSERT|ALTER)"; then
|
||||||
|
echo -e "${GREEN}数据库备份验证通过 ($SQL_SIZE bytes)${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}警告: 数据库备份格式异常,请检查${NC}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# 压缩备份文件
|
# 压缩备份文件
|
||||||
echo -e "${BLUE}压缩备份文件...${NC}"
|
echo -e "${BLUE}压缩备份文件...${NC}"
|
||||||
tar -czf "$BACKUP_DIR.tar.gz" "$BACKUP_DIR"
|
tar -czf "$BACKUP_DIR.tar.gz" "$BACKUP_DIR"
|
||||||
rm -rf "$BACKUP_DIR"
|
rm -rf "$BACKUP_DIR"
|
||||||
|
|
||||||
echo -e "${GREEN}备份完成: $BACKUP_DIR.tar.gz${NC}"
|
# 清理旧备份(保留最近 30 天)
|
||||||
|
echo -e "${BLUE}清理旧备份(保留30天)...${NC}"
|
||||||
|
find backups/ -name "*.tar.gz" -mtime +30 -delete 2>/dev/null
|
||||||
|
find backups/ -name "*.tar.gz" -mtime +30 -exec echo " 删除: {}" \; 2>/dev/null
|
||||||
|
|
||||||
|
BACKUP_COUNT=$(find backups/ -name "*.tar.gz" | wc -l)
|
||||||
|
echo -e "${GREEN}备份完成: $BACKUP_DIR.tar.gz (现存 ${BACKUP_COUNT} 个备份)${NC}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# 函数:恢复数据
|
# 函数:恢复数据
|
||||||
|
|||||||
@@ -5,3 +5,11 @@ export const getSummary = () => request.get('/stats/summary')
|
|||||||
export const getTrend = (days = 7) => request.get('/stats/trend', { params: { days } })
|
export const getTrend = (days = 7) => request.get('/stats/trend', { params: { days } })
|
||||||
|
|
||||||
export const getByRegion = () => request.get('/stats/by-region')
|
export const getByRegion = () => request.get('/stats/by-region')
|
||||||
|
|
||||||
|
export const getOltStats = () => request.get('/stats/olt-stats')
|
||||||
|
|
||||||
|
export const getModelDistribution = () => request.get('/stats/model-distribution')
|
||||||
|
|
||||||
|
export const getOfflineSchools = () => request.get('/stats/offline-schools')
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -210,14 +210,23 @@ const updateTime = () => {
|
|||||||
currentTime.value = now.toLocaleTimeString('zh-CN', { hour12: false })
|
currentTime.value = now.toLocaleTimeString('zh-CN', { hour12: false })
|
||||||
}
|
}
|
||||||
let timer = null
|
let timer = null
|
||||||
|
|
||||||
|
// 键盘快捷键
|
||||||
|
const shortcuts = { '1': '/dashboard', '2': '/devices', '3': '/charts', '4': '/olt', '5': '/inventory' }
|
||||||
|
const onKeydown = (e) => {
|
||||||
|
if (e.ctrlKey && e.key === 'k') { e.preventDefault(); document.querySelector('.search-input input')?.focus() }
|
||||||
|
if (e.ctrlKey && shortcuts[e.key]) { e.preventDefault(); router.push(shortcuts[e.key]) }
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
updateTime()
|
updateTime()
|
||||||
timer = setInterval(updateTime, 1000)
|
timer = setInterval(updateTime, 1000)
|
||||||
|
document.addEventListener('keydown', onKeydown)
|
||||||
if (authStore.token && !authStore.user) {
|
if (authStore.token && !authStore.user) {
|
||||||
await authStore.fetchProfile()
|
await authStore.fetchProfile()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
onUnmounted(() => clearInterval(timer))
|
onUnmounted(() => { clearInterval(timer); document.removeEventListener('keydown', onKeydown) })
|
||||||
|
|
||||||
const allNavItems = [
|
const allNavItems = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog :model-value="visible" @update:model-value="$emit('update:visible', $event)" title="数据导入" width="min(90vw, 560px)" destroy-on-close>
|
||||||
|
<div class="import-hint">请先下载模板,按格式填写后上传。导入只更新设备信息,不会删除已有设备。</div>
|
||||||
|
<div class="import-actions">
|
||||||
|
<el-button size="small" @click="$emit('download-template')">下载导入模板</el-button>
|
||||||
|
</div>
|
||||||
|
<el-upload
|
||||||
|
ref="uploadRef"
|
||||||
|
:auto-upload="false"
|
||||||
|
:show-file-list="true"
|
||||||
|
:limit="1"
|
||||||
|
accept=".xlsx,.xls"
|
||||||
|
:on-change="(f) => $emit('file-change', f)"
|
||||||
|
:on-remove="() => $emit('file-remove')"
|
||||||
|
drag
|
||||||
|
style="margin-top: 16px"
|
||||||
|
>
|
||||||
|
<div class="upload-area">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="color: var(--text-muted); margin-bottom: 8px">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
|
<polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
|
||||||
|
</svg>
|
||||||
|
<div style="font-size:13px;color:var(--text-secondary)">拖拽文件到此处,或 <em style="color:var(--accent)">点击选择</em></div>
|
||||||
|
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">支持 .xlsx / .xls 格式</div>
|
||||||
|
</div>
|
||||||
|
</el-upload>
|
||||||
|
<div v-if="result" style="margin-top:16px">
|
||||||
|
<el-alert
|
||||||
|
:type="result.failed?.length ? 'warning' : 'success'"
|
||||||
|
:title="`导入完成:成功 ${result.success} 条${result.failed?.length ? ',失败 ' + result.failed.length + ' 条' : ''}`"
|
||||||
|
:closable="false"
|
||||||
|
/>
|
||||||
|
<div v-if="result.failed?.length" style="margin-top:10px;max-height:160px;overflow-y:auto">
|
||||||
|
<div v-for="f in result.failed" :key="f.row" style="font-size:12px;color:var(--danger);padding:2px 0">
|
||||||
|
第 {{ f.row }} 行<template v-if="f.mac">({{ f.mac }})</template>:{{ f.reason }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="$emit('update:visible', false); $emit('close')">关闭</el-button>
|
||||||
|
<el-button type="primary" :loading="loading" :disabled="!hasFile" @click="$emit('import')">开始导入</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
visible: { type: Boolean, default: false },
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
hasFile: { type: Boolean, default: false },
|
||||||
|
result: { type: Object, default: null },
|
||||||
|
})
|
||||||
|
defineEmits(['update:visible', 'download-template', 'file-change', 'file-remove', 'import', 'close'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.import-hint { font-size:13px; color:var(--text-muted); margin-bottom:12px; }
|
||||||
|
.import-actions { margin-bottom:4px; }
|
||||||
|
.upload-area { display:flex; flex-direction:column; align-items:center; padding:20px 0; }
|
||||||
|
</style>
|
||||||
@@ -42,7 +42,7 @@ onMounted(async () => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.about-page {
|
.about-page {
|
||||||
padding: 24px 28px;
|
padding: 24px 28px;
|
||||||
max-width: 860px;
|
max-width: 1100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
|
|||||||
+156
-61
@@ -31,17 +31,59 @@
|
|||||||
</div>
|
</div>
|
||||||
<div ref="regionChart" class="chart-area"></div>
|
<div ref="regionChart" class="chart-area"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- OLT 设备在线率 -->
|
||||||
|
<div class="chart-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-2px;margin-right:6px">
|
||||||
|
<rect x="2" y="2" width="20" height="8" rx="1"/><rect x="2" y="14" width="20" height="8" rx="1"/>
|
||||||
|
<line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
OLT 设备在线率
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div ref="oltChart" class="chart-area" style="height:360px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 设备型号分布 -->
|
||||||
|
<div class="chart-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-2px;margin-right:6px">
|
||||||
|
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
|
||||||
|
<rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>
|
||||||
|
</svg>
|
||||||
|
设备型号分布
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div ref="modelChart" class="chart-area" style="height:360px"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
import { getTrend, getByRegion } from '../api/stats'
|
import { getTrend, getByRegion, getOltStats, getModelDistribution } from '../api/stats'
|
||||||
|
|
||||||
const trendChart = ref(null)
|
const trendChart = ref(null)
|
||||||
const regionChart = ref(null)
|
const regionChart = ref(null)
|
||||||
|
const oltChart = ref(null)
|
||||||
|
const modelChart = ref(null)
|
||||||
|
const chartInstances = []
|
||||||
|
|
||||||
|
const _initChart = (dom) => {
|
||||||
|
if (!dom) return null
|
||||||
|
const existing = echarts.getInstanceByDom(dom)
|
||||||
|
if (existing) existing.dispose()
|
||||||
|
const chart = echarts.init(dom)
|
||||||
|
chartInstances.push(chart)
|
||||||
|
return chart
|
||||||
|
}
|
||||||
|
|
||||||
|
const _resizeCharts = () => chartInstances.forEach(c => { try { c.resize() } catch {} })
|
||||||
|
|
||||||
const chartTheme = {
|
const chartTheme = {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
@@ -59,70 +101,60 @@ const chartTheme = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const initTrendChart = async () => {
|
const initTrendChart = async () => {
|
||||||
const { data } = await getTrend(7)
|
try {
|
||||||
const chart = echarts.init(trendChart.value)
|
const { data } = await getTrend(7)
|
||||||
chart.setOption({
|
if (!data || !data.length) return
|
||||||
...chartTheme,
|
const chart = _initChart(trendChart.value)
|
||||||
tooltip: { ...chartTheme.tooltip, trigger: 'axis' },
|
if (!chart) return
|
||||||
legend: {
|
chart.setOption({
|
||||||
data: ['在线', '离线'],
|
backgroundColor: 'transparent',
|
||||||
textStyle: { color: '#8a9ab8' },
|
textStyle: { color: '#8a9ab8', fontFamily: 'Noto Sans SC, sans-serif', fontSize: 12 },
|
||||||
top: 4,
|
grid: { left: '3%', right: '4%', bottom: '3%', top: '12%', containLabel: true },
|
||||||
},
|
tooltip: {
|
||||||
xAxis: {
|
trigger: 'axis',
|
||||||
type: 'category',
|
backgroundColor: '#141c30',
|
||||||
data: data.map(d => d.date),
|
borderColor: 'rgba(255,255,255,0.10)',
|
||||||
axisLine: chartTheme.axisLine,
|
textStyle: { color: '#e8edf5' },
|
||||||
axisTick: chartTheme.axisTick,
|
extraCssText: 'border-radius: 8px; box-shadow: 0 8px 32px rgba(0,0,0,0.4);'
|
||||||
axisLabel: { color: '#8a9ab8', fontSize: 11 },
|
|
||||||
},
|
|
||||||
yAxis: {
|
|
||||||
type: 'value',
|
|
||||||
axisLine: { show: false },
|
|
||||||
axisTick: { show: false },
|
|
||||||
axisLabel: { color: '#8a9ab8', fontSize: 11 },
|
|
||||||
splitLine: chartTheme.splitLine,
|
|
||||||
},
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: '在线',
|
|
||||||
type: 'line',
|
|
||||||
data: data.map(d => d.online),
|
|
||||||
smooth: true,
|
|
||||||
symbol: 'circle',
|
|
||||||
symbolSize: 5,
|
|
||||||
lineStyle: { color: '#00d2b4', width: 2 },
|
|
||||||
itemStyle: { color: '#00d2b4' },
|
|
||||||
areaStyle: {
|
|
||||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
||||||
{ offset: 0, color: 'rgba(0,210,180,0.20)' },
|
|
||||||
{ offset: 1, color: 'rgba(0,210,180,0.00)' },
|
|
||||||
])
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
legend: { data: ['在线', '离线'], textStyle: { color: '#8a9ab8' }, top: 4 },
|
||||||
name: '离线',
|
xAxis: {
|
||||||
type: 'line',
|
type: 'category', data: data.map(d => d.date),
|
||||||
data: data.map(d => d.offline),
|
axisLabel: { color: '#8a9ab8', fontSize: 11 },
|
||||||
smooth: true,
|
axisLine: { lineStyle: { color: 'rgba(255,255,255,0.08)' } },
|
||||||
symbol: 'circle',
|
|
||||||
symbolSize: 5,
|
|
||||||
lineStyle: { color: '#ef4444', width: 2 },
|
|
||||||
itemStyle: { color: '#ef4444' },
|
|
||||||
areaStyle: {
|
|
||||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
||||||
{ offset: 0, color: 'rgba(239,68,68,0.15)' },
|
|
||||||
{ offset: 1, color: 'rgba(239,68,68,0.00)' },
|
|
||||||
])
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
yAxis: {
|
||||||
})
|
type: 'value',
|
||||||
|
axisLabel: { color: '#8a9ab8', fontSize: 11 },
|
||||||
|
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.05)', type: 'dashed' } },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '在线', type: 'line', data: data.map(d => d.online), smooth: true,
|
||||||
|
symbol: 'circle', symbolSize: 5,
|
||||||
|
lineStyle: { color: '#00d2b4', width: 2 }, itemStyle: { color: '#00d2b4' },
|
||||||
|
areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: 'rgba(0,210,180,0.20)' }, { offset: 1, color: 'rgba(0,210,180,0.00)' }
|
||||||
|
])}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '离线', type: 'line', data: data.map(d => d.offline), smooth: true,
|
||||||
|
symbol: 'circle', symbolSize: 5,
|
||||||
|
lineStyle: { color: '#ef4444', width: 2 }, itemStyle: { color: '#ef4444' },
|
||||||
|
areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: 'rgba(239,68,68,0.15)' }, { offset: 1, color: 'rgba(239,68,68,0.00)' }
|
||||||
|
])}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Trend chart error:', e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const initRegionChart = async () => {
|
const initRegionChart = async () => {
|
||||||
const { data } = await getByRegion()
|
const { data } = await getByRegion()
|
||||||
const chart = echarts.init(regionChart.value)
|
const chart = _initChart(regionChart.value)
|
||||||
const colors = ['#00d2b4', '#3b82f6', '#a855f7', '#f59e0b', '#22c55e', '#ef4444', '#ec4899']
|
const colors = ['#00d2b4', '#3b82f6', '#a855f7', '#f59e0b', '#22c55e', '#ef4444', '#ec4899']
|
||||||
chart.setOption({
|
chart.setOption({
|
||||||
...chartTheme,
|
...chartTheme,
|
||||||
@@ -154,9 +186,72 @@ const initRegionChart = async () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
const initOltChart = async () => {
|
||||||
|
const { data } = await getOltStats()
|
||||||
|
const chart = _initChart(oltChart.value)
|
||||||
|
const sorted = [...data].sort((a, b) => {
|
||||||
|
const ra = a.online / (a.total || 1), rb = b.online / (b.total || 1)
|
||||||
|
return ra - rb
|
||||||
|
})
|
||||||
|
chart.setOption({
|
||||||
|
...chartTheme,
|
||||||
|
tooltip: { ...chartTheme.tooltip, trigger: 'axis', axisPointer: { type: 'shadow' },
|
||||||
|
formatter: (ps) => {
|
||||||
|
const d = ps[0]
|
||||||
|
return `<b>${d.name}</b><br/>在线: ${d.data.online}/${d.data.total}<br/>在线率: ${(d.data.online/(d.data.total||1)*100).toFixed(1)}%<br/>离线: ${d.data.offline}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: { left: '3%', right: '8%', bottom: '3%', top: '8%', containLabel: true },
|
||||||
|
xAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } },
|
||||||
|
yAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: sorted.map(d => d.name),
|
||||||
|
axisLabel: { fontSize: 11, width: 120, overflow: 'truncate' },
|
||||||
|
axisLine: { show: false }, axisTick: { show: false },
|
||||||
|
},
|
||||||
|
series: [{
|
||||||
|
type: 'bar',
|
||||||
|
data: sorted.map(d => ({
|
||||||
|
name: d.name, value: +(d.online / (d.total || 1) * 100).toFixed(1),
|
||||||
|
total: d.total, online: d.online, offline: d.offline,
|
||||||
|
itemStyle: { color: d.online === 0 ? '#ef4444' : +(d.online/(d.total||1)*100).toFixed(1) < 70 ? '#f59e0b' : '#00d2b4',
|
||||||
|
borderRadius: [0, 4, 4, 0] }
|
||||||
|
})),
|
||||||
|
barMaxWidth: 22,
|
||||||
|
label: { show: true, position: 'right', fontSize: 11, color: '#8a9ab8', formatter: '{c}%' },
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const initModelChart = async () => {
|
||||||
|
const { data } = await getModelDistribution()
|
||||||
|
if (!data.length) return
|
||||||
|
const chart = _initChart(modelChart.value)
|
||||||
|
const colors = ['#00d2b4','#3b82f6','#a855f7','#f59e0b','#22c55e','#ef4444','#ec4899','#6366f1','#14b8a6','#eab308']
|
||||||
|
chart.setOption({
|
||||||
|
...chartTheme,
|
||||||
|
tooltip: { ...chartTheme.tooltip, trigger: 'item', formatter: '{b}: {c} 台 ({d}%)' },
|
||||||
|
series: [{
|
||||||
|
type: 'pie', radius: ['42%','70%'], center: ['50%','50%'],
|
||||||
|
data: data.map((d, i) => ({ value: d.count, name: d.model, itemStyle: { color: colors[i % colors.length] } })),
|
||||||
|
label: { show: true, fontSize: 10, color: '#8a9ab8', formatter: '{b}\n{d}%' },
|
||||||
|
labelLine: { length: 16, length2: 12 },
|
||||||
|
emphasis: { itemStyle: { shadowBlur: 12, shadowColor: 'rgba(0,0,0,0.3)' } },
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick()
|
||||||
initTrendChart()
|
initTrendChart()
|
||||||
initRegionChart()
|
initRegionChart()
|
||||||
|
initOltChart()
|
||||||
|
initModelChart()
|
||||||
|
window.addEventListener('resize', _resizeCharts)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', _resizeCharts)
|
||||||
|
chartInstances.forEach(c => { try { c.dispose() } catch {} })
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,17 @@
|
|||||||
<h1 class="page-title">统计概览</h1>
|
<h1 class="page-title">统计概览</h1>
|
||||||
<span class="page-subtitle">实时监控 ONU 设备在线状态</span>
|
<span class="page-subtitle">实时监控 ONU 设备在线状态</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="refresh-btn" :class="{ loading }" @click="loadData">
|
<div class="header-actions">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" :class="{ spinning: loading }">
|
<button class="refresh-btn" :class="{ loading }" @click="loadData">
|
||||||
<polyline points="23 4 23 10 17 10"/>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" :class="{ spinning: loading }">
|
||||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
<polyline points="23 4 23 10 17 10"/>
|
||||||
</svg>
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||||
<span v-if="loading">刷新中…</span>
|
</svg>
|
||||||
<span v-else-if="lastUpdated">{{ lastUpdated }}</span>
|
<span v-if="loading">刷新中…</span>
|
||||||
<span v-else>刷新数据</span>
|
<span v-else-if="lastUpdated">{{ lastUpdated }}</span>
|
||||||
</button>
|
<span v-else>刷新数据</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 汇总卡片 -->
|
<!-- 汇总卡片 -->
|
||||||
@@ -170,6 +172,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 全离线学校(可折叠) -->
|
||||||
|
<div v-if="offlineSchools.length > 0" class="offline-alert" :class="{ collapsed: offlineCollapsed }">
|
||||||
|
<div class="offline-alert-header" @click="offlineCollapsed = !offlineCollapsed">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||||
|
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||||
|
</svg>
|
||||||
|
<span>全离线学校({{ offlineSchools.length }} 所)</span>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="collapse-arrow" :class="{ rotated: !offlineCollapsed }">
|
||||||
|
<polyline points="6 9 12 15 18 9"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div v-show="!offlineCollapsed" class="offline-schools-list">
|
||||||
|
<span v-for="s in offlineSchools" :key="s.school_name" class="offline-school-tag" @click="goToSchool(s.school_name)">
|
||||||
|
{{ s.school_name }}({{ s.total }}台)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 乡镇详情对话框 -->
|
<!-- 乡镇详情对话框 -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="townVisible"
|
v-model="townVisible"
|
||||||
@@ -197,9 +218,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import request from '../utils/request'
|
import request from '../utils/request'
|
||||||
|
import { getOfflineSchools } from '../api/stats'
|
||||||
import { useMobile } from '../composables/useMobile'
|
import { useMobile } from '../composables/useMobile'
|
||||||
|
|
||||||
const { isMobile } = useMobile()
|
const { isMobile } = useMobile()
|
||||||
@@ -210,6 +232,8 @@ const data = ref({})
|
|||||||
const lastUpdated = ref('')
|
const lastUpdated = ref('')
|
||||||
const townVisible = ref(false)
|
const townVisible = ref(false)
|
||||||
const selectedTown = ref(null)
|
const selectedTown = ref(null)
|
||||||
|
const offlineSchools = ref([])
|
||||||
|
const offlineCollapsed = ref(true)
|
||||||
|
|
||||||
const rate = (item) => {
|
const rate = (item) => {
|
||||||
if (!item || !item.total) return 0
|
if (!item || !item.total) return 0
|
||||||
@@ -239,6 +263,13 @@ const goToSchool = (schoolName) => {
|
|||||||
router.push({ path: '/devices', query: { school_name: schoolName } })
|
router.push({ path: '/devices', query: { school_name: schoolName } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadOfflineSchools = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await getOfflineSchools()
|
||||||
|
offlineSchools.value = data || []
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -248,9 +279,24 @@ const loadData = async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
loadOfflineSchools()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadData)
|
let ws = null
|
||||||
|
const connectWs = () => {
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
ws = new WebSocket(`${proto}//${location.host}/api/ws/dashboard`)
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(e.data)
|
||||||
|
if (msg.type === 'check_complete') loadData()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
ws.onclose = () => { setTimeout(connectWs, 10000) }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { loadData(); connectWs() })
|
||||||
|
onUnmounted(() => { if (ws) ws.close() })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -286,6 +332,34 @@ onMounted(loadData)
|
|||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
.refresh-btn {
|
.refresh-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -352,6 +426,64 @@ onMounted(loadData)
|
|||||||
box-shadow: var(--shadow-glow);
|
box-shadow: var(--shadow-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 全离线学校告警 */
|
||||||
|
.offline-alert {
|
||||||
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 14px 18px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-alert-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ef4444;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-alert.collapsed .offline-alert-header {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-arrow {
|
||||||
|
margin-left: auto;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-arrow.rotated {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-schools-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-school-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-school-tag:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes card-in {
|
@keyframes card-in {
|
||||||
from { opacity: 0; transform: translateY(12px); }
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
|||||||
@@ -16,6 +16,13 @@
|
|||||||
<el-button v-if="can('device.import')" type="success" size="small" @click="importDialogVisible = true">
|
<el-button v-if="can('device.import')" type="success" size="small" @click="importDialogVisible = true">
|
||||||
数据导入
|
数据导入
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<a href="/api/devices/export/csv" class="csv-export-btn" title="导出CSV">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<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
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -49,6 +56,12 @@
|
|||||||
<el-option label="未知" value="unknown" />
|
<el-option label="未知" value="unknown" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label class="filter-label">标签</label>
|
||||||
|
<el-select v-model="filters.tag" placeholder="全部" clearable @change="search" size="small" style="width:130px">
|
||||||
|
<el-option v-for="t in availableTags" :key="t" :label="t" :value="t" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<label class="filter-label">搜索</label>
|
<label class="filter-label">搜索</label>
|
||||||
<el-input
|
<el-input
|
||||||
@@ -428,6 +441,7 @@
|
|||||||
<el-input v-model="editForm.place_type" placeholder="如:宿舍、教室、办公室…" />
|
<el-input v-model="editForm.place_type" placeholder="如:宿舍、教室、办公室…" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="备注"><el-input v-model="editForm.notes" type="textarea" :rows="2" /></el-form-item>
|
<el-form-item label="备注"><el-input v-model="editForm.notes" type="textarea" :rows="2" /></el-form-item>
|
||||||
|
<el-form-item label="标签"><el-input v-model="editForm.tags" placeholder="多个标签用逗号分隔,如:重点设备,考试用" /></el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="editVisible = false">取消</el-button>
|
<el-button @click="editVisible = false">取消</el-button>
|
||||||
@@ -632,7 +646,8 @@ const total = ref(0)
|
|||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(20)
|
const pageSize = ref(20)
|
||||||
const regions = ref([])
|
const regions = ref([])
|
||||||
const filters = ref({ region: '', keyword: '', school_name: '', status: '' })
|
const filters = ref({ region: '', keyword: '', school_name: '', status: '', tag: '' })
|
||||||
|
const availableTags = ref([])
|
||||||
|
|
||||||
const detailVisible = ref(false)
|
const detailVisible = ref(false)
|
||||||
const selectedDevice = ref({})
|
const selectedDevice = ref({})
|
||||||
@@ -647,7 +662,7 @@ let cooldownTimer = null
|
|||||||
const clearing = ref(false)
|
const clearing = ref(false)
|
||||||
|
|
||||||
const editVisible = ref(false)
|
const editVisible = ref(false)
|
||||||
const editForm = ref({ region: '', school_name: '', building: '', room_number: '', place_type: '', notes: '' })
|
const editForm = ref({ region: '', school_name: '', building: '', room_number: '', place_type: '', notes: '', tags: '' })
|
||||||
const regionOptions = ref([])
|
const regionOptions = ref([])
|
||||||
const editSaving = ref(false)
|
const editSaving = ref(false)
|
||||||
|
|
||||||
@@ -1003,6 +1018,7 @@ const openEdit = () => {
|
|||||||
room_number: selectedDevice.value.room_number || '',
|
room_number: selectedDevice.value.room_number || '',
|
||||||
place_type: selectedDevice.value.place_type || '',
|
place_type: selectedDevice.value.place_type || '',
|
||||||
notes: selectedDevice.value.notes || '',
|
notes: selectedDevice.value.notes || '',
|
||||||
|
tags: selectedDevice.value.tags || '',
|
||||||
}
|
}
|
||||||
loadRegionOptions()
|
loadRegionOptions()
|
||||||
detailVisible.value = false
|
detailVisible.value = false
|
||||||
@@ -1063,6 +1079,7 @@ const loadDevices = async () => {
|
|||||||
keyword: filters.value.keyword || undefined,
|
keyword: filters.value.keyword || undefined,
|
||||||
school_name: filters.value.school_name || undefined,
|
school_name: filters.value.school_name || undefined,
|
||||||
status: filters.value.status || undefined,
|
status: filters.value.status || undefined,
|
||||||
|
tag: filters.value.tag || undefined,
|
||||||
})
|
})
|
||||||
devices.value = data.items
|
devices.value = data.items
|
||||||
total.value = data.total
|
total.value = data.total
|
||||||
@@ -1079,12 +1096,20 @@ const handleSizeChange = (val) => {
|
|||||||
loadDevices()
|
loadDevices()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchTags = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await request.get('/devices/tags')
|
||||||
|
availableTags.value = data || []
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (route.query.school_name) {
|
if (route.query.school_name) {
|
||||||
filters.value.keyword = route.query.school_name
|
filters.value.keyword = route.query.school_name
|
||||||
}
|
}
|
||||||
loadRegions()
|
loadRegions()
|
||||||
loadDevices()
|
loadDevices()
|
||||||
|
fetchTags()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -1123,6 +1148,26 @@ onMounted(() => {
|
|||||||
.header-actions {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-export-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.csv-export-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 筛选栏 */
|
/* 筛选栏 */
|
||||||
|
|||||||
@@ -1026,6 +1026,11 @@ const formatTime = (t) => fmtTimeRaw(t, { slice: 16 })
|
|||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.inventory-page { padding: 12px; }
|
||||||
|
.summary-cards { grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||||
|
}
|
||||||
|
|
||||||
.summary-cards {
|
.summary-cards {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-wrap { padding: 24px; max-width: 1100px; }
|
.page-wrap { padding: 24px; }
|
||||||
.page-header { margin-bottom: 20px; }
|
.page-header { margin-bottom: 20px; }
|
||||||
.page-title { font-size: 18px; font-weight: 600; color: var(--text-primary); margin: 0; }
|
.page-title { font-size: 18px; font-weight: 600; color: var(--text-primary); margin: 0; }
|
||||||
.layout { display: flex; gap: 20px; align-items: flex-start; }
|
.layout { display: flex; gap: 20px; align-items: flex-start; }
|
||||||
|
|||||||
@@ -99,6 +99,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 企业微信告警设置 -->
|
||||||
|
<div class="settings-card" style="margin-top: 20px">
|
||||||
|
<div class="card-header">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: -2px; margin-right: 8px">
|
||||||
|
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||||||
|
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||||
|
</svg>
|
||||||
|
企业微信告警
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="setting-desc">当某个学校所有设备全部离线时,通过企业微信应用消息 API 发送告警。请填写企业微信自建应用的凭证信息。</p>
|
||||||
|
<div class="wechat-field">
|
||||||
|
<label class="wechat-label">CorpID</label>
|
||||||
|
<input v-model="wechatCorpId" class="webhook-input" placeholder="企业ID" :disabled="wechatSaving" />
|
||||||
|
</div>
|
||||||
|
<div class="wechat-field">
|
||||||
|
<label class="wechat-label">CorpSecret</label>
|
||||||
|
<input v-model="wechatCorpSecret" type="password" class="webhook-input" placeholder="应用 Secret" :disabled="wechatSaving" />
|
||||||
|
</div>
|
||||||
|
<div class="wechat-field">
|
||||||
|
<label class="wechat-label">AgentID</label>
|
||||||
|
<input v-model="wechatAgentId" class="webhook-input" placeholder="应用 AgentID" :disabled="wechatSaving" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-footer">
|
||||||
|
<button class="save-btn" :disabled="wechatSaving" @click="saveWebhook">
|
||||||
|
<svg v-if="wechatSaving" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spinning">
|
||||||
|
<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||||
|
</svg>
|
||||||
|
{{ wechatSaving ? '保存中…' : '保存设置' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -179,6 +214,7 @@ const save = async () => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
load()
|
load()
|
||||||
loadAbout()
|
loadAbout()
|
||||||
|
loadWebhook()
|
||||||
const timer = setInterval(load, 10000)
|
const timer = setInterval(load, 10000)
|
||||||
onUnmounted(() => clearInterval(timer))
|
onUnmounted(() => clearInterval(timer))
|
||||||
})
|
})
|
||||||
@@ -204,12 +240,43 @@ const saveAbout = async () => {
|
|||||||
aboutSaving.value = false
|
aboutSaving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wechatCorpId = ref('')
|
||||||
|
const wechatCorpSecret = ref('')
|
||||||
|
const wechatAgentId = ref('')
|
||||||
|
const wechatSaving = ref(false)
|
||||||
|
|
||||||
|
const loadWebhook = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await getSettings()
|
||||||
|
const setVal = (key, ref) => { const s = data?.[key]; if (s) ref.value = s.value || '' }
|
||||||
|
setVal('wechat_corpid', wechatCorpId)
|
||||||
|
setVal('wechat_corpsecret', wechatCorpSecret)
|
||||||
|
setVal('wechat_agentid', wechatAgentId)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveWebhook = async () => {
|
||||||
|
wechatSaving.value = true
|
||||||
|
try {
|
||||||
|
await request.put('/settings/webhook', {
|
||||||
|
corpid: wechatCorpId.value,
|
||||||
|
corpsecret: wechatCorpSecret.value,
|
||||||
|
agentid: wechatAgentId.value,
|
||||||
|
})
|
||||||
|
ElMessage.success('企业微信配置已保存')
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e?.response?.data?.detail || '保存失败')
|
||||||
|
} finally {
|
||||||
|
wechatSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.settings-page {
|
.settings-page {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
max-width: 640px;
|
max-width: 960px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
@@ -427,6 +494,54 @@ const saveAbout = async () => {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.webhook-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 0 14px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-input:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wechat-section-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wechat-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wechat-label {
|
||||||
|
width: 90px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.settings-page {
|
.settings-page {
|
||||||
padding: 16px 12px;
|
padding: 16px 12px;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>用户名</th>
|
<th>用户名</th>
|
||||||
<th>邮箱</th>
|
<th>姓名</th>
|
||||||
<th>角色</th>
|
<th>角色</th>
|
||||||
<th>区域/学校</th>
|
<th>区域/学校</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr v-for="u in users" :key="u.id">
|
<tr v-for="u in users" :key="u.id">
|
||||||
<td class="td-username">{{ u.username }}</td>
|
<td class="td-username">{{ u.username }}</td>
|
||||||
<td class="td-muted">{{ u.email || '—' }}</td>
|
<td class="td-muted">{{ u.display_name || u.username || '—' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="role-badge" :class="'role-' + u.role">{{ roleLabel(u.role) }}</span>
|
<span class="role-badge" :class="'role-' + u.role">{{ roleLabel(u.role) }}</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
<div v-if="editUser" class="modal-overlay" @click.self="editUser = null">
|
<div v-if="editUser" class="modal-overlay" @click.self="editUser = null">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<span class="modal-title">编辑用户:{{ editUser.username }}</span>
|
<span class="modal-title">编辑用户:{{ editUser.display_name || editUser.username }}</span>
|
||||||
<button class="modal-close" @click="editUser = null">✕</button>
|
<button class="modal-close" @click="editUser = null">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
@@ -330,7 +330,7 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-wrap { padding: 24px; max-width: 1200px; }
|
.page-wrap { padding: 24px; }
|
||||||
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; flex-wrap: wrap; gap: 12px; }
|
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; flex-wrap: wrap; gap: 12px; }
|
||||||
.page-title { font-size: 18px; font-weight: 600; color: var(--text-primary); margin: 0; }
|
.page-title { font-size: 18px; font-weight: 600; color: var(--text-primary); margin: 0; }
|
||||||
.header-filters { display: flex; gap: 10px; }
|
.header-filters { display: flex; gap: 10px; }
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ export default defineConfig({
|
|||||||
'/api': {
|
'/api': {
|
||||||
target: process.env.VITE_API_PROXY_TARGET || 'http://localhost:8001',
|
target: process.env.VITE_API_PROXY_TARGET || 'http://localhost:8001',
|
||||||
changeOrigin: true
|
changeOrigin: true
|
||||||
|
},
|
||||||
|
'/ws': {
|
||||||
|
target: (process.env.VITE_API_PROXY_TARGET || 'http://localhost:8001').replace('http', 'ws'),
|
||||||
|
ws: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user