feat:对接读写卡器
feat:优化用户下拉框搜索 feat:优化下单卡顿问题 feat:优化商户列表样式
This commit is contained in:
1 parent
b95603829a
commit
8b2c08920f
24 files changed
+2461
-974
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -58,7 +58,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
excludeUserIds: () => [],
|
||||
multiple: false,
|
||||
valueField: "userId",
|
||||
size: 10,
|
||||
// 下拉接口关闭了 COUNT 且不翻页,一次拉取条数需要覆盖长尾匹配结果
|
||||
size: 50,
|
||||
})
|
||||
|
||||
/** 远程搜索条件,用户输入关键词后从第一页重新加载。 */
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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: '关 闭',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
Vendored
+3
-1
@@ -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 参数类型定义
|
||||
|
||||
Vendored
+55
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 '';
|
||||
}
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user