portal 上傳頁:POST /portal/data/upload(server-side 寫 Gitea)+前端拖放上傳

server:requirePortalUser 閘;filename 驗證(去路徑分隔、強制 .md、限 100 字);
server-side POST Gitea contents API 到 PORTAL_UPLOAD_REPO 的 docs/{filename}
——token 只在 server,前端零 repo/token 字串。409/422 → 誠實「同名文件已存在」。
新增三個 Bindings:PORTAL_UPLOAD_REPO / PORTAL_UPLOAD_GITEA / PORTAL_UPLOAD_TOKEN
(secret),任一缺 → 404「未啟用上傳」=Mira 零影響(功能不存在,現行為一字不變)。
/portal/session 補 upload_enabled 布林(server 判 bindings 齊全;顯示提示,真閘在路由層)。
前端:上傳 nav(sidenav+tabbar,一般用戶可見、未啟用時隱藏)+檔案選擇(.md/.txt
多選)+拖放區;讀檔轉 b64 逐檔 POST、逐檔狀態列;成功顯示「已收件,約 1 分鐘後
可在搜尋頁找到;AI 整理後會以 wiki 卡形式出現」。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BH7LdhuCdVUHfHXbM7N8r5
This commit is contained in:
Claude
2026-07-17 06:24:14 +00:00
parent c01c6c4d02
commit fde1827928
4 changed files with 182 additions and 4 deletions
+90 -3
View File
@@ -215,6 +215,7 @@ function renderPortalHtml(brand: string): string {
<div id="sidenav">
<div class="logo">${brand}</div>
<div class="nav" data-nav="search">搜尋</div>
<div class="nav hide" id="nav-upload" data-nav="upload">上傳</div>
<div class="nav hide" id="nav-workflows" data-nav="workflows">工作流</div>
<div class="nav hide" id="nav-admin" data-nav="admin">管理</div>
<div class="nav" data-nav="settings">設定</div>
@@ -266,6 +267,22 @@ function renderPortalHtml(brand: string): string {
<div id="cd-main" style="padding:24px 0 40px"><div class="muted">載入中…</div></div>
</div>
<!-- 上傳(portal-demo-suiteupload bindings 齊全才顯示 nav——/portal/session 的
upload_enabled;藏只是 UX,真閘在 /portal/data/upload 路由層 404-->
<div class="view page narrow" id="v-upload">
<div class="pagehead"><span class="t">上傳</span><span class="m">Markdown / 純文字</span></div>
<div style="margin-top:16px;padding:13px 16px;border-radius:11px;background:rgba(var(--amber-rgb),.06);border:1px dashed rgba(var(--amber-rgb),.35);font-size:14px;line-height:1.7;color:rgba(var(--ink-rgb),.65)">
上傳的文件會進入知識庫收件管線:<b style="color:var(--ink)">約 1 分鐘後可在搜尋頁找到</b>;AI 整理後會以 wiki 卡形式出現。支援 .md / .txt(一律以 .md 收件)。
</div>
<div id="up-drop" style="margin-top:16px;padding:44px 20px;border-radius:13px;border:2px dashed rgba(var(--amber-rgb),.4);background:rgba(var(--amber-rgb),.04);text-align:center;cursor:pointer">
<div style="font-size:16px;color:rgba(var(--ink-rgb),.7)">把 .md / .txt 檔案拖放到這裡</div>
<div class="muted" style="margin-top:8px;font-size:14px">或</div>
<button class="btn2" id="up-pick" style="margin-top:10px;padding:11px 22px;font-size:15px">選擇檔案(可多選)</button>
<input type="file" id="up-file" accept=".md,.txt" multiple style="display:none">
</div>
<div class="listcol" id="up-list"></div>
</div>
<!-- 工作流(D-8:預設 admin 才看得到;唯讀,不開 trigger-->
<div class="view page narrow" id="v-workflows">
<div class="pagehead"><span class="t">工作流</span><span class="m">唯讀・系統狀態</span></div>
@@ -349,6 +366,7 @@ function renderPortalHtml(brand: string): string {
<!-- 手機底部導覽 -->
<div id="tabbar">
<div class="tab" data-nav="search">搜尋</div>
<div class="tab hide" id="tab-upload" data-nav="upload">上傳</div>
<div class="tab hide" id="tab-workflows" data-nav="workflows">工作流</div>
<div class="tab hide" id="tab-admin" data-nav="admin">管理</div>
<div class="tab" data-nav="settings">設定</div>
@@ -479,14 +497,15 @@ function renderPortalHtml(brand: string): string {
}
// ── 路由 ──
var VIEWS = ['search', 'card', 'workflows', 'admin', 'settings'];
var VIEWS = ['search', 'card', 'upload', 'workflows', 'admin', 'settings'];
var HOME = 'search';
function allowedViews() {
// 工作流/管理頁按 session 能力顯示(真閘在 server/portal/data/workflows 403/404、
// /portal/admin/* role 閘 403——前端藏只是 UX,curl 直打也繞不過)
// 工作流/管理/上傳頁按 session 能力顯示(真閘在 server/portal/data/workflows 403/404、
// /portal/admin/* role 閘 403、/portal/data/upload 未啟用 404——前端藏只是 UX,curl 直打也繞不過)
return VIEWS.filter(function (v) {
if (v === 'workflows') return !!(S.profile && S.profile.workflows_visible);
if (v === 'admin') return !!(S.profile && S.profile.role === 'admin');
if (v === 'upload') return !!(S.profile && S.profile.upload_enabled);
return true;
});
}
@@ -535,6 +554,9 @@ function renderPortalHtml(brand: string): string {
$('side-foot').innerHTML = esc(p.display_name || '') + '<br>' + esc(location.host);
$('nav-workflows').classList.toggle('hide', !p.workflows_visible);
$('tab-workflows').classList.toggle('hide', !p.workflows_visible);
// 上傳 nav:一般用戶可見,但只在實例啟用上傳(bindings 齊全)時顯示(Mira 零影響)
$('nav-upload').classList.toggle('hide', !p.upload_enabled);
$('tab-upload').classList.toggle('hide', !p.upload_enabled);
$('nav-admin').classList.toggle('hide', p.role !== 'admin');
$('tab-admin').classList.toggle('hide', p.role !== 'admin');
$('mode-graph').classList.toggle('hide', !p.graph_allowed);
@@ -810,6 +832,71 @@ function renderPortalHtml(brand: string): string {
.catch(function (e) { $('wf-list').innerHTML = '<div class="err">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });
}
// ── 上傳頁(portal-demo-suite)──
// 前端只讀檔轉 base64 逐檔 POST /portal/data/uploadGitea 位置與 token 全在 server 側,
// 這裡看不到任何 repo/token 字串。逐檔獨立狀態列(一檔失敗不拖累其他檔)。
var UP_OK_MSG = '已收件,約 1 分鐘後可在搜尋頁找到;AI 整理後會以 wiki 卡形式出現。';
function b64FromDataUrl(dataUrl) {
var i = String(dataUrl).indexOf(',');
return i >= 0 ? String(dataUrl).slice(i + 1) : '';
}
function uploadStatusRow(name) {
var row = document.createElement('div');
row.className = 'card-item';
row.innerHTML = '<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap">' +
'<span class="mono" style="font-size:14.5px;word-break:break-all">' + esc(name) + '</span>' +
'<span class="up-st muted" style="margin-left:auto;font-size:14px;flex:none">讀取中…</span></div>' +
'<div class="up-msg" style="margin-top:6px;font-size:13.5px;line-height:1.65;color:rgba(var(--ink-rgb),.6)"></div>';
$('up-list').prepend(row);
return row;
}
function setRowStatus(row, ok, statusText, msg) {
var st = row.querySelector('.up-st');
st.textContent = statusText;
st.className = 'up-st ' + (ok === true ? 'ok' : (ok === false ? 'err' : 'muted'));
row.querySelector('.up-msg').textContent = msg || '';
}
function uploadOneFile(file) {
var row = uploadStatusRow(file.name);
if (!/\\.(md|txt)$/i.test(file.name)) {
setRowStatus(row, false, '已略過', '只收 .md / .txt 檔。');
return;
}
var reader = new FileReader();
reader.onerror = function () { setRowStatus(row, false, '讀檔失敗', '瀏覽器讀不到這個檔案。'); };
reader.onload = function () {
setRowStatus(row, null, '上傳中…', '');
fetch('/portal/data/upload', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
body: JSON.stringify({ filename: file.name, content_b64: b64FromDataUrl(reader.result) })
})
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (guard401(x.status)) return;
if (!x.ok) { setRowStatus(row, false, '失敗', x.d.error || ('HTTP ' + x.status)); return; }
setRowStatus(row, true, '已收件', UP_OK_MSG + (x.d.path ? '' + x.d.path + '' : ''));
})
.catch(function (e) { setRowStatus(row, false, '失敗', friendlyErr(e)); });
};
reader.readAsDataURL(file); // dataURL 尾段即 base64unicode 安全,免手刻 btoa 編碼)
}
function handleUploadFiles(files) {
for (var i = 0; i < files.length; i++) uploadOneFile(files[i]);
}
$('up-pick').addEventListener('click', function (ev) { ev.stopPropagation(); $('up-file').click(); });
$('up-drop').addEventListener('click', function () { $('up-file').click(); });
$('up-file').addEventListener('change', function () { handleUploadFiles(this.files); this.value = ''; });
['dragover', 'dragenter'].forEach(function (t) {
$('up-drop').addEventListener(t, function (ev) { ev.preventDefault(); ev.currentTarget.style.background = 'rgba(var(--amber-rgb),.12)'; });
});
['dragleave', 'drop'].forEach(function (t) {
$('up-drop').addEventListener(t, function (ev) { ev.preventDefault(); ev.currentTarget.style.background = 'rgba(var(--amber-rgb),.04)'; });
});
$('up-drop').addEventListener('drop', function (ev) {
if (ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files.length) handleUploadFiles(ev.dataTransfer.files);
});
// ── 管理頁(P4:帳號管理+庫目錄管理。全走 /portal/admin/*,前端零業務邏輯;
// 一般用戶 nav 不顯示,且 server role 閘 403——藏只是 UX,真閘在後端)──
var adminLibs = []; // 庫目錄快取(渲染每帳號的庫勾選用)