Compare commits

8 Commits

Author SHA1 Message Date
dxc
5513e4cd81 fix: prevent 500 errors in service list endpoint
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-11 14:55:28 +08:00
dxc
83f040728f fix: add missing import and correct SQL query for service list
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-11 14:51:26 +08:00
dxc
d3d35e03cd feat: increase default page size and options for inbound semi list 2026-02-11 14:51:14 +08:00
dxc
9f0134b2e4 feat: add material search filters to semi, product, service modules
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-11 14:42:16 +08:00
dxc
b3fdc65d33 fix: import ref and reactive in dashboard component
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-11 14:29:56 +08:00
dxc
9c70d78d9f feat: add printer settings dialog to dashboard
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-11 14:26:43 +08:00
dxc
cfb36ebf0b feat: add printer config management API
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-11 14:23:43 +08:00
dxc
c6fd0aca90 feat: add dynamic printer configuration manager
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-11 14:14:59 +08:00
16 changed files with 419 additions and 40 deletions

View File

@ -1,6 +1,7 @@
# app/api/v1/common/print.py
from flask import Blueprint, request, jsonify
from app.services.print.label_service import LabelPrintService
from app.services.print.print_config import PrintConfigManager
from app.models.inbound.buy import StockBuy
# 引入其他模型 StockSemi, StockProduct
import traceback
@ -24,4 +25,25 @@ def execute_print():
LabelPrintService.send_to_printer(data)
return jsonify({"code": 200, "msg": "指令已发送至打印机"})
except Exception as e:
return jsonify({"code": 500, "msg": str(e)}), 500
return jsonify({"code": 500, "msg": str(e)}), 500
@print_bp.route('/config', methods=['GET'])
def get_printer_config():
try:
label = PrintConfigManager.get_config('label_printer')
network = PrintConfigManager.get_config('network_printer')
config = {'label_printer': label, 'network_printer': network}
return jsonify({"code": 200, "msg": "success", "data": config})
except Exception as e:
return jsonify({"code": 500, "msg": str(e)}), 500
@print_bp.route('/config', methods=['POST'])
def update_printer_config():
try:
data = request.get_json()
PrintConfigManager.save_config(data)
return jsonify({"code": 200, "msg": "配置保存成功"})
except Exception as e:
return jsonify({"code": 500, "msg": str(e)}), 500

View File

@ -114,3 +114,15 @@ def get_user_suggestions():
keyword = request.args.get('keyword', '')
data = ProductInboundService.search_system_users(keyword)
return jsonify({"code": 200, "msg": "success", "data": data})
# ------------------------------------------------------------------
# 7. 获取筛选选项
# ------------------------------------------------------------------
@inbound_product_bp.route('/options', methods=['GET'])
def get_options():
try:
data = ProductInboundService.get_filter_options()
return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e:
return jsonify({"code": 500, "msg": str(e)}), 500

View File

@ -128,3 +128,15 @@ def get_user_suggestions():
keyword = request.args.get('keyword', '')
data = SemiInboundService.search_system_users(keyword)
return jsonify({"code": 200, "msg": "success", "data": data})
# ------------------------------------------------------------------
# 7. 获取筛选选项
# ------------------------------------------------------------------
@inbound_semi_bp.route('/options', methods=['GET'])
def get_options():
try:
data = SemiInboundService.get_filter_options()
return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e:
return jsonify({"code": 500, "msg": str(e)}), 500

View File

@ -4,6 +4,7 @@ from . import inbound_bp
from app.schemas.stock_schema import stock_service_schema
from app.services.inbound.service_service import ServiceService
from app.utils.decorators import role_required
import traceback
@inbound_bp.route('/service/search-base', methods=['GET'])
@ -49,6 +50,7 @@ def get_service_list():
})
except Exception as e:
current_app.logger.error(f'获取服务列表失败: {str(e)}')
traceback.print_exc()
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
@ -162,3 +164,16 @@ def get_user_suggestions():
keyword = request.args.get('keyword', '')
data = ServiceService.search_system_users(keyword)
return jsonify({'code': 200, 'msg': 'success', 'data': data})
# ------------------------------------------------------------------
# 获取筛选选项
# ------------------------------------------------------------------
@inbound_bp.route('/service/options', methods=['GET'])
@jwt_required()
def get_options():
try:
data = ServiceService.get_filter_options()
return jsonify({'code': 200, 'msg': 'success', 'data': data})
except Exception as e:
return jsonify({'code': 500, 'msg': str(e)}), 500

