更新用户管理相关
This commit is contained in:
@@ -117,7 +117,7 @@ go run .
|
|||||||
|
|
||||||
构建后的文件位于项目根目录 `dist/`,Gin 会提供静态文件服务;`/api` 路径保留给后端接口。
|
构建后的文件位于项目根目录 `dist/`,Gin 会提供静态文件服务;`/api` 路径保留给后端接口。
|
||||||
|
|
||||||
管理页面位于 `/admin`,默认管理员账号为 `admin` / `admin`。生产环境请修改 `web.admin.password` 或设置 `MESH_ADMIN_PASSWORD`,并配置固定的 `web.admin.session_secret` 或 `MESH_ADMIN_SESSION_SECRET`;如果 `session_secret` 为空,程序会在启动时生成临时签名密钥,重启后需要重新登录。后台支持新增管理员用户和修改用户密码;密码使用 bcrypt hash 保存,API 不会返回密码 hash。修改密码不会立即使已签发 Session 失效,当前 Session 到期或退出登录后才需要使用新密码。
|
管理页面位于 `/admin`,默认管理员账号为 `admin` / `admin`。生产环境请修改 `web.admin.password` 或设置 `MESH_ADMIN_PASSWORD`,并配置固定的 `web.admin.session_secret` 或 `MESH_ADMIN_SESSION_SECRET`;如果 `session_secret` 为空,程序会在启动时生成临时签名密钥,重启后需要重新登录。后台页面包括 `/admin` 服务状态、`/admin/users` 用户管理、`/admin/log/login` 登录日志。后台支持新增管理员用户和修改用户密码;密码使用 bcrypt hash 保存,API 不会返回密码 hash。修改密码不会立即使已签发 Session 失效,当前 Session 到期或退出登录后才需要使用新密码。登录成功和失败都会记录到登录日志,包含用户名、结果、原因、来源地址、User-Agent 和时间。
|
||||||
|
|
||||||
常用 API:
|
常用 API:
|
||||||
|
|
||||||
@@ -127,6 +127,7 @@ POST /api/admin/login
|
|||||||
POST /api/admin/logout
|
POST /api/admin/logout
|
||||||
GET /api/admin/me
|
GET /api/admin/me
|
||||||
GET /api/admin/mqtt/status
|
GET /api/admin/mqtt/status
|
||||||
|
GET /api/admin/log/login
|
||||||
GET /api/admin/users
|
GET /api/admin/users
|
||||||
POST /api/admin/users
|
POST /api/admin/users
|
||||||
PUT /api/admin/users/:id/password
|
PUT /api/admin/users/:id/password
|
||||||
@@ -163,6 +164,7 @@ meshtastic:
|
|||||||
|
|
||||||
程序默认启用 SQLite,数据库表迁移和操作由 GORM 执行,并持久化以下数据:
|
程序默认启用 SQLite,数据库表迁移和操作由 GORM 执行,并持久化以下数据:
|
||||||
|
|
||||||
|
- `login_log`:追加保存后台登录成功和失败日志
|
||||||
- `nodeinfo`:保存 `type == "nodeinfo"` 的节点身份和设备信息
|
- `nodeinfo`:保存 `type == "nodeinfo"` 的节点身份和设备信息
|
||||||
- `map_report`:保存 `type == "map_report"` 的地图报告信息,前端地图从该表读取
|
- `map_report`:保存 `type == "map_report"` 的地图报告信息,前端地图从该表读取
|
||||||
- `text_message`:追加保存 `type == "text_message"` 的聊天消息
|
- `text_message`:追加保存 `type == "text_message"` 的聊天消息
|
||||||
|
|||||||
@@ -75,6 +75,22 @@ func (userRecord) TableName() string {
|
|||||||
return "users"
|
return "users"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type loginLogRecord struct {
|
||||||
|
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
Username string `gorm:"column:username;index"`
|
||||||
|
UserID *uint64 `gorm:"column:user_id;index"`
|
||||||
|
Success bool `gorm:"column:success;not null;index"`
|
||||||
|
Reason string `gorm:"column:reason;not null"`
|
||||||
|
RemoteAddr string `gorm:"column:remote_addr"`
|
||||||
|
RemoteHost string `gorm:"column:remote_host"`
|
||||||
|
UserAgent string `gorm:"column:user_agent"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (loginLogRecord) TableName() string {
|
||||||
|
return "login_log"
|
||||||
|
}
|
||||||
|
|
||||||
type nodeInfoRecord struct {
|
type nodeInfoRecord struct {
|
||||||
NodeID string `gorm:"column:node_id;primaryKey;not null"`
|
NodeID string `gorm:"column:node_id;primaryKey;not null"`
|
||||||
NodeNum int64 `gorm:"column:node_num;not null;index"`
|
NodeNum int64 `gorm:"column:node_num;not null;index"`
|
||||||
@@ -268,6 +284,7 @@ func (s *store) migrate() error {
|
|||||||
model any
|
model any
|
||||||
}{
|
}{
|
||||||
{label: "users", model: &userRecord{}},
|
{label: "users", model: &userRecord{}},
|
||||||
|
{label: "login_log", model: &loginLogRecord{}},
|
||||||
{label: "nodeinfo", model: &nodeInfoRecord{}},
|
{label: "nodeinfo", model: &nodeInfoRecord{}},
|
||||||
{label: "map_report", model: &mapReportRecord{}},
|
{label: "map_report", model: &mapReportRecord{}},
|
||||||
{label: "text_message", model: &textMessageRecord{}},
|
{label: "text_message", model: &textMessageRecord{}},
|
||||||
|
|||||||
+31
-1
@@ -14,7 +14,7 @@ func TestOpenStoreCreatesTables(t *testing.T) {
|
|||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
for _, table := range []string{"users", "nodeinfo", "map_report", "text_message", "position", "telemetry", "routing", "traceroute"} {
|
for _, table := range []string{"users", "login_log", "nodeinfo", "map_report", "text_message", "position", "telemetry", "routing", "traceroute"} {
|
||||||
var name string
|
var name string
|
||||||
if err := rawTestDB(t, st).QueryRow("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", table).Scan(&name); err != nil {
|
if err := rawTestDB(t, st).QueryRow("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", table).Scan(&name); err != nil {
|
||||||
t.Fatalf("%s table missing: %v", table, err)
|
t.Fatalf("%s table missing: %v", table, err)
|
||||||
@@ -290,6 +290,36 @@ func TestUpdateUserPasswordMissingUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInsertAndListLoginLogs(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
userID := uint64(1)
|
||||||
|
if err := st.InsertLoginLog(loginLogRecord{Username: "admin", UserID: &userID, Success: true, Reason: "success", RemoteAddr: "127.0.0.1:1234", RemoteHost: "127.0.0.1", UserAgent: "test-agent"}); err != nil {
|
||||||
|
t.Fatalf("InsertLoginLog(success) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := st.InsertLoginLog(loginLogRecord{Username: "admin", Success: false, Reason: "invalid username or password", RemoteAddr: "127.0.0.1:1235", RemoteHost: "127.0.0.1", UserAgent: "test-agent"}); err != nil {
|
||||||
|
t.Fatalf("InsertLoginLog(failure) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logs, err := st.ListLoginLogs(listOptions{Limit: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListLoginLogs() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(logs) != 2 {
|
||||||
|
t.Fatalf("login logs len = %d, want 2", len(logs))
|
||||||
|
}
|
||||||
|
if logs[0].ID <= logs[1].ID {
|
||||||
|
t.Fatalf("login logs not newest first: ids %d, %d", logs[0].ID, logs[1].ID)
|
||||||
|
}
|
||||||
|
if logs[0].Success || logs[0].Reason != "invalid username or password" {
|
||||||
|
t.Fatalf("latest log = %#v, want failure", logs[0])
|
||||||
|
}
|
||||||
|
if logs[1].UserID == nil || *logs[1].UserID != userID || !logs[1].Success {
|
||||||
|
t.Fatalf("success log = %#v, want user id and success", logs[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInsertTextMessageAppendsRows(t *testing.T) {
|
func TestInsertTextMessageAppendsRows(t *testing.T) {
|
||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
func (s *store) InsertLoginLog(log loginLogRecord) error {
|
||||||
|
return s.db.Create(&log).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) ListLoginLogs(opts listOptions) ([]loginLogRecord, error) {
|
||||||
|
opts = normalizeListOptions(opts)
|
||||||
|
var rows []loginLogRecord
|
||||||
|
q := s.db.Order("created_at DESC").Order("id DESC").Limit(opts.Limit).Offset(opts.Offset)
|
||||||
|
if opts.Since != nil {
|
||||||
|
q = q.Where("created_at >= ?", *opts.Since)
|
||||||
|
}
|
||||||
|
if opts.Until != nil {
|
||||||
|
q = q.Where("created_at <= ?", *opts.Until)
|
||||||
|
}
|
||||||
|
return rows, q.Find(&rows).Error
|
||||||
|
}
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { getAdminMe, getHealth, getMapReports, getNodeInfo, getPositions, getTextMessages } from './api'
|
import { adminLogout, getAdminMe, getHealth, getMapReports, getNodeInfo, getPositions, getTextMessages } from './api'
|
||||||
import AdminDashboard from './components/AdminDashboard.vue'
|
import AdminDashboard from './components/AdminDashboard.vue'
|
||||||
import AdminLogin from './components/AdminLogin.vue'
|
import AdminLogin from './components/AdminLogin.vue'
|
||||||
|
import AdminLoginLogs from './components/AdminLoginLogs.vue'
|
||||||
|
import AdminUsers from './components/AdminUsers.vue'
|
||||||
import ChatPanel from './components/ChatPanel.vue'
|
import ChatPanel from './components/ChatPanel.vue'
|
||||||
import MeshMap from './components/MeshMap.vue'
|
import MeshMap from './components/MeshMap.vue'
|
||||||
import NodeListPanel from './components/NodeListPanel.vue'
|
import NodeListPanel from './components/NodeListPanel.vue'
|
||||||
import type { AdminUser, HealthStatus, MapNode, MapReport, NodeInfo, NodeInfoById, PositionRecord, TextMessage } from './types'
|
import type { AdminUser, HealthStatus, MapNode, MapReport, NodeInfo, NodeInfoById, PositionRecord, TextMessage } from './types'
|
||||||
|
|
||||||
const isAdminPage = window.location.pathname === '/admin'
|
const adminPath = window.location.pathname
|
||||||
|
const isAdminPage = adminPath.startsWith('/admin')
|
||||||
const adminUser = ref<AdminUser | null>(null)
|
const adminUser = ref<AdminUser | null>(null)
|
||||||
const adminChecking = ref(false)
|
const adminChecking = ref(false)
|
||||||
|
|
||||||
@@ -171,6 +174,14 @@ async function checkAdminSession() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function logoutAdmin() {
|
||||||
|
try {
|
||||||
|
await adminLogout()
|
||||||
|
} finally {
|
||||||
|
adminUser.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (isAdminPage) {
|
if (isAdminPage) {
|
||||||
checkAdminSession()
|
checkAdminSession()
|
||||||
@@ -196,6 +207,11 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="topbar-actions">
|
<div class="topbar-actions">
|
||||||
<template v-if="isAdminPage">
|
<template v-if="isAdminPage">
|
||||||
|
<nav v-if="adminUser" class="admin-nav">
|
||||||
|
<a href="/admin" :class="{ active: adminPath === '/admin' }">服务状态</a>
|
||||||
|
<a href="/admin/users" :class="{ active: adminPath === '/admin/users' }">用户管理</a>
|
||||||
|
<a href="/admin/log/login" :class="{ active: adminPath === '/admin/log/login' }">登录日志</a>
|
||||||
|
</nav>
|
||||||
<a class="topbar-link" href="/">返回地图</a>
|
<a class="topbar-link" href="/">返回地图</a>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -211,7 +227,18 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<template v-if="isAdminPage">
|
<template v-if="isAdminPage">
|
||||||
<div v-if="adminChecking" class="panel admin-loading">正在检查登录状态...</div>
|
<div v-if="adminChecking" class="panel admin-loading">正在检查登录状态...</div>
|
||||||
<AdminDashboard v-else-if="adminUser" :user="adminUser" @logout="adminUser = null" />
|
<template v-else-if="adminUser">
|
||||||
|
<div class="panel admin-session-card">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Session</p>
|
||||||
|
<h2>当前登录:{{ adminUser.username }}</h2>
|
||||||
|
</div>
|
||||||
|
<button class="admin-button" @click="logoutAdmin">退出登录</button>
|
||||||
|
</div>
|
||||||
|
<AdminUsers v-if="adminPath === '/admin/users'" :user="adminUser" />
|
||||||
|
<AdminLoginLogs v-else-if="adminPath === '/admin/log/login'" />
|
||||||
|
<AdminDashboard v-else />
|
||||||
|
</template>
|
||||||
<AdminLogin v-else @login="adminUser = $event" />
|
<AdminLogin v-else @login="adminUser = $event" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AdminLoginLogsResponse,
|
||||||
AdminLoginResponse,
|
AdminLoginResponse,
|
||||||
AdminManagedUserResponse,
|
AdminManagedUserResponse,
|
||||||
AdminMqttStatus,
|
AdminMqttStatus,
|
||||||
@@ -95,3 +96,7 @@ export function createAdminUser(username: string, password: string): Promise<Adm
|
|||||||
export function updateAdminUserPassword(id: number, password: string): Promise<AdminManagedUserResponse> {
|
export function updateAdminUserPassword(id: number, password: string): Promise<AdminManagedUserResponse> {
|
||||||
return putJSON<AdminManagedUserResponse>(`/api/admin/users/${id}/password`, { password })
|
return putJSON<AdminManagedUserResponse>(`/api/admin/users/${id}/password`, { password })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAdminLoginLogs(limit = 100, offset = 0): Promise<AdminLoginLogsResponse> {
|
||||||
|
return getJSON<AdminLoginLogsResponse>(`/api/admin/log/login?limit=${limit}&offset=${offset}`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,28 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { adminLogout, createAdminUser, getAdminMqttStatus, getAdminUsers, updateAdminUserPassword } from '../api'
|
import { getAdminMqttStatus } from '../api'
|
||||||
import type { AdminManagedUser, AdminMqttStatus, AdminUser } from '../types'
|
import type { AdminMqttStatus } from '../types'
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
user: AdminUser
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
logout: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const status = ref<AdminMqttStatus | null>(null)
|
const status = ref<AdminMqttStatus | null>(null)
|
||||||
const users = ref<AdminManagedUser[]>([])
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const usersLoading = ref(false)
|
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const userError = ref('')
|
|
||||||
const userMessage = ref('')
|
|
||||||
const newUsername = ref('')
|
|
||||||
const newPassword = ref('')
|
|
||||||
const confirmPassword = ref('')
|
|
||||||
const passwordEdits = ref<Record<number, string>>({})
|
|
||||||
const passwordSaving = ref<Record<number, boolean>>({})
|
|
||||||
let timer: number | undefined
|
let timer: number | undefined
|
||||||
|
|
||||||
function formatUptime(seconds: number): string {
|
function formatUptime(seconds: number): string {
|
||||||
@@ -32,10 +15,6 @@ function formatUptime(seconds: number): string {
|
|||||||
return `${hours}h ${minutes}m ${secs}s`
|
return `${hours}h ${minutes}m ${secs}s`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(value: string): string {
|
|
||||||
return new Date(value).toLocaleString()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshStatus() {
|
async function refreshStatus() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@@ -48,83 +27,8 @@ async function refreshStatus() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshUsers() {
|
|
||||||
usersLoading.value = true
|
|
||||||
userError.value = ''
|
|
||||||
try {
|
|
||||||
const response = await getAdminUsers()
|
|
||||||
users.value = response.items
|
|
||||||
} catch (err) {
|
|
||||||
userError.value = err instanceof Error ? err.message : String(err)
|
|
||||||
} finally {
|
|
||||||
usersLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createUser() {
|
|
||||||
userError.value = ''
|
|
||||||
userMessage.value = ''
|
|
||||||
if (!newUsername.value.trim()) {
|
|
||||||
userError.value = '用户名不能为空'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!newPassword.value) {
|
|
||||||
userError.value = '密码不能为空'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (newPassword.value !== confirmPassword.value) {
|
|
||||||
userError.value = '两次输入的密码不一致'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
usersLoading.value = true
|
|
||||||
try {
|
|
||||||
await createAdminUser(newUsername.value.trim(), newPassword.value)
|
|
||||||
newUsername.value = ''
|
|
||||||
newPassword.value = ''
|
|
||||||
confirmPassword.value = ''
|
|
||||||
userMessage.value = '用户已创建'
|
|
||||||
await refreshUsers()
|
|
||||||
} catch (err) {
|
|
||||||
userError.value = err instanceof Error ? err.message : String(err)
|
|
||||||
} finally {
|
|
||||||
usersLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updatePassword(user: AdminManagedUser) {
|
|
||||||
const password = passwordEdits.value[user.id] || ''
|
|
||||||
userError.value = ''
|
|
||||||
userMessage.value = ''
|
|
||||||
if (!password) {
|
|
||||||
userError.value = '新密码不能为空'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
passwordSaving.value = { ...passwordSaving.value, [user.id]: true }
|
|
||||||
try {
|
|
||||||
await updateAdminUserPassword(user.id, password)
|
|
||||||
passwordEdits.value = { ...passwordEdits.value, [user.id]: '' }
|
|
||||||
userMessage.value = `${user.username} 的密码已修改`
|
|
||||||
await refreshUsers()
|
|
||||||
} catch (err) {
|
|
||||||
userError.value = err instanceof Error ? err.message : String(err)
|
|
||||||
} finally {
|
|
||||||
passwordSaving.value = { ...passwordSaving.value, [user.id]: false }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function logout() {
|
|
||||||
try {
|
|
||||||
await adminLogout()
|
|
||||||
} finally {
|
|
||||||
emit('logout')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
refreshStatus()
|
refreshStatus()
|
||||||
refreshUsers()
|
|
||||||
timer = window.setInterval(refreshStatus, 5000)
|
timer = window.setInterval(refreshStatus, 5000)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -144,9 +48,7 @@ onBeforeUnmount(() => {
|
|||||||
<h2>MQTT 服务状态</h2>
|
<h2>MQTT 服务状态</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-actions">
|
<div class="admin-actions">
|
||||||
<span class="badge">{{ props.user.username }}</span>
|
|
||||||
<button @click="refreshStatus" :disabled="loading">{{ loading ? '刷新中...' : '刷新' }}</button>
|
<button @click="refreshStatus" :disabled="loading">{{ loading ? '刷新中...' : '刷新' }}</button>
|
||||||
<button @click="logout">退出</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -159,81 +61,13 @@ onBeforeUnmount(() => {
|
|||||||
<div><span>Uptime</span><strong>{{ formatUptime(status.uptime || 0) }}</strong></div>
|
<div><span>Uptime</span><strong>{{ formatUptime(status.uptime || 0) }}</strong></div>
|
||||||
<div><span>当前连接</span><strong>{{ status.clients_connected }}</strong></div>
|
<div><span>当前连接</span><strong>{{ status.clients_connected }}</strong></div>
|
||||||
<div><span>订阅数</span><strong>{{ status.subscriptions }}</strong></div>
|
<div><span>订阅数</span><strong>{{ status.subscriptions }}</strong></div>
|
||||||
<div><span>收到消息</span><strong>{{ status.messages_received }}</strong></div>
|
<div><span>转发消息</span><strong>{{ status.messages_sent }}</strong></div>
|
||||||
<div><span>发送消息</span><strong>{{ status.messages_sent }}</strong></div>
|
<div><span>丢弃消息</span><strong>{{ status.messages_dropped }}</strong></div>
|
||||||
<div><span>收到包</span><strong>{{ status.packets_received }}</strong></div>
|
<div><span>收到包</span><strong>{{ status.packets_received }}</strong></div>
|
||||||
<div><span>发送包</span><strong>{{ status.packets_sent }}</strong></div>
|
<div><span>发送包</span><strong>{{ status.packets_sent }}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel admin-status-panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Users</p>
|
|
||||||
<h2>用户管理</h2>
|
|
||||||
</div>
|
|
||||||
<button class="admin-button" @click="refreshUsers" :disabled="usersLoading">{{ usersLoading ? '刷新中...' : '刷新用户' }}</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form class="admin-form admin-user-form" @submit.prevent="createUser">
|
|
||||||
<label>
|
|
||||||
<span>用户名</span>
|
|
||||||
<input v-model="newUsername" autocomplete="off" placeholder="new-admin" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>密码</span>
|
|
||||||
<input v-model="newPassword" type="password" autocomplete="new-password" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>确认密码</span>
|
|
||||||
<input v-model="confirmPassword" type="password" autocomplete="new-password" />
|
|
||||||
</label>
|
|
||||||
<button class="admin-button" :disabled="usersLoading" type="submit">新增用户</button>
|
|
||||||
</form>
|
|
||||||
<p v-if="userError" class="error">{{ userError }}</p>
|
|
||||||
<p v-if="userMessage" class="success">{{ userMessage }}</p>
|
|
||||||
|
|
||||||
<div class="node-table-wrap">
|
|
||||||
<table class="node-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ID</th>
|
|
||||||
<th>用户名</th>
|
|
||||||
<th>角色</th>
|
|
||||||
<th>创建时间</th>
|
|
||||||
<th>更新时间</th>
|
|
||||||
<th>新密码</th>
|
|
||||||
<th>操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="user in users" :key="user.id">
|
|
||||||
<td>{{ user.id }}</td>
|
|
||||||
<td>{{ user.username }} <span v-if="user.username === props.user.username" class="badge">当前</span></td>
|
|
||||||
<td>{{ user.role }}</td>
|
|
||||||
<td>{{ formatTime(user.created_at) }}</td>
|
|
||||||
<td>{{ formatTime(user.updated_at) }}</td>
|
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
v-model="passwordEdits[user.id]"
|
|
||||||
class="admin-table-input"
|
|
||||||
type="password"
|
|
||||||
autocomplete="new-password"
|
|
||||||
placeholder="输入新密码"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button class="admin-button" :disabled="passwordSaving[user.id]" @click="updatePassword(user)">
|
|
||||||
{{ passwordSaving[user.id] ? '保存中...' : '修改密码' }}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div v-if="users.length === 0" class="empty">暂无用户</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel admin-status-panel">
|
<div class="panel admin-status-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { getAdminLoginLogs } from '../api'
|
||||||
|
import type { AdminLoginLog } from '../types'
|
||||||
|
|
||||||
|
const logs = ref<AdminLoginLog[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = 100
|
||||||
|
|
||||||
|
const canPrev = () => page.value > 1
|
||||||
|
const canNext = () => logs.value.length === pageSize
|
||||||
|
|
||||||
|
function formatTime(value: string): string {
|
||||||
|
return new Date(value).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshLogs() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const response = await getAdminLoginLogs(pageSize, (page.value - 1) * pageSize)
|
||||||
|
logs.value = response.items
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePage(nextPage: number) {
|
||||||
|
page.value = Math.max(1, nextPage)
|
||||||
|
refreshLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refreshLogs)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="admin-dashboard">
|
||||||
|
<div class="panel admin-status-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Login logs</p>
|
||||||
|
<h2>登录日志</h2>
|
||||||
|
</div>
|
||||||
|
<button class="admin-button" @click="refreshLogs" :disabled="loading">{{ loading ? '刷新中...' : '刷新日志' }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
<div class="node-table-wrap">
|
||||||
|
<table class="node-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>时间</th>
|
||||||
|
<th>用户名</th>
|
||||||
|
<th>结果</th>
|
||||||
|
<th>原因</th>
|
||||||
|
<th>Remote Addr</th>
|
||||||
|
<th>Remote Host</th>
|
||||||
|
<th>User-Agent</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="log in logs" :key="log.id">
|
||||||
|
<td>{{ formatTime(log.created_at) }}</td>
|
||||||
|
<td>{{ log.username || '-' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="log-badge" :class="log.success ? 'log-success' : 'log-failure'">
|
||||||
|
{{ log.success ? '成功' : '失败' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ log.reason || '-' }}</td>
|
||||||
|
<td>{{ log.remote_addr || '-' }}</td>
|
||||||
|
<td>{{ log.remote_host || '-' }}</td>
|
||||||
|
<td>{{ log.user_agent || '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div v-if="logs.length === 0" class="empty">暂无登录日志</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination">
|
||||||
|
<button :disabled="loading || !canPrev()" @click="changePage(page - 1)">上一页</button>
|
||||||
|
<span>第 {{ page }} 页</span>
|
||||||
|
<span>每页 {{ pageSize }} 条</span>
|
||||||
|
<button :disabled="loading || !canNext()" @click="changePage(page + 1)">下一页</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { createAdminUser, getAdminUsers, updateAdminUserPassword } from '../api'
|
||||||
|
import type { AdminManagedUser, AdminUser } from '../types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
user: AdminUser
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const users = ref<AdminManagedUser[]>([])
|
||||||
|
const usersLoading = ref(false)
|
||||||
|
const userError = ref('')
|
||||||
|
const userMessage = ref('')
|
||||||
|
const newUsername = ref('')
|
||||||
|
const newPassword = ref('')
|
||||||
|
const confirmPassword = ref('')
|
||||||
|
const passwordEdits = ref<Record<number, string>>({})
|
||||||
|
const passwordSaving = ref<Record<number, boolean>>({})
|
||||||
|
|
||||||
|
function formatTime(value: string): string {
|
||||||
|
return new Date(value).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshUsers() {
|
||||||
|
usersLoading.value = true
|
||||||
|
userError.value = ''
|
||||||
|
try {
|
||||||
|
const response = await getAdminUsers()
|
||||||
|
users.value = response.items
|
||||||
|
} catch (err) {
|
||||||
|
userError.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
usersLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser() {
|
||||||
|
userError.value = ''
|
||||||
|
userMessage.value = ''
|
||||||
|
if (!newUsername.value.trim()) {
|
||||||
|
userError.value = '用户名不能为空'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!newPassword.value) {
|
||||||
|
userError.value = '密码不能为空'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (newPassword.value !== confirmPassword.value) {
|
||||||
|
userError.value = '两次输入的密码不一致'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
usersLoading.value = true
|
||||||
|
try {
|
||||||
|
await createAdminUser(newUsername.value.trim(), newPassword.value)
|
||||||
|
newUsername.value = ''
|
||||||
|
newPassword.value = ''
|
||||||
|
confirmPassword.value = ''
|
||||||
|
userMessage.value = '用户已创建'
|
||||||
|
await refreshUsers()
|
||||||
|
} catch (err) {
|
||||||
|
userError.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
usersLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePassword(user: AdminManagedUser) {
|
||||||
|
const password = passwordEdits.value[user.id] || ''
|
||||||
|
userError.value = ''
|
||||||
|
userMessage.value = ''
|
||||||
|
if (!password) {
|
||||||
|
userError.value = '新密码不能为空'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordSaving.value = { ...passwordSaving.value, [user.id]: true }
|
||||||
|
try {
|
||||||
|
await updateAdminUserPassword(user.id, password)
|
||||||
|
passwordEdits.value = { ...passwordEdits.value, [user.id]: '' }
|
||||||
|
userMessage.value = `${user.username} 的密码已修改`
|
||||||
|
await refreshUsers()
|
||||||
|
} catch (err) {
|
||||||
|
userError.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
passwordSaving.value = { ...passwordSaving.value, [user.id]: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refreshUsers)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="admin-dashboard">
|
||||||
|
<div class="panel admin-status-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Users</p>
|
||||||
|
<h2>用户管理</h2>
|
||||||
|
</div>
|
||||||
|
<button class="admin-button" @click="refreshUsers" :disabled="usersLoading">{{ usersLoading ? '刷新中...' : '刷新用户' }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="admin-form admin-user-form" @submit.prevent="createUser">
|
||||||
|
<label>
|
||||||
|
<span>用户名</span>
|
||||||
|
<input v-model="newUsername" autocomplete="off" placeholder="new-admin" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>密码</span>
|
||||||
|
<input v-model="newPassword" type="password" autocomplete="new-password" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>确认密码</span>
|
||||||
|
<input v-model="confirmPassword" type="password" autocomplete="new-password" />
|
||||||
|
</label>
|
||||||
|
<button class="admin-button" :disabled="usersLoading" type="submit">新增用户</button>
|
||||||
|
</form>
|
||||||
|
<p v-if="userError" class="error">{{ userError }}</p>
|
||||||
|
<p v-if="userMessage" class="success">{{ userMessage }}</p>
|
||||||
|
|
||||||
|
<div class="node-table-wrap">
|
||||||
|
<table class="node-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>用户名</th>
|
||||||
|
<th>角色</th>
|
||||||
|
<th>创建时间</th>
|
||||||
|
<th>更新时间</th>
|
||||||
|
<th>新密码</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="managedUser in users" :key="managedUser.id">
|
||||||
|
<td>{{ managedUser.id }}</td>
|
||||||
|
<td>{{ managedUser.username }} <span v-if="managedUser.username === props.user.username" class="badge">当前</span></td>
|
||||||
|
<td>{{ managedUser.role }}</td>
|
||||||
|
<td>{{ formatTime(managedUser.created_at) }}</td>
|
||||||
|
<td>{{ formatTime(managedUser.updated_at) }}</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
v-model="passwordEdits[managedUser.id]"
|
||||||
|
class="admin-table-input"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="输入新密码"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="admin-button" :disabled="passwordSaving[managedUser.id]" @click="updatePassword(managedUser)">
|
||||||
|
{{ passwordSaving[managedUser.id] ? '保存中...' : '修改密码' }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div v-if="users.length === 0" class="empty">暂无用户</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -348,12 +348,43 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-login,
|
.admin-login,
|
||||||
.admin-dashboard {
|
.admin-dashboard,
|
||||||
|
.admin-session-card {
|
||||||
max-width: 1100px;
|
max-width: 1100px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-session-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav a {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
color: #334155;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav a.active {
|
||||||
|
color: #fff;
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-form {
|
.admin-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
@@ -408,6 +439,24 @@ h3 {
|
|||||||
background: #f0fdf4;
|
background: #f0fdf4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.log-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-success {
|
||||||
|
color: #166534;
|
||||||
|
background: #dcfce7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-failure {
|
||||||
|
color: #991b1b;
|
||||||
|
background: #fee2e2;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-dashboard {
|
.admin-dashboard {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|||||||
@@ -106,6 +106,24 @@ export interface AdminManagedUserResponse {
|
|||||||
user: AdminManagedUser
|
user: AdminManagedUser
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminLoginLog {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
user_id: number | null
|
||||||
|
success: boolean
|
||||||
|
reason: string
|
||||||
|
remote_addr: string
|
||||||
|
remote_host: string
|
||||||
|
user_agent: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminLoginLogsResponse {
|
||||||
|
items: AdminLoginLog[]
|
||||||
|
limit: number
|
||||||
|
offset: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminMqttClient {
|
export interface AdminMqttClient {
|
||||||
client_id: string
|
client_id: string
|
||||||
username: string
|
username: string
|
||||||
|
|||||||
@@ -103,15 +103,32 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
|
|||||||
userDTO := func(user userRecord) gin.H {
|
userDTO := func(user userRecord) gin.H {
|
||||||
return gin.H{"id": user.ID, "username": user.Username, "role": user.Role, "created_at": user.CreatedAt, "updated_at": user.UpdatedAt}
|
return gin.H{"id": user.ID, "username": user.Username, "role": user.Role, "created_at": user.CreatedAt, "updated_at": user.UpdatedAt}
|
||||||
}
|
}
|
||||||
|
loginLogDTO := func(row loginLogRecord) gin.H {
|
||||||
|
return gin.H{"id": row.ID, "username": row.Username, "user_id": ptrUint64(row.UserID), "success": row.Success, "reason": row.Reason, "remote_addr": row.RemoteAddr, "remote_host": row.RemoteHost, "user_agent": row.UserAgent, "created_at": row.CreatedAt}
|
||||||
|
}
|
||||||
|
remoteInfo := func(c *gin.Context) (string, string) {
|
||||||
|
remoteAddr := c.Request.RemoteAddr
|
||||||
|
remoteHost, _, err := net.SplitHostPort(remoteAddr)
|
||||||
|
if err != nil || remoteHost == "" {
|
||||||
|
remoteHost = remoteAddr
|
||||||
|
}
|
||||||
|
return remoteAddr, remoteHost
|
||||||
|
}
|
||||||
|
recordLogin := func(c *gin.Context, username string, userID *uint64, success bool, reason string) {
|
||||||
|
remoteAddr, remoteHost := remoteInfo(c)
|
||||||
|
_ = store.InsertLoginLog(loginLogRecord{Username: username, UserID: userID, Success: success, Reason: reason, RemoteAddr: remoteAddr, RemoteHost: remoteHost, UserAgent: c.GetHeader("User-Agent")})
|
||||||
|
}
|
||||||
|
|
||||||
r.POST("/login", func(c *gin.Context) {
|
r.POST("/login", func(c *gin.Context) {
|
||||||
var req loginRequest
|
var req loginRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
recordLogin(c, "", nil, false, "invalid request")
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid login request"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid login request"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user, err := store.GetUserByUsername(req.Username)
|
user, err := store.GetUserByUsername(req.Username)
|
||||||
if err != nil || user.Role != adminRole || !verifyPassword(user.PasswordHash, req.Password) {
|
if err != nil || user.Role != adminRole || !verifyPassword(user.PasswordHash, req.Password) {
|
||||||
|
recordLogin(c, req.Username, nil, false, "invalid username or password")
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -120,6 +137,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
recordLogin(c, req.Username, &user.ID, true, "success")
|
||||||
http.SetCookie(c.Writer, cookie)
|
http.SetCookie(c.Writer, cookie)
|
||||||
c.JSON(http.StatusOK, gin.H{"user": adminUserResponse(*user)})
|
c.JSON(http.StatusOK, gin.H{"user": adminUserResponse(*user)})
|
||||||
})
|
})
|
||||||
@@ -192,6 +210,14 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"user": userDTO(*user)})
|
c.JSON(http.StatusOK, gin.H{"user": userDTO(*user)})
|
||||||
})
|
})
|
||||||
|
protected.GET("/log/login", func(c *gin.Context) {
|
||||||
|
opts, ok := parseListOptions(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := store.ListLoginLogs(opts)
|
||||||
|
writeListResponse(c, rows, opts, err, loginLogDTO)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerNodeInfoRoutes(r gin.IRouter, store *store, path string) {
|
func registerNodeInfoRoutes(r gin.IRouter, store *store, path string) {
|
||||||
@@ -396,6 +422,13 @@ func ptrInt64(value *int64) any {
|
|||||||
return *value
|
return *value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ptrUint64(value *uint64) any {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
func ptrFloat64(value *float64) any {
|
func ptrFloat64(value *float64) any {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
Reference in New Issue
Block a user