feat: 新增 update 升级模块(渠道/全局/灰度/热更新/保存流程接口与页面)
This commit is contained in:
62
src/api/update/channel.ts
Normal file
62
src/api/update/channel.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
/** 更新渠道 */
|
||||
export interface UpdateChannel {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
sort: number;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
}
|
||||
|
||||
/** 更新渠道表单 */
|
||||
export interface UpdateChannelForm {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
sort: number;
|
||||
}
|
||||
|
||||
const BASE_URL = "/api/v1/app/update/channels";
|
||||
|
||||
const UpdateChannelAPI = {
|
||||
/** 渠道列表(按排序返回) */
|
||||
list() {
|
||||
return request<unknown, UpdateChannel[]>({
|
||||
url: `${BASE_URL}/list`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
/** 新增渠道 */
|
||||
create(data: UpdateChannelForm) {
|
||||
return request<unknown, UpdateChannel>({
|
||||
url: BASE_URL,
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
},
|
||||
|
||||
/** 更新渠道 */
|
||||
update(id: string, data: UpdateChannelForm) {
|
||||
return request<unknown, UpdateChannel>({
|
||||
url: `${BASE_URL}/${id}`,
|
||||
method: "put",
|
||||
data,
|
||||
});
|
||||
},
|
||||
|
||||
/** 删除渠道 */
|
||||
delete(id: string) {
|
||||
return request<unknown, boolean>({
|
||||
url: `${BASE_URL}/${id}`,
|
||||
method: "delete",
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default UpdateChannelAPI;
|
||||
171
src/api/update/global.ts
Normal file
171
src/api/update/global.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import request, { uploadHttp } from "@/utils/request";
|
||||
import type { PageResult } from "@/api/common";
|
||||
|
||||
/** 更新类型 */
|
||||
export type UpdateType = "GLOBAL" | "GRAY" | "SAVE_FLOW" | "HOT";
|
||||
|
||||
/** 发布单状态 */
|
||||
export type UpdateReleaseStatus = "DRAFT" | "PUBLISHED" | "STOPPED";
|
||||
|
||||
/** 应用更新发布单 */
|
||||
export interface AppUpdateRelease {
|
||||
id: string;
|
||||
updateType: UpdateType;
|
||||
channelCode: string;
|
||||
channelName: string;
|
||||
packageName: string;
|
||||
appLabel: string;
|
||||
versionName: string;
|
||||
versionCode: number;
|
||||
fileUrl: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
fileSizeText: string;
|
||||
md5: string;
|
||||
sha1: string;
|
||||
sha256: string;
|
||||
iconUrl: string;
|
||||
forceUpdate: boolean;
|
||||
listed: boolean;
|
||||
releaseNotes: string;
|
||||
status: UpdateReleaseStatus;
|
||||
publishedAt: string | null;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
}
|
||||
|
||||
/** 全局更新安装包上传结果(后端自动解析包名/版本/图标) */
|
||||
export interface AppUpdateFile {
|
||||
fileUrl: string;
|
||||
fileName: string;
|
||||
fileSize: number | string;
|
||||
fileSizeText: string;
|
||||
md5: string;
|
||||
sha1: string;
|
||||
sha256: string;
|
||||
iconUrl: string;
|
||||
packageName: string;
|
||||
appLabel: string;
|
||||
versionName: string;
|
||||
versionCode: number | string | null;
|
||||
parsed: boolean;
|
||||
}
|
||||
|
||||
/** 发布单表单 */
|
||||
export interface AppUpdateReleaseForm {
|
||||
fileUrl: string;
|
||||
fileName: string;
|
||||
fileSize?: number | string;
|
||||
fileSizeText?: string;
|
||||
md5?: string;
|
||||
sha1?: string;
|
||||
sha256?: string;
|
||||
iconUrl?: string;
|
||||
channelCode: string;
|
||||
packageName: string;
|
||||
appLabel?: string;
|
||||
versionName: string;
|
||||
versionCode?: number;
|
||||
forceUpdate: boolean;
|
||||
listed: boolean;
|
||||
releaseNotes?: string;
|
||||
}
|
||||
|
||||
/** 发布单分页查询参数 */
|
||||
export interface AppUpdateReleasePageQuery {
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
keywords?: string;
|
||||
status?: UpdateReleaseStatus | "";
|
||||
}
|
||||
|
||||
const BASE_URL = "/api/v1/app/update/global";
|
||||
|
||||
const AppUpdateReleaseAPI = {
|
||||
/** 上传全局更新安装包(独立存储目录,自动解析包信息) */
|
||||
upload(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return uploadHttp<unknown, AppUpdateFile>({
|
||||
url: `${BASE_URL}/upload`,
|
||||
method: "post",
|
||||
data: formData,
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
},
|
||||
|
||||
/** 分页查询全局更新发布单 */
|
||||
page(query: AppUpdateReleasePageQuery) {
|
||||
return request<unknown, PageResult<AppUpdateRelease>>({
|
||||
url: `${BASE_URL}/page`,
|
||||
method: "get",
|
||||
params: {
|
||||
pageNum: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
keywords: query.keywords,
|
||||
status: query.status || undefined,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/** 发布单详情 */
|
||||
detail(id: string) {
|
||||
return request<unknown, AppUpdateRelease>({
|
||||
url: `${BASE_URL}/${id}`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
/** 创建全局更新草稿 */
|
||||
create(data: AppUpdateReleaseForm) {
|
||||
return request<unknown, AppUpdateRelease>({
|
||||
url: BASE_URL,
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
},
|
||||
|
||||
/** 编辑全局更新草稿 */
|
||||
update(id: string, data: AppUpdateReleaseForm) {
|
||||
return request<unknown, AppUpdateRelease>({
|
||||
url: `${BASE_URL}/${id}`,
|
||||
method: "put",
|
||||
data,
|
||||
});
|
||||
},
|
||||
|
||||
/** 发布 */
|
||||
publish(id: string) {
|
||||
return request<unknown, AppUpdateRelease>({
|
||||
url: `${BASE_URL}/${id}/publish`,
|
||||
method: "post",
|
||||
});
|
||||
},
|
||||
|
||||
/** 停止发布 */
|
||||
stop(id: string) {
|
||||
return request<unknown, AppUpdateRelease>({
|
||||
url: `${BASE_URL}/${id}/stop`,
|
||||
method: "post",
|
||||
});
|
||||
},
|
||||
|
||||
/** 上架 / 下架 */
|
||||
updateListed(id: string, listed: boolean) {
|
||||
return request<unknown, AppUpdateRelease>({
|
||||
url: `${BASE_URL}/${id}/listed`,
|
||||
method: "put",
|
||||
params: { listed },
|
||||
});
|
||||
},
|
||||
|
||||
/** 删除发布单 */
|
||||
delete(id: string) {
|
||||
return request<unknown, boolean>({
|
||||
url: `${BASE_URL}/${id}`,
|
||||
method: "delete",
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default AppUpdateReleaseAPI;
|
||||
138
src/api/update/gray.ts
Normal file
138
src/api/update/gray.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import request, { uploadHttp } from "@/utils/request";
|
||||
import type { PageResult } from "@/api/common";
|
||||
import type { AppUpdateFile, AppUpdateRelease, UpdateReleaseStatus } from "./global";
|
||||
|
||||
/** 灰度下发目标 */
|
||||
export type GrayTargetType = "CHANNEL" | "SN";
|
||||
|
||||
/** 灰度更新发布单(与全局发布单同集合,按 updateType=GRAY 区分) */
|
||||
export interface AppUpdateGrayRelease extends AppUpdateRelease {
|
||||
targetType: GrayTargetType;
|
||||
/** 目标设备数量(按 SN 下发时) */
|
||||
snCount: number | null;
|
||||
/** 目标设备序列号(仅详情返回) */
|
||||
snList: string[] | null;
|
||||
}
|
||||
|
||||
/** 灰度更新发布单表单 */
|
||||
export interface AppUpdateGrayForm {
|
||||
fileUrl: string;
|
||||
fileName: string;
|
||||
fileSize?: number | string;
|
||||
fileSizeText?: string;
|
||||
md5?: string;
|
||||
sha1?: string;
|
||||
sha256?: string;
|
||||
iconUrl?: string;
|
||||
targetType: GrayTargetType;
|
||||
/** 按渠道下发时的渠道编码 */
|
||||
channelCode?: string;
|
||||
/** 按设备下发时的设备序列号列表 */
|
||||
snList: string[];
|
||||
packageName: string;
|
||||
appLabel?: string;
|
||||
versionName: string;
|
||||
versionCode?: number;
|
||||
forceUpdate: boolean;
|
||||
listed: boolean;
|
||||
releaseNotes?: string;
|
||||
}
|
||||
|
||||
/** 灰度发布单分页查询参数 */
|
||||
export interface AppUpdateGrayPageQuery {
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
keywords?: string;
|
||||
status?: UpdateReleaseStatus | "";
|
||||
}
|
||||
|
||||
const BASE_URL = "/api/v1/app/update/gray";
|
||||
|
||||
const AppUpdateGrayAPI = {
|
||||
/** 上传灰度更新安装包(独立存储目录,自动解析包信息) */
|
||||
upload(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return uploadHttp<unknown, AppUpdateFile>({
|
||||
url: `${BASE_URL}/upload`,
|
||||
method: "post",
|
||||
data: formData,
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
},
|
||||
|
||||
/** 分页查询灰度发布单 */
|
||||
page(query: AppUpdateGrayPageQuery) {
|
||||
return request<unknown, PageResult<AppUpdateGrayRelease>>({
|
||||
url: `${BASE_URL}/page`,
|
||||
method: "get",
|
||||
params: {
|
||||
pageNum: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
keywords: query.keywords,
|
||||
status: query.status || undefined,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/** 灰度发布单详情(含目标设备序列号) */
|
||||
detail(id: string) {
|
||||
return request<unknown, AppUpdateGrayRelease>({
|
||||
url: `${BASE_URL}/${id}`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
/** 创建灰度更新草稿 */
|
||||
create(data: AppUpdateGrayForm) {
|
||||
return request<unknown, AppUpdateGrayRelease>({
|
||||
url: BASE_URL,
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
},
|
||||
|
||||
/** 编辑灰度更新草稿 */
|
||||
update(id: string, data: AppUpdateGrayForm) {
|
||||
return request<unknown, AppUpdateGrayRelease>({
|
||||
url: `${BASE_URL}/${id}`,
|
||||
method: "put",
|
||||
data,
|
||||
});
|
||||
},
|
||||
|
||||
/** 发布 */
|
||||
publish(id: string) {
|
||||
return request<unknown, AppUpdateGrayRelease>({
|
||||
url: `${BASE_URL}/${id}/publish`,
|
||||
method: "post",
|
||||
});
|
||||
},
|
||||
|
||||
/** 停止发布 */
|
||||
stop(id: string) {
|
||||
return request<unknown, AppUpdateGrayRelease>({
|
||||
url: `${BASE_URL}/${id}/stop`,
|
||||
method: "post",
|
||||
});
|
||||
},
|
||||
|
||||
/** 上架 / 下架 */
|
||||
updateListed(id: string, listed: boolean) {
|
||||
return request<unknown, AppUpdateGrayRelease>({
|
||||
url: `${BASE_URL}/${id}/listed`,
|
||||
method: "put",
|
||||
params: { listed },
|
||||
});
|
||||
},
|
||||
|
||||
/** 删除发布单 */
|
||||
delete(id: string) {
|
||||
return request<unknown, boolean>({
|
||||
url: `${BASE_URL}/${id}`,
|
||||
method: "delete",
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default AppUpdateGrayAPI;
|
||||
174
src/api/update/save-flow.ts
Normal file
174
src/api/update/save-flow.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import request, { uploadHttp } from "@/utils/request";
|
||||
import type { PageResult } from "@/api/common";
|
||||
import type { AppUpdateFile, AppUpdateRelease, UpdateReleaseStatus } from "./global";
|
||||
|
||||
/** 差分补丁生成状态 */
|
||||
export type PatchStatus = "GENERATING" | "READY" | "FAILED";
|
||||
|
||||
/** 差分源包来源 */
|
||||
export type PatchBaseSource = "RELEASE" | "UPLOAD";
|
||||
|
||||
/** 省流更新差分补丁 */
|
||||
export interface AppUpdatePatch {
|
||||
id: string;
|
||||
targetReleaseId: string;
|
||||
channelCode: string;
|
||||
channelName: string;
|
||||
packageName: string;
|
||||
appLabel: string;
|
||||
iconUrl: string;
|
||||
targetVersionName: string;
|
||||
targetVersionCode: number;
|
||||
targetFileSize: number;
|
||||
targetFileSizeText: string;
|
||||
targetSha256: string;
|
||||
baseVersionName: string;
|
||||
baseVersionCode: number;
|
||||
baseSource: PatchBaseSource;
|
||||
baseReleaseId: string | null;
|
||||
baseFileUrl: string;
|
||||
baseFileName: string;
|
||||
baseSha256: string;
|
||||
patchUrl: string | null;
|
||||
patchFileName: string;
|
||||
patchSize: number | null;
|
||||
patchSizeText: string | null;
|
||||
patchRatio: number | null;
|
||||
md5: string | null;
|
||||
sha1: string | null;
|
||||
sha256: string | null;
|
||||
patchAlgorithm: string;
|
||||
sizeTier: string | null;
|
||||
status: PatchStatus;
|
||||
errorMessage: string | null;
|
||||
/** 生成成功但运营层面给出的建议(如补丁率过高建议改用全量),非空表示默认未上架 */
|
||||
advice: string | null;
|
||||
listed: boolean;
|
||||
forceUpdate: boolean;
|
||||
targetReleaseStatus: UpdateReleaseStatus | null;
|
||||
targetListed: boolean | null;
|
||||
generatedAt: string | null;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
}
|
||||
|
||||
/** 补录的差分源包 */
|
||||
export interface PatchBasePackage {
|
||||
fileUrl: string;
|
||||
fileName: string;
|
||||
packageName: string;
|
||||
appLabel?: string;
|
||||
versionName: string;
|
||||
versionCode: number;
|
||||
md5: string;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
/** 差分补丁创建表单 */
|
||||
export interface AppUpdatePatchForm {
|
||||
targetReleaseId: string;
|
||||
baseReleaseIds: string[];
|
||||
basePackages: PatchBasePackage[];
|
||||
}
|
||||
|
||||
/** 差分补丁分页查询参数 */
|
||||
export interface AppUpdatePatchPageQuery {
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
status?: PatchStatus | "";
|
||||
channelCode?: string;
|
||||
keywords?: string;
|
||||
}
|
||||
|
||||
const BASE_URL = "/api/v1/app/update/save-flow";
|
||||
|
||||
const AppUpdatePatchAPI = {
|
||||
/** 上传补录的旧安装包(差分源包) */
|
||||
uploadBase(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return uploadHttp<unknown, AppUpdateFile>({
|
||||
url: `${BASE_URL}/upload-base`,
|
||||
method: "post",
|
||||
data: formData,
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
},
|
||||
|
||||
/** 目标发布单可勾选的历史源版本 */
|
||||
baseVersions(targetReleaseId: string) {
|
||||
return request<unknown, AppUpdateRelease[]>({
|
||||
url: `${BASE_URL}/base-versions`,
|
||||
params: { targetReleaseId },
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
/** 分页查询差分补丁 */
|
||||
page(query: AppUpdatePatchPageQuery) {
|
||||
return request<unknown, PageResult<AppUpdatePatch>>({
|
||||
url: `${BASE_URL}/page`,
|
||||
method: "get",
|
||||
params: {
|
||||
pageNum: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
status: query.status || undefined,
|
||||
channelCode: query.channelCode || undefined,
|
||||
keywords: query.keywords || undefined,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/** 差分补丁详情 */
|
||||
detail(id: string) {
|
||||
return request<unknown, AppUpdatePatch>({
|
||||
url: `${BASE_URL}/${id}`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
/** 提交差分生成任务 */
|
||||
create(data: AppUpdatePatchForm) {
|
||||
return request<unknown, AppUpdatePatch[]>({
|
||||
url: BASE_URL,
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
},
|
||||
|
||||
/** 上架 / 下架补丁 */
|
||||
updateListed(id: string, listed: boolean) {
|
||||
return request<unknown, AppUpdatePatch>({
|
||||
url: `${BASE_URL}/${id}/listed`,
|
||||
method: "put",
|
||||
params: { listed },
|
||||
});
|
||||
},
|
||||
|
||||
/** 开启 / 关闭补丁的强制更新 */
|
||||
updateForceUpdate(id: string, forceUpdate: boolean) {
|
||||
return request<unknown, AppUpdatePatch>({
|
||||
url: `${BASE_URL}/${id}/force-update`,
|
||||
method: "put",
|
||||
params: { forceUpdate },
|
||||
});
|
||||
},
|
||||
|
||||
/** 重新生成补丁 */
|
||||
regenerate(id: string) {
|
||||
return request<unknown, AppUpdatePatch>({
|
||||
url: `${BASE_URL}/${id}/regenerate`,
|
||||
method: "post",
|
||||
});
|
||||
},
|
||||
|
||||
/** 删除补丁 */
|
||||
delete(id: string) {
|
||||
return request<unknown, boolean>({
|
||||
url: `${BASE_URL}/${id}`,
|
||||
method: "delete",
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default AppUpdatePatchAPI;
|
||||
235
src/views/update/channel/index.vue
Normal file
235
src/views/update/channel/index.vue
Normal file
@@ -0,0 +1,235 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-card shadow="never" class="table-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">渠道管理</span>
|
||||
<el-button type="primary" :icon="Plus" @click="openDialog()">新增渠道</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border stripe>
|
||||
<el-table-column label="渠道编码" prop="code" width="180" />
|
||||
<el-table-column label="渠道名称" prop="name" min-width="160" />
|
||||
<el-table-column label="描述" prop="description" min-width="240" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-model="row.enabled"
|
||||
inline-prompt
|
||||
active-text="启用"
|
||||
inactive-text="停用"
|
||||
@change="handleToggle(row as UpdateChannel)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="排序" prop="sort" width="90" align="center" />
|
||||
<el-table-column label="创建时间" prop="createTime" width="180" />
|
||||
<el-table-column label="操作" width="160" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" :icon="Edit" @click="openDialog(row as UpdateChannel)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
@click="handleDelete(row as UpdateChannel)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑渠道' : '新增渠道'"
|
||||
width="520px"
|
||||
@closed="handleClosed"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||
<el-form-item label="渠道编码" prop="code">
|
||||
<el-input
|
||||
v-model="form.code"
|
||||
placeholder="设备端上报的 channel,如 official"
|
||||
maxlength="64"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="渠道名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="展示名称,如 官网渠道" maxlength="64" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input
|
||||
v-model="form.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="渠道说明"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="enabled">
|
||||
<el-switch v-model="form.enabled" inline-prompt active-text="启用" inactive-text="停用" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number v-model="form.sort" :min="0" :max="999" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { Delete, Edit, Plus } from "@element-plus/icons-vue";
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
|
||||
import UpdateChannelAPI, { type UpdateChannel, type UpdateChannelForm } from "@/api/update/channel";
|
||||
|
||||
const list = ref<UpdateChannel[]>([]);
|
||||
const loading = ref(false);
|
||||
const dialogVisible = ref(false);
|
||||
const isEdit = ref(false);
|
||||
const submitting = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
const currentId = ref<string>();
|
||||
|
||||
const form = reactive<UpdateChannelForm>({
|
||||
code: "",
|
||||
name: "",
|
||||
description: "",
|
||||
enabled: true,
|
||||
sort: 0,
|
||||
});
|
||||
|
||||
const rules: FormRules<UpdateChannelForm> = {
|
||||
code: [
|
||||
{ required: true, message: "请输入渠道编码", trigger: "blur" },
|
||||
{
|
||||
pattern: /^[A-Za-z0-9_-]{2,64}$/,
|
||||
message: "仅支持 2-64 位字母、数字、下划线或短横线",
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
name: [{ required: true, message: "请输入渠道名称", trigger: "blur" }],
|
||||
};
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
list.value = await UpdateChannelAPI.list();
|
||||
} catch {
|
||||
// 失败提示由请求拦截器统一发出
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openDialog(row?: UpdateChannel) {
|
||||
if (row) {
|
||||
isEdit.value = true;
|
||||
currentId.value = row.id;
|
||||
Object.assign(form, {
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
enabled: row.enabled,
|
||||
sort: row.sort ?? 0,
|
||||
});
|
||||
} else {
|
||||
isEdit.value = false;
|
||||
currentId.value = undefined;
|
||||
}
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.code = "";
|
||||
form.name = "";
|
||||
form.description = "";
|
||||
form.enabled = true;
|
||||
form.sort = 0;
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
function handleClosed() {
|
||||
resetForm();
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return;
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const payload: UpdateChannelForm = {
|
||||
code: form.code.trim(),
|
||||
name: form.name.trim(),
|
||||
description: form.description,
|
||||
enabled: form.enabled,
|
||||
sort: form.sort,
|
||||
};
|
||||
if (isEdit.value && currentId.value) {
|
||||
await UpdateChannelAPI.update(currentId.value, payload);
|
||||
ElMessage.success("更新成功");
|
||||
} else {
|
||||
await UpdateChannelAPI.create(payload);
|
||||
ElMessage.success("新增成功");
|
||||
}
|
||||
dialogVisible.value = false;
|
||||
await fetchList();
|
||||
} catch {
|
||||
// 失败提示由请求拦截器统一发出,弹窗保持打开便于修正后重试
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggle(row: UpdateChannel) {
|
||||
try {
|
||||
await UpdateChannelAPI.update(row.id, {
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
enabled: row.enabled,
|
||||
sort: row.sort,
|
||||
});
|
||||
ElMessage.success(row.enabled ? "已启用" : "已停用");
|
||||
} catch {
|
||||
// 失败提示由请求拦截器统一发出,重新拉取列表以回滚开关状态
|
||||
await fetchList();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row: UpdateChannel) {
|
||||
await ElMessageBox.confirm(`确认删除渠道「${row.name}」?`, "提示", {
|
||||
type: "warning",
|
||||
});
|
||||
await UpdateChannelAPI.delete(row.id);
|
||||
ElMessage.success("删除成功");
|
||||
await fetchList();
|
||||
}
|
||||
|
||||
onMounted(fetchList);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
326
src/views/update/components/PatchCreateDrawer.vue
Normal file
326
src/views/update/components/PatchCreateDrawer.vue
Normal file
@@ -0,0 +1,326 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
:model-value="modelValue"
|
||||
title="制作省流包"
|
||||
size="720px"
|
||||
:close-on-click-modal="false"
|
||||
@update:model-value="emit('update:modelValue', $event as boolean)"
|
||||
@closed="handleClosed"
|
||||
>
|
||||
<div v-loading="loading" class="drawer-body">
|
||||
<el-descriptions v-if="target" :column="1" border title="目标版本" class="block">
|
||||
<el-descriptions-item label="应用">
|
||||
{{ target.appLabel || "-" }}({{ target.packageName }})
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="渠道">
|
||||
{{ target.channelName || "-" }}({{ target.channelCode }})
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="版本">
|
||||
{{ target.versionName }}({{ target.versionCode }})
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="安装包">
|
||||
<div class="pkg">{{ target.fileName }}</div>
|
||||
<div class="muted">{{ target.fileSizeText }}</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="校验值">
|
||||
<div class="muted">MD5 {{ target.md5 || "-" }}</div>
|
||||
<div class="muted">SHA256 {{ target.sha256 || "-" }}</div>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="block">
|
||||
<div class="block__title">
|
||||
<span>选择源版本</span>
|
||||
<span class="muted">设备已安装包的 SHA-256 命中源包才会下发差分</span>
|
||||
</div>
|
||||
<el-table
|
||||
ref="baseTableRef"
|
||||
:data="baseVersions"
|
||||
row-key="id"
|
||||
border
|
||||
size="small"
|
||||
max-height="240"
|
||||
@selection-change="onSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="44" reserve-selection />
|
||||
<el-table-column label="版本名称" prop="versionName" min-width="110" />
|
||||
<el-table-column label="版本号" prop="versionCode" width="90" />
|
||||
<el-table-column label="安装包" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="pkg">{{ row.fileName }}</div>
|
||||
<div class="muted">{{ row.fileSizeText }} · 基线 {{ shortHash(row.sha256) }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="渠道" width="130">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.channelName || row.channelCode || "-" }}</div>
|
||||
<el-tag
|
||||
v-if="row.channelCode !== target?.channelCode"
|
||||
type="info"
|
||||
size="small"
|
||||
class="cross-channel"
|
||||
>
|
||||
跨渠道
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
{{ row.status === "PUBLISHED" ? "已发布" : "已停止" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<span class="muted">
|
||||
没有可勾选的历史版本(需同包名、版本号更低、带校验值的单文件 APK)
|
||||
</span>
|
||||
</template>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="block">
|
||||
<div class="block__title">
|
||||
<span>补录旧安装包</span>
|
||||
<span class="muted">不在发布单里的历史包,按文件 SHA-256 匹配设备</span>
|
||||
</div>
|
||||
<input ref="fileInputRef" type="file" accept=".apk" class="hidden-input" @change="onPick" />
|
||||
<div
|
||||
v-loading="uploading"
|
||||
class="base-picker"
|
||||
element-loading-text="正在上传并解析安装包,请稍候…"
|
||||
@click="fileInputRef?.click()"
|
||||
>
|
||||
<el-icon><UploadFilled /></el-icon>
|
||||
<span>点击上传旧安装包</span>
|
||||
</div>
|
||||
<div v-for="(item, index) in basePackages" :key="item.fileUrl" class="base-item">
|
||||
<div class="base-item__main">
|
||||
<div class="pkg">{{ item.fileName }}</div>
|
||||
<div class="muted">
|
||||
{{ item.versionName }}({{ item.versionCode }})· {{ item.packageName }} · 基线
|
||||
{{ shortHash(item.sha256) }}
|
||||
</div>
|
||||
</div>
|
||||
<el-button link type="danger" :icon="Delete" @click="basePackages.splice(index, 1)">
|
||||
移除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="close">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">提交生成</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { Delete, UploadFilled } from "@element-plus/icons-vue";
|
||||
import { ElMessage, type TableInstance } from "element-plus";
|
||||
import type { AppUpdateFile, AppUpdateRelease } from "@/api/update/global";
|
||||
import AppUpdatePatchAPI, { type PatchBasePackage } from "@/api/update/save-flow";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
target: AppUpdateRelease | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: boolean];
|
||||
success: [];
|
||||
}>();
|
||||
|
||||
/** 差分只对单文件 APK 生效:xapk / apks / apkm 安装后没有唯一的已安装文件可哈希 */
|
||||
const APK_EXTENSION = ".apk";
|
||||
|
||||
const loading = ref(false);
|
||||
const uploading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const baseTableRef = ref<TableInstance>();
|
||||
const fileInputRef = ref<HTMLInputElement>();
|
||||
const baseVersions = ref<AppUpdateRelease[]>([]);
|
||||
const selectedBaseIds = ref<string[]>([]);
|
||||
const basePackages = ref<PatchBasePackage[]>([]);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async (visible) => {
|
||||
if (!visible || !props.target) return;
|
||||
await loadBaseVersions();
|
||||
}
|
||||
);
|
||||
|
||||
async function loadBaseVersions() {
|
||||
if (!props.target) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
baseVersions.value = await AppUpdatePatchAPI.baseVersions(props.target.id);
|
||||
} catch {
|
||||
// 失败提示由请求拦截器统一发出
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function shortHash(value: string | null | undefined) {
|
||||
if (!value) return "-";
|
||||
return `${value.slice(0, 8)}…${value.slice(-4)}`;
|
||||
}
|
||||
|
||||
function onSelectionChange(rows: AppUpdateRelease[]) {
|
||||
selectedBaseIds.value = rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
function onPick(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (file) handleBaseFile(file);
|
||||
input.value = "";
|
||||
}
|
||||
|
||||
async function handleBaseFile(file: File) {
|
||||
if (uploading.value) return;
|
||||
if (!file.name.toLowerCase().endsWith(APK_EXTENSION)) {
|
||||
ElMessage.error("差分源包仅支持单文件 .apk");
|
||||
return;
|
||||
}
|
||||
const target = props.target;
|
||||
if (!target) return;
|
||||
|
||||
uploading.value = true;
|
||||
try {
|
||||
const vo = await AppUpdatePatchAPI.uploadBase(file);
|
||||
if (!vo.parsed || !vo.packageName || vo.versionCode == null) {
|
||||
ElMessage.error("未能解析出该安装包的应用包名与版本号,无法作为差分源包");
|
||||
return;
|
||||
}
|
||||
if (!vo.md5 || !vo.sha256) {
|
||||
ElMessage.error("该安装包未算出校验值,无法作为差分源包");
|
||||
return;
|
||||
}
|
||||
if (vo.packageName !== target.packageName) {
|
||||
ElMessage.error(`源包应用(${vo.packageName})与目标版本(${target.packageName})不一致`);
|
||||
return;
|
||||
}
|
||||
if (Number(vo.versionCode) >= Number(target.versionCode)) {
|
||||
ElMessage.error(`源包版本 ${vo.versionName} 的版本号不低于目标版本,不能作为基线`);
|
||||
return;
|
||||
}
|
||||
if (basePackages.value.some((item) => Number(item.versionCode) === Number(vo.versionCode))) {
|
||||
ElMessage.warning(`已补录过版本号 ${vo.versionCode} 的源包`);
|
||||
return;
|
||||
}
|
||||
basePackages.value.push(toBasePackage(vo));
|
||||
ElMessage.success("旧安装包已上传并解析完成");
|
||||
} catch {
|
||||
// 失败提示由请求拦截器统一发出
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toBasePackage(vo: AppUpdateFile): PatchBasePackage {
|
||||
return {
|
||||
fileUrl: vo.fileUrl,
|
||||
fileName: vo.fileName,
|
||||
packageName: vo.packageName,
|
||||
appLabel: vo.appLabel,
|
||||
versionName: vo.versionName,
|
||||
versionCode: Number(vo.versionCode),
|
||||
md5: vo.md5,
|
||||
sha256: vo.sha256,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const target = props.target;
|
||||
if (!target) return;
|
||||
if (selectedBaseIds.value.length === 0 && basePackages.value.length === 0) {
|
||||
ElMessage.warning("请至少勾选一个源版本或补录一个旧安装包");
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const created = await AppUpdatePatchAPI.create({
|
||||
targetReleaseId: target.id,
|
||||
baseReleaseIds: selectedBaseIds.value,
|
||||
basePackages: basePackages.value,
|
||||
});
|
||||
ElMessage.success(`已提交 ${created.length} 个差分生成任务`);
|
||||
emit("success");
|
||||
close();
|
||||
} catch {
|
||||
// 失败提示由请求拦截器统一发出,抽屉保持打开便于调整后重试
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit("update:modelValue", false);
|
||||
}
|
||||
|
||||
function handleClosed() {
|
||||
baseTableRef.value?.clearSelection();
|
||||
baseVersions.value = [];
|
||||
selectedBaseIds.value = [];
|
||||
basePackages.value = [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.drawer-body {
|
||||
min-height: 200px;
|
||||
}
|
||||
.block {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.block__title {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.hidden-input {
|
||||
display: none;
|
||||
}
|
||||
.base-picker {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: pointer;
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.base-picker:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
.base-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px dashed var(--el-border-color-lighter);
|
||||
}
|
||||
.base-item__main {
|
||||
min-width: 0;
|
||||
}
|
||||
.pkg {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.muted {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.cross-channel {
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
918
src/views/update/global/index.vue
Normal file
918
src/views/update/global/index.vue
Normal file
@@ -0,0 +1,918 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-card shadow="never" class="table-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">全局更新</span>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">新建全局更新</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-input
|
||||
v-model="query.keywords"
|
||||
placeholder="搜索应用名 / 包名 / 版本名称"
|
||||
clearable
|
||||
style="width: 260px"
|
||||
:prefix-icon="Search"
|
||||
@keyup.enter="handleQuery"
|
||||
@clear="handleQuery"
|
||||
/>
|
||||
<el-select
|
||||
v-model="query.status"
|
||||
placeholder="全部状态"
|
||||
clearable
|
||||
style="width: 140px"
|
||||
@change="handleQuery"
|
||||
>
|
||||
<el-option label="草稿" value="DRAFT" />
|
||||
<el-option label="已发布" value="PUBLISHED" />
|
||||
<el-option label="已停止" value="STOPPED" />
|
||||
</el-select>
|
||||
<el-button type="primary" :icon="Search" @click="handleQuery">查询</el-button>
|
||||
<el-button :icon="Refresh" @click="handleRefresh">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border stripe>
|
||||
<el-table-column label="应用" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="app-cell">
|
||||
<el-image :src="withBase(row.iconUrl)" fit="contain" class="app-icon">
|
||||
<template #error>
|
||||
<div class="app-icon__fallback">
|
||||
<el-icon><Box /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
<div class="app-meta">
|
||||
<div class="app-name">{{ row.appLabel || "未知应用" }}</div>
|
||||
<div class="app-package">{{ row.packageName }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="版本" width="150">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.versionName || "-" }}</div>
|
||||
<div class="muted">versionCode {{ row.versionCode }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="渠道" width="140">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.channelName || row.channelCode || "-" }}</div>
|
||||
<div class="muted">{{ row.channelCode || "-" }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="安装包" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="pkg-name" :title="row.fileName">{{ row.fileName || "-" }}</div>
|
||||
<div class="muted">{{ row.fileSizeText || "-" }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上架" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
:model-value="row.listed"
|
||||
:disabled="row.status !== 'PUBLISHED'"
|
||||
@change="(val) => handleListedChange(row as AppUpdateRelease, val)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="强制更新" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.listed && row.forceUpdate" type="danger" effect="dark" size="small">
|
||||
强制
|
||||
</el-tag>
|
||||
<el-tag v-else-if="row.listed" type="info" size="small">可选</el-tag>
|
||||
<span v-else class="muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" size="small">
|
||||
{{ statusLabel(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发布时间" prop="publishedAt" width="180">
|
||||
<template #default="{ row }">{{ row.publishedAt || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="400" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
:icon="View"
|
||||
@click="openDetail(row as AppUpdateRelease)"
|
||||
>
|
||||
详情
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 'DRAFT'"
|
||||
link
|
||||
type="primary"
|
||||
:icon="Edit"
|
||||
@click="openEdit(row as AppUpdateRelease)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status !== 'PUBLISHED'"
|
||||
link
|
||||
type="success"
|
||||
:icon="Promotion"
|
||||
@click="handlePublish(row as AppUpdateRelease)"
|
||||
>
|
||||
发布
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 'PUBLISHED'"
|
||||
link
|
||||
type="warning"
|
||||
:icon="VideoPause"
|
||||
@click="handleStop(row as AppUpdateRelease)"
|
||||
>
|
||||
停止
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 'PUBLISHED'"
|
||||
link
|
||||
type="primary"
|
||||
:icon="DataLine"
|
||||
@click="openPatchDrawer(row as AppUpdateRelease)"
|
||||
>
|
||||
制作省流包
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status !== 'PUBLISHED'"
|
||||
link
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
@click="handleDelete(row as AppUpdateRelease)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-if="total > 0"
|
||||
class="pager"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:current-page="query.pageNum"
|
||||
:page-size="query.pageSize"
|
||||
background
|
||||
@current-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑全局更新' : '新建全局更新'"
|
||||
width="680px"
|
||||
:close-on-click-modal="false"
|
||||
@closed="handleClosed"
|
||||
>
|
||||
<div
|
||||
v-loading="uploading"
|
||||
element-loading-text="正在上传并解析安装包,请稍候…"
|
||||
element-loading-lock
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="92px">
|
||||
<el-form-item label="安装包" prop="fileUrl">
|
||||
<div class="apk-picker" :class="{ 'is-selected': !!form.fileUrl }">
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".apk,.apks,.apkm,.xapk"
|
||||
class="apk-picker__input"
|
||||
@change="onPick"
|
||||
/>
|
||||
<div
|
||||
class="apk-picker__body"
|
||||
@click="fileInput?.click()"
|
||||
@dragover.prevent
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
|
||||
<div class="el-upload__text">
|
||||
将安装包拖到此处,或
|
||||
<em>点击选择</em>
|
||||
</div>
|
||||
<div v-if="form.fileUrl" class="apk-picker__file">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span class="apk-picker__name">{{ form.fileName }}</span>
|
||||
<span class="muted">{{ form.fileSizeText || "-" }}</span>
|
||||
</div>
|
||||
<div v-else class="el-upload__tip">
|
||||
支持 .apk / .apks / .apkm / .xapk,选择后自动上传并解析包信息
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-alert
|
||||
v-if="parseFailed"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="自动解析失败,请手动填写应用包名与版本信息"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<el-form-item label="渠道" prop="channelCode">
|
||||
<el-select
|
||||
v-model="form.channelCode"
|
||||
filterable
|
||||
placeholder="请选择渠道"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="channel in channelOptions"
|
||||
:key="channel.code"
|
||||
:label="`${channel.name}(${channel.code})`"
|
||||
:value="channel.code"
|
||||
:disabled="channel.enabled === false"
|
||||
/>
|
||||
</el-select>
|
||||
<span class="form-tip">设备/家属端按渠道匹配更新</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="应用图标">
|
||||
<div class="icon-preview">
|
||||
<el-image
|
||||
v-if="form.iconUrl"
|
||||
:src="withBase(form.iconUrl)"
|
||||
fit="contain"
|
||||
class="icon-preview__img"
|
||||
>
|
||||
<template #error>
|
||||
<div class="icon-preview__fallback">
|
||||
<el-icon><Box /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
<span v-else class="icon-preview__placeholder">上传后自动提取</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="应用名称" prop="appLabel">
|
||||
<el-input v-model="form.appLabel" placeholder="自动解析,可修改" maxlength="100" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="应用包名" prop="packageName">
|
||||
<el-input v-model="form.packageName" placeholder="如 com.example.app" maxlength="200" />
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="版本名称" prop="versionName">
|
||||
<el-input v-model="form.versionName" placeholder="如 1.2.0" maxlength="64" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="版本号" prop="versionCode">
|
||||
<el-input-number
|
||||
v-model="form.versionCode"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
:controls="false"
|
||||
placeholder="versionCode"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="上架" prop="listed">
|
||||
<el-switch v-model="form.listed" />
|
||||
<span class="form-tip">关闭后设备与家属端均检查不到该更新</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="强制更新" prop="forceUpdate">
|
||||
<el-switch v-model="form.forceUpdate" :disabled="!form.listed" />
|
||||
<span class="form-tip">
|
||||
{{
|
||||
form.listed ? "开启后设备必须完成更新才能继续使用" : "未上架的更新不能开启强制更新"
|
||||
}}
|
||||
</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="更新说明" prop="releaseNotes">
|
||||
<el-input
|
||||
v-model="form.releaseNotes"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
placeholder="本次更新内容"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存草稿</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-drawer v-model="detailVisible" title="发布单详情" size="640px">
|
||||
<el-descriptions v-if="current" :column="1" border>
|
||||
<el-descriptions-item label="应用名称">{{ current.appLabel || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="包名">{{ current.packageName || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="渠道">
|
||||
{{ current.channelName || "-" }}({{ current.channelCode || "-" }})
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="版本">
|
||||
{{ current.versionName || "-" }}({{ current.versionCode }})
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="安装包">
|
||||
<el-link
|
||||
v-if="current.fileUrl"
|
||||
:href="withBase(current.fileUrl)"
|
||||
target="_blank"
|
||||
type="primary"
|
||||
>
|
||||
{{ current.fileName || "下载安装包" }}
|
||||
</el-link>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="大小">{{ current.fileSizeText || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="MD5">{{ current.md5 || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="SHA1">{{ current.sha1 || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="SHA256">{{ current.sha256 || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="上架">{{ current.listed ? "是" : "否" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="强制更新">
|
||||
{{ current.listed && current.forceUpdate ? "是" : "否" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ statusLabel(current.status) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="发布时间">
|
||||
{{ current.publishedAt || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">
|
||||
{{ current.createTime || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">
|
||||
{{ current.updateTime || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="更新说明">
|
||||
<pre class="notes">{{ current.releaseNotes || "-" }}</pre>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-drawer>
|
||||
|
||||
<PatchCreateDrawer v-model="patchDrawerVisible" :target="patchTarget" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, watch } from "vue";
|
||||
import {
|
||||
Box,
|
||||
DataLine,
|
||||
Delete,
|
||||
Document,
|
||||
Edit,
|
||||
Plus,
|
||||
Promotion,
|
||||
Refresh,
|
||||
Search,
|
||||
UploadFilled,
|
||||
VideoPause,
|
||||
View,
|
||||
} from "@element-plus/icons-vue";
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
|
||||
import { withBase } from "@/api/app/package";
|
||||
import UpdateChannelAPI, { type UpdateChannel } from "@/api/update/channel";
|
||||
import AppUpdateReleaseAPI, {
|
||||
type AppUpdateRelease,
|
||||
type AppUpdateReleaseForm,
|
||||
type UpdateReleaseStatus,
|
||||
} from "@/api/update/global";
|
||||
import PatchCreateDrawer from "../components/PatchCreateDrawer.vue";
|
||||
|
||||
const INSTALLABLE_TYPES = ["apk", "apks", "apkm", "xapk"];
|
||||
|
||||
const STATUS_META: Record<
|
||||
UpdateReleaseStatus,
|
||||
{ label: string; type: "info" | "success" | "danger" }
|
||||
> = {
|
||||
DRAFT: { label: "草稿", type: "info" },
|
||||
PUBLISHED: { label: "已发布", type: "success" },
|
||||
STOPPED: { label: "已停止", type: "danger" },
|
||||
};
|
||||
|
||||
const query = reactive({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
keywords: "",
|
||||
status: "" as UpdateReleaseStatus | "",
|
||||
});
|
||||
|
||||
const list = ref<AppUpdateRelease[]>([]);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
|
||||
const dialogVisible = ref(false);
|
||||
const isEdit = ref(false);
|
||||
const submitting = ref(false);
|
||||
const uploading = ref(false);
|
||||
const parseFailed = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
const fileInput = ref<HTMLInputElement>();
|
||||
const currentId = ref<string>();
|
||||
|
||||
const form = reactive<AppUpdateReleaseForm>({
|
||||
fileUrl: "",
|
||||
fileName: "",
|
||||
fileSize: undefined,
|
||||
fileSizeText: "",
|
||||
md5: "",
|
||||
sha1: "",
|
||||
sha256: "",
|
||||
iconUrl: "",
|
||||
channelCode: "",
|
||||
packageName: "",
|
||||
appLabel: "",
|
||||
versionName: "",
|
||||
versionCode: undefined,
|
||||
forceUpdate: false,
|
||||
listed: false,
|
||||
releaseNotes: "",
|
||||
});
|
||||
|
||||
const rules: FormRules<AppUpdateReleaseForm> = {
|
||||
fileUrl: [{ required: true, message: "请先选择并上传安装包", trigger: "change" }],
|
||||
channelCode: [{ required: true, message: "请选择渠道", trigger: "change" }],
|
||||
packageName: [{ required: true, message: "请填写应用包名", trigger: "blur" }],
|
||||
versionName: [{ required: true, message: "请填写版本名称", trigger: "blur" }],
|
||||
versionCode: [{ required: true, message: "请填写版本号", trigger: "change" }],
|
||||
};
|
||||
|
||||
const channelOptions = ref<UpdateChannel[]>([]);
|
||||
|
||||
watch(
|
||||
() => form.listed,
|
||||
(listed) => {
|
||||
if (!listed) form.forceUpdate = false;
|
||||
}
|
||||
);
|
||||
|
||||
const detailVisible = ref(false);
|
||||
const current = ref<AppUpdateRelease | null>(null);
|
||||
|
||||
const patchDrawerVisible = ref(false);
|
||||
const patchTarget = ref<AppUpdateRelease | null>(null);
|
||||
|
||||
function openPatchDrawer(row: AppUpdateRelease) {
|
||||
patchTarget.value = row;
|
||||
patchDrawerVisible.value = true;
|
||||
}
|
||||
|
||||
function statusLabel(status: UpdateReleaseStatus) {
|
||||
return STATUS_META[status]?.label ?? status;
|
||||
}
|
||||
|
||||
function statusTagType(status: UpdateReleaseStatus) {
|
||||
return STATUS_META[status]?.type ?? "info";
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await AppUpdateReleaseAPI.page({
|
||||
pageNum: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
keywords: query.keywords || undefined,
|
||||
status: query.status || undefined,
|
||||
});
|
||||
list.value = res.list;
|
||||
total.value = res.total;
|
||||
} catch {
|
||||
// 失败提示由请求拦截器统一发出
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleQuery() {
|
||||
query.pageNum = 1;
|
||||
loadData();
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
loadData();
|
||||
}
|
||||
|
||||
function onPageChange(page: number) {
|
||||
query.pageNum = page;
|
||||
loadData();
|
||||
}
|
||||
|
||||
function onSizeChange(size: number) {
|
||||
query.pageSize = size;
|
||||
query.pageNum = 1;
|
||||
loadData();
|
||||
}
|
||||
|
||||
async function loadChannels() {
|
||||
try {
|
||||
channelOptions.value = await UpdateChannelAPI.list();
|
||||
} catch {
|
||||
channelOptions.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function ensureChannelOption(code: string, name: string) {
|
||||
if (!code || channelOptions.value.some((channel) => channel.code === code)) return;
|
||||
channelOptions.value.push({
|
||||
id: code,
|
||||
code,
|
||||
name: name || code,
|
||||
description: "",
|
||||
enabled: false,
|
||||
sort: 0,
|
||||
createTime: "",
|
||||
updateTime: "",
|
||||
});
|
||||
}
|
||||
|
||||
async function openCreate() {
|
||||
isEdit.value = false;
|
||||
currentId.value = undefined;
|
||||
resetForm();
|
||||
await loadChannels();
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
async function openEdit(row: AppUpdateRelease) {
|
||||
isEdit.value = true;
|
||||
currentId.value = row.id;
|
||||
resetForm();
|
||||
await loadChannels();
|
||||
ensureChannelOption(row.channelCode, row.channelName);
|
||||
form.fileUrl = row.fileUrl;
|
||||
form.fileName = row.fileName;
|
||||
form.fileSize = row.fileSize;
|
||||
form.fileSizeText = row.fileSizeText;
|
||||
form.md5 = row.md5;
|
||||
form.sha1 = row.sha1;
|
||||
form.sha256 = row.sha256;
|
||||
form.iconUrl = row.iconUrl;
|
||||
form.channelCode = row.channelCode;
|
||||
form.packageName = row.packageName;
|
||||
form.appLabel = row.appLabel;
|
||||
form.versionName = row.versionName;
|
||||
form.versionCode = Number(row.versionCode);
|
||||
form.forceUpdate = row.forceUpdate;
|
||||
form.listed = row.listed;
|
||||
form.releaseNotes = row.releaseNotes ?? "";
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.fileUrl = "";
|
||||
form.fileName = "";
|
||||
form.fileSize = undefined;
|
||||
form.fileSizeText = "";
|
||||
form.md5 = "";
|
||||
form.sha1 = "";
|
||||
form.sha256 = "";
|
||||
form.iconUrl = "";
|
||||
form.channelCode = "";
|
||||
form.packageName = "";
|
||||
form.appLabel = "";
|
||||
form.versionName = "";
|
||||
form.versionCode = undefined;
|
||||
form.forceUpdate = false;
|
||||
form.listed = false;
|
||||
form.releaseNotes = "";
|
||||
parseFailed.value = false;
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
function handleClosed() {
|
||||
resetForm();
|
||||
if (fileInput.value) fileInput.value.value = "";
|
||||
}
|
||||
|
||||
function onPick(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (file) handleFile(file);
|
||||
input.value = "";
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (file) handleFile(file);
|
||||
}
|
||||
|
||||
async function handleFile(file: File) {
|
||||
if (uploading.value) return;
|
||||
const ext = file.name.split(".").pop()?.toLowerCase();
|
||||
if (!ext || !INSTALLABLE_TYPES.includes(ext)) {
|
||||
ElMessage.error("仅支持 .apk / .apks / .apkm / .xapk 安装包");
|
||||
return;
|
||||
}
|
||||
|
||||
uploading.value = true;
|
||||
parseFailed.value = false;
|
||||
try {
|
||||
const vo = await AppUpdateReleaseAPI.upload(file);
|
||||
form.fileUrl = vo.fileUrl;
|
||||
form.fileName = vo.fileName;
|
||||
form.fileSize = vo.fileSize;
|
||||
form.fileSizeText = vo.fileSizeText;
|
||||
form.md5 = vo.md5;
|
||||
form.sha1 = vo.sha1;
|
||||
form.sha256 = vo.sha256;
|
||||
form.iconUrl = vo.iconUrl || "";
|
||||
form.packageName = vo.packageName || "";
|
||||
form.appLabel = vo.appLabel || "";
|
||||
form.versionName = vo.versionName || "";
|
||||
form.versionCode = vo.versionCode != null ? Number(vo.versionCode) : undefined;
|
||||
parseFailed.value = !vo.parsed;
|
||||
if (!vo.parsed) {
|
||||
ElMessage.warning("安装包已上传,但未能解析出版本信息,请手动填写");
|
||||
} else {
|
||||
ElMessage.success("安装包已上传并解析完成");
|
||||
}
|
||||
formRef.value?.clearValidate(["fileUrl", "packageName", "versionName", "versionCode"]);
|
||||
} catch {
|
||||
// 失败提示由请求拦截器统一发出
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return;
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const payload: AppUpdateReleaseForm = {
|
||||
fileUrl: form.fileUrl,
|
||||
fileName: form.fileName,
|
||||
fileSize: form.fileSize,
|
||||
fileSizeText: form.fileSizeText,
|
||||
md5: form.md5,
|
||||
sha1: form.sha1,
|
||||
sha256: form.sha256,
|
||||
iconUrl: form.iconUrl,
|
||||
channelCode: form.channelCode,
|
||||
packageName: form.packageName.trim(),
|
||||
appLabel: form.appLabel?.trim() || undefined,
|
||||
versionName: form.versionName.trim(),
|
||||
versionCode: Number(form.versionCode),
|
||||
forceUpdate: form.forceUpdate,
|
||||
listed: form.listed,
|
||||
releaseNotes: form.releaseNotes?.trim() || undefined,
|
||||
};
|
||||
if (isEdit.value && currentId.value) {
|
||||
await AppUpdateReleaseAPI.update(currentId.value, payload);
|
||||
ElMessage.success("草稿已更新");
|
||||
} else {
|
||||
await AppUpdateReleaseAPI.create(payload);
|
||||
ElMessage.success("草稿已创建,发布后设备才能检查到更新");
|
||||
}
|
||||
dialogVisible.value = false;
|
||||
await loadData();
|
||||
} catch {
|
||||
// 失败提示由请求拦截器统一发出,弹窗保持打开便于修正后重试
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublish(row: AppUpdateRelease) {
|
||||
await ElMessageBox.confirm(
|
||||
`确认将「${row.appLabel || row.packageName} ${row.versionName || row.versionCode}」发布给全部设备?同渠道同应用的其余已发布版本会被自动停止。`,
|
||||
"发布确认",
|
||||
{ type: "warning" }
|
||||
);
|
||||
try {
|
||||
await AppUpdateReleaseAPI.publish(row.id);
|
||||
ElMessage.success("发布成功");
|
||||
} catch {
|
||||
// 失败提示由请求拦截器统一发出
|
||||
} finally {
|
||||
await loadData();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop(row: AppUpdateRelease) {
|
||||
await ElMessageBox.confirm(
|
||||
`停止后设备将不再检查到「${row.appLabel || row.packageName} ${row.versionName || row.versionCode}」,确认停止?`,
|
||||
"停止确认",
|
||||
{ type: "warning" }
|
||||
);
|
||||
try {
|
||||
await AppUpdateReleaseAPI.stop(row.id);
|
||||
ElMessage.success("已停止");
|
||||
} catch {
|
||||
// 失败提示由请求拦截器统一发出
|
||||
} finally {
|
||||
await loadData();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleListedChange(row: AppUpdateRelease, value: string | number | boolean) {
|
||||
const listed = Boolean(value);
|
||||
try {
|
||||
const updated = await AppUpdateReleaseAPI.updateListed(row.id, listed);
|
||||
row.listed = updated.listed;
|
||||
row.forceUpdate = updated.forceUpdate;
|
||||
ElMessage.success(listed ? "已上架" : "已下架");
|
||||
} catch {
|
||||
// 失败提示由请求拦截器统一发出
|
||||
} finally {
|
||||
await loadData();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row: AppUpdateRelease) {
|
||||
await ElMessageBox.confirm(`确认删除该发布单?`, "提示", { type: "warning" });
|
||||
try {
|
||||
await AppUpdateReleaseAPI.delete(row.id);
|
||||
ElMessage.success("删除成功");
|
||||
} catch {
|
||||
// 失败提示由请求拦截器统一发出
|
||||
} finally {
|
||||
await loadData();
|
||||
}
|
||||
}
|
||||
|
||||
function openDetail(row: AppUpdateRelease) {
|
||||
current.value = row;
|
||||
detailVisible.value = true;
|
||||
}
|
||||
|
||||
onMounted(loadData);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pager {
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.app-cell {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-icon,
|
||||
.icon-preview__img {
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.app-icon__fallback,
|
||||
.icon-preview__fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--el-text-color-secondary);
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.app-meta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-package,
|
||||
.pkg-name,
|
||||
.muted {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.icon-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
overflow: hidden;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.icon-preview__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.icon-preview__fallback {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.icon-preview__placeholder {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.apk-picker {
|
||||
width: 100%;
|
||||
|
||||
&__input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px 12px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-selected &__body {
|
||||
border-color: var(--el-color-primary);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
&__file {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
&__name {
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
margin-left: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.notes {
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
line-height: 1.6;
|
||||
overflow-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
1094
src/views/update/gray/index.vue
Normal file
1094
src/views/update/gray/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
30
src/views/update/hot/index.vue
Normal file
30
src/views/update/hot/index.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<el-card shadow="never" class="update-page">
|
||||
<template #header>
|
||||
<div class="update-page__header">
|
||||
<el-icon><Loading /></el-icon>
|
||||
<span>热更新</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty description="热更新页面开发中,用于免安装即时下发资源/逻辑更新" />
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Loading } from "@element-plus/icons-vue";
|
||||
|
||||
// 热更新:免安装即时下发资源或逻辑更新,无需重启应用
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.update-page {
|
||||
margin: 16px;
|
||||
}
|
||||
.update-page__header {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
698
src/views/update/save-flow/index.vue
Normal file
698
src/views/update/save-flow/index.vue
Normal file
@@ -0,0 +1,698 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-card shadow="never" class="table-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">省流更新</span>
|
||||
<el-button type="primary" :icon="Plus" @click="openTargetPicker">新建省流包</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-input
|
||||
v-model="query.keywords"
|
||||
placeholder="搜索应用名 / 包名 / 版本名称"
|
||||
clearable
|
||||
style="width: 260px"
|
||||
:prefix-icon="Search"
|
||||
@keyup.enter="handleQuery"
|
||||
@clear="handleQuery"
|
||||
/>
|
||||
<el-select
|
||||
v-model="query.channelCode"
|
||||
placeholder="全部目标渠道"
|
||||
clearable
|
||||
style="width: 160px"
|
||||
@change="handleQuery"
|
||||
>
|
||||
<el-option
|
||||
v-for="channel in channelOptions"
|
||||
:key="channel.code"
|
||||
:label="channel.name"
|
||||
:value="channel.code"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="query.status"
|
||||
placeholder="全部状态"
|
||||
clearable
|
||||
style="width: 140px"
|
||||
@change="handleQuery"
|
||||
>
|
||||
<el-option label="生成中" value="GENERATING" />
|
||||
<el-option label="可用" value="READY" />
|
||||
<el-option label="生成失败" value="FAILED" />
|
||||
</el-select>
|
||||
<el-button type="primary" :icon="Search" @click="handleQuery">查询</el-button>
|
||||
<el-button :icon="Refresh" @click="handleRefresh">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border stripe>
|
||||
<el-table-column label="应用" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="app-cell">
|
||||
<el-image :src="withBase(row.iconUrl)" fit="contain" class="app-icon">
|
||||
<template #error>
|
||||
<div class="app-icon__fallback">
|
||||
<el-icon><Box /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
<div class="app-meta">
|
||||
<div class="app-name">{{ row.appLabel || "未知应用" }}</div>
|
||||
<div class="app-package">{{ row.packageName }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="目标版本" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.targetVersionName || "-" }}</div>
|
||||
<div class="muted">versionCode {{ row.targetVersionCode }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="源版本" min-width="170">
|
||||
<template #default="{ row }">
|
||||
<div>
|
||||
{{ row.baseVersionName || "-" }}
|
||||
<el-tag v-if="row.baseSource === 'UPLOAD'" type="warning" size="small">补录</el-tag>
|
||||
</div>
|
||||
<div class="muted">versionCode {{ row.baseVersionCode }}</div>
|
||||
<div class="muted">基线 {{ shortHash(row.baseSha256) }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="目标渠道" width="130">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.channelName || row.channelCode || "-" }}</div>
|
||||
<div class="muted">{{ row.channelCode || "-" }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="补丁 / 压缩率" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.patchSizeText || "-" }}</div>
|
||||
<div class="muted">{{ ratioText(row.patchRatio) }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="校验值" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-popover v-if="row.md5" placement="left" width="380" trigger="click">
|
||||
<template #reference>
|
||||
<el-button link type="primary">查看</el-button>
|
||||
</template>
|
||||
<div class="hash-line">
|
||||
<span>补丁MD5</span>
|
||||
{{ row.md5 }}
|
||||
</div>
|
||||
<div class="hash-line">
|
||||
<span>补丁SHA1</span>
|
||||
{{ row.sha1 }}
|
||||
</div>
|
||||
<div class="hash-line">
|
||||
<span>补丁SHA256</span>
|
||||
{{ row.sha256 }}
|
||||
</div>
|
||||
<div class="hash-line">
|
||||
<span>源包SHA256</span>
|
||||
{{ row.baseSha256 }}
|
||||
</div>
|
||||
<div class="hash-line">
|
||||
<span>目标包SHA256</span>
|
||||
{{ row.targetSha256 }}
|
||||
</div>
|
||||
</el-popover>
|
||||
<span v-else class="muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上架" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
:model-value="row.listed"
|
||||
:disabled="row.status !== 'READY'"
|
||||
@change="(val) => handleListedChange(row as AppUpdatePatch, val)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="强制更新" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
:model-value="row.forceUpdate"
|
||||
:disabled="!row.listed"
|
||||
@change="(val) => handleForceChange(row as AppUpdatePatch, val)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="160" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" size="small">
|
||||
{{ statusLabel(row.status) }}
|
||||
</el-tag>
|
||||
<el-tooltip v-if="row.status === 'FAILED'" :content="row.errorMessage || '生成失败'">
|
||||
<el-icon class="error-icon"><WarningFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
<el-tooltip v-else-if="row.advice" :content="row.advice">
|
||||
<el-tag type="warning" size="small" class="advice-tag">建议全量</el-tag>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" :icon="View" @click="openDetail(row as AppUpdatePatch)">
|
||||
详情
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status !== 'GENERATING'"
|
||||
link
|
||||
type="primary"
|
||||
:icon="RefreshRight"
|
||||
@click="handleRegenerate(row as AppUpdatePatch)"
|
||||
>
|
||||
重新生成
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status !== 'GENERATING'"
|
||||
link
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
@click="handleDelete(row as AppUpdatePatch)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-if="total > 0"
|
||||
class="pager"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:current-page="query.pageNum"
|
||||
:page-size="query.pageSize"
|
||||
background
|
||||
@current-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="targetPickerVisible" title="选择目标版本" width="720px">
|
||||
<div class="toolbar">
|
||||
<el-input
|
||||
v-model="targetKeywords"
|
||||
placeholder="搜索应用名 / 包名 / 版本名称"
|
||||
clearable
|
||||
style="width: 260px"
|
||||
:prefix-icon="Search"
|
||||
@keyup.enter="loadTargets"
|
||||
@clear="loadTargets"
|
||||
/>
|
||||
<el-button type="primary" :icon="Search" @click="loadTargets">查询</el-button>
|
||||
</div>
|
||||
<el-table
|
||||
v-loading="targetsLoading"
|
||||
:data="targets"
|
||||
border
|
||||
highlight-current-row
|
||||
max-height="360"
|
||||
@current-change="onTargetSelect"
|
||||
>
|
||||
<el-table-column label="应用" prop="appLabel" min-width="140" />
|
||||
<el-table-column label="包名" prop="packageName" min-width="160" />
|
||||
<el-table-column label="渠道" prop="channelName" width="120" />
|
||||
<el-table-column label="版本" min-width="140">
|
||||
<template #default="{ row }">{{ row.versionName }}({{ row.versionCode }})</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="安装包" width="110" prop="fileSizeText" />
|
||||
</el-table>
|
||||
<div class="muted picker-tip">点击行选择要制作省流包的已发布版本</div>
|
||||
</el-dialog>
|
||||
|
||||
<PatchCreateDrawer
|
||||
v-model="createDrawerVisible"
|
||||
:target="pickedTarget"
|
||||
@success="handleQuery"
|
||||
/>
|
||||
|
||||
<el-drawer v-model="detailVisible" title="差分补丁详情" size="640px">
|
||||
<el-descriptions v-if="current" :column="1" border>
|
||||
<el-descriptions-item label="应用">
|
||||
{{ current.appLabel || "-" }}({{ current.packageName }})
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="目标渠道">
|
||||
{{ current.channelName || "-" }}({{ current.channelCode || "-" }})
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="目标版本">
|
||||
{{ current.targetVersionName }}({{ current.targetVersionCode }})
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="目标包大小">
|
||||
{{ current.targetFileSizeText || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="源版本">
|
||||
{{ current.baseVersionName }}({{ current.baseVersionCode }})
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="源包">
|
||||
<el-link
|
||||
v-if="current.baseFileUrl"
|
||||
:href="withBase(current.baseFileUrl)"
|
||||
target="_blank"
|
||||
type="primary"
|
||||
>
|
||||
{{ current.baseFileName || "查看源安装包" }}
|
||||
</el-link>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="差分补丁">
|
||||
<el-link
|
||||
v-if="current.patchUrl"
|
||||
:href="withBase(current.patchUrl)"
|
||||
target="_blank"
|
||||
type="primary"
|
||||
>
|
||||
{{ current.patchFileName || "下载补丁" }}
|
||||
</el-link>
|
||||
<span v-else>{{ current.patchFileName || "-" }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="补丁大小">
|
||||
{{ current.patchSizeText || "-" }}({{ ratioText(current.patchRatio) }})
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="差分算法">
|
||||
{{ current.patchAlgorithm || "-" }}
|
||||
<span v-if="current.sizeTier" class="muted">(分档 {{ current.sizeTier }})</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="补丁MD5">{{ current.md5 || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="补丁SHA1">{{ current.sha1 || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="补丁SHA256">{{ current.sha256 || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="源包SHA256">
|
||||
{{ current.baseSha256 || "-" }}
|
||||
<span class="muted">(设备已安装包哈希命中该值才下发补丁)</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="目标包SHA256">
|
||||
{{ current.targetSha256 || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="生成状态">
|
||||
{{ statusLabel(current.status) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="current.errorMessage" label="失败原因">
|
||||
<pre class="notes">{{ current.errorMessage }}</pre>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="current.advice" label="生成建议">
|
||||
<pre class="notes">{{ current.advice }}</pre>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="上架">{{ current.listed ? "是" : "否" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="强制更新">
|
||||
{{ current.listed && current.forceUpdate ? "是" : "否" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="目标发布单">
|
||||
{{ releaseStatusText(current.targetReleaseStatus) }}
|
||||
<span v-if="current.targetListed === false" class="muted">(目标版本已下架)</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="生成时间">
|
||||
{{ current.generatedAt || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">
|
||||
{{ current.createTime || "-" }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onBeforeUnmount, reactive, ref } from "vue";
|
||||
import {
|
||||
Box,
|
||||
Delete,
|
||||
Plus,
|
||||
Refresh,
|
||||
RefreshRight,
|
||||
Search,
|
||||
View,
|
||||
WarningFilled,
|
||||
} from "@element-plus/icons-vue";
|
||||
import { ElMessage, ElMessageBox, ElNotification } from "element-plus";
|
||||
import { withBase } from "@/api/app/package";
|
||||
import UpdateChannelAPI, { type UpdateChannel } from "@/api/update/channel";
|
||||
import AppUpdateReleaseAPI, { type AppUpdateRelease } from "@/api/update/global";
|
||||
import AppUpdatePatchAPI, { type AppUpdatePatch, type PatchStatus } from "@/api/update/save-flow";
|
||||
import PatchCreateDrawer from "../components/PatchCreateDrawer.vue";
|
||||
|
||||
const STATUS_META: Record<PatchStatus, { label: string; type: "info" | "success" | "danger" }> = {
|
||||
GENERATING: { label: "生成中", type: "info" },
|
||||
READY: { label: "可用", type: "success" },
|
||||
FAILED: { label: "生成失败", type: "danger" },
|
||||
};
|
||||
|
||||
/** 有生成中的记录时轮询刷新,差分在服务端串行执行 */
|
||||
const POLL_INTERVAL = 5000;
|
||||
|
||||
const query = reactive({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
keywords: "",
|
||||
channelCode: "",
|
||||
status: "" as PatchStatus | "",
|
||||
});
|
||||
|
||||
const list = ref<AppUpdatePatch[]>([]);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const channelOptions = ref<UpdateChannel[]>([]);
|
||||
|
||||
const targetPickerVisible = ref(false);
|
||||
const targetsLoading = ref(false);
|
||||
const targets = ref<AppUpdateRelease[]>([]);
|
||||
const targetKeywords = ref("");
|
||||
const createDrawerVisible = ref(false);
|
||||
const pickedTarget = ref<AppUpdateRelease | null>(null);
|
||||
|
||||
const detailVisible = ref(false);
|
||||
const current = ref<AppUpdatePatch | null>(null);
|
||||
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function statusLabel(status: PatchStatus) {
|
||||
return STATUS_META[status]?.label ?? status;
|
||||
}
|
||||
|
||||
function statusTagType(status: PatchStatus) {
|
||||
return STATUS_META[status]?.type ?? "info";
|
||||
}
|
||||
|
||||
function releaseStatusText(status: AppUpdateRelease["status"] | null) {
|
||||
if (status === "PUBLISHED") return "已发布";
|
||||
if (status === "STOPPED") return "已停止";
|
||||
if (status === "DRAFT") return "草稿";
|
||||
return "-";
|
||||
}
|
||||
|
||||
function ratioText(ratio: number | null) {
|
||||
if (ratio == null) return "压缩率 -";
|
||||
return `较全量省 ${((1 - ratio) * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function clearPoll() {
|
||||
if (pollTimer !== null) {
|
||||
clearTimeout(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function shortHash(value: string | null) {
|
||||
if (!value) return "-";
|
||||
return `${value.slice(0, 8)}…${value.slice(-4)}`;
|
||||
}
|
||||
|
||||
/** 轮询发现生成中的记录出结果时提示,一次提交多个任务时合并成一条 */
|
||||
function notifyOutcomes(before: Map<string, PatchStatus>, rows: AppUpdatePatch[]) {
|
||||
const finished = rows.filter(
|
||||
(row) => before.get(row.id) === "GENERATING" && row.status !== "GENERATING"
|
||||
);
|
||||
if (finished.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (finished.length === 1) {
|
||||
const row = finished[0];
|
||||
const title = `省流包 ${row.baseVersionName} → ${row.targetVersionName}`;
|
||||
if (row.status === "FAILED") {
|
||||
ElNotification.error({
|
||||
title: `${title} 生成失败`,
|
||||
message: row.errorMessage || "服务端未给出原因,可点重新生成重试",
|
||||
duration: 0,
|
||||
});
|
||||
} else if (row.advice) {
|
||||
ElNotification.warning({ title: `${title} 已生成`, message: row.advice, duration: 0 });
|
||||
} else {
|
||||
ElNotification.success({
|
||||
title: `${title} 已生成`,
|
||||
message: `${row.patchSizeText ?? "-"} · ${ratioText(row.patchRatio)}`,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const failed = finished.filter((row) => row.status === "FAILED");
|
||||
const advised = finished.filter((row) => row.status === "READY" && !!row.advice);
|
||||
const problems = [...failed, ...advised];
|
||||
let type: "success" | "warning" | "error" = "success";
|
||||
if (advised.length > 0) {
|
||||
type = "warning";
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
type = "error";
|
||||
}
|
||||
ElNotification({
|
||||
type,
|
||||
title: `省流包生成完成 ${finished.length - failed.length} 个,失败 ${failed.length} 个`,
|
||||
message:
|
||||
problems.length > 0
|
||||
? problems
|
||||
.slice(0, 3)
|
||||
.map(
|
||||
(row) =>
|
||||
`${row.baseVersionName} → ${row.targetVersionName}:${row.errorMessage || row.advice}`
|
||||
)
|
||||
.join(";")
|
||||
: finished
|
||||
.slice(0, 3)
|
||||
.map(
|
||||
(row) =>
|
||||
`${row.baseVersionName} → ${row.targetVersionName}:${ratioText(row.patchRatio)}`
|
||||
)
|
||||
.join(";"),
|
||||
duration: 8000,
|
||||
});
|
||||
}
|
||||
|
||||
async function loadData(poll = true) {
|
||||
loading.value = true;
|
||||
clearPoll();
|
||||
const before = new Map(list.value.map((row) => [row.id, row.status]));
|
||||
try {
|
||||
const res = await AppUpdatePatchAPI.page({
|
||||
pageNum: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
keywords: query.keywords || undefined,
|
||||
channelCode: query.channelCode || undefined,
|
||||
status: query.status || undefined,
|
||||
});
|
||||
list.value = res.list;
|
||||
total.value = res.total;
|
||||
notifyOutcomes(before, res.list);
|
||||
if (poll && list.value.some((item) => item.status === "GENERATING")) {
|
||||
pollTimer = setTimeout(() => loadData(false), POLL_INTERVAL);
|
||||
}
|
||||
} catch {
|
||||
// 失败提示由请求拦截器统一发出
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleQuery() {
|
||||
query.pageNum = 1;
|
||||
loadData();
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
loadData();
|
||||
}
|
||||
|
||||
function onPageChange(page: number) {
|
||||
query.pageNum = page;
|
||||
loadData();
|
||||
}
|
||||
|
||||
function onSizeChange(size: number) {
|
||||
query.pageSize = size;
|
||||
query.pageNum = 1;
|
||||
loadData();
|
||||
}
|
||||
|
||||
async function loadChannels() {
|
||||
try {
|
||||
channelOptions.value = await UpdateChannelAPI.list();
|
||||
} catch {
|
||||
channelOptions.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function openTargetPicker() {
|
||||
targetPickerVisible.value = true;
|
||||
await loadTargets();
|
||||
}
|
||||
|
||||
async function loadTargets() {
|
||||
targetsLoading.value = true;
|
||||
try {
|
||||
const res = await AppUpdateReleaseAPI.page({
|
||||
pageNum: 1,
|
||||
pageSize: 50,
|
||||
keywords: targetKeywords.value || undefined,
|
||||
status: "PUBLISHED",
|
||||
});
|
||||
targets.value = res.list;
|
||||
} catch {
|
||||
// 失败提示由请求拦截器统一发出
|
||||
targets.value = [];
|
||||
} finally {
|
||||
targetsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onTargetSelect(row: AppUpdateRelease | null) {
|
||||
if (!row) return;
|
||||
pickedTarget.value = row;
|
||||
targetPickerVisible.value = false;
|
||||
createDrawerVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleListedChange(row: AppUpdatePatch, value: string | number | boolean) {
|
||||
const listed = Boolean(value);
|
||||
try {
|
||||
await AppUpdatePatchAPI.updateListed(row.id, listed);
|
||||
row.listed = listed;
|
||||
if (!listed) row.forceUpdate = false;
|
||||
} catch {
|
||||
// 失败提示由请求拦截器统一发出,重新拉取以回滚开关状态
|
||||
await loadData();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleForceChange(row: AppUpdatePatch, value: string | number | boolean) {
|
||||
const forceUpdate = Boolean(value);
|
||||
try {
|
||||
await AppUpdatePatchAPI.updateForceUpdate(row.id, forceUpdate);
|
||||
row.forceUpdate = forceUpdate;
|
||||
} catch {
|
||||
// 失败提示由请求拦截器统一发出,重新拉取以回滚开关状态
|
||||
await loadData();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegenerate(row: AppUpdatePatch) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`将重新生成 ${row.baseVersionName} → ${row.targetVersionName} 的差分补丁,原有补丁会被覆盖。`,
|
||||
"重新生成",
|
||||
{ type: "warning" }
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await AppUpdatePatchAPI.regenerate(row.id);
|
||||
ElMessage.success("已提交生成任务");
|
||||
} catch {
|
||||
// 失败提示由请求拦截器统一发出
|
||||
} finally {
|
||||
await loadData();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row: AppUpdatePatch) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`删除 ${row.baseVersionName} → ${row.targetVersionName} 的差分补丁?`,
|
||||
"删除确认",
|
||||
{ type: "warning" }
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await AppUpdatePatchAPI.delete(row.id);
|
||||
ElMessage.success("删除成功");
|
||||
} catch {
|
||||
// 失败提示由请求拦截器统一发出
|
||||
} finally {
|
||||
await loadData();
|
||||
}
|
||||
}
|
||||
|
||||
function openDetail(row: AppUpdatePatch) {
|
||||
current.value = row;
|
||||
detailVisible.value = true;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadChannels();
|
||||
loadData();
|
||||
});
|
||||
|
||||
onBeforeUnmount(clearPoll);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.pager {
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.app-cell {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.app-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.app-icon__fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: var(--el-text-color-secondary);
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.app-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.app-package {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.muted {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.error-icon {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
.advice-tag {
|
||||
margin-left: 4px;
|
||||
}
|
||||
.hash-line {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.hash-line span {
|
||||
flex: none;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.picker-tip {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.notes {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user