feat(device): 新增应用图标管理功能

新增应用图标库页面,支持图标分页查询、锁定/解锁、手动上传和删除操作。
This commit is contained in:
TongTongStudio
2026-08-06 02:25:15 +08:00
parent af54b42293
commit aba4d2d3bc
3 changed files with 541 additions and 0 deletions

73
src/api/device/appIcon.ts Normal file
View File

@@ -0,0 +1,73 @@
import request from "@/utils/request";
import type { AppIconQuery, AppIconVO } from "./types";
const APP_ICON_BASE_URL = "/api/v1/app-icons";
const AppIconAPI = {
/** 应用图标库分页列表(不传 packageName 返回全部) */
getPage(queryParams: AppIconQuery) {
return request<unknown, PageResult<AppIconVO>>({
url: `${APP_ICON_BASE_URL}/page`,
method: "get",
params: queryParams,
});
},
/** 获取应用当前生效图标信息 */
getCurrentIcon(packageName: string) {
return request<unknown, AppIconVO>({
url: `${APP_ICON_BASE_URL}/${packageName}`,
method: "get",
});
},
/** 获取应用图标历史版本列表(含当前,按版本倒序) */
getHistory(packageName: string) {
return request<unknown, AppIconVO[]>({
url: `${APP_ICON_BASE_URL}/${packageName}/history`,
method: "get",
});
},
/** 选取历史版本并锁定 */
lock(packageName: string, iconId: number) {
return request({
url: `${APP_ICON_BASE_URL}/${packageName}/lock`,
method: "post",
params: { iconId },
});
},
/** 解锁当前图标 */
unlock(packageName: string) {
return request({
url: `${APP_ICON_BASE_URL}/${packageName}/unlock`,
method: "post",
});
},
/** 上传应用图标 */
upload(packageName: string, file: File, appName?: string) {
const formData = new FormData();
formData.append("file", file);
if (appName) {
formData.append("appName", appName);
}
return request<unknown, string>({
url: `${APP_ICON_BASE_URL}/${packageName}/upload`,
method: "post",
data: formData,
headers: { "Content-Type": "multipart/form-data" },
});
},
/** 删除应用图标版本 */
delete(packageName: string, iconId: number) {
return request({
url: `${APP_ICON_BASE_URL}/${packageName}/${iconId}`,
method: "delete",
});
},
};
export default AppIconAPI;

33
src/api/device/types.ts Normal file
View File

@@ -0,0 +1,33 @@
import type { BaseQueryParams } from "@/api/common";
/** 应用图标分页查询参数 */
export interface AppIconQuery extends BaseQueryParams {
/** 应用包名(可选,不传则返回全部) */
packageName?: string;
}
/** 应用图标VO */
export interface AppIconVO {
/** 主键ID */
id?: number;
/** 应用包名 */
packageName?: string;
/** 应用名称 */
appName?: string;
/** 图标访问地址 */
iconUrl?: string;
/** 图标文件MD5 */
iconMd5?: string;
/** 版本号(同包名内自增) */
version?: number;
/** 状态1当前生效 0历史版本 */
status?: number;
/** 是否锁定0未锁定 1已锁定 */
locked?: number;
/** 图标来源device/admin */
source?: string;
/** 创建时间 */
createTime?: string;
/** 更新时间 */
updateTime?: string;
}

View File

