@@ -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">
|
||||
|
||||
@@ -24,7 +24,7 @@ const exactMessages: Record<string, string> = {
|
||||
"file is required": "请选择要上传的文件",
|
||||
"invalid filename": "文件名不合法",
|
||||
"path escape rejected": "文件路径不合法",
|
||||
"check job not found": "未找到心跳检测任务",
|
||||
"check job not found": "未找到服务端检测任务",
|
||||
"streaming is not supported": "当前运行环境不支持实时事件流",
|
||||
};
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ 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 filters = reactive({ q: "", status: "", priority: "", page: 1, perPage: 20 });
|
||||
const update = reactive({ status: "", priority: "", statusDetail: "", publicReply: "" });
|
||||
const commentDraft = reactive({ body: "", internal: true });
|
||||
|
||||
return { page, selected, filters, update, commentDraft };
|
||||
|
||||
@@ -5,10 +5,18 @@ 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 modal = reactive({
|
||||
open: false,
|
||||
type: "",
|
||||
categoryIndex: -1,
|
||||
itemIndex: -1,
|
||||
draft: {} as any,
|
||||
});
|
||||
const activeMediaCategoryIndex = ref(0);
|
||||
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 };
|
||||
return { sync, documents, drafts, modal, activeMediaCategoryIndex };
|
||||
}
|
||||
|
||||
@@ -2,11 +2,52 @@ import { reactive, ref } from "vue";
|
||||
|
||||
export function createSystemStore() {
|
||||
const database = ref<any>(null);
|
||||
const databaseConfig = 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 auditPage = reactive({
|
||||
items: [] as any[],
|
||||
total: 0,
|
||||
page: 1,
|
||||
perPage: 35,
|
||||
q: "",
|
||||
type: "",
|
||||
target: "",
|
||||
selected: null as any | null,
|
||||
});
|
||||
const migrationStatus = ref<any>(null);
|
||||
const branding = reactive({
|
||||
siteIconUrl: "https://img.ymhut.cn/file/1782108850041_icon.webp",
|
||||
developerAvatarUrl: "https://img.ymhut.cn/file/1782108780690_b_3db45f3787f19192c8de8e06bc0987ef.webp",
|
||||
developerName: "YMhut",
|
||||
feedbackEmail: "support@ymhut.cn",
|
||||
});
|
||||
const databaseForm = reactive({
|
||||
provider: "sqlite",
|
||||
sqlitePath: "",
|
||||
mysqlHost: "127.0.0.1",
|
||||
mysqlPort: 3306,
|
||||
mysqlDatabase: "",
|
||||
mysqlUser: "",
|
||||
mysqlPassword: "",
|
||||
mysqlDsn: "",
|
||||
});
|
||||
const databaseConfigCollapsed = ref(true);
|
||||
const mailConfig = reactive({
|
||||
host: "",
|
||||
port: 465,
|
||||
secure: "ssl",
|
||||
username: "",
|
||||
password: "",
|
||||
fromAddress: "",
|
||||
fromName: "YMhut Box Feedback",
|
||||
developerAddress: "",
|
||||
timeoutSeconds: 20,
|
||||
hasPassword: false,
|
||||
configured: false,
|
||||
});
|
||||
const legacySyncMode = ref<"preview" | "run">("preview");
|
||||
|
||||
return { database, databaseLastSync, healthSnapshot, auditLogs, databaseForm, legacySyncMode };
|
||||
return { database, databaseConfig, databaseLastSync, healthSnapshot, auditLogs, auditPage, migrationStatus, branding, databaseForm, databaseConfigCollapsed, mailConfig, legacySyncMode };
|
||||
}
|
||||
|
||||
@@ -115,6 +115,8 @@ input:focus, textarea:focus, select:focus {
|
||||
.btn.ghost { background: transparent; }
|
||||
.btn.compact { min-height: 30px; padding: 5px 8px; font-size: 12px; }
|
||||
.btn.full { width: 100%; }
|
||||
.btn.danger { color: var(--bad); border-color: #f0b8b1; }
|
||||
.btn.danger:hover { background: var(--bad-bg); color: var(--bad); }
|
||||
.button-row, .top-actions, .toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
.alert-line, .notice {
|
||||
@@ -172,6 +174,7 @@ input:focus, textarea:focus, select:focus {
|
||||
}
|
||||
.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-mark img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; display: block; }
|
||||
.brand strong { display: block; }
|
||||
.brand small { display: block; color: var(--muted); margin-top: 2px; }
|
||||
.brand > div { min-width: 0; overflow: hidden; }
|
||||
@@ -229,6 +232,13 @@ input:focus, textarea:focus, select:focus {
|
||||
padding: 16px;
|
||||
box-shadow: 0 1px 2px rgba(17, 24, 39, 0.04);
|
||||
}
|
||||
.panel-soft {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--panel-soft);
|
||||
padding: 14px;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
@@ -242,9 +252,26 @@ 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-panel-relative { position: relative; }
|
||||
.chart { min-height: 260px; width: 100%; flex: 1; }
|
||||
.chart-empty {
|
||||
position: absolute;
|
||||
inset: 56px 16px 16px;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
gap: 6px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 10px;
|
||||
background: rgba(248, 250, 252, 0.84);
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.chart-empty strong { color: var(--ink); }
|
||||
.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); }
|
||||
.legacy-media-editor { grid-template-columns: minmax(340px, 0.95fr) minmax(0, 1.05fr); }
|
||||
.legacy-media-editor > * { min-width: 0; }
|
||||
|
||||
.search-box {
|
||||
min-width: min(420px, 100%);
|
||||
@@ -300,6 +327,48 @@ hr { border: 0; border-top: 1px solid var(--line); width: 100%; margin: 12px 0;
|
||||
.empty-state.compact { min-height: 96px; border: 1px dashed var(--line); border-radius: 6px; }
|
||||
.source-group { margin-top: 12px; }
|
||||
.source-group h3 { display: flex; align-items: center; gap: 8px; }
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
.media-subcategory-panel { min-width: 0; overflow: hidden; }
|
||||
.media-subcategory-table table { table-layout: fixed; min-width: 680px; }
|
||||
.media-subcategory-table th:nth-child(1), .media-subcategory-table td:nth-child(1) { width: 170px; }
|
||||
.media-subcategory-table th:nth-child(2), .media-subcategory-table td:nth-child(2) { width: 120px; }
|
||||
.media-subcategory-table th:nth-child(3), .media-subcategory-table td:nth-child(3) { width: 72px; }
|
||||
.media-subcategory-table th:nth-child(5), .media-subcategory-table td:nth-child(5) { width: 150px; }
|
||||
.media-subcategory-table .button-row { flex-wrap: nowrap; }
|
||||
.url-cell {
|
||||
max-width: 1px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.category-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.category-list button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.18s ease, background-color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
.category-list button:hover, .category-list button.active {
|
||||
border-color: rgba(37, 99, 235, 0.35);
|
||||
background: var(--primary-soft);
|
||||
box-shadow: 0 8px 20px rgba(37, 99, 235, 0.08);
|
||||
}
|
||||
.category-list span:first-child { min-width: 0; display: grid; gap: 2px; }
|
||||
.category-list strong, .category-list small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.code-editor { min-height: 56dvh; white-space: pre; overflow: auto; font-size: 13px; }
|
||||
.compact-editor { min-height: 260px; }
|
||||
details {
|
||||
@@ -334,6 +403,7 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
|
||||
}
|
||||
.revision-list button:hover, .revision-list button.active { border-color: var(--primary); background: #f8fbff; }
|
||||
.revision-list small { display: block; color: var(--muted); margin-top: 3px; }
|
||||
.compact-side { gap: 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; }
|
||||
@@ -375,6 +445,63 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
|
||||
line-height: 1.55;
|
||||
}
|
||||
.ops-note svg { flex: 0 0 auto; margin-top: 3px; }
|
||||
.plain-list { margin: 0; padding-left: 18px; color: var(--muted); line-height: 1.8; }
|
||||
.asset-row {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
padding: 10px;
|
||||
}
|
||||
.asset-row span { color: var(--muted); }
|
||||
.asset-row code { overflow-wrap: anywhere; font-size: 12px; color: var(--primary-dark); }
|
||||
.brand-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--panel-soft);
|
||||
padding: 10px;
|
||||
}
|
||||
.brand-preview img {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
color: var(--muted);
|
||||
font-weight: 800;
|
||||
}
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 900;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
background: rgba(15, 23, 42, 0.42);
|
||||
}
|
||||
.modal-panel {
|
||||
width: min(720px, calc(100vw - 32px));
|
||||
max-height: calc(100dvh - 40px);
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
|
||||
@@ -21,7 +21,7 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<button class="btn primary" @click="ctx.checkSources"><Activity :size="16" />立即心跳检测</button>
|
||||
<button class="btn primary" @click="ctx.checkSources"><Activity :size="16" />立即服务端检测</button>
|
||||
<button class="btn ghost" @click="ctx.toggleAutoRefresh">
|
||||
<component :is="ctx.autoRefreshPaused ? PlayCircle : PauseCircle" :size="16" />
|
||||
{{ ctx.autoRefreshPaused ? "恢复自动刷新" : "暂停自动刷新" }}
|
||||
@@ -30,7 +30,7 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
||||
</div>
|
||||
|
||||
<section v-if="ctx.sourceCheckJobs.length" class="panel">
|
||||
<div class="section-head"><h2>心跳检测任务</h2><span class="badge">{{ ctx.sourceCheckJobs[0].status }}</span></div>
|
||||
<div class="section-head"><h2>服务端检测任务</h2><span class="badge">{{ ctx.sourceCheckJobs[0].status }}</span></div>
|
||||
<table>
|
||||
<thead><tr><th>任务</th><th>进度</th><th>正常</th><th>重定向</th><th>降级</th><th>错误</th><th>开始时间</th></tr></thead>
|
||||
<tbody>
|
||||
@@ -48,14 +48,21 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
||||
</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 chart-panel-relative">
|
||||
<h2>服务端接口延迟</h2>
|
||||
<VChart class="chart" :option="ctx.heartbeatOption" autoresize />
|
||||
<div v-if="ctx.isHeartbeatChartEmpty" class="chart-empty">
|
||||
<strong>暂无服务端检测记录</strong>
|
||||
<span>点击“立即服务端检测”后会生成延迟曲线。</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel chart-panel"><h2>接口健康分布</h2><VChart class="chart" :option="ctx.healthOption" autoresize /></section>
|
||||
<section class="panel chart-panel"><h2>反馈状态分布</h2><VChart class="chart" :option="ctx.feedbackOption" autoresize /></section>
|
||||
<section class="panel chart-panel"><h2>服务可用率</h2><VChart class="chart" :option="ctx.availabilityOption" autoresize /></section>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-head"><h2>最近接口心跳</h2><span class="badge">{{ ctx.heartbeats.length }} 条</span></div>
|
||||
<div class="section-head"><h2>最近服务端检测</h2><span class="badge">{{ ctx.heartbeats.length }} 条</span></div>
|
||||
<table>
|
||||
<thead><tr><th>接口</th><th>状态</th><th>延迟</th><th>错误</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
@@ -66,7 +73,7 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
||||
<td class="hash">{{ item.error || "-" }}</td>
|
||||
<td>{{ item.checkedAt || "-" }}</td>
|
||||
</tr>
|
||||
<tr v-if="ctx.heartbeats.length === 0"><td colspan="5">暂无心跳记录,点击“立即心跳检测”后会刷新。</td></tr>
|
||||
<tr v-if="ctx.heartbeats.length === 0"><td colspan="5">暂无检测记录,点击“立即服务端检测”后会刷新。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { Pencil, Trash2 } from "lucide-vue-next";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel page-stack">
|
||||
<div class="section-head"><h2>客户端动态接口</h2><span class="badge">{{ ctx.visibleEndpointCount }} 可见 / {{ ctx.healthyEndpointCount }} 健康</span></div>
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>客户端动态接口</h2>
|
||||
<p class="muted">删除接口后会由服务端重新生成兼容媒体源 JSON 和更新 JSON。</p>
|
||||
</div>
|
||||
<span class="badge">{{ ctx.visibleEndpointCount }} 可见 / {{ ctx.healthyEndpointCount }} 健康</span>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>分类</th><th>模式</th><th>健康</th><th>缓存</th><th>URL</th><th></th></tr></thead>
|
||||
<thead><tr><th>ID</th><th>分类</th><th>模式</th><th>健康</th><th>缓存</th><th>URL</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in ctx.endpoints" :key="item.id || item.sourceId">
|
||||
<td class="mono">{{ item.id || item.sourceId }}</td>
|
||||
@@ -17,8 +25,13 @@ defineProps<{ ctx: any }>();
|
||||
<span v-if="ctx.endpointStatus(item) === 'redirected' || item.health?.meta?.redirected" class="badge warn">重定向接口</span>
|
||||
</td>
|
||||
<td>{{ item.cacheSeconds || 0 }}s</td>
|
||||
<td class="hash">{{ item.urlTemplate || item.apiUrl }}</td>
|
||||
<td><button class="btn ghost compact" @click="ctx.copyEndpointToSource(item)">编辑</button></td>
|
||||
<td class="hash">{{ item.resolvedUrl || item.urlTemplate || item.apiUrl }}</td>
|
||||
<td>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost compact" @click="ctx.copyEndpointToSource(item)"><Pencil :size="14" />编辑</button>
|
||||
<button class="btn ghost compact danger" @click="ctx.deleteEndpoint(item)"><Trash2 :size="14" />删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="ctx.endpoints.length === 0"><td colspan="7">暂无客户端接口。</td></tr>
|
||||
</tbody>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Save, Search, UploadCloud } from "lucide-vue-next";
|
||||
import { Mail, Save, Search, UploadCloud } from "lucide-vue-next";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
</script>
|
||||
@@ -8,44 +8,107 @@ defineProps<{ ctx: any }>();
|
||||
<section class="split">
|
||||
<section class="panel page-stack">
|
||||
<div class="toolbar">
|
||||
<label class="search-box"><Search :size="16" /><input v-model="ctx.feedbackFilters.q" placeholder="搜索编号、标题、联系方式" @keyup.enter="ctx.loadFeedbacks" /></label>
|
||||
<select v-model="ctx.feedbackFilters.status" @change="ctx.loadFeedbacks"><option value="">全部状态</option><option value="new">new</option><option value="processing">processing</option><option value="closed">closed</option></select>
|
||||
<label class="search-box">
|
||||
<Search :size="16" />
|
||||
<input v-model="ctx.feedbackFilters.q" placeholder="搜索编号、标题、联系方式" @keyup.enter="ctx.loadFeedbacks" />
|
||||
</label>
|
||||
<select v-model="ctx.feedbackFilters.status" @change="ctx.loadFeedbacks">
|
||||
<option value="">全部状态</option>
|
||||
<option value="new">新建</option>
|
||||
<option value="processing">处理中</option>
|
||||
<option value="closed">已关闭</option>
|
||||
</select>
|
||||
<select v-model="ctx.feedbackFilters.priority" @change="ctx.loadFeedbacks">
|
||||
<option value="">全部优先级</option>
|
||||
<option value="low">低</option>
|
||||
<option value="normal">普通</option>
|
||||
<option value="high">高</option>
|
||||
<option value="urgent">紧急</option>
|
||||
</select>
|
||||
<button class="btn ghost" @click="ctx.loadFeedbacks">查询</button>
|
||||
<a class="btn ghost" href="/api/admin/feedbacks/export" target="_blank"><UploadCloud :size="16" />CSV</a>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>编号</th><th>标题</th><th>状态</th><th>优先级</th><th>最近活动</th></tr></thead>
|
||||
<thead><tr><th>编号</th><th>标题</th><th>状态</th><th>优先级</th><th>邮件</th><th>最近活动</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in ctx.feedbackPage.items" :key="item.code" class="clickable" :class="{ selected: ctx.selectedFeedback?.code === item.code }" @click="ctx.openFeedback(item)">
|
||||
<td class="mono">{{ item.code }}</td>
|
||||
<td>{{ item.title || item.summaryText || "未命名反馈" }}</td>
|
||||
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ item.status }}</span></td>
|
||||
<td>{{ item.priority || "-" }}</td>
|
||||
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ ctx.labelStatus(item.status) }}</span></td>
|
||||
<td><span :class="['badge', ctx.statusTone(item.priority)]">{{ ctx.labelPriority(item.priority) }}</span></td>
|
||||
<td><span :class="['badge', item.mailSent ? 'good' : 'warn']">{{ item.mailSent ? "已发送" : "未发送" }}</span></td>
|
||||
<td>{{ item.lastActivityAt || item.createdAt }}</td>
|
||||
</tr>
|
||||
<tr v-if="!ctx.feedbackPage.items?.length"><td colspan="5">暂无反馈工单。</td></tr>
|
||||
<tr v-if="!ctx.feedbackPage.items?.length"><td colspan="6">暂无反馈工单。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<aside class="panel detail-panel">
|
||||
<template v-if="ctx.selectedFeedback">
|
||||
<h2>{{ ctx.selectedFeedback.code }}</h2>
|
||||
<div class="section-head">
|
||||
<h2>{{ ctx.selectedFeedback.code }}</h2>
|
||||
<span :class="['badge', ctx.selectedFeedback.mailSent ? 'good' : 'warn']">{{ ctx.selectedFeedback.mailSent ? "邮件已发送" : "邮件未发送" }}</span>
|
||||
</div>
|
||||
<p class="muted">{{ ctx.selectedFeedback.title || ctx.selectedFeedback.body }}</p>
|
||||
<label>状态<select v-model="ctx.feedbackUpdate.status"><option>new</option><option>processing</option><option>closed</option></select></label>
|
||||
<div class="kv-grid">
|
||||
<span>联系方式</span><strong>{{ ctx.selectedFeedback.contact || "-" }}</strong>
|
||||
<span>来源</span><strong>{{ ctx.selectedFeedback.sourceChannel || "-" }}</strong>
|
||||
<span>接收时间</span><strong>{{ ctx.selectedFeedback.createdAt || "-" }}</strong>
|
||||
</div>
|
||||
|
||||
<label>状态
|
||||
<select v-model="ctx.feedbackUpdate.status">
|
||||
<option value="new">新建</option>
|
||||
<option value="processing">处理中</option>
|
||||
<option value="closed">已关闭</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>优先级
|
||||
<select v-model="ctx.feedbackUpdate.priority">
|
||||
<option value="low">低</option>
|
||||
<option value="normal">普通</option>
|
||||
<option value="high">高</option>
|
||||
<option value="urgent">紧急</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>状态说明<input v-model="ctx.feedbackUpdate.statusDetail" /></label>
|
||||
<label>公开回复<textarea v-model="ctx.feedbackUpdate.publicReply" rows="3"></textarea></label>
|
||||
<button class="btn primary" @click="ctx.saveFeedbackUpdate"><Save :size="16" />保存状态</button>
|
||||
<div class="button-row">
|
||||
<button class="btn primary" @click="ctx.saveFeedbackUpdate"><Save :size="16" />保存工单</button>
|
||||
<button class="btn ghost" @click="ctx.retryFeedbackMail"><Mail :size="16" />重试邮件</button>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
<h3>评论</h3>
|
||||
<div class="comment-list">
|
||||
<div v-for="item in ctx.selectedFeedback.comments || []" :key="item.id" class="comment"><strong>{{ item.author }}</strong><p>{{ item.body }}</p></div>
|
||||
<div v-for="item in ctx.selectedFeedback.comments || []" :key="item.id" class="comment">
|
||||
<strong>{{ item.author }}</strong>
|
||||
<p>{{ item.body }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label>新增评论<textarea v-model="ctx.commentDraft.body" rows="3"></textarea></label>
|
||||
<label class="checkbox"><input v-model="ctx.commentDraft.internal" type="checkbox" />内部备注</label>
|
||||
<button class="btn ghost" @click="ctx.addFeedbackComment">添加评论</button>
|
||||
|
||||
<details>
|
||||
<summary>旧反馈事件 / 邮件记录</summary>
|
||||
<pre class="json-preview small">{{ ctx.pretty({ events: ctx.selectedFeedback.legacyEvents, mail: ctx.selectedFeedback.mailRecords }) }}</pre>
|
||||
<summary>邮件记录</summary>
|
||||
<table>
|
||||
<thead><tr><th>状态</th><th>收件人</th><th>主题</th><th>错误</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in ctx.selectedFeedback.mailRecords || []" :key="item.id">
|
||||
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ ctx.labelStatus(item.status) }}</span></td>
|
||||
<td>{{ item.toAddress || "-" }}</td>
|
||||
<td>{{ item.subject || "-" }}</td>
|
||||
<td>{{ item.errorMessage || "-" }}</td>
|
||||
</tr>
|
||||
<tr v-if="!(ctx.selectedFeedback.mailRecords || []).length"><td colspan="4">暂无邮件记录。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
<details>
|
||||
<summary>旧反馈事件</summary>
|
||||
<pre class="json-preview small">{{ ctx.pretty({ events: ctx.selectedFeedback.legacyEvents }) }}</pre>
|
||||
</details>
|
||||
</template>
|
||||
<div v-else class="empty-state">选择一条工单查看详情。</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircle2, Plus, Save, Trash2 } from "lucide-vue-next";
|
||||
import { CheckCircle2, Pencil, Plus, Save, Trash2 } from "lucide-vue-next";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
</script>
|
||||
@@ -9,7 +9,7 @@ defineProps<{ ctx: any }>();
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>{{ ctx.activeLegacyLabel }}</h2>
|
||||
<p class="muted">以当前兼容 JSON 为基板,表单保存会合并进原 JSON,未知字段保留。</p>
|
||||
<p class="muted">可视化表单只维护常用字段,保存时会合并回当前 JSON,未识别字段继续保留。</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost" @click="ctx.validateLegacy(ctx.activeLegacyName)"><CheckCircle2 :size="16" />校验</button>
|
||||
@@ -18,73 +18,126 @@ defineProps<{ ctx: any }>();
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'form'">可视化表单</button>
|
||||
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'form'">可视化</button>
|
||||
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'raw' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'raw'">Raw JSON</button>
|
||||
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'preview' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'preview'">预览</button>
|
||||
<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="page-stack">
|
||||
<section 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>
|
||||
<label class="wide">公告<textarea v-model="ctx.legacyDrafts['update-info'].form.message" rows="3"></textarea></label>
|
||||
<label class="wide">下载地址<input v-model="ctx.legacyDrafts['update-info'].form.download_url" /></label>
|
||||
<label>包 SHA256<input v-model="ctx.legacyDrafts['update-info'].form.package_sha256" /></label>
|
||||
<label>包大小<input v-model="ctx.legacyDrafts['update-info'].form.package_size" type="number" /></label>
|
||||
<label class="wide">发布说明<textarea v-model="ctx.legacyDrafts['update-info'].form.release_notes" rows="4"></textarea></label>
|
||||
<label class="wide">发布说明 Markdown<textarea v-model="ctx.legacyDrafts['update-info'].form.release_notes_md" rows="4"></textarea></label>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<label class="wide">公告<textarea v-model="ctx.legacyDrafts['update-info'].form.message" rows="3"></textarea></label>
|
||||
<label class="wide">下载地址<input v-model="ctx.legacyDrafts['update-info'].form.download_url" /></label>
|
||||
<label>包 SHA256<input v-model="ctx.legacyDrafts['update-info'].form.package_sha256" /></label>
|
||||
<label>包大小<input v-model="ctx.legacyDrafts['update-info'].form.package_size" type="number" /></label>
|
||||
<label class="wide">发布说明<textarea v-model="ctx.legacyDrafts['update-info'].form.release_notes" rows="4"></textarea></label>
|
||||
<label class="wide">发布说明 Markdown<textarea v-model="ctx.legacyDrafts['update-info'].form.release_notes_md" rows="5"></textarea></label>
|
||||
<label class="wide">更新说明 JSON<textarea v-model="ctx.legacyDrafts['update-info'].form.update_notes" class="code-editor mini-editor"></textarea></label>
|
||||
<label class="wide">上次更新说明 JSON<textarea v-model="ctx.legacyDrafts['update-info'].form.last_update_notes" class="code-editor mini-editor"></textarea></label>
|
||||
<div class="wide button-row">
|
||||
<button class="btn ghost" @click="ctx.addUpdateMirror"><Plus :size="16" />新增镜像字段到底稿</button>
|
||||
<button class="btn" @click="ctx.updateLegacyRawFromForm('update-info')">生成预览 JSON</button>
|
||||
</div>
|
||||
<section class="nested-card">
|
||||
<div class="section-head">
|
||||
<h3>下载镜像</h3>
|
||||
<button class="btn ghost compact" @click="ctx.openUpdateMirrorModal()"><Plus :size="14" />新增镜像</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>名称</th><th>类型</th><th>状态</th><th>URL</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="(mirror, index) in ctx.legacyDrafts['update-info'].form.download_mirrors || []" :key="mirror.id || index">
|
||||
<td class="mono">{{ mirror.id }}</td>
|
||||
<td>{{ mirror.name }}</td>
|
||||
<td>{{ mirror.type || "direct" }}</td>
|
||||
<td><span :class="['badge', mirror.enabled === false ? 'neutral' : 'good']">{{ mirror.enabled === false ? "停用" : "启用" }}</span></td>
|
||||
<td class="hash">{{ mirror.url }}</td>
|
||||
<td>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost compact" @click="ctx.openUpdateMirrorModal(index)"><Pencil :size="14" />编辑</button>
|
||||
<button class="btn ghost compact danger" @click="ctx.removeItem(ctx.legacyDrafts['update-info'].form.download_mirrors, index)"><Trash2 :size="14" />删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!(ctx.legacyDrafts['update-info'].form.download_mirrors || []).length"><td colspan="6">暂无镜像。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<details>
|
||||
<summary>高级 JSON 字段</summary>
|
||||
<div class="form-grid">
|
||||
<label class="wide">更新说明 JSON<textarea v-model="ctx.legacyDrafts['update-info'].form.update_notes" class="code-editor mini-editor"></textarea></label>
|
||||
<label class="wide">上次更新说明 JSON<textarea v-model="ctx.legacyDrafts['update-info'].form.last_update_notes" class="code-editor mini-editor"></textarea></label>
|
||||
</div>
|
||||
</details>
|
||||
<div class="button-row"><button class="btn" @click="ctx.updateLegacyRawFromForm('update-info')">生成预览 JSON</button></div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form'" class="page-stack">
|
||||
<div class="form-grid">
|
||||
<label>布局版本<input v-model="ctx.legacyDrafts['media-types'].form.layout_version" /></label>
|
||||
<label>更新时间<input v-model="ctx.legacyDrafts['media-types'].form.last_updated" /></label>
|
||||
<label class="wide">UI 配置 JSON<textarea v-model="ctx.legacyDrafts['media-types'].form.ui_config" class="code-editor mini-editor"></textarea></label>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost" @click="ctx.addMediaCategory('media-types')"><Plus :size="16" />新增分类</button>
|
||||
<button class="btn" @click="ctx.updateLegacyRawFromForm('media-types')">生成预览 JSON</button>
|
||||
</div>
|
||||
<section v-for="(cat, cIndex) in ctx.legacyDrafts['media-types'].form.categories" :key="cIndex" class="nested-card">
|
||||
<div class="section-head">
|
||||
<h3>分类 {{ cIndex + 1 }}</h3>
|
||||
<button class="btn ghost compact" @click="ctx.removeItem(ctx.legacyDrafts['media-types'].form.categories, cIndex)"><Trash2 :size="14" />删除</button>
|
||||
</div>
|
||||
<section v-else-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form'" class="split legacy-media-editor">
|
||||
<section class="panel-soft page-stack">
|
||||
<div class="form-grid">
|
||||
<label>ID<input v-model="cat.id" /></label>
|
||||
<label>名称<input v-model="cat.name" /></label>
|
||||
<label class="checkbox"><input v-model="cat.enabled" type="checkbox" />启用分类</label>
|
||||
<label>布局版本<input v-model="ctx.legacyDrafts['media-types'].form.layout_version" /></label>
|
||||
<label>更新时间<input v-model="ctx.legacyDrafts['media-types'].form.last_updated" /></label>
|
||||
<label class="wide">UI 配置 JSON<textarea v-model="ctx.legacyDrafts['media-types'].form.ui_config" class="code-editor mini-editor"></textarea></label>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost compact" @click="ctx.addMediaSubcategory(cat)"><Plus :size="14" />新增子接口</button>
|
||||
<div class="section-head">
|
||||
<h3>分类</h3>
|
||||
<button class="btn ghost compact" @click="ctx.openMediaCategoryModal()"><Plus :size="14" />新增分类</button>
|
||||
</div>
|
||||
<section v-for="(sub, sIndex) in cat.subcategories" :key="sIndex" class="nested-card inner">
|
||||
<div class="section-head">
|
||||
<h3>{{ sub.name || "子接口" }}</h3>
|
||||
<button class="btn ghost compact" @click="ctx.removeItem(cat.subcategories, sIndex)"><Trash2 :size="14" />删除</button>
|
||||
<div class="category-list" v-if="ctx.legacyDrafts['media-types'].form.categories.length">
|
||||
<button
|
||||
v-for="(cat, cIndex) in ctx.legacyDrafts['media-types'].form.categories"
|
||||
:key="cat.id || cIndex"
|
||||
type="button"
|
||||
:class="{ active: ctx.activeMediaCategoryIndex === cIndex }"
|
||||
@click="ctx.selectMediaCategory(cIndex)"
|
||||
>
|
||||
<span><strong>{{ cat.name || cat.id || `分类 ${cIndex + 1}` }}</strong><small class="mono">{{ cat.id || "-" }}</small></span>
|
||||
<span class="badge">{{ cat.subcategories?.length || 0 }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="empty-state compact">暂无分类。</div>
|
||||
</section>
|
||||
|
||||
<aside class="panel-soft page-stack media-subcategory-panel">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h3>{{ ctx.activeMediaCategory?.name || ctx.activeMediaCategory?.id || "子接口" }}</h3>
|
||||
<p class="muted">右侧仅显示当前选中分类下的子接口。</p>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label>ID<input v-model="sub.id" /></label>
|
||||
<label>名称<input v-model="sub.name" /></label>
|
||||
<label class="wide">接口 URL<input v-model="sub.api_url" /></label>
|
||||
<label>缩略图<input v-model="sub.thumbnail_url" /></label>
|
||||
<label>刷新间隔<input v-model.number="sub.refresh_interval" type="number" /></label>
|
||||
<label>格式<input v-model="sub.supported_formats" placeholder="json, xml" /></label>
|
||||
<label class="checkbox"><input v-model="sub.downloadable" type="checkbox" />可下载</label>
|
||||
<label class="wide">描述<textarea v-model="sub.description" rows="2"></textarea></label>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost compact" :disabled="!ctx.activeMediaCategory" @click="ctx.openMediaCategoryModal(ctx.activeMediaCategoryIndex)"><Pencil :size="14" />编辑分类</button>
|
||||
<button class="btn ghost compact" :disabled="!ctx.activeMediaCategory" @click="ctx.openMediaSubcategoryModal(ctx.activeMediaCategoryIndex)"><Plus :size="14" />新增子接口</button>
|
||||
<button class="btn" @click="ctx.updateLegacyRawFromForm('media-types')">生成预览 JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
<section v-if="ctx.activeMediaCategory" class="source-group">
|
||||
<div class="button-row">
|
||||
<span :class="['badge', ctx.activeMediaCategory.enabled === false ? 'neutral' : 'good']">{{ ctx.activeMediaCategory.enabled === false ? "停用" : "启用" }}</span>
|
||||
<button class="btn ghost compact danger" @click="ctx.removeItem(ctx.legacyDrafts['media-types'].form.categories, ctx.activeMediaCategoryIndex)"><Trash2 :size="14" />删除分类</button>
|
||||
</div>
|
||||
<div class="table-scroll media-subcategory-table">
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>名称</th><th>刷新</th><th>URL</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="(sub, sIndex) in ctx.activeMediaCategory.subcategories || []" :key="sub.id || sIndex">
|
||||
<td class="mono">{{ sub.id }}</td>
|
||||
<td>{{ sub.name }}</td>
|
||||
<td>{{ sub.refresh_interval || 300 }}s</td>
|
||||
<td class="hash url-cell" :title="sub.api_url">{{ sub.api_url }}</td>
|
||||
<td>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost compact" @click="ctx.openMediaSubcategoryModal(ctx.activeMediaCategoryIndex, sIndex)"><Pencil :size="14" />编辑</button>
|
||||
<button class="btn ghost compact danger" @click="ctx.removeItem(ctx.activeMediaCategory.subcategories, sIndex)"><Trash2 :size="14" />删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!(ctx.activeMediaCategory.subcategories || []).length"><td colspan="5">当前分类暂无子接口。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
<div v-else class="empty-state compact">请选择或新增一个分类。</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section v-else-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'raw'" class="page-stack">
|
||||
@@ -102,5 +155,46 @@ defineProps<{ ctx: any }>();
|
||||
</button>
|
||||
<div v-if="(ctx.legacyDocuments[ctx.activeLegacyName]?.revisions || []).length === 0" class="empty-state compact">暂无历史版本。</div>
|
||||
</section>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="ctx.legacyModal.open" class="modal-backdrop" @click.self="ctx.closeLegacyModal">
|
||||
<section class="modal-panel">
|
||||
<div class="section-head">
|
||||
<h2>{{ ctx.legacyModal.type === 'media-category' ? '分类' : ctx.legacyModal.type === 'media-subcategory' ? '子接口' : '下载镜像' }}</h2>
|
||||
<button class="btn ghost compact" @click="ctx.closeLegacyModal">关闭</button>
|
||||
</div>
|
||||
|
||||
<div v-if="ctx.legacyModal.type === 'media-category'" class="form-grid">
|
||||
<label>ID<input v-model="ctx.legacyModal.draft.id" /></label>
|
||||
<label>名称<input v-model="ctx.legacyModal.draft.name" /></label>
|
||||
<label class="checkbox wide"><input v-model="ctx.legacyModal.draft.enabled" type="checkbox" />启用分类</label>
|
||||
</div>
|
||||
|
||||
<div v-else-if="ctx.legacyModal.type === 'media-subcategory'" class="form-grid">
|
||||
<label>ID<input v-model="ctx.legacyModal.draft.id" /></label>
|
||||
<label>名称<input v-model="ctx.legacyModal.draft.name" /></label>
|
||||
<label class="wide">接口 URL<input v-model="ctx.legacyModal.draft.api_url" /></label>
|
||||
<label>缩略图<input v-model="ctx.legacyModal.draft.thumbnail_url" /></label>
|
||||
<label>刷新间隔<input v-model.number="ctx.legacyModal.draft.refresh_interval" type="number" /></label>
|
||||
<label>格式<input v-model="ctx.legacyModal.draft.supported_formats" placeholder="json, mp4, webp" /></label>
|
||||
<label class="checkbox"><input v-model="ctx.legacyModal.draft.downloadable" type="checkbox" />可下载</label>
|
||||
<label class="wide">描述<textarea v-model="ctx.legacyModal.draft.description" rows="2"></textarea></label>
|
||||
</div>
|
||||
|
||||
<div v-else class="form-grid">
|
||||
<label>ID<input v-model="ctx.legacyModal.draft.id" /></label>
|
||||
<label>名称<input v-model="ctx.legacyModal.draft.name" /></label>
|
||||
<label>类型<input v-model="ctx.legacyModal.draft.type" /></label>
|
||||
<label class="wide">URL<input v-model="ctx.legacyModal.draft.url" /></label>
|
||||
<label>SHA256<input v-model="ctx.legacyModal.draft.sha256" /></label>
|
||||
<label class="checkbox"><input v-model="ctx.legacyModal.draft.enabled" type="checkbox" />启用</label>
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button class="btn primary" @click="ctx.applyLegacyModal">保存</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -56,13 +56,18 @@ defineProps<{ ctx: any }>();
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<aside class="panel editor-panel">
|
||||
<div class="section-head"><h2>版本日志</h2><button class="btn ghost" @click="ctx.importNotices">导入目录</button></div>
|
||||
<aside class="panel editor-panel compact-side">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>版本日志</h2>
|
||||
<p class="muted">以 update-info.json 模板为基础动态生成更新信息。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="revision-list">
|
||||
<button v-for="item in ctx.releaseNotices" :key="item.version" :class="{ active: ctx.noticeDraft.version === item.version }" @click="ctx.openNotice(item.version)">
|
||||
<strong>{{ item.version }}</strong><small>{{ item.title || item.updatedAt }}</small>
|
||||
</button>
|
||||
<div v-if="ctx.releaseNotices.length === 0" class="empty-state compact">暂无版本日志,可先执行导入。</div>
|
||||
<div v-if="ctx.releaseNotices.length === 0" class="empty-state compact">暂无版本日志,可直接填写版本和 Raw JSON 后保存。</div>
|
||||
</div>
|
||||
<label>版本<input v-model="ctx.noticeDraft.version" placeholder="1.0.0" /></label>
|
||||
<label>Raw JSON<textarea v-model="ctx.noticeDraft.raw" class="code-editor compact-editor"></textarea></label>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { Activity, AlertTriangle, ArrowDownUp, Clock3, Database, KeyRound, ListChecks, RefreshCw, ShieldCheck } from "lucide-vue-next";
|
||||
import { Activity, AlertTriangle, ArrowDownUp, Clock3, Database, HardDrive, KeyRound, ListChecks, Mail, RefreshCw, Save, ShieldCheck, UserRound } from "lucide-vue-next";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
|
||||
const tabs = [
|
||||
{ id: "database", label: "数据库", icon: Database },
|
||||
{ id: "migration", label: "迁移状态", icon: HardDrive },
|
||||
{ id: "sync", label: "旧项目同步", icon: RefreshCw },
|
||||
{ id: "security", label: "安全设置", icon: ShieldCheck },
|
||||
{ id: "security", label: "安全与邮件", icon: ShieldCheck },
|
||||
{ id: "health", label: "健康快照", icon: Activity },
|
||||
{ id: "audit", label: "审计日志", icon: ListChecks },
|
||||
];
|
||||
@@ -15,13 +16,7 @@ const tabs = [
|
||||
<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)"
|
||||
>
|
||||
<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>
|
||||
@@ -43,35 +38,86 @@ const tabs = [
|
||||
</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><span><ArrowDownUp :size="15" />最近方向</span><strong>{{ ctx.databaseSyncDirectionLabel(ctx.databaseLastSync?.direction) }}</strong></div>
|
||||
<div><span><ListChecks :size="15" />最近状态</span><strong>{{ ctx.databaseSyncStatusLabel(ctx.databaseLastSync?.status) }}</strong></div>
|
||||
<div><span><Clock3 :size="15" />影响记录</span><strong>{{ ctx.databaseSyncTableCount(ctx.databaseLastSync) }}</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="ops-note">
|
||||
<div v-if="ctx.databaseLastSync?.warnings?.length" class="ops-note">
|
||||
<AlertTriangle :size="16" />
|
||||
<span>数据库同步是覆盖式全表 upsert。执行前确认方向:SQLite 导入远端会以本地库为源,远端同步回本地会以 MySQL 为源。</span>
|
||||
<span>{{ ctx.databaseLastSync.warnings.join(";") }}</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="section-head">
|
||||
<h2>连接与同步</h2>
|
||||
<div class="button-row compact-row">
|
||||
<span v-if="ctx.databaseFormEditing" class="badge warn">编辑中,自动刷新不会覆盖表单</span>
|
||||
<button v-if="ctx.databaseFormEditing" class="btn ghost compact" type="button" @click="ctx.reloadDatabaseConfig">重新读取配置</button>
|
||||
<button v-if="ctx.databaseConfigCollapsed" class="btn ghost compact" type="button" @click="ctx.editDatabaseConfig">修改配置</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="ctx.databaseConfigCollapsed" class="kv-grid">
|
||||
<span>当前配置</span><strong>{{ ctx.databaseConfigSummary() }}</strong>
|
||||
<span>密码状态</span><strong>{{ ctx.databaseConfig?.hasPassword ? "已保存,前端不回显" : "未保存" }}</strong>
|
||||
</div>
|
||||
|
||||
<div v-else class="form-stack" @input="ctx.markDatabaseFormEditing" @change="ctx.markDatabaseFormEditing">
|
||||
<label>Provider
|
||||
<select v-model="ctx.databaseForm.provider">
|
||||
<option value="sqlite">SQLite</option>
|
||||
<option value="mysql">MySQL</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="ctx.databaseForm.provider === 'sqlite'">SQLite 路径
|
||||
<input v-model="ctx.databaseForm.sqlitePath" placeholder="留空使用当前配置" />
|
||||
</label>
|
||||
<div v-else class="form-grid">
|
||||
<label>主机<input v-model="ctx.databaseForm.mysqlHost" placeholder="127.0.0.1" /></label>
|
||||
<label>端口<input v-model.number="ctx.databaseForm.mysqlPort" type="number" min="1" placeholder="3306" /></label>
|
||||
<label>数据库名<input v-model="ctx.databaseForm.mysqlDatabase" placeholder="ymhut_unified" /></label>
|
||||
<label>数据库用户<input v-model="ctx.databaseForm.mysqlUser" autocomplete="username" /></label>
|
||||
<label class="wide">数据库密码
|
||||
<input v-model="ctx.databaseForm.mysqlPassword" type="password" autocomplete="new-password" :placeholder="ctx.databaseConfig?.hasPassword ? '留空沿用已保存密码' : '请输入数据库密码'" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<button class="btn primary" @click="ctx.saveDatabase"><Save :size="16" />测试并保存</button>
|
||||
<button class="btn ghost" @click="ctx.syncDatabase('import')">SQLite -> MySQL</button>
|
||||
<button class="btn ghost" @click="ctx.syncDatabase('sync')">MySQL -> SQLite</button>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section v-else-if="ctx.systemTab === 'migration'" class="split">
|
||||
<section class="panel page-stack">
|
||||
<div class="section-head"><h2>数据库优先迁移</h2><button class="btn ghost" @click="ctx.loadMigrationStatus"><RefreshCw :size="16" />刷新</button></div>
|
||||
<div class="kv-grid">
|
||||
<span>策略</span><strong>{{ ctx.migrationStatus?.strategy || "-" }}</strong>
|
||||
<span>SQLite 文件</span><strong>{{ ctx.migrationStatus?.sqlitePath || "-" }}</strong>
|
||||
<span>活动数据库</span><strong>{{ ctx.migrationStatus?.activeProvider || "-" }}</strong>
|
||||
<span>最后同步</span><strong>{{ ctx.migrationStatus?.lastSyncAt || "-" }}</strong>
|
||||
<span>同步错误</span><strong>{{ ctx.migrationStatus?.lastSyncError || "-" }}</strong>
|
||||
</div>
|
||||
<div class="ops-note">
|
||||
<AlertTriangle :size="16" />
|
||||
<span>数据库保存站点结构化状态;发布包、下载文件和反馈附件仍属于文件资产,迁移时需要连同数据库一起备份。</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="panel page-stack">
|
||||
<h2>数据库覆盖范围</h2>
|
||||
<ul class="plain-list">
|
||||
<li v-for="item in ctx.migrationStatus?.databaseCovers || []" :key="item">{{ item }}</li>
|
||||
</ul>
|
||||
<h2>文件资产目录</h2>
|
||||
<div v-for="asset in ctx.migrationStatus?.fileAssets || []" :key="asset.name" class="asset-row">
|
||||
<strong>{{ asset.name }}</strong>
|
||||
<span>{{ asset.description }}</span>
|
||||
<code>{{ asset.path }}</code>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
@@ -80,37 +126,23 @@ const tabs = [
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>旧项目同步</h2>
|
||||
<p class="muted">预览只检查旧目录和影响范围;执行会先备份当前发布目录,再复制旧项目数据并导入反馈记录。</p>
|
||||
<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><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>
|
||||
<div class="button-row"><button class="btn primary" @click="ctx.runLegacySync">执行旧项目同步</button></div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="ctx.systemTab === 'security'" class="split">
|
||||
@@ -121,15 +153,52 @@ const tabs = [
|
||||
<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>
|
||||
<section class="panel editor-panel">
|
||||
<div class="section-head">
|
||||
<h2>站点品牌</h2>
|
||||
<span class="badge neutral">{{ ctx.branding.developerName || "YMhut" }}</span>
|
||||
</div>
|
||||
<div class="brand-preview">
|
||||
<img :src="ctx.branding.siteIconUrl" alt="站点图标" />
|
||||
<img :src="ctx.branding.developerAvatarUrl" alt="开发者头像" />
|
||||
<strong>{{ ctx.branding.developerName }}</strong>
|
||||
</div>
|
||||
<label>站点图标 URL<input v-model="ctx.branding.siteIconUrl" /></label>
|
||||
<label>开发者头像 URL<input v-model="ctx.branding.developerAvatarUrl" /></label>
|
||||
<label>开发者名称<input v-model="ctx.branding.developerName" /></label>
|
||||
<label>反馈邮箱<input v-model="ctx.branding.feedbackEmail" /></label>
|
||||
<button class="btn primary" @click="ctx.saveBranding"><UserRound :size="16" />保存品牌</button>
|
||||
</section>
|
||||
|
||||
<section class="panel editor-panel">
|
||||
<div class="section-head">
|
||||
<h2>反馈邮件通知</h2>
|
||||
<div class="button-row compact-row">
|
||||
<span v-if="ctx.mailConfigEditing" class="badge warn">编辑中,自动刷新不会覆盖表单</span>
|
||||
<button v-if="ctx.mailConfigEditing" class="btn ghost compact" type="button" @click="ctx.reloadMailConfig">重新读取配置</button>
|
||||
<span :class="['badge', ctx.mailConfig.configured ? 'good' : 'warn']">{{ ctx.mailConfig.configured ? "已配置" : "未完成" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid" @input="ctx.markMailConfigEditing" @change="ctx.markMailConfigEditing">
|
||||
<label>SMTP 主机<input v-model="ctx.mailConfig.host" placeholder="smtp.example.com" /></label>
|
||||
<label>端口<input v-model.number="ctx.mailConfig.port" type="number" min="1" /></label>
|
||||
<label>加密方式
|
||||
<select v-model="ctx.mailConfig.secure">
|
||||
<option value="ssl">SSL/TLS</option>
|
||||
<option value="starttls">STARTTLS</option>
|
||||
<option value="none">不加密</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>账号<input v-model="ctx.mailConfig.username" autocomplete="username" /></label>
|
||||
<label>密码<input v-model="ctx.mailConfig.password" type="password" autocomplete="new-password" :placeholder="ctx.mailConfig.hasPassword ? '留空沿用已保存密码' : '请输入 SMTP 密码'" /></label>
|
||||
<label>超时秒数<input v-model.number="ctx.mailConfig.timeoutSeconds" type="number" min="3" /></label>
|
||||
<label>发件地址<input v-model="ctx.mailConfig.fromAddress" /></label>
|
||||
<label>发件名称<input v-model="ctx.mailConfig.fromName" /></label>
|
||||
<label class="wide">开发者收件地址<input v-model="ctx.mailConfig.developerAddress" :placeholder="ctx.branding.feedbackEmail" /></label>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button class="btn primary" @click="ctx.saveMailConfig"><Mail :size="16" />保存邮件配置</button>
|
||||
<button class="btn ghost" @click="ctx.testMail">发送测试邮件</button>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
@@ -140,20 +209,46 @@ const tabs = [
|
||||
</section>
|
||||
|
||||
<section v-else class="panel page-stack">
|
||||
<div class="section-head"><h2>审计日志</h2><button class="btn ghost" @click="ctx.loadAudit">刷新</button></div>
|
||||
<div class="section-head">
|
||||
<h2>审计日志</h2>
|
||||
<button class="btn ghost" @click="ctx.loadAudit">刷新</button>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<input v-model="ctx.auditPage.q" placeholder="搜索操作、目标、信息或 IP" @keyup.enter="ctx.loadAudit" />
|
||||
<input v-model="ctx.auditPage.type" placeholder="类型,例如 source.deleted" @keyup.enter="ctx.loadAudit" />
|
||||
<input v-model="ctx.auditPage.target" placeholder="目标" @keyup.enter="ctx.loadAudit" />
|
||||
<button class="btn ghost" @click="ctx.auditPage.page = 1; 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">
|
||||
<tr v-for="item in ctx.auditPage.items" :key="item.id" class="clickable" @click="ctx.selectAuditLog(item)">
|
||||
<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>
|
||||
<tr v-if="ctx.auditPage.items.length === 0"><td colspan="5">暂无审计日志。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="pager">
|
||||
<button class="btn ghost compact" :disabled="ctx.auditPage.page <= 1" @click="ctx.setAuditPage(ctx.auditPage.page - 1)">上一页</button>
|
||||
<span>第 {{ ctx.auditPage.page }} 页 / 共 {{ Math.max(1, Math.ceil(ctx.auditPage.total / ctx.auditPage.perPage)) }} 页,{{ ctx.auditPage.total }} 条</span>
|
||||
<button class="btn ghost compact" :disabled="ctx.auditPage.page >= Math.ceil(ctx.auditPage.total / ctx.auditPage.perPage)" @click="ctx.setAuditPage(ctx.auditPage.page + 1)">下一页</button>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="ctx.auditPage.selected" class="modal-backdrop" @click.self="ctx.auditPage.selected = null">
|
||||
<section class="modal-panel">
|
||||
<div class="section-head">
|
||||
<h2>审计详情</h2>
|
||||
<button class="btn ghost compact" @click="ctx.auditPage.selected = null">关闭</button>
|
||||
</div>
|
||||
<pre class="json-preview tall">{{ ctx.pretty(ctx.auditPage.selected) }}</pre>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user