FastAPI 和 Html+css+js 开发的 PDF打印服务器 连接到服务器的物理打印机打印

PDF打印服务器

一个基于FastAPI的PDF打印服务器,支持通过网页界面上传PDF文件并打印到局域网内的物理打印机。

功能特性

  • 📄 支持PDF文件上传和打印

  • 🖨️ 自动检测系统打印机列表

  • 📱 响应式设计,支持移动端和桌面端

  • 🔄 实时打印任务状态监控

  • 💾 自动保存用户设置(打印机、打印份数)

  • 🔒 安全的文件处理,临时文件自动清理

  • 🌐 支持局域网多设备访问

  • 🎯 可靠的打印份数控制(支持1-100份)

系统要求

操作系统

  • Windows 10/11 (推荐,功能最完整)

软件依赖

  • Python 3.8+

  • SumatraPDF (Windows用户必需)

  • 系统打印机驱动

安装步骤

1. 安装Python依赖

复制代码
pip install fastapi uvicorn psutil
Windows用户额外安装:
复制代码
pip install pywin32

2. 安装SumatraPDF (仅Windows)

  1. 下载SumatraPDF:https://www.sumatrapdfreader.org/download-free-pdf-viewer.html

  2. 安装到默认路径:C:\Program Files\SumatraPDF\

3. 克隆或下载代码

复制代码
git clone https://github.com/yourusername/pdf-print-server.git
cd pdf-print-server

4. 创建必要的目录结构

复制代码
pdf-print-server/
├── uploads/          # 自动创建,用于临时存储上传的文件
├── static/           # 前端静态文件
│   └── index.html   # 前端页面
└── server.py        # 后端服务器代码

使用方法

1. 启动服务器

复制代码
python server.py

服务器启动后将显示:

复制代码
============================================================
PDF打印服务器启动 - SumatraPDF修复版
============================================================
本地访问: http://localhost:8083
局域网访问: http://192.168.1.100:8083
服务器地址: 192.168.1.100
系统: Windows
============================================================

2. 访问网页界面

  • 在服务器本机:打开浏览器访问 http://localhost:8083

  • 在局域网其他设备:打开浏览器访问 http://服务器IP:8083

3. 使用流程

  1. 选择PDF文件:点击"选择PDF文件"按钮或拖放文件到区域

  2. 选择打印机:从下拉列表中选择要使用的打印机

  3. 设置打印份数:输入需要打印的份数(1-100)

  4. 开始打印:点击"上传并打印"按钮

  5. 查看状态:在右侧任务状态区域查看打印进度

API接口

主要接口

端点 方法 描述
/ GET 前端页面
/api/printers GET 获取打印机列表
/api/upload POST 上传并打印PDF文件
/api/tasks GET 获取所有任务状态
/api/tasks/{task_id} GET 获取特定任务状态
/api/health GET 服务器健康检查
/api/sumatra-test GET 测试SumatraPDF安装

上传文件参数

复制代码
curl -X POST http://localhost:8083/api/upload \
  -F "file=@document.pdf" \
  -F "printer=HP LaserJet" \
  -F "copies=2"

配置文件说明

服务器配置

server.py 中可以修改以下配置:

复制代码
# 端口配置
PORT = 8083  # 默认端口
​
# 文件上传限制
MAX_FILE_SIZE = 10 * 1024 * 1024  # 10MB
​
# 临时文件清理
FILE_CLEANUP_HOURS = 1  # 1小时后清理临时文件

前端配置

前端设置自动保存在浏览器的LocalStorage中:

  • 选择的打印机

  • 打印份数

  • 刷新页面后设置保持不变

故障排除

常见问题

1. 打印机列表为空
  • 检查打印机是否已连接并安装驱动

  • Windows:确保打印机已设置为"默认打印机"

  • Linux:确保CUPS服务正在运行

2. 打印失败
  • Windows:检查SumatraPDF是否正确安装

  • 查看服务器控制台日志获取详细错误信息

  • 确保PDF文件没有损坏

3. 打印份数不起作用
  • Windows:确认SumatraPDF版本支持-print-settings "Nx"参数

  • 尝试使用打印对话框方案

4. 无法访问网页
  • 检查防火墙设置,确保8083端口开放

  • 确保服务器和客户端在同一局域网

日志查看

服务器日志包含详细的操作信息:

  • 文件上传状态

  • 打印命令执行情况

  • 错误信息

安全注意事项

  1. 文件安全:上传的文件会存储在临时目录,打印完成后自动删除

  2. 访问控制:当前版本无认证,建议在内网安全环境使用

  3. 文件大小限制:默认限制为10MB,防止大文件攻击

  4. 端口安全:建议在路由器中限制8083端口的访问

开发说明

项目结构

复制代码
pdf-print-server/
├── server.py              # FastAPI后端服务器
├── static/
│   ├── index.html        # 前端HTML页面
│   └── (CSS/JS内联)      # 样式和脚本
├── uploads/              # 临时文件目录
├── requirements.txt      # Python依赖
└── README.md            # 项目说明

更新日志

v1.3.0 (最新)

  • 根据SumatraPDF官方文档修正打印份数设置

  • 增加打印对话框备用方案

  • 增强错误处理和日志记录

  • 新增SumatraPDF测试接口

v1.2.0

  • 修复移动端显示问题

  • 添加异步任务处理

  • 改进打印份数备用方案

  • 增加系统健康检查

v1.1.0

  • 添加设置自动保存功能

  • 优化移动端用户体验

  • 改进打印机列表获取

v1.0.0

  • 初始版本发布

  • 基本PDF上传和打印功能

  • 打印机列表自动检测

  • 任务状态监控

