feat(DeviceSn): 添加设备组件并更新本地接口地址

This commit is contained in:
2026-07-30 17:13:38 +08:00
parent 1ff46bc33b
commit 742b32490f
22 changed files with 3767 additions and 2 deletions

View File

@@ -0,0 +1,312 @@
import request from "@/utils/request";
import type {
DeviceInfo,
DeviceForm,
DeviceQueryParams,
DeviceItem,
DeviceProfileDetail,
DeviceProfileForm,
DevicePasswordChangeForm,
DevicePasswordVerifyForm,
DeviceMobileUpdateForm,
DeviceEmailUpdateForm,
} from "./types";
import type { ExcelResult, PageResult, OptionItem } from "@/api/common";
const DEVICE_BASE_URL = "/api/v1/device";
const DeviceAPI = {
/**
* 获取当前登录用户信息
*
* @returns 登录用户昵称、头像信息,包括角色和权限
*/
getInfo() {
return request<any, DeviceInfo>({
url: `${DEVICE_BASE_URL}/me`,
method: "get",
});
},
/**
* 获取用户分页列表
*
* @param queryParams 查询参数
*/
getPage(queryParams: DeviceQueryParams) {
return request<any, PageResult<DeviceItem>>({
url: `${DEVICE_BASE_URL}`,
method: "get",
params: queryParams,
});
},
/**
* 获取设备表单详情
*
* @param sn 设备SN
* @returns 设备表单详情
*/
getFormData(sn: string) {
return request<any, DeviceForm>({
url: `${DEVICE_BASE_URL}/${sn}/info`,
method: "get",
});
},
/**
* 添加设备
*
* @param data 设备表单数据
*/
create(data: DeviceForm) {
return request({
url: `${DEVICE_BASE_URL}`,
method: "post",
data,
});
},
/**
* 修改用户
*
* @param id 用户ID
* @param data 用户表单数据
*/
update(id: string, data: DeviceForm) {
return request({
url: `${DEVICE_BASE_URL}/${id}`,
method: "put",
data,
});
},
/**
* 修改用户密码
*
* @param id 用户ID
* @param password 新密码
*/
resetPassword(id: string, password: string) {
return request({
url: `${DEVICE_BASE_URL}/${id}/password/reset`,
method: "put",
params: { password },
});
},
/**
* 批量删除用户,多个以英文逗号(,)分割
*
* @param ids 用户ID字符串多个以英文逗号(,)分割
*/
deleteByIds(ids: string) {
return request({
url: `${DEVICE_BASE_URL}/${ids}`,
method: "delete",
});
},
/** 下载用户导入模板 */
downloadTemplate() {
return request({
url: `${DEVICE_BASE_URL}/template`,
method: "get",
responseType: "blob",
});
},
/**
* 导出用户
*
* @param queryParams 查询参数
*/
export(queryParams: DeviceQueryParams) {
return request({
url: `${DEVICE_BASE_URL}/export`,
method: "get",
params: queryParams,
responseType: "blob",
});
},
/**
* 导入用户
*
* @param file 导入文件
*/
import(file: File) {
const formData = new FormData();
formData.append("file", file);
return request<any, ExcelResult>({
url: `${DEVICE_BASE_URL}/import`,
method: "post",
data: formData,
headers: {
"Content-Type": "multipart/form-data",
},
});
},
/** 获取个人中心用户信息 */
getProfile() {
return request<any, DeviceProfileDetail>({
url: `${DEVICE_BASE_URL}/profile`,
method: "get",
});
},
/** 修改个人中心用户信息 */
updateProfile(data: DeviceProfileForm) {
return request({
url: `${DEVICE_BASE_URL}/profile`,
method: "put",
data,
});
},
/** 修改个人中心用户密码 */
changePassword(data: DevicePasswordChangeForm) {
return request({
url: `${DEVICE_BASE_URL}/password`,
method: "put",
data,
});
},
/** 发送短信验证码(绑定或更换手机号)*/
sendMobileCode(mobile: string) {
return request({
url: `${DEVICE_BASE_URL}/mobile/code`,
method: "post",
params: { mobile },
});
},
/** 绑定或更换手机号 */
bindOrChangeMobile(data: DeviceMobileUpdateForm) {
return request({
url: `${DEVICE_BASE_URL}/mobile`,
method: "put",
data,
});
},
/** 解绑手机号 */
unbindMobile(data: DevicePasswordVerifyForm) {
return request({
url: `${DEVICE_BASE_URL}/mobile`,
method: "delete",
data,
});
},
/** 发送邮箱验证码(绑定或更换邮箱)*/
sendEmailCode(email: string) {
return request({
url: `${DEVICE_BASE_URL}/email/code`,
method: "post",
params: { email },
});
},
/** 绑定或更换邮箱 */
bindOrChangeEmail(data: DeviceEmailUpdateForm) {
return request({
url: `${DEVICE_BASE_URL}/email`,
method: "put",
data,
});
},
/** 解绑邮箱 */
unbindEmail(data: DevicePasswordVerifyForm) {
return request({
url: `${DEVICE_BASE_URL}/email`,
method: "delete",
data,
});
},
/**
* 获取用户下拉列表
*/
getOptions() {
return request<any, OptionItem[]>({
url: `${DEVICE_BASE_URL}/options`,
method: "get",
});
},
/**
* 截图刷新
* @param sn 设备SN号
*/
deviceRefresh(sn: string) {
return request({
url: `${DEVICE_BASE_URL}/refresh`,
method: "post",
params: { sn },
});
},
/**
* 截图设备
* @param sn 设备SN号
*/
deviceScreenshot(sn: string) {
return request({
url: `${DEVICE_BASE_URL}/screenshot`,
method: "post",
params: { sn },
});
},
/**
* 设备重启
* @param sn 设备SN号
*/
deviceReboot(sn: string) {
return request({
url: `${DEVICE_BASE_URL}/reboot`,
method: "post",
params: { sn },
});
},
/**
* 设备关机
* @param sn 设备SN号
*/
deviceShutdown(sn: string) {
return request({
url: `${DEVICE_BASE_URL}/shutdown`,
method: "post",
params: { sn },
});
},
/**
* 定位设备
* @param sn 设备SN号
*/
deviceLocate(sn: string) {
return request({
url: `${DEVICE_BASE_URL}/locate`,
method: "post",
params: { sn },
});
},
/**
* 重置设备
* @param sn 设备SN号
*/
deviceRestore(sn: string) {
return request({
url: `${DEVICE_BASE_URL}/restore`,
method: "post",
params: { sn },
});
},
};
export default DeviceAPI;

View File

@@ -0,0 +1,163 @@
/**
* Device 用户类型定义
*/
import type { BaseQueryParams } from "@/api/common";
/** 登录用户信息 */
export interface DeviceInfo {
/** 用户ID */
userId?: string;
/** 用户名 */
username?: string;
/** 用户昵称 */
nickname?: string;
/** 头像URL */
avatar?: string;
/** 租户切换权限true 可切换租户) */
canSwitchTenant?: boolean;
/** 角色集合 */
roles: string[];
/** 权限集合 */
perms: string[];
}
/** 用户分页查询参数 */
export interface DeviceQueryParams extends BaseQueryParams {
/** 搜索关键字 */
keywords?: string;
/** 用户状态 */
status?: number;
/** 部门ID */
deptId?: string;
/** 创建时间 */
createTime?: [string, string];
}
/** 用户分页对象 */
export interface DeviceItem {
/** 用户ID */
id: string;
/** 用户头像地址 */
avatar?: string;
/** 创建时间 */
createTime?: Date;
/** 部门名称 */
deptName?: string;
/** 用户邮箱 */
email?: string;
/** 性别 */
gender?: number;
/** 手机号 */
mobile?: string;
/** 用户昵称 */
nickname?: string;
/** 角色名称,多个使用英文逗号(,)分割 */
roleNames?: string;
/** 用户状态(1:启用;0:禁用) */
status?: number;
/** 用户名 */
Devicename?: string;
}
/** 设备表单对象 */
export interface DeviceForm {
/** 设备ID */
id?: string;
/** 设备序列号 */
serialno?: string;
/** 设备名称 */
snName?: string;
/** 设备手机号 */
snMobile?: string;
/** 设备在线状态(1:在线;0:离线) */
onlineStatus?: number;
/** 设备IMEI */
snImei?: string;
/** 所属部门 */
department?: string;
/** 分组 */
group?: string;
/** 设备类型 */
deviceType?: string;
/** 设备来源 */
source?: string;
/** 激活时间 */
activationTime?: string;
/** 最后上线时间 */
lastOnline?: string;
/** 角色ID集合 */
roleIds?: number[];
/** 设备状态(1:正常;0:禁用) */
status?: number;
}
/** 个人中心用户信息 */
export interface DeviceProfileDetail {
/** 用户ID */
id?: string;
/** 用户名 */
Devicename?: string;
/** 用户昵称 */
nickname?: string;
/** 头像URL */
avatar?: string;
/** 性别 */
gender?: number;
/** 手机号 */
mobile?: string;
/** 邮箱 */
email?: string;
/** 部门名称 */
deptName?: string;
/** 角色名称 */
roleNames?: string;
/** 创建时间 */
createTime?: Date;
}
/** 个人中心用户信息表单 */
export interface DeviceProfileForm {
/** 用户昵称 */
nickname?: string;
/** 头像URL */
avatar?: string;
/** 性别 */
gender?: number;
}
/** 修改密码表单 */
export interface DevicePasswordChangeForm {
/** 原密码 */
oldPassword?: string;
/** 新密码 */
newPassword?: string;
/** 确认新密码 */
confirmPassword?: string;
}
/** 密码校验表单 */
export interface DevicePasswordVerifyForm {
/** 当前密码 */
password?: string;
}
/** 修改手机表单 */
export interface DeviceMobileUpdateForm {
/** 手机号 */
mobile?: string;
/** 验证码 */
code?: string;
/** 当前密码 */
password?: string;
}
/** 修改邮箱表单 */
export interface DeviceEmailUpdateForm {
/** 邮箱 */
email?: string;
/** 验证码 */
code?: string;
/** 当前密码 */
password?: string;
}

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg t="1777312644020" class="icon" viewBox="0 0 1024 1024" version="1.1"
xmlns="http://www.w3.org/2000/svg" p-id="5755" xmlns:xlink="http://www.w3.org/1999/xlink"
width="200" height="200">
<path
d="M170.666667 213.333333v597.333334h682.666666V213.333333H170.666667zM128 128h768a42.666667 42.666667 0 0 1 42.666667 42.666667v682.666666a42.666667 42.666667 0 0 1-42.666667 42.666667H128a42.666667 42.666667 0 0 1-42.666667-42.666667V170.666667a42.666667 42.666667 0 0 1 42.666667-42.666667z m128 170.666667h128v426.666666H256V298.666667z m170.666667 0h85.333333v426.666666h-85.333333V298.666667z m128 0h42.666666v426.666666h-42.666666V298.666667z m85.333333 0h128v426.666666h-128V298.666667z"
p-id="5756"></path>
</svg>

