feat(alerts): 添加微信告警、WebSocket实时推送和地图功能

- 新增微信告警服务(wechat_service)和告警任务(alert_tasks)
- 新增 WebSocket 实时推送端点
- 新增监控管理模块(monitor)
- 增强统计仪表板:趋势图、区域分布、光功率历史
- 设备管理:添加坐标信息、标签系统、复合索引优化
- 前端:重构Dashboard/Charts页面,新增业务组件
- 新增5个数据库迁移(坐标、复合索引、标签、光功率历史、显示名)
- 更新部署配置和脚本
- 新增测试框架基础结构
This commit is contained in:
2026-06-11 15:16:29 +08:00
parent 3453441754
commit b2c20ec43d
45 changed files with 2089 additions and 107 deletions
+9
View File
@@ -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')
+6 -1
View File
@@ -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()
+6 -2
View File
@@ -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()
+92
View File
@@ -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"}
)
+45
View File
@@ -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)}
+26
View File
@@ -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
View File
@@ -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
]
+1 -1
View File
@@ -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()
+240
View File
@@ -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
)
+46
View File
@@ -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)
+9
View File
@@ -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
View File
@@ -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
+15 -1
View File
@@ -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"
+1
View File
@@ -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))
+1
View File
@@ -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
+24 -6
View File
@@ -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)
+331
View File
@@ -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)
+82
View File
@@ -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()
+10
View File
@@ -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,
+1
View File
@@ -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']
+5
View File
@@ -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
View File
+18
View File
@@ -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)
+42
View File
@@ -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')) == ''
+93
View File
@@ -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
+9
View File
@@ -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
+21
View File
@@ -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:
+21 -2
View File
@@ -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}"
} }
# 函数:恢复数据 # 函数:恢复数据
+8
View File
@@ -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')
+10 -1
View File
@@ -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>
+1 -1
View File
@@ -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
View File
@@ -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>
+143 -11
View File
@@ -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); }
+47 -2
View File
@@ -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);
} }
/* 筛选栏 */ /* 筛选栏 */
+5
View File
@@ -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;
+1 -1
View File
@@ -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; }
+116 -1
View File
@@ -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;
+4 -4
View File
@@ -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; }
+4
View File
@@ -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
} }
} }
} }