feat(device): 新增应用图标管理功能
新增应用图标库页面,支持图标分页查询、锁定/解锁、手动上传和删除操作。
This commit is contained in:
73
src/api/device/appIcon.ts
Normal file
73
src/api/device/appIcon.ts
Normal 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
33
src/api/device/types.ts
Normal 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;
|
||||
}
|
||||
435
src/views/devices/app-icon/index.vue
Normal file
435
src/views/devices/app-icon/index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user