v1.3.0 (最新) 最新代码 index.html

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
    <title>PDF打印服务器</title>
    <style>
        :root {
            --primary-color: #4361ee;
            --secondary-color: #3a0ca3;
            --success-color: #2ecc71;
            --error-color: #e74c3c;
            --warning-color: #f39c12;
            --info-color: #3498db;
            --light-bg: #f8f9fa;
            --dark-text: #333;
            --light-text: #666;
            --border-color: #ddd;
            --shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
            --shadow-hover: 0 8px 15px rgba(0, 0, 0, 0.15);
        }

        * {
            box-sizing: border-box;
            margin: 0;
            padding: 0;
            -webkit-tap-highlight-color: transparent;
        }

        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            line-height: 1.6;
            color: var(--dark-text);
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
            padding: 10px;
            overflow-x: hidden;
        }

        .container {
            max-width: 1200px;
            margin: 0 auto;
            background: rgba(255, 255, 255, 0.98);
            border-radius: 12px;
            box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
            overflow: hidden;
        }

        /* 移动端优化头部 */
        header {
            background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
            color: white;
            padding: 15px;
            text-align: center;
            border-bottom: 3px solid rgba(255, 255, 255, 0.2);
        }

        .header-content h1 {
            font-size: 1.6rem;
            margin-bottom: 8px;
            display: flex;
            align-items: center;
            justify-content: center;
            gap: 8px;
            flex-wrap: wrap;
        }

        .header-content h1 i {
            font-size: 1.4rem;
            animation: pulse 2s infinite;
        }

        @keyframes pulse {
            0% {
                transform: scale(1);
            }

            50% {
                transform: scale(1.1);
            }

            100% {
                transform: scale(1);
            }
        }

        .header-content p {
            font-size: 0.9rem;
            opacity: 0.9;
            line-height: 1.3;
        }

        /* 移动端主内容布局 - 修复上传区域 */
        .main-content {
            display: flex;
            flex-direction: column;
            gap: 15px;
            padding: 15px;
            background: var(--light-bg);
            width: 100%;
            overflow: hidden;
        }

        @media (min-width: 768px) {
            .main-content {
                display: grid;
                grid-template-columns: 1fr 1fr;
                gap: 20px;
                padding: 25px;
            }
            
            .header-content h1 {
                font-size: 2.2rem;
            }
            
            .header-content p {
                font-size: 1.1rem;
            }
            
            header {
                padding: 25px 30px;
            }
            
            body {
                padding: 15px;
            }
        }

        /* 卡片优化 - 确保不超出容器 */
        .card {
            background: white;
            border-radius: 10px;
            padding: 18px;
            box-shadow: var(--shadow);
            transition: all 0.3s ease;
            border: 1px solid rgba(0, 0, 0, 0.05);
            width: 100%;
            max-width: 100%;
            overflow: hidden;
        }

        @media (min-width: 768px) {
            .card {
                padding: 25px;
                border-radius: 12px;
            }
        }

        .card h2 {
            color: var(--primary-color);
            margin-bottom: 15px;
            padding-bottom: 10px;
            border-bottom: 2px solid var(--light-bg);
            display: flex;
            align-items: center;
            gap: 8px;
            font-size: 1.2rem;
            word-break: break-word;
        }

        @media (min-width: 768px) {
            .card h2 {
                font-size: 1.4rem;
                margin-bottom: 20px;
                padding-bottom: 12px;
            }
        }

        /* 表单组优化 - 确保不超出边界 */
        .form-group {
            margin-bottom: 15px;
            width: 100%;
            max-width: 100%;
            overflow: hidden;
        }

        @media (min-width: 768px) {
            .form-group {
                margin-bottom: 20px;
            }
        }

        .form-group label {
            display: block;
            margin-bottom: 6px;
            font-weight: 600;
            color: var(--dark-text);
            font-size: 0.9rem;
            display: flex;
            align-items: center;
            gap: 5px;
            width: 100%;
            word-break: break-word;
        }

        @media (min-width: 768px) {
            .form-group label {
                font-size: 1rem;
                margin-bottom: 8px;
            }
        }

        .form-group label i {
            color: var(--primary-color);
            width: 16px;
            text-align: center;
            flex-shrink: 0;
        }

        /* 文件输入优化 - 修复超出问题 */
        .file-input-wrapper {
            position: relative;
            display: flex;
            align-items: center;
            gap: 6px;
            width: 100%;
            max-width: 100%;
            overflow: hidden;
        }

        #pdfFile {
            flex: 1;
            min-width: 0;
            padding: 10px;
            border: 2px dashed var(--border-color);
            border-radius: 6px;
            background: white;
            cursor: pointer;
            transition: all 0.3s;
            font-size: 0.85rem;
            width: 100%;
            max-width: 100%;
            box-sizing: border-box;
        }

        .file-name {
            font-size: 0.8rem;
            color: var(--light-text);
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
            max-width: 120px;
            flex-shrink: 0;
        }

        @media (min-width: 768px) {
            .file-name {
                max-width: 180px;
                font-size: 0.85rem;
            }
            
            #pdfFile {
                font-size: 0.9rem;
                padding: 12px;
            }
        }

        .file-hint {
            font-size: 0.75rem;
            color: var(--light-text);
            margin-top: 4px;
            font-style: italic;
            word-break: break-word;
        }

        /* 打印机选择优化 - 确保不超出 */
        .printer-select-wrapper {
            display: flex;
            gap: 6px;
            align-items: center;
            width: 100%;
            max-width: 100%;
            overflow: hidden;
        }

        #printerSelect {
            flex: 1;
            min-width: 0;
            padding: 10px;
            border: 2px solid var(--border-color);
            border-radius: 6px;
            font-size: 0.9rem;
            background: white;
            cursor: pointer;
            transition: all 0.3s;
            -webkit-appearance: none;
            appearance: none;
            width: 100%;
            max-width: 100%;
            box-sizing: border-box;
        }

        /* 触摸友好的按钮 - 确保不超出 */
        .icon-btn, .primary-btn, .secondary-btn {
            padding: 10px 12px;
            background: var(--primary-color);
            color: white;
            border: none;
            border-radius: 6px;
            cursor: pointer;
            transition: all 0.3s;
            font-size: 0.9rem;
            touch-action: manipulation;
            min-height: 40px;
            white-space: nowrap;
            flex-shrink: 0;
        }

        .icon-btn {
            padding: 10px;
            min-width: 40px;
            display: flex;
            align-items: center;
            justify-content: center;
        }

        .primary-btn {
            padding: 14px 16px;
            font-size: 1rem;
            font-weight: 600;
            width: 100%;
            display: flex;
            align-items: center;
            justify-content: center;
            gap: 8px;
            box-shadow: 0 3px 10px rgba(67, 97, 238, 0.3);
        }

        .secondary-btn {
            padding: 8px 12px;
            font-size: 0.85rem;
            display: flex;
            align-items: center;
            gap: 5px;
            background: white;
            color: var(--primary-color);
            border: 2px solid var(--primary-color);
        }

        /* 触摸反馈 */
        .icon-btn:active, .primary-btn:active, .secondary-btn:active {
            transform: scale(0.98);
        }

        /* 份数输入优化 */
        #copies {
            width: 80px;
            padding: 10px;
            border: 2px solid var(--border-color);
            border-radius: 6px;
            font-size: 0.9rem;
            text-align: center;
            min-height: 40px;
            box-sizing: border-box;
        }

        @media (min-width: 768px) {
            #copies {
                width: 100px;
            }
        }

        /* 任务列表优化 */
        .task-controls {
            display: flex;
            gap: 6px;
            margin-bottom: 12px;
            flex-wrap: wrap;
            width: 100%;
        }

        .task-list {
            min-height: 160px;
            max-height: 300px;
            overflow-y: auto;
            background: #fafafa;
            border-radius: 6px;
            padding: 8px;
            border: 1px solid #eee;
            width: 100%;
        }

        .task-item {
            padding: 10px;
            margin-bottom: 6px;
            background: white;
            border-radius: 6px;
            border-left: 3px solid var(--primary-color);
            display: flex;
            justify-content: space-between;
            align-items: center;
            transition: all 0.2s;
            animation: slideIn 0.3s ease;
            width: 100%;
            max-width: 100%;
            overflow: hidden;
            box-sizing: border-box;
        }

        @media (min-width: 768px) {
            .task-item {
                padding: 12px;
                margin-bottom: 8px;
            }
        }

        .task-info {
            flex: 1;
            padding-right: 8px;
            min-width: 0;
            overflow: hidden;
        }

        .task-filename {
            font-weight: 600;
            color: var(--dark-text);
            margin-bottom: 4px;
            font-size: 0.85rem;
            word-break: break-word;
            overflow: hidden;
            text-overflow: ellipsis;
            display: -webkit-box;
            -webkit-line-clamp: 2;
            -webkit-box-orient: vertical;
        }

        .task-details {
            font-size: 0.75rem;
            color: var(--light-text);
            display: flex;
            gap: 8px;
            flex-wrap: wrap;
        }

        /* 信息框优化 */
        .info-box {
            background: white;
            border-radius: 10px;
            padding: 18px;
            margin: 0 12px 15px;
            box-shadow: var(--shadow);
            border: 1px solid rgba(0, 0, 0, 0.05);
            width: calc(100% - 24px);
            max-width: 100%;
            overflow: hidden;
        }

        @media (min-width: 768px) {
            .info-box {
                padding: 25px;
                margin: 0 20px 25px;
                border-radius: 12px;
                width: calc(100% - 40px);
            }
        }

        .info-box h3 {
            color: var(--primary-color);
            margin-bottom: 12px;
            font-size: 1.1rem;
            display: flex;
            align-items: center;
            gap: 6px;
            word-break: break-word;
        }

        @media (min-width: 768px) {
            .info-box h3 {
                font-size: 1.25rem;
                margin-bottom: 15px;
            }
        }

        .info-box ol {
            padding-left: 18px;
            margin-bottom: 15px;
        }

        .info-box li {
            margin-bottom: 6px;
            line-height: 1.5;
            font-size: 0.9rem;
            word-break: break-word;
        }

        @media (min-width: 768px) {
            .info-box li {
                margin-bottom: 8px;
                line-height: 1.6;
                font-size: 0.95rem;
            }
        }

        /* 服务器信息优化 */
        .server-info {
            background: linear-gradient(135deg, #f8f9fa, #e9ecef);
            padding: 12px;
            border-radius: 6px;
            margin-bottom: 12px;
            border: 1px solid #dee2e6;
            width: 100%;
            overflow: hidden;
        }

        @media (min-width: 768px) {
            .server-info {
                padding: 15px;
                margin-bottom: 15px;
            }
        }

        .info-row {
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding: 5px 0;
            border-bottom: 1px dashed #dee2e6;
            font-size: 0.85rem;
            width: 100%;
            flex-wrap: wrap;
        }

        .info-row:last-child {
            border-bottom: none;
        }

        /* 响应式工具提示 */
        .mobile-tip {
            display: block;
            font-size: 0.75rem;
            color: var(--info-color);
            background: rgba(52, 152, 219, 0.1);
            padding: 5px 8px;
            border-radius: 4px;
            margin-top: 4px;
            border-left: 3px solid var(--info-color);
            word-break: break-word;
        }

        @media (min-width: 768px) {
            .mobile-tip {
                display: none;
            }
        }

        /* 滚动条优化 */
        .task-list::-webkit-scrollbar {
            width: 5px;
        }

        .task-list::-webkit-scrollbar-track {
            background: #f1f1f1;
            border-radius: 4px;
        }

        .task-list::-webkit-scrollbar-thumb {
            background: var(--primary-color);
            border-radius: 4px;
        }

        /* 移动端下拉菜单优化 */
        select {
            font-size: 16px; /* 防止iOS缩放 */
        }

        /* 触摸设备悬停状态修复 */
        @media (hover: none) {
            .card:hover {
                transform: none;
                box-shadow: var(--shadow);
            }
            
            .icon-btn:hover, .primary-btn:hover, .secondary-btn:hover {
                transform: none;
            }
        }

        /* 确保长按不会出现文本选择 */
        .no-select {
            -webkit-user-select: none;
            -moz-user-select: none;
            -ms-user-select: none;
            user-select: none;
        }

        /* 设置信息样式 */
        .settings-info {
            background: linear-gradient(135deg, #e3f2fd, #bbdefb);
            padding: 10px;
            border-radius: 6px;
            margin: 12px 0;
            border-left: 3px solid #2196f3;
            font-size: 0.85rem;
            color: #1565c0;
            display: flex;
            align-items: center;
            gap: 6px;
            width: 100%;
            box-sizing: border-box;
        }

        .printer-hint {
            font-size: 0.8rem;
            color: var(--info-color);
            margin-top: 6px;
            padding: 6px 8px;
            background: rgba(52, 152, 219, 0.1);
            border-radius: 4px;
            border-left: 3px solid var(--info-color);
            word-break: break-word;
        }

        .copies-hint {
            font-size: 0.75rem;
            color: var(--light-text);
            margin-top: 4px;
        }

        /* 消息框动画 */
        .message {
            padding: 10px;
            border-radius: 6px;
            margin-top: 10px;
            font-weight: 500;
            display: none;
            border-left: 3px solid;
            animation: slideIn 0.3s ease;
            font-size: 0.9rem;
            word-break: break-word;
            width: 100%;
            box-sizing: border-box;
        }

        @keyframes slideIn {
            from {
                opacity: 0;
                transform: translateY(-8px);
            }

            to {
                opacity: 1;
                transform: translateY(0);
            }
        }

        .message.success {
            background-color: #d4edda;
            color: #155724;
            border-color: var(--success-color);
            display: block;
        }

        .message.error {
            background-color: #f8d7da;
            color: #721c24;
            border-color: var(--error-color);
            display: block;
        }

        .message.info {
            background-color: #d1ecf1;
            color: #0c5460;
            border-color: var(--info-color);
            display: block;
        }

        /* 设置管理优化 */
        .settings-manage {
            background: #f8f9fa;
            padding: 12px;
            border-radius: 6px;
            margin-top: 12px;
            border: 1px solid #dee2e6;
            width: 100%;
            box-sizing: border-box;
        }

        .settings-manage h4 {
            color: var(--primary-color);
            margin-bottom: 10px;
            display: flex;
            align-items: center;
            gap: 5px;
            font-size: 0.95rem;
        }

        .settings-actions {
            display: flex;
            gap: 6px;
            margin-bottom: 10px;
            flex-wrap: wrap;
        }

        .settings-note {
            font-size: 0.8rem;
            color: var(--light-text);
            margin-top: 8px;
            padding: 6px 8px;
            background: rgba(67, 97, 238, 0.05);
            border-radius: 4px;
            border-left: 3px solid var(--primary-color);
            line-height: 1.4;
            word-break: break-word;
        }

        /* 故障排除优化 */
        .troubleshooting {
            background: #fff3cd;
            padding: 10px;
            border-radius: 6px;
            border: 1px solid #ffeaa7;
            width: 100%;
            box-sizing: border-box;
        }

        .troubleshooting h4 {
            color: #856404;
            margin-bottom: 6px;
            display: flex;
            align-items: center;
            gap: 5px;
            font-size: 0.95rem;
        }

        .troubleshooting ul {
            padding-left: 16px;
        }

        .troubleshooting li {
            margin-bottom: 5px;
            color: #856404;
            font-size: 0.8rem;
            word-break: break-word;
        }

        /* 页脚优化 */
        .footer {
            text-align: center;
            padding: 12px;
            background: #f8f9fa;
            color: var(--light-text);
            border-top: 1px solid #e9ecef;
            font-size: 0.8rem;
            width: 100%;
            box-sizing: border-box;
        }

        .footer p {
            margin: 3px 0;
            word-break: break-word;
        }

        /* 空状态 */
        .empty-state {
            text-align: center;
            padding: 30px 15px;
            color: var(--light-text);
        }

        .empty-state i {
            color: #ccc;
            margin-bottom: 10px;
        }

        .empty-state p {
            font-size: 1rem;
            margin-bottom: 4px;
        }

        .empty-state small {
            font-size: 0.85rem;
        }

        /* 状态标签 */
        .task-status {
            padding: 5px 8px;
            border-radius: 15px;
            font-size: 0.7rem;
            font-weight: 600;
            text-transform: uppercase;
            white-space: nowrap;
            min-width: 70px;
            text-align: center;
            flex-shrink: 0;
        }

        .status-queued {
            background-color: #fff3cd;
            color: #856404;
            border: 1px solid #ffeaa7;
        }

        .status-processing {
            background-color: #d1ecf1;
            color: #0c5460;
            border: 1px solid #bee5eb;
        }

        .status-completed {
            background-color: #d4edda;
            color: #155724;
            border: 1px solid #c3e6cb;
        }

        .status-failed {
            background-color: #f8d7da;
            color: #721c24;
            border: 1px solid #f5c6cb;
        }

        /* 进度条 */
        .progress-bar {
            height: 8px;
            background-color: #e0e0e0;
            border-radius: 4px;
            margin: 15px 0;
            overflow: hidden;
            display: none;
            position: relative;
            width: 100%;
        }

        .progress-fill {
            height: 100%;
            background: linear-gradient(90deg, var(--primary-color), var(--success-color));
            width: 0%;
            transition: width 0.3s ease;
            border-radius: 4px;
        }

        /* 修复移动端输入框聚焦时的缩放问题 */
        @media screen and (max-width: 767px) {
            input, select, textarea {
                font-size: 16px !important;
            }
        }
    </style>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>

<body>
    <div class="container">
        <header>
            <div class="header-content">
                <h1><i class="fas fa-print"></i> PDF打印服务器</h1>
                <p>上传PDF文件并通过连接到服务器的物理打印机打印</p>
                <div class="mobile-tip">
                    <i class="fas fa-mobile-alt"></i> 移动端优化版本
                </div>
            </div>
        </header>

        <div class="main-content">
            <!-- 上传区域 - 已修复显示问题 -->
            <div class="card" id="uploadCard">
                <h2><i class="fas fa-upload"></i> 上传PDF文件</h2>

                <div class="form-group">
                    <label for="pdfFile">选择PDF文件:</label>
                    <div class="file-input-wrapper">
                        <input type="file" id="pdfFile" accept=".pdf" required>
                        <span class="file-name" id="fileName">未选择文件</span>
                    </div>
                    <div class="file-hint">最大支持 10MB,仅限PDF格式</div>
                    <div class="mobile-tip">
                        <i class="fas fa-hand-pointer"></i> 点击选择文件
                    </div>
                </div>

                <div class="form-group">
                    <label for="printerSelect">
                        <i class="fas fa-print"></i> 选择打印机:
                    </label>
                    <div class="printer-select-wrapper">
                        <select id="printerSelect" required>
                            <option value="">加载中...</option>
                        </select>
                        <button type="button" id="refreshPrinters" class="icon-btn" title="刷新打印机列表">
                            <i class="fas fa-sync-alt"></i>
                        </button>
                    </div>
                    <div class="printer-hint">
                        <i class="fas fa-info-circle"></i> 打印机列表会自动从服务器获取
                    </div>
                    <div class="mobile-tip">
                        <i class="fas fa-arrow-down"></i> 点击下拉选择打印机
                    </div>
                </div>

                <div class="form-group">
                    <label for="copies">
                        <i class="fas fa-copy"></i> 打印份数:
                    </label>
                    <input type="number" id="copies" min="1" max="100" value="1" required>
                    <div class="copies-hint">范围: 1 - 100</div>
                </div>

                <div class="settings-info">
                    <i class="fas fa-save"></i> 设置会自动保存,刷新页面后仍然有效
                </div>

                <button id="uploadBtn" class="primary-btn">
                    <i class="fas fa-cloud-upload-alt"></i> 上传并打印
                </button>

                <div id="uploadProgress" class="progress-bar">
                    <div class="progress-fill" style="width: 0%"></div>
                </div>

                <div id="message" class="message"></div>
            </div>

            <!-- 任务状态区域 -->
            <div class="card" id="taskCard">
                <h2><i class="fas fa-tasks"></i> 打印任务状态</h2>

                <div class="task-controls">
                    <button id="clearCompleted" class="secondary-btn">
                        <i class="fas fa-trash-alt"></i> 清除已完成
                    </button>
                    <button id="refreshTasks" class="secondary-btn">
                        <i class="fas fa-sync-alt"></i> 刷新状态
                    </button>
                </div>

                <div id="taskStatus" class="task-list">
                    <div class="empty-state">
                        <i class="fas fa-inbox fa-3x"></i>
                        <p>暂无任务</p>
                        <small>上传文件后,任务将显示在这里</small>
                    </div>
                </div>
                <div class="mobile-tip">
                    <i class="fas fa-arrows-alt-v"></i> 可以上下滚动查看任务列表
                </div>
            </div>
        </div>

        <!-- 信息区域 -->
        <div class="info-box">
            <h3><i class="fas fa-info-circle"></i> 使用说明</h3>
            <ol>
                <li>确保您的设备与服务器在同一局域网中</li>
                <li>选择要打印的PDF文件(最大10MB)</li>
                <li><strong>选择连接到服务器的打印机</strong></li>
                <li>设置打印份数(1-100)</li>
                <li>点击"上传并打印"按钮</li>
                <li>打印完成后,临时文件会自动删除</li>
            </ol>

            <div class="server-info">
                <div class="info-row">
                    <strong><i class="fas fa-network-wired"></i> 服务器地址:</strong>
                    <span id="serverAddress">获取中...</span>
                </div>
                <div class="info-row">
                    <strong><i class="fas fa-desktop"></i> 系统:</strong>
                    <span id="systemInfo">检测中...</span>
                </div>
                <div class="info-row">
                    <strong><i class="fas fa-print"></i> 打印机状态:</strong>
                    <span id="printerStatus">未知</span>
                </div>
            </div>

            <div class="settings-manage">
                <h4><i class="fas fa-cog"></i> 设置管理</h4>
                <div class="settings-actions">
                    <button id="showSettings" class="secondary-btn">
                        <i class="fas fa-eye"></i> 查看当前设置
                    </button>
                    <button id="clearSettings" class="secondary-btn">
                        <i class="fas fa-broom"></i> 清除保存的设置
                    </button>
                </div>
                <p class="settings-note">
                    <i class="fas fa-info-circle"></i>
                    您的打印设置(打印机和份数)会自动保存到浏览器中,刷新页面后仍然有效。
                </p>
            </div>

            <div class="troubleshooting">
                <h4><i class="fas fa-tools"></i> 常见问题解决</h4>
                <ul>
                    <li><strong>Windows用户:</strong> 请确保已安装SumatraPDF,或修改代码使用其他PDF打印工具</li>
                    <li><strong>打印机未列出:</strong> 确保打印机已连接并安装驱动</li>
                    <li><strong>打印失败:</strong> 检查控制台日志,确认打印机状态</li>
                    <li><strong>设置不保存:</strong> 检查浏览器是否禁用了localStorage</li>
                </ul>
            </div>
        </div>

        <div class="footer">
            <p>PDF打印服务器 v1.3 | 移动端优化版</p>
            <p>Powered by FastAPI & Python</p>
            <p><i class="fas fa-save"></i> 设置自动保存功能已启用</p>
            <p><i class="fas fa-mobile-alt"></i> 移动端优化版本</p>
        </div>
    </div>

    <script>
        document.addEventListener('DOMContentLoaded', function () {
            // 获取DOM元素
            const pdfFileInput = document.getElementById('pdfFile');
            const fileNameSpan = document.getElementById('fileName');
            const printerSelect = document.getElementById('printerSelect');
            const refreshPrintersBtn = document.getElementById('refreshPrinters');
            const copiesInput = document.getElementById('copies');
            const uploadBtn = document.getElementById('uploadBtn');
            const uploadProgress = document.getElementById('uploadProgress');
            const progressFill = document.querySelector('.progress-fill');
            const messageDiv = document.getElementById('message');
            const taskStatusDiv = document.getElementById('taskStatus');
            const serverAddressSpan = document.getElementById('serverAddress');
            const systemInfoSpan = document.getElementById('systemInfo');
            const printerStatusSpan = document.getElementById('printerStatus');
            const clearCompletedBtn = document.getElementById('clearCompleted');
            const refreshTasksBtn = document.getElementById('refreshTasks');
            const showSettingsBtn = document.getElementById('showSettings');
            const clearSettingsBtn = document.getElementById('clearSettings');

            // 设置服务器信息
            const serverUrl = `${window.location.protocol}//${window.location.hostname}:${window.location.port}`;
            serverAddressSpan.textContent = serverUrl;

            // 存储任务列表
            let tasks = [];

            // 设置键名
            const SETTINGS_KEY = 'pdfPrintSettings';

            // 保存设置到localStorage
            function saveSettings() {
                const settings = {
                    printer: printerSelect.value,
                    copies: copiesInput.value,
                    lastSaved: new Date().toISOString()
                };
                localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
                console.log('设置已保存:', settings);
            }

            // 从localStorage加载设置
            function loadSettings() {
                try {
                    const savedSettings = localStorage.getItem(SETTINGS_KEY);
                    if (savedSettings) {
                        const settings = JSON.parse(savedSettings);
                        console.log('加载保存的设置:', settings);
                        return settings;
                    }
                } catch (error) {
                    console.error('加载设置失败:', error);
                }
                return null;
            }

            // 清除保存的设置
            function clearSettings() {
                localStorage.removeItem(SETTINGS_KEY);
                showMessage('已清除保存的设置', 'info');
                console.log('设置已清除');
            }

            // 应用保存的设置
            function applySavedSettings(settings) {
                if (settings) {
                    // 保存的打印机会在打印机列表加载后设置
                    // 保存的份数立即设置
                    if (settings.copies) {
                        copiesInput.value = settings.copies;
                    }
                }
            }

            // 获取系统信息
            async function fetchSystemInfo() {
                try {
                    const response = await fetch('/api/health');
                    const data = await response.json();
                    systemInfoSpan.textContent = data.system;

                    // 检查上传目录
                    if (data.upload_dir_exists) {
                        printerStatusSpan.textContent = '就绪';
                        printerStatusSpan.style.color = 'var(--success-color)';
                    } else {
                        printerStatusSpan.textContent = '上传目录缺失';
                        printerStatusSpan.style.color = 'var(--error-color)';
                    }
                } catch (error) {
                    systemInfoSpan.textContent = '未知';
                    printerStatusSpan.textContent = '连接失败';
                    printerStatusSpan.style.color = 'var(--error-color)';
                }
            }

            // 获取可用打印机列表
            async function fetchPrinters(showLoading = true) {
                if (showLoading) {
                    printerSelect.innerHTML = '<option value="">加载中...</option>';
                    printerSelect.disabled = true;
                    refreshPrintersBtn.disabled = true;
                }

                try {
                    const response = await fetch('/api/printers');
                    const data = await response.json();

                    printerSelect.innerHTML = '';

                    if (data.printers && data.printers.length > 0) {
                        data.printers.forEach(printer => {
                            const option = document.createElement('option');
                            option.value = printer;
                            option.textContent = printer;
                            printerSelect.appendChild(option);
                        });

                        // 尝试应用保存的打印机设置
                        const savedSettings = loadSettings();
                        if (savedSettings && savedSettings.printer) {
                            // 检查保存的打印机是否在当前列表中
                            const printerExists = data.printers.some(p => p === savedSettings.printer);
                            if (printerExists) {
                                printerSelect.value = savedSettings.printer;
                                showMessage(`已恢复打印机: ${savedSettings.printer}`, 'info');
                            }
                        }

                        printerStatusSpan.textContent = `找到 ${data.printers.length} 台打印机`;
                        printerStatusSpan.style.color = 'var(--success-color)';
                    } else {
                        const option = document.createElement('option');
                        option.value = '';
                        option.textContent = '未检测到打印机';
                        printerSelect.appendChild(option);

                        printerStatusSpan.textContent = '未找到打印机';
                        printerStatusSpan.style.color = 'var(--warning-color)';
                    }

                    printerSelect.disabled = false;
                    refreshPrintersBtn.disabled = false;

                } catch (error) {
                    console.error('获取打印机失败:', error);
                    printerSelect.innerHTML = '<option value="">错误</option>';
                    printerSelect.disabled = false;
                    refreshPrintersBtn.disabled = false;

                    printerStatusSpan.textContent = '获取失败';
                    printerStatusSpan.style.color = 'var(--error-color)';

                    showMessage('无法获取打印机列表: ' + error.message, 'error');
                }
            }

            // 文件选择处理
            pdfFileInput.addEventListener('change', function () {
                if (this.files.length > 0) {
                    const file = this.files[0];
                    fileNameSpan.textContent = file.name;

                    // 验证文件类型和大小
                    if (!file.name.toLowerCase().endsWith('.pdf')) {
                        showMessage('请只选择PDF文件', 'error');
                        this.value = '';
                        fileNameSpan.textContent = '未选择文件';
                        return;
                    }

                    if (file.size > 10 * 1024 * 1024) {
                        showMessage('文件大小不能超过10MB', 'error');
                        this.value = '';
                        fileNameSpan.textContent = '未选择文件';
                        return;
                    }

                    showMessage(`已选择: ${file.name} (${formatFileSize(file.size)})`, 'info');
                } else {
                    fileNameSpan.textContent = '未选择文件';
                }
            });

            // 打印机选择变化时保存设置
            printerSelect.addEventListener('change', function () {
                if (this.value) {
                    saveSettings();
                }
            });

            // 份数变化时保存设置
            copiesInput.addEventListener('change', function () {
                if (this.value) {
                    saveSettings();
                }
            });

            // 文件大小格式化
            function formatFileSize(bytes) {
                if (bytes === 0) return '0 Bytes';
                const k = 1024;
                const sizes = ['Bytes', 'KB', 'MB', 'GB'];
                const i = Math.floor(Math.log(bytes) / Math.log(k));
                return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
            }

            // 上传文件
            async function uploadFile() {
                const file = pdfFileInput.files[0];
                if (!file) {
                    showMessage('请先选择PDF文件', 'error');
                    return;
                }

                const printer = printerSelect.value;
                if (!printer) {
                    showMessage('请选择打印机', 'error');
                    return;
                }

                const copies = parseInt(copiesInput.value) || 1;

                // 准备表单数据
                const formData = new FormData();
                formData.append('file', file);
                formData.append('printer', printer);
                formData.append('copies', copies);

                // 显示进度条
                uploadProgress.style.display = 'block';
                progressFill.style.width = '0%';
                uploadBtn.disabled = true;
                uploadBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 上传中...';
                hideMessage();

                // 保存设置(在上传前)
                saveSettings();

                try {
                    // 使用XMLHttpRequest以获取上传进度
                    const xhr = new XMLHttpRequest();

                    // 进度事件
                    xhr.upload.addEventListener('progress', (e) => {
                        if (e.lengthComputable) {
                            const percent = (e.loaded / e.total) * 100;
                            progressFill.style.width = `${percent}%`;
                        }
                    });

                    // 完成事件
                    xhr.addEventListener('load', () => {
                        if (xhr.status === 200) {
                            const response = JSON.parse(xhr.responseText);
                            showMessage(`文件已提交打印! 任务ID: ${response.task_id}`, 'success');

                            // 添加任务到状态列表
                            addTaskToList({
                                id: response.task_id,
                                filename: file.name,
                                printer: printer,
                                copies: copies,
                                status: response.status
                            });

                            // 清空文件输入
                            pdfFileInput.value = '';
                            fileNameSpan.textContent = '未选择文件';
                            // 注意:不重置打印机和份数,保持用户的设置
                        } else {
                            const error = JSON.parse(xhr.responseText);
                            showMessage(`上传失败: ${error.detail || '服务器错误'}`, 'error');
                        }
                        resetUploadButton();
                    });

                    // 错误事件
                    xhr.addEventListener('error', () => {
                        showMessage('网络错误,请检查连接', 'error');
                        resetUploadButton();
                    });

                    // 发送请求
                    xhr.open('POST', '/api/upload');
                    xhr.send(formData);

                } catch (error) {
                    showMessage('上传出错: ' + error.message, 'error');
                    resetUploadButton();
                }
            }

            function resetUploadButton() {
                uploadBtn.disabled = false;
                uploadBtn.innerHTML = '<i class="fas fa-cloud-upload-alt"></i> 上传并打印';
                uploadProgress.style.display = 'none';
                progressFill.style.width = '0%';
            }

            function showMessage(text, type) {
                messageDiv.textContent = text;
                messageDiv.className = `message ${type}`;
            }

            function hideMessage() {
                messageDiv.style.display = 'none';
            }

            function addTaskToList(task) {
                // 移除空状态提示
                if (taskStatusDiv.querySelector('.empty-state')) {
                    taskStatusDiv.innerHTML = '';
                }

                // 添加到任务数组
                tasks.push(task);

                // 创建任务元素
                const taskElement = document.createElement('div');
                taskElement.className = 'task-item';
                taskElement.id = `task-${task.id}`;

                const statusClass = `status-${task.status}`;
                let statusText = '';
                let statusIcon = '';

                switch (task.status) {
                    case 'queued':
                        statusText = '排队中';
                        statusIcon = '<i class="fas fa-clock"></i>';
                        break;
                    case 'processing':
                        statusText = '处理中';
                        statusIcon = '<i class="fas fa-cog fa-spin"></i>';
                        break;
                    case 'completed':
                        statusText = '已完成';
                        statusIcon = '<i class="fas fa-check-circle"></i>';
                        break;
                    case 'failed':
                        statusText = '失败';
                        statusIcon = '<i class="fas fa-exclamation-circle"></i>';
                        break;
                    default:
                        statusText = task.status;
                        statusIcon = '<i class="fas fa-question-circle"></i>';
                }

                taskElement.innerHTML = `
            <div class="task-info">
                <div class="task-filename"><i class="fas fa-file-pdf"></i> ${task.filename}</div>
                <div class="task-details">
                    <span><i class="fas fa-print"></i> ${task.printer}</span>
                    <span><i class="fas fa-copy"></i> ${task.copies}份</span>
                </div>
            </div>
            <div class="task-status ${statusClass}">
                ${statusIcon} ${statusText}
            </div>
        `;

                // 添加到顶部
                taskStatusDiv.insertBefore(taskElement, taskStatusDiv.firstChild);

                // 如果任务还在进行中,开始状态检查
                if (task.status === 'queued' || task.status === 'processing') {
                    setTimeout(() => checkTaskStatus(task.id), 3000);
                }
            }

            async function checkTaskStatus(taskId) {
                try {
                    const response = await fetch(`/api/tasks/${taskId}`);
                    const task = await response.json();

                    // 更新任务数组
                    const taskIndex = tasks.findIndex(t => t.id === taskId);
                    if (taskIndex !== -1) {
                        tasks[taskIndex] = { ...tasks[taskIndex], ...task };
                    }

                    const taskElement = document.getElementById(`task-${taskId}`);
                    if (taskElement) {
                        const statusElement = taskElement.querySelector('.task-status');
                        const statusClass = `status-${task.status}`;
                        let statusText = '';
                        let statusIcon = '';

                        switch (task.status) {
                            case 'queued':
                                statusText = '排队中';
                                statusIcon = '<i class="fas fa-clock"></i>';
                                break;
                            case 'processing':
                                statusText = '处理中';
                                statusIcon = '<i class="fas fa-cog fa-spin"></i>';
                                break;
                            case 'completed':
                                statusText = '已完成';
                                statusIcon = '<i class="fas fa-check-circle"></i>';
                                break;
                            case 'failed':
                                statusText = '失败';
                                statusIcon = '<i class="fas fa-exclamation-circle"></i>';
                                break;
                            default:
                                statusText = task.status;
                                statusIcon = '<i class="fas fa-question-circle"></i>';
                        }

                        statusElement.className = `task-status ${statusClass}`;
                        statusElement.innerHTML = `${statusIcon} ${statusText}`;

                        // 如果任务还在进行中,继续检查
                        if (task.status === 'queued' || task.status === 'processing') {
                            setTimeout(() => checkTaskStatus(taskId), 3000);
                        }
                    }
                } catch (error) {
                    console.error('获取任务状态失败:', error);
                }
            }

            // 清除已完成任务
            function clearCompletedTasks() {
                const completedTasks = tasks.filter(t => t.status === 'completed' || t.status === 'failed');

                completedTasks.forEach(task => {
                    const taskElement = document.getElementById(`task-${task.id}`);
                    if (taskElement) {
                        taskElement.style.opacity = '0';
                        taskElement.style.transform = 'translateX(-20px)';
                        setTimeout(() => {
                            taskElement.remove();
                        }, 300);
                    }
                });

                // 更新任务数组
                tasks = tasks.filter(t => t.status !== 'completed' && t.status !== 'failed');

                // 如果没有任务了,显示空状态
                if (tasks.length === 0) {
                    taskStatusDiv.innerHTML = `
                <div class="empty-state">
                    <i class="fas fa-inbox fa-3x"></i>
                    <p>暂无任务</p>
                    <small>上传文件后,任务将显示在这里</small>
                </div>
            `;
                }

                showMessage(`已清除 ${completedTasks.length} 个任务`, 'info');
            }

            // 刷新所有任务状态
            function refreshAllTasks() {
                tasks.forEach(task => {
                    if (task.status === 'queued' || task.status === 'processing') {
                        checkTaskStatus(task.id);
                    }
                });
                showMessage('任务状态已刷新', 'info');
            }

            // 显示当前保存的设置
            function showSavedSettingsInfo() {
                const settings = loadSettings();
                if (settings) {
                    showMessage(`已保存设置 - 打印机: ${settings.printer || '未设置'}, 份数: ${settings.copies || '未设置'}`, 'info');
                } else {
                    showMessage('当前没有保存的设置', 'info');
                }
            }

            // 事件监听
            uploadBtn.addEventListener('click', uploadFile);
            refreshPrintersBtn.addEventListener('click', () => fetchPrinters(true));
            clearCompletedBtn.addEventListener('click', clearCompletedTasks);
            refreshTasksBtn.addEventListener('click', refreshAllTasks);
            showSettingsBtn.addEventListener('click', showSavedSettingsInfo);
            clearSettingsBtn.addEventListener('click', clearSettings);

            // 页面加载时获取打印机列表和系统信息,并应用保存的设置
            fetchPrinters(false).then(() => {
                // 在打印机列表加载后,应用保存的设置
                const savedSettings = loadSettings();
                if (savedSettings) {
                    applySavedSettings(savedSettings);
                }
            });
            fetchSystemInfo();

            // 每30秒刷新一次打印机列表
            setInterval(() => fetchPrinters(false), 30000);
        });

    </script>
</body>

</html>

FastApi代码:

python 复制代码
import os
import uuid
import subprocess
import platform
import logging
import time
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pathlib import Path
from typing import Optional
import uvicorn
import sys
from concurrent.futures import ThreadPoolExecutor
import threading

# 配置日志
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

app = FastAPI(title="PDF打印服务器", version="1.3.0")

# 创建必要的目录
UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)

# 挂载静态文件目录
app.mount("/static", StaticFiles(directory="static"), name="static")

# 全局状态存储任务信息
tasks = {}
task_lock = threading.Lock()

# 线程池用于异步执行打印任务
executor = ThreadPoolExecutor(max_workers=3)

def get_system_printers():
    """获取系统可用的打印机列表"""
    system = platform.system()
    printers = []

    try:
        if system == "Windows":
            import win32print
            # 获取本地打印机
            printers_info = win32print.EnumPrinters(win32print.PRINTER_ENUM_LOCAL)
            printers = [p[2] for p in printers_info]

            # 获取网络打印机(如果需要)
            try:
                network_printers = win32print.EnumPrinters(win32print.PRINTER_ENUM_CONNECTIONS)
                network_printer_names = [p[2] for p in network_printers]
                printers.extend(network_printer_names)
            except:
                pass

        elif system == "Linux":
            # 使用lpstat命令获取打印机
            result = subprocess.run(["lpstat", "-p"], capture_output=True, text=True, timeout=5)
            for line in result.stdout.splitlines():
                if line.startswith("printer"):
                    printer_name = line.split()[1]
                    # 移除"打印机状态"等后缀
                    if "disabled" in line:
                        printer_name = printer_name + " (已禁用)"
                    printers.append(printer_name)

        elif system == "Darwin":  # macOS
            result = subprocess.run(["lpstat", "-p"], capture_output=True, text=True, timeout=5)
            for line in result.stdout.splitlines():
                if "printer" in line:
                    printer_name = line.split()[1]
                    printers.append(printer_name)

    except Exception as e:
        logger.error(f"获取打印机列表失败: {e}")
        # 返回默认选项
        return ["默认打印机"]

    # 去重并排序
    printers = sorted(list(set(printers)))

    # 如果没有找到打印机,添加默认选项
    if not printers:
        printers = ["默认打印机"]

    return printers

def print_pdf(file_path: str, printer_name: str = None, copies: int = 1):
    """打印PDF文件 - 根据官方文档修复打印份数设置"""
    system = platform.system()
    file_path = str(file_path)
    logger.info(f"开始打印: 文件={file_path}, 打印机={printer_name}, 份数={copies}")

    try:
        if system == "Windows":
            # 检查SumatraPDF是否存在
            sumatra_paths = [
                "SumatraPDF.exe",  # 如果在PATH中
                r"C:\Program Files\SumatraPDF\SumatraPDF.exe",
                r"C:\Program Files (x86)\SumatraPDF\SumatraPDF.exe",
                r"C:\Users\Public\Downloads\SumatraPDF.exe"
            ]

            sumatra_exe = None
            for path in sumatra_paths:
                if os.path.exists(path):
                    sumatra_exe = path
                    break

            if not sumatra_exe:
                raise Exception("SumatraPDF未找到,请先安装SumatraPDF")

            # 构建打印命令
            cmd = [sumatra_exe]

            # 处理打印机名称
            if printer_name and printer_name != "默认打印机":
                # 清理打印机名称,移除可能的状态后缀
                clean_printer_name = printer_name.split(" (")[0]
                cmd.extend(["-print-to", clean_printer_name])
                logger.info(f"使用指定打印机: {clean_printer_name}")
            else:
                # 使用默认打印机
                cmd.extend(["-print-to-default"])
                logger.info("使用默认打印机")

            # 根据官方文档修复打印份数设置
            # -print-settings "3x" 表示打印3份
            if copies > 1:
                cmd.extend(["-print-settings", f"{copies}x"])
                logger.info(f"设置打印份数: {copies}x")

            # 静默模式,不显示错误信息
            cmd.append("-silent")
            
            # 添加文件路径
            cmd.append(file_path)

            logger.info(f"执行打印命令: {' '.join(cmd)}")

            # 执行打印
            result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)

            if result.returncode == 0:
                logger.info(f"打印命令执行成功,份数: {copies}")
                return True
            else:
                error_msg = result.stderr or result.stdout
                logger.error(f"打印失败: {error_msg}")
                
                # 尝试备用方案:使用print-dialog显示打印对话框
                logger.info(f"尝试备用方案:显示打印对话框")
                dialog_cmd = [sumatra_exe, "-print-dialog", "-exit-when-done", file_path]
                
                try:
                    dialog_result = subprocess.run(dialog_cmd, capture_output=True, text=True, timeout=30)
                    if dialog_result.returncode == 0:
                        logger.info(f"打印对话框方案成功")
                        return True
                    else:
                        raise Exception(f"打印对话框也失败: {dialog_result.stderr}")
                except Exception as dialog_error:
                    logger.error(f"打印对话框方案失败: {dialog_error}")
                    
                    # 最终方案:多次调用打印命令
                    if copies > 1:
                        logger.info(f"尝试最终方案:分{copies}次打印")
                        success_count = 0
                        for i in range(copies):
                            try:
                                logger.info(f"打印第 {i+1}/{copies} 份")
                                single_copy_cmd = cmd.copy()
                                # 移除份数设置,只打印1份
                                if f"{copies}x" in single_copy_cmd:
                                    idx = single_copy_cmd.index(f"{copies}x")
                                    single_copy_cmd.pop(idx-1)  # 移除-print-settings
                                    single_copy_cmd.pop(idx-1)  # 移除份数值
                                
                                retry_result = subprocess.run(single_copy_cmd, capture_output=True, text=True, timeout=30)
                                if retry_result.returncode == 0:
                                    success_count += 1
                                    time.sleep(2)  # 每次打印间隔2秒,避免打印机过载
                                else:
                                    logger.warning(f"第 {i+1} 份打印失败")
                            except Exception as e:
                                logger.warning(f"第 {i+1} 份打印异常: {e}")
                        
                        if success_count > 0:
                            logger.info(f"最终方案成功打印 {success_count}/{copies} 份")
                            return True
                        else:
                            raise Exception(f"所有打印尝试都失败: {error_msg}")
                    else:
                        raise Exception(f"打印失败: {error_msg}")

        elif system == "Linux":
            # Linux系统使用lp命令
            cmd = ["lp", "-n", str(copies)]

            if printer_name and printer_name != "默认打印机":
                # 清理打印机名称
                clean_printer_name = printer_name.split(" (")[0]
                cmd.extend(["-d", clean_printer_name])
                logger.info(f"使用指定打印机: {clean_printer_name}")
            else:
                logger.info("使用默认打印机")

            cmd.append(file_path)

            logger.info(f"执行命令: {' '.join(cmd)}")

            result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)

            if result.returncode == 0:
                logger.info(f"打印任务已提交,份数: {copies}")
                return True
            else:
                error_msg = result.stderr or result.stdout
                logger.error(f"打印失败: {error_msg}")
                raise Exception(f"打印失败: {error_msg}")

        elif system == "Darwin":  # macOS
            # macOS也使用lp命令
            cmd = ["lp", "-n", str(copies)]

            if printer_name and printer_name != "默认打印机":
                # 清理打印机名称
                clean_printer_name = printer_name.split(" (")[0]
                cmd.extend(["-d", clean_printer_name])
                logger.info(f"使用指定打印机: {clean_printer_name}")
            else:
                logger.info("使用默认打印机")

            cmd.append(file_path)

            logger.info(f"执行命令: {' '.join(cmd)}")

            result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)

            if result.returncode == 0:
                logger.info(f"打印任务已提交,份数: {copies}")
                return True
            else:
                error_msg = result.stderr or result.stdout
                logger.error(f"打印失败: {error_msg}")
                raise Exception(f"打印失败: {error_msg}")

    except subprocess.TimeoutExpired:
        logger.error("打印命令执行超时")
        raise Exception("打印命令执行超时,请检查打印机状态")
    except Exception as e:
        logger.error(f"打印异常: {e}")
        raise

