Merge remote-tracking branch 'origin/master'

# Conflicts:
#	src/views/canteen/canteenSpu/form.vue
This commit is contained in:
zjf committed 2026-08-04 16:46:24 +08:00
commit 0e7050daeb
25 files changed
+1608 -122

No files matched your search

+8
View File
@@ -49,6 +49,14 @@ export const addObj = (obj: Object) => {
})
}
/** 获取后端生成且当前未被占用的用户登录账号 */
export const generateUsername = () => {
return request({
url: "/admin/user/generate-username",
method: "get",
})
}
export const getObj = (id: String) => {
return request({
url: "/admin/user/details/" + id,
+98
View File
@@ -0,0 +1,98 @@
import request from "/@/utils/request"
export interface TerminalLogQuery {
current?: number
size?: number
hardId?: string | number
hardName?: string
uniqueCode?: string
merchantId?: string | number
deptId?: string | number
logDateStart?: string
logDateEnd?: string
uploadTimeStart?: string
uploadTimeEnd?: string
}
export interface TerminalLogDevice {
hardId: string | number
hardName: string
uniqueCode: string
hardType?: string
merchantId?: string | number
deptId?: string | number
fileCount: number
latestUploadTime?: string
}
export interface TerminalLogTreeNode {
key: string
label: string
relativePath: string
directory: boolean
fileId?: string | number
fileSize?: number
logDate?: string
uploadTime?: string
children?: TerminalLogTreeNode[]
}
export interface TerminalLogPreview {
id: string | number
originalName: string
relativePath: string
content: string
lineCount: number
fileSize: number
truncated: boolean
}
export function fetchList(query?: Object) {
return request({
url: "/hard/hardTerminalLogFile/page",
method: "get",
params: query,
})
}
export function fetchDevicePage(query?: TerminalLogQuery) {
return request({
url: "/hard/hardTerminalLogFile/device-page",
method: "get",
params: query,
})
}
export function fetchFileTree(query: TerminalLogQuery) {
return request({
url: "/hard/hardTerminalLogFile/file-tree",
method: "get",
params: query,
})
}
export function fetchPreview(id: string | number) {
return request({
url: `/hard/hardTerminalLogFile/${id}/preview`,
method: "get",
})
}
export function pullTerminalLog(hardId: string | number) {
return request({
url: `/hard/hardTerminalLogFile/pull/${hardId}`,
method: "post",
})
}
export function downloadUrl(id: string | number) {
return `/hard/hardTerminalLogFile/${id}/download`
}
export function downloadFile(id: string | number) {
return request({
url: downloadUrl(id),
method: "get",
responseType: "blob",
})
}
+2
View File
@@ -1,6 +1,8 @@
<!-- excel 导入组件 -->
<template>
<el-dialog :title="prop.title" v-model="state.upload.open" :close-on-click-modal="false" draggable>
<!-- 可选前置区域供业务组件放置导入类型等条件,不影响既有上传调用。 -->
<slot name="before-upload" :uploading="state.upload.isUploading" />
<el-upload v-loading="state.upload.isUploading" ref="uploadRef" :limit="1" accept=".xlsx, .xls"
:headers="headers" :action="baseURL + other.adaptationUrl(url)" :disabled="state.upload.isUploading"
:on-progress="handleFileUploadProgress" :on-success="handleFileSuccess" :on-error="handleFileError"
+1
View File
@@ -1,5 +1,6 @@
// 申明外部 npm 插件模块
declare module 'splitpanes';
declare module 'codemirror';
declare module 'js-cookie';
declare module '@wangeditor/editor-for-vue';
declare module 'qs';
@@ -0,0 +1,72 @@
<!-- 用户导入组件:按所选用户类型同步切换导入接口和模板。 -->
<template>
<upload-excel ref="uploadExcelRef" title="用户导入" :temp-url="currentImportOption.tempUrl"
:url="currentImportOption.url" @refreshDataList="handleRefreshDataList">
<template #before-upload="{ uploading }">
<el-form class="mb20" :model="form" label-width="90px">
<el-form-item label="导入类型" prop="importType" required class="mb20">
<el-select v-model="form.importType" placeholder="请选择导入类型" class="w100" :disabled="uploading">
<el-option v-for="item in importOptions" :key="item.value" :label="item.label"
:value="item.value" :disabled="!auth(item.permission)" />
</el-select>
</el-form-item>
</el-form>
</template>
</upload-excel>
</template>
<script setup lang="ts" name="user-import-excel">
import UploadExcel from "/@/components/Upload/Excel.vue"
import { auth } from "/@/utils/authFunction"
type UserImportType = "backend" | "student"
interface UserImportOption {
value: UserImportType
label: string
url: string
tempUrl: string
permission: string
}
const emit = defineEmits(["refreshDataList"])
// 接口和模板保持在同一份配置中,避免切换导入类型时二者不一致。
const importOptions: UserImportOption[] = [
{
value: "backend",
label: "后台用户",
url: "/admin/user/import-face",
tempUrl: "/admin/sys-file/local/file/user.xlsx",
permission: "sys_user_export",
},
{
value: "student",
label: "学生用户",
url: "/admin/user/import-student",
tempUrl: "/admin/sys-file/local/file/student_user_import.xlsx",
permission: "sys_student_user_export",
},
]
const uploadExcelRef = ref()
const form = reactive({
importType: "backend" as UserImportType,
})
const currentImportOption = computed(() => importOptions.find((item) => item.value === form.importType) ?? importOptions[0])
const handleRefreshDataList = () => {
emit("refreshDataList")
}
/** 每次打开优先恢复为后台用户导入;仅有学生导入权限时自动选中学生用户。 */
const show = () => {
form.importType = auth(importOptions[0].permission) ? "backend" : "student"
uploadExcelRef.value?.show()
}
defineExpose({
show,
})
</script>
@@ -4,7 +4,16 @@
<el-col :span="12" class="mb20">
<el-form-item :label="$t('sysuser.username')" prop="username">
<el-input v-model.trim="model.username" :disabled="isEdit && !auth('user_sysUser_editUsername')"
placeholder="请输入用户名" />
placeholder="请输入用户名">
<template #suffix>
<el-tooltip v-if="!isEdit && auth('sys_user_add')" content="自动生成用户名" placement="top">
<el-icon class="cursor-pointer text-primary" @click.stop="handleGenerateUsername">
<ele-Loading v-if="generateUsernameLoading" class="animate-spin" />
<ele-MagicStick v-else />
</el-icon>
</el-tooltip>
</template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="12" class="mb20">
@@ -125,8 +134,8 @@
</el-col>
<el-col :span="12" class="mb20">
<el-form-item label="学校名称" prop="studentInfo.schoolName">
<el-input v-model.trim="model.studentInfo.schoolName" clearable
placeholder="请输入学校名称" />
<dict-select v-model="model.studentInfo.schoolName"
dict-code="user_student_school" />
</el-form-item>
</el-col>
<el-col :span="12" class="mb20">
@@ -145,7 +154,7 @@
<script setup lang="ts" name="UserInfoForm">
import type { FormInstance, FormRules } from "element-plus"
import { validatePhone, validateUsername } from "/@/api/admin/user"
import { generateUsername, validatePhone, validateUsername } from "/@/api/admin/user"
import { deptTree } from "/@/api/admin/dept"
import { list as roleList } from "/@/api/admin/role"
import { list as postList } from "/@/api/admin/post"
@@ -176,6 +185,7 @@ const { lock_flag } = useDict("lock_flag")
const formRef = ref<FormInstance>()
const optionsLoading = ref(false)
const generateUsernameLoading = ref(false)
const deptData = ref<any[]>([])
const roleData = ref<any[]>([])
const postData = ref<any[]>([])
@@ -205,6 +215,22 @@ const validatePhoneUnique = (currentRule: any, value: string, callback: (error?:
validatePhone(currentRule, value, callback, isEdit.value)
}
// 账号由后端统一生成并确认未被占用,避免前端随机值与现有用户发生冲突。
const handleGenerateUsername = async () => {
if (isEdit.value || generateUsernameLoading.value) return
generateUsernameLoading.value = true
try {
const { data } = await generateUsername()
model.value.username = data
await nextTick()
formRef.value?.clearValidate("username")
} catch (error: any) {
useMessage().error(error?.msg || "自动生成用户名失败")
} finally {
generateUsernameLoading.value = false
}
}
const formRules: FormRules = {
username: [
{ required: true, message: "用户名不能为空", trigger: "blur" },
@@ -239,6 +265,7 @@ const formRules: FormRules = {
{ required: true, message: "学号不能为空", trigger: "blur" },
{ validator: rule.overLength, trigger: "blur" },
],
"studentInfo.schoolName": [{ required: true, message: "学校名称不能为空", trigger: "blur" }],
}
watch(
+4 -4
View File
@@ -35,8 +35,8 @@
<div class="mb8" style="width: 100%">
<el-button v-auth="'sys_user_add'" icon="folder-add" type="primary"
@click="userDialogRef.openDialog()">新增</el-button>
<el-button plain v-auth="'sys_user_export'" class="ml10" icon="upload-filled" type="primary"
@click="excelUploadRef.show()">
<el-button plain v-auths="['sys_user_export', 'sys_student_user_export']" class="ml10"
icon="upload-filled" type="primary" @click="excelUploadRef.show()">
导入
</el-button>
<el-button plain v-auth="'sys_user_del'" :disabled="multiple" class="ml10" icon="Delete"
@@ -181,8 +181,7 @@
<user-form ref="userDialogRef" @refresh="getDataList(false)" :deptId="state.queryForm.deptId" />
<face-manage ref="faceManageRef" @refresh="getDataList(false)" />
<upload-excel ref="excelUploadRef" title="用户导入" temp-url="/admin/sys-file/local/file/user.xlsx"
url="/admin/user/import-face" @refreshDataList="getDataList" />
<user-import-excel ref="excelUploadRef" @refreshDataList="getDataList" />
<!-- 审批抽屉 -->
<process-drawer ref="processDrawerRef" @task-submit-event="taskSubmitEvent" />
<!-- 批量审批对话框 -->
@@ -207,6 +206,7 @@ const FaceManage = defineAsyncComponent(() => import("./face-manage.vue"))
const PopoverInput = defineAsyncComponent(() => import("/@/components/PopoverInput/index.vue"))
const BatchDeal = defineAsyncComponent(() => import("./batchDeal.vue"))
const TagTable = defineAsyncComponent(() => import("./tag-table.vue"))
const UserImportExcel = defineAsyncComponent(() => import("./components/user-import-excel.vue"))
const { t } = useI18n()
const { push } = useRouter()
+28 -22
View File
@@ -1,5 +1,6 @@
<template>
<el-dialog :close-on-click-modal="false" :title="form.id ? $t('common.editBtn') : $t('common.addBtn')" draggable v-model="visible">
<el-dialog :close-on-click-modal="false" :title="form.id ? $t('common.editBtn') : $t('common.addBtn')" draggable
v-model="visible">
<el-form :model="form" :rules="dataRules" label-width="90px" ref="dataFormRef" v-loading="loading">
<el-row :gutter="24">
<el-col :span="12" class="mb20">
@@ -11,7 +12,8 @@
<el-col :span="12" class="mb20">
<el-form-item :label="t('channel.channelId')" prop="state">
<el-select v-model="form.channelId">
<el-option :key="item.value" :label="item.label" :value="item.value" v-for="item in channel_type"> </el-option>
<el-option :key="item.value" :label="item.label" :value="item.value"
v-for="item in channel_type"> </el-option>
</el-select>
</el-form-item>
</el-col>
@@ -31,7 +33,8 @@
<el-col :span="12" class="mb20">
<el-form-item :label="t('channel.state')" prop="state">
<el-select v-model="form.state">
<el-option :key="item.value" :label="item.label" :value="item.value" v-for="item in status_type"> </el-option>
<el-option :key="item.value" :label="item.label" :value="item.value"
v-for="item in status_type"> </el-option>
</el-select>
</el-form-item>
</el-col>
@@ -50,13 +53,15 @@
<el-col :span="24" class="mb20">
<el-form-item :label="t('channel.remark')" prop="remark">
<el-input :placeholder="t('channel.inputRemarkTip')" :rows="2" type="textarea" v-model="form.remark" />
<el-input :placeholder="t('channel.inputRemarkTip')" :rows="2" type="textarea"
v-model="form.remark" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item :label="t('channel.param')" prop="param">
<el-input :placeholder="t('channel.inputParamTip')" :rows="4" type="textarea" v-model="form.param" />
<el-input :placeholder="t('channel.inputParamTip')" :rows="4" type="textarea"
v-model="form.param" />
</el-form-item>
</el-col>
</el-row>
@@ -64,7 +69,8 @@
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false">{{ $t('common.cancelButtonText') }}</el-button>
<el-button @click="onSubmit" type="primary" :disabled="loading">{{ $t('common.confirmButtonText') }}</el-button>
<el-button @click="onSubmit" type="primary" :disabled="loading">{{ $t('common.confirmButtonText')
}}</el-button>
</span>
</template>
</el-dialog>
@@ -105,24 +111,24 @@ const form = reactive({
// 定义校验规则
const dataRules = ref({
appId: [
{ validator: rule.overLength, trigger: 'blur' },
{ validator: rule.overLength, trigger: 'blur' },
{ required: true, message: 'appId不能为空', trigger: 'blur' }
],
channelName: [{ required: true, message: '渠道名称不能为空', trigger: 'blur' },{ validator: rule.overLength, trigger: 'blur' }],
channelName: [{ required: true, message: '渠道名称不能为空', trigger: 'blur' }, { validator: rule.overLength, trigger: 'blur' }],
channelMchId: [
{ validator: rule.overLength, trigger: 'blur' },
{ validator: rule.overLength, trigger: 'blur' },
{ required: true, message: '商户ID不能为空', trigger: 'blur' }
],
state: [{ required: true, message: '状态不能为空', trigger: 'blur' },{ validator: rule.overLength, trigger: 'blur' }],
state: [{ required: true, message: '状态不能为空', trigger: 'blur' }, { validator: rule.overLength, trigger: 'blur' }],
remark:[{ validator: rule.overLength, trigger: 'blur' }],
remark: [{ validator: rule.overLength, trigger: 'blur' }],
returnUrl: [
{ validator: rule.overLength, trigger: 'blur' },
{ validator: rule.overLength, trigger: 'blur' },
{ validater: rule.url, trigger: 'blur' },
],
notifyUrl: [{ required: true, message: '后端回调不能为空', trigger: 'blur' },{ validator: rule.overLength, trigger: 'blur' }],
notifyUrl: [{ required: true, message: '后端回调不能为空', trigger: 'blur' }, { validator: rule.overLength, trigger: 'blur' }],
param: [{ required: true, message: '参数配置不能为空', trigger: 'blur' }],
});
@@ -145,20 +151,20 @@ const openDialog = (id: string) => {
// 提交
const onSubmit = async () => {
const valid = await dataFormRef.value.validate().catch(() => {});
const valid = await dataFormRef.value.validate().catch(() => { });
if (!valid) return false;
try {
loading.value = true;
// 清除占位符,避免提交错误的数据
const { appId, channelMchId } = form;
// 清除占位符,避免提交错误的数据
const { appId, channelMchId } = form;
if (appId?.includes('**')) {
form.appId = undefined;
}
if (channelMchId?.includes('**')) {
form.channelMchId = undefined;
}
if (appId?.includes('**')) {
form.appId = undefined;
}
if (channelMchId?.includes('**')) {
form.channelMchId = undefined;
}
form.id ? await putObj(form) : await addObj(form);
useMessage().success(t(form.id ? 'common.editSuccessText' : 'common.addSuccessText'));
visible.value = false;
@@ -3,6 +3,11 @@
<el-form ref="dataFormRef" :model="form" :rules="dataRules" formDialogRef label-width="90px"
v-loading="loading">
<el-row :gutter="24">
<el-col :span="14" class="mb20">
<el-form-item label="商户" prop="merchantId" class="w-60 my-6">
<select-mch v-model="form.merchantId" autoFirst :apiParams="{ types: '1', }" />
</el-form-item>
</el-col>
<el-col :span="14" class="mb20">
<el-form-item label="分类名称" prop="name">
<el-input v-model.trim="form.name" placeholder="请输入分类名称" />
@@ -71,11 +76,13 @@ const form = reactive({
updater: "",
deleted: "",
deptId: "",
merchantId: "",
categoryType: [],
})
// 定义校验规则
const dataRules = ref({
merchantId: [{ required: true, message: "请选择商户", trigger: "change" }],
name: [{ required: true, message: "分类名称不能为空", trigger: "blur" }],
categoryType: [{ required: true, message: "餐段类型不能为空", trigger: "blur" }],
sort: [{ required: true, message: "分类排序不能为空", trigger: "blur" }],
+18 -7
View File
@@ -1,6 +1,7 @@
<template>
<el-dialog :title="form.id ? '编辑' : '新增'" v-model="visible" :close-on-click-modal="false" draggable width="820px">
<el-form ref="dataFormRef" :model="form" :rules="dataRules" formDialogRef label-width="140px" v-loading="loading">
<el-dialog :title="form.id ? '编辑' : '新增'" v-model="visible" :close-on-click-modal="false" draggable>
<el-form ref="dataFormRef" :model="form" :rules="dataRules" formDialogRef label-width="140px"
v-loading="loading">
<el-row :gutter="24">
<el-col :span="14" class="mb20">
<el-form-item label="商品类型" prop="spuType">
@@ -10,6 +11,11 @@
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="14" class="mb20">
<el-form-item label="商户" prop="merchantId">
<select-mch v-model="form.merchantId" autoFirst :apiParams="{ types: '1', }" />
</el-form-item>
</el-col>
<el-col :span="14" class="mb20">
<el-form-item label="菜品名称" prop="name">
<el-input v-model.trim="form.name" placeholder="请输入菜品名称" />
@@ -52,9 +58,10 @@
</el-form-item>
</el-col>
<el-col :span="12" class="mb20">
<el-form-item label="是否在移动端显示" prop="isShow">
<el-form-item label="是否在移动端显示" prop="status">
<el-radio-group v-model="form.isShow">
<el-radio :label="item.value" v-for="(item, index) in is_show" border :key="index">{{ item.label }} </el-radio>
<el-radio :label="item.value" v-for="(item, index) in is_show" border :key="index">{{
item.label }} </el-radio>
</el-radio-group>
</el-form-item>
</el-col>
@@ -299,12 +306,15 @@ const openDialog = async (id: string) => {
form.spuType = 1
form.comboItems = []
dataFormRef.value?.resetFields()
// 获取canteenSpu信息
if (id) {
form.id = id
getcanteenSpuData(id)
}
}
// 提交
const onSubmit = async () => {
const valid = await dataFormRef?.value.validate().catch(() => {})
if (!valid) return false
@@ -336,7 +346,9 @@ const onSubmit = async () => {
}
}
// 初始化表单数据
const getcanteenSpuData = (id: string) => {
// 获取数据
loading.value = true
getObj(id)
.then((res: any) => {
@@ -360,13 +372,12 @@ const getcanteenSpuData = (id: string) => {
loading.value = false
})
}
const categoryList = ref<any>([])
const categoryList = ref<any>([]) // 菜品分类树
onMounted(async () => {
const data = await getAllSimple()
categoryList.value = data.data
})
// 暴露变量
defineExpose({
openDialog,
})
@@ -109,8 +109,11 @@ const selectionChangHandle = (objs: { id: string }[]) => {
multiple.value = !objs.length
}
const openDialog = (createTimeStr: any, hourType: any) => {
const openDialog = (createTimeStr: any, hourType: any, queryParams: any) => {
visible.value = true
state.queryForm = {
...queryParams,
}
state.queryForm.orderApptUseDateStr = createTimeStr
state.queryForm.hourType = hourType
getDataList()
@@ -107,7 +107,7 @@
<el-table-column fixed="right" label="操作">
<template #default="{ row }">
<el-button icon="Tickets" text type="primary"
@click="productTotalTableRef.openDialog(parseTime(row.createTime, '{y}-{m}-{d}'), row.hourType)">
@click="productTotalTableRef.openDialog(parseTime(row.createTime, '{y}-{m}-{d}'), row.hourType, state.queryForm)">
菜品详情
</el-button>
</template>
@@ -109,8 +109,11 @@ const selectionChangHandle = (objs: { id: string }[]) => {
multiple.value = !objs.length
}
const openDialog = (createTimeStr: any, hourType: any) => {
const openDialog = (createTimeStr: any, hourType: any, queryParams: any) => {
visible.value = true
state.queryForm = {
...queryParams,
}
state.queryForm.createTimeStr = createTimeStr
state.queryForm.hourType = hourType
getDataList()
@@ -117,7 +117,7 @@
<el-table-column fixed="right" label="操作">
<template #default="{ row }">
<el-button icon="Tickets" text type="primary"
@click="productTotalTableRef.openDialog(parseTime(row.createTime, '{y}-{m}-{d}'), row.hourType)">
@click="productTotalTableRef.openDialog(parseTime(row.createTime, '{y}-{m}-{d}'), row.hourType, state.queryForm)">
菜品详情
</el-button>
</template>
+3 -2
View File
@@ -50,7 +50,7 @@
<el-form-item label="起始日" prop="targetStartOffsetDays">
<div class="flex items-center gap-2">
<span class="whitespace-nowrap text-sm text-gray-500">截止后第</span>
<el-input-number v-model="form.targetStartOffsetDays" :min="1" :max="31"
<el-input-number v-model="form.targetStartOffsetDays" :min="0" :max="31"
controls-position="right" />
<span class="whitespace-nowrap text-sm text-gray-500">天</span>
</div>
@@ -263,8 +263,9 @@ const previewBatch = computed(() => {
return windowForMonday(monday.add(1, "week"))
})
// 第 0 天表示截止日期当天,不能用真值回退,否则 0 会被误算成第 1 天。
const targetPreviewStart = computed(() =>
previewBatch.value?.close.startOf("day").add(Number(form.targetStartOffsetDays) || 1, "day"),
previewBatch.value?.close.startOf("day").add(Number(form.targetStartOffsetDays ?? 0), "day"),
)
const batchPreview = computed(() => {
+71
View File
@@ -49,6 +49,43 @@
<el-table-column prop="merchantName" width="150" label="商户" />
<el-table-column prop="deptName" width="200" label="部门" />
<el-table-column prop="uniqueCode" width="200" label="设备唯一编号" />
<el-table-column width="100" align="center" label="在线状态">
<template #default="{ row }">
<el-tag v-if="isTerminalStatusSupported(row)" :type="row.online ? 'success' : 'danger'"
size="small" effect="light">
{{ row.online ? "在线" : "离线" }}
</el-tag>
<span v-else class="text-xs text-[var(--el-text-color-secondary)]">暂不支持</span>
</template>
</el-table-column>
<el-table-column width="150" label="APK 版本">
<template #default="{ row }">
<div v-if="isTerminalStatusSupported(row) && (row.apkVersionName || row.apkVersionCode)"
class="flex flex-col leading-5">
<span>{{ row.apkVersionName || "--" }}</span>
<span v-if="row.apkVersionCode" class="text-xs text-[var(--el-text-color-secondary)]">
Build {{ row.apkVersionCode }}
</span>
</div>
<span v-else class="text-[var(--el-text-color-secondary)]">
{{ isTerminalStatusSupported(row) ? "尚未上报" : "暂不支持" }}
</span>
</template>
</el-table-column>
<el-table-column width="180" label="最后在线时间">
<template #default="{ row }">
<span :class="{ 'text-[var(--el-text-color-secondary)]': !row.lastOnlineTime }">
{{ getLastOnlineTimeText(row) }}
</span>
</template>
</el-table-column>
<el-table-column width="230" label="可能断线原因" show-overflow-tooltip>
<template #default="{ row }">
<span :class="{ 'text-[var(--el-text-color-secondary)]': !row.disconnectReason }">
{{ getDisconnectReasonText(row) }}
</span>
</template>
</el-table-column>
<el-table-column prop="mainTopic" width="350" label="topic" />
<el-table-column prop="isTakeFood" width="200" label="是否为当前商户的取餐机器">
<template #default="{ row }">
@@ -119,6 +156,40 @@ const showSearch = ref(true)
const selectObjs = ref([]) as any
const multiple = ref(true)
/** 支持终端心跳与 APK 状态上报的设备类型编码,仅包含超市和食堂 Android 终端。 */
const terminalStatusSupportedTypes = ["1", "2"]
/**
* 判断当前设备是否支持在线与版本状态展示。
*
* @param row 设备分页行,类型编码来源于 hard_manager.type
* @returns 超市或食堂终端返回 true,其他硬件返回 false
*/
const isTerminalStatusSupported = (row: any) => terminalStatusSupportedTypes.includes(String(row.type))
/**
* 获取最后在线时间列文案,明确区分未上报和不支持的设备。
*
* @param row 设备分页行
* @returns 服务端心跳时间或对应的空状态文案
*/
const getLastOnlineTimeText = (row: any) => {
if (!isTerminalStatusSupported(row)) return "暂不支持"
return row.lastOnlineTime || "尚未上报"
}
/**
* 获取可能断线原因。在线设备没有计划内原因时使用短横线,避免误导为故障。
*
* @param row 设备分页行
* @returns 服务端推断原因或对应的空状态文案
*/
const getDisconnectReasonText = (row: any) => {
if (!isTerminalStatusSupported(row)) return "暂不支持"
if (row.disconnectReason) return row.disconnectReason
return row.online ? "--" : "尚未收到终端心跳"
}
const state: BasicTableProps = reactive<BasicTableProps>({
queryForm: {},
pageList: hardManageApi.pageJoin,
@@ -0,0 +1,88 @@
<template>
<section class="flex h-full min-h-0 flex-col" aria-label="终端设备日志列表">
<header class="flex items-center justify-between border-b border-br-light px-3 py-2">
<div>
<div class="font-medium text-[var(--el-text-color-primary)]">终端设备</div>
<div class="mt-0.5 text-xs text-[var(--el-text-color-secondary)]">共 {{ total }} 台有日志设备</div>
</div>
<el-button text icon="Refresh" :loading="loading" aria-label="刷新设备列表" @click="$emit('refresh')" />
</header>
<el-scrollbar v-loading="loading" class="min-h-0 flex-1">
<div v-if="devices.length" class="space-y-2 p-2">
<button
v-for="device in devices"
:key="device.hardId"
type="button"
class="device-card w-full rounded border p-3 text-left transition-colors"
:class="{ 'is-active': String(device.hardId) === String(selectedHardId) }"
@click="$emit('select', device)"
>
<div class="flex items-start justify-between gap-2">
<span class="truncate font-medium text-[var(--el-text-color-primary)]">{{ device.hardName || "未命名设备" }}</span>
<el-tag size="small" effect="plain">{{ device.fileCount }} 个文件</el-tag>
</div>
<div class="mt-1 truncate text-xs text-[var(--el-text-color-regular)]">编号:{{ device.uniqueCode || "-" }}</div>
<div class="mt-2 text-xs text-[var(--el-text-color-secondary)]">最近上传:{{ formatTime(device.latestUploadTime) }}</div>
</button>
</div>
<el-empty v-else :image-size="72" description="暂无符合条件的终端日志" />
</el-scrollbar>
<footer v-if="total > size" class="flex justify-center border-t border-br-light px-2 py-2">
<el-pagination
small
background
layout="prev, pager, next"
:page-size="size"
:current-page="current"
:total="total"
@current-change="$emit('page-change', $event)"
/>
</footer>
</section>
</template>
<script setup lang="ts">
import type { TerminalLogDevice } from "/@/api/hard/hard-terminal-log-file"
import { formatDate } from "/@/utils/formatTime"
defineProps<{
devices: TerminalLogDevice[]
loading: boolean
total: number
current: number
size: number
selectedHardId?: string | number
}>()
defineEmits<{
(event: "select", device: TerminalLogDevice): void
(event: "refresh"): void
(event: "page-change", page: number): void
}>()
function formatTime(value?: string) {
if (!value) return "暂无"
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : formatDate(date, "YYYY-mm-dd HH:MM:SS")
}
</script>
<style scoped>
.device-card {
border-color: var(--el-border-color-light);
background: var(--el-bg-color);
}
.device-card:hover {
border-color: var(--el-color-primary-light-5);
background: var(--el-color-primary-light-9);
}
.device-card.is-active {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
box-shadow: 0 0 0 1px var(--el-color-primary-light-7);
}
</style>
@@ -0,0 +1,88 @@
<template>
<section class="flex h-full min-h-0 flex-col" aria-label="终端日志文件树">
<header class="flex items-center justify-between border-b border-br-light px-3 py-2">
<div class="min-w-0">
<div class="truncate font-medium text-[var(--el-text-color-primary)]">日志文件</div>
<div class="mt-0.5 truncate text-xs text-[var(--el-text-color-secondary)]">{{ deviceName || "请先选择设备" }}</div>
</div>
<el-button text icon="Refresh" :disabled="!hardId" :loading="loading" aria-label="刷新日志文件树" @click="$emit('refresh')" />
</header>
<el-scrollbar v-loading="loading" class="min-h-0 flex-1">
<el-tree
v-if="hardId && tree.length"
class="terminal-log-tree px-2 py-2"
:data="tree"
node-key="key"
:current-node-key="selectedKey"
:expand-on-click-node="false"
:highlight-current="true"
:default-expand-all="true"
@node-click="handleNodeClick"
>
<template #default="{ data }">
<div class="flex min-w-0 flex-1 items-center gap-2 pr-2">
<el-icon class="shrink-0 text-[var(--el-text-color-secondary)]">
<Folder v-if="data.directory" />
<Document v-else />
</el-icon>
<div class="min-w-0 flex-1">
<div class="truncate">{{ data.label }}</div>
<div v-if="!data.directory" class="truncate text-xs text-[var(--el-text-color-secondary)]">
{{ formatSize(data.fileSize) }} · {{ formatTime(data.uploadTime) }}
</div>
</div>
</div>
</template>
</el-tree>
<el-empty v-else :image-size="72" :description="hardId ? '该设备暂无符合条件的日志' : '请先选择终端设备'" />
</el-scrollbar>
</section>
</template>
<script setup lang="ts">
import type { TerminalLogTreeNode } from "/@/api/hard/hard-terminal-log-file"
import { formatDate } from "/@/utils/formatTime"
defineProps<{
tree: TerminalLogTreeNode[]
loading: boolean
hardId?: string | number
deviceName?: string
selectedKey?: string
}>()
const emit = defineEmits<{
(event: "select", node: TerminalLogTreeNode): void
(event: "refresh"): void
}>()
function handleNodeClick(node: TerminalLogTreeNode) {
if (!node.directory && node.fileId != null) emit("select", node)
}
function formatSize(bytes?: number) {
if (bytes == null) return "-"
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
function formatTime(value?: string) {
if (!value) return "-"
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : formatDate(date, "YYYY-mm-dd HH:MM:SS")
}
</script>
<style scoped>
.terminal-log-tree {
background: transparent;
}
.terminal-log-tree:deep(.el-tree-node__content) {
height: auto;
min-height: 40px;
border-radius: 4px;
}
</style>
+229
View File
@@ -0,0 +1,229 @@
<template>
<el-dialog :title="form.id ? '编辑' : '新增'" v-model="visible" :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="设备ID(hard_manager.id)" prop="hardId">
<el-input v-model="form.hardId" placeholder="请输入设备ID(hard_manager.id)" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="上传时设备名称快照" prop="hardName">
<el-input v-model="form.hardName" placeholder="请输入上传时设备名称快照" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="上传时设备唯一编号快照" prop="uniqueCode">
<el-input v-model="form.uniqueCode" placeholder="请输入上传时设备唯一编号快照" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="上传时设备类型快照" prop="hardType">
<el-input v-model="form.hardType" placeholder="请输入上传时设备类型快照" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="上传时商户ID快照" prop="merchantId">
<el-input v-model="form.merchantId" placeholder="请输入上传时商户ID快照" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="上传时部门ID快照" prop="deptId">
<el-input v-model="form.deptId" placeholder="请输入上传时部门ID快照" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="日志所属日期" prop="logDate">
<el-input v-model="form.logDate" placeholder="请输入日志所属日期" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="终端日志根目录下的相对路径" prop="relativePath">
<el-input v-model="form.relativePath" placeholder="请输入终端日志根目录下的相对路径" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="原日志文件名" prop="originalName">
<el-input v-model="form.originalName" placeholder="请输入原日志文件名" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="文件大小(字节)" prop="fileSize">
<el-input v-model="form.fileSize" placeholder="请输入文件大小(字节)" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="文件内容哈希(当前使用MD5)" prop="fileHash">
<el-input v-model="form.fileHash" placeholder="请输入文件内容哈希(当前使用MD5)" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="终端本地文件最后修改时间" prop="fileModifiedTime">
<el-input v-model="form.fileModifiedTime" placeholder="请输入终端本地文件最后修改时间" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="文件中心sys_file记录ID" prop="sysFileId">
<el-input v-model="form.sysFileId" placeholder="请输入文件中心sys_file记录ID" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="OSS对象文件名" prop="storageFileName">
<el-input v-model="form.storageFileName" placeholder="请输入OSS对象文件名" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="文件访问相对地址" prop="storageUrl">
<el-input v-model="form.storageUrl" placeholder="请输入文件访问相对地址" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="上传时应用版本" prop="appVersion">
<el-input v-model="form.appVersion" placeholder="请输入上传时应用版本" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="日志脱敏规则版本" prop="sanitizeVersion">
<el-input v-model="form.sanitizeVersion" placeholder="请输入日志脱敏规则版本" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="最近成功上传时间" prop="uploadTime">
<el-input v-model="form.uploadTime" placeholder="请输入最近成功上传时间" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="成功上传次数" prop="uploadCount">
<el-input v-model="form.uploadCount" placeholder="请输入成功上传次数" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" placeholder="请输入备注" />
</el-form-item>
</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="HardTerminalLogFileDialog">
import { useDict } from '/@/hooks/dict';
import { useMessage } from "/@/hooks/message";
import * as hardTerminalLogFile from "/@/api/hard/hard-terminal-log-file";
import { rule } from '/@/utils/validate';
const emit = defineEmits(['refresh']);
// 定义变量内容
const dataFormRef = ref();
const visible = ref(false)
const loading = ref(false)
// 定义字典
// 提交表单数据
const form = reactive({
id: '',
hardId: '',
hardName: '',
uniqueCode: '',
hardType: '',
merchantId: '',
deptId: '',
logDate: '',
relativePath: '',
originalName: '',
fileSize: '',
fileHash: '',
fileModifiedTime: '',
sysFileId: '',
storageFileName: '',
storageUrl: '',
appVersion: '',
sanitizeVersion: '',
uploadTime: '',
uploadCount: '',
remark: '',
});
// 定义校验规则
const dataRules = ref({
})
// 打开弹窗
const openDialog = (id: string) => {
visible.value = true
form.id = ''
// 重置表单数据
nextTick(() => {
dataFormRef.value?.resetFields();
});
// 获取hardTerminalLogFile信息
if (id) {
form.id = id
gethardTerminalLogFileData(id)
}
};
// 提交
const onSubmit = async () => {
const valid = await dataFormRef.value.validate().catch(() => { });
if (!valid) return false;
try {
loading.value = true;
form.id ? await hardTerminalLogFile.putObj(form) : await hardTerminalLogFile.addObj(form);
useMessage().success(form.id ? '修改成功' : '添加成功');
visible.value = false;
emit('refresh');
} catch (err: any) {
useMessage().error(err.msg);
} finally {
loading.value = false;
}
};
// 初始化表单数据
const gethardTerminalLogFileData = (id: string) => {
// 获取数据
loading.value = true
getObj(id).then((res: any) => {
Object.assign(form, res.data)
}).finally(() => {
loading.value = false
})
};
// 暴露变量
defineExpose({
openDialog
});
</script>
@@ -0,0 +1,521 @@
<template>
<div class="layout-padding">
<div class="layout-padding-auto">
<div class="layout-padding-view flex min-h-0 flex-col">
<section class="border-b border-br-light px-3 py-3">
<el-form
ref="queryRef"
:model="queryForm"
label-width="72px"
class="terminal-log-query grid grid-cols-12 items-start gap-x-4 gap-y-2"
>
<el-form-item class="col-span-12 md:col-span-6 lg:col-span-3" label="设备名称" prop="hardName">
<el-input v-model="queryForm.hardName" clearable placeholder="请输入设备名称" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item class="col-span-12 md:col-span-6 lg:col-span-3" label="唯一编号" prop="uniqueCode">
<el-input v-model="queryForm.uniqueCode" clearable placeholder="请输入设备唯一编号" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item class="col-span-12 md:col-span-6 lg:col-span-3" label="商户" prop="merchantId">
<select-mch v-model="queryForm.merchantId" placeholder="请选择商户" />
</el-form-item>
<el-form-item class="col-span-12 md:col-span-6 lg:col-span-3" label="部门" prop="deptId">
<select-dept v-model="queryForm.deptId" placeholder="请选择部门" />
</el-form-item>
<el-form-item class="col-span-12 md:col-span-6 lg:col-span-4" label="日志日期" prop="logDateRange">
<el-date-picker
v-model="queryForm.logDateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
/>
</el-form-item>
<el-form-item class="col-span-12 md:col-span-6 lg:col-span-5" label="上传时间" prop="uploadTimeRange">
<el-date-picker
v-model="queryForm.uploadTimeRange"
type="datetimerange"
value-format="YYYY-MM-DDTHH:mm:ss"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
/>
</el-form-item>
<el-form-item class="terminal-log-query__actions col-span-12 lg:col-span-3">
<el-button type="primary" icon="Search" :loading="deviceLoading" @click="handleQuery">查询</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<div class="mt-1 flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-2">
<el-button v-if="isNarrow" icon="Monitor" @click="deviceDrawerVisible = true">选择设备</el-button>
<el-button v-if="isNarrow" icon="Files" :disabled="!selectedDevice" @click="fileDrawerVisible = true">文件树</el-button>
<el-tag v-if="selectedDevice" type="info" effect="plain">
当前设备:{{ selectedDevice.hardName }}({{ selectedDevice.uniqueCode }})
</el-tag>
</div>
<div class="flex items-center gap-2">
<el-button v-auth="'hard_hardTerminalLogFile_pull'" icon="Aim" @click="openPullDialog">选择其他设备拉取</el-button>
<el-button
v-auth="'hard_hardTerminalLogFile_pull'"
type="primary"
icon="Promotion"
:disabled="!selectedDevice"
:loading="pulling"
@click="pullSelectedDevice"
>
立即拉取当前日志
</el-button>
</div>
</div>
</section>
<div class="min-h-0 flex-1">
<splitpanes v-if="!isNarrow" class="terminal-log-split">
<pane :size="17" :min-size="15" :max-size="22">
<device-panel
:devices="devices"
:loading="deviceLoading"
:total="deviceTotal"
:current="devicePage"
:size="devicePageSize"
:selected-hard-id="selectedDevice?.hardId"
@select="selectDevice"
@refresh="loadDevices(true)"
@page-change="changeDevicePage"
/>
</pane>
<pane :size="20" :min-size="17" :max-size="26">
<file-tree-panel
:tree="fileTree"
:loading="treeLoading"
:hard-id="selectedDevice?.hardId"
:device-name="selectedDevice?.hardName"
:selected-key="selectedFile?.key"
@select="selectFile"
@refresh="loadFileTree"
/>
</pane>
<pane :size="63" :min-size="48">
<preview-panel
v-model:keyword="keyword"
:preview="preview"
:loading="previewLoading"
:can-preview="canPreview"
:has-device="Boolean(selectedDevice)"
:has-file="Boolean(selectedFile)"
@copy="copyPreview"
@refresh="refreshPreview"
@download="downloadCurrent"
/>
</pane>
</splitpanes>
<preview-panel
v-else
v-model:keyword="keyword"
:preview="preview"
:loading="previewLoading"
:can-preview="canPreview"
:has-device="Boolean(selectedDevice)"
:has-file="Boolean(selectedFile)"
@copy="copyPreview"
@refresh="refreshPreview"
@download="downloadCurrent"
/>
</div>
</div>
</div>
<el-drawer v-model="deviceDrawerVisible" title="选择终端设备" direction="ltr" size="360px">
<device-panel
:devices="devices"
:loading="deviceLoading"
:total="deviceTotal"
:current="devicePage"
:size="devicePageSize"
:selected-hard-id="selectedDevice?.hardId"
@select="selectDeviceFromDrawer"
@refresh="loadDevices(true)"
@page-change="changeDevicePage"
/>
</el-drawer>
<el-drawer v-model="fileDrawerVisible" title="日志文件树" direction="ltr" size="420px">
<file-tree-panel
:tree="fileTree"
:loading="treeLoading"
:hard-id="selectedDevice?.hardId"
:device-name="selectedDevice?.hardName"
:selected-key="selectedFile?.key"
@select="selectFileFromDrawer"
@refresh="loadFileTree"
/>
</el-drawer>
<el-dialog v-model="pullDialogVisible" title="拉取无历史日志设备的当前日志" width="520px" destroy-on-close>
<el-alert
type="info"
:closable="false"
show-icon
title="指令发送后,客户端会等待支付和识别结束再上传;日志无变化时列表不会更新。"
/>
<el-form label-width="88px" class="mt-4">
<el-form-item label="终端设备">
<select-hard v-model="pullHardId" placeholder="请选择要拉取日志的终端" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="pullDialogVisible = false">取消</el-button>
<el-button type="primary" :disabled="!pullHardId" :loading="pulling" @click="pullOtherDevice">发送拉取指令</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts" name="systemHardTerminalLogFile">
import { useMediaQuery } from "@vueuse/core"
import * as terminalLogApi from "/@/api/hard/hard-terminal-log-file"
import type { TerminalLogDevice, TerminalLogPreview, TerminalLogQuery, TerminalLogTreeNode } from "/@/api/hard/hard-terminal-log-file"
import { useMessage } from "/@/hooks/message"
import { auth } from "/@/utils/authFunction"
import commonFunction from "/@/utils/commonFunction"
import DevicePanel from "./device-panel.vue"
import FileTreePanel from "./file-tree-panel.vue"
import PreviewPanel from "./preview-panel.vue"
const PREVIEW_PERMISSION = "hard_hardTerminalLogFile_preview"
const DOWNLOAD_PERMISSION = "hard_hardTerminalLogFile_down"
const PULL_PERMISSION = "hard_hardTerminalLogFile_pull"
const POLL_INTERVAL_MS = 3000
const POLL_MAX_COUNT = 20
const queryRef = ref()
const queryForm = reactive({
hardName: "",
uniqueCode: "",
merchantId: undefined as string | number | undefined,
deptId: undefined as string | number | undefined,
logDateRange: [] as string[],
uploadTimeRange: [] as string[],
})
const isNarrow = useMediaQuery("(max-width: 1280px)")
const devices = ref<TerminalLogDevice[]>([])
const deviceLoading = ref(false)
const deviceTotal = ref(0)
const devicePage = ref(1)
const devicePageSize = 12
const selectedDevice = ref<TerminalLogDevice>()
const fileTree = ref<TerminalLogTreeNode[]>([])
const treeLoading = ref(false)
const selectedFile = ref<TerminalLogTreeNode>()
const preview = ref<TerminalLogPreview>()
const previewLoading = ref(false)
const keyword = ref("")
const pulling = ref(false)
const pullHardId = ref<string | number>()
const pullDialogVisible = ref(false)
const deviceDrawerVisible = ref(false)
const fileDrawerVisible = ref(false)
let pollTimer: ReturnType<typeof setTimeout> | undefined
const canPreview = computed(() => auth(PREVIEW_PERMISSION))
const { copyText } = commonFunction()
function buildQuery(): TerminalLogQuery {
return {
hardName: queryForm.hardName || undefined,
uniqueCode: queryForm.uniqueCode || undefined,
merchantId: queryForm.merchantId,
deptId: queryForm.deptId,
logDateStart: queryForm.logDateRange?.[0],
logDateEnd: queryForm.logDateRange?.[1],
uploadTimeStart: queryForm.uploadTimeRange?.[0],
uploadTimeEnd: queryForm.uploadTimeRange?.[1],
}
}
async function loadDevices(keepSelection = false) {
deviceLoading.value = true
try {
const { data } = await terminalLogApi.fetchDevicePage({
...buildQuery(),
current: devicePage.value,
size: devicePageSize,
})
devices.value = data?.records ?? []
deviceTotal.value = Number(data?.total ?? 0)
if (keepSelection && selectedDevice.value) {
const refreshed = devices.value.find((item) => String(item.hardId) === String(selectedDevice.value?.hardId))
if (refreshed) {
selectedDevice.value = refreshed
return
}
await selectDevice(devices.value[0])
return
}
if (!devices.value.some((item) => String(item.hardId) === String(selectedDevice.value?.hardId))) {
await selectDevice(devices.value[0])
}
} catch (error: any) {
useMessage().error(error?.msg || "获取终端日志设备失败")
} finally {
deviceLoading.value = false
}
}
async function selectDevice(device?: TerminalLogDevice) {
selectedDevice.value = device
selectedFile.value = undefined
preview.value = undefined
keyword.value = ""
fileTree.value = []
if (device) await loadFileTree()
}
async function loadFileTree() {
if (!selectedDevice.value) return
treeLoading.value = true
try {
const { data } = await terminalLogApi.fetchFileTree({
...buildQuery(),
hardId: selectedDevice.value.hardId,
})
fileTree.value = data ?? []
const refreshedFile = selectedFile.value
? findTreeFile(fileTree.value, selectedFile.value.key)
: undefined
if (refreshedFile) {
selectedFile.value = refreshedFile
} else {
selectedFile.value = undefined
preview.value = undefined
keyword.value = ""
}
} catch (error: any) {
useMessage().error(error?.msg || "获取日志文件树失败")
} finally {
treeLoading.value = false
}
}
function findTreeFile(nodes: TerminalLogTreeNode[], key: string): TerminalLogTreeNode | undefined {
for (const node of nodes) {
if (node.key === key && !node.directory) return node
const child = findTreeFile(node.children ?? [], key)
if (child) return child
}
return undefined
}
async function selectFile(file: TerminalLogTreeNode) {
selectedFile.value = file
preview.value = undefined
keyword.value = ""
if (!canPreview.value) {
useMessage().warning(auth(DOWNLOAD_PERMISSION) ? "暂无日志预览权限,可使用完整下载" : "暂无日志预览权限")
return
}
await refreshPreview()
}
async function refreshPreview() {
if (selectedFile.value?.fileId == null || !canPreview.value) return
previewLoading.value = true
try {
const { data } = await terminalLogApi.fetchPreview(selectedFile.value.fileId)
preview.value = data
} catch (error: any) {
useMessage().error(error?.msg || "读取日志预览失败")
} finally {
previewLoading.value = false
}
}
function copyPreview() {
if (!preview.value?.content) return
const normalizedKeyword = keyword.value.trim().toLocaleLowerCase()
const content = normalizedKeyword
? preview.value.content
.split(/\r?\n/)
.filter((line) => line.toLocaleLowerCase().includes(normalizedKeyword))
.join("\n")
: preview.value.content
copyText(content)
}
async function downloadCurrent() {
if (selectedFile.value?.fileId == null || !auth(DOWNLOAD_PERMISSION)) return
try {
const blob = await terminalLogApi.downloadFile(selectedFile.value.fileId)
saveLogBlob(blob, preview.value?.originalName || selectedFile.value.label)
} catch (error: any) {
useMessage().error(error?.msg || "日志下载失败")
}
}
function saveLogBlob(blob: Blob, fileName: string) {
const link = document.createElement("a")
link.href = URL.createObjectURL(blob)
link.download = fileName.replace(/[<>:"/\\|?*]/g, "_").substring(0, 255)
link.style.display = "none"
document.body.appendChild(link)
link.click()
setTimeout(() => {
URL.revokeObjectURL(link.href)
link.remove()
}, 0)
}
function handleQuery() {
stopPolling()
devicePage.value = 1
selectDevice(undefined)
loadDevices()
}
function resetQuery() {
queryRef.value?.resetFields()
queryForm.logDateRange = []
queryForm.uploadTimeRange = []
handleQuery()
}
function changeDevicePage(page: number) {
stopPolling()
devicePage.value = page
selectDevice(undefined)
loadDevices()
}
function selectDeviceFromDrawer(device: TerminalLogDevice) {
deviceDrawerVisible.value = false
selectDevice(device)
}
function selectFileFromDrawer(file: TerminalLogTreeNode) {
fileDrawerVisible.value = false
selectFile(file)
}
function openPullDialog() {
if (!auth(PULL_PERMISSION)) return
pullHardId.value = undefined
pullDialogVisible.value = true
}
async function pullSelectedDevice() {
if (selectedDevice.value) await sendPullCommand(selectedDevice.value.hardId, selectedDevice.value.latestUploadTime)
}
async function pullOtherDevice() {
if (!pullHardId.value) return
pullDialogVisible.value = false
await sendPullCommand(pullHardId.value)
}
async function sendPullCommand(hardId: string | number, previousUploadTime?: string) {
if (!auth(PULL_PERMISSION) || pulling.value) return
stopPolling()
pulling.value = true
try {
let baselineTime = previousUploadTime
let hadHistory = previousUploadTime != null
if (!hadHistory) {
const { data } = await terminalLogApi.fetchDevicePage({ hardId, current: 1, size: 1 })
const baseline = data?.records?.[0] as TerminalLogDevice | undefined
hadHistory = Boolean(baseline)
baselineTime = baseline?.latestUploadTime
}
await terminalLogApi.pullTerminalLog(hardId)
useMessage().success("指令已发送,日志无变化时列表不会更新")
startPolling(hardId, baselineTime, hadHistory)
} catch (error: any) {
pulling.value = false
useMessage().error(error?.msg || "日志拉取指令发送失败")
}
}
// MQTT 只负责通知终端上传,管理端按固定窗口轮询,避免持续占用接口资源。
function startPolling(hardId: string | number, previousUploadTime: string | undefined, hadHistory: boolean) {
let count = 0
const poll = async () => {
count += 1
try {
const { data } = await terminalLogApi.fetchDevicePage({ hardId, current: 1, size: 1 })
const current = data?.records?.[0] as TerminalLogDevice | undefined
const changed = current && (!hadHistory || current.latestUploadTime !== previousUploadTime)
if (changed) {
stopPolling()
devicePage.value = 1
await loadDevices()
const refreshed = devices.value.find((item) => String(item.hardId) === String(hardId)) ?? current
await selectDevice(refreshed)
useMessage().success("终端日志已更新")
return
}
} catch {
// 单次轮询失败不终止等待,网络恢复后仍可在剩余窗口内发现新日志。
}
if (count >= POLL_MAX_COUNT) {
stopPolling()
useMessage().info("等待结束,终端离线或日志内容无变化")
return
}
pollTimer = setTimeout(poll, POLL_INTERVAL_MS)
}
pollTimer = setTimeout(poll, POLL_INTERVAL_MS)
}
function stopPolling() {
if (pollTimer) clearTimeout(pollTimer)
pollTimer = undefined
pulling.value = false
}
onMounted(() => loadDevices())
onBeforeUnmount(stopPolling)
</script>
<style scoped>
.terminal-log-query:deep(.el-form-item) {
margin: 0;
}
/* 查询项由外层十二列栅格分配宽度,控件只负责填满网格,避免固定像素宽度造成随机换行。 */
.terminal-log-query:deep(.el-input),
.terminal-log-query:deep(.el-select),
.terminal-log-query:deep(.el-tree-select),
.terminal-log-query:deep(.el-date-editor) {
width: 100%;
min-width: 0;
}
.terminal-log-query:deep(.el-form-item__content) {
min-width: 0;
}
.terminal-log-query__actions:deep(.el-form-item__content) {
justify-content: flex-end;
}
.terminal-log-split,
.terminal-log-split:deep(.splitpanes__pane) {
height: 100%;
min-height: 0;
background: var(--el-bg-color);
}
.terminal-log-split:deep(.splitpanes__splitter) {
width: 5px;
border-left: 1px solid var(--el-border-color-lighter);
border-right: 1px solid var(--el-border-color-lighter);
background: var(--el-fill-color-light);
}
:deep(.el-drawer__body) {
min-height: 0;
padding: 0;
}
</style>
@@ -0,0 +1,140 @@
<template>
<div ref="hostRef" class="terminal-log-viewer" aria-label="终端日志只读预览"></div>
</template>
<script setup lang="ts">
import CodeMirror from "codemirror"
import "codemirror/lib/codemirror.css"
import "codemirror/addon/selection/active-line"
const props = withDefaults(
defineProps<{
modelValue?: string
keyword?: string
}>(),
{
modelValue: "",
keyword: "",
},
)
const hostRef = ref<HTMLElement>()
// 当前工程未引入 CodeMirror 5 类型包,运行时 API 保持由锁定版本提供。
let editor: any
let keywordMarks: any[] = []
const displayValue = computed(() => {
const keyword = props.keyword.trim().toLocaleLowerCase()
if (!keyword) return props.modelValue
return props.modelValue
.split(/\r?\n/)
.filter((line) => line.toLocaleLowerCase().includes(keyword))
.join("\n")
})
const logLevelOverlay = {
token(stream: any) {
if (stream.match(/\b(ERROR|FATAL)\b/)) return "log-error"
if (stream.match(/\bWARN(?:ING)?\b/)) return "log-warn"
if (stream.match(/\bINFO\b/)) return "log-info"
while (stream.next() != null && !stream.match(/\b(ERROR|FATAL|WARN(?:ING)?|INFO)\b/, false)) {
// 连续消费普通字符,避免逐字符触发 overlay 解析。
}
return null
},
}
function refreshKeywordMarks() {
keywordMarks.forEach((mark) => mark.clear())
keywordMarks = []
const keyword = props.keyword.trim()
if (!editor || !keyword) return
const normalizedKeyword = keyword.toLocaleLowerCase()
for (let line = 0; line < editor.lineCount(); line += 1) {
const source = editor.getLine(line)
const normalizedSource = source.toLocaleLowerCase()
let start = normalizedSource.indexOf(normalizedKeyword)
while (start >= 0) {
keywordMarks.push(
editor.markText(
{ line, ch: start },
{ line, ch: start + keyword.length },
{ className: "terminal-log-keyword" },
),
)
start = normalizedSource.indexOf(normalizedKeyword, start + keyword.length)
}
}
}
watch([displayValue, () => props.keyword], ([value]) => {
if (!editor) return
if (editor.getValue() !== value) editor.setValue(value)
// 不同关键词可能筛出相同的行,仍需单独刷新高亮位置。
refreshKeywordMarks()
})
onMounted(() => {
if (!hostRef.value) return
editor = CodeMirror(hostRef.value, {
value: displayValue.value,
readOnly: "nocursor",
lineNumbers: true,
lineWrapping: false,
styleActiveLine: true,
viewportMargin: 50,
})
editor.addOverlay(logLevelOverlay)
refreshKeywordMarks()
})
onBeforeUnmount(() => {
keywordMarks.forEach((mark) => mark.clear())
keywordMarks = []
editor = undefined
})
</script>
<style scoped>
.terminal-log-viewer {
height: 100%;
min-height: 0;
border-top: 1px solid var(--el-border-color-lighter);
background: var(--el-bg-color);
}
.terminal-log-viewer:deep(.CodeMirror) {
height: 100%;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 13px;
line-height: 1.65;
background: var(--el-bg-color);
color: var(--el-text-color-primary);
}
.terminal-log-viewer:deep(.CodeMirror-gutters) {
border-right-color: var(--el-border-color-lighter);
background: var(--el-fill-color-lighter);
}
.terminal-log-viewer:deep(.cm-log-error) {
color: var(--el-color-danger);
font-weight: 700;
}
.terminal-log-viewer:deep(.cm-log-warn) {
color: var(--el-color-warning);
font-weight: 700;
}
.terminal-log-viewer:deep(.cm-log-info) {
color: var(--el-color-primary);
font-weight: 600;
}
.terminal-log-viewer:deep(.terminal-log-keyword) {
border-radius: 2px;
background: var(--el-color-warning-light-7);
color: var(--el-text-color-primary);
}
</style>
@@ -0,0 +1,74 @@
<template>
<section class="flex h-full min-h-0 flex-col bg-[var(--el-bg-color)]" aria-label="终端日志预览">
<header class="flex min-h-[53px] flex-wrap items-center justify-between gap-2 border-b border-br-light px-3 py-2">
<div class="min-w-0">
<div class="truncate font-medium text-[var(--el-text-color-primary)]">{{ preview?.originalName || "日志预览" }}</div>
<div class="mt-0.5 truncate text-xs text-[var(--el-text-color-secondary)]">
{{ preview?.relativePath || "从文件树选择一个日志文件" }}
</div>
</div>
<div class="flex items-center gap-2">
<el-input v-model="keyword" clearable prefix-icon="Search" placeholder="筛选并高亮关键词" class="w-56" />
<el-button icon="CopyDocument" :disabled="!preview?.content" @click="$emit('copy')">复制</el-button>
<el-button
v-auth="'hard_hardTerminalLogFile_preview'"
icon="Refresh"
:disabled="!hasFile"
:loading="loading"
@click="$emit('refresh')"
>
刷新
</el-button>
<el-button
v-auth="'hard_hardTerminalLogFile_down'"
type="primary"
plain
icon="Download"
:disabled="!hasFile"
@click="$emit('download')"
>
完整下载
</el-button>
</div>
</header>
<el-alert
v-if="preview?.truncated"
class="rounded-none"
type="warning"
:closable="false"
show-icon
title="仅展示日志尾部最多 1MB / 5000 行,完整内容请下载查看。"
/>
<div v-loading="loading" class="min-h-0 flex-1">
<log-viewer v-if="preview" :model-value="preview.content" :keyword="keyword" />
<el-empty v-else :description="emptyDescription" />
</div>
</section>
</template>
<script setup lang="ts">
import type { TerminalLogPreview } from "/@/api/hard/hard-terminal-log-file"
import LogViewer from "./log-viewer.vue"
const props = defineProps<{
preview?: TerminalLogPreview
loading: boolean
canPreview: boolean
hasDevice: boolean
hasFile: boolean
}>()
defineEmits<{
(event: "copy"): void
(event: "refresh"): void
(event: "download"): void
}>()
const keyword = defineModel<string>("keyword", { default: "" })
const emptyDescription = computed(() => {
if (!props.hasDevice) return "请先选择终端设备"
if (props.hasFile && !props.canPreview) return "暂无日志预览权限,可使用完整下载"
return "请选择日志文件"
})
</script>
@@ -1,7 +1,12 @@
<template>
<el-dialog title="编辑定餐配置" v-model="visible" :close-on-click-modal="false" draggable>
<el-form ref="dataFormRef" :model="form" label-width="180px" v-loading="loading">
<el-form ref="dataFormRef" :model="form" label-width="250px" v-loading="loading">
<el-row :gutter="24">
<el-col :span="24" class="mb20">
<el-form-item label="预约模式" prop="mode">
<dict-select v-model="form.mode" dict-code="mch_table_reservation_mode" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="预约时间模式" prop="reserveMode">
<dict-select v-model="form.reserveMode" dict-code="hard_appt_reserve_mode" />
@@ -17,9 +22,16 @@
inactive-text="否" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="同一人同一天同一餐段仅允许预约一次" prop="singleReservationOnlyPerMeal">
<el-switch v-model="form.singleReservationOnlyPerMeal" inline-prompt active-text="是"
inactive-text="否" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="代订餐用户关系" prop="userRelationTypeIdList">
<dict-select v-model="form.userRelationTypeIdList" dict-code="sys_user_relation_type" multiple />
<dict-select v-model="form.userRelationTypeIdList" dict-code="sys_user_relation_type"
multiple />
</el-form-item>
</el-col>
</el-row>
@@ -45,15 +57,19 @@ const visible = ref(false);
const loading = ref(false);
const createDefaultTableReservationConf = () => ({
mode: "10",
reserveMode: RESERVE_MODE.TIME_WINDOW,
singleProductOnlyInCart: false,
singleReservationOnlyPerMeal: false,
})
const form = reactive({
id: "",
mode: "10",
tableReservationConf: createDefaultTableReservationConf(),
reserveMode: RESERVE_MODE.TIME_WINDOW,
singleProductOnlyInCart: false,
singleReservationOnlyPerMeal: false,
userRelationTypeIdList: [],
})
@@ -62,8 +78,10 @@ const openDialog = (mchId: string) => {
visible.value = true
form.id = mchId
form.tableReservationConf = createDefaultTableReservationConf()
form.mode = "10"
form.reserveMode = RESERVE_MODE.TIME_WINDOW
form.singleProductOnlyInCart = false
form.singleReservationOnlyPerMeal = false
form.userRelationTypeIdList = []
nextTick(() => {
dataFormRef.value?.resetFields?.()
@@ -90,8 +108,10 @@ const onSubmit = async () => {
tableReservationConf: {
...createDefaultTableReservationConf(),
...(data?.tableReservationConf || {}),
mode: form.mode,
reserveMode: normalizeReserveMode(form.reserveMode),
singleProductOnlyInCart: form.singleProductOnlyInCart,
singleReservationOnlyPerMeal: form.singleReservationOnlyPerMeal,
userRelationTypeIdList: form.userRelationTypeIdList,
}
}
@@ -125,7 +145,9 @@ const getTableReservationConfData = async (mchId: string) => {
form.tableReservationConf = tableReservationConf
form.reserveMode = tableReservationConf.reserveMode
form.singleProductOnlyInCart = tableReservationConf.singleProductOnlyInCart
form.singleReservationOnlyPerMeal = tableReservationConf.singleReservationOnlyPerMeal
form.userRelationTypeIdList = tableReservationConf.userRelationTypeIdList || []
form.mode = tableReservationConf.mode || "10"
} catch (error: any) {
useMessage().error(error?.msg || error?.message || "获取定餐配置失败")
} finally {
@@ -98,6 +98,11 @@
</div>
<div class="take-card__placeholder">
<div class="take-info" v-loading="state.tableReservationConfLoading">
<div class="take-info-item">
<span class="take-info-item__label">预约模式</span>
<dict-tag class="take-info-item__value" :options="mch_table_reservation_mode"
:value="state.tableReservationConf.mode" />
</div>
<div class="take-info-item">
<span class="take-info-item__label">预约时间模式</span>
<dict-tag class="take-info-item__value" :options="hard_appt_reserve_mode"
@@ -109,6 +114,12 @@
{{ state.tableReservationConf.singleProductOnlyInCart ? "是" : "否" }}
</span>
</div>
<div class="take-info-item">
<span class="take-info-item__label">同一人同一天同一餐段仅允许预约一次</span>
<span class="take-info-item__value">
{{ state.tableReservationConf.singleReservationOnlyPerMeal ? "是" : "否" }}
</span>
</div>
<div class="take-info-item">
<span class="take-info-item__label">代订餐用户关系</span>
<span class="take-info-item__value">
@@ -123,84 +134,84 @@
<div class="take-card__placeholder" style="margin-top: 16px;">
<template v-if="currentReserveMode === RESERVE_MODE.TIME_WINDOW">
<div class="mb-4 text-base font-medium text-gray-700">动态时间窗餐段规则</div>
<el-row>
<div class="mb8" style="width: 100%">
<el-button icon="folder-add" type="primary" class="ml10"
@click="formDialogRef.openDialog(null, state.mchDetail.deptId, state.mchId)"
v-auth="'hard_hardAppt_add'">
新 增
<el-row>
<div class="mb8" style="width: 100%">
<el-button icon="folder-add" type="primary" class="ml10"
@click="formDialogRef.openDialog(null, state.mchDetail.deptId, state.mchId)"
v-auth="'hard_hardAppt_add'">
新 增
</el-button>
<el-button plain icon="Delete" type="primary" v-auth="'hard_hardAppt_del'"
@click="handleDelete(selectObjs)">
删除
</el-button>
<right-toolbar class="ml10 mr20" style="float: right" @queryTable="getDataList" />
</div>
</el-row>
<el-table :data="apptState.dataList" v-loading="apptState.loading" border
:cell-style="tableStyle.cellStyle" :header-cell-style="tableStyle.headerCellStyle"
@sort-change="sortChangeHandle">
<el-table-column type="index" label="#" width="40" />
<el-table-column prop="title" label="标题" />
<el-table-column prop="title" label="类型">
<template #default="{ row }">
<dict-tag :options="daily_menu" :value="row.hourType" />
</template>
</el-table-column>
<el-table-column label="预约模式" width="130">
<template #default="{ row }">
<dict-tag :options="hard_appt_reserve_mode"
:value="normalizeReserveMode(row.reserveMode)" />
</template>
</el-table-column>
<el-table-column label="可预约时间" width="300">
<template #default="{ row }">
<span v-if="normalizeReserveMode(row.reserveMode) === RESERVE_MODE.WEEKLY_CYCLE">
由周期预约规则配置
</span>
<div v-else class="flex flex-wrap">
<dict-tag style="margin-right: 5px" v-for="value in row.reserveTimeList"
:key="value" :options="hard_appt_reserve_date_type" :value="value" />
</div>
</template>
</el-table-column>
<el-table-column label="时间段" width="300">
<template #default="{ row }">
<span v-if="normalizeReserveMode(row.reserveMode) === RESERVE_MODE.WEEKLY_CYCLE">
由周期预约规则配置
</span>
<div v-else class="take-time-range">
<div class="take-time-range__item">
<dict-tag :options="hard_appt_time_type" :value="row.startTimeType" />
<span class="take-time-range__time">{{ row.startTime }}</span>
</div>
<span class="take-time-range__sep">—</span>
<div class="take-time-range__item">
<dict-tag :options="hard_appt_time_type" :value="row.endTimeType" />
<span class="take-time-range__time">{{ row.endTime }}</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="status" label="状态">
<template #default="{ row }">
<dict-tag :options="yes_no_type" :value="row.status" />
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" />
<el-table-column label="操作" width="150">
<template #default="scope">
<el-button icon="edit-pen" text type="primary" v-auth="'hard_hardAppt_edit'"
@click="formDialogRef.openDialog(scope.row.id, state.mchDetail.deptId, state.mchId)">
编辑
</el-button>
<el-button plain icon="Delete" type="primary" v-auth="'hard_hardAppt_del'"
@click="handleDelete(selectObjs)">
<el-button icon="delete" text type="primary" v-auth="'hard_hardAppt_del'"
@click="handleDelete([scope.row.id])">
删除
</el-button>
<right-toolbar class="ml10 mr20" style="float: right" @queryTable="getDataList" />
</div>
</el-row>
<el-table :data="apptState.dataList" v-loading="apptState.loading" border
:cell-style="tableStyle.cellStyle" :header-cell-style="tableStyle.headerCellStyle"
@sort-change="sortChangeHandle">
<el-table-column type="index" label="#" width="40" />
<el-table-column prop="title" label="标题" />
<el-table-column prop="title" label="类型">
<template #default="{ row }">
<dict-tag :options="daily_menu" :value="row.hourType" />
</template>
</el-table-column>
<el-table-column label="预约模式" width="130">
<template #default="{ row }">
<dict-tag :options="hard_appt_reserve_mode"
:value="normalizeReserveMode(row.reserveMode)" />
</template>
</el-table-column>
<el-table-column label="可预约时间" width="300">
<template #default="{ row }">
<span v-if="normalizeReserveMode(row.reserveMode) === RESERVE_MODE.WEEKLY_CYCLE">
由周期预约规则配置
</span>
<div v-else class="flex flex-wrap">
<dict-tag style="margin-right: 5px" v-for="value in row.reserveTimeList"
:key="value" :options="hard_appt_reserve_date_type" :value="value" />
</div>
</template>
</el-table-column>
<el-table-column label="时间段" width="300">
<template #default="{ row }">
<span v-if="normalizeReserveMode(row.reserveMode) === RESERVE_MODE.WEEKLY_CYCLE">
由周期预约规则配置
</span>
<div v-else class="take-time-range">
<div class="take-time-range__item">
<dict-tag :options="hard_appt_time_type" :value="row.startTimeType" />
<span class="take-time-range__time">{{ row.startTime }}</span>
</div>
<span class="take-time-range__sep">—</span>
<div class="take-time-range__item">
<dict-tag :options="hard_appt_time_type" :value="row.endTimeType" />
<span class="take-time-range__time">{{ row.endTime }}</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="status" label="状态">
<template #default="{ row }">
<dict-tag :options="yes_no_type" :value="row.status" />
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" />
<el-table-column label="操作" width="150">
<template #default="scope">
<el-button icon="edit-pen" text type="primary" v-auth="'hard_hardAppt_edit'"
@click="formDialogRef.openDialog(scope.row.id, state.mchDetail.deptId, state.mchId)">
编辑
</el-button>
<el-button icon="delete" text type="primary" v-auth="'hard_hardAppt_del'"
@click="handleDelete([scope.row.id])">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</template>
</el-table-column>
</el-table>
</template>
<template v-else>
<cycle-rule-list :mch-id="props.mchId" :dept-id="state.mchDetail.deptId" />
@@ -279,6 +290,7 @@ const createDefaultFunctionScope = () => ({
const createDefaultTableReservationConf = () => ({
reserveMode: RESERVE_MODE.TIME_WINDOW,
singleProductOnlyInCart: false,
singleReservationOnlyPerMeal: false,
})
const takeOutFormRef = ref()
@@ -311,6 +323,7 @@ const {
hard_appt_reserve_date_type,
hard_appt_reserve_mode,
sys_user_relation_type,
mch_table_reservation_mode,
} = useDict(
'yes_no_type',
'daily_menu',
@@ -318,6 +331,7 @@ const {
'hard_appt_reserve_date_type',
'hard_appt_reserve_mode',
'sys_user_relation_type',
'mch_table_reservation_mode',
)
const currentReserveMode = computed(() => normalizeReserveMode(state.tableReservationConf.reserveMode))
@@ -43,8 +43,8 @@ const dataRules = ref({
validator: (_rule: any, value: string, callback: (error?: Error) => void) => {
const length = (value ?? "").trim().length
if (length === 0) return callback()
if (length === 10 || length === 12) return callback()
callback(new Error("卡号长度必须为10位或12位"))
if (length === 8 || length === 10 || length === 12) return callback()
callback(new Error("卡号长度必须为8、10、12位"))
},
trigger: "blur",
},