feat(device): 新增设备已安装应用管理功能

为设备详情页添加应用信息标签页,支持查看已安装应用列表、打开/清除/卸载应用,以及上传应用图标等操作。
This commit is contained in:
2026-08-05 17:13:00 +08:00
parent 6e700851c4
commit 4b4365a42d
3 changed files with 312 additions and 4 deletions

View File

@@ -15,6 +15,7 @@ import type {
DeviceNetworkInfo, DeviceNetworkInfo,
DeviceOtherInfo, DeviceOtherInfo,
DeviceSecurityInfo, DeviceSecurityInfo,
ApkInstallInfo,
} from "./types"; } from "./types";
import type { ExcelResult, PageResult, OptionItem } from "@/api/common"; import type { ExcelResult, PageResult, OptionItem } from "@/api/common";
@@ -355,6 +356,55 @@ const DeviceAPI = {
method: "get", method: "get",
}); });
}, },
/** 设备已安装应用列表 */
getApkList(serialno: string) {
return request<any, ApkInstallInfo[]>({
url: `${DEVICE_BASE_URL}/${serialno}/apk_list`,
method: "get",
});
},
/** 打开应用 */
launchApp(sn: string, packageName: string) {
return request({
url: `${DEVICE_BASE_URL}/${sn}/app/launch`,
method: "post",
data: { packageName },
});
},
/** 清除应用数据 */
clearAppData(sn: string, packageName: string) {
return request({
url: `${DEVICE_BASE_URL}/${sn}/app/clear_data`,
method: "post",
data: { packageName },
});
},
/** 卸载应用 */
uninstallApp(sn: string, packageName: string) {
return request({
url: `${DEVICE_BASE_URL}/${sn}/app/uninstall`,
method: "post",
data: { packageName },
});
},
/** 上传应用图标(全局图标库) */
uploadAppIcon(packageName: string, file: File) {
const formData = new FormData();
formData.append("file", file);
return request<any, string>({
url: `/api/v1/app-icons/${packageName}/upload`,
method: "post",
data: formData,
headers: {
"Content-Type": "multipart/form-data",
},
});
},
}; };
export default DeviceAPI; export default DeviceAPI;

View File

@@ -329,3 +329,33 @@ export interface DeviceOtherInfo {
freeRam?: string; freeRam?: string;
totalRam?: string; totalRam?: string;
} }
// ===================== 设备应用信息 =====================
/** 设备已安装应用信息 */
export interface ApkInstallInfo {
/** 包名 */
packageName?: string;
/** 应用名称 */
appName?: string;
/** 版本名称 */
versionName?: string;
/** 版本号 */
versionCode?: number;
/** 安装时间 */
installTime?: Date | string;
/** 最后更新时间 */
lastUpdateTime?: Date | string;
/** Apk文件大小 */
apkSize?: number;
/** App数据占用空间 */
dataSize?: number;
/** App缓存占用空间 */
cacheSize?: number;
/** apk MD5 */
md5?: string;
/** 是否为系统应用 */
systemApp?: boolean;
/** 应用图标访问地址 */
iconUrl?: string;
}

View File

