feat: add shared PostgreSQL fnOS service and refresh UI
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"build:postgres-admin": "vite build --config vite.postgres.config.ts",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -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%} }
|
||||
@@ -28,7 +28,7 @@ const route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
const { isMobile } = useViewport();
|
||||
const { backendUnavailable, backendMessage, backendLastChangedAt } = useBackendStatus();
|
||||
const { sidebarCollapsed, toggleSidebarCollapsed } = useUiPreferences();
|
||||
const { resolvedTheme, sidebarCollapsed, cycleThemeMode, toggleSidebarCollapsed } = useUiPreferences();
|
||||
|
||||
const mobileNavVisible = ref(false);
|
||||
|
||||
@@ -78,14 +78,6 @@ const navigationGroups = [
|
||||
const userDisplayName = computed(() => authStore.user?.displayName || authStore.user?.username || "管理员");
|
||||
const userAvatarText = computed(() => userDisplayName.value.trim().slice(0, 1).toUpperCase() || "录");
|
||||
|
||||
const isDark = ref(false);
|
||||
try { isDark.value = localStorage.getItem("lr-theme") === "dark"; } catch { /* noop */ }
|
||||
function toggleTheme() {
|
||||
isDark.value = !isDark.value;
|
||||
document.documentElement.dataset.theme = isDark.value ? "dark" : "light";
|
||||
try { localStorage.setItem("lr-theme", isDark.value ? "dark" : "light"); } catch { /* noop */ }
|
||||
}
|
||||
|
||||
function isNavItemActive(index: string) {
|
||||
return route.path === index || route.path.startsWith(`${index}/`);
|
||||
}
|
||||
@@ -120,8 +112,6 @@ function closeMobileNav() { mobileNavVisible.value = false; }
|
||||
|
||||
watch(() => route.fullPath, () => { mobileNavVisible.value = false; });
|
||||
|
||||
// sync dark class on mount
|
||||
watch(isDark, (v) => { document.documentElement.dataset.theme = v ? "dark" : "light"; }, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -203,8 +193,8 @@ watch(isDark, (v) => { document.documentElement.dataset.theme = v ? "dark" : "li
|
||||
|
||||
<div class="app-topbar__spacer" />
|
||||
|
||||
<button class="app-topbar__icon-btn" title="切换主题" @click="toggleTheme">
|
||||
<el-icon :size="17"><component :is="isDark ? Sunny : Moon" /></el-icon>
|
||||
<button class="app-topbar__icon-btn" title="切换主题" @click="cycleThemeMode">
|
||||
<el-icon :size="17"><component :is="resolvedTheme === 'dark' ? Sunny : Moon" /></el-icon>
|
||||
</button>
|
||||
<button class="app-topbar__icon-btn" @click="router.push({ name: 'logs' })">
|
||||
<el-icon :size="17"><Bell /></el-icon>
|
||||
|
||||
@@ -11,7 +11,7 @@ const STORAGE_KEYS = {
|
||||
|
||||
const state = reactive({
|
||||
themeMode: "system" as ThemeMode,
|
||||
density: "comfortable" as DensityMode,
|
||||
density: "compact" as DensityMode,
|
||||
sidebarCollapsed: false,
|
||||
systemTheme: "light" as "light" | "dark",
|
||||
initialized: false
|
||||
@@ -25,7 +25,8 @@ function readStoredThemeMode(): ThemeMode {
|
||||
}
|
||||
|
||||
function readStoredDensity(): DensityMode {
|
||||
return window.localStorage.getItem(STORAGE_KEYS.density) === "compact" ? "compact" : "comfortable";
|
||||
const stored = window.localStorage.getItem(STORAGE_KEYS.density);
|
||||
return stored === "comfortable" ? "comfortable" : "compact";
|
||||
}
|
||||
|
||||
function readStoredSidebarCollapsed() {
|
||||
|
||||
@@ -87,7 +87,7 @@ const router = createRouter({
|
||||
component: RecoveryView
|
||||
},
|
||||
{
|
||||
path: "settings",
|
||||
path: "settings/:section?",
|
||||
name: "settings",
|
||||
component: SettingsView
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* =============================================================
|
||||
Live Recorder · Design System
|
||||
商业级 SaaS · 浅色侧栏 + 层次投影 + 移动适配
|
||||
紧凑 NAS 管理台 · 信息优先 + 低动画 + 移动适配
|
||||
Base: Element Plus 2.x overrides
|
||||
============================================================= */
|
||||
|
||||
@@ -71,8 +71,8 @@
|
||||
--sidebar-w-collapsed: 72px;
|
||||
--topbar-h: 62px;
|
||||
--page-max: 1320px;
|
||||
--page-gap: 22px;
|
||||
--content-padding: 28px;
|
||||
--page-gap: 16px;
|
||||
--content-padding: 20px;
|
||||
--content-padding-mobile: 14px;
|
||||
--control-height: 40px;
|
||||
--control-height-sm: 34px;
|
||||
@@ -191,9 +191,9 @@ button { font-family: inherit; cursor: pointer; }
|
||||
|
||||
/* ============================ Page layout ============================ */
|
||||
.page-stack { display: grid; gap: var(--page-gap); width: min(100%, var(--page-max)); margin: 0 auto; }
|
||||
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; animation: fadeUp .3s ease both; }
|
||||
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
|
||||
.page-header > div:first-child { flex: 1; min-width: 0; }
|
||||
.page-title { margin: 0; font-size: 28px; font-weight: 800; letter-spacing: -.025em; line-height: 1.2; color: var(--text-primary); }
|
||||
.page-title { margin: 0; font-size: 24px; font-weight: 750; letter-spacing: -.02em; line-height: 1.25; color: var(--text-primary); }
|
||||
.page-subtitle { max-width: 76ch; margin: 8px 0 0; color: var(--text-muted); font-size: 13.5px; line-height: 1.6; }
|
||||
.page-kicker { margin-bottom: 7px; color: var(--accent); font-size: 12px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
|
||||
.page-toolbar, .header-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; flex-shrink: 0; }
|
||||
@@ -202,28 +202,22 @@ button { font-family: inherit; cursor: pointer; }
|
||||
.surface-card {
|
||||
--el-card-bg-color: transparent;
|
||||
border-radius: var(--radius-md); border: 1px solid var(--border-subtle);
|
||||
background: var(--surface); box-shadow: var(--shadow-sm); transition: box-shadow .18s ease; animation: fadeUp .3s ease both;
|
||||
background: var(--surface); box-shadow: var(--shadow-xs);
|
||||
}
|
||||
.surface-card:hover { box-shadow: var(--shadow-md); }
|
||||
.surface-card .el-card__body { padding: 24px; }
|
||||
.surface-card:hover { box-shadow: var(--shadow-sm); }
|
||||
.surface-card .el-card__body { padding: 18px; }
|
||||
|
||||
/* stat grid / KPI cards */
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; }
|
||||
.stat-card {
|
||||
display: grid; gap: 11px; min-height: auto; padding: 20px; border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-subtle); background: var(--surface); box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow .18s, transform .18s; position: relative; overflow: hidden;
|
||||
animation: fadeUp .35s ease both;
|
||||
display: grid; gap: 6px; min-height: auto; padding: 12px 14px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-subtle); background: var(--surface); box-shadow: none;
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.stat-card:nth-child(2) { animation-delay: .05s; }
|
||||
.stat-card:nth-child(3) { animation-delay: .10s; }
|
||||
.stat-card:nth-child(4) { animation-delay: .15s; }
|
||||
.stat-card:nth-child(5) { animation-delay: .20s; }
|
||||
.stat-card:nth-child(6) { animation-delay: .25s; }
|
||||
.stat-card:hover { box-shadow: var(--shadow-md); transform: translateY(-2px); }
|
||||
.stat-card:hover { border-color: var(--border-base); }
|
||||
.stat-card__label { color: var(--text-muted); font-size: 12.5px; font-weight: 600; }
|
||||
.stat-card__value { color: var(--text-primary); font-size: 31px; font-weight: 800; letter-spacing: -.04em; line-height: 1; font-variant-numeric: tabular-nums; }
|
||||
.stat-card__hint { color: var(--text-muted); font-size: 12.5px; line-height: 1.65; font-weight: 500; }
|
||||
.stat-card__value { color: var(--text-primary); font-size: 24px; font-weight: 750; letter-spacing: -.03em; line-height: 1.1; font-variant-numeric: tabular-nums; }
|
||||
.stat-card__hint { color: var(--text-muted); font-size: 11.5px; line-height: 1.45; font-weight: 500; }
|
||||
|
||||
/* section */
|
||||
.section-title { margin: 0 0 6px; color: var(--text-primary); font-size: 16px; font-weight: 700; letter-spacing: -.01em; }
|
||||
@@ -280,7 +274,7 @@ button { font-family: inherit; cursor: pointer; }
|
||||
|
||||
/* buttons */
|
||||
.el-button { min-height: var(--control-height); padding: 0 15px; border-radius: var(--radius-sm); font-weight: 700; letter-spacing: 0; transition: all .15s ease; }
|
||||
.el-button:not(.is-disabled):hover { transform: translateY(-1px); }
|
||||
.el-button:not(.is-disabled):hover { transform: none; }
|
||||
.el-button.el-button--default:not(.is-text):not(.is-link) { border-color: var(--border-base); background: var(--surface); color: var(--text-secondary); box-shadow: none; }
|
||||
.el-button.el-button--default:not(.is-text):not(.is-link):hover { border-color: var(--text-soft); color: var(--text-primary); box-shadow: var(--shadow-xs); }
|
||||
|
||||
@@ -370,8 +364,7 @@ html[data-theme="dark"] .el-table tbody tr:nth-child(even) { background: rgba(25
|
||||
.skel--title { height: 18px; width: 50%; }
|
||||
|
||||
/* ============================ Motion ============================ */
|
||||
@keyframes fadeUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||
@media (prefers-reduced-motion: reduce) { * { animation: none !important; } }
|
||||
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition-duration: 0.01ms !important; } }
|
||||
|
||||
/* ============================ Responsive ============================ */
|
||||
@media (max-width: 1280px) { .stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .highlight-grid { grid-template-columns: 1fr; } }
|
||||
@@ -379,7 +372,7 @@ html[data-theme="dark"] .el-table tbody tr:nth-child(even) { background: rgba(25
|
||||
@media (max-width: 768px) {
|
||||
.page-header, .section-header, .toolbar-row { flex-direction: column; }
|
||||
.page-title { font-size: 22px; }
|
||||
.page-subtitle { margin-top: 6px; font-size: 12.5px; line-height: 1.6; }
|
||||
.page-subtitle { display: none; }
|
||||
.surface-card .el-card__body { padding: 14px; }
|
||||
.stats-grid, .data-card__grid { grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||
.stat-card { padding: 14px; gap: 8px; }
|
||||
|
||||
@@ -16,7 +16,7 @@ const { themeMode } = useUiPreferences();
|
||||
|
||||
const form = reactive({
|
||||
username: "admin",
|
||||
password: "Admin@123"
|
||||
password: ""
|
||||
});
|
||||
|
||||
const currentThemeIcon = computed(() => {
|
||||
@@ -81,7 +81,12 @@ async function handleLogin() {
|
||||
|
||||
<el-form label-position="top" class="login-form" @submit.prevent="handleLogin">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.username" :prefix-icon="User" />
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
:prefix-icon="User"
|
||||
autocomplete="username"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码">
|
||||
@@ -89,6 +94,7 @@ async function handleLogin() {
|
||||
v-model="form.password"
|
||||
:prefix-icon="Lock"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
show-password
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
|
||||
@@ -924,23 +924,6 @@ onBeforeUnmount(() => {
|
||||
<MetricCard label="弹幕事件" :value="totalDanmakuCount" description="累计写入 XML 的事件总数" :icon="Bell" />
|
||||
</div>
|
||||
|
||||
<div class="record-feature-strip">
|
||||
<article class="record-feature-card">
|
||||
<div class="record-feature-card__eyebrow">能力说明</div>
|
||||
<div class="record-feature-card__title">开播自动录制</div>
|
||||
<p class="record-feature-card__description">继续复用后端轮询、自动开录和活动会话保护逻辑,不新增任何前端假状态。</p>
|
||||
</article>
|
||||
<article class="record-feature-card">
|
||||
<div class="record-feature-card__eyebrow">能力说明</div>
|
||||
<div class="record-feature-card__title">分片后处理</div>
|
||||
<p class="record-feature-card__description">保留现有转码、分片完成事件和实时进度展示,聚焦运维可读性。</p>
|
||||
</article>
|
||||
<article class="record-feature-card">
|
||||
<div class="record-feature-card__eyebrow">能力说明</div>
|
||||
<div class="record-feature-card__title">上传归档</div>
|
||||
<p class="record-feature-card__description">继续调用真实上传接口,空数据时显示空状态而不是伪造归档数量。</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="surface-card sessions-card" shadow="never">
|
||||
|
||||
+327
-179
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||
import type {
|
||||
CleanupOperation,
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useViewport } from "@/composables/useViewport";
|
||||
import { useUiPreferences } from "@/composables/useUiPreferences";
|
||||
import { useRoute } from "vue-router";
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from "vue-router";
|
||||
|
||||
type ScriptEventType = "live_started" | "live_ended" | "segment_completed";
|
||||
|
||||
@@ -41,6 +41,7 @@ type SettingsFormModel = SystemSettings & {
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const retentionCleanupStorageKey = "live-recorder-settings-retention-cleanup-operation-id";
|
||||
const { isMobile } = useViewport();
|
||||
const { themeMode, density, sidebarCollapsed } = useUiPreferences();
|
||||
@@ -74,13 +75,20 @@ const scriptTestResults = reactive<Record<ScriptEventType, EventScriptTestResult
|
||||
const webhookTestResult = ref<WebhookTestResult | null>(null);
|
||||
const retentionCleanupOperation = ref<CleanupOperation | null>(null);
|
||||
const loadError = ref("");
|
||||
const activeSettingTab = ref("recording");
|
||||
const settingSections = ["recording", "upload", "automation", "notifications", "platform", "account"] as const;
|
||||
type SettingSection = typeof settingSections[number];
|
||||
|
||||
function normalizeSettingSection(value: unknown): SettingSection {
|
||||
const section = String(value || "recording") as SettingSection;
|
||||
return settingSections.includes(section) ? section : "recording";
|
||||
}
|
||||
|
||||
const activeSettingTab = ref<SettingSection>(normalizeSettingSection(route.params.section));
|
||||
const profileDisplayName = computed(() => authStore.user?.displayName || authStore.user?.username || "管理员");
|
||||
const profileUsername = computed(() => authStore.user?.username || "--");
|
||||
const profileUserId = computed(() => authStore.user?.userId || "--");
|
||||
const profileExpiresAt = computed(() => authStore.user?.expiresAt || "--");
|
||||
const profileInitial = computed(() => profileDisplayName.value.trim().slice(0, 1).toUpperCase() || "录");
|
||||
const qualitySupportHint = "Different platforms expose different quality ladders. If a target quality is unavailable, the recorder automatically falls back to the closest stream that platform offers.";
|
||||
const qualitySupportHint = "不同平台提供的画质档位并不完全一致;目标画质不可用时,录制器会自动选择最接近的可用流。";
|
||||
const qualityOptions = qualityOptionList;
|
||||
const platformRequestPlatforms = platformOptionList;
|
||||
let retentionCleanupPollTimer: number | null = null;
|
||||
@@ -201,34 +209,34 @@ const form = reactive<SettingsFormModel>({
|
||||
emailToAddresses: "",
|
||||
notifyOnLiveStarted: true,
|
||||
notifyOnException: true,
|
||||
emailLiveStartedSubjectTemplate: "[{{appName}}] Live started: {{anchor}} {{title}} ({{roomId}})",
|
||||
emailLiveStartedSubjectTemplate: "[{{appName}}] 直播已开始:{{anchor}} {{title}}({{roomId}})",
|
||||
emailLiveStartedBodyTemplateHtml: `<div style="font-family: 'Segoe UI', 'PingFang SC', sans-serif; color: #1f2937; line-height: 1.7;">
|
||||
<h2 style="margin: 0 0 16px; color: #3e5f7c;">Live started</h2>
|
||||
<p>The monitored live room is now online.</p>
|
||||
<h2 style="margin: 0 0 16px; color: #3e5f7c;">直播已开始</h2>
|
||||
<p>监控的直播间现已开播。</p>
|
||||
<ul>
|
||||
<li><strong>Platform:</strong> {{platform}}</li>
|
||||
<li><strong>Room ID:</strong> {{roomId}}</li>
|
||||
<li><strong>Title:</strong> {{title}}</li>
|
||||
<li><strong>Anchor:</strong> {{anchor}}</li>
|
||||
<li><strong>Detected At (Beijing Time):</strong> {{detectedAtUtc}}</li>
|
||||
<li><strong>平台:</strong> {{platform}}</li>
|
||||
<li><strong>房间号:</strong> {{roomId}}</li>
|
||||
<li><strong>标题:</strong> {{title}}</li>
|
||||
<li><strong>主播:</strong> {{anchor}}</li>
|
||||
<li><strong>检测时间(北京时间):</strong> {{detectedAtUtc}}</li>
|
||||
</ul>
|
||||
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
|
||||
<p><strong>直播地址:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
|
||||
<div style="margin-top: 16px;">
|
||||
<strong>Event Script Output:</strong>
|
||||
<strong>事件脚本输出:</strong>
|
||||
</div>
|
||||
<div style="margin-top: 8px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{eventScriptOutput}}</div>
|
||||
</div>`,
|
||||
emailExceptionSubjectTemplate: "[{{appName}}] Exception: {{source}}",
|
||||
emailExceptionSubjectTemplate: "[{{appName}}] 录制异常:{{source}}",
|
||||
emailExceptionBodyTemplateHtml: `<div style="font-family: 'Segoe UI', 'PingFang SC', sans-serif; color: #1f2937; line-height: 1.7;">
|
||||
<h2 style="margin: 0 0 16px; color: #8b5e3c;">Exception detected</h2>
|
||||
<h2 style="margin: 0 0 16px; color: #8b5e3c;">检测到录制异常</h2>
|
||||
<p>{{summary}}</p>
|
||||
<ul>
|
||||
<li><strong>Source:</strong> {{source}}</li>
|
||||
<li><strong>Live Room ID:</strong> {{liveRoomId}}</li>
|
||||
<li><strong>Room ID:</strong> {{roomId}}</li>
|
||||
<li><strong>Record Task ID:</strong> {{recordTaskId}}</li>
|
||||
<li><strong>Task Status:</strong> {{taskStatus}}</li>
|
||||
<li><strong>Occurred At (Beijing Time):</strong> {{occurredAtUtc}}</li>
|
||||
<li><strong>来源:</strong> {{source}}</li>
|
||||
<li><strong>直播间 ID:</strong> {{liveRoomId}}</li>
|
||||
<li><strong>平台房间号:</strong> {{roomId}}</li>
|
||||
<li><strong>录制任务 ID:</strong> {{recordTaskId}}</li>
|
||||
<li><strong>任务状态:</strong> {{taskStatus}}</li>
|
||||
<li><strong>发生时间(北京时间):</strong> {{occurredAtUtc}}</li>
|
||||
</ul>
|
||||
<div style="margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{detail}}</div>
|
||||
</div>`,
|
||||
@@ -244,6 +252,33 @@ const form = reactive<SettingsFormModel>({
|
||||
douyinCookie: ""
|
||||
});
|
||||
|
||||
const savedSettingsSnapshot = ref<SettingsFormModel | null>(null);
|
||||
|
||||
function cloneSettingsForm(): SettingsFormModel {
|
||||
return JSON.parse(JSON.stringify(form)) as SettingsFormModel;
|
||||
}
|
||||
|
||||
function markSettingsSaved() {
|
||||
savedSettingsSnapshot.value = cloneSettingsForm();
|
||||
}
|
||||
|
||||
const isDirty = computed(() => {
|
||||
if (!savedSettingsSnapshot.value || loading.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return JSON.stringify(form) !== JSON.stringify(savedSettingsSnapshot.value);
|
||||
});
|
||||
|
||||
function discardSettingsChanges() {
|
||||
if (!savedSettingsSnapshot.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.assign(form, JSON.parse(JSON.stringify(savedSettingsSnapshot.value)) as SettingsFormModel);
|
||||
ElMessage.info("已放弃未保存的修改");
|
||||
}
|
||||
|
||||
function normalizePlatformRequestSettings(
|
||||
value?: Record<string, PlatformRequestSettings> | null
|
||||
): Record<string, PlatformRequestSettings> {
|
||||
@@ -364,36 +399,36 @@ const webhookTemplateTokens = [
|
||||
];
|
||||
|
||||
const eventScriptEnvironmentExamples = [
|
||||
{ name: "LIVE_RECORDER_EVENT", example: "segment_completed", scope: "All events" },
|
||||
{ name: "LIVE_RECORDER_PLATFORM", example: "Douyin", scope: "All events" },
|
||||
{ name: "LIVE_RECORDER_LIVE_ROOM_ID", example: "6f73a2f2-1d4c-4e7a-a9b1-3d29d54ed901", scope: "All events" },
|
||||
{ name: "LIVE_RECORDER_ROOM_ID", example: "676493068539", scope: "All events" },
|
||||
{ name: "LIVE_RECORDER_TITLE", example: "Casual stream", scope: "All events" },
|
||||
{ name: "LIVE_RECORDER_ANCHOR", example: "Streamer Name", scope: "All events" },
|
||||
{ name: "LIVE_RECORDER_SOURCE_URL", example: "https://live.douyin.com/676493068539", scope: "All events" },
|
||||
{ name: "LIVE_RECORDER_OCCURRED_AT_UTC", example: "2026-04-25T20:34:56.7890000+08:00", scope: "All events" },
|
||||
{ name: "LIVE_RECORDER_EVENT", example: "segment_completed", scope: "所有事件" },
|
||||
{ name: "LIVE_RECORDER_PLATFORM", example: "Douyin", scope: "所有事件" },
|
||||
{ name: "LIVE_RECORDER_LIVE_ROOM_ID", example: "6f73a2f2-1d4c-4e7a-a9b1-3d29d54ed901", scope: "所有事件" },
|
||||
{ name: "LIVE_RECORDER_ROOM_ID", example: "676493068539", scope: "所有事件" },
|
||||
{ name: "LIVE_RECORDER_TITLE", example: "日常直播", scope: "所有事件" },
|
||||
{ name: "LIVE_RECORDER_ANCHOR", example: "主播名称", scope: "所有事件" },
|
||||
{ name: "LIVE_RECORDER_SOURCE_URL", example: "https://live.douyin.com/676493068539", scope: "所有事件" },
|
||||
{ name: "LIVE_RECORDER_OCCURRED_AT_UTC", example: "2026-04-25T20:34:56.7890000+08:00", scope: "所有事件" },
|
||||
{
|
||||
name: "LIVE_RECORDER_SCRIPT_LOG_PATH",
|
||||
example: "/tmp/live-recorder-script-log-7a13c2c5e5cd4f2d8ec2c3b2d5f3f1aa.txt",
|
||||
scope: "All events"
|
||||
scope: "所有事件"
|
||||
},
|
||||
{ name: "LIVE_RECORDER_RECORD_SESSION_ID", example: "8e2e9c64-b8f6-4d15-b6cb-1d4ce0adab77", scope: "Segment completed only" },
|
||||
{ name: "LIVE_RECORDER_RECORD_TASK_ID", example: "2a4810a2-7ef4-4a22-90d4-0211b90cc54c", scope: "Segment completed only" },
|
||||
{ name: "LIVE_RECORDER_SEGMENT_INDEX", example: "1", scope: "Segment completed only" },
|
||||
{ name: "LIVE_RECORDER_RECORD_SESSION_ID", example: "8e2e9c64-b8f6-4d15-b6cb-1d4ce0adab77", scope: "仅分片完成" },
|
||||
{ name: "LIVE_RECORDER_RECORD_TASK_ID", example: "2a4810a2-7ef4-4a22-90d4-0211b90cc54c", scope: "仅分片完成" },
|
||||
{ name: "LIVE_RECORDER_SEGMENT_INDEX", example: "1", scope: "仅分片完成" },
|
||||
{
|
||||
name: "LIVE_RECORDER_SEGMENT_FILE_PATH",
|
||||
example: "/app/records/Douyin/Streamer Name/2026-04-25/203000_casual-stream__00001.mp4",
|
||||
scope: "Segment completed only"
|
||||
scope: "仅分片完成"
|
||||
},
|
||||
{
|
||||
name: "LIVE_RECORDER_DANMAKU_FILE_PATH",
|
||||
example: "/app/records/Douyin/Streamer Name/2026-04-25/203000_casual-stream__00001.xml",
|
||||
scope: "Segment completed only"
|
||||
scope: "仅分片完成"
|
||||
},
|
||||
{ name: "LIVE_RECORDER_DURATION_SECONDS", example: "2185.1", scope: "Segment completed only" },
|
||||
{ name: "LIVE_RECORDER_FILE_SIZE_BYTES", example: "734003200", scope: "Segment completed only" },
|
||||
{ name: "LIVE_RECORDER_TASK_STATUS", example: "Completed", scope: "Segment completed only" },
|
||||
{ name: "LIVE_RECORDER_SESSION_STATUS", example: "Running", scope: "Segment completed only" }
|
||||
{ name: "LIVE_RECORDER_DURATION_SECONDS", example: "2185.1", scope: "仅分片完成" },
|
||||
{ name: "LIVE_RECORDER_FILE_SIZE_BYTES", example: "734003200", scope: "仅分片完成" },
|
||||
{ name: "LIVE_RECORDER_TASK_STATUS", example: "Completed", scope: "仅分片完成" },
|
||||
{ name: "LIVE_RECORDER_SESSION_STATUS", example: "Running", scope: "仅分片完成" }
|
||||
];
|
||||
|
||||
const eventScriptModeOptions = [
|
||||
@@ -402,25 +437,25 @@ const eventScriptModeOptions = [
|
||||
];
|
||||
|
||||
const uploadTargetOptions = [
|
||||
{ label: "Do not upload", value: 0 },
|
||||
{ label: "不上传", value: 0 },
|
||||
{ label: "WebDAV", value: 1 },
|
||||
{ label: "S3", value: 2 },
|
||||
{ label: "OpenList", value: 3 }
|
||||
];
|
||||
|
||||
const retentionVideoFileOptions = [
|
||||
{ label: "Any file state", value: "any" as CleanupVideoFileCondition },
|
||||
{ label: "All video files missing", value: "allMissing" as CleanupVideoFileCondition },
|
||||
{ label: "All video files present", value: "allPresent" as CleanupVideoFileCondition }
|
||||
{ label: "任意文件状态", value: "any" as CleanupVideoFileCondition },
|
||||
{ label: "视频文件全部缺失", value: "allMissing" as CleanupVideoFileCondition },
|
||||
{ label: "视频文件全部存在", value: "allPresent" as CleanupVideoFileCondition }
|
||||
];
|
||||
const retentionTaskStatusOptions = Object.entries(taskStatusLabelMap)
|
||||
.map(([value, label]) => ({ value: Number(value), label }))
|
||||
.filter((option) => option.value !== 1 && option.value !== 2 && option.value !== 3);
|
||||
const retentionCleanupStatusLabelMap: Record<CleanupOperation["status"], string> = {
|
||||
queued: "Queued",
|
||||
running: "Running",
|
||||
completed: "Completed",
|
||||
failed: "Failed"
|
||||
queued: "等待执行",
|
||||
running: "执行中",
|
||||
completed: "已完成",
|
||||
failed: "失败"
|
||||
};
|
||||
const retentionCleanupStatusLabel = computed(() =>
|
||||
retentionCleanupOperation.value ? retentionCleanupStatusLabelMap[retentionCleanupOperation.value.status] : ""
|
||||
@@ -453,7 +488,7 @@ const retentionCleanupProgressText = computed(() => {
|
||||
}
|
||||
|
||||
if (retentionCleanupOperation.value.totalSessionCount === 0) {
|
||||
return retentionCleanupOperation.value.status === "queued" ? "Scanning candidate sessions" : "0 / 0";
|
||||
return retentionCleanupOperation.value.status === "queued" ? "正在扫描候选录制会话" : "0 / 0";
|
||||
}
|
||||
|
||||
return `${retentionCleanupOperation.value.processedSessionCount} / ${retentionCleanupOperation.value.totalSessionCount}`;
|
||||
@@ -464,13 +499,13 @@ const retentionCleanupSummary = computed(() => {
|
||||
}
|
||||
|
||||
return [
|
||||
`sessions ${retentionCleanupOperation.value.deletedSessionCount}`,
|
||||
`tasks ${retentionCleanupOperation.value.deletedTaskCount}`,
|
||||
`results ${retentionCleanupOperation.value.deletedResultCount}`,
|
||||
`logs ${retentionCleanupOperation.value.deletedLogCount}`,
|
||||
`files ${retentionCleanupOperation.value.deletedFileCount}`,
|
||||
`danmaku ${retentionCleanupOperation.value.deletedDanmakuFileCount}`
|
||||
].join(" 路 ");
|
||||
`会话 ${retentionCleanupOperation.value.deletedSessionCount}`,
|
||||
`任务 ${retentionCleanupOperation.value.deletedTaskCount}`,
|
||||
`结果 ${retentionCleanupOperation.value.deletedResultCount}`,
|
||||
`日志 ${retentionCleanupOperation.value.deletedLogCount}`,
|
||||
`视频 ${retentionCleanupOperation.value.deletedFileCount}`,
|
||||
`弹幕 ${retentionCleanupOperation.value.deletedDanmakuFileCount}`
|
||||
].join(" · ");
|
||||
});
|
||||
const retentionCleanupWarningsPreview = computed(() => retentionCleanupOperation.value?.warnings.slice(0, 6) ?? []);
|
||||
|
||||
@@ -610,8 +645,9 @@ async function loadSettings() {
|
||||
Object.assign(form, data);
|
||||
form.platformRequestSettings = normalizePlatformRequestSettings(data.platformRequestSettings);
|
||||
syncLegacyPlatformAliasesFromMap();
|
||||
markSettingsSaved();
|
||||
} catch (error) {
|
||||
loadError.value = getApiErrorMessage(error, "Failed to load system settings. Please try again later.");
|
||||
loadError.value = getApiErrorMessage(error, "系统设置加载失败,请稍后重试。");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -624,17 +660,17 @@ const pwdForm = reactive({
|
||||
});
|
||||
|
||||
const pwdRules = {
|
||||
currentPassword: [{ required: true, message: "Please enter the current password", trigger: "blur" }],
|
||||
currentPassword: [{ required: true, message: "请输入当前密码", trigger: "blur" }],
|
||||
newPassword: [
|
||||
{ required: true, message: "请输入新密码", trigger: "blur" },
|
||||
{ min: 6, message: "Password must be at least 6 characters", trigger: "blur" }
|
||||
{ min: 6, message: "密码至少需要 6 个字符", trigger: "blur" }
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: "请再次输入新密码", trigger: "blur" },
|
||||
{
|
||||
validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
|
||||
if (value !== pwdForm.newPassword) {
|
||||
callback(new Error("Passwords do not match"));
|
||||
callback(new Error("两次输入的密码不一致"));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
@@ -653,13 +689,13 @@ async function changePassword() {
|
||||
changingPassword.value = true;
|
||||
try {
|
||||
await authStore.changePassword(pwdForm.currentPassword, pwdForm.newPassword);
|
||||
ElMessage.success("Password updated.");
|
||||
ElMessage.success("密码已更新");
|
||||
pwdForm.currentPassword = "";
|
||||
pwdForm.newPassword = "";
|
||||
pwdForm.confirmPassword = "";
|
||||
pwdFormRef.value?.resetFields();
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, "Failed to change password. Please try again later."));
|
||||
ElMessage.error(getApiErrorMessage(error, "密码修改失败,请稍后重试。"));
|
||||
} finally {
|
||||
changingPassword.value = false;
|
||||
}
|
||||
@@ -678,7 +714,8 @@ async function saveSettings() {
|
||||
Object.assign(form, data);
|
||||
form.platformRequestSettings = normalizePlatformRequestSettings(data.platformRequestSettings);
|
||||
syncLegacyPlatformAliasesFromMap();
|
||||
ElMessage.success("Settings saved.");
|
||||
markSettingsSaved();
|
||||
ElMessage.success("设置已保存");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
@@ -705,7 +742,7 @@ async function sendTestEmail() {
|
||||
|
||||
ElMessage.success("测试邮件已发送,请检查收件箱");
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, "Failed to send test email."));
|
||||
ElMessage.error(getApiErrorMessage(error, "测试邮件发送失败。"));
|
||||
} finally {
|
||||
testingEmail.value = false;
|
||||
}
|
||||
@@ -760,7 +797,7 @@ async function runRetentionCleanup() {
|
||||
try {
|
||||
const { data } = await apiClient.post<CleanupOperation>("/settings/retention/run-now");
|
||||
await startRetentionCleanupTracking(data);
|
||||
ElMessage.success("Retention cleanup background task created.");
|
||||
ElMessage.success("保留清理任务已创建");
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, "保留清理执行失败"));
|
||||
} finally {
|
||||
@@ -823,7 +860,7 @@ async function refreshRetentionCleanupOperation(operationId: string, options?: {
|
||||
stopRetentionCleanupPolling();
|
||||
|
||||
if (!options?.silent) {
|
||||
ElMessage.error(getApiErrorMessage(error, "Failed to load retention cleanup task status."));
|
||||
ElMessage.error(getApiErrorMessage(error, "保留清理任务状态加载失败。"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -869,7 +906,7 @@ async function exportSettingsBackup() {
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
ElMessage.success("Settings backup exported.");
|
||||
ElMessage.success("配置备份已导出");
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, "系统设置导出失败"));
|
||||
} finally {
|
||||
@@ -896,7 +933,7 @@ async function importSettingsBackup(event: Event) {
|
||||
const payload = JSON.parse(await file.text());
|
||||
const { data } = await apiClient.post<SystemSettings>("/settings/import", payload);
|
||||
Object.assign(form, data);
|
||||
ElMessage.success("Settings imported from backup.");
|
||||
ElMessage.success("配置备份已导入,请检查后保存");
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, "系统设置导入失败"));
|
||||
} finally {
|
||||
@@ -976,7 +1013,7 @@ async function syncSettingsHash(hash = route.hash) {
|
||||
}
|
||||
|
||||
if (hash === "#security") {
|
||||
activeSettingTab.value = "security";
|
||||
activeSettingTab.value = "account";
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
@@ -984,6 +1021,9 @@ async function syncSettingsHash(hash = route.hash) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (String(route.params.section || "") !== activeSettingTab.value) {
|
||||
await router.replace({ name: "settings", params: { section: activeSettingTab.value }, hash: route.hash });
|
||||
}
|
||||
await loadSettings();
|
||||
await restoreRetentionCleanupTracking();
|
||||
await syncSettingsHash();
|
||||
@@ -993,12 +1033,45 @@ onBeforeUnmount(() => {
|
||||
stopRetentionCleanupPolling();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.params.section,
|
||||
(section) => {
|
||||
activeSettingTab.value = normalizeSettingSection(section);
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
activeSettingTab,
|
||||
(section) => {
|
||||
if (String(route.params.section || "") !== section) {
|
||||
void router.replace({ name: "settings", params: { section }, hash: route.hash });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => route.hash,
|
||||
(hash) => {
|
||||
void syncSettingsHash(hash);
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeRouteLeave(async () => {
|
||||
if (!isDirty.value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
"当前设置尚未保存,离开后修改会丢失。",
|
||||
"确认离开设置页",
|
||||
{ confirmButtonText: "离开", cancelButtonText: "继续编辑", type: "warning" }
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -1015,7 +1088,6 @@ watch(
|
||||
<div class="page-toolbar">
|
||||
<el-button :loading="exportingSettings" @click="exportSettingsBackup">导出配置</el-button>
|
||||
<el-button :loading="importingSettings" @click="triggerImportSettings">导入配置</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="saveSettings">保存设置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1029,71 +1101,42 @@ watch(
|
||||
|
||||
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
|
||||
|
||||
<div class="settings-overview-grid">
|
||||
<el-card id="profile" class="surface-card settings-overview-card" shadow="never">
|
||||
<div class="settings-overview-card__header">
|
||||
<div>
|
||||
<div class="settings-overview-card__eyebrow">个人资料</div>
|
||||
<h3 class="section-title">当前登录账户</h3>
|
||||
</div>
|
||||
<section id="preferences" class="settings-quickbar surface-card" aria-label="账户与显示偏好">
|
||||
<div class="settings-profile settings-profile--compact">
|
||||
<div class="settings-profile__avatar">{{ profileInitial }}</div>
|
||||
<div>
|
||||
<div class="settings-profile__name">{{ profileDisplayName }}</div>
|
||||
<div class="settings-profile__meta">{{ profileUsername }} · {{ profileUserId }}</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-profile">
|
||||
<div class="settings-profile__avatar">{{ profileInitial }}</div>
|
||||
<div>
|
||||
<div class="settings-profile__name">{{ profileDisplayName }}</div>
|
||||
<div class="settings-profile__meta">{{ profileUsername }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-overview-list">
|
||||
<div><span>用户名</span><strong>{{ profileUsername }}</strong></div>
|
||||
<div><span>用户 ID</span><strong>{{ profileUserId }}</strong></div>
|
||||
<div><span>邮箱</span><strong>--</strong></div>
|
||||
<div><span>角色</span><strong>--</strong></div>
|
||||
<div><span>当前空间</span><strong>--</strong></div>
|
||||
<div><span>在线状态</span><strong>在线</strong></div>
|
||||
<div><span>凭证到期</span><strong>{{ profileExpiresAt }}</strong></div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card id="preferences" class="surface-card settings-overview-card" shadow="never">
|
||||
<div class="settings-overview-card__header">
|
||||
<div>
|
||||
<div class="settings-overview-card__eyebrow">偏好设置</div>
|
||||
<h3 class="section-title">控制台显示偏好</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-preferences">
|
||||
<div class="settings-preferences__row">
|
||||
<span>主题模式</span>
|
||||
<el-select v-model="themeMode">
|
||||
<el-option label="跟随系统" value="system" />
|
||||
<el-option label="浅色" value="light" />
|
||||
<el-option label="深色" value="dark" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="settings-preferences__row">
|
||||
<span>显示密度</span>
|
||||
<el-select v-model="density">
|
||||
<el-option label="舒适密度" value="comfortable" />
|
||||
<el-option label="紧凑密度" value="compact" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="settings-preferences__row settings-preferences__row--switch">
|
||||
<div>
|
||||
<strong>侧栏折叠</strong>
|
||||
<p>继续复用当前前端偏好存储逻辑。</p>
|
||||
</div>
|
||||
<el-switch v-model="sidebarCollapsed" />
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
<label class="settings-quickbar__control">
|
||||
<span>主题</span>
|
||||
<el-select v-model="themeMode" size="small">
|
||||
<el-option label="跟随系统" value="system" />
|
||||
<el-option label="浅色" value="light" />
|
||||
<el-option label="深色" value="dark" />
|
||||
</el-select>
|
||||
</label>
|
||||
<label class="settings-quickbar__control">
|
||||
<span>密度</span>
|
||||
<el-select v-model="density" size="small">
|
||||
<el-option label="舒适" value="comfortable" />
|
||||
<el-option label="紧凑" value="compact" />
|
||||
</el-select>
|
||||
</label>
|
||||
<label class="settings-quickbar__switch">
|
||||
<span>折叠侧栏</span>
|
||||
<el-switch v-model="sidebarCollapsed" />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div class="settings-grid" v-loading="loading">
|
||||
<el-tabs v-model="activeSettingTab" type="border-card" class="settings-tabs">
|
||||
<el-tabs
|
||||
v-model="activeSettingTab"
|
||||
type="border-card"
|
||||
class="settings-tabs"
|
||||
:tab-position="isMobile ? 'top' : 'left'"
|
||||
>
|
||||
<el-tab-pane label="录制" name="recording">
|
||||
<el-card class="surface-card settings-card" shadow="never">
|
||||
<h3 class="section-title">录制基础</h3>
|
||||
@@ -1312,7 +1355,7 @@ watch(
|
||||
</el-form>
|
||||
|
||||
<div class="action-strip">
|
||||
<div class="helper-text">Run now and the daily retention cleanup use the same saved rules. Save this section first if you just changed the filters.</div>
|
||||
<div class="helper-text">立即执行和每日自动清理使用相同规则;刚修改筛选条件时请先保存设置。</div>
|
||||
<el-button :loading="runningRetentionCleanup" @click="runRetentionCleanup">立即执行清理</el-button>
|
||||
</div>
|
||||
|
||||
@@ -1321,10 +1364,10 @@ watch(
|
||||
class="test-result"
|
||||
:class="retentionCleanupOperation.status === 'failed' || retentionCleanupOperation.warnings.length ? 'test-result--warning' : 'test-result--success'"
|
||||
>
|
||||
<div class="test-result__title">Current cleanup task</div>
|
||||
<div class="test-result__title">当前清理任务</div>
|
||||
<div class="test-result__meta">
|
||||
Status={{ retentionCleanupStatusLabel }} 路
|
||||
progress={{ retentionCleanupProgressText }} 路
|
||||
状态={{ retentionCleanupStatusLabel }} ·
|
||||
进度={{ retentionCleanupProgressText }} ·
|
||||
{{ retentionCleanupSummary }}
|
||||
</div>
|
||||
<div v-if="retentionCleanupOperation.errorMessage" class="test-result__detail">
|
||||
@@ -1334,8 +1377,8 @@ watch(
|
||||
<li v-for="warning in retentionCleanupWarningsPreview" :key="warning">{{ warning }}</li>
|
||||
</ul>
|
||||
<div class="action-strip">
|
||||
<div class="helper-text">This page keeps polling the same task after refresh until it finishes or fails.</div>
|
||||
<el-button v-if="retentionCleanupFinished" text @click="clearTrackedRetentionCleanup">Dismiss</el-button>
|
||||
<div class="helper-text">刷新页面后仍会继续跟踪该任务,直到完成或失败。</div>
|
||||
<el-button v-if="retentionCleanupFinished" text @click="clearTrackedRetentionCleanup">关闭</el-button>
|
||||
<el-tag v-else :type="retentionCleanupTagType">{{ retentionCleanupStatusLabel }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1343,7 +1386,7 @@ watch(
|
||||
|
||||
<el-card class="surface-card settings-card" shadow="never">
|
||||
<h3 class="section-title">弹幕录制</h3>
|
||||
<p class="section-subtitle">Control parallel danmaku XML recording, non-chat event capture, and retry / polling pacing.</p>
|
||||
<p class="section-subtitle">控制弹幕 XML 并行录制、非聊天事件采集和失败重试节奏。</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
@@ -1353,12 +1396,12 @@ watch(
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Record non-chat events">
|
||||
<el-form-item label="记录非聊天事件">
|
||||
<el-switch v-model="form.danmakuIncludeNonChatEvents" :disabled="!form.enableDanmakuRecording" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Minimum polling interval (ms)">
|
||||
<el-form-item label="最小轮询间隔(毫秒)">
|
||||
<el-input-number
|
||||
v-model="form.danmakuMinPollIntervalMilliseconds"
|
||||
:min="100"
|
||||
@@ -1368,7 +1411,7 @@ watch(
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="鏈€澶ч噸璇曢€€閬匡紙绉掞級">
|
||||
<el-form-item label="最大重试退避(秒)">
|
||||
<el-input-number
|
||||
v-model="form.danmakuRetryDelayMaxSeconds"
|
||||
:min="1"
|
||||
@@ -1383,7 +1426,7 @@ watch(
|
||||
|
||||
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
|
||||
<h3 class="section-title">路径模板</h3>
|
||||
<p class="section-subtitle">Both directory and filename templates support variables. Segmented layouts are fully controlled by the templates themselves.</p>
|
||||
<p class="section-subtitle">目录和文件名模板均支持变量,分片目录结构完全由模板控制。</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="目录模板">
|
||||
@@ -1395,7 +1438,7 @@ watch(
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Output filename template">
|
||||
<el-form-item label="输出文件名模板">
|
||||
<el-input
|
||||
v-model="form.outputFileNameTemplate"
|
||||
type="textarea"
|
||||
@@ -1412,15 +1455,15 @@ watch(
|
||||
</div>
|
||||
|
||||
<div class="helper-panel">
|
||||
<code>{fileStem}</code> is only for directory templates and represents the rendered filename stem. <code>{segmentSuffix}</code> is only for filename templates and expands to suffixes like <code>_00001</code> in segmented mode while staying empty in single-file mode.
|
||||
<code>{fileStem}</code> 仅用于目录模板,表示渲染后的文件主名;<code>{segmentSuffix}</code> 仅用于文件名模板,分片模式下生成 <code>_00001</code> 一类后缀,单文件模式下为空。
|
||||
</div>
|
||||
|
||||
<div class="helper-panel">
|
||||
Time variables in both directory and filename templates are rendered in Beijing time (UTC+8), and the examples above use the same timezone.
|
||||
目录和文件名模板中的时间变量统一使用北京时间(UTC+8),上方示例也使用相同时区。
|
||||
</div>
|
||||
|
||||
<div class="helper-panel">
|
||||
<code>{quality}</code> renders a stable quality key such as <code>origin</code>, <code>FULL_HD</code>, <code>HD</code>, or <code>SD</code>, which works well in directory names or automation scripts.
|
||||
<code>{quality}</code> 会生成 <code>origin</code>、<code>FULL_HD</code>、<code>HD</code>、<code>SD</code> 等稳定画质标识,适合用于目录或自动化脚本。
|
||||
</div>
|
||||
|
||||
<div class="token-list">
|
||||
@@ -1429,7 +1472,7 @@ watch(
|
||||
</el-card>
|
||||
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="轮询与上传" name="polling">
|
||||
<el-tab-pane label="轮询与上传" name="upload">
|
||||
|
||||
<el-card class="surface-card settings-card" shadow="never">
|
||||
<h3 class="section-title">后台巡检</h3>
|
||||
@@ -1501,7 +1544,7 @@ watch(
|
||||
<div class="template-section__header">
|
||||
<div>
|
||||
<h4 class="template-section__title">WebDAV 目标</h4>
|
||||
<p class="template-section__subtitle">Create remote directories from recording-relative paths and upload the video plus danmaku files.</p>
|
||||
<p class="template-section__subtitle">按录制相对路径创建远端目录,并上传视频及对应弹幕文件。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1535,7 +1578,7 @@ watch(
|
||||
<div class="template-section__header">
|
||||
<div>
|
||||
<h4 class="template-section__title">S3 目标</h4>
|
||||
<p class="template-section__subtitle">Supports custom endpoint, bucket, region, and prefix settings for object-storage compatible services.</p>
|
||||
<p class="template-section__subtitle">支持兼容对象存储服务的自定义端点、存储桶、区域和前缀。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1740,7 +1783,7 @@ watch(
|
||||
</el-card>
|
||||
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="事件脚本" name="scripts">
|
||||
<el-tab-pane label="自动化脚本" name="automation">
|
||||
|
||||
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
|
||||
<h3 class="section-title">事件脚本</h3>
|
||||
@@ -1942,26 +1985,26 @@ watch(
|
||||
Live-started and live-ended scripts receive the shared event variables. Segment-completed scripts also receive file paths, danmaku paths, duration, file size, and task status values. Missing values are passed as empty strings.
|
||||
</div>
|
||||
<div class="event-script-help__intro">
|
||||
Inline script mode runs with <code>/bin/sh -c</code> on Linux / Docker and with <code>PowerShell -Command</code> on Windows.
|
||||
内联脚本在 Linux / Docker 下通过 <code>/bin/sh -c</code> 执行,在 Windows 下通过 <code>PowerShell -Command</code> 执行。
|
||||
</div>
|
||||
<div class="event-script-help__intro">
|
||||
If a script wants to append custom content to the system log, write text into the temporary file pointed to by <code>LIVE_RECORDER_SCRIPT_LOG_PATH</code>.
|
||||
脚本需要向系统日志追加内容时,请写入 <code>LIVE_RECORDER_SCRIPT_LOG_PATH</code> 指向的临时文件。
|
||||
</div>
|
||||
|
||||
<div class="event-script-help__intro">
|
||||
The variable name <code>LIVE_RECORDER_OCCURRED_AT_UTC</code> is kept for compatibility, but the actual value is rendered in Beijing time (UTC+8).
|
||||
变量名 <code>LIVE_RECORDER_OCCURRED_AT_UTC</code> 为兼容旧版本而保留,实际值使用北京时间(UTC+8)。
|
||||
</div>
|
||||
|
||||
<div class="event-script-help__intro">
|
||||
The official Docker image includes <code>curl</code> and <code>jq</code> by default. If you run on a host machine or a custom image, rely on the commands available in that environment.
|
||||
官方 Docker 镜像默认包含 <code>curl</code> 和 <code>jq</code>;宿主机或自定义镜像只能使用对应环境中已有的命令。
|
||||
</div>
|
||||
|
||||
<div class="event-script-help__intro">
|
||||
Set <code>Retry attempts</code> to <code>0</code> to disable automatic retries. Retry exhaustion failures are sent through the existing exception notification channel.
|
||||
将重试次数设为 <code>0</code> 可关闭自动重试;重试耗尽后会通过现有异常通知渠道告警。
|
||||
</div>
|
||||
|
||||
<div class="event-script-example">
|
||||
<div class="event-script-example__label">Environment variable examples</div>
|
||||
<div class="event-script-example__label">环境变量示例</div>
|
||||
<div class="event-script-example__grid">
|
||||
<div v-for="item in eventScriptEnvironmentExamples" :key="item.name" class="event-script-example__row">
|
||||
<code>{{ item.name }}</code>
|
||||
@@ -1983,7 +2026,7 @@ watch(
|
||||
|
||||
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
|
||||
<h3 class="section-title">Webhook 通知</h3>
|
||||
<p class="section-subtitle">Send fixed JSON POST payloads with custom headers. Exception notifications also cover low-storage stop events and event-script failures after retries are exhausted.</p>
|
||||
<p class="section-subtitle">使用自定义请求头发送 JSON POST;异常通知同时覆盖存储不足停录和脚本重试耗尽。</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
@@ -2044,7 +2087,7 @@ watch(
|
||||
</div>
|
||||
|
||||
<div class="action-strip">
|
||||
<div class="helper-text">The test sends a sample live_started payload, including sample event script output, using the current URL, headers, and timeout values from this form.</div>
|
||||
<div class="helper-text">测试会使用当前 URL、请求头和超时设置发送一条包含脚本输出示例的开播通知。</div>
|
||||
<el-button :loading="testingWebhook" :disabled="!canSendTestWebhook" @click="testWebhook">测试 Webhook</el-button>
|
||||
</div>
|
||||
|
||||
@@ -2056,7 +2099,7 @@ watch(
|
||||
|
||||
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
|
||||
<h3 class="section-title">邮件通知</h3>
|
||||
<p class="section-subtitle">Configure SMTP plus HTML templates for live-started and exception alerts. Exception notifications also cover low-storage stop events and event-script failures after retries are exhausted.</p>
|
||||
<p class="section-subtitle">配置 SMTP、开播和异常 HTML 模板;异常通知同时覆盖存储不足停录和脚本重试耗尽。</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
@@ -2071,7 +2114,7 @@ watch(
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="Live started alert">
|
||||
<el-form-item label="开播提醒">
|
||||
<el-switch v-model="form.notifyOnLiveStarted" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -2082,23 +2125,23 @@ watch(
|
||||
</el-col>
|
||||
|
||||
<el-col :span="8">
|
||||
<el-form-item label="SMTP Host">
|
||||
<el-form-item label="SMTP 主机">
|
||||
<el-input v-model="form.emailSmtpHost" placeholder="smtp.example.com" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="SMTP Port">
|
||||
<el-form-item label="SMTP 端口">
|
||||
<el-input-number v-model="form.emailSmtpPort" :min="1" :max="65535" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="Sender name">
|
||||
<el-form-item label="发件人名称">
|
||||
<el-input v-model="form.emailFromDisplayName" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="8">
|
||||
<el-form-item label="SMTP username">
|
||||
<el-form-item label="SMTP 用户名">
|
||||
<el-input v-model="form.emailUsername" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -2114,7 +2157,7 @@ watch(
|
||||
</el-col>
|
||||
|
||||
<el-col :span="24">
|
||||
<el-form-item label="Recipient list">
|
||||
<el-form-item label="收件人列表">
|
||||
<el-input
|
||||
v-model="form.emailToAddresses"
|
||||
type="textarea"
|
||||
@@ -2128,8 +2171,8 @@ watch(
|
||||
<div class="template-section">
|
||||
<div class="template-section__header">
|
||||
<div>
|
||||
<h4 class="template-section__title">Live started template</h4>
|
||||
<p class="template-section__subtitle">Subject templates render plain text, while the body template supports HTML and can include event script output placeholders.</p>
|
||||
<h4 class="template-section__title">开播通知模板</h4>
|
||||
<p class="template-section__subtitle">主题使用纯文本,正文支持 HTML 和事件脚本输出占位符。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2148,7 +2191,7 @@ watch(
|
||||
<div class="template-section__header">
|
||||
<div>
|
||||
<h4 class="template-section__title">异常提醒模板</h4>
|
||||
<p class="template-section__subtitle">Exception emails inject source, summary, detail, task context values, and optional event script output into the HTML body.</p>
|
||||
<p class="template-section__subtitle">异常邮件可在 HTML 正文中插入来源、摘要、详情、任务上下文和脚本输出。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2165,7 +2208,7 @@ watch(
|
||||
</el-form>
|
||||
|
||||
<div class="template-help">
|
||||
<div class="template-help__label">Available placeholders</div>
|
||||
<div class="template-help__label">可用占位符</div>
|
||||
<div class="token-list">
|
||||
<span v-for="token in emailTemplateTokens" :key="token" class="token-chip">{{ token }}</span>
|
||||
</div>
|
||||
@@ -2176,13 +2219,13 @@ watch(
|
||||
</div>
|
||||
|
||||
<div class="action-strip">
|
||||
<div class="helper-text">Sending a test email does not save settings. The email renders both the live-started and exception template examples.</div>
|
||||
<div class="helper-text">发送测试邮件不会保存设置;邮件会同时渲染开播和异常模板示例。</div>
|
||||
<el-button :loading="testingEmail" :disabled="!canSendTestEmail" @click="sendTestEmail">测试邮件</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="Security and platform" name="security">
|
||||
<el-tab-pane label="账户安全" name="account">
|
||||
|
||||
<el-card id="security" class="surface-card settings-card settings-grid__full" shadow="never">
|
||||
<h3 class="section-title">账号安全</h3>
|
||||
@@ -2208,9 +2251,12 @@ watch(
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="平台请求" name="platform">
|
||||
|
||||
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
|
||||
<h3 class="section-title">Platform request settings</h3>
|
||||
<p class="section-subtitle">Configure independent proxy, User-Agent, Referer, and Cookie values for each platform.</p>
|
||||
<h3 class="section-title">平台请求设置</h3>
|
||||
<p class="section-subtitle">为每个平台分别配置代理、User-Agent、Referer 和 Cookie。</p>
|
||||
|
||||
<div class="event-script-grid">
|
||||
<div
|
||||
@@ -2222,7 +2268,7 @@ watch(
|
||||
<div>
|
||||
<h4 class="event-script-section__title">{{ platform.label }}</h4>
|
||||
<p class="event-script-section__subtitle">
|
||||
These settings apply only to {{ platform.label }} status checks and stream requests.
|
||||
这些设置仅用于 {{ platform.label }} 的状态检查和直播流请求。
|
||||
</p>
|
||||
</div>
|
||||
<el-switch v-model="form.platformRequestSettings[platform.key].proxy.enabled" />
|
||||
@@ -2253,7 +2299,7 @@ watch(
|
||||
v-model="form.platformRequestSettings[platform.key].cookie"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="Optional cookies for this platform only"
|
||||
placeholder="仅用于该平台的可选 Cookie"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@@ -2288,12 +2334,13 @@ watch(
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
<div class="settings-savebar" :style="savebarStyle">
|
||||
<div v-if="isDirty" class="settings-savebar" :style="savebarStyle">
|
||||
<div class="settings-savebar__content">
|
||||
<div class="settings-savebar__copy">
|
||||
<div class="settings-savebar__title">当前修改不会自动保存</div>
|
||||
<div class="settings-savebar__subtitle">您可以在此页面任意位置保存当前设置。</div>
|
||||
<div class="settings-savebar__subtitle">保存后立即应用到录制和后台任务。</div>
|
||||
</div>
|
||||
<el-button :disabled="saving" @click="discardSettingsChanges">放弃修改</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="saveSettings">保存设置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2443,6 +2490,37 @@ watch(
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.settings-quickbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.settings-profile--compact {
|
||||
min-width: 220px;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.settings-quickbar__control {
|
||||
display: grid;
|
||||
grid-template-columns: auto 118px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-quickbar__switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-grid__full {
|
||||
grid-column: auto;
|
||||
}
|
||||
@@ -2497,6 +2575,55 @@ watch(
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.settings-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 168px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-tabs :deep(.el-tabs__header.is-left) {
|
||||
width: 168px;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
border-radius: 0;
|
||||
background: var(--surface-muted);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.settings-tabs :deep(.el-tabs__nav-wrap.is-left) {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.settings-tabs :deep(.el-tabs__item.is-left) {
|
||||
height: 40px;
|
||||
margin: 2px 0;
|
||||
padding: 0 12px;
|
||||
border-radius: 7px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.settings-tabs :deep(.el-tabs__item.is-left.is-active) {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.settings-tabs :deep(.el-tabs__content) {
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-card :deep(.el-card__body) {
|
||||
padding-top: 20px;
|
||||
}
|
||||
@@ -2929,6 +3056,27 @@ watch(
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.settings-quickbar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-profile--compact {
|
||||
grid-column: 1 / -1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-quickbar__control {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.settings-quickbar__switch {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.settings-card :deep(.el-col) {
|
||||
flex: 0 0 100%;
|
||||
max-width: 100%;
|
||||
|
||||
@@ -281,8 +281,10 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-skeleton v-if="loading" :rows="6" animated />
|
||||
|
||||
<EmptyState
|
||||
v-if="!loading && items.length === 0"
|
||||
v-else-if="items.length === 0"
|
||||
title="暂无上传任务"
|
||||
description="当前筛选条件下没有可展示的上传记录。"
|
||||
action-text="刷新列表"
|
||||
@@ -293,7 +295,6 @@ onBeforeUnmount(() => {
|
||||
<div class="table-scroll-shell">
|
||||
<el-table
|
||||
:data="items"
|
||||
v-loading="loading"
|
||||
class="premium-table upload-table"
|
||||
table-layout="auto"
|
||||
row-key="recordTaskId"
|
||||
@@ -425,20 +426,8 @@ onBeforeUnmount(() => {
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.upload-card :deep(.el-card__body) {
|
||||
|
||||
@@ -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