View File

@ -280,7 +280,7 @@ class ProductInboundService:
# 6. 获取列表
# ============================================================
@staticmethod
def get_list(page, limit, keyword=None, statuses=None):
def get_list(page, limit, keyword=None, statuses=None, category=None, material_type=None):
from app.models.inbound.product import StockProduct
try:
query = db.session.query(StockProduct).outerjoin(MaterialBase, StockProduct.base_id == MaterialBase.id)
@ -295,6 +295,13 @@ class ProductInboundService:
StockProduct.sku.ilike(f'%{keyword}%')
))
# 类别筛选
if category and category.strip():
query = query.filter(MaterialBase.category == category.strip())
# 类型筛选
if material_type and material_type.strip():
query = query.filter(MaterialBase.material_type == material_type.strip())
if not statuses:
statuses = ['在库', '借库']
@ -377,3 +384,25 @@ class ProductInboundService:
return users
except Exception:
return []
# ============================================================
# 8. 获取筛选选项(类别、类型)
# ============================================================
@staticmethod
def get_filter_options():
try:
from app.models.base import MaterialBase
categories = db.session.query(MaterialBase.category) \
.filter(MaterialBase.category != None, MaterialBase.category != '') \
.distinct().all()
types = db.session.query(MaterialBase.material_type) \
.filter(MaterialBase.material_type != None, MaterialBase.material_type != '') \
.distinct().all()
return {
"categories": [r[0] for r in categories],
"types": [r[0] for r in types]
}
except Exception:
import traceback
traceback.print_exc()
return {"categories": [], "types": []}

View File

@ -378,7 +378,7 @@ class SemiInboundService:
# 6. 获取列表
# ============================================================
@staticmethod
def get_list(page, limit, keyword=None, statuses=None):
def get_list(page, limit, keyword=None, statuses=None, category=None, material_type=None):
from app.models.inbound.semi import StockSemi
try:
query = db.session.query(StockSemi).outerjoin(MaterialBase, StockSemi.base_id == MaterialBase.id)
@ -397,6 +397,13 @@ class SemiInboundService:
)
)
# 类别筛选
if category and category.strip():
query = query.filter(MaterialBase.category == category.strip())
# 类型筛选
if material_type and material_type.strip():
query = query.filter(MaterialBase.material_type == material_type.strip())
if not statuses:
statuses = ['在库', '借库']
@ -479,3 +486,25 @@ class SemiInboundService:
return users
except Exception:
return []
# ============================================================
# 8. 获取筛选选项(类别、类型)
# ============================================================
@staticmethod
def get_filter_options():
try:
from app.models.base import MaterialBase
categories = db.session.query(MaterialBase.category) \
.filter(MaterialBase.category != None, MaterialBase.category != '') \
.distinct().all()
types = db.session.query(MaterialBase.material_type) \
.filter(MaterialBase.material_type != None, MaterialBase.material_type != '') \
.distinct().all()
return {
"categories": [r[0] for r in categories],
"types": [r[0] for r in types]
}
except Exception:
import traceback
traceback.print_exc()
return {"categories": [], "types": []}

View File

