Add server components
build-winui / winui (push) Has been cancelled

This commit is contained in:
QWQLwToo
2026-06-26 13:28:09 +08:00
parent 7ecc6a8923
commit 079ee4eaeb
168 changed files with 37475 additions and 0 deletions
+12
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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": {}
}
+471
View File
@@ -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 />);
+91
View File
@@ -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;
}
}
+21
View File
@@ -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": []
}
+12
View File
@@ -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
}
});