@@ -0,0 +1,435 @@
<template>
<div class="app-container">
<el-card shadow="never" class="mb-4">
<el-form ref="queryFormRef" :model="params" :inline="true" @submit.prevent>
<el-form-item label="应用包名" prop="packageName">
<el-input
v-model.trim="params.packageName"
placeholder="按包名筛选(留空显示全部)"
clearable
style="width: 320px"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleQuery">搜索</el-button>
<el-button :icon="RefreshLeft" @click="handleResetQuery">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never">
<!-- 工具栏 -->
<div class="flex items-center justify-between mb-3 flex-wrap gap-2">
<div class="flex items-center gap-2 flex-wrap">
<span v-if="selectedRow" class="text-sm">
已选中v{{ selectedRow.version }} ·
<span class="text-info">{{ selectedRow.packageName }}</span>
<el-tag
v-if="selectedRow.status === 1"
type="success"
effect="light"
size="small"
class="ml-1"
>
当前生效
</el-tag>
<el-tag
v-if="selectedRow.locked === 1"
type="warning"
effect="light"
size="small"
class="ml-1"
>
已锁定
</el-tag>
</span>
<span v-else class="text-info text-xs">请先在下方列表选择一行再进行锁定 / 解锁</span>
</div>
<div class="flex items-center gap-2">
<el-button
type="primary"
:icon="Lock"
:disabled="!selectedRow || selectedRow.status === 1"
:loading="lockLoading"
@click="handleToolbarLock"
>
锁定
</el-button>
<el-button
type="warning"
:icon="Unlock"
:disabled="!selectedRow || selectedRow.locked !== 1"
:loading="unlockLoading"
@click="handleToolbarUnlock"
>
解锁
</el-button>
<el-button type="success" :icon="Upload" @click="openUploadDialog">手动上传</el-button>
</div>
</div>
<el-table
v-loading="loading"
:data="list"
row-key="id"
border
stripe
highlight-current-row
@current-change="handleCurrentChange"
>
<el-table-column label="图标" width="100" align="center">
<template #default="scope">
<el-image
:src="resolveIconUrl(scope.row.iconUrl)"
fit="contain"
style="width: 48px; height: 48px; border-radius: 6px"
:preview-src-list="previewList"
:initial-index="previewIndex(scope.row)"
preview-teleported
/>
</template>
</el-table-column>
<el-table-column
label="应用包名"
prop="packageName"
min-width="180"
show-overflow-tooltip
/>
<el-table-column label="应用名称" prop="appName" min-width="140" show-overflow-tooltip />
<el-table-column label="版本" prop="version" width="80" align="center" />
<el-table-column label="状态" width="100" align="center">
<template #default="scope">
<el-tag :type="scope.row.status === 1 ? 'success' : 'info'" effect="light">
{{ scope.row.status === 1 ? "当前生效" : "历史版本" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="锁定" width="90" align="center">
<template #default="scope">
<el-tag v-if="scope.row.locked === 1" type="warning" effect="light">已锁定</el-tag>
<span v-else class="text-info"></span>
</template>
</el-table-column>
<el-table-column label="来源" prop="source" width="100" align="center">
<template #default="scope">
<el-tag v-if="scope.row.source === 'admin'" effect="plain">管理端</el-tag>
<el-tag v-else-if="scope.row.source === 'device'" effect="plain" type="info">
设备端
</el-tag>
<span v-else>{{ scope.row.source }}</span>
</template>
</el-table-column>
<el-table-column label="MD5" prop="iconMd5" min-width="200" show-overflow-tooltip />
<el-table-column label="更新时间" prop="updateTime" width="180" />
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="scope">
<el-button
v-if="scope.row.status === 0"
type="primary"
link
:icon="Lock"
:loading="lockLoadingId === scope.row.id"
@click="handleLock(scope.row)"
>
锁定此版本
</el-button>
<el-button
v-else-if="scope.row.locked === 1"
type="warning"
link
:icon="Unlock"
:loading="lockLoadingId === scope.row.id"
@click="handleUnlock(scope.row)"
>
解锁
</el-button>
<el-button
type="danger"
link
:icon="Delete"
:loading="deleteLoadingId === scope.row.id"
@click="handleDelete(scope.row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-if="total > 0"
v-model:total="total"
v-model:page="params.pageNum"
v-model:limit="params.pageSize"
:page-sizes="[10, 20, 50, 100]"
@pagination="fetchData"
/>
<el-empty v-if="!loading && total === 0" description="暂无图标" />
<!-- 手动上传对话框 -->
<el-dialog
v-model="uploadDialogVisible"
title="手动上传图标"
width="480px"
append-to-body
@closed="resetUploadDialog"
>
<el-form :model="uploadForm" label-width="96px">
<el-form-item label="应用包名" required>
<el-input
v-model.trim="uploadForm.packageName"
placeholder="如 com.example.app"
clearable
/>
</el-form-item>
<el-form-item label="应用名称">
<el-input
v-model.trim="uploadForm.appName"
placeholder="可选,不填沿用已有"
clearable
/>
</el-form-item>
<el-form-item label="图标文件" required>
<el-upload
v-model:file-list="uploadFileList"
:auto-upload="false"
:limit="1"
:show-file-list="true"
accept=".jpg,.jpeg,.png,.webp,image/*"
:before-upload="validateUploadFile"
drag
class="w-full"
>
<div class="el-upload__text">
将图标拖到此处
<em>点击选择</em>
</div>
<template #tip>
<div class="text-info text-xs">仅支持 jpg/jpeg/png/webp大小 2MB</div>
</template>
</el-upload>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="uploadDialogVisible = false">取消</el-button>
<el-button type="primary" :icon="Upload" :loading="uploadLoading" @click="submitUpload">
确认上传
</el-button>
</template>
</el-dialog>
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from "vue";
import { Delete, Lock, RefreshLeft, Search, Unlock, Upload } from "@element-plus/icons-vue";
import {
ElMessage,
ElMessageBox,
type FormInstance,
type UploadRawFile,
type UploadUserFile,
} from "element-plus";
import { usePageTable } from "@/composables";
import AppIconAPI from "@/api/device/appIcon";
import type { AppIconQuery, AppIconVO } from "@/api/device/types";
defineOptions({
name: "AppIcon",
});
const queryFormRef = ref<FormInstance>();
// 分页表格(打开即加载全部图标)
const { loading, list, total, params, fetchData, handleQuery, handleResetQuery } = usePageTable<
AppIconVO,
AppIconQuery
>({
initialParams: { pageNum: 1, pageSize: 10, packageName: "" },
request: AppIconAPI.getPage,
onBeforeReset: () => queryFormRef.value?.resetFields(),
});
// 当前选中行
const selectedRow = ref<AppIconVO | null>(null);
// 上传(手动上传对话框)
const uploadDialogVisible = ref(false);
const uploadLoading = ref(false);
const uploadForm = reactive({
packageName: "",
appName: "",
});
const uploadFileList = ref<UploadUserFile[]>([]);
// loading 状态
const lockLoading = ref(false);
const unlockLoading = ref(false);
const lockLoadingId = ref<number | null>(null);
const deleteLoadingId = ref<number | null>(null);
function handleCurrentChange(row: AppIconVO | null) {
selectedRow.value = row;
}
const previewList = computed(() => list.value.map((item) => resolveIconUrl(item.iconUrl)));
const previewIndex = (row: AppIconVO) => {
const idx = list.value.findIndex((item) => item.id === row.id);
return idx < 0 ? 0 : idx;
};
/**
* 拼接图标完整访问地址
* 后端返回的是相对路径(如 /static/app_icon/{md5}.png
* el-image 的 src 是浏览器原生请求,不经过 axios拿不到 baseURL
* 因此必须手动补上代理前缀VITE_APP_BASE_API否则请求会打到前端 dev server 上导致 404。
*/
function resolveIconUrl(url?: string | null): string {
if (!url) return "";
if (/^(https?:)?\/\//.test(url) || url.startsWith("data:")) return url;
const baseApi = import.meta.env.VITE_APP_BASE_API ?? "";
return `${baseApi.replace(/\/$/, "")}${url.startsWith("/") ? "" : "/"}${url}`;
}
function validateUploadFile(file: UploadRawFile) {
const allowExt = ["jpg", "jpeg", "png", "webp"];
const ext = file.name.split(".").pop()?.toLowerCase() || "";
const isImage = file.type.startsWith("image/") || allowExt.includes(ext);
if (!isImage) {
ElMessage.warning("仅支持 jpg/jpeg/png/webp 格式图标");
return false;
}
if (file.size > 2 * 1024 * 1024) {
ElMessage.warning("图标大小不能超过 2MB");
return false;
}
return true;
}
function openUploadDialog() {
uploadDialogVisible.value = true;
}
function resetUploadDialog() {
uploadForm.packageName = "";
uploadForm.appName = "";
uploadFileList.value = [];
}
async function submitUpload() {
const pkg = uploadForm.packageName.trim();
if (!pkg) {
ElMessage.warning("请填写应用包名");
return;
}
if (!/^[A-Za-z0-9._]+$/.test(pkg)) {
ElMessage.warning("应用包名仅支持字母、数字、. 和 _");
return;
}
const file = uploadFileList.value[0]?.raw as File | undefined;
if (!file) {
ElMessage.warning("请选择图标文件");
return;
}
uploadLoading.value = true;
try {
await AppIconAPI.upload(pkg, file, uploadForm.appName.trim() || undefined);
ElMessage.success("上传成功");
uploadDialogVisible.value = false;
await fetchData();
} finally {
uploadLoading.value = false;
}
}
async function handleLock(row: AppIconVO) {
if (!row.id || !row.packageName) return;
lockLoadingId.value = row.id;
try {
await AppIconAPI.lock(row.packageName, row.id);
ElMessage.success("已锁定该版本");
await fetchData();
} finally {
lockLoadingId.value = null;
}
}
async function handleUnlock(row: AppIconVO) {
if (!row.packageName) return;
lockLoadingId.value = row.id ?? null;
try {
await AppIconAPI.unlock(row.packageName);
ElMessage.success("已解锁");
await fetchData();
} finally {
lockLoadingId.value = null;
}
}
// 工具栏:锁定当前选中行
async function handleToolbarLock() {
const row = selectedRow.value;
if (!row || !row.id || !row.packageName) return;
lockLoading.value = true;
try {
await AppIconAPI.lock(row.packageName, row.id);
ElMessage.success("已锁定该版本");
await fetchData();
} finally {
lockLoading.value = false;
}
}
// 工具栏:解锁当前选中行(需为已锁定)
async function handleToolbarUnlock() {
const row = selectedRow.value;
if (!row || !row.packageName) return;
unlockLoading.value = true;
try {
await AppIconAPI.unlock(row.packageName);
ElMessage.success("已解锁");
await fetchData();
} finally {
unlockLoading.value = false;
}
}
async function handleDelete(row: AppIconVO) {
if (!row.id || !row.packageName) return;
const isActive = row.status === 1;
const tip = isActive
? "该图标为当前生效版本,删除后该应用将无生效图标,确认删除?"
: "确认删除该历史图标版本?";
try {
await ElMessageBox.confirm(tip, "删除图标", {
type: "warning",
confirmButtonText: "确定",
cancelButtonText: "取消",
});
} catch {
return;
}
deleteLoadingId.value = row.id;
try {
await AppIconAPI.delete(row.packageName, row.id);
ElMessage.success("删除成功");
await fetchData();
} finally {
deleteLoadingId.value = null;
}
}
onMounted(() => {
fetchData();
});
</script>
<style scoped lang="scss">
.text-info {
color: var(--el-text-color-secondary);
}
</style>