refactor(device): 设备列表改用上游 Crud 体系,移除 3932 行本地分叉实现

列表、搜索、新增、编辑全部改为 CrudPage/CrudSearch/CrudForm 配置驱动,在线状态通过独立响应式字典接收 SSE 推送。
This commit is contained in:
2026-11-05 09:37:59 +08:00
parent 01f42ab21b
commit 9f0d5f37a1
12 changed files with 247 additions and 2255 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,274 +0,0 @@
<template>
<div>
<!-- drawer -->
<template v-if="modalConfig.component === 'drawer'">
<el-drawer
v-model="modalVisible"
v-bind="{ destroyOnClose: true, ...modalConfig.drawer }"
@close="handleClose"
>
<el-form ref="formRef" v-bind="modalConfig.form" :model="formData" :rules="formRules">
<el-row :gutter="20">
<template v-for="item in formItems" :key="item.prop">
<el-col v-show="!item.hidden" v-bind="item.col">
<el-form-item :label="item.label" :prop="item.prop">
<!-- Label -->
<template #label>
<span>
{{ item?.label || "" }}
<el-tooltip v-if="item?.tips" v-bind="getTooltipProps(item.tips)">
<QuestionFilled class="w-4 h-4 mx-1" />
</el-tooltip>
<span v-if="modalConfig.colon" class="ml-0.5">:</span>
</span>
</template>
<!-- components -->
<template v-if="item.type === 'custom'">
<slot
:name="item.slotName ?? item.prop"
:prop="item.prop"
:form-data="formData"
:attrs="item.attrs"
></slot>
</template>
<component
:is="componentMap.get(item.type)"
v-else
v-model.trim="formData[item.prop]"
v-bind="{ style: { width: '100%' }, ...item.attrs }"
>
<template v-if="['select', 'radio', 'checkbox'].includes(item.type)">
<component
:is="childrenMap.get(item.type)"
v-for="opt in item.options"
:key="opt.value"
:label="opt.label"
:value="opt.value"
></component>
</template>
<template v-if="item?.slotName && $slots[item.slotName]" #[item.slotName]>
<slot :name="item.slotName" />
</template>
</component>
</el-form-item>
</el-col>
</template>
</el-row>
</el-form>
<template #footer>
<el-button v-if="!formDisable" type="primary" @click="handleSubmit">确定</el-button>
<el-button @click="handleClose">关闭</el-button>
</template>
</el-drawer>
</template>
<!-- dialog -->
<template v-else>
<el-dialog
v-model="modalVisible"
v-bind="{ destroyOnClose: true, alignCenter: true, ...modalConfig.dialog }"
@close="handleClose"
>
<el-form ref="formRef" v-bind="modalConfig.form" :model="formData" :rules="formRules">
<el-scrollbar max-height="70vh" :view-style="{ overflowX: 'hidden' }">
<el-row :gutter="20">
<template v-for="item in formItems" :key="item.prop">
<el-col v-show="!item.hidden" v-bind="item.col">
<el-form-item :label="item.label" :prop="item.prop">
<template #label>
<span>
{{ item?.label || "" }}
<el-tooltip v-if="item?.tips" v-bind="getTooltipProps(item.tips)">
<QuestionFilled class="w-4 h-4 mx-1" />
</el-tooltip>
<span v-if="modalConfig.colon" class="ml-0.5">:</span>
</span>
</template>
<template v-if="item.type === 'custom'">
<slot
:name="item.slotName ?? item.prop"
:prop="item.prop"
:form-data="formData"
:attrs="item.attrs"
></slot>
</template>
<component
:is="componentMap.get(item.type)"
v-else
v-model.trim="formData[item.prop]"
v-bind="{ style: { width: '100%' }, ...item.attrs }"
>
<template v-if="['select', 'radio', 'checkbox'].includes(item.type)">
<component
:is="childrenMap.get(item.type)"
v-for="opt in item.options"
:key="opt.value"
:label="opt.label"
:value="opt.value"
></component>
</template>
<template v-if="item?.slotName && $slots[item.slotName]" #[item.slotName]>
<slot :name="item.slotName" />
</template>
</component>
</el-form-item>
</el-col>
</template>
</el-row>
<slot name="bottom" :form-data="formData"></slot>
</el-scrollbar>
</el-form>
<template #footer>
<el-button v-if="!formDisable" type="primary" @click="handleSubmit">确定</el-button>
<el-button @click="handleClose">关闭</el-button>
</template>
</el-dialog>
</template>
</div>
</template>
<script setup lang="ts">
import { useThrottleFn } from "@vueuse/core";
import cloneDeep from "lodash-es/cloneDeep";
import type { FormInstance, FormRules } from "element-plus";
import type { IComponentType, IModalConfig, IObject } from "./types";
import InputTag from "@/components/InputTag/index.vue";
import IconSelect from "@/components/IconSelect/index.vue";
defineSlots<{ [key: string]: (_args: any) => any }>();
// 定义接收的属性
const props = defineProps<{ modalConfig: IModalConfig }>();
// 自定义事件
const emit = defineEmits<{ submitClick: []; customSubmit: [queryParams: IObject] }>();
// 组件映射
const componentMap = new Map<IComponentType, any>([
// @ts-ignore
["input", markRaw(ElInput)], // @ts-ignore
["select", markRaw(ElSelect)], // @ts-ignore
["switch", markRaw(ElSwitch)], // @ts-ignore
["cascader", markRaw(ElCascader)], // @ts-ignore
["input-number", markRaw(ElInputNumber)], // @ts-ignore
["input-tag", markRaw(InputTag)], // @ts-ignore
["time-picker", markRaw(ElTimePicker)], // @ts-ignore
["time-select", markRaw(ElTimeSelect)], // @ts-ignore
["date-picker", markRaw(ElDatePicker)], // @ts-ignore
["tree-select", markRaw(ElTreeSelect)], // @ts-ignore"
["custom-tag", markRaw(InputTag)], // @ts-ignore
["text", markRaw(ElText)], // @ts-ignore
["radio", markRaw(ElRadioGroup)], // @ts-ignore"
["checkbox", markRaw(ElCheckboxGroup)], // @ts-ignore"
["icon-select", markRaw(IconSelect)], // @ts-ignore"
["custom", ""],
]);
const childrenMap = new Map<IComponentType, any>([
// @ts-ignore
["select", markRaw(ElOption)], // @ts-ignore
["radio", markRaw(ElRadio)], // @ts-ignore"
["checkbox", markRaw(ElCheckbox)],
]);
const pk = props.modalConfig.pk ?? "id"; // 主键名,用于表单数据处理
const modalVisible = ref(false); // 弹窗显示状态"
const formRef = ref<FormInstance>(); // 表单实例
const formItems = reactive(props.modalConfig.formItems ?? []); // 表单配置项"
const formData = reactive<IObject>({}); // 表单数据
const formRules: FormRules = {}; // 表单验证规则
const formDisable = ref(false); // 表单禁用状态"
// 获取 tooltip 提示框属性
const getTooltipProps = (tips: string | IObject) => {
return typeof tips === "string" ? { content: tips } : tips;
};
// 隐藏弹窗
const handleClose = () => {
modalVisible.value = false;
formRef.value?.resetFields();
};
// 设置表单项
const setFormData = (data: IObject) => {
for (const key in formData) {
if (Object.prototype.hasOwnProperty.call(formData, key) && key in data) {
formData[key] = cloneDeep(data[key]);
}
}
if (Object.prototype.hasOwnProperty.call(data, pk)) {
formData[pk] = cloneDeep(data[pk]);
}
};
// 表单提交
const handleSubmit = useThrottleFn(() => {
formRef.value?.validate((valid: boolean) => {
if (!valid) return;
if (typeof props.modalConfig.beforeSubmit === "function") {
props.modalConfig.beforeSubmit(formData);
}
if (!props.modalConfig?.formAction) {
emit("customSubmit", formData);
handleClose();
return;
}
props.modalConfig.formAction(formData).then(() => {
if (props.modalConfig.component === "drawer") {
ElMessage.success(`${props.modalConfig.drawer?.title}成功`);
} else {
ElMessage.success(`${props.modalConfig.dialog?.title}成功`);
}
emit("submitClick");
handleClose();
});
});
}, 3000);
onMounted(() => {
formItems.forEach((item) => {
if (item.initFn) {
item.initFn(item);
}
formRules[item.prop] = item?.rules ?? [];
props.modalConfig.form = { labelWidth: "auto", ...props.modalConfig?.form };
if (["input-tag", "custom-tag", "cascader"].includes(item.type)) {
formData[item.prop] = Array.isArray(item.initialValue) ? item.initialValue : [];
} else if (item.type === "input-number") {
formData[item.prop] = item.initialValue ?? null;
} else {
formData[item.prop] = item.initialValue ?? "";
}
});
});
// 暴露的属性和方法
defineExpose({
setFormData,
// 展示/隐藏 modal
setModalVisible: (visible: boolean = true) => (modalVisible.value = visible),
// 获取表单数据
getFormData: (key: string) => formData[key] ?? formData,
// 设置表单项
setFormItemData: (key: string, value: any) => (formData[key] = value),
// 禁用表单
handleDisabled: (disable: boolean) => {
formDisable.value = disable;
props.modalConfig.form = {
...props.modalConfig.form,
disabled: disable,
};
},
});
</script>
<style lang="scss" scoped>
:deep(.el-input-number .el-input__inner) {
text-align: left;
}
:deep(.el-input-number.is-without-controls .el-input__wrapper) {
padding-right: 11px !important ;
padding-left: 11px !important;
}
</style>