def process_print_task(task_id: str, file_path: Path, printer: str, copies: int, original_filename: str):
    """异步处理打印任务"""
    try:
        logger.info(f"开始处理打印任务 {task_id}, 份数: {copies}")
        
        # 更新任务状态为处理中
        with task_lock:
            tasks[task_id]["status"] = "processing"
            tasks[task_id]["started_at"] = time.strftime("%H:%M:%S")
        
        # 打印文件
        success = print_pdf(file_path, printer, copies)
        
        # 更新任务状态
        with task_lock:
            if success:
                tasks[task_id]["status"] = "completed"
                tasks[task_id]["copies_printed"] = copies
            else:
                tasks[task_id]["status"] = "failed"
                tasks[task_id]["copies_printed"] = 0
            
            tasks[task_id]["completed_at"] = time.strftime("%H:%M:%S")
            tasks[task_id]["success"] = success
        
        # 打印完成后删除文件
        if os.path.exists(file_path):
            try:
                os.remove(file_path)
                logger.info(f"临时文件已删除: {file_path}")
            except Exception as e:
                logger.warning(f"删除临时文件失败: {e}")
            
        logger.info(f"打印任务 {task_id} 完成,状态: {'成功' if success else '失败'}")

    except Exception as e:
        logger.error(f"处理打印任务失败: {e}")
        
        with task_lock:
            tasks[task_id]["status"] = "failed"
            tasks[task_id]["error"] = str(e)
            tasks[task_id]["completed_at"] = time.strftime("%H:%M:%S")
            tasks[task_id]["copies_printed"] = 0
        
        # 确保清理临时文件
        if os.path.exists(file_path):
            try:
                os.remove(file_path)
                logger.info(f"清理临时文件: {file_path}")
            except:
                pass