After

Width:  |  Height:  |  Size: 869 B

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg t="1777312716989" class="icon" viewBox="0 0 1024 1024" version="1.1"
xmlns="http://www.w3.org/2000/svg" p-id="11318" xmlns:xlink="http://www.w3.org/1999/xlink"
width="200" height="200">
<path
d="M1022.06544 583.40119c0 11.0558-4.034896 20.61962-12.111852 28.696576-8.077979 8.077979-17.639752 12.117992-28.690436 12.117992L838.446445 624.215758c0 72.690556-14.235213 134.320195-42.718941 184.89915l132.615367 133.26312c8.076956 8.065699 12.117992 17.634636 12.117992 28.690436 0 11.050684-4.034896 20.614503-12.117992 28.691459-7.653307 8.065699-17.209964 12.106736-28.690436 12.106736-11.475356 0-21.040199-4.041036-28.690436-12.106736L744.717737 874.15318c-2.124384 2.118244-5.308913 4.88424-9.558703 8.283664-4.259 3.3984-13.180184 9.463536-26.78504 18.171871-13.598716 8.715499-27.415396 16.473183-41.439808 23.276123-14.029528 6.797823-31.462572 12.966313-52.289923 18.49319-20.827351 5.517667-41.446971 8.28571-61.842487 8.28571L552.801776 379.38668l-81.611739 0 0 571.277058c-21.668509 0-43.250036-2.874467-64.707744-8.615215-21.473057-5.734608-39.960107-12.749372-55.476499-21.039175-15.518438-8.289804-29.541827-16.572444-42.077328-24.867364-12.541641-8.290827-21.781072-15.193027-27.739784-20.714787l-9.558703-8.93244L154.95056 998.479767c-8.500605 8.921183-18.699897 13.386892-30.606065 13.386892-10.201339 0-19.335371-3.40454-27.409257-10.202363-8.079002-7.652284-12.437264-17.10968-13.080923-28.372188-0.633427-11.263531 2.659573-21.143553 9.893324-29.647227l128.787178-144.727219c-24.650423-48.464805-36.980239-106.699114-36.980239-174.710091L42.738895 624.207571c-11.057847 0-20.61655-4.041036-28.690436-12.111852-8.079002-8.082072-12.120039-17.640776-12.120039-28.696576 0-11.050684 4.041036-20.61962 12.120039-28.689413 8.073886-8.072863 17.632589-12.107759 28.690436-12.107759l142.81466 0L185.553555 355.156836l-110.302175-110.302175c-8.074909-8.077979-12.113899-17.640776-12.113899-28.691459 0-11.04966 4.044106-20.61962 12.113899-28.690436 8.071839-8.076956 17.638729-12.123109 28.691459-12.123109 11.056823 0 20.612457 4.052293 28.692482 12.123109l110.302175 110.302175 538.128077 0 110.303198-110.302175c8.070816-8.076956 17.632589-12.123109 28.690436-12.123109 11.050684 0 20.617573 4.052293 28.689413 12.123109 8.077979 8.070816 12.119015 17.640776 12.119015 28.690436 0 11.050684-4.041036 20.614503-12.119015 28.691459l-110.302175 110.302175 0 187.448206 142.815683 0c11.0558 0 20.618597 4.034896 28.690436 12.113899 8.076956 8.069793 12.117992 17.638729 12.117992 28.683273l0 0L1022.06544 583.40119 1022.06544 583.40119zM716.021162 216.158085 307.968605 216.158085c0-56.526411 19.871583-104.667851 59.616796-144.414087 39.733956-39.746236 87.88256-59.611679 144.411017-59.611679 56.529481 0 104.678084 19.865443 144.413064 59.611679C696.156742 111.48921 716.021162 159.631674 716.021162 216.158085L716.021162 216.158085 716.021162 216.158085 716.021162 216.158085z"
p-id="11319"></path>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg t="1777312670502" class="icon" viewBox="0 0 1084 1024" version="1.1"
xmlns="http://www.w3.org/2000/svg" p-id="7125" xmlns:xlink="http://www.w3.org/1999/xlink"
width="211.71875" height="200">
<path
d="M421.647059 542.117647v481.882353h662.588235V542.117647H421.647059z m602.352941 421.647059h-542.117647V602.352941h542.117647v361.411765z"
p-id="7126"></path>
<path
d="M975.811765 789.082353L849.317647 662.588235l-42.164706 42.164706 84.329412 84.329412 42.164706 42.164706zM710.354824 876.182588l-42.586353 42.586353-85.172706-85.172706 42.586353-42.646588zM626.447059 789.082353l84.329412-84.329412-42.164706-42.164706-126.494118 126.494118 42.164706 42.164706zM933.948235 833.234824l-85.172706 85.232941-42.586353-42.646589 85.172706-85.172705zM783.058824 240.941176c0-132.517647-108.423529-240.941176-240.941177-240.941176S301.176471 108.423529 301.176471 240.941176s108.423529 240.941176 240.941176 240.941177 240.941176-108.423529 240.941177-240.941177z m-421.647059 6.02353c0-102.4 78.305882-180.705882 180.705882-180.705882s180.705882 78.305882 180.705882 180.705882-78.305882 180.705882-180.705882 180.705882c-96.376471 0-180.705882-84.329412-180.705882-180.705882zM325.270588 481.882353C132.517647 536.094118 0 716.8 0 915.576471c0 18.070588 18.070588 30.117647 36.141176 30.117647s36.141176-12.047059 36.141177-30.117647C66.258824 740.894118 180.705882 590.305882 343.341176 542.117647c18.070588-6.023529 30.117647-24.094118 24.094118-42.164706-6.023529-12.047059-24.094118-24.094118-42.164706-18.070588z"
p-id="7127"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg t="1777312566509" class="icon" viewBox="0 0 1024 1024" version="1.1"
xmlns="http://www.w3.org/2000/svg" p-id="3770" xmlns:xlink="http://www.w3.org/1999/xlink"
width="200" height="200">
<path
d="M647.499 60.657H375.167c-89.602 0-162.497 72.895-162.497 162.497v573.907c0 89.602 72.895 162.497 162.497 162.497h272.332c89.602 0 162.497-72.895 162.497-162.497V223.154c0.001-89.602-72.895-162.497-162.497-162.497z m108.332 736.404c0 59.733-48.598 108.331-108.331 108.331H375.167c-59.733 0-108.331-48.598-108.331-108.331V223.154c0-59.733 48.598-108.332 108.331-108.332h56.168c0.15 14.827 12.193 26.81 27.055 26.81h105.885c14.863 0 26.905-11.983 27.055-26.81h56.168c59.733 0 108.331 48.598 108.331 108.332v573.907z"
p-id="3771"></path>
<path
d="M564.276 819.304H458.391c-14.956 0-27.083 12.126-27.083 27.083s12.126 27.083 27.083 27.083h105.885c14.956 0 27.083-12.126 27.083-27.083s-12.127-27.083-27.083-27.083z"
p-id="3772"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1777856178349" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4344" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M835.3 250.9v522.3H188.7V250.9h646.6m49.8-49.8H138.9v621.8H885V201.1h0.1z" p-id="4345"></path><path d="M138.9 101.6v99.5H64.3v49.8H188.7V101.6zM885.1 922.4v-99.5h74.6v-49.8H835.3v149.3zM511 705.4H297.5c-7.8 0-20.4-2.9-23.3-7.8-2.9-4.9 1.9-16.5 5.8-23.3 26.2-39.8 53.4-79.6 79.6-119.4 8.7-12.6 17.5-13.6 31.1-7.8 44.6 21.4 90.3 42.7 135.9 64.1 12.6 5.8 28.1 1.9 35.9-9.7 24.3-37.9 49.5-74.7 75.7-111.6 4.9-6.8 13.6-17.5 20.4-17.5 7.8 0 13.6 12.6 16.5 20.4 24.3 60.2 47.6 121.3 70.9 181.5 9.7 25.2 5.8 31.1-23.3 31.1H511z m-78.6-298c0 29.1-25.2 52.4-54.4 51.4-29.1-1-51.4-23.3-51.4-51.4 0-29.1 24.3-52.4 54.4-51.4 27.1 0.9 51.4 24.2 51.4 51.4z" p-id="4346"></path></svg>

