From 8b2c08920ff6fa097b7a66117ebc8f2eadcb3c1b Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Tue, 13 Oct 2026 17:24:57 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E5=AF=B9=E6=8E=A5=E8=AF=BB?= =?UTF-8?q?=E5=86=99=E5=8D=A1=E5=99=A8=20feat=EF=BC=9A=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E4=B8=8B=E6=8B=89=E6=A1=86=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=20feat=EF=BC=9A=E4=BC=98=E5=8C=96=E4=B8=8B=E5=8D=95=E5=8D=A1?= =?UTF-8?q?=E9=A1=BF=E9=97=AE=E9=A2=98=20feat=EF=BC=9A=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=95=86=E6=88=B7=E5=88=97=E8=A1=A8=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/wallet/mch-manage.ts | 10 + src/api/wallet/mch-rule-join.ts | 55 -- .../DeviceWidget/CardReaderDialog.vue | 489 +++++++++++++ src/components/DeviceWidget/index.vue | 107 +++ src/components/Select/SelectMchRule.vue | 105 --- src/components/Select/SelectUser.vue | 3 +- src/components/index.ts | 2 - src/i18n/lang/en.ts | 51 ++ src/i18n/lang/zh-cn.ts | 51 ++ src/layout/component/main.vue | 2 + src/types/mitt.d.ts | 4 +- src/types/web-hid.d.ts | 55 ++ src/utils/card-reader/index.ts | 523 ++++++++++++++ src/utils/card-reader/protocol.ts | 306 ++++++++ src/views/hard/hard-manager/details.vue | 16 +- src/views/hard/hard-order/index.vue | 41 ++ src/views/mch/components/mch-rule-desc.vue | 34 - src/views/mch/mch-manage/detail.vue | 40 +- src/views/mch/mch-manage/form.vue | 380 ++++++---- src/views/mch/mch-manage/index.vue | 652 ++++++++++++------ .../mch/mch-manage/mch-rule-config-form.vue | 128 ---- src/views/mch/mch-manage/mch-rule-config.vue | 146 ---- src/views/mch/mch-manage/setting.vue | 128 ---- src/views/wallet/wallet-active/index.vue | 107 ++- 24 files changed, 2461 insertions(+), 974 deletions(-) delete mode 100644 src/api/wallet/mch-rule-join.ts create mode 100644 src/components/DeviceWidget/CardReaderDialog.vue create mode 100644 src/components/DeviceWidget/index.vue delete mode 100644 src/components/Select/SelectMchRule.vue create mode 100644 src/types/web-hid.d.ts create mode 100644 src/utils/card-reader/index.ts create mode 100644 src/utils/card-reader/protocol.ts delete mode 100644 src/views/mch/components/mch-rule-desc.vue delete mode 100644 src/views/mch/mch-manage/mch-rule-config-form.vue delete mode 100644 src/views/mch/mch-manage/mch-rule-config.vue delete mode 100644 src/views/mch/mch-manage/setting.vue diff --git a/src/api/wallet/mch-manage.ts b/src/api/wallet/mch-manage.ts index f250cff9..86099bba 100644 --- a/src/api/wallet/mch-manage.ts +++ b/src/api/wallet/mch-manage.ts @@ -56,6 +56,16 @@ export function getObj(id?: string) { }) } +/** + * 查询商户饭卡扇区密码(读卡器工具用,仅返回 id 与卡密码) + */ +export function getCardKey(id?: string) { + return request({ + url: "/wallet/mch-manage/card-key/" + id, + method: "get", + }) +} + export function delObjs(ids?: Object) { return request({ url: "/wallet/mch-manage", diff --git a/src/api/wallet/mch-rule-join.ts b/src/api/wallet/mch-rule-join.ts deleted file mode 100644 index 8c1ab490..00000000 --- a/src/api/wallet/mch-rule-join.ts +++ /dev/null @@ -1,55 +0,0 @@ -import request from "/@/utils/request" - -export function pageJoin(query?: Object) { - return request({ - url: "/wallet/mch-rule-join/page-join", - method: "get", - params: query, - }) -} - -export function fetchList(query?: Object) { - return request({ - url: "/wallet/mch-rule-join/page", - method: "get", - params: query, - }) -} - -export function addObj(obj?: Object) { - return request({ - url: "/wallet/mch-rule-join", - method: "post", - data: obj, - }) -} - -export function getObj(id?: string) { - return request({ - url: "/wallet/mch-rule-join/" + id, - method: "get", - }) -} - -export function getJoinDetail(id?: string) { - return request({ - url: "/wallet/mch-rule-join/join-detail/" + id, - method: "get", - }) -} - -export function delObjs(ids?: Object) { - return request({ - url: "/wallet/mch-rule-join", - method: "delete", - data: ids, - }) -} - -export function putObj(obj?: Object) { - return request({ - url: "/wallet/mch-rule-join", - method: "put", - data: obj, - }) -} diff --git a/src/components/DeviceWidget/CardReaderDialog.vue b/src/components/DeviceWidget/CardReaderDialog.vue new file mode 100644 index 00000000..3fd93cc5 --- /dev/null +++ b/src/components/DeviceWidget/CardReaderDialog.vue @@ -0,0 +1,489 @@ + + + + + diff --git a/src/components/DeviceWidget/index.vue b/src/components/DeviceWidget/index.vue new file mode 100644 index 00000000..d95ba46e --- /dev/null +++ b/src/components/DeviceWidget/index.vue @@ -0,0 +1,107 @@ + + + diff --git a/src/components/Select/SelectMchRule.vue b/src/components/Select/SelectMchRule.vue deleted file mode 100644 index f02ff95d..00000000 --- a/src/components/Select/SelectMchRule.vue +++ /dev/null @@ -1,105 +0,0 @@ - - - diff --git a/src/components/Select/SelectUser.vue b/src/components/Select/SelectUser.vue index e505edc0..511c2746 100644 --- a/src/components/Select/SelectUser.vue +++ b/src/components/Select/SelectUser.vue @@ -58,7 +58,8 @@ const props = withDefaults(defineProps(), { excludeUserIds: () => [], multiple: false, valueField: "userId", - size: 10, + // 下拉接口关闭了 COUNT 且不翻页,一次拉取条数需要覆盖长尾匹配结果 + size: 50, }) /** 远程搜索条件,用户输入关键词后从第一页重新加载。 */ diff --git a/src/components/index.ts b/src/components/index.ts index b9315c99..c1fd12f9 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -34,7 +34,6 @@ import SelectCtcg from "/@/components/Select/SelectCtcg.vue" import SelectCtcgol from "/@/components/Select/SelectCtcgol.vue" import SelectHandlerUser from "/@/components/Select/SelectHandlerUser.vue" import SelectSupermarketSupplier from "/@/components/Select/SelectSupermarketSupplier.vue" -import SelectMchRule from "/@/components/Select/SelectMchRule.vue" import SelectHardSpuCategory from "/@/components/Select/SelectHardSpuCategory.vue" import SelectHard from "/@/components/Select/SelectHard.vue" import SelectHardSpu from "/@/components/Select/SelectHardSpu.vue" @@ -109,7 +108,6 @@ export default { app.component("SelectHardGroup", SelectHardGroup) app.component("SelectHandlerUser", SelectHandlerUser) app.component("SelectSupermarketSupplier", SelectSupermarketSupplier) - app.component("SelectMchRule", SelectMchRule) app.component("SelectRole", SelectRole) app.component("SelectCabinet", SelectCabinet) app.component("SelectCanteenSupplier", SelectCanteenSupplier) diff --git a/src/i18n/lang/en.ts b/src/i18n/lang/en.ts index cf6d3d74..c6c4ef19 100644 --- a/src/i18n/lang/en.ts +++ b/src/i18n/lang/en.ts @@ -197,4 +197,55 @@ export default { btnTwo: 'Update Now', btnTwoLoading: 'Updating', }, + deviceWidget: { + fabLabel: 'Device tools', + panelTitle: 'Device tools', + cardReader: { + name: 'USB card reader', + desc: 'Read IC card number (M1)', + unsupported: 'Unsupported', + unsupportedTitle: 'This browser cannot connect USB card readers', + unsupportedDesc: 'Please use Chrome or Edge and access the system via HTTPS or localhost, then try again.', + connect: 'Connect reader', + connecting: 'Connecting', + changeDevice: 'Change device', + disconnect: 'Disconnect', + connected: 'Connected', + disconnected: 'Not connected', + readCard: 'Read card', + reading: 'Reading', + readHint: 'Place the card flat on the reader; card No., UID and content are read at once', + beep: 'Beep', + noCardYet: 'Not read yet. Click "Read card" above and place the card on the reader', + contentNotRead: 'Card content not read (wrong key or read failed)', + cardNoLabel: 'Card No. (decimal)', + uidLabel: 'UID (hex)', + cardType: 'Card type', + readAt: 'Read at', + cardContent: 'Content', + mch: 'Merchant', + mchLocked: 'Merchant is locked to the selected row and cannot be changed', + keyHex: 'Card key', + keyPlaceholder: 'Auto-filled after selecting a merchant', + keyView: 'Reveal full key', + keyHide: 'Hide key', + keyAutoHint: 'Auto-filled from the selected merchant (full value restricted); blank cards are auto-detected', + writeSection: 'Write card', + writeDataLabel: 'Content', + writeDataPlaceholder: '8, 10 or 12 digits/letters, e.g. 1234567890', + writeBtn: 'Write', + writeTip: 'Shorter content is automatically padded with spaces to 16 bytes; existing content is filled in after reading for editing.', + writePreview: 'Actual write', + writeDataInvalid: 'Content must be 8, 10 or 12 digits/letters', + writeConfirmTitle: 'Confirm writing to card?', + writeConfirm: '"{content}" will be written to the card. This modifies the card data. Continue?', + writeOk: 'Written successfully', + hex: 'Hex', + dec: 'Dec', + frames: 'Protocol frames', + clearFrames: 'Clear', + noFrames: 'No frames', + close: 'Close', + }, + }, }; diff --git a/src/i18n/lang/zh-cn.ts b/src/i18n/lang/zh-cn.ts index c1cd522e..0dc54a31 100644 --- a/src/i18n/lang/zh-cn.ts +++ b/src/i18n/lang/zh-cn.ts @@ -190,4 +190,55 @@ export default { btnTwo: '马上更新', btnTwoLoading: '更新中', }, + deviceWidget: { + fabLabel: '设备工具', + panelTitle: '设备工具', + cardReader: { + name: 'USB 读卡器', + desc: '读取 IC 卡卡号(M1 卡)', + unsupported: '浏览器不支持', + unsupportedTitle: '当前浏览器不支持连接 USB 读卡器', + unsupportedDesc: '请使用 Chrome 或 Edge 浏览器,并通过 HTTPS 或本机地址(localhost)访问系统后重试。', + connect: '连接读卡器', + connecting: '连接中', + changeDevice: '更换设备', + disconnect: '断开连接', + connected: '已连接', + disconnected: '未连接', + readCard: '读取卡片', + reading: '读取中', + readHint: '将卡片平放在读写器感应区,一次读出卡号、UID 和卡内容', + beep: '设备鸣响', + noCardYet: '尚未读取,点击上方「读取卡片」,将卡片平放在读写器感应区', + contentNotRead: '卡内容未读取(密码不正确或读取失败)', + cardNoLabel: '卡号(十进制)', + uidLabel: 'UID(十六进制)', + cardType: '卡片类型', + readAt: '读取时间', + cardContent: '卡内容', + mch: '商户', + mchLocked: '商户已锁定为当前记录的所属商户,不可更改', + keyHex: '饭卡密码', + keyPlaceholder: '选择商户后自动带入', + keyView: '查看完整密码', + keyHide: '隐藏密码', + keyAutoHint: '选择商户后自动带入(仅授权人员可查看完整值),空白新卡自动识别', + writeSection: '写卡', + writeDataLabel: '写入内容', + writeDataPlaceholder: '8、10 或 12 位数字/字母,如卡号 1234567890', + writeBtn: '写入', + writeTip: '内容不足 16 字节时自动在后面补空格;读取后此处会自动填入卡上现有内容,直接修改即可。', + writePreview: '实际写入', + writeDataInvalid: '写入内容须为 8、10 或 12 位数字/字母', + writeConfirmTitle: '确认写入卡片?', + writeConfirm: '即将把「{content}」写入卡片,此操作会修改卡片数据,确认继续?', + writeOk: '写入成功', + hex: '十六进制', + dec: '十进制', + frames: '通讯报文', + clearFrames: '清空', + noFrames: '暂无报文', + close: '关 闭', + }, + }, }; diff --git a/src/layout/component/main.vue b/src/layout/component/main.vue index 0abd8fc0..ce509f73 100644 --- a/src/layout/component/main.vue +++ b/src/layout/component/main.vue @@ -11,6 +11,7 @@ + @@ -23,6 +24,7 @@ import { NextLoading } from '/@/utils/loading'; const LayoutParentView = defineAsyncComponent(() => import('/@/layout/routerView/parent.vue')); const CheckToken = defineAsyncComponent(() => import('/@/components/CheckToken/index.vue')); const Chat = defineAsyncComponent(() => import('/@/components/Chat/index.vue')); +const DeviceWidget = defineAsyncComponent(() => import('/@/components/DeviceWidget/index.vue')); // 定义变量内容 const layoutMainScrollbarRef = ref(); diff --git a/src/types/mitt.d.ts b/src/types/mitt.d.ts index b68b80df..2bb98173 100644 --- a/src/types/mitt.d.ts +++ b/src/types/mitt.d.ts @@ -11,6 +11,7 @@ * @method openShareTagsView 布局设置弹窗,开启 TagsView 共用 * @method onTagsViewRefreshRouterView tagsview 刷新界面 * @method onCurrentContextmenuClick tagsview 右键菜单每项点击时 + * @method cardReaderRead 串口读写卡器读到卡片(DeviceWidget 全局设备工具广播,业务页面可订阅) */ declare type MittType = { openSetingsDrawer?: string; @@ -20,9 +21,10 @@ declare type MittType = { getBreadcrumbIndexSetFilterRoutes?: string; layoutMobileResize: T; openOrCloseSortable?: string; - openShareTagsView?: string; + openShareTagsView?: T; onTagsViewRefreshRouterView?: T; onCurrentContextmenuClick?: T; + cardReaderRead: T; }; // mitt 参数类型定义 diff --git a/src/types/web-hid.d.ts b/src/types/web-hid.d.ts new file mode 100644 index 00000000..b377ff85 --- /dev/null +++ b/src/types/web-hid.d.ts @@ -0,0 +1,55 @@ +/** + * WebHID API 最小类型声明 + * + * TS 4.9 的 lib.dom 未收录该实验性 API,本地声明以避免引入额外依赖。 + * 规范: https://developer.mozilla.org/en-US/docs/Web/API/WebHID_API + * 仅声明本项目用到的成员,hid 属性声明为可选,运行时以 `'hid' in navigator` 探测。 + */ + +interface HIDDeviceRequestOptions { + filters: Array<{ + vendorId?: number; + productId?: number; + usagePage?: number; + usage?: number; + }>; +} + +interface HIDDeviceInfo { + readonly vendorId: number; + readonly productId: number; + readonly productName: string; +} + +interface HIDDevice extends HIDDeviceInfo { + readonly opened: boolean; + open(): Promise; + close(): Promise; + forget(): Promise; + sendReport(reportId: number, data: BufferSource): Promise; + sendFeatureReport(reportId: number, data: BufferSource): Promise; + receiveFeatureReport(reportId: number): Promise; + addEventListener(type: 'inputreport', listener: (event: HIDInputReportEvent) => void): void; + removeEventListener(type: 'inputreport', listener: (event: HIDInputReportEvent) => void): void; +} + +interface HIDInputReportEvent extends Event { + readonly device: HIDDevice; + readonly reportId: number; + readonly data: DataView; +} + +interface HIDConnectionEvent extends Event { + readonly device: HIDDevice; +} + +interface HID { + getDevices(): Promise; + requestDevice(options: HIDDeviceRequestOptions): Promise; + addEventListener(type: 'connect' | 'disconnect', listener: (event: HIDConnectionEvent) => void): void; + removeEventListener(type: 'connect' | 'disconnect', listener: (event: HIDConnectionEvent) => void): void; +} + +interface Navigator { + readonly hid?: HID; +} diff --git a/src/utils/card-reader/index.ts b/src/utils/card-reader/index.ts new file mode 100644 index 00000000..7149aa0a --- /dev/null +++ b/src/utils/card-reader/index.ts @@ -0,0 +1,523 @@ +/** + * USB 免驱读写卡器通信层(荣士 IC-02 / OUR 系列,浏览器 WebHID 直连) + * + * 要求 Chrome/Edge 桌面版 + HTTPS 或 localhost 访问。模块级单例状态(仿 + * src/api/plugin/cardPrinter.ts 的模式):弹窗反复开关共享同一次连接,设备保持打开, + * 读卡失败/超时不自动断开,仅物理拔出或显式断开时释放。 + * 设备授权会被浏览器持久记住:弹窗打开时静默自动重连(免选择器直连),换设备用 pickDevice()。 + * + * 开发联调:.env 中 VITE_PLUGIN_MOCK=true 可脱离硬件模拟读卡全流程。 + */ +import { reactive } from 'vue'; +import emitter from '/@/utils/mitt'; +import { + CMD, + OUR_USB_VID, + buildAuthKeyE2Payload, + buildChangeKeyPayload, + buildLoadKeyPayload, + buildOurFrame, + buildOurFrames, + buildReadBlocksPayload, + buildWriteBlocksPayload, + crc8Ours, + parseKeyHex, + parseOurPacket, + statusText, + toHex, +} from './protocol'; + +export * from './protocol'; + +const MOCK_ENABLED = import.meta.env.VITE_PLUGIN_MOCK === 'true' || import.meta.env.VITE_PLUGIN_MOCK === '1'; + +/** 单帧收发超时(ms) */ +const TRANSCEIVE_TIMEOUT = 3000; +/** 报文日志上限 */ +const FRAME_LOG_LIMIT = 50; + +export interface CardInfo { + /** 卡号十六进制(大写无分隔,如 04A1B2C3) */ + uid: string; + uidBytes: number[]; + /** M1 / NTAG/DesFire */ + cardType: string; + readAt: number; +} + +export interface FrameLogItem { + /** tx 发送帧 / rx 收到完整有效帧 / rx-bad 校验失败帧 / rx-raw 收到的原始字节(未成帧,联调判断设备是否应答) / sys 连接事件 */ + dir: 'tx' | 'rx' | 'rx-bad' | 'rx-raw' | 'sys' | 'mock'; + /** hex 为报文十六进制;sys 事件时为文字说明 */ + hex: string; + at: number; +} + +export type PortState = 'disconnected' | 'connecting' | 'connected'; + +interface Waiter { + resolve: (payload: number[]) => void; + reject: (err: Error) => void; + timer: number; +} + +const state = reactive<{ + /** 浏览器是否支持 WebHID */ + hidSupported: boolean; + portState: PortState; + reading: boolean; + beeping: boolean; + lastCard: CardInfo | null; + error: string; + portInfo: { usbVendorId?: number; usbProductId?: number } | null; + frames: FrameLogItem[]; +}>({ + hidSupported: typeof navigator !== 'undefined' && 'hid' in navigator, + portState: 'disconnected', + reading: false, + beeping: false, + lastCard: null, + error: '', + portInfo: null, + frames: [], +}); + +let hidDevice: HIDDevice | null = null; +let hidWaiter: Waiter | null = null; +/** 当前等待的应答命令回显,用于过滤迟到/串线的无关报文 */ +let hidExpectedEcho = 0; + +const pushFrame = (dir: FrameLogItem['dir'], hex: string) => { + state.frames.unshift({ dir, hex, at: Date.now() }); + if (state.frames.length > FRAME_LOG_LIMIT) state.frames.length = FRAME_LOG_LIMIT; +}; + +// --------------------------------------------------------------------------- +// 收包:先记录原始字节(判断设备是否应答过任何数据),再按 78 68 同步头提取应答。 +// 应答首字节须匹配当前指令的命令回显,不匹配的(迟到旧包)丢弃,避免串线。 +// --------------------------------------------------------------------------- +const onInputReport = (event: HIDInputReportEvent) => { + const bytes = Array.from(new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength)); + if (bytes.length) pushFrame('rx-raw', toHex(bytes)); + const payload = parseOurPacket(bytes); + if (!payload) return; + if (hidWaiter && payload[0] !== hidExpectedEcho) { + pushFrame('rx-bad', `串线报文已丢弃: ${toHex(buildOurFrame(payload))}`); + return; + } + pushFrame('rx', toHex(buildOurFrame(payload))); + if (hidWaiter) { + const waiter = hidWaiter; + hidWaiter = null; + window.clearTimeout(waiter.timer); + waiter.resolve(payload); + } +}; + +const onHidDisconnect = (event: HIDConnectionEvent) => { + if (event.device === hidDevice) { + handlePortLost(); + state.error = '设备连接已断开,请重新连接'; + pushFrame('sys', 'USB 设备已断开(拔出)'); + } +}; + +// 物理插拔监听全局注册一次 +if (typeof navigator !== 'undefined' && navigator.hid) { + navigator.hid.addEventListener('disconnect', onHidDisconnect); +} + +const waitHidPayload = (timeout: number) => + new Promise((resolve, reject) => { + const timer = window.setTimeout(() => { + hidWaiter = null; + reject(new Error('读写器无响应,请检查设备连接后重试')); + }, timeout); + hidWaiter = { + timer, + resolve: (payload) => { + window.clearTimeout(timer); + resolve(payload); + }, + reject: (err) => { + window.clearTimeout(timer); + reject(err); + }, + }; + }); + +const hidTransceive = async (payload: number[], timeout = TRANSCEIVE_TIMEOUT): Promise => { + if (!hidDevice?.opened) throw new Error('设备未连接'); + const echo = payload[0]; + for (const frame of buildOurFrames(payload)) { + await hidDevice.sendReport(0, new Uint8Array(frame)); + pushFrame('tx', toHex(frame)); + } + if (hidWaiter) { + const waiter = hidWaiter; + hidWaiter = null; + waiter.reject(new Error('新的指令已发出,上一条等待作废')); + } + hidExpectedEcho = echo; + return await waitHidPayload(timeout); +}; + +/** 打开读写器并挂收包监听(热更新等场景设备可能已被之前的页面实例打开,直接接管) */ +const openHidDevice = async (device: HIDDevice): Promise => { + if (!device.opened) await device.open(); + hidDevice = device; + device.addEventListener('inputreport', onInputReport); + state.portInfo = { usbVendorId: device.vendorId, usbProductId: device.productId }; + state.portState = 'connected'; + pushFrame('sys', `USB 免驱读卡器已连接 VID:${toHex16(device.vendorId)} PID:${toHex16(device.productId)}`); +}; + +const toHex16 = (value: number) => value.toString(16).toUpperCase().padStart(4, '0'); + +const handlePortLost = () => { + if (hidDevice) { + try { + hidDevice.removeEventListener('inputreport', onInputReport); + } catch { + // 设备已拔出时移除监听可能报错,忽略 + } + } + hidDevice = null; + loadedKeyTag = ''; + if (hidWaiter) { + const waiter = hidWaiter; + hidWaiter = null; + window.clearTimeout(waiter.timer); + waiter.reject(new Error('设备连接已断开')); + } + state.portState = 'disconnected'; + state.portInfo = null; +}; + +/** + * 连接读写器。优先复用浏览器已授权的 OUR 设备(免弹窗直连);找不到时弹出系统设备 + * 选择器(requestDevice 需在用户点击手势调用链内触发)。 + * @param options.silent 静默模式:只尝试已授权设备,找不到不弹选择器、不报错,用于弹窗打开时自动重连 + * @returns 是否连接成功(用户在系统弹窗取消选择也返回 false,不视为错误) + */ +const connect = async (options: { silent?: boolean } = {}): Promise => { + if (!state.hidSupported) throw new Error('当前浏览器不支持 USB 设备操作,请使用 Chrome/Edge 并通过 HTTPS 或本机地址访问'); + if (state.portState !== 'disconnected') return state.portState === 'connected'; + state.error = ''; + state.portState = 'connecting'; + try { + if (MOCK_ENABLED) { + await new Promise((resolve) => setTimeout(resolve, 400)); + state.portState = 'connected'; + pushFrame('mock', '已启用模拟模式(VITE_PLUGIN_MOCK)'); + return true; + } + const hid = navigator.hid; + if (!hid) throw new Error('当前浏览器不支持 USB 设备操作'); + // 1) 浏览器记住的已授权设备:优先厂家识别号(VID 0801),其次设备名 IC 开头(不同批次 VID 可能不同) + const granted = await hid.getDevices(); + const authorized = granted.find((d) => d.vendorId === OUR_USB_VID) ?? granted.find((d) => (d.productName ?? '').toUpperCase().startsWith('IC')); + if (authorized) { + await openHidDevice(authorized); + return true; + } + if (options.silent) { + state.portState = 'disconnected'; + return false; + } + // 2) 弹出系统设备选择器(按厂商号过滤) + let devices: HIDDevice[]; + try { + devices = await hid.requestDevice({ filters: [{ vendorId: OUR_USB_VID }] }); + } catch { + // 用户在系统弹窗取消选择 + state.portState = 'disconnected'; + return false; + } + if (!devices.length) { + state.portState = 'disconnected'; + return false; + } + await openHidDevice(devices[0]); + return true; + } catch (e) { + handlePortLost(); + state.error = `连接失败:${e instanceof Error ? e.message : String(e)}`; + return false; + } +}; + +/** 强制重新弹出选择器更换读写器(会先断开当前连接) */ +const pickDevice = async (): Promise => { + if (!state.hidSupported) throw new Error('当前浏览器不支持 USB 设备操作'); + if (state.portState === 'connected') await disconnect(); + state.error = ''; + state.portState = 'connecting'; + try { + const devices = await navigator.hid!.requestDevice({ filters: [{ vendorId: OUR_USB_VID }] }); + if (!devices.length) { + state.portState = 'disconnected'; + return false; + } + await openHidDevice(devices[0]); + return true; + } catch (e) { + state.portState = 'disconnected'; + const msg = e instanceof Error ? e.message : String(e); + if (/No device|cancel|abort/i.test(msg)) return false; + state.error = `连接失败:${msg}`; + return false; + } +}; + +/** 主动断开并释放设备 */ +const disconnect = async (): Promise => { + const device = hidDevice; + handlePortLost(); + if (MOCK_ENABLED) return; + if (!device) return; + try { + await device.close(); + } catch { + // 忽略 + } + pushFrame('sys', 'USB 免驱读卡器已断开'); +}; + +// --------------------------------------------------------------------------- +// 业务指令 +// --------------------------------------------------------------------------- +/** 读卡号应答解析:payload = [F0 回显][状态][卡号4字节][crc8(状态+卡号)] */ +const parseOurReadIdResponse = (payload: number[]): { uidBytes: number[]; cardType: string } => { + if (payload[0] !== CMD.READ_ID) throw new Error(`应答命令不符(0x${payload[0].toString(16)})`); + const status = payload[1]; + if (status !== 0x00) throw new Error(statusText(status)); + if (payload.length < 7) throw new Error('应答缺少卡号数据'); + const body = payload.slice(1, 6); + if (crc8Ours(body) !== payload[6]) throw new Error('应答校验失败,请重新读卡'); + const uidBytes = payload.slice(2, 6); + return { uidBytes, cardType: 'M1' }; +}; + +const readCard = async (): Promise => { + if (state.reading) throw new Error('正在读取中,请稍候'); + state.error = ''; + if (MOCK_ENABLED) { + state.reading = true; + try { + await new Promise((resolve) => setTimeout(resolve, 600)); + const rand = new Uint8Array(2); + crypto.getRandomValues(rand); + const uidBytes = [0x04, 0xa1, rand[0], rand[1]]; + const payload = [CMD.READ_ID, 0x00, ...uidBytes, crc8Ours([0x00, ...uidBytes])]; + pushFrame('tx', toHex(buildOurFrame([CMD.READ_ID]))); + pushFrame('rx', toHex(buildOurFrame(payload))); + const card: CardInfo = { uid: toHex(uidBytes), uidBytes, cardType: 'M1', readAt: Date.now() }; + state.lastCard = card; + emitter.emit('cardReaderRead', card); + return card; + } finally { + state.reading = false; + } + } + if (state.portState !== 'connected') throw new Error('设备未连接,请先连接读写器'); + state.reading = true; + try { + const payload = await hidTransceive([CMD.READ_ID]); + const { uidBytes, cardType } = parseOurReadIdResponse(payload); + const card: CardInfo = { uid: toHex(uidBytes), uidBytes, cardType, readAt: Date.now() }; + state.lastCard = card; + emitter.emit('cardReaderRead', card); + return card; + } catch (e) { + state.error = e instanceof Error ? e.message : String(e); + throw e; + } finally { + state.reading = false; + } +}; + +/** 蜂鸣鸣响(用于确认链路/找回设备位置),超时不视为失败 */ +const beep = async (): Promise => { + if (state.beeping) return; + if (!MOCK_ENABLED && state.portState !== 'connected') throw new Error('设备未连接,请先连接读写器'); + state.beeping = true; + try { + if (MOCK_ENABLED) { + pushFrame('mock', 'BEEP'); + await new Promise((resolve) => setTimeout(resolve, 200)); + return; + } + // OUR 蜂鸣:0F 时长低 时长高 + crc8(两字节时长) + const duration = [0xff, 0x00]; + await hidTransceive([CMD.BEEP, duration[0], duration[1], crc8Ours(duration)], 1500); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (!msg.includes('无响应')) throw e; + } finally { + state.beeping = false; + } +}; + +// --------------------------------------------------------------------------- +// 扇区读写(本设备实际流程:装载密码(87) → 寻卡(F0) → C3认证紧邻 → F7读/F8写) +// 认证策略(用户无感):先用商户密码认证;失败自动用出厂密码 FFFFFFFFFFFF 再试; +// 出厂密码能通说明是空白新卡 → 自动把卡密码改为商户密码(0x3C,设备不支持则跳过), +// 之后统一用商户密码读写。 +// --------------------------------------------------------------------------- +export interface SectorReadResult { + uidBytes: number[]; + /** 16 字节卡内容(系统约定的扇区固定数据块) */ + data: number[]; +} + +/** 出厂密码(空白新卡) */ +const FACTORY_KEY = 'FFFFFFFFFFFF'; + +/** 已装载进读写器 E2 的密码缓存标记,避免每次读写都重装 */ +let loadedKeyTag = ''; + +/** 密码认证失败专用错误,用于触发出厂密码降级 */ +class AuthFailError extends Error {} + +/** 把扇区密码装载进读写器 E2(应答 [87][状态]),装载失败抛白话提示 */ +const ensureReaderKey = async (area: number, keyAB: 0 | 1, keyHex: string): Promise => { + const tag = `${area}:${keyAB}:${keyHex}`; + if (loadedKeyTag === tag) return; + const payload = await hidTransceive(buildLoadKeyPayload(area, keyAB, parseKeyHex(keyHex))); + if (payload[0] !== CMD.KEY_LOAD_E2) throw new Error(`应答命令不符(0x${payload[0].toString(16)})`); + const status = payload[1]; + if (status !== 0x00) throw new Error('密码装载失败:' + statusText(status)); + loadedKeyTag = tag; +}; + +/** 寻卡(F0,选中卡片)——认证必须紧随其后 */ +const requestCard = async (): Promise => { + const card = await readCard(); + return card.uidBytes; +}; + +/** 用 E2 已装载密码认证扇区(应答 [C3][状态])。紧跟寻卡,此时卡片必然在位,失败即密码不对。 */ +const authKeyE2 = async (uidBytes: number[], area: number, keyAB: 0 | 1): Promise => { + const payload = await hidTransceive(buildAuthKeyE2Payload(uidBytes, area, keyAB)); + if (payload[0] !== CMD.AUTH_KEY_E2) throw new Error(`应答命令不符(0x${payload[0].toString(16)})`); + const status = payload[1]; + if (status !== 0x00) { + throw new AuthFailError('饭卡密码不正确,卡片拒绝了读写。请核对商户"饭卡密码"配置是否与这张卡一致,或更换正确的卡'); + } +}; + +/** 装载密码 + 寻卡 + 认证一条龙 */ +const authFlow = async (area: number, keyAB: 0 | 1, keyHex: string): Promise => { + await ensureReaderKey(area, keyAB, keyHex); + const uidBytes = await requestCard(); + await authKeyE2(uidBytes, area, keyAB); + return uidBytes; +}; + +/** 用 0x3C 把卡密码从旧密码改为新密码(需已用旧密码认证),设备不支持时抛错由调用方降级 */ +const changeCardKeyCmd = async (uidBytes: number[], area: number, keyAB: 0 | 1, oldKeyHex: string, newKeyHex: string): Promise => { + const payload = await hidTransceive(buildChangeKeyPayload(uidBytes, area, keyAB, parseKeyHex(oldKeyHex), parseKeyHex(newKeyHex))); + if (payload[0] !== CMD.CHANGE_KEY) throw new Error(`应答命令不符(0x${payload[0].toString(16)})`); + const status = payload[1]; + if (status !== 0x00) throw new Error(statusText(status)); + loadedKeyTag = ''; +}; + +/** + * 认证入口:先商户密码,失败降级出厂密码;空白卡自动改密为商户密码(尽力而为)。 + * 返回完成认证的卡号。两次都失败抛 AuthFailError(白话提示在 error.message)。 + */ +const authWithFallback = async (area: number, keyAB: 0 | 1, mchKeyHex: string): Promise => { + // 1) 商户密码直接认证 + try { + return await authFlow(area, keyAB, mchKeyHex); + } catch (e) { + if (!(e instanceof AuthFailError)) throw e; + } + // 2) 出厂密码(空白新卡) + if (mchKeyHex.toUpperCase() === FACTORY_KEY) { + throw new AuthFailError('饭卡密码不正确,卡片拒绝了读写。请核对商户"饭卡密码"配置是否与这张卡一致,或更换正确的卡'); + } + let uidBytes: number[]; + try { + uidBytes = await authFlow(area, keyAB, FACTORY_KEY); + } catch (e) { + if (e instanceof AuthFailError) { + throw new AuthFailError('饭卡密码不正确,卡片拒绝了读写。请核对商户"饭卡密码"配置是否与这张卡一致,或更换正确的卡'); + } + throw e; + } + // 3) 空白卡:自动把密码改为商户密码;设备不支持改密命令时跳过,本次仍用出厂密码完成 + try { + await changeCardKeyCmd(uidBytes, area, keyAB, FACTORY_KEY, mchKeyHex); + return await authFlow(area, keyAB, mchKeyHex); + } catch { + loadedKeyTag = ''; + return uidBytes; + } +}; + +/** 系统约定:饭卡数据统一存扇区 1 的第 1 块(扇区内块号 1,绝对块 5),界面不暴露块概念 */ +const DATA_BLOCK_OFFSET = 1; + +/** + * 读卡内容(扇区固定数据块 16 字节),F7 读出。 + * 应答 payload = [F7][状态][字节数][数据][crc(状态至数据末)]。 + * 失败原因写入 state.error 供弹窗展示。 + */ +const readSector = async (area: number, keyAB: 0 | 1, mchKeyHex: string): Promise => { + try { + const uidBytes = await authWithFallback(area, keyAB, mchKeyHex); + const payload = await hidTransceive(buildReadBlocksPayload(area * 4 + DATA_BLOCK_OFFSET, 1)); + if (payload[0] !== CMD.BLOCKS_READ) throw new Error(`应答命令不符(0x${payload[0].toString(16)})`); + const status = payload[1]; + if (status !== 0x00) throw new Error(statusText(status)); + if (payload.length < 3) throw new Error('应答数据不完整'); + if (crc8Ours(payload.slice(1, -1)) !== payload[payload.length - 1]) throw new Error('应答校验失败,请重试'); + const count = payload[2]; + if (payload.length < 3 + count + 1) throw new Error('应答数据不完整'); + return { uidBytes, data: payload.slice(3, 3 + count).slice(0, 16) }; + } catch (e) { + state.error = e instanceof Error ? e.message : String(e); + throw e; + } +}; + +/** + * 写卡内容(16 字节写到扇区固定数据块),F8 写入。 + * 应答 payload = [F8][状态]。 + * 失败原因写入 state.error 供弹窗展示。 + */ +const writeSector = async (area: number, keyAB: 0 | 1, mchKeyHex: string, blockData: number[]): Promise<{ uidBytes: number[] }> => { + if (blockData.length !== 16) throw new Error('单块数据须为 16 字节'); + try { + const uidBytes = await authWithFallback(area, keyAB, mchKeyHex); + const payload = await hidTransceive(buildWriteBlocksPayload(area * 4 + DATA_BLOCK_OFFSET, 1, blockData)); + if (payload[0] !== CMD.BLOCKS_WRITE) throw new Error(`应答命令不符(0x${payload[0].toString(16)})`); + const status = payload[1]; + if (status !== 0x00) throw new Error(statusText(status)); + return { uidBytes }; + } catch (e) { + state.error = e instanceof Error ? e.message : String(e); + throw e; + } +}; + +const clearFrames = () => { + state.frames = []; +}; + +export function useCardReader() { + return { + state, + connect, + disconnect, + pickDevice, + readCard, + readSector, + writeSector, + beep, + clearFrames, + }; +} diff --git a/src/utils/card-reader/protocol.ts b/src/utils/card-reader/protocol.ts new file mode 100644 index 00000000..64aa10bd --- /dev/null +++ b/src/utils/card-reader/protocol.ts @@ -0,0 +1,306 @@ +/** + * OUR(荣士)USB 免驱读写卡器协议层("21xh" 帧) + * + * 依据厂家 libOURMIFARE.so 逆向 + 真机应答帧验证: + * 传输:64 字节 HID 报文,帧 = [0x78, 0x68, len, payload..., 0x00 填充] + * 应答:报文同样以 78 68 开头,剥掉 3 字节头后的 payload = [命令回显, 状态, 数据..., crc] + * 读卡号:cmd 0xF0,应答 payload = [F0, 状态, 卡号0~3, crc8(状态+卡号)] + * 蜂鸣:cmd 0x0F + 时长低字节 + 时长高字节 + crc8(两字节时长),应答 [0F, 00] + * 错误码:08 寻不到卡 / 09 多卡冲突 / 0A 无法激活 / 0B 密码装载失败 / 0C 密码认证失败 + */ + +/** 命令字(帧 payload 首字节) */ +export const CMD = { + /** 蜂鸣器鸣响 */ + BEEP: 0x0f, + /** Mifare One 读卡号(寻卡+选卡,认证/读写前必须先执行) */ + READ_ID: 0xf0, + /** 用读写器 E2 已装载的密码校验扇区(寻卡后紧接着执行) */ + AUTH_KEY_E2: 0xc3, + /** 按绝对块号读多个数据块(认证后执行) */ + BLOCKS_READ: 0xf7, + /** 按绝对块号写多个数据块(认证后执行) */ + BLOCKS_WRITE: 0xf8, + /** 读单个数据块(16 字节,认证后执行) */ + READ_BLOCK: 0xb4, + /** 写单个数据块(16 字节,认证后执行) */ + WRITE_BLOCK: 0xa5, + /** 把扇区密码装载进读写器 E2 存储 */ + KEY_LOAD_E2: 0x87, + /** 修改卡片扇区密码(gen1 加密族,部分固件不支持) */ + CHANGE_KEY: 0x3c, +} as const; + +/** 应答状态码 -> 白话提示 */ +export const STATUS_TEXT: Record = { + 0x01: '密码认证成功,但读取扇区内容失败', + 0x08: '感应区没有检测到卡片,请将卡片放好后再试', + 0x09: '感应区内有两张以上卡片,请一次只放一张', + 0x0a: '无法选择激活卡片,请重新放卡', + 0x0b: '密码装载失败', + 0x0c: '卡片密码认证失败', + 0x0d: '读卡数据失败,请确认卡片未移开且密码正确', + 0x14: '写卡数据失败,请确认卡片未移开且密码正确', + 0x1b: '读写器拒绝了该指令', +}; + +/** 状态码转提示,未收录给通用兜底 */ +export const statusText = (status: number): string => { + return STATUS_TEXT[status] || `读写器返回异常状态(0x${toHexByte(status)})`; +}; + +/** OUR 读卡器 USB 厂商识别号(用于 WebHID 设备过滤) */ +export const OUR_USB_VID = 0x0801; + +/** OUR 报文同步头 */ +const OUR_FRAME_HEAD = [0x78, 0x68]; + +/** + * OUR 私有校验的半字节查找表(来自厂家 libOURMIFARE.so 的 cal_crcm8v2 逆向, + * 经真实读卡应答帧 + 20 组 oracle 随机向量双重验证,勿改动) + */ +const OUR_T1 = [0xc3, 0xe2, 0xc7, 0xad, 0x1a, 0xff, 0xa0, 0x85, 0xf8, 0x8d, 0xe9, 0xed, 0xe5, 0x3d, 0x7f, 0xa0]; +const OUR_T2 = [0x00, 0x31, 0x62, 0x53, 0xc4, 0xf5, 0xa6, 0x97, 0xb9, 0x88, 0xdb, 0xea, 0x7d, 0x4c, 0x1f, 0x2e]; + +/** OUR 应答校验(厂家私有状态机算法,覆盖范围见各命令的应答格式) */ +export const crc8Ours = (bytes: number[]): number => { + if (bytes.length === 0) return 0xaa; + let crc = 0; + let cnt = (bytes.length * 2) & 0xff; + let ptr = 0; + let b = bytes[ptr]; + let v = OUR_T1[(b >> 2) & 0xf] ^ b; + let n2 = v & 0x7; + if (n2 === 0) n2 = 1; + let n3 = v >> 4; + while (true) { + const n1 = crc >> 4; + crc = ((crc << 4) & 0xff) ^ OUR_T2[(n1 ^ n3) & 0xf]; + cnt = (cnt - 1) & 0xff; + if (cnt === 0) return (crc ^ 0xaa) & 0xff; + if (cnt & 1) { + n3 = v & 0xf; + continue; + } + n2 = (n2 - 1) & 0xff; + if (n2 !== 0) { + ptr += 1; + b = bytes[ptr]; + v = OUR_T1[(b >> 2) & 0xf] ^ b; + n3 = v >> 4; + continue; + } + v = OUR_T1[(cnt >> 1) & 0xf]; + n3 = v >> 4; + n2 = v & 0x7; + if (n2 <= 1) n2 = 2; + cnt = (cnt + 2) & 0xff; + } +}; + +/** 按 OUR 协议组 64 字节报文:[78 68][len][payload][补零](payload ≤ 61 字节) */ +export const buildOurFrame = (payload: number[]): number[] => { + if (payload.length === 0 || payload.length > 61) throw new Error(`指令长度非法: ${payload.length}`); + const frame: number[] = new Array(64).fill(0); + frame[0] = OUR_FRAME_HEAD[0]; + frame[1] = OUR_FRAME_HEAD[1]; + frame[2] = payload.length; + for (let i = 0; i < payload.length; i++) frame[3 + i] = payload[i]; + return frame; +}; + +/** + * 按 OUR 协议把超长 payload 组成多个 64 字节报文(写扇区等 61 字节以上指令): + * 首包 [78 68][总长][payload 前 61 字节](恰好占满 64 字节),后续包 [78 68][剩余][补零]。 + */ +export const buildOurFrames = (payload: number[]): number[][] => { + if (payload.length <= 61) return [buildOurFrame(payload)]; + const frames: number[][] = []; + const first: number[] = new Array(64).fill(0); + first[0] = OUR_FRAME_HEAD[0]; + first[1] = OUR_FRAME_HEAD[1]; + first[2] = payload.length; + for (let i = 0; i < 61; i++) first[3 + i] = payload[i]; + frames.push(first); + for (let offset = 61; offset < payload.length; offset += 61) { + const chunk = payload.slice(offset, offset + 61); + const next: number[] = new Array(64).fill(0); + next[0] = OUR_FRAME_HEAD[0]; + next[1] = OUR_FRAME_HEAD[1]; + for (let i = 0; i < chunk.length; i++) next[2 + i] = chunk[i]; + frames.push(next); + } + return frames; +}; + +/** + * 厂家魔改 IDEA 加密的子密钥表(libOURMIFARE.so 的 EKV2,52 个 16 位子密钥)。 + * 与标准 IDEA 的唯一差异在轮末赋值:x2=C^I、x4=D^J(标准为 C^J、D^I), + * 已用容器 oracle 的 24 组随机向量 + 真机帧双重验证。 + */ +const IDEA_EK = [ + 0x39cd, 0x6963, 0x5a84, 0x8e71, 0xbc10, 0xbab1, 0x674f, 0x9b2f, 0xc6b5, 0x091c, 0xe378, 0x2175, 0x62ce, 0x9f36, 0x5e73, 0x9ad2, + 0x39c6, 0xf042, 0xeac5, 0x9d3e, 0x6cbc, 0xe735, 0xa58d, 0x6a12, 0x85d5, 0x8b3a, 0x7cd9, 0x79ce, 0x6b4b, 0x1ad4, 0x2473, 0x8de0, + 0x74f9, 0xb2f3, 0x9cd6, 0x9635, 0xa848, 0xe71b, 0xc10b, 0xab16, 0xe739, 0xad2c, 0x6b50, 0x91ce, 0x3782, 0x1756, 0x2ce9, 0xf365, + 0x58d6, 0xa123, 0x9c6f, 0x042e, +]; + +/** IDEA 乘法:模 65537,0 视为 65536,结果 65536 归 0 */ +const ideaMul = (a: number, b: number): number => { + const x = a === 0 ? 0x10000 : a; + const y = b === 0 ? 0x10000 : b; + return ((x * y) % 0x10001) & 0xffff; +}; + +/** + * 厂家加密变换(ideaturnv2(src, dst)):把 src 前 8 字节加密后输出。 + * 扇区读/写等带密码指令发送前,先构造明文 body,再把变换结果覆盖到 body[6..13] + * (即明文帧 = [命令][控制字][卡号4B] + [加密8字节] + [其余数据]),最后再做 CRC。 + */ +const ideaTurn = (src: number[]): number[] => { + let x1 = (src[0] << 8) | src[1]; + let x2 = (src[2] << 8) | src[3]; + let x3 = (src[4] << 8) | src[5]; + let x4 = (src[6] << 8) | src[7]; + for (let r = 0; r < 8; r++) { + const k = 6 * r; + const a = ideaMul(x1, IDEA_EK[k]); + const b = (x2 + IDEA_EK[k + 1]) & 0xffff; + const c = (x3 + IDEA_EK[k + 2]) & 0xffff; + const d = ideaMul(x4, IDEA_EK[k + 3]); + const g = ideaMul(a ^ c, IDEA_EK[k + 4]); + const h = (g + (b ^ d)) & 0xffff; + const i = ideaMul(h, IDEA_EK[k + 5]); + const j = (g + i) & 0xffff; + x1 = a ^ i; + x2 = c ^ i; + x3 = b ^ j; + x4 = d ^ j; + } + const out = [ideaMul(x1, IDEA_EK[48]), (x3 + IDEA_EK[49]) & 0xffff, (x2 + IDEA_EK[50]) & 0xffff, ideaMul(x4, IDEA_EK[51])]; + const bytes: number[] = []; + for (const w of out) bytes.push((w >> 8) & 0xff, w & 0xff); + return bytes; +}; + +/** + * 用读写器 E2 已装载的密码校验扇区指令 payload(8 字节,寻卡后紧接着发送): + * [C3][卡号4B][区号][密码类型][crc(卡号+区号+类型共6字节)] + * 密码本体已通过 buildLoadKeyPayload 预先装载进读写器。 + */ +export const buildAuthKeyE2Payload = (uidBytes: number[], area: number, keyAB: 0 | 1): number[] => { + const body = [CMD.AUTH_KEY_E2, ...uidBytes.slice(0, 4), area, keyAB]; + return [...body, crc8Ours(body.slice(1, 7))]; +}; + +/** + * 读单个数据块指令 payload(3 字节):[B4][绝对块号][crc(命令字节)] + */ +export const buildReadBlockPayload = (blockNo: number): number[] => { + return [CMD.READ_BLOCK, blockNo, crc8Ours([CMD.READ_BLOCK])]; +}; + +/** + * 按绝对块号读多个数据块指令 payload(4 字节): + * [F7][起始块号][块数量][crc(块号+块数量)] + * 应答 payload = [F7][状态][字节数][数据][crc(状态至数据末)]。 + */ +export const buildReadBlocksPayload = (blockNo: number, blockNum: number): number[] => { + const body = [CMD.BLOCKS_READ, blockNo, blockNum]; + return [...body, crc8Ours(body.slice(1, 3))]; +}; + +/** + * 修改卡片扇区密码指令 payload(21 字节,需先用旧密码认证): + * 明文 [3C][控制字][卡号4B][区号][A/B][旧密码6B][新密码6B] → + * 双段加密:前 8 字节密文覆盖到 [4..11],[12..19] 原地加密 → + * [3C][控制字][卡号2B][密文8B][密文8B][crc(前20字节)] + * 应答 payload = [3C][状态]。 + */ +export const buildChangeKeyPayload = (uidBytes: number[], area: number, keyAB: 0 | 1, oldKey: number[], newKey: number[]): number[] => { + const plain = [CMD.CHANGE_KEY, 0, ...uidBytes.slice(0, 4), area, keyAB, ...oldKey.slice(0, 6), ...newKey.slice(0, 6)]; + const buf = [...plain]; + const t1 = ideaTurn(plain.slice(0, 8)); + for (let i = 0; i < 8; i++) buf[4 + i] = t1[i]; + const t2 = ideaTurn(plain.slice(12, 20)); + for (let i = 0; i < 8; i++) buf[12 + i] = t2[i]; + return [...buf, crc8Ours(buf.slice(0, 20))]; +}; + +/** + * 按绝对块号写多个数据块指令 payload(4 + 16×块数 字节): + * [F8][起始块号][块数量][数据 16×块数][crc(块号+块数量+数据)] + * 应答 payload = [F8][状态]。 + */ +export const buildWriteBlocksPayload = (blockNo: number, blockNum: number, data: number[]): number[] => { + if (data.length !== blockNum * 16) throw new Error(`写块数据须为 ${blockNum * 16} 字节,当前 ${data.length}`); + const body = [CMD.BLOCKS_WRITE, blockNo, blockNum, ...data]; + return [...body, crc8Ours(body.slice(1, body.length))]; +}; + +/** + * 写单个数据块指令 payload(19 字节):[A5][绝对块号][数据16B][crc(块号+数据共17字节)] + */ +export const buildWriteBlockPayload = (blockNo: number, blockData: number[]): number[] => { + if (blockData.length !== 16) throw new Error(`单块数据须为 16 字节,当前 ${blockData.length}`); + const body = [CMD.WRITE_BLOCK, blockNo, ...blockData]; + return [...body, crc8Ours(body.slice(1, 18))]; +}; + +/** + * 把扇区密码装载进读写器 E2 存储指令 payload(10 字节): + * [87][IDEA(区号+密码类型+密码6B)][crc(密文8字节)] + * F7/F8 读写块时读写器用已装载的密码自动认证。 + * @param keyAB 1 = A 密码,0 = B 密码 + */ +export const buildLoadKeyPayload = (area: number, keyAB: 0 | 1, key: number[]): number[] => { + const cipher = ideaTurn([area, keyAB, ...key.slice(0, 6)]); + return [CMD.KEY_LOAD_E2, ...cipher, crc8Ours(cipher)]; +}; + +/** 6 字节密码的十六进制串转字节数组(12 个 hex 字符) */ +export const parseKeyHex = (hex: string): number[] => { + const clean = hex.replace(/\s/g, ''); + if (!/^[0-9A-Fa-f]{12}$/.test(clean)) throw new Error('密码须为 12 位十六进制字符(6 字节)'); + return clean.match(/.{2}/g)!.map((b) => parseInt(b, 16)); +}; + +/** 任意长度数据十六进制串转字节数组(偶数个 hex 字符) */ +export const parseDataHex = (hex: string): number[] => { + const clean = hex.replace(/\s/g, ''); + if (!/^[0-9A-Fa-f]*$/.test(clean) || clean.length % 2 !== 0) throw new Error('数据须为偶数个十六进制字符'); + return clean.match(/.{2}/g)?.map((b) => parseInt(b, 16)) ?? []; +}; + +/** + * 从原始收包字节中提取 OUR 应答 payload:按 78 68 同步头定位、按长度截取。 + * 兼容报文前多出/缺少 reportId 字节的位移情况。 + */ +export const parseOurPacket = (bytes: number[]): number[] | null => { + for (let i = bytes.indexOf(OUR_FRAME_HEAD[0]); i >= 0 && i + 3 <= bytes.length; i = bytes.indexOf(OUR_FRAME_HEAD[0], i + 1)) { + if (bytes[i + 1] !== OUR_FRAME_HEAD[1]) continue; + const len = bytes[i + 2]; + if (len === 0 || i + 3 + len > bytes.length) continue; + return bytes.slice(i + 3, i + 3 + len); + } + return null; +}; + +/** 字节数组转大写十六进制串,如 [0x04,0xa1] -> "04A1" */ +export const toHex = (bytes: number[]): string => { + return bytes.map((b) => toHexByte(b)).join(''); +}; + +export const toHexByte = (b: number): string => { + return (b & 0xff).toString(16).toUpperCase().padStart(2, '0'); +}; + +/** 卡号十六进制串转无符号十进制(按大端) */ +export const hexToDecimal = (hex: string): string => { + if (!/^[0-9A-Fa-f]+$/.test(hex) || hex.length === 0) return ''; + try { + return BigInt(`0x${hex}`).toString(10); + } catch { + return ''; + } +}; diff --git a/src/views/hard/hard-manager/details.vue b/src/views/hard/hard-manager/details.vue index 63117fe9..02b2d0a7 100644 --- a/src/views/hard/hard-manager/details.vue +++ b/src/views/hard/hard-manager/details.vue @@ -44,8 +44,9 @@ {{ runtimeStatusText }} {{ - isPrinterAsset ? printerProxyTargetText : isCoffeeMachine ? "屋里咖啡平台维护在线状态" : detail.lastOnlineTime || "暂无心跳时间" - }} + isPrinterAsset ? printerProxyTargetText : isCoffeeMachine ? "屋里咖啡平台维护在线状态" : + detail.lastOnlineTime || "暂无心跳时间" + }}
{{ isPrinterAsset || isCoffeeMachine ? "客户端归属" : "客户端版本" }} @@ -113,7 +114,7 @@
{{ isPrinterAsset ? "设备编号" : "唯一编号" }}
{{ isPrinterAsset ? "不适用(无屏 USB 外设)" : displayValue(detail.uniqueCode, - "尚未绑定") }} + "尚未绑定") }}
@@ -263,7 +264,7 @@
{{ faceConfigured ? "人脸 SDK 授权已配置" : "人脸 SDK 配置不完整" }} {{ faceConfigured ? "App ID、SDK Key 和 Active Key 均已维护" : "请补齐三项授权信息后再进行终端识别" - }} + }}
@@ -341,7 +342,7 @@
USB VID / PID
{{ formatUsbId(detail.usbVendorId) }} / {{ formatUsbId(detail.usbProductId) - }}
+ }}
纸张与切刀
@@ -372,7 +373,7 @@
纸张与切刀
{{ detail.paperWidth || 80 }} mm / {{ detail.autoCut === "0" ? "不自动切刀" : "自动切刀" - }}
+ }}
图片指令模式
@@ -633,7 +634,8 @@

