Initial commit: Web2App static site to Tauri platform

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-27 21:56:23 +08:00
commit 8298278c10
64 changed files with 11509 additions and 0 deletions

23
web/src/App.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { Route, Routes } from "react-router-dom";
import JobStatus from "./pages/JobStatus";
import Upload from "./pages/Upload";
export default function App() {
return (
<div className="app-shell">
<header className="app-header">
<div>
<p className="eyebrow">Web2App</p>
<h1></h1>
</div>
<p className="subtitle"> zip index.html Windows Android </p>
</header>
<main>
<Routes>
<Route path="/" element={<Upload />} />
<Route path="/jobs/:id" element={<JobStatus />} />
</Routes>
</main>
</div>
);
}

59
web/src/api.ts Normal file
View File

@@ -0,0 +1,59 @@
export type BuildStatus =
| "pending"
| "queued"
| "in_progress"
| "completed"
| "failed";
export interface Build {
id: string;
appName: string;
appIdentifier: string;
status: BuildStatus;
workflowRunId: number | null;
windowsUrl: string | null;
androidUrl: string | null;
error: string | null;
createdAt: string;
updatedAt: string;
actionsUrl?: string | null;
}
export async function createBuild(formData: FormData): Promise<{ id: string }> {
const response = await fetch("/api/builds", {
method: "POST",
body: formData,
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error ?? "Upload failed");
}
return data;
}
export async function fetchBuild(id: string): Promise<Build> {
const response = await fetch(`/api/builds/${id}`);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error ?? "Failed to load build");
}
return data;
}
export function statusLabel(status: BuildStatus): string {
switch (status) {
case "pending":
return "准备中";
case "queued":
return "排队中";
case "in_progress":
return "构建中";
case "completed":
return "已完成";
case "failed":
return "失败";
default:
return status;
}
}

13
web/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);

120
web/src/pages/JobStatus.tsx Normal file
View File

@@ -0,0 +1,120 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { fetchBuild, statusLabel, type Build } from "../api";
export default function JobStatus() {
const { id } = useParams<{ id: string }>();
const [build, setBuild] = useState<Build | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!id) return;
let cancelled = false;
async function poll() {
try {
const data = await fetchBuild(id!);
if (cancelled) return;
setBuild(data);
setError(null);
if (data.status === "completed" || data.status === "failed") {
return;
}
window.setTimeout(poll, 5000);
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : "加载失败");
window.setTimeout(poll, 5000);
}
}
poll();
return () => {
cancelled = true;
};
}, [id]);
if (!id) {
return <p className="error"> ID</p>;
}
return (
<section className="card">
<div className="status-header">
<h2></h2>
<Link to="/"></Link>
</div>
{error ? <p className="error">{error}</p> : null}
{build ? (
<>
<dl className="meta">
<div>
<dt> ID</dt>
<dd>{build.id}</dd>
</div>
<div>
<dt></dt>
<dd>{build.appName}</dd>
</div>
<div>
<dt>Bundle ID</dt>
<dd>{build.appIdentifier}</dd>
</div>
<div>
<dt></dt>
<dd>
<span className={`badge badge-${build.status}`}>
{statusLabel(build.status)}
</span>
</dd>
</div>
</dl>
{build.status === "completed" ? (
<div className="downloads">
<h3></h3>
<div className="download-actions">
{build.windowsUrl ? (
<a className="button" href={build.windowsUrl} target="_blank" rel="noreferrer">
Windows
</a>
) : (
<span className="muted">Windows </span>
)}
{build.androidUrl ? (
<a className="button secondary" href={build.androidUrl} target="_blank" rel="noreferrer">
Android
</a>
) : (
<span className="muted">Android </span>
)}
</div>
</div>
) : null}
{build.status === "failed" ? (
<div className="error-box">
<p>{build.error ?? "构建失败,请查看 GitHub Actions 日志。"}</p>
{build.actionsUrl ? (
<a href={build.actionsUrl} target="_blank" rel="noreferrer">
Actions
</a>
) : null}
</div>
) : null}
{build.status !== "completed" && build.status !== "failed" ? (
<p className="hint"> GitHub Actions ...</p>
) : null}
</>
) : (
<p className="hint">...</p>
)}
</section>
);
}

78
web/src/pages/Upload.tsx Normal file
View File

