feat(设备详情): 优化应用列表展示和操作交互

- 新增停止应用功能,操作列改为图标按钮并优化样式
- 应用列表增加排序功能,新增更新时间列
- 修复应用图标加载路径问题,补全代理前缀
- 上传图标接口支持可选应用名称参数
- 调整定位信息标签页位置
This commit is contained in:
TongTongStudio
2026-08-05 22:06:59 +08:00
parent 4b4365a42d
commit af98e7d88d
2 changed files with 137 additions and 47 deletions

View File

@@ -392,14 +392,31 @@ const DeviceAPI = {
});
},
/** 上传应用图标(全局图标库) */
uploadAppIcon(packageName: string, file: File) {
/** 停止应用 */
stopApp(sn: string, packageName: string) {
return request({
url: `${DEVICE_BASE_URL}/${sn}/app/stop`,
method: "post",
data: { packageName },
});
},
/**
* 上传应用图标(全局图标库)
*
* @param packageName 应用包名
* @param file 图标文件jpg/jpeg/png/webp
* @param appName 应用名称,可选;不传则由服务端沿用已有名称
*/
uploadAppIcon(packageName: string, file: File, appName?: string) {
const formData = new FormData();
formData.append("file", file);
return request<any, string>({
url: `/api/v1/app-icons/${packageName}/upload`,
method: "post",
data: formData,
// appName 服务端是 @RequestParam走 query 而非 FormData 才能稳定绑定
params: appName ? { appName } : undefined,
headers: {
"Content-Type": "multipart/form-data",
},

View File

@@ -468,10 +468,10 @@
<el-table-column label="图标" width="70" align="center">
<template #default="{ row }">
<el-image
:src="row.iconUrl"
:src="resolveIconUrl(row.iconUrl)"
fit="cover"
class="apk-icon"
:preview-src-list="row.iconUrl ? [row.iconUrl] : []"
:preview-src-list="row.iconUrl ? [resolveIconUrl(row.iconUrl)] : []"
preview-teleported
>
<template #error>
@@ -484,43 +484,88 @@
prop="appName"
label="应用名称"
min-width="160"
sortable
show-overflow-tooltip
/>
<el-table-column
prop="packageName"
label="包名"
min-width="220"
sortable
show-overflow-tooltip
/>
<el-table-column prop="versionName" label="版本" width="100" show-overflow-tooltip />
<el-table-column label="大小" width="110">
<el-table-column prop="versionName" label="版本" width="120" show-overflow-tooltip />
<el-table-column label="大小" width="110" sortable sort-by="apkSize">
<template #default="{ row }">{{ formatSize(row.apkSize) }}</template>
</el-table-column>
<el-table-column label="系统应用" width="90" align="center">
<el-table-column
label="系统应用"
width="120"
align="center"
sortable
sort-by="systemApp"
>
<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">
<el-table-column
label="安装时间"
width="170"
sortable
:sort-method="
(a: ApkInstallInfo, b: ApkInstallInfo) =>
dateValue(a.installTime) - dateValue(b.installTime)
"
>
<template #default="{ row }">{{ formatDate(row.installTime) }}</template>
</el-table-column>
<el-table-column label="操作" width="300" fixed="right">
<el-table-column
label="更新时间"
width="170"
sortable
:sort-method="
(a: ApkInstallInfo, b: ApkInstallInfo) =>
dateValue(a.lastUpdateTime) - dateValue(b.lastUpdateTime)
"
>
<template #default="{ row }">{{ formatDate(row.lastUpdateTime) }}</template>
</el-table-column>
<el-table-column label="操作" width="160" 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>
<div class="apk-actions">
<el-tooltip content="打开" placement="top">
<el-button
link
type="primary"
:icon="VideoPlay"
@click="handleLaunchApp(row)"
/>
</el-tooltip>
<el-tooltip content="停止" placement="top">
<el-button
link
type="warning"
:icon="SwitchButton"
@click="handleStopApp(row)"
/>
</el-tooltip>
<el-tooltip content="清除数据" placement="top">
<el-button link type="warning" :icon="Brush" @click="handleClearAppData(row)" />
</el-tooltip>
<el-tooltip content="卸载" placement="top">
<el-button link type="danger" :icon="Delete" @click="handleUninstallApp(row)" />
</el-tooltip>
</div>
</template>
</el-table-column>
</el-table>
<input
ref="iconFileInput"
type="file"
accept="image/jpeg,image/png,image/webp"
style="display: none"
@change="onIconFileChange"
/>
</div>
</el-tab-pane>
<el-tab-pane label="定位信息" name="locationInfo">
<div class="tab-content">
<el-empty description="定位信息加载中..." />
</div>
</el-tab-pane>
@@ -541,12 +586,6 @@
<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>
</template>
@@ -571,6 +610,9 @@ import {
Delete,
RefreshRight,
ArrowDown,
VideoPlay,
Brush,
SwitchButton,
} from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
@@ -650,6 +692,22 @@ const apkList = ref<ApkInstallInfo[]>([]);
/** 应用信息是否已加载(懒加载,仅首次进入 tab 时拉取) */
const apkLoaded = ref(false);
/**
* 拼接应用图标完整访问地址
*
* 后端返回的是相对路径(如 /static/app_icon/{md5}.png
* img/el-image 的 src 是浏览器原生请求,不经过 axios拿不到 baseURL
* 因此必须手动补上代理前缀,否则请求会打到前端 dev server 上导致 404。
*/
function resolveIconUrl(url?: string | null): string {
if (!url) return "";
// 已是绝对地址http/https或 base64直接返回
if (/^(https?:)?\/\//.test(url) || url.startsWith("data:")) return url;
const baseApi = import.meta.env.VITE_APP_BASE_API ?? "";
// 避免 baseApi 结尾与 url 开头的斜杠重复
return `${baseApi.replace(/\/$/, "")}${url.startsWith("/") ? "" : "/"}${url}`;
}
/** 空值统一显示占位符 */
function formatText(value?: string | number | null): string {
return value === undefined || value === null || value === "" ? "-" : String(value);
@@ -683,6 +741,14 @@ function formatDate(value?: Date | string | null): string {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
/** 将时间转为可用于排序的时间戳,无效值视为 0排在最前 */
function dateValue(value?: Date | string | null): number {
if (value === undefined || value === null || value === "") return 0;
const date = value instanceof Date ? value : new Date(value);
const time = date.getTime();
return Number.isNaN(time) ? 0 : time;
}
function assignTo<T extends object>(reactiveObj: T, data?: object) {
Object.keys(reactiveObj as object).forEach(
(key) => delete (reactiveObj as Record<string, unknown>)[key]
@@ -866,28 +932,17 @@ const handleUninstallApp = (row: ApkInstallInfo) => {
.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)
/** 停止应用 */
const handleStopApp = (row: ApkInstallInfo) => {
ElMessageBox.confirm(`确定要停止运行应用「${row.appName || row.packageName}」吗?`, "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
})
.then(() => {
ElMessage.success("图标上传成功");
loadApkInfo(props.serialno);
DeviceAPI.stopApp(props.serialno, row.packageName ?? "").then(() => {
ElMessage.success("停止应用指令已发送");
});
})
.catch(() => {});
};
@@ -1140,6 +1195,24 @@ const handleMoreActions = (command: string) => {
margin-bottom: 12px;
}
.apk-actions {
display: flex;
gap: 6px;
align-items: center;
.el-button {
padding: 4px;
margin-left: 0;
font-size: 20px;
}
.el-button .el-icon,
.el-button svg {
width: 1.2em;
height: 1.2em;
}
}
.apk-icon {
width: 36px;
height: 36px;