@ -4,6 +4,7 @@ from app.models.inbound.service import StockService
from app.models.base import MaterialBase
from datetime import datetime, timedelta
import re
import traceback
class ServiceService:
@ -130,24 +131,29 @@ class ServiceService:
query = StockService.query.filter_by(is_deleted=False)
# 关键词搜索:可搜索 SKU 或 关联物料名称
if keyword:
# 子查询查找物料名称匹配的 base_id
subquery = MaterialBase.query.filter(
MaterialBase.name.ilike(f'%{keyword}%')
).subquery()
# 直接子查询
query = query.filter(
db.or_(
StockService.sku.ilike(f'%{keyword}%'),
StockService.base_id.in_([row.id for row in db.session.query(subquery.c.id)])
StockService.base_id.in_(
db.session.query(MaterialBase.id).filter(MaterialBase.name.ilike(f'%{keyword}%'))
)
)
)
if start_date:
start = datetime.strptime(start_date, '%Y-%m-%d')
query = query.filter(StockService.created_at >= start)
try:
start = datetime.strptime(start_date, '%Y-%m-%d')
query = query.filter(StockService.created_at >= start)
except ValueError:
pass # ignore invalid date format
if end_date:
end = datetime.strptime(end_date, '%Y-%m-%d')
# 包含当天
end = end + timedelta(days=1) - timedelta(seconds=1)
query = query.filter(StockService.created_at <= end)
try:
end = datetime.strptime(end_date, '%Y-%m-%d')
# 包含当天
end = end + timedelta(days=1) - timedelta(seconds=1)
query = query.filter(StockService.created_at <= end)
except ValueError:
pass
if provider_name:
query = query.filter(StockService.provider_name.ilike(f'%{provider_name}%'))
# 总数
@ -204,3 +210,25 @@ class ServiceService:
return users
except Exception:
return []
# ============================================================
# 获取筛选选项(类别、类型)
# ============================================================
@classmethod
def get_filter_options(cls):
try:
from app.models.base import MaterialBase
categories = db.session.query(MaterialBase.category) \
.filter(MaterialBase.category != None, MaterialBase.category != '') \
.distinct().all()
types = db.session.query(MaterialBase.material_type) \
.filter(MaterialBase.material_type != None, MaterialBase.material_type != '') \
.distinct().all()
return {
"categories": [r[0] for r in categories],
"types": [r[0] for r in types]
}
except Exception:
import traceback
traceback.print_exc()
return {"categories": [], "types": []}

View File

@ -3,6 +3,7 @@ import base64
import os
from io import BytesIO
from PIL import Image, ImageDraw, ImageFont
from .print_config import PrintConfigManager
# 引入二维码生成库
try:
@ -12,8 +13,7 @@ except ImportError:
class LabelPrintService:
PRINTER_IP = "192.168.9.221"
PRINTER_PORT = 9100
# Printer IP and port now managed by PrintConfigManager
# ================= 1. 尺寸与分辨率配置 (300 DPI) =================
DOTS_PER_MM = 12 # 300 DPI
@ -271,8 +271,9 @@ class LabelPrintService:
@staticmethod
def send_to_printer(data):
ip = LabelPrintService.PRINTER_IP
port = LabelPrintService.PRINTER_PORT
config = PrintConfigManager.get_config('label_printer')
ip = config['ip']
port = config['port']
try:
# 1. 获取 RGB 图像

View File

