Files
vue3-element-admin/src/views/app-manager/upload-package/index.vue

571 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="app-package-container">
<!-- 工具栏 -->
<div class="toolbar">
<div class="toolbar-left">
<el-input
v-model="query.keywords"
placeholder="搜索包名 / 应用名 / 文件名"
clearable
style="width: 260px"
:prefix-icon="Search"
@keyup.enter="handleQuery"
@clear="handleQuery"
/>
<el-select
v-model="query.packageType"
placeholder="全部类型"
clearable
style="width: 130px"
@change="handleQuery"
>
<el-option label="APK" value="apk" />
<el-option label="APKS" value="apks" />
<el-option label="AAB" value="aab" />
<el-option label="APKM" value="apkm" />
<el-option label="XAPK" value="xapk" />
</el-select>
<el-button type="primary" :icon="Search" @click="handleQuery">查询</el-button>
<el-button :icon="Refresh" @click="handleRefresh">刷新</el-button>
</div>
<el-button type="success" :icon="Upload" @click="uploadVisible = true">上传安装包</el-button>
</div>
<!-- 卡片列表 -->
<div v-loading="loading" class="card-grid">
<el-empty v-if="!loading && list.length === 0" description="暂无安装包,点击右上角上传" />
<el-card v-for="item in list" :key="item.id" class="pkg-card" shadow="hover">
<div class="pkg-card__header">
<el-image
:src="withBase(item.iconUrl)"
fit="contain"
class="pkg-card__icon"
:preview-src-list="item.iconUrl ? [withBase(item.iconUrl)] : []"
>
<template #error>
<div class="pkg-card__icon-fallback">
<el-icon><Box /></el-icon>
</div>
</template>
</el-image>
<div class="pkg-card__title">
<div class="pkg-card__name" :title="item.appLabel || item.packageName">
{{ item.appLabel || item.packageName || "未知应用" }}
</div>
<el-tag size="small" :type="typeTagType(item.packageType)" effect="dark" round>
{{ item.packageType?.toUpperCase() }}
</el-tag>
</div>
</div>
<div class="pkg-card__body">
<div class="pkg-row">
<span class="pkg-label">包名</span>
<span class="pkg-value" :title="item.packageName">{{ item.packageName || "-" }}</span>
</div>
<div class="pkg-row">
<span class="pkg-label">分类</span>
<span class="pkg-value">
<el-select
v-model="item.categories"
multiple
collapse-tags
collapse-tags-tooltip
placeholder="选择分类"
style="width: 100%"
:loading="categorySaving[item.id]"
@change="(val: string[]) => handleCategoryChange(item, val)"
>
<el-option
v-for="opt in categoryOptions"
:key="opt.code"
:label="opt.name"
:value="opt.code"
>
<img v-if="opt.icon" :src="opt.icon" class="cat-icon" alt="" />
<span>{{ opt.name }}</span>
</el-option>
</el-select>
</span>
</div>
<div class="pkg-row">
<span class="pkg-label">版本</span>
<span class="pkg-value">
{{ item.versionName || "-" }}
<template v-if="item.versionCode">({{ item.versionCode }})</template>
</span>
</div>
<div class="pkg-row">
<span class="pkg-label">大小</span>
<span class="pkg-value">{{ item.fileSizeText || "-" }}</span>
</div>
<div class="pkg-row">
<span class="pkg-label">文件名</span>
<span class="pkg-value" :title="item.originalName">{{ item.originalName || "-" }}</span>
</div>
</div>
<div class="pkg-card__footer">
<el-button text type="primary" :icon="View" @click="openDetail(item)">详情</el-button>
<el-button text type="primary" :icon="Download" @click="download(item)">下载</el-button>
<el-popconfirm title="确认删除该安装包?" @confirm="handleDelete(item)">
<template #reference>
<el-button text type="danger" :icon="Delete">删除</el-button>
</template>
</el-popconfirm>
</div>
</el-card>
</div>
<!-- 分页 -->
<el-pagination
v-if="total > 0"
class="pager"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
:page-sizes="[12, 24, 48]"
:current-page="query.pageNum"
:page-size="query.pageSize"
background
@current-change="onPageChange"
@size-change="onSizeChange"
/>
<!-- 上传对话框 -->
<UploadDialog v-model:visible="uploadVisible" @success="handleUploaded" />
<!-- 详情抽屉 -->
<el-drawer v-model="detailVisible" title="安装包详情" size="460px">
<el-descriptions v-if="current" :column="1" border class="detail-desc">
<el-descriptions-item label="应用名称">{{ current.appLabel || "-" }}</el-descriptions-item>
<el-descriptions-item label="包名">{{ current.packageName || "-" }}</el-descriptions-item>
<el-descriptions-item label="类型">
{{ current.packageType?.toUpperCase() }}
</el-descriptions-item>
<el-descriptions-item label="版本名称">
{{ current.versionName || "-" }}
</el-descriptions-item>
<el-descriptions-item label="版本号">{{ current.versionCode ?? "-" }}</el-descriptions-item>
<el-descriptions-item label="最低 SDK">{{ current.minSdk ?? "-" }}</el-descriptions-item>
<el-descriptions-item label="目标 SDK">{{ current.targetSdk ?? "-" }}</el-descriptions-item>
<el-descriptions-item label="编译 SDK">
{{ current.compileSdk ?? "-" }}
</el-descriptions-item>
<el-descriptions-item label="文件大小">
{{ current.fileSizeText || "-" }}
</el-descriptions-item>
<el-descriptions-item label="文件名">
{{ current.originalName || "-" }}
</el-descriptions-item>
<el-descriptions-item label="MD5">{{ current.md5 || "-" }}</el-descriptions-item>
<el-descriptions-item label="CPU 架构">
<el-tag v-for="abi in current.abiList || []" :key="abi" size="small" class="abi-tag">
{{ abi }}
</el-tag>
<span v-if="!current.abiList || current.abiList.length === 0">-</span>
</el-descriptions-item>
<el-descriptions-item label="系统应用">
{{ current.systemApp ? "是" : "否" }}
</el-descriptions-item>
<el-descriptions-item label="应用分类">
<el-tag
v-for="c in current.categories || []"
:key="c"
size="small"
type="success"
class="abi-tag"
>
<img v-if="categoryMap[c]?.icon" :src="categoryMap[c].icon" class="cat-icon" alt="" />
{{ categoryMap[c]?.name || c }}
</el-tag>
<span v-if="!current.categories || current.categories.length === 0">-</span>
</el-descriptions-item>
<el-descriptions-item label="应用截图">
<div class="shot-gallery">
<el-image
v-for="(s, i) in current.screenshots || []"
:key="i"
:src="withBase(s)"
:preview-src-list="(current.screenshots || []).map(withBase)"
:initial-index="i"
fit="cover"
class="shot-gallery__item"
>
<template #error>
<div class="shot-gallery__fallback">
<el-icon><Picture /></el-icon>
</div>
</template>
</el-image>
<span v-if="!current.screenshots || current.screenshots.length === 0">-</span>
</div>
</el-descriptions-item>
<el-descriptions-item label="可调试">
{{ current.debuggable == null ? "-" : current.debuggable ? "是" : "否" }}
</el-descriptions-item>
<el-descriptions-item label="上传时间">
{{ current.createTime || "-" }}
</el-descriptions-item>
<el-descriptions-item label="备注">{{ current.remark || "-" }}</el-descriptions-item>
<el-descriptions-item label="权限列表">
<div class="perm-list">
<el-tag
v-for="p in current.permissions || []"
:key="p"
size="small"
type="info"
class="perm-tag"
>
{{ p }}
</el-tag>
<span v-if="!current.permissions || current.permissions.length === 0">-</span>
</div>
</el-descriptions-item>
</el-descriptions>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import {
Box,
Delete,
Download,
Picture,
Refresh,
Search,
Upload,
View,
} from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import AppPackageAPI, { withBase, type AppPackage, type PackageType } from "@/api/app/package";
import AppCategoryAPI, { type AppCategory } from "@/api/app/category";
import UploadDialog from "./components/UploadDialog.vue";
/** 分类 code -> 分类对象 映射(从后端加载) */
const categoryMap = ref<Record<string, AppCategory>>({});
/** 分类下拉选项(按后端排序) */
const categoryOptions = ref<AppCategory[]>([]);
/** 各卡片分类保存中状态(key=packageId) */
const categorySaving = reactive<Record<string, boolean>>({});
async function loadCategories() {
try {
const cats = await AppCategoryAPI.list();
categoryMap.value = Object.fromEntries(cats.map((c) => [c.code, c]));
categoryOptions.value = cats;
} catch {
categoryMap.value = {};
categoryOptions.value = [];
}
}
/** 卡片内直接修改分类:变更即保存到后端 */
async function handleCategoryChange(item: AppPackage, val: string[]) {
categorySaving[item.id] = true;
try {
await AppPackageAPI.updateCategories(item.id, val);
ElMessage.success("分类已更新");
} catch (e) {
ElMessage.error((e as Error)?.message || "分类更新失败");
// 失败回滚:重新加载该包分类
await loadData();
} finally {
categorySaving[item.id] = false;
}
}
const query = reactive({
pageNum: 1,
pageSize: 12,
keywords: "",
packageType: "" as PackageType | "",
});
const list = ref<AppPackage[]>([]);
const total = ref(0);
const loading = ref(false);
const uploadVisible = ref(false);
const detailVisible = ref(false);
const current = ref<AppPackage | null>(null);
function typeTagType(type?: PackageType) {
if (type === "apk") return "success";
if (type === "aab") return "warning";
if (type === "xapk") return "danger";
return "info";
}
async function loadData() {
loading.value = true;
try {
const res = await AppPackageAPI.page({
pageNum: query.pageNum,
pageSize: query.pageSize,
keywords: query.keywords || undefined,
packageType: query.packageType || undefined,
});
list.value = res.list;
total.value = res.total;
} catch (e: any) {
ElMessage.error(e?.message || "加载失败");
} finally {
loading.value = false;
}
}
function handleQuery() {
query.pageNum = 1;
loadData();
}
function handleRefresh() {
query.pageNum = 1;
query.pageSize = 12;
query.keywords = "";
query.packageType = "";
loadData();
}
function handleUploaded() {
query.pageNum = 1;
loadData();
}
function openDetail(item: AppPackage) {
current.value = item;
detailVisible.value = true;
}
function download(item: AppPackage) {
if (!item.fileUrl) {
ElMessage.warning("文件地址缺失");
return;
}
const a = document.createElement("a");
a.href = withBase(item.fileUrl);
a.download = item.originalName || `${item.packageName}.${item.packageType}`;
a.click();
}
async function handleDelete(item: AppPackage) {
try {
await AppPackageAPI.delete(item.id);
ElMessage.success("已删除");
loadData();
} catch (e: any) {
ElMessage.error(e?.message || "删除失败");
}
}
function onPageChange(page: number) {
query.pageNum = page;
loadData();
}
function onSizeChange(size: number) {
query.pageSize = size;
query.pageNum = 1;
loadData();
}
onMounted(() => {
loadData();
loadCategories();
});
</script>
<style scoped lang="scss">
.app-package-container {
padding: 16px;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
.toolbar-left {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
min-height: 200px;
}
.pkg-card {
display: flex;
flex-direction: column;
// 固定卡片高度,保证所有卡片(跨行)严格等高,不再随内容多少而变化
height: 300px;
// Element Plus 的 el-card 内容由 .el-card__body 包裹,需让它撑满高度并纵向布局,
// 否则 flex:1 / margin-top:auto 拿不到可分配空间,底部无法对齐。
:deep(.el-card__body) {
display: flex;
flex: 1;
flex-direction: column;
width: 100%;
padding: 16px;
}
&__header {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 12px;
}
&__icon {
flex-shrink: 0;
width: 48px;
height: 48px;
overflow: hidden;
background: var(--el-fill-color-light);
border-radius: 10px;
}
&__icon-fallback {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 24px;
color: var(--el-text-color-secondary);
}
&__title {
flex: 1;
min-width: 0;
}
&__name {
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 15px;
font-weight: 600;
white-space: nowrap;
}
&__body {
flex: 1;
}
&__footer {
display: flex;
gap: 4px;
justify-content: flex-end;
padding-top: 8px;
margin-top: auto;
border-top: 1px solid var(--el-border-color-lighter);
}
}
.pkg-row {
display: flex;
font-size: 13px;
line-height: 22px;
.pkg-label {
flex-shrink: 0;
width: 56px;
color: var(--el-text-color-secondary);
}
.pkg-value {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
// 单行截断,防止长文本换行导致卡片高度不一致(完整内容通过 title 悬浮提示查看)
white-space: nowrap;
}
}
.abi-tag {
display: inline-flex;
gap: 4px;
align-items: center;
margin: 0 4px 4px 0;
}
.cat-icon {
display: inline-block;
width: 14px;
height: 14px;
margin-right: 2px;
vertical-align: middle;
object-fit: contain;
border-radius: 3px;
}
.perm-list {
max-height: 160px;
overflow-y: auto;
.perm-tag {
margin: 0 4px 4px 0;
}
}
.pager {
justify-content: flex-end;
margin-top: 16px;
}
/* 安装包详情:每行(标签 + 内容)固定单行不换行,超长省略 */
.detail-desc :deep(.el-descriptions__label),
.detail-desc :deep(.el-descriptions__content) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 权限 / CPU 架构等多标签列表仍按块级展示,不被单行规则影响 */
.detail-desc :deep(.perm-list),
.detail-desc :deep(.el-descriptions__content) > div {
white-space: normal;
}
/* 应用截图画廊 */
.shot-gallery {
display: flex;
flex-wrap: wrap;
gap: 8px;
&__item {
width: 96px;
height: 170px;
overflow: hidden;
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
&__fallback {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 22px;
color: var(--el-text-color-secondary);
}
}
</style>