diff --git a/backend/.env.example b/backend/.env.example index d351d66..36b79d4 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -24,3 +24,12 @@ SSH_TIMEOUT=30 # 任务配置 CHECK_INTERVAL=1800 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 diff --git a/backend/alembic/versions/i9j0k1l2m3n4_add_onu_coordinates.py b/backend/alembic/versions/i9j0k1l2m3n4_add_onu_coordinates.py new file mode 100644 index 0000000..e0312f7 --- /dev/null +++ b/backend/alembic/versions/i9j0k1l2m3n4_add_onu_coordinates.py @@ -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') diff --git a/backend/alembic/versions/j0k1l2m3n4o5_add_composite_indexes.py b/backend/alembic/versions/j0k1l2m3n4o5_add_composite_indexes.py new file mode 100644 index 0000000..e15a3a7 --- /dev/null +++ b/backend/alembic/versions/j0k1l2m3n4o5_add_composite_indexes.py @@ -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') diff --git a/backend/alembic/versions/k0l1m2n3o4p5_add_device_tags.py b/backend/alembic/versions/k0l1m2n3o4p5_add_device_tags.py new file mode 100644 index 0000000..8700730 --- /dev/null +++ b/backend/alembic/versions/k0l1m2n3o4p5_add_device_tags.py @@ -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') diff --git a/backend/alembic/versions/l1m2n3o4p5q6_add_optical_power_history.py b/backend/alembic/versions/l1m2n3o4p5q6_add_optical_power_history.py new file mode 100644 index 0000000..c6bb6a1 --- /dev/null +++ b/backend/alembic/versions/l1m2n3o4p5q6_add_optical_power_history.py @@ -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') diff --git a/backend/alembic/versions/m1n2o3p4q5r6_add_user_display_name.py b/backend/alembic/versions/m1n2o3p4q5r6_add_user_display_name.py new file mode 100644 index 0000000..a3ed4b6 --- /dev/null +++ b/backend/alembic/versions/m1n2o3p4q5r6_add_user_display_name.py @@ -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') diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 82ef0fa..3d72568 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -53,11 +53,16 @@ def callback(body: CallbackRequest, db: Session = Depends(get_db)): if not user: user = User( 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"), role="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() db.commit() diff --git a/backend/app/api/v1/check.py b/backend/app/api/v1/check.py index 2e55540..39b7cab 100644 --- a/backend/app/api/v1/check.py +++ b/backend/app/api/v1/check.py @@ -1,11 +1,13 @@ """状态检查 API""" import asyncio import logging -from fastapi import APIRouter, HTTPException, Depends +from fastapi import APIRouter, HTTPException, Depends, Request from celery.result import AsyncResult from pydantic import BaseModel from typing import Optional, List 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.core.celery_app import celery_app from app.core.database import get_db @@ -14,6 +16,7 @@ from app.middleware.permission_middleware import require_permission logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/check", tags=["状态检查"]) +limiter = Limiter(key_func=get_remote_address) class CheckResult(BaseModel): @@ -31,7 +34,8 @@ class CheckError(BaseModel): @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: task = check_all_devices.delay() diff --git a/backend/app/api/v1/devices.py b/backend/app/api/v1/devices.py index 328c7a0..f27868a 100644 --- a/backend/app/api/v1/devices.py +++ b/backend/app/api/v1/devices.py @@ -1,5 +1,8 @@ """设备管理 API""" +import csv +import io from fastapi import APIRouter, Depends, Query, HTTPException +from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session, joinedload from sqlalchemy import asc, desc, distinct, or_ from pydantic import BaseModel @@ -20,6 +23,7 @@ def get_devices( school_name: str = None, keyword: str = None, status: str = None, + tag: str = None, db: Session = Depends(get_db), current: dict = Depends(require_permission('device.view')), ): @@ -68,6 +72,8 @@ def get_devices( query = query.filter(ONUDevice.region == region) if school_name: query = query.filter(ONUDevice.school_name.contains(school_name)) + if tag: + query = query.filter(ONUDevice.tags.contains(tag)) if keyword: query = query.filter( or_( @@ -395,6 +401,7 @@ class DeviceUpdate(BaseModel): room_number: Optional[str] = None place_type: Optional[str] = None notes: Optional[str] = None + tags: Optional[str] = None class DeviceReplaceRequest(BaseModel): @@ -430,6 +437,7 @@ def update_device( device.room_number = body.room_number or None device.place_type = body.place_type or None device.notes = body.notes or None + device.tags = body.tags or None # 若该设备 MAC 在 new_devices 待入库列表中,自动移除(已在设备列表中补全信息) from app.models.device import NewDevice @@ -613,6 +621,18 @@ def get_device_optical_power( data = IMCService().get_optical_power(device.mac_address) if data is None: 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( power_in=data.get("powerIn"), power_out=data.get("powerOut"), @@ -661,3 +681,75 @@ def get_onu_events( raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}") finally: 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"} + ) diff --git a/backend/app/api/v1/monitor.py b/backend/app/api/v1/monitor.py new file mode 100644 index 0000000..4a09cc5 --- /dev/null +++ b/backend/app/api/v1/monitor.py @@ -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)} diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py index 01952a4..eb6277e 100644 --- a/backend/app/api/v1/settings.py +++ b/backend/app/api/v1/settings.py @@ -98,3 +98,29 @@ def update_about( db.add(SystemSetting(key='about_content', value=content, description='关于页面内容(Markdown)')) db.commit() 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": "企业微信配置已保存"} + + + diff --git a/backend/app/api/v1/stats.py b/backend/app/api/v1/stats.py index 5b55ed5..9adec18 100644 --- a/backend/app/api/v1/stats.py +++ b/backend/app/api/v1/stats.py @@ -179,16 +179,14 @@ def get_trend( ) snapshot_map = {s.snapshot_date: s for s in snapshots} - # 今天实时聚合 + # 今天实时聚合 — 取每个设备最新状态(不限日期),反映真实当前状况 today_str = today.strftime('%Y-%m-%d') - start_of_today = datetime.combine(today, datetime.min.time()) - daily_latest_subq = ( + latest_subq = ( db.query( DeviceStatusHistory.onu_device_id, func.max(DeviceStatusHistory.checked_at).label("max_checked_at"), ) - .filter(DeviceStatusHistory.checked_at >= start_of_today) .group_by(DeviceStatusHistory.onu_device_id) .subquery() ) @@ -198,9 +196,9 @@ def get_trend( func.sum(case((DeviceStatusHistory.status == 'offline', 1), else_=0)).label("offline"), ) .join( - daily_latest_subq, - (DeviceStatusHistory.onu_device_id == daily_latest_subq.c.onu_device_id) & - (DeviceStatusHistory.checked_at == daily_latest_subq.c.max_checked_at) + latest_subq, + (DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) & + (DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at) ) .one() ) @@ -246,3 +244,175 @@ def get_trend( 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 + ] + diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index 4e33658..a110f70 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -28,7 +28,7 @@ def get_users( query = query.filter(User.role == role) if keyword: 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)) total = query.count() diff --git a/backend/app/api/v1/wechat.py b/backend/app/api/v1/wechat.py new file mode 100644 index 0000000..b09c069 --- /dev/null +++ b/backend/app/api/v1/wechat.py @@ -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 + ) diff --git a/backend/app/api/v1/ws.py b/backend/app/api/v1/ws.py new file mode 100644 index 0000000..7b45c4a --- /dev/null +++ b/backend/app/api/v1/ws.py @@ -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) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 18ffbf6..0e43c5f 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -29,6 +29,15 @@ class Settings(BaseSettings): # iMC API 配置(用于 ONU 远程重启和光功率查询) 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_PASSWORD: str = "" IMC_API_VERIFY_SSL: bool = False diff --git a/backend/app/main.py b/backend/app/main.py index f11842f..c115fc6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,15 +1,52 @@ """FastAPI 主应用""" -from fastapi import FastAPI +import logging +from pythonjsonlogger import jsonlogger +from fastapi import FastAPI, Request 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.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 -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( CORSMiddleware, - allow_origins=["*"], + allow_origins=allowed if allowed else ["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -28,8 +65,35 @@ app.include_router(roles.router) app.include_router(inventory.router) app.include_router(settings_api.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") 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 diff --git a/backend/app/models/device.py b/backend/app/models/device.py index 4503cda..80c20b8 100644 --- a/backend/app/models/device.py +++ b/backend/app/models/device.py @@ -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.sql import func from app.core.database import Base @@ -39,6 +39,9 @@ class ONUDevice(Base): place_type = Column(String(50)) # 场所类型 room_number = Column(String(50)) notes = Column(Text) # 备注 + tags = Column(Text) # 标签(逗号分隔),如 "重点设备,考试用" + latitude = Column(Float, nullable=True) # 纬度 + longitude = Column(Float, nullable=True) # 经度 created_at = Column(TIMESTAMP, server_default=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()) +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): """设备更换记录""" __tablename__ = "device_replacements" diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 824b734..bde3e35 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -10,6 +10,7 @@ class User(Base): id = Column(BigInteger, primary_key=True, index=True) casdoor_id = Column(String(100), unique=True, nullable=False) username = Column(String(100), nullable=False) + display_name = Column(String(100)) # 中文姓名 email = Column(String(255)) role = Column(String(50), default="user") assigned_area = Column(String(100)) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 5327aa2..54d1eb3 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -7,6 +7,7 @@ from datetime import datetime class UserListItem(BaseModel): id: int username: str + display_name: Optional[str] = None email: Optional[str] = None role: str assigned_area: Optional[str] = None diff --git a/backend/app/services/check_service.py b/backend/app/services/check_service.py index 5ec7c8b..a3bcd56 100644 --- a/backend/app/services/check_service.py +++ b/backend/app/services/check_service.py @@ -1,4 +1,5 @@ """设备状态检查服务""" +import time from typing import List, Dict, Optional from sqlalchemy.orm import Session from app.services.ssh_service import SSHService @@ -6,6 +7,26 @@ from app.models.device import OLTDevice, ONUDevice, DeviceStatusHistory, Duplica from datetime import datetime 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]: """将距离字符串转为整数,如 '<1000' -> 1000, '1234' -> 1234""" @@ -29,9 +50,8 @@ class CheckService: if not olt: 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: - ssh.connect() output = ssh.execute_command(olt.slot_command) onu_info_dict, _ = ssh.parse_onu_info(output) @@ -160,9 +180,8 @@ class CheckService: if not olt: 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: - ssh.connect() output = ssh.execute_command(olt.slot_command) onu_info_dict, duplicate_dict = ssh.parse_onu_info(output) @@ -241,9 +260,8 @@ class CheckService: if not olt: 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: - ssh.connect() output = ssh.execute_command(olt.slot_command) onu_info_dict, duplicate_dict = ssh.parse_onu_info(output) diff --git a/backend/app/services/wechat_service.py b/backend/app/services/wechat_service.py new file mode 100644 index 0000000..4327381 --- /dev/null +++ b/backend/app/services/wechat_service.py @@ -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) diff --git a/backend/app/tasks/alert_tasks.py b/backend/app/tasks/alert_tasks.py new file mode 100644 index 0000000..b54cf55 --- /dev/null +++ b/backend/app/tasks/alert_tasks.py @@ -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 = [ + "## [告警] 学校全部离线", + 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() diff --git a/backend/app/tasks/check_tasks.py b/backend/app/tasks/check_tasks.py index ce005c9..e78180d 100644 --- a/backend/app/tasks/check_tasks.py +++ b/backend/app/tasks/check_tasks.py @@ -97,6 +97,16 @@ def check_all_devices(self): 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 { 'success': True, 'total_olts': total, diff --git a/backend/celery_worker.py b/backend/celery_worker.py index 604449f..44ef6e8 100644 --- a/backend/celery_worker.py +++ b/backend/celery_worker.py @@ -2,5 +2,6 @@ from app.core.celery_app import celery_app from app.tasks import check_tasks # noqa: F401 - 导入以注册任务 from app.tasks import audit_tasks # noqa: F401 - 导入以注册任务 +from app.tasks import alert_tasks # noqa: F401 - 导入以注册任务 __all__ = ['celery_app'] diff --git a/backend/requirements.txt b/backend/requirements.txt index 0aeb435..ce57de6 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -14,6 +14,11 @@ paramiko==3.4.0 pandas==2.1.4 openpyxl==3.1.2 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 aiohttp>=3.9.0 PyJWT>=2.8.0 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..7170242 --- /dev/null +++ b/backend/tests/conftest.py @@ -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) diff --git a/backend/tests/test_import_service.py b/backend/tests/test_import_service.py new file mode 100644 index 0000000..00b2dfb --- /dev/null +++ b/backend/tests/test_import_service.py @@ -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')) == '' diff --git a/backend/tests/test_ssh_parser.py b/backend/tests/test_ssh_parser.py new file mode 100644 index 0000000..d938da2 --- /dev/null +++ b/backend/tests/test_ssh_parser.py @@ -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 diff --git a/deploy/.env.example b/deploy/.env.example index 6499db0..1da49ce 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -168,6 +168,15 @@ RATE_LIMIT_PER_HOUR=1000 # 数据库备份目录 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 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index bc3426b..f864411 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -18,6 +18,13 @@ services: - CASDOOR_APP_NAME=${CASDOOR_APP_NAME} - SECRET_KEY=${SECRET_KEY} - 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: - ../backend/logs:/app/logs - ../backend/static:/app/static @@ -46,6 +53,13 @@ services: - CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME} - CASDOOR_APP_NAME=${CASDOOR_APP_NAME} - 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: - ../backend/logs:/app/logs depends_on: @@ -69,6 +83,13 @@ services: - CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME} - CASDOOR_APP_NAME=${CASDOOR_APP_NAME} - 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: - ../backend/logs:/app/logs depends_on: diff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh index 9d190e6..6ef49d5 100755 --- a/deploy/scripts/deploy.sh +++ b/deploy/scripts/deploy.sh @@ -216,12 +216,31 @@ backup_data() { echo "环境: $ENV" >> "$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}" tar -czf "$BACKUP_DIR.tar.gz" "$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}" } # 函数:恢复数据 diff --git a/frontend/src/api/stats.js b/frontend/src/api/stats.js index 5ecba01..b02fe39 100644 --- a/frontend/src/api/stats.js +++ b/frontend/src/api/stats.js @@ -5,3 +5,11 @@ export const getSummary = () => request.get('/stats/summary') export const getTrend = (days = 7) => request.get('/stats/trend', { params: { days } }) 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') + + diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue index 6ddd82d..8e2ed3e 100644 --- a/frontend/src/components/Layout.vue +++ b/frontend/src/components/Layout.vue @@ -210,14 +210,23 @@ const updateTime = () => { currentTime.value = now.toLocaleTimeString('zh-CN', { hour12: false }) } 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 () => { updateTime() timer = setInterval(updateTime, 1000) + document.addEventListener('keydown', onKeydown) if (authStore.token && !authStore.user) { await authStore.fetchProfile() } }) -onUnmounted(() => clearInterval(timer)) +onUnmounted(() => { clearInterval(timer); document.removeEventListener('keydown', onKeydown) }) const allNavItems = [ { diff --git a/frontend/src/components/business/DeviceImportDialog.vue b/frontend/src/components/business/DeviceImportDialog.vue new file mode 100644 index 0000000..ea6f76b --- /dev/null +++ b/frontend/src/components/business/DeviceImportDialog.vue @@ -0,0 +1,60 @@ + + + 请先下载模板,按格式填写后上传。导入只更新设备信息,不会删除已有设备。 + + 下载导入模板 + + + + + + + + 拖拽文件到此处,或 点击选择 + 支持 .xlsx / .xls 格式 + + + + + + + 第 {{ f.row }} 行({{ f.mac }}):{{ f.reason }} + + + + + 关闭 + 开始导入 + + + + + + + diff --git a/frontend/src/views/About.vue b/frontend/src/views/About.vue index 1f127e7..4ba10dc 100644 --- a/frontend/src/views/About.vue +++ b/frontend/src/views/About.vue @@ -42,7 +42,7 @@ onMounted(async () => {