View File

@@ -1,169 +0,0 @@
<template>
<div v-show="visible">
<el-card v-bind="cardAttrs">
<el-form ref="queryFormRef" :model="queryParams" v-bind="formAttrs" :class="isGrid">
<template v-for="(item, index) in formItems" :key="item.prop">
<el-form-item
v-show="isExpand ? true : index < showNumber"
:label="item?.label"
:prop="item.prop"
>
<!-- Label -->
<template #label>
<span class="flex-y-center">
{{ item?.label || "" }}
<el-tooltip v-if="item?.tips" v-bind="getTooltipProps(item.tips)">
<QuestionFilled class="w-4 h-4 mx-1" />
</el-tooltip>
<span v-if="searchConfig.colon" class="ml-0.5">:</span>
</span>
</template>
<!-- 自定义插槽 -->
<slot
v-if="item.type === 'custom'"
:name="item.slotName"
:form-data="queryParams"
:prop="item.prop"
:attrs="{ style: { width: '100%' }, ...item.attrs }"
/>
<el-cascader
v-else-if="item.type === 'cascader'"
v-model.trim="queryParams[item.prop]"
v-bind="{ style: { width: '100%' }, ...item.attrs }"
v-on="item.events || {}"
/>
<component
:is="componentMap.get(item.type)"
v-else
v-model.trim="queryParams[item.prop]"
v-bind="{ style: { width: '100%' }, ...item.attrs }"
v-on="item.events || {}"
>
<template v-if="item.type === 'select'">
<template v-for="opt in item.options" :key="opt.value">
<el-option :label="opt.label" :value="opt.value" />
</template>
</template>
</component>
</el-form-item>
</template>
<el-form-item :class="{ 'col-[auto/-1] justify-self-end': searchConfig?.grid === 'right' }">
<el-button icon="search" type="primary" @click="handleQuery">搜索</el-button>
<el-button icon="refresh" @click="handleReset">重置</el-button>
<!-- 展开/收起 -->
<template v-if="isExpandable && formItems.length > showNumber">
<el-link class="ml-3" type="primary" underline="never" @click="isExpand = !isExpand">
{{ isExpand ? "收起" : "展开" }}
<component :is="isExpand ? ArrowUp : ArrowDown" class="w-4 h-4 ml-2" />
</el-link>
</template>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import type { IObject, IForm, ISearchConfig, ISearchComponent } from "./types";
import { ArrowUp, ArrowDown } from "@element-plus/icons-vue";
import type { FormInstance } from "element-plus";
import InputTag from "@/components/InputTag/index.vue";
// 定义接收的属性
const props = defineProps<{ searchConfig: ISearchConfig }>();
// 自定义事件
const emit = defineEmits<{
queryClick: [queryParams: IObject];
resetClick: [queryParams: IObject];
}>();
// 组件映射表
const componentMap = new Map<ISearchComponent, any>([
// @ts-ignore
["input", markRaw(ElInput)], // @ts-ignore
["select", markRaw(ElSelect)], // @ts-ignore
["cascader", markRaw(ElCascader)], // @ts-ignore
["input-number", markRaw(ElInputNumber)], // @ts-ignore
["date-picker", markRaw(ElDatePicker)], // @ts-ignore
["time-picker", markRaw(ElTimePicker)], // @ts-ignore
["time-select", markRaw(ElTimeSelect)], // @ts-ignore
["tree-select", markRaw(ElTreeSelect)], // @ts-ignore
["input-tag", markRaw(ElInputTag)], // @ts-ignore
["custom-tag", markRaw(InputTag)],
]);
// 存储表单实例
const queryFormRef = ref<FormInstance>();
// 存储查询参数
const queryParams = reactive<IObject>({});
// 是否显示
const visible = ref(true);
// 响应式的formItems
const formItems = reactive(props.searchConfig?.formItems ?? []);
// 是否可展开/收缩
const isExpandable = ref(props.searchConfig?.isExpandable ?? true);
// 是否已展开
const isExpand = ref(false);
// 表单项展示数量,若可展开,超出展示数量的表单项隐藏
const showNumber = computed(() =>
isExpandable.value ? (props.searchConfig?.showNumber ?? 3) : formItems.length,
);
// 卡片组件自定义属性(阴影、自定义边距样式等)
const cardAttrs = computed<IObject>(() => {
return { shadow: "never", style: { "margin-bottom": "12px" }, ...props.searchConfig?.cardAttrs };
});
// 表单组件自定义属性(label位置、宽度、对齐方式等)
const formAttrs = computed<IForm>(() => {
return { inline: true, ...props.searchConfig?.form };
});
// 是否使用自适应网格布局
const isGrid = computed(() =>
props.searchConfig?.grid
? "grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 4xl:grid-cols-6 gap-5"
: "flex flex-wrap gap-x-8 gap-y-4",
);
// 获取tooltip提示框属性
const getTooltipProps = (tips: string | IObject) => {
return typeof tips === "string" ? { content: tips } : tips;
};
// 查询/重置操作
const handleQuery = () => emit("queryClick", queryParams);
const handleReset = () => {
queryFormRef.value?.resetFields();
emit("resetClick", queryParams);
};
onMounted(() => {
formItems.forEach((item) => {
if (item?.initFn) {
item.initFn(item);
}
if (["input-tag", "custom-tag", "cascader"].includes(item?.type ?? "")) {
queryParams[item.prop] = Array.isArray(item.initialValue) ? item.initialValue : [];
} else if (item.type === "input-number") {
queryParams[item.prop] = item.initialValue ?? null;
} else {
queryParams[item.prop] = item.initialValue ?? "";
}
});
});
// 暴露的属性和方法
defineExpose({
// 获取分页数据
getQueryParams: () => queryParams,
// 显示/隐藏 SearchForm
toggleVisible: () => (visible.value = !visible.value),
});
</script>
<style lang="scss" scoped>
:deep(.el-input-number .el-input__inner) {
text-align: left;
}
.el-form-item {
margin-right: 0;
margin-bottom: 0;
}
</style>

