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

This commit is contained in:
QWQLwToo
2026-06-27 18:09:11 +08:00
parent 2513eb2903
commit 962a2f2143
56 changed files with 4564 additions and 714 deletions
+480 -30
View File
@@ -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">