服务端媒体源导入/保存/客户端输出链路修复:支持 snake/camel、subcategories/sources,默认客户端可见,保存后发布兼容 media-types.json。
build-winui / winui (push) Has been cancelled

新增数据库同步 Job API、持久化状态、实时输出、最新任务恢复,以及系统日志聚合接口。
管理端优化:日志中心、运维实时状态框、同步输出自动滚动、仪表盘“输出”列、真实延迟空态、本地 favicon/avatar。
新增 server/unified-management/assets/favicon.ico 和 developer-avatar.png,并接好 /favicon.ico、/admin/favicon.ico、/setup/favicon.ico、/assets/*。
WinUI 随机放映室卡片优先显示子接口原始 Description。
Inno 安装器输出框改为选区末尾 + SendMessage 滚动到底部。
This commit is contained in:
QWQLwToo
2026-06-29 22:28:58 +08:00
parent f00124c1c0
commit 7745e7a2d4
36 changed files with 1482 additions and 153 deletions
+105 -13
View File
@@ -31,7 +31,7 @@ import { createSystemStore } from "./stores/system";
const DashboardView = defineAsyncComponent(() => import("./views/DashboardView.vue"));
type SystemTab = "database" | "migration" | "sync" | "security" | "health" | "audit";
type SystemTab = "database" | "migration" | "sync" | "security" | "health" | "logs" | "audit";
type ToastState = { message: string; type: "success" | "warn" | "error" };
type LoadSystemOptions = { preserveForms?: boolean };
type LoadDatabaseOptions = { previewLegacy?: boolean; preserveForm?: boolean };
@@ -64,6 +64,7 @@ const autoRefreshPaused = ref(false);
const databaseFormEditing = ref(false);
const mailConfigEditing = ref(false);
let refreshTimer: number | undefined;
let systemRefreshTimer: number | undefined;
let toastTimer: number | undefined;
let events: EventSource | null = null;
@@ -81,7 +82,7 @@ const { page: feedbackPage, selected: selectedFeedback, filters: feedbackFilters
const { releases, notices: releaseNotices, selectedNotice, noticeDraft, uploadDraft } = releaseStore;
const { sync: legacySync, documents: legacyDocuments, drafts: legacyDrafts, modal: legacyModal, activeMediaCategoryIndex } = legacyStore;
const { sources, endpoints, draft: sourceDraft } = sourceStore;
const { database, databaseConfig, databaseLastSync, healthSnapshot, auditLogs, auditPage, migrationStatus, branding, databaseForm, databaseConfigCollapsed, mailConfig, legacySyncMode } = systemStore;
const { database, databaseConfig, databaseLastSync, databaseSyncJob, databaseSyncOutput, healthSnapshot, auditLogs, auditPage, systemLogPage, migrationStatus, branding, databaseForm, databaseConfigCollapsed, mailConfig, legacySyncMode } = systemStore;
const routes: RouteItem[] = [
{ path: "/admin/dashboard", label: "仪表盘", description: "服务状态、接口心跳与运营指标", icon: LayoutDashboard },
@@ -136,7 +137,7 @@ const heartbeatChartRows = computed(() => {
status: labelStatus(item.status),
}))
.filter((item: any) => Number.isFinite(item.latency));
return rows.length ? rows : [{ time: Date.now(), label: "暂无", latency: 0, name: "暂无检测记录", status: "未检测" }];
return rows;
});
const isHeartbeatChartEmpty = computed(() => heartbeats.value.length === 0);
@@ -255,6 +256,8 @@ const viewContext = computed(() => ({
databaseFormEditing: databaseFormEditing.value,
databaseForm,
databaseLastSync: databaseLastSync.value,
databaseSyncJob: databaseSyncJob.value,
databaseSyncOutput: databaseSyncOutput.value,
databaseSyncStatusLabel,
databaseSyncDirectionLabel,
databaseSyncTableCount,
@@ -268,6 +271,7 @@ const viewContext = computed(() => ({
feedbackPage: feedbackPage.value,
feedbackUpdate,
formatBytes,
formatHealthOutput,
healthOption: healthOption.value,
healthSnapshot: healthSnapshot.value,
healthyEndpointCount: healthyEndpointCount.value,
@@ -290,6 +294,7 @@ const viewContext = computed(() => ({
loadBranding,
loadFeedbacks,
loadMigrationStatus,
loadSystemLogs,
mailConfig,
mailConfigEditing: mailConfigEditing.value,
markDatabaseFormEditing,
@@ -329,9 +334,12 @@ const viewContext = computed(() => ({
sourceDraft,
statusTone,
syncDatabase,
systemLogPage,
systemTab: systemTab.value,
setSystemTab,
setAuditPage,
setSystemLogPage,
selectSystemLog,
selectAuditLog,
testDatabase,
toggleAutoRefresh,
@@ -367,7 +375,7 @@ function normalizeAdminPath(value: string) {
function normalizeSystemTab(value: unknown): SystemTab {
const tab = Array.isArray(value) ? value[0] : value;
if (tab === "migration" || tab === "sync" || tab === "security" || tab === "health" || tab === "audit") return tab;
if (tab === "migration" || tab === "sync" || tab === "security" || tab === "health" || tab === "logs" || tab === "audit") return tab;
return "database";
}
@@ -485,6 +493,8 @@ async function loadSystem(options: LoadSystemOptions = {}) {
loadMailConfig({ preserveForm: options.preserveForms }),
loadHealth(),
loadAudit(),
loadSystemLogs(),
loadDatabaseSyncLatest(),
loadMigrationStatus(),
loadBranding(),
]);
@@ -977,6 +987,10 @@ async function loadDatabase(options: LoadDatabaseOptions = {}) {
const data = await api<{ database: any; config?: any }>("/api/admin/database/status");
database.value = data.database;
databaseConfig.value = data.config || null;
if (data.database?.currentSyncJob) {
databaseSyncJob.value = data.database.currentSyncJob;
databaseSyncOutput.value = data.database.currentSyncJob.output || [];
}
if (!options.preserveForm || !databaseFormEditing.value) {
applyDatabaseConfig(data.config || {}, data.database || {});
databaseFormEditing.value = false;
@@ -1036,18 +1050,44 @@ async function saveDatabase() {
async function syncDatabase(direction: "import" | "sync") {
await guarded(async () => {
const data = await api<{ result?: any; finishedAt?: string }>(direction === "import" ? "/api/admin/database/import-sqlite" : "/api/admin/database/sync", { method: "POST", body: "{}" });
databaseLastSync.value = data.result || { direction: direction === "import" ? "sqlite_to_remote" : "remote_to_sqlite", finishedAt: data.finishedAt };
const result = databaseLastSync.value || {};
if (result.skipped) {
setToast(result.warnings?.[0] || "远端 MySQL 未配置,同步已跳过", "warn");
} else {
setToast(direction === "import" ? "SQLite 已导入远端库" : "远端库已同步回本地");
}
await loadDatabase({ previewLegacy: false });
const payload = { direction: direction === "import" ? "sqlite_to_remote" : "remote_to_sqlite" };
const data = await api<{ jobId: number; job: any }>("/api/admin/database/sync/jobs", { method: "POST", body: JSON.stringify(payload) });
databaseSyncJob.value = data.job;
databaseLastSync.value = data.job;
databaseSyncOutput.value = data.job?.output || [];
setToast("数据库同步任务已启动");
await pollDatabaseSyncJob(data.jobId || data.job?.id);
await Promise.all([loadDatabase({ previewLegacy: false, preserveForm: true }), loadSystemLogs()]);
});
}
async function loadDatabaseSyncLatest() {
const data = await api<{ job: any | null }>("/api/admin/database/sync/jobs/latest");
if (data.job) {
databaseSyncJob.value = data.job;
databaseLastSync.value = data.job;
databaseSyncOutput.value = data.job.output || [];
}
}
async function pollDatabaseSyncJob(id: number) {
if (!id) return;
for (let index = 0; index < 120; index += 1) {
const data = await api<{ job: any }>(`/api/admin/database/sync/jobs/${id}`);
databaseSyncJob.value = data.job;
databaseLastSync.value = data.job;
databaseSyncOutput.value = data.job?.output || [];
if (!data.job || data.job.status !== "running") {
if (data.job?.status === "failed") setToast(data.job.errors?.[0] || "数据库同步失败", "error");
else if (data.job?.status === "skipped") setToast(data.job.warnings?.[0] || "数据库同步已跳过", "warn");
else setToast("数据库同步已完成");
return;
}
await delay(1000);
}
setToast("数据库同步仍在执行,输出会继续随状态刷新", "warn");
}
function editDatabaseConfig() {
databaseFormEditing.value = true;
databaseConfigCollapsed.value = false;
@@ -1228,6 +1268,32 @@ function selectAuditLog(item: any) {
auditPage.selected = item;
}
async function loadSystemLogs() {
const params = new URLSearchParams({
page: String(systemLogPage.page || 1),
perPage: String(systemLogPage.perPage || 35),
});
if (systemLogPage.q) params.set("q", systemLogPage.q);
if (systemLogPage.category) params.set("category", systemLogPage.category);
const data = await api<{ items: any[]; page?: any }>(`/api/admin/system/logs?${params}`);
const page = data.page || { items: data.items || [], total: data.items?.length || 0, page: systemLogPage.page, perPage: systemLogPage.perPage };
Object.assign(systemLogPage, {
items: page.items || [],
total: Number(page.total || 0),
page: Number(page.page || systemLogPage.page || 1),
perPage: Number(page.perPage || systemLogPage.perPage || 35),
});
}
function setSystemLogPage(page: number) {
systemLogPage.page = Math.max(1, page);
void loadSystemLogs();
}
function selectSystemLog(item: any) {
systemLogPage.selected = item;
}
async function changePassword() {
await guarded(async () => {
const data = await api<{ isDefaultPassword: boolean; warning?: string }>("/api/admin/auth/password", { method: "POST", body: JSON.stringify(passwordForm) });
@@ -1338,6 +1404,24 @@ function databaseSyncTableCount(result: any) {
return Object.values(tables).reduce((total: number, value: any) => total + Number(value || 0), 0);
}
function formatHealthOutput(item: any) {
const raw = String(item?.error || item?.lastError || "").trim();
if (!raw) return "-";
try {
const meta = JSON.parse(raw);
const parts = [
meta.finalStatus ? `状态 ${meta.finalStatus}` : "",
meta.finalUrl ? `最终 URL ${meta.finalUrl}` : "",
meta.resolvedUrl ? `媒体 ${meta.resolvedUrl}` : "",
meta.mediaType ? `类型 ${meta.mediaType}` : "",
meta.error ? `错误 ${meta.error}` : "",
].filter(Boolean);
return parts.length ? parts.join(" / ") : raw;
} catch {
return raw;
}
}
function formatBytes(value: number) {
if (!Number.isFinite(value) || value <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
@@ -1389,12 +1473,19 @@ function splitList(value: string) {
.filter(Boolean);
}
function delay(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
onMounted(() => {
localStorage.removeItem("ymhut.csrf");
void load();
refreshTimer = window.setInterval(() => {
if (!autoRefreshPaused.value && currentPath.value === "/admin/dashboard" && csrf.value) void loadDashboard();
}, 15000);
systemRefreshTimer = window.setInterval(() => {
if (!autoRefreshPaused.value && currentPath.value === "/admin/system" && csrf.value) void loadSystem({ preserveForms: true });
}, 60000);
});
watch(currentPath, () => {
@@ -1403,6 +1494,7 @@ watch(currentPath, () => {
onUnmounted(() => {
if (refreshTimer) window.clearInterval(refreshTimer);
if (systemRefreshTimer) window.clearInterval(systemRefreshTimer);
events?.close();
events = null;
});
@@ -4,6 +4,8 @@ export function createSystemStore() {
const database = ref<any>(null);
const databaseConfig = ref<any>(null);
const databaseLastSync = ref<any>(null);
const databaseSyncJob = ref<any>(null);
const databaseSyncOutput = ref<string[]>([]);
const healthSnapshot = ref<any>(null);
const auditLogs = ref<any[]>([]);
const auditPage = reactive({
@@ -16,10 +18,19 @@ export function createSystemStore() {
target: "",
selected: null as any | null,
});
const systemLogPage = reactive({
items: [] as any[],
total: 0,
page: 1,
perPage: 35,
q: "",
category: "",
selected: null as any | null,
});
const migrationStatus = ref<any>(null);
const branding = reactive({
siteIconUrl: "https://img.ymhut.cn/file/1782108850041_icon.webp",
developerAvatarUrl: "https://img.ymhut.cn/file/1782108780690_b_3db45f3787f19192c8de8e06bc0987ef.webp",
siteIconUrl: "/assets/favicon.ico",
developerAvatarUrl: "/assets/developer-avatar.png",
developerName: "YMhut",
feedbackEmail: "support@ymhut.cn",
});
@@ -49,5 +60,5 @@ export function createSystemStore() {
});
const legacySyncMode = ref<"preview" | "run">("preview");
return { database, databaseConfig, databaseLastSync, healthSnapshot, auditLogs, auditPage, migrationStatus, branding, databaseForm, databaseConfigCollapsed, mailConfig, legacySyncMode };
return { database, databaseConfig, databaseLastSync, databaseSyncJob, databaseSyncOutput, healthSnapshot, auditLogs, auditPage, systemLogPage, migrationStatus, branding, databaseForm, databaseConfigCollapsed, mailConfig, legacySyncMode };
}
@@ -433,6 +433,48 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
overflow-wrap: anywhere;
font-size: 18px;
}
.runtime-status {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
}
.runtime-status div {
min-width: 0;
border: 1px solid var(--line);
border-radius: 8px;
background: #f8fafc;
padding: 10px 12px;
}
.runtime-status span {
display: block;
color: var(--muted);
font-size: 12px;
margin-bottom: 4px;
}
.runtime-status strong {
display: block;
overflow-wrap: anywhere;
}
.sync-output-panel {
display: grid;
gap: 10px;
margin-top: 12px;
}
.sync-output {
min-height: 180px;
max-height: 260px;
overflow: auto;
margin: 0;
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: #0f172a;
color: #e2e8f0;
font-family: "JetBrains Mono", "Cascadia Code", Consolas, monospace;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
}
.ops-note {
display: flex;
gap: 8px;
@@ -64,13 +64,13 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
<section class="panel">
<div class="section-head"><h2>最近服务端检测</h2><span class="badge">{{ ctx.heartbeats.length }} </span></div>
<table>
<thead><tr><th>接口</th><th>状态</th><th>延迟</th><th>错误</th><th>时间</th></tr></thead>
<thead><tr><th>接口</th><th>状态</th><th>延迟</th><th>输出</th><th>时间</th></tr></thead>
<tbody>
<tr v-for="item in ctx.heartbeats.slice(0, 10)" :key="item.id">
<td>{{ item.name || item.sourceId }}</td>
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ ctx.labelStatus(item.status) }}</span></td>
<td>{{ item.latencyMs || 0 }}ms</td>
<td class="hash">{{ item.error || "-" }}</td>
<td class="hash">{{ ctx.formatHealthOutput(item) }}</td>
<td>{{ item.checkedAt || "-" }}</td>
</tr>
<tr v-if="ctx.heartbeats.length === 0"><td colspan="5">暂无检测记录点击立即服务端检测后会刷新</td></tr>
@@ -9,13 +9,14 @@ defineProps<{ ctx: any }>();
<div v-for="cat in ctx.sourceCategories" :key="cat.id || cat.name" class="source-group">
<h3>{{ cat.name || cat.id }} <span class="badge">{{ cat.subcategories?.length || 0 }}</span></h3>
<table>
<thead><tr><th>名称</th><th>模式</th><th>状态</th><th>延迟</th><th>URL</th></tr></thead>
<thead><tr><th>名称</th><th>描述</th><th>模式</th><th>状态</th><th>延迟</th><th>URL</th></tr></thead>
<tbody>
<tr v-for="src in cat.subcategories || []" :key="src.id || src.sourceId">
<td>{{ src.name }}</td>
<td class="hash">{{ src.description || "-" }}</td>
<td>{{ src.proxyMode || src.proxy_mode || "client_direct" }}</td>
<td><span :class="['badge', ctx.statusTone(src.health?.status || src.lastStatus)]">{{ src.health?.status || src.lastStatus || "unknown" }}</span></td>
<td>{{ src.health?.latency_ms || src.lastLatencyMs || 0 }}ms</td>
<td>{{ src.health?.latency_ms ?? src.lastLatencyMs ?? 0 }}ms</td>
<td class="hash">{{ src.api_url || src.urlTemplate || src.apiUrl }}</td>
</tr>
</tbody>
@@ -1,7 +1,18 @@
<script setup lang="ts">
import { nextTick, ref, watch } from "vue";
import { Activity, AlertTriangle, ArrowDownUp, Clock3, Database, HardDrive, KeyRound, ListChecks, Mail, RefreshCw, Save, ShieldCheck, UserRound } from "lucide-vue-next";
defineProps<{ ctx: any }>();
const props = defineProps<{ ctx: any }>();
const syncOutputRef = ref<HTMLElement | null>(null);
watch(
() => props.ctx.databaseSyncOutput?.join("\n"),
async () => {
await nextTick();
if (syncOutputRef.value) syncOutputRef.value.scrollTop = syncOutputRef.value.scrollHeight;
},
{ flush: "post" },
);
const tabs = [
{ id: "database", label: "数据库", icon: Database },
@@ -11,6 +22,7 @@ const tabs = [
{ id: "health", label: "健康快照", icon: Activity },
{ id: "audit", label: "审计日志", icon: ListChecks },
];
tabs.splice(tabs.length - 1, 0, { id: "logs", label: "日志中心", icon: ListChecks });
</script>
<template>
@@ -40,6 +52,16 @@ const tabs = [
<span>最近错误</span><strong>{{ ctx.database?.lastSyncError || ctx.database?.lastError || "-" }}</strong>
</div>
<div class="runtime-status">
<div><span>Active provider</span><strong>{{ ctx.database?.activeProvider || "-" }}</strong></div>
<div><span>SQLite</span><strong>{{ ctx.database?.sqliteReady ? "ready" : "missing" }}</strong></div>
<div><span>MySQL</span><strong>{{ ctx.database?.remoteReady ? "ready" : "offline" }}</strong></div>
<div><span>Failover</span><strong>{{ ctx.database?.failoverActive ? "active" : "standby" }}</strong></div>
<div><span>最近检测</span><strong>{{ ctx.database?.lastHealthCheckedAt || "-" }}</strong></div>
<div><span>检测状态</span><strong>{{ ctx.database?.lastHealthStatus || "-" }}</strong></div>
<div><span>当前同步</span><strong>{{ ctx.databaseSyncJob?.status === "running" ? ctx.databaseSyncDirectionLabel(ctx.databaseSyncJob.direction) : "idle" }}</strong></div>
</div>
<div class="sync-summary">
<div><span><ArrowDownUp :size="15" />最近方向</span><strong>{{ ctx.databaseSyncDirectionLabel(ctx.databaseLastSync?.direction) }}</strong></div>
<div><span><ListChecks :size="15" />最近状态</span><strong>{{ ctx.databaseSyncStatusLabel(ctx.databaseLastSync?.status) }}</strong></div>
@@ -89,8 +111,15 @@ const tabs = [
<div class="button-row">
<button class="btn ghost" @click="ctx.testDatabase">测试连接</button>
<button class="btn primary" @click="ctx.saveDatabase"><Save :size="16" />测试并保存</button>
<button class="btn ghost" @click="ctx.syncDatabase('import')">SQLite -> MySQL</button>
<button class="btn ghost" @click="ctx.syncDatabase('sync')">MySQL -> SQLite</button>
<button class="btn ghost" :disabled="ctx.databaseSyncJob?.status === 'running'" @click="ctx.syncDatabase('import')">SQLite -> MySQL</button>
<button class="btn ghost" :disabled="ctx.databaseSyncJob?.status === 'running'" @click="ctx.syncDatabase('sync')">MySQL -> SQLite</button>
</div>
<div class="sync-output-panel">
<div class="section-head">
<h2>操作输出</h2>
<span :class="['badge', ctx.statusTone(ctx.databaseSyncJob?.status)]">{{ ctx.databaseSyncStatusLabel(ctx.databaseSyncJob?.status) }}</span>
</div>
<pre ref="syncOutputRef" class="sync-output">{{ (ctx.databaseSyncOutput || []).join("\n") || "等待同步任务输出。" }}</pre>
</div>
</aside>
</section>
@@ -214,6 +243,56 @@ const tabs = [
<pre class="json-preview tall">{{ ctx.pretty(ctx.healthSnapshot) }}</pre>
</section>
<section v-else-if="ctx.systemTab === 'logs'" class="panel page-stack">
<div class="section-head">
<h2>日志中心</h2>
<button class="btn ghost" @click="ctx.loadSystemLogs">刷新</button>
</div>
<div class="toolbar">
<select v-model="ctx.systemLogPage.category" @change="ctx.systemLogPage.page = 1; ctx.loadSystemLogs()">
<option value="">全部分类</option>
<option value="operation">操作审计</option>
<option value="health">接口健康</option>
<option value="client">客户端调用</option>
<option value="database_sync">数据库同步</option>
<option value="legacy_sync">旧项目同步</option>
</select>
<input v-model="ctx.systemLogPage.q" placeholder="搜索类型、目标、状态或内容" @keyup.enter="ctx.systemLogPage.page = 1; ctx.loadSystemLogs()" />
<button class="btn ghost" @click="ctx.systemLogPage.page = 1; ctx.loadSystemLogs()">筛选</button>
</div>
<table>
<thead><tr><th>分类</th><th>类型</th><th>状态</th><th>目标</th><th>内容</th><th>时间</th></tr></thead>
<tbody>
<tr v-for="item in ctx.systemLogPage.items" :key="`${item.category}-${item.id}`" class="clickable" @click="ctx.selectSystemLog(item)">
<td><span class="badge neutral">{{ item.category }}</span></td>
<td>{{ item.type || "-" }}</td>
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ ctx.labelStatus(item.status) }}</span></td>
<td>{{ item.target || "-" }}</td>
<td class="hash">{{ item.message || item.detail || "-" }}</td>
<td>{{ item.createdAt || "-" }}</td>
</tr>
<tr v-if="ctx.systemLogPage.items.length === 0"><td colspan="6">暂无系统日志</td></tr>
</tbody>
</table>
<div class="pager">
<button class="btn ghost compact" :disabled="ctx.systemLogPage.page <= 1" @click="ctx.setSystemLogPage(ctx.systemLogPage.page - 1)">上一页</button>
<span> {{ ctx.systemLogPage.page }} / {{ Math.max(1, Math.ceil(ctx.systemLogPage.total / ctx.systemLogPage.perPage)) }} {{ ctx.systemLogPage.total }} </span>
<button class="btn ghost compact" :disabled="ctx.systemLogPage.page >= Math.ceil(ctx.systemLogPage.total / ctx.systemLogPage.perPage)" @click="ctx.setSystemLogPage(ctx.systemLogPage.page + 1)">下一页</button>
</div>
<Teleport to="body">
<div v-if="ctx.systemLogPage.selected" class="modal-backdrop" @click.self="ctx.systemLogPage.selected = null">
<section class="modal-panel">
<div class="section-head">
<h2>日志详情</h2>
<button class="btn ghost compact" @click="ctx.systemLogPage.selected = null">关闭</button>
</div>
<pre class="json-preview tall">{{ ctx.pretty(ctx.systemLogPage.selected) }}</pre>
</section>
</div>
</Teleport>
</section>
<section v-else class="panel page-stack">
<div class="section-head">
<h2>审计日志</h2>