View File

@@ -1,222 +0,0 @@
import type { DialogProps, DrawerProps, FormItemRule, PaginationProps } from "element-plus";
import type { FormProps, TableProps, ColProps, ButtonProps, CardProps } from "element-plus";
import type PageContent from "./DevicePageContent.vue";
import type PageModal from "./DevicePageModal.vue";
import type PageSearch from "./DevicePageSearch.vue";
import type SnDetail from "./DeviceSnDetail.vue";
import type { CSSProperties } from "vue";
import type { PageResult } from "@/api/common";
export type PageSearchInstance = InstanceType<typeof PageSearch>;
export type PageContentInstance = InstanceType<typeof PageContent>;
export type PageModalInstance = InstanceType<typeof PageModal>;
export type SnDetailInstance = InstanceType<typeof SnDetail>;
export type IObject = Record<string, any>;
type DateComponent = "date-picker" | "time-picker" | "time-select" | "custom-tag" | "input-tag";
type InputComponent = "input" | "select" | "input-number" | "cascader" | "tree-select";
type OtherComponent = "text" | "radio" | "checkbox" | "switch" | "icon-select" | "custom";
export type ISearchComponent = DateComponent | InputComponent | "custom";
export type IComponentType = DateComponent | InputComponent | OtherComponent;
type ToolbarLeft = "add" | "delete" | "import" | "export";
type ToolbarRight = "refresh" | "filter" | "imports" | "exports" | "search";
type ToolbarTable = "edit" | "view" | "delete";
export type IToolsButton = {
name: string; // 按钮名称
text?: string; // 按钮文本
perm?: Array<string> | string; // 权限标识(可以是完整权限字符串如'sys:user:create'或操作权限如'create')
attrs?: Partial<ButtonProps> & { style?: CSSProperties }; // 按钮属性
render?: (row: IObject) => boolean; // 条件渲染
children?: IToolsButton[];
};
export type IToolsDefault = ToolbarLeft | ToolbarRight | ToolbarTable | IToolsButton;
export interface IOperateData {
name: string;
row: IObject;
column: IObject;
$index: number;
}
export interface ISearchConfig {
// 权限前缀(如sys:user,用于组成权限标识),不提供则不进行权限校验
permPrefix?: string;
// 标签冒号(默认:false)
colon?: boolean;
// 表单项(默认:[])
formItems?: IFormItems<ISearchComponent>;
// 是否开启展开和收缩(默认:true)
isExpandable?: boolean;
// 默认展示的表单项数量(默认:3)
showNumber?: number;
// 卡片属性
cardAttrs?: Partial<CardProps> & { style?: CSSProperties };
// form组件属性
form?: IForm;
// 自适应网格布局(使用时表单不要添加 style: { width: "200px" })
grid?: boolean | "left" | "right";
}
export interface IContentConfig<TQuery = any, TItem = any> {
// 权限前缀(如sys:user,用于组成权限标识),不提供则不进行权限校验
permPrefix?: string;
// table组件属性
table?: Partial<Omit<TableProps<any>, "data">>;
// 分页组件位置(默认:left)
pagePosition?: "left" | "right";
// pagination组件属性
pagination?:
| boolean
| Partial<
Omit<
PaginationProps,
"v-model:page-size" | "v-model:current-page" | "total" | "currentPage"
>
>;
// 列表的网络请求函数(需返回promise)
indexAction: (queryParams: TQuery) => Promise<PageResult<TItem> | TItem[]>;
// 默认的分页相关的请求参数
request?: {
pageName: string;
limitName: string;
};
// 分页接口统一返回 PageResult { list, total }
// 修改属性的网络请求函数(需返回promise)
modifyAction?: (data: {
[key: string]: any;
field: string;
value: boolean | string | number;
}) => Promise<any>;
// 删除的网络请求函数(需返回promise)
deleteAction?: (ids: string) => Promise<any>;
// 后端导出的网络请求函数(需返回promise)
exportAction?: (queryParams: TQuery) => Promise<any>;
// 前端全量导出的网络请求函数(需返回promise)
exportsAction?: (queryParams: TQuery) => Promise<TItem[]>;
// 导入模板
importTemplate?: string | (() => Promise<any>);
// 后端导入的网络请求函数(需返回promise)
importAction?: (file: File) => Promise<any>;
// 前端导入的网络请求函数(需返回promise)
importsAction?: (data: IObject[]) => Promise<any>;
// 主键名(默认为id)
pk?: string;
// 表格工具栏(默认:add,delete,export,也可自定义)
toolbar?: Array<ToolbarLeft | IToolsButton>;
// 表格工具栏右侧图标(默认:refresh,filter,imports,exports,search)
defaultToolbar?: Array<ToolbarRight | IToolsButton>;
// table组件列属性(额外的属性templet,operat,slotName)
cols: Array<{
type?: "default" | "selection" | "index" | "expand";
label?: string;
prop?: string;
width?: string | number;
align?: "left" | "center" | "right";
columnKey?: string;
reserveSelection?: boolean;
// 列是否显示
show?: boolean;
// 模板
templet?:
| "image"
| "list"
| "url"
| "switch"
| "input"
| "price"
| "percent"
| "icon"
| "date"
| "tool"
| "custom";
// image模板相关参数
imageWidth?: number;
imageHeight?: number;
// list模板相关参数
selectList?: IObject;
// switch模板相关参数
activeValue?: boolean | string | number;
inactiveValue?: boolean | string | number;
activeText?: string;
inactiveText?: string;
// input模板相关参数
inputType?: string;
// price模板相关参数
priceFormat?: string;
// date模板相关参数
dateFormat?: string;
// tool模板相关参数
operat?: Array<ToolbarTable | IToolsButton>;
// filter值拼接符
filterJoin?: string;
[key: string]: any;
// 初始化数据函数
initFn?: (item: IObject) => void;
}>;
}
export interface IModalConfig<T = any> {
// 权限前缀(如sys:user,用于组成权限标识),不提供则不进行权限校验
permPrefix?: string;
// 标签冒号(默认:false)
colon?: boolean;
// 主键名(主要用于编辑数据,默认为id)
pk?: string;
// 组件类型(默认:dialog)
component?: "dialog" | "drawer";
// dialog组件属性
dialog?: Partial<Omit<DialogProps, "modelValue">>;
// drawer组件属性
drawer?: Partial<Omit<DrawerProps, "modelValue">>;
// form组件属性
form?: IForm;
// 表单项
formItems: IFormItems<IComponentType>;
// 提交之前处理
beforeSubmit?: (data: T) => void;
// 提交的网络请求函数(需返回promise)
formAction?: (data: T) => Promise<any>;
}
export type IForm = Partial<Omit<FormProps, "model" | "rules">>;
// 表单项
export type IFormItems<T = IComponentType> = Array<{
// 组件类型(如input,select,radio,custom等)
type: T;
// 标签提示
tips?: string | IObject;
// 标签文本
label: string;
// 键名
prop: string;
// 组件属性
attrs?: IObject;
// 组件可选项(只适用于select,radio,checkbox组件)
options?: Array<{ label: string; value: any; [key: string]: any }> | Ref<any[]>;
// 验证规则
rules?: FormItemRule[];
// 初始值
initialValue?: any;
// 插槽名(适用于自定义组件,设置类型为custom)
slotName?: string;
// 是否隐藏
hidden?: boolean;
// layout组件Col属性
col?: Partial<ColProps>;
// 组件事件
events?: Record<string, (...args: any) => void>;
// 初始化数据函数扩展
initFn?: (item: IObject) => void;
}>;
export interface IPageForm {
// 主键名(主要用于编辑数据,默认为id)
pk?: string;
// form组件属性
form?: IForm;
// 表单项
formItems: IFormItems<IComponentType>;
}

