diff --git a/.gitignore b/.gitignore index 8335722..5db2ef0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ node_modules/ frontend/dist/ !frontend/dist/index.html +frontend/dist-desktop/ +frontend/.env.desktop uploads/ data/ .env diff --git a/frontend/index.html b/frontend/index.html index 85c6088..6d492eb 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,9 @@ Web2App + + +
diff --git a/frontend/package.json b/frontend/package.json index c25d0be..d73159b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", + "build": "tsc -b && vite build && vite build --mode desktop", + "build:desktop": "vite build --mode desktop", "preview": "vite preview" }, "dependencies": { diff --git a/frontend/public/favicon.png b/frontend/public/favicon.png new file mode 100644 index 0000000..90ab7bc Binary files /dev/null and b/frontend/public/favicon.png differ diff --git a/frontend/public/logo-192.png b/frontend/public/logo-192.png new file mode 100644 index 0000000..dca750f Binary files /dev/null and b/frontend/public/logo-192.png differ diff --git a/frontend/public/logo.svg b/frontend/public/logo.svg new file mode 100644 index 0000000..bdfa519 --- /dev/null +++ b/frontend/public/logo.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 5843cf3..1f87eff 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,11 +1,15 @@ -import { Link, Outlet } from "react-router-dom"; +import { Link, Outlet, useLocation } from "react-router-dom"; export default function Layout() { + const { pathname } = useLocation(); + const isBuilds = pathname === "/jobs"; + return (
+ Web2App
-
+
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 ( - <> -

构建记录

-

最近 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 ?

{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()} -
    -
  • - ))} -
- ) : null} -
- + const filtered = selectedPkg + ? builds.filter((b) => b.appIdentifier === selectedPkg) + : builds; + + function selectPkg(pkg: string | null) { + setSelectedPkg(pkg); + setSidebarOpen(false); + } + + return ( +
+ {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + + + +
+
+ +

构建记录

+
+

+ {selectedPkg + ? `${selectedPkg} 的构建记录` + : "最近 20 条构建任务。"} +

+ +
+ {error ?

{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()} +
    +
  • + ))} +
+ ) : null} +
+
+
); } 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +