feat:更改页面
This commit is contained in:
1 parent
b422df5b76
commit
44b2df144c
84 files changed
+11701
-2114
No files matched your search
@@ -135,6 +135,7 @@
|
||||
<script setup lang="ts" name="hard-door-log">
|
||||
import * as hardFlowManager from "/@/api/hard/hard-door-flow"
|
||||
import { useMessage } from "/@/hooks/message"
|
||||
import { auth, auths, authAll } from '/@/utils/authFunction';
|
||||
|
||||
type Props = {
|
||||
orderId?: string
|
||||
@@ -170,6 +171,10 @@ watch(
|
||||
|
||||
const getFlow = async () => {
|
||||
if (!props.orderId) return
|
||||
// 判断是否有"dzc_hardDoorFlow_view"权限
|
||||
if (!auth("dzc_hardDoorFlow_view")) {
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const _pa = { orderId: props.orderId }
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" :title="title" width="520px" draggable :close-on-click-modal="false">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px" v-loading="loading">
|
||||
<el-form-item label="订单号">
|
||||
<el-input :model-value="orderLabel" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="凭证类型">
|
||||
<el-tag :type="options.printType === '2' ? 'warning' : 'primary'">
|
||||
{{ options.printType === "2" ? "兑换券" : "发票" }}
|
||||
</el-tag>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="showSourceSelection" label="来源终端" prop="sourceHardId">
|
||||
<el-select v-model="form.sourceHardId" class="w-full" filterable placeholder="请选择已绑定尔印打印机的业务终端">
|
||||
<el-option v-for="source in sources" :key="source.id" :label="source.name" :value="String(source.id)" />
|
||||
</el-select>
|
||||
<div v-if="sources.length === 0" class="mt-1 text-xs text-[var(--el-color-danger)]">
|
||||
该商户没有已绑定尔印打印机的业务终端,请先在设备管理完成绑定。
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="requiresReason" label="重打原因" prop="reprintReason">
|
||||
<el-input v-model="form.reprintReason" type="textarea" :rows="3" maxlength="200" show-word-limit
|
||||
placeholder="请填写本次人工重打原因" />
|
||||
</el-form-item>
|
||||
<el-alert v-if="options.action === 'retry'" type="info" :closable="false" show-icon
|
||||
title="重试原任务不会增加打印序号;终端只处理新的重试版本。" />
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" :disabled="sourceUnavailable" @click="submit">
|
||||
提交打印任务
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="HardPrintTaskDialog">
|
||||
import * as hardInvoiceApi from "/@/api/hard/hard-invoice"
|
||||
import * as printerApi from "/@/api/hard/hard-printer"
|
||||
import { useMessage, useMessageBox } from "/@/hooks/message"
|
||||
|
||||
type PrintAction = "create" | "retry" | "reprint"
|
||||
|
||||
interface PrintDialogOptions {
|
||||
action: PrintAction
|
||||
printType: "1" | "2"
|
||||
orderId: string | number
|
||||
merchantId?: string | number
|
||||
taskId?: string | number
|
||||
sourceHardId?: string | number | null
|
||||
orderNo?: string
|
||||
printNo?: string | number | null
|
||||
}
|
||||
|
||||
const emit = defineEmits(["submitted"])
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const formRef = ref()
|
||||
const sources = ref<any[]>([])
|
||||
const options = reactive<PrintDialogOptions>({ action: "create", printType: "1", orderId: "" })
|
||||
/** 来源设备使用字符串保存,避免选择或自动回填时破坏后端 Long 主键精度。 */
|
||||
const form = reactive<{ sourceHardId: printerApi.HardwareId | null; reprintReason: string }>({
|
||||
sourceHardId: null,
|
||||
reprintReason: "",
|
||||
})
|
||||
|
||||
/** 已打印任务再次出纸必须记录原因;失败任务重试不改变打印次数。 */
|
||||
const requiresReason = computed(() => options.action === "reprint" || Boolean(options.printNo && options.action === "create"))
|
||||
const showSourceSelection = computed(() => options.action !== "reprint" && !(options.action === "retry" && options.sourceHardId))
|
||||
const sourceUnavailable = computed(() => showSourceSelection.value && !loading.value && sources.value.length === 0)
|
||||
const orderLabel = computed(() => options.orderNo || String(options.orderId || "--"))
|
||||
const title = computed(() => {
|
||||
if (options.action === "retry") return "重试失败打印任务"
|
||||
if (options.action === "reprint") return `重打${options.printType === "2" ? "兑换券" : "发票"}`
|
||||
return `${requiresReason.value ? "再次打印" : "打印"}${options.printType === "2" ? "兑换券" : "发票"}`
|
||||
})
|
||||
const rules = computed(() => ({
|
||||
sourceHardId: showSourceSelection.value ? [{ required: true, message: "请选择来源业务终端", trigger: "change" }] : [],
|
||||
reprintReason: requiresReason.value ? [{ required: true, message: "请填写重打原因", trigger: "blur" }] : [],
|
||||
}))
|
||||
|
||||
/** 打开任务弹窗,并根据商户的有效绑定自动选择唯一来源终端。 */
|
||||
const open = async (input: PrintDialogOptions) => {
|
||||
Object.assign(options, input)
|
||||
form.sourceHardId = null
|
||||
form.reprintReason = ""
|
||||
sources.value = []
|
||||
visible.value = true
|
||||
await nextTick()
|
||||
formRef.value?.clearValidate?.()
|
||||
if (!showSourceSelection.value) return
|
||||
if (!input.merchantId) {
|
||||
useMessage().error("订单缺少商户信息,无法选择打印终端")
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await printerApi.listBoundSources(String(input.merchantId))
|
||||
sources.value = response.data || []
|
||||
const preferred = sources.value.find((source) => input.sourceHardId != null
|
||||
&& String(source.id) === String(input.sourceHardId))
|
||||
if (preferred) form.sourceHardId = String(preferred.id)
|
||||
else if (sources.value.length === 1) form.sourceHardId = String(sources.value[0].id)
|
||||
} catch (error: any) {
|
||||
useMessage().error(error?.msg || "读取可用打印终端失败")
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 校验、二次确认并提交任务;响应只代表任务受理,不代表打印机已经出纸。 */
|
||||
const submit = async () => {
|
||||
const valid = await formRef.value?.validate?.().catch(() => false)
|
||||
if (valid === false) return
|
||||
if (requiresReason.value) {
|
||||
try {
|
||||
await useMessageBox().confirm(`确认提交第 ${Number(options.printNo || 0) + 1} 次打印?提交后将保留操作原因。`)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
if (options.action === "retry") {
|
||||
await hardInvoiceApi.retry({ historyId: options.taskId, sourceHardId: form.sourceHardId || undefined })
|
||||
} else if (options.action === "reprint") {
|
||||
await hardInvoiceApi.reprint({ historyId: options.taskId, reprintReason: form.reprintReason.trim() })
|
||||
} else {
|
||||
await hardInvoiceApi.print({
|
||||
orderId: options.orderId,
|
||||
printType: options.printType,
|
||||
sourceHardId: form.sourceHardId || undefined,
|
||||
reprintReason: form.reprintReason.trim() || undefined,
|
||||
})
|
||||
}
|
||||
visible.value = false
|
||||
useMessage().success("打印任务已提交")
|
||||
emit("submitted")
|
||||
} catch (error: any) {
|
||||
useMessage().error(error?.msg || "打印任务提交失败")
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
@@ -72,6 +72,9 @@ const state: BasicTableProps = reactive<BasicTableProps>({
|
||||
pageList: hardAfterSaleApi.pageJoin,
|
||||
})
|
||||
|
||||
// table hook
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, tableStyle } = useTable(state)
|
||||
|
||||
watch(
|
||||
() => props.orderId,
|
||||
(newVal, oldVal) => {
|
||||
@@ -82,9 +85,6 @@ watch(
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
// table hook
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, tableStyle } = useTable(state)
|
||||
|
||||
// 定义查询字典
|
||||
const { hard_after_sale_type, hard_after_sale_way } = useDict("hard_after_sale_type", "hard_after_sale_way")
|
||||
|
||||
|
||||
@@ -1,78 +1,128 @@
|
||||
<template>
|
||||
<div class="layout-padding">
|
||||
<div class="layout-padding-auto layout-padding-view">
|
||||
<el-form ref="queryRef" :inline="true" :model="state.queryForm" @keyup.enter="getDataList">
|
||||
<el-row>
|
||||
<el-form-item style="width: 250px" label="用户" prop="userIds">
|
||||
<select-user v-model="state.queryForm.userIds" />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 200px" label="退款状态" prop="status">
|
||||
<dict-select v-model="state.queryForm.status" dict-code="hard_sale_status" />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 300px" label="申请时间" prop="createTimeList">
|
||||
<el-date-picker v-model="state.queryForm.createTimeList" value-format="YYYY-MM-DD"
|
||||
type="daterange" />
|
||||
</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-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="40" />
|
||||
<el-table-column prop="username" label="用户" />
|
||||
<el-table-column prop="userAvatar" label="头像">
|
||||
<template #default="{ row }">
|
||||
<preview-image :src="row.userAvatar" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="userMobile" width="120" label="手机号" />
|
||||
<el-table-column label="支付金额" align="center" prop="practicalTotalPrice">
|
||||
<template #default="{ row }">
|
||||
<span class="color-primary">
|
||||
{{ displayPrice(row.orderPayPrice) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="退款金额" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="color-success">
|
||||
{{ displayPrice(row.refundPrice) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="退款状态" align="center" min-width="120px">
|
||||
<template #default="{ row }">
|
||||
<dict-tag :options="hard_sale_status" :value="row.status" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="申请时间" align="center" width="180px" prop="createTime" />
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button icon="ShoppingCartFull" text type="primary"
|
||||
@click="shopCarDialogRef.openDialog(row.orderId)">购物车</el-button>
|
||||
<el-button icon="WalletFilled" v-auth="'hard_hardAfterSale_detail_view'" link type="primary"
|
||||
@click="doRefund(row)">
|
||||
退款
|
||||
</el-button>
|
||||
<el-button v-if="false" icon="edit-pen" text type="primary" v-auth="'hard_hardAfterSale_edit'"
|
||||
@click="formDialogRef.openDialog(row.id)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button v-if="false" icon="delete" text type="primary" v-auth="'hard_hardAfterSale_del'"
|
||||
@click="handleDelete([row.id])">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle"
|
||||
v-bind="state.pagination" />
|
||||
<el-tabs class="after-sale-tabs">
|
||||
<el-tab-pane label="售后订单" name="AFTER_SALE">
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<SearchPanel v-show="showSearch" ref="queryRef" :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: 250px" label="退款订单人" prop="orderUserIds">
|
||||
<select-user v-model="state.queryForm.orderUserIds" />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 250px" label="发起退款人" prop="userIds">
|
||||
<select-user v-model="state.queryForm.userIds" />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 200px" label="退款状态" prop="status">
|
||||
<dict-select v-model="state.queryForm.status" dict-code="hard_sale_status" />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 300px" label="订单状态" prop="orderStatusCodes">
|
||||
<dict-select v-model="state.queryForm.orderStatusCodes" dict-code="hard_order_status"
|
||||
multiple />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 300px" label="申请时间" prop="createTimeList">
|
||||
<el-date-picker v-model="state.queryForm.createTimeList" value-format="YYYY-MM-DD"
|
||||
type="daterange" />
|
||||
</el-form-item>
|
||||
<template #more>
|
||||
<el-form-item style="width: 250px" label="用餐时间段" prop="hourTypeCodes">
|
||||
<dict-select v-model="state.queryForm.hourTypeCodes" dict-code="daily_menu"
|
||||
multiple />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 450px" label="预约时间" prop="apptUseDateTimeList">
|
||||
<el-date-picker v-model="state.queryForm.apptUseDateTimeList"
|
||||
value-format="YYYY-MM-DD HH:mm:ss" type="daterange"
|
||||
:default-time="dateRangeDefaultTime" />
|
||||
</el-form-item>
|
||||
<user-identity-query v-model:identity-types="state.queryForm.identityTypes"
|
||||
v-model:student-info="state.queryForm.studentInfo"
|
||||
v-model:teacher-info="state.queryForm.teacherInfo"
|
||||
v-model:has-face="state.queryForm.hasFace" />
|
||||
</template>
|
||||
</SearchPanel>
|
||||
<el-tabs v-if="orderTypeTabs.length" v-model="activeOrderType" class="order-type-tabs shrink-0"
|
||||
@tab-click="handleOrderTypeClick">
|
||||
<el-tab-pane v-for="item in orderTypeTabs" :key="String(item.value)" :label="item.label"
|
||||
:name="item.value" />
|
||||
</el-tabs>
|
||||
<el-table class="min-h-0 flex-1" height="100%" :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="index" label="#" width="60" />
|
||||
<el-table-column label="用户信息" min-width="280">
|
||||
<template #default="{ row }"><user-info-cell :user="row"
|
||||
:show-gender="false" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="身份信息" min-width="300">
|
||||
<template #default="{ row }"><user-identity-info :user="row" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="支付金额" min-width="120" align="center" prop="practicalTotalPrice">
|
||||
<template #default="{ row }">
|
||||
<span class="color-primary">
|
||||
{{ displayPrice(row.orderPayPrice) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column min-width="120" label="退款金额" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="color-success">
|
||||
{{ displayPrice(row.refundPrice) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column min-width="120" label="退款状态" align="center">
|
||||
<template #default="{ row }">
|
||||
<dict-tag :options="hard_sale_status" :value="row.status" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="退款类型" align="center" min-width="240">
|
||||
<template #default>
|
||||
<span>人工审核退款</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="处理方式" align="center" min-width="150">
|
||||
<template #default>
|
||||
<span>人工审核</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="支付时间" align="center" width="250px" prop="orderPayTime">
|
||||
<template #default="{ row }">
|
||||
<div class="status">
|
||||
<span>支付时间</span>
|
||||
<span>{{ row.orderPayTime || "-" }}</span>
|
||||
</div>
|
||||
<div class="status">
|
||||
<span>申请时间</span>
|
||||
<span>{{ row.createTime || "-" }}</span>
|
||||
</div>
|
||||
<div v-if="row.orderApptUseDate" class="status">
|
||||
<span>预约时间</span>
|
||||
<span>{{ row.orderApptUseDate }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" fixed="right" width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="flex flex-wrap items-start -mx-1">
|
||||
<div class="w-1/2 h-[30px] flex justify-start items-center">
|
||||
<el-button icon="ShoppingCartFull" text type="primary"
|
||||
@click="shopCarDialogRef.openDialog(row.orderId)">购物车</el-button>
|
||||
</div>
|
||||
<div class="w-1/2 h-[30px] flex justify-start items-center"
|
||||
v-auth="'hard_hardAfterSale_detail_view'">
|
||||
<el-button icon="WalletFilled" link type="primary" @click="doRefund(row)">
|
||||
退款
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle"
|
||||
v-bind="state.pagination" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
<!-- 编辑、新增 -->
|
||||
@@ -86,7 +136,12 @@ import { BasicTableProps, useTable } from "/@/hooks/table"
|
||||
import { pageJoin, delObjs } from "/@/api/hard/hard-after-sale"
|
||||
import { useMessage, useMessageBox } from "/@/hooks/message"
|
||||
import { useDict } from "/@/hooks/dict"
|
||||
import { getDicts } from "/@/api/admin/dict"
|
||||
import { displayPrice } from "/@/utils/formatMoney"
|
||||
import {
|
||||
createEmptyUserIdentityStudentQuery,
|
||||
createEmptyUserIdentityTeacherQuery,
|
||||
} from "/@/components/UserIdentityQuery/types"
|
||||
|
||||
// 引入组件
|
||||
const FormDialog = defineAsyncComponent(() => import("./form.vue"))
|
||||
@@ -99,6 +154,17 @@ const shopCarDialogRef = ref()
|
||||
// 搜索变量
|
||||
const queryRef = ref()
|
||||
const showSearch = ref(true)
|
||||
/** 订单类型页签数据结构,value 为空时表示不限制订单类型。 */
|
||||
interface OrderTypeTab {
|
||||
/** 后台订单类型字典展示名称。 */
|
||||
label: string
|
||||
/** 后台订单类型字典编码;null 专用于“全部”页签。 */
|
||||
value: string | null
|
||||
}
|
||||
/** 当前选中的订单类型字典编码;null 表示查询全部订单类型。 */
|
||||
const activeOrderType = ref<string | null>(null)
|
||||
/** 表格上方的订单类型页签,字典加载前仍保留“全部”入口。 */
|
||||
const orderTypeTabs = ref<OrderTypeTab[]>([{ label: "全部", value: null }])
|
||||
// 多选变量
|
||||
const selectObjs = ref([]) as any
|
||||
const multiple = ref(true)
|
||||
@@ -106,14 +172,52 @@ const draggableDepTreeRef = ref() // 树组件ref引用
|
||||
|
||||
const { push } = useRouter()
|
||||
|
||||
/** 日期范围默认覆盖开始日零点至结束日最后一秒,避免遗漏结束日期内的预约订单。 */
|
||||
const dateRangeDefaultTime = [new Date(2000, 0, 1, 0, 0, 0), new Date(2000, 0, 1, 23, 59, 59)]
|
||||
|
||||
const state: BasicTableProps = reactive<BasicTableProps>({
|
||||
queryForm: {},
|
||||
queryForm: {
|
||||
orderStatusCodes: [],
|
||||
hourTypeCodes: [],
|
||||
identityTypes: [],
|
||||
studentInfo: createEmptyUserIdentityStudentQuery(),
|
||||
teacherInfo: createEmptyUserIdentityTeacherQuery(),
|
||||
hasFace: "",
|
||||
},
|
||||
pageList: pageJoin,
|
||||
})
|
||||
|
||||
// table hook
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, downBlobFile, tableStyle } = useTable(state)
|
||||
|
||||
/**
|
||||
* 加载订单类型字典并组装表格次级页签。
|
||||
* 请求失败时保留默认“全部”页签,页面仍可继续查询售后订单。
|
||||
*/
|
||||
const loadOrderTypeTabs = async () => {
|
||||
try {
|
||||
const { data = [] } = await getDicts("hard_order_type")
|
||||
orderTypeTabs.value = [
|
||||
{ label: "全部", value: null },
|
||||
...data.map((item: any) => ({ label: item.label, value: item.value })),
|
||||
]
|
||||
} catch {
|
||||
orderTypeTabs.value = [{ label: "全部", value: null }]
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadOrderTypeTabs)
|
||||
|
||||
/**
|
||||
* 切换订单类型后从第一页重新查询售后列表。
|
||||
*
|
||||
* @param pane Element Plus 当前点击的订单类型页签上下文
|
||||
*/
|
||||
const handleOrderTypeClick = (pane: any) => {
|
||||
state.queryForm.orderType = orderTypeTabs.value[Number(pane.index)]?.value ?? null
|
||||
getDataList()
|
||||
}
|
||||
|
||||
// 清空搜索条件
|
||||
const resetQuery = () => {
|
||||
// 清空搜索条件
|
||||
@@ -122,7 +226,7 @@ const resetQuery = () => {
|
||||
selectObjs.value = []
|
||||
state.queryForm.deptId = ""
|
||||
state.queryForm.deptIds = []
|
||||
draggableDepTreeRef.value.resetData()
|
||||
draggableDepTreeRef.value?.resetData()
|
||||
getDataList()
|
||||
}
|
||||
|
||||
@@ -146,7 +250,10 @@ const handleGetSelectNode = (curClickedNode: any, checkedTreeNodes: any, isDeepT
|
||||
}
|
||||
|
||||
const doRefund = (row: any) => {
|
||||
push({ path: "/hard/hard-after-sale/order-refund", query: { id: row.orderId, type: "apply" } })
|
||||
push({
|
||||
path: "/hard/hard-after-sale/order-refund",
|
||||
query: { id: row.orderId, type: "apply" },
|
||||
})
|
||||
}
|
||||
|
||||
// 删除操作
|
||||
@@ -166,3 +273,56 @@ const handleDelete = async (ids: string[]) => {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.after-sale-tabs {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
|
||||
:deep(.el-tabs__content),
|
||||
:deep(.el-tab-pane) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.order-type-tabs {
|
||||
:deep(.el-tabs__header) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__content) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 2px;
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
span:nth-child(1) {
|
||||
min-width: 60px;
|
||||
max-width: 100px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
span:nth-child(2) {
|
||||
flex: 1;
|
||||
min-width: 50px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,22 +1,16 @@
|
||||
<template>
|
||||
<el-dialog title="智能柜设置" v-model="visible" width="80%" :close-on-click-modal="true" draggable>
|
||||
<el-form ref="queryRef" :inline="true" :model="state.queryForm" @keyup.enter="getDataList">
|
||||
<el-row>
|
||||
<el-form-item style="width: 300px" label="商品名" prop="name">
|
||||
<el-input v-model="state.queryForm.name" placeholder="商品名" />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 300px" label="商品类型" prop="skuType">
|
||||
<dict-select v-model="state.queryForm.skuType" dict-code="hard_ks_sku_type" />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 300px" label="商品分类" prop="spuCategoryId">
|
||||
<select-hard-spu-category v-model="state.queryForm.spuCategoryId" placeholder="请输入商品分类" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button icon="Search" type="primary" @click="getDataList">查询</el-button>
|
||||
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<SearchPanel ref="queryRef" :model="state.queryForm" :loading="Boolean(state.loading)" @search="getDataList" @reset="resetQuery">
|
||||
<el-form-item style="width: 300px" label="商品名" prop="name">
|
||||
<el-input v-model="state.queryForm.name" placeholder="商品名" />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 300px" label="商品类型" prop="skuType">
|
||||
<dict-select v-model="state.queryForm.skuType" dict-code="hard_ks_sku_type" />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 300px" label="商品分类" prop="spuCategoryId">
|
||||
<select-hard-spu-category v-model="state.queryForm.spuCategoryId" placeholder="请输入商品分类" />
|
||||
</el-form-item>
|
||||
</SearchPanel>
|
||||
|
||||
<el-table
|
||||
:data="state.dataList"
|
||||
|
||||
@@ -287,7 +287,7 @@ const refreshInfo = () => {
|
||||
// 音量设置
|
||||
const doVolumeSetting = async () => {
|
||||
if (volumeSettingRef.value) {
|
||||
volumeSettingRef.value.openDialog(state.deviceDetail.id, state.deviceDetail.mediaVolume, state.deviceDetail.systemVolume)
|
||||
volumeSettingRef.value.openDialog(state.deviceDetail.hardId, state.deviceDetail.mediaVolume, state.deviceDetail.systemVolume)
|
||||
}
|
||||
}
|
||||
const refreshVolume = (data: { hardId: string; mediaVolume: number; systemVolume: number }) => {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,34 +1,44 @@
|
||||
<template>
|
||||
<el-dialog :title="form.id ? '编辑' : '新增'" width="40%" v-model="visible" :close-on-click-modal="false" draggable>
|
||||
<el-form ref="dataFormRef" :model="form" :rules="dataRules" formDialogRef label-width="120px"
|
||||
v-loading="loading">
|
||||
<el-form ref="dataFormRef" :model="form" :rules="dataRules" formDialogRef label-width="120px" v-loading="loading">
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="24" class="mb20">
|
||||
<el-form-item label="设备名" prop="name">
|
||||
<el-input v-model="form.name" placeholder="请输入设备名" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="24" class="mb20">
|
||||
<el-form-item label="商户" prop="merchantId">
|
||||
<select-mch :apiParams="{
|
||||
parentIsNull: false
|
||||
}" v-model="form.merchantId" placeholder="请选择商户" />
|
||||
<select-mch v-model="form.merchantId" placeholder="请选择商户" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<el-col v-if="['9', '10', '11'].includes(form.type)" :span="24" class="mb20">
|
||||
<el-form-item label="设备唯一编号" prop="uniqueCode">
|
||||
<el-input v-model="form.uniqueCode" placeholder="请输入设备唯一编号" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<el-col v-if="false" :span="24" class="mb20">
|
||||
<el-form-item label="分组" prop="hardGroupId">
|
||||
<select-hard-group v-model="form.hardGroupId" placeholder="分组" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="24" class="mb20">
|
||||
<el-form-item label="设备类型" prop="type">
|
||||
<dict-select v-model="form.type" dict-code="hard_type" clearable />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="['10', '11'].includes(form.type)" :span="24" class="mb20">
|
||||
<el-form-item label="唯一编号" prop="uniqueCode">
|
||||
<el-input v-model="form.uniqueCode" placeholder="设备唯一编号" />
|
||||
</el-form-item>
|
||||
|
||||
<el-col v-if="form.type === '12'" :span="24" class="mb20">
|
||||
<el-alert
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
title="尔印打印机是无屏 USB 外设,不需要设备唯一编号、账号或独立心跳;新增后请将它绑定到同商户、同部门的食堂或超市终端。"
|
||||
/>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
@@ -42,10 +52,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="HardManagerDialog">
|
||||
import { useDict } from "/@/hooks/dict"
|
||||
import { useMessage } from "/@/hooks/message"
|
||||
import { getObj, addObj, putObj } from "/@/api/hard/hard-manager"
|
||||
import { rule } from "/@/utils/validate"
|
||||
const emit = defineEmits(["refresh"])
|
||||
|
||||
// 定义变量内容
|
||||
@@ -55,11 +63,11 @@ const loading = ref(false)
|
||||
// 定义字典
|
||||
|
||||
// 提交表单数据
|
||||
const form = reactive({
|
||||
const form = reactive<any>({
|
||||
id: "",
|
||||
uniqueCode: "",
|
||||
merchantId: "",
|
||||
deptId: "",
|
||||
uniqueCode: "",
|
||||
name: "",
|
||||
hardGroupId: "",
|
||||
type: "",
|
||||
@@ -68,9 +76,9 @@ const form = reactive({
|
||||
// 定义校验规则
|
||||
const dataRules = ref({
|
||||
name: [{ required: true, message: "设备名不能为空", trigger: "blur" }],
|
||||
uniqueCode: [{ required: true, message: "设备唯一编号不能为空", trigger: "blur" }],
|
||||
merchantId: [{ required: true, message: "商户不能为空", trigger: "change" }],
|
||||
hardGroupId: [{ required: true, message: "分组不能为空", trigger: "change" }],
|
||||
uniqueCode: [{ required: true, message: "设备唯一编号不能为空", trigger: "change" }],
|
||||
type: [{ required: true, message: "设备类型不能为空", trigger: "change" }],
|
||||
})
|
||||
|
||||
@@ -93,12 +101,16 @@ 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
|
||||
form.id ? await putObj(form) : await addObj(form)
|
||||
const _params = { ...form }
|
||||
if (!["9", "10", "11"].includes(_params.type)) {
|
||||
_params.uniqueCode = null
|
||||
}
|
||||
form.id ? await putObj(_params) : await addObj(_params)
|
||||
useMessage().success(form.id ? "修改成功" : "添加成功")
|
||||
visible.value = false
|
||||
emit("refresh")
|
||||
|
||||
@@ -1,94 +1,167 @@
|
||||
<template>
|
||||
<div class="layout-padding">
|
||||
<div class="layout-padding-auto layout-padding-view">
|
||||
<el-form ref="queryRef" :inline="true" :model="state.queryForm" @keyup.enter="getDataList">
|
||||
<el-row>
|
||||
<el-form-item style="width: 200px" label="设备名" prop="name">
|
||||
<el-input v-model="state.queryForm.name" placeholder="设备名" />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 200px" label="设备类型" prop="type">
|
||||
<dict-select v-model="state.queryForm.type" dict-code="hard_type" />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 220px" label="商户" prop="merchantIds">
|
||||
<select-mch v-model="state.queryForm.merchantIds" />
|
||||
</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="'hard_hardManager_add'">
|
||||
新 增
|
||||
</el-button>
|
||||
<el-button plain :disabled="multiple" icon="Delete" type="primary" v-auth="'hard_hardManager_del'"
|
||||
@click="handleDelete(selectObjs)">
|
||||
删除
|
||||
</el-button>
|
||||
<right-toolbar v-model:showSearch="showSearch" :export="'hard_hardManager_export'"
|
||||
@exportExcel="exportExcel" class="ml10 mr20" style="float: right"
|
||||
@queryTable="getDataList"></right-toolbar>
|
||||
<div class="layout-padding-auto layout-padding-view hard-manager-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-success)]">{{ currentOnlineCount }} 台在线</span>
|
||||
<span class="mx-1 text-[var(--el-border-color)]">/</span>
|
||||
{{ currentOfflineCount }} 台离线
|
||||
</p>
|
||||
</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="name" width="200" label="设备名" />
|
||||
<el-table-column prop="type" width="100" label="设备类型">
|
||||
<el-button type="primary" icon="Plus" @click="formDialogRef?.openDialog()" v-auth="'hard_hardManager_add'"> 新增设备 </el-button>
|
||||
</header>
|
||||
|
||||
<SearchPanel
|
||||
v-show="showSearch"
|
||||
ref="queryRef"
|
||||
class="hard-manager-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: 250px" label="唯一编号" prop="uniqueCode">
|
||||
<el-input v-model="state.queryForm.uniqueCode" clearable placeholder="请输入设备唯一编号" />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 230px" label="设备类型" prop="type">
|
||||
<dict-select v-model="state.queryForm.type" dict-code="hard_type" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 260px" label="所属商户" prop="merchantIds">
|
||||
<select-mch v-model="state.queryForm.merchantIds" placeholder="请选择商户" />
|
||||
</el-form-item>
|
||||
</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">
|
||||
<el-button
|
||||
plain
|
||||
type="danger"
|
||||
icon="Delete"
|
||||
:disabled="multiple"
|
||||
v-auth="'hard_hardManager_del'"
|
||||
@click="handleDelete(selectObjs)"
|
||||
>
|
||||
批量删除
|
||||
</el-button>
|
||||
<span v-if="selectObjs.length" class="text-sm text-[var(--el-text-color-secondary)]">
|
||||
已选择 {{ selectObjs.length }} 台设备
|
||||
</span>
|
||||
<right-toolbar
|
||||
v-model:showSearch="showSearch"
|
||||
:export="'hard_hardManager_export'"
|
||||
@exportExcel="exportExcel"
|
||||
@queryTable="getDataList"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="state.dataList"
|
||||
v-loading="state.loading"
|
||||
border
|
||||
stripe
|
||||
class="device-table"
|
||||
:cell-style="tableStyle.cellStyle"
|
||||
:header-cell-style="tableStyle.headerCellStyle"
|
||||
row-key="id"
|
||||
@selection-change="selectionChangHandle"
|
||||
@sort-change="sortChangeHandle"
|
||||
@row-dblclick="openDetails"
|
||||
>
|
||||
<el-table-column type="selection" width="42" align="center" />
|
||||
<el-table-column label="设备" min-width="235">
|
||||
<template #default="{ row }">
|
||||
<dict-tag :options="hard_type" :value="row.type" />
|
||||
<div class="device-identity">
|
||||
<button type="button" class="device-name" @click="openDetails(row)">
|
||||
{{ row.name || "未命名设备" }}
|
||||
</button>
|
||||
<div class="device-code" :title="isPrinterAsset(row) ? String(row.id) : row.uniqueCode || ''">
|
||||
<span>{{ isPrinterAsset(row) ? "资产 ID" : "编号" }}</span>
|
||||
<strong>{{ isPrinterAsset(row) ? row.id : row.uniqueCode || "尚未绑定" }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="hardGroupName" width="200" label="分类" />
|
||||
<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 prop="mainTopic" width="350" label="topic" />
|
||||
<el-table-column prop="isTakeFood" width="200" label="是否为当前商户的取餐机器">
|
||||
<el-table-column label="类型 / 分组" min-width="170">
|
||||
<template #default="{ row }">
|
||||
<el-switch :disabled="!useUserInfo().userInfos.roleCodes.includes('ROLE_ADMIN')"
|
||||
:loading="switchTakeFoodLoading" active-text="是" inactive-text="否" active-value="1"
|
||||
inactive-value="0" v-model="row.isTakeFood" @change="changeTakeFood($event, row)" />
|
||||
<div class="cell-stack">
|
||||
<dict-tag :options="hard_type" :value="row.type" />
|
||||
<span>{{ row.hardGroupName || "未分组" }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" width="200" label="创建时间" />
|
||||
<el-table-column v-auths="['hard_hardManager_edit', 'hard_hardManager_del']" label="操作" fixed="right"
|
||||
width="400">
|
||||
<el-table-column label="归属" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.type == '4'" icon="InfoFilled" text type="primary"
|
||||
v-auth="'hard_hardManager_edit'" @click="doInfo(row)">
|
||||
详情
|
||||
</el-button>
|
||||
<el-button icon="edit-pen" text type="primary" v-auth="'hard_hardManager_edit'"
|
||||
@click="formDialogRef.openDialog(row.id)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button v-if="!['10', '11'].includes(row.type)" icon="TrendCharts" link type="primary"
|
||||
v-auth="'hard_hardManager_edit'" @click="mqformDialogRef.openDialog(row.id)">
|
||||
mq设置
|
||||
</el-button>
|
||||
<el-button v-if="!['10', '11'].includes(row.type)" icon="SetUp" link type="primary"
|
||||
v-auth="'hard_hardManager_edit'" @click="faceFormDialogRef.openDialog(row.id)">
|
||||
人脸sdk设置
|
||||
</el-button>
|
||||
<el-button icon="delete" text type="primary" v-auth="'hard_hardManager_del'"
|
||||
@click="handleDelete([row.id])">删除</el-button>
|
||||
<div class="cell-stack">
|
||||
<strong>{{ row.merchantName || "未关联商户" }}</strong>
|
||||
<span>{{ row.deptName || "未关联部门" }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="运行状态" min-width="245" align="center">
|
||||
<template #default="{ row }">
|
||||
<div v-if="isTerminalStatusSupported(row)" class="runtime-cell">
|
||||
<div class="runtime-cell__status">
|
||||
<span class="status-dot" :class="row.online ? 'is-online' : 'is-offline'" aria-hidden="true"></span>
|
||||
<strong>{{ row.online ? "在线" : "离线" }}</strong>
|
||||
<span v-if="row.apkVersionName" class="runtime-version">v{{ row.apkVersionName }}</span>
|
||||
</div>
|
||||
<span>{{ row.lastOnlineTime || "尚未上报心跳" }}</span>
|
||||
</div>
|
||||
<div v-else-if="isPrinterAsset(row)" class="runtime-cell">
|
||||
<strong>无屏 USB 外设</strong>
|
||||
<span>由绑定的业务终端代理</span>
|
||||
</div>
|
||||
<span v-else class="text-sm text-[var(--el-text-color-secondary)]">该类型暂不支持状态上报</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="能力" min-width="250">
|
||||
<template #default="{ row }">
|
||||
<div class="capability-list">
|
||||
<span v-if="supportsMq(row)" class="capability-chip" :class="getConfigState(row, 'mq')">MQ</span>
|
||||
<span v-if="supportsFace(row)" class="capability-chip" :class="getConfigState(row, 'face')">人脸</span>
|
||||
<span v-if="supportsPrinter(row)" class="capability-chip" :class="getConfigState(row, 'printer')">打印</span>
|
||||
<span v-if="isSmartCabinet(row)" class="capability-chip is-ready">智能柜</span>
|
||||
<span v-if="row.isTakeFood === '1'" class="capability-chip is-info">取餐机</span>
|
||||
<span v-if="!hasVisibleCapability(row)" class="text-sm text-[var(--el-text-color-placeholder)]">无扩展配置</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="创建时间" width="175" sortable="custom" />
|
||||
<el-table-column label="操作" fixed="right" width="248" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="row-actions">
|
||||
<el-button link type="primary" icon="View" @click="openDetails(row)">查看详情</el-button>
|
||||
<el-button v-if="canEdit" link type="primary" icon="EditPen" @click="formDialogRef?.openDialog(String(row.id))"
|
||||
>编辑</el-button
|
||||
>
|
||||
<el-button v-if="canDelete" link type="danger" icon="Delete" @click="handleDelete([String(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)" />
|
||||
<face-form-dialog ref="faceFormDialogRef" @refresh="getDataList(false)" />
|
||||
<mq-form-dialog ref="mqformDialogRef" @refresh="getDataList(false)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -97,93 +170,457 @@ import { BasicTableProps, useTable } from "/@/hooks/table"
|
||||
import * as hardManageApi from "/@/api/hard/hard-manager"
|
||||
import { useMessage, useMessageBox } from "/@/hooks/message"
|
||||
import { useDict } from "/@/hooks/dict"
|
||||
import { useUserInfo } from "/@/stores/userInfo"
|
||||
import { auth } from "/@/utils/authFunction"
|
||||
|
||||
const { push } = useRouter()
|
||||
/** 设备分页行,仅声明当前列表实际读取的后端字段。 */
|
||||
interface HardManagerRow {
|
||||
/** 设备雪花主键,路由和接口传输时始终保持字符串形态。 */
|
||||
id: string | number
|
||||
/** 设备展示名称。 */
|
||||
name?: string
|
||||
/** hard_type 字典中的设备类型编码。 */
|
||||
type?: string
|
||||
/** 终端绑定或上报的唯一编号。 */
|
||||
uniqueCode?: string
|
||||
/** 所属硬件分组名称。 */
|
||||
hardGroupName?: string
|
||||
/** 所属商户名称。 */
|
||||
merchantName?: string
|
||||
/** 所属部门名称。 */
|
||||
deptName?: string
|
||||
/** 支持心跳的终端在线状态,不支持的类型返回空值。 */
|
||||
online?: boolean | null
|
||||
/** 服务端最近收到终端心跳的时间。 */
|
||||
lastOnlineTime?: string
|
||||
/** 终端最近上报的 APK 展示版本。 */
|
||||
apkVersionName?: string
|
||||
/** MQTT 服务地址。 */
|
||||
mqSocketAddress?: string
|
||||
/** MQTT 服务端口。 */
|
||||
mqSocketPort?: string
|
||||
/** MQTT 主订阅主题。 */
|
||||
mainTopic?: string
|
||||
/** 人脸 SDK 应用编号。 */
|
||||
faceAppId?: string
|
||||
/** 人脸 SDK 授权密钥。 */
|
||||
faceSdkKey?: string
|
||||
/** 人脸 SDK 激活密钥。 */
|
||||
faceActiveKey?: string
|
||||
/** 业务终端绑定的打印机主键。 */
|
||||
boundPrinterHardId?: string | number
|
||||
/** 打印机资产反向绑定的业务终端主键。 */
|
||||
boundSourceHardId?: string | number
|
||||
/** USB 打印机型号。 */
|
||||
printerModel?: string
|
||||
/** 当前设备是否为商户取餐机,1 是、0 否。 */
|
||||
isTakeFood?: string
|
||||
/** 设备创建时间。 */
|
||||
createTime?: string
|
||||
}
|
||||
|
||||
// 引入组件
|
||||
/** 列表中的配置能力类型,用于统一计算配置完整度。 */
|
||||
type ConfigurationCapability = "mq" | "face" | "printer"
|
||||
|
||||
/** 新增和编辑设备沿用现有业务弹窗。 */
|
||||
const FormDialog = defineAsyncComponent(() => import("./form.vue"))
|
||||
const FaceFormDialog = defineAsyncComponent(() => import("./face-form.vue"))
|
||||
const MqFormDialog = defineAsyncComponent(() => import("./mq-form.vue"))
|
||||
// 定义查询字典
|
||||
/** 设备类型字典用于列表标签展示。 */
|
||||
const { hard_type } = useDict("hard_type")
|
||||
// 定义变量内容
|
||||
const formDialogRef = ref()
|
||||
const faceFormDialogRef = ref()
|
||||
const mqformDialogRef = ref()
|
||||
const switchTakeFoodLoading = ref(false)
|
||||
// 搜索变量
|
||||
/** 页面路由实例用于打开可深链访问的设备详情。 */
|
||||
const router = useRouter()
|
||||
/** 新增、编辑设备弹窗实例。 */
|
||||
const formDialogRef = ref<InstanceType<typeof FormDialog>>()
|
||||
/** 查询面板表单实例。 */
|
||||
const queryRef = ref()
|
||||
/** 查询区域是否展开。 */
|
||||
const showSearch = ref(true)
|
||||
// 多选变量
|
||||
const selectObjs = ref([]) as any
|
||||
const multiple = ref(true)
|
||||
/** 当前批量选中的设备主键,保持后端 Long 的原始字符串精度。 */
|
||||
const selectObjs = ref<string[]>([])
|
||||
/** 未选择设备时禁用批量删除按钮。 */
|
||||
const multiple = computed(() => selectObjs.value.length === 0)
|
||||
/** 支持终端心跳与 APK 状态上报的设备类型编码。 */
|
||||
const terminalStatusSupportedTypes = ["1", "2"]
|
||||
/** 不适用 MQTT 和人脸终端配置的设备类型编码。 */
|
||||
const terminalConfigurationExcludedTypes = ["9", "10", "11", "12"]
|
||||
|
||||
/** 设备列表分页状态与查询函数。 */
|
||||
const state: BasicTableProps = reactive<BasicTableProps>({
|
||||
queryForm: {},
|
||||
pageList: hardManageApi.pageJoin,
|
||||
})
|
||||
|
||||
// table hook
|
||||
/** 列表通用分页、排序、导出和表格样式能力。 */
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, downBlobFile, tableStyle } = useTable(state)
|
||||
/** 当前页在线且支持心跳的设备数量。 */
|
||||
const currentOnlineCount = computed(
|
||||
() => ((state.dataList || []) as HardManagerRow[]).filter((row) => isTerminalStatusSupported(row) && row.online === true).length,
|
||||
)
|
||||
/** 当前页离线且支持心跳的设备数量。 */
|
||||
const currentOfflineCount = computed(
|
||||
() => ((state.dataList || []) as HardManagerRow[]).filter((row) => isTerminalStatusSupported(row) && row.online === false).length,
|
||||
)
|
||||
/** 当前账号是否可以编辑设备。 */
|
||||
const canEdit = computed(() => auth("hard_hardManager_edit"))
|
||||
/** 当前账号是否可以删除设备。 */
|
||||
const canDelete = computed(() => auth("hard_hardManager_del"))
|
||||
|
||||
// 清空搜索条件
|
||||
const resetQuery = () => {
|
||||
// 清空搜索条件
|
||||
/** 判断当前设备是否支持在线、版本和终端日志状态展示。 */
|
||||
function isTerminalStatusSupported(row: HardManagerRow): boolean {
|
||||
return terminalStatusSupportedTypes.includes(String(row.type))
|
||||
}
|
||||
|
||||
/** 判断当前设备是否提供 MQTT 运维配置。 */
|
||||
function supportsMq(row: HardManagerRow): boolean {
|
||||
return !terminalConfigurationExcludedTypes.includes(String(row.type))
|
||||
}
|
||||
|
||||
/** 判断当前设备是否提供人脸 SDK 配置。 */
|
||||
function supportsFace(row: HardManagerRow): boolean {
|
||||
return !terminalConfigurationExcludedTypes.includes(String(row.type))
|
||||
}
|
||||
|
||||
/** 判断当前设备是否为无屏 EY_PRINT 打印机资产。 */
|
||||
function isPrinterAsset(row: HardManagerRow): boolean {
|
||||
return String(row.type) === "12"
|
||||
}
|
||||
|
||||
/** 判断设备是否属于可绑定打印机的业务终端或尔印打印机资产。 */
|
||||
function supportsPrinter(row: HardManagerRow): boolean {
|
||||
return ["1", "2", "12"].includes(String(row.type))
|
||||
}
|
||||
|
||||
/** 判断设备是否为生鲜智能柜。 */
|
||||
function isSmartCabinet(row: HardManagerRow): boolean {
|
||||
return String(row.type) === "4"
|
||||
}
|
||||
|
||||
/** 判断能力区是否至少有一个可见标记。 */
|
||||
function hasVisibleCapability(row: HardManagerRow): boolean {
|
||||
return supportsMq(row) || supportsFace(row) || supportsPrinter(row) || isSmartCabinet(row) || row.isTakeFood === "1"
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算能力标签的配置状态,仅判断后端已有字段是否完整,不展示任何密钥原文。
|
||||
*
|
||||
* @param row 当前设备分页行
|
||||
* @param capability 待检查的配置能力
|
||||
* @returns is-ready 表示关键项完整,is-pending 表示仍需配置
|
||||
*/
|
||||
function getConfigState(row: HardManagerRow, capability: ConfigurationCapability): "is-ready" | "is-pending" {
|
||||
if (capability === "mq") {
|
||||
return row.mqSocketAddress && row.mqSocketPort && row.mainTopic ? "is-ready" : "is-pending"
|
||||
}
|
||||
if (capability === "face") {
|
||||
return row.faceAppId && row.faceSdkKey && row.faceActiveKey ? "is-ready" : "is-pending"
|
||||
}
|
||||
return row.boundPrinterHardId || row.boundSourceHardId || row.printerModel ? "is-ready" : "is-pending"
|
||||
}
|
||||
|
||||
/** 重置查询条件、批量选择和当前分页。 */
|
||||
function resetQuery(): void {
|
||||
queryRef.value?.resetFields()
|
||||
// 清空多选
|
||||
selectObjs.value = []
|
||||
getDataList()
|
||||
}
|
||||
|
||||
const changeTakeFood = async (e: any, row: any) => {
|
||||
switchTakeFoodLoading.value = true
|
||||
/** 导出当前查询条件或已选择设备,避免修改响应式查询对象本身。 */
|
||||
function exportExcel(): void {
|
||||
downBlobFile("/hard/hardManager/export", { ...state.queryForm, ids: selectObjs.value }, "设备列表.xlsx")
|
||||
}
|
||||
|
||||
/** 同步表格选择的设备主键。 */
|
||||
function selectionChangHandle(rows: HardManagerRow[]): void {
|
||||
selectObjs.value = rows.map((row) => String(row.id))
|
||||
}
|
||||
|
||||
/** 打开设备详情页,双击表格行与“查看详情”按钮共用该入口。 */
|
||||
function openDetails(row: HardManagerRow): void {
|
||||
router.push({ path: "/hard/hard-manager/details", query: { id: String(row.id) } })
|
||||
}
|
||||
|
||||
/** 二次确认后删除设备,后端仍会执行打印机绑定保护校验。 */
|
||||
async function handleDelete(ids: string[]): Promise<void> {
|
||||
try {
|
||||
const pa = {
|
||||
id: row.id,
|
||||
merchantId: row.merchantId,
|
||||
isTakeFood: e,
|
||||
}
|
||||
await hardManageApi.putObj(pa)
|
||||
} catch (error: any) {
|
||||
console.error(error)
|
||||
useMessage().error(error.msg)
|
||||
} finally {
|
||||
switchTakeFoodLoading.value = false
|
||||
getDataList()
|
||||
}
|
||||
}
|
||||
|
||||
// 导出excel
|
||||
const exportExcel = () => {
|
||||
downBlobFile("/hard/hardManager/export", Object.assign(state.queryForm, { ids: selectObjs }), "设备列表.xlsx")
|
||||
}
|
||||
|
||||
// 多选事件
|
||||
const selectionChangHandle = (objs: { id: string }[]) => {
|
||||
selectObjs.value = objs.map(({ id }) => id)
|
||||
multiple.value = !objs.length
|
||||
}
|
||||
|
||||
// 硬件详情
|
||||
const doInfo = (row: any) => {
|
||||
push({ path: "/hard/hard-door-manager/index", query: { id: row.id } })
|
||||
}
|
||||
|
||||
// 删除操作
|
||||
const handleDelete = async (ids: string[]) => {
|
||||
try {
|
||||
await useMessageBox().confirm("此操作将永久删除")
|
||||
await useMessageBox().confirm("确认删除选中的 " + ids.length + " 台设备?删除后无法恢复。")
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await hardManageApi.delObjs(ids)
|
||||
getDataList()
|
||||
useMessage().success("删除成功")
|
||||
} catch (err: any) {
|
||||
useMessage().error(err.msg)
|
||||
selectObjs.value = []
|
||||
getDataList()
|
||||
} catch (error: any) {
|
||||
useMessage().error(error?.msg || "删除失败")
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.hard-manager-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);
|
||||
|
||||
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;
|
||||
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(.hard-manager-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(.hard-manager-search .search-panel__header) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
:deep(.hard-manager-search .search-panel__row) {
|
||||
column-gap: 10px;
|
||||
}
|
||||
|
||||
:deep(.hard-manager-search .el-form-item) {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.device-identity,
|
||||
.cell-stack,
|
||||
.runtime-cell {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
line-height: 20px;
|
||||
|
||||
> span,
|
||||
.runtime-version {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.device-name {
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--el-color-primary);
|
||||
font: inherit;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.device-code {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
|
||||
> span {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
strong {
|
||||
overflow: hidden;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.runtime-cell {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.runtime-cell__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex: none;
|
||||
border-radius: 50%;
|
||||
background: var(--el-text-color-placeholder);
|
||||
|
||||
&.is-online {
|
||||
background: var(--el-color-success);
|
||||
box-shadow: 0 0 0 3px var(--el-color-success-light-9);
|
||||
}
|
||||
|
||||
&.is-offline {
|
||||
background: var(--el-color-danger);
|
||||
box-shadow: 0 0 0 3px var(--el-color-danger-light-9);
|
||||
}
|
||||
}
|
||||
|
||||
.capability-list {
|
||||
display: flex;
|
||||
min-height: 26px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.capability-chip {
|
||||
display: inline-flex;
|
||||
height: 24px;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 4px;
|
||||
background: var(--el-fill-color-light);
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 22px;
|
||||
|
||||
&.is-ready {
|
||||
border-color: var(--el-color-success-light-7);
|
||||
background: var(--el-color-success-light-9);
|
||||
color: var(--el-color-success-dark-2);
|
||||
}
|
||||
|
||||
&.is-pending {
|
||||
border-style: dashed;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
&.is-info {
|
||||
border-color: var(--el-color-primary-light-7);
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
|
||||
:deep(.el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@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>
|
||||
@@ -0,0 +1,289 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" :title="dialogTitle" width="560px" draggable :close-on-click-modal="false">
|
||||
<el-form v-if="mode === 'bind'" ref="bindFormRef" :model="bindForm" label-width="120px">
|
||||
<el-form-item label="业务终端">
|
||||
<el-input :model-value="sourceName" disabled />
|
||||
</el-form-item>
|
||||
<el-alert class="mb-5" type="info" :closable="false" show-icon>
|
||||
<template #title
|
||||
>此处建立“业务终端 → 尔印 USB 打印机”的一对一关系。打印机没有独立屏幕、账号和心跳;绑定的食堂或超市终端负责发现 USB
|
||||
设备、接收任务、打印并回传结果。</template
|
||||
>
|
||||
</el-alert>
|
||||
<el-form-item label="尔印打印机" prop="printerHardId" :rules="[{ required: true, message: '请选择打印机' }]">
|
||||
<el-select
|
||||
v-model="bindForm.printerHardId"
|
||||
class="w-full"
|
||||
filterable
|
||||
:loading="loading"
|
||||
placeholder="请选择同商户同部门的空闲打印机"
|
||||
>
|
||||
<el-option
|
||||
v-for="printer in availablePrinters"
|
||||
:key="printer.id"
|
||||
:label="`${printer.name || '未命名打印机'}(ID:${printer.id})`"
|
||||
:value="String(printer.id)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<p v-if="!loading && availablePrinters.length === 0" class="text-sm text-[var(--el-text-color-secondary)]">
|
||||
当前部门没有可绑定的空闲尔印打印机资产,请先新增并分配商户部门。
|
||||
</p>
|
||||
</el-form>
|
||||
<el-form v-else ref="configFormRef" :model="configForm" :rules="configRules" label-width="150px">
|
||||
<el-alert class="mb-5" type="info" :closable="false" show-icon>
|
||||
<template #title
|
||||
>推荐在已绑定的食堂或超市终端打开“打印机信息”,点击“上报当前设备”自动回填型号、VID 和
|
||||
PID;管理端手填仅用于现场无法上报时。</template
|
||||
>
|
||||
</el-alert>
|
||||
<el-form-item prop="printerModel">
|
||||
<template #label>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
打印机型号
|
||||
<el-tooltip
|
||||
content="用于运维人员识别实际 USB 打印机。优先由绑定的食堂或超市终端自动上报;手工填写示例:GEZHI / micro-printer。"
|
||||
placement="top"
|
||||
popper-class="max-w-[360px] leading-5"
|
||||
>
|
||||
<el-icon class="cursor-help text-[var(--el-text-color-secondary)]" tabindex="0" aria-label="打印机型号填写说明"
|
||||
><InfoFilled
|
||||
/></el-icon>
|
||||
</el-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
<el-input v-model="configForm.printerModel" placeholder="推荐由绑定的业务终端自动上报" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="usbVendorId">
|
||||
<template #label>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
USB VID
|
||||
<el-tooltip
|
||||
content="USB 厂商编号,管理端填写十进制值,范围 0~65535。当前联调设备识别为 10473(十六进制 0x28E9);不确定时请勿猜测,使用终端自动上报。"
|
||||
placement="top"
|
||||
popper-class="max-w-[360px] leading-5"
|
||||
>
|
||||
<el-icon class="cursor-help text-[var(--el-text-color-secondary)]" tabindex="0" aria-label="USB VID 填写说明"
|
||||
><InfoFilled
|
||||
/></el-icon>
|
||||
</el-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
<el-input-number
|
||||
v-model="configForm.usbVendorId"
|
||||
:min="0"
|
||||
:max="65535"
|
||||
controls-position="right"
|
||||
class="w-full"
|
||||
placeholder="例如 10473"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="usbProductId">
|
||||
<template #label>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
USB PID
|
||||
<el-tooltip
|
||||
content="USB 产品编号,管理端填写十进制值,必须与 VID 同时填写或同时留空。当前联调设备识别为 649(十六进制 0x0289);推荐使用终端自动上报。"
|
||||
placement="top"
|
||||
popper-class="max-w-[360px] leading-5"
|
||||
>
|
||||
<el-icon class="cursor-help text-[var(--el-text-color-secondary)]" tabindex="0" aria-label="USB PID 填写说明"
|
||||
><InfoFilled
|
||||
/></el-icon>
|
||||
</el-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
<el-input-number
|
||||
v-model="configForm.usbProductId"
|
||||
:min="0"
|
||||
:max="65535"
|
||||
controls-position="right"
|
||||
class="w-full"
|
||||
placeholder="例如 649"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="paperWidth">
|
||||
<template #label>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
纸宽
|
||||
<el-tooltip
|
||||
content="首期只支持 80mm 热敏纸,对应后端图片宽度 576px。此项固定为 80,无需修改。"
|
||||
placement="top"
|
||||
popper-class="max-w-[360px] leading-5"
|
||||
>
|
||||
<el-icon class="cursor-help text-[var(--el-text-color-secondary)]" tabindex="0" aria-label="纸宽配置说明"
|
||||
><InfoFilled
|
||||
/></el-icon>
|
||||
</el-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
<el-input-number v-model="configForm.paperWidth" :min="80" :max="80" controls-position="right" class="w-full" />
|
||||
<span class="ml-2">mm(首期固定)</span>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
自动切刀
|
||||
<el-tooltip
|
||||
content="打印完成后发送切纸命令。打印机带自动切刀时保持开启;若型号没有切刀,或测试打印在切纸阶段报错,再关闭此项。"
|
||||
placement="top"
|
||||
popper-class="max-w-[360px] leading-5"
|
||||
>
|
||||
<el-icon class="cursor-help text-[var(--el-text-color-secondary)]" tabindex="0" aria-label="自动切刀配置说明"
|
||||
><InfoFilled
|
||||
/></el-icon>
|
||||
</el-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
<el-switch v-model="configForm.autoCut" active-value="1" inactive-value="0" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
图片指令模式
|
||||
<el-tooltip
|
||||
content="控制 PNG 图片转换成哪种 ESC/POS 指令。优先使用 GS v 0;只有真机测试出现白纸、缺行或图片不完整时,才切换 ESC * 兼容模式并重新测试。"
|
||||
placement="top"
|
||||
popper-class="max-w-[360px] leading-5"
|
||||
>
|
||||
<el-icon class="cursor-help text-[var(--el-text-color-secondary)]" tabindex="0" aria-label="图片指令模式说明"
|
||||
><InfoFilled
|
||||
/></el-icon>
|
||||
</el-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
<el-radio-group v-model="configForm.imageMode"
|
||||
><el-radio label="GS_V0">GS v 0(默认)</el-radio><el-radio label="ESC_STAR">ESC *(兼容回退)</el-radio></el-radio-group
|
||||
>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="loading" :disabled="mode === 'bind' && availablePrinters.length === 0" @click="submit"
|
||||
>保存</el-button
|
||||
>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="HardPrinterDialog">
|
||||
import { InfoFilled } from "@element-plus/icons-vue"
|
||||
import * as printerApi from "/@/api/hard/hard-printer"
|
||||
import { useMessage } from "/@/hooks/message"
|
||||
|
||||
const emit = defineEmits(["refresh"])
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const mode = ref<"bind" | "config">("bind")
|
||||
const sourceName = ref("")
|
||||
/** 来源业务终端雪花 ID,始终按字符串传递以保留完整精度。 */
|
||||
const sourceHardId = ref<printerApi.HardwareId | null>(null)
|
||||
/** 目标打印机资产雪花 ID,始终按字符串传递以保留完整精度。 */
|
||||
const printerHardId = ref<printerApi.HardwareId | null>(null)
|
||||
const availablePrinters = ref<any[]>([])
|
||||
const bindFormRef = ref()
|
||||
const configFormRef = ref()
|
||||
/** 当前待提交的一对一打印机绑定。 */
|
||||
const bindForm = reactive<{ printerHardId: printerApi.HardwareId | null }>({ printerHardId: null })
|
||||
const configForm = reactive<printerApi.HardPrinterConfig>({
|
||||
printerHardId: "",
|
||||
printerModel: "",
|
||||
usbVendorId: undefined,
|
||||
usbProductId: undefined,
|
||||
paperWidth: 80,
|
||||
autoCut: "1",
|
||||
imageMode: "GS_V0",
|
||||
})
|
||||
const validateUsbPair = (_rule: unknown, _value: unknown, callback: (error?: Error) => void) => {
|
||||
const hasVid = configForm.usbVendorId !== undefined && configForm.usbVendorId !== null
|
||||
const hasPid = configForm.usbProductId !== undefined && configForm.usbProductId !== null
|
||||
callback(hasVid === hasPid ? undefined : new Error("USB VID 和 PID 必须同时填写或同时留空"))
|
||||
}
|
||||
const configRules = {
|
||||
usbVendorId: [{ validator: validateUsbPair, trigger: "change" }],
|
||||
usbProductId: [{ validator: validateUsbPair, trigger: "change" }],
|
||||
paperWidth: [{ required: true, message: "纸宽必须为80mm", trigger: "change" }],
|
||||
}
|
||||
const dialogTitle = computed(() => (mode.value === "bind" ? "绑定尔印打印机" : "打印机配置"))
|
||||
|
||||
/** 恢复打印配置默认值,避免连续打开不同设备时残留上一台打印机的参数。 */
|
||||
const resetConfig = (hardId: printerApi.HardwareId) => {
|
||||
Object.assign(configForm, {
|
||||
id: undefined,
|
||||
printerHardId: hardId,
|
||||
printerModel: "",
|
||||
usbVendorId: undefined,
|
||||
usbProductId: undefined,
|
||||
paperWidth: 80,
|
||||
autoCut: "1",
|
||||
imageMode: "GS_V0",
|
||||
})
|
||||
}
|
||||
|
||||
/** 打开来源终端绑定弹窗,并使用未经过数值转换的雪花 ID 查询候选打印机。 */
|
||||
const openBind = async (row: any) => {
|
||||
mode.value = "bind"
|
||||
const hardId = String(row.id)
|
||||
sourceHardId.value = hardId
|
||||
sourceName.value = row.name || `设备 ${row.id}`
|
||||
bindForm.printerHardId = null
|
||||
visible.value = true
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await printerApi.listAvailable(hardId)
|
||||
availablePrinters.value = response.data || []
|
||||
} catch (error: any) {
|
||||
availablePrinters.value = []
|
||||
useMessage().error(error?.msg || "读取可绑定打印机失败")
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开 EY_PRINT 参数弹窗,并保持目标设备雪花 ID 的字符串形态。 */
|
||||
const openConfig = async (row: any) => {
|
||||
mode.value = "config"
|
||||
const hardId = String(row.id)
|
||||
printerHardId.value = hardId
|
||||
resetConfig(hardId)
|
||||
visible.value = true
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await printerApi.getConfig(hardId)
|
||||
Object.assign(configForm, response.data || {}, { printerHardId: hardId })
|
||||
} catch (error: any) {
|
||||
useMessage().error(error?.msg || "读取打印机配置失败")
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 校验并提交绑定或配置,设备主键保持字符串以便后端无损转换为 Long。 */
|
||||
const submit = async () => {
|
||||
const formRef = mode.value === "bind" ? bindFormRef.value : configFormRef.value
|
||||
const valid = await formRef?.validate?.().catch(() => false)
|
||||
if (valid === false) return
|
||||
loading.value = true
|
||||
try {
|
||||
if (mode.value === "bind") {
|
||||
if (!sourceHardId.value || !bindForm.printerHardId) {
|
||||
useMessage().warning("请选择尔印打印机")
|
||||
return
|
||||
}
|
||||
await printerApi.bind({ sourceHardId: sourceHardId.value, printerHardId: bindForm.printerHardId })
|
||||
useMessage().success("绑定成功,请在该业务终端的“打印机信息”页面刷新并上报 USB 设备")
|
||||
} else {
|
||||
await printerApi.saveConfig({ ...configForm, printerHardId: printerHardId.value || configForm.printerHardId })
|
||||
useMessage().success("配置保存成功")
|
||||
}
|
||||
visible.value = false
|
||||
emit("refresh")
|
||||
} catch (error: any) {
|
||||
useMessage().error(error?.msg || "保存失败")
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ openBind, openConfig })
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div class="layout-padding">
|
||||
<div class="layout-padding-auto layout-padding-view">
|
||||
<el-row>
|
||||
<SearchPanel
|
||||
v-show="showSearch"
|
||||
ref="queryRef"
|
||||
: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: 300px" label="设备名称" prop="hardName">
|
||||
<el-input v-model="state.queryForm.hardName" />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 300px" label="设备唯一编号" prop="uniqueCode">
|
||||
<el-input v-model="state.queryForm.uniqueCode" />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 450px" label="创建时间" prop="createTimeList">
|
||||
<el-date-picker v-model="state.queryForm.createTimeList" value-format="YYYY-MM-DD HH:mm:ss" type="datetimerange" />
|
||||
</el-form-item>
|
||||
|
||||
<template #more>
|
||||
<el-form-item style="width: 300px" label="设备类型" prop="hardType">
|
||||
<dict-select v-model="state.queryForm.hardType" dict-code="hard_type" />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 300px" label="简介" prop="title">
|
||||
<el-input v-model="state.queryForm.title" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</SearchPanel>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<div class="mb8" style="width: 100%">
|
||||
<right-toolbar v-model:showSearch="showSearch" :export="false" @exportExcel="exportExcel" class="ml10 mr20"
|
||||
style="float: right;" @queryTable="getDataList"></right-toolbar>
|
||||
</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="40" />
|
||||
<el-table-column prop="hardName" label="设备名称" width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="uniqueCode" label="设备唯一编号" width="120" />
|
||||
<el-table-column prop="hardType" label="设备类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<dict-tag :options="hard_type" :value="row.hardType" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="简介" width="250" show-overflow-tooltip />
|
||||
<el-table-column prop="headers" label="headers" width="180">
|
||||
<template #default="{ row }">
|
||||
<preview-json :json="row.headers" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="res" label="res" width="180">
|
||||
<template #default="{ row }">
|
||||
<preview-json :json="row.res" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="req" label="req" width="180">
|
||||
<template #default="{ row }">
|
||||
<preview-json :json="row.req" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="startTime" label="请求时间">
|
||||
<el-table-column prop="startTime" label="开始时间" width="180" />
|
||||
<el-table-column prop="endTime" label="结束时间" width="180" />
|
||||
<el-table-column prop="cost" label="耗时(秒)" width="100">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.cost }}毫秒</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="state.pagination" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="systemHardXfjLog">
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table";
|
||||
import * as hardXfjLogApi from "/@/api/hard/hard-xfj-log";
|
||||
import { useMessage, useMessageBox } from "/@/hooks/message";
|
||||
import { useDict } from '/@/hooks/dict';
|
||||
|
||||
// 定义查询字典
|
||||
const { hard_type } = useDict("hard_type")
|
||||
// 定义变量内容
|
||||
|
||||
// 搜索变量
|
||||
const queryRef = ref()
|
||||
const showSearch = ref(true)
|
||||
// 多选变量
|
||||
const selectObjs = ref([]) as any
|
||||
const multiple = ref(true)
|
||||
|
||||
const state: BasicTableProps = reactive<BasicTableProps>({
|
||||
queryForm: {},
|
||||
pageList: hardXfjLogApi.fetchList
|
||||
})
|
||||
|
||||
// table hook
|
||||
const {
|
||||
getDataList,
|
||||
currentChangeHandle,
|
||||
sizeChangeHandle,
|
||||
sortChangeHandle,
|
||||
downBlobFile,
|
||||
tableStyle
|
||||
} = useTable(state)
|
||||
|
||||
// 清空搜索条件
|
||||
const resetQuery = () => {
|
||||
// 清空搜索条件
|
||||
queryRef.value?.resetFields()
|
||||
// 清空多选
|
||||
selectObjs.value = []
|
||||
getDataList()
|
||||
}
|
||||
|
||||
// 导出excel
|
||||
const exportExcel = () => {
|
||||
downBlobFile('/hard/hardXfjLog/export', Object.assign(state.queryForm, { ids: selectObjs }), 'hardXfjLog.xlsx')
|
||||
}
|
||||
|
||||
// 多选事件
|
||||
const selectionChangHandle = (objs: { id: string }[]) => {
|
||||
selectObjs.value = objs.map(({ id }) => id);
|
||||
multiple.value = !objs.length;
|
||||
};
|
||||
|
||||
// 删除操作
|
||||
const handleDelete = async (ids: string[]) => {
|
||||
try {
|
||||
await useMessageBox().confirm('此操作将永久删除');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await hardXfjLogApi.delObjs(ids);
|
||||
getDataList();
|
||||
useMessage().success('删除成功');
|
||||
} catch (err: any) {
|
||||
useMessage().error(err.msg);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
Reference in new issue
Block a user