@app.get("/", response_class=HTMLResponse)
async def root():
    """返回主页面"""
    try:
        with open("static/index.html", "r", encoding="utf-8") as f:
            return HTMLResponse(content=f.read(), status_code=200)
    except FileNotFoundError:
        # 如果静态文件不存在,返回一个简单的重定向页面
        return HTMLResponse(content="""
        <!DOCTYPE html>
        <html>
        <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <title>PDF打印服务器</title>
            <style>
                body { font-family: Arial, sans-serif; margin: 50px; text-align: center; }
                h1 { color: #333; }
                .info { background: #f0f8ff; padding: 20px; border-radius: 10px; margin: 20px auto; max-width: 600px; }
                .error { color: red; font-weight: bold; }
            </style>
        </head>
        <body>
            <h1>PDF打印服务器</h1>
            <div class="info">
                <p class="error">静态文件目录未找到</p>
                <p>请确保已经创建了 static/index.html 文件</p>
                <p>或者直接使用API接口:</p>
                <ul style="text-align: left; display: inline-block;">
                    <li>GET /api/printers - 获取打印机列表</li>
                    <li>POST /api/upload - 上传并打印PDF</li>
                    <li>GET /api/health - 服务器健康检查</li>
                </ul>
            </div>
        </body>
        </html>
        """, status_code=404)

