feat: add native fnOS PostgreSQL shared service
This commit is contained in:
Generated
+1691
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "nxsir-postgresql-admin",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --config vite.postgres.config.ts",
|
||||
"build": "vite build --config vite.postgres.config.ts",
|
||||
"build:postgres-admin": "vite build --config vite.postgres.config.ts",
|
||||
"preview": "vite preview --config vite.postgres.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"element-plus": "^2.10.1",
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.2.0",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
|
||||
type Section = "overview" | "clients" | "databases" | "roles" | "sessions" | "sql" | "backups";
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
interface Client {
|
||||
appId: string;
|
||||
displayName: string;
|
||||
databaseName: string;
|
||||
roleName: string;
|
||||
extensions: string[];
|
||||
status: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface Database {
|
||||
name: string;
|
||||
owner: string;
|
||||
sizeBytes: number;
|
||||
activeConnections: number;
|
||||
isManaged: boolean;
|
||||
}
|
||||
|
||||
interface Role {
|
||||
name: string;
|
||||
canLogin: boolean;
|
||||
connectionLimit: number;
|
||||
isManaged: boolean;
|
||||
}
|
||||
|
||||
interface Session {
|
||||
processId: number;
|
||||
database: string;
|
||||
username: string;
|
||||
state: string;
|
||||
query?: string;
|
||||
queryStartedAt?: string;
|
||||
}
|
||||
|
||||
interface Backup {
|
||||
fileName: string;
|
||||
database: string;
|
||||
sizeBytes: number;
|
||||
createdAt: string;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
const authenticated = ref<boolean | null>(null);
|
||||
const loading = ref(false);
|
||||
const active = ref<Section>("overview");
|
||||
const dark = ref(localStorage.getItem("postgres-admin-theme") === "dark");
|
||||
const loginForm = reactive({ username: "admin", password: "" });
|
||||
const overview = ref<JsonObject>({});
|
||||
const clients = ref<Client[]>([]);
|
||||
const databases = ref<Database[]>([]);
|
||||
const roles = ref<Role[]>([]);
|
||||
const sessions = ref<Session[]>([]);
|
||||
const backups = ref<Backup[]>([]);
|
||||
const sql = ref("SELECT current_database(), current_user, now();");
|
||||
const sqlDatabase = ref("postgres");
|
||||
const queryResult = ref<{ columns: string[]; rows: unknown[][]; rowCount: number; truncated: boolean; elapsedMilliseconds: number } | null>(null);
|
||||
|
||||
const sections: Array<{ key: Section; label: string }> = [
|
||||
{ key: "overview", label: "运行概览" },
|
||||
{ key: "clients", label: "接入应用" },
|
||||
{ key: "databases", label: "数据库" },
|
||||
{ key: "roles", label: "角色" },
|
||||
{ key: "sessions", label: "活动会话" },
|
||||
{ key: "sql", label: "只读 SQL" },
|
||||
{ key: "backups", label: "备份恢复" }
|
||||
];
|
||||
|
||||
const activeLabel = computed(() => sections.find(item => item.key === active.value)?.label ?? "管理面板");
|
||||
|
||||
function applyTheme() {
|
||||
document.documentElement.dataset.theme = dark.value ? "dark" : "light";
|
||||
localStorage.setItem("postgres-admin-theme", dark.value ? "dark" : "light");
|
||||
}
|
||||
|
||||
async function api<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
credentials: "same-origin",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers || {})
|
||||
}
|
||||
});
|
||||
if (response.status === 401) {
|
||||
authenticated.value = false;
|
||||
throw new Error("管理会话已过期,请重新登录。");
|
||||
}
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({ error: `请求失败(${response.status})` })) as { error?: string };
|
||||
throw new Error(body.error || `请求失败(${response.status})`);
|
||||
}
|
||||
return response.status === 204 ? undefined as T : await response.json() as T;
|
||||
}
|
||||
|
||||
async function login() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await api("/api/v1/auth/login", { method: "POST", body: JSON.stringify(loginForm) });
|
||||
authenticated.value = true;
|
||||
loginForm.password = "";
|
||||
await loadAll();
|
||||
} catch (error) {
|
||||
ElMessage.error((error as Error).message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await api("/api/v1/auth/logout", { method: "POST" }).catch(() => undefined);
|
||||
authenticated.value = false;
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const results = await Promise.all([
|
||||
api<JsonObject>("/api/v1/overview"),
|
||||
api<Client[]>("/api/v1/clients"),
|
||||
api<Database[]>("/api/v1/databases"),
|
||||
api<Role[]>("/api/v1/roles"),
|
||||
api<Session[]>("/api/v1/sessions"),
|
||||
api<Backup[]>("/api/v1/backups")
|
||||
]);
|
||||
[overview.value, clients.value, databases.value, roles.value, sessions.value, backups.value] = results;
|
||||
authenticated.value = true;
|
||||
if (!databases.value.some(item => item.name === sqlDatabase.value)) {
|
||||
sqlDatabase.value = databases.value[0]?.name || "postgres";
|
||||
}
|
||||
} catch (error) {
|
||||
if (authenticated.value !== false) ElMessage.error((error as Error).message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateEnrollmentToken() {
|
||||
const result = await api<{ token: string }>("/api/v1/enrollment-token/rotate", { method: "POST" });
|
||||
await ElMessageBox.alert(result.token, "新的接入令牌(仅显示一次)", {
|
||||
confirmButtonText: "我已保存",
|
||||
customClass: "secret-dialog"
|
||||
});
|
||||
}
|
||||
|
||||
async function rotateClient(client: Client) {
|
||||
await ElMessageBox.confirm(`轮换 ${client.displayName} 的数据库密码后,客户端必须立即更新凭据。`, "轮换密码", { type: "warning" });
|
||||
const result = await api<{ password: string }>(`/api/v1/clients/${encodeURIComponent(client.appId)}/rotate`, { method: "POST" });
|
||||
await ElMessageBox.alert(result.password, "新密码(仅显示一次)", { confirmButtonText: "我已保存" });
|
||||
}
|
||||
|
||||
async function revokeClient(client: Client) {
|
||||
await ElMessageBox.confirm(`吊销后 ${client.displayName} 将无法连接数据库,但不会删除数据。`, "吊销客户端", { type: "warning" });
|
||||
await api(`/api/v1/clients/${encodeURIComponent(client.appId)}/revoke`, { method: "POST" });
|
||||
await loadAll();
|
||||
}
|
||||
|
||||
async function createDatabase() {
|
||||
const { value: name } = await ElMessageBox.prompt("仅允许小写字母、数字和下划线。", "新建数据库", { inputPattern: /^[a-z][a-z0-9_]{2,62}$/, inputErrorMessage: "数据库名称格式无效" });
|
||||
await api("/api/v1/databases", { method: "POST", body: JSON.stringify({ name }) });
|
||||
await loadAll();
|
||||
}
|
||||
|
||||
async function dropDatabase(database: Database) {
|
||||
const { value } = await ElMessageBox.prompt(`请输入 ${database.name} 确认删除。`, "删除数据库", { type: "warning" });
|
||||
await api(`/api/v1/databases/${encodeURIComponent(database.name)}?confirmation=${encodeURIComponent(value)}`, { method: "DELETE" });
|
||||
await loadAll();
|
||||
}
|
||||
|
||||
async function createRole() {
|
||||
const { value: name } = await ElMessageBox.prompt("仅允许小写字母、数字和下划线。", "新建登录角色", { inputPattern: /^[a-z][a-z0-9_]{2,62}$/, inputErrorMessage: "角色名称格式无效" });
|
||||
const { value: password } = await ElMessageBox.prompt("密码至少 16 个字符。", "设置角色密码", { inputType: "password", inputValidator: value => value.length >= 16 || "密码至少需要 16 个字符" });
|
||||
await api("/api/v1/roles", { method: "POST", body: JSON.stringify({ name, password }) });
|
||||
await loadAll();
|
||||
}
|
||||
|
||||
async function dropRole(role: Role) {
|
||||
const { value } = await ElMessageBox.prompt(`请输入 ${role.name} 确认删除。`, "删除角色", { type: "warning" });
|
||||
await api(`/api/v1/roles/${encodeURIComponent(role.name)}?confirmation=${encodeURIComponent(value)}`, { method: "DELETE" });
|
||||
await loadAll();
|
||||
}
|
||||
|
||||
async function terminateSession(session: Session) {
|
||||
await ElMessageBox.confirm(`确认终止 PID ${session.processId} 的连接?`, "终止会话", { type: "warning" });
|
||||
await api(`/api/v1/sessions/${session.processId}/terminate`, { method: "POST" });
|
||||
await loadAll();
|
||||
}
|
||||
|
||||
async function runQuery() {
|
||||
loading.value = true;
|
||||
try {
|
||||
queryResult.value = await api("/api/v1/query", { method: "POST", body: JSON.stringify({ database: sqlDatabase.value, sql: sql.value }) });
|
||||
} catch (error) {
|
||||
ElMessage.error((error as Error).message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createBackup(database: string) {
|
||||
await api("/api/v1/backups", { method: "POST", body: JSON.stringify({ database }) });
|
||||
ElMessage.success("备份已完成");
|
||||
await loadAll();
|
||||
}
|
||||
|
||||
async function restoreBackup(backup: Backup) {
|
||||
const { value: targetDatabase } = await ElMessageBox.prompt("建议恢复到新的数据库名称。", "恢复备份", { inputValue: `${backup.database}_restored`, inputPattern: /^[a-z][a-z0-9_]{2,62}$/, inputErrorMessage: "数据库名称格式无效" });
|
||||
const exists = databases.value.some(item => item.name === targetDatabase);
|
||||
const { value: confirmation } = await ElMessageBox.prompt(`请输入 ${targetDatabase} 确认恢复${exists ? "并覆盖现有数据库" : ""}。`, "确认恢复", { type: "warning" });
|
||||
await api("/api/v1/backups/restore", { method: "POST", body: JSON.stringify({ backupFileName: backup.fileName, targetDatabase, overwrite: exists, confirmation }) });
|
||||
ElMessage.success("备份已恢复");
|
||||
await loadAll();
|
||||
}
|
||||
|
||||
function formatBytes(value: unknown) {
|
||||
const bytes = Number(value || 0);
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
applyTheme();
|
||||
await loadAll();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main v-if="authenticated === false" class="login-screen">
|
||||
<section class="login-card">
|
||||
<div class="brand-mark">PG</div>
|
||||
<h1>PostgreSQL 服务</h1>
|
||||
<p>使用安装时设置的独立管理密码登录。</p>
|
||||
<el-form label-position="top" @submit.prevent="login">
|
||||
<el-form-item label="用户名"><el-input v-model="loginForm.username" autocomplete="username" /></el-form-item>
|
||||
<el-form-item label="密码"><el-input v-model="loginForm.password" type="password" show-password autocomplete="current-password" @keyup.enter="login" /></el-form-item>
|
||||
<el-button type="primary" :loading="loading" class="full" @click="login">登录</el-button>
|
||||
</el-form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div v-else class="shell" v-loading="loading && authenticated === null">
|
||||
<aside class="sidebar">
|
||||
<div class="brand"><span class="brand-mark">PG</span><div><strong>PostgreSQL</strong><small>共享数据库服务</small></div></div>
|
||||
<nav>
|
||||
<button v-for="item in sections" :key="item.key" :class="{ active: active === item.key }" @click="active = item.key">{{ item.label }}</button>
|
||||
</nav>
|
||||
<div class="sidebar-foot"><span class="health-dot" />仅回环数据库端口</div>
|
||||
</aside>
|
||||
|
||||
<section class="content">
|
||||
<header class="topbar">
|
||||
<div><h1>{{ activeLabel }}</h1><small>127.0.0.1:{{ overview.port || 15432 }}</small></div>
|
||||
<div class="top-actions">
|
||||
<el-button size="small" @click="dark = !dark; applyTheme()">{{ dark ? "浅色" : "深色" }}</el-button>
|
||||
<el-button size="small" @click="loadAll">刷新</el-button>
|
||||
<el-button size="small" @click="logout">退出</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mobile-section"><el-select v-model="active"><el-option v-for="item in sections" :key="item.key" :label="item.label" :value="item.key" /></el-select></div>
|
||||
|
||||
<section v-if="active === 'overview'" class="page-stack">
|
||||
<div class="metric-grid">
|
||||
<article><span>PostgreSQL 版本</span><strong>{{ overview.version || "-" }}</strong></article>
|
||||
<article><span>活动连接</span><strong>{{ overview.connections || 0 }} / {{ overview.maxConnections || 0 }}</strong></article>
|
||||
<article><span>数据库</span><strong>{{ overview.databases || 0 }}</strong></article>
|
||||
<article><span>数据总量</span><strong>{{ formatBytes(overview.sizeBytes) }}</strong></article>
|
||||
</div>
|
||||
<section class="panel"><div class="panel-head"><div><h2>安全接入</h2><p>接入令牌只用于本机客户端登记,轮换后旧令牌立即失效。</p></div><el-button type="warning" plain @click="rotateEnrollmentToken">轮换接入令牌</el-button></div></section>
|
||||
</section>
|
||||
|
||||
<section v-else-if="active === 'clients'" class="panel">
|
||||
<div class="panel-head"><div><h2>接入应用</h2><p>每个应用使用独立数据库和 SCRAM 角色。</p></div></div>
|
||||
<el-table :data="clients" table-layout="auto">
|
||||
<el-table-column prop="displayName" label="应用" min-width="160"><template #default="{ row }"><strong>{{ row.displayName }}</strong><small class="block">{{ row.appId }}</small></template></el-table-column>
|
||||
<el-table-column prop="databaseName" label="数据库" min-width="190" />
|
||||
<el-table-column prop="roleName" label="角色" min-width="190" />
|
||||
<el-table-column label="扩展"><template #default="{ row }">{{ row.extensions.join(", ") || "-" }}</template></el-table-column>
|
||||
<el-table-column label="状态"><template #default="{ row }"><el-tag :type="row.status === 'active' ? 'success' : 'danger'">{{ row.status === "active" ? "正常" : "已吊销" }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button size="small" @click="rotateClient(row)">轮换密码</el-button><el-button size="small" type="danger" plain @click="revokeClient(row)">吊销</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section v-else-if="active === 'databases'" class="panel">
|
||||
<div class="panel-head"><div><h2>数据库</h2><p>托管数据库必须先吊销客户端,不能直接删除。</p></div><el-button type="primary" @click="createDatabase">新建数据库</el-button></div>
|
||||
<el-table :data="databases"><el-table-column prop="name" label="名称" min-width="180" /><el-table-column prop="owner" label="所有者" min-width="160" /><el-table-column label="容量"><template #default="{ row }">{{ formatBytes(row.sizeBytes) }}</template></el-table-column><el-table-column prop="activeConnections" label="连接" /><el-table-column label="类型"><template #default="{ row }"><el-tag v-if="row.isManaged">应用托管</el-tag><span v-else>普通</span></template></el-table-column><el-table-column label="操作" width="170"><template #default="{ row }"><el-button size="small" @click="createBackup(row.name)">备份</el-button><el-button size="small" type="danger" plain :disabled="row.isManaged || ['postgres','template0','template1','postgres_service'].includes(row.name)" @click="dropDatabase(row)">删除</el-button></template></el-table-column></el-table>
|
||||
</section>
|
||||
|
||||
<section v-else-if="active === 'roles'" class="panel">
|
||||
<div class="panel-head"><div><h2>登录角色</h2><p>新角色默认无超级用户、建库和建角色权限。</p></div><el-button type="primary" @click="createRole">新建角色</el-button></div>
|
||||
<el-table :data="roles"><el-table-column prop="name" label="名称" min-width="200" /><el-table-column label="可登录"><template #default="{ row }">{{ row.canLogin ? "是" : "否" }}</template></el-table-column><el-table-column prop="connectionLimit" label="连接限制" /><el-table-column label="类型"><template #default="{ row }"><el-tag v-if="row.isManaged">应用托管</el-tag><span v-else>普通</span></template></el-table-column><el-table-column label="操作" width="100"><template #default="{ row }"><el-button size="small" type="danger" plain :disabled="row.isManaged" @click="dropRole(row)">删除</el-button></template></el-table-column></el-table>
|
||||
</section>
|
||||
|
||||
<section v-else-if="active === 'sessions'" class="panel">
|
||||
<div class="panel-head"><div><h2>活动会话</h2><p>终止连接可能中断应用事务,请谨慎操作。</p></div></div>
|
||||
<el-table :data="sessions" table-layout="auto"><el-table-column prop="processId" label="PID" width="90" /><el-table-column prop="database" label="数据库" min-width="140" /><el-table-column prop="username" label="用户" min-width="150" /><el-table-column prop="state" label="状态" width="100" /><el-table-column prop="query" label="当前语句" min-width="300" show-overflow-tooltip /><el-table-column label="操作" width="100"><template #default="{ row }"><el-button size="small" type="danger" plain @click="terminateSession(row)">终止</el-button></template></el-table-column></el-table>
|
||||
</section>
|
||||
|
||||
<section v-else-if="active === 'sql'" class="page-stack">
|
||||
<section class="panel"><div class="panel-head"><div><h2>只读 SQL 工作台</h2><p>强制只读事务、30 秒超时、单语句和最多 1000 行结果。</p></div><el-select v-model="sqlDatabase" class="database-select"><el-option v-for="item in databases" :key="item.name" :label="item.name" :value="item.name" /></el-select></div><el-input v-model="sql" type="textarea" :rows="8" class="sql-editor" spellcheck="false" /><div class="query-actions"><el-button type="primary" :loading="loading" @click="runQuery">执行查询</el-button></div></section>
|
||||
<section v-if="queryResult" class="panel"><div class="panel-head"><div><h2>查询结果</h2><p>{{ queryResult.rowCount }} 行 · {{ queryResult.elapsedMilliseconds }} ms<span v-if="queryResult.truncated"> · 已截断</span></p></div></div><div class="table-scroll"><el-table :data="queryResult.rows"><el-table-column v-for="(column, index) in queryResult.columns" :key="column + index" :label="column" min-width="140"><template #default="{ row }">{{ row[index] }}</template></el-table-column></el-table></div></section>
|
||||
</section>
|
||||
|
||||
<section v-else class="panel">
|
||||
<div class="panel-head"><div><h2>手动备份与恢复</h2><p>默认不执行定时备份;恢复到已有数据库需要二次确认并会中断连接。</p></div></div>
|
||||
<el-table :data="backups"><el-table-column prop="database" label="来源数据库" min-width="160" /><el-table-column prop="fileName" label="文件" min-width="260" /><el-table-column label="大小"><template #default="{ row }">{{ formatBytes(row.sizeBytes) }}</template></el-table-column><el-table-column label="创建时间" min-width="170"><template #default="{ row }">{{ new Date(row.createdAt).toLocaleString() }}</template></el-table-column><el-table-column prop="sha256" label="SHA-256" min-width="220" show-overflow-tooltip /><el-table-column label="操作" width="100"><template #default="{ row }"><el-button size="small" @click="restoreBackup(row)">恢复</el-button></template></el-table-column></el-table>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>PostgreSQL 服务</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createApp } from "vue";
|
||||
import ElementPlus from "element-plus";
|
||||
import "element-plus/dist/index.css";
|
||||
import App from "./App.vue";
|
||||
import "./style.css";
|
||||
|
||||
createApp(App).use(ElementPlus).mount("#app");
|
||||
@@ -0,0 +1,26 @@
|
||||
:root { color-scheme: light; font-family: "Segoe UI Variable", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; --bg:#eef2f6; --surface:#fff; --muted:#f4f6f9; --border:#dde3eb; --text:#172033; --secondary:#68758a; --accent:#2563eb; --side:#f7f8fa; }
|
||||
:root[data-theme="dark"] { color-scheme: dark; --bg:#0b1220; --surface:#121c2d; --muted:#18253a; --border:#29384e; --text:#edf2fb; --secondary:#91a2bb; --accent:#60a5fa; --side:#0e1727; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; min-width:320px; min-height:100vh; color:var(--text); background:var(--bg); }
|
||||
button,input,textarea { font:inherit; }
|
||||
.shell { min-height:100vh; }
|
||||
.sidebar { position:fixed; inset:0 auto 0 0; width:220px; display:flex; flex-direction:column; padding:14px 12px; background:var(--side); border-right:1px solid var(--border); }
|
||||
.brand { display:flex; align-items:center; gap:10px; height:52px; padding:0 8px 14px; border-bottom:1px solid var(--border); }
|
||||
.brand-mark { display:grid; place-items:center; width:36px; height:36px; flex:0 0 auto; border-radius:9px; color:#fff; background:#2563eb; font-weight:800; }
|
||||
.brand strong,.brand small { display:block; }.brand small { margin-top:2px; color:var(--secondary); font-size:11px; }
|
||||
nav { display:grid; gap:3px; padding-top:12px; }
|
||||
nav button { height:38px; padding:0 12px; border:0; border-radius:7px; color:var(--secondary); background:transparent; text-align:left; font-weight:650; cursor:pointer; }
|
||||
nav button:hover,nav button.active { color:var(--accent); background:color-mix(in srgb,var(--accent) 12%,transparent); }
|
||||
.sidebar-foot { margin-top:auto; padding:12px 8px; color:var(--secondary); font-size:11px; border-top:1px solid var(--border); }.health-dot { display:inline-block; width:7px; height:7px; margin-right:7px; border-radius:50%; background:#16a34a; }
|
||||
.content { min-height:100vh; margin-left:220px; padding:0 22px 24px; }
|
||||
.topbar { position:sticky; top:0; z-index:5; display:flex; align-items:center; justify-content:space-between; min-height:58px; margin:0 -22px 18px; padding:8px 22px; background:color-mix(in srgb,var(--surface) 88%,transparent); border-bottom:1px solid var(--border); backdrop-filter:blur(10px); }
|
||||
.topbar h1 { margin:0; font-size:20px; }.topbar small { color:var(--secondary); }.top-actions { display:flex; gap:7px; }
|
||||
.page-stack { display:grid; gap:14px; }.panel { padding:16px; border:1px solid var(--border); border-radius:10px; background:var(--surface); overflow:hidden; }
|
||||
.panel-head { display:flex; align-items:flex-start; justify-content:space-between; gap:14px; margin-bottom:14px; }.panel h2 { margin:0; font-size:16px; }.panel p { margin:5px 0 0; color:var(--secondary); font-size:12px; }
|
||||
.metric-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; }.metric-grid article { display:grid; gap:8px; padding:14px; border:1px solid var(--border); border-radius:9px; background:var(--surface); }.metric-grid span { color:var(--secondary); font-size:12px; }.metric-grid strong { font-size:22px; }
|
||||
.block { display:block; margin-top:3px; color:var(--secondary); }.full { width:100%; }.database-select { width:220px; }.query-actions { display:flex; justify-content:flex-end; margin-top:12px; }.sql-editor :is(textarea) { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; line-height:1.65; }.table-scroll { overflow:auto; }
|
||||
.login-screen { min-height:100vh; display:grid; place-items:center; padding:18px; }.login-card { width:min(100%,390px); padding:24px; border:1px solid var(--border); border-radius:12px; background:var(--surface); box-shadow:0 14px 40px #0002; }.login-card .brand-mark { margin-bottom:16px; }.login-card h1 { margin:0; font-size:24px; }.login-card p { margin:8px 0 20px; color:var(--secondary); font-size:13px; }
|
||||
.mobile-section { display:none; margin-bottom:12px; }
|
||||
.el-table { --el-table-bg-color:transparent; --el-table-tr-bg-color:transparent; --el-table-header-bg-color:var(--muted); --el-table-row-hover-bg-color:var(--muted); --el-table-border-color:var(--border); color:var(--text); }.el-message-box { max-width:calc(100vw - 24px); }
|
||||
@media(max-width:960px){ .metric-grid{grid-template-columns:repeat(2,1fr)} }
|
||||
@media(max-width:720px){ .sidebar{display:none}.content{margin-left:0;padding:0 12px 18px}.topbar{margin:0 -12px 12px;padding:8px 12px}.topbar h1{font-size:17px}.top-actions .el-button:first-child{display:none}.mobile-section{display:block}.panel{padding:12px}.panel-head{flex-direction:column}.metric-grid{grid-template-columns:repeat(2,1fr);gap:8px}.metric-grid strong{font-size:18px}.database-select{width:100%} }
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
|
||||
const root = fileURLToPath(new URL("./postgres-admin", import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
root,
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
outDir: resolve(root, "../dist-postgres"),
|
||||
emptyOutDir: true
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user