可以通过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> 网友回复


