修改条形码为二维码,同时对于扫码展示部分进行修改

This commit is contained in:
dxc
2026-02-09 14:48:09 +08:00
parent bdee5fb27a
commit fdf22b9973
5 changed files with 402 additions and 205 deletions

1
.gitignore vendored
View File

@ -17,3 +17,4 @@ inventory-web/*.local
*.log *.log
pgdata_docker/ pgdata_docker/
inventory-backend/uploads/ inventory-backend/uploads/
.aider*

View File

@ -4,12 +4,11 @@ import os
from io import BytesIO from io import BytesIO
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
# 引入条形码生成库 # 引入二维码生成库
try: try:
import barcode import qrcode
from barcode.writer import ImageWriter
except ImportError: except ImportError:
print("❌ 警告: 未安装 python-barcode 库,无法生成真实条形码。请执行: pip install python-barcode") print("❌ 警告: 未安装 qrcode 库,无法生成二维码。请执行: pip install qrcode[pil]")
class LabelPrintService: class LabelPrintService:
@ -25,53 +24,65 @@ class LabelPrintService:
LABEL_WIDTH = int(LABEL_WIDTH_MM * DOTS_PER_MM) LABEL_WIDTH = int(LABEL_WIDTH_MM * DOTS_PER_MM)
LABEL_HEIGHT = int(LABEL_HEIGHT_MM * DOTS_PER_MM) LABEL_HEIGHT = int(LABEL_HEIGHT_MM * DOTS_PER_MM)
# 顶部留白 # ================= 2. 布局配置 =================
TOP_MARGIN_MM = 1.5 MARGIN_LEFT = int(2 * DOTS_PER_MM) # 左边距 2mm
TOP_MARGIN_PX = int(TOP_MARGIN_MM * DOTS_PER_MM) MARGIN_RIGHT = int(1 * DOTS_PER_MM) # 右边距 1mm
TOP_MARGIN = int(5 * DOTS_PER_MM) # 顶部边距 2mm
# 定义左边距 (3mm) - 用于正文左对齐 # 二维码尺寸 15mm * 15mm
MARGIN_LEFT = int(3 * DOTS_PER_MM) QR_SIZE_MM = 15
# 定义右边距 (防止文字贴边,留 2mm) QR_SIZE_PX = int(QR_SIZE_MM * DOTS_PER_MM) # 180px
MARGIN_RIGHT = int(2 * DOTS_PER_MM)
# 计算文字允许的最大像素宽度 # 左右分栏的间距
MAX_TEXT_WIDTH = LABEL_WIDTH - MARGIN_LEFT - MARGIN_RIGHT GAP_COLUMNS = int(2 * DOTS_PER_MM) # 2mm 间距
@staticmethod @staticmethod
def _get_font(size): def _get_font(size):
"""获取字体""" """获取字体 (优先使用黑体/微软雅黑)"""
# 尝试加载中文字体,否则乱码 font_names = ["simhei.ttf", "msyh.ttf", "SimHei.ttf", "arial.ttf", "NotoSansCJK-Regular.ttc"]
font_names = ["simhei.ttf", "msyh.ttf", "SimHei.ttf", "arial.ttf"] base_dirs = [os.getcwd(), os.path.dirname(__file__), "/usr/share/fonts", "C:\\Windows\\Fonts"]
base_dirs = [os.getcwd(), os.path.dirname(__file__)]
for d in base_dirs: for d in base_dirs:
for name in font_names: for name in font_names:
path = os.path.join(d, name) path = os.path.join(d, name)
if os.path.exists(path): if os.path.exists(path):
try:
return ImageFont.truetype(path, size) return ImageFont.truetype(path, size)
except:
continue
return ImageFont.load_default() return ImageFont.load_default()
@staticmethod @staticmethod
def _generate_barcode_image(content, width_px, height_px): def _generate_qr_image(content, size_px):
"""生成真实的条形码图片""" """生成指定像素大小的二维码"""
try: try:
if not content: content = "0000000000" if not content: content = "000000"
code128 = barcode.get('code128', content, writer=ImageWriter())
buffer = BytesIO() # 创建二维码对象
code128.write(buffer, options={"write_text": False, "module_height": 10.0, "quiet_zone": 1.0}) qr = qrcode.QRCode(
buffer.seek(0) version=1,
bc_img = Image.open(buffer) error_correction=qrcode.constants.ERROR_CORRECT_M,
return bc_img.resize((width_px, height_px), Image.Resampling.LANCZOS) box_size=10,
border=0,
)
qr.add_data(content)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
# [重要] 必须转为 RGB 模式
img = img.convert('RGB')
# 调整为指定像素大小
return img.resize((size_px, size_px), Image.Resampling.LANCZOS)
except Exception as e: except Exception as e:
print(f"条形码生成失败: {e}") print(f"二维码生成失败: {e}")
return Image.new('RGB', (width_px, height_px), color='black') return Image.new('RGB', (size_px, size_px), color='gray')
@staticmethod @staticmethod
def draw_text_wrap(draw, text, x, y, font, max_width, line_spacing=0): def draw_text_wrap(draw, text, x, y, font, max_width, line_spacing=0, stroke_width=1):
""" """
[核心功能] 自动换行绘制文本 [核心功能] 自动换行绘制文本
:param line_spacing: 行与行之间的额外像素距离
:return: 绘制结束后的 Y 坐标
""" """
if not text: if not text:
return y return y
@ -79,36 +90,36 @@ class LabelPrintService:
lines = [] lines = []
current_line = "" current_line = ""
# 1. 计算折行逻辑 # 计算折行
for char in text: for char in text:
# 预测加入新字符后的宽度
test_line = current_line + char test_line = current_line + char
width = font.getlength(test_line) width = font.getlength(test_line)
if width <= max_width: if width <= max_width:
current_line = test_line current_line = test_line
else: else:
# 宽度超出,将当前行存入,新字符作为下一行开头 if current_line: lines.append(current_line)
lines.append(current_line)
current_line = char current_line = char
# 将最后剩余的内容加入
if current_line: if current_line:
lines.append(current_line) lines.append(current_line)
# 2. 绘制每一行 # 绘制
current_y = y current_y = y
font_height = font.size # 获取字号高度 font_height = font.size
for line in lines: for line in lines:
# 边界检查:如果超出图片高度,停止绘制
if current_y + font_height > LabelPrintService.LABEL_HEIGHT: if current_y + font_height > LabelPrintService.LABEL_HEIGHT:
break break
# 绘制文字 (stroke_width=1 加粗) draw.text(
draw.text((x, current_y), line, font=font, fill='black', stroke_width=1, stroke_fill='black') (x, current_y),
line,
# 更新 Y 坐标 (字高 + 行间距) font=font,
fill='black',
stroke_width=stroke_width, # 支持动态调整粗细
stroke_fill='black'
)
current_y += font_height + line_spacing current_y += font_height + line_spacing
return current_y return current_y
@ -117,121 +128,144 @@ class LabelPrintService:
def _create_image_object(data): def _create_image_object(data):
""" """
[绘图层] 生成标签图片 [绘图层] 生成标签图片
新布局逻辑:
---------------------------------------
| [QR Code] (15mm) | 名: XXXXXX |
| | 规: XXXXXX |
| SKU: XXXXX(大/粗)| 属: XXXXXX |
| 库: XXXXX (中/粗)| SN: XXXXXX |
---------------------------------------
""" """
# 1. 创建画布 # 1. 创建画布
img = Image.new('RGB', (LabelPrintService.LABEL_WIDTH, LabelPrintService.LABEL_HEIGHT), color='white') img = Image.new('RGB', (LabelPrintService.LABEL_WIDTH, LabelPrintService.LABEL_HEIGHT), color='white')
d = ImageDraw.Draw(img) d = ImageDraw.Draw(img)
# 2. 字体配置 # 2. 字体配置 (字号再次加大)
# [保持] 正文内容维持 24号以节省空间 # [修改] 通用字体加大到 28
font_body = LabelPrintService._get_font(24) font_text = LabelPrintService._get_font(28)
# [修改] 编码(SKU)字体设置为 30号 # [修改] SKU字体加大到 34 (特大)
font_code = LabelPrintService._get_font(30) font_sku = LabelPrintService._get_font(34)
# 3. 数据准备 # 3. 数据准备
sku_code = data.get('sku') sku_code = str(data.get('sku') or data.get('serial_number') or '000000')
if not sku_code:
sku_code = data.get('serial_number') or str(data.get('global_print_id', '0000000000')).zfill(10)
# ==================== 绘制布局 ====================
GLOBAL_OFFSET_X = LabelPrintService.MARGIN_LEFT
CURRENT_Y = LabelPrintService.TOP_MARGIN_PX
# --- A. 绘制条形码 (居中) ---
bc_w = int(37 * LabelPrintService.DOTS_PER_MM)
bc_h = int(8 * LabelPrintService.DOTS_PER_MM) # 高度
bc_img = LabelPrintService._generate_barcode_image(sku_code, bc_w, bc_h)
# [修改核心] 计算条形码的居中 X 坐标
# 公式:(标签总宽 - 条码宽) / 2
bc_x_centered = (LabelPrintService.LABEL_WIDTH - bc_w) // 2
img.paste(bc_img, (bc_x_centered, CURRENT_Y))
# --- B. 绘制条形码下方数字 (居中 + 30号字) ---
text_y_pos = CURRENT_Y + bc_h + 2
# [修改核心] 计算文字宽度 并 居中
text_width = font_code.getlength(sku_code)
text_x_centered = (LabelPrintService.LABEL_WIDTH - text_width) // 2
d.text(
(text_x_centered, text_y_pos),
sku_code,
font=font_code, # 使用30号字体
fill='black',
stroke_width=1,
stroke_fill='black'
)
# 更新 Y 坐标,准备开始绘制正文
# 30(字高) + 4(间距)
CURRENT_Y = text_y_pos + 30 + 4
# --- C. 绘制其余信息 (保持左对齐 + 24号字 + 自动换行) ---
# 1. 准备完整文本
name = str(data.get('material_name', '') or '-') name = str(data.get('material_name', '') or '-')
spec = str(data.get('spec_model', '') or '-') spec = str(data.get('spec_model', '') or '-')
loc = str(data.get('warehouse_loc', '') or '-') loc = str(data.get('warehouse_loc', '') or '-')
cat = str(data.get('category', '') or '') cat = str(data.get('category', '') or '')
typ = str(data.get('material_type', '') or '') typ = str(data.get('material_type', '') or '')
attr = f"{cat}/{typ}" attr = f"{cat}/{typ}" if (cat or typ) else "-"
# 底部文字 # 底部编号逻辑
bottom_text = "" bottom_val = ""
bottom_label = "NO"
if data.get('print_no'): if data.get('print_no'):
val = str(data.get('print_no')) bottom_val = str(data.get('print_no'))
label_type = data.get('print_label', '') l_type = data.get('print_label', '')
bottom_text = f"{'SN' if label_type == '' else 'BN' if label_type == '' else 'NO'}: {val}" bottom_label = 'SN' if l_type == '' else 'BN' if l_type == '' else 'NO'
elif data.get('serial_number'): elif data.get('serial_number'):
bottom_text = f"SN: {data.get('serial_number')}" bottom_label = "SN"
bottom_val = str(data.get('serial_number'))
elif data.get('batch_number'): elif data.get('batch_number'):
bottom_text = f"BN: {data.get('batch_number')}" bottom_label = "BN"
bottom_val = str(data.get('batch_number'))
else: else:
bottom_text = f"NO: {sku_code}" bottom_val = sku_code
# 2. 依次调用自动换行绘制函数 (使用正文字体 font_body且坐标使用 GLOBAL_OFFSET_X) bottom_text_full = f"{bottom_label}:{bottom_val}"
# 绘制名称 # ==================== 绘制区域划分 ====================
CURRENT_Y = LabelPrintService.draw_text_wrap(
d, f"名: {name}", GLOBAL_OFFSET_X, CURRENT_Y, font_body, LabelPrintService.MAX_TEXT_WIDTH, line_spacing=2 # --- A. 左侧区域 (二维码 + SKU + 库位) ---
qr_x = LabelPrintService.MARGIN_LEFT
qr_y = LabelPrintService.TOP_MARGIN
# 1. 绘制二维码
qr_img = LabelPrintService._generate_qr_image(sku_code, LabelPrintService.QR_SIZE_PX)
img.paste(qr_img, (qr_x, qr_y))
# 计算中心点,用于 SKU 和 库位 居中
qr_center_x = qr_x + (LabelPrintService.QR_SIZE_PX // 2)
# 2. 绘制 SKU (特大 + 特粗)
# 位于二维码下方,留 6px 间距
current_left_y = qr_y + LabelPrintService.QR_SIZE_PX + 6
sku_w = font_sku.getlength(sku_code)
sku_x = int(qr_center_x - (sku_w // 2))
if sku_x < 2: sku_x = 2 # 边界保护
d.text(
(sku_x, current_left_y),
sku_code,
font=font_sku,
fill='black',
stroke_width=2, # [修改] SKU 增加到 2px 描边,更粗
stroke_fill='black'
) )
CURRENT_Y += 2
# 绘制规格 # 3. 绘制 库位 (放在 SKU 下方)
CURRENT_Y = LabelPrintService.draw_text_wrap( # 位于 SKU 下方,留 6px 间距
d, f"规: {spec}", GLOBAL_OFFSET_X, CURRENT_Y, font_body, LabelPrintService.MAX_TEXT_WIDTH, line_spacing=2 current_left_y += 34 + 6 # 34是字号大致高度
loc_text = f"库:{loc}"
loc_w = font_text.getlength(loc_text)
loc_x = int(qr_center_x - (loc_w // 2))
if loc_x < 2: loc_x = 2
d.text(
(loc_x, current_left_y),
loc_text,
font=font_text,
fill='black',
stroke_width=1, # 普通加粗
stroke_fill='black'
) )
CURRENT_Y += 2
# 绘制库位 # --- B. 右侧区域 (名称、规格、属性、编号) ---
CURRENT_Y = LabelPrintService.draw_text_wrap(
d, f"库: {loc}", GLOBAL_OFFSET_X, CURRENT_Y, font_body, LabelPrintService.MAX_TEXT_WIDTH, line_spacing=2 # 右侧起始 X
right_start_x = LabelPrintService.MARGIN_LEFT + LabelPrintService.QR_SIZE_PX + LabelPrintService.GAP_COLUMNS
# 右侧最大宽度
right_max_width = LabelPrintService.LABEL_WIDTH - right_start_x - LabelPrintService.MARGIN_RIGHT
current_right_y = LabelPrintService.TOP_MARGIN
# [修改] 增大行间距 line_spacing=8
LINE_SPACING = 8
# 1. 名称
current_right_y = LabelPrintService.draw_text_wrap(
d, f"名:{name}", right_start_x, current_right_y, font_text, right_max_width, line_spacing=LINE_SPACING
) )
CURRENT_Y += 2 current_right_y += LINE_SPACING
# 绘制属性 # 2. 规格
CURRENT_Y = LabelPrintService.draw_text_wrap( current_right_y = LabelPrintService.draw_text_wrap(
d, f"属: {attr}", GLOBAL_OFFSET_X, CURRENT_Y, font_body, LabelPrintService.MAX_TEXT_WIDTH, line_spacing=2 d, f"规:{spec}", right_start_x, current_right_y, font_text, right_max_width, line_spacing=LINE_SPACING
) )
CURRENT_Y += 2 current_right_y += LINE_SPACING
# 绘制底部编号 # 3. 属性
CURRENT_Y = LabelPrintService.draw_text_wrap( current_right_y = LabelPrintService.draw_text_wrap(
d, bottom_text, GLOBAL_OFFSET_X, CURRENT_Y, font_body, LabelPrintService.MAX_TEXT_WIDTH, line_spacing=2 d, f"属:{attr}", right_start_x, current_right_y, font_text, right_max_width, line_spacing=LINE_SPACING
)
current_right_y += LINE_SPACING
# 4. 序列号/批号
LabelPrintService.draw_text_wrap(
d, bottom_text_full, right_start_x, current_right_y, font_text, right_max_width, line_spacing=LINE_SPACING
) )
return img return img
@staticmethod @staticmethod
def generate_preview_image(data): def generate_preview_image(data):
"""生成 Base64 预览图"""
img = LabelPrintService._create_image_object(data) img = LabelPrintService._create_image_object(data)
output_buffer = BytesIO() output_buffer = BytesIO()
img.save(output_buffer, format='JPEG') img.save(output_buffer, format='JPEG', quality=95)
base64_str = base64.b64encode(output_buffer.getvalue()).decode('utf-8') base64_str = base64.b64encode(output_buffer.getvalue()).decode('utf-8')
return f"data:image/jpeg;base64,{base64_str}" return f"data:image/jpeg;base64,{base64_str}"
@ -241,12 +275,21 @@ class LabelPrintService:
port = LabelPrintService.PRINTER_PORT port = LabelPrintService.PRINTER_PORT
try: try:
# 1. 获取 RGB 图像
img_rgb = LabelPrintService._create_image_object(data) img_rgb = LabelPrintService._create_image_object(data)
# 2. 转换为灰度
img_gray = img_rgb.convert('L') img_gray = img_rgb.convert('L')
img_bw = img_gray.convert('1', dither=Image.Dither.NONE)
# 3. 二值化处理
img_bw = img_gray.point(lambda x: 0 if x < 128 else 255, '1')
# 4. 生成打印指令
bitmap_data = img_bw.tobytes() bitmap_data = img_bw.tobytes()
width_bytes = (img_bw.width + 7) // 8 width_bytes = (img_bw.width + 7) // 8
height_dots = img_bw.height height_dots = img_bw.height
# TSPL 协议头
header = ( header = (
f"SIZE {LabelPrintService.LABEL_WIDTH_MM} mm, {LabelPrintService.LABEL_HEIGHT_MM} mm\r\n" f"SIZE {LabelPrintService.LABEL_WIDTH_MM} mm, {LabelPrintService.LABEL_HEIGHT_MM} mm\r\n"
"GAP 2 mm, 0 mm\r\n" "GAP 2 mm, 0 mm\r\n"
@ -254,8 +297,12 @@ class LabelPrintService:
"DIRECTION 1\r\n" "DIRECTION 1\r\n"
"REFERENCE 0, 0\r\n" "REFERENCE 0, 0\r\n"
).encode('gbk') ).encode('gbk')
# 位图指令
bitmap_cmd = f"BITMAP 0,0,{width_bytes},{height_dots},0,".encode('gbk') bitmap_cmd = f"BITMAP 0,0,{width_bytes},{height_dots},0,".encode('gbk')
footer = b"\r\nPRINT 1,1\r\n" footer = b"\r\nPRINT 1,1\r\n"
# 5. 发送 socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(5) s.settimeout(5)
s.connect((ip, port)) s.connect((ip, port))
@ -265,3 +312,7 @@ class LabelPrintService:
except Exception as e: except Exception as e:
print(f"❌ 打印异常: {e}") print(f"❌ 打印异常: {e}")
raise Exception(f"打印机连接失败: {str(e)}") raise Exception(f"打印机连接失败: {str(e)}")
if __name__ == "__main__":
pass

View File

@ -6,7 +6,11 @@ marshmallow-sqlalchemy==1.0.0
psycopg2-binary==2.9.9 psycopg2-binary==2.9.9
python-dotenv==1.0.0 python-dotenv==1.0.0
flask-cors==4.0.0 flask-cors==4.0.0
# 图片处理核心库
Pillow>=10.0.0 Pillow>=10.0.0
# [旧] 条形码生成库 (建议保留,防止旧代码报错)
python-barcode>=0.14.0 python-barcode>=0.14.0
# [新增] 二维码生成库 (标签打印必需包含PIL支持)
qrcode[pil]>=7.4.2
# [新增] 必须添加,用于处理 token 登录 # [新增] 必须添加,用于处理 token 登录
Flask-JWT-Extended==4.6.0 Flask-JWT-Extended==4.6.0

View File

@ -6,7 +6,7 @@
<div class="focus-tip" v-if="!errorMsg && !isPaused"> <div class="focus-tip" v-if="!errorMsg && !isPaused">
<div class="scan-line"></div> <div class="scan-line"></div>
<div class="scan-text">将条码横向填满红框</div> <div class="scan-text">将条码置于镜头范围内即可</div>
</div> </div>
<div class="focus-tip success" v-if="isPaused"> <div class="focus-tip success" v-if="isPaused">
@ -15,25 +15,43 @@
扫描成功3秒后继续... 扫描成功3秒后继续...
</div> </div>
</div> </div>
<div v-if="hasZoom" class="zoom-control">
<span class="zoom-icon">-</span>
<input
type="range"
:min="zoomMin"
:max="zoomMax"
step="0.1"
v-model="currentZoom"
@input="handleZoom"
/>
<span class="zoom-icon">+</span>
<div class="zoom-value">{{ currentZoom }}x</div>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode' import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode'
import { CircleCheckFilled } from '@element-plus/icons-vue' // 引入图标用于成功提示 import { CircleCheckFilled } from '@element-plus/icons-vue'
// 定义事件
const emit = defineEmits(['decode', 'error']) const emit = defineEmits(['decode', 'error'])
const errorMsg = ref('') const errorMsg = ref('')
const isPaused = ref(false) // ★ 新增:控制暂停状态 const isPaused = ref(false)
let html5QrCode: Html5Qrcode | null = null let html5QrCode: Html5Qrcode | null = null
const scannerElementId = "qr-reader" const scannerElementId = "qr-reader"
// 变焦控制状态
const hasZoom = ref(false)
const zoomMin = ref(1)
const zoomMax = ref(5)
const currentZoom = ref(1)
const startScanning = async () => { const startScanning = async () => {
try { try {
// 1. 实例化
html5QrCode = new Html5Qrcode(scannerElementId, { html5QrCode = new Html5Qrcode(scannerElementId, {
useBarCodeDetectorIfSupported: true, useBarCodeDetectorIfSupported: true,
formatsToSupport: [ formatsToSupport: [
@ -43,37 +61,34 @@ const startScanning = async () => {
verbose: false verbose: false
}) })
// 2. 启动配置
const config = { const config = {
fps: 20, fps: 20,
qrbox: { width: 320, height: 60 }, // ★★★ 核心修改点 2移除了 qrbox 属性 ★★★
// 移除后,库默认会对每一帧的“全画面”进行解析,不再局限于中间区域
// qrbox: { width: 300, height: 100 },
disableFlip: false, disableFlip: false,
videoConstraints: { videoConstraints: {
facingMode: "environment", facingMode: "environment",
width: { min: 1280, ideal: 1920, max: 3840 }, // 保持高分辨率以支持微小条码
height: { min: 720, ideal: 1080, max: 2160 }, width: { min: 1280, ideal: 3840, max: 3840 },
height: { min: 720, ideal: 2160, max: 2160 },
focusMode: "continuous", focusMode: "continuous",
advanced: [{ focusMode: "macro" }] advanced: [{ focusMode: "macro" }, { zoom: 2.0 }]
} }
} }
// 3. 启动
await html5QrCode.start( await html5QrCode.start(
{ facingMode: "environment" }, { facingMode: "environment" },
config, config,
(decodedText) => { (decodedText) => {
// ★ 核心修改:如果处于暂停冷却期,直接忽略后续扫描结果
if (isPaused.value) return if (isPaused.value) return
console.log(`Scan: ${decodedText}`) console.log(`Scan: ${decodedText}`)
// 1. 锁定状态
isPaused.value = true isPaused.value = true
// 2. 发送数据
emit('decode', decodedText) emit('decode', decodedText)
// 3. 开启 3 秒倒计时解锁 if (navigator.vibrate) navigator.vibrate(200);
setTimeout(() => { setTimeout(() => {
isPaused.value = false isPaused.value = false
}, 3000) }, 3000)
@ -82,20 +97,52 @@ const startScanning = async () => {
// ignore // ignore
} }
) )
checkZoomCapability()
} catch (err: any) { } catch (err: any) {
let msg = '无法启动摄像头' let msg = '无法启动摄像头'
const errStr = err.toString()
if (errStr.includes('Permission')) msg = '请允许摄像头权限'
else if (errStr.includes('Secure')) msg = '需要 HTTPS 或 localhost'
else if (errStr.includes('NotFound')) msg = '未检测到后置摄像头'
else if (errStr.includes('OverconstrainedError')) msg = '摄像头不支持高分辨率'
console.error("Scanner Error:", err) console.error("Scanner Error:", err)
errorMsg.value = msg errorMsg.value = msg
emit('error', msg) emit('error', msg)
} }
} }
// 检测硬件变焦能力
const checkZoomCapability = () => {
if (!html5QrCode) return
try {
const videoTrack = html5QrCode.getRunningTrackCameraCapabilities() as MediaTrackCapabilities;
// @ts-ignore
if (videoTrack && 'zoom' in videoTrack) {
hasZoom.value = true
// @ts-ignore
zoomMin.value = videoTrack.zoom.min || 1
// @ts-ignore
zoomMax.value = videoTrack.zoom.max || 5
// @ts-ignore
currentZoom.value = videoTrack.zoom.min || 1
}
} catch (e) {
console.warn("无法获取变焦能力", e)
}
}
// 处理滑块拖动
const handleZoom = () => {
if (!html5QrCode) return
try {
html5QrCode.applyVideoConstraints({
advanced: [{ zoom: Number(currentZoom.value) }]
})
} catch (e) {
console.error("变焦失败", e)
}
}
const stopScanning = async () => { const stopScanning = async () => {
if (html5QrCode) { if (html5QrCode) {
try { try {
@ -131,7 +178,8 @@ onUnmounted(() => {
justify-content: center; justify-content: center;
align-items: center; align-items: center;
overflow: hidden; overflow: hidden;
border-radius: 12px; /* 如果是全屏模式这里不需要圆角或者保持圆角视你的UI设计而定 */
border-radius: 0;
} }
.scanner-box { .scanner-box {
@ -151,7 +199,6 @@ onUnmounted(() => {
height: 100% !important; height: 100% !important;
object-fit: cover !important; object-fit: cover !important;
display: block !important; display: block !important;
border-radius: 12px;
} }
.error-msg { .error-msg {
@ -167,62 +214,98 @@ onUnmounted(() => {
z-index: 20; z-index: 20;
} }
/* --- 视觉辅助线 --- */ /* --- ★ 修改点 3视觉层 CSS 更新 --- */
.focus-tip { .focus-tip {
position: absolute; position: absolute;
top: 50%; top: 0;
left: 50%; left: 0;
transform: translate(-50%, -50%); width: 100%;
width: 320px; height: 100%;
height: 60px; /* 移除了 border 和 box-shadow不再显示红框和黑色遮罩 */
border: 2px solid rgba(255, 0, 0, 0.6);
border-radius: 6px;
pointer-events: none; pointer-events: none;
box-shadow: 0 0 0 2000px rgba(0, 0, 0, 0.6);
z-index: 10; z-index: 10;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
transition: all 0.3s;
} }
/* ★ 扫描成功时的绿色框样式 */
.focus-tip.success { .focus-tip.success {
border-color: #67c23a; /* 绿色边框 */ background: rgba(103, 194, 58, 0.2); /* 成功时全屏微微泛绿 */
background: rgba(103, 194, 58, 0.1);
} }
/* 扫描线改为全屏宽度 */
.scan-line { .scan-line {
width: 95%; width: 100%;
height: 2px; height: 2px;
background: #ff0000; background: rgba(255, 0, 0, 0.5);
box-shadow: 0 0 4px #ff0000; box-shadow: 0 0 4px rgba(255, 0, 0, 0.8);
position: absolute; position: absolute;
animation: scan-move 1.5s infinite ease-in-out; /* 动画范围从 10% 到 90% */
animation: scan-move 2.5s infinite linear;
} }
.scan-text { .scan-text {
position: absolute; position: absolute;
bottom: -35px; bottom: 150px; /* 调整文字位置 */
color: rgba(255, 255, 255, 0.9); color: rgba(255, 255, 255, 0.8);
font-size: 13px; font-size: 14px;
white-space: nowrap; text-shadow: 0 1px 3px rgba(0,0,0,0.8);
text-shadow: 0 1px 2px #000; background: rgba(0,0,0,0.3);
padding: 4px 10px;
border-radius: 4px;
} }
.scan-text-success { .scan-text-success {
color: #67c23a; color: #fff;
font-size: 16px; font-size: 20px;
font-weight: bold; font-weight: bold;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 5px; gap: 10px;
text-shadow: 0 1px 3px rgba(0,0,0,0.8); text-shadow: 0 2px 4px rgba(0,0,0,0.8);
background: rgba(103, 194, 58, 0.9);
padding: 15px 30px;
border-radius: 50px;
} }
@keyframes scan-move { @keyframes scan-move {
0% { top: 10%; opacity: 0.5; } 0% { top: 0%; opacity: 0; }
50% { top: 90%; opacity: 1; } 10% { opacity: 1; }
100% { top: 10%; opacity: 0.5; } 90% { opacity: 1; }
100% { top: 100%; opacity: 0; }
}
/* 变焦控制器 */
.zoom-control {
position: absolute;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
width: 80%;
max-width: 300px;
display: flex;
align-items: center;
gap: 10px;
background: rgba(0, 0, 0, 0.5);
padding: 10px 20px;
border-radius: 30px;
z-index: 50;
color: white;
}
.zoom-control input[type=range] {
flex: 1;
height: 4px;
}
.zoom-icon {
font-size: 20px;
font-weight: bold;
}
.zoom-value {
font-size: 14px;
width: 30px;
text-align: right;
} }
</style> </style>

View File

@ -16,18 +16,10 @@
</template> </template>
<div class="scan-section"> <div class="scan-section">
<div v-if="showCamera" class="camera-wrapper">
<QrScanner @decode="onScanSuccess" />
<div class="scan-overlay">
<el-button type="info" size="small" bg text @click="showCamera = false" icon="Close">
关闭摄像头
</el-button>
</div>
</div>
<div v-else class="camera-placeholder" @click="showCamera = true"> <div class="camera-placeholder" @click="showCamera = true">
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon> <el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
<span class="text">点击开启扫码</span> <span class="text">点击开启全屏扫码</span>
</div> </div>
<div class="input-box"> <div class="input-box">
@ -150,6 +142,22 @@
</div> </div>
</el-card> </el-card>
<div v-if="showCamera" class="fullscreen-scanner-overlay">
<div class="scanner-header">
<el-button circle icon="Close" @click="showCamera = false" class="close-btn" />
<span class="scanner-title">扫码模式</span>
<div class="scanner-placeholder"></div> </div>
<div class="scanner-body">
<QrScanner @decode="onScanSuccess" />
</div>
<div class="scanner-footer">
<p>请对准条形码识别成功后自动添加</p>
<p v-if="cartItems.length > 0" class="current-count">当前已添加: {{ cartItems.length }} </p>
</div>
</div>
<el-dialog <el-dialog
v-model="showSignatureDialog" v-model="showSignatureDialog"
fullscreen fullscreen
@ -201,7 +209,7 @@ import { useUserStore } from '@/stores/user'
const barcodeInput = ref('') const barcodeInput = ref('')
const cartItems = ref<any[]>([]) const cartItems = ref<any[]>([])
const loading = ref(false) const loading = ref(false)
const showCamera = ref(false) // ★ 核心修改:默认改为 false const showCamera = ref(false)
const barcodeRef = ref() const barcodeRef = ref()
const formRef = ref() const formRef = ref()
const userStore = useUserStore() const userStore = useUserStore()
@ -266,26 +274,21 @@ const onScanSuccess = (code: string) => {
if (!code) return if (!code) return
const trimCode = code.trim() const trimCode = code.trim()
// ★★★ 核心修改:防误触校验 ★★★
// 1. 正则校验:只允许 数字、字母、横杠、点
// 这样可以屏蔽掉条码解析错误产生的 { } $ # 等乱码
const validPattern = /^[A-Za-z0-9\-\.]+$/ const validPattern = /^[A-Za-z0-9\-\.]+$/
if (!validPattern.test(trimCode)) { if (!validPattern.test(trimCode)) {
ElMessage.warning(`识别到异常符号,已忽略:${trimCode}`) ElMessage.warning(`识别到异常符号,已忽略:${trimCode}`)
return return
} }
// 2. 长度校验:避免误扫到环境中的短数字
if (trimCode.length < 3) { if (trimCode.length < 3) {
ElMessage.warning('扫描结果过短,请对准重试') ElMessage.warning('扫描结果过短,请对准重试')
return return
} }
// 防抖:防止同一条码连续触发
if (loading.value) return if (loading.value) return
barcodeInput.value = trimCode barcodeInput.value = trimCode
handleManualInput() // 复用手动输入逻辑 handleManualInput()
} }
const handleManualInput = async () => { const handleManualInput = async () => {
@ -343,10 +346,13 @@ const handleManualInput = async () => {
if (navigator.vibrate) navigator.vibrate([200, 100, 200]) if (navigator.vibrate) navigator.vibrate([200, 100, 200])
} finally { } finally {
loading.value = false loading.value = false
// 聚焦输入框,方便连续扫 // 注意:全屏扫码模式下,我们不需要 refocus input因为用户还在看摄像头
// 只有在非全屏模式下才 focus
if (!showCamera.value) {
nextTick(() => { barcodeRef.value?.focus() }) nextTick(() => { barcodeRef.value?.focus() })
} }
} }
}
const removeFromCart = (index: number) => { const removeFromCart = (index: number) => {
cartItems.value.splice(index, 1) cartItems.value.splice(index, 1)
@ -505,21 +511,73 @@ onUnmounted(() => {
.title-box { font-size: 16px; font-weight: bold; display: flex; align-items: center; gap: 8px; } .title-box { font-size: 16px; font-weight: bold; display: flex; align-items: center; gap: 8px; }
.header-price { font-size: 18px; color: #F56C6C; font-weight: bold; } .header-price { font-size: 18px; color: #F56C6C; font-weight: bold; }
/* 扫码区 */ /* 扫码区(卡片内触发器) */
.scan-section { margin-bottom: 20px; } .scan-section { margin-bottom: 20px; }
.camera-wrapper {
height: 25vh; background: #000; border-radius: 12px; overflow: hidden; position: relative; margin-bottom: 10px;
}
.scan-overlay {
position: absolute; bottom: 10px; right: 10px; z-index: 10;
}
.camera-placeholder { .camera-placeholder {
height: 120px; background: #f5f7fa; border: 1px dashed #dcdfe6; border-radius: 8px; height: 120px; background: #f5f7fa; border: 1px dashed #dcdfe6; border-radius: 8px;
display: flex; flex-direction: column; justify-content: center; align-items: center; display: flex; flex-direction: column; justify-content: center; align-items: center;
color: #909399; margin-bottom: 10px; cursor: pointer; color: #909399; margin-bottom: 10px; cursor: pointer;
transition: all 0.3s;
} }
.camera-placeholder:active { background: #e6e8eb; }
.camera-placeholder .text { margin-top: 5px; font-size: 13px; } .camera-placeholder .text { margin-top: 5px; font-size: 13px; }
/* ★ 全屏扫码层样式 */
.fullscreen-scanner-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: #000;
z-index: 9999;
display: flex;
flex-direction: column;
}
.scanner-header {
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 15px;
background: rgba(0,0,0,0.6);
color: #fff;
position: absolute;
top: 0;
width: 100%;
z-index: 10;
}
.scanner-title { font-size: 16px; font-weight: bold; }
.close-btn { background: rgba(255,255,255,0.2); border: none; color: #fff; }
.scanner-body {
flex: 1;
width: 100%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
/* 强制子组件QrScanner填满容器 */
:deep(.qr-scanner-container) {
width: 100% !important;
height: 100% !important;
border-radius: 0 !important;
}
.scanner-footer {
position: absolute;
bottom: 0;
width: 100%;
padding: 20px;
background: rgba(0,0,0,0.6);
color: #fff;
text-align: center;
z-index: 10;
}
.current-count { color: #67c23a; font-weight: bold; margin-top: 5px; font-size: 16px; }
/* 表单与购物车 */ /* 表单与购物车 */
.cart-section { margin-bottom: 20px; } .cart-section { margin-bottom: 20px; }
.form-section { background: #fff; } .form-section { background: #fff; }