@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>YMhut Unified Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1387
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "ymhut-unified-admin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 127.0.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"echarts": "^6.1.0",
|
||||
"lucide-vue-next": "^0.468.0",
|
||||
"vite": "^6.3.5",
|
||||
"vue": "^3.5.16",
|
||||
"vue-echarts": "^8.0.1",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,777 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
CheckCircle2,
|
||||
ClipboardList,
|
||||
Code2,
|
||||
Database,
|
||||
FileJson,
|
||||
HeartPulse,
|
||||
LayoutDashboard,
|
||||
ListChecks,
|
||||
LogOut,
|
||||
MessageSquareText,
|
||||
Network,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
} from "lucide-vue-next";
|
||||
import AuditView from "./views/AuditView.vue";
|
||||
import DashboardView from "./views/DashboardView.vue";
|
||||
import DatabaseView from "./views/DatabaseView.vue";
|
||||
import EndpointsView from "./views/EndpointsView.vue";
|
||||
import FeedbacksView from "./views/FeedbacksView.vue";
|
||||
import HealthView from "./views/HealthView.vue";
|
||||
import LegacyJsonView from "./views/LegacyJsonView.vue";
|
||||
import ReleasesView from "./views/ReleasesView.vue";
|
||||
import SettingsView from "./views/SettingsView.vue";
|
||||
import SourcesView from "./views/SourcesView.vue";
|
||||
|
||||
type LegacyName = "update-info" | "media-types";
|
||||
|
||||
type Captcha = {
|
||||
captchaId: string;
|
||||
image: string;
|
||||
};
|
||||
|
||||
type AuthBootstrap = {
|
||||
isDefaultPassword: boolean;
|
||||
defaultUsername: string;
|
||||
defaultPassword: string;
|
||||
};
|
||||
|
||||
type RouteItem = {
|
||||
path: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: any;
|
||||
};
|
||||
|
||||
const csrf = ref(localStorage.getItem("ymhut.csrf") || "");
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const currentPath = computed(() => normalizeAdminPath(route.path));
|
||||
const loading = ref(false);
|
||||
const toast = ref("");
|
||||
const autoRefreshPaused = ref(false);
|
||||
let refreshTimer: number | undefined;
|
||||
|
||||
const captcha = ref<Captcha | null>(null);
|
||||
const authBootstrap = ref<AuthBootstrap | null>(null);
|
||||
const dashboard = ref<any>({});
|
||||
const feedbackPage = ref<any>({ items: [], total: 0, page: 1, perPage: 20 });
|
||||
const selectedFeedback = ref<any | null>(null);
|
||||
const releases = ref<any>(null);
|
||||
const releaseNotices = ref<any[]>([]);
|
||||
const selectedNotice = ref<any | null>(null);
|
||||
const sources = ref<any>({ categories: [] });
|
||||
const endpoints = ref<any[]>([]);
|
||||
const database = ref<any>(null);
|
||||
const healthSnapshot = ref<any>(null);
|
||||
const auditLogs = ref<any[]>([]);
|
||||
const legacySync = ref<any>(null);
|
||||
const legacyDocuments = reactive<Record<LegacyName, any | null>>({ "update-info": null, "media-types": null });
|
||||
|
||||
const loginForm = reactive({ username: "admin", password: "", captcha: "" });
|
||||
const passwordForm = reactive({ currentPassword: "", newPassword: "" });
|
||||
const feedbackFilters = reactive({ q: "", status: "", page: 1, perPage: 20 });
|
||||
const feedbackUpdate = reactive({ status: "", statusDetail: "", publicReply: "" });
|
||||
const commentDraft = reactive({ body: "", internal: true });
|
||||
const databaseForm = reactive({ provider: "sqlite", sqlitePath: "", mysqlDsn: "" });
|
||||
const sourceDraft = reactive({
|
||||
sourceId: "",
|
||||
categoryId: "custom",
|
||||
categoryName: "自定义接口",
|
||||
name: "",
|
||||
description: "",
|
||||
method: "GET",
|
||||
apiUrl: "",
|
||||
urlTemplate: "",
|
||||
thumbnailUrl: "",
|
||||
proxyMode: "client_direct",
|
||||
timeoutMs: 8000,
|
||||
retryCount: 1,
|
||||
cacheSeconds: 300,
|
||||
checkIntervalSec: 300,
|
||||
enabled: true,
|
||||
clientVisible: true,
|
||||
supportedFormats: "[\"json\"]",
|
||||
});
|
||||
const legacyDrafts = reactive<Record<LegacyName, { raw: string; note: string; preview: any | null }>>({
|
||||
"update-info": { raw: "", note: "", preview: null },
|
||||
"media-types": { raw: "", note: "", preview: null },
|
||||
});
|
||||
const noticeDraft = reactive({ version: "", raw: "", note: "", preview: null as any });
|
||||
|
||||
const routes: RouteItem[] = [
|
||||
{ path: "/admin/dashboard", label: "仪表盘", description: "服务状态、接口心跳与运营指标", icon: LayoutDashboard },
|
||||
{ path: "/admin/feedbacks", label: "反馈工单", description: "旧客户端反馈与处理流转", icon: MessageSquareText },
|
||||
{ path: "/admin/releases", label: "发布与日志", description: "发布包、版本公告和兼容日志", icon: ArrowDownToLine },
|
||||
{ path: "/admin/legacy/update-info", label: "更新 JSON", description: "可视化维护 update-info.json", icon: FileJson },
|
||||
{ path: "/admin/legacy/media-types", label: "媒体源 JSON", description: "维护旧客户端媒体源结构", icon: ClipboardList },
|
||||
{ path: "/admin/sources", label: "来源目录", description: "媒体/数据源目录和健康检测", icon: Network },
|
||||
{ path: "/admin/endpoints", label: "客户端接口", description: "新版客户端动态接口配置", icon: Code2 },
|
||||
{ path: "/admin/database", label: "数据库与同步", description: "SQLite、MySQL 和旧项目同步", icon: Database },
|
||||
{ path: "/admin/health", label: "健康快照", description: "服务端运行状态和预检信息", icon: HeartPulse },
|
||||
{ path: "/admin/settings", label: "系统设置", description: "密码与旧库同步入口", icon: Settings },
|
||||
{ path: "/admin/audit", label: "审计日志", description: "后台操作和同步记录", icon: ListChecks },
|
||||
];
|
||||
|
||||
const navGroups = [
|
||||
{ label: "概览", items: routes.filter((item) => ["/admin/dashboard"].includes(item.path)) },
|
||||
{ label: "反馈", items: routes.filter((item) => ["/admin/feedbacks"].includes(item.path)) },
|
||||
{ label: "发布与兼容", items: routes.filter((item) => ["/admin/releases", "/admin/legacy/update-info", "/admin/legacy/media-types"].includes(item.path)) },
|
||||
{ label: "客户端接口", items: routes.filter((item) => ["/admin/sources", "/admin/endpoints"].includes(item.path)) },
|
||||
{ label: "系统运维", items: routes.filter((item) => ["/admin/database", "/admin/health", "/admin/settings", "/admin/audit"].includes(item.path)) },
|
||||
];
|
||||
|
||||
const quickActions = [
|
||||
{ path: "/admin/feedbacks", label: "反馈处理", description: "查看和处理客户端反馈工单", icon: MessageSquareText },
|
||||
{ path: "/admin/releases", label: "发布与日志", description: "维护发布包和 update-notice", icon: ArrowDownToLine },
|
||||
{ path: "/admin/legacy/update-info", label: "更新 JSON", description: "编辑旧版 update-info.json", icon: FileJson },
|
||||
{ path: "/admin/legacy/media-types", label: "媒体源 JSON", description: "同步旧客户端媒体源结构", icon: ClipboardList },
|
||||
{ path: "/admin/sources", label: "接口源目录", description: "新增接口并执行健康检测", icon: Network },
|
||||
{ path: "/admin/database", label: "数据库同步", description: "管理 SQLite/MySQL 和旧项目同步", icon: Database },
|
||||
{ path: "/admin/audit", label: "审计日志", description: "查看后台操作与同步记录", icon: ListChecks },
|
||||
];
|
||||
|
||||
const pageMeta = computed(() => routes.find((item) => item.path === currentPath.value) || routes[0]);
|
||||
const activeLegacyName = computed<LegacyName | null>(() => {
|
||||
if (currentPath.value.endsWith("/update-info")) return "update-info";
|
||||
if (currentPath.value.endsWith("/media-types")) return "media-types";
|
||||
return null;
|
||||
});
|
||||
const kpis = computed(() => dashboard.value?.kpis || {});
|
||||
const sourceHealth = computed(() => dashboard.value?.sourceHealth || {});
|
||||
const feedbackStatus = computed(() => dashboard.value?.feedbackStatus || {});
|
||||
const heartbeats = computed(() => dashboard.value?.heartbeats || []);
|
||||
const clientCalls = computed(() => dashboard.value?.clientCalls || []);
|
||||
const releasePackages = computed(() => releases.value?.packages || []);
|
||||
const sourceCategories = computed(() => sources.value?.categories || []);
|
||||
const visibleEndpointCount = computed(() => endpoints.value.filter((item) => item.enabled && item.clientVisible).length);
|
||||
const healthyEndpointCount = computed(() => endpoints.value.filter((item) => endpointStatus(item) === "ok").length);
|
||||
const latestNotice = computed(() => releaseNotices.value[0] || null);
|
||||
const activeLegacyLabel = computed(() => activeLegacyName.value === "media-types" ? "media-types.json" : "update-info.json");
|
||||
|
||||
const heartbeatOption = computed(() => ({
|
||||
tooltip: { trigger: "axis" },
|
||||
grid: { left: 44, right: 18, top: 28, bottom: 34 },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: heartbeats.value.slice().reverse().map((item: any) => timeLabel(item.checkedAt)),
|
||||
axisLine: { lineStyle: { color: "#cbd5e1" } },
|
||||
},
|
||||
yAxis: { type: "value", name: "ms", axisLine: { lineStyle: { color: "#cbd5e1" } }, splitLine: { lineStyle: { color: "#e5e7eb" } } },
|
||||
series: [
|
||||
{
|
||||
name: "接口延迟",
|
||||
type: "line",
|
||||
smooth: true,
|
||||
areaStyle: { opacity: 0.18 },
|
||||
data: heartbeats.value.slice().reverse().map((item: any) => item.latencyMs || 0),
|
||||
color: "#2563eb",
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const healthOption = computed(() => ({
|
||||
tooltip: { trigger: "item" },
|
||||
legend: { bottom: 0 },
|
||||
series: [
|
||||
{
|
||||
name: "接口健康",
|
||||
type: "pie",
|
||||
radius: ["48%", "72%"],
|
||||
data: objectEntries(sourceHealth.value),
|
||||
color: ["#16a34a", "#f59e0b", "#dc2626", "#64748b"],
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const feedbackOption = computed(() => ({
|
||||
tooltip: { trigger: "axis" },
|
||||
grid: { left: 34, right: 12, top: 20, bottom: 28 },
|
||||
xAxis: { type: "category", data: objectEntries(feedbackStatus.value).map((item) => item.name) },
|
||||
yAxis: { type: "value", splitLine: { lineStyle: { color: "#e5e7eb" } } },
|
||||
series: [{ name: "工单", type: "bar", data: objectEntries(feedbackStatus.value).map((item) => item.value), color: "#0f766e" }],
|
||||
}));
|
||||
|
||||
const availabilityOption = computed(() => {
|
||||
const total = Number(kpis.value.sourceTotal || 0);
|
||||
const ok = Number(sourceHealth.value.ok || 0);
|
||||
const value = total ? Math.round((ok / total) * 100) : 0;
|
||||
return {
|
||||
series: [
|
||||
{
|
||||
type: "gauge",
|
||||
progress: { show: true, width: 12 },
|
||||
axisLine: { lineStyle: { width: 12 } },
|
||||
axisLabel: { distance: 16 },
|
||||
pointer: { width: 4 },
|
||||
detail: { formatter: "{value}%", fontSize: 24 },
|
||||
data: [{ value, name: "可用率" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const viewContext = computed(() => ({
|
||||
activeLegacyLabel: activeLegacyLabel.value,
|
||||
activeLegacyName: activeLegacyName.value,
|
||||
addFeedbackComment,
|
||||
auditLogs: auditLogs.value,
|
||||
autoRefreshPaused: autoRefreshPaused.value,
|
||||
availabilityOption: availabilityOption.value,
|
||||
changePassword,
|
||||
checkSources,
|
||||
clientCalls: clientCalls.value,
|
||||
commentDraft,
|
||||
copyEndpointToSource,
|
||||
database: database.value,
|
||||
databaseForm,
|
||||
endpointStatus,
|
||||
endpoints: endpoints.value,
|
||||
feedbackFilters,
|
||||
feedbackOption: feedbackOption.value,
|
||||
feedbackPage: feedbackPage.value,
|
||||
feedbackUpdate,
|
||||
formatBytes,
|
||||
healthOption: healthOption.value,
|
||||
healthSnapshot: healthSnapshot.value,
|
||||
healthyEndpointCount: healthyEndpointCount.value,
|
||||
heartbeatOption: heartbeatOption.value,
|
||||
heartbeats: heartbeats.value,
|
||||
importNotices,
|
||||
kpis: kpis.value,
|
||||
labelStatus,
|
||||
latestNotice: latestNotice.value,
|
||||
legacyDocuments,
|
||||
legacyDrafts,
|
||||
legacySync: legacySync.value,
|
||||
loadAudit,
|
||||
loadFeedbacks,
|
||||
navigate,
|
||||
noticeDraft,
|
||||
openFeedback,
|
||||
openNotice,
|
||||
passwordForm,
|
||||
pretty,
|
||||
previewLegacySync,
|
||||
quickActions,
|
||||
releaseNotices: releaseNotices.value,
|
||||
releasePackages: releasePackages.value,
|
||||
releases: releases.value,
|
||||
restoreLegacy,
|
||||
restoreNotice,
|
||||
runLegacySync,
|
||||
saveFeedbackUpdate,
|
||||
saveLegacy,
|
||||
saveNotice,
|
||||
saveSource,
|
||||
selectedFeedback: selectedFeedback.value,
|
||||
selectedNotice: selectedNotice.value,
|
||||
sourceCategories: sourceCategories.value,
|
||||
sourceDraft,
|
||||
statusTone,
|
||||
syncDatabase,
|
||||
testDatabase,
|
||||
toggleAutoRefresh,
|
||||
validateLegacy,
|
||||
validateNotice,
|
||||
visibleEndpointCount: visibleEndpointCount.value,
|
||||
}));
|
||||
|
||||
async function api<T>(target: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
if (!headers.has("Content-Type") && init.body) headers.set("Content-Type", "application/json");
|
||||
if (csrf.value) headers.set("X-CSRF-Token", csrf.value);
|
||||
const res = await fetch(target, { ...init, headers, credentials: "include" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data.ok === false) throw new Error(data.message || data.error || `HTTP ${res.status}`);
|
||||
return data as T;
|
||||
}
|
||||
|
||||
function normalizeAdminPath(value: string) {
|
||||
if (value === "/admin" || value === "/admin/") return "/admin/dashboard";
|
||||
if (value === "/") return "/admin/dashboard";
|
||||
return value;
|
||||
}
|
||||
|
||||
function navigate(next: string) {
|
||||
if (currentPath.value === next) {
|
||||
void load();
|
||||
return;
|
||||
}
|
||||
void router.push(next);
|
||||
}
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
autoRefreshPaused.value = !autoRefreshPaused.value;
|
||||
}
|
||||
|
||||
function setToast(message: string) {
|
||||
toast.value = message;
|
||||
window.setTimeout(() => {
|
||||
if (toast.value === message) toast.value = "";
|
||||
}, 4200);
|
||||
}
|
||||
|
||||
async function guarded(task: () => Promise<void>) {
|
||||
loading.value = true;
|
||||
try {
|
||||
await task();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast.value = message;
|
||||
if (message.includes("Login required") || message.includes("UNAUTHORIZED")) {
|
||||
navigate("/admin/login");
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCaptcha() {
|
||||
captcha.value = await api<Captcha>("/api/admin/auth/captcha");
|
||||
}
|
||||
|
||||
async function loadAuthBootstrap() {
|
||||
authBootstrap.value = await api<AuthBootstrap>("/api/admin/auth/bootstrap");
|
||||
}
|
||||
|
||||
async function login() {
|
||||
await guarded(async () => {
|
||||
const data = await api<{ csrfToken: string }>("/api/admin/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...loginForm, captchaId: captcha.value?.captchaId }),
|
||||
});
|
||||
csrf.value = data.csrfToken;
|
||||
localStorage.setItem("ymhut.csrf", csrf.value);
|
||||
navigate("/admin/dashboard");
|
||||
});
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await api("/api/admin/auth/logout", { method: "POST", body: "{}" }).catch(() => undefined);
|
||||
csrf.value = "";
|
||||
localStorage.removeItem("ymhut.csrf");
|
||||
navigate("/admin/login");
|
||||
}
|
||||
|
||||
async function load() {
|
||||
await guarded(async () => {
|
||||
if (currentPath.value === "/admin/login") {
|
||||
await Promise.all([loadAuthBootstrap(), loadCaptcha()]);
|
||||
return;
|
||||
}
|
||||
if (currentPath.value === "/admin/dashboard") await loadDashboard();
|
||||
if (currentPath.value === "/admin/feedbacks") await loadFeedbacks();
|
||||
if (currentPath.value === "/admin/releases") await loadReleases();
|
||||
if (currentPath.value === "/admin/sources") await loadSources();
|
||||
if (currentPath.value === "/admin/endpoints") await loadEndpoints();
|
||||
if (currentPath.value === "/admin/database") await loadDatabase();
|
||||
if (currentPath.value === "/admin/health") await loadHealth();
|
||||
if (currentPath.value === "/admin/audit") await loadAudit();
|
||||
if (currentPath.value === "/admin/settings") await previewLegacySync();
|
||||
const legacyName = activeLegacyName.value;
|
||||
if (legacyName) await loadLegacy(legacyName);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
dashboard.value = await api("/api/admin/dashboard/overview?window=24h");
|
||||
}
|
||||
|
||||
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);
|
||||
const data = await api<{ page: any }>(`/api/admin/feedbacks?${params}`);
|
||||
feedbackPage.value = data.page || { items: [], total: 0, page: 1, perPage: 20 };
|
||||
}
|
||||
|
||||
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.statusDetail = data.feedback.statusDetail || "";
|
||||
feedbackUpdate.publicReply = data.feedback.publicReply || "";
|
||||
}
|
||||
|
||||
async function saveFeedbackUpdate() {
|
||||
if (!selectedFeedback.value) return;
|
||||
await guarded(async () => {
|
||||
await api(`/api/admin/feedbacks/${encodeURIComponent(selectedFeedback.value.code)}`, { method: "PATCH", body: JSON.stringify(feedbackUpdate) });
|
||||
setToast("反馈工单已更新");
|
||||
await openFeedback(selectedFeedback.value);
|
||||
await loadFeedbacks();
|
||||
});
|
||||
}
|
||||
|
||||
async function addFeedbackComment() {
|
||||
if (!selectedFeedback.value || !commentDraft.body.trim()) return;
|
||||
await guarded(async () => {
|
||||
await api(`/api/admin/feedbacks/${encodeURIComponent(selectedFeedback.value.code)}/comments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ author: "admin", body: commentDraft.body, internal: commentDraft.internal }),
|
||||
});
|
||||
commentDraft.body = "";
|
||||
await openFeedback(selectedFeedback.value);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadReleases() {
|
||||
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);
|
||||
}
|
||||
|
||||
async function importNotices() {
|
||||
await guarded(async () => {
|
||||
const data = await api<{ items: any[] }>("/api/admin/releases/notices/import", { method: "POST", body: "{}" });
|
||||
releaseNotices.value = data.items || [];
|
||||
setToast("版本日志已从目录重新导入");
|
||||
});
|
||||
}
|
||||
|
||||
async function openNotice(version: string) {
|
||||
const data = await api<{ document: any }>(`/api/admin/releases/notices/${encodeURIComponent(version)}`);
|
||||
selectedNotice.value = data.document;
|
||||
noticeDraft.version = version;
|
||||
noticeDraft.raw = data.document.raw || "";
|
||||
noticeDraft.preview = data.document.parsed || null;
|
||||
}
|
||||
|
||||
async function validateNotice() {
|
||||
if (!noticeDraft.version) return;
|
||||
const data = await api<{ document: any }>(`/api/admin/releases/notices/${encodeURIComponent(noticeDraft.version)}/validate`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ raw: noticeDraft.raw }),
|
||||
});
|
||||
noticeDraft.raw = data.document.raw;
|
||||
noticeDraft.preview = data.document.parsed;
|
||||
setToast("版本日志 JSON 校验通过");
|
||||
}
|
||||
|
||||
async function saveNotice() {
|
||||
if (!noticeDraft.version) return;
|
||||
await guarded(async () => {
|
||||
const data = await api<{ document: any }>(`/api/admin/releases/notices/${encodeURIComponent(noticeDraft.version)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ raw: noticeDraft.raw, note: noticeDraft.note }),
|
||||
});
|
||||
selectedNotice.value = data.document;
|
||||
noticeDraft.note = "";
|
||||
setToast("版本日志已保存并同步兼容更新信息");
|
||||
await loadReleases();
|
||||
});
|
||||
}
|
||||
|
||||
async function restoreNotice(revisionId: number) {
|
||||
if (!noticeDraft.version) return;
|
||||
await guarded(async () => {
|
||||
const data = await api<{ document: any }>(`/api/admin/releases/notices/${encodeURIComponent(noticeDraft.version)}/restore`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ revisionId }),
|
||||
});
|
||||
selectedNotice.value = data.document;
|
||||
noticeDraft.raw = data.document.raw || "";
|
||||
noticeDraft.preview = data.document.parsed || null;
|
||||
setToast("版本日志已恢复");
|
||||
});
|
||||
}
|
||||
|
||||
async function loadLegacy(name: LegacyName) {
|
||||
const data = await api<{ document: any }>(`/api/admin/legacy/${name}`);
|
||||
legacyDocuments[name] = data.document;
|
||||
legacyDrafts[name].raw = data.document.raw || "";
|
||||
legacyDrafts[name].preview = data.document.parsed || null;
|
||||
}
|
||||
|
||||
async function validateLegacy(name: LegacyName) {
|
||||
const data = await api<{ document: any }>(`/api/admin/legacy/${name}/validate`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ raw: legacyDrafts[name].raw }),
|
||||
});
|
||||
legacyDrafts[name].raw = data.document.raw;
|
||||
legacyDrafts[name].preview = data.document.parsed;
|
||||
setToast("兼容 JSON 校验通过");
|
||||
}
|
||||
|
||||
async function saveLegacy(name: LegacyName) {
|
||||
await guarded(async () => {
|
||||
const data = await api<{ document: any }>(`/api/admin/legacy/${name}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ raw: legacyDrafts[name].raw, note: legacyDrafts[name].note }),
|
||||
});
|
||||
legacyDocuments[name] = data.document;
|
||||
legacyDrafts[name].raw = data.document.raw;
|
||||
legacyDrafts[name].preview = data.document.parsed;
|
||||
legacyDrafts[name].note = "";
|
||||
setToast("兼容 JSON 已保存并发布到旧路径");
|
||||
});
|
||||
}
|
||||
|
||||
async function restoreLegacy(name: LegacyName, revisionId: number) {
|
||||
await guarded(async () => {
|
||||
const data = await api<{ document: any }>(`/api/admin/legacy/${name}/restore`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ revisionId }),
|
||||
});
|
||||
legacyDocuments[name] = data.document;
|
||||
legacyDrafts[name].raw = data.document.raw;
|
||||
legacyDrafts[name].preview = data.document.parsed;
|
||||
setToast("兼容 JSON 已恢复");
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSources() {
|
||||
const data = await api<{ catalog: any }>("/api/admin/sources");
|
||||
sources.value = data.catalog || { categories: [] };
|
||||
}
|
||||
|
||||
async function saveSource() {
|
||||
await guarded(async () => {
|
||||
await api("/api/admin/sources", { method: "POST", body: JSON.stringify(sourceDraft) });
|
||||
setToast("接口源已保存");
|
||||
await Promise.all([loadSources(), loadEndpoints()]);
|
||||
});
|
||||
}
|
||||
|
||||
async function checkSources() {
|
||||
await guarded(async () => {
|
||||
await api("/api/admin/sources/check", { method: "POST", body: "{}" });
|
||||
setToast("接口心跳检测已进入队列");
|
||||
if (currentPath.value === "/admin/dashboard") await loadDashboard();
|
||||
if (currentPath.value === "/admin/sources") await loadSources();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadEndpoints() {
|
||||
const data = await api<{ items: any[] }>("/api/admin/endpoints");
|
||||
endpoints.value = data.items || [];
|
||||
}
|
||||
|
||||
function copyEndpointToSource(item: any) {
|
||||
Object.assign(sourceDraft, {
|
||||
sourceId: item.id || item.sourceId,
|
||||
categoryId: item.category || item.categoryId || "custom",
|
||||
categoryName: item.category || item.categoryName || "自定义接口",
|
||||
name: item.name,
|
||||
method: item.method || "GET",
|
||||
apiUrl: item.urlTemplate || item.apiUrl || "",
|
||||
urlTemplate: item.urlTemplate || item.apiUrl || "",
|
||||
proxyMode: item.proxyMode || "client_direct",
|
||||
enabled: item.enabled,
|
||||
clientVisible: item.clientVisible,
|
||||
cacheSeconds: item.cacheSeconds || 300,
|
||||
checkIntervalSec: item.checkIntervalSec || item.cacheSeconds || 300,
|
||||
supportedFormats: JSON.stringify(item.supportedFormats || ["json"]),
|
||||
});
|
||||
navigate("/admin/sources");
|
||||
}
|
||||
|
||||
async function loadDatabase() {
|
||||
const data = await api<{ database: any }>("/api/admin/database/status");
|
||||
database.value = data.database;
|
||||
databaseForm.provider = data.database?.configProvider || "sqlite";
|
||||
await previewLegacySync();
|
||||
}
|
||||
|
||||
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 }),
|
||||
});
|
||||
setToast("数据库连接测试通过");
|
||||
});
|
||||
}
|
||||
|
||||
async function syncDatabase(direction: "import" | "sync") {
|
||||
await guarded(async () => {
|
||||
await api(direction === "import" ? "/api/admin/database/import-sqlite" : "/api/admin/database/sync", { method: "POST", body: "{}" });
|
||||
setToast(direction === "import" ? "SQLite 已导入远端库" : "远端库已同步回本地");
|
||||
await loadDatabase();
|
||||
});
|
||||
}
|
||||
|
||||
async function previewLegacySync() {
|
||||
legacySync.value = await api("/api/admin/sync/legacy/preview").catch((error) => ({ ok: false, errors: [String(error)] }));
|
||||
}
|
||||
|
||||
async function runLegacySync() {
|
||||
await guarded(async () => {
|
||||
legacySync.value = await api("/api/admin/sync/legacy/run", { method: "POST", body: "{}" });
|
||||
setToast("旧项目同步已完成");
|
||||
await Promise.all([loadDatabase(), loadReleases().catch(() => undefined), loadSources().catch(() => undefined)]);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadHealth() {
|
||||
healthSnapshot.value = await api("/api/admin/system/health");
|
||||
}
|
||||
|
||||
async function loadAudit() {
|
||||
const data = await api<{ items: any[] }>("/api/admin/system/audit");
|
||||
auditLogs.value = data.items || [];
|
||||
}
|
||||
|
||||
async function changePassword() {
|
||||
await guarded(async () => {
|
||||
await api("/api/admin/auth/password", { method: "POST", body: JSON.stringify(passwordForm) });
|
||||
passwordForm.currentPassword = "";
|
||||
passwordForm.newPassword = "";
|
||||
setToast("后台密码已修改,登录页将不再提示默认密码");
|
||||
});
|
||||
}
|
||||
|
||||
function endpointStatus(item: any) {
|
||||
return item.health?.status || item.lastStatus || "unknown";
|
||||
}
|
||||
|
||||
function statusTone(status: string) {
|
||||
const value = String(status || "").toLowerCase();
|
||||
if (["ok", "online", "new", "sqlite", "mysql", "sent", "ready"].includes(value)) return "good";
|
||||
if (["degraded", "pending", "processing", "queued", "missing"].includes(value)) return "warn";
|
||||
if (["error", "failed", "closed", "offline"].includes(value)) return "bad";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function objectEntries(value: Record<string, number>) {
|
||||
return Object.entries(value || {}).map(([name, item]) => ({ name: labelStatus(name), value: item || 0 }));
|
||||
}
|
||||
|
||||
function labelStatus(value: string) {
|
||||
const labels: Record<string, string> = {
|
||||
ok: "正常",
|
||||
error: "错误",
|
||||
degraded: "降级",
|
||||
unknown: "未知",
|
||||
new: "新建",
|
||||
processing: "处理中",
|
||||
closed: "已关闭",
|
||||
failed: "失败",
|
||||
};
|
||||
return labels[value] || value || "未知";
|
||||
}
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let next = value;
|
||||
let index = 0;
|
||||
while (next >= 1024 && index < units.length - 1) {
|
||||
next /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
return `${next.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function timeLabel(value: string) {
|
||||
if (!value) return "-";
|
||||
return value.length > 10 ? value.slice(11, 19) : value;
|
||||
}
|
||||
|
||||
function pretty(value: any) {
|
||||
return JSON.stringify(value || {}, null, 2);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
refreshTimer = window.setInterval(() => {
|
||||
if (!autoRefreshPaused.value && currentPath.value === "/admin/dashboard" && csrf.value) void loadDashboard();
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer) window.clearInterval(refreshTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main v-if="currentPath === '/admin/login'" class="login-shell">
|
||||
<section class="login-panel">
|
||||
<div>
|
||||
<p class="eyebrow">YMhut Unified Management</p>
|
||||
<h1>后台登录</h1>
|
||||
<p class="muted">验证码和密码都由服务端校验,登录后写操作继续要求 CSRF Token。</p>
|
||||
</div>
|
||||
<p v-if="authBootstrap?.isDefaultPassword" class="alert-line">
|
||||
当前使用默认账号:{{ authBootstrap.defaultUsername || "admin" }} / {{ authBootstrap.defaultPassword || "admin" }}
|
||||
</p>
|
||||
<form class="form-stack" @submit.prevent="login">
|
||||
<label>账号<input v-model="loginForm.username" autocomplete="username" /></label>
|
||||
<label>密码<input v-model="loginForm.password" type="password" autocomplete="current-password" /></label>
|
||||
<label>
|
||||
验证码
|
||||
<div class="captcha-row">
|
||||
<input v-model="loginForm.captcha" />
|
||||
<button class="captcha-button" type="button" title="刷新验证码" @click="loadCaptcha">
|
||||
<img v-if="captcha?.image" :src="captcha.image" alt="验证码" />
|
||||
<span v-else>刷新</span>
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<button class="btn primary full" type="submit">登录</button>
|
||||
</form>
|
||||
<p v-if="toast" class="notice">{{ toast }}</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<nav class="nav-groups">
|
||||
<section v-for="group in navGroups" :key="group.label" class="nav-group">
|
||||
<p>{{ group.label }}</p>
|
||||
<button
|
||||
v-for="item in group.items"
|
||||
:key="item.path"
|
||||
:class="{ active: currentPath === item.path || (item.path.includes('/legacy/') && activeLegacyName) }"
|
||||
@click="navigate(item.path)"
|
||||
>
|
||||
<component :is="item.icon" :size="17" />
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</section>
|
||||
</nav>
|
||||
<button class="logout" @click="logout"><LogOut :size="16" />退出</button>
|
||||
</aside>
|
||||
|
||||
<section class="workspace">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">update.ymhut.cn</p>
|
||||
<h1>{{ pageMeta.label }}</h1>
|
||||
<p class="muted">{{ pageMeta.description }}</p>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<span v-if="loading" class="badge warn">加载中</span>
|
||||
<button class="btn ghost" @click="load"><RefreshCw :size="16" />刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
<p v-if="toast" class="notice">{{ toast }}</p>
|
||||
|
||||
<DashboardView v-if="currentPath === '/admin/dashboard'" :ctx="viewContext" />
|
||||
<FeedbacksView v-else-if="currentPath === '/admin/feedbacks'" :ctx="viewContext" />
|
||||
<ReleasesView v-else-if="currentPath === '/admin/releases'" :ctx="viewContext" />
|
||||
<LegacyJsonView v-else-if="activeLegacyName" :ctx="viewContext" />
|
||||
<SourcesView v-else-if="currentPath === '/admin/sources'" :ctx="viewContext" />
|
||||
<EndpointsView v-else-if="currentPath === '/admin/endpoints'" :ctx="viewContext" />
|
||||
<DatabaseView v-else-if="currentPath === '/admin/database'" :ctx="viewContext" />
|
||||
<HealthView v-else-if="currentPath === '/admin/health'" :ctx="viewContext" />
|
||||
<SettingsView v-else-if="currentPath === '/admin/settings'" :ctx="viewContext" />
|
||||
<AuditView v-else-if="currentPath === '/admin/audit'" :ctx="viewContext" />
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ tone?: "default" | "warning" | "danger" }>(), {
|
||||
tone: "default"
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['ui-alert', `ui-alert--${tone}`]">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { cn } from "../../lib/cn";
|
||||
|
||||
const props = withDefaults(defineProps<{ variant?: "default" | "secondary" | "warning" }>(), {
|
||||
variant: "default"
|
||||
});
|
||||
|
||||
const classes = computed(() => cn("ui-badge", `ui-badge--${props.variant}`));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="classes"><slot /></span>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { cn } from "../../lib/cn";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
variant?: "default" | "primary" | "ghost" | "outline";
|
||||
type?: "button" | "submit" | "reset";
|
||||
}>(), {
|
||||
variant: "default",
|
||||
type: "button"
|
||||
});
|
||||
|
||||
const classes = computed(() => cn("ui-button", `ui-button--${props.variant}`));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button :type="type" :class="classes">
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<section class="ui-card">
|
||||
<slot />
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div class="ui-table-wrap">
|
||||
<table class="ui-table">
|
||||
<slot />
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
export function cn(...values: Array<string | false | null | undefined>) {
|
||||
return values.filter(Boolean).join(" ");
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createApp } from "vue";
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import App from "./App.vue";
|
||||
import "./styles.css";
|
||||
|
||||
const RoutePlaceholder = { template: "<span />" };
|
||||
|
||||
const routes = [
|
||||
"/admin/login",
|
||||
"/admin/dashboard",
|
||||
"/admin/feedbacks",
|
||||
"/admin/releases",
|
||||
"/admin/legacy/update-info",
|
||||
"/admin/legacy/media-types",
|
||||
"/admin/sources",
|
||||
"/admin/endpoints",
|
||||
"/admin/database",
|
||||
"/admin/health",
|
||||
"/admin/settings",
|
||||
"/admin/audit",
|
||||
].map((path) => ({ path, component: RoutePlaceholder }));
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
...routes,
|
||||
{ path: "/admin", redirect: "/admin/dashboard" },
|
||||
{ path: "/admin/:pathMatch(.*)*", redirect: "/admin/dashboard" },
|
||||
],
|
||||
});
|
||||
|
||||
createApp(App).use(router).mount("#app");
|
||||
@@ -0,0 +1,325 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: "Microsoft YaHei UI", "Segoe UI", Arial, sans-serif;
|
||||
background: #f5f7fb;
|
||||
color: #111827;
|
||||
--bg: #f5f7fb;
|
||||
--panel: #ffffff;
|
||||
--panel-soft: #f8fafc;
|
||||
--line: #dfe5ee;
|
||||
--line-strong: #c6d1de;
|
||||
--ink: #111827;
|
||||
--muted: #5f6b7a;
|
||||
--primary: #2563eb;
|
||||
--primary-dark: #1d4ed8;
|
||||
--primary-soft: #e8f0ff;
|
||||
--good: #047857;
|
||||
--good-bg: #e8f7ef;
|
||||
--warn: #b45309;
|
||||
--warn-bg: #fff7e6;
|
||||
--bad: #b42318;
|
||||
--bad-bg: #fff0ed;
|
||||
--shadow: 0 18px 50px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { min-width: 320px; }
|
||||
body { margin: 0; background: var(--bg); }
|
||||
button, input, textarea, select { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button:disabled { cursor: not-allowed; opacity: 0.65; }
|
||||
a { color: inherit; }
|
||||
h1, h2, h3, p { margin-top: 0; }
|
||||
h1 { margin-bottom: 4px; font-size: 28px; line-height: 1.15; letter-spacing: 0; }
|
||||
h2 { margin-bottom: 12px; font-size: 18px; line-height: 1.25; }
|
||||
h3 { margin-bottom: 8px; font-size: 15px; }
|
||||
.muted { margin-bottom: 0; color: var(--muted); line-height: 1.65; }
|
||||
.mono, .hash, pre, .code-editor { font-family: "Cascadia Mono", "SFMono-Regular", Consolas, monospace; }
|
||||
.hash { max-width: 380px; overflow-wrap: anywhere; font-size: 12px; }
|
||||
.eyebrow { margin: 0 0 6px; color: var(--primary); text-transform: uppercase; letter-spacing: 0.08em; font-size: 12px; font-weight: 800; }
|
||||
|
||||
.login-shell {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(37, 99, 235, 0.12), transparent 40%),
|
||||
radial-gradient(circle at 82% 12%, rgba(4, 120, 87, 0.10), transparent 34%),
|
||||
#f5f7fb;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
width: min(460px, 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding: 28px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.form-stack, .page-stack, .editor-panel { display: flex; flex-direction: column; gap: 14px; }
|
||||
label { display: flex; flex-direction: column; gap: 6px; color: #374151; font-weight: 700; font-size: 13px; }
|
||||
input, textarea, select {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
padding: 8px 10px;
|
||||
outline: none;
|
||||
}
|
||||
textarea { resize: vertical; line-height: 1.55; }
|
||||
input:focus, textarea:focus, select:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
.captcha-row { display: grid; grid-template-columns: 1fr 150px; gap: 10px; align-items: end; }
|
||||
.captcha-button {
|
||||
min-height: 40px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 800;
|
||||
}
|
||||
.captcha-button img { width: 100%; height: 42px; object-fit: cover; display: block; }
|
||||
|
||||
.btn {
|
||||
min-height: 38px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
padding: 8px 12px;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-weight: 800;
|
||||
transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
.btn:hover { border-color: var(--line-strong); background: #f9fafb; }
|
||||
.btn.primary { background: var(--primary); color: #fff; border-color: var(--primary); }
|
||||
.btn.primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); }
|
||||
.btn.ghost { background: transparent; }
|
||||
.btn.compact { min-height: 30px; padding: 5px 8px; font-size: 12px; }
|
||||
.btn.full { width: 100%; }
|
||||
.button-row, .top-actions, .toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
.alert-line, .notice {
|
||||
border: 1px solid #f0c36a;
|
||||
background: var(--warn-bg);
|
||||
color: #7a3b00;
|
||||
border-radius: 6px;
|
||||
padding: 11px 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.app-shell { min-height: 100dvh; display: grid; grid-template-columns: 260px minmax(0, 1fr); }
|
||||
.sidebar {
|
||||
border-right: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
backdrop-filter: blur(12px);
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100dvh;
|
||||
}
|
||||
.brand { display: flex; gap: 12px; align-items: center; padding-bottom: 14px; border-bottom: 1px solid var(--line); }
|
||||
.brand-mark { width: 38px; height: 38px; border-radius: 8px; display: grid; place-items: center; background: #111827; color: #fff; }
|
||||
.brand strong { display: block; }
|
||||
.brand small { display: block; color: var(--muted); margin-top: 2px; }
|
||||
.nav-groups { display: flex; flex-direction: column; gap: 14px; flex: 1; overflow-y: auto; }
|
||||
.nav-group { display: flex; flex-direction: column; gap: 5px; }
|
||||
.nav-group p {
|
||||
margin: 0 0 2px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.nav-group button, .logout {
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #526070;
|
||||
font-weight: 800;
|
||||
transition: background-color 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
.nav-group button:hover, .logout:hover { background: #eef4ff; color: var(--primary-dark); }
|
||||
.nav-group button.active { background: var(--primary-soft); color: var(--primary-dark); }
|
||||
.logout { color: #7f1d1d; }
|
||||
|
||||
.workspace { min-width: 0; padding: 24px; display: flex; flex-direction: column; gap: 18px; }
|
||||
.topbar, .section-head { display: flex; justify-content: space-between; align-items: center; gap: 14px; }
|
||||
.topbar { min-height: 72px; }
|
||||
.section-head h2 { margin: 0; }
|
||||
.section-head a { color: var(--primary); font-weight: 800; text-decoration: none; }
|
||||
|
||||
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
|
||||
.metric, .panel {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 1px 2px rgba(17, 24, 39, 0.04);
|
||||
}
|
||||
.metric { min-height: 116px; display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.metric span, .metric small { color: var(--muted); }
|
||||
.metric strong { font-size: 26px; overflow-wrap: anywhere; }
|
||||
|
||||
.chart-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.chart-panel { min-height: 330px; display: flex; flex-direction: column; }
|
||||
.chart { min-height: 260px; width: 100%; flex: 1; }
|
||||
.quick-panel { display: flex; flex-direction: column; gap: 12px; }
|
||||
.quick-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
|
||||
.quick-grid button {
|
||||
min-height: 112px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 7px;
|
||||
text-align: left;
|
||||
}
|
||||
.quick-grid button:hover { border-color: var(--primary); background: #f8fbff; }
|
||||
.quick-grid svg { color: var(--primary); }
|
||||
.quick-grid span { color: var(--muted); line-height: 1.45; font-size: 13px; }
|
||||
.split { display: grid; grid-template-columns: minmax(0, 1fr) 390px; gap: 14px; align-items: start; }
|
||||
.split.wide-split { grid-template-columns: minmax(380px, 0.95fr) minmax(0, 1.05fr); }
|
||||
|
||||
.search-box {
|
||||
min-width: min(420px, 100%);
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 0 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.search-box input { border: 0; box-shadow: none; }
|
||||
.search-box input:focus { box-shadow: none; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
th, td { border-bottom: 1px solid var(--line); padding: 10px 8px; text-align: left; vertical-align: top; }
|
||||
th { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
tr.clickable { cursor: pointer; }
|
||||
tr.clickable:hover td { background: #f8fbff; }
|
||||
tr.selected td { background: #eef4ff; }
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
min-height: 24px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge.good { background: var(--good-bg); color: var(--good); border-color: #b7e4ca; }
|
||||
.badge.warn { background: var(--warn-bg); color: var(--warn); border-color: #f4d38c; }
|
||||
.badge.bad { background: var(--bad-bg); color: var(--bad); border-color: #f0b8b1; }
|
||||
.badge.neutral { background: #f3f4f6; color: #4b5563; border-color: var(--line); }
|
||||
|
||||
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.checkbox { flex-direction: row; align-items: center; font-weight: 700; color: var(--muted); }
|
||||
.checkbox input { width: auto; min-height: auto; }
|
||||
.detail-panel { position: sticky; top: 18px; max-height: calc(100dvh - 36px); overflow: auto; }
|
||||
hr { border: 0; border-top: 1px solid var(--line); width: 100%; margin: 12px 0; }
|
||||
.comment-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.comment { border: 1px solid var(--line); border-radius: 6px; padding: 9px; background: var(--panel-soft); }
|
||||
.comment p { margin: 4px 0 0; color: var(--muted); }
|
||||
.empty-state { min-height: 220px; display: grid; place-items: center; align-content: center; gap: 10px; color: var(--muted); text-align: center; }
|
||||
.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; }
|
||||
.code-editor { min-height: 56dvh; white-space: pre; overflow: auto; font-size: 13px; }
|
||||
.compact-editor { min-height: 260px; }
|
||||
details {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
|
||||
.json-preview {
|
||||
margin: 0;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #0f172a;
|
||||
color: #dbeafe;
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.json-preview.small { max-height: 260px; }
|
||||
.json-preview.tall { max-height: 70dvh; }
|
||||
.revision-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.revision-list button {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
text-align: left;
|
||||
padding: 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; }
|
||||
.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; }
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.chart-grid, .split, .split.wide-split { grid-template-columns: 1fr; }
|
||||
.detail-panel { position: static; max-height: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.app-shell { grid-template-columns: 1fr; }
|
||||
.sidebar { position: static; height: auto; }
|
||||
.nav-groups { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.workspace { padding: 16px; }
|
||||
.topbar, .section-head { align-items: stretch; flex-direction: column; }
|
||||
.metric-grid, .two-col { grid-template-columns: 1fr; }
|
||||
.quick-grid { grid-template-columns: 1fr; }
|
||||
.captcha-row { grid-template-columns: 1fr; }
|
||||
table { min-width: 720px; }
|
||||
.panel { overflow-x: auto; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { transition: none !important; scroll-behavior: auto !important; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ ctx: any }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel page-stack">
|
||||
<div class="section-head"><h2>审计日志</h2><button class="btn ghost" @click="ctx.loadAudit">刷新</button></div>
|
||||
<table>
|
||||
<thead><tr><th>类型</th><th>目标</th><th>信息</th><th>IP</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in ctx.auditLogs" :key="item.id">
|
||||
<td>{{ item.type }}</td>
|
||||
<td>{{ item.target }}</td>
|
||||
<td>{{ item.message }}</td>
|
||||
<td>{{ item.ip || "-" }}</td>
|
||||
<td>{{ item.createdAt }}</td>
|
||||
</tr>
|
||||
<tr v-if="ctx.auditLogs.length === 0"><td colspan="5">暂无审计日志。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import VChart from "vue-echarts";
|
||||
import { use } from "echarts/core";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
import { BarChart, GaugeChart, LineChart, PieChart } from "echarts/charts";
|
||||
import { GridComponent, LegendComponent, TooltipComponent } from "echarts/components";
|
||||
import { Activity, PauseCircle, PlayCircle } from "lucide-vue-next";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
|
||||
use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, TooltipComponent, LegendComponent]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-stack">
|
||||
<div class="metric-grid">
|
||||
<article class="metric"><span>反馈总数</span><strong>{{ ctx.kpis.feedbackTotal || 0 }}</strong><small>今日新增 {{ ctx.kpis.feedbackToday || 0 }}</small></article>
|
||||
<article class="metric"><span>可见接口</span><strong>{{ ctx.kpis.sourceVisible || 0 }}</strong><small>接口总数 {{ ctx.kpis.sourceTotal || 0 }}</small></article>
|
||||
<article class="metric"><span>版本日志</span><strong>{{ ctx.kpis.releaseNotices || 0 }}</strong><small>{{ ctx.latestNotice?.version || "暂无最新版本" }}</small></article>
|
||||
<article class="metric"><span>邮件失败</span><strong>{{ ctx.kpis.mailFailed || 0 }}</strong><small>旧反馈兼容记录</small></article>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<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 ? "恢复自动刷新" : "暂停自动刷新" }}
|
||||
</button>
|
||||
<span class="muted">每 15 秒自动刷新仪表盘数据。</span>
|
||||
</div>
|
||||
|
||||
<section class="panel quick-panel">
|
||||
<div class="section-head"><h2>功能总览</h2><span class="badge">{{ ctx.quickActions.length }} 个入口</span></div>
|
||||
<div class="quick-grid">
|
||||
<button v-for="item in ctx.quickActions" :key="item.path" @click="ctx.navigate(item.path)">
|
||||
<component :is="item.icon" :size="18" />
|
||||
<strong>{{ item.label }}</strong>
|
||||
<span>{{ item.description }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="chart-grid">
|
||||
<section class="panel chart-panel"><h2>接口心跳延迟</h2><VChart class="chart" :option="ctx.heartbeatOption" autoresize /></section>
|
||||
<section class="panel chart-panel"><h2>接口健康分布</h2><VChart class="chart" :option="ctx.healthOption" autoresize /></section>
|
||||
<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>
|
||||
<table>
|
||||
<thead><tr><th>接口</th><th>状态</th><th>延迟</th><th>错误</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in ctx.heartbeats.slice(0, 10)" :key="item.id">
|
||||
<td>{{ item.name || item.sourceId }}</td>
|
||||
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ ctx.labelStatus(item.status) }}</span></td>
|
||||
<td>{{ item.latencyMs || 0 }}ms</td>
|
||||
<td class="hash">{{ item.error || "-" }}</td>
|
||||
<td>{{ item.checkedAt || "-" }}</td>
|
||||
</tr>
|
||||
<tr v-if="ctx.heartbeats.length === 0"><td colspan="5">暂无心跳记录,点击“立即心跳检测”后会刷新。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-head"><h2>客户端调用上报</h2><span class="badge">{{ ctx.clientCalls.length }} 条</span></div>
|
||||
<table>
|
||||
<thead><tr><th>接口</th><th>状态</th><th>延迟</th><th>客户端</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in ctx.clientCalls.slice(0, 8)" :key="item.id">
|
||||
<td>{{ item.sourceId }}</td>
|
||||
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ ctx.labelStatus(item.status) }}</span></td>
|
||||
<td>{{ item.latencyMs || 0 }}ms</td>
|
||||
<td class="hash">{{ item.client || "-" }}</td>
|
||||
<td>{{ item.createdAt || "-" }}</td>
|
||||
</tr>
|
||||
<tr v-if="ctx.clientCalls.length === 0"><td colspan="5">暂无客户端调用上报。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ ctx: any }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="split">
|
||||
<section class="panel page-stack">
|
||||
<div class="section-head"><h2>数据库运行状态</h2><span :class="['badge', ctx.statusTone(ctx.database?.activeProvider)]">{{ ctx.database?.activeProvider || "-" }}</span></div>
|
||||
<div class="kv-grid">
|
||||
<span>配置类型</span><strong>{{ ctx.database?.configProvider || "-" }}</strong>
|
||||
<span>SQLite</span><strong>{{ ctx.database?.sqliteReady ? "ready" : "missing" }}</strong>
|
||||
<span>MySQL</span><strong>{{ ctx.database?.remoteReady ? "ready" : "offline" }}</strong>
|
||||
<span>最后同步</span><strong>{{ ctx.database?.lastSyncAt || "-" }}</strong>
|
||||
<span>最近错误</span><strong>{{ ctx.database?.lastSyncError || ctx.database?.lastError || "-" }}</strong>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="section-head"><h2>旧项目同步</h2><button class="btn ghost" @click="ctx.previewLegacySync">预览</button></div>
|
||||
<pre class="json-preview small">{{ ctx.pretty(ctx.legacySync) }}</pre>
|
||||
<button class="btn primary" @click="ctx.runLegacySync">执行旧项目同步</button>
|
||||
</section>
|
||||
<aside class="panel editor-panel">
|
||||
<h2>连接与同步</h2>
|
||||
<label>Provider<select v-model="ctx.databaseForm.provider"><option>sqlite</option><option>mysql</option></select></label>
|
||||
<label>SQLite 路径<input v-model="ctx.databaseForm.sqlitePath" placeholder="留空使用当前配置" /></label>
|
||||
<label>MySQL DSN<input v-model="ctx.databaseForm.mysqlDsn" placeholder="user:pass@tcp(host:3306)/db?parseTime=true" /></label>
|
||||
<button class="btn ghost" @click="ctx.testDatabase">测试连接</button>
|
||||
<button class="btn primary" @click="ctx.syncDatabase('import')">SQLite 导入远端</button>
|
||||
<button class="btn ghost" @click="ctx.syncDatabase('sync')">远端同步回本地</button>
|
||||
</aside>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
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>
|
||||
<table>
|
||||
<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>
|
||||
<td>{{ item.category || item.categoryId }}</td>
|
||||
<td>{{ item.proxyMode }}</td>
|
||||
<td><span :class="['badge', ctx.statusTone(ctx.endpointStatus(item))]">{{ ctx.endpointStatus(item) }}</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>
|
||||
</tr>
|
||||
<tr v-if="ctx.endpoints.length === 0"><td colspan="7">暂无客户端接口。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { Save, Search, UploadCloud } from "lucide-vue-next";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<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>
|
||||
<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>{{ item.lastActivityAt || item.createdAt }}</td>
|
||||
</tr>
|
||||
<tr v-if="!ctx.feedbackPage.items?.length"><td colspan="5">暂无反馈工单。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<aside class="panel detail-panel">
|
||||
<template v-if="ctx.selectedFeedback">
|
||||
<h2>{{ ctx.selectedFeedback.code }}</h2>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</details>
|
||||
</template>
|
||||
<div v-else class="empty-state">选择一条工单查看详情。</div>
|
||||
</aside>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ ctx: any }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel page-stack">
|
||||
<h2>健康快照</h2>
|
||||
<pre class="json-preview tall">{{ ctx.pretty(ctx.healthSnapshot) }}</pre>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircle2, Save } from "lucide-vue-next";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="split wide-split">
|
||||
<section class="panel editor-panel">
|
||||
<div class="section-head">
|
||||
<h2>{{ ctx.activeLegacyLabel }}</h2>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost" @click="ctx.validateLegacy(ctx.activeLegacyName)"><CheckCircle2 :size="16" />校验</button>
|
||||
<button class="btn primary" @click="ctx.saveLegacy(ctx.activeLegacyName)"><Save :size="16" />保存发布</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea v-model="ctx.legacyDrafts[ctx.activeLegacyName].raw" class="code-editor"></textarea>
|
||||
<label>保存备注<input v-model="ctx.legacyDrafts[ctx.activeLegacyName].note" /></label>
|
||||
</section>
|
||||
<aside class="panel page-stack">
|
||||
<h2>预览与历史</h2>
|
||||
<pre class="json-preview">{{ ctx.pretty(ctx.legacyDrafts[ctx.activeLegacyName].preview) }}</pre>
|
||||
<div class="revision-list">
|
||||
<button v-for="revision in ctx.legacyDocuments[ctx.activeLegacyName]?.revisions || []" :key="revision.id" @click="ctx.restoreLegacy(ctx.activeLegacyName, revision.id)">
|
||||
#{{ revision.id }} {{ revision.createdAt }}<small>{{ revision.note || "无备注" }}</small>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircle2, Save } from "lucide-vue-next";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="split wide-split">
|
||||
<section class="panel page-stack">
|
||||
<div class="section-head">
|
||||
<h2>发布包</h2>
|
||||
<a href="/update-info.json" target="_blank">查看旧版 update-info.json</a>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>文件</th><th>版本</th><th>平台</th><th>大小</th><th>SHA256</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="pkg in ctx.releasePackages" :key="pkg.fileName || pkg.url">
|
||||
<td>{{ pkg.fileName || pkg.name }}</td>
|
||||
<td>{{ pkg.version || ctx.releases?.app_version || "-" }}</td>
|
||||
<td>{{ pkg.platform || "-" }}/{{ pkg.arch || "-" }}</td>
|
||||
<td>{{ ctx.formatBytes(pkg.sizeBytes || pkg.size || 0) }}</td>
|
||||
<td class="hash">{{ pkg.sha256 || "-" }}</td>
|
||||
</tr>
|
||||
<tr v-if="ctx.releasePackages.length === 0"><td colspan="5">暂无可见发布包。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<aside class="panel editor-panel">
|
||||
<div class="section-head"><h2>版本日志</h2><button class="btn ghost" @click="ctx.importNotices">导入目录</button></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>
|
||||
<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>
|
||||
<label>备注<input v-model="ctx.noticeDraft.note" /></label>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost" @click="ctx.validateNotice"><CheckCircle2 :size="16" />校验</button>
|
||||
<button class="btn primary" @click="ctx.saveNotice"><Save :size="16" />保存日志</button>
|
||||
</div>
|
||||
<details v-if="ctx.selectedNotice?.revisions?.length">
|
||||
<summary>历史版本</summary>
|
||||
<div class="revision-list">
|
||||
<button v-for="revision in ctx.selectedNotice.revisions" :key="revision.id" @click="ctx.restoreNotice(revision.id)">#{{ revision.id }} {{ revision.createdAt }}</button>
|
||||
</div>
|
||||
</details>
|
||||
</aside>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { KeyRound } from "lucide-vue-next";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="split">
|
||||
<section class="panel editor-panel">
|
||||
<h2>修改后台密码</h2>
|
||||
<label>当前密码<input v-model="ctx.passwordForm.currentPassword" type="password" /></label>
|
||||
<label>新密码<input v-model="ctx.passwordForm.newPassword" type="password" /></label>
|
||||
<button class="btn primary" @click="ctx.changePassword"><KeyRound :size="16" />保存密码</button>
|
||||
</section>
|
||||
<section class="panel page-stack">
|
||||
<div class="section-head"><h2>旧项目同步预览</h2><button class="btn ghost" @click="ctx.previewLegacySync">刷新预览</button></div>
|
||||
<pre class="json-preview">{{ ctx.pretty(ctx.legacySync) }}</pre>
|
||||
<button class="btn primary" @click="ctx.runLegacySync">执行同步</button>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ ctx: any }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="split">
|
||||
<section class="panel page-stack">
|
||||
<div class="section-head"><h2>媒体/数据源</h2><button class="btn primary" @click="ctx.checkSources">批量检测</button></div>
|
||||
<div v-for="cat in ctx.sourceCategories" :key="cat.id || cat.name" class="source-group">
|
||||
<h3>{{ cat.name || cat.id }} <span class="badge">{{ cat.subcategories?.length || 0 }}</span></h3>
|
||||
<table>
|
||||
<thead><tr><th>名称</th><th>模式</th><th>状态</th><th>延迟</th><th>URL</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="src in cat.subcategories || []" :key="src.id || src.sourceId">
|
||||
<td>{{ src.name }}</td>
|
||||
<td>{{ src.proxyMode || src.proxy_mode || "client_direct" }}</td>
|
||||
<td><span :class="['badge', ctx.statusTone(src.health?.status || src.lastStatus)]">{{ src.health?.status || src.lastStatus || "unknown" }}</span></td>
|
||||
<td>{{ src.health?.latency_ms || src.lastLatencyMs || 0 }}ms</td>
|
||||
<td class="hash">{{ src.api_url || src.urlTemplate || src.apiUrl }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="ctx.sourceCategories.length === 0" class="empty-state">暂无接口源,可从旧 media-types.json 导入或手动添加。</div>
|
||||
</section>
|
||||
<aside class="panel editor-panel">
|
||||
<h2>添加/覆盖接口</h2>
|
||||
<label>ID<input v-model="ctx.sourceDraft.sourceId" /></label>
|
||||
<label>名称<input v-model="ctx.sourceDraft.name" /></label>
|
||||
<label>分类 ID<input v-model="ctx.sourceDraft.categoryId" /></label>
|
||||
<label>分类名称<input v-model="ctx.sourceDraft.categoryName" /></label>
|
||||
<label>URL<input v-model="ctx.sourceDraft.apiUrl" /></label>
|
||||
<label>代理模式<select v-model="ctx.sourceDraft.proxyMode"><option>client_direct</option><option>server_proxy</option><option>disabled</option></select></label>
|
||||
<div class="two-col">
|
||||
<label>缓存秒数<input v-model.number="ctx.sourceDraft.cacheSeconds" type="number" /></label>
|
||||
<label>检测间隔<input v-model.number="ctx.sourceDraft.checkIntervalSec" type="number" /></label>
|
||||
</div>
|
||||
<label class="checkbox"><input v-model="ctx.sourceDraft.enabled" type="checkbox" />启用</label>
|
||||
<label class="checkbox"><input v-model="ctx.sourceDraft.clientVisible" type="checkbox" />客户端可见</label>
|
||||
<button class="btn primary" @click="ctx.saveSource">保存接口</button>
|
||||
</aside>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default defineConfig({
|
||||
base: "/admin/",
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://127.0.0.1:33550"
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
//go:build embed_web
|
||||
|
||||
package webassets
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed admin/dist portal/dist setup/dist
|
||||
var FS embed.FS
|
||||
|
||||
const Embedded = true
|
||||
|
||||
func ReadFile(name string) ([]byte, error) {
|
||||
return FS.ReadFile(name)
|
||||
}
|
||||
|
||||
func ReadDir(name string) ([]fs.DirEntry, error) {
|
||||
return FS.ReadDir(name)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//go:build !embed_web
|
||||
|
||||
package webassets
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
const Embedded = false
|
||||
|
||||
func ReadFile(name string) ([]byte, error) {
|
||||
return nil, errors.New("web assets were not embedded; build with -tags embed_web")
|
||||
}
|
||||
|
||||
func ReadDir(name string) ([]fs.DirEntry, error) {
|
||||
return nil, errors.New("web assets were not embedded; build with -tags embed_web")
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>YMhut Box Service Portal</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1350
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "ymhut-unified-portal",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"lucide-vue-next": "^0.468.0",
|
||||
"vite": "^6.3.5",
|
||||
"vue": "^3.5.16",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from "vue";
|
||||
import { RouterLink, RouterView, useRoute } from "vue-router";
|
||||
import { Activity, ArrowDownToLine, FileJson, Home, MessageSquareText, Network, ShieldCheck } from "lucide-vue-next";
|
||||
import { usePortalState } from "./state";
|
||||
|
||||
const route = useRoute();
|
||||
const state = usePortalState();
|
||||
|
||||
const navItems = [
|
||||
{ path: "/", label: "状态总览", icon: Home },
|
||||
{ path: "/releases", label: "发布版本", icon: ArrowDownToLine },
|
||||
{ path: "/sources", label: "接口源", icon: Network },
|
||||
{ path: "/feedback", label: "反馈查询", icon: MessageSquareText },
|
||||
{ path: "/compatibility", label: "兼容说明", icon: FileJson },
|
||||
];
|
||||
|
||||
onMounted(() => state.load());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="portal-shell">
|
||||
<nav class="topnav">
|
||||
<RouterLink class="brand" to="/">
|
||||
<span><ShieldCheck :size="22" /></span>
|
||||
<strong>YMhut Box</strong>
|
||||
</RouterLink>
|
||||
<div class="nav-links">
|
||||
<RouterLink v-for="item in navItems" :key="item.path" :to="item.path" :class="{ active: route.path === item.path }">
|
||||
<component :is="item.icon" :size="15" />{{ item.label }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
<a class="admin-link" href="/admin/login">控制台</a>
|
||||
</nav>
|
||||
|
||||
<p v-if="state.error.value" class="state-banner error">部分状态读取失败:{{ state.error.value }}</p>
|
||||
<p v-if="state.loading.value" class="state-banner loading"><Activity :size="16" />正在读取服务状态...</p>
|
||||
|
||||
<RouterView />
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createApp } from "vue";
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import App from "./App.vue";
|
||||
import OverviewPage from "./pages/OverviewPage.vue";
|
||||
import ReleasesPage from "./pages/ReleasesPage.vue";
|
||||
import SourcesPage from "./pages/SourcesPage.vue";
|
||||
import FeedbackPage from "./pages/FeedbackPage.vue";
|
||||
import CompatibilityPage from "./pages/CompatibilityPage.vue";
|
||||
import "./styles.css";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: "/", component: OverviewPage },
|
||||
{ path: "/releases", component: ReleasesPage },
|
||||
{ path: "/sources", component: SourcesPage },
|
||||
{ path: "/feedback", component: FeedbackPage },
|
||||
{ path: "/compatibility", component: CompatibilityPage },
|
||||
],
|
||||
});
|
||||
|
||||
createApp(App).use(router).mount("#app");
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
const routes = [
|
||||
{ path: "/api/client/bootstrap", label: "新版客户端 Bootstrap" },
|
||||
{ path: "/api/client/releases", label: "新版发布信息" },
|
||||
{ path: "/api/client/sources", label: "新版接口源目录" },
|
||||
{ path: "/update-info.json", label: "旧版更新 JSON" },
|
||||
{ path: "/media-types.json", label: "旧版媒体源 JSON" },
|
||||
{ path: "/tool-status.json", label: "旧版工具状态" },
|
||||
{ path: "/modules.json", label: "旧版模块清单" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-heading">
|
||||
<p class="eyebrow">Compatibility</p>
|
||||
<h1>兼容说明</h1>
|
||||
<p>新旧客户端共用 update.ymhut.cn,旧路径和旧 JSON 字段继续保留。</p>
|
||||
</section>
|
||||
|
||||
<section class="panel wide">
|
||||
<h2>公开路径</h2>
|
||||
<div class="route-list">
|
||||
<a v-for="item in routes" :key="item.path" :href="item.path">
|
||||
<strong>{{ item.path }}</strong>
|
||||
<span>{{ item.label }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { MessageSquareText } from "lucide-vue-next";
|
||||
|
||||
const feedbackCode = ref("");
|
||||
const statusUrl = computed(() => feedbackCode.value.trim() ? `/?api=status&code=${encodeURIComponent(feedbackCode.value.trim())}` : "/?api=status&code=");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-heading">
|
||||
<p class="eyebrow">Feedback</p>
|
||||
<h1>反馈查询</h1>
|
||||
<p>旧客户端继续向根路径提交反馈。已有反馈可通过反馈码查询处理状态。</p>
|
||||
</section>
|
||||
|
||||
<section class="panel feedback-panel">
|
||||
<h2>查询反馈状态</h2>
|
||||
<div class="feedback-box">
|
||||
<input v-model="feedbackCode" placeholder="输入反馈码,例如 FB-20260626-0001" />
|
||||
<a class="button primary" :href="statusUrl"><MessageSquareText :size="18" />查询状态</a>
|
||||
</div>
|
||||
<p class="muted">反馈提交接口保持旧版兼容:客户端仍可 POST 到服务根路径。</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { Activity, ArrowDownToLine, Database, ExternalLink, HeartPulse, Network, ShieldCheck } from "lucide-vue-next";
|
||||
import { usePortalState } from "../state";
|
||||
|
||||
const state = usePortalState();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="hero">
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow">update.ymhut.cn</p>
|
||||
<h1>统一发布、反馈与接口源状态门户</h1>
|
||||
<p>
|
||||
新版客户端通过 bootstrap 动态获取发布信息、版本日志、媒体/数据源目录和接口健康状态。旧客户端仍可继续访问
|
||||
update-info.json、media-types.json、下载路径和反馈根路径。
|
||||
</p>
|
||||
<div class="actions">
|
||||
<a class="button primary" :href="state.downloadUrl.value"><ArrowDownToLine :size="18" />下载最新版本</a>
|
||||
<a class="button" href="/api/client/bootstrap"><ShieldCheck :size="18" />客户端配置</a>
|
||||
<RouterLink class="button" to="/compatibility"><ExternalLink :size="18" />兼容路径</RouterLink>
|
||||
</div>
|
||||
<div class="hero-tags">
|
||||
<span>Legacy JSON 兼容</span>
|
||||
<span>接口健康检测</span>
|
||||
<span>反馈状态追踪</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="release-card">
|
||||
<span class="live-dot">服务在线</span>
|
||||
<span>当前版本</span>
|
||||
<strong>{{ state.appVersion.value }}</strong>
|
||||
<p>{{ state.latestNotice.value?.title || state.releases.value?.title || "服务已启动,等待发布数据同步。" }}</p>
|
||||
<div class="release-meta">
|
||||
<span>{{ state.packages.value.length }} 个发布包</span>
|
||||
<span>{{ state.sourceCount.value }} 个接口源</span>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section class="metric-grid">
|
||||
<article class="metric"><Database :size="20" /><span>数据库</span><strong>{{ state.databaseStatus.value }}</strong></article>
|
||||
<article class="metric"><Network :size="20" /><span>可见接口源</span><strong>{{ state.sourceCount.value }}</strong></article>
|
||||
<article class="metric"><HeartPulse :size="20" /><span>健康接口</span><strong>{{ state.healthyCount.value }}</strong></article>
|
||||
<article class="metric"><Activity :size="20" /><span>可用率</span><strong>{{ state.availability.value }}%</strong></article>
|
||||
</section>
|
||||
|
||||
<section class="content-grid">
|
||||
<article class="panel">
|
||||
<div class="section-head"><h2>服务入口</h2><a href="/api/client/bootstrap">Bootstrap <ExternalLink :size="14" /></a></div>
|
||||
<div class="route-list">
|
||||
<RouterLink to="/releases"><strong>发布版本</strong><span>下载包、版本公告和 update-notice 日志</span></RouterLink>
|
||||
<RouterLink to="/sources"><strong>接口源健康</strong><span>媒体源、数据源和动态客户端接口状态</span></RouterLink>
|
||||
<RouterLink to="/feedback"><strong>反馈查询</strong><span>按反馈码查看旧客户端反馈处理状态</span></RouterLink>
|
||||
</div>
|
||||
</article>
|
||||
<article class="panel">
|
||||
<div class="section-head"><h2>最新版本日志</h2><RouterLink to="/releases">查看全部</RouterLink></div>
|
||||
<div v-if="state.latestNotice.value" class="notice-card">
|
||||
<strong>{{ state.latestNotice.value.title || state.latestNotice.value.version }}</strong>
|
||||
<p>{{ state.latestNotice.value.message || state.latestNotice.value.releaseNotes || "暂无详细说明。" }}</p>
|
||||
</div>
|
||||
<p v-else class="empty">暂无远程版本日志。可在后台“发布与日志”中导入 update-notice。</p>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { BookOpenText, ExternalLink } from "lucide-vue-next";
|
||||
import { usePortalState } from "../state";
|
||||
|
||||
const state = usePortalState();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-heading">
|
||||
<p class="eyebrow">Releases</p>
|
||||
<h1>发布版本</h1>
|
||||
<p>展示发布包、下载入口和 update-notice 版本日志。</p>
|
||||
</section>
|
||||
|
||||
<section class="content-grid">
|
||||
<article class="panel wide">
|
||||
<div class="section-head"><h2>发布包</h2><a href="/update-info.json">旧版 update-info.json <ExternalLink :size="14" /></a></div>
|
||||
<table>
|
||||
<thead><tr><th>文件</th><th>版本</th><th>平台</th><th>大小</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="pkg in state.packages.value" :key="pkg.fileName || pkg.url">
|
||||
<td>{{ pkg.fileName || pkg.name || "-" }}</td>
|
||||
<td>{{ pkg.version || state.appVersion.value }}</td>
|
||||
<td>{{ pkg.platform || "-" }}/{{ pkg.arch || "-" }}</td>
|
||||
<td>{{ state.formatBytes(pkg.sizeBytes || pkg.size || 0) }}</td>
|
||||
<td><a :href="pkg.url || state.downloadUrl.value">下载</a></td>
|
||||
</tr>
|
||||
<tr v-if="state.packages.value.length === 0"><td colspan="5">暂无可见发布包,旧客户端接口仍保持可用。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
|
||||
<article class="panel wide">
|
||||
<div class="section-head"><h2>版本日志</h2><a href="/api/client/notices">Notices API <ExternalLink :size="14" /></a></div>
|
||||
<div class="notice-list">
|
||||
<section v-for="notice in state.notices.value" :key="notice.version" class="notice-card">
|
||||
<BookOpenText :size="22" />
|
||||
<div>
|
||||
<strong>{{ notice.title || notice.version }}</strong>
|
||||
<p>{{ notice.message || notice.releaseNotes || notice.release_notes || "暂无详细说明。" }}</p>
|
||||
<span>{{ notice.publishedAt || notice.published_at || notice.updatedAt || notice.updated_at || "-" }}</span>
|
||||
</div>
|
||||
</section>
|
||||
<p v-if="state.notices.value.length === 0" class="empty">暂无版本日志。</p>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircle2, ExternalLink } from "lucide-vue-next";
|
||||
import { usePortalState } from "../state";
|
||||
|
||||
const state = usePortalState();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-heading">
|
||||
<p class="eyebrow">Sources</p>
|
||||
<h1>接口源健康</h1>
|
||||
<p>媒体源、数据源和客户端动态接口目录的可用性汇总。</p>
|
||||
</section>
|
||||
|
||||
<section class="panel wide">
|
||||
<div class="section-head"><h2>接口源可用性</h2><a href="/api/client/sources">Sources API <ExternalLink :size="14" /></a></div>
|
||||
<div v-if="state.categories.value.length" class="source-board">
|
||||
<section v-for="cat in state.categories.value" :key="cat.id || cat.name" class="source-group">
|
||||
<div>
|
||||
<h3>{{ cat.name || cat.id }}</h3>
|
||||
<p>{{ cat.subcategories?.length || 0 }} 个数据源</p>
|
||||
</div>
|
||||
<div class="source-list">
|
||||
<span v-for="src in cat.subcategories || []" :key="src.id || src.sourceId" :class="['badge', state.statusTone(state.sourceStatus(src))]">
|
||||
<CheckCircle2 :size="13" />{{ src.name }}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<p v-else class="empty">暂无接口源数据。后台同步旧 media-types.json 或手动添加后会显示在这里。</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,102 @@
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
const bootstrap = ref<any>(null);
|
||||
const releases = ref<any>(null);
|
||||
const sources = ref<any>(null);
|
||||
const notices = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
let loaded = false;
|
||||
|
||||
async function fetchJSON(path: string) {
|
||||
const res = await fetch(path);
|
||||
if (!res.ok) throw new Error(`${path} returned HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function usePortalState() {
|
||||
const packages = computed(() => releases.value?.packages || bootstrap.value?.release?.packages || []);
|
||||
const categories = computed(() => sources.value?.categories || bootstrap.value?.sources?.categories || []);
|
||||
const latestNotice = computed(() => notices.value[0] || releases.value?.latest_notice || bootstrap.value?.release?.latest_notice || null);
|
||||
const sourceCount = computed(() => categories.value.reduce((total: number, cat: any) => total + (cat.subcategories?.length || 0), 0));
|
||||
const healthyCount = computed(() => categories.value.reduce((total: number, cat: any) => {
|
||||
return total + (cat.subcategories || []).filter((item: any) => sourceStatus(item) === "ok").length;
|
||||
}, 0));
|
||||
const availability = computed(() => sourceCount.value ? Math.round((healthyCount.value / sourceCount.value) * 100) : 0);
|
||||
const downloadUrl = computed(() => releases.value?.download_url || bootstrap.value?.release?.download_url || "/update-info.json");
|
||||
const appVersion = computed(() => releases.value?.app_version || bootstrap.value?.release?.app_version || latestNotice.value?.version || "未发布");
|
||||
const databaseStatus = computed(() => bootstrap.value?.health?.database?.activeProvider || bootstrap.value?.health?.database?.configProvider || "-");
|
||||
const serviceVersion = computed(() => bootstrap.value?.serviceVersion || "-");
|
||||
|
||||
async function load(force = false) {
|
||||
if (loaded && !force) return;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const [bootstrapData, releaseData, sourceData, noticeData] = await Promise.allSettled([
|
||||
fetchJSON("/api/client/bootstrap"),
|
||||
fetchJSON("/api/client/releases"),
|
||||
fetchJSON("/api/client/sources"),
|
||||
fetchJSON("/api/client/notices"),
|
||||
]);
|
||||
if (bootstrapData.status === "fulfilled") bootstrap.value = bootstrapData.value;
|
||||
if (releaseData.status === "fulfilled") releases.value = releaseData.value;
|
||||
if (sourceData.status === "fulfilled") sources.value = sourceData.value;
|
||||
if (noticeData.status === "fulfilled") notices.value = noticeData.value.items || [];
|
||||
const firstFailure = [bootstrapData, releaseData, sourceData, noticeData].find((item) => item.status === "rejected") as PromiseRejectedResult | undefined;
|
||||
if (firstFailure && !bootstrap.value) error.value = firstFailure.reason?.message || String(firstFailure.reason);
|
||||
loaded = true;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bootstrap,
|
||||
releases,
|
||||
sources,
|
||||
notices,
|
||||
loading,
|
||||
error,
|
||||
packages,
|
||||
categories,
|
||||
latestNotice,
|
||||
sourceCount,
|
||||
healthyCount,
|
||||
availability,
|
||||
downloadUrl,
|
||||
appVersion,
|
||||
databaseStatus,
|
||||
serviceVersion,
|
||||
load,
|
||||
sourceStatus,
|
||||
statusTone,
|
||||
formatBytes,
|
||||
};
|
||||
}
|
||||
|
||||
export function sourceStatus(item: any) {
|
||||
return item.health?.status || item.lastStatus || "unknown";
|
||||
}
|
||||
|
||||
export function statusTone(status: string) {
|
||||
const value = String(status || "").toLowerCase();
|
||||
if (["ok", "sqlite", "mysql", "online", "ready"].includes(value)) return "good";
|
||||
if (["degraded", "pending", "missing"].includes(value)) return "warn";
|
||||
if (["error", "offline", "failed"].includes(value)) return "bad";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
export function formatBytes(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let next = value;
|
||||
let index = 0;
|
||||
while (next >= 1024 && index < units.length - 1) {
|
||||
next /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
return `${next.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: "Microsoft YaHei UI", "Segoe UI", Arial, sans-serif;
|
||||
color: #172033;
|
||||
background: #f7f9ff;
|
||||
--ink: #172033;
|
||||
--muted: #63718a;
|
||||
--soft: #f7f9ff;
|
||||
--panel: rgba(255, 255, 255, 0.82);
|
||||
--panel-strong: #ffffff;
|
||||
--line: rgba(112, 132, 170, 0.18);
|
||||
--line-strong: rgba(94, 114, 158, 0.28);
|
||||
--primary: #3b82f6;
|
||||
--primary-dark: #2563eb;
|
||||
--cyan: #06b6d4;
|
||||
--violet: #8b5cf6;
|
||||
--pink: #f472b6;
|
||||
--good: #059669;
|
||||
--warn: #b7791f;
|
||||
--bad: #dc2626;
|
||||
--shadow: 0 22px 65px rgba(65, 88, 140, 0.16);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { min-width: 320px; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
background:
|
||||
radial-gradient(circle at 8% 6%, rgba(96, 165, 250, 0.30), transparent 28%),
|
||||
radial-gradient(circle at 88% 8%, rgba(244, 114, 182, 0.20), transparent 30%),
|
||||
linear-gradient(180deg, #eef6ff 0%, #f8fbff 42%, #ffffff 100%);
|
||||
}
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(rgba(59, 130, 246, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(59, 130, 246, 0.05) 1px, transparent 1px);
|
||||
background-size: 42px 42px;
|
||||
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.55), transparent 70%);
|
||||
}
|
||||
a { color: inherit; }
|
||||
button, input { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
|
||||
.portal-shell {
|
||||
position: relative;
|
||||
min-height: 100dvh;
|
||||
padding: 18px clamp(14px, 2.4vw, 28px) 48px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topnav {
|
||||
position: sticky;
|
||||
z-index: 20;
|
||||
top: 14px;
|
||||
min-height: 64px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
width: min(1180px, 100%);
|
||||
margin: 0 auto 22px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.72);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
box-shadow: 0 16px 42px rgba(62, 87, 130, 0.12);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 44px;
|
||||
padding: 5px 12px 5px 6px;
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.brand span {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--primary), var(--cyan));
|
||||
box-shadow: 0 12px 26px rgba(37, 99, 235, 0.26);
|
||||
}
|
||||
.brand strong { letter-spacing: 0; }
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.nav-links a, .admin-link {
|
||||
min-height: 38px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 999px;
|
||||
padding: 8px 12px;
|
||||
color: #53627d;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
.nav-links a:hover, .nav-links a.active {
|
||||
color: var(--primary-dark);
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
.admin-link {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #2563eb, #7c3aed);
|
||||
box-shadow: 0 12px 28px rgba(59, 130, 246, 0.24);
|
||||
}
|
||||
.admin-link:hover { box-shadow: 0 16px 36px rgba(59, 130, 246, 0.32); }
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
width: min(1180px, 100%);
|
||||
min-height: 520px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 360px;
|
||||
gap: 22px;
|
||||
align-items: stretch;
|
||||
border: 1px solid rgba(255, 255, 255, 0.70);
|
||||
border-radius: 32px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.62)),
|
||||
radial-gradient(circle at 88% 18%, rgba(14, 165, 233, 0.26), transparent 34%),
|
||||
radial-gradient(circle at 18% 82%, rgba(139, 92, 246, 0.18), transparent 30%);
|
||||
box-shadow: var(--shadow);
|
||||
padding: clamp(28px, 5vw, 58px);
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -80px;
|
||||
bottom: -120px;
|
||||
width: 360px;
|
||||
height: 360px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(59, 130, 246, 0.20), transparent 68%);
|
||||
}
|
||||
.hero-copy {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 780px;
|
||||
align-self: center;
|
||||
}
|
||||
.eyebrow {
|
||||
margin: 0 0 12px;
|
||||
color: var(--primary-dark);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 18px;
|
||||
max-width: 900px;
|
||||
color: #12213a;
|
||||
font-size: clamp(36px, 6vw, 68px);
|
||||
line-height: 1.02;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
h2, h3, p { margin-top: 0; }
|
||||
p {
|
||||
color: var(--muted);
|
||||
line-height: 1.85;
|
||||
font-size: 16px;
|
||||
}
|
||||
.hero-copy p { max-width: 690px; font-size: 17px; }
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
.hero-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
.hero-tags span {
|
||||
border: 1px solid rgba(59, 130, 246, 0.18);
|
||||
border-radius: 999px;
|
||||
padding: 7px 11px;
|
||||
color: #355075;
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.button {
|
||||
min-height: 46px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid rgba(118, 137, 178, 0.22);
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
color: #263856;
|
||||
font-weight: 900;
|
||||
box-shadow: 0 10px 26px rgba(65, 88, 140, 0.10);
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
|
||||
}
|
||||
.button:hover {
|
||||
transform: translateY(-1px);
|
||||
background: #fff;
|
||||
box-shadow: 0 16px 36px rgba(65, 88, 140, 0.16);
|
||||
}
|
||||
.button.primary {
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
background: linear-gradient(135deg, #2563eb, #06b6d4);
|
||||
box-shadow: 0 16px 34px rgba(37, 99, 235, 0.26);
|
||||
}
|
||||
|
||||
.release-card, .panel, .metric {
|
||||
border: 1px solid rgba(255, 255, 255, 0.74);
|
||||
border-radius: 24px;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 14px 42px rgba(65, 88, 140, 0.11);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
.release-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
align-self: center;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 24px;
|
||||
}
|
||||
.release-card span { color: var(--muted); font-weight: 800; }
|
||||
.release-card .live-dot {
|
||||
width: fit-content;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--good);
|
||||
border: 1px solid rgba(16, 185, 129, 0.22);
|
||||
border-radius: 999px;
|
||||
padding: 5px 9px;
|
||||
background: rgba(209, 250, 229, 0.66);
|
||||
}
|
||||
.release-card .live-dot::before {
|
||||
content: "";
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #10b981;
|
||||
box-shadow: 0 0 0 5px rgba(16, 185, 129, 0.13);
|
||||
}
|
||||
.release-card strong {
|
||||
display: block;
|
||||
font-size: 44px;
|
||||
line-height: 1.05;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.release-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.release-meta span, .badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 5px 10px;
|
||||
color: #44536e;
|
||||
background: rgba(255, 255, 255, 0.68);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
width: min(1180px, 100%);
|
||||
margin: 18px auto 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.metric {
|
||||
min-height: 132px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 18px;
|
||||
}
|
||||
.metric svg { color: var(--primary); }
|
||||
.metric span { color: var(--muted); font-weight: 800; }
|
||||
.metric strong { color: #15233b; font-size: 30px; overflow-wrap: anywhere; }
|
||||
|
||||
.content-grid {
|
||||
width: min(1180px, 100%);
|
||||
margin: 18px auto 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1.12fr 0.88fr;
|
||||
gap: 18px;
|
||||
}
|
||||
.panel {
|
||||
padding: 22px;
|
||||
}
|
||||
.panel.wide { grid-column: 1 / -1; }
|
||||
.page-heading {
|
||||
width: min(1180px, 100%);
|
||||
margin: 0 auto 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.74);
|
||||
border-radius: 28px;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.68));
|
||||
box-shadow: var(--shadow);
|
||||
padding: clamp(24px, 4vw, 42px);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
.page-heading h1 { font-size: clamp(32px, 4.6vw, 52px); }
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.section-head h2 { margin: 0; color: #14223a; }
|
||||
.section-head a {
|
||||
color: var(--primary-dark);
|
||||
font-weight: 900;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
text-decoration: none;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
th, td {
|
||||
border-bottom: 1px solid rgba(112, 132, 170, 0.18);
|
||||
padding: 11px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.muted, .empty { color: var(--muted); }
|
||||
.notice-list { display: grid; gap: 12px; }
|
||||
.notice-card {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
border: 1px solid rgba(112, 132, 170, 0.15);
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
padding: 14px;
|
||||
}
|
||||
.notice-card svg { color: var(--primary); }
|
||||
.notice-card strong { display: block; margin-bottom: 6px; overflow-wrap: anywhere; }
|
||||
.notice-card p { margin-bottom: 8px; font-size: 14px; line-height: 1.65; }
|
||||
.notice-card span { color: var(--muted); font-size: 13px; }
|
||||
|
||||
.feedback-panel { max-width: 780px; margin: 0 auto; }
|
||||
.feedback-box {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
min-height: 46px;
|
||||
border: 1px solid rgba(112, 132, 170, 0.24);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
color: #172033;
|
||||
padding: 10px 14px;
|
||||
outline: none;
|
||||
}
|
||||
input:focus {
|
||||
border-color: rgba(59, 130, 246, 0.65);
|
||||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.14);
|
||||
}
|
||||
.source-board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.source-group {
|
||||
border: 1px solid rgba(112, 132, 170, 0.16);
|
||||
border-radius: 22px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
.source-group h3 { margin-bottom: 2px; color: #14223a; }
|
||||
.source-group p { margin-bottom: 10px; font-size: 14px; }
|
||||
.source-list {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.badge.good { color: var(--good); background: rgba(209, 250, 229, 0.78); border-color: rgba(16, 185, 129, 0.28); }
|
||||
.badge.warn { color: var(--warn); background: rgba(254, 243, 199, 0.82); border-color: rgba(245, 158, 11, 0.28); }
|
||||
.badge.bad { color: var(--bad); background: rgba(254, 226, 226, 0.82); border-color: rgba(239, 68, 68, 0.26); }
|
||||
.route-list { display: grid; gap: 10px; }
|
||||
.route-list a {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
border: 1px solid rgba(112, 132, 170, 0.16);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.70);
|
||||
padding: 14px;
|
||||
text-decoration: none;
|
||||
font-weight: 900;
|
||||
transition: transform 0.18s ease, border-color 0.18s ease, background-color 0.18s ease;
|
||||
}
|
||||
.route-list a:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(59, 130, 246, 0.36);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
.route-list span { color: var(--muted); font-size: 13px; font-weight: 700; }
|
||||
.state-banner {
|
||||
width: min(1180px, 100%);
|
||||
margin: 12px auto;
|
||||
border-radius: 999px;
|
||||
padding: 10px 14px;
|
||||
font-weight: 900;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
box-shadow: 0 12px 30px rgba(65, 88, 140, 0.10);
|
||||
}
|
||||
.error { color: var(--bad); }
|
||||
.loading { color: var(--muted); }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.topnav {
|
||||
position: static;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
border-radius: 24px;
|
||||
}
|
||||
.nav-links { justify-content: flex-start; }
|
||||
.hero, .content-grid, .metric-grid, .source-board { grid-template-columns: 1fr; }
|
||||
.hero { min-height: auto; }
|
||||
.feedback-box { grid-template-columns: 1fr; }
|
||||
table { min-width: 680px; }
|
||||
.panel { overflow-x: auto; }
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.portal-shell { padding-inline: 10px; }
|
||||
.hero, .page-heading { border-radius: 24px; padding: 24px; }
|
||||
h1 { font-size: 36px; }
|
||||
.actions .button { width: 100%; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { transition: none !important; scroll-behavior: auto !important; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://127.0.0.1:33550",
|
||||
"/update-info.json": "http://127.0.0.1:33550",
|
||||
"/downloads": "http://127.0.0.1:33550"
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>YMhut Unified Setup</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1328
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "ymhut-unified-setup",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"lucide-vue-next": "^0.468.0",
|
||||
"vite": "^6.3.5",
|
||||
"vue": "^3.5.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Database,
|
||||
FolderCog,
|
||||
LoaderCircle,
|
||||
LockKeyhole,
|
||||
ServerCog,
|
||||
ShieldCheck,
|
||||
TriangleAlert,
|
||||
} from "lucide-vue-next";
|
||||
|
||||
type SetupStatus = {
|
||||
ok: boolean;
|
||||
initialized: boolean;
|
||||
baseDir: string;
|
||||
configPath: string;
|
||||
defaults?: {
|
||||
provider?: string;
|
||||
sqlitePath?: string;
|
||||
mysqlDsn?: string;
|
||||
baseUrl?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type TestResult = {
|
||||
ok: boolean;
|
||||
provider: string;
|
||||
normalized?: Record<string, unknown>;
|
||||
maskedDsn?: string;
|
||||
latencyMs?: number;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{ title: "环境确认", description: "确认服务基准目录和公开地址", icon: ServerCog },
|
||||
{ title: "数据库选择", description: "选择 SQLite 或 MySQL", icon: Database },
|
||||
{ title: "连接配置", description: "填写对应数据库参数", icon: FolderCog },
|
||||
{ title: "连接测试", description: "由后端真实测试连接", icon: ShieldCheck },
|
||||
{ title: "完成确认", description: "写入配置并创建默认账号", icon: LockKeyhole },
|
||||
];
|
||||
|
||||
const currentStep = ref(0);
|
||||
const loading = ref(false);
|
||||
const status = ref<SetupStatus | null>(null);
|
||||
const testResult = ref<TestResult | null>(null);
|
||||
const error = ref("");
|
||||
const completed = ref(false);
|
||||
|
||||
const form = reactive({
|
||||
baseUrl: "https://update.ymhut.cn",
|
||||
provider: "sqlite",
|
||||
sqliteDir: "storage",
|
||||
sqliteFile: "unified.sqlite",
|
||||
mysql: {
|
||||
host: "127.0.0.1",
|
||||
port: 3306,
|
||||
database: "ymhut_unified",
|
||||
username: "",
|
||||
password: "",
|
||||
charset: "utf8mb4",
|
||||
parseTime: true,
|
||||
tls: "false",
|
||||
},
|
||||
});
|
||||
|
||||
const sqlitePath = computed(() => {
|
||||
const dir = form.sqliteDir.trim().replace(/[\\/]+$/g, "") || "storage";
|
||||
const file = form.sqliteFile.trim() || "unified.sqlite";
|
||||
return `${dir}/${file}`.replace(/\\/g, "/");
|
||||
});
|
||||
|
||||
const canContinue = computed(() => {
|
||||
if (currentStep.value === 0) return form.baseUrl.trim().length > 0;
|
||||
if (currentStep.value === 1) return form.provider === "sqlite" || form.provider === "mysql";
|
||||
if (currentStep.value === 2) {
|
||||
if (form.provider === "sqlite") return form.sqliteDir.trim() && form.sqliteFile.trim();
|
||||
return form.mysql.host.trim() && form.mysql.port > 0 && form.mysql.database.trim() && form.mysql.username.trim();
|
||||
}
|
||||
if (currentStep.value === 3) return testResult.value?.ok === true;
|
||||
return true;
|
||||
});
|
||||
|
||||
const payload = computed(() => ({
|
||||
provider: form.provider,
|
||||
baseUrl: form.baseUrl.trim(),
|
||||
sqlitePath: sqlitePath.value,
|
||||
mysql: {
|
||||
host: form.mysql.host.trim(),
|
||||
port: Number(form.mysql.port || 3306),
|
||||
database: form.mysql.database.trim(),
|
||||
username: form.mysql.username.trim(),
|
||||
password: form.mysql.password,
|
||||
charset: form.mysql.charset.trim() || "utf8mb4",
|
||||
parseTime: form.mysql.parseTime,
|
||||
tls: form.mysql.tls.trim() || "false",
|
||||
},
|
||||
}));
|
||||
|
||||
async function api<T>(target: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
if (init.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
|
||||
const res = await fetch(target, { ...init, headers });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data.ok === false) throw new Error(data.message || data.error || `HTTP ${res.status}`);
|
||||
return data as T;
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
status.value = await api<SetupStatus>("/api/setup/status");
|
||||
form.baseUrl = status.value.defaults?.baseUrl || form.baseUrl;
|
||||
form.provider = status.value.defaults?.provider || "sqlite";
|
||||
const defaultSQLite = status.value.defaults?.sqlitePath || "storage/unified.sqlite";
|
||||
const normalized = defaultSQLite.replace(/\\/g, "/");
|
||||
const index = normalized.lastIndexOf("/");
|
||||
if (index > -1) {
|
||||
form.sqliteDir = normalized.slice(0, index) || "storage";
|
||||
form.sqliteFile = normalized.slice(index + 1) || "unified.sqlite";
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function nextStep() {
|
||||
if (!canContinue.value) return;
|
||||
if (currentStep.value < steps.length - 1) currentStep.value += 1;
|
||||
}
|
||||
|
||||
function previousStep() {
|
||||
if (currentStep.value > 0) currentStep.value -= 1;
|
||||
}
|
||||
|
||||
async function testDatabase() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
testResult.value = null;
|
||||
try {
|
||||
testResult.value = await api<TestResult>("/api/setup/database/test", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload.value),
|
||||
});
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function completeSetup() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await api("/api/setup/complete", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload.value),
|
||||
});
|
||||
completed.value = true;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadStatus);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="setup-shell">
|
||||
<section class="setup-card">
|
||||
<aside class="setup-aside">
|
||||
<p class="eyebrow">update.ymhut.cn</p>
|
||||
<h1>初始化统一管理服务端</h1>
|
||||
<p>使用分步向导确认运行目录、数据库连接和默认账号。所有连接测试都由服务端完成。</p>
|
||||
|
||||
<ol class="step-list">
|
||||
<li v-for="(step, index) in steps" :key="step.title" :class="{ active: currentStep === index, done: currentStep > index || completed }">
|
||||
<span><component :is="step.icon" :size="18" /></span>
|
||||
<div>
|
||||
<strong>{{ step.title }}</strong>
|
||||
<small>{{ step.description }}</small>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</aside>
|
||||
|
||||
<section class="setup-main">
|
||||
<header class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Step {{ currentStep + 1 }} / {{ steps.length }}</p>
|
||||
<h2>{{ steps[currentStep].title }}</h2>
|
||||
</div>
|
||||
<span v-if="loading" class="badge"><LoaderCircle :size="15" class="spin" />处理中</span>
|
||||
</header>
|
||||
|
||||
<p v-if="error" class="alert bad"><TriangleAlert :size="17" />{{ error }}</p>
|
||||
|
||||
<section v-if="completed" class="complete-panel">
|
||||
<CheckCircle2 :size="42" />
|
||||
<h2>初始化完成</h2>
|
||||
<p>配置已写入服务基准目录。请重启服务后打开 <code>/admin/login</code>,使用默认账号 <strong>admin/admin</strong> 登录并立即修改密码。</p>
|
||||
</section>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="currentStep === 0" class="form-grid">
|
||||
<label class="wide">服务基准目录<input :value="status?.baseDir || '-'" readonly /></label>
|
||||
<label class="wide">配置文件路径<input :value="status?.configPath || '-'" readonly /></label>
|
||||
<label class="wide">规范服务地址<input v-model.trim="form.baseUrl" placeholder="https://update.ymhut.cn" /></label>
|
||||
<p class="hint wide">相对路径会以服务基准目录为准。生产环境可继续通过反向代理把其他域名重定向到该地址。</p>
|
||||
</div>
|
||||
|
||||
<div v-if="currentStep === 1" class="choice-grid">
|
||||
<button :class="{ selected: form.provider === 'sqlite' }" @click="form.provider = 'sqlite'; testResult = null">
|
||||
<Database :size="24" />
|
||||
<strong>SQLite</strong>
|
||||
<span>推荐单机或轻量部署。默认写入 storage/unified.sqlite。</span>
|
||||
</button>
|
||||
<button :class="{ selected: form.provider === 'mysql' }" @click="form.provider = 'mysql'; testResult = null">
|
||||
<ServerCog :size="24" />
|
||||
<strong>MySQL</strong>
|
||||
<span>适合远端热同步、迁移和多实例备份场景。</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="currentStep === 2 && form.provider === 'sqlite'" class="form-grid">
|
||||
<label>SQLite 目录<input v-model.trim="form.sqliteDir" placeholder="storage" /></label>
|
||||
<label>SQLite 文件名<input v-model.trim="form.sqliteFile" placeholder="unified.sqlite" /></label>
|
||||
<label class="wide">最终路径<input :value="sqlitePath" readonly /></label>
|
||||
<p class="hint wide">保存时仍会由后端校验并创建目录,不允许路径逃逸。</p>
|
||||
</div>
|
||||
|
||||
<div v-if="currentStep === 2 && form.provider === 'mysql'" class="form-grid">
|
||||
<label>Host<input v-model.trim="form.mysql.host" placeholder="127.0.0.1" /></label>
|
||||
<label>Port<input v-model.number="form.mysql.port" type="number" min="1" /></label>
|
||||
<label>Database<input v-model.trim="form.mysql.database" placeholder="ymhut_unified" /></label>
|
||||
<label>Username<input v-model.trim="form.mysql.username" autocomplete="username" /></label>
|
||||
<label>Password<input v-model="form.mysql.password" type="password" autocomplete="new-password" /></label>
|
||||
<label>Charset<input v-model.trim="form.mysql.charset" placeholder="utf8mb4" /></label>
|
||||
<label>parseTime<select v-model="form.mysql.parseTime"><option :value="true">true</option><option :value="false">false</option></select></label>
|
||||
<label>TLS<select v-model="form.mysql.tls"><option>false</option><option>true</option><option>skip-verify</option><option>preferred</option></select></label>
|
||||
</div>
|
||||
|
||||
<div v-if="currentStep === 3" class="test-panel">
|
||||
<div class="summary-box">
|
||||
<span>数据库类型</span><strong>{{ form.provider }}</strong>
|
||||
<span>SQLite 路径</span><strong>{{ form.provider === "sqlite" ? sqlitePath : "-" }}</strong>
|
||||
<span>MySQL 地址</span><strong>{{ form.provider === "mysql" ? `${form.mysql.host}:${form.mysql.port}/${form.mysql.database}` : "-" }}</strong>
|
||||
</div>
|
||||
<button class="btn primary" data-testid="setup-test-database" @click="testDatabase"><ShieldCheck :size="17" />执行连接测试</button>
|
||||
<div v-if="testResult" class="result good">
|
||||
<CheckCircle2 :size="20" />
|
||||
<div>
|
||||
<strong>连接测试通过</strong>
|
||||
<p>{{ testResult.provider }},耗时 {{ testResult.latencyMs || 0 }}ms</p>
|
||||
<code v-if="testResult.maskedDsn">{{ testResult.maskedDsn }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="currentStep === 4" class="confirm-panel">
|
||||
<div class="summary-box">
|
||||
<span>服务地址</span><strong>{{ form.baseUrl }}</strong>
|
||||
<span>数据库</span><strong>{{ form.provider }}</strong>
|
||||
<span>连接</span><strong>{{ form.provider === "sqlite" ? sqlitePath : `${form.mysql.host}:${form.mysql.port}/${form.mysql.database}` }}</strong>
|
||||
<span>默认账号</span><strong>admin / admin</strong>
|
||||
</div>
|
||||
<p class="alert warn"><TriangleAlert :size="17" />初始化不会在此处修改默认密码。进入后台后登录页会提示默认密码,请立即修改。</p>
|
||||
<button class="btn primary" data-testid="setup-complete" @click="completeSetup"><CheckCircle2 :size="17" />写入配置并完成初始化</button>
|
||||
</div>
|
||||
|
||||
<footer class="setup-actions">
|
||||
<button class="btn ghost" data-testid="setup-prev" :disabled="currentStep === 0" @click="previousStep"><ChevronLeft :size="17" />上一步</button>
|
||||
<button v-if="currentStep < steps.length - 1" class="btn primary" data-testid="setup-next" :disabled="!canContinue" @click="nextStep">下一步<ChevronRight :size="17" /></button>
|
||||
</footer>
|
||||
</template>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./styles.css";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
@@ -0,0 +1,190 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: "Microsoft YaHei UI", "Segoe UI", Arial, sans-serif;
|
||||
color: #e5eefb;
|
||||
background: #0b1220;
|
||||
--panel: rgba(15, 23, 42, 0.9);
|
||||
--panel-soft: rgba(30, 41, 59, 0.72);
|
||||
--line: rgba(148, 163, 184, 0.28);
|
||||
--muted: #94a3b8;
|
||||
--ink: #f8fafc;
|
||||
--primary: #22c55e;
|
||||
--blue: #38bdf8;
|
||||
--warn: #fbbf24;
|
||||
--bad: #fb7185;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 320px; background: #0b1220; }
|
||||
button, input, select { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
|
||||
.setup-shell {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(circle at 10% 10%, rgba(56, 189, 248, 0.15), transparent 32%),
|
||||
radial-gradient(circle at 82% 18%, rgba(34, 197, 94, 0.16), transparent 34%),
|
||||
linear-gradient(145deg, #0b1220, #111827 52%, #0f172a);
|
||||
}
|
||||
|
||||
.setup-card {
|
||||
width: min(1120px, 100%);
|
||||
min-height: 680px;
|
||||
display: grid;
|
||||
grid-template-columns: 360px minmax(0, 1fr);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: rgba(2, 6, 23, 0.74);
|
||||
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.36);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.setup-aside {
|
||||
padding: 28px;
|
||||
background: linear-gradient(180deg, rgba(15, 23, 42, 0.95), rgba(15, 23, 42, 0.68));
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.setup-aside h1 { margin: 0 0 16px; font-size: 38px; line-height: 1.08; letter-spacing: 0; }
|
||||
.setup-aside p { color: var(--muted); line-height: 1.75; }
|
||||
.eyebrow { margin: 0 0 8px; color: var(--primary); font-size: 12px; font-weight: 900; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
.step-list { list-style: none; margin: 28px 0 0; padding: 0; display: grid; gap: 12px; }
|
||||
.step-list li {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 23, 42, 0.54);
|
||||
}
|
||||
.step-list li > span {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
background: #1e293b;
|
||||
color: var(--muted);
|
||||
}
|
||||
.step-list li.active { border-color: rgba(34, 197, 94, 0.75); background: rgba(34, 197, 94, 0.08); }
|
||||
.step-list li.done > span, .step-list li.active > span { color: #052e16; background: var(--primary); }
|
||||
.step-list strong { display: block; }
|
||||
.step-list small { color: var(--muted); }
|
||||
|
||||
.setup-main { padding: 28px; display: flex; flex-direction: column; gap: 18px; }
|
||||
.section-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
||||
.section-head h2 { margin: 0; font-size: 26px; }
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 5px 9px;
|
||||
color: var(--muted);
|
||||
font-weight: 800;
|
||||
}
|
||||
.spin { animation: spin 1s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.wide { grid-column: 1 / -1; }
|
||||
label { display: grid; gap: 7px; color: #cbd5e1; font-weight: 800; font-size: 13px; }
|
||||
input, select {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #334155;
|
||||
background: #020617;
|
||||
color: var(--ink);
|
||||
padding: 9px 11px;
|
||||
outline: none;
|
||||
}
|
||||
input:focus, select:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.15); }
|
||||
input[readonly] { color: var(--muted); }
|
||||
.hint { margin: 0; color: var(--muted); line-height: 1.65; }
|
||||
|
||||
.choice-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.choice-grid button {
|
||||
min-height: 190px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
padding: 18px;
|
||||
text-align: left;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 10px;
|
||||
}
|
||||
.choice-grid button.selected { border-color: var(--primary); background: rgba(34, 197, 94, 0.09); }
|
||||
.choice-grid span { color: var(--muted); line-height: 1.6; }
|
||||
|
||||
.summary-box {
|
||||
display: grid;
|
||||
grid-template-columns: 140px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel-soft);
|
||||
padding: 16px;
|
||||
}
|
||||
.summary-box span { color: var(--muted); }
|
||||
.summary-box strong { overflow-wrap: anywhere; }
|
||||
.test-panel, .confirm-panel, .complete-panel { display: grid; gap: 16px; }
|
||||
.complete-panel {
|
||||
min-height: 420px;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
.complete-panel svg { color: var(--primary); }
|
||||
.complete-panel p { max-width: 620px; color: var(--muted); line-height: 1.8; }
|
||||
code { color: #bfdbfe; }
|
||||
|
||||
.btn {
|
||||
min-height: 42px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 10px 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-weight: 900;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
}
|
||||
.btn.primary { background: var(--primary); color: #052e16; border-color: var(--primary); }
|
||||
.btn.ghost { color: #cbd5e1; background: #1e293b; }
|
||||
.setup-actions { margin-top: auto; display: flex; justify-content: space-between; gap: 10px; }
|
||||
.alert, .result {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.alert.bad { color: #fecdd3; background: rgba(244, 63, 94, 0.12); border: 1px solid rgba(244, 63, 94, 0.36); }
|
||||
.alert.warn { color: #fde68a; background: rgba(245, 158, 11, 0.12); border: 1px solid rgba(245, 158, 11, 0.36); }
|
||||
.result.good { color: #bbf7d0; background: rgba(34, 197, 94, 0.12); border: 1px solid rgba(34, 197, 94, 0.36); }
|
||||
.result p { margin: 4px 0; color: var(--muted); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.setup-card { grid-template-columns: 1fr; }
|
||||
.setup-aside { border-right: 0; border-bottom: 1px solid var(--line); }
|
||||
.form-grid, .choice-grid { grid-template-columns: 1fr; }
|
||||
.summary-box { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default defineConfig({
|
||||
base: "/setup/",
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://127.0.0.1:33550"
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "ymhut_unified_wasm"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
wasm-bindgen = "0.2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,96 @@
|
||||
use serde_json::{json, Value};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(js_name = compareVersions)]
|
||||
pub fn compare_versions(a: &str, b: &str) -> i32 {
|
||||
let mut av = parse_version(a);
|
||||
let mut bv = parse_version(b);
|
||||
av.resize(4, 0);
|
||||
bv.resize(4, 0);
|
||||
for index in 0..4 {
|
||||
if av[index] > bv[index] {
|
||||
return 1;
|
||||
}
|
||||
if av[index] < bv[index] {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = scoreEndpointHealth)]
|
||||
pub fn score_endpoint_health(status: &str, latency_ms: i32, failures: i32) -> i32 {
|
||||
let base = match status {
|
||||
"ok" => 100,
|
||||
"degraded" => 70,
|
||||
"error" => 30,
|
||||
_ => 50,
|
||||
};
|
||||
let latency_penalty = (latency_ms / 250).clamp(0, 30);
|
||||
let failure_penalty = (failures * 12).clamp(0, 48);
|
||||
(base - latency_penalty - failure_penalty).clamp(0, 100)
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = normalizeReleaseManifest)]
|
||||
pub fn normalize_release_manifest(input: &str) -> String {
|
||||
let mut value: Value = serde_json::from_str(input).unwrap_or_else(|_| json!({}));
|
||||
if value.get("manifest_version").is_none() {
|
||||
value["manifest_version"] = json!(2);
|
||||
}
|
||||
if value.get("packages").is_none() {
|
||||
value["packages"] = json!([]);
|
||||
}
|
||||
serde_json::to_string(&value).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = validateSourceCatalog)]
|
||||
pub fn validate_source_catalog(input: &str) -> String {
|
||||
let value: Value = serde_json::from_str(input).unwrap_or_else(|_| json!({}));
|
||||
let categories = value
|
||||
.get("categories")
|
||||
.and_then(|item| item.as_array())
|
||||
.map(|items| items.len())
|
||||
.unwrap_or_default();
|
||||
let mut source_count = 0usize;
|
||||
if let Some(items) = value.get("categories").and_then(|item| item.as_array()) {
|
||||
for category in items {
|
||||
source_count += category
|
||||
.get("subcategories")
|
||||
.and_then(|item| item.as_array())
|
||||
.map(|items| items.len())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
serde_json::to_string(&json!({
|
||||
"ok": categories > 0,
|
||||
"categories": categories,
|
||||
"sources": source_count
|
||||
})).unwrap_or_else(|_| "{\"ok\":false}".to_string())
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = mergeLegacyMediaTypes)]
|
||||
pub fn merge_legacy_media_types(current: &str, legacy: &str) -> String {
|
||||
let current_value: Value = serde_json::from_str(current).unwrap_or_else(|_| json!({"categories":[]}));
|
||||
let legacy_value: Value = serde_json::from_str(legacy).unwrap_or_else(|_| json!({"categories":[]}));
|
||||
let mut categories = current_value
|
||||
.get("categories")
|
||||
.and_then(|item| item.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if let Some(legacy_categories) = legacy_value.get("categories").and_then(|item| item.as_array()) {
|
||||
for category in legacy_categories {
|
||||
categories.push(category.clone());
|
||||
}
|
||||
}
|
||||
serde_json::to_string(&json!({
|
||||
"layout_version": "2.0.0",
|
||||
"categories": categories
|
||||
})).unwrap_or_else(|_| "{\"categories\":[]}".to_string())
|
||||
}
|
||||
|
||||
fn parse_version(value: &str) -> Vec<i32> {
|
||||
value
|
||||
.split('.')
|
||||
.map(|part| part.parse::<i32>().unwrap_or_default())
|
||||
.collect()
|
||||
}
|
||||
Reference in New Issue
Block a user