@@ -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 Update Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+2337
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "ymhut-update-admin",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 127.0.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"daisyui": "^5.0.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"typescript": "^5.9.0",
|
||||
"vite": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Boxes,
|
||||
CheckCircle2,
|
||||
Download,
|
||||
FileJson,
|
||||
Lock,
|
||||
LogOut,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Shield,
|
||||
UserRound
|
||||
} from "lucide-react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./styles.css";
|
||||
|
||||
type ApiResult<T> = T & {
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type User = {
|
||||
id: number;
|
||||
username: string;
|
||||
email?: string;
|
||||
is_admin: boolean;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
type ReleaseFile = {
|
||||
name: string;
|
||||
size: number;
|
||||
size_text: string;
|
||||
mod_time: string;
|
||||
url: string;
|
||||
kind: string;
|
||||
sha256?: string;
|
||||
};
|
||||
|
||||
type Manifest = {
|
||||
latestVersion?: string;
|
||||
channel?: string;
|
||||
createdAt?: string;
|
||||
published_at?: string;
|
||||
latest?: {
|
||||
version?: string;
|
||||
channel?: string;
|
||||
published_at?: string;
|
||||
fullInstaller?: ReleaseSummary;
|
||||
msix?: ReleaseSummary;
|
||||
};
|
||||
fullInstaller?: ReleaseSummary;
|
||||
msix?: ReleaseSummary;
|
||||
messages?: Record<string, string>;
|
||||
};
|
||||
|
||||
type ReleaseSummary = {
|
||||
fileName?: string;
|
||||
url?: string;
|
||||
sha256?: string;
|
||||
size?: number;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
type SystemInfo = {
|
||||
users?: number;
|
||||
routes?: number;
|
||||
logs?: number;
|
||||
version?: string;
|
||||
server_time?: string;
|
||||
};
|
||||
|
||||
type LogEntry = {
|
||||
time: string;
|
||||
level: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
async function api<T>(path: string, init?: RequestInit): Promise<ApiResult<T>> {
|
||||
const response = await fetch(path, {
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {})
|
||||
},
|
||||
...init
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : {};
|
||||
if (!response.ok) {
|
||||
throw Object.assign(new Error(data.message || data.error || response.statusText), {
|
||||
status: response.status,
|
||||
data
|
||||
});
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [checking, setChecking] = useState(true);
|
||||
const [loginError, setLoginError] = useState("");
|
||||
|
||||
const refreshSession = useCallback(async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
const result = await api<{ user: User }>("/api/admin/me");
|
||||
setUser(result.user);
|
||||
} catch {
|
||||
setUser(null);
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshSession();
|
||||
}, [refreshSession]);
|
||||
|
||||
async function handleLogin(username: string, password: string) {
|
||||
setLoginError("");
|
||||
try {
|
||||
const result = await api<{ user: User }>("/api/admin/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
setUser(result.user);
|
||||
} catch (error) {
|
||||
setLoginError(error instanceof Error ? error.message : "登录失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await api("/api/admin/logout", { method: "POST" });
|
||||
setUser(null);
|
||||
}
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<div className="grid min-h-screen place-items-center p-6">
|
||||
<span className="loading loading-spinner loading-lg text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <UnauthorizedView error={loginError} onLogin={handleLogin} />;
|
||||
}
|
||||
|
||||
return <AdminDashboard user={user} onLogout={handleLogout} />;
|
||||
}
|
||||
|
||||
function UnauthorizedView({
|
||||
error,
|
||||
onLogin
|
||||
}: {
|
||||
error: string;
|
||||
onLogin: (username: string, password: string) => Promise<void>;
|
||||
}) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onLogin(username, password);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center px-4 py-10">
|
||||
<section className="surface w-full max-w-5xl rounded-lg p-5 lg:p-7">
|
||||
<div className="grid gap-6 lg:grid-cols-[1.05fr_0.95fr] lg:items-center">
|
||||
<div className="space-y-5">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-warning/15 text-warning">
|
||||
<Lock size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-warning">Unauthorized</p>
|
||||
<h1 className="mt-2 text-3xl font-semibold tracking-normal text-base-content">未授权 / 请登录</h1>
|
||||
<p className="mt-3 max-w-xl text-base leading-7 text-base-content/70">
|
||||
后台 API 仅允许已认证管理员访问。登录成功后可管理完整安装包、MSIX 发布物、清单刷新和会话状态。
|
||||
</p>
|
||||
</div>
|
||||
<div className="alert border-warning/20 bg-warning/10 text-warning">
|
||||
<AlertTriangle size={18} />
|
||||
<span>未登录访问后台时只显示此授权边界,不会提前加载后台数据。</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="rounded-lg border border-base-300 bg-base-100 p-5 shadow-sm" onSubmit={submit}>
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Shield size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">管理员登录</h2>
|
||||
<p className="text-sm text-base-content/60">使用已有管理员账号继续</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="form-control">
|
||||
<span className="label-text">用户名</span>
|
||||
<input
|
||||
className="input input-bordered mt-1"
|
||||
value={username}
|
||||
autoComplete="username"
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="form-control mt-3">
|
||||
<span className="label-text">密码</span>
|
||||
<input
|
||||
className="input input-bordered mt-1"
|
||||
type="password"
|
||||
value={password}
|
||||
autoComplete="current-password"
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error && <div className="alert alert-error mt-4 py-2 text-sm">{error}</div>}
|
||||
<button className="btn btn-primary mt-5 w-full" disabled={submitting} type="submit">
|
||||
{submitting ? <span className="loading loading-spinner loading-sm" /> : <UserRound size={18} />}
|
||||
登录
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminDashboard({ user, onLogout }: { user: User; onLogout: () => Promise<void> }) {
|
||||
const [manifest, setManifest] = useState<Manifest | null>(null);
|
||||
const [files, setFiles] = useState<ReleaseFile[]>([]);
|
||||
const [system, setSystem] = useState<SystemInfo | null>(null);
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const latest = manifest?.latest;
|
||||
const fullInstaller = latest?.fullInstaller ?? manifest?.fullInstaller;
|
||||
const msix = latest?.msix ?? manifest?.msix;
|
||||
const latestVersion = manifest?.latestVersion ?? latest?.version ?? fullInstaller?.version ?? "unknown";
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const [manifestResponse, fileResponse, systemResponse, logResponse] = await Promise.all([
|
||||
fetch("/update-info.json", { credentials: "include" }).then((response) => response.json()),
|
||||
api<{ files: ReleaseFile[] }>("/api/admin/releases/files"),
|
||||
api<SystemInfo>("/api/admin/system"),
|
||||
api<{ logs: LogEntry[] }>("/api/admin/logs?limit=8")
|
||||
]);
|
||||
setManifest(manifestResponse);
|
||||
setFiles(fileResponse.files ?? []);
|
||||
setSystem(systemResponse);
|
||||
setLogs(logResponse.logs ?? []);
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "加载失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const metrics = useMemo(
|
||||
() => [
|
||||
{ label: "最新版本", value: latestVersion, icon: CheckCircle2 },
|
||||
{ label: "发布文件", value: String(files.length), icon: Download },
|
||||
{ label: "后台用户", value: String(system?.users ?? 0), icon: Shield },
|
||||
{ label: "日志数量", value: String(system?.logs ?? 0), icon: Server }
|
||||
],
|
||||
[files.length, latestVersion, system?.logs, system?.users]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="admin-sidebar p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary text-primary-content">
|
||||
<Boxes size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-base font-semibold">YMhut Update</h1>
|
||||
<p className="text-xs text-base-content/60">Full installer / MSIX</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="menu mt-6 rounded-lg bg-base-100 p-2">
|
||||
<li>
|
||||
<a className="active">
|
||||
<Server size={16} />
|
||||
发布概览
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/update-info.json">
|
||||
<FileJson size={16} />
|
||||
清单 JSON
|
||||
</a>
|
||||
</li>
|
||||
</nav>
|
||||
|
||||
<div className="mt-6 rounded-lg border border-base-300 bg-base-100 p-3">
|
||||
<p className="text-xs text-base-content/60">当前会话</p>
|
||||
<p className="mt-1 font-medium">{user.username}</p>
|
||||
<p className="text-xs text-base-content/60">{user.is_admin ? "Administrator" : "User"}</p>
|
||||
<button className="btn btn-ghost btn-sm mt-3 w-full justify-start" onClick={() => void onLogout()}>
|
||||
<LogOut size={16} />
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="admin-main">
|
||||
<header className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-primary">Admin</p>
|
||||
<h2 className="text-2xl font-semibold tracking-normal">更新发布后台</h2>
|
||||
</div>
|
||||
<div className="join">
|
||||
<button className="btn join-item" onClick={() => void refresh()} disabled={busy}>
|
||||
{busy ? <span className="loading loading-spinner loading-sm" /> : <RefreshCw size={17} />}
|
||||
刷新
|
||||
</button>
|
||||
<a className="btn btn-primary join-item" href="/" target="_blank" rel="noreferrer">
|
||||
前台
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <div className="alert alert-error mb-4">{error}</div>}
|
||||
|
||||
<section className="metric-grid mb-4">
|
||||
{metrics.map((metric) => (
|
||||
<article className="surface rounded-lg p-4" key={metric.label}>
|
||||
<metric.icon className="mb-3 text-primary" size={20} />
|
||||
<p className="text-sm text-base-content/60">{metric.label}</p>
|
||||
<p className="mt-1 truncate text-xl font-semibold">{metric.value}</p>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="content-grid">
|
||||
<div className="space-y-4">
|
||||
<Panel title="发布物" subtitle="downloads 白名单安装产物">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>文件</th>
|
||||
<th>类型</th>
|
||||
<th>大小</th>
|
||||
<th>更新时间</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{files.map((file) => (
|
||||
<tr key={file.name}>
|
||||
<td className="max-w-[280px] truncate">{file.name}</td>
|
||||
<td>
|
||||
<span className="badge badge-outline rounded-md">{file.kind}</span>
|
||||
</td>
|
||||
<td>{file.size_text}</td>
|
||||
<td>{file.mod_time}</td>
|
||||
<td>
|
||||
<a className="btn btn-ghost btn-xs" href={file.url}>
|
||||
下载
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{files.length === 0 && (
|
||||
<tr>
|
||||
<td className="text-base-content/60" colSpan={5}>
|
||||
暂无发布文件
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="清单摘要" subtitle="公开清单仅包含完整安装包和 MSIX">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<SummaryCard title="完整安装包" item={fullInstaller} />
|
||||
<SummaryCard title="MSIX" item={msix} />
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Panel title="服务状态" subtitle={system?.server_time ?? "loading"}>
|
||||
<dl className="grid grid-cols-2 gap-3 text-sm">
|
||||
<Info label="版本" value={system?.version ?? "-"} />
|
||||
<Info label="路由" value={String(system?.routes ?? 0)} />
|
||||
<Info label="用户" value={String(system?.users ?? 0)} />
|
||||
<Info label="日志" value={String(system?.logs ?? 0)} />
|
||||
</dl>
|
||||
</Panel>
|
||||
|
||||
<Panel title="最近日志" subtitle="后台运行事件">
|
||||
<div className="space-y-2">
|
||||
{logs.map((log) => (
|
||||
<div className="rounded-md bg-base-200/80 p-2 text-sm" key={`${log.time}-${log.message}`}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{log.level}</span>
|
||||
<span className="text-xs text-base-content/50">{log.time}</span>
|
||||
</div>
|
||||
<p className="mt-1 break-words text-base-content/70">{log.message}</p>
|
||||
</div>
|
||||
))}
|
||||
{logs.length === 0 && <p className="text-sm text-base-content/60">暂无日志</p>}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="surface rounded-lg p-4">
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-semibold">{title}</h3>
|
||||
{subtitle && <p className="text-sm text-base-content/60">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({ title, item }: { title: string; item?: ReleaseSummary }) {
|
||||
return (
|
||||
<article className="rounded-lg border border-base-300 bg-base-100 p-3">
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
<p className="mt-2 truncate text-sm text-base-content/70">{item?.fileName ?? "未配置"}</p>
|
||||
<p className="mono mt-2 truncate text-xs text-base-content/50">{item?.sha256 ?? "-"}</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function Info({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md bg-base-200/80 p-3">
|
||||
<dt className="text-base-content/55">{label}</dt>
|
||||
<dd className="mt-1 font-medium">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
@@ -0,0 +1,91 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "daisyui" {
|
||||
themes: light --default, dark --prefersdark;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: "Segoe UI Variable", "Segoe UI", system-ui, sans-serif;
|
||||
background: #f5f7fb;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(244, 247, 251, 0.96)),
|
||||
#f5f7fb;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.28);
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
min-width: 0;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.surface {
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
box-shadow: 0 16px 42px rgba(15, 23, 42, 0.07);
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.65fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.28);
|
||||
}
|
||||
|
||||
.metric-grid,
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.admin-main {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": []
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
base: "/admin/",
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user