refactor: 统一静态资源地址拼接为 utils/asset-url,解除更新模块对应用模块的反向依赖

This commit is contained in:
2026-11-04 17:39:28 +08:00
parent 8c1e02e6d0
commit b8773a0e78
9 changed files with 47 additions and 72 deletions

View File

@@ -117,16 +117,3 @@ const AppPackageAPI = {
};
export default AppPackageAPI;
/**
* 拼接静态资源完整访问地址(img / el-image 的 src 不经过 axios,需手动补 baseURL)
* 说明:后端现在直接返回 OSS 绝对地址(https://xxx.aliyuncs.com/...),不再需要拼前缀,
* 这里同时兼容历史相对路径(/20261019/xxx.png)。
*/
export function withBase(url?: string): string {
if (!url) return "";
// 已是绝对地址(http(s)://、协议相对 //、base64)时原样返回,避免拼出 /dev-apihttps://... 这类错误地址
if (/^(https?:)?\/\//i.test(url) || url.startsWith("data:")) return url;
const baseApi = import.meta.env.VITE_APP_BASE_API ?? "";
return `${baseApi.replace(/\/$/, "")}${url.startsWith("/") ? "" : "/"}${url}`;
}

View File

@@ -125,7 +125,7 @@
<el-carousel-item v-for="(item, index) in screenshots" :key="item.id">
<div class="screenshot-item">
<el-image
:src="resolveIconUrl(item.url)"
:src="withBase(item.url)"
fit="contain"
class="screenshot-image"
@click="openPreview(index)"
@@ -148,7 +148,7 @@
<!-- 全屏预览单张截图 -->
<el-image-viewer
v-if="previewVisible"
:url-list="screenshots.map((s) => resolveIconUrl(s.url) as string)"
:url-list="screenshots.map((s) => withBase(s.url) as string)"
:initial-index="previewIndex"
teleported
@close="previewVisible = false"
@@ -564,10 +564,10 @@
<el-table-column label="图标" width="70" align="center">
<template #default="{ row }">
<el-image
:src="resolveIconUrl(row.iconUrl)"
:src="withBase(row.iconUrl)"
fit="cover"
class="apk-icon"
:preview-src-list="row.iconUrl ? [resolveIconUrl(row.iconUrl)] : []"
:preview-src-list="row.iconUrl ? [withBase(row.iconUrl)] : []"
preview-teleported
>
<template #error>
@@ -822,6 +822,7 @@ import {
Select,
} from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { withBase } from "@/utils/asset-url";
// 接收传入的 props
interface Props {
@@ -851,7 +852,7 @@ watch(
loadAll(newSerialno);
}
},
{ immediate: true }
{ immediate: true },
);
const activeNames = ref(["basic", "hardware", "network", "security", "other"]);
@@ -957,22 +958,6 @@ const policyItems: { label: string; field: string }[] = [
{ label: "深色模式", field: "darkMode" },
];
/**
* 拼接应用图标完整访问地址
*
* 后端返回的是相对路径(如 /static/app_icon/{md5}.png)。
* img/el-image 的 src 是浏览器原生请求,不经过 axios,拿不到 baseURL,
* 因此必须手动补上代理前缀,否则请求会打到前端 dev server 上导致 404。
*/
function resolveIconUrl(url?: string | null): string {
if (!url) return "";
// 已是绝对地址(http/https)或 base64,直接返回
if (/^(https?:)?\/\//.test(url) || url.startsWith("data:")) return url;
const baseApi = import.meta.env.VITE_APP_BASE_API ?? "";
// 避免 baseApi 结尾与 url 开头的斜杠重复
return `${baseApi.replace(/\/$/, "")}${url.startsWith("/") ? "" : "/"}${url}`;
}
/** 空值统一显示占位符 */
function formatText(value?: string | number | null): string {
return value === undefined || value === null || value === "" ? "-" : String(value);
@@ -1016,7 +1001,7 @@ function dateValue(value?: Date | string | null): number {
function assignTo<T extends object>(reactiveObj: T, data?: object) {
Object.keys(reactiveObj as object).forEach(
(key) => delete (reactiveObj as Record<string, unknown>)[key]
(key) => delete (reactiveObj as Record<string, unknown>)[key],
);
if (data) {
Object.assign(reactiveObj, data);
@@ -1440,7 +1425,7 @@ const handleClearAppData = (row: ApkInstallInfo) => {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}
},
)
.then(() => {
DeviceAPI.clearAppData(props.serialno, row.packageName ?? "").then(() => {
@@ -1459,7 +1444,7 @@ const handleUninstallApp = (row: ApkInstallInfo) => {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "error",
}
},
)
.then(() => {
DeviceAPI.uninstallApp(props.serialno, row.packageName ?? "").then(() => {
@@ -1513,7 +1498,7 @@ onMounted(() => {
const sse = useSse();
unsubscribeDeviceOnlineStatus = sse.on<DeviceOnlineVO>(
"device-online-status",
handleDeviceOnlineStatus
handleDeviceOnlineStatus,
);
});

13
src/utils/asset-url.ts Normal file
View File

@@ -0,0 +1,13 @@
/**
* 拼接静态资源完整访问地址
*
* img / el-image 的 src 不经过 axios,拿不到请求实例的 baseURL,
* 这里按当前环境补上代理前缀,兼容后端返回的相对路径。
*/
export function withBase(url?: string | null): string {
if (!url) return "";
// 绝对地址(http(s)://、协议相对 //、base64)原样返回
if (/^(https?:)?\/\//i.test(url) || url.startsWith("data:")) return url;
const baseApi = import.meta.env.VITE_APP_BASE_API ?? "";
return `${baseApi.replace(/\/$/, "")}${url.startsWith("/") ? "" : "/"}${url}`;
}

View File

@@ -159,7 +159,8 @@ import { onMounted, reactive, ref } from "vue";
import { ElMessage, type FormInstance } from "element-plus";
import { Box, Delete, Document, Picture, UploadFilled } from "@element-plus/icons-vue";
import { parseApkMeta } from "apk-meta-parser";
import AppPackageAPI, { withBase, type AppPackage } from "@/api/app/package";
import AppPackageAPI, { type AppPackage } from "@/api/app/package";
import { withBase } from "@/utils/asset-url";
import AppCategoryAPI, { type AppCategory } from "@/api/app/category";
const visible = defineModel<boolean>("visible", { default: false });

View File

@@ -239,7 +239,8 @@ import {
View,
} from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import AppPackageAPI, { withBase, type AppPackage, type PackageType } from "@/api/app/package";
import AppPackageAPI, { type AppPackage, type PackageType } from "@/api/app/package";
import { withBase } from "@/utils/asset-url";
import AppCategoryAPI, { type AppCategory } from "@/api/app/category";
import UploadDialog from "./components/UploadDialog.vue";

View File

@@ -81,7 +81,7 @@
<el-table-column label="图标" width="100" align="center">
<template #default="scope">
<el-image
:src="resolveIconUrl(scope.row.iconUrl)"
:src="withBase(scope.row.iconUrl)"
fit="contain"
style="width: 48px; height: 48px; border-radius: 6px"
:preview-src-list="previewList"
@@ -236,6 +236,7 @@ import {
import { usePageTable } from "@/composables";
import AppIconAPI from "@/api/device/appIcon";
import type { AppIconQuery, AppIconVO } from "@/api/device/types";
import { withBase } from "@/utils/asset-url";
defineOptions({
name: "AppIcon",
@@ -275,25 +276,12 @@ function handleCurrentChange(row: AppIconVO | null) {
selectedRow.value = row;
}
const previewList = computed(() => list.value.map((item) => resolveIconUrl(item.iconUrl)));
const previewList = computed(() => list.value.map((item) => withBase(item.iconUrl)));
const previewIndex = (row: AppIconVO) => {
const idx = list.value.findIndex((item) => item.id === row.id);
return idx < 0 ? 0 : idx;
};
/**
* 拼接图标完整访问地址
* 后端返回的是相对路径(如 /static/app_icon/{md5}.png)。
* el-image 的 src 是浏览器原生请求,不经过 axios,拿不到 baseURL,
* 因此必须手动补上代理前缀(VITE_APP_BASE_API),否则请求会打到前端 dev server 上导致 404。
*/
function resolveIconUrl(url?: string | null): string {
if (!url) return "";
if (/^(https?:)?\/\//.test(url) || url.startsWith("data:")) return url;
const baseApi = import.meta.env.VITE_APP_BASE_API ?? "";
return `${baseApi.replace(/\/$/, "")}${url.startsWith("/") ? "" : "/"}${url}`;
}
function validateUploadFile(file: UploadRawFile) {
const allowExt = ["jpg", "jpeg", "png", "webp"];
const ext = file.name.split(".").pop()?.toLowerCase() || "";

View File

@@ -386,7 +386,7 @@ import {
View,
} from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { withBase } from "@/api/app/package";
import { withBase } from "@/utils/asset-url";
import UpdateChannelAPI, { type UpdateChannel } from "@/api/update/channel";
import AppUpdateReleaseAPI, {
type AppUpdateRelease,
@@ -459,7 +459,7 @@ watch(
() => form.listed,
(listed) => {
if (!listed) form.forceUpdate = false;
}
},
);
const detailVisible = ref(false);
@@ -697,7 +697,7 @@ async function handlePublish(row: AppUpdateRelease) {
await ElMessageBox.confirm(
`确认将「${row.appLabel || row.packageName} ${row.versionName || row.versionCode}」发布给全部设备?同渠道同应用的其余已发布版本会被自动停止。`,
"发布确认",
{ type: "warning" }
{ type: "warning" },
);
try {
await AppUpdateReleaseAPI.publish(row.id);
@@ -713,7 +713,7 @@ async function handleStop(row: AppUpdateRelease) {
await ElMessageBox.confirm(
`停止后设备将不再检查到「${row.appLabel || row.packageName} ${row.versionName || row.versionCode}」,确认停止?`,
"停止确认",
{ type: "warning" }
{ type: "warning" },
);
try {
await AppUpdateReleaseAPI.stop(row.id);

View File

@@ -439,7 +439,7 @@ import {
View,
} from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { withBase } from "@/api/app/package";
import { withBase } from "@/utils/asset-url";
import UpdateChannelAPI, { type UpdateChannel } from "@/api/update/channel";
import type { UpdateReleaseStatus } from "@/api/update/global";
import AppUpdateGrayAPI, {
@@ -531,7 +531,7 @@ watch(
() => form.listed,
(listed) => {
if (!listed) form.forceUpdate = false;
}
},
);
watch(
@@ -543,7 +543,7 @@ watch(
form.channelCode = "";
}
formRef.value?.clearValidate(["channelCode", "snList"]);
}
},
);
const detailVisible = ref(false);
@@ -784,8 +784,8 @@ async function readSnFile(file: File): Promise<string[]> {
}
return Array.from(
new Set(
cells.map((cell) => cell.trim()).filter((cell) => cell && !SN_HEADER_PATTERN.test(cell))
)
cells.map((cell) => cell.trim()).filter((cell) => cell && !SN_HEADER_PATTERN.test(cell)),
),
);
}
@@ -844,7 +844,7 @@ async function handlePublish(row: AppUpdateGrayRelease) {
await ElMessageBox.confirm(
`确认将「${row.appLabel || row.packageName} ${row.versionName || row.versionCode}」灰度发布给${targetText(row)}?目标重叠的已发布灰度会被自动停止。`,
"发布确认",
{ type: "warning" }
{ type: "warning" },
);
try {
await AppUpdateGrayAPI.publish(row.id);
@@ -860,7 +860,7 @@ async function handleStop(row: AppUpdateGrayRelease) {
await ElMessageBox.confirm(
`停止后${targetText(row)}将不再检查到该灰度版本,确认停止?`,
"停止确认",
{ type: "warning" }
{ type: "warning" },
);
try {
await AppUpdateGrayAPI.stop(row.id);

View File

@@ -330,7 +330,7 @@ import {
WarningFilled,
} from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox, ElNotification } from "element-plus";
import { withBase } from "@/api/app/package";
import { withBase } from "@/utils/asset-url";
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";
@@ -405,7 +405,7 @@ function shortHash(value: string | null) {
/** 轮询发现生成中的记录出结果时提示,一次提交多个任务时合并成一条 */
function notifyOutcomes(before: Map<string, PatchStatus>, rows: AppUpdatePatch[]) {
const finished = rows.filter(
(row) => before.get(row.id) === "GENERATING" && row.status !== "GENERATING"
(row) => before.get(row.id) === "GENERATING" && row.status !== "GENERATING",
);
if (finished.length === 0) {
return;
@@ -448,14 +448,14 @@ function notifyOutcomes(before: Map<string, PatchStatus>, rows: AppUpdatePatch[]
.slice(0, 3)
.map(
(row) =>
`${row.baseVersionName} → ${row.targetVersionName}:${row.errorMessage || row.advice}`
`${row.baseVersionName} → ${row.targetVersionName}:${row.errorMessage || row.advice}`,
)
.join(";")
: finished
.slice(0, 3)
.map(
(row) =>
`${row.baseVersionName} → ${row.targetVersionName}:${ratioText(row.patchRatio)}`
`${row.baseVersionName} → ${row.targetVersionName}:${ratioText(row.patchRatio)}`,
)
.join(";"),
duration: 8000,
@@ -573,7 +573,7 @@ async function handleRegenerate(row: AppUpdatePatch) {
await ElMessageBox.confirm(
`将重新生成 ${row.baseVersionName} → ${row.targetVersionName} 的差分补丁,原有补丁会被覆盖。`,
"重新生成",
{ type: "warning" }
{ type: "warning" },
);
} catch {
return;
@@ -593,7 +593,7 @@ async function handleDelete(row: AppUpdatePatch) {
await ElMessageBox.confirm(
`删除 ${row.baseVersionName} → ${row.targetVersionName} 的差分补丁?`,
"删除确认",
{ type: "warning" }
{ type: "warning" },
);
} catch {
return;