571 lines
16 KiB
Vue
571 lines
16 KiB
Vue
<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>
|