@@ -452,7 +452,75 @@
<!-- 扩展标签页应用信息 / 策略信息 / 合规状态 / 操作日志 / 定位信息 --> <!-- 扩展标签页应用信息 / 策略信息 / 合规状态 / 操作日志 / 定位信息 -->
<el-tab-pane label="应用信息" name="appInfo"> <el-tab-pane label="应用信息" name="appInfo">
<div class="tab-content"> <div class="tab-content">
<el-empty description="应用信息加载中..." /> <div class="apk-toolbar">
<el-button
type="primary"
plain
size="small"
:loading="loading.apk"
@click="loadApkInfo(props.serialno)"
>
<el-icon><Refresh /></el-icon>
刷新应用列表
</el-button>
</div>
<el-table v-loading="loading.apk" :data="apkList" border stripe empty-text="暂无应用信息">
<el-table-column label="图标" width="70" align="center">
<template #default="{ row }">
<el-image
:src="row.iconUrl"
fit="cover"
class="apk-icon"
:preview-src-list="row.iconUrl ? [row.iconUrl] : []"
preview-teleported
>
<template #error>
<div class="apk-icon-placeholder"></div>
</template>
</el-image>
</template>
</el-table-column>
<el-table-column
prop="appName"
label="应用名称"
min-width="160"
show-overflow-tooltip
/>
<el-table-column
prop="packageName"
label="包名"
min-width="220"
show-overflow-tooltip
/>
<el-table-column prop="versionName" label="版本" width="100" show-overflow-tooltip />
<el-table-column label="大小" width="110">
<template #default="{ row }">{{ formatSize(row.apkSize) }}</template>
</el-table-column>
<el-table-column label="系统应用" width="90" align="center">
<template #default="{ row }">
<el-tag v-if="row.systemApp" type="info" size="small">系统</el-tag>
<el-tag v-else type="success" size="small">普通</el-tag>
</template>
</el-table-column>
<el-table-column label="安装时间" width="170">
<template #default="{ row }">{{ formatDate(row.installTime) }}</template>
</el-table-column>
<el-table-column label="操作" width="300" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleLaunchApp(row)">打开</el-button>
<el-button link type="warning" @click="handleClearAppData(row)">清除数据</el-button>
<el-button link type="info" @click="handleUploadIcon(row)">上传图标</el-button>
<el-button link type="danger" @click="handleUninstallApp(row)">卸载</el-button>
</template>
</el-table-column>
</el-table>
<input
ref="iconFileInput"
type="file"
accept="image/jpeg,image/png,image/webp"
style="display: none"
@change="onIconFileChange"
/>
</div> </div>
</el-tab-pane> </el-tab-pane>
@@ -486,6 +554,7 @@
<script setup lang="ts"> <script setup lang="ts">
import DeviceAPI from "@/api/system/device"; import DeviceAPI from "@/api/system/device";
import type { import type {
ApkInstallInfo,
DeviceBasicInfo, DeviceBasicInfo,
DeviceHardwareInfo, DeviceHardwareInfo,
DeviceNetworkInfo, DeviceNetworkInfo,
@@ -527,6 +596,9 @@ watch(
() => props.serialno, () => props.serialno,
(newSerialno) => { (newSerialno) => {
if (newSerialno) { if (newSerialno) {
// 切换设备时重置应用信息懒加载状态
apkLoaded.value = false;
apkList.value = [];
loadAll(newSerialno); loadAll(newSerialno);
} }
}, },
@@ -570,8 +642,14 @@ const loading = reactive({
network: false, network: false,
security: false, security: false,
other: false, other: false,
apk: false,
}); });
/** 设备已安装应用列表 */
const apkList = ref<ApkInstallInfo[]>([]);
/** 应用信息是否已加载(懒加载,仅首次进入 tab 时拉取) */
const apkLoaded = ref(false);
/** 空值统一显示占位符 */ /** 空值统一显示占位符 */
function formatText(value?: string | number | null): string { function formatText(value?: string | number | null): string {
return value === undefined || value === null || value === "" ? "-" : String(value); return value === undefined || value === null || value === "" ? "-" : String(value);
@@ -583,6 +661,28 @@ function formatBool(value?: number | null): string {
return value === 1 ? "是" : "否"; return value === 1 ? "是" : "否";
} }
/** 字节数转可读大小 */
function formatSize(bytes?: number): string {
if (bytes === undefined || bytes === null || bytes === 0) return "-";
const units = ["B", "KB", "MB", "GB", "TB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}
/** 时间格式化 */
function formatDate(value?: Date | string | null): string {
if (value === undefined || value === null || value === "") return "-";
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return "-";
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
function assignTo<T extends object>(reactiveObj: T, data?: object) { function assignTo<T extends object>(reactiveObj: T, data?: object) {
Object.keys(reactiveObj as object).forEach( Object.keys(reactiveObj as object).forEach(
(key) => delete (reactiveObj as Record<string, unknown>)[key] (key) => delete (reactiveObj as Record<string, unknown>)[key]
@@ -692,6 +792,106 @@ async function loadAll(serialno?: string) {
loadOtherInfo(serialno); loadOtherInfo(serialno);
} }
/** 应用信息 */
async function loadApkInfo(serialno: string) {
loading.apk = true;
try {
const data = await DeviceAPI.getApkList(serialno);
apkList.value = data || [];
apkLoaded.value = true;
} catch (e) {
console.error("加载应用信息失败", e);
} finally {
loading.apk = false;
}
}
/** 切换标签页懒加载应用信息(仅首次进入时拉取) */
watch(activeTab, (tab) => {
if (tab === "appInfo" && props.serialno && !apkLoaded.value) {
loadApkInfo(props.serialno);
}
});
/** 打开应用 */
const handleLaunchApp = (row: ApkInstallInfo) => {
ElMessageBox.confirm(`确定要打开应用「${row.appName || row.packageName}」吗?`, "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
})
.then(() => {
DeviceAPI.launchApp(props.serialno, row.packageName ?? "").then(() => {
ElMessage.success("打开应用指令已发送");
});
})
.catch(() => {});
};
/** 清除应用数据 */
const handleClearAppData = (row: ApkInstallInfo) => {
ElMessageBox.confirm(
`确定要清除应用「${row.appName || row.packageName}」的数据吗?此操作不可逆!`,
"警告",
{
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}
)
.then(() => {
DeviceAPI.clearAppData(props.serialno, row.packageName ?? "").then(() => {
ElMessage.success("清除应用数据指令已发送");
});
})
.catch(() => {});
};
/** 卸载应用 */
const handleUninstallApp = (row: ApkInstallInfo) => {
ElMessageBox.confirm(
`确定要卸载应用「${row.appName || row.packageName}」吗?此操作不可逆!`,
"警告",
{
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "error",
}
)
.then(() => {
DeviceAPI.uninstallApp(props.serialno, row.packageName ?? "").then(() => {
ElMessage.success("卸载应用指令已发送");
});
})
.catch(() => {});
};
/** 图标文件输入框 */
const iconFileInput = ref<HTMLInputElement>();
/** 当前准备上传图标的应用 */
let uploadIconTarget: ApkInstallInfo | null = null;
/** 上传图标:点击触发文件选择 */
const handleUploadIcon = (row: ApkInstallInfo) => {
uploadIconTarget = row;
iconFileInput.value?.click();
};
/** 选择图标文件后上传 */
const onIconFileChange = (event: Event) => {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (!file || !uploadIconTarget) return;
const row = uploadIconTarget;
DeviceAPI.uploadAppIcon(row.packageName ?? "", file)
.then(() => {
ElMessage.success("图标上传成功");
loadApkInfo(props.serialno);
})
.catch(() => {});
};
defineExpose({ loadAll }); defineExpose({ loadAll });
/** 操作按钮事件 */ /** 操作按钮事件 */
@@ -704,9 +904,11 @@ const handleEdit = () => {
}; };
const handleRefresh = () => { const handleRefresh = () => {
loadAll(props.serialno).then(() => { loadAll(props.serialno);
ElMessage.success("刷新数据成功"); if (apkLoaded.value && props.serialno) {
}); loadApkInfo(props.serialno);
}
ElMessage.success("刷新数据成功");
}; };
const handleRemoteLock = () => { const handleRemoteLock = () => {
@@ -931,6 +1133,32 @@ const handleMoreActions = (command: string) => {
.tab-content { .tab-content {
min-height: 120px; min-height: 120px;
.apk-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
}
.apk-icon {
width: 36px;
height: 36px;
vertical-align: middle;
border-radius: 6px;
}
.apk-icon-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
margin: 0 auto;
font-size: 12px;
color: #c0c4cc;
background: #f5f7fa;
border-radius: 6px;
}
} }
} }
} }