Merge branch 'master' of gitee.com:youlaiorg/vue3-element-admin

This commit is contained in:
TongTongStudio
2026-08-14 19:46:04 +08:00
48 changed files with 5032 additions and 5721 deletions

View File

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

View File

@@ -32,8 +32,8 @@ trailingComma: "es5"
useTabs: false
# Vue 文件中的 <script> 和 <style> 不增加额外的缩进
vueIndentScriptAndStyle: false
# 根据系统自动检测换行符
endOfLine: "auto"
# 统一使用 LF 换行符,避免跨平台差异产生大量 diff
endOfLine: "lf"
# 对 HTML 文件应用特定格式化规则
overrides:
- files: "*.html"

View File

@@ -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
View 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
View 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
View 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
View 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} 成功` : "删除成功",
};
},
},
]);

View File

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

View File

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

9499
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
<template>
<template>
<template v-if="tagType">
<el-tag :type="tagType" :size="tagSize">{{ label }}</el-tag>
</template>

View File

@@ -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);
/** 当前激活的 Tab0=未读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;

View File

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

View File

@@ -1,4 +1,4 @@
<template>
<template>
<div ref="tableSelectRef" :style="'width:' + width">
<el-popover
:visible="popoverVisible"

View File

@@ -1,4 +1,4 @@
<!--
<!--
TextScroll 组件 - 文本滚动公告
功能:

View File

@@ -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[]);

View File

@@ -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[]>([]);

View File

@@ -83,10 +83,7 @@ const props = defineProps({
},
});
const modelValue = defineModel("modelValue", {
type: String,
default: () => "",
});
const modelValue = defineModel<string>({ default: "" });
/**
* 限制用户上传文件的格式和大小

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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();

View File

@@ -1,4 +1,4 @@
<template>
<template>
<div class="page-container">
<TableList ref="tableListRef" @generate="handleOpenDrawer" @reset-config="handleResetConfig" />

View File

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

View File

@@ -1,4 +1,4 @@
<!-- 文件上传组件示例 -->
<!-- 文件上传组件示例 -->
<template>
<div class="page-container">
<el-link

View File

@@ -1,4 +1,4 @@
<template>
<template>
<div class="page-container h-full flex flex-1 flex-col">
<div class="mb-10">
<el-link

View File

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

View File

@@ -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) {
// 更新最后更新时间

View File

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

View File

@@ -1,4 +1,4 @@
<template>
<template>
<div class="canvas-dom">
<h3>基于canvas实现的签名组件</h3>
<header>

View File

@@ -1,4 +1,4 @@
<template>
<template>
<div class="page-container">
<!-- 表格 -->
<VxeGrid ref="xGrid" v-bind="gridOptions" v-on="gridEvents">

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
<template>
<template>
<div class="profile-page">
<section class="profile-hero">
<div class="profile-hero__body">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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=":">

View File

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

View File

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

View File

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

View File

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

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

View File

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