chore(deps): 新增 apk-meta-parser、jszip、spark-md5 依赖,新增应用分类和安装包 API,重构设备详情抽屉组件
This commit is contained in:
83
src/api/app/category.ts
Normal file
83
src/api/app/category.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
/** 应用分类对象 */
|
||||
export interface AppCategory {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
sort: number;
|
||||
createTime?: string;
|
||||
updateTime?: string;
|
||||
}
|
||||
|
||||
/** 分类表单(新增/更新) */
|
||||
export interface AppCategoryForm {
|
||||
code: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
description?: string;
|
||||
sort?: number;
|
||||
}
|
||||
|
||||
const BASE_URL = "/api/v1/app/category";
|
||||
|
||||
/** 应用分类管理 API */
|
||||
export const AppCategoryAPI = {
|
||||
/** 分类列表(全部) */
|
||||
list() {
|
||||
return request<unknown, AppCategory[]>({
|
||||
url: `${BASE_URL}/list`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
/** 分类详情 */
|
||||
detail(id: string) {
|
||||
return request<unknown, AppCategory>({
|
||||
url: `${BASE_URL}/${id}`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
/** 新增分类 */
|
||||
create(data: AppCategoryForm) {
|
||||
return request<unknown, AppCategory>({
|
||||
url: `${BASE_URL}`,
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
},
|
||||
|
||||
/** 更新分类 */
|
||||
update(id: string, data: AppCategoryForm) {
|
||||
return request<unknown, AppCategory>({
|
||||
url: `${BASE_URL}/${id}`,
|
||||
method: "put",
|
||||
data,
|
||||
});
|
||||
},
|
||||
|
||||
/** 删除分类 */
|
||||
delete(id: string) {
|
||||
return request<unknown, boolean>({
|
||||
url: `${BASE_URL}/${id}`,
|
||||
method: "delete",
|
||||
});
|
||||
},
|
||||
|
||||
/** 上传分类图标(手动上传图片,返回访问 URL) */
|
||||
uploadIcon(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return request<unknown, string>({
|
||||
url: `${BASE_URL}/icon/upload`,
|
||||
method: "post",
|
||||
data: formData,
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default AppCategoryAPI;
|
||||
126
src/api/app/package.ts
Normal file
126
src/api/app/package.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import request, { uploadHttp } from "@/utils/request";
|
||||
import type { PageResult } from "@/api/common";
|
||||
|
||||
/** 安装包类型 */
|
||||
export type PackageType = "apk" | "apks" | "aab" | "apkm" | "xapk";
|
||||
|
||||
/** 应用安装包资源对象 */
|
||||
export interface AppPackage {
|
||||
id: string;
|
||||
packageType: PackageType;
|
||||
originalName: string;
|
||||
fileUrl: string;
|
||||
fileSize: number;
|
||||
fileSizeText: string;
|
||||
md5: string;
|
||||
packageName: string;
|
||||
appLabel: string;
|
||||
versionName: string;
|
||||
versionCode: number;
|
||||
minSdk: number;
|
||||
targetSdk: number;
|
||||
compileSdk: number;
|
||||
abiList: string[];
|
||||
permissions: string[];
|
||||
iconUrl: string;
|
||||
/** 应用分类ID列表(多选) */
|
||||
categories: string[];
|
||||
/** 应用截图URL列表(与安装包、图标分目录存储) */
|
||||
screenshots: string[];
|
||||
systemApp: boolean;
|
||||
debuggable: boolean;
|
||||
remark: string;
|
||||
createTime: string;
|
||||
}
|
||||
|
||||
/** 安装包分页查询参数 */
|
||||
export interface AppPackagePageQuery {
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
keywords?: string;
|
||||
packageType?: PackageType;
|
||||
}
|
||||
|
||||
const BASE_URL = "/api/v1/app/packages";
|
||||
|
||||
const AppPackageAPI = {
|
||||
/** 上传安装包(apk/aab/xapk/apks/apkm),自动解析详细字段 */
|
||||
upload(params: {
|
||||
file: File;
|
||||
packageName?: string;
|
||||
versionName?: string;
|
||||
versionCode?: string;
|
||||
remark?: string;
|
||||
/** 应用分类ID列表(多选) */
|
||||
categories?: string[];
|
||||
/** 应用截图文件列表(与安装包、图标分目录存储) */
|
||||
screenshots?: File[];
|
||||
}) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", params.file);
|
||||
if (params.packageName) formData.append("packageName", params.packageName);
|
||||
if (params.versionName) formData.append("versionName", params.versionName);
|
||||
if (params.versionCode) formData.append("versionCode", params.versionCode);
|
||||
if (params.remark) formData.append("remark", params.remark);
|
||||
if (params.categories?.length) {
|
||||
params.categories.forEach((c) => formData.append("categories", c));
|
||||
}
|
||||
if (params.screenshots?.length) {
|
||||
params.screenshots.forEach((s) => formData.append("screenshots", s));
|
||||
}
|
||||
return uploadHttp<unknown, AppPackage>({
|
||||
url: `${BASE_URL}/upload`,
|
||||
method: "post",
|
||||
data: formData,
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
},
|
||||
|
||||
/** 分页查询安装包列表 */
|
||||
page(query: AppPackagePageQuery) {
|
||||
return request<unknown, PageResult<AppPackage>>({
|
||||
url: `${BASE_URL}/page`,
|
||||
method: "get",
|
||||
params: {
|
||||
pageNum: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
keywords: query.keywords,
|
||||
packageType: query.packageType,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/** 安装包详情 */
|
||||
detail(id: string) {
|
||||
return request<unknown, AppPackage>({
|
||||
url: `${BASE_URL}/${id}`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
/** 删除安装包 */
|
||||
delete(id: string) {
|
||||
return request<unknown, boolean>({
|
||||
url: `${BASE_URL}/${id}`,
|
||||
method: "delete",
|
||||
});
|
||||
},
|
||||
|
||||
/** 更新安装包分类(覆盖写) */
|
||||
updateCategories(id: string, categories: string[]) {
|
||||
return request<unknown, boolean>({
|
||||
url: `${BASE_URL}/${id}/categories`,
|
||||
method: "put",
|
||||
data: categories,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default AppPackageAPI;
|
||||
|
||||
/** 拼接静态资源完整访问地址(img / el-image 的 src 不经过 axios,需手动补 baseURL) */
|
||||
export function withBase(url?: string): string {
|
||||
if (!url) return "";
|
||||
const baseApi = import.meta.env.VITE_APP_BASE_API ?? "";
|
||||
return `${baseApi}${url}`;
|
||||
}
|
||||
@@ -68,6 +68,8 @@ export interface DeviceForm {
|
||||
serialno?: string;
|
||||
/** 设备名称 */
|
||||
snName?: string;
|
||||
/** 设备型号 */
|
||||
snModel?: string;
|
||||
/** 设备手机号 */
|
||||
snMobile?: string;
|
||||
/** 设备在线状态(1:在线;0:离线) */
|
||||
|
||||
1
src/assets/icons/android.svg
Normal file
1
src/assets/icons/android.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg class="icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="200" height="200"><path d="M235.555161 774.52539c0 26.741667 21.8276 48.569267 48.683548 48.569266h55.654666v137.593876c0 34.855591 27.998754 63.311468 62.397222 63.311468 34.51275 0 62.397223-28.341596 62.397223-63.311468V822.980376h97.138534v137.593876c0 34.855591 27.998754 63.311468 62.397222 63.311467 34.51275 0 62.397223-28.341596 62.397223-63.311467V822.980376h55.654666c26.855948 0 48.683547-21.8276 48.683547-48.569267V322.774068h-555.403851v451.751322z m408.096122-685.798048l51.312002-74.282408c2.971296-4.342664 2.285613-10.170976-1.714209-13.027991-3.999822-2.742735-9.713853-1.371368-12.68515 2.971296l-53.254773 77.253704c-35.084153-13.827956-74.168127-21.484758-115.194872-21.484758-41.026745 0-80.11072 7.656802-115.194873 21.484758l-53.254773-77.139423c-2.971296-4.342664-8.685328-5.828312-12.685149-2.971297-3.999822 2.742735-4.685506 8.456766-1.71421 13.027992l51.312002 74.282408c-81.596368 37.941168-138.736682 109.823683-146.050642 193.819945h555.403851c-7.542521-84.110542-64.797116-155.993057-146.279204-193.934226zM394.862357 205.864986a30.741489 30.741489 0 1 1 30.741488-30.741489 30.821485 30.821485 0 0 1-30.741488 30.741489z m238.160828 0a30.741489 30.741489 0 1 1 30.741489-30.741489 30.821485 30.821485 0 0 1-30.741489 30.741489z m255.302922 115.080592c-34.51275 0-62.397223 28.341596-62.397222 63.311468v247.303278c0 34.855591 27.998754 63.311468 62.397222 63.311468 34.51275 0 62.397223-28.341596 62.397223-63.311468V384.142765c0.114281-34.969872-27.770193-63.197187-62.397223-63.197187z m-752.652214 0c-34.51275 0-62.397223 28.341596-62.397223 63.311468v247.303278c0 34.855591 27.998754 63.311468 62.397223 63.311468 34.51275 0 62.397223-28.341596 62.397222-63.311468V384.142765c0-34.969872-27.998754-63.197187-62.397222-63.197187z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
1
src/assets/icons/appstore.svg
Normal file
1
src/assets/icons/appstore.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg t="1786023125973" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1720" width="200" height="200"><path d="M735.9 66.5H288.1C176.4 66.5 85.5 157.3 85.5 269v486c0 111.7 90.9 202.6 202.6 202.6H736c111.7 0 202.6-90.9 202.6-202.6V269c-0.1-111.7-91-202.5-202.7-202.5zM884.8 755c0 82.1-66.8 148.9-148.9 148.9H288.1C206 903.8 139.2 837 139.2 755V269c0-82.1 66.8-148.9 148.9-148.9H736c82.1 0 148.9 66.8 148.9 148.9v486z" p-id="1721"></path><path d="M651.2 344.5c-18.5 0-33.6 15-33.6 33.6 0 58.3-47.4 105.7-105.7 105.7-58.3 0-105.7-47.4-105.7-105.7 0-18.5-15-33.6-33.6-33.6-18.5 0-33.6 15-33.6 33.6 0 95.3 77.5 172.8 172.8 172.8s172.8-77.5 172.8-172.8c0.2-18.6-14.8-33.6-33.4-33.6z" p-id="1722"></path></svg>
|
||||
|
After Width: | Height: | Size: 750 B |
File diff suppressed because it is too large
Load Diff
@@ -18,93 +18,108 @@ const http = axios.create({
|
||||
paramsSerializer: (params) => qs.stringify(params, { arrayFormat: "repeat" }),
|
||||
});
|
||||
|
||||
http.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const token = AuthStorage.getAccessToken();
|
||||
// 大文件上传专用实例:直连后端地址(VITE_APP_API_URL),绕过 vite dev proxy,
|
||||
// 避免 vite/http-proxy 转发大请求体时缓冲/超时导致连接中断(客户端 EOFException)。
|
||||
const uploadHttp = axios.create({
|
||||
baseURL: import.meta.env.VITE_APP_API_URL,
|
||||
// 大文件上传放宽超时到 10 分钟(默认 50s 对 apks 等大文件不足)
|
||||
timeout: 600000,
|
||||
headers: { "Content-Type": "application/json;charset=utf-8" },
|
||||
});
|
||||
|
||||
// 约定:调用方设置 Authorization 为 "no-auth" 即跳过 token 注入
|
||||
if (config.headers.Authorization === "no-auth") {
|
||||
delete config.headers.Authorization;
|
||||
} else if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
function setupInterceptors(instance: typeof http) {
|
||||
instance.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const token = AuthStorage.getAccessToken();
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
// 约定:调用方设置 Authorization 为 "no-auth" 即跳过 token 注入
|
||||
if (config.headers.Authorization === "no-auth") {
|
||||
delete config.headers.Authorization;
|
||||
} else if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
http.interceptors.response.use(
|
||||
(response: AxiosResponse<ApiResult>): AxiosResponse | any => {
|
||||
const { responseType } = response.config;
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// 二进制数据直接透传
|
||||
if (responseType === "blob" || responseType === "arraybuffer") {
|
||||
return response;
|
||||
}
|
||||
instance.interceptors.response.use(
|
||||
(response: AxiosResponse<ApiResult>): AxiosResponse | any => {
|
||||
const { responseType } = response.config;
|
||||
|
||||
const { code, data, msg } = response.data;
|
||||
// 二进制数据直接透传
|
||||
if (responseType === "blob" || responseType === "arraybuffer") {
|
||||
return response;
|
||||
}
|
||||
|
||||
if (code === ApiCodeEnum.SUCCESS) {
|
||||
return data;
|
||||
}
|
||||
const { code, data, msg } = response.data;
|
||||
|
||||
ElMessage.error(msg || "系统出错");
|
||||
return Promise.reject(new Error(msg || "系统出错"));
|
||||
},
|
||||
if (code === ApiCodeEnum.SUCCESS) {
|
||||
return data;
|
||||
}
|
||||
|
||||
async (error) => {
|
||||
const { config, response } = error;
|
||||
ElMessage.error(msg || "系统出错");
|
||||
return Promise.reject(new Error(msg || "系统出错"));
|
||||
},
|
||||
|
||||
if (!response) {
|
||||
ElMessage.error("网络连接失败");
|
||||
return Promise.reject(error);
|
||||
}
|
||||
async (error) => {
|
||||
const { config, response } = error;
|
||||
|
||||
const { code, msg } = response.data as ApiResult;
|
||||
if (!response) {
|
||||
ElMessage.error("网络连接失败");
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// Token 过期
|
||||
if (code === ApiCodeEnum.ACCESS_TOKEN_INVALID) {
|
||||
if (!config || retriedRequests.has(config)) {
|
||||
await redirectToLogin("登录已过期,请重新登录");
|
||||
const { code, msg } = response.data as ApiResult;
|
||||
|
||||
// Token 过期
|
||||
if (code === ApiCodeEnum.ACCESS_TOKEN_INVALID) {
|
||||
if (!config || retriedRequests.has(config)) {
|
||||
await redirectToLogin("登录已过期,请重新登录");
|
||||
return Promise.reject(new Error("Token Invalid"));
|
||||
}
|
||||
|
||||
retriedRequests.add(config);
|
||||
|
||||
try {
|
||||
const userStore = useUserStoreHook();
|
||||
await userStore.refreshTokenOnce();
|
||||
|
||||
const token = AuthStorage.getAccessToken();
|
||||
if (token) {
|
||||
config.headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
|
||||
return instance(config);
|
||||
} catch {
|
||||
await redirectToLogin("登录已过期,请重新登录");
|
||||
return Promise.reject(new Error("Token Invalid"));
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh token 失效
|
||||
if (code === ApiCodeEnum.REFRESH_TOKEN_INVALID) {
|
||||
await redirectToLogin("登录已过期,请重新登录", false);
|
||||
return Promise.reject(new Error("Token Invalid"));
|
||||
}
|
||||
|
||||
retriedRequests.add(config);
|
||||
|
||||
try {
|
||||
const userStore = useUserStoreHook();
|
||||
await userStore.refreshTokenOnce();
|
||||
|
||||
const token = AuthStorage.getAccessToken();
|
||||
if (token) {
|
||||
config.headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
|
||||
return http(config);
|
||||
} catch {
|
||||
await redirectToLogin("登录已过期,请重新登录");
|
||||
return Promise.reject(new Error("Token refresh failed"));
|
||||
// 权限不足
|
||||
if (code === ApiCodeEnum.PERMISSION_DENIED) {
|
||||
const permissionStore = usePermissionStoreHook();
|
||||
await permissionStore.refreshPermissions();
|
||||
ElMessage.error(msg || "权限不足");
|
||||
return Promise.reject(new Error(msg || "权限不足"));
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh token 失效
|
||||
if (code === ApiCodeEnum.REFRESH_TOKEN_INVALID) {
|
||||
await redirectToLogin("登录已过期,请重新登录", false);
|
||||
return Promise.reject(new Error("Token Invalid"));
|
||||
ElMessage.error(msg || "请求失败");
|
||||
return Promise.reject(new Error(msg || "请求失败"));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 权限不足
|
||||
if (code === ApiCodeEnum.PERMISSION_DENIED) {
|
||||
const permissionStore = usePermissionStoreHook();
|
||||
await permissionStore.refreshPermissions();
|
||||
ElMessage.error(msg || "权限不足");
|
||||
return Promise.reject(new Error(msg || "权限不足"));
|
||||
}
|
||||
|
||||
ElMessage.error(msg || "请求失败");
|
||||
return Promise.reject(new Error(msg || "请求失败"));
|
||||
}
|
||||
);
|
||||
setupInterceptors(http);
|
||||
setupInterceptors(uploadHttp);
|
||||
|
||||
export default http;
|
||||
export { uploadHttp };
|
||||
|
||||
292
src/views/app-manager/category/index.vue
Normal file
292
src/views/app-manager/category/index.vue
Normal file
@@ -0,0 +1,292 @@
|
||||
<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="图标" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<img v-if="row.icon" :src="row.icon" class="cat-icon" alt="图标" />
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="编码" prop="code" width="160" />
|
||||
<el-table-column label="名称" prop="name" min-width="140" />
|
||||
<el-table-column label="描述" prop="description" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column label="排序" prop="sort" width="90" align="center" />
|
||||
<el-table-column label="操作" width="160" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" :icon="Edit" @click="openDialog(row)">编辑</el-button>
|
||||
<el-button link type="danger" :icon="Delete" @click="handleDelete(row)">删除</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="80px">
|
||||
<el-form-item label="编码" prop="code">
|
||||
<el-input
|
||||
v-model="form.code"
|
||||
placeholder="唯一编码,如 game / tools"
|
||||
:disabled="isEdit"
|
||||
maxlength="32"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="展示名称,如 游戏" maxlength="32" />
|
||||
</el-form-item>
|
||||
<el-form-item label="图标" prop="icon">
|
||||
<div class="icon-upload">
|
||||
<el-upload
|
||||
class="cat-uploader"
|
||||
:show-file-list="false"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
:auto-upload="true"
|
||||
:http-request="handleIconUpload"
|
||||
>
|
||||
<img v-if="form.icon" :src="form.icon" class="cat-uploader-img" alt="图标" />
|
||||
<el-icon v-else class="cat-uploader-icon"><Plus /></el-icon>
|
||||
</el-upload>
|
||||
<div class="icon-tip">
|
||||
<span v-if="form.icon" class="icon-clear" @click="form.icon = ''">移除</span>
|
||||
<span class="tip-text">点击上传图标(png/jpg/webp,建议 1:1)</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input
|
||||
v-model="form.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="分类描述说明"
|
||||
maxlength="200"
|
||||
/>
|
||||
</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,
|
||||
type UploadRequestOptions,
|
||||
} from "element-plus";
|
||||
import AppCategoryAPI, { type AppCategory, type AppCategoryForm } from "@/api/app/category";
|
||||
|
||||
const list = ref<AppCategory[]>([]);
|
||||
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<AppCategoryForm>({
|
||||
code: "",
|
||||
name: "",
|
||||
icon: "",
|
||||
description: "",
|
||||
sort: 0,
|
||||
});
|
||||
|
||||
const rules: FormRules<AppCategoryForm> = {
|
||||
code: [{ required: true, message: "请输入分类编码", trigger: "blur" }],
|
||||
name: [{ required: true, message: "请输入分类名称", trigger: "blur" }],
|
||||
};
|
||||
|
||||
const iconUploading = ref(false);
|
||||
|
||||
async function handleIconUpload(options: UploadRequestOptions) {
|
||||
iconUploading.value = true;
|
||||
try {
|
||||
const url = await AppCategoryAPI.uploadIcon(options.file as File);
|
||||
form.icon = url;
|
||||
ElMessage.success("图标上传成功");
|
||||
} catch (e) {
|
||||
ElMessage.error((e as Error)?.message || "图标上传失败");
|
||||
} finally {
|
||||
iconUploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
list.value = await AppCategoryAPI.list();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openDialog(row?: AppCategory) {
|
||||
if (row) {
|
||||
isEdit.value = true;
|
||||
currentId.value = row.id;
|
||||
Object.assign(form, {
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
icon: row.icon,
|
||||
description: row.description,
|
||||
sort: row.sort ?? 0,
|
||||
});
|
||||
} else {
|
||||
isEdit.value = false;
|
||||
currentId.value = undefined;
|
||||
}
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.code = "";
|
||||
form.name = "";
|
||||
form.icon = "";
|
||||
form.description = "";
|
||||
form.sort = 0;
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
function handleClosed() {
|
||||
resetForm();
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return;
|
||||
await formRef.value.validate();
|
||||
submitting.value = true;
|
||||
try {
|
||||
const payload: AppCategoryForm = {
|
||||
code: form.code,
|
||||
name: form.name,
|
||||
icon: form.icon,
|
||||
description: form.description,
|
||||
sort: form.sort,
|
||||
};
|
||||
if (isEdit.value && currentId.value) {
|
||||
await AppCategoryAPI.update(currentId.value, payload);
|
||||
ElMessage.success("更新成功");
|
||||
} else {
|
||||
await AppCategoryAPI.create(payload);
|
||||
ElMessage.success("新增成功");
|
||||
}
|
||||
dialogVisible.value = false;
|
||||
await fetchList();
|
||||
} catch (e) {
|
||||
ElMessage.error((e as Error)?.message || "保存失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row: AppCategory) {
|
||||
await ElMessageBox.confirm(`确认删除分类「${row.name}」?`, "提示", {
|
||||
type: "warning",
|
||||
});
|
||||
await AppCategoryAPI.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;
|
||||
}
|
||||
|
||||
.cat-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin: 0 auto;
|
||||
object-fit: contain;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.icon-upload {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cat-uploader {
|
||||
:deep(.el-upload) {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
transition: var(--el-transition-border);
|
||||
}
|
||||
|
||||
:deep(.el-upload:hover) {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.cat-uploader-img {
|
||||
display: block;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
object-fit: contain;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.cat-uploader-icon {
|
||||
font-size: 20px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.icon-tip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.icon-clear {
|
||||
width: fit-content;
|
||||
color: var(--el-color-danger);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tip-text {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
479
src/views/app-manager/upload-package/components/UploadDialog.vue
Normal file
479
src/views/app-manager/upload-package/components/UploadDialog.vue
Normal file
@@ -0,0 +1,479 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="上传应用安装包(APK / APKS / AAB / APKM / XAPK)"
|
||||
width="560px"
|
||||
:close-on-click-modal="false"
|
||||
@closed="handleClosed"
|
||||
>
|
||||
<div
|
||||
v-loading="parsing"
|
||||
element-loading-text="正在解析安装包,请稍候…"
|
||||
element-loading-lock
|
||||
class="pkg-parse-wrap"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" label-width="92px">
|
||||
<el-form-item label="安装包">
|
||||
<div class="apk-picker" :class="{ 'is-selected': selectedFile }">
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".apk,.apks,.aab,.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="selectedFile" class="apk-picker__file">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span class="apk-picker__name">{{ selectedFile.name }}</span>
|
||||
<el-button type="danger" link :icon="Delete" @click.stop="clearFile">
|
||||
移除
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-else class="el-upload__tip">
|
||||
支持 .apk / .aab / .xapk / .apks / .apkm(单个文件),APK 选择后将自动解析包信息
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="应用截图">
|
||||
<div class="shot-picker">
|
||||
<input
|
||||
ref="shotInput"
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.webp"
|
||||
multiple
|
||||
class="shot-picker__input"
|
||||
@change="onShotsPick"
|
||||
/>
|
||||
<div class="shot-picker__add" @click="shotInput?.click()">
|
||||
<el-icon><Picture /></el-icon>
|
||||
<span>添加截图</span>
|
||||
</div>
|
||||
<div v-for="(_, i) in selectedShots" :key="i" class="shot-picker__item">
|
||||
<el-image :src="shotPreview(i)" fit="cover" class="shot-picker__img" />
|
||||
<el-button
|
||||
type="danger"
|
||||
link
|
||||
:icon="Delete"
|
||||
class="shot-picker__del"
|
||||
@click.stop="removeShot(i)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="el-upload__tip">
|
||||
支持 .png / .jpg / .jpeg / .webp,可多选,与安装包/图标分目录存储
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-alert
|
||||
v-if="parseFailed"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="自动解析失败,请稍候重试或更换安装包"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<el-form-item label="应用图标">
|
||||
<div class="icon-preview">
|
||||
<el-image v-if="form.icon" :src="form.icon" 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="应用分类">
|
||||
<el-select
|
||||
v-model="form.categories"
|
||||
multiple
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
clearable
|
||||
placeholder="可多选应用分类"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="c in categoryOptions" :key="c.code" :label="c.name" :value="c.code" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="应用名称">
|
||||
<el-input v-model="form.appLabel" readonly placeholder="解析自动填充" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="应用包名">
|
||||
<el-input v-model="form.packageName" readonly placeholder="解析自动填充" />
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="版本名称">
|
||||
<el-input v-model="form.versionName" readonly placeholder="解析自动填充" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="版本号">
|
||||
<el-input v-model="form.versionCode" readonly placeholder="解析自动填充" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="备注说明">
|
||||
<el-input
|
||||
v-model="form.remark"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="选填,便于区分同名应用的不同渠道/用途"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">开始上传</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 AppCategoryAPI, { type AppCategory } from "@/api/app/category";
|
||||
|
||||
const visible = defineModel<boolean>("visible", { default: false });
|
||||
const emit = defineEmits<{ success: [] }>();
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const fileInput = ref<HTMLInputElement>();
|
||||
const selectedFile = ref<File>();
|
||||
const submitting = ref(false);
|
||||
const parsing = ref(false);
|
||||
const parseFailed = ref(false);
|
||||
|
||||
const form = reactive({
|
||||
packageName: "",
|
||||
appLabel: "",
|
||||
versionName: "",
|
||||
versionCode: "",
|
||||
icon: "",
|
||||
categories: [] as string[],
|
||||
remark: "",
|
||||
});
|
||||
|
||||
const shotInput = ref<HTMLInputElement>();
|
||||
const selectedShots = ref<File[]>([]);
|
||||
|
||||
/** 应用分类(从后端加载) */
|
||||
const categoryOptions = ref<AppCategory[]>([]);
|
||||
async function loadCategories() {
|
||||
try {
|
||||
categoryOptions.value = await AppCategoryAPI.list();
|
||||
} catch {
|
||||
categoryOptions.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function onPick(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const f = input.files?.[0];
|
||||
if (f) handleFile(f);
|
||||
input.value = "";
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
const f = e.dataTransfer?.files?.[0];
|
||||
if (f) handleFile(f);
|
||||
}
|
||||
|
||||
function handleFile(f: File) {
|
||||
const ext = f.name.split(".").pop()?.toLowerCase();
|
||||
const allowed = ["apk", "apks", "aab", "apkm", "xapk"];
|
||||
if (!ext || !allowed.includes(ext)) {
|
||||
ElMessage.error("仅支持 .apk / .aab / .xapk / .apks / .apkm 安装包");
|
||||
selectedFile.value = undefined;
|
||||
return;
|
||||
}
|
||||
selectedFile.value = f;
|
||||
if (ext === "apk") {
|
||||
parseApk(f);
|
||||
} else {
|
||||
// aab / xapk / apks / apkm 不支持浏览器端二进制解析,需手动填写包名与版本信息
|
||||
resetParsedFields();
|
||||
}
|
||||
}
|
||||
|
||||
function clearFile() {
|
||||
selectedFile.value = undefined;
|
||||
resetParsedFields();
|
||||
}
|
||||
|
||||
function onShotsPick(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const files = input.files;
|
||||
if (files?.length) {
|
||||
for (const f of Array.from(files)) {
|
||||
const ext = f.name.split(".").pop()?.toLowerCase();
|
||||
const allowedImg = ["png", "jpg", "jpeg", "webp"];
|
||||
if (!ext || !allowedImg.includes(ext)) {
|
||||
ElMessage.error("截图仅支持 png / jpg / jpeg / webp 格式");
|
||||
continue;
|
||||
}
|
||||
selectedShots.value.push(f);
|
||||
}
|
||||
}
|
||||
input.value = "";
|
||||
}
|
||||
|
||||
function shotPreview(i: number): string {
|
||||
const f = selectedShots.value[i];
|
||||
return f ? URL.createObjectURL(f) : "";
|
||||
}
|
||||
|
||||
function removeShot(i: number) {
|
||||
const f = selectedShots.value[i];
|
||||
if (f) {
|
||||
const url = URL.createObjectURL(f);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
selectedShots.value.splice(i, 1);
|
||||
}
|
||||
|
||||
function clearShots() {
|
||||
selectedShots.value.forEach((f) => URL.revokeObjectURL(URL.createObjectURL(f)));
|
||||
selectedShots.value = [];
|
||||
}
|
||||
|
||||
onMounted(loadCategories);
|
||||
|
||||
/** 浏览器端解析 apk 元数据:包名 / 应用名 / 版本名 / 版本号(apk-meta-parser,纯浏览器) */
|
||||
async function parseApk(file: File) {
|
||||
parseFailed.value = false;
|
||||
parsing.value = true;
|
||||
try {
|
||||
const meta = await parseApkMeta(file);
|
||||
form.packageName = meta?.packageName || "";
|
||||
form.appLabel = meta?.label || "";
|
||||
form.versionName = meta?.versionName || "";
|
||||
form.versionCode = meta?.versionCode != null ? String(meta.versionCode) : "";
|
||||
// 注:apk-meta-parser 不解析图标,icon 由后端入库时生成
|
||||
if (!form.packageName) {
|
||||
parseFailed.value = true;
|
||||
}
|
||||
} catch {
|
||||
parseFailed.value = true;
|
||||
ElMessage.warning("APK 解析失败,请手动填写包名与版本信息");
|
||||
} finally {
|
||||
parsing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetParsedFields() {
|
||||
form.packageName = "";
|
||||
form.appLabel = "";
|
||||
form.versionName = "";
|
||||
form.versionCode = "";
|
||||
form.icon = "";
|
||||
form.categories = [];
|
||||
parseFailed.value = false;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return;
|
||||
await formRef.value.validate();
|
||||
const raw = selectedFile.value as File | undefined;
|
||||
if (!raw) {
|
||||
ElMessage.warning("请先选择 APK 文件");
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const vo: AppPackage = await AppPackageAPI.upload({
|
||||
file: raw,
|
||||
packageName: form.packageName || undefined,
|
||||
versionName: form.versionName || undefined,
|
||||
versionCode: form.versionCode || undefined,
|
||||
remark: form.remark || undefined,
|
||||
categories: form.categories.length ? form.categories : undefined,
|
||||
screenshots: selectedShots.value.length ? selectedShots.value : undefined,
|
||||
});
|
||||
// 后端已解析图标,前端直接以 iconUrl 显示
|
||||
if (vo?.iconUrl) form.icon = withBase(vo.iconUrl);
|
||||
ElMessage.success("上传成功,安装包已入库");
|
||||
emit("success");
|
||||
visible.value = false;
|
||||
} catch (e) {
|
||||
ElMessage.error((e as Error)?.message || "上传失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClosed() {
|
||||
selectedFile.value = undefined;
|
||||
resetParsedFields();
|
||||
clearShots();
|
||||
form.remark = "";
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.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;
|
||||
|
||||
&__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 26px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
&__placeholder {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.shot-picker {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
&__input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__add {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: pointer;
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
&__item {
|
||||
position: relative;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
&__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__del {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
padding: 2px;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.apk-picker {
|
||||
width: 100%;
|
||||
|
||||
&__input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px 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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
570
src/views/app-manager/upload-package/index.vue
Normal file
570
src/views/app-manager/upload-package/index.vue
Normal file
@@ -0,0 +1,570 @@
|
||||
<template>
|
||||
<div class="app-package-container">
|
||||
<!-- 工具栏 -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<el-input
|
||||
v-model="query.keywords"
|
||||
placeholder="搜索包名 / 应用名 / 文件名"
|
||||
clearable
|
||||
style="width: 260px"
|
||||
:prefix-icon="Search"
|
||||
@keyup.enter="handleQuery"
|
||||
@clear="handleQuery"
|
||||
/>
|
||||
<el-select
|
||||
v-model="query.packageType"
|
||||
placeholder="全部类型"
|
||||
clearable
|
||||
style="width: 130px"
|
||||
@change="handleQuery"
|
||||
>
|
||||
<el-option label="APK" value="apk" />
|
||||
<el-option label="APKS" value="apks" />
|
||||
<el-option label="AAB" value="aab" />
|
||||
<el-option label="APKM" value="apkm" />
|
||||
<el-option label="XAPK" value="xapk" />
|
||||
</el-select>
|
||||
<el-button type="primary" :icon="Search" @click="handleQuery">查询</el-button>
|
||||
<el-button :icon="Refresh" @click="handleRefresh">刷新</el-button>
|
||||
</div>
|
||||
<el-button type="success" :icon="Upload" @click="uploadVisible = true">上传安装包</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 卡片列表 -->
|
||||
<div v-loading="loading" class="card-grid">
|
||||
<el-empty v-if="!loading && list.length === 0" description="暂无安装包,点击右上角上传" />
|
||||
|
||||
<el-card v-for="item in list" :key="item.id" class="pkg-card" shadow="hover">
|
||||
<div class="pkg-card__header">
|
||||
<el-image
|
||||
:src="withBase(item.iconUrl)"
|
||||
fit="contain"
|
||||
class="pkg-card__icon"
|
||||
:preview-src-list="item.iconUrl ? [withBase(item.iconUrl)] : []"
|
||||
>
|
||||
<template #error>
|
||||
<div class="pkg-card__icon-fallback">
|
||||
<el-icon><Box /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
<div class="pkg-card__title">
|
||||
<div class="pkg-card__name" :title="item.appLabel || item.packageName">
|
||||
{{ item.appLabel || item.packageName || "未知应用" }}
|
||||
</div>
|
||||
<el-tag size="small" :type="typeTagType(item.packageType)" effect="dark" round>
|
||||
{{ item.packageType?.toUpperCase() }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pkg-card__body">
|
||||
<div class="pkg-row">
|
||||
<span class="pkg-label">包名</span>
|
||||
<span class="pkg-value" :title="item.packageName">{{ item.packageName || "-" }}</span>
|
||||
</div>
|
||||
<div class="pkg-row">
|
||||
<span class="pkg-label">分类</span>
|
||||
<span class="pkg-value">
|
||||
<el-select
|
||||
v-model="item.categories"
|
||||
multiple
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
placeholder="选择分类"
|
||||
style="width: 100%"
|
||||
:loading="categorySaving[item.id]"
|
||||
@change="(val: string[]) => handleCategoryChange(item, val)"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in categoryOptions"
|
||||
:key="opt.code"
|
||||
:label="opt.name"
|
||||
:value="opt.code"
|
||||
>
|
||||
<img v-if="opt.icon" :src="opt.icon" class="cat-icon" alt="" />
|
||||
<span>{{ opt.name }}</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</span>
|
||||
</div>
|
||||
<div class="pkg-row">
|
||||
<span class="pkg-label">版本</span>
|
||||
<span class="pkg-value">
|
||||
{{ item.versionName || "-" }}
|
||||
<template v-if="item.versionCode">({{ item.versionCode }})</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="pkg-row">
|
||||
<span class="pkg-label">大小</span>
|
||||
<span class="pkg-value">{{ item.fileSizeText || "-" }}</span>
|
||||
</div>
|
||||
<div class="pkg-row">
|
||||
<span class="pkg-label">文件名</span>
|
||||
<span class="pkg-value" :title="item.originalName">{{ item.originalName || "-" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pkg-card__footer">
|
||||
<el-button text type="primary" :icon="View" @click="openDetail(item)">详情</el-button>
|
||||
<el-button text type="primary" :icon="Download" @click="download(item)">下载</el-button>
|
||||
<el-popconfirm title="确认删除该安装包?" @confirm="handleDelete(item)">
|
||||
<template #reference>
|
||||
<el-button text type="danger" :icon="Delete">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination
|
||||
v-if="total > 0"
|
||||
class="pager"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
:page-sizes="[12, 24, 48]"
|
||||
:current-page="query.pageNum"
|
||||
:page-size="query.pageSize"
|
||||
background
|
||||
@current-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
|
||||
<!-- 上传对话框 -->
|
||||
<UploadDialog v-model:visible="uploadVisible" @success="handleUploaded" />
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<el-drawer v-model="detailVisible" title="安装包详情" size="460px">
|
||||
<el-descriptions v-if="current" :column="1" border class="detail-desc">
|
||||
<el-descriptions-item label="应用名称">{{ current.appLabel || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="包名">{{ current.packageName || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="类型">
|
||||
{{ current.packageType?.toUpperCase() }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="版本名称">
|
||||
{{ current.versionName || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="版本号">{{ current.versionCode ?? "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最低 SDK">{{ current.minSdk ?? "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="目标 SDK">{{ current.targetSdk ?? "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="编译 SDK">
|
||||
{{ current.compileSdk ?? "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="文件大小">
|
||||
{{ current.fileSizeText || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="文件名">
|
||||
{{ current.originalName || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="MD5">{{ current.md5 || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="CPU 架构">
|
||||
<el-tag v-for="abi in current.abiList || []" :key="abi" size="small" class="abi-tag">
|
||||
{{ abi }}
|
||||
</el-tag>
|
||||
<span v-if="!current.abiList || current.abiList.length === 0">-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="系统应用">
|
||||
{{ current.systemApp ? "是" : "否" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="应用分类">
|
||||
<el-tag
|
||||
v-for="c in current.categories || []"
|
||||
:key="c"
|
||||
size="small"
|
||||
type="success"
|
||||
class="abi-tag"
|
||||
>
|
||||
<img v-if="categoryMap[c]?.icon" :src="categoryMap[c].icon" class="cat-icon" alt="" />
|
||||
{{ categoryMap[c]?.name || c }}
|
||||
</el-tag>
|
||||
<span v-if="!current.categories || current.categories.length === 0">-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="应用截图">
|
||||
<div class="shot-gallery">
|
||||
<el-image
|
||||
v-for="(s, i) in current.screenshots || []"
|
||||
:key="i"
|
||||
:src="withBase(s)"
|
||||
:preview-src-list="(current.screenshots || []).map(withBase)"
|
||||
:initial-index="i"
|
||||
fit="cover"
|
||||
class="shot-gallery__item"
|
||||
>
|
||||
<template #error>
|
||||
<div class="shot-gallery__fallback">
|
||||
<el-icon><Picture /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
<span v-if="!current.screenshots || current.screenshots.length === 0">-</span>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="可调试">
|
||||
{{ current.debuggable == null ? "-" : current.debuggable ? "是" : "否" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="上传时间">
|
||||
{{ current.createTime || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="备注">{{ current.remark || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="权限列表">
|
||||
<div class="perm-list">
|
||||
<el-tag
|
||||
v-for="p in current.permissions || []"
|
||||
:key="p"
|
||||
size="small"
|
||||
type="info"
|
||||
class="perm-tag"
|
||||
>
|
||||
{{ p }}
|
||||
</el-tag>
|
||||
<span v-if="!current.permissions || current.permissions.length === 0">-</span>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import {
|
||||
Box,
|
||||
Delete,
|
||||
Download,
|
||||
Picture,
|
||||
Refresh,
|
||||
Search,
|
||||
Upload,
|
||||
View,
|
||||
} from "@element-plus/icons-vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import AppPackageAPI, { withBase, type AppPackage, type PackageType } from "@/api/app/package";
|
||||
import AppCategoryAPI, { type AppCategory } from "@/api/app/category";
|
||||
import UploadDialog from "./components/UploadDialog.vue";
|
||||
|
||||
/** 分类 code -> 分类对象 映射(从后端加载) */
|
||||
const categoryMap = ref<Record<string, AppCategory>>({});
|
||||
|
||||
/** 分类下拉选项(按后端排序) */
|
||||
const categoryOptions = ref<AppCategory[]>([]);
|
||||
|
||||
/** 各卡片分类保存中状态(key=packageId) */
|
||||
const categorySaving = reactive<Record<string, boolean>>({});
|
||||
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const cats = await AppCategoryAPI.list();
|
||||
categoryMap.value = Object.fromEntries(cats.map((c) => [c.code, c]));
|
||||
categoryOptions.value = cats;
|
||||
} catch {
|
||||
categoryMap.value = {};
|
||||
categoryOptions.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 卡片内直接修改分类:变更即保存到后端 */
|
||||
async function handleCategoryChange(item: AppPackage, val: string[]) {
|
||||
categorySaving[item.id] = true;
|
||||
try {
|
||||
await AppPackageAPI.updateCategories(item.id, val);
|
||||
ElMessage.success("分类已更新");
|
||||
} catch (e) {
|
||||
ElMessage.error((e as Error)?.message || "分类更新失败");
|
||||
// 失败回滚:重新加载该包分类
|
||||
await loadData();
|
||||
} finally {
|
||||
categorySaving[item.id] = false;
|
||||
}
|
||||
}
|
||||
|
||||
const query = reactive({
|
||||
pageNum: 1,
|
||||
pageSize: 12,
|
||||
keywords: "",
|
||||
packageType: "" as PackageType | "",
|
||||
});
|
||||
|
||||
const list = ref<AppPackage[]>([]);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const uploadVisible = ref(false);
|
||||
const detailVisible = ref(false);
|
||||
const current = ref<AppPackage | null>(null);
|
||||
|
||||
function typeTagType(type?: PackageType) {
|
||||
if (type === "apk") return "success";
|
||||
if (type === "aab") return "warning";
|
||||
if (type === "xapk") return "danger";
|
||||
return "info";
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await AppPackageAPI.page({
|
||||
pageNum: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
keywords: query.keywords || undefined,
|
||||
packageType: query.packageType || undefined,
|
||||
});
|
||||
list.value = res.list;
|
||||
total.value = res.total;
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || "加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleQuery() {
|
||||
query.pageNum = 1;
|
||||
loadData();
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
query.pageNum = 1;
|
||||
query.pageSize = 12;
|
||||
query.keywords = "";
|
||||
query.packageType = "";
|
||||
loadData();
|
||||
}
|
||||
|
||||
function handleUploaded() {
|
||||
query.pageNum = 1;
|
||||
loadData();
|
||||
}
|
||||
|
||||
function openDetail(item: AppPackage) {
|
||||
current.value = item;
|
||||
detailVisible.value = true;
|
||||
}
|
||||
|
||||
function download(item: AppPackage) {
|
||||
if (!item.fileUrl) {
|
||||
ElMessage.warning("文件地址缺失");
|
||||
return;
|
||||
}
|
||||
const a = document.createElement("a");
|
||||
a.href = withBase(item.fileUrl);
|
||||
a.download = item.originalName || `${item.packageName}.${item.packageType}`;
|
||||
a.click();
|
||||
}
|
||||
|
||||
async function handleDelete(item: AppPackage) {
|
||||
try {
|
||||
await AppPackageAPI.delete(item.id);
|
||||
ElMessage.success("已删除");
|
||||
loadData();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || "删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
function onPageChange(page: number) {
|
||||
query.pageNum = page;
|
||||
loadData();
|
||||
}
|
||||
|
||||
function onSizeChange(size: number) {
|
||||
query.pageSize = size;
|
||||
query.pageNum = 1;
|
||||
loadData();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
loadCategories();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-package-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.pkg-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
// 固定卡片高度,保证所有卡片(跨行)严格等高,不再随内容多少而变化
|
||||
height: 300px;
|
||||
|
||||
// Element Plus 的 el-card 内容由 .el-card__body 包裹,需让它撑满高度并纵向布局,
|
||||
// 否则 flex:1 / margin-top:auto 拿不到可分配空间,底部无法对齐。
|
||||
:deep(.el-card__body) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
overflow: hidden;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
&__icon-fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 24px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
&__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
justify-content: flex-end;
|
||||
padding-top: 8px;
|
||||
margin-top: auto;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
}
|
||||
|
||||
.pkg-row {
|
||||
display: flex;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
|
||||
.pkg-label {
|
||||
flex-shrink: 0;
|
||||
width: 56px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.pkg-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
// 单行截断,防止长文本换行导致卡片高度不一致(完整内容通过 title 悬浮提示查看)
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.abi-tag {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
margin: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.cat-icon {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-right: 2px;
|
||||
vertical-align: middle;
|
||||
object-fit: contain;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.perm-list {
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
|
||||
.perm-tag {
|
||||
margin: 0 4px 4px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.pager {
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* 安装包详情:每行(标签 + 内容)固定单行不换行,超长省略 */
|
||||
.detail-desc :deep(.el-descriptions__label),
|
||||
.detail-desc :deep(.el-descriptions__content) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 权限 / CPU 架构等多标签列表仍按块级展示,不被单行规则影响 */
|
||||
.detail-desc :deep(.perm-list),
|
||||
.detail-desc :deep(.el-descriptions__content) > div {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* 应用截图画廊 */
|
||||
.shot-gallery {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
&__item {
|
||||
width: 96px;
|
||||
height: 170px;
|
||||
overflow: hidden;
|
||||
background: var(--el-fill-color-light);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
&__fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 22px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,30 +1,31 @@
|
||||
import UserAPI from "@/api/system/user";
|
||||
import type { UserForm } from "@/types/api";
|
||||
import DeviceAPI from "@/api/system/device";
|
||||
import type { DeviceForm } from "@/api/system/device/types";
|
||||
import type { IModalConfig } from "@/components/DeviceSn/types";
|
||||
import { deptArr, roleArr } from "./options";
|
||||
|
||||
const modalConfig: IModalConfig<UserForm> = {
|
||||
permPrefix: "sys:user",
|
||||
const modalConfig: IModalConfig<DeviceForm> = {
|
||||
permPrefix: "sys:device",
|
||||
dialog: {
|
||||
title: "新增用户",
|
||||
title: "新增设备",
|
||||
width: 800,
|
||||
draggable: true,
|
||||
},
|
||||
form: {
|
||||
labelWidth: 100,
|
||||
},
|
||||
formAction: UserAPI.create,
|
||||
formAction(data) {
|
||||
return DeviceAPI.create(data);
|
||||
},
|
||||
beforeSubmit(data) {
|
||||
console.warn("提交之前处理", data);
|
||||
return data;
|
||||
},
|
||||
formItems: [
|
||||
{
|
||||
label: "用户名",
|
||||
prop: "username",
|
||||
rules: [{ required: true, message: "用户名不能为空", trigger: "blur" }],
|
||||
label: "设备SN",
|
||||
prop: "serialno",
|
||||
rules: [{ required: true, message: "设备SN不能为空", trigger: "blur" }],
|
||||
type: "input",
|
||||
attrs: {
|
||||
placeholder: "请输入用户名",
|
||||
placeholder: "请输入设备序列号",
|
||||
},
|
||||
col: {
|
||||
xs: 24,
|
||||
@@ -32,12 +33,12 @@ const modalConfig: IModalConfig<UserForm> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "用户昵称",
|
||||
prop: "nickname",
|
||||
rules: [{ required: true, message: "用户昵称不能为空", trigger: "blur" }],
|
||||
label: "设备型号",
|
||||
prop: "snModel",
|
||||
rules: [{ required: true, message: "设备型号不能为空", trigger: "blur" }],
|
||||
type: "input",
|
||||
attrs: {
|
||||
placeholder: "请输入用户昵称",
|
||||
placeholder: "请输入设备型号",
|
||||
},
|
||||
col: {
|
||||
xs: 24,
|
||||
@@ -45,48 +46,21 @@ const modalConfig: IModalConfig<UserForm> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "所属部门",
|
||||
prop: "deptId",
|
||||
rules: [{ required: true, message: "所属部门不能为空", trigger: "change" }],
|
||||
type: "tree-select",
|
||||
attrs: {
|
||||
placeholder: "请选择所属部门",
|
||||
data: deptArr,
|
||||
filterable: true,
|
||||
"check-strictly": true,
|
||||
"render-after-expand": false,
|
||||
},
|
||||
// async initFn(formItem) {
|
||||
// // 注意:如果initFn函数不是箭头函数,this会指向此配置项对象,那么也就可以用this来替代形参formItem
|
||||
// formItem.attrs.data = await DeptAPI.getOptions();
|
||||
// },
|
||||
},
|
||||
{
|
||||
type: "custom",
|
||||
label: "性别",
|
||||
prop: "gender",
|
||||
initialValue: 1,
|
||||
attrs: { style: { width: "100%" } },
|
||||
},
|
||||
{
|
||||
label: "角色",
|
||||
prop: "roleIds",
|
||||
rules: [{ required: true, message: "用户角色不能为空", trigger: "change" }],
|
||||
type: "select",
|
||||
attrs: {
|
||||
placeholder: "请选择",
|
||||
multiple: true,
|
||||
},
|
||||
options: roleArr,
|
||||
initialValue: [],
|
||||
// async initFn(formItem) {
|
||||
// formItem.options = await RoleAPI.getOptions();
|
||||
// },
|
||||
},
|
||||
{
|
||||
label: "设备昵称",
|
||||
prop: "snName",
|
||||
rules: [{ required: true, message: "设备昵称不能为空", trigger: "blur" }],
|
||||
type: "input",
|
||||
attrs: {
|
||||
placeholder: "请输入内容",
|
||||
},
|
||||
col: {
|
||||
xs: 24,
|
||||
sm: 12,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "手机号码",
|
||||
prop: "mobile",
|
||||
prop: "snMobile",
|
||||
rules: [
|
||||
{
|
||||
pattern: /^1[3|4|5|6|7|8|9][0-9]\d{8}$/,
|
||||
@@ -94,25 +68,50 @@ const modalConfig: IModalConfig<UserForm> = {
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
type: "input",
|
||||
attrs: {
|
||||
placeholder: "请输入手机号码",
|
||||
maxlength: 11,
|
||||
},
|
||||
col: {
|
||||
xs: 24,
|
||||
sm: 12,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "邮箱",
|
||||
prop: "email",
|
||||
rules: [
|
||||
{
|
||||
pattern: /\w[-\w.+]*@([A-Za-z0-9][-A-Za-z0-9]+\.)+[A-Za-z]{2,14}/,
|
||||
message: "请输入正确的邮箱地址",
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
label: "IMEI",
|
||||
prop: "snImei",
|
||||
type: "input",
|
||||
attrs: {
|
||||
placeholder: "请输入邮箱",
|
||||
maxlength: 50,
|
||||
placeholder: "请输入设备IMEI",
|
||||
},
|
||||
col: {
|
||||
xs: 24,
|
||||
sm: 12,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "设备类型",
|
||||
prop: "deviceType",
|
||||
type: "input",
|
||||
attrs: {
|
||||
placeholder: "请输入设备类型",
|
||||
},
|
||||
col: {
|
||||
xs: 24,
|
||||
sm: 12,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "来源",
|
||||
prop: "source",
|
||||
type: "input",
|
||||
attrs: {
|
||||
placeholder: "请输入设备来源",
|
||||
},
|
||||
col: {
|
||||
xs: 24,
|
||||
sm: 12,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -120,17 +119,11 @@ const modalConfig: IModalConfig<UserForm> = {
|
||||
prop: "status",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "正常", value: 1 },
|
||||
{ label: "启用", value: 1 },
|
||||
{ label: "禁用", value: 0 },
|
||||
],
|
||||
initialValue: 1,
|
||||
},
|
||||
{
|
||||
type: "custom",
|
||||
label: "二级弹窗",
|
||||
prop: "openModal",
|
||||
slotName: "openModal",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,87 +1,69 @@
|
||||
import UserAPI from "@/api/system/user";
|
||||
import type { UserForm } from "@/api/system/user/types";
|
||||
import { reactive } from "vue";
|
||||
import DeviceAPI from "@/api/system/device";
|
||||
import type { DeviceForm } from "@/api/system/device/types";
|
||||
import type { IModalConfig } from "@/components/DeviceSn/types";
|
||||
import { DeviceEnum } from "@/enums/settings";
|
||||
import { useAppStore } from "@/stores";
|
||||
import { deptArr, roleArr } from "./options";
|
||||
|
||||
const modalConfig: IModalConfig<UserForm> = {
|
||||
permPrefix: "sys:user",
|
||||
/** 只读输入框配置(展示常用参数,不可编辑) */
|
||||
const readonlyInput = (placeholder = "系统自动填充") => ({
|
||||
type: "input" as const,
|
||||
attrs: {
|
||||
placeholder,
|
||||
readonly: true,
|
||||
disabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const modalConfig: IModalConfig<DeviceForm> = {
|
||||
permPrefix: "sys:device",
|
||||
component: "drawer",
|
||||
drawer: {
|
||||
title: "修改用户",
|
||||
title: "修改设备",
|
||||
size: useAppStore().device === DeviceEnum.MOBILE ? "80%" : 500,
|
||||
},
|
||||
pk: "id",
|
||||
// 编辑只回传可修改字段,其余保持不变
|
||||
beforeSubmit(data) {
|
||||
console.warn("beforeSubmit", data);
|
||||
return {
|
||||
id: data.id,
|
||||
serialno: data.serialno,
|
||||
snName: data.snName,
|
||||
status: data.status,
|
||||
} as DeviceForm;
|
||||
},
|
||||
formAction(data) {
|
||||
return UserAPI.update(data.id as string, data);
|
||||
return DeviceAPI.update(data.id as string, data);
|
||||
},
|
||||
formItems: [
|
||||
{
|
||||
label: "用户名",
|
||||
prop: "username",
|
||||
rules: [{ required: true, message: "用户名不能为空", trigger: "blur" }],
|
||||
label: "设备SN",
|
||||
prop: "serialno",
|
||||
type: "input",
|
||||
attrs: {
|
||||
placeholder: "请输入用户名",
|
||||
placeholder: "设备序列号",
|
||||
readonly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "用户昵称",
|
||||
prop: "nickname",
|
||||
rules: [{ required: true, message: "用户昵称不能为空", trigger: "blur" }],
|
||||
label: "设备型号",
|
||||
prop: "snModel",
|
||||
rules: [{ required: true, message: "设备型号不能为空", trigger: "blur" }],
|
||||
...readonlyInput("设备型号"),
|
||||
},
|
||||
{
|
||||
label: "设备昵称",
|
||||
prop: "snName",
|
||||
rules: [{ required: true, message: "设备昵称不能为空", trigger: "blur" }],
|
||||
type: "input",
|
||||
attrs: {
|
||||
placeholder: "请输入用户昵称",
|
||||
placeholder: "请输入设备昵称",
|
||||
maxlength: 50,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "所属部门",
|
||||
prop: "deptId",
|
||||
rules: [{ required: true, message: "所属部门不能为空", trigger: "blur" }],
|
||||
type: "tree-select",
|
||||
attrs: {
|
||||
placeholder: "请选择所属部门",
|
||||
data: deptArr, // setup,Vue会自动解包ref,不需要.value
|
||||
filterable: true,
|
||||
"check-strictly": true,
|
||||
"render-after-expand": false,
|
||||
},
|
||||
// async initFn(formItem) {
|
||||
// // 注意:如果initFn函数不是箭头函数,this会指向此配置项对象,那么也就可以用this来替代形参formItem
|
||||
// formItem.attrs.data = await DeptAPI.getOptions();
|
||||
// },
|
||||
},
|
||||
{
|
||||
type: "custom",
|
||||
label: "性别",
|
||||
prop: "gender",
|
||||
initialValue: 1,
|
||||
attrs: { style: { width: "100%" } },
|
||||
},
|
||||
{
|
||||
label: "角色",
|
||||
prop: "roleIds",
|
||||
rules: [{ required: true, message: "用户角色不能为空", trigger: "blur" }],
|
||||
type: "select",
|
||||
attrs: {
|
||||
placeholder: "请选择",
|
||||
multiple: true,
|
||||
},
|
||||
options: roleArr,
|
||||
initialValue: [],
|
||||
// async initFn(formItem) {
|
||||
// formItem.options = await RoleAPI.getOptions();
|
||||
// },
|
||||
},
|
||||
{
|
||||
type: "input",
|
||||
label: "手机号码",
|
||||
prop: "mobile",
|
||||
prop: "snMobile",
|
||||
rules: [
|
||||
{
|
||||
pattern: /^1[3|4|5|6|7|8|9][0-9]\d{8}$/,
|
||||
@@ -89,26 +71,32 @@ const modalConfig: IModalConfig<UserForm> = {
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
attrs: {
|
||||
placeholder: "请输入手机号码",
|
||||
maxlength: 11,
|
||||
},
|
||||
...readonlyInput("绑定手机号"),
|
||||
},
|
||||
{
|
||||
label: "邮箱",
|
||||
prop: "email",
|
||||
rules: [
|
||||
{
|
||||
pattern: /\w[-\w.+]*@([A-Za-z0-9][-A-Za-z0-9]+\.)+[A-Za-z]{2,14}/,
|
||||
message: "请输入正确的邮箱地址",
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
type: "input",
|
||||
attrs: {
|
||||
placeholder: "请输入邮箱",
|
||||
maxlength: 50,
|
||||
},
|
||||
label: "IMEI",
|
||||
prop: "snImei",
|
||||
...readonlyInput("设备IMEI"),
|
||||
},
|
||||
{
|
||||
label: "设备类型",
|
||||
prop: "deviceType",
|
||||
...readonlyInput("设备类型"),
|
||||
},
|
||||
{
|
||||
label: "来源",
|
||||
prop: "source",
|
||||
...readonlyInput("设备来源"),
|
||||
},
|
||||
{
|
||||
label: "激活时间",
|
||||
prop: "activationTime",
|
||||
...readonlyInput("激活时间"),
|
||||
},
|
||||
{
|
||||
label: "最后上线",
|
||||
prop: "lastOnline",
|
||||
...readonlyInput("最后上线时间"),
|
||||
},
|
||||
{
|
||||
label: "状态",
|
||||
@@ -116,7 +104,7 @@ const modalConfig: IModalConfig<UserForm> = {
|
||||
type: "switch",
|
||||
attrs: {
|
||||
inlinePrompt: true,
|
||||
activeText: "正常",
|
||||
activeText: "启用",
|
||||
inactiveText: "禁用",
|
||||
activeValue: 1,
|
||||
inactiveValue: 0,
|
||||
|
||||
@@ -103,7 +103,6 @@ const {
|
||||
handleResetClick,
|
||||
handleAddClick,
|
||||
handleEditClick,
|
||||
handleViewClick,
|
||||
handleSubmitClick,
|
||||
handleExportClick,
|
||||
handleSearchClick,
|
||||
@@ -125,12 +124,8 @@ function handleReturnClick() {
|
||||
// 表格工具
|
||||
const handleOperateClick = (data: IObject) => {
|
||||
if (data.name === "detail") {
|
||||
editModalConfig.drawer = { ...editModalConfig.drawer, title: "查看" };
|
||||
handleViewClick(data.row, async () => {
|
||||
// 加载下拉数据源,建议在初始化配置项"initFn 中加载,避免多次请求
|
||||
// editModalConfig.formItems[2]!.attrs!.data = await DeptAPI.getOptions();
|
||||
return await DeviceAPI.getFormData(data.row.serialno as string); // 根据SN获取详情
|
||||
});
|
||||
// 打开设备详情抽屉,读取常用参数
|
||||
handleSerialnoClick(data.row);
|
||||
} else if (data.name === "edit") {
|
||||
editModalConfig.drawer = { ...editModalConfig.drawer, title: "修改" };
|
||||
handleEditClick(data.row, async () => {
|
||||
|
||||
Reference in New Issue
Block a user