+
32
-

回答

可以通过http的方式对外提供本地的打印服务,比如局域网中,通过python暴露一个网页,打开网页上传文档,点击打印,python调用win32print或pycups(linux)实现文档打印。

由于Windows自带的打印功能对PDF、Word等复杂文档支持不佳。最稳妥的方法是使用一个支持命令行打印的程序。SumatraPDF 是一个极佳的选择,它轻量、免费、启动快,且有强大的命令行接口。

下载并安装 SumatraPDF: https://www.sumatrapdfreader.org/free-pdf-reader

记下它的安装路径,例如 C:\Program Files\SumatraPDF\SumatraPDF.exe。我们将在代码中使用这个路径。

python后端代码

# app.py
import os
import sqlite3
import random
import string
import threading
import time
import subprocess
from flask import Flask, request, jsonify, render_template
from werkzeug.utils import secure_filename

# --- 配置 ---
# 警告:在生产环境中,请使用更安全的密钥
APP_SECRET_KEY = 'your-super-secret-key' 
UPLOAD_FOLDER = 'uploads'
ALLOWED_EXTENSIONS = {'txt', 'pdf', 'png', 'jpg', 'jpeg', 'docx', 'xlsx'}
DATABASE = 'printer.db'

# --- 关键:配置你的打印机和打印程序 ---
# 1. 在Windows "打印机和扫描仪" 设置中找到你的打印机确切名称
PRINTER_NAME = "Microsoft Print to PDF" # <-- 替换成你的真实打印机名称, 如 "HP LaserJet Pro M102w"
# 2. (推荐) SumatraPDF 的路径,用于打印PDF等复杂文件
SUMATRA_PDF_PATH = "C:\\Program Files\\SumatraPDF\\SumatraPDF.exe" # <-- 替换成你的SumatraPDF.exe路径

# --- Flask应用初始化 ---
app = Flask(__name__, template_folder='.')
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
app.config['SECRET_KEY'] = APP_SECRET_KEY

# --- 数据库辅助函数 ---
def get_db():
    db = sqlite3.connect(DATABASE)
    db.row_factory = sqlite3.Row
    return db

def init_db():
    """初始化数据库,创建表结构"""
    if os.path.exists(DATABASE):
        return
    print("Creating database...")
    db = get_db()
    with db:
        db.execute('''
            CREATE TABLE print_jobs (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                original_filename TEXT NOT NULL,
                stored_filepath TEXT NOT NULL,
                pickup_code TEXT UNIQUE NOT NULL,
                status TEXT NOT NULL, -- 'pending', 'printing', 'printed', 'completed', 'error'
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            )
        ''')
    print("Database created.")

def allowed_file(filename):
    return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS

def generate_pickup_code(length=6):
    """生成一个唯一的、纯数字的提取码"""
    while True:
        code = ''.join(random.choices(string.digits, k=length))
        db = get_db()
        exists = db.execute('SELECT id FROM print_jobs WHERE pickup_code = ?', (code,)).fetchone()
        db.close()
        if not exists:
            return code

def print_document_task(job_id, filepath, printer_name):
    """在后台线程中执行的打印任务"""
    db = get_db()
    try:
        print(f"Job {job_id}: Starting to print file {filepath} on printer {printer_name}")
        db.execute('UPDATE print_jobs SET status = ? WHERE id = ?', ('printing', job_id))
        db.commit()

        # 使用 SumatraPDF 命令行进行打印 (推荐,支持多种格式)
        # -print-to "printer_name": 指定打印机
        # -silent: 静默模式,不显示任何UI
        # -exit-on-print: 打印后自动退出
        if os.path.exists(SUMATRA_PDF_PATH):
            command = [
                SUMATRA_PDF_PATH,
                '-print-to', printer_name,
                '-silent',
                '-exit-on-print',
                filepath
            ]
            subprocess.run(command, check=True, timeout=120) # 120秒超时
        else:
            # 备用方案: 使用Windows shell命令 (仅对某些文件类型有效)
            # import win32api
            # win32api.ShellExecute(0, "print", filepath, f'"{printer_name}"', ".", 0)
            # 这行代码更简单,但兼容性较差,推荐使用SumatraPDF
            raise FileNotFoundError(f"SumatraPDF not found at {SUMATRA_PDF_PATH}. Printing failed.")

        time.sleep(5) # 留出一点时间让文件进入打印机队列

        print(f"Job {job_id}: Print command sent successfully.")
        db.execute('UPDATE print_jobs SET status = ? WHERE id = ?', ('printed', job_id))
        db.commit()

    except Exception as e:
        print(f"Error printing job {job_id}: {e}")
        db.execute('UPDATE print_jobs SET status = ? WHERE id = ?', ('error', job_id))
        db.commit()
    finally:
        db.close()
        # 打印完成后,无论成功与否,都删除临时文件
        try:
            os.remove(filepath)
            print(f"Job {job_id}: Temporary file {filepath} deleted.")
        except OSError as e:
            print(f"Error deleting file {filepath}: {e}")

