From 104780d1951fed22d4e2e6f5c8c7a18ed4eead6c Mon Sep 17 00:00:00 2001 From: 97694731 <97694731@qq.com> Date: Fri, 26 Jun 2026 12:01:34 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A0=B8=E4=BB=B7=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 - node_modules/.vite/deps/_metadata.json | 26 +-- src/utils/ServiceManager.js | 28 +++ src/views/config/config.vue | 177 +++++++++++++- src/views/printerManager/printerManager.vue | 26 ++- vite.config.js | 245 ++++++++++++++++++++ 6 files changed, 475 insertions(+), 28 deletions(-) create mode 100644 vite.config.js diff --git a/.gitignore b/.gitignore index 2190f82b..28d8fafb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ dist/ -vite.config.js text.txt diff --git a/node_modules/.vite/deps/_metadata.json b/node_modules/.vite/deps/_metadata.json index 69a87e91..d64e55eb 100644 --- a/node_modules/.vite/deps/_metadata.json +++ b/node_modules/.vite/deps/_metadata.json @@ -1,71 +1,71 @@ { - "hash": "a0d5344e", - "browserHash": "906d96e7", + "hash": "780fd6b7", + "browserHash": "11a50aed", "optimized": { "@element-plus/icons-vue": { "src": "../../@element-plus/icons-vue/dist/index.js", "file": "@element-plus_icons-vue.js", - "fileHash": "b0bff00e", + "fileHash": "e73a6617", "needsInterop": false }, "axios": { "src": "../../axios/index.js", "file": "axios.js", - "fileHash": "3661d772", + "fileHash": "9ada0434", "needsInterop": false }, "crypto-js": { "src": "../../crypto-js/index.js", "file": "crypto-js.js", - "fileHash": "42456c05", + "fileHash": "35b4399e", "needsInterop": true }, "dayjs": { "src": "../../dayjs/dayjs.min.js", "file": "dayjs.js", - "fileHash": "00d3ce60", + "fileHash": "3c458ca9", "needsInterop": true }, "element-plus": { "src": "../../element-plus/es/index.mjs", "file": "element-plus.js", - "fileHash": "8289c005", + "fileHash": "65c76065", "needsInterop": false }, "element-plus/es/locale/lang/zh-cn": { "src": "../../element-plus/es/locale/lang/zh-cn.mjs", "file": "element-plus_es_locale_lang_zh-cn.js", - "fileHash": "2b74d0fe", + "fileHash": "05b5b909", "needsInterop": false }, "jsbarcode": { "src": "../../jsbarcode/bin/JsBarcode.js", "file": "jsbarcode.js", - "fileHash": "5774375a", + "fileHash": "d60441c9", "needsInterop": true }, "json-bigint": { "src": "../../json-bigint/index.js", "file": "json-bigint.js", - "fileHash": "a76f358e", + "fileHash": "4eb91e5f", "needsInterop": true }, "pinia": { "src": "../../pinia/dist/pinia.mjs", "file": "pinia.js", - "fileHash": "3c37150a", + "fileHash": "461b0d9b", "needsInterop": false }, "vue": { "src": "../../vue/dist/vue.runtime.esm-bundler.js", "file": "vue.js", - "fileHash": "c01889f7", + "fileHash": "aa9868f1", "needsInterop": false }, "vue-router": { "src": "../../vue-router/dist/vue-router.mjs", "file": "vue-router.js", - "fileHash": "ced1274b", + "fileHash": "010be937", "needsInterop": false } }, diff --git a/src/utils/ServiceManager.js b/src/utils/ServiceManager.js index b56ab1b9..034faadb 100644 --- a/src/utils/ServiceManager.js +++ b/src/utils/ServiceManager.js @@ -250,6 +250,34 @@ export class ServiceManager { return false; } } + /** + * 注册服务(将 exe 路径注册到启动器) + * @param {Object} serviceInfo - { id, name, exe_path, args } + */ + async registerService(serviceInfo) { + this.log('info', `注册服务: ${serviceInfo.id || serviceInfo.exe_path}`); + this.log('debug', `POST /api/services`); + try { + const response = await fetch(`${this.baseUrl}/api/services`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(serviceInfo) + }); + this.log('debug', `响应状态: ${response.status}`); + const result = await response.json(); + this.log('debug', `响应数据: ${JSON.stringify(result)}`); + if (result.code === 0) { + this.log('success', `注册成功: ${result.msg}`); + return result.data || true; + } + this.log('error', `注册失败: ${result.msg}`); + return false; + } + catch (error) { + this.log('error', `注册请求失败: ${error.message}`); + return false; + } + } // ============================================ // 自动刷新 // ============================================ diff --git a/src/views/config/config.vue b/src/views/config/config.vue index e5b31aeb..216d1957 100644 --- a/src/views/config/config.vue +++ b/src/views/config/config.vue @@ -25,14 +25,16 @@ - - + +
- - 打开程序 + + 选择地址 + + + 打开程序 -
@@ -279,6 +281,7 @@ import { ref, onMounted } from 'vue' import axios from 'axios' import { ElMessage, ElMessageBox } from 'element-plus' import { Delete, Plus, Setting, User, Link, VideoPlay } from '@element-plus/icons-vue' +import { ServiceManager } from '@/utils/ServiceManager' import { testConnection, kongfzLogin, @@ -406,17 +409,166 @@ export default { const bindLoading = ref(false) const result = ref<{ success: boolean; message: string } | null>(null) - /** 通过自定义协议启动本地 kfz-goods-pricing.exe */ - const handleOpenExe = () => { + /** 通过本地服务管理器启动核价器 exe */ + const fileInputRef = ref(null) + const serviceManager = new ServiceManager('http://127.0.0.1:5000') + const SERVICE_ID = 'kfz-goods-pricing' + + const handleBrowseExe = () => { + fileInputRef.value?.click() + } + + const handleFileSelected = async () => { + const input = fileInputRef.value + if (!input?.files?.length) return + const file = input.files[0] + // 优先取完整路径(部分运行环境支持 file.path) + let fullPath = (file as any).path || '' + // 浏览器只给文件名时,从 ServiceManager 查完整路径 + if (!fullPath || !(fullPath.includes('\\') || fullPath.includes('/'))) { + try { + const services = await serviceManager.getServicesStatus() + const matched = services.find(s => s.exe_path && s.exe_path.includes(file.name)) + if (matched) { + fullPath = matched.exe_path + } + } catch { + // ServiceManager 不可用,降级用文件名 + } + } + dir.value = fullPath || file.name + localStorage.setItem(STORAGE_KEY_FILE_DIR, dir.value) + + // 注册到启动器并启动 + if (fullPath && (fullPath.includes('\\') || fullPath.includes('/'))) { + const exeName = file.name.replace(/\.exe$/i, '') + try { + await serviceManager.registerService({ + id: SERVICE_ID, + name: '核价器', + exe_path: fullPath, + }) + ElMessage.success('已注册到启动器') + } catch { + // 注册失败不阻塞 + } + + // 通过 Vite 本地 API 直接启动(优先) + try { + const resp = await fetch('/api/local/launch-exe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ exe_path: fullPath }) + }) + const result = await resp.json() + if (result.code === 0) { + ElMessage.success({ message: `核价器已启动 (PID ${result.pid})`, duration: 1000, customClass: 'scan-success-message' }) + } else { + // 降级:通过 ServiceManager 启动 + await serviceManager.startService(SERVICE_ID) + } + } catch { + // Vite API 不可用,降级 ServiceManager + try { + await serviceManager.startService(SERVICE_ID) + } catch { /* 无法启动 */ } + } + } + // 重置 input 以便重复选择同一文件时仍触发 change 事件 + input.value = '' + } + + const handleOpenExe = async () => { if (!dir.value) { ElMessage.warning({ message: '请先设置程序所在位置', customClass: 'scan-warning-message' }) return } + + // 首选:通过 Vite 开发服务器本地 API 直接启动(无需任何外部脚本/服务) try { - // 调用自定义协议 kfzgs://,launcher.exe 会读取 dir 并启动目标 exe - window.location.href = `kfzgs://launch?dir=${encodeURIComponent(dir.value)}` - } catch (err: any) { - ElMessage.error({ message: `启动失败: ${err.message}`, customClass: 'scan-error-message' }) + const resp = await fetch('/api/local/launch-exe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ exe_path: dir.value }) + }) + const result = await resp.json() + if (result.code === 0) { + ElMessage.success({ message: `核价器已启动 (PID ${result.pid})`, duration: 1000, customClass: 'scan-success-message' }) + return + } + console.warn('[启动程序] Vite 本地 API 失败:', result.msg) + } catch { + // Vite API 不可用,继续降级 + } + + try { + // 尝试通过服务管理器启动 + const services = await serviceManager.getServicesStatus() + if (services.length > 0) { + let target = null + // 完整路径:按 exe_path 精确匹配;仅文件名:直接用 SERVICE_ID + const isFullPath = dir.value.includes('\\') || dir.value.includes('/') + if (isFullPath) { + target = services.find(s => s.exe_path && s.exe_path.includes(dir.value)) + } + if (!target) { + target = services.find(s => s.id === SERVICE_ID) + } + if (target) { + const ok = await serviceManager.startService(target.id) + if (ok) { + ElMessage.success({ message: `已启动: ${target.name || target.id}`, duration: 1000, customClass: 'scan-success-message' }) + return + } + } + } + // 服务管理器不可用或无匹配服务,回退到自定义协议 + console.warn('[启动程序] 服务管理器不可用,回退到自定义协议') + // 分离目录和 exe 文件名 + const lastSep = Math.max(dir.value.lastIndexOf('\\'), dir.value.lastIndexOf('/')) + const dirPath = lastSep > 0 ? dir.value.substring(0, lastSep) : dir.value + const exeName = lastSep > 0 ? dir.value.substring(lastSep + 1) : '' + + let launched = false + // 1. 尝试 kfzgs:// 协议 + try { + if (exeName) { + window.location.href = `kfzgs://launch?dir=${encodeURIComponent(dirPath)}&exe=${encodeURIComponent(exeName)}` + } else { + window.location.href = `kfzgs://launch?dir=${encodeURIComponent(dirPath)}` + } + launched = true + } catch { /* ignore */ } + + // 2. 降级:file:// 直链 + window.open + if (!launched) { + const fileUrl = `file:///${dir.value.replace(/\\/g, '/')}` + const win = window.open(fileUrl, '_blank') + if (!win) { + // 弹窗被拦截,用隐藏 a 标签触发 + const a = document.createElement('a') + a.href = fileUrl + a.target = '_blank' + a.click() + } + } + ElMessage.success({ message: '启动指令已发送', duration: 1000, customClass: 'scan-success-message' }) + } catch (err) { + // 服务管理器连接失败,回退到文件协议 + console.warn('[启动程序] 服务管理器连接失败:', err) + try { + const fileUrl = `file:///${dir.value.replace(/\\/g, '/')}` + const win = window.open(fileUrl, '_blank') + if (!win) { + const a = document.createElement('a') + a.href = fileUrl + a.target = '_blank' + a.click() + } + ElMessage.success({ message: '启动指令已发送', duration: 1000, customClass: 'scan-success-message' }) + } catch (err2) { + ElMessage.error({ message: `启动失败: ${err2.message || '未知错误'}`, customClass: 'scan-error-message' }) + } } } @@ -791,6 +943,9 @@ export default { return { dir, + fileInputRef, + handleBrowseExe, + handleFileSelected, ip, port, verifyIndex, diff --git a/src/views/printerManager/printerManager.vue b/src/views/printerManager/printerManager.vue index 30c703a5..fd622676 100644 --- a/src/views/printerManager/printerManager.vue +++ b/src/views/printerManager/printerManager.vue @@ -70,6 +70,13 @@ 打印条码 + +
+ + + 打印条码 +
+
@@ -128,7 +135,7 @@ const cameraLoading = ref(false) const cameras = ref([]) const selectedCamera = ref('') -const shortcutItems = ['Alt+c', 'Alt+a', 'Alt+x', 'Alt+b'] +const shortcutItems = ['Alt+c', 'Alt+a', 'Alt+x', 'Alt+b', 'Alt+r'] const barcodeImages = reactive({}) shortcutItems.forEach(key => { @@ -191,6 +198,9 @@ const confirmPaperSize = () => { if (paperSize.value) { localStorage.setItem(STORAGE_KEY_PAPER_SIZE, paperSize.value) ElMessage.success({ message: '小票纸大小已保存', duration: 1000, customClass: 'scan-success-message' }) + } else { + localStorage.removeItem(STORAGE_KEY_PAPER_SIZE) + ElMessage.success({ message: '小票纸大小已清除', duration: 1000, customClass: 'scan-success-message' }) } } @@ -206,16 +216,26 @@ const confirmExpressPrinter = () => { const fetchCameras = async () => { cameraLoading.value = true + let stream = null try { + if (!navigator.mediaDevices) { + throw new Error('当前环境不支持摄像头') + } + // HTTP 环境下需先获取摄像头权限再枚举设备 + stream = await navigator.mediaDevices.getUserMedia({ video: true }) const devices = await navigator.mediaDevices.enumerateDevices() cameras.value = devices.filter((d) => d.kind === 'videoinput').map((d) => ({ deviceId: d.deviceId, label: d.label || `摄像头 ${d.deviceId.slice(0, 8)}` })) - } catch { - ElMessage.error({ message: '获取摄像头列表失败', customClass: 'scan-error-message' }) + } catch (err) { + console.warn('[摄像头] 获取列表失败:', err) + ElMessage.error({ message: '获取摄像头列表失败: ' + (err?.message || '未知错误'), customClass: 'scan-error-message' }) cameras.value = [] } finally { + if (stream) { + stream.getTracks().forEach(track => track.stop()) + } cameraLoading.value = false } } diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 00000000..9b8b213a --- /dev/null +++ b/vite.config.js @@ -0,0 +1,245 @@ +const { defineConfig } = require('vite') +const vue = require('@vitejs/plugin-vue').default +const path = require('path') +const { spawn } = require('child_process') + +// https://vitejs.dev/config/ +module.exports = defineConfig({ + css: { + preprocessorOptions: { + scss: { + silenceDeprecations: ['legacy-js-api'], + api: 'modern-compiler' + } + } + }, + plugins: [ + vue(), + // 孔夫子旧书网登录代理(避免浏览器跨域限制) + { + name: 'kongfz-login-proxy', + configureServer(server) { + server.middlewares.use('/kongfz-login', async (req, res) => { + if (req.method !== 'POST') { + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ code: 405, message: '仅支持 POST 请求' })) + return + } + + let body = '' + req.on('data', chunk => { body += chunk.toString() }) + req.on('end', async () => { + try { + const params = new URLSearchParams(body) + const username = params.get('loginName') || '' + const password = params.get('loginPass') || '' + + if (!username || !password) { + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ code: 400, message: '请输入用户名和密码!' })) + return + } + + const https = require('https') + const http = require('http') + + function requestWithRedirect(options, bodyData, redirectCount = 0) { + return new Promise((resolve, reject) => { + if (redirectCount > 5) { + reject(new Error('重定向次数过多')) + return + } + const mod = options.port === 443 ? https : http + const req = mod.request(options, (response) => { + if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { + const redirectUrl = new URL(response.headers.location, `https://${options.hostname}`) + const redirectOpts = { + hostname: redirectUrl.hostname, + port: redirectUrl.port || (redirectUrl.protocol === 'https:' ? 443 : 80), + path: redirectUrl.pathname + redirectUrl.search, + method: 'GET', + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8' + }, + timeout: 15000 + } + if (response.headers['set-cookie']) { + const cookies = Array.isArray(response.headers['set-cookie']) + ? response.headers['set-cookie'].join('; ') + : response.headers['set-cookie'] + redirectOpts.headers['Cookie'] = cookies.split(';')[0].trim() + } + resolve(requestWithRedirect(redirectOpts, null, redirectCount + 1)) + return + } + let responseData = '' + response.on('data', chunk => { responseData += chunk }) + response.on('end', () => resolve({ statusCode: response.statusCode, headers: response.headers, body: responseData })) + }) + req.on('error', err => reject(new Error(`登录请求失败: ${err.message}`))) + req.on('timeout', () => { req.destroy(); reject(new Error('登录请求超时')) }) + if (bodyData) req.write(bodyData) + req.end() + }) + } + + const formData = new URLSearchParams() + formData.append('loginName', username) + formData.append('loginPass', password) + formData.append('returnUrl', 'http://user.kongfz.com/') + const formDataStr = formData.toString() + + const loginResponse = await requestWithRedirect({ + hostname: 'login.kongfz.com', + port: 443, + path: '/Pc/Login/account', + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': Buffer.byteLength(formDataStr), + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8' + }, + timeout: 15000 + }, formDataStr) + + if (loginResponse.statusCode !== 200) { + throw new Error(`登录失败(HTTP状态码: ${loginResponse.statusCode})`) + } + + const setCookie = loginResponse.headers['set-cookie'] || [] + const cookieStr = Array.isArray(setCookie) ? setCookie.join('; ') : setCookie || '' + + if (loginResponse.body.includes("window.location.href='https://login.kongfz.cn/Pc/Session/rsync")) { + if (!cookieStr) throw new Error('登录成功但未获取到Cookie') + if (!cookieStr.includes('PHPSESSID=')) throw new Error('登录失败: 未找到 PHPSESSID') + + const token = cookieStr.split('PHPSESSID=')[1].split(';')[0] + + const userInfoResponse = await requestWithRedirect({ + hostname: 'user.kongfz.com', + port: 443, + path: '/User/Index/getUserInfo/', + method: 'GET', + headers: { + 'Cookie': `PHPSESSID=${token}`, + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Accept': 'application/json, text/plain, */*', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8' + }, + timeout: 15000 + }, null) + + let displayName = username + try { + const userJson = JSON.parse(userInfoResponse.body) + if (userJson.status && userJson.data) displayName = userJson.data.nickname || username + } catch { } + + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ + code: 200, message: '登录成功', + data: { token, username: displayName, nickname: displayName } + })) + } else { + try { + const errJson = JSON.parse(loginResponse.body) + if (errJson.errCode === 1001 || errJson.errCode === 1005) throw new Error('账号或密码错误') + throw new Error(errJson.errInfo || '登录失败,未知错误!') + } catch (parseErr) { + if (parseErr instanceof SyntaxError) throw new Error('登录失败,未知错误!') + throw parseErr + } + } + } catch (error) { + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ code: 500, message: error.message })) + } + }) + }) + } + }, + // 本地程序启动器(无需协议注册/脚本/额外服务,Vite 服务器直接 spawn exe) + { + name: 'local-launcher', + configureServer(server) { + server.middlewares.use('/api/local/launch-exe', (req, res) => { + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'Content-Type') + + if (req.method === 'OPTIONS') { + res.statusCode = 204 + return res.end() + } + + if (req.method !== 'POST') { + res.setHeader('Content-Type', 'application/json') + return res.end(JSON.stringify({ code: 405, msg: '仅支持 POST' })) + } + + let body = '' + req.on('data', chunk => { body += chunk.toString() }) + req.on('end', () => { + try { + const { exe_path } = JSON.parse(body) + if (!exe_path) { + res.setHeader('Content-Type', 'application/json') + return res.end(JSON.stringify({ code: 400, msg: '缺少 exe_path' })) + } + + const fs = require('fs') + if (!fs.existsSync(exe_path)) { + res.setHeader('Content-Type', 'application/json') + return res.end(JSON.stringify({ code: 404, msg: `文件不存在: ${exe_path}` })) + } + + const exeDir = path.dirname(exe_path) + const proc = spawn(exe_path, [], { + cwd: exeDir, + detached: true, + stdio: 'ignore', + windowsHide: false + }) + proc.unref() + + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ code: 0, msg: '已启动', pid: proc.pid })) + } catch (err) { + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ code: 500, msg: err.message })) + } + }) + }) + } + } + ], + resolve: { + alias: { + '@': path.resolve(__dirname, './src') + } + }, + define: { + __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false' + }, + server: { + port: 5174, + host: '0.0.0.0', + historyApiFallback: true, + proxy: { + '/api': { + // target: 'http://127.0.0.1:9090', + // target: 'http://192.168.101.213:9090', + target: 'https://psi.api.buzhiyushu.cn', + // target: 'https://psi.api.buzhiyushu.cn', + changeOrigin: true + }, + '/api/print': { + // target: 'http://192.168.101.127:8075', + target: 'https://print.buzhiyushu.cn', + changeOrigin: true + } + } + } +})