From 5b93e4536f99eb37d7edecfd125f509cf9e4bf07 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 23 Jul 2026 13:13:31 +0800 Subject: [PATCH] add jose --- .../api/v1/__pycache__/alerts.cpython-311.pyc | Bin 9917 -> 10143 bytes .../api/v1/__pycache__/snmp.cpython-311.pyc | Bin 25547 -> 25175 bytes backend/app/api/v1/alerts.py | 5 +- backend/app/api/v1/audit.py | 3 +- backend/app/api/v1/snmp.py | 47 +++---- backend/app/schemas/__init__.py | 0 .../__pycache__/network.cpython-311.pyc | Bin 10216 -> 11642 bytes backend/app/schemas/_tz_util.py | 109 +++++++++++++++ backend/app/schemas/network.py | 30 ++++- backend/requirements.txt | 1 + frontend/src/utils/datetime.js | 124 ++++++++++++++++++ frontend/src/views/Alerts.vue | 10 +- frontend/src/views/IPs.vue | 8 +- frontend/src/views/Networks.vue | 8 +- frontend/src/views/SNMP.vue | 12 +- frontend/src/views/Users.vue | 13 +- 16 files changed, 302 insertions(+), 68 deletions(-) create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/_tz_util.py create mode 100644 frontend/src/utils/datetime.js diff --git a/backend/app/api/v1/__pycache__/alerts.cpython-311.pyc b/backend/app/api/v1/__pycache__/alerts.cpython-311.pyc index 5ddfd0daf9181549654a4459a76f4a47ec281e85..88565a9030dea2405b55a9547e91d502a63b20c7 100644 GIT binary patch delta 2776 zcmaJ@YiyHM82-Lqw_etE?b@~7+OAs%-9`rk$GB|cB65?PgaC_J%FfqO(ye&DucAa^ zVvI3{MDR%D$5f25#DIn{BmQ7f5`U>tFeBoZ81=^@Km1Ys$q(cEo^M^-ed<}y+jGwQ zyw7_%mu_~?d;6SMolcto?cKLCr*2(z2F2-%%MR@#0Pca{vGhpsi!#Qy~1 z;(U$ud|3-|OIFDjtiT20Su$^4GWSX?#0R}aGG`sc|BAT;w%4FLG>$QUB_OLn0;Ki2 zlU*?-I)V!(CK9TeELa4FI^JM~Hz<%8>Cp2M zJ4=}YN#uzGPv-(x1L@Lng(_T-Rf2TuvRbCI2-jFFCrQns9m{4QJvww7p!qfee=q5) zL$A=G+bd|uEJgZtnZb5rW{KL(vXGTJo=7D|&a+C#1BVBBHV;5$RxddSYnIG0vj*~* z&PNn{#Bg^Zvq7?Up>jiu=V6`BqN5TOSga=->T0o3hmO~vhsj7CdQ^w*tU+%go9ob9 zbm*=c^j7kC9eSG%ov1-SLAGn?sJ7!fbm(r+{WX`-aF zIVn$M)l;{a&&i7FQVQu4ML9Pm$#Oa;7g(qDs=;BH`PTY}*sZi{R|@5eOYS-`P8-0X zi8iyZ8sn>7sylP~bZW9dq`aKUw16}@$&DnnQ-A@eODBep9&n1TM z+DGtX8ew1BcZv^~%`t0GTx@@1$N{li_ELPnijK+-F4ycm&c<_XItSdBrWJupxkCnP1URbnm7`=CR8PdSPEXM5yw@t!>~XHVQU zC1@YJ@4Vw40zK_Vu@c29R%+@L2iez6i9YUaJ+5+>gD|T1MMqGHVvvPhPl|o)r0d77 z8ZyunT_EN!zW(sz()_KP53hbO|LP3g$j03r1Dt;pDK?>~I>D4NAuS`i1+^$f*=O#( z;Kl5DKe-hb|IgL@7qh=#`Vd@w?}>F+3&*p-AD%_}ZODXT2Wx5G1@y<8KM{wS%iGh# zL)s218F#EwVJ9t4rHQ0S<4UeDMR$Vg02}xAg6SLH8>`VsHOopyk?YKSiyMr|`Us=GI9OluU9(qDUWDy#i49ceJQ(f#fsMSRNc&)oqR6m!S_Xk^u4UG~9~Tdx zc#2K?yCADO{-nER6;&bj$lvb5vqKM6M4ojAHUY^*;3u)4ZEp2jR-tN`9c$g)bC_F! zP_1%td>pQ@TvyPes7G;*{m{A{SVF-Wv5S2d9EkB8o(5HIW#}<9K=B%jg@(Z3XlND; z0^yYn&%kQZ05R?h2elYaAQ;7)>`Zt)fNzFx1GvzZN>#w1RBh7pCo@H;abc>?R~F4t zOtWv=(3h$GmUx`qZjbiYn%k$OGqA_cN06E|0dw z`CasDrzBm-X7ljYRS4-6*xz8;=z0rY&*U;zik|QA;Y%iupOhvuaw@HyO&671p5J`_ zNKq$C#lkP)t3&Z(tESThcpmWfX*YzQV?2!fO!2e8H&nI2;|QmfZ;4iGUPE39o(_id zkw7sP{)Rki*a$CbF)xkKbFha0xXB)iAe4=wQH1yGUM#?ViCNgg*jVI&aOCcPf7q4| zk{K-v0de$!!*}7}`8{PLjDKzNUy#pFmd!9OTLe$=viB`#*^09U!QNDE#F0&KxXN}M zIfO=c*@+`0;^I%EU`Wl0E6N5~Vb*w}x4SG@#O}*R{&gk5zp4W9>*mCvvH?IdhvO$q HCQSDqdCFo` delta 2630 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 N|CM}NXTU?`e*yWMza0Po diff --git a/backend/app/api/v1/alerts.py b/backend/app/api/v1/alerts.py index 722639f..b8309eb 100644 --- a/backend/app/api/v1/alerts.py +++ b/backend/app/api/v1/alerts.py @@ -5,6 +5,7 @@ from typing import Optional from app.core.database import get_db from app.core.security import get_current_user from app.services.alert_service import AlertService +from app.schemas._tz_util import serialize_dt_fields, serialize_dt_list, to_business_iso router = APIRouter( prefix="/alerts", @@ -37,7 +38,7 @@ def get_alerts( total = query.count() 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="获取告警详情") @@ -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() if not alert: raise HTTPException(status_code=404, detail="告警不存在") - return alert + return serialize_dt_fields(alert) @router.post("/{alert_id}/acknowledge", summary="确认告警") diff --git a/backend/app/api/v1/audit.py b/backend/app/api/v1/audit.py index 49d1b07..37cb8cf 100644 --- a/backend/app/api/v1/audit.py +++ b/backend/app/api/v1/audit.py @@ -8,6 +8,7 @@ from app.core.security import get_current_user, require_permission from app.models.auth import User from app.models.audit import AuditAction, AuditResource from app.services.audit_service import AuditService +from app.schemas._tz_util import to_business_iso router = APIRouter(prefix="/audit", tags=["审计日志"]) @@ -66,7 +67,7 @@ def get_audit_logs( "request_path": item.request_path, "success": bool(item.success), "error_message": item.error_message, - "created_at": item.created_at + "created_at": to_business_iso(item.created_at) }) return {"total": total, "items": result_items} diff --git a/backend/app/api/v1/snmp.py b/backend/app/api/v1/snmp.py index 07a484a..9e7b17a 100644 --- a/backend/app/api/v1/snmp.py +++ b/backend/app/api/v1/snmp.py @@ -7,6 +7,7 @@ from app.core.security import get_current_user from app.models.auth import User from app.services.snmp_service import SNMPService from app.services.audit_service import AuditService, AuditAction +from app.schemas._tz_util import serialize_dt_fields, serialize_dt_list router = APIRouter( prefix="/snmp", @@ -34,7 +35,7 @@ def get_snmp_credentials( query = db.query(SNMPCredential) total = query.count() 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="获取凭据详情") @@ -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() if not credential: raise HTTPException(status_code=404, detail="凭据不存在") - return credential + return serialize_dt_fields(credential) @router.post("/credentials", summary="创建 SNMP 凭据") @@ -100,7 +101,7 @@ def create_snmp_credential( user_agent=u, detail={"version": credential.version, "name": credential.name}, ) - return credential + return serialize_dt_fields(credential) @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}, }, ) - return credential + return serialize_dt_fields(credential) @router.delete("/credentials/{credential_id}", summary="删除 SNMP 凭据") @@ -243,29 +244,15 @@ def get_network_devices( serialized = [] for d in items: - item = { - "id": d.id, - "name": d.name, - "description": d.description, - "ip_address": d.ip_address, - "port": d.port, - "device_type": d.device_type.value if hasattr(d.device_type, 'value') else d.device_type, - "vendor": d.vendor, - "model": d.model, - "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, - } + item = serialize_dt_fields(d) + # 补充手写的关联字段(datetime 已经由 serialize_dt_fields 序列化) + item["device_type"] = d.device_type.value if hasattr(d.device_type, 'value') else d.device_type + item["credential_name"] = cred_map.get(d.snmp_credential_id) + item["last_polled_at"] = item.get("last_polled_at") # 已是 ISO 字符串 + item["last_successful_poll"] = item.get("last_successful_poll") + item["arp_poll_interval"] = d.arp_poll_interval + item["mac_poll_interval"] = d.mac_poll_interval + item["interface_poll_interval"] = d.interface_poll_interval serialized.append(item) 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() if not device: raise HTTPException(status_code=404, detail="设备不存在") - return device + return serialize_dt_fields(device) @router.post("/devices", summary="创建网络设备") @@ -329,7 +316,7 @@ def create_network_device( 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}, ) - return device + return serialize_dt_fields(device) @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="删除网络设备") diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/__pycache__/network.cpython-311.pyc b/backend/app/schemas/__pycache__/network.cpython-311.pyc index 6f4185d1993d41ceecdfb4ce2ecd3a3d61f1954a..730031d6661f99329d2a613122fa82e51e9add59 100644 GIT binary patch delta 4785 zcmcInU2Ggz6~43Ho&B+Q*MDn!ZLhsv+lki=N$jMQ5;x$qY2skJQQSmyO0t@GW|OV= z$Gx-0iWOyz5{al1DC8m~eJF}}2t-aLDoYSSRlx(|)vo+tMxq2FBzS?f5(FwBIOmM* zv1c6!5{!2~-+RAv@0_{!oNw;^{cE2prSAoU0S4FgZ>w*8(#Y5&tZZEJdg1Xo7h|`W z#xyC*a*`tDT#74KrBvnIid({VSJso06*=csyg8rZ%lQ>QwO3^WxquR&vO8O?RD-Q2 zTayba!CXiQ}yH$)Wz-?Vx;2OItK?~g0 zr8KzMF{V{tV_JjiF?-EqBN7je6R>#N`r#3h02ui&~h_XJBm_Xi56q+5)+ zy6eo^bVko=DMRNOHJh2!MZq;zO1jK&v5-1HV`TEWVWcuf!3;pdOmPy+yc#?)Wi>mi zUNXSeKYLNnr)CUYGixR<>XTC`8q}EFj192I$ie$@9N-o!Zs9IFBB^>>2@f&wUiY@-dot8Yj1vqhC?w8sZ7v&DAzYpqWATym(HI2jj^5>u->Ee6fAO8%1 z0eQ#bao!^S<~<Zp~11(nH zL*h$aQE{O9UWxp1x&p?@8txd@_8i}ge->efj{F5A96o#j+Yr!8c z(+z0+cVR4T7sLt1BhZ640o-$$ZbQ$eY2eLp!iR)Y#W_3uxG~o}uJ0$W_BxoOVE(uk zV4nRh+;N*PhZ8H|#F8(;TOmva#V>=6L)+KW45xV^mr{#Go;g2L)D6d)nexkpd^&T1 zzXqd@h-9e!Jgw6ysE#MAozX$7PBDQ3U`05tAf*8x#S->(JcJKp8^RFW#xKO}(Ce?t zOm158L~dm7j4pcyRy+e`m*nZb)A#+)tt1YX8I&Ki{IvVS7!_?;t77uthILsMZ?_Ft z!9FaOYog+%@NZj>;L|8MDVSfz>MICG#f!BGIf2Dqn6Hu2Nh~^onEZbO2nERrge@}? zaLkD5O{KKLB&57OHIz7*{H7 zcw}_=$mmf%je|KJkjFLxVK%*B4V|Yz*x&r861^0?5A}7t6uwEa*0G-4o(Nn2>>us%~Hxgjw-^%LkSUVigD7yf#&E)l$J*(K96-_FSlgn)fcu{{pT7U8H>8 z=th-keo#WcT?Zu$R48G9l#q624kfJCf}n&U(?4=@c>Uvd1jgp20wPYF>CdW05fn<# zn}KwOub26&XmAVxwUQqP_+JPmKLG<7F8s*h2N#D%VVR3=I_Q+echF`G!IHt5*2br& z_8i!K6KyA`1A--X6`#WGp=f0X$=)+4UU&uvM)(@s#wWtpcHzWBPXrZj>dw#)+8mlJVEJ=8<#w#1z1I((87 zdxMoumnl;h+D{+HfJpkBL*vfN+9puIkcQCX9OGX>JA`+|@z^12>?`Y=n~3;PtnL_o z9GnS`v&Rv>gN2t6NR6>+=M4J%&4#Gx>-b?w4`=}mqFW2y4?8lbR`+bp7_AQK+xHUn z6&X}b8AL}9M+VhsA;_SJ>6@5T^RKJM6vqUr;WQs5ba`4dcfMW1d>KzlyaAj3A1MRl zZp)aK4TmUU`Zjm?-1q=Yje3B2_U8br?X28N{G_wt={bb?^8ju~4kd%m;IQM4;(}r& zh#g5>?PS_<=KzOb#;h$#9TtU;b#@@WOkLL7+q$S41HXG5zfFof}H7E*a zikU2(>Ub^kv6QA3b$nCntXaatrX8r(nTXCdcyh2dKBuun+Z`24c?A5TTCzquMq+~+ zVZ{$E1#>zCHB^@&X#6kp$c){O!4 EFUOgm(*OVf delta 3347 zcmb7GUu;ul6u-Ca+TPoB?Y4Gp*R5-}F=&A~{s_VbY>2Yy7~`+*DmG=egN@#{``W3* zKo>*I5+4x01RsnTOauvukt{L67vqDz5EJ4PsXk~(4C;fB?ZJSFo^$TlWw+ad9rwp~ z&+j|m_nq@Q=W8D+_xIOcbGu~$K07XsOkDB`!o7<&f2q+T2pG7C zSVgp1GB&MBsnYCx0+g#wG^S|FI}Q0cuN#Mp-KlN}I$mACx|}*CN@>|C0QTJ{;mM40mOIgaAU2 zN91s{4Tl{7*M&Jifwdxdo`5_%A|Dj(o%GIcx_Vz+jl(qvafI~E98G$qd&k_jBII^`!n2xm>Z3yU9coSgZ zSp02wK1Rn!b`+k)k8NQz#{{1D2YIZfHv;wI0JWW0A?G(R@AOd|ssvig{B})0e7<5rwU z1w{%xhp*cZw)4;HTJ2O&K`4z7g>9)1ly)I&Qh-j-55&tz?na=r>ru$2D9VmxQq7Gf zle(G|4ylgOl&i)vxm1xY7t|i$?F?}zzg+*F9>*T4w%0qh@>{-+o~5EZwSfZOB4@Q}1AWUA}{7^bWQ+9eox$7{3uEQwKX$89LZimIj9V7VcT9`!{f(B>%N3 zZ12Wl&tjo}+w5Dp&`-kLcX0l0gr^E!GH1qUSIPp_iHuImRkw-+0SpS4MC{oRKNH!< zyL_8;_0`2%|EMJ}un!%E0EXg@m3OAXr)7;ax1#676DaY=EenM|K^qK3kMv`1D(@dR z`cT7C?kFG%(zJF4=OMhu|BQC)htEKEJGm08?cac`$s>n0g!gfPx0aq+G%c_DeO>q4 zv9Gp)?)0O@q`RzoLHF9S^U!E2dpK1%#SGas+#$)U+a|^C1=-iFEIG=389mIByH-v- zC5IALjh@sSD~dmfQ(TDW;VJxq;(u)jKe-_MzW*aSt!Ya08QcfqQ@HJ7ync1PF3uU) z;8tlBve@`TdQ)Q;m)-EgB%jL`(#hhumX23MJb+F{=O4gV<3^mu*Ow4XCn=!~;Tk-J zF@R~Vt{E#)KcPL|Qg-G~tEp^pe3V(iK-Vd&f>GICJe?oUp3wK93OBA+$}#=0s!Qeb zomx&!X9}I!bn&emJH@CGNf468gX?x7;7 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 diff --git a/backend/app/schemas/network.py b/backend/app/schemas/network.py index 97d5726..dc20705 100644 --- a/backend/app/schemas/network.py +++ b/backend/app/schemas/network.py @@ -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 datetime import datetime import ipaddress 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 ========== @@ -55,6 +65,11 @@ class Network(NetworkBase): created_at: 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: from_attributes = True @@ -120,6 +135,10 @@ class IPAddress(IPAddressBase): created_at: 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: from_attributes = True @@ -155,6 +174,13 @@ class ScanTask(BaseModel): error_message: Optional[str] 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: from_attributes = True @@ -170,4 +196,4 @@ class ScanResult(BaseModel): status: str mac_address: Optional[str] = None hostname: Optional[str] = None - response_time: Optional[float] = None + response_time: Optional[float] = None \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index ed45633..6bc2a9e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -14,3 +14,4 @@ scapy==2.5.0 python-dotenv==1.0.1 passlib[bcrypt]==1.7.4 httpx==0.27.0 +python-jose[cryptography] \ No newline at end of file diff --git a/frontend/src/utils/datetime.js b/frontend/src/utils/datetime.js new file mode 100644 index 0000000..2b58ce6 --- /dev/null +++ b/frontend/src/utils/datetime.js @@ -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}` +} \ No newline at end of file diff --git a/frontend/src/views/Alerts.vue b/frontend/src/views/Alerts.vue index 1072775..e869e48 100644 --- a/frontend/src/views/Alerts.vue +++ b/frontend/src/views/Alerts.vue @@ -141,7 +141,7 @@ @@ -194,7 +194,7 @@ @@ -238,6 +238,7 @@ import { ref, reactive, onMounted } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import { alertApi } from '@/api' +import { formatDateTime, formatDate, formatRelative } from '@/utils/datetime' const loading = ref(false) const detecting = ref(false) @@ -460,10 +461,7 @@ const getAlertTypeText = (type) => { return texts[type] || type } -const formatDate = (date) => { - if (!date) return '-' - return new Date(date).toLocaleString('zh-CN') -} +// 旧 formatDate 已由 @/utils/datetime 统一替代 onMounted(() => { loadStats() diff --git a/frontend/src/views/IPs.vue b/frontend/src/views/IPs.vue index c6460dd..6d32626 100644 --- a/frontend/src/views/IPs.vue +++ b/frontend/src/views/IPs.vue @@ -107,7 +107,7 @@ @@ -174,6 +174,7 @@ import { ref, reactive, onMounted, computed } from 'vue' import { useRoute } from 'vue-router' import { ElMessage } from 'element-plus' import { ipApi, networkApi, scanApi } from '@/api' +import { formatDateTime, formatDate, formatRelative } from '@/utils/datetime' const route = useRoute() @@ -350,10 +351,7 @@ const getStatusText = (status) => { return texts[status] || status } -const formatDate = (date) => { - if (!date) return '-' - return new Date(date).toLocaleString('zh-CN') -} +// 旧 formatDate 已由 @/utils/datetime 统一替代 onMounted(() => { loadNetworks() diff --git a/frontend/src/views/Networks.vue b/frontend/src/views/Networks.vue index f3af107..6f90a8f 100644 --- a/frontend/src/views/Networks.vue +++ b/frontend/src/views/Networks.vue @@ -58,7 +58,7 @@ @@ -139,6 +139,7 @@ import { ref, reactive, onMounted } from 'vue' import { useRouter } from 'vue-router' import { ElMessage, ElMessageBox } from 'element-plus' import { networkApi, scanApi } from '@/api' +import { formatDateTime, formatDate } from '@/utils/datetime' const router = useRouter() @@ -304,10 +305,7 @@ const getGroupTagType = (group) => { return types[group] || 'info' } -const formatDate = (date) => { - if (!date) return '-' - return new Date(date).toLocaleString('zh-CN') -} +// 旧 formatDate 已由 @/utils/datetime 统一替代 onMounted(() => { loadNetworks() diff --git a/frontend/src/views/SNMP.vue b/frontend/src/views/SNMP.vue index fabfd77..1d39e33 100644 --- a/frontend/src/views/SNMP.vue +++ b/frontend/src/views/SNMP.vue @@ -39,7 +39,7 @@ @@ -98,7 +98,7 @@ @@ -152,7 +152,7 @@ @@ -275,6 +275,7 @@ import { ref, reactive, onMounted } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import { snmpApi } from '@/api' +import { formatDateTime, formatDate, formatRelative } from '@/utils/datetime' const activeTab = ref('devices') const loading = ref(false) @@ -530,10 +531,7 @@ const deleteCredential = async (id) => { } } -const formatDate = (date) => { - if (!date) return '-' - return new Date(date).toLocaleString('zh-CN') -} +// 旧 formatDate 已由 @/utils/datetime 统一替代 onMounted(() => { loading.value = true diff --git a/frontend/src/views/Users.vue b/frontend/src/views/Users.vue index 70c9aef..9fe873d 100644 --- a/frontend/src/views/Users.vue +++ b/frontend/src/views/Users.vue @@ -58,7 +58,7 @@ @@ -162,6 +162,7 @@ import { ref, reactive, onMounted, computed } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import { Plus, Search, Refresh } from '@element-plus/icons-vue' import { userApi } from '@/api/auth' +import { formatDateTime, formatDate, formatRelative } from '@/utils/datetime' import { authStore } from '@/utils/auth' 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 statusTagType(s) { return STATUS_MAP[s]?.tagType || '' } -function formatDate(d) { - 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 } -} +// 旧 formatDate 已由 @/utils/datetime 统一替代 async function loadUsers() { loading.value = true