# --- API 路由 ---

@app.route('/')
def index():
    """主页,渲染HTML"""
    return render_template('index.html')

@app.route('/api/upload', methods=['POST'])
def upload_file():
    """处理文件上传和创建打印任务"""
    if 'file' not in request.files:
        return jsonify({'error': 'No file part'}), 400
    file = request.files['file']
    if file.filename == '':
        return jsonify({'error': 'No selected file'}), 400
    
    if file and allowed_file(file.filename):
        filename = secure_filename(file.filename)
        # 使用时间戳确保文件名唯一,避免覆盖
        unique_filename = f"{int(time.time())}_{filename}"
        filepath = os.path.join(app.config['UPLOAD_FOLDER'], unique_filename)
        file.save(filepath)

        pickup_code = generate_pickup_code()

        db = get_db()
        cursor = db.cursor()
        cursor.execute(
            'INSERT INTO print_jobs (original_filename, stored_filepath, pickup_code, status) VALUES (?, ?, ?, ?)',
            (filename, filepath, pickup_code, 'pending')
        )
        job_id = cursor.lastrowid
        db.commit()
        db.close()

        # 创建并启动一个新线程来处理打印,防止阻塞Web请求
        print_thread = threading.Thread(target=print_document_task, args=(job_id, filepath, PRINTER_NAME))
        print_thread.start()

        return jsonify({'message': 'File uploaded successfully', 'pickup_code': pickup_code})
    
    return jsonify({'error': 'File type not allowed'}), 400

@app.route('/api/pickup', methods=['POST'])
def pickup_file():
    """核销提取码"""
    data = request.get_json()
    code = data.get('code')
    if not code:
        return jsonify({'error': 'Pickup code is required'}), 400

    db = get_db()
    job = db.execute('SELECT id, status, original_filename FROM print_jobs WHERE pickup_code = ?', (code,)).fetchone()

    if not job:
        db.close()
        return jsonify({'error': 'Invalid pickup code'}), 404

    if job['status'] == 'printed':
        db.execute('UPDATE print_jobs SET status = ? WHERE id = ?', ('completed', job['id']))
        db.commit()
        db.close()
        return jsonify({'success': True, 'message': f"Verification successful for file: {job['original_filename']}"})
    elif job['status'] in ['pending', 'printing']:
        db.close()
        return jsonify({'error': 'Document is still printing. Please wait.'}), 425 # Too Early
    elif job['status'] == 'completed':
        db.close()
        return jsonify({'error': 'This code has already been used.'}), 410 # Gone
    else: # error
        db.close()
        return jsonify({'error': 'An error occurred while printing this document.'}), 500

# --- 主程序入口 ---
if __name__ == '__main__':
    # 确保上传目录存在
    if not os.path.exists(UPLOAD_FOLDER):
        os.makedirs(UPLOAD_FOLDER)
    
    # 初始化数据库
    init_db()
    
    # 启动Flask应用
    # host='0.0.0.0' 使其在局域网内可访问
    app.run(host='0.0.0.0', port=5000, debug=True)