@ -1,11 +1,13 @@
import socket
import datetime
from .print_config import PrintConfigManager
class NetworkPrintService:
def __init__(self, ip='192.168.9.250', port=9100):
self.ip = ip
self.port = port
def __init__(self, ip=None, port=None):
config = PrintConfigManager.get_config('network_printer')
self.ip = ip if ip is not None else config['ip']
self.port = port if port is not None else config['port']
def _send_to_printer(self, content):
"""
@ -37,4 +39,4 @@ class NetworkPrintService:
def print_stocktake_report(self, data):
# 同样处理
return self._send_to_printer(f"盘点报告: 应盘{data.get('total')}, 实盘{data.get('scanned')}")
return self._send_to_printer(f"盘点报告: 应盘{data.get('total')}, 实盘{data.get('scanned')}")

View File

@ -0,0 +1,61 @@
import json
import os
from pathlib import Path
class PrintConfigManager:
CONFIG_FILENAME = 'printer_config.json'
DEFAULT_CONFIG = {
'label_printer': {'ip': '192.168.9.221', 'port': 9100},
'network_printer': {'ip': '192.168.9.250', 'port': 9100}
}
@classmethod
def _get_config_path(cls):
# Determine the path relative to this file's directory
current_dir = Path(__file__).parent
return current_dir / cls.CONFIG_FILENAME
@classmethod
def get_config(cls, printer_type='label_printer'):
"""
Retrieve configuration for a given printer type.
Returns a dict with 'ip' and 'port'.
"""
config_path = cls._get_config_path()
if not config_path.exists():
# Write default config if not exists
cls.save_config(cls.DEFAULT_CONFIG)
config = cls.DEFAULT_CONFIG
else:
try:
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
except Exception as e:
print(f"Error reading printer config: {e}")
config = cls.DEFAULT_CONFIG
# Return specific printer config, falling back to default for that type
printer_config = config.get(printer_type, cls.DEFAULT_CONFIG.get(printer_type))
# Ensure it's a dict with ip and port
if not printer_config or 'ip' not in printer_config:
printer_config = cls.DEFAULT_CONFIG.get(printer_type, {'ip': '127.0.0.1', 'port': 9100})
return printer_config
@classmethod
def save_config(cls, new_config):
"""
Save entire config dictionary to file.
new_config should be a dict with keys 'label_printer' and/or 'network_printer'.
"""
config_path = cls._get_config_path()
try:
# If file exists, merge existing with new
existing = {}
if config_path.exists():
with open(config_path, 'r', encoding='utf-8') as f:
existing = json.load(f)
existing.update(new_config)
with open(config_path, 'w', encoding='utf-8') as f:
json.dump(existing, f, indent=2)
except Exception as e:
print(f"Error saving printer config: {e}")
raise

View File

@ -14,4 +14,19 @@ export function executePrint(data: any) {
method: 'post',
data
})
}
}
export function getPrinterConfig() {
return request({
url: '/common/print/config',
method: 'get'
})
}
export function updatePrinterConfig(data: any) {
return request({
url: '/common/print/config',
method: 'post',
data
})
}

View File

@ -49,3 +49,11 @@ export function getUserSuggestions(params: any) {
params
})
}
// 筛选选项
export function getFilterOptions() {
return request({
url: '/inbound/product/options',
method: 'get'
})
}

View File

@ -52,3 +52,11 @@ export function getUserSuggestions(params: any) {
params
})
}
// 筛选选项
export function getFilterOptions() {
return request({
url: '/inbound/semi/options',
method: 'get'
})
}

View File

@ -122,6 +122,14 @@ export function getUserSuggestions(params: any) {
})
}
// 筛选选项
export function getFilterOptions() {
return request({
url: '/v1/inbound/service/options',
method: 'get'
})
}
// 删除服务权益
export function deleteService(id: number) {
return request({

View File

@ -4,7 +4,12 @@
<template #header>
<div class="card-header">
<span class="title">👋 欢迎回来{{ userStore.username }}</span>
<el-tag type="success">系统运行正常</el-tag>
<div style="display: flex; align-items: center; gap: 10px;">
<el-tag type="success">系统运行正常</el-tag>
<el-button type="info" plain size="small" @click="openPrinterDialog" :icon="Setting" class="printer-btn">
打印设置
</el-button>
</div>
</div>
</template>
@ -30,20 +35,103 @@
</div>
</div>
</el-card>
<el-dialog v-model="printerDialogVisible" title="打印机 IP 配置" width="500px">
<el-form :model="printerForm" label-width="120px">
<el-form-item label="标签打印机 IP">
<el-input v-model="printerForm.label_ip" placeholder="例如 192.168.9.221" />
</el-form-item>
<el-form-item label="标签打印机端口">
<el-input v-model.number="printerForm.label_port" placeholder="例如 9100" />
</el-form-item>
<el-form-item label="网络打印机 IP">
<el-input v-model="printerForm.network_ip" placeholder="例如 192.168.9.250" />
</el-form-item>
<el-form-item label="网络打印机端口">
<el-input v-model.number="printerForm.network_port" placeholder="例如 9100" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="printerDialogVisible = false">取消</el-button>
<el-button type="primary" @click="savePrinterConfig" :loading="loading">保存</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
// 1. 引入 User Store
import { useUserStore } from '@/stores/user'
// 引入需要的图标
import { Box, TrendCharts, ShoppingCart, Operation } from '@element-plus/icons-vue'
import { Box, TrendCharts, ShoppingCart, Operation, Setting } from '@element-plus/icons-vue'
import { getPrinterConfig, updatePrinterConfig } from '@/api/common/print'
import { ElMessage } from 'element-plus'
const router = useRouter()
// 2. 实例化 store
const userStore = useUserStore()
// 打印机配置相关
const printerDialogVisible = ref(false)
const printerForm = reactive({
label_ip: '',
label_port: '',
network_ip: '',
network_port: ''
})
const loading = ref(false)
const openPrinterDialog = async () => {
try {
loading.value = true
const res = await getPrinterConfig()
if (res.code === 200) {
const config = res.data
printerForm.label_ip = config.label_printer?.ip || ''
printerForm.label_port = config.label_printer?.port || ''
printerForm.network_ip = config.network_printer?.ip || ''
printerForm.network_port = config.network_printer?.port || ''
}
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
printerDialogVisible.value = true
}
const savePrinterConfig = async () => {
try {
loading.value = true
const config = {
label_printer: {
ip: printerForm.label_ip,
port: Number(printerForm.label_port)
},
network_printer: {
ip: printerForm.network_ip,
port: Number(printerForm.network_port)
}
}
const res = await updatePrinterConfig(config)
if (res.code === 200) {
ElMessage.success('保存成功')
printerDialogVisible.value = false
} else {
ElMessage.error(res.msg || '保存失败')
}
} catch (e) {
ElMessage.error('请求异常')
} finally {
loading.value = false
}
}
// 统一跳转函数
const handleNav = (path: string) => {
router.push(path)
@ -109,4 +197,4 @@ const handleNav = (path: string) => {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
</style>
</style>

View File

@ -1,21 +1,36 @@
<template>
<div class="semi-module">
<div class="header-tools">
<div class="left-tools">
<div class="left-tools" style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
<el-input
v-model="queryParams.keyword"
placeholder="🔍 搜索物料 / 批号 / SN / 工单号 / BOM..."
class="search-input"
placeholder="请输入名称或规格"
clearable
@clear="fetchData"
@keyup.enter="fetchData"
style="width: 300px; margin-right: 10px;"
style="width: 240px;"
/>
<el-select
v-model="queryParams.category"
placeholder="类别"
clearable
filterable
@change="fetchData"
style="width: 160px;"
>
<template #append>
<el-button :icon="Search" @click="fetchData"/>
</template>
</el-input>
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-select
v-model="queryParams.material_type"
placeholder="类型"
clearable
filterable
@change="fetchData"
style="width: 160px;"
>
<el-option v-for="item in typeOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-button type="primary" plain @click="fetchData">搜索</el-button>
<el-button @click="resetQuery">重置</el-button>
<el-select
v-model="queryParams.statuses"
multiple
@ -166,7 +181,7 @@
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.pageSize"
:total="total"
:page-sizes="[15, 30, 50, 100]"
:page-sizes="[100, 200, 500, 1000]"
layout="total, sizes, prev, pager, next, jumper"
background
@size-change="fetchData"
@ -436,7 +451,8 @@ import {
createSemiInbound,
updateSemiInbound,
deleteSemiInbound,
searchMaterialBase
searchMaterialBase,
getFilterOptions
} from '@/api/inbound/semi'
import { uploadFile, deleteFile } from '@/api/inbound/buy'
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
@ -453,7 +469,9 @@ const dialogStatus = ref<'create' | 'update'>('create')
const tableData = ref([])
const total = ref(0)
const formRef = ref()
const queryParams = reactive({ page: 1, pageSize: 15, keyword: '', statuses: ['在库', '借库'] })
const queryParams = reactive({ page: 1, pageSize: 100, keyword: '', category: '', material_type: '', statuses: ['在库', '借库'] })
const categoryOptions = ref<string[]>([])
const typeOptions = ref<string[]>([])
const materialOptions = ref<any[]>([])
// 打印相关变量
@ -625,6 +643,26 @@ const fetchData = async () => {
} finally { loading.value = false }
}
const fetchOptions = async () => {
try {
const res: any = await getFilterOptions()
if (res.code === 200) {
categoryOptions.value = res.data.categories
typeOptions.value = res.data.types
}
} catch (e) {
console.error('Fetch options failed', e)
}
}
const resetQuery = () => {
queryParams.keyword = ''
queryParams.category = ''
queryParams.material_type = ''
queryParams.page = 1
fetchData()
}
const handleCreate = () => {
dialogStatus.value = 'create'
resetForm()
@ -780,7 +818,10 @@ const getStatusType = (status: string) => { const map: any = { '在库': 'succes
const getQualityType = (status: string) => { const map: any = { '合格': 'success', '不合格': 'danger', '待检': 'info', '返修中': 'warning' }; return map[status] || 'info' }
const formatMoney = (val: any) => { const num = Number(val); return isNaN(num) ? '-' : `¥ ${num.toFixed(2)}` }
onMounted(() => fetchData())
onMounted(() => {
fetchData()
fetchOptions()
})
</script>
<style scoped>