diff --git a/backend/app/api/v1/__pycache__/alerts.cpython-311.pyc b/backend/app/api/v1/__pycache__/alerts.cpython-311.pyc index 5ddfd0d..88565a9 100644 Binary files a/backend/app/api/v1/__pycache__/alerts.cpython-311.pyc and b/backend/app/api/v1/__pycache__/alerts.cpython-311.pyc differ diff --git a/backend/app/api/v1/__pycache__/snmp.cpython-311.pyc b/backend/app/api/v1/__pycache__/snmp.cpython-311.pyc index 74542e3..b4f05b5 100644 Binary files a/backend/app/api/v1/__pycache__/snmp.cpython-311.pyc and b/backend/app/api/v1/__pycache__/snmp.cpython-311.pyc differ 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 6f4185d..730031d 100644 Binary files a/backend/app/schemas/__pycache__/network.cpython-311.pyc and b/backend/app/schemas/__pycache__/network.cpython-311.pyc differ diff --git a/backend/app/schemas/_tz_util.py b/backend/app/schemas/_tz_util.py new file mode 100644 index 0000000..71ae81a --- /dev/null +++ b/backend/app/schemas/_tz_util.py @@ -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 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