前端h5

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>自助共享打印</title>
    <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
    <style>
        :root {
            --bg-color: #f0f2f5;
            --card-bg: #ffffff;
            --text-color: #333;
            --primary-color: #007bff;
            --primary-hover: #0056b3;
            --border-color: #e0e0e0;
            --success-color: #28a745;
            --error-color: #dc3545;
            --gray-text: #6c757d;
        }

        /* 暗黑模式 */
        @media (prefers-color-scheme: dark) {
            :root {
                --bg-color: #1a1a1a;
                --card-bg: #2c2c2c;
                --text-color: #e0e0e0;
                --primary-color: #3b82f6;
                --primary-hover: #2563eb;
                --border-color: #444;
            }
        }

        * {
            box-sizing: border-box;
            margin: 0;
            padding: 0;
        }

        body {
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
            background-color: var(--bg-color);
            color: var(--text-color);
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
            transition: background-color 0.3s, color 0.3s;
        }

        #app {
            width: 100%;
            max-width: 500px;
            padding: 2rem;
        }

        .main-card {
            background-color: var(--card-bg);
            border-radius: 16px;
            box-shadow: 0 8px 30px rgba(0,0,0,0.1);
            padding: 2.5rem;
            transition: background-color 0.3s;
        }
        
        .header {
            text-align: center;
            margin-bottom: 2rem;
        }

        .header .icon {
            width: 48px;
            height: 48px;
            color: var(--primary-color);
            margin-bottom: 1rem;
        }

        .header h1 {
            font-size: 1.75rem;
            font-weight: 600;
        }

        .tabs {
            display: flex;
            margin-bottom: 2rem;
            border-radius: 8px;
            background-color: var(--bg-color);
            padding: 4px;
        }
        
        .tab {
            flex: 1;
            padding: 0.75rem;
            text-align: center;
            cursor: pointer;
            border-radius: 6px;
            font-weight: 500;
            transition: background-color 0.2s, color 0.2s;
            color: var(--gray-text);
        }

        .tab.active {
            background-color: var(--card-bg);
            color: var(--primary-color);
            box-shadow: 0 2px 4px rgba(0,0,0,0.05);
        }

        .upload-area {
            border: 2px dashed var(--border-color);
            border-radius: 12px;
            padding: 3rem 1.5rem;
            text-align: center;
            cursor: pointer;
            transition: border-color 0.3s, background-color 0.3s;
        }

        .upload-area.drag-over {
            border-color: var(--primary-color);
            background-color: rgba(59, 130, 246, 0.05);
        }

        .upload-area .icon {
            width: 40px;
            height: 40px;
            color: var(--gray-text);
            margin-bottom: 1rem;
        }

        .upload-area p {
            font-size: 1rem;
            color: var(--gray-text);
        }

        .upload-area span {
            color: var(--primary-color);
            font-weight: 500;
        }

        .btn {
            display: block;
            width: 100%;
            background-color: var(--primary-color);
            color: white;
            padding: 0.8rem 1rem;
            border: none;
            border-radius: 8px;
            font-size: 1rem;
            font-weight: 500;
            cursor: pointer;
            transition: background-color 0.2s;
            margin-top: 1.5rem;
        }

        .btn:hover {
            background-color: var(--primary-hover);
        }
        
        .btn:disabled {
            background-color: var(--gray-text);
            cursor: not-allowed;
        }
        
        .processing-view, .success-view {
            text-align: center;
            padding: 2rem 0;
        }

        .processing-view .spinner {
            width: 48px;
            height: 48px;
            border: 4px solid var(--border-color);
            border-top-color: var(--primary-color);
            border-radius: 50%;
            animation: spin 1s linear infinite;
            margin: 0 auto 1.5rem;
        }

        @keyframes spin {
            to { transform: rotate(360deg); }
        }

        .success-view .icon {
            width: 64px;
            height: 64px;
            color: var(--success-color);
            margin-bottom: 1.5rem;
        }
        
        .pickup-code-display {
            font-size: 3.5rem;
            font-weight: bold;
            color: var(--primary-color);
            letter-spacing: 0.5rem;
            margin: 1rem 0 1.5rem;
            background-color: var(--bg-color);
            padding: 1rem;
            border-radius: 8px;
        }
        
        .pickup-form .form-group {
            margin-bottom: 1.5rem;
        }
        
        .pickup-form label {
            display: block;
            margin-bottom: 0.5rem;
            font-weight: 500;
        }

        .pickup-form input {
            width: 100%;
            padding: 0.8rem;
            font-size: 1.5rem;
            text-align: center;
            letter-spacing: 0.5rem;
            border: 1px solid var(--border-color);
            border-radius: 8px;
            background-color: var(--bg-color);
            color: var(--text-color);
        }

        .message {
            margin-top: 1rem;
            padding: 0.8rem;
            border-radius: 8px;
            font-weight: 500;
        }

        .message.success {
            background-color: rgba(40, 167, 69, 0.1);
            color: var(--success-color);
        }

        .message.error {
            background-color: rgba(220, 53, 69, 0.1);
            color: var(--error-color);
        }

        .fade-enter-active, .fade-leave-active {
            transition: opacity 0.3s ease;
        }

        .fade-enter-from, .fade-leave-to {
            opacity: 0;
        }

    </style>
