Compare commits
1 Commits
ipam-0.0.3
...
5b93e4536f
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b93e4536f |
Binary file not shown.
Binary file not shown.
@@ -5,6 +5,7 @@ from typing import Optional
|
|||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import get_current_user
|
from app.core.security import get_current_user
|
||||||
from app.services.alert_service import AlertService
|
from app.services.alert_service import AlertService
|
||||||
|
from app.schemas._tz_util import serialize_dt_fields, serialize_dt_list, to_business_iso
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/alerts",
|
prefix="/alerts",
|
||||||
@@ -37,7 +38,7 @@ def get_alerts(
|
|||||||
total = query.count()
|
total = query.count()
|
||||||
items = query.order_by(Alert.created_at.desc()).offset(skip).limit(limit).all()
|
items = query.order_by(Alert.created_at.desc()).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
return {"total": total, "items": items}
|
return {"total": total, "items": serialize_dt_list(items)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{alert_id}", summary="获取告警详情")
|
@router.get("/{alert_id}", summary="获取告警详情")
|
||||||
@@ -47,7 +48,7 @@ def get_alert(alert_id: int, db: Session = Depends(get_db)):
|
|||||||
alert = db.query(Alert).filter(Alert.id == alert_id).first()
|
alert = db.query(Alert).filter(Alert.id == alert_id).first()
|
||||||
if not alert:
|
if not alert:
|
||||||
raise HTTPException(status_code=404, detail="告警不存在")
|
raise HTTPException(status_code=404, detail="告警不存在")
|
||||||
return alert
|
return serialize_dt_fields(alert)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{alert_id}/acknowledge", summary="确认告警")
|
@router.post("/{alert_id}/acknowledge", summary="确认告警")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from app.core.security import get_current_user, require_permission
|
|||||||
from app.models.auth import User
|
from app.models.auth import User
|
||||||
from app.models.audit import AuditAction, AuditResource
|
from app.models.audit import AuditAction, AuditResource
|
||||||
from app.services.audit_service import AuditService
|
from app.services.audit_service import AuditService
|
||||||
|
from app.schemas._tz_util import to_business_iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/audit", tags=["审计日志"])
|
router = APIRouter(prefix="/audit", tags=["审计日志"])
|
||||||
|
|
||||||
@@ -66,7 +67,7 @@ def get_audit_logs(
|
|||||||
"request_path": item.request_path,
|
"request_path": item.request_path,
|
||||||
"success": bool(item.success),
|
"success": bool(item.success),
|
||||||
"error_message": item.error_message,
|
"error_message": item.error_message,
|
||||||
"created_at": item.created_at
|
"created_at": to_business_iso(item.created_at)
|
||||||
})
|
})
|
||||||
|
|
||||||
return {"total": total, "items": result_items}
|
return {"total": total, "items": result_items}
|
||||||
|
|||||||
+17
-30
@@ -7,6 +7,7 @@ from app.core.security import get_current_user
|
|||||||
from app.models.auth import User
|
from app.models.auth import User
|
||||||
from app.services.snmp_service import SNMPService
|
from app.services.snmp_service import SNMPService
|
||||||
from app.services.audit_service import AuditService, AuditAction
|
from app.services.audit_service import AuditService, AuditAction
|
||||||
|
from app.schemas._tz_util import serialize_dt_fields, serialize_dt_list
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/snmp",
|
prefix="/snmp",
|
||||||
@@ -34,7 +35,7 @@ def get_snmp_credentials(
|
|||||||
query = db.query(SNMPCredential)
|
query = db.query(SNMPCredential)
|
||||||
total = query.count()
|
total = query.count()
|
||||||
items = query.order_by(SNMPCredential.id.desc()).offset(skip).limit(limit).all()
|
items = query.order_by(SNMPCredential.id.desc()).offset(skip).limit(limit).all()
|
||||||
return {"total": total, "items": items}
|
return {"total": total, "items": serialize_dt_list(items)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/credentials/{credential_id}", summary="获取凭据详情")
|
@router.get("/credentials/{credential_id}", summary="获取凭据详情")
|
||||||
@@ -43,7 +44,7 @@ def get_snmp_credential(credential_id: int, db: Session = Depends(get_db)):
|
|||||||
credential = db.query(SNMPCredential).filter(SNMPCredential.id == credential_id).first()
|
credential = db.query(SNMPCredential).filter(SNMPCredential.id == credential_id).first()
|
||||||
if not credential:
|
if not credential:
|
||||||
raise HTTPException(status_code=404, detail="凭据不存在")
|
raise HTTPException(status_code=404, detail="凭据不存在")
|
||||||
return credential
|
return serialize_dt_fields(credential)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/credentials", summary="创建 SNMP 凭据")
|
@router.post("/credentials", summary="创建 SNMP 凭据")
|
||||||
@@ -100,7 +101,7 @@ def create_snmp_credential(
|
|||||||
user_agent=u,
|
user_agent=u,
|
||||||
detail={"version": credential.version, "name": credential.name},
|
detail={"version": credential.version, "name": credential.name},
|
||||||
)
|
)
|
||||||
return credential
|
return serialize_dt_fields(credential)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/credentials/{credential_id}", summary="更新 SNMP 凭据")
|
@router.put("/credentials/{credential_id}", summary="更新 SNMP 凭据")
|
||||||
@@ -172,7 +173,7 @@ def update_snmp_credential(
|
|||||||
"after": {"name": credential.name, "description": credential.description, "is_active": credential.is_active, "timeout": credential.timeout, "retries": credential.retries},
|
"after": {"name": credential.name, "description": credential.description, "is_active": credential.is_active, "timeout": credential.timeout, "retries": credential.retries},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return credential
|
return serialize_dt_fields(credential)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/credentials/{credential_id}", summary="删除 SNMP 凭据")
|
@router.delete("/credentials/{credential_id}", summary="删除 SNMP 凭据")
|
||||||
@@ -243,29 +244,15 @@ def get_network_devices(
|
|||||||
|
|
||||||
serialized = []
|
serialized = []
|
||||||
for d in items:
|
for d in items:
|
||||||
item = {
|
item = serialize_dt_fields(d)
|
||||||
"id": d.id,
|
# 补充手写的关联字段(datetime 已经由 serialize_dt_fields 序列化)
|
||||||
"name": d.name,
|
item["device_type"] = d.device_type.value if hasattr(d.device_type, 'value') else d.device_type
|
||||||
"description": d.description,
|
item["credential_name"] = cred_map.get(d.snmp_credential_id)
|
||||||
"ip_address": d.ip_address,
|
item["last_polled_at"] = item.get("last_polled_at") # 已是 ISO 字符串
|
||||||
"port": d.port,
|
item["last_successful_poll"] = item.get("last_successful_poll")
|
||||||
"device_type": d.device_type.value if hasattr(d.device_type, 'value') else d.device_type,
|
item["arp_poll_interval"] = d.arp_poll_interval
|
||||||
"vendor": d.vendor,
|
item["mac_poll_interval"] = d.mac_poll_interval
|
||||||
"model": d.model,
|
item["interface_poll_interval"] = d.interface_poll_interval
|
||||||
"firmware_version": d.firmware_version,
|
|
||||||
"serial_number": d.serial_number,
|
|
||||||
"location": d.location,
|
|
||||||
"snmp_credential_id": d.snmp_credential_id,
|
|
||||||
"credential_name": cred_map.get(d.snmp_credential_id),
|
|
||||||
"is_active": d.is_active,
|
|
||||||
"last_polled_at": d.last_polled_at.isoformat() if d.last_polled_at else None,
|
|
||||||
"last_successful_poll": d.last_successful_poll.isoformat() if d.last_successful_poll else None,
|
|
||||||
"arp_poll_interval": d.arp_poll_interval,
|
|
||||||
"mac_poll_interval": d.mac_poll_interval,
|
|
||||||
"interface_poll_interval": d.interface_poll_interval,
|
|
||||||
"created_at": d.created_at.isoformat() if d.created_at else None,
|
|
||||||
"updated_at": d.updated_at.isoformat() if d.updated_at else None,
|
|
||||||
}
|
|
||||||
serialized.append(item)
|
serialized.append(item)
|
||||||
|
|
||||||
return {"total": total, "items": serialized}
|
return {"total": total, "items": serialized}
|
||||||
@@ -278,7 +265,7 @@ def get_network_device(device_id: int, db: Session = Depends(get_db)):
|
|||||||
device = db.query(NetworkDevice).filter(NetworkDevice.id == device_id).first()
|
device = db.query(NetworkDevice).filter(NetworkDevice.id == device_id).first()
|
||||||
if not device:
|
if not device:
|
||||||
raise HTTPException(status_code=404, detail="设备不存在")
|
raise HTTPException(status_code=404, detail="设备不存在")
|
||||||
return device
|
return serialize_dt_fields(device)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/devices", summary="创建网络设备")
|
@router.post("/devices", summary="创建网络设备")
|
||||||
@@ -329,7 +316,7 @@ def create_network_device(
|
|||||||
user_agent=u,
|
user_agent=u,
|
||||||
detail={"ip_address": device.ip_address, "port": device.port, "device_type": device.device_type.value if hasattr(device.device_type, 'value') else device.device_type},
|
detail={"ip_address": device.ip_address, "port": device.port, "device_type": device.device_type.value if hasattr(device.device_type, 'value') else device.device_type},
|
||||||
)
|
)
|
||||||
return device
|
return serialize_dt_fields(device)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/devices/{device_id}", summary="更新网络设备")
|
@router.put("/devices/{device_id}", summary="更新网络设备")
|
||||||
@@ -400,7 +387,7 @@ def update_network_device(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return device
|
return serialize_dt_fields(device)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/devices/{device_id}", summary="删除网络设备")
|
@router.delete("/devices/{device_id}", summary="删除网络设备")
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,109 @@
|
|||||||
|
"""
|
||||||
|
统一时间格式化工具
|
||||||
|
|
||||||
|
背景:MySQL DATETIME 不带时区,backend 用 datetime.utcnow() 写入 UTC naive datetime。
|
||||||
|
原 schema 直接 .isoformat() 输出,前端拿到无时区字符串后当成 Asia/Shanghai 本地时间显示,
|
||||||
|
导致所有时间统一少 8 小时。
|
||||||
|
|
||||||
|
本工具把任意 datetime(naive 或 aware)一律按 UTC 输出,带 'Z' 后缀。
|
||||||
|
前端收到 'Z' 后缀字符串后,按 UTC 解析后再 +8 小时显示为 Asia/Shanghai 时间。
|
||||||
|
|
||||||
|
也兼容 MySQL 返回 naive 时被错当成 local time 的情况:调用方传入数字或字符串时
|
||||||
|
不会出错,自动识别。
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from typing import Optional, Union
|
||||||
|
|
||||||
|
|
||||||
|
# 业务时区:服务器期望最终用户看到的时区。
|
||||||
|
# 目前写死 Asia/Shanghai (UTC+8);如以后部署到其他时区,改这里即可。
|
||||||
|
BUSINESS_TZ = timezone(timedelta(hours=8))
|
||||||
|
BUSINESS_TZ_NAME = "Asia/Shanghai"
|
||||||
|
|
||||||
|
|
||||||
|
def to_utc_iso(dt: Optional[datetime]) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
把 datetime 转成 UTC ISO8601 字符串(带 Z 后缀)。
|
||||||
|
- aware datetime 调 astimezone(UTC)
|
||||||
|
- naive datetime 假定为 UTC(与 datetime.utcnow() 写入策略一致)
|
||||||
|
- None 返回 None
|
||||||
|
"""
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
# naive 一律视为 UTC(与 datetime.utcnow() 写入策略一致)
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
# 用 '+00:00' 替换成 'Z',更标准的 ISO8601 形式
|
||||||
|
return dt.astimezone(timezone.utc).isoformat().replace('+00:00', 'Z')
|
||||||
|
|
||||||
|
|
||||||
|
def to_business_iso(dt: Optional[datetime]) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
把 datetime 转成业务时区(Asia/Shanghai)的 ISO8601 字符串(带 +08:00 后缀)。
|
||||||
|
前端拿到后可直接当本地时间显示。
|
||||||
|
"""
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(BUSINESS_TZ).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def utc_str_to_business_str(s: Optional[str]) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
把 'YYYY-MM-DDTHH:MM:SS[.ffffff][Z|+HH:MM]' 字符串按 UTC 解析,
|
||||||
|
转成业务时区 ISO8601 字符串。
|
||||||
|
用于后端已经输出 UTC 时,前端(或后端自己)做时区转换的辅助函数。
|
||||||
|
"""
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(s.replace('Z', '+00:00'))
|
||||||
|
return to_business_iso(dt)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def now_business_iso() -> str:
|
||||||
|
"""供后端临时插入用:返回当前业务时区时间"""
|
||||||
|
return datetime.now(BUSINESS_TZ).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_dt_fields(model, fields: Optional[list] = None) -> dict:
|
||||||
|
"""
|
||||||
|
把 ORM model 转成 dict,datetime 字段自动用业务时区 ISO 字符串输出。
|
||||||
|
|
||||||
|
用于 audit/snmp/alerts 等内联 dict 序列化的 endpoint,确保返回的
|
||||||
|
时间字段不会因为裸 .isoformat() 输出而少 8 小时。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
return serialize_dt_fields(alert, ['created_at', 'updated_at'])
|
||||||
|
return serialize_dt_fields(alert) # 自动检测所有 datetime 字段
|
||||||
|
"""
|
||||||
|
from sqlalchemy import inspect as sa_inspect
|
||||||
|
result = {}
|
||||||
|
# inspect Class(不是 instance),可以拿 mapper.columns
|
||||||
|
mapper = sa_inspect(type(model)) if not isinstance(model, type) else sa_inspect(model)
|
||||||
|
datetime_fields: list = []
|
||||||
|
if fields is None:
|
||||||
|
for column in mapper.columns:
|
||||||
|
col_type = str(column.type).upper()
|
||||||
|
if 'DATETIME' in col_type or 'TIMESTAMP' in col_type:
|
||||||
|
datetime_fields.append(column.key)
|
||||||
|
else:
|
||||||
|
datetime_fields = fields
|
||||||
|
for column in mapper.columns:
|
||||||
|
value = getattr(model, column.key, None)
|
||||||
|
if column.key in datetime_fields:
|
||||||
|
result[column.key] = to_business_iso(value)
|
||||||
|
else:
|
||||||
|
if hasattr(value, 'value'): # Enum
|
||||||
|
result[column.key] = value.value
|
||||||
|
else:
|
||||||
|
result[column.key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_dt_list(models, fields: Optional[list] = None) -> list:
|
||||||
|
"""批量版本:返回每个 model 的 dict 列表"""
|
||||||
|
return [serialize_dt_fields(m, fields) for m in models] # type: ignore
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator, field_serializer
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import ipaddress
|
import ipaddress
|
||||||
from app.models.network import IPStatus, TaskStatus, TaskType
|
from app.models.network import IPStatus, TaskStatus, TaskType
|
||||||
|
from app.schemas._tz_util import to_business_iso, to_utc_iso
|
||||||
|
|
||||||
|
|
||||||
|
# Pydantic v2 字段序列化器:把 datetime 转成业务时区 ISO 字符串
|
||||||
|
# 让前端拿到 'YYYY-MM-DDTHH:MM:SS+08:00',可直接当本地时间显示
|
||||||
|
_datetime_business_serializer = field_serializer(
|
||||||
|
'datetime',
|
||||||
|
when_used='always',
|
||||||
|
check_fields=None, # 重要:应用到所有 datetime 字段
|
||||||
|
)(lambda dt: to_business_iso(dt))
|
||||||
|
|
||||||
|
|
||||||
# ========== 网段相关 Schemas ==========
|
# ========== 网段相关 Schemas ==========
|
||||||
@@ -55,6 +65,11 @@ class Network(NetworkBase):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: Optional[datetime]
|
updated_at: Optional[datetime]
|
||||||
|
|
||||||
|
# Pydantic v2: 用 model_serializer 在序列化整个 model 时把所有 datetime 转成业务时区
|
||||||
|
@field_serializer('created_at', 'updated_at')
|
||||||
|
def _tz_serializers(self, dt: Optional[datetime]) -> Optional[str]:
|
||||||
|
return to_business_iso(dt)
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
@@ -120,6 +135,10 @@ class IPAddress(IPAddressBase):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: Optional[datetime]
|
updated_at: Optional[datetime]
|
||||||
|
|
||||||
|
@field_serializer('last_seen', 'first_seen', 'created_at', 'updated_at')
|
||||||
|
def _tz_serializers(self, dt: Optional[datetime]) -> Optional[str]:
|
||||||
|
return to_business_iso(dt)
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
@@ -155,6 +174,13 @@ class ScanTask(BaseModel):
|
|||||||
error_message: Optional[str]
|
error_message: Optional[str]
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
@field_serializer(
|
||||||
|
'started_at', 'completed_at', 'created_at',
|
||||||
|
check_fields=None,
|
||||||
|
)
|
||||||
|
def _tz_serializers(self, dt: Optional[datetime]) -> Optional[str]:
|
||||||
|
return to_business_iso(dt)
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
@@ -170,4 +196,4 @@ class ScanResult(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
mac_address: Optional[str] = None
|
mac_address: Optional[str] = None
|
||||||
hostname: Optional[str] = None
|
hostname: Optional[str] = None
|
||||||
response_time: Optional[float] = None
|
response_time: Optional[float] = None
|
||||||
@@ -14,3 +14,4 @@ scapy==2.5.0
|
|||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
passlib[bcrypt]==1.7.4
|
passlib[bcrypt]==1.7.4
|
||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
|
python-jose[cryptography]
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* 统一时间格式化工具
|
||||||
|
*
|
||||||
|
* 背景:后端用 datetime.utcnow() 写入 UTC naive datetime。
|
||||||
|
* 修复后端后,schema 输出会带 +08:00 或 Z(Asia/Shanghai 或 UTC)时区标签。
|
||||||
|
* 本 helper:
|
||||||
|
* 1. 收到的字符串如果带时区标签(Z 或 +HH:MM),按字面解析
|
||||||
|
* 2. 如果无时区标签,假定为 UTC 解析(兼容老 endpoint 内联 dict 序列化)
|
||||||
|
* 3. 转 Asia/Shanghai (+08:00) 显示
|
||||||
|
* 4. 显示成 'YYYY-MM-DD HH:MM:SS' 字符串
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BUSINESS_TZ = 'Asia/Shanghai'
|
||||||
|
|
||||||
|
export function parseToBusiness (input) {
|
||||||
|
if (input == null || input === '') return null
|
||||||
|
let d
|
||||||
|
if (input instanceof Date) {
|
||||||
|
d = input
|
||||||
|
} else if (typeof input === 'number') {
|
||||||
|
d = new Date(input)
|
||||||
|
} else {
|
||||||
|
// 字符串:优先按 ISO 解析(含 Z / +HH:MM 则按对应时区)
|
||||||
|
// JS 原生 new Date() 对 'YYYY-MM-DD HH:MM:SS'(无 T,无时区)按本地时区解析,
|
||||||
|
// 对 'YYYY-MM-DDTHH:MM:SSZ' 等标准 ISO 8601 则按 UTC 解析。
|
||||||
|
const s = String(input).trim()
|
||||||
|
// 后端 schema 修复后输出 'YYYY-MM-DDTHH:MM:SS+08:00'(aware),
|
||||||
|
// 老 endpoint 内联 dict 输出 'YYYY-MM-DD HH:MM:SS'(naive UTC)。
|
||||||
|
// JS Date 无法区分后者的'naive UTC'和'naive 本地时间'。
|
||||||
|
// 但本项目所有 datetime 后端都是 datetime.utcnow() 写的,
|
||||||
|
// 所以 naive 也按 UTC 处理。
|
||||||
|
d = new Date(s.includes('T') ? s : s.replace(' ', 'T') + 'Z')
|
||||||
|
}
|
||||||
|
if (isNaN(d.getTime())) return null
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把任意 datetime 输入格式化成 Asia/Shanghai 时区的 'YYYY-MM-DD HH:MM:SS'。
|
||||||
|
* 不做时区转换的纯字符串渲染请用 formatUtc。
|
||||||
|
*/
|
||||||
|
export function formatDateTime (input) {
|
||||||
|
if (input == null || input === '') return '-'
|
||||||
|
const d = parseToBusiness(input)
|
||||||
|
if (!d) return '-'
|
||||||
|
const opts = {
|
||||||
|
timeZone: BUSINESS_TZ,
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', opts).format(d)
|
||||||
|
// zh-CN 格式默认是 "2026/07/23 14:31:19",替换成 "2026-07-23 14:31:19"
|
||||||
|
.replace(/\//g, '-')
|
||||||
|
} catch {
|
||||||
|
// 浏览器不支持 Intl 时区时回退:用本地时区手动格式化
|
||||||
|
return formatLocal(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅显示日期 'YYYY-MM-DD'。
|
||||||
|
*/
|
||||||
|
export function formatDate (input) {
|
||||||
|
if (input == null || input === '') return '-'
|
||||||
|
const d = parseToBusiness(input)
|
||||||
|
if (!d) return '-'
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
timeZone: BUSINESS_TZ,
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
}).format(d).replace(/\//g, '-')
|
||||||
|
} catch {
|
||||||
|
return formatLocal(d).substring(0, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅显示时间 'HH:MM' 或 'HH:MM:SS'(带 seconds 参数控制)。
|
||||||
|
*/
|
||||||
|
export function formatTime (input, { seconds = false } = {}) {
|
||||||
|
if (input == null || input === '') return '-'
|
||||||
|
const d = parseToBusiness(input)
|
||||||
|
if (!d) return '-'
|
||||||
|
const opts = {
|
||||||
|
timeZone: BUSINESS_TZ,
|
||||||
|
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||||
|
}
|
||||||
|
if (seconds) opts.second = '2-digit'
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', opts).format(d)
|
||||||
|
} catch {
|
||||||
|
return formatLocal(d).substring(11)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户友好的相对时间描述("3 分钟前")。
|
||||||
|
*/
|
||||||
|
export function formatRelative (input) {
|
||||||
|
if (input == null || input === '') return '-'
|
||||||
|
const d = parseToBusiness(input)
|
||||||
|
if (!d) return '-'
|
||||||
|
const diff = (Date.now() - d.getTime()) / 1000
|
||||||
|
if (diff < 60) return '刚刚'
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`
|
||||||
|
if (diff < 604800) return `${Math.floor(diff / 86400)} 天前`
|
||||||
|
return formatDateTime(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内部:浏览器不支持时区时回退到本地格式化(业务时区手动转)
|
||||||
|
function formatLocal (d) {
|
||||||
|
// 用本地方法拿到 YYYY-MM-DD HH:MM:SS(按浏览器本地时区,可能错 8 小时但作为兜底)
|
||||||
|
const pad = n => String(n).padStart(2, '0')
|
||||||
|
const y = d.getFullYear()
|
||||||
|
const mo = pad(d.getMonth() + 1)
|
||||||
|
const da = pad(d.getDate())
|
||||||
|
const h = pad(d.getHours())
|
||||||
|
const mi = pad(d.getMinutes())
|
||||||
|
const s = pad(d.getSeconds())
|
||||||
|
return `${y}-${mo}-${da} ${h}:${mi}:${s}`
|
||||||
|
}
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="created_at" label="时间" width="180">
|
<el-table-column prop="created_at" label="时间" width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ formatDate(scope.row.created_at) }}
|
{{ formatDateTime(scope.row.created_at) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="200" align="center" fixed="right">
|
<el-table-column label="操作" width="200" align="center" fixed="right">
|
||||||
@@ -194,7 +194,7 @@
|
|||||||
<el-table-column prop="owner" label="所有者" width="120" />
|
<el-table-column prop="owner" label="所有者" width="120" />
|
||||||
<el-table-column prop="created_at" label="添加时间" width="180">
|
<el-table-column prop="created_at" label="添加时间" width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ formatDate(scope.row.created_at) }}
|
{{ formatDateTime(scope.row.created_at) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="100" align="center">
|
<el-table-column label="操作" width="100" align="center">
|
||||||
@@ -238,6 +238,7 @@
|
|||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { alertApi } from '@/api'
|
import { alertApi } from '@/api'
|
||||||
|
import { formatDateTime, formatDate, formatRelative } from '@/utils/datetime'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const detecting = ref(false)
|
const detecting = ref(false)
|
||||||
@@ -460,10 +461,7 @@ const getAlertTypeText = (type) => {
|
|||||||
return texts[type] || type
|
return texts[type] || type
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (date) => {
|
// 旧 formatDate 已由 @/utils/datetime 统一替代
|
||||||
if (!date) return '-'
|
|
||||||
return new Date(date).toLocaleString('zh-CN')
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadStats()
|
loadStats()
|
||||||
|
|||||||
@@ -107,7 +107,7 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="last_seen" label="最后发现" width="170">
|
<el-table-column prop="last_seen" label="最后发现" width="170">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ scope.row.last_seen ? formatDate(scope.row.last_seen) : '-' }}
|
{{ scope.row.last_seen ? formatDateTime(scope.row.last_seen) : '-' }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="180" align="center" fixed="right">
|
<el-table-column label="操作" width="180" align="center" fixed="right">
|
||||||
@@ -174,6 +174,7 @@ import { ref, reactive, onMounted, computed } from 'vue'
|
|||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { ipApi, networkApi, scanApi } from '@/api'
|
import { ipApi, networkApi, scanApi } from '@/api'
|
||||||
|
import { formatDateTime, formatDate, formatRelative } from '@/utils/datetime'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
@@ -350,10 +351,7 @@ const getStatusText = (status) => {
|
|||||||
return texts[status] || status
|
return texts[status] || status
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (date) => {
|
// 旧 formatDate 已由 @/utils/datetime 统一替代
|
||||||
if (!date) return '-'
|
|
||||||
return new Date(date).toLocaleString('zh-CN')
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadNetworks()
|
loadNetworks()
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="created_at" label="创建时间" width="180">
|
<el-table-column prop="created_at" label="创建时间" width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ formatDate(scope.row.created_at) }}
|
{{ formatDateTime(scope.row.created_at) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="280" align="center" fixed="right">
|
<el-table-column label="操作" width="280" align="center" fixed="right">
|
||||||
@@ -139,6 +139,7 @@ import { ref, reactive, onMounted } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { networkApi, scanApi } from '@/api'
|
import { networkApi, scanApi } from '@/api'
|
||||||
|
import { formatDateTime, formatDate } from '@/utils/datetime'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -304,10 +305,7 @@ const getGroupTagType = (group) => {
|
|||||||
return types[group] || 'info'
|
return types[group] || 'info'
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (date) => {
|
// 旧 formatDate 已由 @/utils/datetime 统一替代
|
||||||
if (!date) return '-'
|
|
||||||
return new Date(date).toLocaleString('zh-CN')
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadNetworks()
|
loadNetworks()
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="last_polled_at" label="最后轮询" width="180">
|
<el-table-column prop="last_polled_at" label="最后轮询" width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ scope.row.last_polled_at ? formatDate(scope.row.last_polled_at) : '-' }}
|
{{ scope.row.last_polled_at ? formatDateTime(scope.row.last_polled_at) : '-' }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="300" align="center" fixed="right">
|
<el-table-column label="操作" width="300" align="center" fixed="right">
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
<el-table-column prop="retries" label="重试次数" width="100" align="center" />
|
<el-table-column prop="retries" label="重试次数" width="100" align="center" />
|
||||||
<el-table-column prop="created_at" label="创建时间" width="180">
|
<el-table-column prop="created_at" label="创建时间" width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ formatDate(scope.row.created_at) }}
|
{{ formatDateTime(scope.row.created_at) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="150" align="center" fixed="right">
|
<el-table-column label="操作" width="150" align="center" fixed="right">
|
||||||
@@ -152,7 +152,7 @@
|
|||||||
<el-table-column prop="interface" label="接口" width="120" />
|
<el-table-column prop="interface" label="接口" width="120" />
|
||||||
<el-table-column prop="last_seen" label="最后发现" width="180">
|
<el-table-column prop="last_seen" label="最后发现" width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ formatDate(scope.row.last_seen) }}
|
{{ formatDateTime(scope.row.last_seen) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -275,6 +275,7 @@
|
|||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { snmpApi } from '@/api'
|
import { snmpApi } from '@/api'
|
||||||
|
import { formatDateTime, formatDate, formatRelative } from '@/utils/datetime'
|
||||||
|
|
||||||
const activeTab = ref('devices')
|
const activeTab = ref('devices')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -530,10 +531,7 @@ const deleteCredential = async (id) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (date) => {
|
// 旧 formatDate 已由 @/utils/datetime 统一替代
|
||||||
if (!date) return '-'
|
|
||||||
return new Date(date).toLocaleString('zh-CN')
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="最后登录" width="170">
|
<el-table-column label="最后登录" width="170">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ scope.row.last_login_at ? formatDate(scope.row.last_login_at) : '-' }}
|
{{ scope.row.last_login_at ? formatDateTime(scope.row.last_login_at) : '-' }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="280" align="center" fixed="right">
|
<el-table-column label="操作" width="280" align="center" fixed="right">
|
||||||
@@ -162,6 +162,7 @@ import { ref, reactive, onMounted, computed } from 'vue'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Plus, Search, Refresh } from '@element-plus/icons-vue'
|
import { Plus, Search, Refresh } from '@element-plus/icons-vue'
|
||||||
import { userApi } from '@/api/auth'
|
import { userApi } from '@/api/auth'
|
||||||
|
import { formatDateTime, formatDate, formatRelative } from '@/utils/datetime'
|
||||||
import { authStore } from '@/utils/auth'
|
import { authStore } from '@/utils/auth'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -206,15 +207,7 @@ function roleTagType(r) { return ROLE_MAP[r]?.tagType || '' }
|
|||||||
function statusLabel(s) { return STATUS_MAP[s]?.label || s || '-' }
|
function statusLabel(s) { return STATUS_MAP[s]?.label || s || '-' }
|
||||||
function statusTagType(s) { return STATUS_MAP[s]?.tagType || '' }
|
function statusTagType(s) { return STATUS_MAP[s]?.tagType || '' }
|
||||||
|
|
||||||
function formatDate(d) {
|
// 旧 formatDate 已由 @/utils/datetime 统一替代
|
||||||
if (!d) return '-'
|
|
||||||
try {
|
|
||||||
const dt = new Date(d)
|
|
||||||
if (isNaN(dt.getTime())) return d
|
|
||||||
const pad = n => String(n).padStart(2, '0')
|
|
||||||
return `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`
|
|
||||||
} catch { return d }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|||||||
Reference in New Issue
Block a user