Compare commits
2 Commits
1c0f776571
...
refactor/c
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c193ca46e | |||
| 9af28f41b4 |
193
README.md
193
README.md
@@ -1,125 +1,47 @@
|
||||
# zikai file service
|
||||
# zTools2 - 个人 Web 服务后端
|
||||
|
||||
基于 FastAPI 的个人 Web 服务,提供**文件上传/浏览/下载、共享记事本(实时协作)、
|
||||
主机监控、SFTP 暂存、反向隧道、PDF 转换**。采用 Spring 风格分层架构,自带 API 文档。
|
||||
基于 FastAPI 的个人 Web 服务后端,提供文件上传/浏览/下载、共享记事本(实时协作)、主机监控、SFTP 暂存、反向隧道、PDF 转换。采用 Spring 风格分层架构(controller -> service -> dao -> ORM model -> MySQL),自带 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}`(心跳 3s,5 次失活移除) | 公开 |
|
||||
| 记事本管理 | `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,非 iframe);zTools2 仅提供 `/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`。
|
||||
所有入口统一挂在 `/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` 反代到本服务。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
server/
|
||||
zTools2/
|
||||
├── app/
|
||||
│ ├── main.py # FastAPI 应用工厂、路由注册、生命周期(reaper)
|
||||
│ ├── config.py # 从 config.yaml 加载的类型化 Settings(pydantic-settings)
|
||||
│ ├── database.py # SQLAlchemy 引擎/Session/Base/get_db 依赖
|
||||
│ ├── database.py # SQLAlchemy 引擎/Session/Base/get_db 依赖 + schema 校验
|
||||
│ ├── security.py # Basic Auth(require_docs_auth,常量时间比较)
|
||||
│ ├── controllers/ # 路由层(@RestController):file/system/chunk/tunnel/whiteboard/pdf/admin
|
||||
│ ├── services/ # 业务层:UploadService/ChunkUploadService/SystemService/
|
||||
│ │ # WhiteboardService/WhiteboardHub/TunnelService/sftp_server
|
||||
│ │ # PdfService(转换编排)+ pdf_converter(epub->pdf 纯 Python)
|
||||
│ ├── controllers/ # 路由层:file/system/chunk/tunnel/whiteboard/pdf/admin
|
||||
│ ├── services/ # 业务层:Upload/ChunkUpload/System/Whiteboard/Tunnel/Pdf/sftp
|
||||
│ ├── dao/ # 数据访问层:唯一发 SQL 的层(SQLAlchemy ORM 参数化)
|
||||
│ ├── models/ # ORM 实体:UploadedFile/UploadSession/Whiteboard/TunnelSession/PdfJob
|
||||
│ ├── schemas/ # pydantic 请求/响应 DTO
|
||||
│ ├── views/ # 服务端渲染 HTML(系统状态页、上传页)
|
||||
│ ├── static/ # 前端静态资源(common + file_browser + whiteboard + pdf + pdf_admin)
|
||||
│ ├── static/ # 前端静态资源
|
||||
│ └── scripts/init_db.py # 数据库初始化(建库建账、随机密码写回 config.yaml)
|
||||
├── sql/schema.sql # 建表 DDL(参考;实际由 ORM 自动建表)
|
||||
├── config.example.yaml # 配置模板(含注释)
|
||||
├── config.yaml # 实际配置(git-ignored,含密码)
|
||||
├── requirements.txt
|
||||
├── setup.sh # 一次性初始化:venv + 依赖 + 建库 + SFTP 密钥
|
||||
├── start.sh / stop.sh # 启停 HTTP(6867)+ SFTP(2022)
|
||||
└── logs/ # app.log / sftp.log
|
||||
├── start.sh / stop.sh # 启停 HTTP(127.0.0.1:6867)+ SFTP(2022)
|
||||
└── deploy/ # systemd 持久化部署
|
||||
```
|
||||
|
||||
**请求流程**:`controller → service → dao → ORM model → MySQL`。
|
||||
DB Session 由 `get_db` 依赖注入。前端页面走「StaticFiles 挂载 + 具名 HTML 路由」前后端分离,JS 调同源 `/api/...`。
|
||||
**请求流程**:`controller -> service -> dao -> ORM model -> MySQL`。DB Session 由 `get_db` 依赖注入。
|
||||
|
||||
## 从零安装(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
|
||||
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 反向代理
|
||||
### Apache2 反向代理配置
|
||||
|
||||
服务只绑 `127.0.0.1:6867`,通过 Apache 对外提供 HTTPS。安装模块并配置 vhost:
|
||||
|
||||
@@ -154,62 +76,49 @@ systemctl reload apache2
|
||||
> **防火墙**:放开 443(HTTPS)与 2022(SFTP)。6867 不对外(仅 loopback)。
|
||||
> **大文件上传**:Apache 全局 `Timeout 300`,慢链路建议走分片上传(`/api/upload`)或 SFTP。
|
||||
|
||||
## 配置说明
|
||||
## 从零安装(Ubuntu 22.04+)
|
||||
|
||||
所有运行时配置在 `config.yaml`(git-ignored)。完整 schema 见 `config.example.yaml`。
|
||||
### 1. 安装系统依赖
|
||||
|
||||
| 段 | 关键项 | 说明 |
|
||||
|----|--------|------|
|
||||
| `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/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) |
|
||||
```bash
|
||||
apt update
|
||||
apt install -y python3-venv python3-pip mysql-server apache2 \
|
||||
libssl-dev build-essential # build-essential 给 bcrypt/asyncssh 编译
|
||||
# PDF 转换依赖 weasyprint,需 pango/cairo 系统库:
|
||||
apt install -y libpango-1.0-0 libpangoft2-1.0-0 libcairo2 libgdk-pixbuf-2.0-0
|
||||
```
|
||||
|
||||
## 访问入口
|
||||
### 2. 获取代码并初始化
|
||||
|
||||
| 入口 | URL |
|
||||
|------|-----|
|
||||
| API 文档 | https://f.zikai.wang/docs(Basic Auth) |
|
||||
| 上传页 | https://f.zikai.wang/api/upload |
|
||||
| 文件浏览 | https://f.zikai.wang/api/files-page(Basic Auth) |
|
||||
| 共享记事本 | https://f.zikai.wang/api/wb-page/{id}(公开,`{id}` 为 `[a-zA-Z0-9_-]{1,64}`) |
|
||||
| 记事本管理 | https://f.zikai.wang/api/wb-admin(Basic Auth) |
|
||||
| PDF 转换 | 经 zMainPage 的 PDF 页签(zPDF_package 组件)调用 `/api/pdf/jobs` 等 REST API(公开,凭 cookie) |
|
||||
| 系统状态 | https://f.zikai.wang/api/system/status(HTML,`?format=json` 切 JSON) |
|
||||
| curl 上传 | `curl -F file=@big.iso https://f.zikai.wang/api/files/upload` |
|
||||
| SFTP | `sftp -P 2022 uploader@f.zikai.wang` |
|
||||
```bash
|
||||
git clone <repo> /root/zikai
|
||||
cd /root/zikai/zTools2
|
||||
./setup.sh # 创建 .venv + 装依赖 + 复制 config.yaml + 建 MySQL 库账 + 生成 SFTP 密钥
|
||||
```
|
||||
|
||||
## 运维
|
||||
### 3. 配置凭据
|
||||
|
||||
- **日志**:`logs/app.log`(HTTP)、`logs/sftp.log`(SFTP);pidfile:`app.pid`、`sftp.pid`。
|
||||
- **临时文件清理**:分片上传完成后立即删 `.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` 供参考/手动初始化。
|
||||
编辑 `config.yaml`(详见 [`docs/configuration.md`](./docs/configuration.md)):`docs.username/password`(管理页 Basic Auth)、`sftp.users[].password_hash`(bcrypt)、可选 `tunnel.users[]`。
|
||||
|
||||
## 持久化部署(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
|
||||
./deploy/install-systemd.sh # 安装并启动 ztools2 服务(开机自启)
|
||||
systemctl status ztools2 # 状态
|
||||
journalctl -u ztools2 -f # 日志
|
||||
sudo systemctl restart ztools2 # 重启(更新代码后)
|
||||
systemctl status ztools2
|
||||
journalctl -u ztools2 -f
|
||||
```
|
||||
|
||||
脚本自动检测 `.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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -39,7 +39,6 @@ def _resolve_cookie(request: Request, zk_pdf: str | None = Cookie(default=None))
|
||||
|
||||
cookie 缺失时把新值挂到 request.state,供响应阶段 set_cookie。
|
||||
"""
|
||||
cfg = get_settings().pdf
|
||||
if zk_pdf and len(zk_pdf) == 32:
|
||||
return zk_pdf
|
||||
new = new_owner_cookie()
|
||||
|
||||
@@ -228,12 +228,12 @@ def _parse(raw: str) -> dict | None:
|
||||
async def _safe_send(ws: WebSocket, msg: dict) -> None:
|
||||
try:
|
||||
await ws.send_json(msg)
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.debug("发送 WS 消息失败: %s", exc)
|
||||
|
||||
|
||||
async def _safe_close(ws: WebSocket) -> None:
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.debug("关闭 WS 失败: %s", exc)
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -33,6 +31,11 @@ class PdfJobDAO:
|
||||
self.db.refresh(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]:
|
||||
"""用户视角:仅未软删的任务,按创建时间倒序。"""
|
||||
stmt = (
|
||||
|
||||
@@ -31,15 +31,6 @@ class TunnelSessionDAO:
|
||||
)
|
||||
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]:
|
||||
stmt = select(TunnelSession).where(TunnelSession.status == "active")
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
@@ -6,11 +6,16 @@ get_or_create 用于「访问即新建」语义(路由 GET /api/wb/{id} 不存
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models.whiteboard import Whiteboard
|
||||
|
||||
logger = logging.getLogger("zikai.whiteboard")
|
||||
|
||||
|
||||
class WhiteboardDAO:
|
||||
def __init__(self, db: Session) -> None:
|
||||
@@ -27,14 +32,18 @@ class WhiteboardDAO:
|
||||
return self.db.scalars(stmt).first()
|
||||
|
||||
def get_or_create(self, board_id: str) -> Whiteboard:
|
||||
"""存在则返回,否则新建空板。利用 unique 约束兜底并发首访。"""
|
||||
"""存在则返回,否则新建空板。利用 unique 约束兜底并发首访。
|
||||
|
||||
仅 IntegrityError(并发下另一事务已插入违反唯一约束)才回滚重读;
|
||||
其他异常向上抛,避免掩盖 schema/连接等真实故障。
|
||||
"""
|
||||
board = self.get(board_id)
|
||||
if board is not None:
|
||||
return board
|
||||
board = Whiteboard(board_id=board_id, content="", version=0, edit_count=0)
|
||||
try:
|
||||
return self.create(board)
|
||||
except Exception:
|
||||
except IntegrityError:
|
||||
# 并发下另一事务已插入:回滚后重新读
|
||||
self.db.rollback()
|
||||
return self.get(board_id) # type: ignore[return-value]
|
||||
|
||||
@@ -64,7 +64,29 @@ def get_db() -> Generator[Session, None, 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
|
||||
get_engine()
|
||||
Base.metadata.create_all(bind=_engine)
|
||||
engine = get_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 或迁移脚本更新表结构。"
|
||||
)
|
||||
|
||||
37
app/main.py
37
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
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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="失败原因")
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
@@ -26,7 +26,7 @@ from ..config import get_settings
|
||||
from ..dao.pdf_job_dao import PdfJobDAO
|
||||
from ..dao.uploaded_file_dao import UploadedFileDAO
|
||||
from ..models.pdf_job import PdfJob
|
||||
from ..schemas.pdf import PdfJobOut, PdfJobListResponse, PdfSubmitResponse
|
||||
from ..schemas.pdf import PdfJobOut, PdfJobListResponse
|
||||
from . import pdf_converter
|
||||
from .upload_service import UploadService
|
||||
|
||||
@@ -172,7 +172,6 @@ class PdfService:
|
||||
|
||||
# 落产物 UploadedFile(复用 commit_entity 的原子改名 + 入库)
|
||||
from ..models.uploaded_file import UploadedFile
|
||||
import hashlib
|
||||
size = part_path.stat().st_size
|
||||
sha256 = self._hash_file(part_path)
|
||||
entity = UploadedFile(
|
||||
@@ -277,8 +276,7 @@ class PdfService:
|
||||
if job.output_file_id is not None:
|
||||
self._safe_delete_file(job.output_file_id)
|
||||
# 删 PdfJob 行
|
||||
self.job_dao.db.delete(job)
|
||||
self.job_dao.db.commit()
|
||||
self.job_dao.delete(job)
|
||||
logger.info("管理员硬删 PDF 任务 job=%s", job_id)
|
||||
return True
|
||||
|
||||
@@ -293,7 +291,6 @@ class PdfService:
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.warning("删除文件失败 file_id=%s path=%s: %s", file_id, path, exc)
|
||||
try:
|
||||
self.file_dao.db.delete(row)
|
||||
self.file_dao.db.commit()
|
||||
self.file_dao.delete(file_id)
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.warning("删除文件 DB 行失败 file_id=%s: %s", file_id, exc)
|
||||
|
||||
@@ -27,8 +27,9 @@ class ZikaiSFTPServer(asyncssh.SFTPServer):
|
||||
super().__init__(chan, chroot=str(upload_root).encode())
|
||||
try:
|
||||
self._username = chan.get_extra_info("username") or "unknown"
|
||||
except Exception: # pragma: no cover
|
||||
except Exception as exc: # pragma: no cover
|
||||
self._username = "unknown"
|
||||
logger.debug("读取 SFTP 会话用户名失败: %s", exc)
|
||||
logger.info("SFTP 会话开始 user=%s chroot=%s", self._username, upload_root)
|
||||
|
||||
def exit(self) -> None:
|
||||
@@ -45,8 +46,8 @@ def _tunnel_dao():
|
||||
def _close_tunnel_dao(dao) -> None:
|
||||
try:
|
||||
dao.db.close()
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.debug("关闭隧道 DAO 会话失败: %s", exc)
|
||||
|
||||
|
||||
class ZikaiSSHServer(asyncssh.SSHServer):
|
||||
@@ -101,7 +102,8 @@ class ZikaiSSHServer(asyncssh.SSHServer):
|
||||
try:
|
||||
# asyncssh 命中返回 dict(可能为空),未命中返回 None
|
||||
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
|
||||
ok = result is not None
|
||||
if ok:
|
||||
|
||||
@@ -52,11 +52,6 @@ class TunnelService:
|
||||
def get_active(self, user_name: str) -> TunnelSession | None:
|
||||
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:
|
||||
"""兜底清理:关闭所有 active 会话(进程重启时 DB 里残留的孤儿记录)。
|
||||
|
||||
|
||||
@@ -92,8 +92,9 @@ class WhiteboardHub:
|
||||
# 尽力关闭 websocket(可能已关闭)
|
||||
try:
|
||||
await conn.websocket.close()
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.debug("关闭 websocket 时出错 board=%s client=%s: %s",
|
||||
conn.board_id, conn.client_id, exc)
|
||||
logger.info("连接移除 board=%s client=%s(剩余 %d 人)",
|
||||
conn.board_id, conn.client_id, self.connection_count(conn.board_id))
|
||||
|
||||
@@ -161,8 +162,9 @@ class WhiteboardHub:
|
||||
for conn in conns:
|
||||
try:
|
||||
await conn.websocket.close()
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.debug("关闭 websocket 时出错 board=%s client=%s: %s",
|
||||
conn.board_id, conn.client_id, exc)
|
||||
logger.info("关闭白板 board=%s,踢出 %d 个连接", board_id, len(conns))
|
||||
|
||||
|
||||
@@ -179,10 +181,3 @@ def get_hub() -> WhiteboardHub:
|
||||
if _hub is None:
|
||||
_hub = WhiteboardHub()
|
||||
return _hub
|
||||
|
||||
|
||||
def reset_hub() -> None:
|
||||
"""测试用:重置单例。"""
|
||||
global _hub
|
||||
with _hub_lock:
|
||||
_hub = None
|
||||
|
||||
@@ -128,7 +128,7 @@ def render(status: SystemStatus) -> str:
|
||||
</table>
|
||||
</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>
|
||||
</html>
|
||||
"""
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from html import escape
|
||||
|
||||
# 默认分片大小 4 MiB:大于 Apache 300s 限制下单片可数秒传完,小到内存恒定。
|
||||
DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024
|
||||
# 同一文件分片并发数
|
||||
@@ -38,7 +36,7 @@ def render() -> str:
|
||||
|
||||
<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>
|
||||
const CHUNK_SIZE = {DEFAULT_CHUNK_SIZE};
|
||||
|
||||
39
docs/configuration.md
Normal file
39
docs/configuration.md
Normal 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 hash(SFTP/隧道用户密码)
|
||||
|
||||
```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
29
docs/error-handling.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# 错误处理与日志约定
|
||||
|
||||
zTools2 采用 Spring 风格分层架构,错误处理分三层:DAO fail-fast(抛异常),Service 捕获后转换业务异常并记日志,Controller 捕获后转 HTTP 状态码。
|
||||
|
||||
## DAO 层(app/dao/)
|
||||
|
||||
- **唯一发 SQL 的层**:所有写操作(create/update/delete)均在该层 commit,service 不直接操作 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-effort);WS 主循环异常 `logger.warning` 后正常关闭连接。
|
||||
- 所有管理 API(`/api/admin/*`)经 `require_docs_auth` Basic Auth 守卫(常量时间比较)。
|
||||
|
||||
## 日志位置
|
||||
|
||||
- `logs/app.log`(HTTP)、`logs/sftp.log`(SFTP);pidfile:`app.pid`、`sftp.pid`。
|
||||
- 日志器命名:`zikai.pdf` / `zikai.whiteboard` / `zikai.tunnel` / `sftp`,便于按模块过滤。
|
||||
51
docs/routes.md
Normal file
51
docs/routes.md
Normal 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,非 iframe);zTools2 仅提供 `/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}`(心跳 3s,5 次失活移除) | 公开 |
|
||||
| 记事本管理 | `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/docs(Basic Auth) |
|
||||
| 上传页 | https://f.zikai.wang/api/upload |
|
||||
| 文件管理 | https://f.zikai.wang/api/files-page(Basic Auth,含 PDF 转换管理) |
|
||||
| 共享记事本 | https://f.zikai.wang/api/wb-page/{id}(公开,`{id}` 为 `[a-zA-Z0-9_-]{1,64}`) |
|
||||
| 记事本管理 | https://f.zikai.wang/api/wb-admin(Basic Auth) |
|
||||
| PDF 转换 | 经 zMainPage 的 PDF 页签(zPDF_package 组件)调用 `/api/pdf/jobs` 等 REST API(公开,凭 cookie) |
|
||||
| 系统状态 | https://f.zikai.wang/api/system/status(HTML,`?format=json` 切 JSON) |
|
||||
| curl 上传 | `curl -F file=@big.iso https://f.zikai.wang/api/files/upload` |
|
||||
| SFTP | `sftp -P 2022 uploader@f.zikai.wang` |
|
||||
@@ -5,6 +5,7 @@
|
||||
.files-table th.col-size { width: 9em; }
|
||||
.files-table th.col-src { width: 6em; }
|
||||
.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 td.col-act { text-align: right; white-space: nowrap; }
|
||||
.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 tbody tr.sel { 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); }
|
||||
.sha-short { cursor: pointer; }
|
||||
.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; }
|
||||
|
||||
.toolbar-spacer { flex: 1; }
|
||||
@@ -47,5 +63,6 @@
|
||||
.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-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; }
|
||||
}
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<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/file_browser.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>文件浏览</h1>
|
||||
<p class="sub">查看已上传的文件、下载或删除。支持多选与分页。删除后不再显示。</p>
|
||||
<h1>文件管理</h1>
|
||||
<p class="sub">浏览 / 下载 / 删除已上传文件,并合并 PDF 转换管理:关联文件会标注转换状态与用户软删标记,可硬删任务。支持多选与分页。</p>
|
||||
|
||||
<div class="toolbar">
|
||||
<button class="btn primary" id="refresh">刷新</button>
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
<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>
|
||||
<script src="/api/static/common.js"></script>
|
||||
<script src="/api/static/file_browser.js"></script>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
74
static/index.html
Normal file
74
static/index.html
Normal 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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -20,6 +20,7 @@
|
||||
<span class="wb-online" id="online" title="在线人数">●</span>
|
||||
</div>
|
||||
<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="copyBtn" title="复制全部文本">复制文本</button>
|
||||
<button class="btn danger" id="clearBtn" title="清空全部内容(所有人)">清空</button>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<div class="skel">加载中…</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>
|
||||
<script src="/api/static/common.js"></script>
|
||||
<script src="/api/static/whiteboard_admin.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user