feat(frontend): implement basic frontend interface

This commit is contained in:
2026-09-08 23:15:50 +08:00
parent 292e879515
commit a90b4f5497
29 changed files with 5190 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
<script setup>
import { onBeforeUnmount, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { toasts } from './utils/toast.js';
const router = useRouter();
const glyph = { success: '✓', error: '✕', info: 'i', warn: '!' };
function onAuthExpired() {
if (router.currentRoute.value.name !== 'Login') {
router.push({ name: 'Login' });
}
}
onMounted(() => window.addEventListener('auth:expired', onAuthExpired));
onBeforeUnmount(() => window.removeEventListener('auth:expired', onAuthExpired));
</script>
<template>
<router-view />
<div class="toasts" aria-live="polite">
<div
v-for="t in toasts"
:key="t.id"
class="toast"
:class="[t.type, { leaving: t.leaving }]"
role="status"
>
<span class="t-icon">{{ glyph[t.type] || 'i' }}</span>
<span class="t-msg">{{ t.message }}</span>
</div>
</div>
</template>
+20
View File
@@ -0,0 +1,20 @@
import http from '../utils/http.js';
export const authApi = {
login: (username, password) => http.post('/user/login', { username, password }),
register: (username, password) => http.post('/user/register', { username, password })
};
// /api/server/getStatus?uuid=all → { success, "<uuid>": { uuid, name, online, report } }
export const serverApi = {
statusAll: () => http.get('/server/getStatus?uuid=all'),
infoAll: () => http.get('/server/getInfo?uuid=all')
};
// /api/settings/get?type=… / /api/settings/set?type=…
// `type` is one of global | bot_user_config | bot_node_config.
// For set, pass a partial object; a JSON null value removes that key.
export const settingsApi = {
get: (type) => http.get(`/settings/get?type=${encodeURIComponent(type)}`),
set: (type, patch) => http.post(`/settings/set?type=${encodeURIComponent(type)}`, patch)
};
+91
View File
@@ -0,0 +1,91 @@
<script setup>
import { reactive, watch } from 'vue';
const props = defineProps({
modelValue: { type: Object, default: () => ({}) }
});
const emit = defineEmits(['update:modelValue']);
const rows = reactive([]);
let suppress = false;
function sync() {
rows.length = 0;
for (const [k, v] of Object.entries(props.modelValue || {})) {
if (k !== '') rows.push({ k, v: v === null || v === undefined ? '' : String(v) });
}
if (!rows.length) rows.push({ k: '', v: '' });
}
function commit() {
const obj = {};
for (const r of rows) {
const key = r.k.trim();
if (key) obj[key] = r.v;
}
// Emitting updates the parent model, which flows back through the prop and
// would otherwise rebuild the rows mid-typing (and drop focus). Suppress
// that self-echo for this tick.
suppress = true;
emit('update:modelValue', obj);
setTimeout(() => { suppress = false; }, 0);
}
function addRow() {
if (rows.length && !rows[rows.length - 1].k.trim()) return;
rows.push({ k: '', v: '' });
}
function removeRow(i) {
rows.splice(i, 1);
if (!rows.length) rows.push({ k: '', v: '' });
commit();
}
// Re-sync only when the value is replaced externally (e.g. after reload).
watch(() => props.modelValue, () => {
if (!suppress) sync();
}, { deep: false });
sync();
</script>
<template>
<div class="kv-editor">
<div v-for="(r, i) in rows" :key="i" class="kv-row">
<input v-model="r.k" class="input kv-key mono" placeholder="Header name" @input="commit" />
<input v-model="r.v" class="input kv-val mono" placeholder="Value" @input="commit" />
<button type="button" class="icon-btn danger" title="Remove header" @click="removeRow(i)">
<i class="fas fa-minus" />
</button>
</div>
<button type="button" class="btn btn-ghost btn-sm kv-add" @click="addRow">
<i class="fas fa-plus" /> Add header
</button>
</div>
</template>
<style scoped>
.kv-editor {
display: flex;
flex-direction: column;
gap: 8px;
}
.kv-row {
display: grid;
grid-template-columns: 1fr 1.4fr 34px;
gap: 8px;
align-items: center;
}
.kv-key {
color: var(--text);
}
.kv-add {
align-self: flex-start;
}
</style>
+32
View File
@@ -0,0 +1,32 @@
<script setup>
defineProps({
open: { type: Boolean, default: false },
title: { type: String, default: '' },
wide: { type: Boolean, default: false }
});
const emit = defineEmits(['close']);
</script>
<template>
<Teleport to="body">
<Transition name="fade-switch">
<div v-if="open" class="modal-mask" @click.self="emit('close')">
<div class="modal" :class="{ wide }" role="dialog" aria-modal="true" @keydown.esc="emit('close')">
<div class="modal-head">
<h3>{{ title }}</h3>
<button class="icon-btn" title="Close" @click="emit('close')">
<i class="fas fa-xmark" />
</button>
</div>
<div class="modal-body">
<slot />
</div>
<div v-if="$slots.foot" class="modal-foot">
<slot name="foot" />
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
+141
View File
@@ -0,0 +1,141 @@
<script setup>
const groups = [
{
label: 'Operations',
items: [
{ name: 'Overview', to: '/overview', title: 'Nodes', icon: 'fa-server' },
{ name: 'Trusted', to: '/trusted', title: 'Trust', icon: 'fa-shield-halved' }
]
},
{
label: 'System',
items: [
{ name: 'Settings', to: '/settings', title: 'Settings', icon: 'fa-sliders' },
{ name: 'Logs', to: '/logs', title: 'Logs', icon: 'fa-terminal' }
]
}
];
</script>
<template>
<aside class="sidebar">
<nav class="nav">
<div v-for="group in groups" :key="group.label" class="group">
<p class="group-label">{{ group.label }}</p>
<router-link
v-for="item in group.items"
:key="item.name"
:to="item.to"
class="nav-item"
active-class="active"
>
<i class="fas" :class="item.icon" aria-hidden="true" />
<span>{{ item.title }}</span>
</router-link>
</div>
</nav>
<div class="side-foot">
<span class="foot-dot" />
<span>Nukumizu Console</span>
</div>
</aside>
</template>
<style scoped>
.sidebar {
position: fixed;
top: 52px;
left: 0;
bottom: 0;
width: 232px;
z-index: 30;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 16px 12px;
background: var(--surface);
border-right: 1px solid var(--line);
}
.group-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-3);
margin: 4px 10px 6px;
}
.group + .group {
margin-top: 18px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 9px 12px;
border-radius: var(--r-m);
color: var(--text-2);
font-size: 14px;
font-weight: 500;
transition: background 0.14s ease, color 0.14s ease;
position: relative;
}
.nav-item i {
width: 17px;
text-align: center;
font-size: 14px;
color: var(--text-3);
transition: color 0.14s ease;
}
.nav-item:hover {
background: var(--surface-3);
color: var(--text);
}
.nav-item:hover i {
color: var(--text-2);
}
.nav-item.active {
background: var(--accent-soft);
color: var(--accent);
font-weight: 600;
}
.nav-item.active i {
color: var(--accent);
}
.nav-item.active::before {
content: '';
position: absolute;
left: -12px;
top: 8px;
bottom: 8px;
width: 3px;
border-radius: 0 3px 3px 0;
background: var(--accent);
}
.side-foot {
display: flex;
align-items: center;
gap: 8px;
margin: 10px 6px 2px;
font-size: 11.5px;
color: var(--text-3);
}
.foot-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--ok);
box-shadow: 0 0 0 3px var(--ok-soft);
}
</style>
+118
View File
@@ -0,0 +1,118 @@
<script setup>
import { ref } from 'vue';
const props = defineProps({
modelValue: { type: Array, default: () => [] },
placeholder: { type: String, default: 'Type and press Enter to add' }
});
const emit = defineEmits(['update:modelValue']);
const text = ref('');
function add() {
const items = text.value
.split(/[,\n]/)
.map((s) => s.trim())
.filter(Boolean);
if (!items.length) return;
const next = [...props.modelValue];
for (const it of items) {
if (!next.includes(it)) next.push(it);
}
emit('update:modelValue', next);
text.value = '';
}
function removeAt(i) {
const next = props.modelValue.slice();
next.splice(i, 1);
emit('update:modelValue', next);
}
function onKeydown(e) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
add();
} else if (e.key === 'Backspace' && !text.value && props.modelValue.length) {
removeAt(props.modelValue.length - 1);
}
}
</script>
<template>
<div class="tags-editor">
<div v-if="modelValue.length" class="tags">
<span v-for="(t, i) in modelValue" :key="i" class="tag">
{{ t }}
<button type="button" class="tag-x" @click="removeAt(i)"><i class="fas fa-xmark" /></button>
</span>
</div>
<input
v-model="text"
class="input tags-input"
:placeholder="modelValue.length ? placeholder : placeholder"
@keydown="onKeydown"
@blur="add"
/>
</div>
</template>
<style scoped>
.tags-editor {
display: flex;
flex-direction: column;
gap: 8px;
background: var(--surface-2);
border: 1px solid var(--line-strong);
border-radius: var(--r-s);
padding: 8px;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.tags-editor:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
background: var(--surface);
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 6px 3px 10px;
background: var(--accent-soft);
color: var(--accent);
border-radius: var(--r-pill);
font-size: 12.5px;
font-weight: 600;
}
.tag-x {
width: 16px;
height: 16px;
border-radius: 50%;
display: grid;
place-items: center;
color: inherit;
font-size: 10px;
}
.tag-x:hover { background: color-mix(in srgb, var(--accent) 20%, transparent); }
.tags-input {
border: 0;
background: transparent;
padding: 2px 4px;
box-shadow: none !important;
}
.tags-input:focus {
background: transparent;
}
</style>
+29
View File
@@ -0,0 +1,29 @@
<script setup>
defineProps({
modelValue: { type: Boolean, default: false },
label: { type: String, default: '' },
disabled: { type: Boolean, default: false }
});
const emit = defineEmits(['update:modelValue']);
</script>
<template>
<label class="switch" :class="{ disabled }">
<input
type="checkbox"
:checked="modelValue"
:disabled="disabled"
@change="emit('update:modelValue', $event.target.checked)"
/>
<span class="track" />
<span v-if="label" class="switch-label">{{ label }}</span>
</label>
</template>
<style scoped>
.switch.disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
+180
View File
@@ -0,0 +1,180 @@
<script setup>
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { auth, clearSession } from '../utils/auth.js';
import { currentTheme, toggleTheme } from '../utils/theme.js';
import { toast } from '../utils/toast.js';
const router = useRouter();
const isDark = ref(currentTheme() === 'dark');
const user = computed(() => auth.user || {});
const initial = computed(() => ((user.value.username || '?').slice(0, 1) || '?').toUpperCase());
function onToggleTheme() {
isDark.value = toggleTheme() === 'dark';
}
function logout() {
clearSession();
toast.info('Signed out');
router.push({ name: 'Login' });
}
</script>
<template>
<header class="topbar">
<div class="brand">
<span class="mark">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2 3 6.5v3.1C3 15.2 6.9 20 12 21c5.1-1 9-5.8 9-11.4V6.5L12 2Zm0 7.2V19c-3.7-.7-6.6-4.4-6.6-8.2V7.6L12 4.4l6.6 3.2v3.2c0 1.9-.5 3.7-1.4 5.1H12Z" fill="currentColor"/></svg>
</span>
<span class="wordmark">
<strong>Nukumizu</strong>
<small>Console</small>
</span>
</div>
<div class="topbar-right">
<button class="icon-btn" :title="isDark ? 'Switch to light' : 'Switch to dark'" @click="onToggleTheme">
<i class="fas" :class="isDark ? 'fa-sun' : 'fa-moon'" />
</button>
<div class="user-chip">
<span class="avatar">{{ initial }}</span>
<div class="who">
<span class="name">{{ user.username || '—' }}</span>
<span class="level">{{ user.level || 'admin' }}</span>
</div>
</div>
<button class="icon-btn" title="Sign out" @click="logout">
<i class="fas fa-arrow-right-from-bracket" />
</button>
</div>
</header>
</template>
<style scoped>
.topbar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 52px;
z-index: 40;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 18px;
background: var(--surface);
border-bottom: 1px solid var(--line);
box-shadow: var(--shadow-1);
}
.brand {
display: flex;
align-items: center;
gap: 11px;
}
.mark {
width: 30px;
height: 30px;
border-radius: 8px;
display: grid;
place-items: center;
background: linear-gradient(150deg, var(--accent), var(--accent-strong));
color: #fff;
box-shadow: 0 3px 10px -3px color-mix(in srgb, var(--accent) 60%, transparent);
}
.mark svg {
width: 19px;
height: 19px;
}
.wordmark {
display: flex;
align-items: baseline;
gap: 8px;
line-height: 1;
}
.wordmark strong {
font-family: var(--font-display);
font-size: 17px;
font-weight: 700;
letter-spacing: -0.02em;
}
.wordmark small {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-3);
}
.topbar-right {
display: flex;
align-items: center;
gap: 10px;
}
.icon-btn {
width: 34px;
height: 34px;
border-radius: 50%;
display: grid;
place-items: center;
color: var(--text-2);
font-size: 15px;
transition: background 0.15s ease, color 0.15s ease;
}
.icon-btn:hover {
background: var(--surface-3);
color: var(--text);
}
.user-chip {
display: flex;
align-items: center;
gap: 10px;
padding: 5px 6px 5px 5px;
border-radius: var(--r-pill);
border: 1px solid var(--line);
background: var(--surface-2);
}
.avatar {
width: 30px;
height: 30px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--accent-soft);
color: var(--accent);
font-weight: 700;
font-size: 14px;
}
.who {
display: flex;
flex-direction: column;
line-height: 1.15;
padding-right: 6px;
}
.who .name {
font-size: 13px;
font-weight: 600;
}
.who .level {
font-size: 11px;
color: var(--text-3);
text-transform: uppercase;
letter-spacing: 0.06em;
}
</style>
+11
View File
@@ -0,0 +1,11 @@
import { createApp } from 'vue';
import App from './App.vue';
import router from './router/index.js';
import { initTheme } from './utils/theme.js';
import '@fortawesome/fontawesome-free/css/all.min.css';
import './styles/theme.css';
import './styles/ui.css';
initTheme();
createApp(App).use(router).mount('#app');
+60
View File
@@ -0,0 +1,60 @@
import { createRouter, createWebHistory } from 'vue-router';
import { isLoggedIn } from '../utils/auth.js';
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('../views/Login.vue'),
meta: { public: true }
},
{
path: '/',
component: () => import('../views/Layout.vue'),
redirect: { name: 'Overview' },
children: [
{
path: 'overview',
name: 'Overview',
component: () => import('../views/Overview.vue'),
meta: { title: 'Nodes' }
},
{
path: 'trusted',
name: 'Trusted',
component: () => import('../views/Trusted.vue'),
meta: { title: 'Bot trust' }
},
{
path: 'settings',
name: 'Settings',
component: () => import('../views/Settings.vue'),
meta: { title: 'Settings' }
},
{
path: 'logs',
name: 'Logs',
component: () => import('../views/Logs.vue'),
meta: { title: 'Logs' }
}
]
},
{ path: '/:pathMatch(.*)*', redirect: '/' }
];
const router = createRouter({
history: createWebHistory(),
routes
});
router.beforeEach((to) => {
if (!to.meta.public && !isLoggedIn()) {
return { name: 'Login', query: { redirect: to.fullPath } };
}
if (to.name === 'Login' && isLoggedIn()) {
return { name: 'Overview' };
}
return true;
});
export default router;
+95
View File
@@ -0,0 +1,95 @@
/* Nukumizu Console — design tokens. Light is the default, dark is applied by
setting data-theme="dark" on <html>. The look is a restrained, Apple-school
console: neutral warm-gray fields, one accent reserved for interaction, and
color used only where it carries meaning (health, log severity). */
:root {
--font-ui: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-display: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
--bg: #eef0f3;
--bg-grad: linear-gradient(180deg, #f4f5f7 0%, #eaedf1 100%);
--surface: #ffffff;
--surface-2: #fafbfc;
--surface-3: #eef0f3;
--surface-hover: #e7eaee;
--text: #1b1e24;
--text-2: rgba(27, 30, 36, 0.68);
--text-3: rgba(27, 30, 36, 0.46);
--line: rgba(20, 24, 32, 0.09);
--line-strong: rgba(20, 24, 32, 0.16);
--accent: #006ee0;
--accent-strong: #0059b8;
--accent-soft: rgba(0, 110, 224, 0.09);
--accent-contrast: #ffffff;
--ok: #1f9d58;
--ok-soft: rgba(31, 157, 88, 0.12);
--warn: #c07b06;
--warn-soft: rgba(192, 123, 6, 0.13);
--bad: #d73a49;
--bad-soft: rgba(215, 58, 73, 0.1);
--muted: #8a919c;
--log-debug: #188a4b;
--log-info: #0a62d0;
--log-warn: #b5720a;
--log-error: #cc3340;
--log-fatal: #a31b28;
--r-s: 7px;
--r-m: 11px;
--r-l: 16px;
--r-pill: 999px;
--shadow-1: 0 1px 2px rgba(16, 22, 30, 0.05);
--shadow-2: 0 1px 1px rgba(16, 22, 30, 0.04), 0 6px 24px -8px rgba(16, 22, 30, 0.14);
--shadow-3: 0 2px 4px rgba(16, 22, 30, 0.06), 0 18px 48px -12px rgba(16, 22, 30, 0.22);
--nav-bg: rgba(15, 17, 21, 0.86);
--nav-text: rgba(255, 255, 255, 0.72);
--nav-text-hover: #ffffff;
--nav-line: rgba(255, 255, 255, 0.08);
}
html[data-theme="dark"] {
--bg: #0b0c10;
--bg-grad: linear-gradient(180deg, #0e1014 0%, #090a0e 100%);
--surface: #15171d;
--surface-2: #1b1e25;
--surface-3: #24272f;
--surface-hover: #2b2f38;
--text: #eceef2;
--text-2: rgba(236, 238, 242, 0.66);
--text-3: rgba(236, 238, 242, 0.44);
--line: rgba(255, 255, 255, 0.08);
--line-strong: rgba(255, 255, 255, 0.14);
--accent: #2f8dff;
--accent-strong: #57a3ff;
--accent-soft: rgba(47, 141, 255, 0.16);
--ok: #3ecf83;
--ok-soft: rgba(62, 207, 131, 0.16);
--warn: #e3a23a;
--warn-soft: rgba(227, 162, 58, 0.16);
--bad: #ff6b76;
--bad-soft: rgba(255, 107, 118, 0.15);
--muted: #6b7280;
--log-debug: #43cf86;
--log-info: #4b9dff;
--log-warn: #efb458;
--log-error: #ff7b84;
--log-fatal: #ff525e;
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-2: 0 1px 1px rgba(0, 0, 0, 0.4), 0 8px 28px -10px rgba(0, 0, 0, 0.6);
--shadow-3: 0 2px 4px rgba(0, 0, 0, 0.4), 0 22px 56px -16px rgba(0, 0, 0, 0.7);
}
+845
View File
@@ -0,0 +1,845 @@
/* Nukumizu Console — shared UI primitives. Components may add scoped styles,
but everything reusable lives here. */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
}
body {
font-family: var(--font-ui);
font-size: 14px;
line-height: 1.5;
letter-spacing: -0.01em;
color: var(--text);
background: var(--bg-grad) fixed;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
transition: background 0.25s ease, color 0.25s ease;
}
#app {
min-height: 100vh;
}
::selection {
background: var(--accent-soft);
}
a {
color: var(--accent);
text-decoration: none;
}
button {
font: inherit;
color: inherit;
background: none;
border: 0;
cursor: pointer;
}
input, select, textarea {
font: inherit;
color: inherit;
}
h1, h2, h3, h4 {
font-family: var(--font-display);
font-weight: 600;
letter-spacing: -0.02em;
color: var(--text);
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: var(--line-strong);
border-radius: 999px;
border: 3px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background: var(--muted);
background-clip: content-box;
border: 3px solid transparent;
}
::-webkit-scrollbar-track {
background: transparent;
}
/* --- Page scaffolding --------------------------------------------------- */
.page {
padding: 34px 40px 60px;
max-width: 1180px;
margin: 0 auto;
}
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
margin-bottom: 26px;
}
.page-head .eyebrow {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 6px;
}
.page-head h1 {
font-size: 30px;
line-height: 1.12;
}
.page-head .lead {
margin-top: 6px;
color: var(--text-2);
font-size: 14px;
max-width: 620px;
}
.head-actions {
display: flex;
gap: 10px;
align-items: center;
flex: none;
}
.section-title {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--text-2);
margin: 26px 0 12px;
}
.mono {
font-family: var(--font-mono);
font-size: 0.92em;
}
/* --- Buttons ------------------------------------------------------------ */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 8px 15px;
border-radius: var(--r-m);
font-size: 14px;
font-weight: 500;
line-height: 1.2;
border: 1px solid transparent;
background: var(--surface-3);
color: var(--text);
transition: background 0.15s ease, border-color 0.15s ease, transform 0.08s ease, opacity 0.15s ease, box-shadow 0.15s ease;
white-space: nowrap;
user-select: none;
}
.btn:hover {
background: var(--surface-hover);
}
.btn:active {
transform: translateY(1px);
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn-primary {
background: var(--accent);
color: var(--accent-contrast);
}
.btn-primary:hover {
background: var(--accent-strong);
}
.btn-danger {
background: var(--bad-soft);
color: var(--bad);
}
.btn-danger:hover {
background: color-mix(in srgb, var(--bad) 16%, transparent);
}
.btn-ghost {
background: transparent;
color: var(--text-2);
border-color: var(--line);
}
.btn-ghost:hover {
background: var(--surface-3);
color: var(--text);
}
.btn-sm {
padding: 5px 11px;
font-size: 13px;
border-radius: var(--r-s);
}
.btn-icon {
width: 34px;
height: 34px;
padding: 0;
border-radius: 50%;
}
.icon-btn {
width: 34px;
height: 34px;
display: inline-grid;
place-items: center;
border-radius: 50%;
color: var(--text-2);
font-size: 15px;
transition: background 0.15s ease, color 0.15s ease;
}
.icon-btn:hover {
background: var(--surface-3);
color: var(--text);
}
.icon-btn.danger:hover {
background: var(--bad-soft);
color: var(--bad);
}
/* --- Cards -------------------------------------------------------------- */
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-l);
box-shadow: var(--shadow-1);
}
.card + .card {
margin-top: 18px;
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 20px;
border-bottom: 1px solid var(--line);
}
.card-head h3 {
font-size: 16px;
}
.card-head .hint {
color: var(--text-3);
font-size: 12.5px;
margin-top: 2px;
}
.card-body {
padding: 20px;
}
/* --- Forms -------------------------------------------------------------- */
.field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.field > label {
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.01em;
color: var(--text-2);
}
.field > label .required {
color: var(--bad);
margin-left: 2px;
}
.field .help {
font-size: 12px;
color: var(--text-3);
}
.input, .select, .textarea {
width: 100%;
padding: 9px 12px;
background: var(--surface-2);
border: 1px solid var(--line-strong);
border-radius: var(--r-s);
color: var(--text);
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
}
.input:hover, .select:hover, .textarea:hover {
border-color: var(--muted);
}
.input:focus, .select:focus, .textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
background: var(--surface);
}
.textarea {
resize: vertical;
min-height: 84px;
line-height: 1.55;
font-family: var(--font-mono);
font-size: 13px;
tab-size: 4;
}
.select {
appearance: none;
background-image: linear-gradient(45deg, transparent 50%, var(--text-2) 50%),
linear-gradient(135deg, var(--text-2) 50%, transparent 50%);
background-position: calc(100% - 17px) 55%, calc(100% - 12px) 55%;
background-size: 5px 5px;
background-repeat: no-repeat;
padding-right: 30px;
cursor: pointer;
}
.field input[type="color"] {
padding: 2px;
height: 38px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px 20px;
}
.form-grid .span-2 {
grid-column: span 2;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 8px;
}
/* --- Switch ------------------------------------------------------------- */
.switch {
position: relative;
display: inline-flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.switch input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.switch .track {
width: 40px;
height: 24px;
border-radius: 999px;
background: var(--surface-3);
border: 1px solid var(--line-strong);
position: relative;
transition: background 0.18s ease, border-color 0.18s ease;
flex: none;
}
.switch .track::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
transition: transform 0.18s ease;
}
.switch input:checked + .track {
background: var(--accent);
border-color: var(--accent);
}
.switch input:checked + .track::after {
transform: translateX(16px);
}
.switch input:focus-visible + .track {
box-shadow: 0 0 0 3px var(--accent-soft);
}
.switch .switch-label {
font-size: 13px;
color: var(--text-2);
}
.switch input:checked ~ .switch-label {
color: var(--text);
}
/* --- Badges / dots ------------------------------------------------------- */
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: var(--r-pill);
font-size: 12px;
font-weight: 600;
line-height: 1.4;
white-space: nowrap;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex: none;
background: var(--muted);
}
.badge-online, .b-online .dot { background: var(--ok-soft); color: var(--ok); }
.badge-online .dot { background: var(--ok); }
.badge-offline, .b-offline .dot { background: var(--surface-3); color: var(--text-3); }
.badge-offline .dot { background: var(--muted); }
.badge-warn { background: var(--warn-soft); color: var(--warn); }
.badge-danger { background: var(--bad-soft); color: var(--bad); }
.badge-accent { background: var(--accent-soft); color: var(--accent); }
/* --- Stat / metric ------------------------------------------------------- */
.metric {
display: flex;
flex-direction: column;
gap: 2px;
}
.metric .m-label {
font-size: 12px;
color: var(--text-3);
font-weight: 500;
}
.metric .m-value {
font-size: 17px;
font-weight: 600;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
}
.metric .m-value small {
font-size: 12px;
font-weight: 500;
color: var(--text-3);
}
/* --- Meter (soft progress) ----------------------------------------------- */
.meter {
height: 5px;
border-radius: 999px;
background: var(--surface-3);
overflow: hidden;
}
.meter > i {
display: block;
height: 100%;
border-radius: 999px;
background: var(--accent);
transition: width 0.3s ease;
}
.meter > i.warn { background: var(--warn); }
.meter > i.bad { background: var(--bad); }
.meter > i.ok { background: var(--ok); }
/* --- Server list --------------------------------------------------------- */
.grid-2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.grid-3 {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
@media (max-width: 980px) {
.grid-3 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.form-grid { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
.grid-2, .grid-3 { grid-template-columns: 1fr; }
}
.server-card {
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px 18px 16px;
cursor: default;
}
.server-card .row-1 {
display: flex;
align-items: flex-start;
gap: 12px;
}
.server-card .row-2 {
display: flex;
justify-content: space-between;
gap: 10px;
}
.server-card .sv-name {
font-size: 16px;
font-weight: 600;
letter-spacing: -0.01em;
line-height: 1.2;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.server-card .sv-uuid {
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--text-3);
margin-top: 3px;
overflow: hidden;
text-overflow: ellipsis;
}
.server-card .row-3 {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 12px;
}
/* --- Members / options list ---------------------------------------------- */
.opt-list {
display: flex;
flex-direction: column;
}
.opt-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
}
.opt-row:last-child {
border-bottom: 0;
}
.opt-row .who {
display: flex;
flex-direction: column;
min-width: 0;
}
.opt-row .who .id {
font-family: var(--font-mono);
font-size: 14px;
font-weight: 600;
}
.opt-row .who .sub {
font-size: 12px;
color: var(--text-3);
margin-top: 2px;
}
.opt-row .toggles {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 9px;
border-radius: var(--r-pill);
font-size: 11.5px;
font-weight: 600;
background: var(--surface-3);
color: var(--text-2);
letter-spacing: 0.02em;
text-transform: uppercase;
}
/* --- Empty / skeleton ----------------------------------------------------- */
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 48px 20px;
color: var(--text-3);
text-align: center;
}
.empty .e-icon {
font-size: 30px;
opacity: 0.5;
}
/* --- Toolbar --------------------------------------------------------------- */
.toolbar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.toolbar .spacer {
flex: 1;
}
.search {
position: relative;
flex: 1;
min-width: 200px;
max-width: 340px;
}
.search .icon {
position: absolute;
left: 11px;
top: 50%;
transform: translateY(-50%);
color: var(--text-3);
pointer-events: none;
font-size: 13px;
}
.search input {
width: 100%;
padding: 8px 12px 8px 32px;
background: var(--surface-2);
border: 1px solid var(--line-strong);
border-radius: var(--r-pill);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.search input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
/* --- Modal ------------------------------------------------------------------ */
.modal-mask {
position: fixed;
inset: 0;
z-index: 90;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 10vh 20px 40px;
background: rgba(12, 14, 18, 0.5);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
animation: fade-in 0.18s ease;
overflow-y: auto;
}
.modal {
width: 100%;
max-width: 480px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-l);
box-shadow: var(--shadow-3);
animation: rise 0.22s cubic-bezier(0.2, 0.9, 0.3, 1);
}
.modal.wide { max-width: 640px; }
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 20px 14px;
}
.modal-head h3 { font-size: 17px; }
.modal-body {
padding: 4px 20px 8px;
}
.modal-foot {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 20px 18px;
}
/* --- Toasts ------------------------------------------------------------------ */
.toasts {
position: fixed;
top: 18px;
left: 50%;
transform: translateX(-50%);
z-index: 120;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
pointer-events: none;
width: min(420px, calc(100vw - 32px));
}
.toast {
pointer-events: auto;
display: flex;
align-items: flex-start;
gap: 11px;
width: 100%;
padding: 12px 15px;
border-radius: var(--r-m);
background: var(--surface);
border: 1px solid var(--line);
box-shadow: var(--shadow-3);
font-size: 13.5px;
animation: toast-in 0.28s cubic-bezier(0.2, 0.9, 0.3, 1);
}
.toast .t-icon {
flex: none;
width: 22px;
height: 22px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 12px;
color: #fff;
background: var(--muted);
}
.toast.success .t-icon { background: var(--ok); }
.toast.error .t-icon { background: var(--bad); }
.toast.info .t-icon { background: var(--accent); }
.toast.warn .t-icon { background: var(--warn); }
.toast .t-msg {
flex: 1;
line-height: 1.45;
padding-top: 2px;
word-break: break-word;
}
.toast.leaving { animation: toast-out 0.25s ease forwards; }
/* --- Animations -------------------------------------------------------------- */
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes rise {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes toast-in {
from { opacity: 0; transform: translateY(-16px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes toast-out {
to { opacity: 0; transform: translateY(-14px) scale(0.97); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
display: inline-block;
width: 18px;
height: 18px;
border: 2px solid var(--line-strong);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
/* route transition */
.fade-switch-enter-active, .fade-switch-leave-active {
transition: opacity 0.16s ease, transform 0.16s ease;
}
.fade-switch-enter-from {
opacity: 0;
transform: translateY(6px);
}
.fade-switch-leave-to {
opacity: 0;
}
+39
View File
@@ -0,0 +1,39 @@
import { reactive } from 'vue';
const TOKEN_KEY = 'nukumizu_token';
const USER_KEY = 'nukumizu_user';
function readUser() {
try {
return JSON.parse(localStorage.getItem(USER_KEY)) || null;
} catch {
return null;
}
}
export const auth = reactive({
token: localStorage.getItem(TOKEN_KEY) || '',
user: readUser()
});
export function setSession(token, user) {
auth.token = token;
auth.user = user;
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(USER_KEY, JSON.stringify(user));
}
export function clearSession() {
auth.token = '';
auth.user = null;
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}
export function getToken() {
return auth.token;
}
export function isLoggedIn() {
return !!auth.token;
}
+43
View File
@@ -0,0 +1,43 @@
// Severity levels as sent by the backend log websocket (see postLog package):
// 0=DEBUG 1=INFO 2=WARN 3=ERROR 4=FATAL
export const LOG_LEVELS = [
{ key: 'DEBUG', value: 0 },
{ key: 'INFO', value: 1 },
{ key: 'WARN', value: 2 },
{ key: 'ERROR', value: 3 },
{ key: 'FATAL', value: 4 }
];
export const levelOf = (value) => LOG_LEVELS.find((l) => l.value === value) || { key: 'LOG', value };
export function formatBytes(n, digits = 1) {
if (n === null || n === undefined || Number.isNaN(n)) return '-';
if (n < 1024) return `${Math.round(n)} B`;
const units = ['KB', 'MB', 'GB', 'TB', 'PB'];
let v = n;
let u = -1;
do {
v /= 1024;
u += 1;
} while (v >= 1024 && u < units.length - 1);
return `${v.toFixed(digits)} ${units[u]}`;
}
export function formatPercent(used, total) {
if (!total) return '—';
return `${Math.min(100, Math.max(0, Math.round((used / total) * 100)))}%`;
}
export function shortUUID(uuid = '') {
if (!uuid) return '';
if (uuid.length <= 13) return uuid;
return `${uuid.slice(0, 8)}…${uuid.slice(-4)}`;
}
// Online/offline counts + aggregate online ratio. Report arrays carry the
// report; when absent the server has no live telemetry yet.
export function sumUp(entries) {
const total = entries.length;
const online = entries.filter((e) => e.online).length;
return { total, online, offline: total - online };
}
+37
View File
@@ -0,0 +1,37 @@
import axios from 'axios';
import { getToken, clearSession } from './auth.js';
const http = axios.create({
baseURL: '/api',
timeout: 15000
});
http.interceptors.request.use((config) => {
const token = getToken();
if (token) {
config.headers['X-Token'] = token;
}
// Backend expects a Unix timestamp in *seconds* with a ±30 min tolerance.
config.headers['X-Timestamp'] = Math.floor(Date.now() / 1000);
return config;
});
http.interceptors.response.use(
(response) => response.data,
(error) => {
const status = error.response ? error.response.status : 0;
const payload = error.response && error.response.data;
const message = (payload && payload.message) || error.message || 'Network error';
if (status === 401) {
clearSession();
window.dispatchEvent(new CustomEvent('auth:expired'));
}
const err = new Error(message);
err.status = status;
return Promise.reject(err);
}
);
export default http;
+32
View File
@@ -0,0 +1,32 @@
const KEY = 'nukumizu_theme';
function systemPrefersDark() {
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
}
export function applyTheme(theme) {
const dark = theme === 'dark' || (theme !== 'light' && systemPrefersDark());
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
return dark ? 'dark' : 'light';
}
export function currentTheme() {
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
}
export function initTheme() {
const saved = localStorage.getItem(KEY);
applyTheme(saved || 'system');
}
export function setTheme(theme) {
localStorage.setItem(KEY, theme);
return applyTheme(theme);
}
export function toggleTheme() {
const next = currentTheme() === 'dark' ? 'light' : 'dark';
localStorage.setItem(KEY, next);
applyTheme(next);
return next;
}
+39
View File
@@ -0,0 +1,39 @@
import { reactive } from 'vue';
let seq = 0;
export const toasts = reactive([]);
function dismiss(id) {
const idx = toasts.findIndex((t) => t.id === id);
if (idx !== -1) toasts[idx].leaving = true;
setTimeout(() => {
const i = toasts.findIndex((t) => t.id === id);
if (i !== -1) toasts.splice(i, 1);
}, 240);
}
function push(type, message, timeout) {
const id = ++seq;
toasts.push({ id, type, message, leaving: false });
if (timeout > 0) {
setTimeout(() => dismiss(id), timeout);
}
return id;
}
export const toast = {
success(message, timeout = 3600) {
return push('success', message, timeout);
},
error(message, timeout = 5200) {
return push('error', message, timeout);
},
info(message, timeout = 3200) {
return push('info', message, timeout);
},
warn(message, timeout = 4200) {
return push('warn', message, timeout);
},
dismiss
};
+38
View File
@@ -0,0 +1,38 @@
<script setup>
import TopBar from '../components/TopBar.vue';
import SideBar from '../components/SideBar.vue';
</script>
<template>
<div class="app">
<TopBar />
<div class="app-body">
<SideBar />
<main class="content">
<router-view v-slot="{ Component }">
<transition name="fade-switch" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
</div>
</div>
</template>
<style scoped>
.app {
min-height: 100vh;
}
.app-body {
display: flex;
min-height: 100vh;
}
.content {
flex: 1;
min-width: 0;
margin-left: 232px;
padding-top: 52px;
}
</style>
+242
View File
@@ -0,0 +1,242 @@
<script setup>
import { reactive, ref, computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { authApi } from '../api/index.js';
import { setSession } from '../utils/auth.js';
import { toast } from '../utils/toast.js';
const router = useRouter();
const route = useRoute();
const mode = ref('login');
const loading = ref(false);
const showPw = ref(false);
const form = reactive({ username: '', password: '' });
const errMsg = ref('');
const canSubmit = computed(() => form.username.trim().length > 0 && form.password.length >= 6);
function goTo(target) {
mode.value = target;
errMsg.value = '';
}
function afterLogin(token, user) {
setSession(token, user);
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/overview';
router.push(redirect);
}
async function submit() {
if (!canSubmit.value || loading.value) return;
loading.value = true;
errMsg.value = '';
try {
if (mode.value === 'login') {
const res = await authApi.login(form.username.trim(), form.password);
afterLogin(res.token, {
userID: res.userID,
username: res.username,
level: res.level,
registerDate: res.registerDate
});
} else {
const res = await authApi.register(form.username.trim(), form.password);
afterLogin(res.token, {
userID: res.userID,
username: res.username,
level: res.level
});
toast.success('Admin created — welcome to Nukumizu');
}
} catch (e) {
if (mode.value === 'register' && e.status === 403) {
errMsg.value = 'An admin already exists — registration is closed. Please sign in.';
mode.value = 'login';
} else {
errMsg.value = e.message || 'Operation failed';
}
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="auth">
<div class="auth-inner">
<div class="brand">
<span class="mark">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2 3 6.5v3.1C3 15.2 6.9 20 12 21c5.1-1 9-5.8 9-11.4V6.5L12 2Zm0 7.2V19c-3.7-.7-6.6-4.4-6.6-8.2V7.6L12 4.4l6.6 3.2v3.2c0 1.9-.5 3.7-1.4 5.1H12Z" fill="currentColor"/></svg>
</span>
<h1>Nukumizu</h1>
<p>Remote server monitoring &amp; alert bot — admin console</p>
</div>
<div class="panel">
<div class="tabs">
<button :class="{ on: mode === 'login' }" @click="goTo('login')">Sign in</button>
<button :class="{ on: mode === 'register' }" @click="goTo('register')">Create admin</button>
</div>
<form class="fields" @submit.prevent="submit">
<div class="field">
<label for="username">Username</label>
<input id="username" v-model="form.username" class="input" autocomplete="username" autofocus />
</div>
<div class="field">
<label for="password">Password</label>
<div class="pw">
<input
id="password"
v-model="form.password"
class="input"
:type="showPw ? 'text' : 'password'"
autocomplete="current-password"
placeholder="At least 6 characters"
/>
<button type="button" class="icon-btn eye" tabindex="-1" @click="showPw = !showPw">
<i class="fas" :class="showPw ? 'fa-eye-slash' : 'fa-eye'" />
</button>
</div>
</div>
<p v-if="errMsg" class="form-err">{{ errMsg }}</p>
<button class="btn btn-primary submit" type="submit" :disabled="!canSubmit || loading">
<span v-if="loading" class="spinner" style="width:14px;height:14px;border-width:2px" />
<span>{{ mode === 'login' ? 'Sign in' : 'Create & enter' }}</span>
</button>
<p v-if="mode === 'register'" class="hint">
Only available while no user exists yet. The first account becomes the admin.
</p>
</form>
</div>
</div>
</div>
</template>
<style scoped>
.auth {
min-height: 100vh;
display: grid;
place-items: center;
padding: 32px 20px;
background:
radial-gradient(900px 480px at 15% -10%, var(--accent-soft), transparent 60%),
radial-gradient(700px 420px at 110% 110%, color-mix(in srgb, var(--ok) 8%, transparent), transparent 60%),
var(--bg-grad) fixed;
}
.auth-inner {
width: 100%;
max-width: 360px;
display: flex;
flex-direction: column;
gap: 22px;
}
.brand {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.mark {
width: 54px;
height: 54px;
border-radius: 15px;
display: grid;
place-items: center;
background: linear-gradient(150deg, var(--accent), var(--accent-strong));
color: #fff;
box-shadow: 0 10px 30px -8px color-mix(in srgb, var(--accent) 55%, transparent);
margin-bottom: 4px;
}
.mark svg { width: 32px; height: 32px; }
.brand h1 {
font-size: 26px;
letter-spacing: -0.02em;
}
.brand p {
font-size: 13px;
color: var(--text-2);
}
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-l);
box-shadow: var(--shadow-2);
padding: 8px 24px 24px;
}
.tabs {
display: flex;
gap: 4px;
margin: 0 -24px 20px;
padding: 0 20px;
border-bottom: 1px solid var(--line);
}
.tabs button {
position: relative;
padding: 14px 8px;
font-size: 14px;
font-weight: 600;
color: var(--text-3);
}
.tabs button.on { color: var(--accent); }
.tabs button.on::after {
content: '';
position: absolute;
left: 8px;
right: 8px;
bottom: -1px;
height: 2px;
border-radius: 2px;
background: var(--accent);
}
.fields {
display: flex;
flex-direction: column;
gap: 14px;
}
.pw { position: relative; }
.pw .input { padding-right: 40px; }
.pw .eye {
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
}
.form-err {
font-size: 13px;
color: var(--bad);
background: var(--bad-soft);
border-radius: var(--r-s);
padding: 8px 12px;
}
.submit { width: 100%; padding: 11px; }
.hint {
font-size: 12.5px;
color: var(--text-3);
text-align: center;
}
</style>
+272
View File
@@ -0,0 +1,272 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { LOG_LEVELS } from '../utils/fmt.js';
const MAX_LOGS = 1200;
const logs = ref([]);
const pending = ref([]);
const status = ref('connecting');
const paused = ref(false);
const query = ref('');
const enabled = reactive(Object.fromEntries(LOG_LEVELS.map((l) => [l.key, true])));
let ws = null;
let closedManually = false;
let reconnectTimer = null;
const levelOf = (value) => LOG_LEVELS.find((l) => l.value === value) || { key: 'LOG', value };
const visible = computed(() => {
const q = query.value.trim().toLowerCase();
return logs.value.filter((m) => {
if (!enabled[levelOf(m.level).key]) return false;
if (!q) return true;
return (
String(m.content || '').toLowerCase().includes(q) ||
levelOf(m.level).key.toLowerCase().includes(q)
);
});
});
const statusText = computed(() => {
switch (status.value) {
case 'open': return { label: 'Connected', cls: 'ok' };
case 'connecting': return { label: 'Reconnecting', cls: 'warn' };
default: return { label: 'Disconnected', cls: 'muted' };
}
});
function wsUrl() {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${proto}//${window.location.host}/api/system/getLogs`;
}
function connect() {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (ws) {
try { ws.close(); } catch { /* ignore */ }
}
status.value = 'connecting';
try {
ws = new WebSocket(wsUrl());
} catch {
scheduleReconnect();
return;
}
ws.onopen = () => {
status.value = 'open';
};
ws.onmessage = (ev) => {
let msg;
try {
msg = JSON.parse(ev.data);
} catch {
return;
}
if (typeof msg.level !== 'number') return;
push(msg);
};
ws.onclose = () => {
status.value = 'closed';
if (!closedManually) scheduleReconnect();
};
ws.onerror = () => {
try { ws.close(); } catch { /* ignore */ }
};
}
function scheduleReconnect() {
if (closedManually) return;
reconnectTimer = setTimeout(connect, 1800);
}
function push(msg) {
if (paused.value) {
pending.value.push(msg);
if (pending.value.length > MAX_LOGS) pending.value.shift();
return;
}
logs.value.unshift(msg);
if (logs.value.length > MAX_LOGS) logs.value.pop();
}
function togglePause() {
paused.value = !paused.value;
if (!paused.value) {
const buffered = pending.value.splice(0);
logs.value.unshift(...buffered);
if (logs.value.length > MAX_LOGS) logs.value.length = MAX_LOGS;
}
}
function clearLogs() {
logs.value = [];
pending.value = [];
}
function exportLogs() {
const lines = visible.value
.map((m) => `[${m.timestamp}] [${levelOf(m.level).key.padEnd(5)}] ${m.content}`)
.join('\n');
const blob = new Blob([lines], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `nukumizu-logs-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.log`;
a.click();
URL.revokeObjectURL(url);
}
function toggleLevel(key) {
enabled[key] = !enabled[key];
}
onMounted(connect);
onBeforeUnmount(() => {
closedManually = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
if (ws) ws.close();
});
</script>
<template>
<section class="page">
<header class="page-head">
<div>
<p class="eyebrow">Telemetry</p>
<h1>System logs</h1>
<p class="lead">Live log stream from the server. Newest entries appear first.</p>
</div>
<div class="head-actions">
<span class="conn" :class="'conn-' + statusText.cls">
<span class="dot" />{{ statusText.label }}
</span>
<button class="btn btn-ghost" title="Reconnect" @click="connect"><i class="fas fa-rotate-right" /></button>
<button class="btn btn-ghost" title="Export visible logs" @click="exportLogs"><i class="fas fa-download" /></button>
<button class="btn btn-ghost" title="Clear view" @click="clearLogs"><i class="fas fa-eraser" /></button>
</div>
</header>
<div class="toolbar">
<div class="search">
<i class="fas fa-magnifying-glass icon" />
<input v-model="query" type="text" placeholder="Filter logs…" />
</div>
<div class="level-chips">
<button
v-for="l in LOG_LEVELS"
:key="l.key"
class="chip"
:class="['lv-' + l.key.toLowerCase(), { off: !enabled[l.key] }]"
@click="toggleLevel(l.key)"
>
{{ l.key }}
</button>
</div>
<span class="spacer" />
<button class="btn" :class="paused ? 'btn-primary' : 'btn-ghost'" @click="togglePause">
<i class="fas" :class="paused ? 'fa-play' : 'fa-pause'" />
{{ paused ? `Resume${pending.length ? ` (${pending.length})` : ''}` : 'Pause' }}
</button>
</div>
<div class="card log-card">
<div v-if="visible.length === 0" class="empty">
<i class="fas fa-terminal e-icon" />
<p>No matching log entries.</p>
</div>
<div v-else class="log-list">
<div v-for="(m, i) in visible" :key="m.timestamp + '-' + i" class="log-line">
<span class="t mono">{{ m.timestamp }}</span>
<span class="lv mono" :class="'lv-' + levelOf(m.level).key.toLowerCase()">
{{ levelOf(m.level).key }}
</span>
<span class="msg">{{ m.content }}</span>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.conn {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 12.5px;
font-weight: 600;
padding: 5px 12px;
border-radius: var(--r-pill);
border: 1px solid var(--line);
}
.conn-ok { color: var(--ok); }
.conn-ok .dot { background: var(--ok); }
.conn-warn { color: var(--warn); }
.conn-warn .dot { background: var(--warn); }
.conn-muted { color: var(--text-3); }
.conn-muted .dot { background: var(--muted); }
.level-chips { display: flex; gap: 6px; }
.chip { cursor: pointer; transition: opacity 0.15s ease; }
.chip.off { opacity: 0.38; text-decoration: line-through; }
.lv-debug { color: var(--log-debug); }
.lv-info { color: var(--log-info); }
.lv-warn { color: var(--log-warn); }
.lv-error { color: var(--log-error); }
.lv-fatal { color: var(--log-fatal); }
.log-card { overflow: hidden; }
.log-list {
max-height: calc(100vh - 300px);
overflow-y: auto;
font-family: var(--font-mono);
}
.log-line {
display: grid;
grid-template-columns: 170px 72px 1fr;
gap: 14px;
padding: 7px 16px;
font-size: 12.5px;
line-height: 1.45;
border-bottom: 1px solid var(--line);
align-items: baseline;
}
.log-line:hover { background: var(--surface-2); }
.log-line .t { color: var(--text-3); white-space: nowrap; }
.log-line .lv {
font-weight: 700;
font-size: 11px;
letter-spacing: 0.04em;
padding: 1px 8px;
border-radius: var(--r-pill);
background: var(--surface-3);
text-align: center;
}
.log-line .lv.lv-debug { background: var(--ok-soft); }
.log-line .lv.lv-info { background: var(--accent-soft); }
.log-line .lv.lv-warn { background: var(--warn-soft); }
.log-line .lv.lv-error { background: var(--bad-soft); }
.log-line .lv.lv-fatal { background: var(--bad-soft); }
.log-line .msg { color: var(--text); word-break: break-word; white-space: pre-wrap; }
</style>
+309
View File
@@ -0,0 +1,309 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { serverApi, settingsApi } from '../api/index.js';
import { toast } from '../utils/toast.js';
import { formatBytes, shortUUID } from '../utils/fmt.js';
import Toggle from '../components/Toggle.vue';
function percent(m) {
return m && m.total ? Math.min(100, Math.max(0, Math.round((m.used / m.total) * 100))) : 0;
}
const query = ref('');
const loading = ref(true);
const failed = ref(false);
const entries = ref([]);
const saving = reactive(new Set());
let timer = null;
const filtered = computed(() => {
const q = query.value.trim().toLowerCase();
if (!q) return entries.value;
return entries.value.filter((e) =>
(e.name || '').toLowerCase().includes(q) || (e.uuid || '').toLowerCase().includes(q)
);
});
const stats = computed(() => {
let online = 0;
for (const e of entries.value) if (e.online) online += 1;
return { total: entries.value.length, online, offline: entries.value.length - online };
});
function meterClass(pct) {
if (pct >= 90) return 'bad';
if (pct >= 75) return 'warn';
return 'ok';
}
async function load() {
try {
const [status, info, nodes] = await Promise.all([
serverApi.statusAll(),
serverApi.infoAll(),
settingsApi.get('bot_node_config')
]);
const nodeConf = (nodes && nodes.config) || {};
const list = [];
for (const key of Object.keys(status)) {
if (key === 'success') continue;
const s = status[key] || {};
const infoEntry = info[key] || {};
const conf = nodeConf[key] || {};
list.push({
uuid: s.uuid || key,
name: s.name || shortUUID(s.uuid || key),
online: !!s.online,
report: s.report || null,
info: infoEntry.info || null,
notify: conf.enableStatusNotify !== false
});
}
list.sort((a, b) => (a.online === b.online ? a.name.localeCompare(b.name) : b.online ? 1 : -1));
entries.value = list;
failed.value = false;
} catch (e) {
failed.value = true;
if (e.status !== 0) toast.error(e.message);
} finally {
loading.value = false;
}
}
async function toggleNotify(entry) {
const next = !entry.notify;
const prev = entry.notify;
entry.notify = next;
saving.add(entry.uuid);
try {
const patch = {};
patch[entry.uuid] = { enableStatusNotify: next };
await settingsApi.set('bot_node_config', patch);
toast.success(`${entry.name}: status notify ${next ? 'on' : 'off'}`);
} catch (e) {
entry.notify = prev;
toast.error('Save failed: ' + e.message);
} finally {
saving.delete(entry.uuid);
}
}
function copyUUID(uuid) {
if (navigator.clipboard) {
navigator.clipboard.writeText(uuid).then(() => toast.success('UUID copied'), () => {});
}
}
onMounted(() => {
load();
timer = setInterval(load, 15000);
});
onBeforeUnmount(() => {
if (timer) clearInterval(timer);
});
</script>
<template>
<section class="page">
<header class="page-head">
<div>
<p class="eyebrow">Overview</p>
<h1>Nodes</h1>
<p class="lead">Monitored servers with live load, online state, and the per-node status-notify switch.</p>
</div>
<div class="head-actions">
<button class="btn btn-ghost" title="Refresh" :disabled="loading" @click="load">
<i class="fas fa-rotate" :class="{ 'fa-spin': loading }" />
</button>
</div>
</header>
<div class="toolbar">
<div class="search">
<i class="fas fa-magnifying-glass icon" />
<input v-model="query" type="text" placeholder="Search name or UUID…" />
</div>
<div class="badge badge-accent"><span class="dot" /> {{ stats.total }} total</div>
<div class="badge badge-online"><span class="dot" /> {{ stats.online }} online</div>
<div class="badge badge-offline"><span class="dot" /> {{ stats.offline }} offline</div>
<span class="spacer" />
<span class="refresh-hint"><i class="fas fa-circle-info" /> Auto-refreshes every 15s</span>
</div>
<div v-if="failed && !loading" class="card empty">
<i class="fas fa-plug-circle-xmark e-icon" />
<p>Cannot reach the backend service</p>
<button class="btn btn-primary" @click="load">Retry</button>
</div>
<div v-else-if="loading" class="card empty">
<span class="spinner" />
</div>
<div v-else-if="entries.length === 0" class="card empty">
<i class="fas fa-server e-icon" />
<p>No monitored nodes yet</p>
</div>
<div v-else-if="filtered.length === 0" class="card empty">
<i class="fas fa-magnifying-glass e-icon" />
<p>No nodes match “{{ query }}”</p>
</div>
<div v-else class="grid-2">
<article
v-for="e in filtered"
:key="e.uuid"
class="card server-card"
:class="{ dim: !e.online }"
>
<div class="row-1">
<span class="sv-icon"><i class="fas fa-server" /></span>
<div class="sv-main">
<p class="sv-name" :title="e.name">{{ e.name }}</p>
<button class="sv-uuid" type="button" :title="'Copy: ' + e.uuid" @click="copyUUID(e.uuid)">
{{ shortUUID(e.uuid) }}
<i class="fas fa-copy" />
</button>
</div>
<span class="badge" :class="e.online ? 'badge-online' : 'badge-offline'">
<span class="dot" />{{ e.online ? 'Online' : 'Offline' }}
</span>
</div>
<template v-if="e.online && e.report">
<div class="meter-row">
<span class="mr-label">CPU</span>
<div class="meter"><i :class="meterClass(e.report.cpu.usage)" :style="{ width: Math.min(100, e.report.cpu.usage) + '%' }" /></div>
<span class="mr-val">{{ e.report.cpu.usage.toFixed(1) }}%</span>
</div>
<div class="meter-row">
<span class="mr-label">RAM</span>
<div class="meter"><i :class="meterClass(percent(e.report.ram))" :style="{ width: percent(e.report.ram) + '%' }" /></div>
<span class="mr-val">{{ formatBytes(e.report.ram.used) }} / {{ formatBytes(e.report.ram.total) }}</span>
</div>
<div class="meter-row">
<span class="mr-label">Disk</span>
<div class="meter"><i :class="meterClass(percent(e.report.disk))" :style="{ width: percent(e.report.disk) + '%' }" /></div>
<span class="mr-val">{{ formatBytes(e.report.disk.used) }} / {{ formatBytes(e.report.disk.total) }}</span>
</div>
</template>
<template v-else-if="e.online">
<p class="no-report">Online, waiting for the first status report…</p>
</template>
<template v-else>
<p class="no-report">{{ e.report && e.report.message ? e.report.message : 'Currently offline' }}</p>
</template>
<footer class="row-3">
<div class="spec" v-if="e.info && e.info.cpu">
<span>{{ e.info.os ? e.info.os.os || '—' : '—' }}</span>
<span>·</span>
<span>{{ e.info.cpu.cpu_cores ? e.info.cpu.cpu_cores + ' cores' : '' }}</span>
<span>·</span>
<span class="mono">{{ e.info.ipv4 || (e.info.ipv6 ? 'IPv6' : '—') }}</span>
</div>
<div class="notify">
<Toggle
:model-value="e.notify"
label="Status notify"
:disabled="saving.has(e.uuid)"
@update:model-value="toggleNotify(e)"
/>
</div>
</footer>
</article>
</div>
</section>
</template>
<style scoped>
.server-card { transition: opacity 0.25s ease; }
.server-card.dim .sv-icon,
.server-card.dim .sv-name { opacity: 0.75; }
.sv-icon {
width: 40px;
height: 40px;
flex: none;
border-radius: 11px;
display: grid;
place-items: center;
background: var(--accent-soft);
color: var(--accent);
font-size: 17px;
}
.server-card.dim .sv-icon {
background: var(--surface-3);
color: var(--text-3);
}
.sv-main {
flex: 1;
min-width: 0;
}
.sv-uuid {
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--text-3);
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0;
}
.sv-uuid:hover { color: var(--accent); }
.meter-row {
display: grid;
grid-template-columns: 44px 1fr auto;
align-items: center;
gap: 10px;
}
.mr-label { font-size: 12px; color: var(--text-3); font-weight: 600; }
.mr-val {
font-size: 12px;
color: var(--text-2);
font-variant-numeric: tabular-nums;
min-width: 90px;
text-align: right;
}
.no-report {
font-size: 13px;
color: var(--text-3);
padding: 4px 0;
}
.row-3 {
border-top: 1px solid var(--line);
padding-top: 13px;
}
.spec {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-3);
min-width: 0;
flex-wrap: wrap;
}
.notify { display: flex; align-items: center; }
.refresh-hint {
font-size: 12px;
color: var(--text-3);
display: inline-flex;
align-items: center;
gap: 6px;
}
</style>
+350
View File
@@ -0,0 +1,350 @@
<script setup>
import { onMounted, reactive, ref } from 'vue';
import { settingsApi } from '../api/index.js';
import { toast } from '../utils/toast.js';
import Toggle from '../components/Toggle.vue';
import TagsEditor from '../components/TagsEditor.vue';
import HeadersEditor from '../components/HeadersEditor.vue';
const sections = [
{
id: 'system',
title: 'System',
hint: 'HTTP listener and global runtime switches.',
root: ['system'],
fields: [
{ key: 'debugMode', type: 'bool', label: 'Debug mode', help: 'Skipped X-Timestamp checks and verbose debug logging.' },
{ key: 'listenAddr', type: 'text', label: 'Listen address' },
{ key: 'listenPort', type: 'text', label: 'Listen port' },
{ key: 'networkProxy', type: 'text', label: 'Network proxy', placeholder: 'http://host:port' }
]
},
{
id: 'debug',
title: 'Debug logging',
hint: 'Per-module verbosity for the bot pipes.',
root: ['debug'],
fields: [
{ key: 'showNapcatMsg', type: 'bool', label: 'NapCat messages' },
{ key: 'showNapcatAction', type: 'bool', label: 'NapCat actions' },
{ key: 'showTelegramMsg', type: 'bool', label: 'Telegram messages' },
{ key: 'showTriggerCmdEcho', type: 'bool', label: 'Trigger command echo' },
{ key: 'showKomariTaskEcho', type: 'bool', label: 'Komari task echo' },
{ key: 'napcatIgnoreSelfMsg', type: 'bool', label: 'Ignore NapCat self messages' }
]
},
{
id: 'komari',
title: 'Komari dashboard',
hint: 'Connection the monitor reads node data from. Takes effect on restart.',
root: ['komari'],
fields: [
{ key: 'dashboardURL', type: 'text', label: 'Dashboard URL' },
{ key: 'account.username', lp: ['account', 'username'], type: 'text', label: 'Account' },
{ key: 'account.password', lp: ['account', 'password'], type: 'password', label: 'Password' }
]
},
{
id: 'controllerMessage',
title: 'Message templates',
hint: 'Templates rendered for bot pushes. Placeholders like {{ serverName }} stay as-is.',
root: ['controllerMessage'],
fields: [
{ key: 'BOT_STARTED', type: 'textarea', label: 'BOT_STARTED' },
{ key: 'BOT_HELP', type: 'textarea', label: 'BOT_HELP' },
{ key: 'TG_BOT_START', type: 'textarea', label: 'TG_BOT_START' },
{ key: 'SERVER_STATUS_CHANGED', type: 'textarea', label: 'SERVER_STATUS_CHANGED' },
{ key: 'SERVER_LIST', type: 'textarea', label: 'SERVER_LIST' },
{ key: 'SERVER_EXECUTE_RESULT', type: 'textarea', label: 'SERVER_EXECUTE_RESULT' }
]
},
{
id: 'qq',
title: 'QQ controller (NapCat)',
root: ['controllerMethod', 'qq(napcat)'],
fields: [
{ key: 'enabled', type: 'bool', label: 'Enabled' },
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
{ key: 'napcatAddr', type: 'text', label: 'NapCat address' },
{ key: 'napcatPort', type: 'text', label: 'NapCat port' },
{ key: 'napcatToken', type: 'password', label: 'NapCat token' },
{ key: 'botQQID', type: 'number', label: 'Bot QQ ID' },
{ key: 'listenMethod', type: 'select', label: 'Listen method', options: ['global', 'at'] }
]
},
{
id: 'telegram',
title: 'Telegram controller',
root: ['controllerMethod', 'telegram'],
fields: [
{ key: 'enabled', type: 'bool', label: 'Enabled' },
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
{ key: 'botToken', type: 'password', label: 'Bot token' },
{ key: 'listenMethod', type: 'select', label: 'Listen method', options: ['global', 'at'] }
]
},
{
id: 'email',
title: 'Email notifications',
root: ['controllerMethod', 'email'],
fields: [
{ key: 'enabled', type: 'bool', label: 'Enabled' },
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
{ key: 'smtpHost', type: 'text', label: 'SMTP host' },
{ key: 'smtpPort', type: 'number', label: 'SMTP port' },
{ key: 'username', type: 'text', label: 'Username' },
{ key: 'password', type: 'password', label: 'Password' },
{ key: 'from', type: 'text', label: 'From address' },
{ key: 'to', type: 'tags', label: 'Recipients', placeholder: 'Type an address and press Enter' },
{ key: 'useTLS', type: 'bool', label: 'Use TLS' }
]
},
{
id: 'ntfy',
title: 'Ntfy notifications',
root: ['controllerMethod', 'ntfy'],
fields: [
{ key: 'enabled', type: 'bool', label: 'Enabled' },
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
{ key: 'server', type: 'text', label: 'Server' },
{ key: 'topic', type: 'text', label: 'Topic' },
{ key: 'token', type: 'password', label: 'Token' },
{ key: 'priority', type: 'select', label: 'Priority', options: ['min', 'low', 'default', 'high', 'urgent', 'max'] }
]
},
{
id: 'webhook',
title: 'Webhook notifications',
root: ['controllerMethod', 'webhook'],
fields: [
{ key: 'enabled', type: 'bool', label: 'Enabled' },
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
{ key: 'url', type: 'text', label: 'URL' },
{ key: 'method', type: 'select', label: 'Method', options: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] },
{ key: 'headers', type: 'headers', label: 'Headers' },
{ key: 'template', type: 'textarea', label: 'Payload template' }
]
},
{
id: 'paths',
title: 'Storage paths',
hint: 'Where runtime data lives. Takes effect on restart.',
root: [],
fields: [
{ key: 'dataPath', type: 'text', label: 'Data path' },
{ key: 'dbPath', type: 'text', label: 'Database path' }
]
}
];
const loading = ref(true);
const failed = ref(false);
const vals = reactive({});
const saving = reactive(new Set());
function fieldPath(f) {
return f.lp || [f.key];
}
function getVal(obj, path, fb) {
let cur = obj;
for (const k of path) {
if (cur === null || cur === undefined || typeof cur !== 'object') return fb;
cur = cur[k];
}
return cur === undefined || cur === null ? fb : cur;
}
function defaults(f) {
switch (f.type) {
case 'bool': return false;
case 'number': return 0;
case 'tags': return [];
case 'headers': return {};
default: return '';
}
}
async function load() {
try {
const res = await settingsApi.get('global');
const cfg = (res && res.config) || {};
for (const s of sections) {
const obj = {};
for (const f of s.fields) {
obj[f.key] = getVal(cfg, fieldPath(f), defaults(f));
}
vals[s.id] = obj;
}
failed.value = false;
} catch (e) {
failed.value = true;
if (e.status) toast.error('Failed to load settings: ' + e.message);
} finally {
loading.value = false;
}
}
function normalize(f, v) {
switch (f.type) {
case 'number': {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
case 'tags': return Array.isArray(v) ? v : [];
case 'headers': return v && typeof v === 'object' ? v : {};
default: return v === null || v === undefined ? '' : v;
}
}
function nest(obj) {
const out = {};
for (const [k, v] of Object.entries(obj)) {
const path = k.split('.');
let o = out;
for (let i = 0; i < path.length - 1; i += 1) {
const seg = path[i];
if (!o[seg]) o[seg] = {};
o = o[seg];
}
o[path[path.length - 1]] = v;
}
return out;
}
function wrapRoot(section, obj) {
const root = section.root;
if (!root.length) return obj;
const out = {};
let o = out;
for (let i = 0; i < root.length - 1; i += 1) {
o[root[i]] = {};
o = o[root[i]];
}
o[root[root.length - 1]] = obj;
return out;
}
async function save(section) {
const source = vals[section.id];
const obj = {};
for (const f of section.fields) {
obj[f.key] = normalize(f, source[f.key]);
}
const patch = wrapRoot(section, nest(obj));
saving.add(section.id);
try {
await settingsApi.set('global', patch);
toast.success(`${section.title} saved`);
await load();
} catch (e) {
toast.error('Failed to save: ' + e.message);
} finally {
saving.delete(section.id);
}
}
onMounted(load);
</script>
<template>
<section class="page">
<header class="page-head">
<div>
<p class="eyebrow">Configuration</p>
<h1>System settings</h1>
<p class="lead">Edit the global <span class="mono">config.json</span>. Every card saves only its own section — other fields stay untouched.</p>
</div>
<div class="head-actions">
<button class="btn btn-ghost" @click="load"><i class="fas fa-rotate" :class="{ 'fa-spin': loading }" /> Reload</button>
</div>
</header>
<div v-if="failed" class="card empty">
<i class="fas fa-plug-circle-xmark e-icon" />
<p>Failed to load settings</p>
<button class="btn btn-primary" @click="load">Retry</button>
</div>
<div v-else-if="loading" class="card empty"><span class="spinner" /></div>
<div v-else>
<div v-for="s in sections" :key="s.id" class="card">
<div class="card-head">
<div>
<h3>{{ s.title }}</h3>
<p v-if="s.hint" class="hint">{{ s.hint }}</p>
</div>
<button class="btn btn-primary btn-sm" :disabled="saving.has(s.id)" @click="save(s)">
<span v-if="saving.has(s.id)" class="spinner" style="width:12px;height:12px" />
<i v-else class="fas fa-check" /> Save
</button>
</div>
<div class="card-body">
<div class="form-grid">
<template v-for="f in s.fields" :key="f.key">
<div v-if="f.type === 'bool'" class="bool-cell">
<Toggle :model-value="vals[s.id][f.key]" :label="f.label" @update:model-value="vals[s.id][f.key] = $event" />
<p v-if="f.help" class="field-help">{{ f.help }}</p>
</div>
<div v-else-if="f.type === 'tags'" class="field span-2">
<label>{{ f.label }}</label>
<TagsEditor v-model="vals[s.id][f.key]" :placeholder="f.placeholder" />
</div>
<div v-else-if="f.type === 'headers'" class="field span-2">
<label>{{ f.label }}</label>
<HeadersEditor v-model="vals[s.id][f.key]" />
</div>
<div v-else class="field span-2">
<label>{{ f.label }}</label>
<textarea
v-if="f.type === 'textarea'"
v-model="vals[s.id][f.key]"
class="textarea"
rows="4"
spellcheck="false"
/>
<select
v-else-if="f.type === 'select'"
v-model="vals[s.id][f.key]"
class="select"
>
<option v-for="opt in f.options" :key="opt" :value="opt">{{ opt }}</option>
</select>
<input
v-else
v-model="vals[s.id][f.key]"
class="input"
:type="f.type === 'password' ? 'password' : 'text'"
:placeholder="f.placeholder || ''"
autocomplete="off"
spellcheck="false"
/>
</div>
</template>
</div>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.bool-cell {
display: flex;
flex-direction: column;
gap: 3px;
padding: 8px 0;
}
.field-help {
font-size: 12px;
color: var(--text-3);
padding-left: 50px;
max-width: 340px;
}
</style>
+298
View File
@@ -0,0 +1,298 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { settingsApi } from '../api/index.js';
import { toast } from '../utils/toast.js';
import Toggle from '../components/Toggle.vue';
import Modal from '../components/Modal.vue';
const CHANNELS = [
{ key: 'qq(napcat)', label: 'QQ (NapCat)', hint: 'Members are identified by QQ number / group number' },
{ key: 'telegram', label: 'Telegram', hint: 'Members are identified by @username or numeric ID / group ID' }
];
const KINDS = [
{ key: 'admins', label: 'Admins' },
{ key: 'trustedGroups', label: 'Trusted groups' }
];
const OPTIONS = [
{ key: 'event_status_notify', label: 'Status notify' },
{ key: 'event_bot_started', label: 'Startup notify' },
{ key: 'event_reply', label: 'Command replies' }
];
const loading = ref(true);
const failed = ref(false);
const rows = ref([]);
const saving = reactive(new Set());
const addModal = reactive({ open: false, ch: CHANNELS[0].key, kind: 'admins', id: '' });
const removing = reactive({ open: false, ch: '', kind: '', id: '' });
const rowsOf = (ch, kind) => rows.value
.filter((r) => r.ch === ch && r.kind === kind)
.sort((a, b) => a.id.localeCompare(b.id));
const summary = computed(() => {
const out = {};
for (const c of CHANNELS) {
out[c.key] = { admins: rowsOf(c.key, 'admins').length, trustedGroups: rowsOf(c.key, 'trustedGroups').length };
}
return out;
});
const defaultOpts = () => ({
event_status_notify: true,
event_bot_started: true,
event_reply: true
});
async function load() {
try {
const res = await settingsApi.get('bot_user_config');
const cfg = (res && res.config) || {};
const flat = [];
for (const c of CHANNELS) {
const chData = cfg[c.key] || {};
for (const k of KINDS) {
const map = chData[k.key] || {};
for (const [id, opts] of Object.entries(map)) {
flat.push({
ch: c.key,
kind: k.key,
id,
opts: {
event_status_notify: opts.event_status_notify !== false,
event_bot_started: opts.event_bot_started !== false,
event_reply: opts.event_reply !== false
}
});
}
}
}
rows.value = flat;
failed.value = false;
} catch (e) {
failed.value = true;
if (e.status) toast.error('Failed to load: ' + e.message);
} finally {
loading.value = false;
}
}
function patchRow(row) {
const p = {};
p[row.ch] = { [row.kind]: { [row.id]: { ...row.opts } } };
return p;
}
async function applyPatch(patch, okMsg, rollbackFn) {
try {
await settingsApi.set('bot_user_config', patch);
if (okMsg) toast.success(okMsg);
return true;
} catch (e) {
if (rollbackFn) rollbackFn();
toast.error('Save failed: ' + e.message);
return false;
}
}
function toggleOpt(row, key, value) {
const prev = row.opts[key];
row.opts[key] = value;
applyPatch(patchRow(row), undefined, () => { row.opts[key] = prev; });
}
function openAdd(ch, kind) {
addModal.ch = ch;
addModal.kind = kind;
addModal.id = '';
addModal.open = true;
}
async function confirmAdd() {
const id = addModal.id.trim();
if (!id) return toast.warn('Enter a member ID');
const exists = rows.value.some((r) => r.ch === addModal.ch && r.kind === addModal.kind && r.id === id);
if (exists) {
toast.warn('That member already exists');
return;
}
const row = { ch: addModal.ch, kind: addModal.kind, id, opts: defaultOpts() };
if (await applyPatch(patchRow(row), 'Member added')) {
rows.value.push(row);
addModal.open = false;
}
}
function askRemove(row) {
removing.ch = row.ch;
removing.kind = row.kind;
removing.id = row.id;
removing.open = true;
}
async function confirmRemove() {
const { ch, kind, id } = removing;
const p = {};
p[ch] = { [kind]: { [id]: null } };
if (await applyPatch(p, 'Member removed')) {
rows.value = rows.value.filter((r) => !(r.ch === ch && r.kind === kind && r.id === id));
removing.open = false;
}
}
onMounted(load);
</script>
<template>
<section class="page">
<header class="page-head">
<div>
<p class="eyebrow">Bot trust</p>
<h1>Trust management</h1>
<p class="lead">Which admins and groups each bot channel answers to, plus per-member notification and reply switches.</p>
</div>
<div class="head-actions">
<button class="btn btn-ghost" @click="load">
<i class="fas fa-rotate" :class="{ 'fa-spin': loading }" /> Reload
</button>
</div>
</header>
<div v-if="failed" class="card empty">
<i class="fas fa-plug-circle-xmark e-icon" />
<p>Failed to load, please check the backend connection</p>
<button class="btn btn-primary" @click="load">Retry</button>
</div>
<div v-for="c in CHANNELS" :key="c.key" class="card">
<div class="card-head">
<div>
<h3>{{ c.label }}</h3>
<p class="hint">{{ c.hint }}</p>
</div>
<span class="chip">
{{ summary[c.key].admins }} admins · {{ summary[c.key].trustedGroups }} groups
</span>
</div>
<div class="card-body">
<div v-for="k in KINDS" :key="k.key" class="kind-block">
<div class="kind-head">
<span class="kind-title">{{ k.label }}</span>
<button class="btn btn-ghost btn-sm" @click="openAdd(c.key, k.key)">
<i class="fas fa-plus" /> Add
</button>
</div>
<div v-if="loading" class="empty" style="padding:24px"><span class="spinner" /></div>
<div v-else-if="rowsOf(c.key, k.key).length === 0" class="empty" style="padding:22px">
No {{ k.label.toLowerCase() }} yet
</div>
<div v-else class="opt-list">
<div v-for="r in rowsOf(c.key, k.key)" :key="r.id" class="opt-row">
<div class="who">
<span class="id mono">{{ r.id }}</span>
</div>
<div class="toggles">
<div v-for="o in OPTIONS" :key="o.key" class="oc">
<span class="oc-label">{{ o.label }}</span>
<Toggle
:model-value="r.opts[o.key]"
@update:model-value="toggleOpt(r, o.key, $event)"
/>
</div>
<button class="icon-btn danger" title="Remove" @click="askRemove(r)">
<i class="fas fa-trash" />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<Modal :open="addModal.open" title="Add member" @close="addModal.open = false">
<div class="form-grid">
<div class="field">
<label>Channel</label>
<select v-model="addModal.ch" class="select">
<option v-for="c in CHANNELS" :key="c.key" :value="c.key">{{ c.label }}</option>
</select>
</div>
<div class="field">
<label>Type</label>
<select v-model="addModal.kind" class="select">
<option v-for="k in KINDS" :key="k.key" :value="k.key">{{ k.label }}</option>
</select>
</div>
<div class="field span-2">
<label>Member ID</label>
<input
v-model="addModal.id"
class="input mono"
placeholder="QQ / group number, or Telegram @username / numeric ID"
@keydown.enter="confirmAdd"
/>
<p class="help">New members start with every notification enabled; adjust them in the list.</p>
</div>
</div>
<template #foot>
<button class="btn btn-ghost" @click="addModal.open = false">Cancel</button>
<button class="btn btn-primary" @click="confirmAdd">Add</button>
</template>
</Modal>
<Modal :open="removing.open" title="Remove member" @close="removing.open = false">
<p style="line-height:1.6">
Remove <strong class="mono">{{ removing.id }}</strong> from
<strong>{{ KINDS.find((k) => k.key === removing.kind)?.label }}</strong>?
</p>
<template #foot>
<button class="btn btn-ghost" @click="removing.open = false">Cancel</button>
<button class="btn btn-danger" @click="confirmRemove">Remove</button>
</template>
</Modal>
</section>
</template>
<style scoped>
.kind-block + .kind-block { margin-top: 20px; }
.kind-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.kind-title {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--text-2);
}
.toggles {
display: flex;
align-items: center;
gap: 18px;
flex-wrap: wrap;
}
.oc {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
}
.oc-label {
font-size: 11px;
color: var(--text-3);
font-weight: 500;
letter-spacing: 0.02em;
}
</style>