@app.get("/api/printers")
async def get_printers():
    """获取可用打印机列表"""
    try:
        printers = get_system_printers()
        logger.info(f"获取到打印机列表: {printers}")
        return {
            "printers": printers,
            "count": len(printers),
            "system": platform.system(),
            "sumatra_supported": platform.system() == "Windows"
        }
    except Exception as e:
        logger.error(f"获取打印机列表失败: {e}")
        return {
            "printers": ["默认打印机"],
            "error": str(e),
            "system": platform.system()
        }

@app.post("/api/upload")
async def upload_pdf(
    file: UploadFile = File(..., description="PDF文件"),
    printer: Optional[str] = Form(None),
    copies: int = Form(1, ge=1, le=100)
):
    """上传PDF文件并添加到打印队列"""
    # 验证文件类型
    if not file.filename:
        raise HTTPException(status_code=400, detail="未选择文件")

    if not file.filename.lower().endswith(".pdf"):
        raise HTTPException(status_code=400, detail="只支持PDF文件,请上传PDF格式文件")

    # 验证文件大小
    content = await file.read()
    file_size = len(content)
    if file_size > 10 * 1024 * 1024:  # 10MB限制
        raise HTTPException(status_code=400, detail="文件大小不能超过10MB")
    
    # 重置文件指针以便再次读取
    await file.seek(0)

    # 保存文件
    filename = f"{uuid.uuid4().hex}.pdf"
    file_path = UPLOAD_DIR / filename

    try:
        with open(file_path, "wb") as f:
            content = await file.read()
            f.write(content)

        logger.info(f"文件已保存: {file_path}, 原始文件名: {file.filename}, 大小: {file_size}字节, 份数: {copies}")

        # 创建任务ID
        task_id = uuid.uuid4().hex
        with task_lock:
            tasks[task_id] = {
                "id": task_id,
                "status": "queued",
                "filename": file.filename,
                "printer": printer or "默认打印机",
                "copies": copies,
                "file_size": file_size,
                "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
                "original_filename": file.filename
            }

        # 异步执行打印任务
        executor.submit(process_print_task, task_id, file_path, printer, copies, file.filename)

        return {
            "task_id": task_id,
            "status": "queued",
            "filename": file.filename,
            "printer": printer or "默认打印机",
            "copies": copies,
            "message": f"文件已上传,正在排队打印 {copies} 份"
        }

    except Exception as e:
        logger.error(f"处理上传文件失败: {e}")
        # 确保清理临时文件
        if os.path.exists(file_path):
            try:
                os.remove(file_path)
            except:
                pass
        raise HTTPException(status_code=500, detail=f"处理文件失败: {str(e)}")