屋里咖啡扫码支付接入信息与本机最近交易

- +
diff --git a/src/views/hard/hard-order/index.vue b/src/views/hard/hard-order/index.vue index eac2cf7e..f80d0417 100644 --- a/src/views/hard/hard-order/index.vue +++ b/src/views/hard/hard-order/index.vue @@ -24,6 +24,14 @@ + + + + 消费机订单号重复 + + @@ -394,6 +409,7 @@ import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue" import { downBlobFile } from "/@/utils/other" import { TableColumnCtx } from "element-plus" import { VNode } from "vue" +import dayjs from "dayjs" const { push } = useRouter() @@ -548,6 +564,29 @@ const searchOrders = () => { getDataList() } +/** 终端订单号重复筛查未选创建时间时的兜底窗口;同号重复单都在秒级窗口内先后产生,三个月足够覆盖。 */ +const defaultDuplicateCreateTimeList = () => [ + dayjs().subtract(3, "month").startOf("day").format("YYYY-MM-DD HH:mm:ss"), + dayjs().hour(23).minute(59).second(59).format("YYYY-MM-DD HH:mm:ss"), +] + +/** + * 切换「消费机订单号重复」筛查:列表只显示终端本地订单号出现多次的订单(仅食堂、超市终端订单有该号), + * 用于定位网络抖动超时重试造成的重复建单扣款;再次点击恢复普通查询。 + */ +const toggleDuplicateAndroidCode = () => { + if (state.queryForm.duplicateAndroidOrderCode) { + state.queryForm.duplicateAndroidOrderCode = false + } else { + // 服务端按创建时间窗口统计同号订单,未选时间时兜底最近三个月,避免无索引列全表扫描 + if (!state.queryForm.createTimeList || state.queryForm.createTimeList.length !== 2) { + state.queryForm.createTimeList = defaultDuplicateCreateTimeList() + } + state.queryForm.duplicateAndroidOrderCode = true + } + searchOrders() +} + // 清空搜索条件 const resetQuery = () => { // 清空搜索条件 @@ -556,6 +595,8 @@ const resetQuery = () => { clearBatchSelection() // state.queryForm.deptId = "" state.queryForm.userDeptIds = [] + // 消费机订单号重复筛查是独立按钮状态,不随表单 resetFields 回收,需手动关闭。 + state.queryForm.duplicateAndroidOrderCode = false draggableDepTreeRef.value.resetData() getDataList() } diff --git a/src/views/mch/components/mch-rule-desc.vue b/src/views/mch/components/mch-rule-desc.vue deleted file mode 100644 index 0b1b85df..00000000 --- a/src/views/mch/components/mch-rule-desc.vue +++ /dev/null @@ -1,34 +0,0 @@ - - - diff --git a/src/views/mch/mch-manage/detail.vue b/src/views/mch/mch-manage/detail.vue index 8cb470c8..d0ad19c5 100644 --- a/src/views/mch/mch-manage/detail.vue +++ b/src/views/mch/mch-manage/detail.vue @@ -1,7 +1,17 @@ diff --git a/src/views/mch/mch-manage/index.vue b/src/views/mch/mch-manage/index.vue index b2685a66..8f2596df 100644 --- a/src/views/mch/mch-manage/index.vue +++ b/src/views/mch/mch-manage/index.vue @@ -1,174 +1,176 @@ @@ -180,20 +182,19 @@ import { useDict } from "/@/hooks/dict" import { useUserInfo } from "/@/stores/userInfo" import FormDialog from "./form.vue" -import Setting from "./setting.vue" const { push } = useRouter() const { merchant_type, wallet_type } = useDict("merchant_type", "wallet_type") // 定义变量内容 const formDialogRef = ref() -const settingDialogRef = ref() // 搜索变量 const queryRef = ref() const showSearch = ref(true) -// 多选变量 -const selectObjs = ref([]) as any -const multiple = ref(true) +// 只有管理员可以拨动同步/展示开关 +const isAdmin = computed(() => useUserInfo().userInfos.roleCodes.includes("ROLE_ADMIN")) +// 营业时间四档时段文案 +const jobTimeLabels = ["早上", "中午", "晚上", "夜间餐"] const state: BasicTableProps = reactive({ queryForm: {}, @@ -202,8 +203,39 @@ const state: BasicTableProps = reactive({ const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, downBlobFile, tableStyle } = useTable(state) +// 当前页总店/分店数量,用于页头统计 +const parentCount = computed(() => (state.dataList || []).filter((row: any) => isParentMch(row)).length) +const branchCount = computed(() => (state.dataList || []).filter((row: any) => !isParentMch(row)).length) + +/** 总店行:parentId 为空或 -1;分店作为子行挂在其下。 */ +function isParentMch(row: any): boolean { + return !row.parentId || row.parentId == "-1" +} + +/** 商户属性展示文案。 */ +function merchantAttrLabel(row: any): string { + const attrMap: Record = { SCHOOL: "学校", GOVERNMENT: "政府", FACTORY: "工厂" } + return attrMap[row.merchantAttr] || "普通" +} + +/** 商户类型字典值转中文(多个用 、 连接),有分类时追加分类名。 */ +function typeText(row: any): string { + if (!row.types) return "" + const labels = row.types + .split(",") + .map((v: string) => merchant_type.value.find((d: any) => d.value == v)?.label || v) + .join("、") + return row.catgoryName ? `${labels} · ${row.catgoryName}` : labels +} + +/** 取第 i 档营业时间段(HH:mm-HH:mm),无值返回空串。 */ +function jobTimePart(row: any, i: number): string { + return row.jobTime?.split(",")[i] || "" +} + +// 分店行浅绿底色,保留树形层级视觉 const tableRowClassName = ({ row }: { row: any }) => { - if (row.parentId && row.parentId != "-1") { + if (!isParentMch(row)) { return "success-row" } } @@ -212,8 +244,6 @@ const tableRowClassName = ({ row }: { row: any }) => { const resetQuery = () => { // 清空搜索条件 queryRef.value?.resetFields() - // 清空多选 - selectObjs.value = [] getDataList() } @@ -221,22 +251,6 @@ const toMchDetail = (row: any) => { push({ path: "/mch/mch-manage/detail", query: { mchId: row.id } }) } -// 重新生成正元二维码 -const refreshQrcode = async (row: any) => { - try { - await useMessageBox().confirm("是否重新生成正元二维码") - } catch (e) { - return - } - try { - await mchManangeApi.refreshQrcode({ mchId: row.id }) - getDataList() - useMessage().success("生成成功") - } catch (e) { - useMessage().error("生成失败") - } -} - // 修改是否在正元显示 const isSyncZyChange = async (row: any) => { try { @@ -261,13 +275,7 @@ const visibleMobileChange = async (row: any) => { // 导出excel const exportExcel = () => { - downBlobFile("/wallet/mch-manage/export", Object.assign(state.queryForm, { ids: selectObjs }), "商户管理.xlsx") -} - -// 多选事件 -const selectionChangHandle = (objs: { id: string }[]) => { - selectObjs.value = objs.map(({ id }) => id) - multiple.value = !objs.length + downBlobFile("/wallet/mch-manage/export", { ...state.queryForm }, "商户管理.xlsx") } // 删除操作 @@ -287,42 +295,286 @@ const handleDelete = async (ids: string[]) => { } - diff --git a/src/views/mch/mch-manage/mch-rule-config-form.vue b/src/views/mch/mch-manage/mch-rule-config-form.vue deleted file mode 100644 index be0f875a..00000000 --- a/src/views/mch/mch-manage/mch-rule-config-form.vue +++ /dev/null @@ -1,128 +0,0 @@ - - - diff --git a/src/views/mch/mch-manage/mch-rule-config.vue b/src/views/mch/mch-manage/mch-rule-config.vue deleted file mode 100644 index 8722c2be..00000000 --- a/src/views/mch/mch-manage/mch-rule-config.vue +++ /dev/null @@ -1,146 +0,0 @@ - - - diff --git a/src/views/mch/mch-manage/setting.vue b/src/views/mch/mch-manage/setting.vue deleted file mode 100644 index 67ba7559..00000000 --- a/src/views/mch/mch-manage/setting.vue +++ /dev/null @@ -1,128 +0,0 @@ - - - diff --git a/src/views/wallet/wallet-active/index.vue b/src/views/wallet/wallet-active/index.vue index 52c8d397..3c454bc2 100644 --- a/src/views/wallet/wallet-active/index.vue +++ b/src/views/wallet/wallet-active/index.vue @@ -36,62 +36,93 @@ -
- - 导 入 - - - 开 卡 - - - 删除 - - +
+
+ + 导 入 + + + 开 卡 + + + 删除 + +
+
- + +
- - + + - + - - - + + + + + - + @@ -107,6 +138,7 @@ +
@@ -133,6 +165,8 @@ const BindUserFormDialog = defineAsyncComponent(() => import("./bind-user-form.v const WalletActiveImportExcel = defineAsyncComponent(() => import("./wallet-active-import-excel.vue")) /** 饭卡卡面打印抽屉,单条/勾选批量打印。 */ const CardPrintDrawer = defineAsyncComponent(() => import("./card-print-drawer.vue")) +/** USB 读卡器写卡弹窗,按行商户默认选中并预填卡号。 */ +const CardReaderDialog = defineAsyncComponent(() => import("/@/components/DeviceWidget/CardReaderDialog.vue")) // 定义查询字典 const { yes_no_type } = useDict("yes_no_type") @@ -141,6 +175,7 @@ const uploadExcelRef = ref() const formDialogRef = ref() const formCarNumberRef = ref() const bindUserFormDialogRef = ref() +const cardReaderRef = ref() const cardPrintRef = ref() // 搜索变量 const queryRef = ref()