</head>
<body>
    <div id="app">
        <!-- SVG Icons Definition -->
        <svg width="0" height="0" style="display:none;">
            <defs>
                <symbol id="icon-printer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                    <polyline points="6 9 6 2 18 2 18 9"></polyline><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path><rect x="6" y="14" width="12" height="8"></rect>
                </symbol>
                <symbol id="icon-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                    <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line>
                </symbol>
                <symbol id="icon-success" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                    <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline>
                </symbol>
            </defs>
        </svg>

        <div class="main-card">
            <header class="header">
                <svg class="icon"><use href="#icon-printer"></use></svg>
                <h1>自助共享打印服务</h1>
            </header>

            <div class="tabs">
                <div class="tab" :class="{active: currentView === 'upload'}" @click="switchView('upload')">我要打印</div>
                <div class="tab" :class="{active: currentView === 'pickup'}" @click="switchView('pickup')">现场取件</div>
            </div>

            <transition name="fade" mode="out-in">
                <!-- Print View -->
                <div v-if="currentView === 'upload'">
                    <transition name="fade" mode="out-in">
                        <div v-if="uploadState === 'idle'">
                            <div class="upload-area" 
                                @click="triggerFileInput"
                                @dragover.prevent="dragOver = true"
                                @dragleave.prevent="dragOver = false"
                                @drop.prevent="handleDrop"
                                :class="{'drag-over': dragOver}">
                                <svg class="icon"><use href="#icon-upload"></use></svg>
                                <p>将文件拖拽到此处,或<span>点击选择文件</span></p>
                                <p style="font-size: 0.8rem; margin-top: 0.5rem;">支持 PDF, DOCX, JPG, PNG 等</p>
                            </div>
                            <input type="file" ref="fileInput" @change="handleFileSelect" style="display: none;">
                        </div>

                        <div v-else-if="uploadState === 'processing'" class="processing-view">
                            <div class="spinner"></div>
                            <p>正在提交打印任务...</p>
                        </div>

                        <div v-else-if="uploadState === 'success'" class="success-view">
                            <svg class="icon"><use href="#icon-success"></use></svg>
                            <h2>打印任务已提交!</h2>
                            <p style="margin-top: 0.5rem; color: var(--gray-text);">请凭以下提取码前往打印机处领取</p>
                            <div class="pickup-code-display">{{ pickupCode }}</div>
                            <button class="btn" @click="resetUpload">打印其他文件</button>
                        </div>
                    </transition>
                    <div v-if="uploadError" class="message error">{{ uploadError }}</div>
                </div>

                <!-- Pickup View -->
                <div v-else-if="currentView === 'pickup'" class="pickup-form">
                     <div class="form-group">
                        <label for="pickup-code-input">输入提取码</label>
                        <input id="pickup-code-input" type="text" v-model="pickupCodeInput" placeholder="------" maxlength="6" @keyup.enter="verifyPickupCode">
                    </div>
                    <button class="btn" @click="verifyPickupCode" :disabled="!pickupCodeInput || pickupLoading">
                        {{ pickupLoading ? '核销中...' : '确认取件' }}
                    </button>
                    <div v-if="pickupMessage.text" class="message" :class="pickupMessage.type">
                        {{ pickupMessage.text }}
                    </div>
                </div>
            </transition>
        </div>
    </div>

    <script>
        const { createApp, ref, reactive } = Vue;

        createApp({
            setup() {
                // Common State
                const currentView = ref('upload'); // 'upload' or 'pickup'

                // Upload View State
                const uploadState = ref('idle'); // 'idle', 'processing', 'success'
                const dragOver = ref(false);
                const fileInput = ref(null);
                const pickupCode = ref('');
                const uploadError = ref('');

                // Pickup View State
                const pickupCodeInput = ref('');
                const pickupLoading = ref(false);
                const pickupMessage = reactive({ text: '', type: '' });

                // --- Methods ---

                const switchView = (view) => {
                    currentView.value = view;
                    // Reset states when switching
                    resetUpload();
                    resetPickup();
                };

                const triggerFileInput = () => {
                    fileInput.value.click();
                };

                const handleFileSelect = (event) => {
                    const file = event.target.files[0];
                    if (file) {
                        uploadFile(file);
                    }
                };

                const handleDrop = (event) => {
                    dragOver.value = false;
                    const file = event.dataTransfer.files[0];
                    if (file) {
                        uploadFile(file);
                    }
                };
                
                const uploadFile = async (file) => {
                    uploadState.value = 'processing';
                    uploadError.value = '';
                    const formData = new FormData();
                    formData.append('file', file);

                    try {
                        const response = await fetch('/api/upload', {
                            method: 'POST',
                            body: formData,
                        });

                        const data = await response.json();

                        if (!response.ok) {
                            throw new Error(data.error || '上传失败,请重试');
                        }

                        pickupCode.value = data.pickup_code;
                        uploadState.value = 'success';

                    } catch (error) {
                        console.error('Upload error:', error);
                        uploadError.value = error.message;
                        uploadState.value = 'idle';
                    }
                };

                const resetUpload = () => {
                    uploadState.value = 'idle';
                    pickupCode.value = '';
                    uploadError.value = '';
                    if(fileInput.value) fileInput.value.value = '';
                };

                const verifyPickupCode = async () => {
                    if (!pickupCodeInput.value || pickupLoading.value) return;

                    pickupLoading.value = true;
                    pickupMessage.text = '';

                    try {
                        const response = await fetch('/api/pickup', {
                            method: 'POST',
                            headers: { 'Content-Type': 'application/json' },
                            body: JSON.stringify({ code: pickupCodeInput.value }),
                        });
                        
                        const data = await response.json();

                        if(!response.ok) {
                             throw new Error(data.error || '验证失败');
                        }

                        pickupMessage.text = data.message;
                        pickupMessage.type = 'success';
                        pickupCodeInput.value = '';

                    } catch (error) {
                        console.error('Pickup error:', error);
                        pickupMessage.text = error.message;
                        pickupMessage.type = 'error';
                    } finally {
                        pickupLoading.value = false;
                    }
                };

                const resetPickup = () => {
                    pickupCodeInput.value = '';
                    pickupLoading.value = false;
                    pickupMessage.text = '';
                    pickupMessage.type = '';
                };


                return {
                    currentView,
                    uploadState,
                    dragOver,
                    fileInput,
                    pickupCode,
                    uploadError,
                    pickupCodeInput,
                    pickupLoading,
                    pickupMessage,
                    switchView,
                    triggerFileInput,
                    handleFileSelect,
                    handleDrop,
                    resetUpload,
                    verifyPickupCode,
                };
            }
        }).mount('#app');
    </script>
</body>
</html>

网友回复

我知道答案,我要回答