feat: migrate to Cloudflare Workers with icon and version upload
Replace Express/SQLite and web/ with frontend/, worker/, and wrangler deploy. Add optional app icon upload, date-based app_version, and build-app workflow input. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
# 本地开发:复制为 .dev.vars(Wrangler 自动加载,勿提交)
|
||||
# cp .env.example .dev.vars
|
||||
#
|
||||
# 生产部署:敏感项用 wrangler secret put
|
||||
# wrangler secret put GITHUB_TOKEN
|
||||
|
||||
GITHUB_TOKEN=ghp_your_personal_access_token
|
||||
GITHUB_OWNER=shumengya
|
||||
GITHUB_REPO=Web2App
|
||||
DEFAULT_BRANCH=main
|
||||
PORT=3001
|
||||
MAX_UPLOAD_MB=50
|
||||
|
||||
2
.github/scripts/generate-app-icons.mjs
vendored
2
.github/scripts/generate-app-icons.mjs
vendored
@@ -8,7 +8,7 @@ const root = path.resolve(__dirname, "../..");
|
||||
const distDir = path.join(root, "template", "dist");
|
||||
const templateDir = path.join(root, "template");
|
||||
|
||||
const ICON_PRIORITY = ["logo.png", "favicon.ico"];
|
||||
const ICON_PRIORITY = ["logo.png", "logo.jpg", "logo.jpeg", "favicon.ico"];
|
||||
|
||||
function findIconSource(baseDir) {
|
||||
for (const name of ICON_PRIORITY) {
|
||||
|
||||
22
.github/scripts/prepare-template.mjs
vendored
22
.github/scripts/prepare-template.mjs
vendored
@@ -19,6 +19,17 @@ if (!jobId || !appNameZh || !appNameEn || !appIdentifier) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function readAppVersion(jobId) {
|
||||
const versionFile = path.join(root, "builds", jobId, "version.txt");
|
||||
if (fs.existsSync(versionFile)) {
|
||||
const fromFile = fs.readFileSync(versionFile, "utf8").trim();
|
||||
if (fromFile) return fromFile;
|
||||
}
|
||||
const fromEnv = (process.env.APP_VERSION ?? "").trim();
|
||||
return fromEnv || "1.0.0";
|
||||
}
|
||||
|
||||
const appVersion = readAppVersion(jobId);
|
||||
const zipPath = path.join(root, "builds", jobId, "site.zip");
|
||||
const distDir = path.join(root, "template", "dist");
|
||||
const confPath = path.join(root, "template", "src-tauri", "tauri.conf.json");
|
||||
@@ -46,6 +57,15 @@ if (!fs.existsSync(indexPath)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const buildDir = path.join(root, "builds", jobId);
|
||||
for (const iconName of ["logo.png", "logo.jpg", "logo.jpeg", "favicon.ico"]) {
|
||||
const iconSrc = path.join(buildDir, iconName);
|
||||
if (fs.existsSync(iconSrc)) {
|
||||
fs.copyFileSync(iconSrc, path.join(distDir, iconName));
|
||||
console.log(`Copied uploaded icon: builds/${jobId}/${iconName}`);
|
||||
}
|
||||
}
|
||||
|
||||
function toSafeBinaryName(name, fallback) {
|
||||
const ascii = name.replace(/[^a-zA-Z0-9_-]+/g, "");
|
||||
return (ascii || fallback).slice(0, 64);
|
||||
@@ -57,7 +77,7 @@ function patchTauriConfig(filePath) {
|
||||
conf.productName = appNameZh;
|
||||
conf.mainBinaryName = safeBinaryName;
|
||||
conf.identifier = appIdentifier;
|
||||
conf.version = "1.0.0";
|
||||
conf.version = appVersion;
|
||||
if (conf.app?.windows?.[0]) {
|
||||
conf.app.windows[0].title = appNameZh;
|
||||
}
|
||||
|
||||
3
.github/workflows/build-app.yml
vendored
3
.github/workflows/build-app.yml
vendored
@@ -28,6 +28,7 @@ env:
|
||||
APP_NAME: ${{ inputs.app_name }}
|
||||
APP_NAME_EN: ${{ inputs.app_name_en }}
|
||||
APP_IDENTIFIER: ${{ inputs.app_identifier }}
|
||||
APP_VERSION: ${{ inputs.app_version }}
|
||||
NDK_VERSION: "27.2.12479018"
|
||||
|
||||
jobs:
|
||||
@@ -176,7 +177,7 @@ jobs:
|
||||
tag_name: build-${{ inputs.job_id }}
|
||||
name: Web2App ${{ inputs.app_name }} (${{ inputs.job_id }})
|
||||
body: |
|
||||
Automated build for **${{ inputs.app_name }}** (English: `${{ inputs.app_name_en }}`, ID: `${{ inputs.app_identifier }}`).
|
||||
Automated build for **${{ inputs.app_name }}** (English: `${{ inputs.app_name_en }}`, version: `${{ inputs.app_version }}`, ID: `${{ inputs.app_identifier }}`).
|
||||
|
||||
- Windows desktop installer/portable binary
|
||||
- Android APK (signed for sideload install)
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,12 +1,14 @@
|
||||
node_modules/
|
||||
web/dist/
|
||||
server/dist/
|
||||
frontend/dist/
|
||||
!frontend/dist/index.html
|
||||
uploads/
|
||||
data/
|
||||
.env
|
||||
.dev.vars
|
||||
*.db
|
||||
*.db-journal
|
||||
.DS_Store
|
||||
.wrangler/
|
||||
target/
|
||||
template/dist/*
|
||||
!template/dist/index.html
|
||||
157
README.md
157
README.md
@@ -2,83 +2,111 @@
|
||||
|
||||
将已构建好的静态网页(zip,入口 `index.html`)转换为 **Windows 桌面应用** 与 **Android APK**。本仓库包含:
|
||||
|
||||
- `web/`:上传与构建状态页面
|
||||
- `server/`:Express API,负责 zip 校验、GitHub 上传与 workflow 触发
|
||||
- `frontend/`:React + Vite 上传与构建状态页面(文档风格 UI)
|
||||
- `worker/`:Cloudflare Worker API(zip 校验、GitHub 上传、workflow 触发)
|
||||
- `template/`:最小 Tauri 2 壳,CI 中注入用户静态资源后打包
|
||||
- `.github/workflows/build-app.yml`:并行构建 Windows / Android 并发布 Release
|
||||
|
||||
## 架构
|
||||
|
||||
1. 用户在本地网站上传 zip
|
||||
2. 后端校验 zip 后,通过 GitHub Contents API 上传到 `builds/{jobId}/site.zip`
|
||||
3. 后端触发 `build-app.yml` workflow
|
||||
1. 用户访问 Worker 托管的前端并上传 zip
|
||||
2. Worker 校验 zip 后,通过 GitHub Contents API 上传到 `builds/{jobId}/site.zip`
|
||||
3. Worker 触发 `build-app.yml` workflow
|
||||
4. GitHub Actions 解压 zip 到 `template/dist/`,构建 Windows 与 Android
|
||||
5. `finalize` job 创建 Release:`build-{jobId}`,并返回下载链接
|
||||
5. `finalize` job 创建 Release:`build-{jobId}`,前端轮询并展示下载链接
|
||||
|
||||
本地/生产均由 **一个 Worker** 提供 `/api/*` 与静态资源(`wrangler deploy` 一键部署)。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- Node.js 20+
|
||||
- [Cloudflare 账号](https://dash.cloudflare.com/) 与 [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/)
|
||||
- 一个 GitHub 仓库(建议名称 `Web2App`)
|
||||
- Personal Access Token,权限至少包含:
|
||||
- `repo`
|
||||
- `workflow`
|
||||
- Personal Access Token,权限至少包含:`repo`、`workflow`
|
||||
|
||||
## 快速开始
|
||||
## 快速开始(本地)
|
||||
|
||||
### 1. 初始化 GitHub 仓库
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
git init
|
||||
git add .
|
||||
git commit -m "init web2app platform"
|
||||
git branch -M main
|
||||
git remote add origin https://github.com/<owner>/Web2App.git
|
||||
git push -u origin main
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. 配置环境变量
|
||||
### 2. 配置密钥
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
cp .dev.vars.example .dev.vars
|
||||
```
|
||||
|
||||
编辑 `.env`:
|
||||
编辑 `.dev.vars`(Wrangler 本地开发自动加载):
|
||||
|
||||
```env
|
||||
GITHUB_TOKEN=ghp_xxx
|
||||
GITHUB_OWNER=your-github-username
|
||||
GITHUB_REPO=Web2App
|
||||
DEFAULT_BRANCH=main
|
||||
PORT=3001
|
||||
MAX_UPLOAD_MB=50
|
||||
```
|
||||
|
||||
### 3. 安装依赖并启动
|
||||
### 3. 初始化 D1(本地)
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run db:migrate:local
|
||||
```
|
||||
|
||||
### 4. 构建前端并启动
|
||||
|
||||
```bash
|
||||
npm run build -w frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
- 前端:<http://localhost:5173>
|
||||
- 后端:<http://localhost:3001>
|
||||
- 前端开发服务器:<http://localhost:5173>(`/api` 代理到 Worker `8787`)
|
||||
- Worker:<http://127.0.0.1:8787>
|
||||
|
||||
### 4. 上传测试
|
||||
首次 `wrangler dev` 前需存在 `frontend/dist`(`npm run build -w frontend`)。
|
||||
|
||||
准备一个 zip,根目录包含 `index.html`,也可使用 `examples/sample-site/` 打包:
|
||||
### 5. 上传测试
|
||||
|
||||
准备一个 zip,根目录包含 `index.html`,也可使用 `examples/sample-site/` 打包。
|
||||
|
||||
## 部署到 Cloudflare
|
||||
|
||||
### 1. 登录并创建 D1
|
||||
|
||||
```bash
|
||||
cd examples/sample-site
|
||||
powershell Compress-Archive -Path * -DestinationPath ../sample-site.zip -Force
|
||||
wrangler login
|
||||
wrangler d1 create web2app
|
||||
```
|
||||
|
||||
在网页中填写应用名称并上传 zip。
|
||||
将命令输出的 `database_id` 写入根目录 `wrangler.toml` 中对应 `[[d1_databases]]` 条目(若 Wrangler 未自动写入)。
|
||||
|
||||
```bash
|
||||
npm run db:migrate:remote
|
||||
```
|
||||
|
||||
### 2. 配置生产密钥
|
||||
|
||||
```bash
|
||||
wrangler secret put GITHUB_TOKEN
|
||||
```
|
||||
|
||||
在 `wrangler.toml` 的 `[vars]` 中设置 `GITHUB_OWNER`、`GITHUB_REPO`(或使用 `wrangler secret put` 存放全部变量)。
|
||||
|
||||
### 3. 一键部署
|
||||
|
||||
```bash
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
等价于:构建 `frontend/dist` → `wrangler deploy`(Worker API + 静态资源 + D1 绑定)。
|
||||
|
||||
## API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `POST` | `/api/builds` | `multipart/form-data`: `file`, `appNameZh`, `appNameEn`, 可选 `identifier` |
|
||||
| `GET` | `/api/health` | 健康检查 |
|
||||
| `POST` | `/api/builds` | `multipart/form-data`: `file`, `appNameZh`, `appNameEn`, `appVersion`(默认当天如 `2026.5.29`),可选 `identifier`、`icon`(PNG/JPG/ICO) |
|
||||
| `GET` | `/api/builds/:id` | 查询构建状态与下载链接 |
|
||||
| `GET` | `/api/builds` | 最近 20 条构建记录 |
|
||||
|
||||
@@ -88,7 +116,9 @@ powershell Compress-Archive -Path * -DestinationPath ../sample-site.zip -Force
|
||||
|
||||
- `job_id`
|
||||
- `app_name`
|
||||
- `app_name_en`
|
||||
- `app_identifier`
|
||||
- `app_version`
|
||||
|
||||
构建产物发布到 Release tag:`build-{job_id}`。
|
||||
|
||||
@@ -100,20 +130,10 @@ powershell Compress-Archive -Path * -DestinationPath ../sample-site.zip -Force
|
||||
2. `favicon.ico`
|
||||
3. 若都未找到,使用模板内置默认图标
|
||||
|
||||
找到后 CI 会执行 `tauri icon` 生成 Windows / Android 所需的多尺寸图标。
|
||||
### Android 签名
|
||||
|
||||
### Android 签名与安装
|
||||
|
||||
- CI 先构建未签名 APK,再用 `apksigner` **签名**,可直接侧载安装(不修改 Gradle 配置,避免构建失败)
|
||||
- 未配置 Secrets 时,workflow 会生成临时 CI 证书(密码 `web2app-ci`,仅适合 demo)
|
||||
- 生产环境建议在仓库 Settings → Secrets 配置:
|
||||
|
||||
| Secret | 说明 |
|
||||
|--------|------|
|
||||
| `ANDROID_KEYSTORE_BASE64` | `.jks` 文件 base64 |
|
||||
| `ANDROID_KEYSTORE_PASSWORD` | keystore 密码 |
|
||||
| `ANDROID_KEY_PASSWORD` | key 密码 |
|
||||
| `ANDROID_KEY_ALIAS` | 别名,默认 `web2app` |
|
||||
- CI 先构建未签名 APK,再用 `apksigner` 签名
|
||||
- 生产环境建议在仓库 Settings → Secrets 配置 `ANDROID_KEYSTORE_BASE64` 等
|
||||
|
||||
本地生成 keystore:
|
||||
|
||||
@@ -121,54 +141,35 @@ powershell Compress-Archive -Path * -DestinationPath ../sample-site.zip -Force
|
||||
bash scripts/generate-android-keystore.sh web2app-release.jks web2app
|
||||
```
|
||||
|
||||
- 首次 Android 构建会在 CI 中执行 `tauri android init`
|
||||
- Android 构建依赖 NDK / SDK,workflow 已预装常见组件
|
||||
|
||||
### 手动验证 workflow
|
||||
|
||||
在 GitHub 仓库 Actions 页选择 **Build App**,手动填写:
|
||||
|
||||
- `job_id`: 任意已上传目录名(需先存在 `builds/{job_id}/site.zip`)
|
||||
- `app_name`: 演示应用
|
||||
- `app_name_en`: Demo App
|
||||
- `app_identifier`: com.web2app.demo
|
||||
|
||||
## 本地数据
|
||||
|
||||
- SQLite:`data/web2app.db`
|
||||
- 上传临时文件:内存处理,不持久化到磁盘
|
||||
|
||||
## Demo 限制
|
||||
|
||||
- 无登录鉴权,仅适合本地/内网 demo
|
||||
- GitHub Actions 有排队时间与额度限制
|
||||
- 大 zip 会短暂占用仓库空间(finalize 会尝试删除 `site.zip`)
|
||||
- 未配置自有证书时,各次 CI 构建可能使用不同签名,无法覆盖安装旧版 APK
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
Web2App/
|
||||
├── web/ # Vite + React 前端
|
||||
├── server/ # Express 后端
|
||||
├── frontend/ # React + Vite 前端
|
||||
├── worker/ # Cloudflare Worker API + D1 migrations
|
||||
├── wrangler.toml # 统一部署配置
|
||||
├── template/ # Tauri 2 模板
|
||||
├── .github/workflows/ # CI 构建
|
||||
├── examples/ # 示例静态站点
|
||||
└── data/ # 本地 SQLite(运行时生成)
|
||||
├── .github/workflows/ # CI 构建(未随 Worker 迁移改动)
|
||||
└── examples/ # 示例静态站点
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
**`wrangler dev` 找不到静态资源**
|
||||
|
||||
- 运行 `npm run build -w frontend`
|
||||
|
||||
**workflow_dispatch 返回 404**
|
||||
|
||||
- 确认 workflow 文件已在默认分支
|
||||
- 确认 workflow 已在默认分支
|
||||
- 确认 `DEFAULT_BRANCH` 与仓库默认分支一致
|
||||
|
||||
**D1 表不存在**
|
||||
|
||||
- 本地:`npm run db:migrate:local`
|
||||
- 远程:`npm run db:migrate:remote`
|
||||
|
||||
**Release 没有下载链接**
|
||||
|
||||
- 等待 `finalize` job 完成
|
||||
- 检查 Windows / Android job 是否都成功
|
||||
|
||||
**zip 校验失败**
|
||||
|
||||
- 确保 `index.html` 在 zip 根目录,或仅套一层文件夹
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "web2app-web",
|
||||
"name": "web2app-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
17
frontend/src/App.tsx
Normal file
17
frontend/src/App.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import Layout from "./components/Layout";
|
||||
import BuildList from "./pages/BuildList";
|
||||
import JobStatus from "./pages/JobStatus";
|
||||
import Upload from "./pages/Upload";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Upload />} />
|
||||
<Route path="/jobs" element={<BuildList />} />
|
||||
<Route path="/jobs/:id" element={<JobStatus />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
const API_BASE = import.meta.env.VITE_API_BASE ?? "";
|
||||
|
||||
export type BuildStatus =
|
||||
| "pending"
|
||||
| "queued"
|
||||
@@ -10,6 +12,7 @@ export interface Build {
|
||||
appName: string;
|
||||
appNameEn: string;
|
||||
appIdentifier: string;
|
||||
appVersion: string;
|
||||
status: BuildStatus;
|
||||
workflowRunId: number | null;
|
||||
windowsUrl: string | null;
|
||||
@@ -29,15 +32,21 @@ interface CreateBuildResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function apiUrl(path: string): string {
|
||||
return `${API_BASE}${path}`;
|
||||
}
|
||||
|
||||
export async function createBuild(formData: FormData): Promise<{ id: string }> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch("/api/builds", {
|
||||
response = await fetch(apiUrl("/api/builds"), {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
} catch {
|
||||
throw new Error("无法连接后端服务,请确认已运行 npm run dev 且 server 已启动");
|
||||
throw new Error(
|
||||
"无法连接后端,请确认已运行 npm run dev 且 wrangler 已启动",
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await readJson(response)) as CreateBuildResponse;
|
||||
@@ -48,7 +57,7 @@ export async function createBuild(formData: FormData): Promise<{ id: string }> {
|
||||
}
|
||||
|
||||
export async function fetchBuild(id: string): Promise<Build> {
|
||||
const response = await fetch(`/api/builds/${id}`);
|
||||
const response = await fetch(apiUrl(`/api/builds/${id}`));
|
||||
const data = (await readJson(response)) as Build;
|
||||
if (!response.ok) {
|
||||
const errorData = data as Build & ApiErrorResponse;
|
||||
@@ -57,6 +66,15 @@ export async function fetchBuild(id: string): Promise<Build> {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchBuilds(): Promise<Build[]> {
|
||||
const response = await fetch(apiUrl("/api/builds"));
|
||||
const data = (await readJson(response)) as { builds: Build[] };
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load builds");
|
||||
}
|
||||
return data.builds;
|
||||
}
|
||||
|
||||
async function readJson(response: Response): Promise<unknown> {
|
||||
const text = await response.text();
|
||||
if (!text) return {};
|
||||
27
frontend/src/components/Layout.tsx
Normal file
27
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Link, Outlet } from "react-router-dom";
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="site-header">
|
||||
<div className="site-header-inner">
|
||||
<Link to="/" className="site-brand">
|
||||
Web2App
|
||||
</Link>
|
||||
<nav className="site-nav">
|
||||
<Link to="/">新建构建</Link>
|
||||
<Link to="/jobs">构建记录</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main className="site-main">
|
||||
<div className="content">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
<footer className="site-footer">
|
||||
静态网页 zip → Windows / Android 原生应用
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
frontend/src/lib/version.ts
Normal file
4
frontend/src/lib/version.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/** 默认版本号:当前日期,格式如 2026.5.29 */
|
||||
export function getDefaultAppVersion(date = new Date()): string {
|
||||
return `${date.getFullYear()}.${date.getMonth() + 1}.${date.getDate()}`;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
import "./styles/doc.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
45
frontend/src/pages/BuildList.tsx
Normal file
45
frontend/src/pages/BuildList.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { fetchBuilds, statusLabel, type Build } from "../api/client";
|
||||
|
||||
export default function BuildList() {
|
||||
const [builds, setBuilds] = useState<Build[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBuilds()
|
||||
.then(setBuilds)
|
||||
.catch((err) =>
|
||||
setError(err instanceof Error ? err.message : "加载失败"),
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="page-title">构建记录</h1>
|
||||
<p className="page-lead">最近 20 条构建任务。</p>
|
||||
|
||||
<section className="doc-section">
|
||||
{error ? <p className="error-text">{error}</p> : null}
|
||||
{!error && builds.length === 0 ? (
|
||||
<p className="prose">暂无构建记录。</p>
|
||||
) : null}
|
||||
{builds.length > 0 ? (
|
||||
<ul className="build-list">
|
||||
{builds.map((build) => (
|
||||
<li key={build.id}>
|
||||
<Link to={`/jobs/${build.id}`}>
|
||||
{build.appName} ({build.appNameEn})
|
||||
</Link>
|
||||
<div className="meta-line">
|
||||
v{build.appVersion} · {build.id} · {statusLabel(build.status)}{" "}
|
||||
· {new Date(build.createdAt).toLocaleString()}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
150
frontend/src/pages/JobStatus.tsx
Normal file
150
frontend/src/pages/JobStatus.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { fetchBuild, statusLabel, type Build } from "../api/client";
|
||||
|
||||
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-text">缺少任务 ID</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="status-toolbar">
|
||||
<h1 className="page-title" style={{ margin: 0 }}>
|
||||
构建任务
|
||||
</h1>
|
||||
<Link to="/">返回上传</Link>
|
||||
</div>
|
||||
|
||||
{error ? <p className="error-text">{error}</p> : null}
|
||||
|
||||
<section className="doc-section">
|
||||
{build ? (
|
||||
<>
|
||||
<dl className="meta-list">
|
||||
<div>
|
||||
<dt>任务 ID</dt>
|
||||
<dd>
|
||||
<code>{build.id}</code>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>应用中文名</dt>
|
||||
<dd>{build.appName}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>应用英文名</dt>
|
||||
<dd>{build.appNameEn}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Bundle ID</dt>
|
||||
<dd>
|
||||
<code>{build.appIdentifier}</code>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>版本号</dt>
|
||||
<dd>
|
||||
<code>{build.appVersion}</code>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>状态</dt>
|
||||
<dd>
|
||||
<span className={`badge badge-${build.status}`}>
|
||||
{statusLabel(build.status)}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{build.status === "completed" ? (
|
||||
<>
|
||||
<h3>下载安装包</h3>
|
||||
<div className="download-row">
|
||||
{build.windowsUrl ? (
|
||||
<a
|
||||
className="btn btn-primary"
|
||||
href={build.windowsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
下载 Windows 版
|
||||
</a>
|
||||
) : (
|
||||
<span className="muted">Windows 安装包尚未就绪</span>
|
||||
)}
|
||||
{build.androidUrl ? (
|
||||
<a
|
||||
className="btn btn-secondary"
|
||||
href={build.androidUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
下载 Android 版
|
||||
</a>
|
||||
) : (
|
||||
<span className="muted">Android 安装包尚未就绪</span>
|
||||
)}
|
||||
</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="prose" style={{ marginTop: "1rem" }}>
|
||||
正在轮询 GitHub Actions 状态,请稍候…
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<p className="prose">加载任务信息…</p>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
153
frontend/src/pages/Upload.tsx
Normal file
153
frontend/src/pages/Upload.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createBuild } from "../api/client";
|
||||
import { getDefaultAppVersion } from "../lib/version";
|
||||
|
||||
export default function Upload() {
|
||||
const navigate = useNavigate();
|
||||
const [appNameZh, setAppNameZh] = useState("");
|
||||
const [appNameEn, setAppNameEn] = useState("");
|
||||
const [appVersion, setAppVersion] = useState(() => getDefaultAppVersion());
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [icon, setIcon] = useState<File | null>(null);
|
||||
const [iconPreview, setIconPreview] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!icon) {
|
||||
setIconPreview(null);
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(icon);
|
||||
setIconPreview(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [icon]);
|
||||
|
||||
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("appNameZh", appNameZh);
|
||||
formData.append("appNameEn", appNameEn);
|
||||
formData.append("appVersion", appVersion.trim());
|
||||
if (identifier.trim()) {
|
||||
formData.append("identifier", identifier.trim());
|
||||
}
|
||||
if (icon) {
|
||||
formData.append("icon", icon);
|
||||
}
|
||||
|
||||
const result = await createBuild(formData);
|
||||
navigate(`/jobs/${result.id}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "上传失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="page-title">上传静态站点</h1>
|
||||
<p className="page-lead">
|
||||
将包含 index.html 的 zip 打包为 Windows 与 Android 安装包。
|
||||
</p>
|
||||
|
||||
<section className="doc-section">
|
||||
<h2>使用说明</h2>
|
||||
<p className="prose">
|
||||
压缩包需包含 index.html(根目录或单层文件夹内),默认不超过 50MB。
|
||||
可单独上传应用图标(PNG / JPG / ICO,优先于 zip 内图标),版本号默认取当天日期(如
|
||||
2026.5.29)。中文名用于展示,英文名用于安装包与 Bundle ID。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="doc-section">
|
||||
<h2>构建参数</h2>
|
||||
<form className="form" onSubmit={onSubmit}>
|
||||
<label>
|
||||
应用中文名
|
||||
<input
|
||||
type="text"
|
||||
value={appNameZh}
|
||||
onChange={(e) => setAppNameZh(e.target.value)}
|
||||
placeholder="我的应用"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
应用英文名
|
||||
<input
|
||||
type="text"
|
||||
value={appNameEn}
|
||||
onChange={(e) => setAppNameEn(e.target.value)}
|
||||
placeholder="My App"
|
||||
required
|
||||
pattern="[a-zA-Z][a-zA-Z0-9 _.-]*"
|
||||
title="以字母开头,仅含英文字母、数字、空格、下划线和连字符"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
应用版本号
|
||||
<input
|
||||
type="text"
|
||||
value={appVersion}
|
||||
onChange={(e) => setAppVersion(e.target.value)}
|
||||
placeholder={getDefaultAppVersion()}
|
||||
required
|
||||
pattern="\d{4}\.\d{1,2}\.\d{1,2}"
|
||||
title="格式:YYYY.M.D,例如 2026.5.29"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Bundle ID(可选)
|
||||
<input
|
||||
type="text"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
placeholder="com.example.myapp"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
应用图标(可选)
|
||||
<input
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.ico,image/png,image/jpeg,image/x-icon"
|
||||
onChange={(e) => setIcon(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</label>
|
||||
{iconPreview ? (
|
||||
<div className="icon-preview">
|
||||
<img src={iconPreview} alt="图标预览" width={64} height={64} />
|
||||
<span className="muted">{icon?.name}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<label>
|
||||
静态网页 zip
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error ? <p className="error-text">{error}</p> : null}
|
||||
<button type="submit" className="btn btn-primary" disabled={loading}>
|
||||
{loading ? "上传并触发构建…" : "开始构建"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
375
frontend/src/styles/doc.css
Normal file
375
frontend/src/styles/doc.css
Normal file
@@ -0,0 +1,375 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #fafafa;
|
||||
--surface: #ffffff;
|
||||
--text: #1a1a1a;
|
||||
--muted: #5c5c5c;
|
||||
--border: #e5e5e5;
|
||||
--accent: #2563eb;
|
||||
--accent-hover: #1d4ed8;
|
||||
--danger: #b91c1c;
|
||||
--danger-bg: #fef2f2;
|
||||
--success: #15803d;
|
||||
--warning: #a16207;
|
||||
--radius: 8px;
|
||||
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
|
||||
Arial, "Noto Sans SC", sans-serif;
|
||||
--mono: ui-monospace, "Cascadia Code", "Segoe UI Mono", monospace;
|
||||
--content-max: 42rem;
|
||||
--page-pad: 1rem;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font);
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
padding: 0.75rem var(--page-pad);
|
||||
}
|
||||
|
||||
.site-header-inner {
|
||||
max-width: var(--content-max);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.site-brand {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.site-brand:hover {
|
||||
text-decoration: none;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.site-main {
|
||||
flex: 1;
|
||||
padding: 1.5rem var(--page-pad) 3rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: var(--content-max);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.page-lead {
|
||||
margin: 0 0 1.5rem;
|
||||
color: var(--muted);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.doc-section {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.doc-section h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.doc-section h3 {
|
||||
margin: 1.25rem 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.prose:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form input[type="text"],
|
||||
.form input[type="file"] {
|
||||
font: inherit;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.form input[type="file"] {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
padding: 0.625rem 1.25rem;
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
font-size: 0.9375rem;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.error-box {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--danger-bg);
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.error-box p {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.meta-list {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.meta-list > div {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(7rem, 9rem) 1fr;
|
||||
gap: 0.5rem 1rem;
|
||||
align-items: baseline;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.meta-list dt {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.meta-list dd {
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
background: #f3f4f6;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.badge-pending,
|
||||
.badge-queued {
|
||||
background: #fef3c7;
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.badge-in_progress {
|
||||
background: #dbeafe;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.badge-completed {
|
||||
background: #dcfce7;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.badge-failed {
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.download-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.status-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.build-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.build-list li {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.build-list li:first-child {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.build-list a {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.build-list .meta-line {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--muted);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.icon-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.icon-preview img {
|
||||
object-fit: contain;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 1rem var(--page-pad);
|
||||
text-align: center;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
:root {
|
||||
--page-pad: 1.5rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.doc-section {
|
||||
padding: 1.5rem 1.75rem;
|
||||
}
|
||||
|
||||
.download-row {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
9
frontend/src/vite-env.d.ts
vendored
Normal file
9
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
1
frontend/tsconfig.app.tsbuildinfo
Normal file
1
frontend/tsconfig.app.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/layout.tsx","./src/lib/version.ts","./src/pages/buildlist.tsx","./src/pages/jobstatus.tsx","./src/pages/upload.tsx"],"version":"5.6.3"}
|
||||
@@ -4,7 +4,9 @@
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler"
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -7,9 +7,13 @@ export default defineConfig({
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:3001",
|
||||
target: "http://127.0.0.1:8787",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
3713
package-lock.json
generated
3713
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -4,22 +4,21 @@
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"web",
|
||||
"server",
|
||||
"frontend",
|
||||
"worker",
|
||||
"template"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "concurrently -n server,web -c blue,green \"npm run dev:server\" \"npm run dev:web:wait\"",
|
||||
"dev:web:wait": "wait-on http://127.0.0.1:3001/api/health -t 120000 && npm run dev -w web",
|
||||
"dev:web": "npm run dev -w web",
|
||||
"dev:server": "npm run dev -w server",
|
||||
"build": "npm run build -w web && npm run build -w server",
|
||||
"start": "npm run start -w server"
|
||||
"dev": "concurrently -n web,worker -c green,blue \"npm run dev -w frontend\" \"wrangler dev\"",
|
||||
"build": "npm run build -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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"adm-zip": "^0.5.17",
|
||||
"concurrently": "^9.1.2",
|
||||
"typescript": "~5.6.2",
|
||||
"wait-on": "^8.0.5"
|
||||
"wrangler": "^3.99.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import Database from "better-sqlite3";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export type BuildStatus =
|
||||
| "pending"
|
||||
| "queued"
|
||||
| "in_progress"
|
||||
| "completed"
|
||||
| "failed";
|
||||
|
||||
export interface BuildRecord {
|
||||
id: string;
|
||||
app_name: string;
|
||||
app_name_en: string;
|
||||
app_identifier: string;
|
||||
status: BuildStatus;
|
||||
workflow_run_id: number | null;
|
||||
windows_url: string | null;
|
||||
android_url: string | null;
|
||||
error: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
|
||||
export function getDb(): Database.Database {
|
||||
if (db) return db;
|
||||
|
||||
const dataDir = path.resolve(__dirname, "../../data");
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
const dbPath = path.join(dataDir, "web2app.db");
|
||||
db = new Database(dbPath);
|
||||
const schema = fs.readFileSync(path.join(__dirname, "schema.sql"), "utf8");
|
||||
db.exec(schema);
|
||||
migrateBuildsTable(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
function migrateBuildsTable(database: Database.Database) {
|
||||
const columns = database
|
||||
.prepare("PRAGMA table_info(builds)")
|
||||
.all() as Array<{ name: string }>;
|
||||
|
||||
if (!columns.some((column) => column.name === "app_name_en")) {
|
||||
database.exec("ALTER TABLE builds ADD COLUMN app_name_en TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
}
|
||||
|
||||
export function insertBuild(record: {
|
||||
id: string;
|
||||
appName: string;
|
||||
appNameEn: string;
|
||||
appIdentifier: string;
|
||||
}): BuildRecord {
|
||||
const database = getDb();
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO builds (id, app_name, app_name_en, app_identifier, status)
|
||||
VALUES (@id, @appName, @appNameEn, @appIdentifier, 'pending')`,
|
||||
)
|
||||
.run(record);
|
||||
|
||||
return getBuild(record.id)!;
|
||||
}
|
||||
|
||||
export function getBuild(id: string): BuildRecord | null {
|
||||
const row = getDb()
|
||||
.prepare("SELECT * FROM builds WHERE id = ?")
|
||||
.get(id) as BuildRecord | undefined;
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export function listBuilds(limit = 20): BuildRecord[] {
|
||||
return getDb()
|
||||
.prepare("SELECT * FROM builds ORDER BY created_at DESC LIMIT ?")
|
||||
.all(limit) as BuildRecord[];
|
||||
}
|
||||
|
||||
export function updateBuild(
|
||||
id: string,
|
||||
patch: Partial<
|
||||
Pick<
|
||||
BuildRecord,
|
||||
| "status"
|
||||
| "workflow_run_id"
|
||||
| "windows_url"
|
||||
| "android_url"
|
||||
| "error"
|
||||
>
|
||||
>,
|
||||
): BuildRecord | null {
|
||||
const fields: string[] = [];
|
||||
const params: Record<string, unknown> = { id };
|
||||
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value !== undefined) {
|
||||
fields.push(`${key} = @${key}`);
|
||||
params[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.length === 0) return getBuild(id);
|
||||
|
||||
fields.push("updated_at = datetime('now')");
|
||||
getDb()
|
||||
.prepare(`UPDATE builds SET ${fields.join(", ")} WHERE id = @id`)
|
||||
.run(params);
|
||||
|
||||
return getBuild(id);
|
||||
}
|
||||
|
||||
export function toPublicBuild(record: BuildRecord) {
|
||||
return {
|
||||
id: record.id,
|
||||
appName: record.app_name,
|
||||
appNameEn: record.app_name_en || record.app_name,
|
||||
appIdentifier: record.app_identifier,
|
||||
status: record.status,
|
||||
workflowRunId: record.workflow_run_id,
|
||||
windowsUrl: record.windows_url,
|
||||
androidUrl: record.android_url,
|
||||
error: record.error,
|
||||
createdAt: record.created_at,
|
||||
updatedAt: record.updated_at,
|
||||
};
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import "./load-env.js";
|
||||
import cors from "cors";
|
||||
import express from "express";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { MulterError } from "multer";
|
||||
import { buildsRouter } from "./routes/builds.js";
|
||||
|
||||
const app = express();
|
||||
const port = Number(process.env.PORT ?? "3001");
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
app.get("/api/health", (_req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.use("/api/builds", buildsRouter);
|
||||
|
||||
app.use((err: unknown, _req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
if (res.headersSent) {
|
||||
next(err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (err instanceof MulterError) {
|
||||
const status = err.code === "LIMIT_FILE_SIZE" ? 413 : 400;
|
||||
res.status(status).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(err);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
});
|
||||
|
||||
const webDist = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../web/dist");
|
||||
app.use(express.static(webDist));
|
||||
app.get("*", (_req, res, next) => {
|
||||
if (_req.path.startsWith("/api")) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
res.sendFile(path.join(webDist, "index.html"), (err) => {
|
||||
if (err) next();
|
||||
});
|
||||
});
|
||||
|
||||
app.listen(port, "127.0.0.1", () => {
|
||||
console.log(`Web2App server listening on http://127.0.0.1:${port}`);
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
import dotenv from "dotenv";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const serverDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const rootEnv = path.resolve(serverDir, "../.env");
|
||||
|
||||
if (fs.existsSync(rootEnv)) {
|
||||
dotenv.config({ path: rootEnv });
|
||||
} else {
|
||||
dotenv.config();
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"name": "web2app-server",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"postinstall": "node -e \"try{require('better-sqlite3')}catch{process.exit(1)}\" || npm rebuild better-sqlite3",
|
||||
"predev": "npm rebuild better-sqlite3",
|
||||
"dev": "tsx watch index.ts",
|
||||
"prebuild": "npm rebuild better-sqlite3",
|
||||
"build": "tsc",
|
||||
"prestart": "npm rebuild better-sqlite3",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nanoid": "^5.1.0",
|
||||
"octokit": "^4.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.10.5",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "~5.6.2"
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import { Router } from "express";
|
||||
import multer from "multer";
|
||||
import { nanoid } from "nanoid";
|
||||
import {
|
||||
getBuild,
|
||||
insertBuild,
|
||||
listBuilds,
|
||||
toPublicBuild,
|
||||
updateBuild,
|
||||
type BuildStatus,
|
||||
} from "../db/index.js";
|
||||
import {
|
||||
getActionsRunUrl,
|
||||
getReleaseAssets,
|
||||
getWorkflowRun,
|
||||
triggerBuildWorkflow,
|
||||
uploadSiteZip,
|
||||
} from "../services/github.js";
|
||||
import {
|
||||
normalizeAppIdentifier,
|
||||
slugifyIdentifier,
|
||||
validateChineseAppName,
|
||||
validateEnglishAppName,
|
||||
validateZipBuffer,
|
||||
ZipValidationError,
|
||||
} from "../services/zip.js";
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 100 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
const maxUploadMb = Number(process.env.MAX_UPLOAD_MB ?? "50");
|
||||
const maxUploadBytes = maxUploadMb * 1024 * 1024;
|
||||
|
||||
export const buildsRouter = Router();
|
||||
|
||||
buildsRouter.get("/", (_req, res) => {
|
||||
const builds = listBuilds().map(toPublicBuild);
|
||||
res.json({ builds });
|
||||
});
|
||||
|
||||
buildsRouter.get("/:id", async (req, res) => {
|
||||
const record = getBuild(req.params.id);
|
||||
if (!record) {
|
||||
res.status(404).json({ error: "Build not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshed = await refreshBuildStatus(record);
|
||||
res.json({
|
||||
...toPublicBuild(refreshed),
|
||||
actionsUrl: getActionsRunUrl(refreshed.workflow_run_id),
|
||||
});
|
||||
});
|
||||
|
||||
buildsRouter.post("/", upload.single("file"), async (req, res) => {
|
||||
try {
|
||||
const appNameZh = validateChineseAppName(
|
||||
String(req.body.appNameZh ?? req.body.appName ?? ""),
|
||||
);
|
||||
const appNameEn = validateEnglishAppName(
|
||||
String(req.body.appNameEn ?? ""),
|
||||
);
|
||||
const identifierInput = String(req.body.identifier ?? "").trim();
|
||||
|
||||
if (!req.file) {
|
||||
res.status(400).json({ error: "file is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!req.file.originalname.toLowerCase().endsWith(".zip")) {
|
||||
res.status(400).json({ error: "Only .zip files are supported" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { normalizedBuffer } = validateZipBuffer(req.file.buffer, maxUploadBytes);
|
||||
const jobId = nanoid(10);
|
||||
const slug = slugifyIdentifier(appNameEn) || jobId.toLowerCase();
|
||||
const appIdentifier = normalizeAppIdentifier(
|
||||
identifierInput || `com.web2app.${slug}`,
|
||||
);
|
||||
|
||||
insertBuild({
|
||||
id: jobId,
|
||||
appName: appNameZh,
|
||||
appNameEn,
|
||||
appIdentifier,
|
||||
});
|
||||
|
||||
await uploadSiteZip(jobId, normalizedBuffer);
|
||||
updateBuild(jobId, { status: "queued" });
|
||||
|
||||
const workflowRunId = await triggerBuildWorkflow({
|
||||
jobId,
|
||||
appName: appNameZh,
|
||||
appNameEn,
|
||||
appIdentifier,
|
||||
});
|
||||
|
||||
updateBuild(jobId, {
|
||||
workflow_run_id: workflowRunId,
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
id: jobId,
|
||||
status: "in_progress",
|
||||
workflowRunId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ZipValidationError) {
|
||||
res.status(400).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
res.status(500).json({
|
||||
error: error instanceof Error ? error.message : "Failed to create build",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function refreshBuildStatus(record: ReturnType<typeof getBuild> & object) {
|
||||
if (!record.workflow_run_id) return record;
|
||||
if (record.status === "completed" || record.status === "failed") {
|
||||
return record;
|
||||
}
|
||||
|
||||
try {
|
||||
const run = await getWorkflowRun(record.workflow_run_id);
|
||||
|
||||
if (run.status === "queued") {
|
||||
return updateBuild(record.id, { status: "queued" }) ?? record;
|
||||
}
|
||||
|
||||
if (run.status === "in_progress") {
|
||||
return updateBuild(record.id, { status: "in_progress" }) ?? record;
|
||||
}
|
||||
|
||||
if (run.status === "completed") {
|
||||
if (run.conclusion === "success") {
|
||||
const assets = await getReleaseAssets(record.id);
|
||||
return (
|
||||
updateBuild(record.id, {
|
||||
status: "completed",
|
||||
windows_url: assets.windowsUrl,
|
||||
android_url: assets.androidUrl,
|
||||
error:
|
||||
assets.windowsUrl && assets.androidUrl
|
||||
? null
|
||||
: "Build finished but release assets were not found yet",
|
||||
}) ?? record
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
updateBuild(record.id, {
|
||||
status: "failed",
|
||||
error: `Workflow failed with conclusion: ${run.conclusion ?? "unknown"}`,
|
||||
}) ?? record
|
||||
);
|
||||
}
|
||||
|
||||
return record;
|
||||
} catch (error) {
|
||||
return (
|
||||
updateBuild(record.id, {
|
||||
status: "failed",
|
||||
error: error instanceof Error ? error.message : "Status refresh failed",
|
||||
}) ?? record
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPublicMeta(record: ReturnType<typeof getBuild>) {
|
||||
if (!record) return null;
|
||||
return {
|
||||
...toPublicBuild(record),
|
||||
actionsUrl: getActionsRunUrl(record.workflow_run_id),
|
||||
};
|
||||
}
|
||||
|
||||
export type { BuildStatus };
|
||||
@@ -1,169 +0,0 @@
|
||||
import "../load-env.js";
|
||||
import { Octokit } from "octokit";
|
||||
|
||||
function getGitHubConfig() {
|
||||
return {
|
||||
owner: (process.env.GITHUB_OWNER ?? "").trim(),
|
||||
repo: (process.env.GITHUB_REPO ?? "").trim(),
|
||||
token: (process.env.GITHUB_TOKEN ?? "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function getOctokit(): Octokit {
|
||||
const { owner, repo, token } = getGitHubConfig();
|
||||
if (!owner || !repo || !token) {
|
||||
throw new Error(
|
||||
"Missing GITHUB_OWNER, GITHUB_REPO, or GITHUB_TOKEN environment variables",
|
||||
);
|
||||
}
|
||||
return new Octokit({ auth: token });
|
||||
}
|
||||
|
||||
export function getRepoInfo() {
|
||||
const { owner, repo } = getGitHubConfig();
|
||||
return { owner, repo };
|
||||
}
|
||||
|
||||
export async function uploadSiteZip(jobId: string, zipBuffer: Buffer): Promise<void> {
|
||||
const { owner, repo } = getGitHubConfig();
|
||||
const octokit = getOctokit();
|
||||
const path = `builds/${jobId}/site.zip`;
|
||||
const content = zipBuffer.toString("base64");
|
||||
|
||||
try {
|
||||
await octokit.rest.repos.createOrUpdateFileContents({
|
||||
owner,
|
||||
repo,
|
||||
path,
|
||||
message: `chore: upload site for build ${jobId}`,
|
||||
content,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const status = (error as { status?: number }).status;
|
||||
if (status !== 422) throw error;
|
||||
|
||||
const existing = await octokit.rest.repos.getContent({ owner, repo, path });
|
||||
if (Array.isArray(existing.data) || existing.data.type !== "file") {
|
||||
throw new Error(`Unexpected content at ${path}`);
|
||||
}
|
||||
|
||||
await octokit.rest.repos.createOrUpdateFileContents({
|
||||
owner,
|
||||
repo,
|
||||
path,
|
||||
message: `chore: update site for build ${jobId}`,
|
||||
content,
|
||||
sha: existing.data.sha,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function triggerBuildWorkflow(input: {
|
||||
jobId: string;
|
||||
appName: string;
|
||||
appNameEn: string;
|
||||
appIdentifier: string;
|
||||
}): Promise<number> {
|
||||
const { owner, repo } = getGitHubConfig();
|
||||
const octokit = getOctokit();
|
||||
|
||||
await octokit.rest.actions.createWorkflowDispatch({
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: "build-app.yml",
|
||||
ref: process.env.DEFAULT_BRANCH ?? "main",
|
||||
inputs: {
|
||||
job_id: input.jobId,
|
||||
app_name: input.appName,
|
||||
app_name_en: input.appNameEn,
|
||||
app_identifier: input.appIdentifier,
|
||||
},
|
||||
});
|
||||
|
||||
await sleep(3000);
|
||||
|
||||
const since = new Date(Date.now() - 120_000).toISOString();
|
||||
const runs = await octokit.rest.actions.listWorkflowRuns({
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: "build-app.yml",
|
||||
event: "workflow_dispatch",
|
||||
per_page: 10,
|
||||
});
|
||||
|
||||
const run = runs.data.workflow_runs
|
||||
.filter((item) => item.created_at >= since)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||
)[0];
|
||||
|
||||
if (!run) {
|
||||
throw new Error("Failed to locate workflow run after dispatch");
|
||||
}
|
||||
|
||||
return run.id;
|
||||
}
|
||||
|
||||
export async function getWorkflowRun(runId: number) {
|
||||
const { owner, repo } = getGitHubConfig();
|
||||
const octokit = getOctokit();
|
||||
const { data } = await octokit.rest.actions.getWorkflowRun({
|
||||
owner,
|
||||
repo,
|
||||
run_id: runId,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getReleaseAssets(jobId: string): Promise<{
|
||||
windowsUrl: string | null;
|
||||
androidUrl: string | null;
|
||||
}> {
|
||||
const { owner, repo } = getGitHubConfig();
|
||||
const octokit = getOctokit();
|
||||
const tag = `build-${jobId}`;
|
||||
|
||||
try {
|
||||
const { data: release } = await octokit.rest.repos.getReleaseByTag({
|
||||
owner,
|
||||
repo,
|
||||
tag,
|
||||
});
|
||||
|
||||
let windowsUrl: string | null = null;
|
||||
let androidUrl: string | null = null;
|
||||
|
||||
for (const asset of release.assets) {
|
||||
const name = asset.name.toLowerCase();
|
||||
if (
|
||||
!windowsUrl &&
|
||||
(name.endsWith(".exe") ||
|
||||
name.endsWith(".msi") ||
|
||||
name.includes("windows"))
|
||||
) {
|
||||
windowsUrl = asset.browser_download_url;
|
||||
}
|
||||
if (
|
||||
!androidUrl &&
|
||||
(name.endsWith(".apk") || name.includes("android"))
|
||||
) {
|
||||
androidUrl = asset.browser_download_url;
|
||||
}
|
||||
}
|
||||
|
||||
return { windowsUrl, androidUrl };
|
||||
} catch {
|
||||
return { windowsUrl: null, androidUrl: null };
|
||||
}
|
||||
}
|
||||
|
||||
export function getActionsRunUrl(runId: number | null): string | null {
|
||||
const { owner, repo } = getGitHubConfig();
|
||||
if (!runId || !owner || !repo) return null;
|
||||
return `https://github.com/${owner}/${repo}/actions/runs/${runId}`;
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
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>应用英文名</dt>
|
||||
<dd>{build.appNameEn}</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>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createBuild } from "../api";
|
||||
|
||||
export default function Upload() {
|
||||
const navigate = useNavigate();
|
||||
const [appNameZh, setAppNameZh] = useState("");
|
||||
const [appNameEn, setAppNameEn] = 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("appNameZh", appNameZh);
|
||||
formData.append("appNameEn", appNameEn);
|
||||
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。可选在 zip 根目录放置 logo.png 或
|
||||
favicon.ico 作为应用图标(优先 logo.png)。中文名用于界面展示,英文名用于安装包与
|
||||
Bundle ID 生成。
|
||||
</p>
|
||||
<form className="form" onSubmit={onSubmit}>
|
||||
<label>
|
||||
应用中文名
|
||||
<input
|
||||
value={appNameZh}
|
||||
onChange={(e) => setAppNameZh(e.target.value)}
|
||||
placeholder="我的应用"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
应用英文名
|
||||
<input
|
||||
value={appNameEn}
|
||||
onChange={(e) => setAppNameEn(e.target.value)}
|
||||
placeholder="My App"
|
||||
required
|
||||
pattern="[a-zA-Z][a-zA-Z0-9 _.-]*"
|
||||
title="以字母开头,仅含英文字母、数字、空格、下划线和连字符"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
: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
1
web/src/vite-env.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1 +0,0 @@
|
||||
{"root":["./src/app.tsx","./src/api.ts","./src/main.tsx","./src/vite-env.d.ts","./src/pages/jobstatus.tsx","./src/pages/upload.tsx"],"version":"5.6.3"}
|
||||
@@ -1 +0,0 @@
|
||||
{"root":["./src/app.tsx","./src/api.ts","./src/main.tsx","./src/pages/jobstatus.tsx","./src/pages/upload.tsx"],"errors":true,"version":"5.6.3"}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:3001",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
1
worker/migrations/0002_add_version.sql
Normal file
1
worker/migrations/0002_add_version.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE builds ADD COLUMN app_version TEXT NOT NULL DEFAULT '1.0.0';
|
||||
13
worker/package.json
Normal file
13
worker/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "web2app-worker",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.2",
|
||||
"nanoid": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20241127.0"
|
||||
}
|
||||
}
|
||||
128
worker/src/db/builds.ts
Normal file
128
worker/src/db/builds.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { Env } from "../env";
|
||||
|
||||
export type BuildStatus =
|
||||
| "pending"
|
||||
| "queued"
|
||||
| "in_progress"
|
||||
| "completed"
|
||||
| "failed";
|
||||
|
||||
export interface BuildRecord {
|
||||
id: string;
|
||||
app_name: string;
|
||||
app_name_en: string;
|
||||
app_identifier: string;
|
||||
app_version: string;
|
||||
status: BuildStatus;
|
||||
workflow_run_id: number | null;
|
||||
windows_url: string | null;
|
||||
android_url: string | null;
|
||||
error: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export async function insertBuild(
|
||||
env: Env,
|
||||
record: {
|
||||
id: string;
|
||||
appName: string;
|
||||
appNameEn: string;
|
||||
appIdentifier: string;
|
||||
appVersion: string;
|
||||
},
|
||||
): Promise<BuildRecord> {
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO builds (id, app_name, app_name_en, app_identifier, app_version, status)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending')`,
|
||||
)
|
||||
.bind(
|
||||
record.id,
|
||||
record.appName,
|
||||
record.appNameEn,
|
||||
record.appIdentifier,
|
||||
record.appVersion,
|
||||
)
|
||||
.run();
|
||||
|
||||
const row = await getBuild(env, record.id);
|
||||
if (!row) throw new Error("Failed to insert build");
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function getBuild(
|
||||
env: Env,
|
||||
id: string,
|
||||
): Promise<BuildRecord | null> {
|
||||
const row = await env.DB.prepare("SELECT * FROM builds WHERE id = ?")
|
||||
.bind(id)
|
||||
.first<BuildRecord>();
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function listBuilds(
|
||||
env: Env,
|
||||
limit = 20,
|
||||
): Promise<BuildRecord[]> {
|
||||
const { results } = await env.DB.prepare(
|
||||
"SELECT * FROM builds ORDER BY created_at DESC LIMIT ?",
|
||||
)
|
||||
.bind(limit)
|
||||
.all<BuildRecord>();
|
||||
return results ?? [];
|
||||
}
|
||||
|
||||
export async function updateBuild(
|
||||
env: Env,
|
||||
id: string,
|
||||
patch: Partial<
|
||||
Pick<
|
||||
BuildRecord,
|
||||
| "status"
|
||||
| "workflow_run_id"
|
||||
| "windows_url"
|
||||
| "android_url"
|
||||
| "error"
|
||||
>
|
||||
>,
|
||||
): Promise<BuildRecord | null> {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value !== undefined) {
|
||||
fields.push(`${key} = ?`);
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.length === 0) return getBuild(env, id);
|
||||
|
||||
fields.push("updated_at = datetime('now')");
|
||||
values.push(id);
|
||||
|
||||
await env.DB.prepare(
|
||||
`UPDATE builds SET ${fields.join(", ")} WHERE id = ?`,
|
||||
)
|
||||
.bind(...values)
|
||||
.run();
|
||||
|
||||
return getBuild(env, id);
|
||||
}
|
||||
|
||||
export function toPublicBuild(record: BuildRecord) {
|
||||
return {
|
||||
id: record.id,
|
||||
appName: record.app_name,
|
||||
appNameEn: record.app_name_en || record.app_name,
|
||||
appIdentifier: record.app_identifier,
|
||||
appVersion: record.app_version || "1.0.0",
|
||||
status: record.status,
|
||||
workflowRunId: record.workflow_run_id,
|
||||
windowsUrl: record.windows_url,
|
||||
androidUrl: record.android_url,
|
||||
error: record.error,
|
||||
createdAt: record.created_at,
|
||||
updatedAt: record.updated_at,
|
||||
};
|
||||
}
|
||||
9
worker/src/env.d.ts
vendored
Normal file
9
worker/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface Env {
|
||||
DB: D1Database;
|
||||
ASSETS: Fetcher;
|
||||
GITHUB_TOKEN: string;
|
||||
GITHUB_OWNER: string;
|
||||
GITHUB_REPO: string;
|
||||
DEFAULT_BRANCH?: string;
|
||||
MAX_UPLOAD_MB?: string;
|
||||
}
|
||||
19
worker/src/index.ts
Normal file
19
worker/src/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Env } from "./env";
|
||||
import { handleBuildsRequest } from "./routes/builds";
|
||||
import { jsonResponse } from "./lib/response";
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === "/api/health") {
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/builds")) {
|
||||
return handleBuildsRequest(request, env, url);
|
||||
}
|
||||
|
||||
return env.ASSETS.fetch(request);
|
||||
},
|
||||
};
|
||||
17
worker/src/lib/response.ts
Normal file
17
worker/src/lib/response.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export function jsonResponse(
|
||||
data: unknown,
|
||||
status = 200,
|
||||
headers?: Record<string, string>,
|
||||
): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function errorResponse(message: string, status: number): Response {
|
||||
return jsonResponse({ error: message }, status);
|
||||
}
|
||||
226
worker/src/routes/builds.ts
Normal file
226
worker/src/routes/builds.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import type { Env } from "../env";
|
||||
import {
|
||||
getBuild,
|
||||
insertBuild,
|
||||
listBuilds,
|
||||
toPublicBuild,
|
||||
updateBuild,
|
||||
type BuildRecord,
|
||||
} from "../db/builds";
|
||||
import { errorResponse, jsonResponse } from "../lib/response";
|
||||
import {
|
||||
getActionsRunUrl,
|
||||
getReleaseAssets,
|
||||
getWorkflowRun,
|
||||
triggerBuildWorkflow,
|
||||
uploadBuildFile,
|
||||
uploadSiteZip,
|
||||
} from "../services/github";
|
||||
import { IconValidationError, resolveIconUpload } from "../services/icon";
|
||||
import {
|
||||
validateAppVersion,
|
||||
VersionValidationError,
|
||||
} from "../services/version";
|
||||
import {
|
||||
normalizeAppIdentifier,
|
||||
slugifyIdentifier,
|
||||
validateChineseAppName,
|
||||
validateEnglishAppName,
|
||||
validateZipBuffer,
|
||||
ZipValidationError,
|
||||
} from "../services/zip";
|
||||
|
||||
function maxUploadBytes(env: Env): number {
|
||||
const mb = Number(env.MAX_UPLOAD_MB ?? "50");
|
||||
return mb * 1024 * 1024;
|
||||
}
|
||||
|
||||
export async function handleBuildsRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
url: URL,
|
||||
): Promise<Response> {
|
||||
const path = url.pathname;
|
||||
|
||||
if (path === "/api/builds" && request.method === "GET") {
|
||||
const builds = (await listBuilds(env)).map(toPublicBuild);
|
||||
return jsonResponse({ builds });
|
||||
}
|
||||
|
||||
const match = path.match(/^\/api\/builds\/([^/]+)$/);
|
||||
if (match) {
|
||||
const id = match[1];
|
||||
|
||||
if (request.method === "GET") {
|
||||
const record = await getBuild(env, id);
|
||||
if (!record) {
|
||||
return errorResponse("Build not found", 404);
|
||||
}
|
||||
|
||||
const refreshed = await refreshBuildStatus(env, record);
|
||||
return jsonResponse({
|
||||
...toPublicBuild(refreshed),
|
||||
actionsUrl: getActionsRunUrl(env, refreshed.workflow_run_id),
|
||||
});
|
||||
}
|
||||
|
||||
return errorResponse("Method not allowed", 405);
|
||||
}
|
||||
|
||||
if (path === "/api/builds" && request.method === "POST") {
|
||||
return createBuild(request, env);
|
||||
}
|
||||
|
||||
return errorResponse("Not found", 404);
|
||||
}
|
||||
|
||||
async function createBuild(request: Request, env: Env): Promise<Response> {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const appNameZh = validateChineseAppName(
|
||||
String(formData.get("appNameZh") ?? formData.get("appName") ?? ""),
|
||||
);
|
||||
const appNameEn = validateEnglishAppName(
|
||||
String(formData.get("appNameEn") ?? ""),
|
||||
);
|
||||
const identifierInput = String(formData.get("identifier") ?? "").trim();
|
||||
const appVersion = validateAppVersion(
|
||||
String(formData.get("appVersion") ?? ""),
|
||||
);
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return errorResponse("file is required", 400);
|
||||
}
|
||||
|
||||
if (!file.name.toLowerCase().endsWith(".zip")) {
|
||||
return errorResponse("Only .zip files are supported", 400);
|
||||
}
|
||||
|
||||
const buffer = new Uint8Array(await file.arrayBuffer());
|
||||
const { normalizedBuffer } = validateZipBuffer(buffer, maxUploadBytes(env));
|
||||
const jobId = nanoid(10);
|
||||
const slug = slugifyIdentifier(appNameEn) || jobId.toLowerCase();
|
||||
const appIdentifier = normalizeAppIdentifier(
|
||||
identifierInput || `com.web2app.${slug}`,
|
||||
);
|
||||
|
||||
await insertBuild(env, {
|
||||
id: jobId,
|
||||
appName: appNameZh,
|
||||
appNameEn,
|
||||
appIdentifier,
|
||||
appVersion,
|
||||
});
|
||||
|
||||
await uploadSiteZip(env, jobId, normalizedBuffer);
|
||||
await uploadBuildFile(
|
||||
env,
|
||||
jobId,
|
||||
"version.txt",
|
||||
new TextEncoder().encode(appVersion),
|
||||
);
|
||||
|
||||
const iconFile = formData.get("icon");
|
||||
if (iconFile instanceof File && iconFile.size > 0) {
|
||||
const iconBuffer = new Uint8Array(await iconFile.arrayBuffer());
|
||||
const { repoPath } = resolveIconUpload(iconFile, iconBuffer);
|
||||
await uploadBuildFile(env, jobId, repoPath, iconBuffer);
|
||||
}
|
||||
|
||||
await updateBuild(env, jobId, { status: "queued" });
|
||||
|
||||
const workflowRunId = await triggerBuildWorkflow(env, {
|
||||
jobId,
|
||||
appName: appNameZh,
|
||||
appNameEn,
|
||||
appIdentifier,
|
||||
});
|
||||
|
||||
await updateBuild(env, jobId, {
|
||||
workflow_run_id: workflowRunId,
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
return jsonResponse(
|
||||
{
|
||||
id: jobId,
|
||||
status: "in_progress",
|
||||
workflowRunId,
|
||||
},
|
||||
201,
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ZipValidationError ||
|
||||
error instanceof VersionValidationError ||
|
||||
error instanceof IconValidationError
|
||||
) {
|
||||
return errorResponse(error.message, 400);
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
return errorResponse(
|
||||
error instanceof Error ? error.message : "Failed to create build",
|
||||
500,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshBuildStatus(
|
||||
env: Env,
|
||||
record: BuildRecord,
|
||||
): Promise<BuildRecord> {
|
||||
if (!record.workflow_run_id) return record;
|
||||
if (record.status === "completed" || record.status === "failed") {
|
||||
return record;
|
||||
}
|
||||
|
||||
try {
|
||||
const run = await getWorkflowRun(env, record.workflow_run_id);
|
||||
|
||||
if (run.status === "queued") {
|
||||
return (await updateBuild(env, record.id, { status: "queued" })) ?? record;
|
||||
}
|
||||
|
||||
if (run.status === "in_progress") {
|
||||
return (
|
||||
(await updateBuild(env, record.id, { status: "in_progress" })) ?? record
|
||||
);
|
||||
}
|
||||
|
||||
if (run.status === "completed") {
|
||||
if (run.conclusion === "success") {
|
||||
const assets = await getReleaseAssets(env, record.id);
|
||||
return (
|
||||
(await updateBuild(env, record.id, {
|
||||
status: "completed",
|
||||
windows_url: assets.windowsUrl,
|
||||
android_url: assets.androidUrl,
|
||||
error:
|
||||
assets.windowsUrl && assets.androidUrl
|
||||
? null
|
||||
: "Build finished but release assets were not found yet",
|
||||
})) ?? record
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
(await updateBuild(env, record.id, {
|
||||
status: "failed",
|
||||
error: `Workflow failed with conclusion: ${run.conclusion ?? "unknown"}`,
|
||||
})) ?? record
|
||||
);
|
||||
}
|
||||
|
||||
return record;
|
||||
} catch (error) {
|
||||
return (
|
||||
(await updateBuild(env, record.id, {
|
||||
status: "failed",
|
||||
error: error instanceof Error ? error.message : "Status refresh failed",
|
||||
})) ?? record
|
||||
);
|
||||
}
|
||||
}
|
||||
252
worker/src/services/github.ts
Normal file
252
worker/src/services/github.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import type { Env } from "../env";
|
||||
|
||||
function getGitHubConfig(env: Env) {
|
||||
return {
|
||||
owner: (env.GITHUB_OWNER ?? "").trim(),
|
||||
repo: (env.GITHUB_REPO ?? "").trim(),
|
||||
token: (env.GITHUB_TOKEN ?? "").trim(),
|
||||
branch: (env.DEFAULT_BRANCH ?? "main").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function assertGitHubConfig(env: Env) {
|
||||
const { owner, repo, token } = getGitHubConfig(env);
|
||||
if (!owner || !repo || !token) {
|
||||
throw new Error(
|
||||
"Missing GITHUB_OWNER, GITHUB_REPO, or GITHUB_TOKEN configuration",
|
||||
);
|
||||
}
|
||||
return { owner, repo, token };
|
||||
}
|
||||
|
||||
async function githubFetch(
|
||||
env: Env,
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const { token } = assertGitHubConfig(env);
|
||||
return fetch(`https://api.github.com${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"User-Agent": "web2app-worker",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
export async function uploadBuildFile(
|
||||
env: Env,
|
||||
jobId: string,
|
||||
relativePath: string,
|
||||
fileBuffer: Uint8Array,
|
||||
): Promise<void> {
|
||||
const { owner, repo } = assertGitHubConfig(env);
|
||||
const path = `builds/${jobId}/${relativePath}`;
|
||||
const content = bytesToBase64(fileBuffer);
|
||||
|
||||
const put = async (sha?: string) => {
|
||||
const body: Record<string, string> = {
|
||||
message: sha
|
||||
? `chore: update ${relativePath} for build ${jobId}`
|
||||
: `chore: upload ${relativePath} for build ${jobId}`,
|
||||
content,
|
||||
};
|
||||
if (sha) body.sha = sha;
|
||||
|
||||
return githubFetch(env, `/repos/${owner}/${repo}/contents/${path}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
};
|
||||
|
||||
let response = await put();
|
||||
if (response.status === 422) {
|
||||
const getResponse = await githubFetch(
|
||||
env,
|
||||
`/repos/${owner}/${repo}/contents/${path}`,
|
||||
);
|
||||
if (!getResponse.ok) {
|
||||
const text = await getResponse.text();
|
||||
throw new Error(`GitHub get content failed (${getResponse.status}): ${text}`);
|
||||
}
|
||||
|
||||
const existing = (await getResponse.json()) as {
|
||||
sha?: string;
|
||||
type?: string;
|
||||
};
|
||||
if (!existing.sha || existing.type !== "file") {
|
||||
throw new Error(`Unexpected content at ${path}`);
|
||||
}
|
||||
|
||||
response = await put(existing.sha);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`GitHub upload failed (${response.status}): ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadSiteZip(
|
||||
env: Env,
|
||||
jobId: string,
|
||||
zipBuffer: Uint8Array,
|
||||
): Promise<void> {
|
||||
return uploadBuildFile(env, jobId, "site.zip", zipBuffer);
|
||||
}
|
||||
|
||||
export async function triggerBuildWorkflow(
|
||||
env: Env,
|
||||
input: {
|
||||
jobId: string;
|
||||
appName: string;
|
||||
appNameEn: string;
|
||||
appIdentifier: string;
|
||||
},
|
||||
): Promise<number> {
|
||||
const { owner, repo, branch } = getGitHubConfig(env);
|
||||
assertGitHubConfig(env);
|
||||
|
||||
const dispatchResponse = await githubFetch(
|
||||
env,
|
||||
`/repos/${owner}/${repo}/actions/workflows/build-app.yml/dispatches`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ref: branch,
|
||||
inputs: {
|
||||
job_id: input.jobId,
|
||||
app_name: input.appName,
|
||||
app_name_en: input.appNameEn,
|
||||
app_identifier: input.appIdentifier,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!dispatchResponse.ok) {
|
||||
const text = await dispatchResponse.text();
|
||||
throw new Error(
|
||||
`Workflow dispatch failed (${dispatchResponse.status}): ${text}`,
|
||||
);
|
||||
}
|
||||
|
||||
await sleep(3000);
|
||||
|
||||
const since = new Date(Date.now() - 120_000).toISOString();
|
||||
const runsResponse = await githubFetch(
|
||||
env,
|
||||
`/repos/${owner}/${repo}/actions/workflows/build-app.yml/runs?event=workflow_dispatch&per_page=10`,
|
||||
);
|
||||
|
||||
if (!runsResponse.ok) {
|
||||
const text = await runsResponse.text();
|
||||
throw new Error(`Failed to list workflow runs: ${text}`);
|
||||
}
|
||||
|
||||
const runsData = (await runsResponse.json()) as {
|
||||
workflow_runs: Array<{ id: number; created_at: string }>;
|
||||
};
|
||||
|
||||
const run = runsData.workflow_runs
|
||||
.filter((item) => item.created_at >= since)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||
)[0];
|
||||
|
||||
if (!run) {
|
||||
throw new Error("Failed to locate workflow run after dispatch");
|
||||
}
|
||||
|
||||
return run.id;
|
||||
}
|
||||
|
||||
export async function getWorkflowRun(env: Env, runId: number) {
|
||||
const { owner, repo } = assertGitHubConfig(env);
|
||||
const response = await githubFetch(
|
||||
env,
|
||||
`/repos/${owner}/${repo}/actions/runs/${runId}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Failed to get workflow run: ${text}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as {
|
||||
status: string;
|
||||
conclusion: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export async function getReleaseAssets(
|
||||
env: Env,
|
||||
jobId: string,
|
||||
): Promise<{
|
||||
windowsUrl: string | null;
|
||||
androidUrl: string | null;
|
||||
}> {
|
||||
const { owner, repo } = assertGitHubConfig(env);
|
||||
const tag = `build-${jobId}`;
|
||||
|
||||
const response = await githubFetch(
|
||||
env,
|
||||
`/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return { windowsUrl: null, androidUrl: null };
|
||||
}
|
||||
|
||||
const release = (await response.json()) as {
|
||||
assets: Array<{ name: string; browser_download_url: string }>;
|
||||
};
|
||||
|
||||
let windowsUrl: string | null = null;
|
||||
let androidUrl: string | null = null;
|
||||
|
||||
for (const asset of release.assets) {
|
||||
const name = asset.name.toLowerCase();
|
||||
if (
|
||||
!windowsUrl &&
|
||||
(name.endsWith(".exe") ||
|
||||
name.endsWith(".msi") ||
|
||||
name.includes("windows"))
|
||||
) {
|
||||
windowsUrl = asset.browser_download_url;
|
||||
}
|
||||
if (
|
||||
!androidUrl &&
|
||||
(name.endsWith(".apk") || name.includes("android"))
|
||||
) {
|
||||
androidUrl = asset.browser_download_url;
|
||||
}
|
||||
}
|
||||
|
||||
return { windowsUrl, androidUrl };
|
||||
}
|
||||
|
||||
export function getActionsRunUrl(env: Env, runId: number | null): string | null {
|
||||
const { owner, repo } = getGitHubConfig(env);
|
||||
if (!runId || !owner || !repo) return null;
|
||||
return `https://github.com/${owner}/${repo}/actions/runs/${runId}`;
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
50
worker/src/services/icon.ts
Normal file
50
worker/src/services/icon.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export class IconValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "IconValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_ICON_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
const ALLOWED_TYPES = new Set([
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/x-icon",
|
||||
"image/vnd.microsoft.icon",
|
||||
]);
|
||||
|
||||
const ALLOWED_EXT = new Set([".png", ".jpg", ".jpeg", ".ico"]);
|
||||
|
||||
export function resolveIconUpload(
|
||||
file: File,
|
||||
buffer: Uint8Array,
|
||||
): { repoPath: string; fileName: string } {
|
||||
if (buffer.length > MAX_ICON_BYTES) {
|
||||
throw new IconValidationError("图标文件不能超过 2MB");
|
||||
}
|
||||
|
||||
const ext = pathExt(file.name).toLowerCase();
|
||||
if (!ALLOWED_EXT.has(ext)) {
|
||||
throw new IconValidationError("图标仅支持 PNG、JPG、ICO 格式");
|
||||
}
|
||||
|
||||
const type = file.type.toLowerCase();
|
||||
if (type && !ALLOWED_TYPES.has(type) && type !== "application/octet-stream") {
|
||||
throw new IconValidationError("不支持的图标 MIME 类型");
|
||||
}
|
||||
|
||||
if (ext === ".ico") {
|
||||
return { repoPath: "favicon.ico", fileName: "favicon.ico" };
|
||||
}
|
||||
if (ext === ".jpg" || ext === ".jpeg") {
|
||||
return { repoPath: "logo.jpg", fileName: "logo.jpg" };
|
||||
}
|
||||
|
||||
return { repoPath: "logo.png", fileName: "logo.png" };
|
||||
}
|
||||
|
||||
function pathExt(name: string): string {
|
||||
const i = name.lastIndexOf(".");
|
||||
return i >= 0 ? name.slice(i) : "";
|
||||
}
|
||||
25
worker/src/services/version.ts
Normal file
25
worker/src/services/version.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export class VersionValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "VersionValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前日期版本,格式如 2026.5.29(月、日不补零) */
|
||||
export function getDefaultAppVersion(date = new Date()): string {
|
||||
return `${date.getFullYear()}.${date.getMonth() + 1}.${date.getDate()}`;
|
||||
}
|
||||
|
||||
export function validateAppVersion(input: string): string {
|
||||
const value = input.trim() || getDefaultAppVersion();
|
||||
if (!/^\d{4}\.\d{1,2}\.\d{1,2}$/.test(value)) {
|
||||
throw new VersionValidationError(
|
||||
"版本号格式应为 YYYY.M.D,例如 2026.5.29",
|
||||
);
|
||||
}
|
||||
const [, month, day] = value.split(".").map(Number);
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31) {
|
||||
throw new VersionValidationError("版本号中的月或日无效");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
import AdmZip from "adm-zip";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { unzipSync, zipSync } from "fflate";
|
||||
|
||||
export class ZipValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -11,29 +8,31 @@ export class ZipValidationError extends Error {
|
||||
}
|
||||
|
||||
export function validateZipBuffer(
|
||||
buffer: Buffer,
|
||||
buffer: Uint8Array,
|
||||
maxBytes: number,
|
||||
): { normalizedBuffer: Buffer } {
|
||||
): { normalizedBuffer: Uint8Array } {
|
||||
if (buffer.length > maxBytes) {
|
||||
throw new ZipValidationError(
|
||||
`Zip file exceeds ${Math.floor(maxBytes / (1024 * 1024))}MB limit`,
|
||||
);
|
||||
}
|
||||
|
||||
let zip: AdmZip;
|
||||
let files: Record<string, Uint8Array>;
|
||||
try {
|
||||
zip = new AdmZip(buffer);
|
||||
files = unzipSync(buffer);
|
||||
} catch {
|
||||
throw new ZipValidationError("Invalid zip archive");
|
||||
}
|
||||
|
||||
const entries = zip.getEntries().filter((entry) => !entry.isDirectory);
|
||||
const entries = Object.keys(files).filter(
|
||||
(key) => !key.endsWith("/") && files[key].length > 0,
|
||||
);
|
||||
if (entries.length === 0) {
|
||||
throw new ZipValidationError("Zip archive is empty");
|
||||
}
|
||||
|
||||
const indexEntry = entries.find((entry) => {
|
||||
const normalized = entry.entryName.replace(/\\/g, "/");
|
||||
const normalized = entry.replace(/\\/g, "/");
|
||||
if (normalized === "index.html") return true;
|
||||
const parts = normalized.split("/");
|
||||
return parts.length === 2 && parts[1] === "index.html";
|
||||
@@ -45,52 +44,67 @@ export function validateZipBuffer(
|
||||
);
|
||||
}
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "web2app-"));
|
||||
try {
|
||||
zip.extractAllTo(tmpDir, true);
|
||||
const rootIndex = path.join(tmpDir, "index.html");
|
||||
if (!fs.existsSync(rootIndex)) {
|
||||
const subdirs = fs
|
||||
.readdirSync(tmpDir, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory());
|
||||
if (subdirs.length !== 1) {
|
||||
throw new ZipValidationError(
|
||||
"index.html must be at zip root or inside exactly one folder",
|
||||
);
|
||||
}
|
||||
const nestedRoot = path.join(tmpDir, subdirs[0].name);
|
||||
flattenDirectory(nestedRoot, tmpDir);
|
||||
}
|
||||
const normalizedZip = new AdmZip();
|
||||
addDirectoryToZip(normalizedZip, tmpDir, "");
|
||||
return { normalizedBuffer: normalizedZip.toBuffer() };
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
const normalized = normalizeZipFiles(files);
|
||||
return { normalizedBuffer: zipSync(normalized) };
|
||||
}
|
||||
|
||||
function flattenDirectory(sourceDir: string, targetDir: string) {
|
||||
for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
|
||||
const from = path.join(sourceDir, entry.name);
|
||||
const to = path.join(targetDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
fs.cpSync(from, to, { recursive: true });
|
||||
} else {
|
||||
fs.copyFileSync(from, to);
|
||||
}
|
||||
function normalizeZipFiles(
|
||||
files: Record<string, Uint8Array>,
|
||||
): Record<string, Uint8Array> {
|
||||
const map: Record<string, Uint8Array> = {};
|
||||
for (const [key, data] of Object.entries(files)) {
|
||||
const path = key.replace(/\\/g, "/").replace(/^\.\//, "");
|
||||
if (path.endsWith("/")) continue;
|
||||
map[path] = data;
|
||||
}
|
||||
}
|
||||
|
||||
function addDirectoryToZip(zip: AdmZip, dir: string, prefix: string) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
const entryName = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
addDirectoryToZip(zip, fullPath, entryName);
|
||||
} else {
|
||||
zip.addFile(entryName, fs.readFileSync(fullPath));
|
||||
}
|
||||
if (map["index.html"]) {
|
||||
return map;
|
||||
}
|
||||
|
||||
const keys = Object.keys(map);
|
||||
const indexKey = keys.find((k) => {
|
||||
const parts = k.split("/");
|
||||
return parts.length === 2 && parts[1] === "index.html";
|
||||
});
|
||||
|
||||
if (!indexKey) {
|
||||
throw new ZipValidationError(
|
||||
"index.html must be at zip root or inside exactly one folder",
|
||||
);
|
||||
}
|
||||
|
||||
const folder = indexKey.split("/")[0];
|
||||
const topDirs = new Set(
|
||||
keys.map((k) => k.split("/")[0]).filter(Boolean),
|
||||
);
|
||||
|
||||
if (topDirs.size !== 1 || !topDirs.has(folder)) {
|
||||
throw new ZipValidationError(
|
||||
"index.html must be at zip root or inside exactly one folder",
|
||||
);
|
||||
}
|
||||
|
||||
const flat: Record<string, Uint8Array> = {};
|
||||
const prefix = `${folder}/`;
|
||||
for (const [key, data] of Object.entries(map)) {
|
||||
if (!key.startsWith(prefix)) {
|
||||
throw new ZipValidationError(
|
||||
"index.html must be at zip root or inside exactly one folder",
|
||||
);
|
||||
}
|
||||
const rel = key.slice(prefix.length);
|
||||
if (!rel) continue;
|
||||
flat[rel] = data;
|
||||
}
|
||||
|
||||
if (!flat["index.html"]) {
|
||||
throw new ZipValidationError(
|
||||
"index.html must be at zip root or inside exactly one folder",
|
||||
);
|
||||
}
|
||||
|
||||
return flat;
|
||||
}
|
||||
|
||||
const JAVA_RESERVED_SEGMENTS = new Set([
|
||||
14
worker/tsconfig.json
Normal file
14
worker/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
20
wrangler.toml
Normal file
20
wrangler.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
name = "web2app"
|
||||
main = "worker/src/index.ts"
|
||||
compatibility_date = "2024-11-01"
|
||||
|
||||
[assets]
|
||||
directory = "./frontend/dist"
|
||||
not_found_handling = "single-page-application"
|
||||
|
||||
# 运行 `wrangler d1 create web2app` 后将输出的 database_id 替换下方占位符
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "web2app"
|
||||
database_id = "e45bfa13-525b-465c-8dbb-b1a716751b44"
|
||||
migrations_dir = "worker/migrations"
|
||||
|
||||
[vars]
|
||||
DEFAULT_BRANCH = "main"
|
||||
MAX_UPLOAD_MB = "50"
|
||||
GITHUB_OWNER = "shumengya"
|
||||
GITHUB_REPO = "Web2App"
|
||||
Reference in New Issue
Block a user