@@ -0,0 +1,78 @@
import { FormEvent, useState } from "react";
import { useNavigate } from "react-router-dom";
import { createBuild } from "../api";
export default function Upload() {
const navigate = useNavigate();
const [appName, setAppName] = useState("");
const [identifier, setIdentifier] = useState("");
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onSubmit(event: FormEvent) {
event.preventDefault();
if (!file) {
setError("请选择 zip 文件");
return;
}
setLoading(true);
setError(null);
try {
const formData = new FormData();
formData.append("file", file);
formData.append("appName", appName);
if (identifier.trim()) {
formData.append("identifier", identifier.trim());
}
const result = await createBuild(formData);
navigate(`/jobs/${result.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : "上传失败");
} finally {
setLoading(false);
}
}
return (
<section className="card">
<h2></h2>
<p className="hint"> index.html 50MB</p>
<form className="form" onSubmit={onSubmit}>
<label>
<input
value={appName}
onChange={(e) => setAppName(e.target.value)}
placeholder="My App"
required
/>
</label>
<label>
Bundle ID
<input
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
placeholder="com.example.myapp"
/>
</label>
<label>
zip
<input
type="file"
accept=".zip,application/zip"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
required
/>
</label>
{error ? <p className="error">{error}</p> : null}
<button type="submit" disabled={loading}>
{loading ? "上传并触发构建..." : "开始构建"}
</button>
</form>
</section>
);
}

195
web/src/styles.css Normal file
View File

@@ -0,0 +1,195 @@
:root {
color-scheme: light dark;
font-family: Inter, Segoe UI, system-ui, sans-serif;
line-height: 1.5;
background: #0f172a;
color: #e2e8f0;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at top, rgba(56, 189, 248, 0.18), transparent 35%),
#0f172a;
}
a {
color: #38bdf8;
}
.app-shell {
max-width: 880px;
margin: 0 auto;
padding: 48px 20px 80px;
}
.app-header {
margin-bottom: 32px;
}
.eyebrow {
margin: 0 0 8px;
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 12px;
color: #94a3b8;
}
.app-header h1 {
margin: 0 0 8px;
font-size: 2.4rem;
}
.subtitle {
margin: 0;
color: #94a3b8;
}
.card {
background: rgba(15, 23, 42, 0.82);
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 20px;
padding: 28px;
backdrop-filter: blur(12px);
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.35);
}
.card h2,
.card h3 {
margin-top: 0;
}
.form {
display: grid;
gap: 16px;
}
label {
display: grid;
gap: 8px;
font-weight: 600;
}
input[type="text"],
input[type="file"] {
width: 100%;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(148, 163, 184, 0.25);
background: rgba(2, 6, 23, 0.65);
color: inherit;
}
button,
.button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 12px 18px;
border-radius: 12px;
border: none;
background: linear-gradient(135deg, #0284c7, #2563eb);
color: white;
font-weight: 700;
text-decoration: none;
cursor: pointer;
}
button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.button.secondary {
background: linear-gradient(135deg, #059669, #047857);
}
.hint,
.muted {
color: #94a3b8;
}
.error {
color: #fca5a5;
}
.error-box {
margin-top: 16px;
padding: 16px;
border-radius: 12px;
background: rgba(127, 29, 29, 0.25);
border: 1px solid rgba(248, 113, 113, 0.35);
}
.status-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.meta {
display: grid;
gap: 12px;
margin: 20px 0;
}
.meta div {
display: grid;
grid-template-columns: 120px 1fr;
gap: 12px;
}
.meta dt {
color: #94a3b8;
}
.meta dd {
margin: 0;
}
.badge {
display: inline-flex;
padding: 4px 10px;
border-radius: 999px;
font-size: 0.875rem;
background: rgba(148, 163, 184, 0.18);
}
.badge-completed {
background: rgba(34, 197, 94, 0.18);
color: #86efac;
}
.badge-failed {
background: rgba(239, 68, 68, 0.18);
color: #fca5a5;
}
.badge-in_progress,
.badge-queued,
.badge-pending {
background: rgba(56, 189, 248, 0.18);
color: #7dd3fc;
}
.downloads {
margin-top: 24px;
}
.download-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
@media (max-width: 640px) {
.meta div {
grid-template-columns: 1fr;
}
}

1
web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />