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:
@@ -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-suite:upload 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/upload;Gitea 位置與 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 尾段即 base64(unicode 安全,免手刻 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 = []; // 庫目錄快取(渲染每帳號的庫勾選用)
|
||||
|
||||
Reference in New Issue
Block a user