chore(deps): 新增 apk-meta-parser、jszip、spark-md5 依赖,新增应用分类和安装包 API,重构设备详情抽屉组件

This commit is contained in:
TongTongStudio
2026-08-07 12:19:46 +08:00
parent aba4d2d3bc
commit 96a0b12582
16 changed files with 1915 additions and 1998 deletions

83
src/api/app/category.ts Normal file
View 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
View 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}`;
}

View File

@@ -68,6 +68,8 @@ export interface DeviceForm {
serialno?: string;
/** 设备名称 */
snName?: string;
/** 设备型号 */
snModel?: string;
/** 设备手机号 */
snMobile?: string;
/** 设备在线状态(1:在线;0:离线) */

View 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

View 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

View File

@@ -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 };

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

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

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

View File

@@ -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",
},
],
};

View File

@@ -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,

View File

@@ -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 () => {