feat: 新增 update 升级模块(渠道/全局/灰度/热更新/保存流程接口与页面)

This commit is contained in:
2026-11-03 18:23:44 +08:00
parent f287292aeb
commit dda23e99f8
10 changed files with 3846 additions and 0 deletions

62
src/api/update/channel.ts Normal file
View 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
View 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
View 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
View 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;

View 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>

View 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>

View 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>

File diff suppressed because it is too large Load Diff

View 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>

View 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>