Compare commits

..

2 Commits

Author SHA1 Message Date
7c193ca46e 前端整理: 合并文件管理页 + 新增导航页
1. 合并 files-page 与 pdf-admin 为统一文件管理页 /api/files-page:
   - 新增 GET /api/admin/files/with-pdf 接口,以 uploaded_files 为基础,
     用 pdf_jobs.source_file_id / output_file_id 内存匹配,为关联文件标注
     转换状态、用户软删标记与角色(源epub/产物PDF)
   - PdfJobOut 补 source_file_id / output_file_id 字段
   - file_browser 新增 PDF 任务列(状态徽标/软删标记/硬删任务按钮)
   - 删除 /api/pdf-admin 路由及 static/pdf_admin.* 三个文件

2. 新增导航页 /api/index,卡片式收集所有页面入口;
   各子页(upload/whiteboard/system_status/files-page)脚注加返回导航链接

3. 更新 docs/routes.md、docs/configuration.md 同步说明

测试: pytest tests/test_pdf_service.py 7 passed; 手动校验各页面路由与合并接口响应
2026-07-28 14:04:12 +08:00
9af28f41b4 refactor: 清理死代码/提前失败/日志/高内聚低耦合
死代码移除:
- whiteboard_hub.py: 移除未引用的 reset_hub 单例重置函数
- tunnel_service.py: 移除未引用的 is_port_allowed (逻辑已在 sftp_server 内联)
- tunnel_session_dao.py: 移除未引用的 get_active_by_port
- pdf_job_dao.py: 移除未用 datetime 导入
- pdf_converter.py: 移除未用 shutil 导入
- pdf_service.py: 移除未用 PdfSubmitResponse 导入 + _do_convert 内未用 hashlib 导入
- upload_html.py: 移除未用 escape 导入 (JS 侧自有 escapeHtml)
- pdf_controller.py: 移除 _resolve_cookie 内未用 cfg 局部变量

提前失败/分层修复:
- database.py init_db_schema: 建表后用 inspector 校验既有表列与模型一致,
  缺列即抛 RuntimeError (fail-fast on schema drift), 避免运行期才暴露
- whiteboard_dao.get_or_create: 仅 IntegrityError 才回滚重读, 其他异常向上抛
  (原 except Exception 会掩盖 schema/连接等真实故障)
- pdf_service.admin_delete/_safe_delete_file: 改用 PdfJobDAO.delete /
  UploadedFileDAO.delete, 不再直接操作 job_dao.db / file_dao.db (修复分层契约:
  DAO 头注释声明 service 不直接操作 session)
- PdfJobDAO 新增 delete(job) 方法

日志补全 (8 处 silent catch):
- whiteboard_hub.py disconnect/close_board 关闭 ws: logger.debug
- whiteboard_controller _safe_send/_safe_close: logger.debug
- sftp_server _close_tunnel_dao/读用户名: logger.debug
- sftp_server validate_public_key: logger.warning (auth 路径, 避免静默失败)

文档:
- 新增 docs/routes.md, docs/configuration.md, docs/error-handling.md
- README.md 精简为简介/结构/外部依赖/apache2 配置/Ubuntu 安装/docs 链接
2026-07-28 11:34:35 +08:00
30 changed files with 495 additions and 472 deletions

193
README.md
View File

