项目文件夹

文件
yf 3db74f3c8c Add upload/download with PG metadata
- POST /api/cloud/uploads: presigned PUT URL + pending metadata row
- POST /api/cloud/uploads/:id/confirm: mark ready, HEAD object to backfill size
- GET /api/cloud/uploads: list metadata
- GET /api/cloud/uploads/🆔 metadata + presigned GET download URL
- DELETE /api/cloud/uploads/🆔 delete object + metadata
- Auto-migrate app.uploads schema
- S3 credentials exchange with expiry cache
- Frontend: upload (direct PUT), list, download, delete UI
- Drop 512MiB big.bin build (no longer needed)
2026-09-18 16:32:59 +08:00

188 行
5.5 KiB
JavaScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
// 前端:上传(presigned PUT 直传对象存储)→ confirm → 列表 → 下载(presigned GET)。
const API_BASE = "/api/cloud";
const $ = (id) => document.getElementById(id);
function esc(v) {
return String(v == null ? "" : v).replace(/[&<>"]/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c])
);
}
function fmtSize(n) {
n = Number(n) || 0;
if (n < 1024) return n + " B";
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KiB";
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + " MiB";
return (n / 1024 / 1024 / 1024).toFixed(2) + " GiB";
}
function fmtTime(iso) {
if (!iso) return "";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
}
async function api(path, opts = {}) {
const res = await fetch(API_BASE + path, opts);
let data;
try { data = await res.json(); } catch (e) { data = { ok: false, error: "invalid JSON" }; }
if (!res.ok || data.ok === false) {
throw new Error(data.error || "HTTP " + res.status);
}
return data;
}
// --- 状态 ---
async function loadHealth() {
const el = $("status");
el.textContent = "加载中…";
try {
const d = await api("/health");
const b = d.bindings || {};
const rows = [
["数据库迁移", d.migrated ? "✓" : "✗"],
["database", b.database ? "已配置" : "未配置"],
["object_store", b.object_store ? "已配置" : "未配置"],
];
el.innerHTML = rows
.map(
(r) =>
'<div class="row"><span class="k">' + esc(r[0]) + '</span><span class="v ' +
(r[1] === "✓" || r[1] === "已配置" ? "ok" : "no") + '">' + esc(r[1]) + "</span></div>"
)
.join("");
} catch (e) {
el.innerHTML = '<pre>' + esc(e.message) + "</pre>";
}
}
// --- 上传 ---
async function upload() {
const fileEl = $("file");
const out = $("upload-out");
const btn = $("upload");
const file = fileEl.files && fileEl.files[0];
if (!file) {
out.textContent = "请先选择一个文件";
return;
}
btn.disabled = true;
out.textContent = "申请上传地址…";
try {
const created = await api("/uploads", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
filename: file.name,
content_type: file.type || "application/octet-stream",
size: file.size,
}),
});
out.textContent = "直传对象存储…";
const putRes = await fetch(created.upload_url, {
method: "PUT",
headers: { "content-type": file.type || "application/octet-stream" },
body: file,
});
if (!putRes.ok) {
throw new Error("对象存储 PUT 失败: HTTP " + putRes.status);
}
out.textContent = "确认上传…";
const confirmed = await api("/uploads/" + created.id + "/confirm", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ size: file.size }),
});
out.textContent =
"上传完成 ✓\n" +
"id=" + confirmed.id + " size=" + fmtSize(confirmed.size) + " status=" + confirmed.status;
fileEl.value = "";
loadList();
} catch (e) {
out.textContent = "上传失败: " + e.message;
} finally {
btn.disabled = false;
}
}
// --- 列表 ---
function renderRow(it) {
const tag = '<span class="tag ' + esc(it.status) + '">' + esc(it.status) + "</span>";
const actions =
'<button type="button" class="btn small" data-act="download" data-id="' + it.id + '">下载</button>' +
'<button type="button" class="btn small" data-act="delete" data-id="' + it.id + '">删除</button>';
return (
"<tr>" +
"<td>" + it.id + "</td>" +
"<td>" + esc(it.filename) + "</td>" +
"<td>" + esc(it.content_type) + "</td>" +
"<td>" + fmtSize(it.size) + "</td>" +
"<td>" + tag + "</td>" +
"<td>" + esc(fmtTime(it.created_at)) + "</td>" +
'<td class="actions">' + actions + "</td>" +
"</tr>"
);
}
async function loadList() {
const tbody = $("list").querySelector("tbody");
const out = $("list-out");
out.textContent = "";
tbody.innerHTML = '<tr><td colspan="7">加载中…</td></tr>';
try {
const d = await api("/uploads");
if (!d.items.length) {
tbody.innerHTML = '<tr><td colspan="7">还没有文件</td></tr>';
return;
}
tbody.innerHTML = d.items.map(renderRow).join("");
} catch (e) {
tbody.innerHTML = "";
out.textContent = "列表失败: " + e.message;
}
}
async function onListClick(e) {
const btn = e.target.closest("button[data-act]");
if (!btn) return;
const id = btn.dataset.id;
const out = $("list-out");
out.textContent = "";
if (btn.dataset.act === "download") {
try {
const d = await api("/uploads/" + id);
if (!d.download_url) {
out.textContent = "暂无下载链接(状态: " + d.status + "";
return;
}
window.open(d.download_url, "_blank");
} catch (err) {
out.textContent = "下载失败: " + err.message;
}
} else if (btn.dataset.act === "delete") {
if (!confirm("删除 id=" + id + " 的文件与元信息?")) return;
try {
await api("/uploads/" + id, { method: "DELETE" });
loadList();
} catch (err) {
out.textContent = "删除失败: " + err.message;
}
}
}
// --- 绑定 ---
$("refresh").addEventListener("click", loadHealth);
$("upload").addEventListener("click", upload);
$("reload").addEventListener("click", loadList);
$("list").querySelector("tbody").addEventListener("click", onListClick);
loadHealth();
loadList();