After

Width:  |  Height:  |  Size: 1001 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -0,0 +1,977 @@
<template>
<div
class="rounded bg-[var(--el-bg-color)] border border-[var(--el-border-color)] p-5 h-full md:flex flex-1 flex-col md:overflow-auto"
>
<!-- 表格工具 -->
<div class="flex flex-col md:flex-row justify-between gap-y-2.5 mb-2.5">
<!-- 左侧工具 -->
<div class="toolbar-left flex gap-y-2.5 gap-x-2 md:gap-x-3 flex-wrap">
<template v-for="(btn, index) in toolbarLeftBtn" :key="index">
<el-button
v-hasPerm="btn.perm ?? '*:*:*'"
v-bind="btn.attrs"
:disabled="btn.name === 'delete' && removeIds.length === 0"
@click="handleToolbar(btn.name)"
>
{{ btn.text }}
</el-button>
</template>
</div>
<!-- 右侧工具 -->
<div class="toolbar-right flex gap-y-2.5 gap-x-2 md:gap-x-3 flex-wrap">
<template v-for="(btn, index) in toolbarRightBtn" :key="index">
<el-popover v-if="btn.name === 'filter'" placement="bottom" trigger="click">
<template #reference>
<el-button v-bind="btn.attrs"></el-button>
</template>
<el-scrollbar max-height="350px">
<template v-for="col in cols" :key="col.prop">
<el-checkbox v-if="col.prop" v-model="col.show" :label="col.label" />
</template>
</el-scrollbar>
</el-popover>
<el-button
v-else
v-hasPerm="btn.perm ?? '*:*:*'"
v-bind="btn.attrs"
@click="handleToolbar(btn.name)"
></el-button>
</template>
</div>
</div>
<!-- 列表 -->
<el-table
ref="tableRef"
v-loading="loading"
v-bind="contentConfig.table"
:data="pageData"
:row-key="pk"
class="flex-1"
@selection-change="handleSelectionChange"
@filter-change="handleFilterChange"
>
<template v-for="col in cols" :key="col.prop">
<el-table-column v-if="col.show" v-bind="col">
<template #default="scope">
<!-- 显示图片 -->
<template v-if="col.templet === 'image'">
<template v-if="col.prop">
<template v-if="Array.isArray(scope.row[col.prop])">
<template v-for="(item, index) in scope.row[col.prop]" :key="item">
<el-image
:src="item"
:preview-src-list="scope.row[col.prop]"
:initial-index="Number(index)"
:preview-teleported="true"
:style="`width: ${col.imageWidth ?? 40}px; height: ${col.imageHeight ?? 40}px`"
/>
</template>
</template>
<template v-else>
<el-image
:src="scope.row[col.prop]"
:preview-src-list="[scope.row[col.prop]]"
:preview-teleported="true"
:style="`width: ${col.imageWidth ?? 40}px; height: ${col.imageHeight ?? 40}px`"
/>
</template>
</template>
</template>
<!-- 根据行的selectList属性返回对应列表 -->
<template v-else-if="col.templet === 'list'">
<template v-if="col.prop">
{{ (col.selectList ?? {})[scope.row[col.prop]] }}
</template>
</template>
<!-- 格式化显示链接 -->
<template v-else-if="col.templet === 'url'">
<template v-if="col.prop">
<el-link type="primary" :href="scope.row[col.prop]" target="_blank">
{{ scope.row[col.prop] }}
</el-link>
</template>
</template>
<!-- 生成开关组 -->
<template v-else-if="col.templet === 'switch'">
<template v-if="col.prop">
<!-- pageData.length>0: 解决el-switch组件会在表格初始化的时候触发一次change事件 -->
<el-switch
v-model="scope.row[col.prop]"
:active-value="col.activeValue ?? 1"
:inactive-value="col.inactiveValue ?? 0"
:inline-prompt="true"
:active-text="col.activeText ?? ''"
:inactive-text="col.inactiveText ?? ''"
:validate-event="false"
:disabled="!hasButtonPerm(col.prop)"
@change="
pageData.length > 0 && handleModify(col.prop, scope.row[col.prop], scope.row)
"
/>
</template>
</template>
<!-- 生成输入框组 -->
<template v-else-if="col.templet === 'input'">
<template v-if="col.prop">
<el-input
v-model="scope.row[col.prop]"
:type="col.inputType ?? 'text'"
:disabled="!hasButtonPerm(col.prop)"
@blur="handleModify(col.prop, scope.row[col.prop], scope.row)"
/>
</template>
</template>
<!-- 格式化为价格 -->
<template v-else-if="col.templet === 'price'">
<template v-if="col.prop">
{{ `${col.priceFormat ?? ""}${scope.row[col.prop]}` }}
</template>
</template>
<!-- 格式化为百分比 -->
<template v-else-if="col.templet === 'percent'">
<template v-if="col.prop">{{ scope.row[col.prop] }}%</template>
</template>
<!-- 显示图标 -->
<template v-else-if="col.templet === 'icon'">
<template v-if="col.prop">
<template v-if="scope.row[col.prop].startsWith('el-icon-')">
<el-icon>
<component :is="scope.row[col.prop].replace('el-icon-', '')" />
</el-icon>
</template>
<template v-else>
<div class="i-svg:{{ scope.row[col.prop] }}" />
</template>
</template>
</template>
<!-- 格式化时间 -->
<template v-else-if="col.templet === 'date'">
<template v-if="col.prop">
{{
scope.row[col.prop]
? useDateFormat(scope.row[col.prop], col.dateFormat ?? "YYYY-MM-DD HH:mm:ss")
.value
: ""
}}
</template>
</template>
<!-- 列操作栏 -->
<template v-else-if="col.templet === 'tool'">
<div class="flex items-center justify-center gap-x-2">
<template v-for="(btn, index) in tableToolbarBtn" :key="index">
<el-dropdown v-if="btn.children !== undefined && btn.children.length > 0">
<el-button
v-if="btn.render === undefined || btn.render(scope.row)"
v-hasPerm="btn.perm ?? '*:*:*'"
v-bind="btn.attrs"
>
{{ btn.text }}
<el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="(item, index1) in btn.children"
:key="index1"
:name="item.name"
v-bind="item.attrs"
@click="
handleOperate({
name: item.name,
row: scope.row,
column: scope.column,
$index: scope.$index,
})
"
>
{{ item.text }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button
v-else-if="btn.render === undefined || btn.render(scope.row)"
v-hasPerm="btn.perm ?? '*:*:*'"
v-bind="btn.attrs"
@click="
handleOperate({
name: btn.name,
row: scope.row,
column: scope.column,
$index: scope.$index,
})
"
>
{{ btn.text }}
</el-button>
</template>
</div>
</template>
<!-- 自定义 -->
<template v-else-if="col.templet === 'custom'">
<slot :name="col.slotName ?? col.prop" :prop="col.prop" v-bind="scope" />
</template>
</template>
</el-table-column>
</template>
</el-table>
<!-- 分页 -->
<div v-if="showPagination" class="mt-4">
<el-scrollbar :class="['h-8!', { 'flex-x-end': contentConfig?.pagePosition === 'right' }]">
<el-pagination
v-bind="pagination"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</el-scrollbar>
</div>
<!-- 导出弹窗 -->
<el-dialog
v-model="exportsModalVisible"
:align-center="true"
title="导出数据"
width="600px"
style="padding-right: 0"
@close="handleCloseExportsModal"
>
<!-- 滚动 -->
<el-scrollbar max-height="60vh">
<!-- 表单 -->
<el-form
ref="exportsFormRef"
style="padding-right: var(--el-dialog-padding-primary)"
:model="exportsFormData"
:rules="exportsFormRules"
>
<el-form-item label="文件名" prop="filename">
<el-input v-model="exportsFormData.filename" clearable />
</el-form-item>
<el-form-item label="工作表名" prop="sheetname">
<el-input v-model="exportsFormData.sheetname" clearable />
</el-form-item>
<el-form-item label="数据来源" prop="origin">
<el-select v-model="exportsFormData.origin">
<el-option label="当前数据 (当前页的数据)" :value="ExportsOriginEnum.CURRENT" />
<el-option
label="选中数据 (所有选中的数据)"
:value="ExportsOriginEnum.SELECTED"
:disabled="selectionData.length <= 0"
/>
<el-option
label="全量数据 (所有分页的数据)"
:value="ExportsOriginEnum.REMOTE"
:disabled="contentConfig.exportsAction === undefined"
/>
</el-select>
</el-form-item>
<el-form-item label="字段" prop="fields">
<el-checkbox-group v-model="exportsFormData.fields">
<template v-for="col in cols" :key="col.prop">
<el-checkbox v-if="col.prop" :value="col.prop" :label="col.label" />
</template>
</el-checkbox-group>
</el-form-item>
</el-form>
</el-scrollbar>
<!-- 弹窗底部操作按钮 -->
<template #footer>
<div style="padding-right: var(--el-dialog-padding-primary)">
<el-button type="primary" @click="handleExportsSubmit">确定</el-button>
<el-button @click="handleCloseExportsModal">取消</el-button>
</div>
</template>
</el-dialog>
<!-- 导入弹窗 -->
<el-dialog
v-model="importModalVisible"
:align-center="true"
title="导入数据"
width="600px"
style="padding-right: 0"
@close="handleCloseImportModal"
>
<!-- 滚动 -->
<el-scrollbar max-height="60vh">
<!-- 表单 -->
<el-form
ref="importFormRef"
style="padding-right: var(--el-dialog-padding-primary)"
:model="importFormData"
:rules="importFormRules"
>
<el-form-item label="文件" prop="files">
<el-upload
ref="uploadRef"
v-model:file-list="importFormData.files"
class="w-full"
accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel"
:drag="true"
:limit="1"
:auto-upload="false"
:on-exceed="handleFileExceed"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
<span>将文件拖到此处或点击上传</span>
<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
*.xlsx / *.xls
<el-link
v-if="contentConfig.importTemplate"
type="primary"
icon="download"
underline="never"
@click="handleDownloadTemplate"
>
下载模板
</el-link>
</div>
</template>
</el-upload>
</el-form-item>
</el-form>
</el-scrollbar>
<!-- 弹窗底部操作按钮 -->
<template #footer>
<div style="padding-right: var(--el-dialog-padding-primary)">
<el-button
type="primary"
:disabled="importFormData.files.length === 0"
@click="handleImportSubmit"
>
确定
</el-button>
<el-button @click="handleCloseImportModal">取消</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { hasPerm } from "@/utils/auth";
import { useDateFormat, useThrottleFn } from "@vueuse/core";
import {
genFileId,
type FormInstance,
type FormRules,
type UploadInstance,
type UploadRawFile,
type UploadUserFile,
type TableInstance,
} from "element-plus";
import ExcelJS from "exceljs";
import { reactive, ref, computed } from "vue";
import type { IContentConfig, IObject, IOperateData } from "./types";
import type { IToolsButton } from "./types";
// 定义接收的属性
const props = defineProps<{ contentConfig: IContentConfig }>();
// 定义自定义事件
const emit = defineEmits<{
addClick: [];
exportClick: [];
searchClick: [];
toolbarClick: [name: string];
editClick: [row: IObject];
filterChange: [data: IObject];
operateClick: [data: IOperateData];
}>();
// 表格工具栏按钮配置
const config = computed(() => props.contentConfig);
const buttonConfig = reactive<Record<string, IObject>>({
add: { text: "新增", attrs: { icon: "plus", type: "success" }, perm: "add" },
delete: { text: "删除", attrs: { icon: "delete", type: "danger" }, perm: "delete" },
import: { text: "导入", attrs: { icon: "upload", type: "" }, perm: "import" },
export: { text: "导出", attrs: { icon: "download", type: "" }, perm: "export" },
refresh: { text: "刷新", attrs: { icon: "refresh", type: "" }, perm: "*:*:*" },
filter: { text: "筛选列", attrs: { icon: "operation", type: "" }, perm: "*:*:*" },
search: { text: "搜索", attrs: { icon: "search", type: "" }, perm: "search" },
imports: { text: "批量导入", attrs: { icon: "upload", type: "" }, perm: "imports" },
exports: { text: "批量导出", attrs: { icon: "download", type: "" }, perm: "exports" },
view: { text: "查看", attrs: { icon: "view", type: "primary" }, perm: "view" },
edit: { text: "编辑", attrs: { icon: "edit", type: "primary" }, perm: "edit" },
});
// 主键
const pk = props.contentConfig.pk ?? "id";
// 权限名称前缀
const authPrefix = computed(() => props.contentConfig.permPrefix);
// 获取按钮权限标识
function getButtonPerm(action: string): string | null {
// 如果action已经包含完整路径(包含冒号),则直接使用
if (action.includes(":")) {
return action;
}
// 否则使用权限前缀组合
return authPrefix.value ? `${authPrefix.value}:${action}` : null;
}
// 检查是否有权限
function hasButtonPerm(action: string): boolean {
const perm = getButtonPerm(action);
// 如果没有设置权限标识,则默认具有权限
if (!perm) return true;
return hasPerm(perm);
}
// 创建工具栏按钮
function createToolbar(toolbar: Array<string | IToolsButton>, attr = {}) {
return toolbar.map((item) => {
const isString = typeof item === "string";
return {
name: isString ? item : item?.name || "",
text: isString ? buttonConfig[item].text : item?.text,
attrs: {
...attr,
...(isString ? buttonConfig[item].attrs : item?.attrs),
},
render: isString ? undefined : (item?.render ?? undefined),
perm: isString
? getButtonPerm(buttonConfig[item].perm)
: item?.perm
? getButtonPerm(item.perm as string)
: "*:*:*",
children: isString ? undefined : (item?.children ?? undefined),
};
});
}
// 左侧工具栏按钮
const toolbarLeftBtn = computed(() => {
if (!config.value.toolbar || config.value.toolbar.length === 0) return [];
return createToolbar(config.value.toolbar, {});
});
// 右侧工具栏按钮
const toolbarRightBtn = computed(() => {
if (!config.value.defaultToolbar || config.value.defaultToolbar.length === 0) return [];
return createToolbar(config.value.defaultToolbar, { circle: true });
});
// 表格操作工具栏
const tableToolbar = config.value.cols[config.value.cols.length - 1].operat ?? ["edit", "delete"];
const tableToolbarBtn = createToolbar(tableToolbar, { link: true, size: "small" });
// 表格相关
const cols = ref(
props.contentConfig.cols.map((col) => {
if (col.initFn) {
col.initFn(col);
}
if (col.show === undefined) {
col.show = true;
}
if (col.prop !== undefined && col.columnKey === undefined && col["column-key"] === undefined) {
col.columnKey = col.prop;
}
if (
col.type === "selection" &&
col.reserveSelection === undefined &&
col["reserve-selection"] === undefined
) {
// 配合表格row-key实现跨页多选
col.reserveSelection = true;
}
return col;
})
);
// 加载状态
const loading = ref(false);
// 列表数据
const pageData = ref<IObject[]>([]);
// 显示分页
const showPagination = props.contentConfig.pagination !== false;
// 分页配置
const defaultPagination = {
background: true,
layout: "total, sizes, prev, pager, next, jumper",
pageSize: 20,
pageSizes: [10, 20, 30, 50],
total: 0,
currentPage: 1,
};
const pagination = reactive(
typeof props.contentConfig.pagination === "object"
? { ...defaultPagination, ...props.contentConfig.pagination }
: defaultPagination
);
// 分页相关的请求参数
const request = props.contentConfig.request ?? {
pageName: "pageNum",
limitName: "pageSize",
};
const tableRef = ref<TableInstance>();
// 行选中
const selectionData = ref<IObject[]>([]);
// 删除ID集合 用于批量删除
const removeIds = ref<(number | string)[]>([]);
function handleSelectionChange(selection: IObject[]) {
selectionData.value = selection;
removeIds.value = selection.map((item) => item[pk]);
}
// 获取行选中
function getSelectionData() {
return selectionData.value;
}
// 刷新
function handleRefresh(isRestart = false) {
fetchPageData(lastFormData, isRestart);
}
// 删除
function handleDelete(id?: number | string) {
const ids = [id || removeIds.value].join(",");
if (!ids) {
ElMessage.warning("请勾选删除项");
return;
}
ElMessageBox.confirm("确认删除?", "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}).then(
function () {
if (props.contentConfig.deleteAction) {
props.contentConfig.deleteAction(ids).then(
() => {
ElMessage.success("删除成功");
removeIds.value = [];
// 清空选中项
tableRef.value?.clearSelection();
handleRefresh(true);
},
() => {
// 交由全局错误处理
}
);
} else {
ElMessage.error("未配置deleteAction");
}
},
() => {
// 用户取消
}
);
}
// 导出表单
const fields: string[] = [];
cols.value.forEach((item) => {
if (item.prop !== undefined) {
fields.push(item.prop);
}
});
const enum ExportsOriginEnum {
CURRENT = "current",
SELECTED = "selected",
REMOTE = "remote",
}
const exportsModalVisible = ref(false);
const exportsFormRef = ref<FormInstance>();
const exportsFormData = reactive({
filename: "",
sheetname: "",
fields,
origin: ExportsOriginEnum.CURRENT,
});
const exportsFormRules: FormRules = {
fields: [{ required: true, message: "请选择字段" }],
origin: [{ required: true, message: "请选择数据来源" }],
};
// 打开导出弹窗
function handleOpenExportsModal() {
exportsModalVisible.value = true;
}
// 导出确认
const handleExportsSubmit = useThrottleFn(() => {
exportsFormRef.value?.validate((valid: boolean) => {
if (valid) {
handleExports();
handleCloseExportsModal();
}
});
}, 3000);
// 关闭导出弹窗
function handleCloseExportsModal() {
exportsModalVisible.value = false;
exportsFormRef.value?.resetFields();
nextTick(() => {
exportsFormRef.value?.clearValidate();
});
}
// 导出
function handleExports() {
const filename = exportsFormData.filename
? exportsFormData.filename
: props.contentConfig.permPrefix || "export";
const sheetname = exportsFormData.sheetname ? exportsFormData.sheetname : "sheet";
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet(sheetname);
const columns: Partial<ExcelJS.Column>[] = [];
cols.value.forEach((col) => {
if (col.label && col.prop && exportsFormData.fields.includes(col.prop)) {
columns.push({ header: col.label, key: col.prop });
}
});
worksheet.columns = columns;
if (exportsFormData.origin === ExportsOriginEnum.REMOTE) {
if (props.contentConfig.exportsAction) {
props.contentConfig.exportsAction(lastFormData).then((data) => {
worksheet.addRows(data);
workbook.xlsx.writeBuffer().then(
(buffer) => {
saveXlsx(buffer, filename as string);
},
(error) => console.error(error)
);
});
} else {
ElMessage.error("未配置exportsAction");
}
} else {
worksheet.addRows(
exportsFormData.origin === ExportsOriginEnum.SELECTED ? selectionData.value : pageData.value
);
workbook.xlsx.writeBuffer().then(
(buffer) => {
saveXlsx(buffer, filename as string);
},
(error) => console.error(error)
);
}
}
// 导入表单
let isFileImport = false;
const uploadRef = ref<UploadInstance>();
const importModalVisible = ref(false);
const importFormRef = ref<FormInstance>();
const importFormData = reactive<{
files: UploadUserFile[];
}>({
files: [],
});
const importFormRules: FormRules = {
files: [{ required: true, message: "请选择文件" }],
};
// 打开导入弹窗
function handleOpenImportModal(isFile: boolean = false) {
importModalVisible.value = true;
isFileImport = isFile;
}
// 覆盖前一个文件
function handleFileExceed(files: File[]) {
uploadRef.value!.clearFiles();
const file = files[0] as UploadRawFile;
file.uid = genFileId();
uploadRef.value!.handleStart(file);
}
// 下载导入模板
function handleDownloadTemplate() {
const importTemplate = props.contentConfig.importTemplate;
if (typeof importTemplate === "string") {
window.open(importTemplate);
} else if (typeof importTemplate === "function") {
importTemplate().then((response) => {
const fileData = response.data;
const fileName = decodeURI(
response.headers["content-disposition"].split(";")[1].split("=")[1]
);
saveXlsx(fileData, fileName);
});
} else {
ElMessage.error("未配置importTemplate");
}
}
// 导入确认
const handleImportSubmit = useThrottleFn(() => {
importFormRef.value?.validate((valid: boolean) => {
if (valid) {
if (isFileImport) {
handleImport();
} else {
handleImports();
}
}
});
}, 3000);
// 关闭导入弹窗
function handleCloseImportModal() {
importModalVisible.value = false;
importFormRef.value?.resetFields();
nextTick(() => {
importFormRef.value?.clearValidate();
});
}
// 文件导入
function handleImport() {
const importAction = props.contentConfig.importAction;
if (importAction === undefined) {
ElMessage.error("未配置importAction");
return;
}
importAction(importFormData.files[0].raw as File).then(() => {
ElMessage.success("导入数据成功");
handleCloseImportModal();
handleRefresh(true);
});
}
// 导入
function handleImports() {
const importsAction = props.contentConfig.importsAction;
if (importsAction === undefined) {
ElMessage.error("未配置importsAction");
return;
}
// 获取选择的文件
const file = importFormData.files[0].raw as File;
// 创建Workbook实例
const workbook = new ExcelJS.Workbook();
// 使用FileReader对象来读取文件内容
const fileReader = new FileReader();
// 二进制字符串的形式加载文件
fileReader.readAsArrayBuffer(file);
fileReader.onload = (ev) => {
if (ev.target !== null && ev.target.result !== null) {
const result = ev.target.result as ArrayBuffer;
// 从 buffer 中加载并解析数据
workbook.xlsx.load(result).then(
(workbook) => {
// 解析后的数据
const data = [];
// 获取第一个worksheet内容
const worksheet = workbook.getWorksheet(1);
if (worksheet) {
// 获取第一行的标题
const fields: string[] = [];
worksheet.getRow(1).eachCell((cell) => {
fields.push(String(cell.value));
});
// 遍历工作表的每一行(从第二行开始,因为第一行通常是标题行)
for (let rowNumber = 2; rowNumber <= worksheet.rowCount; rowNumber++) {
const rowData: IObject = {};
const row = worksheet.getRow(rowNumber);
// 遍历当前行的每个单元格
row.eachCell((cell, colNumber) => {
// 获取标题对应的键,并将当前单元格的值存储到相应的属性名中
rowData[fields[colNumber - 1]] = cell.value;
});
// 将当前行的数据对象添加到数组中
data.push(rowData);
}
}
if (data.length === 0) {
ElMessage.error("未解析到数据");
return;
}
importsAction(data).then(() => {
ElMessage.success("导入数据成功");
handleCloseImportModal();
handleRefresh(true);
});
},
(error) => console.error(error)
);
} else {
ElMessage.error("读取文件失败");
}
};
}
// 操作人"
function handleToolbar(name: string) {
switch (name) {
case "refresh":
handleRefresh();
break;
case "exports":
handleOpenExportsModal();
break;
case "imports":
handleOpenImportModal();
break;
case "search":
emit("searchClick");
break;
case "add":
emit("addClick");
break;
case "delete":
handleDelete();
break;
case "import":
handleOpenImportModal(true);
break;
case "export":
emit("exportClick");
break;
default:
emit("toolbarClick", name);
break;
}
}
// 操作人"
function handleOperate(data: IOperateData) {
switch (data.name) {
case "delete":
if (props.contentConfig?.deleteAction) {
handleDelete(data.row[pk]);
} else {
emit("operateClick", data);
}
break;
default:
emit("operateClick", data);
break;
}
}
// 属性修改
function handleModify(
field: string,
value: boolean | string | number,
row: Record<string, IObject>
) {
if (props.contentConfig.modifyAction) {
props.contentConfig.modifyAction({
[pk]: row[pk],
field,
value,
});
} else {
ElMessage.error("未配置modifyAction");
}
}
// 分页切换
function handleSizeChange(value: number) {
pagination.pageSize = value;
handleRefresh();
}
function handleCurrentChange(value: number) {
pagination.currentPage = value;
handleRefresh();
}
// 远程数据筛选
let filterParams: IObject = {};
function handleFilterChange(newFilters: IObject) {
const filters: IObject = {};
for (const key in newFilters) {
const col = cols.value.find((col) => {
return col.columnKey === key || col["column-key"] === key;
});
if (col && col.filterJoin !== undefined) {
filters[key] = newFilters[key].join(col.filterJoin);
} else {
filters[key] = newFilters[key];
}
}
filterParams = { ...filterParams, ...filters };
emit("filterChange", filterParams);
}
// 获取筛选条件
function getFilterParams() {
return filterParams;
}
// 获取分页数据
let lastFormData = {};
function fetchPageData(formData: IObject = {}, isRestart = false) {
loading.value = true;
// 上一次搜索条件
lastFormData = formData;
// 重置页码
if (isRestart) {
pagination.currentPage = 1;
}
props.contentConfig
.indexAction(
showPagination
? {
[request.pageName]: pagination.currentPage,
[request.limitName]: pagination.pageSize,
...getFilterParams(),
...formData,
}
: {
...getFilterParams(),
...formData,
}
)
.then((data) => {
if (showPagination) {
pagination.total = (data as IObject)?.total ?? 0;
pageData.value = (data as IObject)?.list ?? [];
} else {
pageData.value = Array.isArray(data) ? data : (data?.list ?? (data as IObject)?.data ?? []);
}
})
.finally(() => {
loading.value = false;
});
}
fetchPageData();
// 导出Excel
function exportPageData(formData: IObject = {}) {
if (props.contentConfig.exportAction) {
props.contentConfig.exportAction(formData).then((response) => {
const fileData = response.data;
const fileName = decodeURI(
response.headers["content-disposition"].split(";")[1].split("=")[1]
);
saveXlsx(fileData, fileName);
});
} else {
ElMessage.error("未配置exportAction");
}
}
// 浏览器保存文件
function saveXlsx(fileData: BlobPart, fileName: string) {
const fileType =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8";
const blob = new Blob([fileData], { type: fileType });
const downloadUrl = window.URL.createObjectURL(blob);
const downloadLink = document.createElement("a");
downloadLink.href = downloadUrl;
downloadLink.download = fileName;
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
window.URL.revokeObjectURL(downloadUrl);
}
// 暴露的属性和方法
defineExpose({ fetchPageData, exportPageData, getFilterParams, getSelectionData, handleRefresh });
</script>
<style lang="scss" scoped>
.toolbar-left,
.toolbar-right {
.el-button {
margin-right: 0 !important;
margin-left: 0 !important;
}
}
</style>

View File

@@ -0,0 +1,274 @@
<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

@@ -0,0 +1,169 @@
<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

@@ -0,0 +1,735 @@
<template>
<div class="device-detail">
<!-- 设备头部信息 -->
<div class="device-header">
<div class="device-info">
<img :src="deviceImage" alt="设备图片" class="device-image" />
<div class="info-content">
<div class="info-row">
<span class="device-name">{{ deviceData.snName }}</span>
<el-tag type="success" size="small">
{{ deviceData.onlineStatus == 1 ? "在线" : "离线" }}
</el-tag>
</div>
<div class="info-grid">
<div class="info-item">
<span class="label">序列号</span>
<span class="value">{{ props.serialno }}</span>
</div>
<div class="info-item">
<span class="label">IMEI</span>
<span class="value">{{ deviceData.imei }}</span>
</div>
<div class="info-item">
<span class="label">设备所有者</span>
<span class="value">{{ deviceData.owner }}</span>
</div>
<div class="info-item">
<span class="label">所属部门</span>
<span class="value">{{ deviceData.department }}</span>
</div>
<div class="info-item">
<span class="label">分组</span>
<span class="value">{{ deviceData.group }}</span>
</div>
<div class="info-item">
<span class="label">设备类型</span>
<span class="value">{{ deviceData.deviceType }}</span>
</div>
<div class="info-item">
<span class="label">设备来源</span>
<span class="value">{{ deviceData.source }}</span>
</div>
<div class="info-item">
<span class="label">激活时间</span>
<span class="value">{{ deviceData.activationTime }}</span>
</div>
<div class="info-item">
<span class="label">最后上线</span>
<span class="value">{{ deviceData.lastOnline }}</span>
</div>
<div class="info-item">
<span class="label">设备编号</span>
<span class="value">{{ deviceData.deviceSn }}</span>
</div>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="action-buttons">
<el-button @click="handleReturn">
<el-icon><Back /></el-icon>
返回
</el-button>
<el-button @click="handleRefresh">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
<el-button type="primary" @click="handleEdit">
<el-icon><Edit /></el-icon>
编辑
</el-button>
<div class="secondary-actions">
<el-button text @click="handleRemoteLock">
<el-icon><Lock /></el-icon>
远程锁定
</el-button>
<el-button text @click="handleLostMode">
<el-icon><Warning /></el-icon>
丢失模式
</el-button>
<el-button text @click="handleWipeDevice">
<el-icon><Delete /></el-icon>
清除设备
</el-button>
<el-button text @click="handleRestartDevice">
<el-icon><RefreshRight /></el-icon>
重启设备
</el-button>
<el-button text @click="handleSyncDevice">
<el-icon><Refresh /></el-icon>
同步设备
</el-button>
<el-dropdown trigger="click" @command="handleMoreActions">
<el-button text>
更多操作
<el-icon><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="assign">分配设备</el-dropdown-item>
<el-dropdown-item command="transfer">转移设备</el-dropdown-item>
<el-dropdown-item command="batch">批量操作</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
<!-- 标签页内容 -->
<div class="tabs-container">
<el-tabs v-model="activeTab" class="device-tabs">
<el-tab-pane label="设备信息" name="deviceInfo">
<div class="tab-content">
<!-- 基本信息 -->
<div class="info-section">
<h3 class="section-title">基本信息</h3>
<el-descriptions :column="2" border>
<el-descriptions-item label="设备名称">
{{ basicInfo.deviceName }}
</el-descriptions-item>
<el-descriptions-item label="设备型号">
{{ basicInfo.deviceModel }}
</el-descriptions-item>
<el-descriptions-item label="操作系统">{{ basicInfo.os }}</el-descriptions-item>
<el-descriptions-item label="操作系统版本号">
{{ basicInfo.osVersion }}
</el-descriptions-item>
<el-descriptions-item label="设备语言">
{{ basicInfo.language }}
</el-descriptions-item>
<el-descriptions-item label="时区">{{ basicInfo.timezone }}</el-descriptions-item>
<el-descriptions-item label="设备标识(IMEI)">
{{ basicInfo.imei }}
</el-descriptions-item>
<el-descriptions-item label="蓝牙地址">
{{ basicInfo.bluetoothMac }}
</el-descriptions-item>
<el-descriptions-item label="Wi-Fi MAC地址">
{{ basicInfo.wifiMac }}
</el-descriptions-item>
<el-descriptions-item label="设备颜色">{{ basicInfo.color }}</el-descriptions-item>
<el-descriptions-item label="设备容量">
{{ basicInfo.capacity }}
</el-descriptions-item>
<el-descriptions-item label="可用容量">
{{ basicInfo.availableCapacity }}
</el-descriptions-item>
<el-descriptions-item label="内存容量">
{{ basicInfo.memoryCapacity }}
</el-descriptions-item>
<el-descriptions-item label="可用内存">
{{ basicInfo.availableMemory }}
</el-descriptions-item>
<el-descriptions-item label="电池电量">
{{ basicInfo.batteryLevel }}
</el-descriptions-item>
<el-descriptions-item label="电池健康度">
{{ basicInfo.batteryHealth }}
</el-descriptions-item>
<el-descriptions-item label="设备重量">{{ basicInfo.weight }}</el-descriptions-item>
<el-descriptions-item label="设备尺寸">
{{ basicInfo.dimensions }}
</el-descriptions-item>
<el-descriptions-item
label="SIM卡状态
"
>
{{ basicInfo.simStatus }}
</el-descriptions-item>
<el-descriptions-item label="电话号码">
{{ basicInfo.phoneNumber }}
</el-descriptions-item>
<el-descriptions-item label="ICCID">
{{ basicInfo.iccid }}
</el-descriptions-item>
<el-descriptions-item label="MEID">
{{ basicInfo.meid }}
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 硬件信息 -->
<div class="info-section">
<h3 class="section-title">硬件信息</h3>
<el-descriptions :column="2" border>
<el-descriptions-item label="处理器">
{{ hardwareInfo.processor }}
</el-descriptions-item>
<el-descriptions-item label="处理器核心数">
{{ hardwareInfo.processorCores }}
</el-descriptions-item>
<el-descriptions-item label="GPU">
{{ hardwareInfo.gpu }}
</el-descriptions-item>
<el-descriptions-item label="屏幕尺寸">
{{ hardwareInfo.screenSize }}
</el-descriptions-item>
<el-descriptions-item label="屏幕分辨率">
{{ hardwareInfo.screenResolution }}
</el-descriptions-item>
<el-descriptions-item label="屏幕像素密度">
{{ hardwareInfo.screenDensity }}
</el-descriptions-item>
<el-descriptions-item label="摄像头">
{{ hardwareInfo.camera }}
</el-descriptions-item>
<el-descriptions-item label="设备传感器">
{{ hardwareInfo.sensors }}
</el-descriptions-item>
<el-descriptions-item label="NFC">
{{ hardwareInfo.nfc }}
</el-descriptions-item>
<el-descriptions-item label="Face ID">
{{ hardwareInfo.faceId }}
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 网络信息 -->
<div class="info-section">
<h3 class="section-title">网络信息</h3>
<el-descriptions :column="2" border>
<el-descriptions-item label="当前网络">
{{ networkInfo.currentNetwork }}
</el-descriptions-item>
<el-descriptions-item label="运营商">
{{ networkInfo.carrier }}
</el-descriptions-item>
<el-descriptions-item label="信号强度">
{{ networkInfo.signalStrength }}
</el-descriptions-item>
<el-descriptions-item label="IP地址">
{{ networkInfo.ipAddress }}
</el-descriptions-item>
<el-descriptions-item label="Wi-Fi名称">
{{ networkInfo.wifiName }}
</el-descriptions-item>
<el-descriptions-item label="Wi-Fi MAC地址">
{{ networkInfo.wifiMac }}
</el-descriptions-item>
<el-descriptions-item label="蜂窝网络类型">
{{ networkInfo.cellularType }}
</el-descriptions-item>
<el-descriptions-item label="漫游状态">
{{ networkInfo.roamingStatus }}
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 安全信息 -->
<div class="info-section">
<h3 class="section-title">安全信息</h3>
<el-descriptions :column="2" border>
<el-descriptions-item label="锁屏密码">
{{ securityInfo.lockScreenPassword }}
</el-descriptions-item>
<el-descriptions-item label="密码复杂度">
{{ securityInfo.passwordComplexity }}
</el-descriptions-item>
<el-descriptions-item label="数据保护">
{{ securityInfo.dataProtection }}
</el-descriptions-item>
<el-descriptions-item label="查找我的iPhone">
{{ securityInfo.findMyIPhone }}
</el-descriptions-item>
<el-descriptions-item label="设备加密">
{{ securityInfo.deviceEncryption }}
</el-descriptions-item>
<el-descriptions-item label="越狱状态">
{{ securityInfo.jailbreakStatus }}
</el-descriptions-item>
<el-descriptions-item label="受监管状态">
{{ securityInfo.supervisedStatus }}
</el-descriptions-item>
<el-descriptions-item label="最后安全扫描时间">
{{ securityInfo.lastSecurityScan }}
</el-descriptions-item>
<el-descriptions-item label="安全补丁版本">
{{ securityInfo.securityPatchVersion }}
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 其他信息 -->
<div class="info-section">
<h3 class="section-title">其他信息</h3>
<el-descriptions :column="2" border>
<el-descriptions-item label="购买日期">
{{ otherInfo.purchaseDate }}
</el-descriptions-item>
<el-descriptions-item label="保修到期日">
{{ otherInfo.warrantyExpiry }}
</el-descriptions-item>
<el-descriptions-item label="设备来源">{{ otherInfo.source }}</el-descriptions-item>
<el-descriptions-item label="分配日期">
{{ otherInfo.assignDate }}
</el-descriptions-item>
<el-descriptions-item label="备注">{{ otherInfo.remark }}</el-descriptions-item>
<el-descriptions-item label="标签">{{ otherInfo.tags }}</el-descriptions-item>
<el-descriptions-item label="自定义字段1">
{{ otherInfo.customField1 }}
</el-descriptions-item>
<el-descriptions-item label="自定义字段2">
{{ otherInfo.customField2 }}
</el-descriptions-item>
</el-descriptions>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="应用信息" name="appInfo">
<div class="ta,b-content">
<el-empty description="应用信息加载中..." />
</div>
</el-tab-pane>
<el-tab-pane label="策略信息" name="policyInfo">
<div class="tab-content">
<el-empty description="策略信息加载中..." />
</div>
</el-tab-pane>
<el-tab-pane label="合规状态" name="complianceInfo">
<div class="tab-content">
<el-empty description="合规状态加载中..." />
</div>
</el-tab-pane>
<el-tab-pane label="操作日志" name="operationLog">
<div class="tab-content">
<el-empty description="操作日志加载中..." />
</div>
</el-tab-pane>
<el-tab-pane label="定位信息" name="locationInfo">
<div class="tab-content">
<el-empty description="定位信息加载中..." />
</div>
,
</el-tab-pane>
</el-tabs>
</div>
</div>
</template>
<script setup lang="ts">
import DeviceAPI from "@/api/system/device";
import { ref, reactive, watch, onMounted } from "vue";
import {
Back,
Edit,
Refresh,
Lock,
Warning,
Delete,
RefreshRight,
ArrowDown,
} from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
onMounted(() => {
if (props.serialno) {
loadDeviceDetail(props.serialno);
}
});
// 接收传入的 props
interface Props {
modelValue?: boolean; // 控制对话框显示隐藏
serialno?: string; // 传递的设备序列号
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
serialno: "",
});
// 定义自定义事件
const emit = defineEmits<{
"update:modelValue": [boolean];
returnClick: [];
}>();
// 监听 serialno 变化,加载对应的数据
watch(
() => props.serialno,
async (newSerialno) => {
if (newSerialno) {
await loadDeviceDetail(newSerialno);
}
},
{ immediate: true }
);
// 加载设备详情的方法
const loadDeviceDetail = async (serialno: string) => {
try {
// 调用 API 获取设备详细信息
const response = await DeviceAPI.getFormData(serialno);
// 更新 deviceData 等响应式数据
if (response) {
Object.assign(deviceData, {
snName: response.snName,
onlineStatus: response.onlineStatus,
deviceSn: response.serialno || serialno,
imei: response.snImei,
department: response.department,
group: response.group,
deviceType: response.deviceType,
source: response.source,
activationTime: response.activationTime,
lastOnline: response.lastOnline,
});
}
} catch (error) {
ElMessage.error("加载设备详情失败");
console.error("Error loading device detail:", error);
}
};
// 设备数据
const deviceData = reactive({
snName: "iPhone 15 Pro Max",
onlineStatus: 1,
owner: "张三",
department: "销售部",
group: "华东区域 / 上海分公司",
deviceType: "iOS",
source: "企业注册",
activationTime: "2024-02-18 09:32:21",
lastOnline: "10:15:42",
deviceSn: "550e8400-e29b-41d4-a716-446655440000",
imei: "35455016276432",
serialNumber: "H0FJXOY7XO",
});
// 设备图片
const deviceImage = ref("./src/assets/images/iphone-17-pro.webp");
// 标签页
const activeTab = ref("deviceInfo");
// 基本信息
const basicInfo = reactive({
deviceName: "iPhone 15 Pro Max",
deviceModel: "iPhone16,2",
os: "iOS 17.4.1",
osVersion: "21E236",
language: "zh-Hans-CN",
timezone: "Asia/Shanghai",
imei: "00008110-001C195E3EA1802E",
bluetoothMac: "90:81:5B:1B:4A:7F",
wifiMac: "90:81:5B:1B:4A:7F",
color: "原色钛金属",
capacity: "256 GB",
availableCapacity: "128.35 GB (50.1%)",
memoryCapacity: "8 GB",
availableMemory: "3.12 GB (39.0%)",
batteryLevel: "87%",
batteryHealth: "98%",
weight: "221 g",
dimensions: "159.9 x 76.7 x 8.25 mm",
simStatus: "单卡",
phoneNumber: "+86 138 0000 0000",
iccid: "8986 0112 3456 7890 1234",
meid: "35455016276543",
});
// 硬件信息
const hardwareInfo = reactive({
processor: "A17 Pro",
processorCores: "6",
gpu: "Apple GPU",
screenSize: "6.7 英寸",
screenResolution: "2796 x 1290",
screenDensity: "4,60 ppi",
camera: "后置三摄 48MP + 12MP + 12MP前置 12MP",
sensors: "加速度计,陀螺仪,指南针,气压计,距离传感器,环境光传感器,面容 ID",
nfc: "支持",
faceId: "支持",
});
// 网络信息
const networkInfo = reactive({
currentNetwork: "Wi-Fi",
carrier: "中国移动",
signalStrength: "强",
ipAddress: "192.168.1.102",
wifiName: "Office-WiFi",
wifiMac: "90:81:5B:1B:4A:7F",
cellularType: "5G",
roamingStatus: "否",
});
// 安全信息
const securityInfo = reactive({
lockScreenPassword: "已设置",
passwordComplexity: "高",
dataProtection: "已开启",
findMyIPhone: "已开启",
deviceEncryption: "已开启",
jailbreakStatus: "否",
supervisedStatus: "是",
lastSecurityScan: "2024-05-23 09:50:11",
securityPatchVersion: "iOS 17.4.1 (21E236)",
});
// 其他信息
const otherInfo = reactive({
purchaseDate: "2024-02-10",
warrantyExpiry: "2025-02-09",
source: "企业注册",
assignDate: "2024-02-18",
remark: "销售部员工配发设备",
tags: "重要设备,外勤设备",
customField1: "-",
customField2: "-",
});
// 更新返回按钮事件
const handleReturn = () => {
emit("update:modelValue", false); // 隐藏对话框
emit("returnClick"); // 触发返回事件
};
const handleEdit = () => {
ElMessage.success("编辑功能");
};
const handleRefresh = () => {
loadDeviceDetail(props.serialno).then(() => {
ElMessage.success("刷新数据成功");
});
};
const handleRemoteLock = () => {
ElMessageBox.confirm("确定要远程锁定该设备吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
})
.then(() => {
ElMessage.success("远程锁定指令已发送");
})
.catch(() => {});
};
const handleLostMode = () => {
ElMessageBox.confirm("确定要开启丢失模式吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
})
.then(() => {
ElMessage.success("丢失模式已开启");
})
.catch(() => {});
};
const handleWipeDevice = () => {
ElMessageBox.confirm("确定要清除该设备数据吗?此操作不可逆!", "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "error",
})
.then(() => {
ElMessage.success("清除设备指令已发送");
})
.catch(() => {});
};
const handleRestartDevice = () => {
ElMessageBox.confirm("确定要重启该设备吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
})
.then(() => {
ElMessage.success("重启设备指令已发送");
})
.catch(() => {});
};
const handleSyncDevice = () => {
ElMessage.success("同步设备指令已发送");
};
const handleMoreActions = (command: string) => {
switch (command) {
case "assign":
ElMessage.success("分配设备功能");
break;
case "transfer":
ElMessage.success("转移设备功能");
break;
case "batch":
ElMessage.success("批量操作功能");
break;
}
};
</script>
<style scoped lang="scss">
.device-detail {
min-height: calc(100vh - 84px);
padding: 20px;
background: #f5f7fa;
.device-header {
padding: 20px;
margin-bottom: 20px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
.device-info {
display: flex;
gap: 20px;
margin-bottom: 20px;
.device-image {
width: 120px;
height: 240px;
object-fit: cover;
background: #f5f7fa;
border-radius: 8px;
}
.info-content {
flex: 1;
.info-row {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
.device-name {
font-size: 20px;
font-weight: 600;
color: #303133;
}
}
.info-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
.info-item {
display: flex;
align-items: center;
.label {
min-width: 80px;
font-size: 13px;
color: #909399;
}
.value {
font-size: 13px;
color: #303133;
}
}
}
}
}
.action-buttons {
display: flex;
gap: 12px;
align-items: center;
padding-top: 16px;
border-top: 1px solid #ebeef5;
.secondary-actions {
display: flex;
gap: 8px;
align-items: center;
margin-left: auto;
}
}
}
.tabs-container {
padding: 20px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
.device-tabs {
:deep(.el-tabs__header) {
margin-bottom: 20px;
}
:deep(.el-tabs__content) {
padding-top: 0;
}
}
.tab-content {
.info-section {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
.section-title {
display: inline-block;
padding-bottom: 8px;
margin-bottom: 16px;
font-size: 16px;
font-weight: 600;
color: #303133;
border-bottom: 2px solid #409eff;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,222 @@
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 "@/types/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

@@ -0,0 +1,105 @@
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,
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,
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

@@ -0,0 +1,138 @@
import UserAPI from "@/api/system/user";
import type { UserForm } from "@/types/api";
import type { IModalConfig } from "@/components/DeviceSn/types";
import { deptArr, roleArr } from "./options";
const modalConfig: IModalConfig<UserForm> = {
permPrefix: "sys:user",
dialog: {
title: "新增用户",
width: 800,
draggable: true,
},
form: {
labelWidth: 100,
},
formAction: UserAPI.create,
beforeSubmit(data) {
console.warn("提交之前处理", data);
},
formItems: [
{
label: "用户名",
prop: "username",
rules: [{ required: true, message: "用户名不能为空", trigger: "blur" }],
type: "input",
attrs: {
placeholder: "请输入用户名",
},
col: {
xs: 24,
sm: 12,
},
},
{
label: "用户昵称",
prop: "nickname",
rules: [{ required: true, message: "用户昵称不能为空", trigger: "blur" }],
type: "input",
attrs: {
placeholder: "请输入用户昵称",
},
col: {
xs: 24,
sm: 12,
},
},
{
label: "所属部门",
prop: "deptId",
rules: [{ required: true, message: "所属部门不能为空", trigger: "change" }],
type: "tree-select",
attrs: {
placeholder: "请选择所属部门",
data: deptArr,
filterable: true,
"check-strictly": true,
"render-after-expand": false,
},
// async initFn(formItem) {
// // 注意:如果initFn函数不是箭头函数,this会指向此配置项对象,那么也就可以用this来替代形参formItem
// formItem.attrs.data = await DeptAPI.getOptions();
// },
},
{
type: "custom",
label: "性别",
prop: "gender",
initialValue: 1,
attrs: { style: { width: "100%" } },
},
{
label: "角色",
prop: "roleIds",
rules: [{ required: true, message: "用户角色不能为空", trigger: "change" }],
type: "select",
attrs: {
placeholder: "请选择",
multiple: true,
},
options: roleArr,
initialValue: [],
// async initFn(formItem) {
// formItem.options = await RoleAPI.getOptions();
// },
},
{
type: "input",
label: "手机号码",
prop: "mobile",
rules: [
{
pattern: /^1[3|4|5|6|7|8|9][0-9]\d{8}$/,
message: "请输入正确的手机号码",
trigger: "blur",
},
],
attrs: {
placeholder: "请输入手机号码",
maxlength: 11,
},
},
{
label: "邮箱",
prop: "email",
rules: [
{
pattern: /\w[-\w.+]*@([A-Za-z0-9][-A-Za-z0-9]+\.)+[A-Za-z]{2,14}/,
message: "请输入正确的邮箱地址",
trigger: "blur",
},
],
type: "input",
attrs: {
placeholder: "请输入邮箱",
maxlength: 50,
},
},
{
label: "状态",
prop: "status",
type: "radio",
options: [
{ label: "正常", value: 1 },
{ label: "禁用", value: 0 },
],
initialValue: 1,
},
{
type: "custom",
label: "二级弹窗",
prop: "openModal",
slotName: "openModal",
},
],
};
// 如果有异步数据会修改配置的推荐用reactive包裹而纯静态配置的可以直接导出
export default reactive(modalConfig);

View File

@@ -0,0 +1,182 @@
import DeviceAPI from "@/api/system/device";
import RoleAPI from "@/api/system/role";
import type { DeviceQueryParams, DeviceItem } from "@/api/system/device/types";
import type { IContentConfig } from "@/components/DeviceSn/types";
const contentConfig: IContentConfig<DeviceQueryParams, DeviceItem> = {
permPrefix: "sys:user", // 不写不进行按钮权限校验
table: {
border: true,
highlightCurrentRow: true,
},
pagination: {
background: true,
layout: "prev,pager,next,jumper,total,sizes",
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,
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: [
{ 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: 150,
},
{ label: "设备型号", align: "center", prop: "snModel", width: 200 },
{ label: "设备昵称", align: "center", prop: "snName", 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: "createTime", width: 180 },
{ label: "激活时间", align: "center", prop: "activateTime", width: 180 },
{
label: "操作",
align: "center",
fixed: "right",
width: 280,
templet: "tool",
operat: [
{
name: "detail",
text: "详情",
attrs: { icon: "Document", type: "primary" },
},
// {
// 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: "locate",
text: "定位",
attrs: { icon: "Position" },
},
{
name: "shutdown",
text: "关机",
attrs: { icon: "SwitchButton" },
},
{
name: "reboot",
text: "重启",
attrs: { icon: "SwitchButton" },
},
{
name: "refactory",
text: "重置",
attrs: { icon: "RefreshLeft" },
},
],
},
],
},
],
};
export default contentConfig;

View File

@@ -0,0 +1,128 @@
import UserAPI from "@/api/system/user";
import type { UserForm } from "@/api/system/user/types";
import type { IModalConfig } from "@/components/DeviceSn/types";
import { DeviceEnum } from "@/enums/settings";
import { useAppStore } from "@/stores";
import { deptArr, roleArr } from "./options";
const modalConfig: IModalConfig<UserForm> = {
permPrefix: "sys:user",
component: "drawer",
drawer: {
title: "修改用户",
size: useAppStore().device === DeviceEnum.MOBILE ? "80%" : 500,
},
pk: "id",
beforeSubmit(data) {
console.warn("beforeSubmit", data);
},
formAction(data) {
return UserAPI.update(data.id as string, data);
},
formItems: [
{
label: "用户名",
prop: "username",
rules: [{ required: true, message: "用户名不能为空", trigger: "blur" }],
type: "input",
attrs: {
placeholder: "请输入用户名",
readonly: true,
},
},
{
label: "用户昵称",
prop: "nickname",
rules: [{ required: true, message: "用户昵称不能为空", trigger: "blur" }],
type: "input",
attrs: {
placeholder: "请输入用户昵称",
},
},
{
label: "所属部门",
prop: "deptId",
rules: [{ required: true, message: "所属部门不能为空", trigger: "blur" }],
type: "tree-select",
attrs: {
placeholder: "请选择所属部门",
data: deptArr, // setupVue会自动解包ref不需要.value
filterable: true,
"check-strictly": true,
"render-after-expand": false,
},
// async initFn(formItem) {
// // 注意:如果initFn函数不是箭头函数,this会指向此配置项对象,那么也就可以用this来替代形参formItem
// formItem.attrs.data = await DeptAPI.getOptions();
// },
},
{
type: "custom",
label: "性别",
prop: "gender",
initialValue: 1,
attrs: { style: { width: "100%" } },
},
{
label: "角色",
prop: "roleIds",
rules: [{ required: true, message: "用户角色不能为空", trigger: "blur" }],
type: "select",
attrs: {
placeholder: "请选择",
multiple: true,
},
options: roleArr,
initialValue: [],
// async initFn(formItem) {
// formItem.options = await RoleAPI.getOptions();
// },
},
{
type: "input",
label: "手机号码",
prop: "mobile",
rules: [
{
pattern: /^1[3|4|5|6|7|8|9][0-9]\d{8}$/,
message: "请输入正确的手机号码",
trigger: "blur",
},
],
attrs: {
placeholder: "请输入手机号码",
maxlength: 11,
},
},
{
label: "邮箱",
prop: "email",
rules: [
{
pattern: /\w[-\w.+]*@([A-Za-z0-9][-A-Za-z0-9]+\.)+[A-Za-z]{2,14}/,
message: "请输入正确的邮箱地址",
trigger: "blur",
},
],
type: "input",
attrs: {
placeholder: "请输入邮箱",
maxlength: 50,
},
},
{
label: "状态",
prop: "status",
type: "switch",
attrs: {
inlinePrompt: true,
activeText: "正常",
inactiveText: "禁用",
activeValue: 1,
inactiveValue: 0,
},
},
],
};
export default reactive(modalConfig);

View File

@@ -0,0 +1,27 @@
/** 公共下载数据,减少重复请求次数 */
import DeptAPI from "@/api/system/dept";
import RoleAPI from "@/api/system/role";
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[]>([
{ 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;
};

View File

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

View File

@@ -0,0 +1,223 @@
<template>
<div class="app-container h-full flex flex-1 flex-col">
<!-- 列表 -->
<div v-show="!dialogVisible" class="flex flex-col h-full">
<!-- 搜索 -->
<device-page-search
ref="searchRef"
:search-config="searchConfig"
@query-click="handleQueryClick"
@reset-click="handleResetClick"
/>
<!-- 列表 -->
<device-page-content
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"
>
<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] }}
</el-link>
<copy-button
v-if="scope.row[scope.prop as string]"
:text="scope.row[scope.prop as string]"
:style="{ marginLeft: '2px' }"
/>
</template>
</device-page-content>
<!-- 新增 -->
<device-page-modal
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>
<template #openModal>
<el-button type="primary" @click="openSecondModal">打开二级弹窗</el-button>
</template>
</device-page-modal>
<!-- 编辑 -->
<device-page-modal
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>
</device-page-modal>
</div>
<div v-show="dialogVisible">
<device-sn-detail
v-model="dialogVisible"
:serialno="selectedRow?.serialno"
@return-click="handleReturnClick"
/>
</div>
</div>
</template>
<script setup lang="ts">
import DeviceAPI from "@/api/system/device";
import type { IObject, PageModalInstance } from "@/components/DeviceSn/types";
import usePage from "@/components/DeviceSn/usePage";
import addModalConfig from "./config/add";
import contentConfig from "./config/content";
import editModalConfig from "./config/edit";
import searchConfig from "./config/search";
import { initOptions } from "./config/options";
const {
searchRef,
contentRef,
addModalRef,
editModalRef,
handleQueryClick,
handleResetClick,
handleAddClick,
handleEditClick,
handleViewClick,
handleSubmitClick,
handleExportClick,
handleSearchClick,
handleFilterChange,
} = usePage();
// 其他工具
function handleToolbarClick(name: string) {
console.warn(name);
if (name === "custom1") {
ElMessage.success("点击了自定义1按钮");
}
}
function handleReturnClick() {
dialogVisible.value = false;
}
// 表格工具
const handleOperateClick = (data: IObject) => {
if (data.name === "detail") {
editModalConfig.drawer = { ...editModalConfig.drawer, title: "查看" };
handleViewClick(data.row, async () => {
// 加载下拉数据源,建议在初始化配置项"initFn 中加载,避免多次请求
// editModalConfig.formItems[2]!.attrs!.data = await DeptAPI.getOptions();
return await DeviceAPI.getFormData(data.row.serialno as string); // 根据SN获取详情
});
} 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 (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 (data.name === "screenshot") {
DeviceAPI.deviceScreenshot(data.row.serialno as string).then(() => {
ElMessage.success(`截图设备设备SN: ${data.row.serialno as string}`);
});
} else if (data.name === "reboot") {
ElMessageBox.confirm("确定要重启该设备吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
})
.then(() => {
DeviceAPI.deviceReboot(data.row.serialno as string).then(() => {
ElMessage.success(`重启设备设备SN: ${data.row.serialno as string}`);
});
})
.catch(() => {});
} else if (data.name === "shutdown") {
DeviceAPI.deviceShutdown(data.row.serialno as string).then(() => {
ElMessage.success(`关机设备设备SN: ${data.row.serialno as string}`);
});
} else if (data.name === "locate") {
DeviceAPI.deviceLocate(data.row.serialno as string).then(() => {
ElMessage.success(`定位设备设备SN: ${data.row.serialno as string}`);
});
} else if (data.name === "refactory") {
ElMessageBox.confirm("确定要重置该设备吗?此操作将清除设备所有数据,是否继续?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "error",
})
.then(() => {
DeviceAPI.deviceRestore(data.row.serialno as string).then(() => {
ElMessage.success(`重置设备设备SN: ${data.row.serialno as string}`);
});
})
.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 selectedRow = ref<IObject>({});
onMounted(() => {
initOptions();
});
</script>