@app.get("/api/tasks/{task_id}")
async def get_task_status(task_id: str):
    """获取任务状态"""
    if task_id not in tasks:
        raise HTTPException(status_code=404, detail="任务不存在")
    
    with task_lock:
        task_info = tasks[task_id].copy()
    
    return task_info

@app.get("/api/tasks")
async def get_all_tasks():
    """获取所有任务"""
    with task_lock:
        # 返回最近的任务,按创建时间倒序
        all_tasks = list(tasks.values())
        sorted_tasks = sorted(all_tasks, key=lambda x: x.get("created_at", ""), reverse=True)
        return {
            "tasks": sorted_tasks,
            "total": len(sorted_tasks),
            "queued": len([t for t in sorted_tasks if t.get("status") == "queued"]),
            "processing": len([t for t in sorted_tasks if t.get("status") == "processing"]),
            "completed": len([t for t in sorted_tasks if t.get("status") == "completed"]),
            "failed": len([t for t in sorted_tasks if t.get("status") == "failed"])
        }

@app.delete("/api/tasks/{task_id}")
async def delete_task(task_id: str):
    """删除任务"""
    with task_lock:
        if task_id in tasks:
            del tasks[task_id]
            return {"message": "任务已删除", "task_id": task_id}
        else:
            raise HTTPException(status_code=404, detail="任务不存在")

