diff --git a/app/controllers/file_admin_controller.py b/app/controllers/file_admin_controller.py index 4caba02..10f4dc2 100644 --- a/app/controllers/file_admin_controller.py +++ b/app/controllers/file_admin_controller.py @@ -14,8 +14,15 @@ from pydantic import BaseModel, Field from sqlalchemy.orm import Session from ..database import get_db +from ..dao.pdf_job_dao import PdfJobDAO from ..dao.uploaded_file_dao import UploadedFileDAO -from ..schemas.file import FileListResponse, UploadedFileOut +from ..schemas.file import ( + FileListResponse, + FileWithPdfListResponse, + PdfJobBrief, + UploadedFileOut, + UploadedFileWithPdfOut, +) from ..security import require_docs_auth from ..services.upload_service import UploadService @@ -57,6 +64,53 @@ def list_files( return FileListResponse(total=total, items=items) +@router.get( + "/with-pdf", + response_model=FileWithPdfListResponse, + summary="列出已上传文件并附带 PDF 转换任务关联(需鉴权)", + description=( + "合并管理页使用:以 uploaded_files 为基础分页拉取,再内存匹配 pdf_jobs," + "为每个文件附带它作为 PDF 任务「源文件(epub)」或「产物(PDF)」时的状态、" + "进度与用户软删标记。匹配键为 pdf_job.source_file_id / output_file_id -> uploaded_file.id。" + "返回前会触发磁盘扫描(频率限制 3s),把 SFTP 上传但未入库的文件补录进来。" + ), +) +def list_files_with_pdf( + limit: int = Query(100, ge=1, le=10000, description="每页条数,1-10000"), + offset: int = Query(0, ge=0, description="偏移量"), + service: UploadService = Depends(_service), + db: Session = Depends(get_db), + _: str = Depends(require_docs_auth), +) -> FileWithPdfListResponse: + service.scan_sftp_files() # 频率限制内置:3s 内重复只返回 DB 缓存 + total, items = service.list_files(limit=limit, offset=offset) + + # 拉全部 pdf_jobs(数据量小,不分页),建反查 map + jobs = PdfJobDAO(db).list_all(limit=10000, offset=0) + by_source: dict[int, list] = {} + by_output: dict[int, list] = {} + for j in jobs: + by_source.setdefault(j.source_file_id, []).append(j) + if j.output_file_id is not None: + by_output.setdefault(j.output_file_id, []).append(j) + + out_items: list[UploadedFileWithPdfOut] = [] + for f in items: + briefs: list[PdfJobBrief] = [] + for j in by_source.get(f.id, []): + briefs.append(PdfJobBrief( + job_id=j.id, role="source", status=j.status, + progress=j.progress, user_deleted=j.user_deleted, deleted_at=j.deleted_at, + )) + for j in by_output.get(f.id, []): + briefs.append(PdfJobBrief( + job_id=j.id, role="output", status=j.status, + progress=j.progress, user_deleted=j.user_deleted, deleted_at=j.deleted_at, + )) + out_items.append(UploadedFileWithPdfOut(**f.model_dump(), pdf_jobs=briefs)) + return FileWithPdfListResponse(total=total, items=out_items) + + @router.post( "/batch-delete", response_model=BatchDeleteResult, diff --git a/app/main.py b/app/main.py index 4ebed17..c29d3b5 100644 --- a/app/main.py +++ b/app/main.py @@ -6,10 +6,11 @@ GET /redoc -> ReDoc (Basic Auth) GET /openapi.json -> OpenAPI 文档(Basic Auth) GET /health -> 存活探针(公开) - GET /upload -> 上传页面(公开 HTML) - GET /files -> 文件浏览页(Basic Auth,同 docs) - GET /wb/{id} -> 白板页面(公开,不存在则新建) - GET /wb-admin -> 白板管理页(Basic Auth,同 docs) + GET /api/index -> 导航页(公开,收集所有页面入口) + GET /api/upload -> 上传页面(公开 HTML) + GET /api/files-page -> 文件管理页(Basic Auth,同 docs;含 PDF 转换管理) + GET /api/wb/{id} -> 白板页面(公开,不存在则新建) + GET /api/wb-admin -> 白板管理页(Basic Auth,同 docs) GET /api/... -> 业务接口 WS /ws/wb/{id} -> 白板实时同步(公开) /static/... -> 前端静态资源(JS/CSS) @@ -183,6 +184,16 @@ def create_app() -> FastAPI: def health() -> dict: return {"status": "ok"} + @app.get( + "/api/index", + response_class=HTMLResponse, + tags=["pages"], + summary="导航页(公开)", + description="收集所有页面入口的卡片式导航页,各子页脚注可返回此处。", + ) + def index_page() -> HTMLResponse: + return _serve_static_html("index.html") + @app.get( "/api/upload", response_class=HTMLResponse, @@ -197,8 +208,12 @@ def create_app() -> FastAPI: "/api/files-page", response_class=HTMLResponse, tags=["pages"], - summary="文件浏览页(需鉴权)", - description="列出 / 下载 / 删除已上传文件;支持多选、批量下载删除与分页。Basic Auth 同 docs。", + summary="文件管理页(需鉴权)", + description=( + "列出 / 下载 / 删除已上传文件,并合并 PDF 转换管理:以 uploaded_files 为基础," + "用 pdf_jobs 匹配标注关联文件的转换状态、用户软删标记,可硬删任务。" + "支持多选、批量下载删除与分页。Basic Auth 同 docs。" + ), ) def files_page(_: str = Depends(require_docs_auth)) -> HTMLResponse: return _serve_static_html("file_browser.html") @@ -223,16 +238,6 @@ def create_app() -> FastAPI: def whiteboard_page(board_id: str) -> HTMLResponse: return _serve_static_html("whiteboard.html") - @app.get( - "/api/pdf-admin", - response_class=HTMLResponse, - tags=["pages"], - summary="PDF 转换管理页(需鉴权)", - description="查看全部转换任务(含用户已软删的,标注是否已删除),并可硬删。Basic Auth 同 docs。", - ) - def pdf_admin_page(_: str = Depends(require_docs_auth)) -> HTMLResponse: - return _serve_static_html("pdf_admin.html") - return app diff --git a/app/schemas/file.py b/app/schemas/file.py index 59ff262..6c61e20 100644 --- a/app/schemas/file.py +++ b/app/schemas/file.py @@ -36,3 +36,31 @@ class FileUploadResponse(BaseModel): class FileListResponse(BaseModel): total: int items: list[UploadedFileOut] + + +class PdfJobBrief(BaseModel): + """文件关联到的 PDF 转换任务摘要(供合并管理页展示)。 + + 一个 uploaded_file 可能同时被多个 job 引用(罕见),故为列表; + 通常每行 0 或 1 条。 + """ + + job_id: int + role: str = Field(..., description='"source"=该文件是 epub 源文件;"output"=该文件是产物 PDF') + status: str = Field(..., description="pending / converting / done / failed") + progress: int = Field(0, description="转换进度 0-100") + user_deleted: bool = Field(False, description="用户是否已软删该任务") + deleted_at: datetime | None = Field(None, description="用户软删时间") + + +class UploadedFileWithPdfOut(UploadedFileOut): + """带 PDF 任务关联信息的文件视图(合并管理页用)。""" + + pdf_jobs: list[PdfJobBrief] = Field(default_factory=list, description="关联到的 PDF 转换任务") + + +class FileWithPdfListResponse(BaseModel): + """合并管理页列表响应:以 uploaded_files 为基础,附带 pdf_jobs 关联。""" + + total: int + items: list[UploadedFileWithPdfOut] diff --git a/app/schemas/pdf.py b/app/schemas/pdf.py index b3c9004..f17c666 100644 --- a/app/schemas/pdf.py +++ b/app/schemas/pdf.py @@ -11,8 +11,10 @@ class PdfJobOut(BaseModel): """任务对外视图(用户与管理页共用,user_deleted 仅管理页关注)。""" id: int + source_file_id: int = Field(..., description="原始 epub 文件的 uploaded_file.id") source_filename: str = Field(..., description="原始上传文件名") source_size: int = Field(..., description="原始文件字节数") + output_file_id: int | None = Field(None, description="产物 PDF 的 uploaded_file.id,转换完成前为 null") status: str = Field(..., description="pending / converting / done / failed") progress: int = Field(0, description="转换进度 0-100") error_message: str = Field("", description="失败原因") diff --git a/app/views/system_status_html.py b/app/views/system_status_html.py index 127326f..d19f7c3 100644 --- a/app/views/system_status_html.py +++ b/app/views/system_status_html.py @@ -128,7 +128,7 @@ def render(status: SystemStatus) -> str: -

zikai file service · 数据来源 psutil

+

导航 · zikai file service · 数据来源 psutil

""" diff --git a/app/views/upload_html.py b/app/views/upload_html.py index d495341..e37d3e6 100644 --- a/app/views/upload_html.py +++ b/app/views/upload_html.py @@ -36,7 +36,7 @@ def render() -> str:
-

系统状态 · zikai file service

+

导航 · 系统状态 · zikai file service

diff --git a/static/file_browser.js b/static/file_browser.js index bf5a41e..d000565 100644 --- a/static/file_browser.js +++ b/static/file_browser.js @@ -1,4 +1,6 @@ -/* 文件浏览页:分页拉取 /api/admin/files、复选框多选 + 全选、批量下载/删除、复制 sha。 +/* 文件管理页:分页拉取 /api/admin/files-with-pdf、复选框多选 + 全选、批量下载/删除、复制 sha。 + 合并 PDF 转换管理:每个文件附带 pdf_jobs(role=source epub / role=output 产物 PDF), + 渲染转换状态徽标、用户软删标记,并支持硬删任务(DELETE /api/admin/pdf/jobs/{id})。 state.items 缓存当前页数据;切换页/页大小重新拉取;删除后若当前页空则回退一页。 */ (function () { "use strict"; @@ -53,7 +55,7 @@ countEl.textContent = ""; pagerEl.classList.add("hidden"); try { - const res = await api(`/api/admin/files?limit=${limit}&offset=${offset}`); + const res = await api(`/api/admin/files/with-pdf?limit=${limit}&offset=${offset}`); if (!res.ok) throw new Error("HTTP " + res.status); const body = await res.json(); state.items = body.items || []; @@ -88,13 +90,15 @@ el("th", { class: "col-size" }, "大小"), el("th", { class: "col-src" }, "来源"), el("th", { class: "col-time" }, "上传时间"), + el("th", { class: "col-pdf" }, "PDF 任务"), el("th", { class: "col-act" }, "操作") ) ); const tbody = el("tbody", null); for (const f of state.items) { const checked = state.selected.has(f.id); - const row = el("tr", { class: checked ? "sel" : "", dataset: { id: f.id } }, + const pdfDeleted = (f.pdf_jobs || []).some((j) => j.user_deleted); + const row = el("tr", { class: [checked ? "sel" : "", pdfDeleted ? "row-pdf-deleted" : ""].filter(Boolean).join(" "), dataset: { id: f.id } }, el("td", { class: "col-sel" }, el("input", { type: "checkbox", class: "row-sel", checked, dataset: { id: f.id } }) ), @@ -105,6 +109,7 @@ el("td", { class: "col-size mono" }, fmtBytes(f.size_bytes)), el("td", { class: "col-src" }, el("span", { class: "tag" }, f.source || "-")), el("td", { class: "col-time muted" }, fmtTime(f.uploaded_at)), + el("td", { class: "col-pdf" }, renderPdfCell(f)), el("td", { class: "col-act" }, el("a", { class: "btn primary", href: `/api/admin/files/${f.id}/download`, download: "" }, "下载"), el("button", { class: "btn danger", onclick: () => removeOne(f) }, "删除") @@ -190,6 +195,58 @@ return sha.length > 16 ? sha.slice(0, 12) + "…" + sha.slice(-4) : sha; } + // 渲染 PDF 任务列:展示角色(源 epub / 产物 PDF)徽标、转换状态、用户软删标记与硬删按钮。 + // 一个文件可能同时被多个 job 引用(罕见),全列出;无关联则显示 -。 + function renderPdfCell(f) { + const jobs = f.pdf_jobs || []; + if (!jobs.length) return el("span", { class: "muted" }, "-"); + const cell = el("div", { class: "pdf-cell" }); + const roles = el("div", { class: "pdf-roles" }); + for (const j of jobs) { + roles.appendChild(el("span", { class: "role-tag " + j.role, title: j.role === "source" ? "PDF 转换的 epub 源文件" : "PDF 转换的产物 PDF" }, + j.role === "source" ? "源" : "产物")); + roles.appendChild(statusTag(j)); + if (j.user_deleted) { + roles.appendChild(el("span", { class: "del-mark", title: "用户已软删:" + fmtTime(j.deleted_at) }, "已删")); + } + roles.appendChild(el("span", { class: "muted" }, "#"+j.job_id)); + } + cell.appendChild(roles); + // 硬删按钮:对每个关联 job 提供(source 与 output 共属同一 job,去重后只显示一个) + const seenJobIds = new Set(); + const actBar = el("div", { class: "pdf-act" }); + for (const j of jobs) { + if (seenJobIds.has(j.job_id)) continue; + seenJobIds.add(j.job_id); + actBar.appendChild(el("button", { + class: "btn danger", onclick: () => removePdfJob(j, f) + }, "硬删任务")); + } + cell.appendChild(actBar); + return cell; + } + + function statusTag(j) { + if (j.status === "done") return el("span", { class: "tag ok" }, "完成"); + if (j.status === "failed") return el("span", { class: "tag err", title: j.error_message || "" }, "失败"); + if (j.status === "converting") return el("span", { class: "tag warn" }, "转换中 " + j.progress + "%"); + return el("span", { class: "tag" }, "排队中"); + } + + // 硬删 PDF 任务:删 job + 其源/产物文件。删除后重载当前页。 + async function removePdfJob(j, f) { + if (!confirm(`确定硬删 PDF 任务 #${j.job_id}?\n将同时删除关联的源文件与产物 PDF,不可恢复。`)) return; + try { + const res = await api(`/api/admin/pdf/jobs/${j.job_id}`, { method: "DELETE" }); + if (res.status === 404) { toast("任务已不存在"); } + else if (!res.ok) throw new Error("HTTP " + res.status); + toast("已硬删任务"); + await load(state.page); + } catch (e) { + toast("硬删失败:" + (e.message || e), "err"); + } + } + async function removeOne(f) { if (!confirm(`确定删除「${f.original_filename}」?\n此操作不可恢复,将同时删除磁盘文件。`)) return; try { diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..56b139f --- /dev/null +++ b/static/index.html @@ -0,0 +1,74 @@ + + + + + +导航 - zikai + + + + +
+

zikai 工具箱

+

个人 Web 服务入口导航。带 🔒 标记的页面需 Basic Auth(凭据见 config.yaml 的 docs 段)。

+ + + +

zikai file service · 健康探针

+
+ + diff --git a/static/pdf_admin.css b/static/pdf_admin.css deleted file mode 100644 index bd6f00d..0000000 --- a/static/pdf_admin.css +++ /dev/null @@ -1,46 +0,0 @@ -/* PDF 转换管理页样式(含任务表格、进度条、软删行标注)。 - 用户侧 PDF 转换页由 zMainPage 的 zPDF_package 组件提供(构建期 import), - zTools2 不再托管用户 UI;本文件仅服务 /api/pdf-admin 管理页。 */ -.jobs-table th.col-size { width: 7em; } -.jobs-table th.col-status { width: 9em; } -.jobs-table th.col-del { width: 8em; } -.jobs-table th.col-time { width: 11em; } -.jobs-table th.col-act { width: 11em; text-align: right; } -.jobs-table td.col-act { text-align: right; white-space: nowrap; } -.jobs-table td.col-act .btn { padding: 0.3em 0.8em; font-size: 0.85em; margin-left: 0.3em; } -.jobs-table td.col-name { font-weight: 600; word-break: break-all; } - -/* 复选框列:窄宽居中,避免视觉噪声 */ -.jobs-table th.col-check, -.jobs-table td.col-check { width: 2.5em; text-align: center; vertical-align: middle; } -.jobs-table td.col-check input { cursor: pointer; } - -.bar { height: 8px; background: var(--surface-2); border-radius: 6px; overflow: hidden; border: 1px solid var(--border); } -.bar__fill { height: 100%; width: 0; background: var(--primary); border-radius: 6px; transition: width 0.2s; } -.bar--sm { display: inline-block; width: 90px; vertical-align: middle; height: 6px; } - -.prog { display: inline-flex; align-items: center; gap: 0.5em; font-size: 0.82em; } -.prog__label { color: var(--text-dim); white-space: nowrap; } - -/* 管理页专属:覆盖 jobs-table 列宽 + 软删行标注 */ -.admin-table th.col-size { width: 7em; } -.admin-table th.col-status { width: 9em; } -.admin-table th.col-del { width: 8em; } -.admin-table th.col-time { width: 11em; } -.admin-table th.col-act { width: 11em; text-align: right; } -.admin-table td.col-act { text-align: right; white-space: nowrap; } -.admin-table td.col-act .btn { padding: 0.3em 0.8em; font-size: 0.85em; margin-left: 0.3em; } - -/* 用户已软删的行:淡化背景提示 */ -.admin-table tbody tr.row-soft-deleted td { background: var(--danger-soft); } -.admin-table tbody tr.row-soft-deleted:hover td { background: var(--danger-soft); } -.row-removed { opacity: 0; transition: opacity 0.25s; } - -.skel, .empty { padding: 1.6em; text-align: center; color: var(--text-dim); font-size: 0.9em; } - -@media (max-width: 640px) { - .jobs-table th, .jobs-table td { padding: 0.5em 0.4em; } - .jobs-table th.col-time, .jobs-table td.col-time { font-size: 0.78em; } - .admin-table th, .admin-table td { padding: 0.5em 0.4em; } - .admin-table th.col-time, .admin-table td.col-time { font-size: 0.78em; } -} diff --git a/static/pdf_admin.html b/static/pdf_admin.html deleted file mode 100644 index dce2d5a..0000000 --- a/static/pdf_admin.html +++ /dev/null @@ -1,30 +0,0 @@ - - - - - -PDF 转换管理 - zikai - - - - -
-

PDF 转换管理

-

查看全部转换任务(含用户已软删的,标注是否已删除),可硬删(真正删除磁盘与记录)。

- -
- - - -
- -
-
加载中…
-
- -

zikai file service

-
- - - - diff --git a/static/pdf_admin.js b/static/pdf_admin.js deleted file mode 100644 index a45ab03..0000000 --- a/static/pdf_admin.js +++ /dev/null @@ -1,177 +0,0 @@ -/* PDF 转换管理页:拉取 /api/admin/pdf/jobs、渲染表格(含「是否已删除」列)、硬删。 - 支持全选/多选批量硬删;删除直接执行,无二次确认。 */ -(function () { - "use strict"; - const { el, toast, fmtBytes, fmtTime, api } = window.ZK; - const listEl = document.getElementById("list"); - const countEl = document.getElementById("count"); - const refreshBtn = document.getElementById("refresh"); - const bulkDeleteBtn = document.getElementById("bulkDelete"); - const selCountEl = document.getElementById("selCount"); - - // 选中集合:以 job id 为键,值为对应行元素,便于 O(1) 增删与遍历 - const selected = new Map(); - - refreshBtn.addEventListener("click", load); - bulkDeleteBtn.addEventListener("click", bulkRemove); - - async function load() { - listEl.innerHTML = '
加载中…
'; - countEl.textContent = ""; - selected.clear(); - updateBulkBtn(); - try { - const res = await api("/api/admin/pdf/jobs?limit=500&offset=0"); - if (!res.ok) throw new Error("HTTP " + res.status); - const body = await res.json(); - render(body.items || []); - countEl.textContent = `共 ${body.total} 个任务`; - } catch (e) { - listEl.innerHTML = '
加载失败:' + (e.message || e) + "
"; - } - } - - function render(items) { - if (!items.length) { - listEl.innerHTML = '
还没有任务。
'; - return; - } - const table = el("table", { class: "jobs-table admin-table" }); - const thead = el("thead", null, - el("tr", null, - el("th", { class: "col-check" }, - el("input", { type: "checkbox", id: "selectAll", onchange: onToggleAll }) - ), - el("th", { class: "col-name" }, "文件名"), - el("th", { class: "col-size" }, "大小"), - el("th", { class: "col-status" }, "状态"), - el("th", { class: "col-del" }, "是否已删除"), - el("th", { class: "col-time" }, "创建时间"), - el("th", { class: "col-act" }, "操作") - ) - ); - const tbody = el("tbody", null); - for (const j of items) tbody.appendChild(renderRow(j)); - table.appendChild(thead); - table.appendChild(tbody); - listEl.innerHTML = ""; - listEl.appendChild(table); - } - - function renderRow(j) { - const checkbox = el("input", { type: "checkbox", value: j.id, onchange: () => onToggleRow(j, checkbox, row) }); - const row = el("tr", { class: j.user_deleted ? "row-soft-deleted" : "" }, - el("td", { class: "col-check" }, checkbox), - el("td", { class: "col-name" }, j.source_filename), - el("td", { class: "col-size mono" }, fmtBytes(j.source_size)), - el("td", { class: "col-status" }, statusCell(j)), - el("td", { class: "col-del" }, deletedCell(j)), - el("td", { class: "col-time muted" }, fmtTime(j.created_at)), - el("td", { class: "col-act" }, - j.status === "done" - ? el("a", { class: "btn", href: `/api/admin/files/${j.output_file_id}/download`, download: "" }, "下载产物") - : el("span", { class: "muted" }, "-"), - el("button", { class: "btn danger", onclick: () => remove(j, row) }, "硬删") - ) - ); - // 行元素上缓存 job/checkbox 引用,供全选回调 O(1) 取用 - row.__job = j; - row.__checkbox = checkbox; - return row; - } - - function onToggleAll(e) { - const checked = e.target.checked; - selected.clear(); - const rows = listEl.querySelectorAll("tbody tr"); - rows.forEach((row) => { - if (row.__checkbox) row.__checkbox.checked = checked; - if (checked && row.__job) selected.set(row.__job.id, row); - }); - updateBulkBtn(); - } - - function onToggleRow(j, box, row) { - if (box.checked) selected.set(j.id, row); - else selected.delete(j.id); - // 同步表头全选框状态 - const allBoxes = listEl.querySelectorAll('tbody input[type="checkbox"]'); - const selectAll = document.getElementById("selectAll"); - if (selectAll) selectAll.checked = allBoxes.length > 0 && [...allBoxes].every((b) => b.checked); - updateBulkBtn(); - } - - function updateBulkBtn() { - const n = selected.size; - bulkDeleteBtn.disabled = n === 0; - selCountEl.textContent = n > 0 ? `(${n})` : ""; - } - - function statusCell(j) { - if (j.status === "done") return el("span", { class: "tag ok" }, "完成"); - if (j.status === "failed") - return el("span", { class: "tag err", title: j.error_message || "" }, "失败"); - return el("span", { class: "tag warn" }, j.status === "converting" ? `转换中 ${j.progress}%` : "排队中"); - } - - function deletedCell(j) { - if (j.user_deleted) { - return el("span", { class: "tag err", title: fmtTime(j.deleted_at) }, "已删除"); - } - return el("span", { class: "muted" }, "否"); - } - - // 单条硬删:直接执行,无二次确认 - async function remove(j, row) { - try { - const res = await api(`/api/admin/pdf/jobs/${j.id}`, { method: "DELETE" }); - if (res.status === 404) { toast("任务已不存在"); } - else if (!res.ok) throw new Error("HTTP " + res.status); - selected.delete(j.id); - row.classList.add("row-removed"); - setTimeout(() => row.remove(), 250); - toast("已硬删"); - decCount(); - updateBulkBtn(); - } catch (e) { - toast("删除失败:" + (e.message || e), "err"); - } - } - - // 批量硬删:并发删除所有选中项,无二次确认 - async function bulkRemove() { - if (!selected.size) return; - bulkDeleteBtn.disabled = true; - const entries = [...selected.entries()]; - let ok = 0, fail = 0; - await Promise.all(entries.map(async ([id, row]) => { - try { - const res = await api(`/api/admin/pdf/jobs/${id}`, { method: "DELETE" }); - if (res.ok || res.status === 404) { - selected.delete(id); - row.classList.add("row-removed"); - setTimeout(() => row.remove(), 250); - ok++; - } else { - fail++; - } - } catch { - fail++; - } - })); - selected.clear(); - if (ok) { decCount(ok); toast(`已硬删 ${ok} 项` + (fail ? `,${fail} 项失败` : "")); } - else if (fail) { toast(`批量删除失败(${fail} 项)`, "err"); } - // 同步全选框 - const selectAll = document.getElementById("selectAll"); - if (selectAll) selectAll.checked = false; - updateBulkBtn(); - } - - function decCount(n = 1) { - const m = (countEl.textContent || "").match(/(\d+)/); - if (m) countEl.textContent = `共 ${Math.max(0, Number(m[1]) - n)} 个任务`; - } - - load(); -})(); diff --git a/static/whiteboard.html b/static/whiteboard.html index e027873..b742c20 100644 --- a/static/whiteboard.html +++ b/static/whiteboard.html @@ -20,6 +20,7 @@
+ 导航 diff --git a/static/whiteboard_admin.html b/static/whiteboard_admin.html index bbedf82..4b39887 100644 --- a/static/whiteboard_admin.html +++ b/static/whiteboard_admin.html @@ -22,7 +22,7 @@
加载中…
-

上传文件 · 文件浏览 · zikai

+

导航 · 上传文件 · 文件管理 · zikai