@@ -31,8 +31,11 @@ import { createSystemStore } from "./stores/system";
|
||||
|
||||
const DashboardView = defineAsyncComponent(() => import("./views/DashboardView.vue"));
|
||||
|
||||
type SystemTab = "database" | "sync" | "security" | "health" | "audit";
|
||||
type SystemTab = "database" | "migration" | "sync" | "security" | "health" | "audit";
|
||||
type ToastState = { message: string; type: "success" | "warn" | "error" };
|
||||
type LoadSystemOptions = { preserveForms?: boolean };
|
||||
type LoadDatabaseOptions = { previewLegacy?: boolean; preserveForm?: boolean };
|
||||
type LoadMailOptions = { preserveForm?: boolean };
|
||||
|
||||
type Captcha = {
|
||||
captchaId: string;
|
||||
@@ -58,6 +61,8 @@ const currentPath = computed(() => normalizeAdminPath(route.path));
|
||||
const loading = ref(false);
|
||||
const toast = ref<ToastState | null>(null);
|
||||
const autoRefreshPaused = ref(false);
|
||||
const databaseFormEditing = ref(false);
|
||||
const mailConfigEditing = ref(false);
|
||||
let refreshTimer: number | undefined;
|
||||
let toastTimer: number | undefined;
|
||||
let events: EventSource | null = null;
|
||||
@@ -74,9 +79,9 @@ const { csrf, captcha, bootstrap: authBootstrap, loginForm, passwordForm } = aut
|
||||
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 { sync: legacySync, documents: legacyDocuments, drafts: legacyDrafts, modal: legacyModal, activeMediaCategoryIndex } = legacyStore;
|
||||
const { sources, endpoints, draft: sourceDraft } = sourceStore;
|
||||
const { database, databaseLastSync, healthSnapshot, auditLogs, databaseForm, legacySyncMode } = systemStore;
|
||||
const { database, databaseConfig, databaseLastSync, healthSnapshot, auditLogs, auditPage, migrationStatus, branding, databaseForm, databaseConfigCollapsed, mailConfig, legacySyncMode } = systemStore;
|
||||
|
||||
const routes: RouteItem[] = [
|
||||
{ path: "/admin/dashboard", label: "仪表盘", description: "服务状态、接口心跳与运营指标", icon: LayoutDashboard },
|
||||
@@ -114,25 +119,59 @@ 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 activeMediaCategory = computed(() => {
|
||||
const categories = legacyDrafts["media-types"].form.categories || [];
|
||||
return categories[activeMediaCategoryIndex.value] || null;
|
||||
});
|
||||
const systemTab = computed<SystemTab>(() => normalizeSystemTab(route.query.tab));
|
||||
const heartbeatChartRows = computed(() => {
|
||||
const rows = heartbeats.value
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((item: any) => ({
|
||||
time: heartbeatTimeValue(item.checkedAt),
|
||||
label: timeLabel(item.checkedAt),
|
||||
latency: Number(item.latencyMs ?? item.latency_ms ?? 0),
|
||||
name: item.name || item.sourceId || "未知接口",
|
||||
status: labelStatus(item.status),
|
||||
}))
|
||||
.filter((item: any) => Number.isFinite(item.latency));
|
||||
return rows.length ? rows : [{ time: Date.now(), label: "暂无", latency: 0, name: "暂无检测记录", status: "未检测" }];
|
||||
});
|
||||
const isHeartbeatChartEmpty = computed(() => heartbeats.value.length === 0);
|
||||
|
||||
const heartbeatOption = computed(() => ({
|
||||
animation: true,
|
||||
tooltip: { trigger: "axis" },
|
||||
grid: { left: 44, right: 18, top: 28, bottom: 34 },
|
||||
grid: { left: 48, right: 22, top: 28, bottom: 40, containLabel: true },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: heartbeats.value.slice().reverse().map((item: any) => timeLabel(item.checkedAt)),
|
||||
boundaryGap: heartbeatChartRows.value.length <= 1,
|
||||
data: heartbeatChartRows.value.map((item: any) => item.label),
|
||||
axisLine: { lineStyle: { color: "#cbd5e1" } },
|
||||
axisLabel: { color: "#64748b" },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
name: "ms",
|
||||
min: 0,
|
||||
axisLine: { lineStyle: { color: "#cbd5e1" } },
|
||||
axisLabel: { color: "#64748b" },
|
||||
splitLine: { lineStyle: { color: "#e5e7eb" } },
|
||||
},
|
||||
yAxis: { type: "value", name: "ms", axisLine: { lineStyle: { color: "#cbd5e1" } }, splitLine: { lineStyle: { color: "#e5e7eb" } } },
|
||||
series: [
|
||||
{
|
||||
name: "接口延迟",
|
||||
type: "line",
|
||||
smooth: true,
|
||||
showSymbol: true,
|
||||
symbolSize: 7,
|
||||
connectNulls: true,
|
||||
areaStyle: { opacity: 0.18 },
|
||||
data: heartbeats.value.slice().reverse().map((item: any) => item.latencyMs || 0),
|
||||
data: heartbeatChartRows.value.map((item: any) => item.latency),
|
||||
color: "#2563eb",
|
||||
lineStyle: { width: 3 },
|
||||
emphasis: { focus: "series" },
|
||||
},
|
||||
],
|
||||
}));
|
||||
@@ -199,19 +238,29 @@ const viewContext = computed(() => ({
|
||||
addMediaCategory,
|
||||
addMediaSubcategory,
|
||||
addUpdateMirror,
|
||||
applyLegacyModal,
|
||||
auditPage,
|
||||
auditLogs: auditLogs.value,
|
||||
autoRefreshPaused: autoRefreshPaused.value,
|
||||
availabilityOption: availabilityOption.value,
|
||||
branding,
|
||||
changePassword,
|
||||
checkSources,
|
||||
clientCalls: clientCalls.value,
|
||||
commentDraft,
|
||||
copyEndpointToSource,
|
||||
database: database.value,
|
||||
databaseConfig: databaseConfig.value,
|
||||
databaseConfigCollapsed: databaseConfigCollapsed.value,
|
||||
databaseFormEditing: databaseFormEditing.value,
|
||||
databaseForm,
|
||||
databaseLastSync: databaseLastSync.value,
|
||||
databaseSyncStatusLabel,
|
||||
databaseSyncDirectionLabel,
|
||||
databaseSyncTableCount,
|
||||
databaseConfigSummary,
|
||||
deleteEndpoint,
|
||||
editDatabaseConfig,
|
||||
endpointStatus,
|
||||
endpoints: endpoints.value,
|
||||
feedbackFilters,
|
||||
@@ -224,16 +273,36 @@ const viewContext = computed(() => ({
|
||||
healthyEndpointCount: healthyEndpointCount.value,
|
||||
heartbeatOption: heartbeatOption.value,
|
||||
heartbeats: heartbeats.value,
|
||||
isHeartbeatChartEmpty: isHeartbeatChartEmpty.value,
|
||||
importNotices,
|
||||
kpis: kpis.value,
|
||||
labelStatus,
|
||||
labelPriority,
|
||||
latestNotice: latestNotice.value,
|
||||
legacyDocuments,
|
||||
legacyDrafts,
|
||||
legacyModal,
|
||||
activeMediaCategoryIndex: activeMediaCategoryIndex.value,
|
||||
activeMediaCategory: activeMediaCategory.value,
|
||||
legacySync: legacySync.value,
|
||||
legacySyncMode: legacySyncMode.value,
|
||||
loadAudit,
|
||||
loadBranding,
|
||||
loadFeedbacks,
|
||||
loadMigrationStatus,
|
||||
mailConfig,
|
||||
mailConfigEditing: mailConfigEditing.value,
|
||||
markDatabaseFormEditing,
|
||||
markMailConfigEditing,
|
||||
migrationStatus: migrationStatus.value,
|
||||
loadMailConfig,
|
||||
reloadDatabaseConfig,
|
||||
reloadMailConfig,
|
||||
saveDatabase,
|
||||
saveBranding,
|
||||
saveMailConfig,
|
||||
testMail,
|
||||
retryFeedbackMail,
|
||||
navigate,
|
||||
noticeDraft,
|
||||
onPackageSelected,
|
||||
@@ -262,8 +331,15 @@ const viewContext = computed(() => ({
|
||||
syncDatabase,
|
||||
systemTab: systemTab.value,
|
||||
setSystemTab,
|
||||
setAuditPage,
|
||||
selectAuditLog,
|
||||
testDatabase,
|
||||
toggleAutoRefresh,
|
||||
openMediaCategoryModal,
|
||||
openMediaSubcategoryModal,
|
||||
openUpdateMirrorModal,
|
||||
selectMediaCategory,
|
||||
closeLegacyModal,
|
||||
updateLegacyRawFromForm,
|
||||
uploadDraft,
|
||||
uploadPackage,
|
||||
@@ -291,7 +367,7 @@ function normalizeAdminPath(value: string) {
|
||||
|
||||
function normalizeSystemTab(value: unknown): SystemTab {
|
||||
const tab = Array.isArray(value) ? value[0] : value;
|
||||
if (tab === "sync" || tab === "security" || tab === "health" || tab === "audit") return tab;
|
||||
if (tab === "migration" || tab === "sync" || tab === "security" || tab === "health" || tab === "audit") return tab;
|
||||
return "database";
|
||||
}
|
||||
|
||||
@@ -392,7 +468,7 @@ async function load() {
|
||||
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/system") await loadSystem();
|
||||
if (currentPath.value === "/admin/system") await loadSystem({ preserveForms: true });
|
||||
const legacyName = activeLegacyName.value;
|
||||
if (legacyName) await loadLegacy(legacyName);
|
||||
connectAdminEvents();
|
||||
@@ -403,14 +479,22 @@ async function loadDashboard() {
|
||||
dashboard.value = await api("/api/admin/dashboard/overview?window=24h");
|
||||
}
|
||||
|
||||
async function loadSystem() {
|
||||
await Promise.all([loadDatabase(), loadHealth(), loadAudit()]);
|
||||
async function loadSystem(options: LoadSystemOptions = {}) {
|
||||
await Promise.all([
|
||||
loadDatabase({ preserveForm: options.preserveForms }),
|
||||
loadMailConfig({ preserveForm: options.preserveForms }),
|
||||
loadHealth(),
|
||||
loadAudit(),
|
||||
loadMigrationStatus(),
|
||||
loadBranding(),
|
||||
]);
|
||||
}
|
||||
|
||||
async function loadFeedbacks() {
|
||||
const params = new URLSearchParams({ page: String(feedbackFilters.page), perPage: String(feedbackFilters.perPage) });
|
||||
if (feedbackFilters.q) params.set("q", feedbackFilters.q);
|
||||
if (feedbackFilters.status) params.set("status", feedbackFilters.status);
|
||||
if (feedbackFilters.priority) params.set("priority", feedbackFilters.priority);
|
||||
const data = await api<{ page: any }>(`/api/admin/feedbacks?${params}`);
|
||||
feedbackPage.value = data.page || { items: [], total: 0, page: 1, perPage: 20 };
|
||||
}
|
||||
@@ -419,6 +503,7 @@ async function openFeedback(item: any) {
|
||||
const data = await api<{ feedback: any }>(`/api/admin/feedbacks/${encodeURIComponent(item.code)}`);
|
||||
selectedFeedback.value = data.feedback;
|
||||
feedbackUpdate.status = data.feedback.status || "new";
|
||||
feedbackUpdate.priority = data.feedback.priority || "normal";
|
||||
feedbackUpdate.statusDetail = data.feedback.statusDetail || "";
|
||||
feedbackUpdate.publicReply = data.feedback.publicReply || "";
|
||||
}
|
||||
@@ -445,14 +530,17 @@ async function addFeedbackComment() {
|
||||
});
|
||||
}
|
||||
|
||||
async function loadReleases() {
|
||||
async function loadReleases(preferredVersion = noticeDraft.version) {
|
||||
const [releaseData, noticeData] = await Promise.all([
|
||||
api<{ manifest: any }>("/api/admin/releases"),
|
||||
api<{ items: any[] }>("/api/admin/releases/notices"),
|
||||
]);
|
||||
releases.value = releaseData.manifest;
|
||||
releaseNotices.value = noticeData.items || [];
|
||||
if (releaseNotices.value.length && !noticeDraft.version) await openNotice(releaseNotices.value[0].version);
|
||||
const target = preferredVersion && releaseNotices.value.some((item: any) => item.version === preferredVersion)
|
||||
? preferredVersion
|
||||
: releaseNotices.value[0]?.version;
|
||||
if (target && noticeDraft.version !== target) await openNotice(target);
|
||||
}
|
||||
|
||||
async function importNotices() {
|
||||
@@ -492,7 +580,7 @@ async function saveNotice() {
|
||||
selectedNotice.value = data.document;
|
||||
noticeDraft.note = "";
|
||||
setToast("版本日志已保存并同步兼容更新信息");
|
||||
await loadReleases();
|
||||
await loadReleases(data.document?.notice?.version || noticeDraft.version);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -516,6 +604,7 @@ async function loadLegacy(name: LegacyName) {
|
||||
legacyDrafts[name].raw = data.document.raw || "";
|
||||
legacyDrafts[name].preview = data.document.parsed || null;
|
||||
legacyDrafts[name].form = makeLegacyForm(name, data.document.parsed || {});
|
||||
if (name === "media-types") clampMediaCategoryIndex();
|
||||
}
|
||||
|
||||
function onPackageSelected(event: Event) {
|
||||
@@ -597,6 +686,12 @@ async function saveLegacy(name: LegacyName) {
|
||||
legacyDrafts[name].preview = data.document.parsed;
|
||||
legacyDrafts[name].form = makeLegacyForm(name, data.document.parsed || {});
|
||||
legacyDrafts[name].note = "";
|
||||
if (name === "media-types") {
|
||||
clampMediaCategoryIndex();
|
||||
}
|
||||
if (name === "update-info") {
|
||||
await loadReleases(legacyDrafts[name].preview?.app_version || legacyDrafts[name].preview?.version || noticeDraft.version);
|
||||
}
|
||||
setToast("兼容 JSON 已保存并发布到旧路径");
|
||||
});
|
||||
}
|
||||
@@ -611,6 +706,8 @@ async function restoreLegacy(name: LegacyName, revisionId: number) {
|
||||
legacyDrafts[name].raw = data.document.raw;
|
||||
legacyDrafts[name].preview = data.document.parsed;
|
||||
legacyDrafts[name].form = makeLegacyForm(name, data.document.parsed || {});
|
||||
if (name === "media-types") clampMediaCategoryIndex();
|
||||
if (name === "update-info") await loadReleases(legacyDrafts[name].preview?.app_version || legacyDrafts[name].preview?.version || noticeDraft.version);
|
||||
setToast("兼容 JSON 已恢复");
|
||||
});
|
||||
}
|
||||
@@ -648,6 +745,7 @@ function makeLegacyForm(name: LegacyName, parsed: any) {
|
||||
release_notes_md: parsed.release_notes_md || "",
|
||||
update_notes: JSON.stringify(parsed.update_notes || {}, null, 2),
|
||||
last_update_notes: JSON.stringify(parsed.last_update_notes || {}, null, 2),
|
||||
download_mirrors: clone(parsed.download_mirrors || []),
|
||||
package_sha256: parsed.package_sha256 || "",
|
||||
package_size: parsed.package_size || "",
|
||||
updated_at: parsed.updated_at || parsed.last_updated || "",
|
||||
@@ -683,6 +781,7 @@ function updateLegacyRawFromForm(name: LegacyName) {
|
||||
if (form[key] !== undefined) current[key] = form[key];
|
||||
}
|
||||
if (form.package_size !== "") current.package_size = Number(form.package_size || 0);
|
||||
current.download_mirrors = clone(form.download_mirrors || current.download_mirrors || []);
|
||||
current.update_notes = parseJSONSafe(form.update_notes, current.update_notes || {});
|
||||
current.last_update_notes = parseJSONSafe(form.last_update_notes, current.last_update_notes || {});
|
||||
}
|
||||
@@ -710,8 +809,104 @@ function addMediaSubcategory(category: any) {
|
||||
category.subcategories.push({ id: `source-${category.subcategories.length + 1}`, name: "新接口", api_url: "", refresh_interval: 300, supported_formats: "json", downloadable: true });
|
||||
}
|
||||
|
||||
function openMediaCategoryModal(index = -1) {
|
||||
const categories = legacyDrafts["media-types"].form.categories || [];
|
||||
const existing = index >= 0 ? categories[index] : null;
|
||||
Object.assign(legacyModal, {
|
||||
open: true,
|
||||
type: "media-category",
|
||||
categoryIndex: index,
|
||||
itemIndex: -1,
|
||||
draft: clone(existing || { id: `category-${categories.length + 1}`, name: "新分类", enabled: true }),
|
||||
});
|
||||
}
|
||||
|
||||
function selectMediaCategory(index: number) {
|
||||
activeMediaCategoryIndex.value = Math.max(0, index);
|
||||
clampMediaCategoryIndex();
|
||||
}
|
||||
|
||||
function clampMediaCategoryIndex() {
|
||||
const categories = legacyDrafts["media-types"].form.categories || [];
|
||||
if (categories.length === 0) {
|
||||
activeMediaCategoryIndex.value = 0;
|
||||
return;
|
||||
}
|
||||
activeMediaCategoryIndex.value = Math.min(Math.max(0, activeMediaCategoryIndex.value), categories.length - 1);
|
||||
}
|
||||
|
||||
function openMediaSubcategoryModal(categoryIndex = activeMediaCategoryIndex.value, itemIndex = -1) {
|
||||
const categories = legacyDrafts["media-types"].form.categories || [];
|
||||
const category = categories[categoryIndex];
|
||||
if (!category) return;
|
||||
activeMediaCategoryIndex.value = categoryIndex;
|
||||
const subcategories = category.subcategories || [];
|
||||
const existing = itemIndex >= 0 ? subcategories[itemIndex] : null;
|
||||
Object.assign(legacyModal, {
|
||||
open: true,
|
||||
type: "media-subcategory",
|
||||
categoryIndex,
|
||||
itemIndex,
|
||||
draft: clone(existing || { id: `source-${subcategories.length + 1}`, name: "新接口", api_url: "", thumbnail_url: "", refresh_interval: 300, supported_formats: "json", downloadable: true, description: "" }),
|
||||
});
|
||||
}
|
||||
|
||||
function openUpdateMirrorModal(index = -1) {
|
||||
const form = legacyDrafts["update-info"].form;
|
||||
if (!Array.isArray(form.download_mirrors)) form.download_mirrors = [];
|
||||
const mirrors = form.download_mirrors;
|
||||
const existing = index >= 0 ? mirrors[index] : null;
|
||||
Object.assign(legacyModal, {
|
||||
open: true,
|
||||
type: "update-mirror",
|
||||
categoryIndex: -1,
|
||||
itemIndex: index,
|
||||
draft: clone(existing || { id: `mirror-${mirrors.length + 1}`, name: "备用镜像", url: "", type: "direct", enabled: true }),
|
||||
});
|
||||
}
|
||||
|
||||
function closeLegacyModal() {
|
||||
Object.assign(legacyModal, { open: false, type: "", categoryIndex: -1, itemIndex: -1, draft: {} });
|
||||
}
|
||||
|
||||
function applyLegacyModal() {
|
||||
if (legacyModal.type === "media-category") {
|
||||
const form = legacyDrafts["media-types"].form;
|
||||
if (!Array.isArray(form.categories)) form.categories = [];
|
||||
const next = { ...legacyModal.draft, enabled: legacyModal.draft.enabled !== false, subcategories: legacyModal.draft.subcategories || [] };
|
||||
if (legacyModal.categoryIndex >= 0) {
|
||||
next.subcategories = form.categories[legacyModal.categoryIndex]?.subcategories || [];
|
||||
form.categories.splice(legacyModal.categoryIndex, 1, next);
|
||||
activeMediaCategoryIndex.value = legacyModal.categoryIndex;
|
||||
} else {
|
||||
form.categories.push(next);
|
||||
activeMediaCategoryIndex.value = form.categories.length - 1;
|
||||
}
|
||||
clampMediaCategoryIndex();
|
||||
}
|
||||
if (legacyModal.type === "media-subcategory") {
|
||||
const category = legacyDrafts["media-types"].form.categories?.[legacyModal.categoryIndex];
|
||||
if (!category) return;
|
||||
if (!Array.isArray(category.subcategories)) category.subcategories = [];
|
||||
const next = { ...legacyModal.draft, refresh_interval: Number(legacyModal.draft.refresh_interval || 300), downloadable: legacyModal.draft.downloadable !== false };
|
||||
if (legacyModal.itemIndex >= 0) category.subcategories.splice(legacyModal.itemIndex, 1, next);
|
||||
else category.subcategories.push(next);
|
||||
}
|
||||
if (legacyModal.type === "update-mirror") {
|
||||
const form = legacyDrafts["update-info"].form;
|
||||
if (!Array.isArray(form.download_mirrors)) form.download_mirrors = [];
|
||||
const mirrors = form.download_mirrors;
|
||||
const next = { ...legacyModal.draft, enabled: legacyModal.draft.enabled !== false };
|
||||
if (legacyModal.itemIndex >= 0) mirrors.splice(legacyModal.itemIndex, 1, next);
|
||||
else mirrors.push(next);
|
||||
updateLegacyRawFromForm("update-info");
|
||||
}
|
||||
closeLegacyModal();
|
||||
}
|
||||
|
||||
function removeItem(list: any[], index: number) {
|
||||
list.splice(index, 1);
|
||||
clampMediaCategoryIndex();
|
||||
}
|
||||
|
||||
async function loadSources() {
|
||||
@@ -731,10 +926,10 @@ async function checkSources() {
|
||||
await guarded(async () => {
|
||||
const data = await api<{ jobId: string; job: any }>("/api/admin/sources/check", { method: "POST", body: "{}" });
|
||||
if (data.job) sourceCheckJobs.value = [data.job, ...sourceCheckJobs.value.filter((item) => item.id !== data.job.id)].slice(0, 5);
|
||||
setToast(`接口心跳检测已进入队列:${data.jobId}`);
|
||||
setToast(`服务端接口检测已进入队列:${data.jobId}`);
|
||||
if (currentPath.value === "/admin/dashboard") await loadDashboard();
|
||||
if (currentPath.value === "/admin/sources") await loadSources();
|
||||
if (currentPath.value === "/admin/system") await loadSystem();
|
||||
if (currentPath.value === "/admin/system") await loadSystem({ preserveForms: true });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -767,32 +962,226 @@ function copyEndpointToSource(item: any) {
|
||||
navigate("/admin/sources");
|
||||
}
|
||||
|
||||
async function loadDatabase(options: { previewLegacy?: boolean } = {}) {
|
||||
const data = await api<{ database: any }>("/api/admin/database/status");
|
||||
async function deleteEndpoint(item: any) {
|
||||
const sourceID = item.id || item.sourceId;
|
||||
if (!sourceID) return;
|
||||
if (!window.confirm(`确认删除客户端接口「${sourceID}」?删除后会同步兼容 media-types.json 和 update-info.json。`)) return;
|
||||
await guarded(async () => {
|
||||
await api(`/api/admin/sources/${encodeURIComponent(sourceID)}`, { method: "DELETE" });
|
||||
setToast("客户端接口已删除,兼容 JSON 已同步");
|
||||
await Promise.all([loadSources().catch(() => undefined), loadEndpoints()]);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadDatabase(options: LoadDatabaseOptions = {}) {
|
||||
const data = await api<{ database: any; config?: any }>("/api/admin/database/status");
|
||||
database.value = data.database;
|
||||
databaseForm.provider = data.database?.configProvider || "sqlite";
|
||||
databaseConfig.value = data.config || null;
|
||||
if (!options.preserveForm || !databaseFormEditing.value) {
|
||||
applyDatabaseConfig(data.config || {}, data.database || {});
|
||||
databaseFormEditing.value = false;
|
||||
}
|
||||
if (options.previewLegacy !== false) await previewLegacySync();
|
||||
}
|
||||
|
||||
function applyDatabaseConfig(config: any, status: any = {}) {
|
||||
databaseForm.provider = config.provider || status.configProvider || "sqlite";
|
||||
databaseForm.sqlitePath = config.sqlitePath || "";
|
||||
databaseForm.mysqlHost = config.mysqlHost || "127.0.0.1";
|
||||
databaseForm.mysqlPort = Number(config.mysqlPort || 3306);
|
||||
databaseForm.mysqlDatabase = config.mysqlDatabase || "";
|
||||
databaseForm.mysqlUser = config.mysqlUser || "";
|
||||
databaseForm.mysqlPassword = "";
|
||||
databaseForm.mysqlDsn = config.mysqlDsn || "";
|
||||
databaseConfigCollapsed.value = databaseForm.provider === "mysql" && Boolean(config.mysqlHost || config.mysqlDatabase || config.mysqlDsn);
|
||||
}
|
||||
|
||||
function databasePayload() {
|
||||
return {
|
||||
provider: databaseForm.provider,
|
||||
sqlite_path: databaseForm.sqlitePath,
|
||||
mysql_host: databaseForm.mysqlHost,
|
||||
mysql_port: Number(databaseForm.mysqlPort || 3306),
|
||||
mysql_database: databaseForm.mysqlDatabase,
|
||||
mysql_user: databaseForm.mysqlUser,
|
||||
mysql_password: databaseForm.mysqlPassword,
|
||||
};
|
||||
}
|
||||
|
||||
async function testDatabase() {
|
||||
await guarded(async () => {
|
||||
await api("/api/admin/database/test", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ provider: databaseForm.provider, sqlite_path: databaseForm.sqlitePath, mysql_dsn: databaseForm.mysqlDsn }),
|
||||
body: JSON.stringify(databasePayload()),
|
||||
});
|
||||
setToast("数据库连接测试通过");
|
||||
});
|
||||
}
|
||||
|
||||
async function saveDatabase() {
|
||||
await guarded(async () => {
|
||||
const data = await api<{ database: any; config: any }>("/api/admin/database/save", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(databasePayload()),
|
||||
});
|
||||
database.value = data.database;
|
||||
databaseConfig.value = data.config;
|
||||
databaseFormEditing.value = false;
|
||||
applyDatabaseConfig(data.config || {}, data.database || {});
|
||||
databaseConfigCollapsed.value = true;
|
||||
setToast("数据库配置已测试、保存并热切换");
|
||||
await loadDatabase({ previewLegacy: false, preserveForm: true });
|
||||
});
|
||||
}
|
||||
|
||||
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 };
|
||||
setToast(direction === "import" ? "SQLite 已导入远端库" : "远端库已同步回本地");
|
||||
const result = databaseLastSync.value || {};
|
||||
if (result.skipped) {
|
||||
setToast(result.warnings?.[0] || "远端 MySQL 未配置,同步已跳过", "warn");
|
||||
} else {
|
||||
setToast(direction === "import" ? "SQLite 已导入远端库" : "远端库已同步回本地");
|
||||
}
|
||||
await loadDatabase({ previewLegacy: false });
|
||||
});
|
||||
}
|
||||
|
||||
function editDatabaseConfig() {
|
||||
databaseFormEditing.value = true;
|
||||
databaseConfigCollapsed.value = false;
|
||||
}
|
||||
|
||||
function markDatabaseFormEditing() {
|
||||
databaseFormEditing.value = true;
|
||||
}
|
||||
|
||||
async function reloadDatabaseConfig() {
|
||||
databaseFormEditing.value = false;
|
||||
await guarded(async () => {
|
||||
await loadDatabase({ previewLegacy: false });
|
||||
setToast("数据库配置已从服务端重新读取");
|
||||
});
|
||||
}
|
||||
|
||||
function databaseConfigSummary() {
|
||||
const config = databaseConfig.value || {};
|
||||
if (databaseForm.provider === "mysql") {
|
||||
const host = config.mysqlHost || databaseForm.mysqlHost || "127.0.0.1";
|
||||
const port = config.mysqlPort || databaseForm.mysqlPort || 3306;
|
||||
const databaseName = config.mysqlDatabase || databaseForm.mysqlDatabase || "-";
|
||||
const user = config.mysqlUser || databaseForm.mysqlUser || "-";
|
||||
return `${host}:${port} / ${databaseName} / ${user}${config.hasPassword ? " / 已保存密码" : ""}`;
|
||||
}
|
||||
return config.sqlitePath || databaseForm.sqlitePath || "使用默认 SQLite 路径";
|
||||
}
|
||||
|
||||
async function loadMigrationStatus() {
|
||||
const data = await api<{ migration: any }>("/api/admin/system/migration");
|
||||
migrationStatus.value = data.migration || null;
|
||||
}
|
||||
|
||||
async function loadBranding() {
|
||||
const data = await api<{ branding: any }>("/api/admin/system/branding");
|
||||
Object.assign(branding, {
|
||||
siteIconUrl: data.branding?.siteIconUrl || branding.siteIconUrl,
|
||||
developerAvatarUrl: data.branding?.developerAvatarUrl || branding.developerAvatarUrl,
|
||||
developerName: data.branding?.developerName || "YMhut",
|
||||
feedbackEmail: data.branding?.feedbackEmail || "support@ymhut.cn",
|
||||
});
|
||||
}
|
||||
|
||||
async function saveBranding() {
|
||||
await guarded(async () => {
|
||||
const data = await api<{ branding: any }>("/api/admin/system/branding", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
siteIconUrl: branding.siteIconUrl,
|
||||
developerAvatarUrl: branding.developerAvatarUrl,
|
||||
developerName: branding.developerName,
|
||||
feedbackEmail: branding.feedbackEmail,
|
||||
}),
|
||||
});
|
||||
Object.assign(branding, data.branding || {});
|
||||
if (!mailConfig.developerAddress) mailConfig.developerAddress = branding.feedbackEmail;
|
||||
setToast("站点品牌信息已保存");
|
||||
});
|
||||
}
|
||||
|
||||
async function loadMailConfig(options: LoadMailOptions = {}) {
|
||||
const data = await api<{ config: any }>("/api/admin/system/mail/config");
|
||||
if (!options.preserveForm || !mailConfigEditing.value) {
|
||||
Object.assign(mailConfig, {
|
||||
host: data.config?.host || "",
|
||||
port: Number(data.config?.port || 465),
|
||||
secure: data.config?.secure || "ssl",
|
||||
username: data.config?.username || "",
|
||||
password: "",
|
||||
fromAddress: data.config?.fromAddress || "",
|
||||
fromName: data.config?.fromName || "YMhut Box Feedback",
|
||||
developerAddress: data.config?.developerAddress || "",
|
||||
timeoutSeconds: Number(data.config?.timeoutSeconds || 20),
|
||||
hasPassword: Boolean(data.config?.hasPassword),
|
||||
configured: Boolean(data.config?.configured),
|
||||
});
|
||||
mailConfigEditing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function mailPayload() {
|
||||
return {
|
||||
host: mailConfig.host,
|
||||
port: Number(mailConfig.port || 465),
|
||||
secure: mailConfig.secure,
|
||||
username: mailConfig.username,
|
||||
password: mailConfig.password,
|
||||
from_address: mailConfig.fromAddress,
|
||||
from_name: mailConfig.fromName,
|
||||
developer_address: mailConfig.developerAddress,
|
||||
timeout_seconds: Number(mailConfig.timeoutSeconds || 20),
|
||||
};
|
||||
}
|
||||
|
||||
async function saveMailConfig() {
|
||||
await guarded(async () => {
|
||||
const data = await api<{ config: any }>("/api/admin/system/mail/config", { method: "POST", body: JSON.stringify(mailPayload()) });
|
||||
mailConfigEditing.value = false;
|
||||
Object.assign(mailConfig, { ...data.config, password: "" });
|
||||
setToast("邮件通知配置已保存");
|
||||
});
|
||||
}
|
||||
|
||||
async function testMail() {
|
||||
await guarded(async () => {
|
||||
await api("/api/admin/system/mail/test", { method: "POST", body: "{}" });
|
||||
setToast("测试邮件已发送");
|
||||
await loadMailConfig({ preserveForm: true });
|
||||
});
|
||||
}
|
||||
|
||||
function markMailConfigEditing() {
|
||||
mailConfigEditing.value = true;
|
||||
}
|
||||
|
||||
async function reloadMailConfig() {
|
||||
mailConfigEditing.value = false;
|
||||
await guarded(async () => {
|
||||
await loadMailConfig();
|
||||
setToast("邮件配置已从服务端重新读取");
|
||||
});
|
||||
}
|
||||
|
||||
async function retryFeedbackMail() {
|
||||
if (!selectedFeedback.value) return;
|
||||
await guarded(async () => {
|
||||
await api(`/api/admin/feedbacks/${encodeURIComponent(selectedFeedback.value.code)}/mail/retry`, { method: "POST", body: "{}" });
|
||||
setToast("反馈邮件已重新发送");
|
||||
await openFeedback(selectedFeedback.value);
|
||||
await loadFeedbacks();
|
||||
});
|
||||
}
|
||||
|
||||
async function previewLegacySync() {
|
||||
legacySyncMode.value = "preview";
|
||||
legacySync.value = await api("/api/admin/sync/legacy/preview").catch((error) => ({ ok: false, errors: [String(error)] }));
|
||||
@@ -803,7 +1192,7 @@ async function runLegacySync() {
|
||||
legacySyncMode.value = "run";
|
||||
legacySync.value = await api("/api/admin/sync/legacy/run", { method: "POST", body: "{}" });
|
||||
setToast("旧项目同步已完成");
|
||||
await Promise.all([loadDatabase({ previewLegacy: false }), loadReleases().catch(() => undefined), loadSources().catch(() => undefined)]);
|
||||
await Promise.all([loadDatabase({ previewLegacy: false, preserveForm: true }), loadReleases().catch(() => undefined), loadSources().catch(() => undefined)]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -812,8 +1201,31 @@ async function loadHealth() {
|
||||
}
|
||||
|
||||
async function loadAudit() {
|
||||
const data = await api<{ items: any[] }>("/api/admin/system/audit");
|
||||
auditLogs.value = data.items || [];
|
||||
const params = new URLSearchParams({
|
||||
page: String(auditPage.page || 1),
|
||||
perPage: String(auditPage.perPage || 35),
|
||||
});
|
||||
if (auditPage.q) params.set("q", auditPage.q);
|
||||
if (auditPage.type) params.set("type", auditPage.type);
|
||||
if (auditPage.target) params.set("target", auditPage.target);
|
||||
const data = await api<{ items: any[]; page?: any }>(`/api/admin/system/audit?${params}`);
|
||||
const page = data.page || { items: data.items || [], total: data.items?.length || 0, page: auditPage.page, perPage: auditPage.perPage };
|
||||
auditLogs.value = page.items || [];
|
||||
Object.assign(auditPage, {
|
||||
items: page.items || [],
|
||||
total: Number(page.total || 0),
|
||||
page: Number(page.page || auditPage.page || 1),
|
||||
perPage: Number(page.perPage || auditPage.perPage || 35),
|
||||
});
|
||||
}
|
||||
|
||||
function setAuditPage(page: number) {
|
||||
auditPage.page = Math.max(1, page);
|
||||
void loadAudit();
|
||||
}
|
||||
|
||||
function selectAuditLog(item: any) {
|
||||
auditPage.selected = item;
|
||||
}
|
||||
|
||||
async function changePassword() {
|
||||
@@ -832,9 +1244,9 @@ function endpointStatus(item: any) {
|
||||
|
||||
function statusTone(status: string) {
|
||||
const value = String(status || "").toLowerCase();
|
||||
if (["ok", "online", "new", "sqlite", "mysql", "sent", "ready"].includes(value)) return "good";
|
||||
if (["redirected", "degraded", "pending", "processing", "queued", "missing"].includes(value)) return "warn";
|
||||
if (["error", "failed", "closed", "offline"].includes(value)) return "bad";
|
||||
if (["ok", "online", "new", "sqlite", "mysql", "sent", "ready", "completed"].includes(value)) return "good";
|
||||
if (["redirected", "degraded", "pending", "processing", "queued", "missing", "skipped", "running", "normal"].includes(value)) return "warn";
|
||||
if (["error", "failed", "closed", "offline", "urgent", "high", "blocking", "major"].includes(value)) return "bad";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
@@ -852,11 +1264,30 @@ function labelStatus(value: string) {
|
||||
new: "新建",
|
||||
processing: "处理中",
|
||||
closed: "已关闭",
|
||||
pending: "待发送",
|
||||
sent: "已发送",
|
||||
skipped: "已跳过",
|
||||
running: "执行中",
|
||||
completed: "已完成",
|
||||
failed: "失败",
|
||||
};
|
||||
return labels[value] || value || "未知";
|
||||
}
|
||||
|
||||
function labelPriority(value: string) {
|
||||
const labels: Record<string, string> = {
|
||||
low: "低",
|
||||
minor: "低",
|
||||
normal: "普通",
|
||||
medium: "普通",
|
||||
high: "高",
|
||||
major: "高",
|
||||
urgent: "紧急",
|
||||
blocking: "紧急",
|
||||
};
|
||||
return labels[String(value || "").toLowerCase()] || value || "普通";
|
||||
}
|
||||
|
||||
function auditTypeLabel(value: string) {
|
||||
const labels: Record<string, string> = {
|
||||
"auth.login": "管理员登录",
|
||||
@@ -892,6 +1323,16 @@ function databaseSyncDirectionLabel(value: string) {
|
||||
return value || "-";
|
||||
}
|
||||
|
||||
function databaseSyncStatusLabel(value: string) {
|
||||
const labels: Record<string, string> = {
|
||||
completed: "已完成",
|
||||
skipped: "已跳过",
|
||||
running: "执行中",
|
||||
failed: "失败",
|
||||
};
|
||||
return labels[String(value || "").toLowerCase()] || value || "-";
|
||||
}
|
||||
|
||||
function databaseSyncTableCount(result: any) {
|
||||
const tables = result?.tables || {};
|
||||
return Object.values(tables).reduce((total: number, value: any) => total + Number(value || 0), 0);
|
||||
@@ -914,6 +1355,12 @@ function timeLabel(value: string) {
|
||||
return value.length > 10 ? value.slice(11, 19) : value;
|
||||
}
|
||||
|
||||
function heartbeatTimeValue(value: string) {
|
||||
if (!value) return Date.now();
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : value;
|
||||
}
|
||||
|
||||
function pretty(value: any) {
|
||||
return JSON.stringify(value || {}, null, 2);
|
||||
}
|
||||
@@ -968,7 +1415,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/system") void loadSystem();
|
||||
if (currentPath.value === "/admin/system") void loadSystem({ preserveForms: true });
|
||||
};
|
||||
for (const name of ["source_check.item", "source_check.progress", "source_check.completed", "heartbeat"]) {
|
||||
events.addEventListener(name, refreshCurrent);
|
||||
@@ -1017,8 +1464,11 @@ function connectAdminEvents() {
|
||||
<main v-else class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<span class="brand-mark"><ShieldCheck :size="22" /></span>
|
||||
<div><strong>YMhut</strong><small>统一管理台</small></div>
|
||||
<span class="brand-mark">
|
||||
<img v-if="branding.siteIconUrl" :src="branding.siteIconUrl" alt="YMhut" />
|
||||
<ShieldCheck v-else :size="22" />
|
||||
</span>
|
||||
<div><strong>{{ branding.developerName || "YMhut" }}</strong><small>统一管理台</small></div>
|
||||
</div>
|
||||
<nav class="nav-groups">
|
||||
<section v-for="group in navGroups" :key="group.label" class="nav-group">
|
||||
|
||||
Reference in New Issue
Block a user