feat:对接读写卡器

feat:优化用户下拉框搜索
feat:优化下单卡顿问题
feat:优化商户列表样式
This commit is contained in:
dzc committed 2026-10-13 17:24:57 +08:00
1 parent b95603829a
commit 8b2c08920f
24 files changed
+2461 -974

No files matched your search

+10
View File
@@ -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",
-55
View File
@@ -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,
})
}
@@ -0,0 +1,489 @@
<template>
<el-dialog
v-model="visible"
width="680px"
append-to-body
draggable
:close-on-click-modal="false"
class="device-reader-dialog"
>
<template #header>
<div class="flex items-center gap-2">
<el-icon class="text-primary"><Cpu /></el-icon>
<span class="text-base font-semibold">{{ t('deviceWidget.cardReader.name') }}</span>
</div>
</template>
<!-- 浏览器不支持 WebHID -->
<el-alert
v-if="!state.hidSupported"
type="warning"
:closable="false"
show-icon
:title="t('deviceWidget.cardReader.unsupportedTitle')"
:description="t('deviceWidget.cardReader.unsupportedDesc')"
/>
<template v-else>
<!-- 连接状态 -->
<div class="flex items-center justify-between rounded-md border border-br-light bg-fill-lighter px-4 py-3">
<div class="flex items-center gap-2.5 min-w-0">
<span class="w-2.5 h-2.5 rounded-full shrink-0" :class="dotClass"></span>
<div class="min-w-0">
<div class="text-sm">{{ statusText }}</div>
<div v-if="portIdText" class="text-xs font-mono text-[var(--el-text-color-secondary)]">{{ portIdText }}</div>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<el-button
v-if="state.portState !== 'connected'"
type="primary"
size="small"
:loading="state.portState === 'connecting'"
@click="onConnect"
>
{{ t('deviceWidget.cardReader.connect') }}
</el-button>
<template v-else>
<el-button size="small" link type="primary" @click="onChangeDevice">{{ t('deviceWidget.cardReader.changeDevice') }}</el-button>
<el-button size="small" plain @click="onDisconnect">{{ t('deviceWidget.cardReader.disconnect') }}</el-button>
</template>
</div>
</div>
<!-- 异常提示 -->
<el-alert v-if="state.error" type="error" :closable="false" show-icon :title="state.error" class="mt-3" />
<!-- 读卡:商户/密码 + 一键读取(卡号、UID、卡内容一次读出) -->
<div class="mt-4 rounded-md border border-br-light px-4 py-4">
<div class="flex flex-wrap items-center gap-3">
<span class="text-xs text-[var(--el-text-color-secondary)] w-14 shrink-0">{{ t('deviceWidget.cardReader.mch') }}</span>
<!-- 商户由行内按钮带入时锁定展示,不可更换;设备悬浮窗入口才可自由选择 -->
<select-mch v-if="!lockedMchId" v-model="mchId" auto-first class="w-52" />
<div v-else class="flex items-center gap-1.5">
<el-tooltip :content="t('deviceWidget.cardReader.mchLocked')" placement="top">
<el-icon class="cursor-help text-[var(--el-text-color-secondary)]"><Lock /></el-icon>
</el-tooltip>
<span class="text-sm font-medium">{{ lockedMchName || lockedMchId }}</span>
</div>
<span class="text-xs text-[var(--el-text-color-secondary)]">{{ t('deviceWidget.cardReader.keyHex') }}</span>
<el-input v-model="keyDisplay" size="small" class="w-40 font-mono" :placeholder="t('deviceWidget.cardReader.keyPlaceholder')" readonly />
<el-tooltip v-if="canViewKey && realKey" :content="keyRevealed ? t('deviceWidget.cardReader.keyHide') : t('deviceWidget.cardReader.keyView')" placement="top">
<el-icon class="cursor-pointer text-primary" :size="16" @click="keyRevealed = !keyRevealed">
<View v-if="!keyRevealed" />
<Hide v-else />
</el-icon>
</el-tooltip>
</div>
<div class="mt-2.5 text-xs text-[var(--el-text-color-secondary)]">{{ t('deviceWidget.cardReader.keyAutoHint') }}</div>
<div class="mt-3 text-center">
<el-button type="primary" size="large" round :loading="readingCard" :disabled="!canRead" @click="onReadCard">
<el-icon class="mr-1"><CreditCard /></el-icon>
{{ readingCard ? t('deviceWidget.cardReader.reading') : t('deviceWidget.cardReader.readCard') }}
</el-button>
<el-button
v-if="state.portState === 'connected'"
link
type="primary"
size="small"
class="ml-3"
:loading="state.beeping"
@click="onBeep"
>
<el-icon class="mr-1"><Bell /></el-icon>
{{ t('deviceWidget.cardReader.beep') }}
</el-button>
</div>
<div class="mt-2 text-center text-xs text-[var(--el-text-color-secondary)]">{{ t('deviceWidget.cardReader.readHint') }}</div>
</div>
<!-- 卡片信息:卡号 / UID / 卡内容 一处展示 -->
<div v-if="state.lastCard" class="mt-4 rounded-md border border-br-light bg-fill-lighter p-4">
<div class="grid grid-cols-2 gap-4">
<div class="min-w-0">
<div class="text-xs text-[var(--el-text-color-secondary)]">{{ t('deviceWidget.cardReader.cardNoLabel') }}</div>
<div class="mt-1.5 flex items-center gap-2">
<span class="font-mono font-semibold text-xl break-all">{{ cardNoDecimal }}</span>
<el-icon class="shrink-0 cursor-pointer text-primary" :size="18" @click="onCopy(cardNoDecimal)"><CopyDocument /></el-icon>
</div>
</div>
<div class="min-w-0">
<div class="text-xs text-[var(--el-text-color-secondary)]">{{ t('deviceWidget.cardReader.uidLabel') }}</div>
<div class="mt-1.5 flex items-center gap-2">
<span class="font-mono font-semibold text-xl tracking-wider break-all">{{ uidHex }}</span>
<el-icon class="shrink-0 cursor-pointer text-primary" :size="18" @click="onCopy(uidHex)"><CopyDocument /></el-icon>
</div>
</div>
</div>
<!-- 卡内容:系统约定扇区固定数据块,仅一处 -->
<div class="mt-3 pt-3 border-t border-br-light">
<div class="flex items-start gap-2">
<span class="text-xs text-[var(--el-text-color-secondary)] w-14 shrink-0 mt-0.5">{{ t('deviceWidget.cardReader.cardContent') }}</span>
<div class="flex-1 min-w-0">
<div v-if="sectorResult" class="text-base font-mono font-semibold break-all">{{ contentText || '(空)' }}</div>
<div v-if="sectorResult" class="mt-0.5 font-mono text-xs leading-5 break-all text-[var(--el-text-color-secondary)]">{{ formatBlock(sectorResult.data) }}</div>
<div v-else class="text-xs leading-6 text-[var(--el-text-color-secondary)]">{{ t('deviceWidget.cardReader.contentNotRead') }}</div>
</div>
<el-icon v-if="sectorResult" class="shrink-0 cursor-pointer mt-1 text-primary" :size="16" @click="onCopy(contentText)"><CopyDocument /></el-icon>
</div>
</div>
<div class="mt-3 flex flex-wrap items-center gap-1 text-xs text-[var(--el-text-color-secondary)]">
<span>{{ t('deviceWidget.cardReader.cardType') }}:</span>
<el-tag size="small" effect="plain">{{ state.lastCard.cardType }}</el-tag>
<span class="ml-4">{{ t('deviceWidget.cardReader.readAt') }}:{{ formatDateTime(state.lastCard.readAt) }}</span>
</div>
</div>
<div v-else class="mt-4 rounded-md border border-dashed border-br py-5 text-center text-xs text-[var(--el-text-color-secondary)]">
{{ t('deviceWidget.cardReader.noCardYet') }}
</div>
<!-- 写卡 -->
<div class="mt-4 rounded-md border border-br-light px-4 py-4">
<div class="text-sm font-medium mb-3">{{ t('deviceWidget.cardReader.writeSection') }}</div>
<div class="flex items-center gap-3 flex-wrap">
<span class="text-xs text-[var(--el-text-color-secondary)] w-14 shrink-0">{{ t('deviceWidget.cardReader.writeDataLabel') }}</span>
<el-input
v-model="writeText"
size="small"
class="flex-1 min-w-64"
maxlength="12"
show-word-limit
clearable
:placeholder="t('deviceWidget.cardReader.writeDataPlaceholder')"
/>
<el-button type="danger" size="small" plain :loading="writingSector" :disabled="!canWriteNow" @click="onWriteSector">
{{ t('deviceWidget.cardReader.writeBtn') }}
</el-button>
</div>
<div class="mt-2 text-xs text-[var(--el-text-color-secondary)]">{{ t('deviceWidget.cardReader.writeTip') }}</div>
<div v-if="writePreview" class="mt-1.5 font-mono text-xs break-all text-[var(--el-text-color-regular)]">
{{ t('deviceWidget.cardReader.writePreview') }}:{{ writePreview }}
</div>
</div>
<!-- 通讯报文(联调排查用) -->
<el-collapse class="mt-4 reader-frame-collapse">
<el-collapse-item :title="t('deviceWidget.cardReader.frames')" name="frames">
<div class="flex justify-end">
<el-button link size="small" :disabled="!state.frames.length" @click="clearFrames">
{{ t('deviceWidget.cardReader.clearFrames') }}
</el-button>
</div>
<div v-if="state.frames.length" class="max-h-40 overflow-y-auto font-mono text-xs leading-6">
<div v-for="(f, i) in state.frames" :key="i" class="flex items-baseline gap-2">
<span class="shrink-0 text-[var(--el-text-color-secondary)]">{{ formatTime(f.at) }}</span>
<span class="shrink-0 font-semibold" :class="dirClass[f.dir]">{{ dirLabel[f.dir] }}</span>
<span class="break-all">{{ f.hex }}</span>
</div>
</div>
<div v-else class="py-2 text-center text-xs text-[var(--el-text-color-secondary)]">
{{ t('deviceWidget.cardReader.noFrames') }}
</div>
</el-collapse-item>
</el-collapse>
</template>
<template #footer>
<el-button @click="visible = false">{{ t('deviceWidget.cardReader.close') }}</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts" name="cardReaderDialog">
import { Bell, CopyDocument, Cpu, CreditCard, Hide, Lock, View } from '@element-plus/icons-vue';
import { useI18n } from 'vue-i18n';
import { ElMessageBox } from 'element-plus';
import { useMessage } from '/@/hooks/message';
import { formatDate } from '/@/utils/formatTime';
import { useCardReader } from '/@/utils/card-reader';
import { hexToDecimal } from '/@/utils/card-reader/protocol';
import { getCardKey } from '/@/api/wallet/mch-manage';
import { useUserInfo } from '/@/stores/userInfo';
import commonFunction from '/@/utils/commonFunction';
const props = defineProps<{
modelValue: boolean;
/** 外部指定默认商户 id(如钱包开卡列表行),打开时自动选中并带入其饭卡密码 */
defaultMchId?: string;
/** 外部预填的写入内容(如行内卡号),打开时自动填入写入框 */
defaultWriteText?: string;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
}>();
const { t } = useI18n();
const message = useMessage();
const { copyText } = commonFunction();
const { state, connect, disconnect, pickDevice, readCard, readSector, writeSector, beep, clearFrames } = useCardReader();
const visible = computed({
get: () => props.modelValue || selfOpen.value,
set: (value: boolean) => {
selfOpen.value = value;
emit('update:modelValue', value);
},
});
/** 外部 openDialog 直接控制时使用(不依赖父级 v-model 绑定) */
const selfOpen = ref(false);
/** 行内入口锁定的商户(含名称):置值后商户不可更换,仅设备悬浮窗入口可自由选择 */
const lockedMchId = ref('');
const lockedMchName = ref('');
// 弹窗打开时静默重连浏览器记住的授权设备(不弹选择器),并应用外部传入的默认商户/预填内容;
// 监听 visible 以同时覆盖 v-model(设备悬浮窗)与 openDialog(业务行内按钮)两种入口
watch(
visible,
(open) => {
if (!open) {
// 关闭时解除商户锁定,由下次打开的入口重新决定
lockedMchId.value = '';
lockedMchName.value = '';
return;
}
if (state.hidSupported && state.portState === 'disconnected') {
connect({ silent: true });
}
if (props.defaultMchId) mchId.value = String(props.defaultMchId);
if (props.defaultWriteText) writeText.value = props.defaultWriteText;
},
{ immediate: true },
);
const canRead = computed(() => state.portState === 'connected' && !readingCard.value);
const statusText = computed(() => {
if (state.portState === 'connected') return t('deviceWidget.cardReader.connected');
if (state.portState === 'connecting') return t('deviceWidget.cardReader.connecting');
return t('deviceWidget.cardReader.disconnected');
});
const dotClass = computed(() => {
if (state.portState === 'connected') return 'bg-[var(--el-color-success)]';
if (state.portState === 'connecting') return 'bg-[var(--el-color-warning)] animate-pulse';
return 'bg-[var(--el-text-color-disabled)]';
});
/** USB 识别号(帮助确认选对设备),无 USB 信息时不展示 */
const portIdText = computed(() => {
const info = state.portInfo;
if (!info?.usbVendorId && !info?.usbProductId) return '';
return `USB VID:${toHex16(info.usbVendorId)} PID:${toHex16(info.usbProductId)}`;
});
/** 卡号:UID 按大端无符号十进制 */
const cardNoDecimal = computed(() => (state.lastCard ? hexToDecimal(state.lastCard.uid) || state.lastCard.uid : ''));
/** UID:十六进制按字节分组 */
const uidHex = computed(() => (state.lastCard ? state.lastCard.uid.replace(/(.{2})/g, '$1 ').trim() : ''));
// ---------------------------------------------------------------------------
// 扇区读写(商户饭卡密码:真实值仅后端下发、内部认证用,界面默认脱敏展示)
// ---------------------------------------------------------------------------
/** 饭卡密码认证钥型:1=A 0=B(待正确密码的卡实测后定稿) */
const KEY_AB = 1 as 0 | 1;
/** 系统饭卡统一读写扇区 1(界面不暴露扇区概念) */
const SECTOR_NO = 1;
const mchId = ref('');
/** 真实密码(后端 card-key 接口下发,仅内部用于读卡认证,不直接展示) */
const realKey = ref('');
/** 有 wallet_mchManage_viewCardKey 按钮权限才允许在界面切换查看完整值 */
const canViewKey = computed(() => useUserInfo().userInfos.authBtnList.includes('wallet_mchManage_viewCardKey'));
const keyRevealed = ref(false);
/** 界面展示值:无权限/未点开时仅 前2位+********+后2位 */
const keyDisplay = computed(() => {
if (!realKey.value) return '';
if (keyRevealed.value && canViewKey.value) return realKey.value;
return realKey.value.slice(0, 2) + '********' + realKey.value.slice(-2);
});
const writeText = ref('');
const readingCard = ref(false);
const writingSector = ref(false);
const sectorResult = ref<{ uidBytes: number[]; data: number[] } | null>(null);
// 选中商户后拉取该商户的饭卡密码(真实值仅内部认证使用)
watch(mchId, async (id) => {
realKey.value = '';
keyRevealed.value = false;
if (!id) return;
try {
const res = await getCardKey(String(id));
if (res.code === 0 && res.data?.cardKey) realKey.value = res.data.cardKey;
} catch {
// 拉取失败时读卡自动走出厂密码降级
}
});
const formatBlock = (block: number[]) => block.map((b) => b.toString(16).toUpperCase().padStart(2, '0')).join(' ');
/** 块数据 -> 可读文本(不可打印字符显示为 ·),普通用户直接看内容 */
const blockToText = (block: number[]): string => {
return block.map((b) => (b >= 0x20 && b <= 0x7e ? String.fromCharCode(b) : '·')).join('');
};
/** 当前卡内容可读文本(去掉尾部补位空格) */
const contentText = computed(() => (sectorResult.value ? blockToText(sectorResult.value.data).replace(/·+$/, '').trimEnd() : ''));
/** 写入内容 -> 16 字节数据:ASCII 编码,末尾用空格(0x20)补齐 */
const buildBlockData = (input: string): number[] => {
const data: number[] = [];
for (const ch of input) data.push(ch.charCodeAt(0) & 0xff);
while (data.length < 16) data.push(0x20);
return data.slice(0, 16);
};
/** 写入内容合法性:8/10/12 位数字或字母 */
const canWrite = computed(() => /^[0-9A-Za-z]{8}$|^[0-9A-Za-z]{10}$|^[0-9A-Za-z]{12}$/.test(writeText.value));
/** 写入按钮可用:内容合法且设备已连接(无需先读取卡内容) */
const canWriteNow = computed(() => canWrite.value && state.portState === 'connected');
/** 实际写入预览:补空格后的文本 + 十六进制 */
const writePreview = computed(() => {
if (!canWrite.value) return '';
const data = buildBlockData(writeText.value);
return `${blockToText(data).replace(/ /g, '␠')}(${formatBlock(data)})`;
});
/** 一键读取:卡号 + UID + 卡内容一次读出;卡内容读取失败不影响卡号展示(错误提示单独显示) */
const onReadCard = async () => {
readingCard.value = true;
try {
await readCard();
try {
sectorResult.value = await readSector(SECTOR_NO, KEY_AB, realKey.value);
writeText.value = contentText.value;
} catch {
// 卡内容读取失败(密码不对等):卡号已读出,错误经 state.error 展示
sectorResult.value = null;
}
successBeep();
} catch {
// 寻卡失败等原因已在弹窗内通过 state.error 展示
} finally {
readingCard.value = false;
}
};
const onWriteSector = async () => {
if (!canWrite.value) {
message.warning(t('deviceWidget.cardReader.writeDataInvalid'));
return;
}
const data = buildBlockData(writeText.value);
try {
await ElMessageBox.confirm(
t('deviceWidget.cardReader.writeConfirm', { content: writeText.value }),
t('deviceWidget.cardReader.writeConfirmTitle'),
{ type: 'warning' },
);
} catch {
return;
}
writingSector.value = true;
try {
await writeSector(SECTOR_NO, KEY_AB, realKey.value, data);
// 写后读回刷新展示(先回读再鸣声,避免鸣声指令与后续指令并发打乱设备)
sectorResult.value = await readSector(SECTOR_NO, KEY_AB, realKey.value);
writeText.value = contentText.value;
message.success(t('deviceWidget.cardReader.writeOk'));
successBeep();
} catch {
// 失败原因已在弹窗内通过 state.error 展示
} finally {
writingSector.value = false;
}
};
const dirLabel: Record<string, string> = { tx: 'TX', rx: 'RX', 'rx-bad': 'RX!', 'rx-raw': 'RAW', sys: 'SYS', mock: 'MOCK' };
const dirClass: Record<string, string> = {
tx: 'text-primary',
rx: 'text-[var(--el-color-success)]',
'rx-bad': 'text-[var(--el-color-danger)]',
'rx-raw': 'text-[var(--el-text-color-secondary)]',
sys: 'text-[var(--el-color-info)]',
mock: 'text-[var(--el-color-warning)]',
};
const toHex16 = (value?: number) => ((value ?? 0).toString(16).toUpperCase() as string).padStart(4, '0');
const formatDateTime = (ts: number) => formatDate(new Date(ts), 'YYYY-mm-dd HH:MM:SS');
const formatTime = (ts: number) => {
const d = new Date(ts);
const p = (n: number, len = 2) => String(n).padStart(len, '0');
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}`;
};
const onConnect = async () => {
try {
await connect();
} catch {
// 失败原因已在弹窗内通过 state.error 展示
}
};
/** 重新弹出选择器更换设备 */
const onChangeDevice = async () => {
try {
await pickDevice();
} catch {
// 失败原因已在弹窗内通过 state.error 展示
}
};
const onDisconnect = async () => {
try {
await disconnect();
sectorResult.value = null;
} catch {
// 忽略断开时的异常
}
};
/** 操作成功后让设备鸣一声提示(失败不影响主流程) */
const successBeep = () => {
beep().catch(() => undefined);
};
const onBeep = async () => {
try {
await beep();
} catch (e) {
message.warning(e instanceof Error ? e.message : t('deviceWidget.cardReader.beep'));
}
};
const onCopy = (text: string) => {
if (text) copyText(text);
};
/**
* 供业务页面调用:打开弹窗并指定默认商户与预填写入内容。
* @param options.mchId 锁定的商户 id(如钱包开卡列表行),传入后商户不可更换
* @param options.mchName 锁定商户的展示名称
* @param options.writeText 预填的写入内容(如行内卡号)
*/
const openDialog = (options: { mchId?: string; mchName?: string; writeText?: string } = {}) => {
if (options.mchId) {
mchId.value = String(options.mchId);
lockedMchId.value = String(options.mchId);
lockedMchName.value = options.mchName ? String(options.mchName) : '';
}
if (options.writeText) writeText.value = options.writeText;
visible.value = true;
};
defineExpose({ openDialog });
</script>
<style lang="scss" scoped>
// 报文折叠面板去掉默认缩进,贴合弹窗内容宽度
.reader-frame-collapse :deep(.el-collapse-item__content) {
padding-bottom: 8px;
}
</style>
+107
View File
@@ -0,0 +1,107 @@
<template>
<div ref="widgetRef">
<!-- 右下角悬浮按钮 -->
<button
type="button"
class="fixed right-4 bottom-4 z-[1000] w-12 h-12 rounded-full bg-primary text-white shadow flex items-center justify-center cursor-pointer transition-colors hover:bg-primary-light-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-light-5"
:class="fabBottomClass"
:aria-label="$t('deviceWidget.fabLabel')"
@click="panelVisible = !panelVisible"
>
<el-icon :size="22"><Cpu /></el-icon>
</button>
<!-- 设备列表面板 -->
<transition name="el-fade-in">
<div
v-if="panelVisible"
class="fixed right-4 bottom-16 z-[1000] w-72 rounded-lg overflow-hidden border border-br-light shadow bg-[var(--el-bg-color)]"
:class="panelBottomClass"
role="menu"
:aria-label="$t('deviceWidget.panelTitle')"
>
<div class="flex items-center justify-between px-4 h-11 bg-primary text-white">
<span class="text-sm font-semibold">{{ $t('deviceWidget.panelTitle') }}</span>
<el-icon class="cursor-pointer opacity-80 hover:opacity-100" :size="16" @click="panelVisible = false"><Close /></el-icon>
</div>
<div class="p-2">
<div
v-for="device in devices"
:key="device.id"
class="flex items-center gap-3 px-3 py-2.5 rounded-md cursor-pointer transition-colors hover:bg-fill-light"
role="menuitem"
@click="openDevice(device.id)"
>
<span class="w-9 h-9 rounded-md bg-primary-light-9 text-primary flex items-center justify-center shrink-0">
<el-icon :size="18"><component :is="device.icon" /></el-icon>
</span>
<span class="min-w-0 flex-1">
<span class="block text-sm truncate">{{ device.name }}</span>
<span class="block text-xs truncate text-[var(--el-text-color-secondary)]">{{ device.desc }}</span>
</span>
<el-tag size="small" :type="device.tagType" effect="light">{{ device.statusText }}</el-tag>
</div>
</div>
</div>
</transition>
<CardReaderDialog v-model="readerDialogVisible" />
</div>
</template>
<script setup lang="ts" name="deviceWidget">
import { onClickOutside } from '@vueuse/core';
import { Close, Cpu } from '@element-plus/icons-vue';
import { useI18n } from 'vue-i18n';
import { useThemeConfig } from '/@/stores/themeConfig';
import { useCardReader } from '/@/utils/card-reader';
import CardReaderDialog from './CardReaderDialog.vue';
const { t } = useI18n();
const storesThemeConfig = useThemeConfig();
const { themeConfig } = storeToRefs(storesThemeConfig);
const { state } = useCardReader();
const panelVisible = ref(false);
const readerDialogVisible = ref(false);
const widgetRef = ref<HTMLElement>();
// AI 助手(Chat)悬浮球也占右下角时,本组件整体上移避让
const fabBottomClass = computed(() => (themeConfig.value.isChat ? 'bottom-24' : 'bottom-4'));
const panelBottomClass = computed(() => (themeConfig.value.isChat ? 'bottom-36' : 'bottom-16'));
onClickOutside(widgetRef, () => {
panelVisible.value = false;
});
/** 已集成的设备注册表:新增设备在此追加条目即可出现在列表里 */
const devices = computed(() => [
{
id: 'card-reader',
icon: markRaw(Cpu),
name: t('deviceWidget.cardReader.name'),
desc: t('deviceWidget.cardReader.desc'),
statusText: readerStatusText.value,
tagType: readerTagType.value,
},
]);
const readerStatusText = computed(() => {
if (!state.hidSupported) return t('deviceWidget.cardReader.unsupported');
if (state.portState === 'connected') return t('deviceWidget.cardReader.connected');
if (state.portState === 'connecting') return t('deviceWidget.cardReader.connecting');
return t('deviceWidget.cardReader.disconnected');
});
const readerTagType = computed<'success' | 'warning' | 'info' | 'danger'>(() => {
if (!state.hidSupported) return 'danger';
if (state.portState === 'connected') return 'success';
if (state.portState === 'connecting') return 'warning';
return 'info';
});
const openDevice = (id: string) => {
panelVisible.value = false;
if (id === 'card-reader') readerDialogVisible.value = true;
};
</script>
-105
View File
@@ -1,105 +0,0 @@
<template>
<el-select
style="width: 100%"
v-model="state.selectId"
:placeholder="props.placeholder"
clearable
remote-show-suffix
filterable
remote
:remote-method="remoteSearch"
:loading="state.loading"
@visible-change="(e: boolean) => e && getOptions()"
>
<el-option v-for="v in state.options" :key="v.id" :label="getDictById(v.rulePriceType, v)" :value="v.id">
<div class="flex items-center">
<div class="flex items-center justify-center ml5">
<dict-tag :options="state.walletRulePriceType" :value="v.rulePriceType" />
<div class="ml-2">
<span>超过</span>
<span class="color-success">{{ displayPrice(v.basePrice) }}</span>
<span>额外支付</span>
<span class="color-danger">{{ v.multiple }}%</span>
</div>
</div>
</div>
</el-option>
</el-select>
</template>
<script setup lang="ts" name="select-mch-rule">
import { reactive } from "vue"
import * as mchRulePriceConfigApi from "/@/api/wallet/mch-rule-price-config"
import { displayPrice } from "/@/utils/formatMoney"
import { useDict } from "/@/hooks/dict"
const selectId = defineModel()
export type Props = {
placeholder?: string
apiParams?: any
}
const props = withDefaults(defineProps<Props>(), {
placeholder: "请选择",
apiParams: {},
})
const queryForm = reactive<any>({
name: null,
current: 1,
size: 10,
})
const state = reactive<any>({
loading: true,
selectId: selectId,
options: [],
walletRulePriceType: [],
})
watch(
selectId,
(newVal, oldValue) => {
if (newVal == oldValue) return
state.selectId = newVal
getOptions()
},
{ immediate: true },
)
async function getOptions() {
try {
state.loading = true
const _pa = {
...queryForm,
...props.apiParams,
}
const { data } = await mchRulePriceConfigApi.pageSelect(_pa)
state.options = data.records ?? []
const { wallet_rule_price_type } = useDict("wallet_rule_price_type")
state.walletRulePriceType = wallet_rule_price_type
} finally {
setTimeout(() => (state.loading = false), 500)
}
}
function remoteSearch(params: string) {
if (params != queryForm.name) {
queryForm.name = params
getOptions()
}
}
function getDictById(value: any, v) {
if (!value) return ""
const target = state.walletRulePriceType.find((v: any) => v.value == value)
if (!target) return ""
return `${target.label} 超过${displayPrice(v.basePrice)},额外支付${v.multiple}%`
}
function clear() {
state.selectId = null
getOptions()
}
</script>
+2 -1
View File
@@ -58,7 +58,8 @@ const props = withDefaults(defineProps<Props>(), {
excludeUserIds: () => [],
multiple: false,
valueField: "userId",
size: 10,
// 下拉接口关闭了 COUNT 且不翻页,一次拉取条数需要覆盖长尾匹配结果
size: 50,
})
/** 远程搜索条件,用户输入关键词后从第一页重新加载。 */
-2
View File
@@ -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)
+51
View File
@@ -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',
},
},
};
+51
View File
@@ -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: '关 闭',
},
},
};
+2
View File
@@ -11,6 +11,7 @@
<el-backtop :target="setBacktopClass" />
<check-token />
<chat/>
<device-widget/>
</el-main>
</template>
@@ -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();
+3 -1
View File
@@ -11,6 +11,7 @@
* @method openShareTagsView 布局设置弹窗,开启 TagsView 共用
* @method onTagsViewRefreshRouterView tagsview 刷新界面
* @method onCurrentContextmenuClick tagsview 右键菜单每项点击时
* @method cardReaderRead 串口读写卡器读到卡片(DeviceWidget 全局设备工具广播,业务页面可订阅)
*/
declare type MittType<T = any> = {
openSetingsDrawer?: string;
@@ -20,9 +21,10 @@ declare type MittType<T = any> = {
getBreadcrumbIndexSetFilterRoutes?: string;
layoutMobileResize: T;
openOrCloseSortable?: string;
openShareTagsView?: string;
openShareTagsView?: T;
onTagsViewRefreshRouterView?: T;
onCurrentContextmenuClick?: T;
cardReaderRead: T;
};
// mitt 参数类型定义
+55
View File
@@ -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<void>;
close(): Promise<void>;
forget(): Promise<void>;
sendReport(reportId: number, data: BufferSource): Promise<void>;
sendFeatureReport(reportId: number, data: BufferSource): Promise<void>;
receiveFeatureReport(reportId: number): Promise<DataView>;
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<HIDDevice[]>;
requestDevice(options: HIDDeviceRequestOptions): Promise<HIDDevice[]>;
addEventListener(type: 'connect' | 'disconnect', listener: (event: HIDConnectionEvent) => void): void;
removeEventListener(type: 'connect' | 'disconnect', listener: (event: HIDConnectionEvent) => void): void;
}
interface Navigator {
readonly hid?: HID;
}
+523
View File
@@ -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<number[]>((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<number[]> => {
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<void> => {
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<boolean> => {
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<boolean> => {
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<void> => {
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<CardInfo> => {
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<void> => {
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<void> => {
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<number[]> => {
const card = await readCard();
return card.uidBytes;
};
/** 用 E2 已装载密码认证扇区(应答 [C3][状态])。紧跟寻卡,此时卡片必然在位,失败即密码不对。 */
const authKeyE2 = async (uidBytes: number[], area: number, keyAB: 0 | 1): Promise<void> => {
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<number[]> => {
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<void> => {
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<number[]> => {
// 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<SectorReadResult> => {
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,
};
}
+306
View File
@@ -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<number, string> = {
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 '';
}
};
+9 -7
View File
@@ -44,8 +44,9 @@
{{ runtimeStatusText }}
</strong>
<small>{{
isPrinterAsset ? printerProxyTargetText : isCoffeeMachine ? "屋里咖啡平台维护在线状态" : detail.lastOnlineTime || "暂无心跳时间"
}}</small>
isPrinterAsset ? printerProxyTargetText : isCoffeeMachine ? "屋里咖啡平台维护在线状态" :
detail.lastOnlineTime || "暂无心跳时间"
}}</small>
</div>
<div class="status-item">
<span>{{ isPrinterAsset || isCoffeeMachine ? "客户端归属" : "客户端版本" }}</span>
@@ -113,7 +114,7 @@
<dt>{{ isPrinterAsset ? "设备编号" : "唯一编号" }}</dt>
<dd class="break-all">
{{ isPrinterAsset ? "不适用(无屏 USB 外设)" : displayValue(detail.uniqueCode,
"尚未绑定") }}
"尚未绑定") }}
</dd>
</div>
</dl>
@@ -263,7 +264,7 @@
<div>
<strong>{{ faceConfigured ? "人脸 SDK 授权已配置" : "人脸 SDK 配置不完整" }}</strong>
<span>{{ faceConfigured ? "App ID、SDK Key 和 Active Key 均已维护" : "请补齐三项授权信息后再进行终端识别"
}}</span>
}}</span>
</div>
</div>
<dl class="configuration-grid">
@@ -341,7 +342,7 @@
<div>
<dt>USB VID / PID</dt>
<dd>{{ formatUsbId(detail.usbVendorId) }} / {{ formatUsbId(detail.usbProductId)
}}</dd>
}}</dd>
</div>
<div>
<dt>纸张与切刀</dt>
@@ -372,7 +373,7 @@
<div>
<dt>纸张与切刀</dt>
<dd>{{ detail.paperWidth || 80 }} mm / {{ detail.autoCut === "0" ? "不自动切刀" : "自动切刀"
}}</dd>
}}</dd>
</div>
<div>
<dt>图片指令模式</dt>
@@ -633,7 +634,8 @@
<p>屋里咖啡扫码支付接入信息与本机最近交易</p>
</div>
</div>
<coffee-machine-section :device-id="String(detail.id || '')" :unique-code="detail.uniqueCode || ''" />
<coffee-machine-section :device-id="String(detail.id || '')"
:unique-code="detail.uniqueCode || ''" />
</section>
<section id="section-maintenance" class="detail-section detail-anchor">
+41
View File
@@ -24,6 +24,14 @@
<dict-select v-model="state.queryForm.orderStatusCodes" dict-code="hard_order_status"
multiple />
</el-form-item>
<el-form-item>
<!-- 仅食堂、超市终端订单存在终端订单号;同号多单通常为网络抖动超时重试的重复扣款 -->
<el-button icon="Warning"
:type="state.queryForm.duplicateAndroidOrderCode ? 'warning' : 'default'"
@click="toggleDuplicateAndroidCode">
消费机订单号重复
</el-button>
</el-form-item>
<template #more>
<el-form-item style="width: 250px" label="商户类型" prop="merchantTypeCodes">
@@ -257,6 +265,13 @@
<dict-tag :options="hard_order_status" :value="row.orderStatus" />
</span>
</div>
<!-- 终端本地订单号:网络抖动重试会造成多单同号,展示该行便于排查重复扣款 -->
<div class="status" v-if="row.androidOrderCode">
<span>终端订单号</span>
<el-tooltip :content="row.androidOrderCode" placement="top-start">
<span class="text-line">{{ row.androidOrderCode }}</span>
</el-tooltip>
</div>
</template>
</el-table-column>
<el-table-column width="270px" label="退款信息">
@@ -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()
}
@@ -1,34 +0,0 @@
<template>
<div class="size-full">
<div class="size-full flex justify-start flex-wrap" v-if="detail.rulePriceType == 10">
<span class="color-primary">{{ detail.sourceMchName ? detail.sourceMchName : "???" }}</span>
<span>到</span>
<span class="color-primary">{{ detail.targetMchName ? detail.targetMchName : "???" }}</span>
<span>消费超过</span>
<span class="color-success">{{ detail.basePrice ? displayPrice(detail.basePrice) : "???" }}</span>
<span>额外支付</span>
<span class="color-danger">{{ detail.multiple ? detail.multiple : "???" }}%</span>
</div>
<div class="size-full flex justify-start flex-wrap" v-if="detail.rulePriceType == 20">
<span class="color-primary">{{ detail.sourceMchName ? detail.sourceMchName : "???" }}</span>
<span>每餐消费超过</span>
<span class="color-success">{{ detail.basePrice ? displayPrice(detail.basePrice) : "???" }}</span>
<span>额外支付</span>
<span class="color-danger">{{ detail.multiple ? detail.multiple : "???" }}%</span>
</div>
</div>
</template>
<script setup lang="ts" name="mchRuleDesc">
import { displayPrice } from "/@/utils/formatMoney"
export type Props = {
detail: any
}
const props = withDefaults(defineProps<Props>(), {
detail: {},
})
const { detail } = toRefs(props)
</script>
+39 -1
View File
@@ -1,7 +1,17 @@
<template>
<div class="layout-padding">
<div class="card">
<div class="title">基本信息</div>
<div class="title">
基本信息
<div class="ml-auto flex items-center gap-3">
<el-button icon="RefreshRight" v-auth="'wallet_mchManage_refresh_qrcode'" @click="refreshQrcode">
刷新二维码
</el-button>
<el-button icon="edit-pen" v-auth="'wallet_mchManage_edit'" @click="formDialogRef.openDialog(state.mchId)">
编辑
</el-button>
</div>
</div>
<div class="info">
<div class="info-item">
<span>商户logo:</span>
@@ -46,6 +56,12 @@
{{ state.mchInfo.phone }}
</span>
</div>
<div class="info-item">
<span>饭卡密码:</span>
<span class="font-mono">
{{ state.mchInfo.cardKey }}
</span>
</div>
<div class="info-item">
<span>营业时间:</span>
<span>
@@ -109,13 +125,18 @@
<app-push-setting ref="appPushSettingRef" :mchId="state.mchId" v-if="activeTab == 'APP_PUSH_SETTING'" />
</div>
</div>
<!-- 编辑商户 -->
<form-dialog ref="formDialogRef" @refresh="getMchInfoDetail" />
</div>
</template>
<script setup lang="ts" name="MchManageDetail">
import * as mchManageApi from "/@/api/wallet/mch-manage";
import { useDict } from "/@/hooks/dict"
import { useMessage, useMessageBox } from "/@/hooks/message"
import { useUserInfo } from "/@/stores/userInfo"
import FormDialog from "./form.vue";
const TakeOutSetting = defineAsyncComponent(() => import("./components/take-out-setting.vue"));
const PaySetting = defineAsyncComponent(() => import("./components/pay-setting.vue"));
@@ -144,6 +165,7 @@ const canAccessAutoVerify = computed(() => canConfigureAutoRefund.value || canVi
const takeOutSettingRef = ref()
const paySettingRef = ref()
const appPushSettingRef = ref()
const formDialogRef = ref()
const state = reactive<any>({
mchId: null,
@@ -171,6 +193,22 @@ const getMchInfoDetail = async () => {
console.log("获取商户详情失败", error);
}
}
// 重新生成正元二维码
const refreshQrcode = async () => {
try {
await useMessageBox().confirm("是否重新生成正元二维码")
} catch (e) {
return
}
try {
await mchManageApi.refreshQrcode({ mchId: state.mchId })
await getMchInfoDetail()
useMessage().success("生成成功")
} catch (e) {
useMessage().error("生成失败")
}
}
</script>
<style lang="scss" scoped>
+250 -130
View File
@@ -1,135 +1,163 @@
<template>
<el-dialog :title="form.id ? '编辑' : '新增'" width="50%" v-model="visible" :close-on-click-modal="false" draggable>
<el-dialog :title="form.id ? '编辑商户' : '新增商户'" width="min(1080px, 94vw)" top="6vh" v-model="visible" :close-on-click-modal="false" draggable>
<el-form ref="dataFormRef" :model="form" :rules="dataRules" label-width="140px" v-loading="loading">
<el-row :gutter="24">
<el-col :span="24" class="mb20">
<el-form-item label="商户名:" prop="name">
<el-input v-model.trim="form.name" placeholder="请输入商户名" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="部门:" prop="deptId">
<select-dept v-model="form.deptId" placeholder="请选择部门" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="商户类型:" prop="types">
<el-radio-group v-model="form.types">
<el-radio :label="item.value" :disabled="merchantTypeRadioDisabled"
v-for="(item, index) in merchant_type" border :key="index"
@click="handleClickRadio(item.value)">
{{ item.label }}
</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="商户属性:" prop="merchantAttr">
<el-radio-group v-model="form.merchantAttr">
<el-radio border label="NORMAL">普通</el-radio>
<el-radio border label="SCHOOL">学校</el-radio>
<el-radio border label="GOVERNMENT">政府</el-radio>
<el-radio border label="FACTORY">工厂</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col v-if="form.types == '2'" :span="24" class="mb20">
<el-form-item label="合作商家类型:" prop="supType">
<el-radio-group v-model="form.supType" @change="(value: string) => form.supType = value">
<el-radio border :label="item.value" v-for="(item, index) in wallet_mch_sup_type"
:key="index">{{ item.label }}</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col v-if="form.types == '2' && form.supType == '20'" :span="24" class="mb20">
<el-form-item label="合作状态:" prop="cooperationStatus">
<el-radio-group v-model="form.cooperationStatus">
<el-radio border :label="item.value" v-for="(item, index) in yes_no_type" :key="index">{{
item.label }}</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col v-if="form.types == '2' && form.supType == '20'" :span="24" class="mb20">
<el-form-item label="品牌:" prop="parentId">
<select-mch v-model="form.parentId" :apiParams="{ types: '2', supType: '10' }" />
</el-form-item>
</el-col>
<el-col v-if="form.types == '2'" :span="24" class="mb20">
<el-form-item label="分类:" prop="catgoryId">
<select-mch-catory v-model="form.catgoryId" :apiParams="{ bizType: form.types }" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="商户logo:" prop="logoPic">
<upload-img v-model:imageUrl="form.logoPic" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="轮播图:" prop="swiperPic">
<upload-imgs v-model="form.swiperPic" />
</el-form-item>
</el-col>
<el-col :span="12" class="mb20">
<el-form-item prop="sort">
<template #label>
<span>排序:</span>
<tip content="越大越在前" />
</template>
<el-input-number v-model="form.sort" placeholder="" />
</el-form-item>
</el-col>
<el-col v-if="form.types == '1'" :span="24" class="mb20">
<el-form-item prop="sort">
<template #label>
<span>是否对外开放:</span>
<tip content="食堂是否对外开放(对外开放非食堂人员可以在手机端看到食堂信息)" />
</template>
<el-radio-group v-model="form.canteenIsOpen">
<el-radio v-for="v in yes_no_type" :key="v.value" :label="v.value" border>
{{ v.label }}
</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col v-if="form.types == '2' && form.supType == '20'" :span="24" class="mb20">
<el-form-item label="优惠价格:" prop="discountsPrice">
<el-input v-model="form.discountsPrice" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item prop="phone">
<template #label>
<span>联系电话:</span>
<tip content="多个联系电话用/分隔" />
</template>
<el-input v-model.trim="form.phone" placeholder="请输入联系电话" />
</el-form-item>
</el-col>
<el-col v-if="form.types == '1'" :span="24" class="mb20">
<el-form-item label="营业时间:" class="my-0" />
<el-form-item class="ml-2" label="早上" prop="jobTime">
<el-time-picker v-model="jobTime.morning" placeholder="请选择营业时间" is-range range-separator="至"
start-placeholder="开始日期" end-placeholder="结束日期" value-format="HH:mm" format="HH:mm" />
</el-form-item>
<el-form-item class="ml-2" label="中午" prop="jobTime">
<el-time-picker v-model="jobTime.noon" placeholder="请选择营业时间" is-range range-separator="至"
start-placeholder="开始日期" end-placeholder="结束日期" value-format="HH:mm" format="HH:mm" />
</el-form-item>
<el-form-item class="ml-2" label="晚上" prop="jobTime">
<el-time-picker v-model="jobTime.evening" placeholder="请选择营业时间" is-range range-separator="至"
start-placeholder="开始日期" end-placeholder="结束日期" value-format="HH:mm" format="HH:mm" />
</el-form-item>
<el-form-item class="ml-2" label="夜间餐" prop="jobTime">
<el-time-picker v-model="jobTime.night" placeholder="请选择营业时间" is-range range-separator="至"
start-placeholder="开始日期" end-placeholder="结束日期" value-format="HH:mm" format="HH:mm" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="店铺地址:" prop="addressDes">
<el-input type="textarea" v-model.trim="form.addressDes" placeholder="请输入店铺地址" />
</el-form-item>
</el-col>
</el-row>
<!-- 基本信息 -->
<div class="form-section">
<div class="form-section-title">基本信息</div>
<div class="form-section-body">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="商户名:" prop="name">
<el-input v-model.trim="form.name" placeholder="请输入商户名" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="部门:" prop="deptId">
<select-dept v-model="form.deptId" placeholder="请选择部门" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="商户类型:" prop="types">
<el-radio-group v-model="form.types">
<el-radio :label="item.value" :disabled="merchantTypeRadioDisabled"
v-for="(item, index) in merchant_type" border :key="index"
@click="handleClickRadio(item.value)">
{{ item.label }}
</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="商户属性:" prop="merchantAttr">
<el-radio-group v-model="form.merchantAttr">
<el-radio border label="NORMAL">普通</el-radio>
<el-radio border label="SCHOOL">学校</el-radio>
<el-radio border label="GOVERNMENT">政府</el-radio>
<el-radio border label="FACTORY">工厂</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col v-if="form.types == '2'" :span="24">
<el-form-item label="合作商家类型:" prop="supType">
<el-radio-group v-model="form.supType" @change="(value: string) => form.supType = value">
<el-radio border :label="item.value" v-for="(item, index) in wallet_mch_sup_type"
:key="index">{{ item.label }}</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col v-if="form.types == '2' && form.supType == '20'" :span="12">
<el-form-item label="合作状态:" prop="cooperationStatus">
<el-radio-group v-model="form.cooperationStatus">
<el-radio border :label="item.value" v-for="(item, index) in yes_no_type" :key="index">{{
item.label }}</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col v-if="form.types == '2' && form.supType == '20'" :span="12">
<el-form-item label="品牌:" prop="parentId">
<select-mch v-model="form.parentId" :apiParams="{ types: '2', supType: '10' }" />
</el-form-item>
</el-col>
<el-col v-if="form.types == '2'" :span="12">
<el-form-item label="分类:" prop="catgoryId">
<select-mch-catory v-model="form.catgoryId" :apiParams="{ bizType: form.types }" />
</el-form-item>
</el-col>
<el-col v-if="form.types == '2' && form.supType == '20'" :span="12">
<el-form-item label="优惠价格:" prop="discountsPrice">
<el-input v-model="form.discountsPrice" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item prop="phone">
<template #label>
<span>联系电话:</span>
<tip content="多个联系电话用/分隔" />
</template>
<el-input v-model.trim="form.phone" placeholder="请输入联系电话" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item prop="sort">
<template #label>
<span>排序:</span>
<tip content="越大越在前" />
</template>
<el-input-number v-model="form.sort" placeholder="" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item prop="cardKey">
<template #label>
<span>饭卡密码:</span>
<tip content="本商户饭卡M1扇区认证密码,12位十六进制(6字节);留空则使用系统默认,仅授权人员可查看完整值" />
</template>
<el-input v-model.trim="form.cardKey"
:placeholder="form.id ? '未修改则保持原密码' : '留空使用系统默认密码'" maxlength="12"
class="font-mono" :disabled="!canViewCardKey" />
</el-form-item>
</el-col>
</el-row>
</div>
</div>
<!-- 图片信息 -->
<div class="form-section">
<div class="form-section-title">图片信息</div>
<div class="form-section-body">
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="商户logo:" prop="logoPic">
<upload-img v-model:imageUrl="form.logoPic" />
</el-form-item>
</el-col>
<el-col :span="16">
<el-form-item label="轮播图:" prop="swiperPic">
<upload-imgs v-model="form.swiperPic" />
</el-form-item>
</el-col>
</el-row>
</div>
</div>
<!-- 营业信息 -->
<div class="form-section">
<div class="form-section-title">营业信息</div>
<div class="form-section-body">
<el-row :gutter="20">
<el-col v-if="form.types == '1'" :span="24">
<el-form-item prop="canteenIsOpen">
<template #label>
<span>是否对外开放:</span>
<tip content="食堂是否对外开放(对外开放非食堂人员可以在手机端看到食堂信息)" />
</template>
<el-radio-group v-model="form.canteenIsOpen">
<el-radio v-for="v in yes_no_type" :key="v.value" :label="v.value" border>
{{ v.label }}
</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col v-if="form.types == '1'" :span="24">
<el-form-item label="营业时间:">
<div class="job-time-grid">
<div v-for="slot in jobTimeSlots" :key="slot.key" class="job-time-item">
<span class="job-time-label">{{ slot.label }}</span>
<el-time-picker v-model="jobTime[slot.key]" is-range range-separator="至"
start-placeholder="开始时间" end-placeholder="结束时间" value-format="HH:mm"
format="HH:mm" />
</div>
</div>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="店铺地址:" prop="addressDes">
<el-input type="textarea" v-model.trim="form.addressDes" placeholder="请输入店铺地址" />
</el-form-item>
</el-col>
</el-row>
</div>
</div>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
@@ -142,6 +170,7 @@
import { useDict } from "/@/hooks/dict"
import { useMessage } from "/@/hooks/message"
import { getObj, addObj, putObj } from "/@/api/wallet/mch-manage"
import { useUserInfo } from "/@/stores/userInfo"
const emit = defineEmits(["refresh"])
// 定义变量内容
@@ -174,6 +203,8 @@ const form = reactive({
parentId: "",
catgoryId: "",
discountsPrice: "",
// 饭卡密码:默认值由后端统一兜底,前端不持有;无查看权限时后端回显脱敏值且不可编辑
cardKey: "",
})
const jobTime = reactive<{
[key: string]: string[]
@@ -183,6 +214,13 @@ const jobTime = reactive<{
evening: [],
night: [],
})
// 营业时间四档时段
const jobTimeSlots = [
{ key: "morning", label: "早上" },
{ key: "noon", label: "中午" },
{ key: "evening", label: "晚上" },
{ key: "night", label: "夜间餐" },
]
const merchantTypeRadioDisabled = ref(false)
// 定义校验规则
@@ -197,8 +235,14 @@ const dataRules = {
catgoryId: [{ required: true, message: "请选择分类", trigger: "change" }],
supType: [{ required: true, message: "请选择合作商家类型", trigger: "change" }],
cooperationStatus: [{ required: true, message: "请选择合作状态", trigger: "change" }],
cardKey: [
{ pattern: /^[0-9A-Fa-f]{12}$/, message: "饭卡密码须为12位十六进制字符(6字节)", trigger: "blur" },
],
}
/** 是否可查看/编辑完整饭卡密码(按钮权限 wallet_mchManage_viewCardKey) */
const canViewCardKey = computed(() => useUserInfo().userInfos.authBtnList.includes("wallet_mchManage_viewCardKey"))
// 打开弹窗
const openDialog = async (id: string) => {
visible.value = true
@@ -244,6 +288,10 @@ const onSubmit = async () => {
if (_pa.parentId == "") {
_pa.parentId = -1
}
// 无查看权限或未修改(脱敏值/空)时不提交饭卡密码,由后端保持原值/默认值
if (!canViewCardKey.value || !_pa.cardKey || _pa.cardKey.includes("*")) {
_pa.cardKey = null
}
try {
loading.value = true
@@ -303,3 +351,75 @@ defineExpose({
openDialog,
})
</script>
<style lang="scss" scoped>
// 表单较长时弹窗内容区内部滚动,避免固定定位弹窗高出视口后顶部内容被裁掉
:deep(.el-dialog__body) {
max-height: calc(94vh - 170px);
overflow-y: auto;
}
// 收紧带边框单选按钮的间距,避免选项组折行错落
:deep(.el-radio) {
margin-right: 12px;
}
.form-section {
border: 1px solid var(--el-border-color-light, #ebeef5);
border-radius: 4px;
background: var(--el-bg-color);
margin-bottom: 12px;
}
.form-section-title {
display: flex;
align-items: center;
padding: 10px 20px;
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary, #333);
border-bottom: 1px solid var(--el-border-color-extra-light, #f2f6fc);
&::before {
content: "";
width: 4px;
height: 15px;
margin-right: 8px;
border-radius: 2px;
background: var(--el-color-primary);
}
}
.form-section-body {
padding: 20px 24px 0;
// 全局样式会把 el-col 内的 el-form-item 下边距清零(:last-of-type 会命中所有项),在列上统一补回行距
:deep(.el-col) {
margin-bottom: 18px;
}
}
.job-time-grid {
display: grid;
grid-template-columns: repeat(2, max-content);
gap: 12px 48px;
}
.job-time-item {
display: flex;
align-items: center;
.job-time-label {
flex-shrink: 0;
width: 45px;
margin-right: 10px;
font-size: 13px;
color: var(--el-text-color-secondary, #666);
}
:deep(.el-date-editor) {
flex: 1;
max-width: 280px;
}
}
</style>
+452 -200
View File
@@ -1,174 +1,176 @@
<template>
<div class="layout-padding">
<div class="layout-padding-auto layout-padding-view">
<el-form v-if="showSearch" ref="queryRef" :inline="true" :model="state.queryForm"
@keyup.enter="getDataList">
<el-row>
<el-form-item style="width: 250px" label="商户名" prop="name">
<el-input v-model="state.queryForm.name" placeholder="商户名" />
</el-form-item>
<el-form-item style="width: 250px" label="商户类型" prop="types">
<dict-select v-model="state.queryForm.types" dict-code="merchant_type" />
</el-form-item>
<el-form-item style="width: 250px" label="是否显示在移动端" prop="visibleMobile">
<dict-select v-model="state.queryForm.visibleMobile" dict-code="yes_no_type" />
</el-form-item>
<el-form-item style="width: 250px" label="是否同步正元" prop="isSyncZy">
<dict-select v-model="state.queryForm.isSyncZy" dict-code="yes_no_type" />
</el-form-item>
<el-form-item style="width: 250px" label="是否对外开放" prop="canteenIsOpen">
<dict-select v-model="state.queryForm.canteenIsOpen" dict-code="yes_no_type" />
</el-form-item>
<el-form-item>
<el-button icon="Search" type="primary" @click="getDataList">
{{ $t("common.queryBtn") }}
</el-button>
<el-button icon="Refresh" @click="resetQuery">{{ $t("common.resetBtn") }}</el-button>
</el-form-item>
</el-row>
</el-form>
<el-row>
<div class="mb8" style="width: 100%">
<el-button icon="folder-add" type="primary" class="ml10" @click="formDialogRef.openDialog()"
v-auth="'wallet_mchManage_add'">
新 增
</el-button>
<el-button plain :disabled="multiple" icon="Delete" type="primary" v-auth="'wallet_mchManage_del'"
@click="handleDelete(selectObjs)">
删 除
</el-button>
<right-toolbar v-model:showSearch="showSearch" :export="'wallet_mchManage_export'"
@exportExcel="exportExcel" class="ml10 mr20" style="float: right" @queryTable="getDataList" />
<div class="layout-padding-auto layout-padding-view mch-manage-page">
<header class="page-header">
<div class="min-w-0">
<h1>商户管理</h1>
<p>
共 {{ Number(state.pagination?.total || 0) }} 条商户记录,当前页
<span class="font-medium text-[var(--el-color-primary)]">{{ parentCount }} 家总店</span>
<span class="mx-1 text-[var(--el-border-color)]">/</span>
{{ branchCount }} 家分店
</p>
</div>
</el-row>
<el-table :data="state.dataList" v-loading="state.loading" :header-cell-style="tableStyle.headerCellStyle"
:row-class-name="tableRowClassName" @selection-change="selectionChangHandle"
@sort-change="sortChangeHandle" :tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
:default-expand-all="false" :indent="30" row-key="id">
<el-table-column type="selection" width="40" align="center" />
<el-table-column type="index" label="#" width="50" align="center" />
<el-table-column prop="name" label="商户名" align="left" min-width="160" />
<el-table-column prop="logoPic" label="logo" align="center">
<el-button type="primary" icon="Plus" @click="formDialogRef?.openDialog()" v-auth="'wallet_mchManage_add'">
新增商户
</el-button>
</header>
<SearchPanel
v-show="showSearch"
ref="queryRef"
class="mch-manage-search"
title="商户筛选"
:model="state.queryForm"
:loading="Boolean(state.loading)"
:search-text="$t('common.queryBtn')"
:reset-text="$t('common.resetBtn')"
@search="getDataList"
@reset="resetQuery"
>
<el-form-item style="width: 240px" label="商户名" prop="name">
<el-input v-model="state.queryForm.name" clearable placeholder="请输入商户名" />
</el-form-item>
<el-form-item style="width: 230px" label="商户类型" prop="types">
<dict-select v-model="state.queryForm.types" dict-code="merchant_type" clearable />
</el-form-item>
<template #more>
<el-form-item style="width: 230px" label="移动端展示" prop="visibleMobile">
<dict-select v-model="state.queryForm.visibleMobile" dict-code="yes_no_type" clearable />
</el-form-item>
<el-form-item style="width: 230px" label="同步正元" prop="isSyncZy">
<dict-select v-model="state.queryForm.isSyncZy" dict-code="yes_no_type" clearable />
</el-form-item>
<el-form-item style="width: 230px" label="对外开放" prop="canteenIsOpen">
<dict-select v-model="state.queryForm.canteenIsOpen" dict-code="yes_no_type" clearable />
</el-form-item>
</template>
</SearchPanel>
<div class="table-toolbar">
<div class="table-toolbar__title">
<span aria-hidden="true"></span>
<h2>商户列表</h2>
<small>共 {{ Number(state.pagination?.total || 0) }} 条</small>
</div>
<div class="table-toolbar__actions">
<right-toolbar v-model:showSearch="showSearch" :export="'wallet_mchManage_export'" @exportExcel="exportExcel"
@queryTable="getDataList" />
</div>
</div>
<el-table :data="state.dataList" v-loading="state.loading" border stripe class="mch-table"
:cell-style="tableStyle.cellStyle" :header-cell-style="tableStyle.headerCellStyle"
:row-class-name="tableRowClassName" @sort-change="sortChangeHandle"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
:default-expand-all="false" :indent="24" row-key="id" @row-dblclick="toMchDetail">
<el-table-column label="商户" min-width="240">
<template #default="{ row }">
<preview-image :src="row.logoPic" />
</template>
</el-table-column>
<el-table-column prop="collectionCode" width="150" align="center" label="商户收款二维码">
<template #default="{ row }">
<div class="flex flex-col justify-center items-center">
<preview-image :src="row.collectionCode" download :downloadName="`${row.name} 收款二维码`" />
<div class="mch-identity">
<div class="mch-logo">
<preview-image width="40px" height="40px" :src="row.logoPic" />
</div>
<div class="min-w-0">
<div class="mch-name-row">
<button type="button" class="mch-name" @click="toMchDetail(row)">
{{ row.name || "未命名商户" }}
</button>
<span v-if="Number(row.mchCount) > 0" class="branch-chip">{{ row.mchCount }} 家分店</span>
</div>
<div class="mch-meta">
<span>{{ row.deptName || "未关联部门" }}</span>
<span v-if="row.phone"> · {{ row.phone }}</span>
</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="collectionUrlCode" width="150" align="center" label="商户url收款码">
<el-table-column label="类型 / 属性" min-width="180">
<template #default="{ row }">
<div class="flex flex-col justify-center items-center">
<preview-image :src="row.collectionUrlCode" download
<div class="kv-list">
<div v-if="typeText(row)" class="kv-row">
<span class="kv-label">类型</span>
<span class="kv-value" :title="typeText(row)">{{ typeText(row) }}</span>
</div>
<div class="kv-row">
<span class="kv-label">属性</span>
<span class="kv-value">{{ merchantAttrLabel(row) }}</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="收款码" width="120" align="center">
<template #default="{ row }">
<div class="qr-cell">
<preview-image width="38px" height="38px" :src="row.collectionCode" download
:downloadName="`${row.name} 收款二维码`" />
<preview-image width="38px" height="38px" :src="row.collectionUrlCode" download
:downloadName="`${row.name} 商户url收款码`" />
</div>
</template>
</el-table-column>
<el-table-column prop="merchantType" label="商户类型" align="center" :min-width="160">
<el-table-column label="支付默认钱包" min-width="140">
<template #default="{ row }">
<div class="flex">
<div class="flex justify-center items-center">
<div v-for="(v, i) in row.types?.split(',')" :key="i" class="mr-1">
<dict-tag :options="merchant_type" :value="v" />
</div>
</div>
<el-tag v-if="row.catgoryName" type="primary">{{ row.catgoryName }}</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="merchantAttr" label="商户属性" align="center" width="100">
<template #default="{ row }">
<span v-if="row.merchantAttr === 'SCHOOL'">学校</span>
<span v-else-if="row.merchantAttr === 'GOVERNMENT'">政府</span>
<span v-else-if="row.merchantAttr === 'FACTORY'">工厂</span>
<span v-else>普通</span>
</template>
</el-table-column>
<el-table-column prop="isSyncZy" label="是否同步正元" min-width="120" align="center">
<template #default="{ row }">
<el-switch :disabled="!useUserInfo().userInfos.roleCodes.includes('ROLE_ADMIN')"
active-value="1" inactive-value="0" v-model="row.isSyncZy" @change="isSyncZyChange(row)" />
</template>
</el-table-column>
<el-table-column label="是否在移动端展示" min-width="150" align="center">
<template #default="{ row }">
<el-switch :disabled="!useUserInfo().userInfos.roleCodes.includes('ROLE_ADMIN')"
active-value="1" inactive-value="0" v-model="row.visibleMobile"
@change="visibleMobileChange(row)" />
</template>
</el-table-column>
<el-table-column width="200" label="支付默认钱包" align="center">
<template #default="{ row }">
<div class="flex items-center justify-center">
<div class="color-primary">{{ row.walletTypeName }}</div>
<div class="flex items-center just ml5">
<div class="cell-stack">
<strong>{{ row.walletTypeName || "未设置" }}</strong>
<span v-if="row.walletTypeUniqueCode">
<dict-tag :options="wallet_type" :value="row.walletTypeUniqueCode" />
</span>
</div>
</template>
</el-table-column>
<el-table-column label="展示开关" min-width="140" align="center">
<template #default="{ row }">
<div class="kv-list">
<div class="kv-row">
<span class="kv-label">同步正元</span>
<el-switch :disabled="!isAdmin" active-value="1" inactive-value="0" v-model="row.isSyncZy"
@change="isSyncZyChange(row)" />
</div>
<div class="kv-row">
<span class="kv-label">移动端</span>
<el-switch :disabled="!isAdmin" active-value="1" inactive-value="0" v-model="row.visibleMobile"
@change="visibleMobileChange(row)" />
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="deptName" label="部门" align="center" min-width="100" />
<el-table-column prop="phone" label="联系电话" :min-width="120" align="center" />
<el-table-column prop="sort" label="排序" :min-width="100" align="center">
<el-table-column label="营业时间" min-width="130">
<template #default="{ row }">
<span class="color-success">{{ row.sort }}</span>
</template>
</el-table-column>
<el-table-column prop="mchCount" label="分店数量" :min-width="100" align="center">
<template #default="{ row }">
<span class="color-primary">{{ row.mchCount || 0 }}</span>
</template>
</el-table-column>
<el-table-column prop="businessTime" label="营业时间" :min-width="160" align="center">
<template #default="{ row }">
<div>{{ row.jobTime?.split(",")[0] ? "早上:" + row.jobTime?.split(",")[0] : "" }}</div>
<div>{{ row.jobTime?.split(",")[1] ? "中午:" + row.jobTime?.split(",")[1] : "" }}</div>
<div>{{ row.jobTime?.split(",")[2] ? "晚上:" + row.jobTime?.split(",")[2] : "" }}</div>
<div>{{ row.jobTime?.split(",")[3] ? "夜间餐:" + row.jobTime?.split(",")[3] : "" }}</div>
</template>
</el-table-column>
<el-table-column prop="addressDes" label="地址" :min-width="160" align="center" />
<el-table-column prop="createTime" label="创建时间" :min-width="160" align="center" />
<el-table-column label="操作" fixed="right"
v-auths="['wallet_mchManage_refresh_qrcode', 'wallet_mchManage_setting', 'wallet_mchManage_edit', 'wallet_mchManage_del']"
min-width="300" align="center">
<template #default="{ row }">
<div class="action-grid">
<el-button style="margin: 0;" icon="InfoFilled" text type="primary"
@click="toMchDetail(row)">
详情
</el-button>
<el-button style="margin: 0;" icon="RefreshRight" text type="primary"
v-auth="'wallet_mchManage_refresh_qrcode'" @click="refreshQrcode(row)">
刷新二维码
</el-button>
<el-button style="margin: 0;" icon="setting" text type="primary"
v-auth="'wallet_mchManage_setting'" @click="settingDialogRef.openDialog(row.id)">
配置
</el-button>
<el-button style="margin: 0;" icon="edit-pen" text type="primary"
v-auth="'wallet_mchManage_edit'" @click="formDialogRef.openDialog(row.id)">
编辑
</el-button>
<el-button style="margin: 0;" icon="delete" text type="primary"
v-auth="'wallet_mchManage_del'" @click="handleDelete([row.id])"> 删除 </el-button>
<div class="jobtime-stack">
<span v-for="(label, i) in jobTimeLabels" :key="i" v-show="jobTimePart(row, i)">
{{ label }} {{ jobTimePart(row, i) }}
</span>
</div>
</template>
</el-table-column>
<el-table-column prop="addressDes" label="地址" min-width="150" show-overflow-tooltip />
<el-table-column prop="sort" label="排序" width="70" align="center">
<template #default="{ row }">
<span class="color-success tabular-nums">{{ row.sort }}</span>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="175" />
<el-table-column label="操作" fixed="right" width="140" align="center"
v-auths="['wallet_mchManage_del']">
<template #default="{ row }">
<div class="row-actions">
<el-button link type="primary" icon="InfoFilled" @click="toMchDetail(row)">详情</el-button>
<el-button link type="danger" icon="Delete" v-auth="'wallet_mchManage_del'"
@click="handleDelete([row.id])">删除</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无符合条件的商户" />
</template>
</el-table>
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle"
v-bind="state.pagination" />
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="state.pagination" />
</div>
<!-- 编辑、新增 -->
<form-dialog ref="formDialogRef" @refresh="getDataList(false)" />
<!-- 商户配置 -->
<Setting ref="settingDialogRef" @refresh="getDataList(false)" />
</div>
</template>
@@ -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<BasicTableProps>({
queryForm: {},
@@ -202,8 +203,39 @@ const state: BasicTableProps = reactive<BasicTableProps>({
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<string, string> = { 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[]) => {
}
</script>
<style scoped lang="scss">
.action-grid {
display: grid;
grid-template-columns: repeat(3, max-content);
justify-content: center;
justify-items: left;
align-items: center;
gap: 6px 12px;
<style lang="scss" scoped>
.mch-manage-page {
overflow-x: hidden;
overflow-y: auto;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
padding: 18px 20px;
border-bottom: 1px solid var(--el-border-color-light);
background: var(--el-fill-color-blank);
.status {
h1 {
margin: 0;
color: var(--el-text-color-primary);
font-size: 20px;
font-weight: 600;
line-height: 28px;
}
p {
margin: 4px 0 0;
color: var(--el-text-color-secondary);
font-size: 13px;
line-height: 20px;
}
}
.table-toolbar {
display: flex;
min-height: 52px;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-bottom: 1px solid var(--el-border-color-lighter);
background: var(--el-fill-color-blank);
}
.table-toolbar__title,
.table-toolbar__actions {
display: flex;
align-items: center;
margin-bottom: 2px;
gap: 8px;
}
.table-toolbar__title {
min-width: 0;
> span {
width: 2px;
height: 14px;
flex: none;
border-radius: 1px;
background: var(--el-color-primary);
}
h2 {
margin: 0;
color: var(--el-text-color-primary);
font-size: 14px;
font-weight: 600;
line-height: 20px;
}
small {
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 20px;
}
}
:deep(.mch-manage-search.search-panel) {
margin-bottom: 0;
padding: 12px 20px 4px;
border: 0;
border-bottom: 1px solid var(--el-border-color-lighter);
border-radius: 0;
}
:deep(.mch-manage-search .search-panel__header) {
margin-bottom: 8px;
}
:deep(.mch-manage-search .search-panel__row) {
column-gap: 10px;
}
:deep(.mch-manage-search .el-form-item) {
margin-right: 0;
}
// 商户身份单元格:logo + 可点击名称 + 分店标记 + 部门电话
.mch-identity {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
.mch-logo {
flex: none;
:deep(img) {
border-radius: 4px;
border: 1px solid var(--el-border-color-lighter);
}
}
}
.mch-name-row {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.mch-name {
overflow: hidden;
padding: 0;
border: 0;
background: transparent;
color: var(--el-color-primary);
font: inherit;
font-size: 14px;
font-weight: 600;
line-height: 20px;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
&:hover {
color: var(--el-color-primary-light-3);
}
&:focus-visible {
border-radius: 2px;
outline: 2px solid var(--el-color-primary-light-5);
outline-offset: 2px;
}
}
.branch-chip {
flex: none;
padding: 0 6px;
border: 1px solid var(--el-color-primary-light-7);
border-radius: 4px;
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
font-size: 12px;
line-height: 18px;
}
.mch-meta {
overflow: hidden;
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.cell-stack {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
line-height: 20px;
> span {
overflow: hidden;
color: var(--el-text-color-secondary);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
strong {
overflow: hidden;
color: var(--el-text-color-primary);
font-size: 14px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
}
// label:value 单元格:短 label 固定宽度左对齐,保证各行的值/开关纵向对齐
.kv-list {
display: flex;
min-width: 0;
flex-direction: column;
gap: 6px;
}
.kv-row {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
line-height: 20px;
}
.kv-label {
width: 48px;
flex: none;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.kv-value {
overflow: hidden;
min-width: 0;
flex: 1;
color: var(--el-text-color-primary);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.qr-cell {
display: flex;
justify-content: center;
gap: 8px;
}
.jobtime-stack {
display: flex;
flex-direction: column;
gap: 2px;
span {
display: inline-block;
}
span:nth-child(1) {
min-width: 60px;
max-width: 100px;
text-align: left;
}
span:nth-child(2) {
flex: 1;
min-width: 50px;
white-space: nowrap;
overflow: hidden;
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
}
:deep(.el-table) .success-row {
--el-table-tr-bg-color: var(--el-color-success-light-9);
.row-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
white-space: nowrap;
:deep(.el-button + .el-button) {
margin-left: 0;
}
}
// 分店行浅绿底色;直接命中 td 保证不被斑马纹覆盖
:deep(.mch-table) {
.success-row {
--el-table-tr-bg-color: var(--el-color-success-light-9);
}
.success-row td.el-table__cell {
background: var(--el-color-success-light-9);
}
}
@media (max-width: 768px) {
.page-header,
.table-toolbar {
align-items: stretch;
flex-direction: column;
}
.page-header > .el-button {
align-self: flex-start;
}
.table-toolbar__actions {
flex-wrap: wrap;
}
}
</style>
@@ -1,128 +0,0 @@
<template>
<el-dialog :title="form.id ? '编辑' : '新增'" width="50%" v-model="visible" append-to-body :close-on-click-modal="false" draggable>
<el-form ref="dataFormRef" :model="form" :rules="dataRules" formDialogRef label-width="90px" v-loading="loading">
<el-row :gutter="24">
<el-col :span="24" class="mb20">
<el-form-item label="规则" prop="rulePriceConfigId">
<select-mch-rule v-model="form.rulePriceConfigId" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="源商户" prop="sourceMchId">
<select-mch v-model="form.sourceMchId" :apiParams="{ typesList: ['1'], notIds: [form.targetMchId] }" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="目标商户" prop="targetMchId">
<select-mch disabled v-model="form.targetMchId" :apiParams="{ types: '1' }" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-alert title="提示" type="info" :closable="false">
<mch-rule-desc :detail="form" />
</el-alert>
</el-col>
</el-row>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="onSubmit" :disabled="loading">确认</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts" name="WalletRuleMchConfigFormDialog">
import { useMessage } from "/@/hooks/message"
import * as walletRuleMchConfigApi from "/@/api/wallet/mch-rule-join"
const mchRuleDesc = defineAsyncComponent(() => import("../components/mch-rule-desc.vue"))
const emit = defineEmits(["refresh"])
// 定义变量内容
const dataFormRef = ref()
const visible = ref(false)
const loading = ref(false)
// 定义字典
// 提交表单数据
const defaultForm = {
id: "",
sourceMchId: "",
targetMchId: "",
targetDeptId: "",
rulePriceConfigId: "",
sourceMchName: "",
targetMchName: "",
basePrice: "",
multiple: "",
}
const form = reactive({
...defaultForm,
})
// 定义校验规则
const dataRules = ref({
sourceMchId: [{ required: true, message: "请选择源商户", trigger: "blur" }],
targetMchId: [{ required: true, message: "请选择目标商户", trigger: "blur" }],
rulePriceConfigId: [{ required: true, message: "请选择规则", trigger: "blur" }],
})
// 打开弹窗
const openDialog = (id: string, targetMchId: string) => {
visible.value = true
Object.assign(form, defaultForm)
if (targetMchId) form.targetMchId = targetMchId
// 重置表单数据
nextTick(() => {
dataFormRef.value?.resetFields()
})
// 获取walletRuleMchConfig信息
if (id) {
form.id = id
getwalletRuleMchConfigData(id)
}
}
// 提交
const onSubmit = async () => {
const valid = await dataFormRef.value.validate().catch(() => {})
if (!valid) return false
try {
loading.value = true
const { data } = form.id ? await walletRuleMchConfigApi.putObj(form) : await walletRuleMchConfigApi.addObj(form)
useMessage().success(form.id ? "修改成功" : "添加成功")
// visible.value = false
// 更新
await getwalletRuleMchConfigData(data)
emit("refresh")
} catch (err: any) {
useMessage().error(err.msg)
} finally {
loading.value = false
}
}
// 初始化表单数据
const getwalletRuleMchConfigData = async (id: string) => {
// 获取数据
loading.value = true
try {
const { data } = await walletRuleMchConfigApi.getJoinDetail(id)
Object.assign(form, data)
} catch (err) {
console.error(err)
} finally {
loading.value = false
}
}
// 暴露变量
defineExpose({
openDialog,
})
</script>
@@ -1,146 +0,0 @@
<template>
<div>
<el-row>
<div class="mb8" style="width: 100%">
<el-button icon="folder-add" type="primary" class="ml10" @click="doAdd" v-auth="'dzc_walletRuleMchConfig_add'"> 新 增 </el-button>
</div>
</el-row>
<el-table
:data="state.dataList"
v-loading="state.loading"
border
:cell-style="tableStyle.cellStyle"
:header-cell-style="tableStyle.headerCellStyle"
@selection-change="selectionChangHandle"
@sort-change="sortChangeHandle"
>
<el-table-column type="selection" width="40" align="center" />
<el-table-column type="index" label="#" width="80" />
<el-table-column prop="sourceMchName" label="源商户" />
<el-table-column prop="targetMchName" label="目标商户" />
<el-table-column prop="rulePriceConfigId" label="规则名">
<template #default="{ row }">
<dict-tag :options="wallet_rule_price_type" :value="row.rulePriceType" />
</template>
</el-table-column>
<el-table-column prop="rulePriceConfigId" min-width="60" label="基础金额">
<template #default="{ row }">
<span class="color-success">{{ displayPrice(row.basePrice) }}</span>
</template>
</el-table-column>
<el-table-column prop="rulePriceConfigId" min-width="60" label="额外百分比">
<template #default="{ row }">
<span class="color-primary">{{ row.multiple }}%</span>
</template>
</el-table-column>
<el-table-column prop="status" min-width="60" label="是否启用">
<template #default="{ row }">
<el-switch active-value="1" inactive-value="0" v-model="row.status" @change="statusChange(row)" />
</template>
</el-table-column>
<el-table-column min-width="200" label="描述">
<template #default="{ row }">
<mch-rule-desc :detail="row" />
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button
icon="edit-pen"
text
type="primary"
v-auth="'dzc_walletRuleMchConfig_edit'"
@click="WalletRuleMchConfigFormDialogRef.openDialog(row.id)"
>
编辑
</el-button>
<el-button icon="delete" text type="primary" v-auth="'dzc_walletRuleMchConfig_del'" @click="handleDelete([row.id])">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="state.pagination" />
<wallet-rule-mch-config-form-dialog ref="WalletRuleMchConfigFormDialogRef" @refresh="getDataList(false)" />
</div>
</template>
<script setup lang="ts" name="mchRuleConfigDialog">
import * as walletRuleMchConfigApi from "/@/api/wallet/mch-rule-join"
import { BasicTableProps, useTable } from "/@/hooks/table"
import { useMessage, useMessageBox } from "/@/hooks/message"
import { useDict } from "/@/hooks/dict"
import { displayPrice } from "/@/utils/formatMoney"
const mchRuleDesc = defineAsyncComponent(() => import("../components/mch-rule-desc.vue"))
const WalletRuleMchConfigFormDialog = defineAsyncComponent(() => import("./mch-rule-config-form.vue"))
export type Props = {
sourceMchId?: string
}
const props = withDefaults(defineProps<Props>(), {
sourceMchId: "",
})
// 多选变量
const selectObjs = ref([]) as any
const multiple = ref(true)
// 定义变量内容
const WalletRuleMchConfigFormDialogRef = ref()
// 定义查询字典
const { wallet_rule_price_type } = useDict("wallet_rule_price_type")
const state: BasicTableProps = reactive<BasicTableProps>({
createdIsNeed: false,
queryForm: {},
pageList: walletRuleMchConfigApi.pageJoin,
})
// table hook
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, downBlobFile, tableStyle } = useTable(state)
// 多选事件
const selectionChangHandle = (objs: { id: string }[]) => {
selectObjs.value = objs.map(({ id }) => id)
multiple.value = !objs.length
}
// 新增
const doAdd = () => {
WalletRuleMchConfigFormDialogRef.value.openDialog(null, props.sourceMchId)
}
// 是否启用
const statusChange = async (row: any) => {
try {
await walletRuleMchConfigApi.putObj(row)
getDataList()
useMessage().success("操作成功")
} catch (e) {
useMessage().error("操作错误")
}
}
// 删除操作
const handleDelete = async (ids: string[]) => {
try {
await useMessageBox().confirm("此操作将永久删除")
} catch {
return
}
try {
await walletRuleMchConfigApi.delObjs(ids)
getDataList()
useMessage().success("删除成功")
} catch (err: any) {
useMessage().error(err.msg)
}
}
onMounted(() => {
state.queryForm.targetMchId = props.sourceMchId
getDataList()
})
</script>
-128
View File
@@ -1,128 +0,0 @@
<template>
<el-dialog width="80%" v-model="visible" :close-on-click-modal="false" @closed="dialogClosed" draggable>
<div>
<el-tabs v-model="form.activeTab" class="demo-tabs">
<div v-for="v in data.tabList" :key="v.id">
<el-tab-pane v-if="v.visible" :label="v.label" :name="v.id" />
</div>
</el-tabs>
<el-form ref="dataFormRef" v-if="form.activeTab == 10" :model="form" :rules="dataRules" label-width="120px" v-loading="loading">
<el-row :gutter="24">
<el-col :span="18" class="mb20">
<el-form-item label="默认钱包类型" prop="defaultWalletTypeId">
<select-wallet-type v-model="form.defaultWalletTypeId" placeholder="请选择默认钱包类型" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<mch-rule-config-dialog :sourceMchId="form.id" v-if="form.activeTab == 20 && form.merchantType == '1'" />
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="onSubmit" :disabled="loading">确认</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts" name="MerchantInfoDialog">
import { useMessage } from "/@/hooks/message"
import { getObj, putObj } from "/@/api/wallet/mch-manage"
const MchRuleConfigDialog = defineAsyncComponent(() => import("./mch-rule-config.vue"))
const emit = defineEmits(["refresh"])
const dataFormRef = ref()
const visible = ref(false)
const loading = ref(false)
const data = reactive({
tabList: [
{ id: 10, label: "钱包配置", visible: true },
{ id: 20, label: "钱包规则", visible: false },
],
})
const defualtForm = {
activeTab: data.tabList[0].id,
id: "",
merchantType: "",
defaultWalletTypeId: "",
}
// 提交表单数据
const form = reactive({ ...defualtForm })
// 定义校验规则
const dataRules = ref({
defaultWalletTypeId: [{ required: true, message: "请选择默认钱包类型", trigger: "blur" }],
})
// 打开弹窗
const openDialog = (id: string) => {
if (!id) {
useMessage().success("商户id不存在")
return
}
visible.value = true
form.id = id
// 重置表单数据
dataFormRef.value?.resetFields()
getmerchantInfoData(id)
}
// 关闭弹框
const dialogClosed = () => {
Object.assign(form, defualtForm)
}
// 初始化表单数据
const getmerchantInfoData = async (id: string) => {
// 获取数据
loading.value = true
try {
const res = await getObj(id)
if (res.code === 0) {
const _data = res.data
Object.assign(form, _data, {
merchantType: _data.types,
})
// tab
if (_data.types == "1") {
data.tabList[1].visible = true
}
}
} catch (error: any) {
useMessage().success(error)
} finally {
loading.value = false
}
}
// 提交
const onSubmit = async () => {
const valid = await dataFormRef.value.validate().catch(() => {})
if (!valid) return false
const _pa = { ...form, types: form.merchantType }
try {
loading.value = true
const res = await putObj(_pa)
if (res.code === 0) {
useMessage().success("修改成功")
visible.value = false
emit("refresh")
} else {
useMessage().error("修改失败")
}
} catch (err: any) {
useMessage().error(err.msg)
} finally {
loading.value = false
}
}
// 暴露变量
defineExpose({
openDialog,
})
</script>
+71 -36
View File
@@ -36,62 +36,93 @@
</template>
</SearchPanel>
<el-row>
<div class="mb8" style="width: 100%">
<el-button icon="folder-add" type="primary" class="ml10" @click="uploadExcelRef.show()"
v-auths="['wallet_walletActive_import', 'wallet_student_walletActive_import']">
导 入
</el-button>
<el-button icon="folder-add" type="primary" class="ml10" @click="formDialogRef.openDialog()"
v-auth="'wallet_walletActive_add'">
开 卡
</el-button>
<el-button plain :disabled="multiple" icon="Delete" type="primary"
v-auth="'wallet_walletActive_del'" @click="handleDelete(selectObjs)">
删除
</el-button>
<right-toolbar v-model:showSearch="showSearch" :export="'wallet_walletActive_export'"
@exportExcel="exportExcel" class="ml10 mr20" style="float: right"
@queryTable="getDataList">
<div class="mb8 flex items-center justify-between" style="width: 100%">
<div class="flex items-center gap-2">
<el-button icon="folder-add" type="primary" @click="uploadExcelRef.show()"
v-auths="['wallet_walletActive_import', 'wallet_student_walletActive_import']">
导 入
</el-button>
<el-button icon="folder-add" type="primary" @click="formDialogRef.openDialog()"
v-auth="'wallet_walletActive_add'">
开 卡
</el-button>
<el-button plain :disabled="multiple" icon="Delete" type="primary"
v-auth="'wallet_walletActive_del'" @click="handleDelete(selectObjs)">
删除
</el-button>
</div>
<div class="flex items-center gap-2">
<el-tooltip effect="dark" placement="top"
:content="selectionRows.length ? `打印卡面(已选 ${selectionRows.length} 张)` : '打印卡面(请先勾选开卡记录)'">
<el-button circle icon="Printer" v-auth="'wallet_walletActive_print'"
:disabled="!selectionRows.length" @click="openCardPrint(selectionRows)" />
</el-tooltip>
</right-toolbar>
<right-toolbar v-model:showSearch="showSearch" :export="'wallet_walletActive_export'"
@exportExcel="exportExcel" @queryTable="getDataList" />
</div>
</div>
</el-row>
<el-table :data="state.dataList" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
:header-cell-style="tableStyle.headerCellStyle" @selection-change="selectionChangHandle"
@sort-change="sortChangeHandle">
<el-table-column type="selection" width="40" align="center" />
<el-table-column type="index" label="#" width="80" />
<el-table-column label="用户信息" min-width="280">
<el-table-column type="index" label="#" width="50" align="center" />
<el-table-column label="用户信息" min-width="220">
<template #default="{ row }"><user-info-cell :user="row" :show-gender="false" /></template>
</el-table-column>
<el-table-column label="身份信息" min-width="300">
<el-table-column label="身份信息" min-width="200">
<template #default="{ row }"><user-identity-info :user="row" /></template>
</el-table-column>
<el-table-column prop="merchantName" label="所属商户" show-overflow-tooltip />
<el-table-column prop="cardNumber" label="用户卡号" show-overflow-tooltip />
<el-table-column prop="isActiveHardware" label="是否激活" show-overflow-tooltip>
<el-table-column prop="merchantName" label="所属商户" min-width="120" show-overflow-tooltip />
<el-table-column prop="cardNumber" label="用户卡号" min-width="110" show-overflow-tooltip>
<template #default="{ row }">
<span class="font-mono text-[13px]">{{ row.cardNumber }}</span>
</template>
</el-table-column>
<el-table-column prop="isActiveHardware" label="是否激活" width="100" align="center">
<template #default="{ row }">
<dict-tag :options="yes_no_type" :value="row.isActiveHardware" />
</template>
</el-table-column>
<el-table-column label="操作" width="250">
<el-table-column label="操作" width="210" fixed="right">
<template #default="{ row }">
<el-button v-auth="'wallet_walletActive_refresh_active'"
v-if="row.isActiveHardware == 1" icon="RefreshRight" text type="primary"
@click="doRefreshAvatar(row)">重新激活</el-button>
<el-button v-auth="'wallet_walletActive_bind_card'" v-if="!row.userId" icon="Paperclip"
text type="primary"
@click="bindUserFormDialogRef.openDialog(row.id)">绑定用户</el-button>
<el-button v-auth="'wallet_walletActive_cancel_bind_card'" v-if="row.userId"
icon="Scissor" text type="primary" @click="doCancelBind(row)">取消绑定</el-button>
<el-button icon="edit-pen" text type="primary" v-auth="'wallet_walletActive_edit'"
@click="formCarNumberRef.openDialog(row.id)">编辑卡号</el-button>
<el-button icon="delete" text type="primary" v-auth="'wallet_walletActive_del'"
@click="handleDelete([row.id])">删除</el-button>
<div class="flex flex-wrap items-start -mx-1">
<div class="w-1/2 h-[30px] flex justify-start items-center"
v-auth="'wallet_walletActive_write_card'">
<el-button icon="CreditCard" text type="primary" style="margin: 0"
@click="cardReaderRef?.openDialog({ mchId: row.merchantId, mchName: row.merchantName, writeText: row.cardNumber })">
写卡
</el-button>
</div>
<div class="w-1/2 h-[30px] flex justify-start items-center"
v-auth="'wallet_walletActive_edit'">
<el-button icon="edit-pen" text type="primary" style="margin: 0"
@click="formCarNumberRef.openDialog(row.id)">
编辑卡号
</el-button>
</div>
<div v-if="row.isActiveHardware == 1"
class="w-1/2 h-[30px] flex justify-start items-center"
v-auth="'wallet_walletActive_refresh_active'">
<el-button icon="RefreshRight" text type="primary" style="margin: 0"
@click="doRefreshAvatar(row)">重新激活</el-button>
</div>
<div v-if="!row.userId" class="w-1/2 h-[30px] flex justify-start items-center"
v-auth="'wallet_walletActive_bind_card'">
<el-button icon="Paperclip" text type="primary" style="margin: 0"
@click="bindUserFormDialogRef.openDialog(row.id)">绑定用户</el-button>
</div>
<div v-if="row.userId" class="w-1/2 h-[30px] flex justify-start items-center"
v-auth="'wallet_walletActive_cancel_bind_card'">
<el-button icon="Scissor" text type="primary" style="margin: 0"
@click="doCancelBind(row)">取消绑定</el-button>
</div>
<div class="w-1/2 h-[30px] flex justify-start items-center"
v-auth="'wallet_walletActive_del'">
<el-button icon="delete" text type="primary" style="margin: 0"
@click="handleDelete([row.id])">删除</el-button>
</div>
</div>
</template>
</el-table-column>
</el-table>
@@ -107,6 +138,7 @@
<wallet-active-import-excel ref="uploadExcelRef" @refreshDataList="getDataList(false)" />
<card-print-drawer ref="cardPrintRef" />
<card-reader-dialog ref="cardReaderRef" />
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess"
@failure="handleExportFailure" />
</div>
@@ -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()