View File

@@ -1,105 +0,0 @@
import { ref } from "vue";
import type { IObject, PageContentInstance, PageModalInstance, PageSearchInstance } from "./types";
function usePage() {
const searchRef = ref<PageSearchInstance>();
const contentRef = ref<PageContentInstance>();
const addModalRef = ref<PageModalInstance>();
const editModalRef = ref<PageModalInstance>();
// 搜索
function handleQueryClick(queryParams: IObject) {
const filterParams = contentRef.value?.getFilterParams();
contentRef.value?.fetchPageData({ ...queryParams, ...filterParams }, true);
}
// 重置
function handleResetClick(queryParams: IObject) {
const filterParams = contentRef.value?.getFilterParams();
contentRef.value?.fetchPageData({ ...queryParams, ...filterParams }, true);
}
// 新增
function handleAddClick(RefImpl?: Ref<PageModalInstance>) {
if (RefImpl) {
RefImpl?.value.setModalVisible();
RefImpl?.value.handleDisabled(false);
} else {
addModalRef.value?.setModalVisible();
addModalRef.value?.handleDisabled(false);
}
}
// 编辑
async function handleEditClick(
row: IObject,
callback?: (result?: IObject) => IObject | Promise<IObject>,
RefImpl?: Ref<PageModalInstance>,
) {
if (RefImpl) {
RefImpl.value?.setModalVisible();
RefImpl.value?.handleDisabled(false);
const from = await (callback?.(row) ?? Promise.resolve(row));
RefImpl.value?.setFormData(from ? from : row);
} else {
editModalRef.value?.setModalVisible();
editModalRef.value?.handleDisabled(false);
const from = await (callback?.(row) ?? Promise.resolve(row));
editModalRef.value?.setFormData(from ? from : row);
}
}
// 查看
async function handleViewClick(
row: IObject,
callback?: (result?: IObject) => IObject | Promise<IObject>,
RefImpl?: Ref<PageModalInstance>,
) {
if (RefImpl) {
RefImpl.value?.setModalVisible();
RefImpl.value?.handleDisabled(true);
const from = await (callback?.(row) ?? Promise.resolve(row));
RefImpl.value?.setFormData(from ? from : row);
} else {
editModalRef.value?.setModalVisible();
editModalRef.value?.handleDisabled(true);
const from = await (callback?.(row) ?? Promise.resolve(row));
editModalRef.value?.setFormData(from ? from : row);
}
}
// 表单提交
function handleSubmitClick() {
//根据检索条件刷新列表数据
const queryParams = searchRef.value?.getQueryParams();
contentRef.value?.fetchPageData(queryParams, true);
}
// 导出
function handleExportClick() {
// 根据检索条件导出数据
const queryParams = searchRef.value?.getQueryParams();
contentRef.value?.exportPageData(queryParams);
}
// 搜索显隐
function handleSearchClick() {
searchRef.value?.toggleVisible();
}
// 筛选数据
function handleFilterChange(filterParams: IObject) {
const queryParams = searchRef.value?.getQueryParams();
contentRef.value?.fetchPageData({ ...queryParams, ...filterParams }, true);
}
return {
searchRef,
contentRef,
addModalRef,
editModalRef,
handleQueryClick,
handleResetClick,
handleAddClick,
handleEditClick,
handleViewClick,
handleSubmitClick,
handleExportClick,
handleSearchClick,
handleFilterChange,
};
}
export default usePage;

View File

