Compare commits
21 Commits
b3524ee1fa
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb2e687d0a | ||
|
|
640a3ecc57 | ||
|
|
c33a65350b | ||
|
|
6c2a618b5a | ||
|
|
384696affc | ||
|
|
5671190b8e | ||
|
|
8904af4327 | ||
|
|
c778abd123 | ||
|
|
84af5737f9 | ||
|
|
52db4699a6 | ||
|
|
b0c1dd97ed | ||
|
|
1965641229 | ||
|
|
e1aa8fc2a1 | ||
|
|
f7470cf735 | ||
|
|
9bdbefcfe2 | ||
|
|
6ad17c5082 | ||
|
|
aebef55e0a | ||
|
|
b8a2c664c8 | ||
|
|
09088815cc | ||
|
|
48f0ac118c | ||
|
|
4403380d49 |
@@ -6,7 +6,8 @@ VITE_APP_TITLE=vue3-element-admin
|
||||
VITE_APP_BASE_API=/dev-api
|
||||
|
||||
# 接口地址
|
||||
# VITE_APP_API_URL=https://api.youlai.tech # 线上
|
||||
#VITE_APP_API_URL=http://180.102.16.123:8000
|
||||
#VITE_APP_API_URL=https://api.youlai.tech # 线上
|
||||
# VITE_APP_API_URL=https://api.youlai.tech/v2 # 线上(多租户)
|
||||
VITE_APP_API_URL=http://192.168.5.224:8000 # 本地
|
||||
|
||||
@@ -16,8 +17,11 @@ VITE_MOCK_DEV_SERVER=false
|
||||
|
||||
# 多租户开关(true:开启 false:关闭)
|
||||
VITE_APP_TENANT_ENABLED=false
|
||||
VITE_API_TYPE = MYSQL # or POSTGRES
|
||||
|
||||
# 百度地图 JavaScript API v4.0 密钥(AK)
|
||||
# 申请地址:https://lbsyun.baidu.com/apiconsole/key
|
||||
# 注意:需在「应用管理-我的应用」中为该 AK 配置「浏览器端」白名单(Referer 域名/IP)
|
||||
VITE_BAIDU_MAP_AK=DaKEjkzMRHg87GZgEJqAmxUxcBbJaYvS
|
||||
|
||||
|
||||
|
||||
@@ -32,8 +32,8 @@ trailingComma: "es5"
|
||||
useTabs: false
|
||||
# Vue 文件中的 <script> 和 <style> 不增加额外的缩进
|
||||
vueIndentScriptAndStyle: false
|
||||
# 根据系统自动检测换行符
|
||||
endOfLine: "auto"
|
||||
# 统一使用 LF 换行符,避免跨平台差异产生大量 diff
|
||||
endOfLine: "lf"
|
||||
# 对 HTML 文件应用特定格式化规则
|
||||
overrides:
|
||||
- files: "*.html"
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
## 项目简介
|
||||
|
||||
[vue3-element-admin](https://gitcode.com/youlai/vue3-element-admin) 基于 Vue 3、Vite、TypeScript、Element Plus 构建的企业级中后台前端,配套 [9 种主后端 + 衍生版本](#生态矩阵)(覆盖 Java / Node.js / Go / Python / PHP / C# / Rust 7 种语言)及移动端 [youlai-app](https://gitee.com/youlaiorg/youlai-app)。其他前端版本:[JS 版](https://gitee.com/youlaiorg/vue3-element-admin-js) · [精简版](https://gitee.com/youlaiorg/vue3-element-template) · [NaiveUI 版](https://gitee.com/youlaiorg/vue3-naiveui-admin)。
|
||||
[vue3-element-admin](https://gitcode.com/youlai/vue3-element-admin) 基于 Vue 3、Vite、TypeScript、Element Plus 构建的企业级中后台前端,配套 [9 种主后端 + 衍生版本](#生态矩阵)(覆盖 Java / Node.js / Go / Python / PHP / C# / Rust 7 种语言)及移动端 [youlai-app](https://gitee.com/youlaiorg/youlai-app)。其他前端版本:[JS 版](https://gitee.com/youlaiorg/vue3-element-admin-js) · [精简版](https://gitee.com/youlaiorg/vue3-element-template) · [NaiveUI 版](https://gitee.com/youlaiorg/vue3-naiveui-admin) · [Electron 桌面版](https://gitee.com/haoxr/youlai-electron)。
|
||||
|
||||
## 项目特色
|
||||
|
||||
@@ -120,6 +120,7 @@ npx skills add https://github.com/youlaitech/youlai-skills --skill vue-admin
|
||||
| [vue3-element-template](https://gitee.com/youlaiorg/vue3-element-template) | Vue 3 + Vite + TS + Element Plus | 精简模板 | ✅️ |
|
||||
| [vue3-naiveui-admin](https://gitee.com/youlaiorg/vue3-naiveui-admin) | Vue 3 + Vite + TS + Naive UI | Naive UI 版本 | ✅️ |
|
||||
| [youlai-app](https://gitee.com/youlaiorg/youlai-app) | Vue 3 + UniApp | 移动端 App | ✅️ |
|
||||
| [youlai-electron](https://gitee.com/haoxr/youlai-electron) | Electron + Vue 3 | 桌面版 | ✅️ |
|
||||
|
||||
**后端**
|
||||
|
||||
|
||||
158
mock/app.mock.ts
Normal file
158
mock/app.mock.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { defineMock } from "./base";
|
||||
|
||||
/** 应用数据源(内存态,支持增删改与状态切换) */
|
||||
const appList: Array<Record<string, any>> = [
|
||||
{
|
||||
id: "1",
|
||||
appName: "有来商城",
|
||||
appCode: "youlai-mall",
|
||||
platform: "WECHAT_MP",
|
||||
appId: "wx5d2e1a2b3c4d5e6f7a8b",
|
||||
appSecret: "c0a8d0c0d1f4a0b1e0f5a2c3d4e5f6a7",
|
||||
merchantId: "1900000001",
|
||||
merchantKey: "MCH_KEY_123456",
|
||||
status: 1,
|
||||
remark: "有来商城公众号应用",
|
||||
createTime: "2024-01-15 10:00:00",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
appName: "有来商城小程序",
|
||||
appCode: "youlai-mall-mini",
|
||||
platform: "WECHAT_MINI",
|
||||
appId: "wx1f2e3d4c5b6a7f8e9d0c",
|
||||
appSecret: "b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6",
|
||||
merchantId: "1900000002",
|
||||
merchantKey: "MCH_KEY_654321",
|
||||
status: 1,
|
||||
remark: "有来商城微信小程序应用",
|
||||
createTime: "2024-02-20 14:30:00",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
appName: "有来商城App",
|
||||
appCode: "youlai-mall-app",
|
||||
platform: "APPLE",
|
||||
appId: "com.youlai.mall.app",
|
||||
appSecret: "",
|
||||
status: 0,
|
||||
remark: "iOS 端应用(已停用)",
|
||||
createTime: "2023-11-08 09:12:00",
|
||||
},
|
||||
];
|
||||
|
||||
export default defineMock([
|
||||
// 应用分页列表
|
||||
{
|
||||
url: "apps",
|
||||
method: ["GET"],
|
||||
body({ query }) {
|
||||
const { keywords, platform, status, pageNum = "1", pageSize = "10" } = query;
|
||||
const filtered = appList.filter((item) => {
|
||||
if (keywords && !`${item.appName}${item.appCode}${item.appId}`.includes(keywords)) {
|
||||
return false;
|
||||
}
|
||||
if (platform && item.platform !== platform) return false;
|
||||
if (status && String(item.status) !== String(status)) return false;
|
||||
return true;
|
||||
});
|
||||
const page = Number(pageNum);
|
||||
const size = Number(pageSize);
|
||||
const start = (page - 1) * size;
|
||||
return {
|
||||
code: "00000",
|
||||
data: {
|
||||
list: filtered.slice(start, start + size),
|
||||
total: filtered.length,
|
||||
},
|
||||
msg: "一切ok",
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// 获取应用表单数据
|
||||
{
|
||||
url: "apps/:id/form",
|
||||
method: ["GET"],
|
||||
body({ params }) {
|
||||
const data = appList.find((item) => item.id === params.id);
|
||||
return {
|
||||
code: "00000",
|
||||
data: data ?? null,
|
||||
msg: "一切ok",
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// 新增应用
|
||||
{
|
||||
url: "apps",
|
||||
method: ["POST"],
|
||||
body({ body }) {
|
||||
appList.unshift({
|
||||
id: String(appList.length + 1),
|
||||
status: 1,
|
||||
createTime: "2024-06-01 10:00:00",
|
||||
...body,
|
||||
});
|
||||
return {
|
||||
code: "00000",
|
||||
data: null,
|
||||
msg: "新增成功",
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// 修改应用
|
||||
{
|
||||
url: "apps/:id",
|
||||
method: ["PUT"],
|
||||
body({ params, body }) {
|
||||
const index = appList.findIndex((item) => item.id === params.id);
|
||||
if (index !== -1) {
|
||||
appList[index] = { ...appList[index], ...body };
|
||||
}
|
||||
return {
|
||||
code: "00000",
|
||||
data: null,
|
||||
msg: "修改成功",
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// 删除应用(多个 ID 以英文逗号分隔)
|
||||
{
|
||||
url: "apps/:ids",
|
||||
method: ["DELETE"],
|
||||
body({ params }) {
|
||||
const idSet = new Set(params.ids.split(","));
|
||||
for (let i = appList.length - 1; i >= 0; i--) {
|
||||
if (idSet.has(appList[i].id)) {
|
||||
appList.splice(i, 1);
|
||||
}
|
||||
}
|
||||
return {
|
||||
code: "00000",
|
||||
data: null,
|
||||
msg: "删除成功",
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// 修改应用状态
|
||||
{
|
||||
url: "apps/:id/status",
|
||||
method: ["PUT"],
|
||||
body({ params, body }) {
|
||||
const item = appList.find((item) => item.id === params.id);
|
||||
if (item) {
|
||||
item.status = body.status;
|
||||
}
|
||||
return {
|
||||
code: "00000",
|
||||
data: null,
|
||||
msg: "状态更新成功",
|
||||
};
|
||||
},
|
||||
},
|
||||
]);
|
||||
351
mock/codegen.mock.ts
Normal file
351
mock/codegen.mock.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import { defineMock } from "./base";
|
||||
|
||||
/** 数据表列表 */
|
||||
const tableList = [
|
||||
{
|
||||
tableName: "sys_user",
|
||||
tableComment: "用户表",
|
||||
engine: "InnoDB",
|
||||
tableCollation: "utf8mb4_general_ci",
|
||||
createTime: "2023-06-01 10:00:00",
|
||||
isConfigured: 1,
|
||||
},
|
||||
{
|
||||
tableName: "sys_role",
|
||||
tableComment: "角色表",
|
||||
engine: "InnoDB",
|
||||
tableCollation: "utf8mb4_general_ci",
|
||||
createTime: "2023-06-01 10:05:00",
|
||||
isConfigured: 1,
|
||||
},
|
||||
{
|
||||
tableName: "sys_menu",
|
||||
tableComment: "菜单表",
|
||||
engine: "InnoDB",
|
||||
tableCollation: "utf8mb4_general_ci",
|
||||
createTime: "2023-06-01 10:10:00",
|
||||
isConfigured: 0,
|
||||
},
|
||||
{
|
||||
tableName: "sys_dept",
|
||||
tableComment: "部门表",
|
||||
engine: "InnoDB",
|
||||
tableCollation: "utf8mb4_general_ci",
|
||||
createTime: "2023-06-01 10:15:00",
|
||||
isConfigured: 0,
|
||||
},
|
||||
];
|
||||
|
||||
/** 下划线命名转驼峰首字母大写,如 sys_user → SysUser */
|
||||
function toPascalCase(name: string): string {
|
||||
return name
|
||||
.split("_")
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** 默认字段配置 */
|
||||
const defaultFieldConfigs = [
|
||||
{
|
||||
columnName: "id",
|
||||
columnType: "bigint",
|
||||
fieldName: "id",
|
||||
fieldType: "Long",
|
||||
fieldComment: "主键",
|
||||
isPrimaryKey: 1,
|
||||
isShowInList: 1,
|
||||
isShowInForm: 0,
|
||||
isShowInQuery: 0,
|
||||
isRequired: 0,
|
||||
formType: 10,
|
||||
queryType: 0,
|
||||
maxLength: 20,
|
||||
fieldSort: 1,
|
||||
dictType: "",
|
||||
},
|
||||
{
|
||||
columnName: "username",
|
||||
columnType: "varchar(50)",
|
||||
fieldName: "username",
|
||||
fieldType: "String",
|
||||
fieldComment: "用户名",
|
||||
isShowInList: 1,
|
||||
isShowInForm: 1,
|
||||
isShowInQuery: 1,
|
||||
isRequired: 1,
|
||||
formType: 1,
|
||||
queryType: 2,
|
||||
maxLength: 50,
|
||||
fieldSort: 2,
|
||||
dictType: "",
|
||||
},
|
||||
{
|
||||
columnName: "nickname",
|
||||
columnType: "varchar(50)",
|
||||
fieldName: "nickname",
|
||||
fieldType: "String",
|
||||
fieldComment: "昵称",
|
||||
isShowInList: 1,
|
||||
isShowInForm: 1,
|
||||
isShowInQuery: 0,
|
||||
isRequired: 0,
|
||||
formType: 1,
|
||||
queryType: 0,
|
||||
maxLength: 50,
|
||||
fieldSort: 3,
|
||||
dictType: "",
|
||||
},
|
||||
{
|
||||
columnName: "gender",
|
||||
columnType: "tinyint",
|
||||
fieldName: "gender",
|
||||
fieldType: "Integer",
|
||||
fieldComment: "性别",
|
||||
isShowInList: 1,
|
||||
isShowInForm: 1,
|
||||
isShowInQuery: 0,
|
||||
isRequired: 0,
|
||||
formType: 2,
|
||||
queryType: 0,
|
||||
maxLength: 3,
|
||||
fieldSort: 4,
|
||||
dictType: "gender",
|
||||
},
|
||||
{
|
||||
columnName: "email",
|
||||
columnType: "varchar(100)",
|
||||
fieldName: "email",
|
||||
fieldType: "String",
|
||||
fieldComment: "邮箱",
|
||||
isShowInList: 0,
|
||||
isShowInForm: 1,
|
||||
isShowInQuery: 0,
|
||||
isRequired: 0,
|
||||
formType: 1,
|
||||
queryType: 0,
|
||||
maxLength: 100,
|
||||
fieldSort: 5,
|
||||
dictType: "",
|
||||
},
|
||||
{
|
||||
columnName: "status",
|
||||
columnType: "tinyint",
|
||||
fieldName: "status",
|
||||
fieldType: "Integer",
|
||||
fieldComment: "状态",
|
||||
isShowInList: 1,
|
||||
isShowInForm: 1,
|
||||
isShowInQuery: 1,
|
||||
isRequired: 1,
|
||||
formType: 6,
|
||||
queryType: 1,
|
||||
maxLength: 3,
|
||||
fieldSort: 6,
|
||||
dictType: "",
|
||||
},
|
||||
{
|
||||
columnName: "create_time",
|
||||
columnType: "datetime",
|
||||
fieldName: "createTime",
|
||||
fieldType: "LocalDateTime",
|
||||
fieldComment: "创建时间",
|
||||
isShowInList: 1,
|
||||
isShowInForm: 0,
|
||||
isShowInQuery: 1,
|
||||
isRequired: 0,
|
||||
formType: 9,
|
||||
queryType: 4,
|
||||
maxLength: 0,
|
||||
fieldSort: 7,
|
||||
dictType: "",
|
||||
},
|
||||
];
|
||||
|
||||
/** 生成配置数据源(内存态,POST 保存、DELETE 重置) */
|
||||
const genConfigMap: Record<string, any> = {
|
||||
sys_user: {
|
||||
id: "1",
|
||||
tableName: "sys_user",
|
||||
businessName: "user",
|
||||
moduleName: "system",
|
||||
packageName: "com.youlai.system",
|
||||
entityName: "SysUser",
|
||||
author: "youlai",
|
||||
parentMenuId: "1",
|
||||
backendAppName: "youlai-admin",
|
||||
frontendAppName: "vue3-element-admin",
|
||||
removeTablePrefix: "sys_",
|
||||
pageType: "classic",
|
||||
fieldConfigs: defaultFieldConfigs,
|
||||
},
|
||||
sys_role: {
|
||||
id: "2",
|
||||
tableName: "sys_role",
|
||||
businessName: "role",
|
||||
moduleName: "system",
|
||||
packageName: "com.youlai.system",
|
||||
entityName: "SysRole",
|
||||
author: "youlai",
|
||||
parentMenuId: "1",
|
||||
backendAppName: "youlai-admin",
|
||||
frontendAppName: "vue3-element-admin",
|
||||
removeTablePrefix: "sys_",
|
||||
pageType: "classic",
|
||||
fieldConfigs: defaultFieldConfigs,
|
||||
},
|
||||
};
|
||||
|
||||
/** 未配置表的默认生成配置(无 id,进入抽屉后从基础配置步骤开始) */
|
||||
function buildDefaultConfig(tableName: string) {
|
||||
return {
|
||||
tableName,
|
||||
businessName: tableName.replace(/^sys_/, ""),
|
||||
moduleName: "system",
|
||||
packageName: "com.youlai.system",
|
||||
entityName: toPascalCase(tableName.replace(/^sys_/, "")),
|
||||
author: "youlai",
|
||||
parentMenuId: "1",
|
||||
backendAppName: "youlai-admin",
|
||||
frontendAppName: "vue3-element-admin",
|
||||
removeTablePrefix: "sys_",
|
||||
pageType: "classic",
|
||||
fieldConfigs: defaultFieldConfigs,
|
||||
};
|
||||
}
|
||||
|
||||
/** 构造代码生成预览文件列表 */
|
||||
function buildPreviewFiles(tableName: string, tableComment: string) {
|
||||
const bizName = tableName.replace(/^sys_/, "");
|
||||
const entityName = toPascalCase(bizName);
|
||||
return [
|
||||
{
|
||||
path: "src/api/system/",
|
||||
fileName: `${bizName}.ts`,
|
||||
content: `import request from "@/utils/request";\n\n/** ${tableComment}相关接口 */\nexport const ${bizName}Api = {\n getPage(params: unknown) {\n return request({ url: "/api/v1/${tableName}s", method: "get", params });\n },\n};\n`,
|
||||
scope: "frontend",
|
||||
language: "ts",
|
||||
},
|
||||
{
|
||||
path: "src/views/system/",
|
||||
fileName: `${bizName}.vue`,
|
||||
content: `<template>\n <div class="page-container">${tableComment}管理</div>\n</template>\n\n<script setup lang="ts">\ndefineOptions({ name: "${entityName}" });\n</script>\n`,
|
||||
scope: "frontend",
|
||||
language: "vue",
|
||||
},
|
||||
{
|
||||
path: "src/main/java/com/youlai/system/controller/",
|
||||
fileName: `${entityName}Controller.java`,
|
||||
content: `package com.youlai.system.controller;\n\nimport org.springframework.web.bind.annotation.RestController;\n\n/**\n * ${tableComment}控制器\n */\n@RestController\npublic class ${entityName}Controller {\n}\n`,
|
||||
scope: "backend",
|
||||
language: "java",
|
||||
},
|
||||
{
|
||||
path: "src/main/resources/mapper/",
|
||||
fileName: `${entityName}Mapper.xml`,
|
||||
content: `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"\n "http://mybatis.org/dtd/mybatis-3-mapper.dtd">\n<mapper namespace="com.youlai.system.mapper.${entityName}Mapper">\n</mapper>\n`,
|
||||
scope: "backend",
|
||||
language: "xml",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default defineMock([
|
||||
// 数据表分页列表
|
||||
{
|
||||
url: "codegen/table",
|
||||
method: ["GET"],
|
||||
body({ query }) {
|
||||
const { keywords, pageNum = "1", pageSize = "10" } = query;
|
||||
const filtered = tableList.filter((item) => {
|
||||
if (keywords && !`${item.tableName}${item.tableComment}`.includes(keywords)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const page = Number(pageNum);
|
||||
const size = Number(pageSize);
|
||||
const start = (page - 1) * size;
|
||||
return {
|
||||
code: "00000",
|
||||
data: {
|
||||
list: filtered.slice(start, start + size),
|
||||
total: filtered.length,
|
||||
},
|
||||
msg: "一切ok",
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// 获取代码生成配置
|
||||
{
|
||||
url: "codegen/:tableName/config",
|
||||
method: ["GET"],
|
||||
body({ params }) {
|
||||
return {
|
||||
code: "00000",
|
||||
data: genConfigMap[params.tableName] ?? buildDefaultConfig(params.tableName),
|
||||
msg: "一切ok",
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// 保存代码生成配置
|
||||
{
|
||||
url: "codegen/:tableName/config",
|
||||
method: ["POST"],
|
||||
body({ params, body }) {
|
||||
genConfigMap[params.tableName] = { id: "1", ...body };
|
||||
const table = tableList.find((item) => item.tableName === params.tableName);
|
||||
if (table) {
|
||||
table.isConfigured = 1;
|
||||
}
|
||||
return {
|
||||
code: "00000",
|
||||
data: null,
|
||||
msg: "保存成功",
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// 重置代码生成配置
|
||||
{
|
||||
url: "codegen/:tableName/config",
|
||||
method: ["DELETE"],
|
||||
body({ params }) {
|
||||
delete genConfigMap[params.tableName];
|
||||
const table = tableList.find((item) => item.tableName === params.tableName);
|
||||
if (table) {
|
||||
table.isConfigured = 0;
|
||||
}
|
||||
return {
|
||||
code: "00000",
|
||||
data: null,
|
||||
msg: "重置成功",
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// 获取代码生成预览数据
|
||||
{
|
||||
url: "codegen/:tableName/preview",
|
||||
method: ["GET"],
|
||||
body({ params }) {
|
||||
const table = tableList.find((item) => item.tableName === params.tableName);
|
||||
return {
|
||||
code: "00000",
|
||||
data: buildPreviewFiles(params.tableName, table?.tableComment || params.tableName),
|
||||
msg: "一切ok",
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// 下载代码生成 ZIP 文件
|
||||
{
|
||||
url: "codegen/:tableName/download",
|
||||
method: ["GET"],
|
||||
headers: {
|
||||
"Content-Disposition": "attachment; filename=codegen.zip",
|
||||
},
|
||||
},
|
||||
]);
|
||||
127
mock/config.mock.ts
Normal file
127
mock/config.mock.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { defineMock } from "./base";
|
||||
|
||||
/** 配置数据源(内存态,支持增删改) */
|
||||
const configList: Array<Record<string, any>> = [
|
||||
{
|
||||
id: "1",
|
||||
configName: "系统名称",
|
||||
configKey: "system.name",
|
||||
configValue: "vue3-element-admin",
|
||||
remark: "后台管理系统名称",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
configName: "系统Logo",
|
||||
configKey: "system.logo",
|
||||
configValue: "https://foruda.gitee.com/images/1723603502796844527/03cdca2a_716974.gif",
|
||||
remark: "后台管理系统 Logo 地址",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
configName: "系统版本",
|
||||
configKey: "system.version",
|
||||
configValue: "4.8.4",
|
||||
remark: "后台管理系统版本号",
|
||||
},
|
||||
];
|
||||
|
||||
export default defineMock([
|
||||
// 配置分页列表
|
||||
{
|
||||
url: "configs",
|
||||
method: ["GET"],
|
||||
body({ query }) {
|
||||
const { keywords, pageNum = "1", pageSize = "10" } = query;
|
||||
const filtered = configList.filter((item) => {
|
||||
if (keywords && !`${item.configName}${item.configKey}`.includes(keywords)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const page = Number(pageNum);
|
||||
const size = Number(pageSize);
|
||||
const start = (page - 1) * size;
|
||||
return {
|
||||
code: "00000",
|
||||
data: {
|
||||
list: filtered.slice(start, start + size),
|
||||
total: filtered.length,
|
||||
},
|
||||
msg: "一切ok",
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// 获取配置表单数据
|
||||
{
|
||||
url: "configs/:id/form",
|
||||
method: ["GET"],
|
||||
body({ params }) {
|
||||
const data = configList.find((item) => item.id === params.id);
|
||||
return {
|
||||
code: "00000",
|
||||
data: data ?? null,
|
||||
msg: "一切ok",
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// 新增配置
|
||||
{
|
||||
url: "configs",
|
||||
method: ["POST"],
|
||||
body({ body }) {
|
||||
configList.unshift({ id: String(configList.length + 1), ...body });
|
||||
return {
|
||||
code: "00000",
|
||||
data: null,
|
||||
msg: "新增成功",
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// 修改配置
|
||||
{
|
||||
url: "configs/:id",
|
||||
method: ["PUT"],
|
||||
body({ params, body }) {
|
||||
const index = configList.findIndex((item) => item.id === params.id);
|
||||
if (index !== -1) {
|
||||
configList[index] = { ...configList[index], ...body };
|
||||
}
|
||||
return {
|
||||
code: "00000",
|
||||
data: null,
|
||||
msg: "修改成功",
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// 删除配置
|
||||
{
|
||||
url: "configs/:id",
|
||||
method: ["DELETE"],
|
||||
body({ params }) {
|
||||
const index = configList.findIndex((item) => item.id === params.id);
|
||||
if (index !== -1) {
|
||||
configList.splice(index, 1);
|
||||
}
|
||||
return {
|
||||
code: "00000",
|
||||
data: null,
|
||||
msg: "删除成功",
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// 刷新配置缓存
|
||||
{
|
||||
url: "configs/refresh",
|
||||
method: ["PUT"],
|
||||
body: {
|
||||
code: "00000",
|
||||
data: null,
|
||||
msg: "刷新成功",
|
||||
},
|
||||
},
|
||||
]);
|
||||
33
mock/file.mock.ts
Normal file
33
mock/file.mock.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { defineMock } from "./base";
|
||||
|
||||
export default defineMock([
|
||||
// 上传文件
|
||||
{
|
||||
url: "files",
|
||||
method: ["POST"],
|
||||
body() {
|
||||
const name = `mock-upload-${Date.now()}.png`;
|
||||
return {
|
||||
code: "00000",
|
||||
data: {
|
||||
name,
|
||||
url: "https://foruda.gitee.com/images/1723603502796844527/03cdca2a_716974.gif",
|
||||
},
|
||||
msg: "上传成功",
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// 删除文件
|
||||
{
|
||||
url: "files",
|
||||
method: ["DELETE"],
|
||||
body({ query }) {
|
||||
return {
|
||||
code: "00000",
|
||||
data: null,
|
||||
msg: query.filePath ? `删除文件 ${query.filePath} 成功` : "删除成功",
|
||||
};
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -1474,7 +1474,7 @@ export default defineMock([
|
||||
type: "M",
|
||||
routeName: null,
|
||||
routePath: "dict-demo",
|
||||
component: "demo/dict",
|
||||
component: "demo/dictionary",
|
||||
sort: 4,
|
||||
visible: 1,
|
||||
icon: "",
|
||||
|
||||
74
package.json
74
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "vue3-element-admin",
|
||||
"description": "Vue3 + Vite + TypeScript + Element-Plus 的后台管理模板,vue-element-admin 的 Vue3 版本",
|
||||
"version": "4.8.1",
|
||||
"version": "4.8.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -48,80 +48,80 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"@wangeditor-next/editor": "^5.7.12",
|
||||
"@vueuse/core": "^14.4.0",
|
||||
"@wangeditor-next/editor": "^5.7.16",
|
||||
"@wangeditor-next/editor-for-vue": "^5.1.14",
|
||||
"apk-meta-parser": "^0.2.3",
|
||||
"jszip": "^3.10.1",
|
||||
"spark-md5": "^3.0.2",
|
||||
"animate.css": "^4.1.1",
|
||||
"axios": "^1.18.0",
|
||||
"axios": "^1.19.0",
|
||||
"codemirror": "^5.65.21",
|
||||
"codemirror-editor-vue3": "^2.8.0",
|
||||
"echarts": "^6.1.0",
|
||||
"element-plus": "^2.14.2",
|
||||
"element-plus": "^2.14.4",
|
||||
"exceljs": "^4.4.0",
|
||||
"lodash-es": "^4.18.1",
|
||||
"nprogress": "^0.2.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"path-to-regexp": "^8.4.2",
|
||||
"pinia": "^3.0.4",
|
||||
"pinia": "^4.0.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"qs": "^6.15.2",
|
||||
"qs": "^6.15.3",
|
||||
"sortablejs": "^1.15.7",
|
||||
"vue": "^3.5.38",
|
||||
"vue": "^3.5.41",
|
||||
"vue-draggable-plus": "^0.6.1",
|
||||
"vue-i18n": "^11.4.6",
|
||||
"vue-router": "^5.1.0",
|
||||
"vue-i18n": "^11.4.8",
|
||||
"vue-router": "^5.2.0",
|
||||
"vxe-table": "~4.6.25"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^20.5.3",
|
||||
"@commitlint/config-conventional": "^20.5.3",
|
||||
"@commitlint/cli": "^21.2.1",
|
||||
"@commitlint/config-conventional": "^21.2.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@iconify/utils": "^3.1.3",
|
||||
"@types/codemirror": "^5.60.17",
|
||||
"@iconify/utils": "^3.1.4",
|
||||
"@types/codemirror": "^5.60.18",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^26.0.0",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/path-browserify": "^1.0.3",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/qs": "^6.15.1",
|
||||
"@types/sortablejs": "^1.15.9",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"commitizen": "^4.3.2",
|
||||
"cz-git": "^1.13.1",
|
||||
"eslint": "^10.5.0",
|
||||
"cz-git": "^1.13.2",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-vue": "^10.9.2",
|
||||
"globals": "^17.6.0",
|
||||
"eslint-plugin-vue": "^10.10.0",
|
||||
"globals": "^17.9.0",
|
||||
"husky": "^9.1.7",
|
||||
"jiti": "^2.7.0",
|
||||
"lint-staged": "^17.0.8",
|
||||
"postcss": "^8.5.15",
|
||||
"postcss-html": "^1.8.1",
|
||||
"lint-staged": "^17.3.0",
|
||||
"postcss": "^8.5.26",
|
||||
"postcss-html": "^2.0.0",
|
||||
"postcss-scss": "^4.0.9",
|
||||
"prettier": "^3.8.4",
|
||||
"sass": "^1.101.0",
|
||||
"stylelint": "^17.13.0",
|
||||
"stylelint-config-html": "^1.1.0",
|
||||
"prettier": "^3.9.6",
|
||||
"sass": "^1.102.0",
|
||||
"stylelint": "^17.14.1",
|
||||
"stylelint-config-html": "^2.0.0",
|
||||
"stylelint-config-recess-order": "^7.7.0",
|
||||
"stylelint-config-recommended": "^18.0.0",
|
||||
"stylelint-config-recommended-scss": "^17.0.1",
|
||||
"stylelint-config-recommended-vue": "^1.6.1",
|
||||
"stylelint-config-recommended-vue": "^2.0.0",
|
||||
"stylelint-prettier": "^5.0.3",
|
||||
"terser": "^5.48.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.61.1",
|
||||
"unocss": "^66.7.2",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"terser": "^5.49.2",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.67.0",
|
||||
"unocss": "^66.7.5",
|
||||
"unplugin-auto-import": "^21.1.0",
|
||||
"unplugin-vue-components": "^32.1.0",
|
||||
"vite": "8.0.6",
|
||||
"vite-plugin-mock-dev-server": "^2.4.1",
|
||||
"vite": "^8.2.1",
|
||||
"vite-plugin-mock-dev-server": "^2.4.2",
|
||||
"vue-eslint-parser": "^10.4.1",
|
||||
"vue-tsc": "^3.3.5"
|
||||
"vue-tsc": "^3.3.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
|
||||
2810
pnpm-lock.yaml
generated
2810
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import type {
|
||||
DeviceQueryParams,
|
||||
DeviceItem,
|
||||
DeviceOnlineVO,
|
||||
DeviceHeartbeatVO,
|
||||
DeviceProfileDetail,
|
||||
DeviceProfileForm,
|
||||
DevicePasswordChangeForm,
|
||||
@@ -19,6 +20,8 @@ import type {
|
||||
ApkInstallInfo,
|
||||
DeviceLocationInfo,
|
||||
DeviceScreenshot,
|
||||
DevicePolicyInfo,
|
||||
DevicePolicyToggleForm,
|
||||
} from "./types";
|
||||
|
||||
import type { ExcelResult, PageResult, OptionItem } from "@/api/common";
|
||||
@@ -271,6 +274,18 @@ const DeviceAPI = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 屏幕镜像(通知设备开启屏幕采集,由家属端 client 进行 WebRTC 实时观看)
|
||||
* @param sn 设备SN号
|
||||
*/
|
||||
deviceMirror(sn: string) {
|
||||
return request({
|
||||
url: `${DEVICE_BASE_URL}/mirror`,
|
||||
method: "post",
|
||||
params: { sn },
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 设备重启
|
||||
* @param sn 设备SN号
|
||||
@@ -318,6 +333,18 @@ const DeviceAPI = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 解除设备手机号绑定(管理员端)
|
||||
* 对应后端 POST /{sn}/unbind (unbindMobile)
|
||||
* @param sn 设备SN号
|
||||
*/
|
||||
unbindDeviceMobile(sn: string) {
|
||||
return request({
|
||||
url: `${DEVICE_BASE_URL}/${sn}/unbind`,
|
||||
method: "post",
|
||||
});
|
||||
},
|
||||
|
||||
// ===================== 设备系统信息(分面板独立接口) =====================
|
||||
|
||||
/** 设备基本信息 */
|
||||
@@ -488,6 +515,19 @@ const DeviceAPI = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 查询设备最后心跳记录(来自 TDengine)
|
||||
*
|
||||
* @param sn 设备序列号
|
||||
*/
|
||||
getLastHeartbeat(sn: string) {
|
||||
return request<any, DeviceHeartbeatVO>({
|
||||
url: `${DEVICE_BASE_URL}/online/heartbeat/last`,
|
||||
method: "get",
|
||||
params: { sn },
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 上传应用图标(全局图标库)
|
||||
*
|
||||
@@ -509,6 +549,52 @@ const DeviceAPI = {
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
// ===================== 设备策略信息 =====================
|
||||
|
||||
/**
|
||||
* 获取设备策略信息
|
||||
* 对应后端 GET /{sn}/policy (getDevicePolicy)
|
||||
* SN 合法且无记录时自动新建默认策略(默认全开)
|
||||
*
|
||||
* @param sn 设备SN号
|
||||
*/
|
||||
getPolicy(sn: string) {
|
||||
return request<any, DevicePolicyInfo>({
|
||||
url: `${DEVICE_BASE_URL}/${sn}/policy`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 全量设置设备策略信息
|
||||
* 对应后端 PUT /{sn}/policy (updateDevicePolicy)
|
||||
*
|
||||
* @param sn 设备SN号
|
||||
* @param data 策略信息(空字段保持原值)
|
||||
*/
|
||||
updatePolicy(sn: string, data: DevicePolicyInfo) {
|
||||
return request<any, boolean>({
|
||||
url: `${DEVICE_BASE_URL}/${sn}/policy`,
|
||||
method: "put",
|
||||
data,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 单独设置设备策略开关
|
||||
* 对应后端 PUT /{sn}/policy/toggle (toggleDevicePolicy)
|
||||
*
|
||||
* @param sn 设备SN号
|
||||
* @param data 策略字段与开关值
|
||||
*/
|
||||
togglePolicy(sn: string, data: DevicePolicyToggleForm) {
|
||||
return request<any, boolean>({
|
||||
url: `${DEVICE_BASE_URL}/${sn}/policy/toggle`,
|
||||
method: "put",
|
||||
data,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default DeviceAPI;
|
||||
|
||||
@@ -34,38 +34,30 @@ export interface DeviceQueryParams extends BaseQueryParams {
|
||||
createTime?: [string, string];
|
||||
}
|
||||
|
||||
/** 用户分页对象 */
|
||||
/** 设备分页对象 */
|
||||
export interface DeviceItem {
|
||||
/** 用户ID */
|
||||
/** 设备ID */
|
||||
id: string;
|
||||
/** 用户头像地址 */
|
||||
avatar?: string;
|
||||
/** 创建时间 */
|
||||
createTime?: Date;
|
||||
/** 部门名称 */
|
||||
deptName?: string;
|
||||
/** 用户邮箱 */
|
||||
email?: string;
|
||||
/** 性别 */
|
||||
gender?: number;
|
||||
/** 手机号 */
|
||||
mobile?: string;
|
||||
/** 用户昵称 */
|
||||
nickname?: string;
|
||||
/** 角色名称,多个使用英文逗号(,)分割 */
|
||||
roleNames?: string;
|
||||
/** 用户状态(1:启用;0:禁用) */
|
||||
status?: number;
|
||||
/** 用户名 */
|
||||
Devicename?: string;
|
||||
/** 设备序列号 */
|
||||
serialno?: string;
|
||||
/** 设备型号 */
|
||||
snModel?: string;
|
||||
/** 设备名 */
|
||||
snName?: string;
|
||||
/** 设备绑定手机 */
|
||||
snMobile?: string;
|
||||
/** 推送ID */
|
||||
pushId?: string;
|
||||
/** 设备状态(1:启用;0:禁用) */
|
||||
status?: number;
|
||||
/** 在线状态(1:在线;0:离线) */
|
||||
online?: number;
|
||||
/** 最后心跳时间 */
|
||||
lastHeartbeatTime?: string;
|
||||
/** 推送ID */
|
||||
pushId?: string;
|
||||
/** 激活时间 */
|
||||
activateTime?: string;
|
||||
/** 创建时间 */
|
||||
createTime?: Date;
|
||||
}
|
||||
|
||||
/** 设备在线状态视图对象 */
|
||||
@@ -82,6 +74,22 @@ export interface DeviceOnlineVO {
|
||||
screenState?: number;
|
||||
}
|
||||
|
||||
/** 设备最后心跳视图对象(来自 TDengine) */
|
||||
export interface DeviceHeartbeatVO {
|
||||
/** 设备序列号 */
|
||||
sn?: string;
|
||||
/** 最后心跳时间 */
|
||||
lastTime?: string;
|
||||
/** 在线状态(1:在线;0:离线) */
|
||||
online?: number;
|
||||
/** 设备 IP */
|
||||
ip?: string;
|
||||
/** 信号强度 */
|
||||
rssi?: string;
|
||||
/** 屏幕是否亮起(1:亮屏;0:熄屏) */
|
||||
screenOn?: number;
|
||||
}
|
||||
|
||||
/** 设备表单对象 */
|
||||
export interface DeviceForm {
|
||||
/** 设备ID */
|
||||
@@ -95,7 +103,7 @@ export interface DeviceForm {
|
||||
/** 设备手机号 */
|
||||
snMobile?: string;
|
||||
/** 设备在线状态(1:在线;0:离线) */
|
||||
onlineStatus?: number;
|
||||
online?: number;
|
||||
/** 设备IMEI */
|
||||
snImei?: string;
|
||||
/** 所属部门 */
|
||||
@@ -337,6 +345,7 @@ export interface DeviceOtherInfo {
|
||||
packageName?: string;
|
||||
channel?: string;
|
||||
deviceId?: string;
|
||||
pushId?: string;
|
||||
gaid?: string;
|
||||
oaid?: string;
|
||||
phoneNumber?: string;
|
||||
@@ -441,3 +450,59 @@ export interface DeviceScreenshot {
|
||||
/** 上传时间 */
|
||||
uploadTime?: string;
|
||||
}
|
||||
|
||||
// ===================== 设备策略信息 =====================
|
||||
|
||||
/** 设备策略信息 */
|
||||
export interface DevicePolicyInfo {
|
||||
/** 策略ID */
|
||||
id?: number;
|
||||
/** 设备序列号 */
|
||||
serialno?: string;
|
||||
/** USB偏好设置(0关闭 1开启) */
|
||||
usbData?: number;
|
||||
/** 时间设置管控(0关闭 1开启) */
|
||||
timeSetting?: number;
|
||||
/** 存储卡(0关闭 1开启) */
|
||||
storageCard?: number;
|
||||
/** 恢复出厂(0关闭 1开启) */
|
||||
factoryReset?: number;
|
||||
/** WiFi热点开关(0关闭 1开启) */
|
||||
wifiHotspot?: number;
|
||||
/** 蓝牙开关(0关闭 1开启) */
|
||||
bluetoothSwitch?: number;
|
||||
/** 壁纸功能(0关闭 1开启) */
|
||||
wallpaper?: number;
|
||||
/** 通知栏显示(0关闭 1开启) */
|
||||
notificationBar?: number;
|
||||
/** 状态栏下拉开关(0关闭 1开启) */
|
||||
statusBarPullDown?: number;
|
||||
/** 系统导航条显示(0关闭 1开启) */
|
||||
systemNavBarShow?: number;
|
||||
/** 系统导航栏设置(0关闭 1开启) */
|
||||
systemNavBarSetting?: number;
|
||||
/** 导航条选项(0关闭 1开启) */
|
||||
navBarOptions?: number;
|
||||
/** 蓝牙功能开关(0关闭 1开启) */
|
||||
bluetoothFunction?: number;
|
||||
/** OTA升级管控(0关闭 1开启) */
|
||||
otaUpgrade?: number;
|
||||
/** 应用安装开关(0关闭 1开启) */
|
||||
appInstall?: number;
|
||||
/** 自动旋转(0关闭 1开启) */
|
||||
autoRotate?: number;
|
||||
/** 自动亮度(0关闭 1开启) */
|
||||
autoBrightness?: number;
|
||||
/** 护眼模式(0关闭 1开启) */
|
||||
eyeProtection?: number;
|
||||
/** 深色模式(0关闭 1开启) */
|
||||
darkMode?: number;
|
||||
}
|
||||
|
||||
/** 设备策略单独设置请求 */
|
||||
export interface DevicePolicyToggleForm {
|
||||
/** 策略字段名(如 usbData、appInstall) */
|
||||
field: string;
|
||||
/** 开关值(0关闭 1开启) */
|
||||
value: number;
|
||||
}
|
||||
|
||||
@@ -14,15 +14,28 @@
|
||||
<el-tag :type="deviceData.onlineStatus == 1 ? 'success' : 'danger'" size="small">
|
||||
{{ deviceData.onlineStatus == 1 ? "在线" : "离线" }}
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-if="deviceData.onlineStatus == 1"
|
||||
:type="deviceData.screenState == 1 ? 'warning' : 'info'"
|
||||
size="small"
|
||||
>
|
||||
{{
|
||||
deviceData.screenState == 1
|
||||
? "亮屏"
|
||||
: deviceData.screenState === 0
|
||||
? "熄屏"
|
||||
: "未知"
|
||||
}}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="label">序列号:</span>
|
||||
<span class="value">{{ props.serialno }}</span>
|
||||
<span class="value">{{ formatText(props.serialno) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">IMEI:</span>
|
||||
<span class="value">{{ formatText(deviceData.imei) }}</span>
|
||||
<span class="value">{{ formatText(deviceData.imei || networkInfo.mobileImei) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">设备所有者:</span>
|
||||
@@ -50,11 +63,11 @@
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">最后上线:</span>
|
||||
<span class="value">{{ formatText(deviceData.lastOnline) }}</span>
|
||||
<span class="value">{{ formatDate(deviceData.lastOnline) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">设备编号:</span>
|
||||
<span class="value">{{ formatText(deviceData.deviceSn) }}</span>
|
||||
<span class="value">{{ formatText() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -491,8 +504,8 @@
|
||||
<el-descriptions-item label="渠道">
|
||||
{{ formatText(otherInfo.channel) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="设备 ID">
|
||||
{{ formatText(otherInfo.deviceId) }}
|
||||
<el-descriptions-item label="极光推送ID">
|
||||
{{ formatText(otherInfo.pushId) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="GAID">
|
||||
{{ formatText(otherInfo.gaid) }}
|
||||
@@ -506,9 +519,6 @@
|
||||
<el-descriptions-item label="ICCID">
|
||||
{{ formatText(otherInfo.iccid) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="IMSI">
|
||||
{{ formatText(otherInfo.imsi) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="充电中">
|
||||
{{ formatBool(otherInfo.isCharging) }}
|
||||
</el-descriptions-item>
|
||||
@@ -725,7 +735,43 @@
|
||||
|
||||
<el-tab-pane label="策略信息" name="policyInfo">
|
||||
<div class="tab-content">
|
||||
<el-empty description="策略信息加载中..." />
|
||||
<div class="policy-toolbar">
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
size="small"
|
||||
:loading="policyLoading"
|
||||
:icon="Refresh"
|
||||
@click="loadPolicyInfo(props.serialno)"
|
||||
>
|
||||
刷新
|
||||
</el-button>
|
||||
<div class="policy-toolbar__right">
|
||||
<el-button
|
||||
type="success"
|
||||
size="small"
|
||||
:loading="policySaving"
|
||||
:icon="Select"
|
||||
@click="handleSavePolicy"
|
||||
>
|
||||
保存
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-loading="policyLoading" class="policy-grid">
|
||||
<div v-for="item in policyItems" :key="item.field" class="policy-item">
|
||||
<div class="policy-item__label">{{ item.label }}</div>
|
||||
<el-switch
|
||||
v-model="policyState[item.field]"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
inline-prompt
|
||||
active-text="开"
|
||||
inactive-text="关"
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
@@ -756,6 +802,7 @@ import type {
|
||||
DeviceSecurityInfo,
|
||||
DeviceLocationInfo,
|
||||
DeviceScreenshot,
|
||||
DevicePolicyInfo,
|
||||
} from "@/api/system/device/types";
|
||||
import { nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useSse } from "@/composables";
|
||||
@@ -772,6 +819,7 @@ import {
|
||||
VideoPlay,
|
||||
Brush,
|
||||
SwitchButton,
|
||||
Select,
|
||||
} from "@element-plus/icons-vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
|
||||
@@ -815,6 +863,7 @@ const activeTab = ref("deviceInfo");
|
||||
const deviceData = reactive({
|
||||
snName: "",
|
||||
onlineStatus: 0,
|
||||
screenState: null as number | null,
|
||||
owner: "",
|
||||
department: "",
|
||||
group: "",
|
||||
@@ -878,6 +927,36 @@ const apkList = ref<ApkInstallInfo[]>([]);
|
||||
/** 应用信息是否已加载(懒加载,仅首次进入 tab 时拉取) */
|
||||
const apkLoaded = ref(false);
|
||||
|
||||
/** 设备策略信息(开关值 0/1) */
|
||||
const policyState = reactive<Record<string, number>>({});
|
||||
/** 策略信息加载中 */
|
||||
const policyLoading = ref(false);
|
||||
/** 策略信息保存中 */
|
||||
const policySaving = ref(false);
|
||||
|
||||
/** 策略开关项配置(label 展示名,field 对应后端字段名) */
|
||||
const policyItems: { label: string; field: string }[] = [
|
||||
{ label: "USB偏好设置", field: "usbData" },
|
||||
{ label: "时间设置管控", field: "timeSetting" },
|
||||
{ label: "存储卡", field: "storageCard" },
|
||||
{ label: "恢复出厂", field: "factoryReset" },
|
||||
{ label: "WiFi热点开关", field: "wifiHotspot" },
|
||||
{ label: "蓝牙开关", field: "bluetoothSwitch" },
|
||||
{ label: "壁纸功能", field: "wallpaper" },
|
||||
{ label: "通知栏显示", field: "notificationBar" },
|
||||
{ label: "状态栏下拉开关", field: "statusBarPullDown" },
|
||||
{ label: "系统导航条显示", field: "systemNavBarShow" },
|
||||
{ label: "系统导航栏设置", field: "systemNavBarSetting" },
|
||||
{ label: "导航条选项", field: "navBarOptions" },
|
||||
{ label: "蓝牙功能开关", field: "bluetoothFunction" },
|
||||
{ label: "OTA升级管控", field: "otaUpgrade" },
|
||||
{ label: "应用安装开关", field: "appInstall" },
|
||||
{ label: "自动旋转", field: "autoRotate" },
|
||||
{ label: "自动亮度", field: "autoBrightness" },
|
||||
{ label: "护眼模式", field: "eyeProtection" },
|
||||
{ label: "深色模式", field: "darkMode" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 拼接应用图标完整访问地址
|
||||
*
|
||||
@@ -944,6 +1023,9 @@ function assignTo<T extends object>(reactiveObj: T, data?: object) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 首次进入时是否已加载过最后心跳时间 */
|
||||
let lastHeartbeatLoaded = false;
|
||||
|
||||
/** 加载设备头部信息 */
|
||||
async function loadDeviceDetail(serialno: string) {
|
||||
try {
|
||||
@@ -951,7 +1033,7 @@ async function loadDeviceDetail(serialno: string) {
|
||||
if (response) {
|
||||
Object.assign(deviceData, {
|
||||
snName: response.snName,
|
||||
onlineStatus: response.onlineStatus,
|
||||
onlineStatus: response.online,
|
||||
deviceSn: response.serialno || serialno,
|
||||
imei: response.snImei,
|
||||
department: response.department,
|
||||
@@ -968,20 +1050,38 @@ async function loadDeviceDetail(serialno: string) {
|
||||
}
|
||||
// 加载完设备基础信息后,单独查询设备在线状态
|
||||
loadOnlineStatus(serialno);
|
||||
// 首次进入时从接口获取最后心跳时间作为最后上线时间
|
||||
if (!lastHeartbeatLoaded) {
|
||||
loadLastHeartbeat(serialno);
|
||||
lastHeartbeatLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** 查询单台设备在线状态 */
|
||||
/** 查询单台设备在线状态(仅更新在线状态和屏幕状态,不更新最后上线时间) */
|
||||
async function loadOnlineStatus(serialno: string) {
|
||||
try {
|
||||
const onlineInfo = await DeviceAPI.getOnlineStatus(serialno);
|
||||
if (onlineInfo) {
|
||||
deviceData.onlineStatus = onlineInfo.online ?? 0;
|
||||
deviceData.screenState = onlineInfo.screenState ?? null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("查询设备在线状态失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/** 查询设备最后心跳时间(仅首次进入时调用,作为最后上线时间) */
|
||||
async function loadLastHeartbeat(serialno: string) {
|
||||
try {
|
||||
const heartbeat = await DeviceAPI.getLastHeartbeat(serialno);
|
||||
if (heartbeat?.lastTime) {
|
||||
deviceData.lastOnline = heartbeat.lastTime;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("查询设备最后心跳时间失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/** 基本信息 */
|
||||
async function loadBasicInfo(serialno: string) {
|
||||
loading.basic = true;
|
||||
@@ -1014,6 +1114,10 @@ async function loadNetworkInfo(serialno: string) {
|
||||
try {
|
||||
const data = await DeviceAPI.getNetworkInfo(serialno);
|
||||
assignTo(networkInfo, data);
|
||||
// 同步 IMEI 到头部信息
|
||||
if (data?.mobileImei) {
|
||||
deviceData.imei = data.mobileImei;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("加载网络信息失败", e);
|
||||
} finally {
|
||||
@@ -1147,6 +1251,7 @@ async function loadAll(serialno?: string) {
|
||||
loadOtherInfo(serialno);
|
||||
loadLocationInfo(serialno);
|
||||
loadScreenshots(serialno);
|
||||
loadPolicyInfo(serialno);
|
||||
}
|
||||
|
||||
/** 设备截图列表 */
|
||||
@@ -1259,7 +1364,46 @@ async function loadApkInfo(serialno: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换标签页懒加载应用信息 / 定位信息(仅首次进入时拉取) */
|
||||
/** 策略信息 */
|
||||
async function loadPolicyInfo(serialno: string) {
|
||||
if (!serialno) return;
|
||||
policyLoading.value = true;
|
||||
try {
|
||||
const data = (await DeviceAPI.getPolicy(serialno)) as DevicePolicyInfo | null;
|
||||
// 初始化/刷新所有开关值
|
||||
policyItems.forEach((item) => {
|
||||
const record = data as Record<string, number | undefined> | null;
|
||||
policyState[item.field] = record?.[item.field] ?? 1;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("加载策略信息失败", e);
|
||||
ElMessage.error("加载策略信息失败");
|
||||
} finally {
|
||||
policyLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 全量保存策略信息 */
|
||||
async function handleSavePolicy() {
|
||||
if (!props.serialno) return;
|
||||
policySaving.value = true;
|
||||
try {
|
||||
const data: DevicePolicyInfo = { serialno: props.serialno };
|
||||
const record = data as Record<string, number | undefined>;
|
||||
policyItems.forEach((item) => {
|
||||
record[item.field] = policyState[item.field];
|
||||
});
|
||||
await DeviceAPI.updatePolicy(props.serialno, data);
|
||||
ElMessage.success("策略信息保存成功");
|
||||
} catch (e) {
|
||||
console.error("保存策略信息失败", e);
|
||||
ElMessage.error("保存策略信息失败");
|
||||
} finally {
|
||||
policySaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换标签页懒加载应用信息 / 定位信息 / 策略信息 */
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === "appInfo" && props.serialno && !apkLoaded.value) {
|
||||
loadApkInfo(props.serialno);
|
||||
@@ -1267,6 +1411,9 @@ watch(activeTab, (tab) => {
|
||||
if (tab === "locationInfo" && props.serialno) {
|
||||
loadLocationInfo(props.serialno);
|
||||
}
|
||||
if (tab === "policyInfo" && props.serialno) {
|
||||
loadPolicyInfo(props.serialno);
|
||||
}
|
||||
});
|
||||
|
||||
/** 打开应用 */
|
||||
@@ -1354,6 +1501,10 @@ function handleDeviceOnlineStatus(data: DeviceOnlineVO) {
|
||||
// 仅当推送的设备 SN 与当前详情页设备匹配时才更新
|
||||
if (data.serialno === props.serialno) {
|
||||
deviceData.onlineStatus = data.online ?? 0;
|
||||
deviceData.screenState = data.screenState ?? null;
|
||||
if (data.lastHeartbeatTime) {
|
||||
deviceData.lastOnline = data.lastHeartbeatTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1780,6 +1931,45 @@ const handleMoreActions = (command: string) => {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.policy-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&__right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.policy-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
|
||||
.policy-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
background: #f5f7fa;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #c0c4cc;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.apk-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="在线状态">
|
||||
<el-tag :type="detailData.onlineStatus === 1 ? 'success' : 'info'" effect="dark" round>
|
||||
{{ detailData.onlineStatus === 1 ? "在线" : "离线" }}
|
||||
<el-tag :type="detailData.online === 1 ? 'success' : 'info'" effect="dark" round>
|
||||
{{ detailData.online === 1 ? "在线" : "离线" }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<template v-if="tagType">
|
||||
<el-tag :type="tagType" :size="tagSize">{{ label }}</el-tag>
|
||||
</template>
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import type { NoticeDetail, NoticeItem, NoticeQueryParams } from "@/api/system/notice";
|
||||
import NoticeAPI from "@/api/system/notice";
|
||||
import { useSse } from "@/composables";
|
||||
import { useSse, SseTopics } from "@/composables";
|
||||
import router from "@/router";
|
||||
|
||||
/** 下拉面板每页展示条数 */
|
||||
const PAGE_SIZE = 5;
|
||||
const NOTICE_EVENT = "notice";
|
||||
const NOTICE_REVOKE_EVENT = "notice-revoke";
|
||||
|
||||
/** 通知读取状态:0=未读,1=已读 */
|
||||
type NoticeStatus = 0 | 1;
|
||||
|
||||
/** SSE 推送的新通知消息体 */
|
||||
interface NoticeMessage {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -17,22 +18,38 @@ interface NoticeMessage {
|
||||
publishTime?: Date;
|
||||
}
|
||||
|
||||
/** SSE 推送的通知撤回消息体 */
|
||||
interface NoticeRevokeMessage {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知下拉面板的响应式数据与业务逻辑
|
||||
* 在组件挂载时拉取列表、建立 SSE 订阅,卸载时自动清理
|
||||
*/
|
||||
export function useNotice() {
|
||||
const { on } = useSse();
|
||||
|
||||
/** 当前 Tab 下的通知列表(最多 PAGE_SIZE 条) */
|
||||
const list = ref<NoticeItem[]>([]);
|
||||
/** 未读通知总数(红点/角标数字) */
|
||||
const unreadTotal = ref(0);
|
||||
/** 当前激活的 Tab:0=未读,1=已读 */
|
||||
const activeStatus = ref<NoticeStatus>(0);
|
||||
/** 查看详情时加载的完整通知数据 */
|
||||
const detail = ref<NoticeDetail | null>(null);
|
||||
/** 详情弹窗可见性 */
|
||||
const dialogVisible = ref(false);
|
||||
/** 列表为空时的占位文案,根据当前 Tab 切换 */
|
||||
const emptyText = computed(() => (activeStatus.value === 0 ? "暂无未读消息" : "暂无已读消息"));
|
||||
|
||||
/** SSE 订阅的取消函数集合,用于组件卸载时解绑 */
|
||||
let stopSubscriptions: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* 拉取通知分页列表
|
||||
* 查询未读 Tab 时同步更新 unreadTotal
|
||||
*/
|
||||
async function fetchList(params?: Partial<NoticeQueryParams>) {
|
||||
const query: NoticeQueryParams = {
|
||||
pageNum: 1,
|
||||
@@ -48,6 +65,7 @@ export function useNotice() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 仅查询未读通知总数(不更新列表),用于切换到已读 Tab 后刷新角标 */
|
||||
async function fetchUnreadTotal() {
|
||||
const page = await NoticeAPI.getMyNoticePage({
|
||||
pageNum: 1,
|
||||
@@ -57,6 +75,10 @@ export function useNotice() {
|
||||
unreadTotal.value = page.total ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换未读/已读 Tab
|
||||
* 同一 Tab 重复点击不重复请求
|
||||
*/
|
||||
async function switchStatus(status: NoticeStatus) {
|
||||
if (activeStatus.value === status) return;
|
||||
|
||||
@@ -64,6 +86,10 @@ export function useNotice() {
|
||||
await fetchList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新数据
|
||||
* 未读 Tab:刷新列表即可;已读 Tab:额外刷新未读总数以更新角标
|
||||
*/
|
||||
async function refresh() {
|
||||
await Promise.all([
|
||||
fetchList(),
|
||||
@@ -71,6 +97,14 @@ export function useNotice() {
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击单条通知查看详情
|
||||
* 1. 标记原列表项是否为未读
|
||||
* 2. 拉取详情并打开弹窗
|
||||
* 3. 从当前列表中移除该项(下拉面板内不再显示)
|
||||
* 4. 若为未读,本地角标 -1
|
||||
* 5. 刷新数据与角标
|
||||
*/
|
||||
async function read(id: string) {
|
||||
const item = list.value.find((notice: NoticeItem) => notice.id === id);
|
||||
const wasUnread = item?.isRead !== 1;
|
||||
@@ -85,6 +119,7 @@ export function useNotice() {
|
||||
await refresh();
|
||||
}
|
||||
|
||||
/** 全部标为已读:调用接口 + 清空本地未读数 + 刷新列表 */
|
||||
async function readAll() {
|
||||
if (unreadTotal.value <= 0) return;
|
||||
|
||||
@@ -98,19 +133,28 @@ export function useNotice() {
|
||||
ElMessage.success("已全部标记为已读");
|
||||
}
|
||||
|
||||
/** 跳转到通知列表页 */
|
||||
function goMore() {
|
||||
router.push({ name: "MyNotice" });
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立 SSE 实时推送订阅
|
||||
* - NOTICE 事件:新通知到达时插入列表头部、更新角标、弹出浏览器通知
|
||||
* - NOTICE_REVOKE 事件:通知被撤回时从列表中移除并更新角标
|
||||
* 重复调用会跳过,避免多次挂载时重复订阅
|
||||
*/
|
||||
function setupSubscription() {
|
||||
if (stopSubscriptions) return;
|
||||
|
||||
const stopNotice = on<NoticeMessage>(NOTICE_EVENT, (data) => {
|
||||
const stopNotice = on<NoticeMessage>(SseTopics.NOTICE, (data) => {
|
||||
try {
|
||||
if (!data.id) return;
|
||||
|
||||
unreadTotal.value += 1;
|
||||
// 当前在已读 Tab 时不操作列表
|
||||
if (activeStatus.value !== 0) return;
|
||||
// 已存在则跳过(防重)
|
||||
if (list.value.some((item: NoticeItem) => item.id === data.id)) return;
|
||||
|
||||
list.value.unshift({
|
||||
@@ -124,6 +168,7 @@ export function useNotice() {
|
||||
isRead: 0,
|
||||
});
|
||||
|
||||
// 超出 PAGE_SIZE 时截断尾部
|
||||
if (list.value.length > PAGE_SIZE) {
|
||||
list.value.length = PAGE_SIZE;
|
||||
}
|
||||
@@ -139,7 +184,7 @@ export function useNotice() {
|
||||
}
|
||||
});
|
||||
|
||||
const stopRevoke = on<NoticeRevokeMessage>(NOTICE_REVOKE_EVENT, (data) => {
|
||||
const stopRevoke = on<NoticeRevokeMessage>(SseTopics.NOTICE_REVOKE, (data) => {
|
||||
try {
|
||||
if (!data.id) return;
|
||||
|
||||
|
||||
@@ -16,49 +16,39 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
total: {
|
||||
type: Number as PropType<number>,
|
||||
default: 0,
|
||||
},
|
||||
pageSizes: {
|
||||
type: Array as PropType<number[]>,
|
||||
default() {
|
||||
return [10, 20, 30, 50];
|
||||
},
|
||||
},
|
||||
layout: {
|
||||
type: String,
|
||||
default: "total, sizes, prev, pager, next, jumper",
|
||||
},
|
||||
background: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
autoScroll: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
hidden: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
interface Props {
|
||||
/** 数据总条数 */
|
||||
total: number;
|
||||
/** 每页条数选项,默认 [10, 20, 50, 100] */
|
||||
pageSizes?: number[];
|
||||
/** 分页布局,控制显示哪些子组件及其顺序 */
|
||||
layout?: string;
|
||||
/** 是否为分页按钮添加背景色 */
|
||||
background?: boolean;
|
||||
/** 是否隐藏整个分页条 */
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
total: 0,
|
||||
pageSizes: () => [10, 20, 50, 100],
|
||||
layout: "total, sizes, prev, pager, next, jumper",
|
||||
background: true,
|
||||
hidden: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits(["pagination"]);
|
||||
const emit = defineEmits<{
|
||||
/** 页码或每页条数变化时触发,回调当前页码与每页条数 */
|
||||
pagination: [query: { page: number; limit: number }];
|
||||
}>();
|
||||
|
||||
const currentPage = defineModel("page", {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 1,
|
||||
});
|
||||
// 当前页码,双向绑定到父组件的 page
|
||||
const currentPage = defineModel<number>("page", { default: 1 });
|
||||
|
||||
const pageSize = defineModel("limit", {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 10,
|
||||
});
|
||||
// 每页条数,双向绑定到父组件的 limit
|
||||
const pageSize = defineModel<number>("limit", { default: 10 });
|
||||
|
||||
// 数据总量变化后,若当前页超出最后一页,则回退到最后一页
|
||||
watch(
|
||||
() => props.total,
|
||||
(newVal: number) => {
|
||||
@@ -70,11 +60,13 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
// 切换每页条数时回到第一页,并触发分页请求
|
||||
function handleSizeChange(val: number) {
|
||||
currentPage.value = 1;
|
||||
emit("pagination", { page: currentPage.value, limit: val });
|
||||
}
|
||||
|
||||
// 切换页码时触发分页请求
|
||||
function handleCurrentChange(val: number) {
|
||||
emit("pagination", { page: val, limit: pageSize.value });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div ref="tableSelectRef" :style="'width:' + width">
|
||||
<el-popover
|
||||
:visible="popoverVisible"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!--
|
||||
<!--
|
||||
TextScroll 组件 - 文本滚动公告
|
||||
|
||||
功能:
|
||||
|
||||
@@ -114,11 +114,7 @@ const props = defineProps({
|
||||
},
|
||||
},
|
||||
});
|
||||
const modelValue = defineModel("modelValue", {
|
||||
type: [Array] as PropType<FileInfo[]>,
|
||||
required: true,
|
||||
default: () => [],
|
||||
});
|
||||
const modelValue = defineModel<FileInfo[]>({ required: true, default: () => [] });
|
||||
|
||||
const fileList = ref([] as UploadFile[]);
|
||||
|
||||
|
||||
@@ -86,10 +86,7 @@ const props = defineProps({
|
||||
const previewVisible = ref(false); // 是否显示预览
|
||||
const previewImageIndex = ref(0); // 预览图片的索引
|
||||
|
||||
const modelValue = defineModel("modelValue", {
|
||||
type: [Array] as PropType<string[]>,
|
||||
default: () => [],
|
||||
});
|
||||
const modelValue = defineModel<string[]>({ default: () => [] });
|
||||
|
||||
const fileList = ref<UploadUserFile[]>([]);
|
||||
|
||||
|
||||
@@ -83,10 +83,7 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const modelValue = defineModel("modelValue", {
|
||||
type: String,
|
||||
default: () => "",
|
||||
});
|
||||
const modelValue = defineModel<string>({ default: "" });
|
||||
|
||||
/**
|
||||
* 限制用户上传文件的格式和大小
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
// SSE 服务
|
||||
export { setupSse, cleanupSseServices } from "./sse";
|
||||
export { useSse, useDictSync, useOnlineCount, cleanupSse, SseConnectionState } from "./sse";
|
||||
export type { DictMessage, DictChangeMessage, DictChangeCallback } from "./sse";
|
||||
export {
|
||||
useSse,
|
||||
useDictSync,
|
||||
useOnlineUsers,
|
||||
cleanupSse,
|
||||
SseConnectionState,
|
||||
SseTopics,
|
||||
} from "./sse";
|
||||
export type { DictChangeMessage, DictChangeCallback, SseTopic } from "./sse";
|
||||
|
||||
// 表格相关
|
||||
export { useTableSelection } from "./useTableSelection";
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { useDictSync } from "./useDictSync";
|
||||
import { useOnlineCount } from "./useOnlineCount";
|
||||
import { cleanupSse } from "./useSse";
|
||||
import { useOnlineUsers } from "./useOnlineUsers";
|
||||
import { useSse, cleanupSse } from "./useSse";
|
||||
|
||||
/**
|
||||
* 初始化所有 SSE 服务
|
||||
*/
|
||||
export function setupSse() {
|
||||
const sse = useSse();
|
||||
sse.connect();
|
||||
|
||||
const dictSync = useDictSync();
|
||||
dictSync.initialize();
|
||||
|
||||
const onlineCount = useOnlineCount();
|
||||
onlineCount.initialize();
|
||||
const onlineUsers = useOnlineUsers();
|
||||
onlineUsers.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,13 +23,15 @@ export function cleanupSseServices() {
|
||||
const dictSync = useDictSync();
|
||||
dictSync.cleanup();
|
||||
|
||||
const onlineCount = useOnlineCount();
|
||||
onlineCount.cleanup();
|
||||
const onlineUsers = useOnlineUsers();
|
||||
onlineUsers.cleanup();
|
||||
|
||||
cleanupSse();
|
||||
}
|
||||
|
||||
export { useDictSync } from "./useDictSync";
|
||||
export { useOnlineCount } from "./useOnlineCount";
|
||||
export { useOnlineUsers } from "./useOnlineUsers";
|
||||
export { useSse, cleanupSse, SseConnectionState } from "./useSse";
|
||||
export type { DictMessage, DictChangeMessage, DictChangeCallback } from "./useDictSync";
|
||||
export { SseTopics } from "./sseTopics";
|
||||
export type { DictChangeMessage, DictChangeCallback } from "./useDictSync";
|
||||
export type { SseTopic } from "./sseTopics";
|
||||
|
||||
17
src/composables/sse/sseTopics.ts
Normal file
17
src/composables/sse/sseTopics.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/** SSE 事件名常量,与后端 SseTopics.java 一一对应 */
|
||||
export const SseTopics = {
|
||||
/** 字典变更事件 */
|
||||
DICT: "dict",
|
||||
/** 在线用户数事件 */
|
||||
ONLINE_USERS: "online-users",
|
||||
/** 系统消息事件 */
|
||||
SYSTEM: "system",
|
||||
/** 心跳事件 */
|
||||
PING: "ping",
|
||||
/** 通知事件 */
|
||||
NOTICE: "notice",
|
||||
/** 通知撤回事件 */
|
||||
NOTICE_REVOKE: "notice-revoke",
|
||||
} as const;
|
||||
|
||||
export type SseTopic = (typeof SseTopics)[keyof typeof SseTopics];
|
||||
@@ -1,65 +1,65 @@
|
||||
import { useDictStoreHook } from "@/stores/dict";
|
||||
import { useSse } from "./useSse";
|
||||
import { SseTopics } from "./sseTopics";
|
||||
|
||||
/** 字典变更消息体 */
|
||||
export interface DictChangeMessage {
|
||||
/** 字典编码 */
|
||||
dictCode: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export type DictMessage = DictChangeMessage;
|
||||
|
||||
/** 字典变更回调函数类型 */
|
||||
export type DictChangeCallback = (message: DictChangeMessage) => void;
|
||||
|
||||
let singletonInstance: ReturnType<typeof createDictSyncComposable> | null = null;
|
||||
let globalInstance: ReturnType<typeof createDictSyncComposable> | null = null;
|
||||
|
||||
function createDictSyncComposable() {
|
||||
const dictStore = useDictStoreHook();
|
||||
const sse = useSse();
|
||||
|
||||
const messageCallbacks = ref<DictChangeCallback[]>([]);
|
||||
|
||||
const callbacks: DictChangeCallback[] = [];
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let initialized = false; // 防止重复初始化导致重复订阅
|
||||
|
||||
const handleDictChangeMessage = (data: DictChangeMessage) => {
|
||||
// 处理字典变更消息:清除指定字典缓存,并通知所有已注册回调
|
||||
const handleDictChange = (data: DictChangeMessage) => {
|
||||
const { dictCode } = data;
|
||||
|
||||
if (!dictCode) {
|
||||
console.warn("[DictSync] 收到无效的字典变更消息:缺少 dictCode");
|
||||
return;
|
||||
}
|
||||
|
||||
dictStore.removeDictItem(dictCode);
|
||||
|
||||
messageCallbacks.value.forEach((callback) => {
|
||||
callbacks.forEach((cb) => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
console.error("[DictSync] 回调函数执行失败:", error);
|
||||
cb(data);
|
||||
} catch (err) {
|
||||
console.error("[DictSync] 回调执行失败:", err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 订阅 SSE 字典变更事件(幂等:重复调用不会产生重复订阅)
|
||||
const initialize = () => {
|
||||
sse.connect();
|
||||
unsubscribe = sse.on("dict", handleDictChangeMessage);
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
unsubscribe = sse.on(SseTopics.DICT, handleDictChange);
|
||||
};
|
||||
|
||||
// 取消 SSE 订阅并清空所有回调
|
||||
const cleanup = () => {
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
unsubscribe = null;
|
||||
}
|
||||
messageCallbacks.value = [];
|
||||
initialized = false;
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
callbacks.length = 0;
|
||||
};
|
||||
|
||||
const onDictChange = (callback: DictChangeCallback) => {
|
||||
messageCallbacks.value.push(callback);
|
||||
|
||||
// 注册字典变更回调,返回取消注册函数
|
||||
const onDictChange = (cb: DictChangeCallback) => {
|
||||
callbacks.push(cb);
|
||||
return () => {
|
||||
const index = messageCallbacks.value.indexOf(callback);
|
||||
if (index !== -1) {
|
||||
messageCallbacks.value.splice(index, 1);
|
||||
}
|
||||
const idx = callbacks.indexOf(cb);
|
||||
if (idx !== -1) callbacks.splice(idx, 1);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -74,10 +74,15 @@ function createDictSyncComposable() {
|
||||
|
||||
/**
|
||||
* 字典同步组合式函数(单例模式)
|
||||
*
|
||||
* 监听 SSE 字典变更事件,收到变更时自动清除对应字典缓存,
|
||||
* 并通知所有已注册的回调函数。
|
||||
*
|
||||
* @returns 字典同步实例,包含连接状态、初始化、清理和回调注册方法
|
||||
*/
|
||||
export function useDictSync() {
|
||||
if (!singletonInstance) {
|
||||
singletonInstance = createDictSyncComposable();
|
||||
if (!globalInstance) {
|
||||
globalInstance = createDictSyncComposable();
|
||||
}
|
||||
return singletonInstance;
|
||||
return globalInstance;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ref, onMounted, getCurrentInstance } from "vue";
|
||||
import { ref, readonly } from "vue";
|
||||
import { useSse } from "./useSse";
|
||||
import { SseTopics } from "./sseTopics";
|
||||
|
||||
let globalInstance: ReturnType<typeof createOnlineCountComposable> | null = null;
|
||||
let globalInstance: ReturnType<typeof createOnlineUsersComposable> | null = null;
|
||||
|
||||
function createOnlineCountComposable() {
|
||||
function createOnlineUsersComposable() {
|
||||
const onlineUserCount = ref(0);
|
||||
const lastUpdateTime = ref(0);
|
||||
|
||||
@@ -11,16 +12,14 @@ function createOnlineCountComposable() {
|
||||
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
// 处理在线人数变更消息
|
||||
const handleOnlineCountMessage = (count: number) => {
|
||||
const handleOnlineUsersMessage = (count: number) => {
|
||||
if (!Number.isFinite(count) || count < 0) return;
|
||||
onlineUserCount.value = count;
|
||||
lastUpdateTime.value = Date.now();
|
||||
};
|
||||
|
||||
const initialize = () => {
|
||||
sse.connect();
|
||||
unsubscribe = sse.on("online-count", handleOnlineCountMessage);
|
||||
unsubscribe = sse.on(SseTopics.ONLINE_USERS, handleOnlineUsersMessage);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
@@ -42,24 +41,10 @@ function createOnlineCountComposable() {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 在线用户计数组合式函数(单例模式)
|
||||
*/
|
||||
export function useOnlineCount(options: { autoInit?: boolean } = {}) {
|
||||
const { autoInit = true } = options;
|
||||
|
||||
/** 在线用户数组合式函数(单例模式) */
|
||||
export function useOnlineUsers() {
|
||||
if (!globalInstance) {
|
||||
globalInstance = createOnlineCountComposable();
|
||||
globalInstance = createOnlineUsersComposable();
|
||||
}
|
||||
|
||||
const instance = getCurrentInstance();
|
||||
if (autoInit && instance) {
|
||||
onMounted(() => {
|
||||
if (!globalInstance!.isConnected.value) {
|
||||
globalInstance!.initialize();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return globalInstance;
|
||||
}
|
||||
@@ -1,27 +1,37 @@
|
||||
import { AuthStorage } from "@/utils/auth";
|
||||
import { useUserStoreHook } from "@/stores/user";
|
||||
|
||||
/** SSE 连接配置选项 */
|
||||
export interface UseSseOptions {
|
||||
url?: string; // SSE 连接地址,默认走 VITE_APP_BASE_API 代理
|
||||
debug?: boolean; // 是否在控制台打印调试日志
|
||||
connectionTimeout?: number; // 连接超时时间(ms)
|
||||
/** 重连间隔基数,实际间隔 = min(基数 × 2^n, 最大间隔) */
|
||||
/** SSE 连接地址,默认走 VITE_APP_BASE_API 代理 */
|
||||
url?: string;
|
||||
/** 是否在控制台打印调试日志 */
|
||||
debug?: boolean;
|
||||
/** 连接超时时间(ms),默认 10000 */
|
||||
connectionTimeout?: number;
|
||||
/** 重连间隔基数(ms),实际间隔 = min(基数 × 2^n, maxReconnectInterval) */
|
||||
reconnectInterval?: number;
|
||||
maxReconnectInterval?: number; // 重连间隔上限(ms)
|
||||
maxReconnectAttempts?: number; // 最大重试次数,超过后停止重连
|
||||
/** 重连间隔上限(ms),默认 120000 */
|
||||
maxReconnectInterval?: number;
|
||||
/** 最大重试次数,超过后停止重连,默认 10 */
|
||||
maxReconnectAttempts?: number;
|
||||
}
|
||||
|
||||
/** SSE 事件处理器类型 */
|
||||
type EventHandler = (data: unknown) => void;
|
||||
|
||||
/** SSE 流解析中间状态 */
|
||||
type SseParseState = {
|
||||
currentEvent: string;
|
||||
currentData: string;
|
||||
buffer: string;
|
||||
};
|
||||
|
||||
/** SSE 连接状态 */
|
||||
export enum SseConnectionState {
|
||||
DISCONNECTED = "DISCONNECTED", // 未连接
|
||||
CONNECTING = "CONNECTING", // 连接中
|
||||
CONNECTED = "CONNECTED", // 已连接
|
||||
DISCONNECTED = "DISCONNECTED",
|
||||
CONNECTING = "CONNECTING",
|
||||
CONNECTED = "CONNECTED",
|
||||
}
|
||||
|
||||
let globalInstance: ReturnType<typeof createSseConnection> | null = null;
|
||||
@@ -49,6 +59,7 @@ function createSseConnection(options: UseSseOptions = {}) {
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let reconnectAttempts = 0;
|
||||
let currentReconnectInterval = config.reconnectInterval;
|
||||
let tokenRefreshed = false; // 本轮拒绝是否已刷新过令牌,防止无限刷新
|
||||
|
||||
const eventHandlers = new Map<string, Set<EventHandler>>();
|
||||
|
||||
@@ -59,27 +70,26 @@ function createSseConnection(options: UseSseOptions = {}) {
|
||||
};
|
||||
const logError = (...args: unknown[]) => console.error("[SSE]", ...args);
|
||||
|
||||
// 清理定时器并返回空值
|
||||
const clearTimer = (timer: typeof connectionTimeoutTimer) => {
|
||||
// 清除定时器并返回 null,用于链式赋值
|
||||
const clearTimer = (timer: ReturnType<typeof setTimeout> | null): null => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
return null;
|
||||
}
|
||||
return timer;
|
||||
return null;
|
||||
};
|
||||
|
||||
// 重置重连计数和间隔
|
||||
// 重置重连状态:次数归零、间隔恢复基数
|
||||
const resetReconnectState = () => {
|
||||
reconnectAttempts = 0;
|
||||
currentReconnectInterval = config.reconnectInterval;
|
||||
};
|
||||
|
||||
// 更新下一次重连间隔
|
||||
// 指数退避:当前间隔翻倍,不超过上限
|
||||
const advanceReconnectState = () => {
|
||||
currentReconnectInterval = Math.min(currentReconnectInterval * 2, config.maxReconnectInterval);
|
||||
};
|
||||
|
||||
// 分发一条完整的 SSE 事件
|
||||
// 分发 SSE 事件:先尝试 JSON.parse,失败则传原始字符串
|
||||
const flushSseEvent = (eventName: string, data: string) => {
|
||||
if (!data) return;
|
||||
const handlers = eventHandlers.get(eventName);
|
||||
@@ -94,7 +104,7 @@ function createSseConnection(options: UseSseOptions = {}) {
|
||||
log(`收到事件[${eventName}]:`, data);
|
||||
};
|
||||
|
||||
// 解析单行 SSE 文本并更新当前事件状态
|
||||
// 解析单行 SSE 数据:区分 event/data/注释/空行(触发分发)
|
||||
const handleSseLine = (line: string, state: SseParseState) => {
|
||||
if (line.startsWith(":")) return;
|
||||
if (line.startsWith("event:")) {
|
||||
@@ -113,30 +123,42 @@ function createSseConnection(options: UseSseOptions = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
// 持续读取 SSE 流并按行解析
|
||||
// 持续读取流数据并按行解析,异常时触发重连
|
||||
const consumeSseStream = async (streamReader: ReadableStreamDefaultReader<Uint8Array>) => {
|
||||
const decoder = new TextDecoder();
|
||||
const state: SseParseState = { currentEvent: "message", currentData: "", buffer: "" };
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await streamReader.read();
|
||||
if (done) {
|
||||
connectionState.value = SseConnectionState.DISCONNECTED;
|
||||
log("SSE 连接已关闭");
|
||||
return;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await streamReader.read();
|
||||
if (done) {
|
||||
reader = null;
|
||||
connectionState.value = SseConnectionState.DISCONNECTED;
|
||||
log("SSE 连接已关闭");
|
||||
return;
|
||||
}
|
||||
|
||||
state.buffer += decoder.decode(value, { stream: true });
|
||||
const lines = state.buffer.split("\n");
|
||||
state.buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
handleSseLine(line, state);
|
||||
}
|
||||
}
|
||||
|
||||
state.buffer += decoder.decode(value, { stream: true });
|
||||
const lines = state.buffer.split("\n");
|
||||
state.buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
handleSseLine(line, state);
|
||||
} catch (err) {
|
||||
reader = null;
|
||||
connectionState.value = SseConnectionState.DISCONNECTED;
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
log("SSE 流读取已主动断开");
|
||||
} else {
|
||||
logError("SSE 流读取错误:", err);
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 指数退避重连
|
||||
// 调度重连:指数退避,达到上限或主动断开时停止
|
||||
const scheduleReconnect = () => {
|
||||
if (isManualDisconnect) return;
|
||||
if (config.maxReconnectAttempts > 0 && reconnectAttempts >= config.maxReconnectAttempts) {
|
||||
@@ -148,12 +170,12 @@ function createSseConnection(options: UseSseOptions = {}) {
|
||||
log(`将在 ${currentReconnectInterval}ms 后重试(${reconnectAttempts})`);
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
connect();
|
||||
advanceReconnectState();
|
||||
connect();
|
||||
}, currentReconnectInterval);
|
||||
};
|
||||
|
||||
// 建立 SSE 连接
|
||||
// 建立连接:校验 token → fetch → 超时检测 → 消费流;401/403 先刷新令牌重连,失败则停止
|
||||
const connect = () => {
|
||||
isManualDisconnect = false;
|
||||
|
||||
@@ -168,14 +190,15 @@ function createSseConnection(options: UseSseOptions = {}) {
|
||||
|
||||
const token = AuthStorage.getAccessToken();
|
||||
if (!token) {
|
||||
log("未检测到有效令牌,跳过 SSE 连接");
|
||||
log("未检测到有效令牌,稍后重试");
|
||||
// 走统一重连调度,受 maxReconnectAttempts 上限约束
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
connectionState.value = SseConnectionState.CONNECTING;
|
||||
abortController = new AbortController();
|
||||
|
||||
// 超时自动断开
|
||||
connectionTimeoutTimer = setTimeout(() => {
|
||||
if (connectionState.value === SseConnectionState.CONNECTING) {
|
||||
log("SSE 连接超时");
|
||||
@@ -193,12 +216,36 @@ function createSseConnection(options: UseSseOptions = {}) {
|
||||
},
|
||||
signal: abortController.signal,
|
||||
})
|
||||
.then((response) => {
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
// 令牌过期:刷新后用新令牌重连,刷新失败或令牌仍无效则停止重连
|
||||
if (!tokenRefreshed) {
|
||||
tokenRefreshed = true;
|
||||
connectionTimeoutTimer = clearTimer(connectionTimeoutTimer);
|
||||
try {
|
||||
const userStore = useUserStoreHook();
|
||||
await userStore.refreshTokenOnce();
|
||||
if (AuthStorage.getAccessToken()) {
|
||||
connectionState.value = SseConnectionState.DISCONNECTED;
|
||||
log(`SSE 连接被拒绝(HTTP ${response.status}),令牌已刷新,使用新令牌重连`);
|
||||
connect();
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
logError("SSE 令牌刷新失败:", err);
|
||||
}
|
||||
}
|
||||
isManualDisconnect = true;
|
||||
connectionState.value = SseConnectionState.DISCONNECTED;
|
||||
log(`SSE 连接被拒绝(HTTP ${response.status}),不再重连`);
|
||||
return null;
|
||||
}
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
connectionTimeoutTimer = clearTimer(connectionTimeoutTimer);
|
||||
connectionState.value = SseConnectionState.CONNECTED;
|
||||
tokenRefreshed = false;
|
||||
resetReconnectState();
|
||||
log("SSE 连接已建立");
|
||||
return response.body?.getReader();
|
||||
@@ -219,7 +266,7 @@ function createSseConnection(options: UseSseOptions = {}) {
|
||||
});
|
||||
};
|
||||
|
||||
// 订阅事件,返回取消函数
|
||||
// 订阅指定事件,返回取消订阅函数
|
||||
const on = <T = unknown>(eventName: string, handler: (data: T) => void): (() => void) => {
|
||||
if (!eventHandlers.has(eventName)) {
|
||||
eventHandlers.set(eventName, new Set());
|
||||
@@ -239,7 +286,7 @@ function createSseConnection(options: UseSseOptions = {}) {
|
||||
};
|
||||
};
|
||||
|
||||
// 主动断开,不会触发重连
|
||||
// 主动断开:清除定时器、取消流读取、中止请求,不触发重连
|
||||
const disconnect = () => {
|
||||
isManualDisconnect = true;
|
||||
connectionTimeoutTimer = clearTimer(connectionTimeoutTimer);
|
||||
@@ -252,7 +299,7 @@ function createSseConnection(options: UseSseOptions = {}) {
|
||||
log("SSE 连接已断开");
|
||||
};
|
||||
|
||||
// 登出时调用,断开并释放所有资源
|
||||
// 断开连接并清空所有事件订阅
|
||||
const cleanup = () => {
|
||||
disconnect();
|
||||
eventHandlers.clear();
|
||||
@@ -269,6 +316,15 @@ function createSseConnection(options: UseSseOptions = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE 连接组合式函数(单例模式)
|
||||
*
|
||||
* 基于 fetch + ReadableStream 实现,支持指数退避重连、
|
||||
* 事件订阅/取消订阅、主动断开与资源清理。
|
||||
*
|
||||
* @param options - 连接配置选项
|
||||
* @returns SSE 连接实例,包含连接状态、connect/disconnect/on/cleanup 方法
|
||||
*/
|
||||
export function useSse(options: UseSseOptions = {}) {
|
||||
if (!globalInstance) {
|
||||
globalInstance = createSseConnection(options);
|
||||
@@ -276,6 +332,7 @@ export function useSse(options: UseSseOptions = {}) {
|
||||
return globalInstance;
|
||||
}
|
||||
|
||||
/** 清理 SSE 单例:断开连接、清空订阅、释放全局引用 */
|
||||
export function cleanupSse() {
|
||||
if (globalInstance) {
|
||||
globalInstance.cleanup();
|
||||
|
||||
@@ -31,10 +31,14 @@
|
||||
<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-empty
|
||||
v-if="!loading && list.length === 0"
|
||||
description="暂无安装包,点击右上角上传"
|
||||
class="empty-state"
|
||||
/>
|
||||
|
||||
<div v-else v-loading="loading" class="card-grid">
|
||||
<el-card v-for="item in list" :key="item.id" class="pkg-card" shadow="hover">
|
||||
<div class="pkg-card__header">
|
||||
<el-image
|
||||
@@ -136,7 +140,7 @@
|
||||
<UploadDialog v-model:visible="uploadVisible" @success="handleUploaded" />
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<el-drawer v-model="detailVisible" title="安装包详情" size="460px">
|
||||
<el-drawer v-model="detailVisible" title="安装包详情" size="800px">
|
||||
<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>
|
||||
@@ -210,15 +214,10 @@
|
||||
<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>
|
||||
<div v-for="(p, i) in current.permissions || []" :key="p" class="perm-item">
|
||||
<span class="perm-index">{{ i + 1 }}.</span>
|
||||
<span class="perm-name">{{ p }}</span>
|
||||
</div>
|
||||
<span v-if="!current.permissions || current.permissions.length === 0">-</span>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
@@ -423,6 +422,12 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
@@ -539,11 +544,28 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.perm-list {
|
||||
max-height: 160px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
|
||||
.perm-tag {
|
||||
margin: 0 4px 4px 0;
|
||||
.perm-item {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: baseline;
|
||||
padding: 2px 0;
|
||||
line-height: 20px;
|
||||
word-break: break-all;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.perm-index {
|
||||
flex-shrink: 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.perm-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<TableList ref="tableListRef" @generate="handleOpenDrawer" @reset-config="handleResetConfig" />
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="dash">
|
||||
<section class="dash-header">
|
||||
<div class="card dash-header__card">
|
||||
@@ -261,11 +261,11 @@ import {
|
||||
Document,
|
||||
VideoPlay,
|
||||
} from "@element-plus/icons-vue";
|
||||
import { useOnlineCount } from "@/composables";
|
||||
import { useOnlineUsers } from "@/composables";
|
||||
|
||||
const userStore = useUserStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { onlineUserCount, isConnected } = useOnlineCount();
|
||||
const { onlineUserCount, isConnected } = useOnlineUsers();
|
||||
|
||||
const hours = new Date().getHours();
|
||||
const greetings = computed(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- 文件上传组件示例 -->
|
||||
<!-- 文件上传组件示例 -->
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-link
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="page-container h-full flex flex-1 flex-col">
|
||||
<div class="mb-10">
|
||||
<el-link
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="page-container h-full flex flex-1 flex-col">
|
||||
<div class="flex-x-between mb-10">
|
||||
<el-link
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card class="box-card">
|
||||
<template #header>
|
||||
@@ -144,7 +144,8 @@ import { useDictStoreHook } from "@/stores/dict";
|
||||
import { useDateFormat } from "@vueuse/core";
|
||||
import DictAPI from "@/api/system/dict";
|
||||
import type { DictItemForm } from "@/api/system/dict";
|
||||
import { useDictSync, DictMessage } from "@/composables";
|
||||
import { useDictSync } from "@/composables";
|
||||
import type { DictChangeMessage } from "@/composables";
|
||||
|
||||
// 性别字典编码
|
||||
const DICT_CODE = "gender";
|
||||
@@ -186,7 +187,7 @@ const setupSse = () => {
|
||||
dictSse.initialize();
|
||||
|
||||
// 注册字典消息回调
|
||||
unregisterCallback = dictSse.onDictChange((message: DictMessage) => {
|
||||
unregisterCallback = dictSse.onDictChange((message: DictChangeMessage) => {
|
||||
// 只有当消息是关于性别字典的更新时才处理
|
||||
if (message.dictCode === DICT_CODE) {
|
||||
// 更新最后更新时间
|
||||
|
||||
@@ -11,28 +11,28 @@
|
||||
</el-link>
|
||||
<el-form>
|
||||
<el-form-item label="性别">
|
||||
<dict v-model="stringValue" code="gender" />
|
||||
<DictSelect v-model="stringValue" code="gender" />
|
||||
<el-link underline="never" type="primary" class="ml-5">
|
||||
值为String: const value = ref("1");
|
||||
</el-link>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="性别">
|
||||
<dict v-model="numberValue" code="gender" />
|
||||
<DictSelect v-model="numberValue" code="gender" />
|
||||
<el-link underline="never" type="success" class="ml-5">
|
||||
值为Number: const value = ref(1);
|
||||
</el-link>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="单选框字典">
|
||||
<dict v-model="numberValue" type="radio" code="gender" />
|
||||
<DictSelect v-model="numberValue" type="radio" code="gender" />
|
||||
<el-link underline="never" type="success" class="ml-5">
|
||||
值为Number: const value = ref(1);
|
||||
</el-link>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="复选框字典">
|
||||
<dict v-model="arrayValue" type="checkbox" code="gender" />
|
||||
<DictSelect v-model="arrayValue" type="checkbox" code="gender" />
|
||||
<el-link underline="never" type="success" class="ml-5">
|
||||
值为Array: const value = ref(["1", "2"]);
|
||||
</el-link>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="canvas-dom">
|
||||
<h3>基于canvas实现的签名组件</h3>
|
||||
<header>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<!-- 表格 -->
|
||||
<VxeGrid ref="xGrid" v-bind="gridOptions" v-on="gridEvents">
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { DeviceQueryParams, DeviceItem } from "@/api/system/device/types";
|
||||
import type { IContentConfig } from "@/components/DeviceSn/types";
|
||||
|
||||
const contentConfig: IContentConfig<DeviceQueryParams, DeviceItem> = {
|
||||
permPrefix: "sys:user", // 不写不进行按钮权限校验
|
||||
permPrefix: "sys:device", // 不写不进行按钮权限校验
|
||||
table: {
|
||||
border: true,
|
||||
highlightCurrentRow: true,
|
||||
@@ -68,9 +68,9 @@ const contentConfig: IContentConfig<DeviceQueryParams, DeviceItem> = {
|
||||
slotName: "onlineStatus",
|
||||
width: 100,
|
||||
},
|
||||
{ label: "设备型号", align: "center", prop: "snModel", width: 200 },
|
||||
{ label: "设备昵称", align: "center", prop: "snName", width: 200 },
|
||||
{ label: "推送ID", align: "center", prop: "pushId", width: 200, showOverflowTooltip: true },
|
||||
{ label: "设备型号", align: "center", prop: "snModel", width: 200 },
|
||||
{ label: "推送ID", align: "center", prop: "pushId", width: 200 },
|
||||
// { label: "设备 IMEI", align: "center", prop: "snImei", width: 200 },
|
||||
// { label: "设备版本号", align: "center", prop: "snDispalyId", width: 400 },
|
||||
// { label: "头像", align: "center", prop: "avatar", templet: "image" },
|
||||
@@ -160,6 +160,11 @@ const contentConfig: IContentConfig<DeviceQueryParams, DeviceItem> = {
|
||||
text: "截图",
|
||||
attrs: { icon: "FullScreen" },
|
||||
},
|
||||
{
|
||||
name: "mirror",
|
||||
text: "屏幕镜像",
|
||||
attrs: { icon: "VideoCamera" },
|
||||
},
|
||||
{
|
||||
name: "locate",
|
||||
text: "定位",
|
||||
@@ -180,6 +185,11 @@ const contentConfig: IContentConfig<DeviceQueryParams, DeviceItem> = {
|
||||
text: "重置",
|
||||
attrs: { icon: "RefreshLeft" },
|
||||
},
|
||||
{
|
||||
name: "unbind",
|
||||
text: "解除绑定",
|
||||
attrs: { icon: "Unlock" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -5,16 +5,6 @@ import type { IModalConfig } from "@/components/DeviceSn/types";
|
||||
import { DeviceEnum } from "@/enums/settings";
|
||||
import { useAppStore } from "@/stores";
|
||||
|
||||
/** 只读输入框配置(展示常用参数,不可编辑) */
|
||||
const readonlyInput = (placeholder = "系统自动填充") => ({
|
||||
type: "input" as const,
|
||||
attrs: {
|
||||
placeholder,
|
||||
readonly: true,
|
||||
disabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const modalConfig: IModalConfig<DeviceForm> = {
|
||||
permPrefix: "sys:device",
|
||||
component: "drawer",
|
||||
@@ -23,11 +13,10 @@ const modalConfig: IModalConfig<DeviceForm> = {
|
||||
size: useAppStore().device === DeviceEnum.MOBILE ? "80%" : 500,
|
||||
},
|
||||
pk: "id",
|
||||
// 编辑只回传可修改字段,其余保持不变
|
||||
// 编辑只回传可修改字段(仅设备昵称)
|
||||
beforeSubmit(data) {
|
||||
return {
|
||||
id: data.id,
|
||||
serialno: data.serialno,
|
||||
snName: data.snName,
|
||||
status: data.status,
|
||||
} as DeviceForm;
|
||||
@@ -43,14 +32,9 @@ const modalConfig: IModalConfig<DeviceForm> = {
|
||||
attrs: {
|
||||
placeholder: "设备序列号",
|
||||
readonly: true,
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "设备型号",
|
||||
prop: "snModel",
|
||||
rules: [{ required: true, message: "设备型号不能为空", trigger: "blur" }],
|
||||
...readonlyInput("设备型号"),
|
||||
},
|
||||
{
|
||||
label: "设备昵称",
|
||||
prop: "snName",
|
||||
@@ -61,43 +45,6 @@ const modalConfig: IModalConfig<DeviceForm> = {
|
||||
maxlength: 50,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "手机号码",
|
||||
prop: "snMobile",
|
||||
rules: [
|
||||
{
|
||||
pattern: /^1[3|4|5|6|7|8|9][0-9]\d{8}$/,
|
||||
message: "请输入正确的手机号码",
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
...readonlyInput("绑定手机号"),
|
||||
},
|
||||
{
|
||||
label: "IMEI",
|
||||
prop: "snImei",
|
||||
...readonlyInput("设备IMEI"),
|
||||
},
|
||||
{
|
||||
label: "设备类型",
|
||||
prop: "deviceType",
|
||||
...readonlyInput("设备类型"),
|
||||
},
|
||||
{
|
||||
label: "来源",
|
||||
prop: "source",
|
||||
...readonlyInput("设备来源"),
|
||||
},
|
||||
{
|
||||
label: "激活时间",
|
||||
prop: "activationTime",
|
||||
...readonlyInput("激活时间"),
|
||||
},
|
||||
{
|
||||
label: "最后上线",
|
||||
prop: "lastOnline",
|
||||
...readonlyInput("最后上线时间"),
|
||||
},
|
||||
{
|
||||
label: "状态",
|
||||
prop: "status",
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ISearchConfig } from "@/components/DeviceSn/types";
|
||||
import { deptArr, stateArr } from "./options";
|
||||
|
||||
const searchConfig: ISearchConfig = {
|
||||
permPrefix: "sys:user",
|
||||
permPrefix: "sys:device",
|
||||
formItems: [
|
||||
{
|
||||
tips: "支持模糊搜索",
|
||||
|
||||
@@ -183,6 +183,13 @@ const handleOperateClick = (data: IObject) => {
|
||||
DeviceAPI.deviceScreenshot(data.row.serialno as string).then(() => {
|
||||
ElMessage.success(`截图设备,设备SN: ${data.row.serialno as string}`);
|
||||
});
|
||||
} else if (data.name === "mirror") {
|
||||
// 屏幕镜像:通知设备端启动屏幕采集,由家属端 client 进行 WebRTC 实时远程观看/控制
|
||||
DeviceAPI.deviceMirror(data.row.serialno as string)
|
||||
.then(() => {
|
||||
ElMessage.success(`已发送屏幕镜像指令,设备SN: ${data.row.serialno as string}`);
|
||||
})
|
||||
.catch(() => {});
|
||||
} else if (data.name === "reboot") {
|
||||
ElMessageBox.confirm("确定要重启该设备吗?", "提示", {
|
||||
confirmButtonText: "确定",
|
||||
@@ -215,6 +222,27 @@ const handleOperateClick = (data: IObject) => {
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
} else if (data.name === "unbind") {
|
||||
// 解除设备手机号绑定(管理员端):确认后清除 snMobile 并推送通知设备端标记解绑
|
||||
const sn = data.row.serialno as string;
|
||||
const mobile = (data.row.snMobile as string) || "";
|
||||
ElMessageBox.confirm(
|
||||
mobile
|
||||
? `设备「${sn}」当前绑定手机号:${mobile}\n确定要解除该绑定吗?解除后需由家属端重新绑定。`
|
||||
: `设备「${sn}」当前未绑定手机号,无需解除绑定。`,
|
||||
"解除绑定",
|
||||
{
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
}
|
||||
)
|
||||
.then(() => {
|
||||
DeviceAPI.unbindDeviceMobile(data.row.serialno as string).then(() => {
|
||||
ElMessage.success(`已解除设备绑定,设备SN: ${data.row.serialno as string}`);
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div>
|
||||
<h3 text-center m-0 mb-20px>{{ t("login.reg") }}</h3>
|
||||
<el-form ref="formRef" :model="model" :rules="rules" size="large">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div>
|
||||
<h3 text-center m-0 mb-20px>{{ t("login.resetPassword") }}</h3>
|
||||
<el-form ref="formRef" :model="model" :rules="rules" size="large">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="profile-page">
|
||||
<section class="profile-hero">
|
||||
<div class="profile-hero__body">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card class="page-search" shadow="never">
|
||||
<el-form ref="queryFormRef" :model="params" :inline="true">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card class="page-search" shadow="never">
|
||||
<el-form ref="queryFormRef" :model="queryParams" :inline="true">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card ref="tableWrapperRef" class="page-search" shadow="never">
|
||||
<el-form ref="queryFormRef" :model="params" :inline="true">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card class="page-search" shadow="never">
|
||||
<el-form ref="queryFormRef" :model="params" :inline="true">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card class="page-search" shadow="never">
|
||||
<el-form ref="queryFormRef" :model="params" :inline="true" label-width="auto">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card class="page-search" shadow="never">
|
||||
<el-form ref="queryFormRef" :model="params" :inline="true" label-suffix=":">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card class="page-search" shadow="never">
|
||||
<el-form ref="queryFormRef" :model="params" :inline="true">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card class="page-search" shadow="never">
|
||||
<el-form ref="queryFormRef" :model="params" :inline="true">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card ref="tableWrapperRef" class="page-search" shadow="never">
|
||||
<el-form ref="queryFormRef" :model="params" :inline="true">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- 用户管理 -->
|
||||
<!-- 用户管理 -->
|
||||
<template>
|
||||
<div class="page-container page-container--split user-page">
|
||||
<aside class="page-aside" :class="{ 'is-collapsed': sidebarCollapsed }">
|
||||
|
||||
3
types/env.d.ts
vendored
3
types/env.d.ts
vendored
@@ -4,8 +4,7 @@
|
||||
* Vite 环境变量类型定义
|
||||
*/
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_PORT: number;
|
||||
readonly VITE_APP_NAME: string;
|
||||
readonly VITE_APP_PORT: string;
|
||||
readonly VITE_APP_BASE_API: string;
|
||||
readonly VITE_APP_API_URL: string;
|
||||
readonly VITE_APP_TITLE?: string;
|
||||
|
||||
@@ -10,7 +10,7 @@ import { mockDevServerPlugin } from "vite-plugin-mock-dev-server";
|
||||
|
||||
import UnoCSS from "unocss/vite";
|
||||
import { resolve } from "path";
|
||||
import { name, version } from "./package.json";
|
||||
import { name, version } from "./package.json" with { type: "json" };
|
||||
|
||||
// 平台名称、版本信息
|
||||
const __APP_INFO__ = {
|
||||
@@ -122,7 +122,8 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
|
||||
"element-plus/es",
|
||||
"element-plus/es/locale/lang/en",
|
||||
"element-plus/es/locale/lang/zh-cn",
|
||||
// Element Plus 组件样式预构建(避免按需发现时触发页面重载)
|
||||
// Element Plus 组件样式预构建:按 src 实际用到的组件清单硬编码,首启即预加载,
|
||||
// 避免首次使用某组件时触发依赖重优化导致页面刷新
|
||||
...[
|
||||
"alert",
|
||||
"avatar",
|
||||
|
||||
Reference in New Issue
Block a user