feat(admin): 增加中枢设置(自定义 ID 与名称,右上角设置入口)
This commit is contained in:
+22
-3
@@ -4,16 +4,28 @@ import { getToken, setToken, api } from './api/admin.js';
|
|||||||
import Login from './views/Login.vue';
|
import Login from './views/Login.vue';
|
||||||
import Users from './views/Users.vue';
|
import Users from './views/Users.vue';
|
||||||
import Logs from './views/Logs.vue';
|
import Logs from './views/Logs.vue';
|
||||||
|
import HubSettings from './components/HubSettings.vue';
|
||||||
|
|
||||||
const authed = ref(false);
|
const authed = ref(false);
|
||||||
const page = ref('overview');
|
const page = ref('overview');
|
||||||
const user = ref(null);
|
const user = ref(null);
|
||||||
const stats = ref({ users: 0, total: 0, denied: 0, p95: 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: '日志查看' };
|
const PAGES = { overview: '概览', users: '用户管理', logs: '日志查看' };
|
||||||
|
|
||||||
function onUnauthorized() { setToken(''); authed.value = false; }
|
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() {
|
async function loadStats() {
|
||||||
try {
|
try {
|
||||||
const [u, l] = await Promise.all([api.users(), api.logs({ page: 1, page_size: 1 })]);
|
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();
|
const r = await api.me();
|
||||||
user.value = r.user;
|
user.value = r.user;
|
||||||
authed.value = true;
|
authed.value = true;
|
||||||
|
loadHub();
|
||||||
loadStats();
|
loadStats();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setToken('');
|
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; }
|
async function onLogout() { try { await api.logout(); } catch {} setToken(''); authed.value = false; }
|
||||||
|
function onSettingsSaved(s) { hub.value = s; settingsOpen.value = false; }
|
||||||
|
|
||||||
onMounted(init);
|
onMounted(init);
|
||||||
</script>
|
</script>
|
||||||
@@ -51,7 +65,7 @@ onMounted(init);
|
|||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="side-brand">
|
<div class="side-brand">
|
||||||
<div class="hex">蜂</div>
|
<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>
|
</div>
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<div class="nav-item" :class="{ active: page === 'overview' }" @click="page = 'overview'"><span>◈</span> 概览</div>
|
<div class="nav-item" :class="{ active: page === 'overview' }" @click="page = 'overview'"><span>◈</span> 概览</div>
|
||||||
@@ -67,7 +81,11 @@ onMounted(init);
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<main class="main">
|
<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">
|
<div class="content">
|
||||||
<section v-if="page === 'overview'">
|
<section v-if="page === 'overview'">
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
@@ -81,6 +99,7 @@ onMounted(init);
|
|||||||
<Logs v-else @unauthorized="onUnauthorized" />
|
<Logs v-else @unauthorized="onUnauthorized" />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
<HubSettings :open="settingsOpen" :hub="hub" @close="settingsOpen = false" @saved="onSettingsSaved" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -43,4 +43,6 @@ export const api = {
|
|||||||
const qs = q.toString();
|
const qs = q.toString();
|
||||||
return request('/logs' + (qs ? '?' + qs : ''));
|
return request('/logs' + (qs ? '?' + qs : ''));
|
||||||
},
|
},
|
||||||
|
settings: () => request('/settings'),
|
||||||
|
updateSettings: (body) => request('/settings', { method: 'PUT', body }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 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
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from ..services.auth import (
|
|||||||
delete_user,
|
delete_user,
|
||||||
)
|
)
|
||||||
from ..services.logview import query_logs
|
from ..services.logview import query_logs
|
||||||
|
from ..services.settings import get_hub_settings, set_hub_settings
|
||||||
from ..upstream import envelope
|
from ..upstream import envelope
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||||
@@ -44,6 +45,11 @@ class UserPatch(BaseModel):
|
|||||||
password: str | None = None
|
password: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HubSettingsBody(BaseModel):
|
||||||
|
hub_id: str | None = None
|
||||||
|
hub_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
@router.post("/login")
|
||||||
async def login(body: LoginBody, db: Database = Depends(get_db)):
|
async def login(body: LoginBody, db: Database = Depends(get_db)):
|
||||||
user = await verify_login(db, body.username, body.password)
|
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)}
|
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")
|
@router.get("/users")
|
||||||
async def users(db: Database = Depends(get_db), _user: dict = Depends(require_admin)):
|
async def users(db: Database = Depends(get_db), _user: dict = Depends(require_admin)):
|
||||||
return {"ok": True, "users": await list_users(db)}
|
return {"ok": True, "users": await list_users(db)}
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user