@app.delete("/api/tasks")
async def clear_completed_tasks():
    """清除已完成和失败的任务"""
    with task_lock:
        tasks_to_delete = []
        for task_id, task_info in list(tasks.items()):
            if task_info.get("status") in ["completed", "failed"]:
                tasks_to_delete.append(task_id)
        
        for task_id in tasks_to_delete:
            del tasks[task_id]
        
        return {
            "message": f"已清除 {len(tasks_to_delete)} 个任务",
            "cleared": tasks_to_delete,
            "remaining": len(tasks)
        }

@app.get("/api/health")
async def health_check():
    """健康检查端点"""
    try:
        # 测试打印机列表获取
        printers = get_system_printers()
        
        # 检查上传目录
        upload_dir_exists = UPLOAD_DIR.exists()
        
        # 检查目录写入权限
        test_file = UPLOAD_DIR / ".test_write"
        try:
            test_file.touch()
            can_write = True
            test_file.unlink()
        except:
            can_write = False
        
        # 检查SumatraPDF是否存在(仅Windows)
        sumatra_exists = False
        if platform.system() == "Windows":
            sumatra_paths = [
                "SumatraPDF.exe",
                r"C:\Program Files\SumatraPDF\SumatraPDF.exe",
                r"C:\Program Files (x86)\SumatraPDF\SumatraPDF.exe",
            ]
            for path in sumatra_paths:
                if os.path.exists(path):
                    sumatra_exists = True
                    break
        
        return {
            "status": "healthy",
            "system": platform.system(),
            "python_version": sys.version.split()[0],
            "upload_dir_exists": upload_dir_exists,
            "can_write": can_write,
            "printers_count": len(printers),
            "tasks_count": len(tasks),
            "sumatra_installed": sumatra_exists,
            "server_time": time.strftime("%Y-%m-%d %H:%M:%S"),
            "version": "1.3.0",
            "features": {
                "copies_support": True,
                "async_printing": True,
                "multiple_fallback": True
            }
        }
    except Exception as e:
        return {
            "status": "error",
            "error": str(e),
            "system": platform.system()
        }

