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_TOKEN=ghp_your_personal_access_token
|
||||||
GITHUB_OWNER=shumengya
|
GITHUB_OWNER=shumengya
|
||||||
GITHUB_REPO=Web2App
|
GITHUB_REPO=Web2App
|
||||||
DEFAULT_BRANCH=main
|
DEFAULT_BRANCH=main
|
||||||
PORT=3001
|
|
||||||
MAX_UPLOAD_MB=50
|
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 distDir = path.join(root, "template", "dist");
|
||||||
const templateDir = path.join(root, "template");
|
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) {
|
function findIconSource(baseDir) {
|
||||||
for (const name of ICON_PRIORITY) {
|
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);
|
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 zipPath = path.join(root, "builds", jobId, "site.zip");
|
||||||
const distDir = path.join(root, "template", "dist");
|
const distDir = path.join(root, "template", "dist");
|
||||||
const confPath = path.join(root, "template", "src-tauri", "tauri.conf.json");
|
const confPath = path.join(root, "template", "src-tauri", "tauri.conf.json");
|
||||||
@@ -46,6 +57,15 @@ if (!fs.existsSync(indexPath)) {
|
|||||||
process.exit(1);
|
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) {
|
function toSafeBinaryName(name, fallback) {
|
||||||
const ascii = name.replace(/[^a-zA-Z0-9_-]+/g, "");
|
const ascii = name.replace(/[^a-zA-Z0-9_-]+/g, "");
|
||||||
return (ascii || fallback).slice(0, 64);
|
return (ascii || fallback).slice(0, 64);
|
||||||
@@ -57,7 +77,7 @@ function patchTauriConfig(filePath) {
|
|||||||
conf.productName = appNameZh;
|
conf.productName = appNameZh;
|
||||||
conf.mainBinaryName = safeBinaryName;
|
conf.mainBinaryName = safeBinaryName;
|
||||||
conf.identifier = appIdentifier;
|
conf.identifier = appIdentifier;
|
||||||
conf.version = "1.0.0";
|
conf.version = appVersion;
|
||||||
if (conf.app?.windows?.[0]) {
|
if (conf.app?.windows?.[0]) {
|
||||||
conf.app.windows[0].title = appNameZh;
|
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: ${{ inputs.app_name }}
|
||||||
APP_NAME_EN: ${{ inputs.app_name_en }}
|
APP_NAME_EN: ${{ inputs.app_name_en }}
|
||||||
APP_IDENTIFIER: ${{ inputs.app_identifier }}
|
APP_IDENTIFIER: ${{ inputs.app_identifier }}
|
||||||
|
APP_VERSION: ${{ inputs.app_version }}
|
||||||
NDK_VERSION: "27.2.12479018"
|
NDK_VERSION: "27.2.12479018"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -176,7 +177,7 @@ jobs:
|
|||||||
tag_name: build-${{ inputs.job_id }}
|
tag_name: build-${{ inputs.job_id }}
|
||||||
name: Web2App ${{ inputs.app_name }} (${{ inputs.job_id }})
|
name: Web2App ${{ inputs.app_name }} (${{ inputs.job_id }})
|
||||||
body: |
|
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
|
- Windows desktop installer/portable binary
|
||||||
- Android APK (signed for sideload install)
|
- Android APK (signed for sideload install)
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,12 +1,14 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
web/dist/
|
frontend/dist/
|
||||||
server/dist/
|
!frontend/dist/index.html
|
||||||
uploads/
|
uploads/
|
||||||
data/
|
data/
|
||||||
.env
|
.env
|
||||||
|
.dev.vars
|
||||||
*.db
|
*.db
|
||||||
*.db-journal
|
*.db-journal
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.wrangler/
|
||||||
target/
|
target/
|
||||||
template/dist/*
|
template/dist/*
|
||||||
!template/dist/index.html
|
!template/dist/index.html
|
||||||
|
|||||||
157
README.md
157
README.md
@@ -2,83 +2,111 @@
|
|||||||
|
|
||||||
将已构建好的静态网页(zip,入口 `index.html`)转换为 **Windows 桌面应用** 与 **Android APK**。本仓库包含:
|
将已构建好的静态网页(zip,入口 `index.html`)转换为 **Windows 桌面应用** 与 **Android APK**。本仓库包含:
|
||||||
|
|
||||||
- `web/`:上传与构建状态页面
|
- `frontend/`:React + Vite 上传与构建状态页面(文档风格 UI)
|
||||||
- `server/`:Express API,负责 zip 校验、GitHub 上传与 workflow 触发
|
- `worker/`:Cloudflare Worker API(zip 校验、GitHub 上传、workflow 触发)
|
||||||
- `template/`:最小 Tauri 2 壳,CI 中注入用户静态资源后打包
|
- `template/`:最小 Tauri 2 壳,CI 中注入用户静态资源后打包
|
||||||
- `.github/workflows/build-app.yml`:并行构建 Windows / Android 并发布 Release
|
- `.github/workflows/build-app.yml`:并行构建 Windows / Android 并发布 Release
|
||||||
|
|
||||||
## 架构
|
## 架构
|
||||||
|
|
||||||
1. 用户在本地网站上传 zip
|
1. 用户访问 Worker 托管的前端并上传 zip
|
||||||
2. 后端校验 zip 后,通过 GitHub Contents API 上传到 `builds/{jobId}/site.zip`
|
2. Worker 校验 zip 后,通过 GitHub Contents API 上传到 `builds/{jobId}/site.zip`
|
||||||
3. 后端触发 `build-app.yml` workflow
|
3. Worker 触发 `build-app.yml` workflow
|
||||||
4. GitHub Actions 解压 zip 到 `template/dist/`,构建 Windows 与 Android
|
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+
|
- Node.js 20+
|
||||||
|
- [Cloudflare 账号](https://dash.cloudflare.com/) 与 [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/)
|
||||||
- 一个 GitHub 仓库(建议名称 `Web2App`)
|
- 一个 GitHub 仓库(建议名称 `Web2App`)
|
||||||
- Personal Access Token,权限至少包含:
|
- Personal Access Token,权限至少包含:`repo`、`workflow`
|
||||||
- `repo`
|
|
||||||
- `workflow`
|
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始(本地)
|
||||||
|
|
||||||
### 1. 初始化 GitHub 仓库
|
### 1. 安装依赖
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git init
|
npm install
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 配置环境变量
|
### 2. 配置密钥
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .dev.vars.example .dev.vars
|
||||||
```
|
```
|
||||||
|
|
||||||
编辑 `.env`:
|
编辑 `.dev.vars`(Wrangler 本地开发自动加载):
|
||||||
|
|
||||||
```env
|
```env
|
||||||
GITHUB_TOKEN=ghp_xxx
|
GITHUB_TOKEN=ghp_xxx
|
||||||
GITHUB_OWNER=your-github-username
|
GITHUB_OWNER=your-github-username
|
||||||
GITHUB_REPO=Web2App
|
GITHUB_REPO=Web2App
|
||||||
DEFAULT_BRANCH=main
|
DEFAULT_BRANCH=main
|
||||||
PORT=3001
|
|
||||||
MAX_UPLOAD_MB=50
|
MAX_UPLOAD_MB=50
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 安装依赖并启动
|
### 3. 初始化 D1(本地)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm run db:migrate:local
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 构建前端并启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build -w frontend
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
- 前端:<http://localhost:5173>
|
- 前端开发服务器:<http://localhost:5173>(`/api` 代理到 Worker `8787`)
|
||||||
- 后端:<http://localhost:3001>
|
- 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
|
```bash
|
||||||
cd examples/sample-site
|
wrangler login
|
||||||
powershell Compress-Archive -Path * -DestinationPath ../sample-site.zip -Force
|
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
|
## 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/:id` | 查询构建状态与下载链接 |
|
||||||
| `GET` | `/api/builds` | 最近 20 条构建记录 |
|
| `GET` | `/api/builds` | 最近 20 条构建记录 |
|
||||||
|
|
||||||
@@ -88,7 +116,9 @@ powershell Compress-Archive -Path * -DestinationPath ../sample-site.zip -Force
|
|||||||
|
|
||||||
- `job_id`
|
- `job_id`
|
||||||
- `app_name`
|
- `app_name`
|
||||||
|
- `app_name_en`
|
||||||
- `app_identifier`
|
- `app_identifier`
|
||||||
|
- `app_version`
|
||||||
|
|
||||||
构建产物发布到 Release tag:`build-{job_id}`。
|
构建产物发布到 Release tag:`build-{job_id}`。
|
||||||
|
|
||||||
@@ -100,20 +130,10 @@ powershell Compress-Archive -Path * -DestinationPath ../sample-site.zip -Force
|
|||||||
2. `favicon.ico`
|
2. `favicon.ico`
|
||||||
3. 若都未找到,使用模板内置默认图标
|
3. 若都未找到,使用模板内置默认图标
|
||||||
|
|
||||||
找到后 CI 会执行 `tauri icon` 生成 Windows / Android 所需的多尺寸图标。
|
### Android 签名
|
||||||
|
|
||||||
### Android 签名与安装
|
- CI 先构建未签名 APK,再用 `apksigner` 签名
|
||||||
|
- 生产环境建议在仓库 Settings → Secrets 配置 `ANDROID_KEYSTORE_BASE64` 等
|
||||||
- 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` |
|
|
||||||
|
|
||||||
本地生成 keystore:
|
本地生成 keystore:
|
||||||
|
|
||||||
@@ -121,54 +141,35 @@ powershell Compress-Archive -Path * -DestinationPath ../sample-site.zip -Force
|
|||||||
bash scripts/generate-android-keystore.sh web2app-release.jks web2app
|
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/
|
Web2App/
|
||||||
├── web/ # Vite + React 前端
|
├── frontend/ # React + Vite 前端
|
||||||
├── server/ # Express 后端
|
├── worker/ # Cloudflare Worker API + D1 migrations
|
||||||
|
├── wrangler.toml # 统一部署配置
|
||||||
├── template/ # Tauri 2 模板
|
├── template/ # Tauri 2 模板
|
||||||
├── .github/workflows/ # CI 构建
|
├── .github/workflows/ # CI 构建(未随 Worker 迁移改动)
|
||||||
├── examples/ # 示例静态站点
|
└── examples/ # 示例静态站点
|
||||||
└── data/ # 本地 SQLite(运行时生成)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 常见问题
|
## 常见问题
|
||||||
|
|
||||||
|
**`wrangler dev` 找不到静态资源**
|
||||||
|
|
||||||
|
- 运行 `npm run build -w frontend`
|
||||||
|
|
||||||
**workflow_dispatch 返回 404**
|
**workflow_dispatch 返回 404**
|
||||||
|
|
||||||
- 确认 workflow 文件已在默认分支
|
- 确认 workflow 已在默认分支
|
||||||
- 确认 `DEFAULT_BRANCH` 与仓库默认分支一致
|
- 确认 `DEFAULT_BRANCH` 与仓库默认分支一致
|
||||||
|
|
||||||
|
**D1 表不存在**
|
||||||
|
|
||||||
|
- 本地:`npm run db:migrate:local`
|
||||||
|
- 远程:`npm run db:migrate:remote`
|
||||||
|
|
||||||
**Release 没有下载链接**
|
**Release 没有下载链接**
|
||||||
|
|
||||||
- 等待 `finalize` job 完成
|
- 等待 `finalize` job 完成
|
||||||
- 检查 Windows / Android job 是否都成功
|
- 检查 Windows / Android job 是否都成功
|
||||||
|
|
||||||
**zip 校验失败**
|
|
||||||
|
|
||||||
- 确保 `index.html` 在 zip 根目录,或仅套一层文件夹
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "web2app-web",
|
"name": "web2app-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"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 =
|
export type BuildStatus =
|
||||||
| "pending"
|
| "pending"
|
||||||
| "queued"
|
| "queued"
|
||||||
@@ -10,6 +12,7 @@ export interface Build {
|
|||||||
appName: string;
|
appName: string;
|
||||||
appNameEn: string;
|
appNameEn: string;
|
||||||
appIdentifier: string;
|
appIdentifier: string;
|
||||||
|
appVersion: string;
|
||||||
status: BuildStatus;
|
status: BuildStatus;
|
||||||
workflowRunId: number | null;
|
workflowRunId: number | null;
|
||||||
windowsUrl: string | null;
|
windowsUrl: string | null;
|
||||||
@@ -29,15 +32,21 @@ interface CreateBuildResponse {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function apiUrl(path: string): string {
|
||||||
|
return `${API_BASE}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createBuild(formData: FormData): Promise<{ id: string }> {
|
export async function createBuild(formData: FormData): Promise<{ id: string }> {
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetch("/api/builds", {
|
response = await fetch(apiUrl("/api/builds"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error("无法连接后端服务,请确认已运行 npm run dev 且 server 已启动");
|
throw new Error(
|
||||||
|
"无法连接后端,请确认已运行 npm run dev 且 wrangler 已启动",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await readJson(response)) as CreateBuildResponse;
|
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> {
|
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;
|
const data = (await readJson(response)) as Build;
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = data as Build & ApiErrorResponse;
|
const errorData = data as Build & ApiErrorResponse;
|
||||||
@@ -57,6 +66,15 @@ export async function fetchBuild(id: string): Promise<Build> {
|
|||||||
return data;
|
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> {
|
async function readJson(response: Response): Promise<unknown> {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (!text) return {};
|
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 { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import "./styles.css";
|
import "./styles/doc.css";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<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"],
|
"lib": ["ES2023"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"moduleResolution": "bundler"
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["vite.config.ts"]
|
||||||
}
|
}
|
||||||
@@ -7,9 +7,13 @@ export default defineConfig({
|
|||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
target: "http://127.0.0.1:3001",
|
target: "http://127.0.0.1:8787",
|
||||||
changeOrigin: true,
|
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",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"web",
|
"frontend",
|
||||||
"server",
|
"worker",
|
||||||
"template"
|
"template"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently -n server,web -c blue,green \"npm run dev:server\" \"npm run dev:web:wait\"",
|
"dev": "concurrently -n web,worker -c green,blue \"npm run dev -w frontend\" \"wrangler dev\"",
|
||||||
"dev:web:wait": "wait-on http://127.0.0.1:3001/api/health -t 120000 && npm run dev -w web",
|
"build": "npm run build -w frontend",
|
||||||
"dev:web": "npm run dev -w web",
|
"deploy": "npm run build && wrangler deploy",
|
||||||
"dev:server": "npm run dev -w server",
|
"db:migrate:local": "wrangler d1 migrations apply web2app --local",
|
||||||
"build": "npm run build -w web && npm run build -w server",
|
"db:migrate:remote": "wrangler d1 migrations apply web2app --remote"
|
||||||
"start": "npm run start -w server"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"adm-zip": "^0.5.17",
|
"adm-zip": "^0.5.17",
|
||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.1.2",
|
||||||
"typescript": "~5.6.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 { unzipSync, zipSync } from "fflate";
|
||||||
import fs from "node:fs";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
export class ZipValidationError extends Error {
|
export class ZipValidationError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
@@ -11,29 +8,31 @@ export class ZipValidationError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function validateZipBuffer(
|
export function validateZipBuffer(
|
||||||
buffer: Buffer,
|
buffer: Uint8Array,
|
||||||
maxBytes: number,
|
maxBytes: number,
|
||||||
): { normalizedBuffer: Buffer } {
|
): { normalizedBuffer: Uint8Array } {
|
||||||
if (buffer.length > maxBytes) {
|
if (buffer.length > maxBytes) {
|
||||||
throw new ZipValidationError(
|
throw new ZipValidationError(
|
||||||
`Zip file exceeds ${Math.floor(maxBytes / (1024 * 1024))}MB limit`,
|
`Zip file exceeds ${Math.floor(maxBytes / (1024 * 1024))}MB limit`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let zip: AdmZip;
|
let files: Record<string, Uint8Array>;
|
||||||
try {
|
try {
|
||||||
zip = new AdmZip(buffer);
|
files = unzipSync(buffer);
|
||||||
} catch {
|
} catch {
|
||||||
throw new ZipValidationError("Invalid zip archive");
|
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) {
|
if (entries.length === 0) {
|
||||||
throw new ZipValidationError("Zip archive is empty");
|
throw new ZipValidationError("Zip archive is empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
const indexEntry = entries.find((entry) => {
|
const indexEntry = entries.find((entry) => {
|
||||||
const normalized = entry.entryName.replace(/\\/g, "/");
|
const normalized = entry.replace(/\\/g, "/");
|
||||||
if (normalized === "index.html") return true;
|
if (normalized === "index.html") return true;
|
||||||
const parts = normalized.split("/");
|
const parts = normalized.split("/");
|
||||||
return parts.length === 2 && parts[1] === "index.html";
|
return parts.length === 2 && parts[1] === "index.html";
|
||||||
@@ -45,52 +44,67 @@ export function validateZipBuffer(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "web2app-"));
|
const normalized = normalizeZipFiles(files);
|
||||||
try {
|
return { normalizedBuffer: zipSync(normalized) };
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function flattenDirectory(sourceDir: string, targetDir: string) {
|
function normalizeZipFiles(
|
||||||
for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
|
files: Record<string, Uint8Array>,
|
||||||
const from = path.join(sourceDir, entry.name);
|
): Record<string, Uint8Array> {
|
||||||
const to = path.join(targetDir, entry.name);
|
const map: Record<string, Uint8Array> = {};
|
||||||
if (entry.isDirectory()) {
|
for (const [key, data] of Object.entries(files)) {
|
||||||
fs.cpSync(from, to, { recursive: true });
|
const path = key.replace(/\\/g, "/").replace(/^\.\//, "");
|
||||||
} else {
|
if (path.endsWith("/")) continue;
|
||||||
fs.copyFileSync(from, to);
|
map[path] = data;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function addDirectoryToZip(zip: AdmZip, dir: string, prefix: string) {
|
if (map["index.html"]) {
|
||||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
return map;
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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([
|
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