@@ -1,125 +1,47 @@
# zikai file service # zTools2 - 个人 Web 服务后端
基于 FastAPI 的个人 Web 服务,提供**文件上传/浏览/下载、共享记事本(实时协作)、 基于 FastAPI 的个人 Web 服务后端,提供文件上传/浏览/下载、共享记事本(实时协作)、主机监控、SFTP 暂存、反向隧道、PDF 转换。采用 Spring 风格分层架构controller -> service -> dao -> ORM model -> MySQL自带 API 文档。
主机监控、SFTP 暂存、反向隧道、PDF 转换**。采用 Spring 风格分层架构,自带 API 文档。
## 功能一览 所有入口统一挂在 `/api/` 前缀下,前端用同源相对路径调用,无需区分 dev/prod。前端子项目[timeTableFix](https://git.zikai.wang/zikai/timeTableFix)、[zPDF_package](https://git.zikai.wang/zikai/zPDF_package)、[zWhiteBoard](https://git.zikai.wang/zikai/zWhiteBoard))经 [zMainPage](https://git.zikai.wang/zikai/zMainPage) 构建期组件 import 集成,生产由 apache2 静态托管 + `/api` 反代到本服务。
| 模块 | 页面 / 接口 | 鉴权 |
|------|------------|------|
| 文件上传 | `POST /api/files/upload`(流式)/ `POST /api/files/chunk-uploads/*`(分片+断点续传) | 公开 |
| 上传页 | `GET /api/upload`(拖拽/多文件/分片/去重) | 公开 |
| 文件浏览 | `GET /api/files-page`(多选/批量下载删除/分页) | Basic Auth |
| 文件管理 API | `GET /api/admin/files``GET/DELETE /api/admin/files/{id}``GET /api/admin/files/{id}/download` | Basic Auth |
| 共享记事本 | `GET /api/wb-page/{id}`(公开,不存在则新建) | 公开 |
| 记事本实时同步 | `WS /api/ws/wb/{id}`(心跳 3s5 次失活移除) | 公开 |
| 记事本管理 | `GET /api/wb-admin`(查看/删除) | Basic Auth |
| 记事本管理 API | `GET /api/admin/wb``DELETE /api/admin/wb/{id}` | Basic Auth |
| PDF 转换 API | `POST /api/pdf/jobs``GET /api/pdf/jobs[/{id}]``GET /api/pdf/jobs/{id}/download``DELETE /api/pdf/jobs/{id}` | 公开cookie |
| PDF 转换管理 | `GET /api/pdf-admin`(全部任务,含已软删标记,硬删) | Basic Auth |
| PDF 转换管理 API | `GET /api/admin/pdf/jobs``DELETE /api/admin/pdf/jobs/{id}` | Basic Auth |
| 主机监控 | `GET /api/system/status`CPU/内存/磁盘HTML+JSON 内容协商) | 公开 |
| 反向隧道反代 | `ALL /api/userPort/{userName}`(经 SSH 隧道转发到 user 本地服务) | 公开 |
| SFTP/SSH | 端口 2022密码+公钥chroot 到上传目录,承载隧道转发) | SSH |
| API 文档 | `GET /docs`Swagger/ `GET /redoc` | Basic Auth |
## 路由约定
**所有 zTools2 托管的入口(页面 / 静态资源 / 健康探针 / WebSocket / REST API统一挂在 `/api/` 前缀下**,只有元信息/文档例外(`/``/docs``/redoc``/openapi.json`)。这样反向代理与 vite dev proxy 都只需一条 `/api/` 规则即可把请求转给当前环境的 zTools2前端用**同源相对路径**(如 `/api/pdf/jobs``/api/health`)即可,与运行环境(本地 / 测试 / 生产)无关,无需区分 dev/prod 指向。
> PDF 转换的用户侧 UI 由 zMainPage 的 zPDF_package 组件提供(构建期 import非 iframezTools2 仅提供 `/api/pdf/jobs` 等 REST API 与 `/api/pdf-admin` 管理页。
页面类入口为避免与同名 REST API 冲突,统一加 `-page` 后缀:
| 类型 | 页面入口 | REST API同名不加后缀 |
|------|---------|------------------------|
| 文件浏览 | `GET /api/files-page` | `GET /api/files``/api/files/{id}` 等 |
| 记事本 | `GET /api/wb-page/{id}` | `GET /api/wb/{id}` |
其余入口:`/api/health`(探针)、`/api/static/*`JS/CSS`/api/ws/wb/{id}`WebSocket`/api/upload``/api/pdf-admin``/api/wb-admin`
## 项目结构 ## 项目结构
``` ```
server/ zTools2/
├── app/ ├── app/
│ ├── main.py # FastAPI 应用工厂、路由注册、生命周期reaper │ ├── main.py # FastAPI 应用工厂、路由注册、生命周期reaper
│ ├── config.py # 从 config.yaml 加载的类型化 Settingspydantic-settings │ ├── config.py # 从 config.yaml 加载的类型化 Settingspydantic-settings
│ ├── database.py # SQLAlchemy 引擎/Session/Base/get_db 依赖 │ ├── database.py # SQLAlchemy 引擎/Session/Base/get_db 依赖 + schema 校验
│ ├── security.py # Basic Authrequire_docs_auth常量时间比较 │ ├── security.py # Basic Authrequire_docs_auth常量时间比较
│ ├── controllers/ # 路由层@RestControllerfile/system/chunk/tunnel/whiteboard/pdf/admin │ ├── controllers/ # 路由层file/system/chunk/tunnel/whiteboard/pdf/admin
│ ├── services/ # 业务层UploadService/ChunkUploadService/SystemService/ │ ├── services/ # 业务层Upload/ChunkUpload/System/Whiteboard/Tunnel/Pdf/sftp
│ │ # WhiteboardService/WhiteboardHub/TunnelService/sftp_server
│ │ # PdfService转换编排+ pdf_converterepub->pdf 纯 Python
│ ├── dao/ # 数据访问层:唯一发 SQL 的层SQLAlchemy ORM 参数化) │ ├── dao/ # 数据访问层:唯一发 SQL 的层SQLAlchemy ORM 参数化)
│ ├── models/ # ORM 实体UploadedFile/UploadSession/Whiteboard/TunnelSession/PdfJob │ ├── models/ # ORM 实体UploadedFile/UploadSession/Whiteboard/TunnelSession/PdfJob
│ ├── schemas/ # pydantic 请求/响应 DTO │ ├── schemas/ # pydantic 请求/响应 DTO
│ ├── views/ # 服务端渲染 HTML系统状态页、上传页 │ ├── views/ # 服务端渲染 HTML系统状态页、上传页
│ ├── static/ # 前端静态资源common + file_browser + whiteboard + pdf + pdf_admin │ ├── static/ # 前端静态资源
│ └── scripts/init_db.py # 数据库初始化(建库建账、随机密码写回 config.yaml │ └── scripts/init_db.py # 数据库初始化(建库建账、随机密码写回 config.yaml
├── sql/schema.sql # 建表 DDL参考实际由 ORM 自动建表) ├── sql/schema.sql # 建表 DDL参考实际由 ORM 自动建表)
├── config.example.yaml # 配置模板(含注释) ├── config.example.yaml # 配置模板(含注释)
├── config.yaml # 实际配置git-ignored含密码 ├── config.yaml # 实际配置git-ignored含密码
├── requirements.txt ├── requirements.txt
├── setup.sh # 一次性初始化venv + 依赖 + 建库 + SFTP 密钥 ├── setup.sh # 一次性初始化venv + 依赖 + 建库 + SFTP 密钥
├── start.sh / stop.sh # 启停 HTTP6867+ SFTP2022 ├── start.sh / stop.sh # 启停 HTTP127.0.0.1:6867+ SFTP2022
└── logs/ # app.log / sftp.log └── deploy/ # systemd 持久化部署
``` ```
**请求流程**`controller service dao ORM model MySQL` **请求流程**`controller -> service -> dao -> ORM model -> MySQL`DB Session 由 `get_db` 依赖注入。
DB Session 由 `get_db` 依赖注入。前端页面走「StaticFiles 挂载 + 具名 HTML 路由」前后端分离JS 调同源 `/api/...`
## 从零安装Ubuntu 22.04+ ## 外部依赖
### 1. 安装系统依赖 | 项 | 说明 |
|----|------|
| Python | ≥ 3.11(用 `.venv` |
| MySQL | 8.x独立库 `zikai_filesvc`,由 `setup.sh` 建账) |
| Apache2 | 反向代理对外提供 HTTPS服务本身只绑 `127.0.0.1:6867` |
| 系统库 | `libpango/cairo`weasyprint PDF 转换)、`build-essential`bcrypt/asyncssh 编译) |
```bash ### Apache2 反向代理配置
apt update
apt install -y python3-venv python3-pip mysql-server apache2 \
libssl-dev build-essential # build-essential 给 bcrypt/asyncssh 编译
# PDF 转换依赖 weasyprint需 pango/cairo 系统库Ubuntu 通常已随桌面环境安装,缺则补装):
apt install -y libpango-1.0-0 libpangoft2-1.0-0 libcairo2 libgdk-pixbuf-2.0-0
```
### 2. 获取代码
```bash
git clone <repo> /root/zikai
cd /root/zikai/server
```
### 3. 初始化venv + 依赖 + 建库 + SFTP 密钥)
```bash
./setup.sh
```
`setup.sh` 会:
- 创建 `.venv` 并安装 `requirements.txt`
- 复制 `config.example.yaml → config.yaml`(若不存在)
- 通过本机 root socket 建独立 MySQL 库 `zikai_filesvc` + 应用账户,随机密码写回 `config.yaml`
- 生成 SFTP 主机密钥(`keys/ssh_host_*`
### 4. 配置凭据
编辑 `config.yaml`(见下方[配置说明](#配置说明)
- `docs.username` / `docs.password`:管理页与 API 文档的 Basic Auth 凭据
- `sftp.users[].password_hash`SFTP 用户bcrypt生成方式见下
- `tunnel.users[]`:反向隧道用户(可选)
```bash
# 生成 bcrypt hash
.venv/bin/python -c "import bcrypt;print(bcrypt.hashpw(b'yourpass',bcrypt.gensalt()).decode())"
```
### 5. 启动
```bash
./start.sh # 启动 HTTP(127.0.0.1:6867) + SFTP(0.0.0.0:2022)
./stop.sh # 停止
```
### 6. 配置 Apache 反向代理
服务只绑 `127.0.0.1:6867`,通过 Apache 对外提供 HTTPS。安装模块并配置 vhost 服务只绑 `127.0.0.1:6867`,通过 Apache 对外提供 HTTPS。安装模块并配置 vhost
@@ -154,62 +76,49 @@ systemctl reload apache2
> **防火墙**:放开 443HTTPS与 2022SFTP。6867 不对外(仅 loopback > **防火墙**:放开 443HTTPS与 2022SFTP。6867 不对外(仅 loopback
> **大文件上传**Apache 全局 `Timeout 300`,慢链路建议走分片上传(`/api/upload`)或 SFTP。 > **大文件上传**Apache 全局 `Timeout 300`,慢链路建议走分片上传(`/api/upload`)或 SFTP。
## 配置说明 ## 从零安装Ubuntu 22.04+
所有运行时配置在 `config.yaml`git-ignored。完整 schema 见 `config.example.yaml` ### 1. 安装系统依赖
| 段 | 关键项 | 说明 | ```bash
|----|--------|------| apt update
| `server` | `host`/`port` | 绑定地址,保持 `127.0.0.1:6867`Apache 反代) | apt install -y python3-venv python3-pip mysql-server apache2 \
| `database` | `host`/`port`/`user`/`password`/`database` | MySQL 连接;密码由 `setup.sh` 自动生成写回 | libssl-dev build-essential # build-essential 给 bcrypt/asyncssh 编译
| `storage` | `upload_dir` | 文件存储根目录(默认 `uploads` | # PDF 转换依赖 weasyprint需 pango/cairo 系统库:
| | `chunk_bytes` | 流式上传分片大小(默认 1 MiB | apt install -y libpango-1.0-0 libpangoft2-1.0-0 libcairo2 libgdk-pixbuf-2.0-0
| | `chunk_session_dir` | 分片会话暂存目录(默认 `uploads/.work` | ```
| | `chunk_session_ttl_seconds` | 被放弃会话存活秒数(默认 300 |
| `docs` | `username`/`password` | `/docs``/api/files-page``/api/wb-admin``/api/pdf-admin``/api/admin/*` 的 Basic Auth明文常量时间比较 |
| `sftp` | `enabled`/`host`/`port` | SFTP 服务,默认 `0.0.0.0:2022` |
| | `users[].username`/`password_hash` | SFTP 用户bcrypt |
| | `host_key_path`/`authorized_keys_path` | 主机密钥与公钥白名单路径 |
| `tunnel` | `enabled`/`users[]` | 反向隧道:`username`/`password_hash`/`tunnel_port`/`local_port` |
| `whiteboard` | `heartbeat_interval_seconds` | 心跳间隔(默认 3s |
| | `heartbeat_miss_threshold` | 失活阈值(默认 5 次 = 15s |
| | `max_board_id_length` | board_id 长度上限(默认 64 |
| | `max_connections_per_board` | 单白板并发连接上限(默认 50 |
| | `list_limit` | 管理页单次列表上限(默认 100 |
## 访问入口 ### 2. 获取代码并初始化
| 入口 | URL | ```bash
|------|-----| git clone <repo> /root/zikai
| API 文档 | https://f.zikai.wang/docsBasic Auth | cd /root/zikai/zTools2
| 上传页 | https://f.zikai.wang/api/upload | ./setup.sh # 创建 .venv + 装依赖 + 复制 config.yaml + 建 MySQL 库账 + 生成 SFTP 密钥
| 文件浏览 | https://f.zikai.wang/api/files-pageBasic Auth | ```
| 共享记事本 | https://f.zikai.wang/api/wb-page/{id}(公开,`{id}``[a-zA-Z0-9_-]{1,64}` |
| 记事本管理 | https://f.zikai.wang/api/wb-adminBasic Auth |
| PDF 转换 | 经 zMainPage 的 PDF 页签zPDF_package 组件)调用 `/api/pdf/jobs` 等 REST API公开凭 cookie |
| 系统状态 | https://f.zikai.wang/api/system/statusHTML`?format=json` 切 JSON |
| curl 上传 | `curl -F file=@big.iso https://f.zikai.wang/api/files/upload` |
| SFTP | `sftp -P 2022 uploader@f.zikai.wang` |
## 运维 ### 3. 配置凭据
- **日志**`logs/app.log`HTTP`logs/sftp.log`SFTPpidfile`app.pid``sftp.pid` 编辑 `config.yaml`(详见 [`docs/configuration.md`](./docs/configuration.md)`docs.username/password`(管理页 Basic Auth`sftp.users[].password_hash`bcrypt、可选 `tunnel.users[]`
- **临时文件清理**:分片上传完成后立即删 `.work/<id>/`;被放弃会话(`pending` 超 5 分钟)由后台 reaper 每 60s 清理;`start.sh` 启动时兜底清残留。
- **重新生成 DB 密码**`.venv/bin/python -m app.scripts.init_db`(保留现有:`KEEP_DB_PASSWORD=1`)。
- **多 worker 限制**:记事本 hub 是进程内存,多 uvicorn worker 下不互通,保持 `workers: 1`
- **数据库表**ORM 启动时自动建表(`init_db_schema``sql/schema.sql` 供参考/手动初始化。
## 持久化部署systemd ### 4. 启动
`./start.sh` 适合手动运维;生产推荐用 systemd 管理实现开机自启 + 崩溃自动重启: ```bash
./start.sh # 启动 HTTP(127.0.0.1:6867) + SFTP(0.0.0.0:2022)
./stop.sh # 停止
```
### 5. 持久化部署systemd
```bash ```bash
./deploy/install-systemd.sh # 安装并启动 ztools2 服务(开机自启) ./deploy/install-systemd.sh # 安装并启动 ztools2 服务(开机自启)
systemctl status ztools2 # 状态 systemctl status ztools2
journalctl -u ztools2 -f # 日志 journalctl -u ztools2 -f
sudo systemctl restart ztools2 # 重启(更新代码后)
``` ```
脚本自动检测 `.venv/bin/uvicorn` 或系统 `uvicorn`,用实际路径填充 `deploy/ztools2.service` 模板。卸载:`systemctl disable --now ztools2 && rm /etc/systemd/system/ztools2.service && systemctl daemon-reload` > 与 zMainPage 整体部署配合systemd 管后端,`zMainPage/deploy.sh --no-restart` 部署前端
> 与 zMainPage 整体部署配合systemd 管后端,`zMainPage/deploy.sh --no-restart` 部署前端。详见 zMainPage 的 [`docs/deployment.md`](https://git.zikai.wang/zikai/zMainPage/src/branch/main/docs/deployment.md)。 ## 了解更多
- [路由与访问入口](./docs/routes.md)
- [配置说明](./docs/configuration.md)
- [错误处理与日志约定](./docs/error-handling.md)

View File

@@ -14,8 +14,15 @@ from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
from ..dao.pdf_job_dao import PdfJobDAO
from ..dao.uploaded_file_dao import UploadedFileDAO 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 ..security import require_docs_auth
from ..services.upload_service import UploadService from ..services.upload_service import UploadService
@@ -57,6 +64,53 @@ def list_files(
return FileListResponse(total=total, items=items) 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( @router.post(
"/batch-delete", "/batch-delete",
response_model=BatchDeleteResult, response_model=BatchDeleteResult,

View File

@@ -39,7 +39,6 @@ def _resolve_cookie(request: Request, zk_pdf: str | None = Cookie(default=None))
cookie 缺失时把新值挂到 request.state供响应阶段 set_cookie。 cookie 缺失时把新值挂到 request.state供响应阶段 set_cookie。
""" """
cfg = get_settings().pdf
if zk_pdf and len(zk_pdf) == 32: if zk_pdf and len(zk_pdf) == 32:
return zk_pdf return zk_pdf
new = new_owner_cookie() new = new_owner_cookie()

View File

@@ -228,12 +228,12 @@ def _parse(raw: str) -> dict | None:
async def _safe_send(ws: WebSocket, msg: dict) -> None: async def _safe_send(ws: WebSocket, msg: dict) -> None:
try: try:
await ws.send_json(msg) await ws.send_json(msg)
except Exception: # pragma: no cover except Exception as exc: # pragma: no cover
pass logger.debug("发送 WS 消息失败: %s", exc)
async def _safe_close(ws: WebSocket) -> None: async def _safe_close(ws: WebSocket) -> None:
try: try:
await ws.close() await ws.close()
except Exception: # pragma: no cover except Exception as exc: # pragma: no cover
pass logger.debug("关闭 WS 失败: %s", exc)

View File

@@ -6,8 +6,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -33,6 +31,11 @@ class PdfJobDAO:
self.db.refresh(job) self.db.refresh(job)
return job return job
def delete(self, job: PdfJob) -> None:
"""硬删 PdfJob 行(管理视角真正删除,不可恢复)。"""
self.db.delete(job)
self.db.commit()
def list_for_user(self, owner_cookie: str, limit: int = 100, offset: int = 0) -> list[PdfJob]: def list_for_user(self, owner_cookie: str, limit: int = 100, offset: int = 0) -> list[PdfJob]:
"""用户视角:仅未软删的任务,按创建时间倒序。""" """用户视角:仅未软删的任务,按创建时间倒序。"""
stmt = ( stmt = (

View File

@@ -31,15 +31,6 @@ class TunnelSessionDAO:
) )
return self.db.scalars(stmt).first() return self.db.scalars(stmt).first()
def get_active_by_port(self, tunnel_port: int) -> TunnelSession | None:
stmt = (
select(TunnelSession)
.where(TunnelSession.tunnel_port == tunnel_port)
.where(TunnelSession.status == "active")
.limit(1)
)
return self.db.scalars(stmt).first()
def list_active(self) -> list[TunnelSession]: def list_active(self) -> list[TunnelSession]:
stmt = select(TunnelSession).where(TunnelSession.status == "active") stmt = select(TunnelSession).where(TunnelSession.status == "active")
return list(self.db.scalars(stmt).all()) return list(self.db.scalars(stmt).all())

View File

@@ -6,11 +6,16 @@ get_or_create 用于「访问即新建」语义(路由 GET /api/wb/{id} 不存
from __future__ import annotations from __future__ import annotations
import logging
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..models.whiteboard import Whiteboard from ..models.whiteboard import Whiteboard
logger = logging.getLogger("zikai.whiteboard")
class WhiteboardDAO: class WhiteboardDAO:
def __init__(self, db: Session) -> None: def __init__(self, db: Session) -> None:
@@ -27,14 +32,18 @@ class WhiteboardDAO:
return self.db.scalars(stmt).first() return self.db.scalars(stmt).first()
def get_or_create(self, board_id: str) -> Whiteboard: def get_or_create(self, board_id: str) -> Whiteboard:
"""存在则返回,否则新建空板。利用 unique 约束兜底并发首访。""" """存在则返回,否则新建空板。利用 unique 约束兜底并发首访。
仅 IntegrityError并发下另一事务已插入违反唯一约束才回滚重读
其他异常向上抛,避免掩盖 schema/连接等真实故障。
"""
board = self.get(board_id) board = self.get(board_id)
if board is not None: if board is not None:
return board return board
board = Whiteboard(board_id=board_id, content="", version=0, edit_count=0) board = Whiteboard(board_id=board_id, content="", version=0, edit_count=0)
try: try:
return self.create(board) return self.create(board)
except Exception: except IntegrityError:
# 并发下另一事务已插入:回滚后重新读 # 并发下另一事务已插入:回滚后重新读
self.db.rollback() self.db.rollback()
return self.get(board_id) # type: ignore[return-value] return self.get(board_id) # type: ignore[return-value]

View File

@@ -64,7 +64,29 @@ def get_db() -> Generator[Session, None, None]:
def init_db_schema() -> None: def init_db_schema() -> None:
"""按需建表(幂等)。先导入 models 以注册映射。""" """按需建表(幂等)并校验既有表列与模型一致fail-fast on schema drift
先导入 models 注册映射create_all 用 IF NOT EXISTS 仅补缺失的表;
随后对每张已存在的表检查模型声明的列是否齐全,缺列即抛 RuntimeError
避免运行期才以晦涩的 OperationalError 暴露 schema 漂移。
"""
from sqlalchemy import inspect
from . import models # noqa: F401 from . import models # noqa: F401
get_engine() engine = get_engine()
Base.metadata.create_all(bind=_engine) Base.metadata.create_all(bind=engine)
inspector = inspect(engine)
missing: list[str] = []
for table, mapper in Base.registry.mappers.items():
if not inspector.has_table(table):
continue
db_cols = {c["name"] for c in inspector.get_columns(table)}
for model_col in mapper.columns.keys():
if model_col not in db_cols:
missing.append(f"{table}.{model_col}")
if missing:
raise RuntimeError(
"数据库 schema 与模型不一致,缺少列: " + ", ".join(missing)
+ "。请执行 sql/schema.sql 或迁移脚本更新表结构。"
)

View File

@@ -6,10 +6,11 @@
GET /redoc -> ReDoc Basic Auth GET /redoc -> ReDoc Basic Auth
GET /openapi.json -> OpenAPI 文档Basic Auth GET /openapi.json -> OpenAPI 文档Basic Auth
GET /health -> 存活探针(公开) GET /health -> 存活探针(公开)
GET /upload -> 上传页面(公开 HTML GET /api/index -> 导航页(公开,收集所有页面入口
GET /files -> 文件浏览页Basic Auth同 docs GET /api/upload -> 上传页面(公开 HTML
GET /wb/{id} -> 白板页面(公开,不存在则新建 GET /api/files-page -> 文件管理页Basic Auth同 docs含 PDF 转换管理
GET /wb-admin -> 白板管理页Basic Auth同 docs GET /api/wb/{id} -> 白板页面(公开,不存在则新建
GET /api/wb-admin -> 白板管理页Basic Auth同 docs
GET /api/... -> 业务接口 GET /api/... -> 业务接口
WS /ws/wb/{id} -> 白板实时同步(公开) WS /ws/wb/{id} -> 白板实时同步(公开)
/static/... -> 前端静态资源JS/CSS /static/... -> 前端静态资源JS/CSS
@@ -183,6 +184,16 @@ def create_app() -> FastAPI:
def health() -> dict: def health() -> dict:
return {"status": "ok"} 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( @app.get(
"/api/upload", "/api/upload",
response_class=HTMLResponse, response_class=HTMLResponse,
@@ -197,8 +208,12 @@ def create_app() -> FastAPI:
"/api/files-page", "/api/files-page",
response_class=HTMLResponse, response_class=HTMLResponse,
tags=["pages"], tags=["pages"],
summary="文件浏览页(需鉴权)", summary="文件管理页(需鉴权)",
description="列出 / 下载 / 删除已上传文件支持多选、批量下载删除与分页。Basic Auth 同 docs。", description=(
"列出 / 下载 / 删除已上传文件,并合并 PDF 转换管理:以 uploaded_files 为基础,"
"用 pdf_jobs 匹配标注关联文件的转换状态、用户软删标记,可硬删任务。"
"支持多选、批量下载删除与分页。Basic Auth 同 docs。"
),
) )
def files_page(_: str = Depends(require_docs_auth)) -> HTMLResponse: def files_page(_: str = Depends(require_docs_auth)) -> HTMLResponse:
return _serve_static_html("file_browser.html") return _serve_static_html("file_browser.html")
@@ -223,16 +238,6 @@ def create_app() -> FastAPI:
def whiteboard_page(board_id: str) -> HTMLResponse: def whiteboard_page(board_id: str) -> HTMLResponse:
return _serve_static_html("whiteboard.html") 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 return app

View File

@@ -36,3 +36,31 @@ class FileUploadResponse(BaseModel):
class FileListResponse(BaseModel): class FileListResponse(BaseModel):
total: int total: int
items: list[UploadedFileOut] 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]

View File

@@ -11,8 +11,10 @@ class PdfJobOut(BaseModel):
"""任务对外视图用户与管理页共用user_deleted 仅管理页关注)。""" """任务对外视图用户与管理页共用user_deleted 仅管理页关注)。"""
id: int id: int
source_file_id: int = Field(..., description="原始 epub 文件的 uploaded_file.id")
source_filename: str = Field(..., description="原始上传文件名") source_filename: str = Field(..., description="原始上传文件名")
source_size: int = 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") status: str = Field(..., description="pending / converting / done / failed")
progress: int = Field(0, description="转换进度 0-100") progress: int = Field(0, description="转换进度 0-100")
error_message: str = Field("", description="失败原因") error_message: str = Field("", description="失败原因")

View File

@@ -11,7 +11,6 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import shutil
import tempfile import tempfile
import zipfile import zipfile
from pathlib import Path from pathlib import Path

View File

@@ -26,7 +26,7 @@ from ..config import get_settings
from ..dao.pdf_job_dao import PdfJobDAO from ..dao.pdf_job_dao import PdfJobDAO
from ..dao.uploaded_file_dao import UploadedFileDAO from ..dao.uploaded_file_dao import UploadedFileDAO
from ..models.pdf_job import PdfJob from ..models.pdf_job import PdfJob
from ..schemas.pdf import PdfJobOut, PdfJobListResponse, PdfSubmitResponse from ..schemas.pdf import PdfJobOut, PdfJobListResponse
from . import pdf_converter from . import pdf_converter
from .upload_service import UploadService from .upload_service import UploadService
@@ -172,7 +172,6 @@ class PdfService:
# 落产物 UploadedFile复用 commit_entity 的原子改名 + 入库) # 落产物 UploadedFile复用 commit_entity 的原子改名 + 入库)
from ..models.uploaded_file import UploadedFile from ..models.uploaded_file import UploadedFile
import hashlib
size = part_path.stat().st_size size = part_path.stat().st_size
sha256 = self._hash_file(part_path) sha256 = self._hash_file(part_path)
entity = UploadedFile( entity = UploadedFile(
@@ -277,8 +276,7 @@ class PdfService:
if job.output_file_id is not None: if job.output_file_id is not None:
self._safe_delete_file(job.output_file_id) self._safe_delete_file(job.output_file_id)
# 删 PdfJob 行 # 删 PdfJob 行
self.job_dao.db.delete(job) self.job_dao.delete(job)
self.job_dao.db.commit()
logger.info("管理员硬删 PDF 任务 job=%s", job_id) logger.info("管理员硬删 PDF 任务 job=%s", job_id)
return True return True
@@ -293,7 +291,6 @@ class PdfService:
except Exception as exc: # pragma: no cover except Exception as exc: # pragma: no cover
logger.warning("删除文件失败 file_id=%s path=%s: %s", file_id, path, exc) logger.warning("删除文件失败 file_id=%s path=%s: %s", file_id, path, exc)
try: try:
self.file_dao.db.delete(row) self.file_dao.delete(file_id)
self.file_dao.db.commit()
except Exception as exc: # pragma: no cover except Exception as exc: # pragma: no cover
logger.warning("删除文件 DB 行失败 file_id=%s: %s", file_id, exc) logger.warning("删除文件 DB 行失败 file_id=%s: %s", file_id, exc)

View File

@@ -27,8 +27,9 @@ class ZikaiSFTPServer(asyncssh.SFTPServer):
super().__init__(chan, chroot=str(upload_root).encode()) super().__init__(chan, chroot=str(upload_root).encode())
try: try:
self._username = chan.get_extra_info("username") or "unknown" self._username = chan.get_extra_info("username") or "unknown"
except Exception: # pragma: no cover except Exception as exc: # pragma: no cover
self._username = "unknown" self._username = "unknown"
logger.debug("读取 SFTP 会话用户名失败: %s", exc)
logger.info("SFTP 会话开始 user=%s chroot=%s", self._username, upload_root) logger.info("SFTP 会话开始 user=%s chroot=%s", self._username, upload_root)
def exit(self) -> None: def exit(self) -> None:
@@ -45,8 +46,8 @@ def _tunnel_dao():
def _close_tunnel_dao(dao) -> None: def _close_tunnel_dao(dao) -> None:
try: try:
dao.db.close() dao.db.close()
except Exception: # pragma: no cover except Exception as exc: # pragma: no cover
pass logger.debug("关闭隧道 DAO 会话失败: %s", exc)
class ZikaiSSHServer(asyncssh.SSHServer): class ZikaiSSHServer(asyncssh.SSHServer):
@@ -101,7 +102,8 @@ class ZikaiSSHServer(asyncssh.SSHServer):
try: try:
# asyncssh 命中返回 dict可能为空未命中返回 None # asyncssh 命中返回 dict可能为空未命中返回 None
result = self._authorized_keys.validate(key, client_host=addr, client_addr=addr) result = self._authorized_keys.validate(key, client_host=addr, client_addr=addr)
except Exception: except Exception as exc: # pragma: no cover
logger.warning("公钥校验异常 user=%s: %s", username, exc)
result = None result = None
ok = result is not None ok = result is not None
if ok: if ok:

View File

@@ -52,11 +52,6 @@ class TunnelService:
def get_active(self, user_name: str) -> TunnelSession | None: def get_active(self, user_name: str) -> TunnelSession | None:
return self.dao.get_active_by_user(user_name) return self.dao.get_active_by_user(user_name)
def is_port_allowed(self, user_name: str, tunnel_port: int) -> bool:
"""校验该 user 是否被允许绑定该隧道端口(防 user 乱绑端口)。"""
user = self.settings.find_user(user_name)
return user is not None and user.tunnel_port == tunnel_port
def reap_orphans(self) -> int: def reap_orphans(self) -> int:
"""兜底清理:关闭所有 active 会话(进程重启时 DB 里残留的孤儿记录)。 """兜底清理:关闭所有 active 会话(进程重启时 DB 里残留的孤儿记录)。

View File

@@ -92,8 +92,9 @@ class WhiteboardHub:
# 尽力关闭 websocket可能已关闭 # 尽力关闭 websocket可能已关闭
try: try:
await conn.websocket.close() await conn.websocket.close()
except Exception: # pragma: no cover except Exception as exc: # pragma: no cover
pass logger.debug("关闭 websocket 时出错 board=%s client=%s: %s",
conn.board_id, conn.client_id, exc)
logger.info("连接移除 board=%s client=%s(剩余 %d 人)", logger.info("连接移除 board=%s client=%s(剩余 %d 人)",
conn.board_id, conn.client_id, self.connection_count(conn.board_id)) conn.board_id, conn.client_id, self.connection_count(conn.board_id))
@@ -161,8 +162,9 @@ class WhiteboardHub:
for conn in conns: for conn in conns:
try: try:
await conn.websocket.close() await conn.websocket.close()
except Exception: # pragma: no cover except Exception as exc: # pragma: no cover
pass logger.debug("关闭 websocket 时出错 board=%s client=%s: %s",
conn.board_id, conn.client_id, exc)
logger.info("关闭白板 board=%s,踢出 %d 个连接", board_id, len(conns)) logger.info("关闭白板 board=%s,踢出 %d 个连接", board_id, len(conns))
@@ -179,10 +181,3 @@ def get_hub() -> WhiteboardHub:
if _hub is None: if _hub is None:
_hub = WhiteboardHub() _hub = WhiteboardHub()
return _hub return _hub
def reset_hub() -> None:
"""测试用:重置单例。"""
global _hub
with _hub_lock:
_hub = None

View File

@@ -128,7 +128,7 @@ def render(status: SystemStatus) -> str:
</table> </table>
</div> </div>
<p class="foot">zikai file service · 数据来源 psutil</p> <p class="foot"><a class="json" href="/api/index">导航</a> · zikai file service · 数据来源 psutil</p>
</body> </body>
</html> </html>
""" """

View File

@@ -6,8 +6,6 @@
from __future__ import annotations from __future__ import annotations
from html import escape
# 默认分片大小 4 MiB大于 Apache 300s 限制下单片可数秒传完,小到内存恒定。 # 默认分片大小 4 MiB大于 Apache 300s 限制下单片可数秒传完,小到内存恒定。
DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024 DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024
# 同一文件分片并发数 # 同一文件分片并发数
@@ -38,7 +36,7 @@ def render() -> str:
<div id="summary" class="foot"></div> <div id="summary" class="foot"></div>
<p class="foot"><a class="json" href="/api/system/status">系统状态</a> · zikai file service</p> <p class="foot"><a class="json" href="/api/index">导航</a> · <a class="json" href="/api/system/status">系统状态</a> · zikai file service</p>
<script> <script>
const CHUNK_SIZE = {DEFAULT_CHUNK_SIZE}; const CHUNK_SIZE = {DEFAULT_CHUNK_SIZE};

39
docs/configuration.md Normal file
View File

@@ -0,0 +1,39 @@
# 配置说明
所有运行时配置在 `config.yaml`git-ignored。完整 schema 见 `config.example.yaml`
## 配置项
| 段 | 关键项 | 说明 |
|----|--------|------|
| `server` | `host`/`port` | 绑定地址,保持 `127.0.0.1:6867`Apache 反代) |
| `database` | `host`/`port`/`user`/`password`/`database` | MySQL 连接;密码由 `setup.sh` 自动生成写回 |
| `storage` | `upload_dir` | 文件存储根目录(默认 `uploads` |
| | `chunk_bytes` | 流式上传分片大小(默认 1 MiB |
| | `chunk_session_dir` | 分片会话暂存目录(默认 `uploads/.work` |
| | `chunk_session_ttl_seconds` | 被放弃会话存活秒数(默认 300 |
| `docs` | `username`/`password` | `/docs``/api/files-page``/api/wb-admin``/api/admin/*` 的 Basic Auth明文常量时间比较 |
| `sftp` | `enabled`/`host`/`port` | SFTP 服务,默认 `0.0.0.0:2022` |
| | `users[].username`/`password_hash` | SFTP 用户bcrypt |
| | `host_key_path`/`authorized_keys_path` | 主机密钥与公钥白名单路径 |
| `tunnel` | `enabled`/`users[]` | 反向隧道:`username`/`password_hash`/`tunnel_port`/`local_port` |
| `whiteboard` | `heartbeat_interval_seconds` | 心跳间隔(默认 3s |
| | `heartbeat_miss_threshold` | 失活阈值(默认 5 次 = 15s |
| | `max_board_id_length` | board_id 长度上限(默认 64 |
| | `max_connections_per_board` | 单白板并发连接上限(默认 50 |
| | `list_limit` | 管理页单次列表上限(默认 100 |
## 生成 bcrypt hashSFTP/隧道用户密码)
```bash
.venv/bin/python -c "import bcrypt;print(bcrypt.hashpw(b'yourpass',bcrypt.gensalt()).decode())"
```
把输出填入 `config.yaml` 对应 `password_hash` 字段。
## 重新生成 DB 密码
```bash
.venv/bin/python -m app.scripts.init_db # 重置密码(写回 config.yaml
KEEP_DB_PASSWORD=1 .venv/bin/python -m app.scripts.init_db # 保留现有密码,仅建库建账
```

29
docs/error-handling.md Normal file
View File

@@ -0,0 +1,29 @@
# 错误处理与日志约定
zTools2 采用 Spring 风格分层架构错误处理分三层DAO fail-fast抛异常Service 捕获后转换业务异常并记日志Controller 捕获后转 HTTP 状态码。
## DAO 层app/dao/
- **唯一发 SQL 的层**所有写操作create/update/delete均在该层 commitservice 不直接操作 session。
- `get_or_create`whiteboard_dao.py`IntegrityError`(并发下另一事务已插入违反唯一约束)才回滚重读;其他异常向上抛,避免掩盖 schema/连接等真实故障。
- `delete`pdf_job_dao.py / uploaded_file_dao.py硬删 DB 行并 commit不存在返回 False。
- schema 漂移检测:`init_db_schema`database.py建表后用 inspector 检查既有表的列是否与模型声明齐全,缺列即抛 `RuntimeError`fail-fast避免运行期才以晦涩的 `OperationalError` 暴露。
## Service 层app/services/
- **PdfService**`submit` 校验扩展名/大小,超限清理已落盘文件后抛 `HTTPException`;后台转换 `_convert_async` 捕获 `TimeoutError` / 通用异常,经 `_mark_failed` 落库 + `logger.warning``admin_delete` 磁盘删除失败仅 `logger.warning`,仍清 DB 行保证列表不再显示。
- **UploadService / ChunkUploadService**:流式落盘出错清理临时文件后 `raise`(向上传播);分片会话被放弃由后台 reaper 每 60s 清理。
- **WhiteboardHub**`disconnect` / `close_board` 关闭 websocket 出错 `logger.debug`(尽力关闭,可能已关闭);`broadcast` 单连接发送失败立即 disconnect不影响其他连接reaper 循环异常 `logger.warning` 后继续。
- **TunnelService**`register` / `close` / `reap_orphans` 均记 `logger.info`SSH 连接断开时清理会话失败 `logger.warning`
- **sftp_server**`validate_public_key` 校验异常 `logger.warning`auth 路径,避免静默失败);`_close_tunnel_dao` / 读会话用户名失败 `logger.debug`(尽力清理)。
## Controller 层app/controllers/
- **pdf_controller**`_resolve_cookie` 解析用户 cookie无则生成新值挂 request.state 供响应 set_cookie。
- **whiteboard_controller**`_safe_send` / `_safe_close` 发送/关闭 WS 失败 `logger.debug`best-effortWS 主循环异常 `logger.warning` 后正常关闭连接。
- 所有管理 API`/api/admin/*`)经 `require_docs_auth` Basic Auth 守卫(常量时间比较)。
## 日志位置
- `logs/app.log`HTTP`logs/sftp.log`SFTPpidfile`app.pid``sftp.pid`
- 日志器命名:`zikai.pdf` / `zikai.whiteboard` / `zikai.tunnel` / `sftp`,便于按模块过滤。

51
docs/routes.md Normal file
View File

@@ -0,0 +1,51 @@
# 路由与访问入口
## 路由约定
**所有 zTools2 托管的入口(页面 / 静态资源 / 健康探针 / WebSocket / REST API统一挂在 `/api/` 前缀下**,只有元信息/文档例外(`/``/docs``/redoc``/openapi.json`)。这样反向代理与 vite dev proxy 都只需一条 `/api/` 规则即可把请求转给当前环境的 zTools2前端用**同源相对路径**(如 `/api/pdf/jobs``/api/health`)即可,与运行环境(本地 / 测试 / 生产)无关,无需区分 dev/prod 指向。
> PDF 转换的用户侧 UI 由 zMainPage 的 zPDF_package 组件提供(构建期 import非 iframezTools2 仅提供 `/api/pdf/jobs` 等 REST API。PDF 转换管理(查看全部任务含软删标记、硬删)已并入文件管理页 `/api/files-page`。
页面类入口为避免与同名 REST API 冲突,统一加 `-page` 后缀:
| 类型 | 页面入口 | REST API同名不加后缀 |
|------|---------|------------------------|
| 文件管理 | `GET /api/files-page` | `GET /api/files``/api/files/{id}` 等 |
| 记事本 | `GET /api/wb-page/{id}` | `GET /api/wb/{id}` |
其余入口:`/api/index`(导航页)、`/api/health`(探针)、`/api/static/*`JS/CSS`/api/ws/wb/{id}`WebSocket`/api/upload``/api/wb-admin`
## 功能一览
| 模块 | 页面 / 接口 | 鉴权 |
|------|------------|------|
| 导航页 | `GET /api/index`(卡片式入口索引,各子页可返回) | 公开 |
| 文件上传 | `POST /api/files/upload`(流式)/ `POST /api/files/chunk-uploads/*`(分片+断点续传) | 公开 |
| 上传页 | `GET /api/upload`(拖拽/多文件/分片/去重) | 公开 |
| 文件管理 | `GET /api/files-page`(多选/批量下载删除/分页,合并 PDF 转换管理:状态、软删标记、硬删任务) | Basic Auth |
| 文件管理 API | `GET /api/admin/files``GET /api/admin/files/with-pdf`(合并视图,附带 pdf_jobs 关联)、`GET/DELETE /api/admin/files/{id}``GET /api/admin/files/{id}/download` | Basic Auth |
| 共享记事本 | `GET /api/wb-page/{id}`(公开,不存在则新建) | 公开 |
| 记事本实时同步 | `WS /api/ws/wb/{id}`(心跳 3s5 次失活移除) | 公开 |
| 记事本管理 | `GET /api/wb-admin`(查看/删除) | Basic Auth |
| 记事本管理 API | `GET /api/admin/wb``DELETE /api/admin/wb/{id}` | Basic Auth |
| PDF 转换 API | `POST /api/pdf/jobs``GET /api/pdf/jobs[/{id}]``GET /api/pdf/jobs/{id}/download``DELETE /api/pdf/jobs/{id}` | 公开cookie |
| PDF 转换管理 API | `GET /api/admin/pdf/jobs``DELETE /api/admin/pdf/jobs/{id}`(管理入口已并入 `/api/files-page` | Basic Auth |
| 主机监控 | `GET /api/system/status`CPU/内存/磁盘HTML+JSON 内容协商) | 公开 |
| 反向隧道反代 | `ALL /api/userPort/{userName}`(经 SSH 隧道转发到 user 本地服务) | 公开 |
| SFTP/SSH | 端口 2022密码+公钥chroot 到上传目录,承载隧道转发) | SSH |
| API 文档 | `GET /docs`Swagger/ `GET /redoc` | Basic Auth |
## 访问入口
| 入口 | URL |
|------|-----|
| 导航页 | https://f.zikai.wang/api/index |
| API 文档 | https://f.zikai.wang/docsBasic Auth |
| 上传页 | https://f.zikai.wang/api/upload |
| 文件管理 | https://f.zikai.wang/api/files-pageBasic Auth含 PDF 转换管理) |
| 共享记事本 | https://f.zikai.wang/api/wb-page/{id}(公开,`{id}``[a-zA-Z0-9_-]{1,64}` |
| 记事本管理 | https://f.zikai.wang/api/wb-adminBasic Auth |
| PDF 转换 | 经 zMainPage 的 PDF 页签zPDF_package 组件)调用 `/api/pdf/jobs` 等 REST API公开凭 cookie |
| 系统状态 | https://f.zikai.wang/api/system/statusHTML`?format=json` 切 JSON |
| curl 上传 | `curl -F file=@big.iso https://f.zikai.wang/api/files/upload` |
| SFTP | `sftp -P 2022 uploader@f.zikai.wang` |

View File

@@ -5,6 +5,7 @@
.files-table th.col-size { width: 9em; } .files-table th.col-size { width: 9em; }
.files-table th.col-src { width: 6em; } .files-table th.col-src { width: 6em; }
.files-table th.col-time { width: 11em; } .files-table th.col-time { width: 11em; }
.files-table th.col-pdf { width: 13em; }
.files-table th.col-act { width: 9em; text-align: right; } .files-table th.col-act { width: 9em; text-align: right; }
.files-table td.col-act { text-align: right; white-space: nowrap; } .files-table td.col-act { text-align: right; white-space: nowrap; }
.files-table td.col-name .fname { font-weight: 600; word-break: break-all; } .files-table td.col-name .fname { font-weight: 600; word-break: break-all; }
@@ -12,9 +13,24 @@
.files-table td.col-act .btn { padding: 0.3em 0.8em; font-size: 0.85em; margin-left: 0.3em; } .files-table td.col-act .btn { padding: 0.3em 0.8em; font-size: 0.85em; margin-left: 0.3em; }
.files-table tbody tr.sel { background: var(--primary-soft); } .files-table tbody tr.sel { background: var(--primary-soft); }
.files-table tbody tr.sel:hover td { background: var(--primary-soft); } .files-table tbody tr.sel:hover td { background: var(--primary-soft); }
/* 关联到已软删 PDF 任务的行:淡化背景提示 */
.files-table tbody tr.row-pdf-deleted td { background: var(--danger-soft); }
.files-table tbody tr.row-pdf-deleted:hover td { background: var(--danger-soft); }
.files-table input[type="checkbox"] { width: 16px; height: 16px; cursor: pointer; accent-color: var(--primary); } .files-table input[type="checkbox"] { width: 16px; height: 16px; cursor: pointer; accent-color: var(--primary); }
.sha-short { cursor: pointer; } .sha-short { cursor: pointer; }
.sha-short:hover { color: var(--primary); } .sha-short:hover { color: var(--primary); }
/* PDF 任务列:角色徽标 + 状态徽标堆叠 */
.pdf-cell { display: flex; flex-direction: column; gap: 0.2em; font-size: 0.82em; }
.pdf-cell .pdf-roles { display: flex; flex-wrap: wrap; gap: 0.3em; align-items: center; }
.pdf-cell .role-tag {
display: inline-block; padding: 0.05em 0.5em; border-radius: 8px;
font-size: 0.78em; background: var(--surface-2); color: var(--text-dim);
}
.pdf-cell .role-tag.source { background: var(--warn-soft); color: var(--warn); }
.pdf-cell .role-tag.output { background: var(--success-soft); color: var(--success); }
.pdf-cell .del-mark { color: var(--danger); font-size: 0.78em; }
.pdf-cell .pdf-act { display: flex; gap: 0.3em; flex-wrap: wrap; }
.pdf-cell .pdf-act .btn { padding: 0.2em 0.6em; font-size: 0.8em; }
.row-removed { opacity: 0; transition: opacity 0.25s; } .row-removed { opacity: 0; transition: opacity 0.25s; }
.toolbar-spacer { flex: 1; } .toolbar-spacer { flex: 1; }
@@ -47,5 +63,6 @@
.files-table th, .files-table td { padding: 0.5em 0.4em; } .files-table th, .files-table td { padding: 0.5em 0.4em; }
.files-table th.col-src, .files-table td.col-src { display: none; } .files-table th.col-src, .files-table td.col-src { display: none; }
.files-table th.col-time, .files-table td.col-time { font-size: 0.78em; } .files-table th.col-time, .files-table td.col-time { font-size: 0.78em; }
.files-table th.col-pdf, .files-table td.col-pdf { font-size: 0.78em; }
.batchbar { font-size: 0.85em; } .batchbar { font-size: 0.85em; }
} }

View File

@@ -3,14 +3,14 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>文件浏览 - zikai</title> <title>文件管理 - zikai</title>
<link rel="stylesheet" href="/api/static/common.css"> <link rel="stylesheet" href="/api/static/common.css">
<link rel="stylesheet" href="/api/static/file_browser.css"> <link rel="stylesheet" href="/api/static/file_browser.css">
</head> </head>
<body> <body>
<div class="wrap"> <div class="wrap">
<h1>文件浏览</h1> <h1>文件管理</h1>
<p class="sub">查看已上传的文件、下载或删除。支持多选与分页。删除后不再显示</p> <p class="sub">浏览 / 下载 / 删除已上传文件,并合并 PDF 转换管理:关联文件会标注转换状态与用户软删标记,可硬删任务。支持多选与分页</p>
<div class="toolbar"> <div class="toolbar">
<button class="btn primary" id="refresh">刷新</button> <button class="btn primary" id="refresh">刷新</button>
@@ -39,7 +39,7 @@
<div id="pager" class="pager hidden"></div> <div id="pager" class="pager hidden"></div>
<p class="foot"><a class="link" href="/api/upload">上传文件</a> · zikai file service</p> <p class="foot"><a class="link" href="/api/index">导航</a> · <a class="link" href="/api/upload">上传文件</a> · zikai file service</p>
</div> </div>
<script src="/api/static/common.js"></script> <script src="/api/static/common.js"></script>
<script src="/api/static/file_browser.js"></script> <script src="/api/static/file_browser.js"></script>

View File

@@ -1,4 +1,6 @@
/* 文件浏览页:分页拉取 /api/admin/files、复选框多选 + 全选、批量下载/删除、复制 sha。 /* 文件管理页:分页拉取 /api/admin/files-with-pdf、复选框多选 + 全选、批量下载/删除、复制 sha。
合并 PDF 转换管理:每个文件附带 pdf_jobsrole=source epub / role=output 产物 PDF
渲染转换状态徽标、用户软删标记并支持硬删任务DELETE /api/admin/pdf/jobs/{id})。
state.items 缓存当前页数据;切换页/页大小重新拉取;删除后若当前页空则回退一页。 */ state.items 缓存当前页数据;切换页/页大小重新拉取;删除后若当前页空则回退一页。 */
(function () { (function () {
"use strict"; "use strict";
@@ -53,7 +55,7 @@
countEl.textContent = ""; countEl.textContent = "";
pagerEl.classList.add("hidden"); pagerEl.classList.add("hidden");
try { 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); if (!res.ok) throw new Error("HTTP " + res.status);
const body = await res.json(); const body = await res.json();
state.items = body.items || []; state.items = body.items || [];
@@ -88,13 +90,15 @@
el("th", { class: "col-size" }, "大小"), el("th", { class: "col-size" }, "大小"),
el("th", { class: "col-src" }, "来源"), el("th", { class: "col-src" }, "来源"),
el("th", { class: "col-time" }, "上传时间"), el("th", { class: "col-time" }, "上传时间"),
el("th", { class: "col-pdf" }, "PDF 任务"),
el("th", { class: "col-act" }, "操作") el("th", { class: "col-act" }, "操作")
) )
); );
const tbody = el("tbody", null); const tbody = el("tbody", null);
for (const f of state.items) { for (const f of state.items) {
const checked = state.selected.has(f.id); 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("td", { class: "col-sel" },
el("input", { type: "checkbox", class: "row-sel", checked, dataset: { id: f.id } }) 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-size mono" }, fmtBytes(f.size_bytes)),
el("td", { class: "col-src" }, el("span", { class: "tag" }, f.source || "-")), 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-time muted" }, fmtTime(f.uploaded_at)),
el("td", { class: "col-pdf" }, renderPdfCell(f)),
el("td", { class: "col-act" }, el("td", { class: "col-act" },
el("a", { class: "btn primary", href: `/api/admin/files/${f.id}/download`, download: "" }, "下载"), el("a", { class: "btn primary", href: `/api/admin/files/${f.id}/download`, download: "" }, "下载"),
el("button", { class: "btn danger", onclick: () => removeOne(f) }, "删除") el("button", { class: "btn danger", onclick: () => removeOne(f) }, "删除")
@@ -190,6 +195,58 @@
return sha.length > 16 ? sha.slice(0, 12) + "…" + sha.slice(-4) : sha; 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) { async function removeOne(f) {
if (!confirm(`确定删除「${f.original_filename}」?\n此操作不可恢复,将同时删除磁盘文件。`)) return; if (!confirm(`确定删除「${f.original_filename}」?\n此操作不可恢复,将同时删除磁盘文件。`)) return;
try { try {

74
static/index.html Normal file
View File

@@ -0,0 +1,74 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>导航 - zikai</title>
<link rel="stylesheet" href="/api/static/common.css">
<style>
/* 导航页:卡片网格,复用 common.css 变量 */
.nav-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1em;
margin-top: 1.5em;
}
.nav-card {
display: flex; flex-direction: column; gap: 0.3em;
padding: 1.2em 1.3em;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
box-shadow: var(--shadow);
text-decoration: none; color: var(--text);
transition: transform 0.05s, border-color 0.15s;
}
.nav-card:hover { transform: translateY(-2px); border-color: var(--primary); }
.nav-card .ico { font-size: 1.6em; }
.nav-card .name { font-size: 1.05em; font-weight: 600; }
.nav-card .desc { color: var(--text-dim); font-size: 0.82em; line-height: 1.4; }
.nav-card .lock { font-size: 0.75em; color: var(--text-dim); }
</style>
</head>
<body>
<div class="wrap">
<h1>zikai 工具箱</h1>
<p class="sub">个人 Web 服务入口导航。带 🔒 标记的页面需 Basic Auth凭据见 config.yaml 的 docs 段)。</p>
<div class="nav-grid">
<a class="nav-card" href="/api/upload">
<span class="ico">📤</span>
<span class="name">上传文件</span>
<span class="desc">拖拽 / 多文件 / 分片4 MiB/ 断点续传</span>
<span class="lock"></span>
</a>
<a class="nav-card" href="/api/files-page">
<span class="ico">📁</span>
<span class="name">文件管理</span>
<span class="desc">浏览 / 下载 / 删除已上传文件,合并 PDF 转换管理(状态、软删标记、硬删任务)</span>
<span class="lock">🔒 需鉴权</span>
</a>
<a class="nav-card" href="/api/wb-admin">
<span class="ico">📝</span>
<span class="name">记事本管理</span>
<span class="desc">查看 / 删除共享文本记事本,可新建并打开</span>
<span class="lock">🔒 需鉴权</span>
</a>
<a class="nav-card" href="/api/system/status">
<span class="ico">📊</span>
<span class="name">系统状态</span>
<span class="desc">CPU / 内存 / 磁盘实时使用率(?format=json 切 JSON</span>
<span class="lock"></span>
</a>
<a class="nav-card" href="/docs">
<span class="ico">📚</span>
<span class="name">API 文档</span>
<span class="desc">Swagger UI全部 REST 接口在线调试</span>
<span class="lock">🔒 需鉴权</span>
</a>
</div>
<p class="foot">zikai file service · <a class="link" href="/api/health">健康探针</a></p>
</div>
</body>
</html>

View File

@@ -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; }
}

View File

@@ -1,30 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PDF 转换管理 - zikai</title>
<link rel="stylesheet" href="/api/static/common.css">
<link rel="stylesheet" href="/api/static/pdf_admin.css">
</head>
<body>
<div class="wrap">
<h1>PDF 转换管理</h1>
<p class="sub">查看全部转换任务(含用户已软删的,标注是否已删除),可硬删(真正删除磁盘与记录)。</p>
<div class="toolbar">
<button class="btn primary" id="refresh">刷新</button>
<button class="btn danger" id="bulkDelete" disabled>批量硬删 <span id="selCount"></span></button>
<span class="muted" id="count"></span>
</div>
<div id="list">
<div class="skel">加载中…</div>
</div>
<p class="foot">zikai file service</p>
</div>
<script src="/api/static/common.js"></script>
<script src="/api/static/pdf_admin.js"></script>
</body>
</html>

View File

@@ -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 = '<div class="skel">加载中…</div>';
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 = '<div class="empty">加载失败:' + (e.message || e) + "</div>";
}
}
function render(items) {
if (!items.length) {
listEl.innerHTML = '<div class="empty">还没有任务。</div>';
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();
})();

View File

@@ -20,6 +20,7 @@
<span class="wb-online" id="online" title="在线人数"></span> <span class="wb-online" id="online" title="在线人数"></span>
</div> </div>
<div class="wb-bar-right"> <div class="wb-bar-right">
<a class="btn" href="/api/index" target="_top" title="返回导航">导航</a>
<button class="btn" id="copyLinkBtn" title="复制分享链接">复制链接</button> <button class="btn" id="copyLinkBtn" title="复制分享链接">复制链接</button>
<button class="btn" id="copyBtn" title="复制全部文本">复制文本</button> <button class="btn" id="copyBtn" title="复制全部文本">复制文本</button>
<button class="btn danger" id="clearBtn" title="清空全部内容(所有人)">清空</button> <button class="btn danger" id="clearBtn" title="清空全部内容(所有人)">清空</button>

View File

@@ -22,7 +22,7 @@
<div class="skel">加载中…</div> <div class="skel">加载中…</div>
</div> </div>
<p class="foot"><a class="link" href="/api/upload">上传文件</a> · <a class="link" href="/api/files-page">文件浏览</a> · zikai</p> <p class="foot"><a class="link" href="/api/index">导航</a> · <a class="link" href="/api/upload">上传文件</a> · <a class="link" href="/api/files-page">文件管理</a> · zikai</p>
</div> </div>
<script src="/api/static/common.js"></script> <script src="/api/static/common.js"></script>
<script src="/api/static/whiteboard_admin.js"></script> <script src="/api/static/whiteboard_admin.js"></script>