@app.get("/api/sumatra-test")
async def test_sumatra():
    """测试SumatraPDF安装和配置"""
    if platform.system() != "Windows":
        return {"supported": False, "message": "此功能仅支持Windows系统"}
    
    try:
        sumatra_paths = [
            "SumatraPDF.exe",
            r"C:\Program Files\SumatraPDF\SumatraPDF.exe",
            r"C:\Program Files (x86)\SumatraPDF\SumatraPDF.exe",
        ]
        
        sumatra_exe = None
        for path in sumatra_paths:
            if os.path.exists(path):
                sumatra_exe = path
                break
        
        if not sumatra_exe:
            return {
                "installed": False,
                "message": "SumatraPDF未安装",
                "download_url": "https://www.sumatrapdfreader.org/download-free-pdf-viewer.html"
            }
        
        # 测试版本
        result = subprocess.run([sumatra_exe, "-version"], capture_output=True, text=True, timeout=5)
        
        return {
            "installed": True,
            "path": sumatra_exe,
            "version": result.stdout.strip() if result.returncode == 0 else "未知版本",
            "print_options": {
                "copies": "使用 -print-settings 'Nx' 格式,如 '3x' 表示3份",
                "printer": "使用 -print-to '打印机名称' 或 -print-to-default",
                "silent": "-silent 参数用于静默打印",
                "dialog": "-print-dialog -exit-when-done 用于显示打印对话框"
            }
        }
    except Exception as e:
        return {"installed": False, "error": str(e)}

if __name__ == "__main__":
    # 获取本地IP地址
    import socket
    try:
        hostname = socket.gethostname()
        local_ip = socket.gethostbyname(hostname)
    except:
        local_ip = "127.0.0.1"

    print("=" * 60)
    print("PDF打印服务器启动 - SumatraPDF修复版")
    print("=" * 60)
    print(f"本地访问: http://localhost:8083")
    print(f"局域网访问: http://{local_ip}:8083")
    print(f"服务器地址: {local_ip}")
    print(f"系统: {platform.system()}")
    print("=" * 60)
    print("重要更新:")
    print("1. 根据官方文档修复打印份数设置: -print-settings 'Nx'")
    print("2. 新增打印对话框备用方案")
    print("3. 增加SumatraPDF测试接口")
    print("=" * 60)
    print("SumatraPDF打印选项:")
    print("  -print-to '打印机名'     # 指定打印机")
    print("  -print-to-default        # 默认打印机")
    print("  -print-settings '3x'     # 打印3份 (根据文档)")
    print("  -silent                  # 静默模式")
    print("  -print-dialog            # 显示打印对话框")
    print("=" * 60)

    # 确保uploads目录存在
    UPLOAD_DIR.mkdir(exist_ok=True)
    
    # 清理旧的临时文件(超过1小时)
    try:
        now = time.time()
        for file in UPLOAD_DIR.glob("*.pdf"):
            if now - file.stat().st_mtime > 3600:  # 1小时
                file.unlink()
                logger.info(f"清理旧文件: {file}")
    except Exception as e:
        logger.warning(f"清理旧文件失败: {e}")

    uvicorn.run(app, host="0.0.0.0", port=8083, log_level="info")
相关推荐
戌中横10 小时前
JavaScript——Web APIs DOM
前端·javascript·html
ghgxm52011 小时前
Fastapi_00_学习方向 ——无编程基础如何用AI实现APP生成
人工智能·学习·fastapi
曲幽11 小时前
FastAPI多进程部署:定时任务重复执行?手把手教你用锁搞定
redis·python·fastapi·web·lock·works
yinmaisoft13 小时前
JNPF 表单模板实操:高效复用表单设计指南
前端·javascript·html
快乐点吧17 小时前
使用 data-属性和 CSS 属性选择器实现状态样式控制
前端·css
东华果汁哥21 小时前
【机器视觉 行人检测算法】FastAPI 部署 YOLO 11行人检测 API 服务教程
算法·yolo·fastapi
芳草萋萋鹦鹉洲哦1 天前
【Vue 3/Vite】Tailwind CSS稳定版安装替代CDN引入
前端·css·vue.js
_OP_CHEN1 天前
【前端开发之CSS】(二)CSS 选择器终极指南:从基础到进阶,精准拿捏页面元素!
前端·css·html·网页开发·css选择器
ヤ鬧鬧o.1 天前
HTML安全密码备忘录
前端·javascript·css·html·css3