继续更新 update 门户站点界面和功能
build-winui / winui (push) Has been cancelled

This commit is contained in:
QWQLwToo
2026-06-26 20:17:34 +08:00
parent f525e5f3ba
commit 2513eb2903
68 changed files with 5586 additions and 3195 deletions
+133 -111
View File
@@ -1,35 +1,37 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
ArrowDownToLine,
CheckCircle2,
ClipboardList,
Code2,
Database,
FileJson,
HeartPulse,
LayoutDashboard,
ListChecks,
LogOut,
MessageSquareText,
Network,
RefreshCw,
Settings,
ShieldCheck,
} from "lucide-vue-next";
import AuditView from "./views/AuditView.vue";
import DashboardView from "./views/DashboardView.vue";
import DatabaseView from "./views/DatabaseView.vue";
import EndpointsView from "./views/EndpointsView.vue";
import FeedbacksView from "./views/FeedbacksView.vue";
import HealthView from "./views/HealthView.vue";
import LegacyJsonView from "./views/LegacyJsonView.vue";
import ReleasesView from "./views/ReleasesView.vue";
import SettingsView from "./views/SettingsView.vue";
import SourcesView from "./views/SourcesView.vue";
import SystemView from "./views/SystemView.vue";
import { adminFetch, toChineseError, uploadAdminFile } from "./api/admin";
import { createAuthStore } from "./stores/auth";
import { createDashboardStore } from "./stores/dashboard";
import { createFeedbackStore } from "./stores/feedback";
import { createLegacyStore, type LegacyName } from "./stores/legacy";
import { createReleaseStore } from "./stores/releases";
import { createSourceStore } from "./stores/sources";
import { createSystemStore } from "./stores/system";
type LegacyName = "update-info" | "media-types";
const DashboardView = defineAsyncComponent(() => import("./views/DashboardView.vue"));
type SystemTab = "database" | "sync" | "security" | "health" | "audit";
type ToastState = { message: string; type: "success" | "warn" | "error" };
type Captcha = {
@@ -50,7 +52,6 @@ type RouteItem = {
icon: any;
};
const csrf = ref(localStorage.getItem("ymhut.csrf") || "");
const route = useRoute();
const router = useRouter();
const currentPath = computed(() => normalizeAdminPath(route.path));
@@ -61,62 +62,21 @@ let refreshTimer: number | undefined;
let toastTimer: number | undefined;
let events: EventSource | null = null;
const captcha = ref<Captcha | null>(null);
const authBootstrap = ref<AuthBootstrap | null>(null);
const dashboard = ref<any>({});
const sourceCheckJobs = ref<any[]>([]);
const feedbackPage = ref<any>({ items: [], total: 0, page: 1, perPage: 20 });
const selectedFeedback = ref<any | null>(null);
const releases = ref<any>(null);
const releaseNotices = ref<any[]>([]);
const selectedNotice = ref<any | null>(null);
const sources = ref<any>({ categories: [] });
const endpoints = ref<any[]>([]);
const database = ref<any>(null);
const healthSnapshot = ref<any>(null);
const auditLogs = ref<any[]>([]);
const legacySync = ref<any>(null);
const legacyDocuments = reactive<Record<LegacyName, any | null>>({ "update-info": null, "media-types": null });
const authStore = createAuthStore();
const dashboardStore = createDashboardStore();
const feedbackStore = createFeedbackStore();
const releaseStore = createReleaseStore();
const legacyStore = createLegacyStore();
const sourceStore = createSourceStore();
const systemStore = createSystemStore();
const loginForm = reactive({ username: "admin", password: "", captcha: "" });
const passwordForm = reactive({ currentPassword: "", newPassword: "" });
const feedbackFilters = reactive({ q: "", status: "", page: 1, perPage: 20 });
const feedbackUpdate = reactive({ status: "", statusDetail: "", publicReply: "" });
const commentDraft = reactive({ body: "", internal: true });
const databaseForm = reactive({ provider: "sqlite", sqlitePath: "", mysqlDsn: "" });
const sourceDraft = reactive({
sourceId: "",
categoryId: "custom",
categoryName: "自定义接口",
name: "",
description: "",
method: "GET",
apiUrl: "",
urlTemplate: "",
thumbnailUrl: "",
proxyMode: "client_direct",
timeoutMs: 8000,
retryCount: 1,
cacheSeconds: 300,
checkIntervalSec: 300,
enabled: true,
clientVisible: true,
supportedFormats: "[\"json\"]",
});
const legacyDrafts = reactive<Record<LegacyName, { raw: string; note: string; preview: any | null; tab: "form" | "raw" | "preview" | "history"; form: any }>>({
"update-info": { raw: "", note: "", preview: null, tab: "form", form: {} },
"media-types": { raw: "", note: "", preview: null, tab: "form", form: { categories: [] } },
});
const noticeDraft = reactive({ version: "", raw: "", note: "", preview: null as any });
const uploadDraft = reactive({
file: null as File | null,
version: "",
platform: "windows",
arch: "x64",
channel: "stable",
notes: "",
updateManifest: true,
});
const { csrf, captcha, bootstrap: authBootstrap, loginForm, passwordForm } = authStore;
const { dashboard, sourceCheckJobs } = dashboardStore;
const { page: feedbackPage, selected: selectedFeedback, filters: feedbackFilters, update: feedbackUpdate, commentDraft } = feedbackStore;
const { releases, notices: releaseNotices, selectedNotice, noticeDraft, uploadDraft } = releaseStore;
const { sync: legacySync, documents: legacyDocuments, drafts: legacyDrafts } = legacyStore;
const { sources, endpoints, draft: sourceDraft } = sourceStore;
const { database, databaseLastSync, healthSnapshot, auditLogs, databaseForm, legacySyncMode } = systemStore;
const routes: RouteItem[] = [
{ path: "/admin/dashboard", label: "仪表盘", description: "服务状态、接口心跳与运营指标", icon: LayoutDashboard },
@@ -126,10 +86,7 @@ const routes: RouteItem[] = [
{ path: "/admin/legacy/media-types", label: "媒体源 JSON", description: "维护旧客户端媒体源结构", icon: ClipboardList },
{ path: "/admin/sources", label: "来源目录", description: "媒体/数据源目录和健康检测", icon: Network },
{ path: "/admin/endpoints", label: "客户端接口", description: "新版客户端动态接口配置", icon: Code2 },
{ path: "/admin/database", label: "数据库与同步", description: "SQLite、MySQL 和旧项目同步", icon: Database },
{ path: "/admin/health", label: "健康快照", description: "服务端运行状态和预检信息", icon: HeartPulse },
{ path: "/admin/settings", label: "系统设置", description: "密码与旧库同步入口", icon: Settings },
{ path: "/admin/audit", label: "审计日志", description: "后台操作和同步记录", icon: ListChecks },
{ path: "/admin/system", label: "系统运维", description: "数据库、旧项目同步、安全、健康与审计", icon: Database },
];
const navGroups = [
@@ -137,17 +94,7 @@ const navGroups = [
{ label: "反馈", items: routes.filter((item) => ["/admin/feedbacks"].includes(item.path)) },
{ label: "发布与兼容", items: routes.filter((item) => ["/admin/releases", "/admin/legacy/update-info", "/admin/legacy/media-types"].includes(item.path)) },
{ label: "客户端接口", items: routes.filter((item) => ["/admin/sources", "/admin/endpoints"].includes(item.path)) },
{ label: "系统运维", items: routes.filter((item) => ["/admin/database", "/admin/health", "/admin/settings", "/admin/audit"].includes(item.path)) },
];
const quickActions = [
{ path: "/admin/feedbacks", label: "反馈处理", description: "查看和处理客户端反馈工单", icon: MessageSquareText },
{ path: "/admin/releases", label: "发布与日志", description: "维护发布包和 update-notice", icon: ArrowDownToLine },
{ path: "/admin/legacy/update-info", label: "更新 JSON", description: "编辑旧版 update-info.json", icon: FileJson },
{ path: "/admin/legacy/media-types", label: "媒体源 JSON", description: "同步旧客户端媒体源结构", icon: ClipboardList },
{ path: "/admin/sources", label: "接口源目录", description: "新增接口并执行健康检测", icon: Network },
{ path: "/admin/database", label: "数据库同步", description: "管理 SQLite/MySQL 和旧项目同步", icon: Database },
{ path: "/admin/audit", label: "审计日志", description: "查看后台操作与同步记录", icon: ListChecks },
{ label: "系统运维", items: routes.filter((item) => ["/admin/system"].includes(item.path)) },
];
const pageMeta = computed(() => routes.find((item) => item.path === currentPath.value) || routes[0]);
@@ -167,6 +114,7 @@ const visibleEndpointCount = computed(() => endpoints.value.filter((item) => ite
const healthyEndpointCount = computed(() => endpoints.value.filter((item) => ["ok", "redirected"].includes(endpointStatus(item))).length);
const latestNotice = computed(() => releaseNotices.value[0] || null);
const activeLegacyLabel = computed(() => activeLegacyName.value === "media-types" ? "media-types.json" : "update-info.json");
const systemTab = computed<SystemTab>(() => normalizeSystemTab(route.query.tab));
const heartbeatOption = computed(() => ({
tooltip: { trigger: "axis" },
@@ -261,6 +209,9 @@ const viewContext = computed(() => ({
copyEndpointToSource,
database: database.value,
databaseForm,
databaseLastSync: databaseLastSync.value,
databaseSyncDirectionLabel,
databaseSyncTableCount,
endpointStatus,
endpoints: endpoints.value,
feedbackFilters,
@@ -280,6 +231,7 @@ const viewContext = computed(() => ({
legacyDocuments,
legacyDrafts,
legacySync: legacySync.value,
legacySyncMode: legacySyncMode.value,
loadAudit,
loadFeedbacks,
navigate,
@@ -291,7 +243,6 @@ const viewContext = computed(() => ({
pretty,
previewLegacySync,
removeItem,
quickActions,
releaseNotices: releaseNotices.value,
releasePackages: releasePackages.value,
releases: releases.value,
@@ -309,6 +260,8 @@ const viewContext = computed(() => ({
sourceDraft,
statusTone,
syncDatabase,
systemTab: systemTab.value,
setSystemTab,
testDatabase,
toggleAutoRefresh,
updateLegacyRawFromForm,
@@ -322,21 +275,26 @@ const viewContext = computed(() => ({
}));
async function api<T>(target: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
if (!headers.has("Content-Type") && init.body && !(init.body instanceof FormData)) headers.set("Content-Type", "application/json");
if (csrf.value) headers.set("X-CSRF-Token", csrf.value);
const res = await fetch(target, { ...init, headers, credentials: "include" });
const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) throw new Error(data.message || data.error || `HTTP ${res.status}`);
return data as T;
return adminFetch<T>(target, init, { csrf: csrf.value });
}
function uploadWithProgress<T>(target: string, form: FormData, onProgress: (loaded: number, total: number) => void): Promise<T> {
return uploadAdminFile<T>(target, form, { csrf: csrf.value }, (progress) => onProgress(progress.loaded, progress.total));
}
function normalizeAdminPath(value: string) {
if (value === "/admin" || value === "/admin/") return "/admin/dashboard";
if (value === "/") return "/admin/dashboard";
if (["/admin/database", "/admin/health", "/admin/settings", "/admin/audit"].includes(value)) return "/admin/system";
return value;
}
function normalizeSystemTab(value: unknown): SystemTab {
const tab = Array.isArray(value) ? value[0] : value;
if (tab === "sync" || tab === "security" || tab === "health" || tab === "audit") return tab;
return "database";
}
function navigate(next: string) {
if (currentPath.value === next) {
void load();
@@ -345,6 +303,10 @@ function navigate(next: string) {
void router.push(next);
}
function setSystemTab(tab: SystemTab) {
void router.replace({ path: "/admin/system", query: tab === "database" ? {} : { tab } });
}
function toggleAutoRefresh() {
autoRefreshPaused.value = !autoRefreshPaused.value;
}
@@ -362,9 +324,15 @@ async function guarded(task: () => Promise<void>) {
try {
await task();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const rawMessage = error instanceof Error ? error.message : String(error);
const message = toChineseError(rawMessage);
setToast(message, "error");
if (message.includes("Login required") || message.includes("UNAUTHORIZED")) {
if (isAuthError(rawMessage, message)) {
csrf.value = "";
sessionStorage.removeItem("ymhut.csrf");
localStorage.removeItem("ymhut.csrf");
events?.close();
events = null;
navigate("/admin/login");
}
} finally {
@@ -372,6 +340,11 @@ async function guarded(task: () => Promise<void>) {
}
}
function isAuthError(raw: string, message: string) {
const text = `${raw} ${message}`.toLowerCase();
return text.includes("unauthorized") || text.includes("login required") || text.includes("401") || message.includes("需要登录");
}
async function loadCaptcha() {
captcha.value = await api<Captcha>("/api/admin/auth/captcha");
}
@@ -387,7 +360,8 @@ async function login() {
body: JSON.stringify({ ...loginForm, captchaId: captcha.value?.captchaId }),
});
csrf.value = data.csrfToken;
localStorage.setItem("ymhut.csrf", csrf.value);
sessionStorage.setItem("ymhut.csrf", csrf.value);
localStorage.removeItem("ymhut.csrf");
connectAdminEvents();
navigate("/admin/dashboard");
});
@@ -396,6 +370,7 @@ async function login() {
async function logout() {
await api("/api/admin/auth/logout", { method: "POST", body: "{}" }).catch(() => undefined);
csrf.value = "";
sessionStorage.removeItem("ymhut.csrf");
localStorage.removeItem("ymhut.csrf");
events?.close();
events = null;
@@ -408,17 +383,19 @@ async function load() {
await Promise.all([loadAuthBootstrap(), loadCaptcha()]);
return;
}
if (!csrf.value) {
navigate("/admin/login");
return;
}
if (currentPath.value === "/admin/dashboard") await loadDashboard();
if (currentPath.value === "/admin/feedbacks") await loadFeedbacks();
if (currentPath.value === "/admin/releases") await loadReleases();
if (currentPath.value === "/admin/sources") await loadSources();
if (currentPath.value === "/admin/endpoints") await loadEndpoints();
if (currentPath.value === "/admin/database") await loadDatabase();
if (currentPath.value === "/admin/health") await loadHealth();
if (currentPath.value === "/admin/audit") await loadAudit();
if (currentPath.value === "/admin/settings") await previewLegacySync();
if (currentPath.value === "/admin/system") await loadSystem();
const legacyName = activeLegacyName.value;
if (legacyName) await loadLegacy(legacyName);
connectAdminEvents();
});
}
@@ -426,6 +403,10 @@ async function loadDashboard() {
dashboard.value = await api("/api/admin/dashboard/overview?window=24h");
}
async function loadSystem() {
await Promise.all([loadDatabase(), loadHealth(), loadAudit()]);
}
async function loadFeedbacks() {
const params = new URLSearchParams({ page: String(feedbackFilters.page), perPage: String(feedbackFilters.perPage) });
if (feedbackFilters.q) params.set("q", feedbackFilters.q);
@@ -540,6 +521,10 @@ async function loadLegacy(name: LegacyName) {
function onPackageSelected(event: Event) {
const input = event.target as HTMLInputElement;
uploadDraft.file = input.files?.[0] || null;
uploadDraft.progress = 0;
uploadDraft.loadedBytes = 0;
uploadDraft.totalBytes = uploadDraft.file?.size || 0;
uploadDraft.status = uploadDraft.file ? "等待上传" : "";
if (uploadDraft.file && !uploadDraft.version) {
const version = uploadDraft.file.name.match(/\d+\.\d+\.\d+(?:\.\d+)?/)?.[0];
if (version) uploadDraft.version = version;
@@ -560,11 +545,33 @@ async function uploadPackage() {
form.append("channel", uploadDraft.channel);
form.append("notes", uploadDraft.notes);
form.append("updateManifest", String(uploadDraft.updateManifest));
await api("/api/admin/releases/packages", { method: "POST", body: form, headers: {} });
uploadDraft.uploading = true;
uploadDraft.status = "正在上传";
uploadDraft.progress = 0;
uploadDraft.loadedBytes = 0;
uploadDraft.totalBytes = uploadDraft.file?.size || 0;
await uploadWithProgress("/api/admin/releases/packages", form, (loaded, total) => {
uploadDraft.loadedBytes = loaded;
uploadDraft.totalBytes = total;
uploadDraft.progress = total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : 0;
uploadDraft.status = uploadDraft.progress >= 100 ? "服务端处理中" : "正在上传";
});
uploadDraft.progress = 100;
uploadDraft.status = "上传完成";
uploadDraft.file = null;
uploadDraft.notes = "";
setToast("发布包已上传并放入下载目录");
await loadReleases();
window.setTimeout(() => {
if (!uploadDraft.uploading) {
uploadDraft.progress = 0;
uploadDraft.loadedBytes = 0;
uploadDraft.totalBytes = 0;
uploadDraft.status = "";
}
}, 1200);
}).finally(() => {
uploadDraft.uploading = false;
});
}
@@ -727,6 +734,7 @@ async function checkSources() {
setToast(`接口心跳检测已进入队列:${data.jobId}`);
if (currentPath.value === "/admin/dashboard") await loadDashboard();
if (currentPath.value === "/admin/sources") await loadSources();
if (currentPath.value === "/admin/system") await loadSystem();
});
}
@@ -759,11 +767,11 @@ function copyEndpointToSource(item: any) {
navigate("/admin/sources");
}
async function loadDatabase() {
async function loadDatabase(options: { previewLegacy?: boolean } = {}) {
const data = await api<{ database: any }>("/api/admin/database/status");
database.value = data.database;
databaseForm.provider = data.database?.configProvider || "sqlite";
await previewLegacySync();
if (options.previewLegacy !== false) await previewLegacySync();
}
async function testDatabase() {
@@ -778,21 +786,24 @@ async function testDatabase() {
async function syncDatabase(direction: "import" | "sync") {
await guarded(async () => {
await api(direction === "import" ? "/api/admin/database/import-sqlite" : "/api/admin/database/sync", { method: "POST", body: "{}" });
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 };
setToast(direction === "import" ? "SQLite 已导入远端库" : "远端库已同步回本地");
await loadDatabase();
await loadDatabase({ previewLegacy: false });
});
}
async function previewLegacySync() {
legacySyncMode.value = "preview";
legacySync.value = await api("/api/admin/sync/legacy/preview").catch((error) => ({ ok: false, errors: [String(error)] }));
}
async function runLegacySync() {
await guarded(async () => {
legacySyncMode.value = "run";
legacySync.value = await api("/api/admin/sync/legacy/run", { method: "POST", body: "{}" });
setToast("旧项目同步已完成");
await Promise.all([loadDatabase(), loadReleases().catch(() => undefined), loadSources().catch(() => undefined)]);
await Promise.all([loadDatabase({ previewLegacy: false }), loadReleases().catch(() => undefined), loadSources().catch(() => undefined)]);
});
}
@@ -875,6 +886,17 @@ function auditMessage(item: any) {
return legacy[message] || message || auditTypeLabel(item?.type);
}
function databaseSyncDirectionLabel(value: string) {
if (value === "sqlite_to_remote") return "SQLite -> MySQL";
if (value === "remote_to_sqlite") return "MySQL -> SQLite";
return value || "-";
}
function databaseSyncTableCount(result: any) {
const tables = result?.tables || {};
return Object.values(tables).reduce((total: number, value: any) => total + Number(value || 0), 0);
}
function formatBytes(value: number) {
if (!Number.isFinite(value) || value <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
@@ -921,13 +943,17 @@ function splitList(value: string) {
}
onMounted(() => {
localStorage.removeItem("ymhut.csrf");
void load();
connectAdminEvents();
refreshTimer = window.setInterval(() => {
if (!autoRefreshPaused.value && currentPath.value === "/admin/dashboard" && csrf.value) void loadDashboard();
}, 15000);
});
watch(currentPath, () => {
void load();
});
onUnmounted(() => {
if (refreshTimer) window.clearInterval(refreshTimer);
events?.close();
@@ -942,8 +968,7 @@ function connectAdminEvents() {
if (currentPath.value === "/admin/dashboard") void Promise.all([loadDashboard(), loadSourceCheckJobs().catch(() => undefined)]);
if (currentPath.value === "/admin/sources") void Promise.all([loadSources(), loadSourceCheckJobs().catch(() => undefined)]);
if (currentPath.value === "/admin/endpoints") void loadEndpoints();
if (currentPath.value === "/admin/audit") void loadAudit();
if (currentPath.value === "/admin/database") void loadDatabase();
if (currentPath.value === "/admin/system") void loadSystem();
};
for (const name of ["source_check.item", "source_check.progress", "source_check.completed", "heartbeat"]) {
events.addEventListener(name, refreshCurrent);
@@ -1001,7 +1026,7 @@ function connectAdminEvents() {
<button
v-for="item in group.items"
:key="item.path"
:class="{ active: currentPath === item.path || (item.path.includes('/legacy/') && activeLegacyName) }"
:class="{ active: currentPath === item.path }"
@click="navigate(item.path)"
>
<component :is="item.icon" :size="17" />
@@ -1030,10 +1055,7 @@ function connectAdminEvents() {
<LegacyJsonView v-else-if="activeLegacyName" :ctx="viewContext" />
<SourcesView v-else-if="currentPath === '/admin/sources'" :ctx="viewContext" />
<EndpointsView v-else-if="currentPath === '/admin/endpoints'" :ctx="viewContext" />
<DatabaseView v-else-if="currentPath === '/admin/database'" :ctx="viewContext" />
<HealthView v-else-if="currentPath === '/admin/health'" :ctx="viewContext" />
<SettingsView v-else-if="currentPath === '/admin/settings'" :ctx="viewContext" />
<AuditView v-else-if="currentPath === '/admin/audit'" :ctx="viewContext" />
<SystemView v-else-if="currentPath === '/admin/system'" :ctx="viewContext" />
</section>
</main>
</template>
@@ -0,0 +1,105 @@
export type UploadProgress = {
loaded: number;
total: number;
};
export type AdminApiOptions = {
csrf?: string;
};
const exactMessages: Record<string, string> = {
"current password is invalid": "当前密码不正确",
"new password is required": "新密码不能为空",
"new password must be at least 8 characters": "新密码至少需要 8 位",
"new password cannot be admin": "新密码不能为 admin",
"new password must be different from current password": "新密码不能与当前密码相同",
"invalid password or captcha": "密码或验证码不正确",
"login required": "需要登录后继续操作",
"csrf token required": "页面安全令牌已失效,请刷新后重试",
"csrf token invalid": "页面安全令牌无效,请刷新后重试",
"code is required": "缺少反馈编号",
"revisionid is required": "请选择要恢复的历史版本",
"post required": "该操作需要使用 POST 请求",
"get required": "该操作需要使用 GET 请求",
"file is required": "请选择要上传的文件",
"invalid filename": "文件名不合法",
"path escape rejected": "文件路径不合法",
"check job not found": "未找到心跳检测任务",
"streaming is not supported": "当前运行环境不支持实时事件流",
};
const codeMessages: Record<string, string> = {
UNAUTHORIZED: "需要登录后继续操作",
LOGIN_FAILED: "登录失败,请检查密码和验证码",
PASSWORD_CHANGE_FAILED: "密码修改失败",
INVALID_PAYLOAD: "提交内容格式不正确",
DATABASE_TEST_FAILED: "数据库连接测试失败",
DATABASE_IMPORT_FAILED: "SQLite 导入远端库失败",
DATABASE_SYNC_FAILED: "远端库同步回本地失败",
LEGACY_SAVE_FAILED: "兼容 JSON 保存失败",
LEGACY_VALIDATE_FAILED: "兼容 JSON 校验失败",
LEGACY_RESTORE_FAILED: "兼容 JSON 恢复失败",
NOTICE_SAVE_FAILED: "版本日志保存失败",
NOTICE_VALIDATE_FAILED: "版本日志校验失败",
NOTICE_RESTORE_FAILED: "版本日志恢复失败",
PACKAGE_UPLOAD_FAILED: "发布包上传失败",
SOURCE_SAVE_FAILED: "接口源保存失败",
CHECK_FAILED: "接口健康检测失败",
SYNC_FAILED: "同步操作失败",
FORBIDDEN: "没有权限执行该操作",
METHOD_NOT_ALLOWED: "请求方法不正确",
};
export async function adminFetch<T>(target: string, init: RequestInit = {}, options: AdminApiOptions = {}): Promise<T> {
const headers = new Headers(init.headers);
if (!headers.has("Content-Type") && init.body && !(init.body instanceof FormData)) {
headers.set("Content-Type", "application/json");
}
if (options.csrf) headers.set("X-CSRF-Token", options.csrf);
const res = await fetch(target, { ...init, headers, credentials: "include" });
const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) {
throw new Error(toChineseError(data.message || data.error || `HTTP ${res.status}`));
}
return data as T;
}
export function uploadAdminFile<T>(target: string, form: FormData, options: AdminApiOptions, onProgress: (progress: UploadProgress) => void): Promise<T> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", target);
xhr.withCredentials = true;
if (options.csrf) xhr.setRequestHeader("X-CSRF-Token", options.csrf);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) onProgress({ loaded: event.loaded, total: event.total });
};
xhr.onload = () => {
const data = parseJSONSafe(xhr.responseText, {});
if (xhr.status < 200 || xhr.status >= 300 || data.ok === false) {
reject(new Error(toChineseError(data.message || data.error || `HTTP ${xhr.status}`)));
return;
}
resolve(data as T);
};
xhr.onerror = () => reject(new Error("网络异常,发布包上传失败"));
xhr.onabort = () => reject(new Error("发布包上传已取消"));
xhr.send(form);
});
}
export function toChineseError(value: string) {
const raw = String(value || "").trim();
const lower = raw.toLowerCase();
if (exactMessages[lower]) return exactMessages[lower];
if (codeMessages[raw]) return codeMessages[raw];
if (/^HTTP\s+\d+/.test(raw)) return `请求失败:${raw}`;
return raw || "操作失败";
}
function parseJSONSafe(value: string, fallback: any) {
try {
return JSON.parse(value || "{}");
} catch {
return fallback;
}
}
@@ -14,16 +14,17 @@ const routes = [
"/admin/legacy/media-types",
"/admin/sources",
"/admin/endpoints",
"/admin/database",
"/admin/health",
"/admin/settings",
"/admin/audit",
"/admin/system",
].map((path) => ({ path, component: RoutePlaceholder }));
const router = createRouter({
history: createWebHistory(),
routes: [
...routes,
{ path: "/admin/database", redirect: { path: "/admin/system", query: { tab: "database" } } },
{ path: "/admin/health", redirect: { path: "/admin/system", query: { tab: "health" } } },
{ path: "/admin/settings", redirect: { path: "/admin/system", query: { tab: "security" } } },
{ path: "/admin/audit", redirect: { path: "/admin/system", query: { tab: "audit" } } },
{ path: "/admin", redirect: "/admin/dashboard" },
{ path: "/admin/:pathMatch(.*)*", redirect: "/admin/dashboard" },
],
@@ -0,0 +1,11 @@
import { reactive, ref } from "vue";
export function createAuthStore() {
const csrf = ref(sessionStorage.getItem("ymhut.csrf") || "");
const captcha = ref<any | null>(null);
const bootstrap = ref<any | null>(null);
const loginForm = reactive({ username: "", password: "", captcha: "" });
const passwordForm = reactive({ currentPassword: "", newPassword: "" });
return { csrf, captcha, bootstrap, loginForm, passwordForm };
}
@@ -0,0 +1,8 @@
import { ref } from "vue";
export function createDashboardStore() {
const dashboard = ref<any>({});
const sourceCheckJobs = ref<any[]>([]);
return { dashboard, sourceCheckJobs };
}
@@ -0,0 +1,11 @@
import { reactive, ref } from "vue";
export function createFeedbackStore() {
const page = ref<any>({ items: [], total: 0, page: 1, perPage: 20 });
const selected = ref<any | null>(null);
const filters = reactive({ q: "", status: "", page: 1, perPage: 20 });
const update = reactive({ status: "", statusDetail: "", publicReply: "" });
const commentDraft = reactive({ body: "", internal: true });
return { page, selected, filters, update, commentDraft };
}
@@ -0,0 +1,14 @@
import { reactive, ref } from "vue";
export type LegacyName = "update-info" | "media-types";
export function createLegacyStore() {
const sync = ref<any>(null);
const documents = reactive<Record<LegacyName, any | null>>({ "update-info": null, "media-types": null });
const drafts = reactive<Record<LegacyName, { raw: string; note: string; preview: any | null; tab: "form" | "raw" | "preview" | "history"; form: any }>>({
"update-info": { raw: "", note: "", preview: null, tab: "form", form: {} },
"media-types": { raw: "", note: "", preview: null, tab: "form", form: { categories: [] } },
});
return { sync, documents, drafts };
}
@@ -0,0 +1,24 @@
import { reactive, ref } from "vue";
export function createReleaseStore() {
const releases = ref<any>(null);
const notices = ref<any[]>([]);
const selectedNotice = ref<any | null>(null);
const noticeDraft = reactive({ version: "", raw: "", note: "", preview: null as any });
const uploadDraft = reactive({
file: null as File | null,
version: "",
platform: "windows",
arch: "x64",
channel: "stable",
notes: "",
updateManifest: true,
uploading: false,
progress: 0,
loadedBytes: 0,
totalBytes: 0,
status: "",
});
return { releases, notices, selectedNotice, noticeDraft, uploadDraft };
}
@@ -0,0 +1,27 @@
import { reactive, ref } from "vue";
export function createSourceStore() {
const sources = ref<any>({ categories: [] });
const endpoints = ref<any[]>([]);
const draft = reactive({
sourceId: "",
categoryId: "custom",
categoryName: "自定义接口",
name: "",
description: "",
method: "GET",
apiUrl: "",
urlTemplate: "",
thumbnailUrl: "",
proxyMode: "client_direct",
timeoutMs: 8000,
retryCount: 1,
cacheSeconds: 300,
checkIntervalSec: 300,
enabled: true,
clientVisible: true,
supportedFormats: "[\"json\"]",
});
return { sources, endpoints, draft };
}
@@ -0,0 +1,12 @@
import { reactive, ref } from "vue";
export function createSystemStore() {
const database = ref<any>(null);
const databaseLastSync = ref<any>(null);
const healthSnapshot = ref<any>(null);
const auditLogs = ref<any[]>([]);
const databaseForm = reactive({ provider: "sqlite", sqlitePath: "", mysqlDsn: "" });
const legacySyncMode = ref<"preview" | "run">("preview");
return { database, databaseLastSync, healthSnapshot, auditLogs, databaseForm, legacySyncMode };
}
@@ -24,8 +24,8 @@
}
* { box-sizing: border-box; }
html { min-width: 320px; overflow-x: hidden; }
body { margin: 0; background: var(--bg); overflow-x: hidden; }
html { min-width: 320px; max-width: 100%; overflow-x: clip; }
body { margin: 0; background: var(--bg); max-width: 100%; overflow-x: clip; }
button, input, textarea, select { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: not-allowed; opacity: 0.65; }
@@ -166,13 +166,18 @@ input:focus, textarea:focus, select:focus {
position: sticky;
top: 0;
height: 100dvh;
min-width: 0;
max-width: 260px;
overflow-x: hidden;
}
.brand { display: flex; gap: 12px; align-items: center; padding-bottom: 14px; border-bottom: 1px solid var(--line); }
.brand { display: flex; gap: 12px; align-items: center; min-width: 0; padding-bottom: 14px; border-bottom: 1px solid var(--line); }
.brand-mark { width: 38px; height: 38px; border-radius: 12px; display: grid; place-items: center; background: #111827; color: #fff; }
.brand strong { display: block; }
.brand small { display: block; color: var(--muted); margin-top: 2px; }
.nav-groups { display: flex; flex-direction: column; gap: 14px; flex: 1; overflow-y: auto; overflow-x: hidden; }
.nav-group { display: flex; flex-direction: column; gap: 5px; }
.brand > div { min-width: 0; overflow: hidden; }
.brand strong, .brand small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav-groups { display: flex; flex-direction: column; gap: 14px; flex: 1; min-width: 0; overflow-y: auto; overflow-x: hidden; scrollbar-gutter: stable; }
.nav-group { display: flex; flex-direction: column; gap: 5px; min-width: 0; overflow-x: hidden; }
.nav-group p {
margin: 0 0 2px;
color: var(--muted);
@@ -193,6 +198,11 @@ input:focus, textarea:focus, select:focus {
color: #526070;
font-weight: 800;
transition: background-color 0.18s ease, color 0.18s ease;
width: 100%;
max-width: 100%;
min-width: 0;
overflow: hidden;
flex: 0 0 auto;
}
.nav-group button svg, .logout svg { flex: 0 0 auto; }
.nav-group button span, .logout span {
@@ -201,7 +211,7 @@ input:focus, textarea:focus, select:focus {
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-group button:hover, .logout:hover { transform: translateX(2px); background: #eef4ff; color: var(--primary-dark); }
.nav-group button:hover, .logout:hover { background: #eef4ff; color: var(--primary-dark); box-shadow: inset 3px 0 0 var(--primary); overflow: hidden; }
.nav-group button.active { background: var(--primary-soft); color: var(--primary-dark); }
.logout { color: #7f1d1d; }
@@ -219,10 +229,10 @@ input:focus, textarea:focus, select:focus {
padding: 16px;
box-shadow: 0 1px 2px rgba(17, 24, 39, 0.04);
}
.metric, .panel, .quick-grid button, .revision-list button, .nested-card {
.metric, .panel, .revision-list button, .nested-card {
transition: transform 0.2s var(--ease), border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
}
.metric:hover, .panel:hover, .quick-grid button:hover, .revision-list button:hover, .nested-card:hover {
.metric:hover, .panel:hover, .revision-list button:hover, .nested-card:hover {
transform: translateY(-1px);
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.08);
}
@@ -233,23 +243,6 @@ input:focus, textarea:focus, select:focus {
.chart-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.chart-panel { min-height: 330px; display: flex; flex-direction: column; }
.chart { min-height: 260px; width: 100%; flex: 1; }
.quick-panel { display: flex; flex-direction: column; gap: 12px; }
.quick-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
.quick-grid button {
min-height: 112px;
border: 1px solid var(--line);
border-radius: 12px;
background: #fff;
color: var(--ink);
padding: 12px;
display: grid;
align-content: start;
gap: 7px;
text-align: left;
}
.quick-grid button:hover { border-color: var(--primary); background: #f8fbff; }
.quick-grid svg { color: var(--primary); }
.quick-grid span { color: var(--muted); line-height: 1.45; font-size: 13px; }
.split { display: grid; grid-template-columns: minmax(0, 1fr) 390px; gap: 14px; align-items: start; }
.split.wide-split { grid-template-columns: minmax(380px, 0.95fr) minmax(0, 1.05fr); }
@@ -344,6 +337,44 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
.kv-grid { display: grid; grid-template-columns: 140px minmax(0, 1fr); gap: 11px 14px; }
.kv-grid span { color: var(--muted); }
.kv-grid strong { overflow-wrap: anywhere; }
.sync-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.sync-summary div {
min-height: 74px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--panel-soft);
padding: 10px;
}
.sync-summary span {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.sync-summary strong {
display: block;
margin-top: 8px;
overflow-wrap: anywhere;
font-size: 18px;
}
.ops-note {
display: flex;
gap: 8px;
align-items: flex-start;
border: 1px solid #f4d38c;
border-radius: 10px;
background: var(--warn-bg);
color: #7a3b00;
padding: 10px 12px;
line-height: 1.55;
}
.ops-note svg { flex: 0 0 auto; margin-top: 3px; }
.tabs {
display: flex;
@@ -386,12 +417,47 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
background: #fff;
}
.upload-card {
background: linear-gradient(135deg, #ffffff, #f8fbff);
background: #fff;
}
.upload-progress {
display: grid;
gap: 7px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--panel-soft);
padding: 10px 12px;
}
.upload-progress-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 13px;
}
.upload-progress-head strong { color: var(--ink); }
.upload-progress-head span { color: var(--primary-dark); font-weight: 900; }
.progress-track {
width: 100%;
height: 9px;
overflow: hidden;
border-radius: 999px;
background: #e5e7eb;
}
.progress-track span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--primary);
transition: width 0.18s var(--ease);
}
.upload-progress small {
color: var(--muted);
overflow-wrap: anywhere;
}
@media (max-width: 1180px) {
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.chart-grid, .split, .split.wide-split { grid-template-columns: 1fr; }
.chart-grid, .split, .split.wide-split, .sync-summary { grid-template-columns: 1fr; }
.detail-panel { position: static; max-height: none; }
}
@@ -403,7 +469,6 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
.topbar, .section-head { align-items: stretch; flex-direction: column; }
.metric-grid, .two-col { grid-template-columns: 1fr; }
.form-grid { grid-template-columns: 1fr; }
.quick-grid { grid-template-columns: 1fr; }
.captcha-row { grid-template-columns: 1fr; }
table { min-width: 720px; }
.panel { overflow-x: auto; max-width: 100%; }
@@ -1,22 +0,0 @@
<script setup lang="ts">
defineProps<{ ctx: any }>();
</script>
<template>
<section class="panel page-stack">
<div class="section-head"><h2>审计日志</h2><button class="btn ghost" @click="ctx.loadAudit">刷新</button></div>
<table>
<thead><tr><th>类型</th><th>目标</th><th>信息</th><th>IP</th><th>时间</th></tr></thead>
<tbody>
<tr v-for="item in ctx.auditLogs" :key="item.id">
<td><span class="badge neutral">{{ ctx.auditTypeLabel(item.type) }}</span></td>
<td>{{ item.target }}</td>
<td>{{ ctx.auditMessage(item) }}</td>
<td>{{ item.ip || "-" }}</td>
<td>{{ item.createdAt }}</td>
</tr>
<tr v-if="ctx.auditLogs.length === 0"><td colspan="5">暂无审计日志</td></tr>
</tbody>
</table>
</section>
</template>
@@ -47,17 +47,6 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
</table>
</section>
<section class="panel quick-panel">
<div class="section-head"><h2>功能总览</h2><span class="badge">{{ ctx.quickActions.length }} 个入口</span></div>
<div class="quick-grid">
<button v-for="item in ctx.quickActions" :key="item.path" @click="ctx.navigate(item.path)">
<component :is="item.icon" :size="18" />
<strong>{{ item.label }}</strong>
<span>{{ item.description }}</span>
</button>
</div>
</section>
<div class="chart-grid">
<section class="panel chart-panel"><h2>接口心跳延迟</h2><VChart class="chart" :option="ctx.heartbeatOption" autoresize /></section>
<section class="panel chart-panel"><h2>接口健康分布</h2><VChart class="chart" :option="ctx.healthOption" autoresize /></section>
@@ -1,31 +0,0 @@
<script setup lang="ts">
defineProps<{ ctx: any }>();
</script>
<template>
<section class="split">
<section class="panel page-stack">
<div class="section-head"><h2>数据库运行状态</h2><span :class="['badge', ctx.statusTone(ctx.database?.activeProvider)]">{{ ctx.database?.activeProvider || "-" }}</span></div>
<div class="kv-grid">
<span>配置类型</span><strong>{{ ctx.database?.configProvider || "-" }}</strong>
<span>SQLite</span><strong>{{ ctx.database?.sqliteReady ? "ready" : "missing" }}</strong>
<span>MySQL</span><strong>{{ ctx.database?.remoteReady ? "ready" : "offline" }}</strong>
<span>最后同步</span><strong>{{ ctx.database?.lastSyncAt || "-" }}</strong>
<span>最近错误</span><strong>{{ ctx.database?.lastSyncError || ctx.database?.lastError || "-" }}</strong>
</div>
<hr />
<div class="section-head"><h2>旧项目同步</h2><button class="btn ghost" @click="ctx.previewLegacySync">预览</button></div>
<pre class="json-preview small">{{ ctx.pretty(ctx.legacySync) }}</pre>
<button class="btn primary" @click="ctx.runLegacySync">执行旧项目同步</button>
</section>
<aside class="panel editor-panel">
<h2>连接与同步</h2>
<label>Provider<select v-model="ctx.databaseForm.provider"><option>sqlite</option><option>mysql</option></select></label>
<label>SQLite 路径<input v-model="ctx.databaseForm.sqlitePath" placeholder="留空使用当前配置" /></label>
<label>MySQL DSN<input v-model="ctx.databaseForm.mysqlDsn" placeholder="user:pass@tcp(host:3306)/db?parseTime=true" /></label>
<button class="btn ghost" @click="ctx.testDatabase">测试连接</button>
<button class="btn primary" @click="ctx.syncDatabase('import')">SQLite 导入远端</button>
<button class="btn ghost" @click="ctx.syncDatabase('sync')">远端同步回本地</button>
</aside>
</section>
</template>
@@ -1,10 +0,0 @@
<script setup lang="ts">
defineProps<{ ctx: any }>();
</script>
<template>
<section class="panel page-stack">
<h2>健康快照</h2>
<pre class="json-preview tall">{{ ctx.pretty(ctx.healthSnapshot) }}</pre>
</section>
</template>
@@ -24,6 +24,10 @@ defineProps<{ ctx: any }>();
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'history' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'history'">历史版本</button>
</div>
<p v-if="ctx.activeLegacyName === 'media-types'" class="notice">
生产环境不再自动依赖旧项目路径需要以 server/update/public/media-types.json 为基板时请切换到 Raw JSON 粘贴完整内容校验通过后保存发布
</p>
<section v-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form' && ctx.activeLegacyName === 'update-info'" class="form-grid">
<label>版本号<input v-model="ctx.legacyDrafts['update-info'].form.app_version" /></label>
<label>标题<input v-model="ctx.legacyDrafts['update-info'].form.title" /></label>
@@ -25,7 +25,22 @@ defineProps<{ ctx: any }>();
<label class="wide">发布说明<textarea v-model="ctx.uploadDraft.notes" rows="3"></textarea></label>
<label class="checkbox wide"><input v-model="ctx.uploadDraft.updateManifest" type="checkbox" />上传后同步更新兼容 update-info.json</label>
</div>
<button class="btn primary" @click="ctx.uploadPackage"><UploadCloud :size="16" />上传发布包</button>
<div v-if="ctx.uploadDraft.file || ctx.uploadDraft.uploading || ctx.uploadDraft.status" class="upload-progress">
<div class="upload-progress-head">
<strong>{{ ctx.uploadDraft.status || "等待上传" }}</strong>
<span>{{ ctx.uploadDraft.progress || 0 }}%</span>
</div>
<div class="progress-track">
<span :style="{ width: `${ctx.uploadDraft.progress || 0}%` }"></span>
</div>
<small>
{{ ctx.uploadDraft.file?.name || "发布包" }}
<template v-if="ctx.uploadDraft.totalBytes">
· {{ ctx.formatBytes(ctx.uploadDraft.loadedBytes || 0) }} / {{ ctx.formatBytes(ctx.uploadDraft.totalBytes || 0) }}
</template>
</small>
</div>
<button class="btn primary" :disabled="ctx.uploadDraft.uploading" @click="ctx.uploadPackage"><UploadCloud :size="16" />{{ ctx.uploadDraft.uploading ? "上传中" : "上传发布包" }}</button>
</section>
<table>
<thead><tr><th>文件</th><th>版本</th><th>平台</th><th>大小</th><th>SHA256</th></tr></thead>
@@ -1,21 +0,0 @@
<script setup lang="ts">
import { KeyRound } from "lucide-vue-next";
defineProps<{ ctx: any }>();
</script>
<template>
<section class="split">
<section class="panel editor-panel">
<h2>修改后台密码</h2>
<label>当前密码<input v-model="ctx.passwordForm.currentPassword" type="password" /></label>
<label>新密码<input v-model="ctx.passwordForm.newPassword" type="password" /></label>
<button class="btn primary" @click="ctx.changePassword"><KeyRound :size="16" />保存密码</button>
</section>
<section class="panel page-stack">
<div class="section-head"><h2>旧项目同步预览</h2><button class="btn ghost" @click="ctx.previewLegacySync">刷新预览</button></div>
<pre class="json-preview">{{ ctx.pretty(ctx.legacySync) }}</pre>
<button class="btn primary" @click="ctx.runLegacySync">执行同步</button>
</section>
</section>
</template>
@@ -0,0 +1,159 @@
<script setup lang="ts">
import { Activity, AlertTriangle, ArrowDownUp, Clock3, Database, KeyRound, ListChecks, RefreshCw, ShieldCheck } from "lucide-vue-next";
defineProps<{ ctx: any }>();
const tabs = [
{ id: "database", label: "数据库", icon: Database },
{ id: "sync", label: "旧项目同步", icon: RefreshCw },
{ id: "security", label: "安全设置", icon: ShieldCheck },
{ id: "health", label: "健康快照", icon: Activity },
{ id: "audit", label: "审计日志", icon: ListChecks },
];
</script>
<template>
<section class="page-stack">
<nav class="tabs" aria-label="系统运维标签">
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
:class="{ active: ctx.systemTab === tab.id }"
@click="ctx.setSystemTab(tab.id)"
>
<component :is="tab.icon" :size="15" />
{{ tab.label }}
</button>
</nav>
<section v-if="ctx.systemTab === 'database'" class="split">
<section class="panel page-stack">
<div class="section-head">
<h2>数据库运行状态</h2>
<span :class="['badge', ctx.statusTone(ctx.database?.activeProvider)]">{{ ctx.database?.activeProvider || "-" }}</span>
</div>
<div class="kv-grid">
<span>配置类型</span><strong>{{ ctx.database?.configProvider || "-" }}</strong>
<span>SQLite</span><strong>{{ ctx.database?.sqliteReady ? "ready" : "missing" }}</strong>
<span>MySQL</span><strong>{{ ctx.database?.remoteReady ? "ready" : "offline" }}</strong>
<span>Failover</span><strong>{{ ctx.database?.failoverActive ? "active" : "standby" }}</strong>
<span>最后同步</span><strong>{{ ctx.database?.lastSyncAt || "-" }}</strong>
<span>最近错误</span><strong>{{ ctx.database?.lastSyncError || ctx.database?.lastError || "-" }}</strong>
</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.databaseSyncTableCount(ctx.databaseLastSync) }}</strong>
</div>
<div>
<span><Clock3 :size="15" />完成时间</span>
<strong>{{ ctx.databaseLastSync?.finishedAt || ctx.database?.lastSyncAt || "-" }}</strong>
</div>
</div>
<div class="ops-note">
<AlertTriangle :size="16" />
<span>数据库同步是覆盖式全表 upsert执行前确认方向SQLite 导入远端会以本地库为源远端同步回本地会以 MySQL 为源</span>
</div>
</section>
<aside class="panel editor-panel">
<h2>连接与同步</h2>
<label>Provider<select v-model="ctx.databaseForm.provider"><option>sqlite</option><option>mysql</option></select></label>
<label>SQLite 路径<input v-model="ctx.databaseForm.sqlitePath" placeholder="留空使用当前配置" /></label>
<label>MySQL DSN<input v-model="ctx.databaseForm.mysqlDsn" placeholder="user:pass@tcp(host:3306)/db?parseTime=true" /></label>
<div class="button-row">
<button class="btn ghost" @click="ctx.testDatabase">测试连接</button>
<button class="btn primary" @click="ctx.syncDatabase('import')">SQLite 导入远端</button>
<button class="btn ghost" @click="ctx.syncDatabase('sync')">远端同步回本地</button>
</div>
</aside>
</section>
<section v-else-if="ctx.systemTab === 'sync'" class="panel page-stack">
<div class="section-head">
<div>
<h2>旧项目同步</h2>
<p class="muted">预览只检查旧目录和影响范围执行会先备份当前发布目录再复制旧项目数据并导入反馈记录</p>
</div>
<button class="btn ghost" @click="ctx.previewLegacySync"><RefreshCw :size="16" />刷新预览</button>
</div>
<div class="sync-summary">
<div>
<span>当前模式</span>
<strong>{{ ctx.legacySyncMode === "run" || ctx.legacySync?.dryRun === false ? "执行结果" : "Dry-run 预览" }}</strong>
</div>
<div>
<span>状态</span>
<strong>{{ ctx.legacySync?.ok === false ? "存在错误" : "可继续" }}</strong>
</div>
<div>
<span>完成时间</span>
<strong>{{ ctx.legacySync?.finishedAt || "-" }}</strong>
</div>
</div>
<div class="kv-grid">
<span>复制文件</span><strong>{{ ctx.legacySync?.stats?.copiedFiles || 0 }}</strong>
<span>复制目录</span><strong>{{ ctx.legacySync?.stats?.copiedDirectories || 0 }}</strong>
<span>导入记录</span><strong>{{ ctx.legacySync?.stats?.importedRows || 0 }}</strong>
<span>缺失路径</span><strong>{{ ctx.legacySync?.stats?.missingPaths || 0 }}</strong>
</div>
<pre class="json-preview tall">{{ ctx.pretty(ctx.legacySync) }}</pre>
<div class="button-row">
<button class="btn primary" @click="ctx.runLegacySync">执行旧项目同步</button>
</div>
</section>
<section v-else-if="ctx.systemTab === 'security'" class="split">
<section class="panel editor-panel">
<h2>修改后台密码</h2>
<label>当前密码<input v-model="ctx.passwordForm.currentPassword" type="password" autocomplete="current-password" /></label>
<label>新密码<input v-model="ctx.passwordForm.newPassword" type="password" autocomplete="new-password" /></label>
<button class="btn primary" @click="ctx.changePassword"><KeyRound :size="16" />保存密码</button>
</section>
<section class="panel page-stack">
<div class="section-head"><h2>当前安全策略</h2><span class="badge good">已启用</span></div>
<div class="kv-grid">
<span>登录保护</span><strong>验证码 + 连续失败限流</strong>
<span>写操作保护</span><strong>HttpOnly Session + CSRF Token</strong>
<span>Cookie</span><strong>HTTPS X-Forwarded-Proto=https 时自动 Secure</strong>
<span>会话范围</span><strong>后台 API SSE 事件流均要求登录</strong>
<span>密码规则</span><strong>至少 8 不能为 admin不能与当前密码相同</strong>
<span>兼容哈希</span><strong>保留 SHA-256 登录兼容后续可平滑迁移到更强算法</strong>
</div>
</section>
</section>
<section v-else-if="ctx.systemTab === 'health'" class="panel page-stack">
<div class="section-head"><h2>健康快照</h2><span class="badge neutral">只读</span></div>
<pre class="json-preview tall">{{ ctx.pretty(ctx.healthSnapshot) }}</pre>
</section>
<section v-else class="panel page-stack">
<div class="section-head"><h2>审计日志</h2><button class="btn ghost" @click="ctx.loadAudit">刷新</button></div>
<table>
<thead><tr><th>类型</th><th>目标</th><th>信息</th><th>IP</th><th>时间</th></tr></thead>
<tbody>
<tr v-for="item in ctx.auditLogs" :key="item.id">
<td><span class="badge neutral">{{ ctx.auditTypeLabel(item.type) }}</span></td>
<td>{{ item.target }}</td>
<td>{{ ctx.auditMessage(item) }}</td>
<td>{{ item.ip || "-" }}</td>
<td>{{ item.createdAt }}</td>
</tr>
<tr v-if="ctx.auditLogs.length === 0"><td colspan="5">暂无审计日志</td></tr>
</tbody>
</table>
</section>
</section>
</template>