+
diff --git a/frontend/src/pages/BuildList.tsx b/frontend/src/pages/BuildList.tsx
index 03322b2..ea3edca 100644
--- a/frontend/src/pages/BuildList.tsx
+++ b/frontend/src/pages/BuildList.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { fetchBuilds, statusLabel, type Build } from "../api/client";
import { getBuildDuration, isBuildFinished } from "../lib/duration";
@@ -6,6 +6,8 @@ import { getBuildDuration, isBuildFinished } from "../lib/duration";
export default function BuildList() {
const [builds, setBuilds] = useState([]);
const [error, setError] = useState(null);
+ const [selectedPkg, setSelectedPkg] = useState(null);
+ const [sidebarOpen, setSidebarOpen] = useState(false);
useEffect(() => {
fetchBuilds()
@@ -15,35 +17,136 @@ export default function BuildList() {
);
}, []);
- return (
- <>
-
- {error ?
- >
+ const filtered = selectedPkg
+ ? builds.filter((b) => b.appIdentifier === selectedPkg)
+ : builds;
+
+ function selectPkg(pkg: string | null) {
+ setSelectedPkg(pkg);
+ setSidebarOpen(false);
+ }
+
+ return (
+
构建记录
-最近 20 条构建任务。
+ const packages = useMemo(() => { + const map = new Map< + string, + { appName: string; latestAt: string; count: number } + >(); + for (const b of builds) { + const existing = map.get(b.appIdentifier); + if (!existing) { + map.set(b.appIdentifier, { + appName: b.appName, + latestAt: b.createdAt, + count: 1, + }); + } else { + existing.count++; + if (b.createdAt > existing.latestAt) { + existing.latestAt = b.createdAt; + existing.appName = b.appName; + } + } + } + return [...map.entries()] + .sort((a, b) => b[1].latestAt.localeCompare(a[1].latestAt)) + .map(([appIdentifier, { appName, count }]) => ({ + appIdentifier, + appName, + count, + })); + }, [builds]); -{error}
: null} - {!error && builds.length === 0 ? ( -暂无构建记录。
- ) : null} - {builds.length > 0 ? ( --
- {builds.map((build) => (
-
-
-
- {build.appName} ({build.appNameEn})
-
- - v{build.appVersion} · {statusLabel(build.status)} - {isBuildFinished(build.status) - ? ` · 耗时 ${getBuildDuration(build).text}` - : null}{" "} - · {new Date(build.createdAt).toLocaleString()} --
- ))}
-
+ {sidebarOpen && (
+
setSidebarOpen(false)}
+ />
+ )}
+
+
+
+
);
}
diff --git a/frontend/src/styles/doc.css b/frontend/src/styles/doc.css
index 3bd1096..245ef31 100644
--- a/frontend/src/styles/doc.css
+++ b/frontend/src/styles/doc.css
@@ -74,6 +74,9 @@ a:hover {
font-size: 1.125rem;
color: var(--text);
text-decoration: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
}
.site-brand:hover {
@@ -81,6 +84,13 @@ a:hover {
color: var(--accent);
}
+.site-logo {
+ width: 28px;
+ height: 28px;
+ border-radius: 6px;
+ flex-shrink: 0;
+}
+
.site-nav {
display: flex;
gap: 1rem;
@@ -97,6 +107,10 @@ a:hover {
margin: 0 auto;
}
+.content-wide {
+ max-width: 64rem;
+}
+
.page-title {
margin: 0 0 0.25rem;
font-size: 1.75rem;
@@ -355,6 +369,209 @@ a:hover {
color: var(--muted);
}
+/* ===== Builds page layout ===== */
+.builds-layout {
+ display: flex;
+ gap: 1.25rem;
+ align-items: flex-start;
+}
+
+.builds-sidebar {
+ width: 190px;
+ flex-shrink: 0;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 0.5rem 0;
+ position: sticky;
+ top: 1rem;
+}
+
+.sidebar-header {
+ display: none;
+}
+
+.sidebar-title {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.sidebar-close {
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-size: 1rem;
+ padding: 0.25rem;
+ color: var(--muted);
+ line-height: 1;
+}
+
+.sidebar-close:hover {
+ color: var(--text);
+}
+
+.pkg-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.pkg-item {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 0.5rem;
+ width: 100%;
+ padding: 0.5rem 0.875rem;
+ text-align: left;
+ background: none;
+ border: none;
+ cursor: pointer;
+ font: inherit;
+ font-size: 0.875rem;
+ color: var(--text);
+ border-radius: 0;
+ transition: background 0.1s;
+}
+
+.pkg-item:hover {
+ background: var(--bg);
+}
+
+.pkg-item-active {
+ color: var(--accent);
+ font-weight: 600;
+ background: #eff6ff;
+}
+
+.pkg-info {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.pkg-name {
+ line-height: 1.4;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.pkg-en {
+ font-size: 0.75rem;
+ color: var(--muted);
+ line-height: 1.3;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.pkg-item-active .pkg-en {
+ color: #93c5fd;
+}
+
+.pkg-count {
+ flex-shrink: 0;
+ background: #e5e7eb;
+ color: #6b7280;
+ font-size: 0.7rem;
+ font-weight: 600;
+ padding: 0.1rem 0.4rem;
+ border-radius: 10px;
+ min-width: 1.375rem;
+ text-align: center;
+ line-height: 1.5;
+}
+
+.pkg-item-active .pkg-count {
+ background: #bfdbfe;
+ color: var(--accent);
+}
+
+.builds-main {
+ flex: 1;
+ min-width: 0;
+}
+
+.builds-topbar {
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ margin-bottom: 0.25rem;
+}
+
+.sidebar-toggle {
+ display: none;
+}
+
+.sidebar-overlay {
+ display: none;
+}
+
+@media (max-width: 767px) {
+ .builds-layout {
+ display: block;
+ }
+
+ .sidebar-toggle {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2.25rem;
+ height: 2.25rem;
+ flex-shrink: 0;
+ padding: 0;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ cursor: pointer;
+ font-size: 1.125rem;
+ color: var(--text);
+ }
+
+ .builds-sidebar {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 280px;
+ max-width: 85vw;
+ height: 100vh;
+ z-index: 200;
+ overflow-y: auto;
+ transform: translateX(-100%);
+ transition: transform 0.25s ease;
+ border-radius: 0;
+ border: none;
+ border-right: 1px solid var(--border);
+ padding-top: 0;
+ }
+
+ .builds-sidebar.sidebar-open {
+ transform: translateX(0);
+ }
+
+ .sidebar-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.875rem 1rem;
+ border-bottom: 1px solid var(--border);
+ margin-bottom: 0.25rem;
+ }
+
+ .sidebar-overlay {
+ display: block;
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.35);
+ z-index: 199;
+ }
+}
+
@media (min-width: 768px) {
:root {
--page-pad: 1.5rem;
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 4454d1e..751ae93 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -1,7 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
-export default defineConfig({
+export default defineConfig(({ mode }) => ({
plugins: [react()],
server: {
port: 5173,
@@ -13,7 +13,7 @@ export default defineConfig({
},
},
build: {
- outDir: "dist",
+ outDir: mode === "desktop" ? "dist-desktop" : "dist",
emptyOutDir: true,
},
-});
+}));
diff --git a/package.json b/package.json
index 5e5fd42..b0d050b 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
"scripts": {
"dev": "concurrently -n web,worker -c green,blue \"npm run dev -w frontend\" \"wrangler dev\"",
"build": "npm run build -w frontend",
+ "build:desktop": "npm run build:desktop -w frontend",
"deploy": "npm run build && wrangler deploy",
"db:migrate:local": "wrangler d1 migrations apply web2app --local",
"db:migrate:remote": "wrangler d1 migrations apply web2app --remote"
diff --git a/web2app-icon.png b/web2app-icon.png
new file mode 100644
index 0000000..1203924
Binary files /dev/null and b/web2app-icon.png differ
diff --git a/web2app-icon.svg b/web2app-icon.svg
new file mode 100644
index 0000000..bdfa519
--- /dev/null
+++ b/web2app-icon.svg
@@ -0,0 +1,71 @@
+
+
+ {error ?
+
+
+
+
+ 构建记录
++ {selectedPkg + ? `${selectedPkg} 的构建记录` + : "最近 20 条构建任务。"} +
+ +{error}
: null} + {!error && filtered.length === 0 ? ( +暂无构建记录。
+ ) : null} + {filtered.length > 0 ? ( +-
+ {filtered.map((build) => (
+
-
+
+ {build.appName} ({build.appNameEn})
+
+ + v{build.appVersion} · {statusLabel(build.status)} + {isBuildFinished(build.status) + ? ` · 耗时 ${getBuildDuration(build).text}` + : null}{" "} + · {new Date(build.createdAt).toLocaleString()} ++
+ ))}
+