@@ -1,9 +1,9 @@
import DeviceAPI from "@/api/device";
import type { DeviceForm } from "@/api/device/types";
import type { IModalConfig } from "../components/types";
import type { CrudFormConfig } from "@/components/Crud/types";
const modalConfig: IModalConfig<DeviceForm> = {
permPrefix: "sys:device",
const addFormConfig: CrudFormConfig<DeviceForm> = {
component: "dialog",
dialog: {
title: "新增设备",
width: 800,
@@ -12,13 +12,8 @@ const modalConfig: IModalConfig<DeviceForm> = {
form: {
labelWidth: 100,
},
formAction(data) {
return DeviceAPI.create(data);
},
beforeSubmit(data) {
return data;
},
formItems: [
submitForm: (data) => DeviceAPI.create(data),
fields: [
{
label: "设备SN",
prop: "serialno",
@@ -125,5 +120,4 @@ const modalConfig: IModalConfig<DeviceForm> = {
],
};
// 如果有异步数据会修改配置的,推荐用reactive包裹,而纯静态配置的可以直接导出
export default reactive(modalConfig) as IModalConfig<DeviceForm>;
export default addFormConfig;

View File

@@ -1,9 +1,27 @@
import DeviceAPI from "@/api/device";
import type { DeviceQueryParams, DeviceItem } from "@/api/device/types";
import type { IContentConfig } from "../components/types";
import type { DeviceItem, DeviceQueryParams } from "@/api/device/types";
import type { CrudTableConfig } from "@/components/Crud/types";
import { onlineStatus, syncOnlineStatus } from "./online";
const contentConfig: IContentConfig<DeviceQueryParams, DeviceItem> = {
permPrefix: "sys:device", // 不写不进行按钮权限校验
/** 列表接口不保证带在线状态,加载后批量补齐 */
async function fetchDevices(params: DeviceQueryParams) {
const page = await DeviceAPI.getPage(params);
syncOnlineStatus(page.list);
const sns = page.list.map((item) => item.serialno).filter((sn): sn is string => !!sn);
if (sns.length) {
await DeviceAPI.getOnlineStatusBatch(sns)
.then((onlineMap) => {
Object.values(onlineMap ?? {}).forEach((info) => {
if (info?.serialno) onlineStatus[info.serialno] = info.online ?? 0;
});
})
.catch(() => undefined);
}
return page;
}
const contentConfig: CrudTableConfig<DeviceQueryParams, DeviceItem> = {
permissionPrefix: "sys:device",
table: {
border: true,
highlightCurrentRow: true,
@@ -14,182 +32,90 @@ const contentConfig: IContentConfig<DeviceQueryParams, DeviceItem> = {
pageSize: 20,
pageSizes: [10, 20, 30, 50],
},
indexAction(params) {
return DeviceAPI.getPage(params);
},
deleteAction: DeviceAPI.deleteByIds,
importAction(file) {
return DeviceAPI.import(file);
},
exportAction: DeviceAPI.export,
fetchData: fetchDevices,
deleteByIds: DeviceAPI.deleteByIds,
importFile: DeviceAPI.import,
importTemplate: DeviceAPI.downloadTemplate,
importsAction(data) {
// 模拟导入数据
console.warn("importsAction", data);
return Promise.resolve();
},
async exportsAction(params) {
// 模拟获取到的是全量数据
const data = await DeviceAPI.getPage(params);
console.warn("exportsAction", data.list);
return data.list;
},
pk: "id",
toolbar: [
"add",
"delete",
"import",
"export",
{
name: "custom1",
text: "自定义1",
perm: "add",
attrs: { icon: "plus", color: "#626AEF" },
},
],
defaultToolbar: ["refresh", "filter", "imports", "exports", "search"],
cols: [
exportFile: (params) => DeviceAPI.export(params as DeviceQueryParams),
rowKey: "id",
toolbarActions: ["create", "delete", "import", "export"],
tableTools: ["refresh", "columns", "search"],
columns: [
{ type: "selection", width: 50, align: "center" },
{ label: "编号", align: "center", prop: "id", width: 100, show: false },
{
label: "SN",
align: "center",
prop: "serialno",
templet: "custom",
slotName: "serialno",
width: 220,
},
{
label: "在线状态",
align: "center",
prop: "online",
templet: "custom",
slotName: "onlineStatus",
width: 100,
},
{ label: "SN", align: "center", prop: "serialno", width: 220 },
{ label: "在线状态", align: "center", prop: "online", width: 100 },
{ label: "设备昵称", align: "center", prop: "snName", width: 200 },
{ label: "设备型号", align: "center", prop: "snModel", width: 200 },
{ label: "推送ID", align: "center", prop: "pushId", width: 200 },
// { label: "设备 IMEI", align: "center", prop: "snImei", width: 200 },
// { label: "设备版本号", align: "center", prop: "snDispalyId", width: 400 },
// { label: "头像", align: "center", prop: "avatar", templet: "image" },
// {
// label: "性别",
// align: "center",
// prop: "gender",
// width: 100,
// templet: "custom",
// slotName: "gender",
// },
// { label: "部门", align: "center", prop: "deptName", width: 120 },
// {
// label: "角色",
// align: "center",
// prop: "roleNames",
// width: 120,
// columnKey: "roleIds",
// filters: [],
// filterMultiple: true,
// filterJoin: ",",
// async initFn(colItem) {
// const roleOptions = await RoleAPI.getOptions();
// colItem.filters = roleOptions.map((item) => {
// return { text: item.label, value: item.value };
// });
// },
// },
{
label: "手机号码",
align: "center",
prop: "snMobile",
templet: "custom",
slotName: "mobile",
width: 150,
},
{
label: "状态",
align: "center",
prop: "status",
templet: "custom",
slotName: "status",
width: 120,
},
{ label: "手机号码", align: "center", prop: "snMobile", width: 150 },
{ label: "状态", align: "center", prop: "status", width: 120 },
{ label: "创建时间", align: "center", prop: "createTime", width: 180 },
{ label: "激活时间", align: "center", prop: "activateTime", width: 180 },
{
label: "操作",
align: "center",
prop: "actions",
fixed: "right",
width: 280,
templet: "tool",
operat: [
cellType: "actions",
maxVisibleActions: 3,
actions: [
{
name: "detail",
text: "详情",
attrs: { icon: "Document", type: "primary" },
key: "detail",
label: "详情",
permission: "*:*:*",
buttonProps: { type: "primary", icon: "Document" },
},
// {
// name: "reset_pwd",
// text: "重置密码",
// // perm: "password-reset",
// attrs: {
// icon: "refresh-left",
// // color: "#626AEF", // 使用 text 属性,颜色不生效
// style: {
// "--el-button-text-color": "#626AEF",
// "--el-button-hover-link-text-color": "#9197f4",
// },
// },
// },
"edit",
"delete",
{
name: "more", // 主按钮名称
text: "更多", // 显示文本
attrs: { icon: "more", type: "primary" }, // 图标
// children 数组定义下拉菜单项
children: [
{
name: "refresh",
text: "刷新",
attrs: { icon: "Refresh" },
},
{
name: "screenshot",
text: "截图",
attrs: { icon: "FullScreen" },
},
{
name: "mirror",
text: "屏幕镜像",
attrs: { icon: "VideoCamera" },
},
{
name: "locate",
text: "定位",
attrs: { icon: "Position" },
},
{
name: "shutdown",
text: "关机",
attrs: { icon: "SwitchButton" },
},
{
name: "reboot",
text: "重启",
attrs: { icon: "SwitchButton" },
},
{
name: "refactory",
text: "重置",
attrs: { icon: "RefreshLeft" },
},
{
name: "unbind",
text: "解除绑定",
attrs: { icon: "Unlock" },
},
],
key: "refresh",
label: "刷新",
permission: "*:*:*",
buttonProps: { icon: "Refresh" },
},
{
key: "screenshot",
label: "截图",
permission: "*:*:*",
buttonProps: { icon: "FullScreen" },
},
{
key: "mirror",
label: "屏幕镜像",
permission: "*:*:*",
buttonProps: { icon: "VideoCamera" },
},
{
key: "locate",
label: "定位",
permission: "*:*:*",
buttonProps: { icon: "Position" },
},
{
key: "shutdown",
label: "关机",
permission: "*:*:*",
buttonProps: { icon: "SwitchButton" },
},
{
key: "reboot",
label: "重启",
permission: "*:*:*",
buttonProps: { icon: "SwitchButton" },
},
{
key: "refactory",
label: "重置",
permission: "*:*:*",
buttonProps: { icon: "RefreshLeft" },
},
{
key: "unbind",
label: "解除绑定",
permission: "*:*:*",
buttonProps: { icon: "Unlock" },
},
],
},

View File

@@ -1,30 +1,30 @@
import { reactive } from "vue";
import DeviceAPI from "@/api/device";
import type { DeviceForm } from "@/api/device/types";
import type { IModalConfig } from "../components/types";
import type { CrudFormConfig } from "@/components/Crud/types";
import { DeviceEnum } from "@/enums/settings";
import { useAppStore } from "@/stores";
const modalConfig: IModalConfig<DeviceForm> = {
permPrefix: "sys:device",
const editFormConfig: CrudFormConfig<DeviceForm> = {
component: "drawer",
rowKey: "id",
drawer: {
title: "修改设备",
size: useAppStore().device === DeviceEnum.MOBILE ? "80%" : 500,
},
pk: "id",
// 编辑只回传可修改字段(仅设备昵称)
beforeSubmit(data) {
return {
id: data.id,
snName: data.snName,
status: data.status,
} as DeviceForm;
form: {
labelWidth: 100,
},
formAction(data) {
return DeviceAPI.update(data.id as string, data);
},
formItems: [
// 编辑只回传可修改字段(仅设备昵称与状态)
submitForm: (data) =>
DeviceAPI.update(
data.id as string,
{
id: data.id,
snName: data.snName,
status: data.status,
} as DeviceForm,
),
fields: [
{
label: "设备SN",
prop: "serialno",
@@ -60,4 +60,4 @@ const modalConfig: IModalConfig<DeviceForm> = {
],
};
export default reactive(modalConfig) as IModalConfig<DeviceForm>;
export default editFormConfig;

View File

@@ -0,0 +1,14 @@
import type { DeviceItem, DeviceOnlineVO } from "@/api/device/types";
/** 设备在线状态:分页接口提供当前页初值,SSE 推送实时覆盖 */
export const onlineStatus = reactive<Record<string, number>>({});
export function syncOnlineStatus(list: DeviceItem[]) {
list.forEach((item) => {
if (item.serialno) onlineStatus[item.serialno] = item.online ?? 0;
});
}
export function updateOnlineStatus(data: DeviceOnlineVO) {
if (data?.serialno) onlineStatus[data.serialno] = data.online ?? 0;
}

View File

@@ -1,27 +1,14 @@
/** 公共下载数据,减少重复请求次数 */
import DeptAPI from "@/api/system/dept";
import RoleAPI from "@/api/system/role";
import type { OptionItem } from "@/api/common";
interface OptionItem {
label: string;
value: any;
[key: string]: any; // 允许其他属性
}
// 明确指定类型为 OptionItem[]
/** 公共下载数据,减少重复请求次数 */
export const deptArr = ref<OptionItem[]>([]);
export const roleArr = ref<OptionItem[]>([]);
export const stateArr = ref<OptionItem[]>([
export const stateArr = [
{ label: "启用", value: 1 },
{ label: "禁用", value: 0 },
]);
];
// 初始化逻辑,在 onMounted 钩子中调用
export const initOptions = async () => {
// 使用Promise.all并行请求
const [dept, roles] = await Promise.all([DeptAPI.getOptions(), RoleAPI.getOptions()]);
// 获取部门选项并赋值
deptArr.value = dept;
// 获取角色选项并赋值
roleArr.value = roles;
};
export async function initOptions() {
deptArr.value = await DeptAPI.getOptions();
}

View File

@@ -1,14 +1,13 @@
import type { ISearchConfig } from "../components/types";
import type { CrudSearchConfig } from "@/components/Crud/types";
import { deptArr, stateArr } from "./options";
const searchConfig: ISearchConfig = {
permPrefix: "sys:device",
formItems: [
const searchConfig: CrudSearchConfig = {
fields: [
{
tips: "支持模糊搜索",
type: "input",
label: "关键字",
prop: "keywords",
tips: "支持模糊搜索",
attrs: {
placeholder: "用户名/昵称/手机号",
clearable: true,
@@ -23,37 +22,35 @@ const searchConfig: ISearchConfig = {
placeholder: "请选择",
data: deptArr,
filterable: true,
"check-strictly": true,
"render-after-expand": false,
checkStrictly: true,
renderAfterExpand: false,
clearable: true,
style: { width: "200px" },
},
// async initFn(formItem) {
// // 注意:如果initFn函数不是箭头函数,this会指向此配置项对象,那么也就可以用this来替代形参formItem
// formItem.attrs.data = await DeptAPI.getOptions();
// },
},
{
type: "select",
label: "状态",
prop: "status",
initialValue: null,
options: stateArr,
attrs: {
placeholder: "全部",
clearable: true,
style: { width: "200px" },
},
options: stateArr,
},
{
type: "date-picker",
label: "创建时间",
prop: "createTime",
initialValue: [],
attrs: {
type: "daterange",
"range-separator": "~",
"start-placeholder": "开始时间",
"end-placeholder": "截止时间",
"value-format": "YYYY-MM-DD",
rangeSeparator: "~",
startPlaceholder: "开始时间",
endPlaceholder: "截止时间",
valueFormat: "YYYY-MM-DD",
style: { width: "200px" },
},
},

View File

@@ -1,96 +1,56 @@
<template>
<div class="app-container h-full flex flex-1 flex-col">
<!-- 列表 -->
<div v-show="!dialogVisible" class="flex flex-col h-full">
<!-- 搜索 -->
<DevicePageSearch
ref="searchRef"
<div v-show="!detailVisible" class="flex flex-col h-full">
<CrudPage
ref="pageRef"
:search-config="searchConfig"
@query-click="handleQueryClick"
@reset-click="handleResetClick"
/>
<!-- 列表 -->
<DevicePageContent
ref="contentRef"
:content-config="contentConfig"
class="flex-1 overflow-hidden"
@add-click="handleAddClick"
@export-click="handleExportClick"
@search-click="handleSearchClick"
@toolbar-click="handleToolbarClick"
@operate-click="handleOperateClick"
@filter-change="handleFilterChange"
@data-loaded="fetchDeviceOnlineStatus"
:table-config="contentConfig"
@create="handleAddClick"
@row-action="handleRowAction"
>
<template #status="scope">
<el-tag :type="scope.row[scope.prop as string] == 1 ? 'success' : 'info'">
{{ scope.row[scope.prop as string] == 1 ? "启用" : "禁用" }}
</el-tag>
</template>
<template #gender="scope">
<DictTag v-model="scope.row[scope.prop as string]" code="gender" />
</template>
<template #mobile="scope">
<el-text>{{ scope.row[scope.prop as string] }}</el-text>
<copy-button
v-if="scope.row[scope.prop as string]"
:text="scope.row[scope.prop as string]"
:style="{ marginLeft: '2px' }"
/>
</template>
<template #serialno="scope">
<el-link type="primary" @click="handleSerialnoClick(scope.row)">
{{ scope.row[scope.prop as string] }}
<template #table-cell-serialno="{ row }">
<el-link type="primary" @click="handleSerialnoClick(row)">
{{ row.serialno }}
</el-link>
<copy-button
v-if="scope.row[scope.prop as string]"
:text="scope.row[scope.prop as string]"
<CopyButton
v-if="row.serialno"
:text="String(row.serialno)"
:style="{ marginLeft: '2px' }"
/>
</template>
<template #onlineStatus="scope">
<template #table-cell-online="{ row }">
<span
class="status-dot"
:style="{ backgroundColor: scope.row.online == 1 ? '#67c23a' : '#f56c6c' }"
:style="{ backgroundColor: isOnline(row) ? '#67c23a' : '#f56c6c' }"
/>
<el-tag :type="scope.row.online == 1 ? 'success' : 'info'" size="small">
{{ scope.row.online == 1 ? "在线" : "离线" }}
<el-tag :type="isOnline(row) ? 'success' : 'info'" size="small">
{{ isOnline(row) ? "在线" : "离线" }}
</el-tag>
</template>
</DevicePageContent>
<!-- 新增 -->
<DevicePageModal
ref="addModalRef"
:modal-config="addModalConfig"
@submit-click="handleSubmitClick"
>
<template #gender="scope">
<DictSelect v-model="scope.formData[scope.prop]" code="gender" v-bind="scope.attrs" />
<template #table-cell-snMobile="{ row }">
<el-text>{{ row.snMobile }}</el-text>
<CopyButton
v-if="row.snMobile"
:text="String(row.snMobile)"
:style="{ marginLeft: '2px' }"
/>
</template>
<template #openModal>
<el-button type="primary" @click="openSecondModal">打开二级弹窗</el-button>
<template #table-cell-status="{ row }">
<el-tag :type="row.status == 1 ? 'success' : 'info'">
{{ row.status == 1 ? "启用" : "禁用" }}
</el-tag>
</template>
</DevicePageModal>
<!-- 编辑 -->
<DevicePageModal
ref="editModalRef"
:modal-config="editModalConfig"
@submit-click="handleSubmitClick"
>
<template #gender="scope">
<DictSelect v-model="scope.formData[scope.prop]" code="gender" v-bind="scope.attrs" />
</template>
</DevicePageModal>
</CrudPage>
</div>
<div v-show="dialogVisible">
<div v-show="detailVisible">
<DeviceSnDetail
v-model="dialogVisible"
v-model="detailVisible"
:serialno="selectedRow?.serialno"
@return-click="handleReturnClick"
/>
</div>
<!-- 操作列详情 - 概览抽屉,样式与编辑抽屉一致 -->
<el-drawer v-model="overviewVisible" title="查看" size="500px" destroy-on-close>
<DeviceSnOverView :serialno="selectedRow?.serialno" />
@@ -98,138 +58,121 @@
<el-button @click="overviewVisible = false">关闭</el-button>
</template>
</el-drawer>
<CrudForm ref="addFormRef" :form-config="addFormConfig" @saved="handleSaved" />
<CrudForm ref="editFormRef" :form-config="editFormConfig" @saved="handleSaved" />
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, ref } from "vue";
import { onBeforeUnmount, onMounted, ref } from "vue";
import DeviceAPI from "@/api/device";
import type { DeviceOnlineVO } from "@/api/device/types";
import type { IObject, PageModalInstance } from "./components/types";
import type { CrudRecord } from "@/components/Crud/types";
import { useSse } from "@/utils/sse";
import DevicePageContent from "./components/DevicePageContent.vue";
import DevicePageModal from "./components/DevicePageModal.vue";
import DevicePageSearch from "./components/DevicePageSearch.vue";
import DeviceSnDetail from "./components/DeviceSnDetail.vue";
import DeviceSnOverView from "./components/DeviceSnOverView.vue";
import usePage from "./components/usePage";
import addModalConfig from "./config/add";
import addFormConfig from "./config/add";
import contentConfig from "./config/content";
import editModalConfig from "./config/edit";
import searchConfig from "./config/search";
import editFormConfig from "./config/edit";
import { onlineStatus, updateOnlineStatus } from "./config/online";
import { initOptions } from "./config/options";
import searchConfig from "./config/search";
const {
searchRef,
contentRef,
addModalRef,
editModalRef,
handleQueryClick,
handleResetClick,
handleAddClick,
handleEditClick,
handleSubmitClick,
handleExportClick,
handleSearchClick,
handleFilterChange,
} = usePage();
const pageRef = ref();
const addFormRef = ref();
const editFormRef = ref();
// 其他工具
function handleToolbarClick(name: string) {
console.warn(name);
if (name === "custom1") {
ElMessage.success("点击了自定义1按钮");
}
const detailVisible = ref(false);
const overviewVisible = ref(false);
const selectedRow = ref<CrudRecord>({});
/** SSE 推送的在线状态优先于列表接口返回值 */
function isOnline(row: CrudRecord): boolean {
return Number(onlineStatus[String(row.serialno)] ?? row.online) === 1;
}
function handleAddClick() {
pageRef.value?.openCreate(addFormRef);
}
function handleSaved() {
pageRef.value?.refresh();
}
function handleReturnClick() {
dialogVisible.value = false;
detailVisible.value = false;
overviewVisible.value = false;
}
// SN点击事件 - 打开详情弹窗
function handleSerialnoClick(row: CrudRecord) {
selectedRow.value = row;
detailVisible.value = true;
}
// 表格工具
const handleOperateClick = (data: IObject) => {
if (data.name === "detail") {
selectedRow.value = data.row;
async function handleRowAction({ key, row }: { key: string; row: CrudRecord }) {
if (key === "detail") {
selectedRow.value = row;
overviewVisible.value = true;
} else if (data.name === "edit") {
editModalConfig.drawer = { ...editModalConfig.drawer, title: "修改" };
handleEditClick(data.row, async () => {
return await DeviceAPI.getFormData(data.row.serialno as string); // 根据SN获取详情
});
} else if (data.name === "reset_pwd") {
ElMessageBox.prompt("请输入用户名" + data.row.username + "」的新密码", "重置密码", {
confirmButtonText: "确定",
cancelButtonText: "取消",
}).then(
(result) => {
const value = result.value;
if (!value || value.length < 6) {
ElMessage.warning("密码至少需6位字符,请重新输入");
return false;
}
DeviceAPI.resetPassword(data.row.id, value).then(() => {
ElMessage.success("密码重置成功,新密码是:" + value);
});
},
() => {
// 用户取消
},
} else if (key === "edit") {
await pageRef.value?.openEdit(
row,
(record: CrudRecord) => DeviceAPI.getFormData(String(record.serialno)),
editFormRef,
);
} else if (data.name === "more") {
// 处理新增的自定义操作
ElMessage.info(`执行了自定义操作,设备ID: ${data.row.id}`);
} else if (data.name === "refresh") {
DeviceAPI.deviceRefresh(data.row.serialno as string).then(() => {
ElMessage.success(`刷新设备,设备SN: ${data.row.serialno as string}`);
} else if (key === "refresh") {
DeviceAPI.deviceRefresh(String(row.serialno)).then(() => {
ElMessage.success(`刷新设备,设备SN: ${row.serialno}`);
});
} else if (data.name === "screenshot") {
DeviceAPI.deviceScreenshot(data.row.serialno as string).then(() => {
ElMessage.success(`截图设备,设备SN: ${data.row.serialno as string}`);
} else if (key === "screenshot") {
DeviceAPI.deviceScreenshot(String(row.serialno)).then(() => {
ElMessage.success(`截图设备,设备SN: ${row.serialno}`);
});
} else if (data.name === "mirror") {
} else if (key === "mirror") {
// 屏幕镜像:通知设备端启动屏幕采集,由家属端 client 进行 WebRTC 实时远程观看/控制
DeviceAPI.deviceMirror(data.row.serialno as string)
DeviceAPI.deviceMirror(String(row.serialno))
.then(() => {
ElMessage.success(`已发送屏幕镜像指令,设备SN: ${data.row.serialno as string}`);
ElMessage.success(`已发送屏幕镜像指令,设备SN: ${row.serialno}`);
})
.catch(() => {});
} else if (data.name === "reboot") {
} else if (key === "reboot") {
ElMessageBox.confirm("确定要重启该设备吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
})
.then(() => {
DeviceAPI.deviceReboot(data.row.serialno as string).then(() => {
ElMessage.success(`重启设备,设备SN: ${data.row.serialno as string}`);
DeviceAPI.deviceReboot(String(row.serialno)).then(() => {
ElMessage.success(`重启设备,设备SN: ${row.serialno}`);
});
})
.catch(() => {});
} else if (data.name === "shutdown") {
DeviceAPI.deviceShutdown(data.row.serialno as string).then(() => {
ElMessage.success(`关机设备,设备SN: ${data.row.serialno as string}`);
} else if (key === "shutdown") {
DeviceAPI.deviceShutdown(String(row.serialno)).then(() => {
ElMessage.success(`关机设备,设备SN: ${row.serialno}`);
});
} else if (data.name === "locate") {
DeviceAPI.deviceLocate(data.row.serialno as string).then(() => {
ElMessage.success(`定位设备,设备SN: ${data.row.serialno as string}`);
} else if (key === "locate") {
DeviceAPI.deviceLocate(String(row.serialno)).then(() => {
ElMessage.success(`定位设备,设备SN: ${row.serialno}`);
});
} else if (data.name === "refactory") {
} else if (key === "refactory") {
ElMessageBox.confirm("确定要重置该设备吗?此操作将清除设备所有数据,是否继续?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "error",
})
.then(() => {
DeviceAPI.deviceRestore(data.row.serialno as string).then(() => {
ElMessage.success(`重置设备,设备SN: ${data.row.serialno as string}`);
DeviceAPI.deviceRestore(String(row.serialno)).then(() => {
ElMessage.success(`重置设备,设备SN: ${row.serialno}`);
});
})
.catch(() => {});
} else if (data.name === "unbind") {
} else if (key === "unbind") {
// 解除设备手机号绑定(管理员端):确认后清除 snMobile 并推送通知设备端标记解绑
const sn = data.row.serialno as string;
const mobile = (data.row.snMobile as string) || "";
const sn = String(row.serialno);
const mobile = (row.snMobile as string) || "";
ElMessageBox.confirm(
mobile
? `设备「${sn}」当前绑定手机号:${mobile}\n确定要解除该绑定吗?解除后需由家属端重新绑定。`
@@ -242,62 +185,12 @@ const handleOperateClick = (data: IObject) => {
},
)
.then(() => {
DeviceAPI.unbindDeviceMobile(data.row.serialno as string).then(() => {
ElMessage.success(`已解除设备绑定,设备SN: ${data.row.serialno as string}`);
DeviceAPI.unbindDeviceMobile(sn).then(() => {
ElMessage.success(`已解除设备绑定,设备SN: ${sn}`);
});
})
.catch(() => {});
}
};
// 打开二级弹窗
const addModalRef2 = ref();
const openSecondModal = () => {
handleAddClick(addModalRef2 as Ref<PageModalInstance>);
};
// SN点击事件 - 打开详情弹窗
const handleSerialnoClick = async (row: IObject) => {
// 将当前行的数据传递给详情组件
selectedRow.value = row;
dialogVisible.value = true;
};
const dialogVisible = ref(false);
const overviewVisible = ref(false);
const selectedRow = ref<IObject>({});
/**
* 批量查询设备在线状态并更新表格数据
*/
async function fetchDeviceOnlineStatus() {
try {
// 从 contentRef 获取当前表格 pageData
const pageData = contentRef.value?.pageData as IObject[] | undefined;
if (!pageData || pageData.length === 0) {
return;
}
// 收集所有设备序列号
const sns = pageData.map((item) => item.serialno as string).filter((sn) => !!sn);
if (sns.length === 0) {
return;
}
// 批量查询在线状态
const onlineMap = await DeviceAPI.getOnlineStatusBatch(sns);
if (onlineMap) {
// 更新表格数据中的在线状态
pageData.forEach((item) => {
const sn = item.serialno as string;
const onlineInfo = onlineMap[sn];
if (onlineInfo) {
item.online = onlineInfo.online;
item.lastHeartbeatTime = onlineInfo.lastHeartbeatTime;
}
});
}
} catch (error) {
console.error("批量查询设备在线状态失败:", error);
}
}
/**
@@ -305,31 +198,13 @@ async function fetchDeviceOnlineStatus() {
*/
let unsubscribeDeviceOnlineStatus: (() => void) | null = null;
/**
* 根据 SSE 推送的设备在线状态更新单条表格数据
*/
function handleDeviceOnlineStatus(data: DeviceOnlineVO) {
if (!data || !data.serialno) {
return;
}
const pageData = contentRef.value?.pageData as IObject[] | undefined;
if (!pageData) {
return;
}
const row = pageData.find((item) => item.serialno === data.serialno);
if (row) {
row.online = data.online;
row.lastHeartbeatTime = data.lastHeartbeatTime;
}
}
onMounted(() => {
initOptions();
// 监听 SSE 推送的设备在线状态,实时更新表格对应行
const sse = useSse();
unsubscribeDeviceOnlineStatus = sse.on<DeviceOnlineVO>(
"device-online-status",
handleDeviceOnlineStatus,
updateOnlineStatus,
);
});