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
+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
};