feat(admin): 增加中枢设置(自定义 ID 与名称,右上角设置入口)

This commit is contained in:
2026-10-15 23:52:17 +08:00
parent c78df3a6a6
commit 1ca2241a97
6 changed files with 142 additions and 3 deletions
+22 -3
View File
@@ -4,16 +4,28 @@ import { getToken, setToken, api } from './api/admin.js';
import Login from './views/Login.vue';
import Users from './views/Users.vue';
import Logs from './views/Logs.vue';
import HubSettings from './components/HubSettings.vue';
const authed = ref(false);
const page = ref('overview');
const user = ref(null);
const stats = ref({ users: 0, total: 0, denied: 0, p95: null });
const hub = ref({ hub_id: '', hub_name: '蜂群中枢' });
const settingsOpen = ref(false);
const PAGES = { overview: '概览', users: '用户管理', logs: '日志查看' };
function onUnauthorized() { setToken(''); authed.value = false; }
async function loadHub() {
try {
const r = await api.settings();
hub.value = r.settings;
} catch (e) {
if (e.status === 401) onUnauthorized();
}
}
async function loadStats() {
try {
const [u, l] = await Promise.all([api.users(), api.logs({ page: 1, page_size: 1 })]);
@@ -32,6 +44,7 @@ async function init() {
const r = await api.me();
user.value = r.user;
authed.value = true;
loadHub();
loadStats();
} catch (e) {
setToken('');
@@ -39,8 +52,9 @@ async function init() {
}
}
function onLogin(u) { user.value = u; authed.value = true; page.value = 'overview'; loadStats(); }
function onLogin(u) { user.value = u; authed.value = true; page.value = 'overview'; loadHub(); loadStats(); }
async function onLogout() { try { await api.logout(); } catch {} setToken(''); authed.value = false; }
function onSettingsSaved(s) { hub.value = s; settingsOpen.value = false; }
onMounted(init);
</script>
@@ -51,7 +65,7 @@ onMounted(init);
<aside class="sidebar">
<div class="side-brand">
<div class="hex">蜂</div>
<div><div class="brand-name">蜂群中枢</div><div class="brand-sub">PlantAI 管理后台</div></div>
<div><div class="brand-name">{{ hub.hub_name }}</div><div class="brand-sub">{{ hub.hub_id.slice(0, 8) }}</div></div>
</div>
<nav class="nav">
<div class="nav-item" :class="{ active: page === 'overview' }" @click="page = 'overview'"><span>◈</span> 概览</div>
@@ -67,7 +81,11 @@ onMounted(init);
</div>
</aside>
<main class="main">
<div class="topbar"><h1>{{ PAGES[page] }}</h1><span class="crumb" style="color:var(--text-3);font-size:13px">中枢</span></div>
<div class="topbar">
<h1>{{ PAGES[page] }}</h1>
<span style="color: var(--text-3); font-size: 13px">{{ hub.hub_name }}</span>
<button class="btn btn-ghost" style="margin-left: auto" @click="settingsOpen = true">⚙ 设置</button>
</div>
<div class="content">
<section v-if="page === 'overview'">
<div class="stats">
@@ -81,6 +99,7 @@ onMounted(init);
<Logs v-else @unauthorized="onUnauthorized" />
</div>
</main>
<HubSettings :open="settingsOpen" :hub="hub" @close="settingsOpen = false" @saved="onSettingsSaved" />
</div>
</template>
+2
View File
@@ -43,4 +43,6 @@ export const api = {
const qs = q.toString();
return request('/logs' + (qs ? '?' + qs : ''));
},
settings: () => request('/settings'),
updateSettings: (body) => request('/settings', { method: 'PUT', body }),
};
+57
View File
@@ -0,0 +1,57 @@
<script setup>
import { ref, watch } from 'vue';
import { api } from '../api/admin.js';
const props = defineProps({ open: Boolean, hub: Object });
const emit = defineEmits(['close', 'saved']);
const hubId = ref('');
const hubName = ref('');
const busy = ref(false);
const error = ref('');
watch(() => props.open, (open) => {
if (open) {
hubId.value = (props.hub && props.hub.hub_id) || '';
hubName.value = (props.hub && props.hub.hub_name) || '';
error.value = '';
busy.value = false;
}
});
async function save() {
if (busy.value) return;
if (!hubName.value.trim()) { error.value = '中枢名称不能为空'; return; }
busy.value = true;
error.value = '';
try {
const r = await api.updateSettings({ hub_id: hubId.value.trim(), hub_name: hubName.value.trim() });
emit('saved', r.settings);
} catch (e) {
error.value = e.message || '保存失败';
} finally {
busy.value = false;
}
}
</script>
<template>
<div v-if="open" class="overlay" @click.self="emit('close')">
<div class="modal">
<div class="modal-head">中枢设置</div>
<div class="modal-body">
<div class="field"><label>中枢名称</label><input v-model="hubName" placeholder="例如:湘湖实验室中枢"></div>
<div class="field" style="margin-bottom: 0"><label>中枢 ID</label><input v-model="hubId" class="mono" placeholder="唯一标识,留空则由系统生成"></div>
<p v-if="error" class="err">{{ error }}</p>
</div>
<div class="modal-foot">
<button class="btn btn-ghost" @click="emit('close')">取消</button>
<button class="btn btn-primary" :disabled="busy" @click="save">{{ busy ? '保存中…' : '保存' }}</button>
</div>
</div>
</div>
</template>
<style scoped>
.err { color: var(--red); font-size: 12px; margin: 12px 0 0; }
</style>
+5
View File
@@ -146,6 +146,11 @@ CREATE TABLE IF NOT EXISTS auth_tokens (
);
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user ON auth_tokens(user_id);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
"""
+16
View File
@@ -21,6 +21,7 @@ from ..services.auth import (
delete_user,
)
from ..services.logview import query_logs
from ..services.settings import get_hub_settings, set_hub_settings
from ..upstream import envelope
router = APIRouter(prefix="/api/admin", tags=["admin"])
@@ -44,6 +45,11 @@ class UserPatch(BaseModel):
password: str | None = None
class HubSettingsBody(BaseModel):
hub_id: str | None = None
hub_name: str | None = None
@router.post("/login")
async def login(body: LoginBody, db: Database = Depends(get_db)):
user = await verify_login(db, body.username, body.password)
@@ -79,6 +85,16 @@ async def units(_user: dict = Depends(require_admin)):
return {"ok": True, "units": list(AUTHORIZATION_UNITS)}
@router.get("/settings")
async def get_settings_api(db: Database = Depends(get_db), _user: dict = Depends(require_admin)):
return {"ok": True, "settings": await get_hub_settings(db)}
@router.put("/settings")
async def put_settings_api(body: HubSettingsBody, db: Database = Depends(get_db), _user: dict = Depends(require_admin)):
return {"ok": True, "settings": await set_hub_settings(db, body.hub_id, body.hub_name)}
@router.get("/users")
async def users(db: Database = Depends(get_db), _user: dict = Depends(require_admin)):
return {"ok": True, "users": await list_users(db)}
+40
View File
@@ -0,0 +1,40 @@
"""services/settings.py —— 中枢级键值设置(hub_id / hub_name)。"""
from __future__ import annotations
import uuid
from ..db import Database
_HUB_ID_KEY = "hub_id"
_HUB_NAME_KEY = "hub_name"
DEFAULT_HUB_NAME = "蜂群中枢"
async def get_setting(db: Database, key: str, default: str | None = None) -> str | None:
row = await db.fetchone("SELECT value FROM settings WHERE key = ?", (key,))
return row["value"] if row else default
async def set_setting(db: Database, key: str, value: str) -> None:
row = await db.fetchone("SELECT 1 FROM settings WHERE key = ?", (key,))
if row is not None:
await db.execute("UPDATE settings SET value = ? WHERE key = ?", (value, key))
else:
await db.execute("INSERT INTO settings (key, value) VALUES (?, ?)", (key, value))
async def get_hub_settings(db: Database) -> dict:
hub_id = await get_setting(db, _HUB_ID_KEY)
if not hub_id:
hub_id = str(uuid.uuid4())
await set_setting(db, _HUB_ID_KEY, hub_id)
hub_name = (await get_setting(db, _HUB_NAME_KEY, DEFAULT_HUB_NAME)) or DEFAULT_HUB_NAME
return {"hub_id": hub_id, "hub_name": hub_name}
async def set_hub_settings(db: Database, hub_id: str | None, hub_name: str | None) -> dict:
if hub_id and hub_id.strip():
await set_setting(db, _HUB_ID_KEY, hub_id.strip())
if hub_name and hub_name.strip():
await set_setting(db, _HUB_NAME_KEY, hub_name.strip())
return await get_hub_settings(db)