33 Commits

Author SHA1 Message Date
rjl
8469e16638 feat(admin): 新增用户排行榜功能并优化活动流程
- 新增用户排行榜页面和待发布页面,支持查看和发布排行榜
- 新增排行榜相关API接口,包括获取活动排行列表、修改队伍分数、发布排名和获取用户排名列表
- 新增RankPublishStatus枚举,用于管理排行榜发布状态
- 在活动卡片中新增结束活动按钮,点击后调用结束活动接口并生成排行榜数据
- 优化用户活动首页,只显示已发布和处理中的活动
- 调整游戏页面定时刷新间隔从2秒改为3秒,优化性能
- 修复排行榜详情页分数输入最大值限制问题
- 清理未使用的代码文件,移除results-detail-copy相关组件和路由
- 更新路由配置和类型定义,支持新的排行榜页面
2026-03-09 08:37:32 +08:00
rjl
e8cd6732d7 feat: 添加排行榜分数编辑和题目详情展示功能
- 在排行榜详情页中,添加队伍题目分数编辑功能,支持批量保存
- 新增打字机组件用于题目答案的动态展示
- 优化游戏页面的答题数量统计逻辑,使用 UserAnswerFont 解析
- 更新题目导入工具,支持更多 JSON 格式
- 调整游戏倒计时逻辑,优先使用题目详情中的时间
- 移除排行榜中的部分冗余列,优化界面显示
2026-03-05 18:08:17 +08:00
rjl
ed3c815cd7 feat(admin): 支持模板页码和题目类型细分,优化评分界面
- 在模板详情页添加页码(PageNo)字段,支持模板按页管理
- 将汉字听写和成语题型细分为多个子类型,提升题目分类精度
- 重构结果详情页的评分界面,区分客观题(网格布局)和主观题(分屏布局)
- 添加AI评分推荐和人工校准功能,支持分数手动调整和快捷打分
- 实现活动复制功能,优化题目排序逻辑和界面间距
- 新增评委打分和状态更新接口,完善评分数据持久化机制
2026-03-03 18:10:44 +08:00
rjl
94af0bec6a feat: 优化用户界面组件与调整布局样式
- 新增田字格组件用于汉字书写练习
- 调整模板详情页的生成参数和页面编号
- 优化用户主页轮播图和游戏页布局
- 修复答题结果页图片显示和状态切换问题
- 移除卡片悬停阴影并优化背景图片显示
- 更新状态管理注释和类型声明
2026-03-03 08:35:28 +08:00
rjl
81643c61ae feat(admin): 优化用户界面交互与数据展示
- 在队伍选择页面增加卷轴展开动画,优化视觉体验
- 为游戏页面添加实时统计图表,使用 ECharts 展示各队答题进度
- 调整题目渲染器中图片的最大宽度为 200px
- 在路由配置中隐藏 results_results-detail-copy 菜单项
- 修复 API 函数命名和参数类型,更新接口返回类型定义
- 移动拼音听写题目数据文件到 store 目录
- 更新 VSCode 拼写检查配置,添加 ECharts 单词
2026-03-02 09:53:34 +08:00
rjl
2b50bd2c71 feat(results): 新增实时结果列表和详情页面,支持评委查看和评分
- 添加 results_results-list 和 results_results-detail 路由及页面
- 实现比赛列表展示,支持跳转到实时结果详情
- 在结果详情页面添加分组和题目切换功能
- 集成游戏统计 API,实时获取队伍答题数据
- 添加 TeamResultCard 和 GroupTabs 可复用组件
- 更新国际化配置和类型定义
2026-02-28 08:46:11 +08:00
rjl
e70409082a feat: 新增测试页面并优化用户界面及API调用
- 新增测试页面 `/test`,用于展示题目答案的交互式网格和列表布局
- 为 CompetitionLayout 组件添加标题背景类型支持,优化视觉一致性
- 更新 `fetchUpdateCurrentQuestionUse` API 参数,简化调用逻辑
- 优化用户界面细节,包括图标替换、样式调整和布局改进
2026-02-27 08:37:45 +08:00
rjl
f1ff053b68 fix(admin): 移除题目标题必填验证并优化诗词理解题目展示
移除题目配置中的标题输入框及相关必填验证,使标题字段变为可选
诗词理解题目类型使用富文本编辑器,其他类型使用普通文本域
优化用户端汉字听写和词语听写题目的米字格展示样式
统一图片展示样式,限制最大宽度为50%并居中显示
2026-02-13 08:34:09 +08:00
rjl
f3136cd9ad feat: 新增题目导入服务并优化用户端答题流程
- 新增 question-importer 服务,支持从 JSON 文件批量导入题目到题库
- 重构用户端答题流程,整合抽题、答题、结果分析页面状态管理
- 将题目分类从 UI 模板类型切换为业务分类(question_category_name)
- 在题库管理页面支持题目批量选择和删除功能
- 优化用户端组别选择,增加“已结束”状态提示和禁用逻辑
- 修复题目新增/更新 API 请求参数格式问题
- 为富文本编辑器配置排除不必要的工具栏按键
2026-02-11 17:27:08 +08:00
rjl
61a7412fc1 feat(user): 重构用户端竞赛流程,集成后端API并优化交互体验
- 新增 `activityinfo` store 管理活动信息,支持持久化
- 重构 `competition` store,分离数据层与流程控制,新增音效管理
- 新增游戏API模块 (`game.ts`),实现抽题、题目详情、提交结果等接口
- 用户端页面全面对接后端数据:首页轮播展示活动列表,组别/队伍页动态加载,封面页显示题目卡片
- 抽题页改为竹签翻转动画,游戏页集成题目详情与倒计时
- 优化布局组件,支持点击返回首页,统一路由参数传递
- 更新类型定义,支持富文本题目渲染
- 添加 `activityinfo` 拼写检查词条
2026-02-10 08:45:26 +08:00
rjl
0fd8ef527a chore: 清理废弃的用户端答题流程代码并优化SCSS导入
移除废弃的`user copy`目录及其所有相关文件,包括组件、类型定义、模拟数据和图片
在vite配置中全局导入SCSS变量,移除各组件中冗余的`@import`语句
更新TypeScript配置以包含build目录,并优化SCSS颜色函数的使用
2026-02-09 09:02:35 +08:00
rjl
b71a758474 feat(user): 重构用户端竞赛流程并添加新页面
- 将原单页竞赛流程拆分为独立路由页面(首页、封面、规则、抽题、答题、图解、组别、队伍)
- 新增 Pinia store 管理竞赛状态、计时器和音频控制
- 添加 SCSS 变量文件定义主题色
- 更新路由配置和类型定义以支持新页面结构
- 重构 QuestionRenderer 组件优化样式
- 添加图标依赖并更新国际化配置
- 移动图片资源到用户目录并清理旧文件
2026-02-06 17:45:44 +08:00
rjl
ca42c6a876 feat(admin): 新增用户端答题流程页面与相关功能模块
- 新增用户端答题流程主页面,包含封面、介绍、答题、答题图解四个状态
- 新增用户端组件:封面、介绍页、答题页、答题图解页、题目渲染器及通用布局组件
- 新增用户端类型定义、模拟数据及业务常量
- 新增用户路由及类型声明,包含组别和队伍选择页面
- 新增管理端首页模块:数据卡片、头部横幅、折线图、饼图、创意横幅和项目新闻
- 新增富文本编辑器组件,支持图片和视频上传至OSS
- 优化题库分类树,改为扁平化列表结构
- 完善比赛创建功能,增加轮次类型、题目分数类型及关联AP字段
- 修复模板删除函数调用参数错误
- 添加 pinyin-pro 依赖以支持拼音处理功能
2026-02-05 18:00:11 +08:00
rjl
ce5ee2a289 feat(admin): 新增活动管理功能并优化字典组件
- 新增活动管理相关功能,包括活动创建、编辑、删除和状态管理
- 新增 PublishStatus 枚举定义活动发布状态
- 优化字典组件,支持字符串和数字类型的字典值
- 重构业务数据存储逻辑,简化字典数据获取流程
- 新增活动详情页面,支持分组管理和队伍配置
- 集成富文本编辑器用于活动规则编辑
- 优化图片上传组件,支持小图上传模式
- 修复模板管理中的区域选择和分页问题
- 更新 TypeScript 类型定义,完善 API 接口
- 调整主题配置,新增活动状态相关颜色
2026-02-04 18:01:52 +08:00
rjl
e1a19c48e0 feat(字典管理): 新增字典管理功能及相关基础设施
- 新增字典管理页面,支持字典项的增删改查
- 添加 pinia-plugin-persistedstate 依赖,实现状态持久化
- 创建字典相关的 API 类型定义和服务接口
- 实现字典数据存储模块,支持字典数据的缓存和复用
- 添加 useDict 组合式函数,方便组件中使用字典数据
- 在题目分类和比赛创建模块中使用字典数据替代硬编码选项
- 优化分类树组件,添加拖拽手柄和题目类型选择
2026-01-30 17:45:46 +08:00
rjl
b39761d58e feat(admin): 新增实时结果页面并优化竞赛创建逻辑
- 新增实时结果页面,包含分组切换、队伍卡片展示和AI识别结果交互
- 实现模拟数据轮询、人工修正状态保持和分数自动计算功能
- 修复竞赛创建时createdTime字段设置为空字符串的问题,改为使用startTime
- 修正teamGroupId字段类型,确保为数字类型
- 统一日期时间选择器格式为ISO标准格式(yyyy-MM-dd'T'HH:mm:ss)
2026-01-30 14:04:07 +08:00
rjl
beaaee72b4 feat(competition): 实现比赛创建功能并集成后端API
- 新增比赛创建页面,包含基本信息、组别管理和题目配置三个步骤
- 添加 OSS 图片上传组件,支持海报上传到阿里云OSS
- 集成后端API:获取房间列表、创建活动、创建队伍和创建题目
- 优化表单数据绑定和验证逻辑,使用防抖减少频繁更新
- 修复组别管理Excel导入的数据填充问题
- 更新类型定义,添加CommonResponse和比赛相关接口类型
2026-01-29 18:03:13 +08:00
rjl
82b1dd7ebe feat(template): 增强答题卡编辑器功能并优化类型定义
- 新增模板详情编辑功能,支持从后端加载并回填数据
- 在画布中增加网格、标尺和坐标显示辅助工具
- 优化模板列表操作按钮布局,新增打印功能
- 扩展响应类型定义,增加 success 字段和 TemplateDetail 接口
- 添加 ESLint 配置排除 Markdown 文件,并完善事件总线类型
- 新增请求工具使用文档,说明错误处理机制
2026-01-29 11:55:18 +08:00
rjl
ba38accdb9 fix(template): 修复区域生成编号和画布点击交互问题
修复批量生成区域时编号未考虑现有区域的问题,现在会基于现有最大编号递增。
修复画布点击交互:支持点击空白处取消选中,并优化多选切换为单选的逻辑。
2026-01-29 09:06:16 +08:00
rjl
16d7ecd172 feat(template): 新增答题卡模板编辑器及相关功能
- 新增模板详情页面,支持上传PDF/图片作为底稿,并生成可拖拽调整的区域
- 新增模板类型定义、上传钩子及画布组件,支持批量生成区域、旋转、对齐参考线等功能
- 更新模板列表页,适配新的API接口并增加删除功能
- 更新OSS工具函数,修复上传凭证处理逻辑
- 更新公共类型定义,调整分页查询返回结构
- 移除旧的书籍相关API文件,替换为模板API
- 更新依赖项,添加pdfjs-dist、vue3-draggable-resizable等库
- 隐藏页面底部,优化界面显示
2026-01-28 18:00:17 +08:00
rjl
78e51d96f6 chore: 清理未使用的模板编辑功能及相关依赖
移除模板编辑相关组件、工具函数和类型定义,并删除不再需要的第三方依赖包。此功能已不再使用,清理以减少项目体积和复杂度。

- 删除模板编辑页面及其所有子组件(left-tree、middle-book、right-topic等)
- 移除PDF转换、图片裁剪、数学公式编辑等工具函数
- 清理富文本编辑器组件及相关类型定义
- 从package.json移除@formkit/auto-animate、@sapphire/snowflake、katex、mathlive等未使用依赖
2026-01-28 09:53:17 +08:00
rjl
133d9a903a feat(admin): 更新题库管理和环境配置
- 禁用强制登录以便本地开发
- 更新开发环境API基础URL为本地服务
- 在代理配置中添加secure: false以支持HTTPS接口
- 修复请求响应中字符串类型数据的JSON解析问题
- 新增题目类型相关常量定义和类型声明
- 扩展transformRecordToOption函数支持数字键值
- 更新题库相关API接口和类型定义
- 在竞赛配置中添加题目分数字段
- 重构题库管理界面,实现分类和题目的完整CRUD操作
- 删除未使用的模板管理组件
2026-01-28 08:33:36 +08:00
rjl
c3d23224ba feat(admin): 新增模板详情页书籍编辑功能
- 新增模板详情页,支持导入PDF书籍、管理目录和页面
- 实现书籍页面布局编辑功能,可添加/编辑普通题目和特殊区域
- 添加题目管理功能,支持单选题、多选题、判断题等题型
- 集成OSS上传服务,支持题目图片裁剪和上传
- 实现事件总线机制,用于组件间通信
- 添加工具函数:路径拼接、版本比较等
- 更新环境配置,启用强制登录并调整API地址
2026-01-27 11:10:58 +08:00
rjl
f6d2a643e8 feat(router): 增加强制登录配置选项
添加 VITE_AUTH_ROUTE_FORCE_LOGIN 环境变量以控制是否强制用户登录
当设置为 Y 时,未登录用户访问非白名单路由将被重定向到登录页
同时更新开发环境配置并添加相应的 TypeScript 类型定义
2026-01-27 10:01:55 +08:00
rjl
5a48a65ef8 feat(admin): 新增模板管理功能并完善用户认证
- 新增模板管理模块,包含模板列表和详情页面
- 重构用户认证逻辑,支持用户ID存储和API调用
- 新增排行榜API接口和类型定义
- 更新环境配置,添加开发环境配置
- 优化表格操作钩子,增强类型安全性
- 新增文件上传工具类和验证工具函数
- 更新路由配置,支持模板详情页面导航
- 修复登录表单默认值问题
2026-01-27 09:48:42 +08:00
rjl
264f600250 feat(user): 新增用户端答题流程页面与相关功能
- 新增用户端答题流程主页面,包含封面、介绍、答题、答题图解四个状态
- 新增用户端组件:封面、介绍页、答题页、答题图解页、题目渲染器
- 新增用户端类型定义、模拟数据及业务常量
- 新增用户路由及类型声明
- 优化题库分类树,改为扁平化列表结构
- 完善比赛创建功能,增加轮次类型、题目分数类型及关联AP字段
- 修复模板删除函数调用参数错误
2026-01-23 18:16:03 +08:00
rjl
f1dacf7c66 feat(admin): 新增题库、排行榜、实时结果和模板管理功能
- 添加题库管理页面及相关组件
- 实现排行榜管理功能,包括列表和详情页
- 新增实时结果展示页面
- 添加模板制作和管理功能
- 完善路由配置和国际化支持
- 新增阿里云OSS文件上传服务
- 添加多种工具函数和类型定义
- 优化表格和表单组件
- 调整布局和样式细节
2026-01-22 17:55:45 +08:00
rjl
697d606611 feat(admin): 新增题库、排行榜、实时结果和模板管理功能
- 添加题库管理页面及相关组件
- 实现排行榜管理功能,包括列表和详情页
- 新增实时结果展示页面
- 添加模板制作和管理功能
- 完善路由配置和国际化支持
- 新增阿里云OSS文件上传服务
- 添加多种工具函数和类型定义
- 优化表格和表单组件
- 调整布局和样式细节
2026-01-22 17:55:35 +08:00
rjl
d950bb4018 feat(admin): 新建题库管理页面,添加分类树和题目管理功能
实现题库分类树结构,支持多级分类管理
添加题目列表展示和搜索功能
新增分类和题目的增删改查操作
优化页面布局和交互体验
2026-01-21 17:45:06 +08:00
rjl
89773bd49b feat(admin): 新增题库模块并优化比赛管理功能
新增题库模块,包含题目管理页面和Excel导入导出功能
优化比赛管理功能,包括分组管理、硬件绑定和题目配置
重构比赛添加流程,增加重置功能和数据校验
添加Excel工具函数,支持读取和导出Excel文件
更新依赖,添加xlsx库支持
2026-01-21 16:29:38 +08:00
rjl
3270e24c25 feat(competition): 新增比赛管理功能并优化UI交互
- 添加比赛创建向导,支持分步配置基础信息、分组管理、硬件绑定和题目配置
- 实现比赛卡片组件,支持复制和删除操作
- 优化比赛详情页的表单交互和样式
- 新增全局组件类型定义和图标
- 添加比赛海报上传功能
- 完善比赛状态管理,新增"已结束"状态
2026-01-21 08:41:36 +08:00
rjl
e6346a771e feat(competition): 新增比赛管理功能并优化UI交互 2026-01-20 18:06:48 +08:00
rjl
764b5727ba refactor: 优化代码格式和注释
- 移除不必要的注释标记
- 简化CountTo组件属性格式
- 禁用不可编辑的输入框
2026-01-20 11:57:56 +08:00
192 changed files with 24685 additions and 1154 deletions

View File

@ -36,5 +36,9 @@
"./apps/user",
"./packages/eslint-config"
],
"totvsLanguageServer.welcomePage": false // `
"totvsLanguageServer.welcomePage": false,
"cSpell.words": [
"activityinfo",
"Echarts"
] // `
}

View File

@ -1,9 +1,17 @@
# 应用的基础URL默认为 "/"
# 如果使用子目录,必须以 "/" 结尾,例如 "/admin/" 而不是 "/admin"
VITE_BASE_URL=/
# VITE_BASE_URL='/admin'
# VITE_BASE_URL="https://api.qyzhjy.com"
VITE_APP_TITLE="阅读比赛配置后台"
# 阿里云OSS地址前缀(用于拼接完整地址)
VITE_BASE_OSS_URL = https://oss.qyzhjy.com/
# 上传图片的URL
VITE_BASE_UPLOAD_URL="http://192.168.20.150:8080"
VITE_APP_DESC="阅读比赛配置"
# 图标名称的前缀
@ -16,6 +24,9 @@ VITE_ICON_LOCAL_PREFIX=icon-local
# 认证路由模式: static (静态) | dynamic (动态)
VITE_AUTH_ROUTE_MODE=static
# 是否强制登录: Y (开启) | N (关闭)
VITE_AUTH_ROUTE_FORCE_LOGIN=N
# 静态认证路由的主页
VITE_ROUTE_HOME=home
@ -23,16 +34,16 @@ VITE_ROUTE_HOME=home
VITE_MENU_ICON=mdi:menu
# 在开发模式下是否启用http代理
VITE_HTTP_PROXY=Y
VITE_HTTP_PROXY=Y # Y 开启后,所有请求都将通过代理服务器,N 关闭后,所有请求都将直接发送到后端服务
# vue-router 模式: hash | history | memory
VITE_ROUTER_HISTORY_MODE=history
# 后端服务成功代码,收到此代码表示请求成功
VITE_SERVICE_SUCCESS_CODE=0000
VITE_SERVICE_SUCCESS_CODE=200
# 后端服务登出代码,收到此代码将登出并重定向到登录页
VITE_SERVICE_LOGOUT_CODES=8888,8889
VITE_SERVICE_LOGOUT_CODES=401,403
# 后端服务模态框登出代码,收到此代码将通过显示模态框登出
VITE_SERVICE_MODAL_LOGOUT_CODES=7777,7778

12
apps/admin/.env.dev Normal file
View File

@ -0,0 +1,12 @@
# backend service base url, test environment
# VITE_SERVICE_BASE_URL=https://mock.apifox.cn/m1/3109515-0-default
# VITE_SERVICE_BASE_URL=http://192.168.5.140:9999
# VITE_SERVICE_BASE_URL=https://api.qyzhjy.com
VITE_SERVICE_BASE_URL=https://172.16.10.130:5000
VITE_OTHER_SERVICE_BASE_URL= `{
"demo": "http://localhost:9528"
}`

View File

@ -1,7 +0,0 @@
# backend service base url, test environment
VITE_SERVICE_BASE_URL=https://mock.apifox.cn/m1/3109515-0-default
# other backend service base url, test environment
VITE_OTHER_SERVICE_BASE_URL= `{
"demo": "http://localhost:9528"
}`

View File

@ -4,10 +4,10 @@ import { bgRed, bgYellow, green, lightBlue } from 'kolorist'
import { createServiceConfig } from '../../src/utils/service'
/**
* Set http proxy
* 设置 HTTP 代理
*
* @param env - The current env
* @param enable - If enable http proxy
* @param env - 当前环境变量
* @param enable - 是否启用 HTTP 代理
*/
export function createViteProxy(env: Env.ImportMeta, enable: boolean) {
const isEnableHttpProxy = enable && env.VITE_HTTP_PROXY === 'Y'
@ -28,27 +28,34 @@ export function createViteProxy(env: Env.ImportMeta, enable: boolean) {
return proxy
}
/**
* 创建 HTTP 代理项
*
* @param item - 服务配置项
* @param enableLog - 是否启用日志记录
*/
function createProxyItem(item: App.Service.ServiceConfigItem, enableLog: boolean) {
const proxy: Record<string, ProxyOptions> = {}
proxy[item.proxyPattern] = {
target: item.baseURL,
changeOrigin: true,
secure: false, // 如果是https接口需要配置这个参数
configure: (_proxy, options) => {
_proxy.on('proxyReq', (_proxyReq, req, _res) => {
if (!enableLog)
return
const requestUrl = `${lightBlue('[proxy url]')}: ${bgYellow(` ${req.method} `)} ${green(`${item.proxyPattern}${req.url}`)}`
const requestUrl = `${lightBlue('[代理地址]')}: ${bgYellow(` ${req.method} `)} ${green(`${item.proxyPattern}${req.url}`)}`
const proxyUrl = `${lightBlue('[real request url]')}: ${green(`${options.target}${req.url}`)}`
const proxyUrl = `${lightBlue('[真实请求地址]')}: ${green(`${options.target}${req.url}`)}`
consola.log(`${requestUrl}\n${proxyUrl}`)
})
_proxy.on('error', (_err, req, _res) => {
if (!enableLog)
return
consola.log(bgRed(`Error: ${req.method} `), green(`${options.target}${req.url}`))
consola.log(bgRed(`错误: ${req.method} `), green(`${options.target}${req.url}`))
})
},
rewrite: path => path.replace(new RegExp(`^${item.proxyPattern}`), ''),

View File

@ -9,11 +9,11 @@
},
"scripts": {
"build": "vite build --mode prod",
"build:test": "vite build --mode test",
"build:dev": "vite build --mode dev",
"cleanup": "sa cleanup",
"commit": "sa git-commit",
"commit:zh": "sa git-commit -l=zh-cn",
"dev": "vite --mode test",
"dev": "vite --mode dev",
"dev:prod": "vite --mode prod",
"gen-route": "sa gen-route",
"lint": "eslint . --fix",
@ -25,13 +25,20 @@
},
"dependencies": {
"@better-scroll/core": "2.5.1",
"@formkit/auto-animate": "^0.9.0",
"@iconify/vue": "5.0.0",
"@opentiny/fluent-editor": "^4.0.1",
"@sa/axios": "workspace:*",
"@sa/color": "workspace:*",
"@sa/hooks": "workspace:*",
"@sa/materials": "workspace:*",
"@sa/utils": "workspace:*",
"@sapphire/snowflake": "^3.5.5",
"@vueuse/core": "14.1.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"@zumer/snapdom": "^2.0.1",
"ali-oss": "^6.23.0",
"clipboard": "2.0.11",
"dayjs": "1.11.19",
"defu": "6.1.4",
@ -39,20 +46,30 @@
"json5": "2.2.3",
"naive-ui": "2.43.2",
"nprogress": "0.2.0",
"path-browserify": "^1.0.1",
"pdfjs-dist": "^5.4.530",
"pinia": "3.0.4",
"pinyin-pro": "^3.28.0",
"quill-toolbar-tip": "^0.1.0",
"tailwind-merge": "3.4.0",
"typeit": "^8.8.7",
"v-scale-screen": "^2.3.0",
"vue": "3.5.26",
"vue-draggable-plus": "0.6.0",
"vue-i18n": "11.2.7",
"vue-router": "4.6.4"
"vue-router": "4.6.4",
"vue3-draggable-resizable": "^1.6.5",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@elegant-router/vue": "0.3.8",
"@iconify/json": "2.2.417",
"@sa/scripts": "workspace:*",
"@sa/uno-preset": "workspace:*",
"@types/ali-oss": "^6.16.13",
"@types/node": "25.0.3",
"@types/nprogress": "0.2.3",
"@types/path-browserify": "^1.0.3",
"@unocss/eslint-config": "66.5.10",
"@unocss/preset-icons": "66.5.10",
"@unocss/preset-uno": "66.5.10",

View File

@ -4,6 +4,7 @@ import { darkTheme, NConfigProvider } from 'naive-ui'
import { computed } from 'vue'
import { naiveDateLocales, naiveLocales } from './locales/naive'
import { useAppStore } from './store/modules/app'
import { useBusinessStore } from './store/modules/business'
import { useThemeStore } from './store/modules/theme'
defineOptions({
@ -12,6 +13,9 @@ defineOptions({
const appStore = useAppStore()
const themeStore = useThemeStore()
const businessStore = useBusinessStore()
/* 初始化字典 */
businessStore.initDict()
const naiveDarkTheme = computed(() => (themeStore.darkMode ? darkTheme : undefined))

Binary file not shown.

After

Width:  |  Height:  |  Size: 656 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.8 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 3.8 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.2 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.2 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 3.2 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

View File

@ -10,10 +10,10 @@ const ContextHolder = defineComponent({
name: 'ContextHolder',
setup() {
function register() {
window.$loadingBar = useLoadingBar()
window.$dialog = useDialog()
window.$message = useMessage()
window.$notification = useNotification()
window.$loadingBar = useLoadingBar() // 注册全局 loading bar
window.$dialog = useDialog()// 注册全局 dialog
window.$message = useMessage()// 注册全局 message
window.$notification = useNotification() // 注册全局 notification
}
register()

View File

@ -0,0 +1,153 @@
<script setup lang="ts">
import type { UploadCustomRequestOptions } from 'naive-ui'
import { NButton, NSpin, NUpload, useMessage } from 'naive-ui'
import { ref } from 'vue'
import { getAliOssTokenAxios } from '@/service/api/upload'
import { browserPathJoin } from '@/utils/date'
import { initOSSClient, uploadFileToOSS } from '@/utils/oss'
const props = withDefaults(defineProps<{
/** 图片地址 */
modelValue?: string
/** 上传路径 */
path?: string // 上传路径,默认 temp/ 目录,同名文件会覆盖
/** 提示文字 */
hint?: string
/** 最大文件大小 (KB) */
maxSize?: number
/** 接受的文件类型 */
accept?: string
/** 模式: default-大图上传(带文字), mini-小图上传(仅图标) */
variant?: 'default' | 'mini'
}>(), {
hint: '支持 JPG/PNG 格式',
accept: 'image/*',
variant: 'default',
})
const emit = defineEmits(['update:modelValue'])
const loading = ref(false)
const message = useMessage()
async function customRequest({ file, onFinish, onError }: UploadCustomRequestOptions) {
try {
// 校验文件大小
if (props.maxSize && file.file) {
const sizeKB = file.file.size / 1024
if (sizeKB > props.maxSize) {
const maxSizeText = props.maxSize >= 1024 ? `${(props.maxSize / 1024).toFixed(2)}MB` : `${props.maxSize}KB`
throw new Error(`文件大小不能超过 ${maxSizeText}`)
}
}
loading.value = true
// 1. 获取上传凭证
const { data: tokenData, error: tokenError } = await getAliOssTokenAxios()
if (tokenError || !tokenData) {
throw new Error('获取上传凭证失败')
}
// 2. 初始化 OSS 客户端
const client = initOSSClient(tokenData.data || {})
// 3. 准备路径
let path = props.path || `temp/${Date.now()}`
path = browserPathJoin(path, file.name)
// 4. 上传文件
await uploadFileToOSS(client, file.file as File, path)
// 5. 获取 URL
const url = browserPathJoin(import.meta.env.VITE_BASE_OSS_URL, path)
emit('update:modelValue', url)
onFinish()
}
catch (error: any) {
message.error(error.message || '上传失败')
onError()
}
finally {
loading.value = false
}
}
function handleRemove() {
emit('update:modelValue', '')
}
</script>
<template>
<NUpload
:accept="accept"
:show-file-list="false"
:custom-request="customRequest"
class="block"
:class="{ 'w-full': props.variant === 'default' }"
>
<div
class="relative flex flex-col cursor-pointer items-center justify-center overflow-hidden transition-all hover:bg-gray-100"
:class="[
props.variant === 'default' ? 'h-[400px] w-full rounded-3xl bg-[#F5F8FF]' : 'h-full w-full bg-[#F5F8FF] rounded-lg',
!modelValue && props.variant === 'default' ? 'border-2 border-dashed border-gray-300' : '',
!modelValue && props.variant === 'mini' ? 'border border-dashed border-gray-300' : '',
]"
>
<div v-if="loading" class="absolute inset-0 z-50 flex items-center justify-center bg-white/50">
<NSpin :size="props.variant === 'mini' ? 'small' : 'large'" />
</div>
<template v-else>
<div
v-if="modelValue"
class="group absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity hover:opacity-100"
>
<div class="flex gap-4">
<!-- NUpload trigger 会自动处理点击事件 -->
<NButton v-if="props.variant === 'default'" ghost color="#fff" size="small">
更换
</NButton>
<div v-else class="cursor-pointer text-xs text-white">
更换
</div>
<NButton v-if="props.variant === 'default'" ghost color="#fff" size="small" @click.stop="handleRemove">
删除
</NButton>
</div>
</div>
<img
v-if="modelValue"
:src="modelValue"
class="h-full w-full object-cover"
alt="uploaded"
>
<div v-else class="flex flex-col items-center justify-center text-gray-400">
<template v-if="props.variant === 'default'">
<slot name="icon">
<icon-ic-baseline-upload class="mb-4 text-6xl" />
</slot>
<div class="text-lg text-gray-600 font-medium">
点击上传
</div>
<div class="mt-2 text-sm text-gray-400">
{{ hint }}
</div>
</template>
<template v-else>
<icon-ic-baseline-upload class="text-xl" />
</template>
</div>
</template>
</div>
</NUpload>
</template>
<style scoped>
:deep(.n-upload-trigger) {
width: 100%;
height: 100%;
}
</style>

View File

@ -0,0 +1,488 @@
<!-- eslint-disable no-console -->
<script setup lang="ts"
generic="K extends string | string[] | Record<string, unknown> | Record<string, unknown>[] | null | undefined"
>
import type OSS from 'ali-oss'
import type { UploadFileInfo } from 'naive-ui'
import type { PropType, Ref } from 'vue'
import { useAutoAnimate } from '@formkit/auto-animate/vue'
import { computed, ref, shallowRef, watch } from 'vue'
import { getAliOssTokenAxios } from '@/service/api/upload'
import { getOnlyId } from '@/utils/data'
import { browserPathJoin } from '@/utils/date'
import { fileToBase64, getFirstFrameOfVideo } from '@/utils/file'
import { initOSSClient, uploadFileToOSS } from '@/utils/oss'
import { FileUploadService } from '@/utils/upload-util'
import { isObject } from '@/utils/verify'
import { verifyAddIntRoZeroNumb } from '@/utils/verify-number'
export interface FileItem {
/** 唯一 ID */
__id: number | string
/** 浏览器中的文件对象 */
file?: File
/** 进度 */
percentage: number
/** 当前 index */
index: number
/** 上传状态; 0:正在上传; 1:上传成功; -1:上传失败; */
uploadStatus: -1 | 0 | 1
/** 错误原因 */
errMsg?: string
/** url */
url: string
/** 海报 */
poster: string
}
export interface CustomKeys {
/** 绑定上传文件链接的 key即modelValue为对象或对象数组时绑定的上传文件链接的key */
bindFileUrlKey?: string
/** 绑定返回海报链接的 key即modelValue为对象或对象数组时绑定的海报链接的key */
bindPosterUrlKey?: string
/** 上传成功后服务器返回链接中文件链接的 key */
upFileUrlKey?: string
/** 上传成功后服务器返回海报字段的的 key */
upPosterUrlKey?: string
}
const props = defineProps({
/** 上传图片的URL */
path: { type: String, default: undefined },
/** 自定义 key */
customKeys: { type: Object as PropType<CustomKeys>, default: (): CustomKeys => ({}) },
/** 提示文字 */
hint: { type: String, default: '点击上传' },
/** 图片对象数组 */
modelValue: { type: [Array, String, Object, null, undefined] as PropType<K>, default: () => [] },
/** 宽度 */
width: { type: [String, Number], default: 100 },
/** 高度 */
height: { type: [String, Number], default: 100 },
/** 最多上传几张图片 */
limit: { type: Number, default: 1 },
/** 限制可以选择的文件类型(根据后缀限制) 如:['.png', 'jpg'] */
accept: { type: Array as PropType<`.${string}`[]>, default: () => ['.png', '.jpg'] },
/** 加载状态 */
loading: { type: Boolean, default: false },
/** 选择图片完成后的回调 */
afterSelectFile: { type: Function as PropType<(data: File[]) => File[] | Promise<File[]>>, default: undefined },
/** 上传成功后的回调 */
afterUpload: { type: Function as PropType<(data: OSS.MultipartUploadResult) => Record<string, unknown> | string>, default: undefined },
/** 限制视频最大允许上传多少 kb */
maxVideoSize: { type: Number, default: undefined },
/** 限制图片最小允许上传多少 kb默认 1 */
minImageSize: { type: Number, default: 1 },
/** 限制图片最大允许上传多少 kb默认 800 */
maxImageSize: { type: Number, default: undefined },
})
const emit = defineEmits<{
(e: 'change', list: FileItem[]): void
(e: 'update:loading', state: boolean): void
(e: 'update:modelValue', list: any): void
(e: 'error', myFile: FileItem): void
}>()
const [parent] = useAutoAnimate()
const upLoading = ref(props.loading)
// 视频预览状态
const showVideoPreview = ref(false)
const videoPreviewSrc = ref('')
const videoPreviewPoster = ref('')
const bindFileUrlKey = computed(() => props.customKeys.bindFileUrlKey || 'url')
const bindPosterUrlKey = computed(() => props.customKeys.bindPosterUrlKey || 'frameUrl')
const upFileUrlKey = computed(() => props.customKeys.upFileUrlKey || 'url')
const upPosterUrlKey = computed(() => props.customKeys.upPosterUrlKey || 'frameUrl')
const newWidth = computed(() => (verifyAddIntRoZeroNumb(`${props.width}`) || typeof props.width === 'number' ? `${props.width}px` : props.width))
const newHeight = computed(() => (verifyAddIntRoZeroNumb(`${props.height}`) || typeof props.height === 'number' ? `${props.height}px` : props.height))
/** 全部的文件(包含上传和未上传的) */
const fileList: Ref<FileItem[]> = ref(getDefFiles(props.modelValue))
/** 得到上传成功的文件(包含本次上传成功和之前上传成功的) */
const uploadedList = shallowRef<Record<string, string>[] | string[]>(getUploadedList())
/** 最终需要得到文件(根据是多选还是单选整理成需要的文件) */
const uploadedFiles = computed(() => (props.limit > 1 ? (uploadedList.value as K) : (uploadedList.value[0] as K)))
/** 现在还可以添加多少个 */
const efficientNumb = computed(() => props.limit - uploadedList.value.length - fileList.value.length)
// NUpload 文件列表,用于选择后清除
const nUploadFileList = ref<UploadFileInfo[]>([])
watch(
() => props.modelValue,
(val) => {
fileList.value = getDefFiles(val)
},
)
watch(
() => upLoading.value,
(val) => {
emit('update:loading', val)
},
)
watch(
() => uploadedFiles.value,
(val) => {
emit('update:modelValue', val)
},
)
/**
* 视频预览
*/
function previewVideoClick(src: string, poster: string) {
if (src) {
videoPreviewSrc.value = src
videoPreviewPoster.value = poster
showVideoPreview.value = true
}
}
/**
* 删除 (已经上传的)
*/
function deleteClick(index: number) {
fileList.value.splice(index, 1)
emit('change', fileList.value)
uploadedList.value = getUploadedList()
}
/**
* 重新上传
*/
async function reUploadClick(index: number) {
try {
upLoading.value = true
await uploadFile(fileList.value[index]!)
upLoading.value = false
emit('change', fileList.value)
uploadedList.value = getUploadedList()
}
catch (error) {
upLoading.value = false
uploadedList.value = getUploadedList()
// eslint-disable-next-line no-console
console.log('重新上传失败====', error)
}
}
/**
* 重置
*/
function reset() {
fileList.value = []
uploadedList.value = []
}
/**
* 上传文件
*/
function handleUploadChange(options: { fileList: UploadFileInfo[] }) {
// 获取新添加的文件
const newFiles = options.fileList.map(item => item.file).filter(Boolean) as File[]
// 清除 NUpload 列表以避免重复并允许重新选择
nUploadFileList.value = []
if (newFiles.length > 0) {
uploadFileList(newFiles)
}
}
/**
* 处理上传所有图片
*/
async function uploadFileList(files: File[] | null) {
try {
if (files && files.length > 0) {
let processFiles = files
if (props.afterSelectFile) {
processFiles = await props.afterSelectFile(files)
}
const promiseArr: FileItem[] = []
// 使用临时列表以避免循环期间 fileList 发生变化的问题(尽管 push 是安全的)
// 但我们需要将其推入 fileList 以立即显示它们
for (let i = 0, len = processFiles.length; i < len; i++) {
if (efficientNumb.value > 0 && processFiles[i]) {
const newItem: FileItem = {
file: processFiles[i]!,
percentage: 0,
uploadStatus: 0,
index: fileList.value.length,
__id: getOnlyId(),
url: '',
poster: '',
}
fileList.value.push(newItem)
// 必须获取响应式对象,否则进度条不会更新
promiseArr.push(fileList.value[fileList.value.length - 1])
}
}
if (promiseArr.length > 0) {
upLoading.value = true
const uploadService = new FileUploadService(uploadFile, 5)
await uploadService.upload(promiseArr)
upLoading.value = false
emit('change', fileList.value)
uploadedList.value = getUploadedList()
}
else {
window.$message?.warning('暂无可上传文件或已达上限')
}
}
}
catch (error) {
uploadedList.value = getUploadedList()
window.$message?.error('上传失败')
console.error(error)
}
}
/**
* 上传文件
*/
async function uploadFile(myFile: FileItem): Promise<FileItem> {
try {
const fileSize = myFile.file!.size / 1024
if (myFile.file!.type.includes('image')) {
if (typeof props.maxImageSize === 'number' && fileSize > props.maxImageSize) {
const msg = getRestrictSizeMsg(props.maxImageSize, 'max')
myFile.uploadStatus = -1
myFile.errMsg = msg
return Promise.reject(new Error(msg))
}
else if (fileSize < props.minImageSize) {
const msg = getRestrictSizeMsg(props.minImageSize, 'min')
myFile.uploadStatus = -1
myFile.errMsg = msg
return Promise.reject(new Error(msg))
}
}
if (typeof props.maxVideoSize === 'number' && myFile.file!.type.includes('video') && fileSize > props.maxVideoSize) {
const msg = getRestrictSizeMsg(props.maxVideoSize, 'max')
myFile.uploadStatus = -1
myFile.errMsg = msg
return Promise.reject(new Error(msg))
}
const { data: tokenData, error: tokenError } = await getAliOssTokenAxios()
if (tokenError || !tokenData) {
throw new Error('获取上传凭证失败')
}
// 初始化OSS客户端
const client = initOSSClient(tokenData?.data || {})
let path = typeof props.path === 'string' ? '' : `temp/${Date.now()}`
path = browserPathJoin(path, myFile.file!.name)
// 上传文件
const res: OSS.MultipartUploadResult = await uploadFileToOSS(client, myFile.file!, path, (progress) => {
myFile.percentage = Math.floor((progress || 0) * 10000) / 100
})
const url = browserPathJoin(import.meta.env.VITE_BASE_OSS_URL, path)
const data = typeof props.afterUpload === 'function' ? props.afterUpload(res) : res
if (isObject(data)) {
const newData = data as Record<string, string>
myFile.url = upFileUrlKey.value in newData ? String(newData[upFileUrlKey.value] || url) : url
myFile.poster = upPosterUrlKey.value in newData ? String(newData[upPosterUrlKey.value] || '') : ''
if (myFile.file!.type.includes('video') && !myFile.poster) {
try {
const info = await getFirstFrameOfVideo(myFile.file!)
myFile.poster = (await fileToBase64(info.firstFrame)).img || ''
}
catch (error) {
// eslint-disable-next-line no-console
console.log('error====', error)
}
}
}
else {
myFile.url = url
}
myFile.uploadStatus = 1
return Promise.resolve(myFile)
}
catch (error: any) {
console.log('上传错误====', error)
myFile.uploadStatus = -1
myFile.errMsg = typeof error.msg === 'string' ? error.msg : '上传失败'
emit('error', error)
return Promise.reject(new Error(myFile.errMsg))
}
}
/** 得到初始已上传的文件 */
function getDefFiles(data: unknown): FileItem[] {
const val: unknown[] = Array.isArray(data) ? data : data ? [data] : []
const newVal: FileItem[] = val.map((item, index: number) => {
const _item: FileItem = isObject(item)
? {
url: String(item[bindFileUrlKey.value] || ''),
poster: String(item[bindPosterUrlKey.value] || ''),
uploadStatus: 1,
index,
__id: getOnlyId(),
percentage: 0,
}
: { url: String(item), poster: '', uploadStatus: 1, index, __id: getOnlyId(), percentage: 0 }
return _item
})
return newVal
}
/** 得到已上传成功的文件 */
function getUploadedList() {
const list = fileList.value.filter(item => item.uploadStatus === 1 && item.url)
// 不能用 bindFileUrlKey 代替
if (props.customKeys.bindFileUrlKey) {
return list.map((item) => {
const obj: Record<string, string> = {}
obj[bindFileUrlKey.value] = item.url
obj[bindPosterUrlKey.value] = item.poster
return obj
})
}
else {
return list.map(item => item.url)
}
}
/** 得到不符合大小限制的提示语 */
function getRestrictSizeMsg(maxSize: number, type: 'max' | 'min') {
const maxSizeText = maxSize >= 1024 ? `${Number((maxSize / 1024).toFixed(2))}M` : `${maxSize}KB`
return type === 'max' ? `文件大小不能超过${maxSizeText}` : `文件大小不能小于${maxSizeText}`
}
defineExpose({ reset, getLoadState: () => upLoading.value })
</script>
<template>
<div class="rest-upload-container">
<NImageGroup>
<div ref="parent" class="flex flex-wrap gap-4">
<!-- 文件列表 -->
<div
v-for="(item, index) in fileList" :key="item.__id"
class="group relative overflow-hidden border border-gray-200 rounded-lg transition-all duration-300 hover:border-primary"
:style="{ width: newWidth, height: newHeight }"
>
<!-- 图片预览 -->
<div
v-if="!item.url.includes('.mp4') && !item.url.includes('.webm') && (item.file?.type?.includes('image') || !item.file)"
class="h-full w-full flex items-center justify-center bg-gray-50"
>
<NImage
:src="item.url" class="h-full w-full flex items-center justify-center"
:img-props="{ style: { width: '100%', height: '100%', objectFit: 'contain' } }" object-fit="cover"
>
<template #error>
<div class="h-full w-full flex items-center justify-center bg-gray-50 text-gray-400">
<SvgIcon icon="mdi:image-broken-variant" class="text-2xl" />
</div>
</template>
</NImage>
</div>
<!-- 视频预览 -->
<div
v-else class="h-full w-full flex items-center justify-center bg-black bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: item.poster ? `url(${item.poster})` : undefined }"
>
<SvgIcon icon="mdi:play-circle-outline" class="text-4xl text-white opacity-80" />
</div>
<!-- 进度覆盖层 -->
<div
v-if="item.uploadStatus === 0"
class="absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/50 p-1"
>
<NProgress
type="circle" :percentage="item.percentage" :color="{ stops: ['#E3F2FD', '#2080f0'] }"
style="width: 60px; height: 60px"
>
<template #default>
<span class="text-xs text-white">{{ item.percentage }}%</span>
</template>
</NProgress>
</div>
<!-- 错误覆盖层 -->
<div
v-if="item.uploadStatus === -1"
class="absolute inset-0 z-20 flex flex-col items-center justify-center bg-red-50/90 p-2 text-center"
>
<span class="mb-1 text-xs text-red-500">{{ item.errMsg || '上传失败' }}</span>
<NButton size="tiny" type="error" ghost @click="reUploadClick(index)">
重试
</NButton>
<div class="absolute right-1 top-1 cursor-pointer" @click="deleteClick(index)">
<SvgIcon icon="mdi:close" class="text-red-500" />
</div>
</div>
<!-- 操作覆盖层悬停 -->
<div
v-if="item.uploadStatus === 1"
class="pointer-events-none absolute inset-0 z-10 flex items-center justify-center gap-2 bg-black/0 opacity-0 transition-all duration-300 group-hover:bg-black/50 group-hover:opacity-100"
>
<NButton
v-if="item.url.includes('.mp4') || item.url.includes('.webm') || item.file?.type?.includes('video')"
circle secondary type="info" class="pointer-events-auto"
@click="previewVideoClick(item.url, item.poster)"
>
<template #icon>
<SvgIcon icon="mdi:eye" />
</template>
</NButton>
<NButton v-else circle secondary type="info" class="pointer-events-none">
<template #icon>
<SvgIcon icon="mdi:eye" />
</template>
</NButton>
<NButton circle secondary type="error" class="pointer-events-auto" @click="deleteClick(index)">
<template #icon>
<SvgIcon icon="mdi:delete" />
</template>
</NButton>
</div>
</div>
<!-- 上传触发器 -->
<div v-if="efficientNumb > 0" :style="{ width: newWidth, height: newHeight }">
<NUpload
:accept="accept.join(',')" :multiple="limit > 1" :max="limit" :show-file-list="false"
:file-list="nUploadFileList" :default-upload="false" class="h-full w-full" @change="handleUploadChange"
>
<template #default>
<div
class="h-full w-full flex flex-col cursor-pointer items-center justify-center border border-gray-300 rounded-lg border-dashed bg-gray-50 transition-colors hover:border-primary hover:bg-primary-50"
:style="{ width: newWidth, height: newHeight }"
>
<SvgIcon icon="mdi:plus" class="text-3xl text-gray-400" />
<span class="mt-1 text-xs text-gray-400">{{ hint }}</span>
</div>
</template>
</NUpload>
</div>
</div>
</NImageGroup>
<!-- 视频预览弹窗 -->
<NModal v-model:show="showVideoPreview" preset="card" title="视频预览" class="max-w-full w-[800px]">
<div class="flex items-center justify-center rounded-lg bg-black/5 p-4">
<video
v-if="videoPreviewSrc" :src="videoPreviewSrc" :poster="videoPreviewPoster" controls
class="max-h-[600px] max-w-full w-auto"
/>
</div>
</NModal>
</div>
</template>
<style scoped>
.rest-upload-container {
/* 确保容器不会塌陷 */
min-height: v-bind(newHeight);
}
</style>

View File

@ -0,0 +1,128 @@
<script setup lang="ts">
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { nextTick, onBeforeUnmount, onMounted, shallowRef } from 'vue'
import { getAliOssTokenAxios } from '@/service/api/upload'
import { browserPathJoin } from '@/utils/date'
import { initOSSClient, uploadFileToOSS } from '@/utils/oss'
import '@wangeditor/editor/dist/css/style.css'
interface Props {
modelValue: string
placeholder?: string
mode?: 'default' | 'simple'
height?: string
path?: string
excludeKeys?: string[]
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
placeholder: '请输入内容...',
mode: 'default',
height: '300px',
path: 'temp',
excludeKeys: () => [],
})
const emit = defineEmits(['update:modelValue'])
// Editor Logic
const editorRef = shallowRef()
const toolbarConfig = {
excludeKeys: props.excludeKeys || [],
}
type InsertFnType = (url: string, alt: string, href: string) => void
async function customUpload(file: File, insertFn: InsertFnType) {
try {
const { data: tokenData, error: tokenError } = await getAliOssTokenAxios()
if (tokenError || !tokenData) {
window.$message?.error('获取上传凭证失败')
return
}
const client = initOSSClient(tokenData?.data || {})
const path = `${props.path}/${Date.now()}/${file.name}`
await uploadFileToOSS(client, file, path)
const url = browserPathJoin(import.meta.env.VITE_BASE_OSS_URL, path)
insertFn(url, file.name, url)
}
catch (error) {
console.error('上传失败', error)
window.$message?.error('上传失败')
}
}
const editorConfig = {
placeholder: props.placeholder,
MENU_CONF: {
uploadImage: {
customUpload,
},
uploadVideo: {
customUpload,
},
},
}
// 监听 placeholder 变化
// watch(() => props.placeholder, (_newVal) => {
// if (editorRef.value) {
// // 似乎 wangeditor 不支持动态修改 placeholder这里作为占位
// }
// })
onMounted(() => {
// eslint-disable-next-line no-console
console.log(toolbarConfig, 'toolbarConfig')
nextTick(() => {
// eslint-disable-next-line no-console
console.log(editorRef.value)
})
})
function handleCreated(editor: any) {
editorRef.value = editor
// eslint-disable-next-line no-console
console.log('Toolbar keys:', editor.getAllMenuKeys())
}
function handleChange(editor: any) {
emit('update:modelValue', editor.getHtml())
}
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor == null)
return
editor.destroy()
})
</script>
<template>
<div class="overflow-hidden border border-gray-200 rounded-lg bg-white">
<Toolbar
style="border-bottom: 1px solid #eee"
:editor="editorRef"
:default-config="toolbarConfig"
:mode="mode"
/>
<div :style="{ height: props.height }">
<Editor
:model-value="modelValue"
:style="{ height: '100%', overflowY: 'hidden' }"
:default-config="editorConfig"
:mode="mode"
@on-created="handleCreated"
@on-change="handleChange"
/>
</div>
</div>
</template>
<style scoped>
:deep(.w-e-text-container) {
background-color: transparent;
}
</style>

View File

@ -0,0 +1,126 @@
# SvgIcon 图标组件使用指南
`SvgIcon` 是本项目统一使用的图标组件,底层基于 `@iconify/vue`,支持渲染 **Iconify 开源图标****本地 SVG 图标**
## 1. 快速开始
### 1.1 使用 Iconify 图标 (推荐)
本项目集成了 [Iconify](https://iconify.design/),可直接使用海量开源图标库(如 Material Design, Carbon, Phosphor 等)。
**语法**
```vue
<SvgIcon icon="图集名:图标名" />
```
**示例**
```vue
<!-- Material Design Icons -->
<SvgIcon icon="mdi:home" class="text-xl text-blue-500" />
<!-- Carbon Icons -->
<SvgIcon icon="carbon:user" />
```
### 1.2 使用本地 SVG 图标
当需要使用设计师提供的自定义图标或彩色图标时。
1. **存放**:将 `.svg` 文件放入 `src/assets/svg-icon/` 目录。
2. **引用**:使用 `local-icon` 属性引用文件名(不含 `.svg` 后缀)。
**语法**
```vue
<SvgIcon local-icon="文件名" />
```
**示例**
假设文件位于 `src/assets/svg-icon/custom-logo.svg`
```vue
<SvgIcon local-icon="custom-logo" class="text-32px" />
```
---
## 2. 属性说明 (Props)
该组件定义在 `src/components/custom/svg-icon.vue`
| 属性名 | 类型 | 必填 | 默认值 | 说明 |
| :---------- | :------- | :--- | :----- | :------------------------------------------- |
| `icon` | `string` | 否 | - | Iconify 图标名称,格式为 `collection:name`。 |
| `localIcon` | `string` | 否 | - | 本地 SVG 文件名。**优先级高于 `icon`**。 |
> **提示**
>
> - 组件设置了 `inheritAttrs: false`,但会手动绑定 `class` 和 `style` 到根元素。
> - 你可以通过 Tailwind CSS 类名(如 `text-xl`, `text-red-500`)直接控制图标的大小和颜色。
---
## 3. 如何查找与使用图标
推荐使用 [Icones.js.org](https://icones.js.org/) 图标搜索引擎。
### 3.1 查找步骤
1. 打开 [Icones.js.org](https://icones.js.org/)。
2. 输入关键词搜索(如 `user`, `setting`)。
3. 点击选中的图标,复制底部的 **ID**(例如 `mdi:shield-airplane-outline`)。
### 3.2 使用示例对照
| Icones 图标 ID | 在本项目中的写法 (推荐) |
| :--------------------------------------------- | :---------------------------------------------------------------- |
| `mdi:shield-airplane-outline` | `<SvgIcon icon="mdi:shield-airplane-outline" />` |
| `material-symbols:android-wifi-4-bar-question` | `<SvgIcon icon="material-symbols:android-wifi-4-bar-question" />` |
| `solar:minimize-square-minimalistic-outline` | `<SvgIcon icon="solar:minimize-square-minimalistic-outline" />` |
| `tabler:align-box-top-right` | `<SvgIcon icon="tabler:align-box-top-right" />` |
---
## 4. 常见问题与进阶
### 4.1 什么是 `icon-ic-baseline-refresh`
在项目中(如 `competition-add/index.vue`)你可能会看到这种写法:
```vue
<icon-ic-baseline-refresh class="text-icon" />
```
这是 `unplugin-icons` 插件提供的**自动组件导入**功能。
- **命名规则**`{Prefix}-{Collection}-{Name}`
- **配置来源**`.env` 文件中的 `VITE_ICON_PREFIX=icon`
- **解析**`icon-ic-baseline-refresh` 对应图集 `ic` 下的 `baseline-refresh` 图标。
**建议**:虽然支持这种写法,但为了统一性和灵活性(支持动态变量),**推荐统一使用 `<SvgIcon />` 组件**。
### 4.2 在 Render 函数中使用 (TSX)
在 Naive UI 的 `NTree`, `NDataTable``NMenu` 等需要渲染函数的场景中:
```ts
import { h } from 'vue'
import SvgIcon from '@/components/custom/svg-icon.vue'
// 渲染 Iconify 图标
function renderIcon() {
return h(SvgIcon, {
icon: 'carbon:folder',
class: 'text-gray-500'
})
}
// 渲染本地图标
function renderLocalIcon() {
return h(SvgIcon, {
localIcon: 'custom-logo'
})
}
```

View File

@ -0,0 +1,45 @@
<script lang="ts" setup>
import type { Options } from 'typeit'
import type { El } from 'typeit/dist/types'
import TypeIt from 'typeit'
import { onMounted, shallowRef } from 'vue'
const textRef = shallowRef<El>()
function init() {
if (!textRef.value)
return
const options: Options = {
strings: 'SoybeanAdmin是一个清新优雅、高颜值且功能强大的后台管理模板',
lifeLike: true,
speed: 120,
loop: true,
}
const initTypeIt = new TypeIt(textRef.value, options)
initTypeIt.go()
}
onMounted(() => {
init()
})
</script>
<template>
<div>
<NCard title="打字机 插件" :bordered="false" class="h-full card-wrapper">
<NSpace :vertical="true">
<GithubLink link="https://github.com/alexmacarthur/typeit" />
<WebSiteLink label="文档地址:" link="https://www.typeitjs.com/docs/vanilla/usage/" />
</NSpace>
<NDivider title-placement="left">
基本示例
</NDivider>
<span ref="textRef" class="text-18px" />
</NCard>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,400 @@
<script lang="ts" setup>
import VScaleScreen from 'v-scale-screen'
import { computed, onMounted, ref } from 'vue'
import _defaultBg from '@/assets/imgs/user/home-bg.svg'
import titleBg2 from '@/assets/imgs/user/title-bg2.svg'
import _defaultTitleBg from '@/assets/imgs/user/title-bg.svg'
import { useRouterPush } from '@/hooks/common/router'
interface Props {
/** 是否显示返回按钮 */
showBack?: boolean
/** 是否显示下一步按钮 */
showNext?: boolean
/** 背景图片URL如果不传则尝试内部获取或使用默认 */
bgUrl?: string
/** 下一步按钮的文本,如果存在则显示为文字按钮,否则显示为图标按钮 */
nextBtnText?: string
/** 页面标题 */
title?: string
/** 是否显示标题 */
showTitle?: boolean
/** 按钮操作栏位置:'bottom' | 'top',默认 'bottom' */
actionPosition?: 'bottom' | 'top'
/** 返回按钮的文本,如果存在则显示为文字按钮,否则显示为图标按钮 */
backBtnText?: string
/** 按钮主题:'primary' (红色填充) | 'light' (白色背景红字),默认 'primary' */
btnTheme?: 'primary' | 'light'
/** 是否禁用下一步按钮 */
nextDisabled?: boolean
/** 标题区域背景图片URL不传则使用默认 title-bg.svg */
titleBgType?: number
}
const props = withDefaults(defineProps<Props>(), {
showBack: true,
showNext: true,
bgUrl: undefined,
nextBtnText: undefined,
title: undefined,
showTitle: true,
actionPosition: 'bottom',
backBtnText: undefined,
btnTheme: 'primary',
nextDisabled: false,
titleBgType: 1,
})
const emit = defineEmits<{
(e: 'back'): void
(e: 'next'): void
}>()
const titleBgUrlMapping: Record<number, string> = {
1: _defaultTitleBg,
2: titleBg2,
}
const { routerPushByKey } = useRouterPush()
const titleBgUrl = computed(() => titleBgUrlMapping[props.titleBgType] || _defaultBg)
// const router = useRouter()
const innerBgUrl = ref(_defaultBg)
async function fetchSystemConfig() {
// 如果外部传入了 bgUrl则优先使用外部的
if (props.bgUrl) {
innerBgUrl.value = props.bgUrl
}
// 模拟接口请求系统配置
// const res = await fetchConfig()
// if (res.bgUrl) innerBgUrl.value = res.bgUrl
// 这里的逻辑保持与原 Home 页面一致:如果没有配置,则使用默认
if (!innerBgUrl.value)
innerBgUrl.value = _defaultBg
}
const showContent = ref(false)
function handleBack() {
emit('back')
}
function handleNext() {
if (props.nextDisabled)
return
emit('next')
}
/** 点击返回首页 */
function handleBackHome() {
routerPushByKey('user_home')
}
onMounted(() => {
fetchSystemConfig()
setTimeout(() => {
showContent.value = true
}, 100)
})
</script>
<template>
<div class="competition-layout-wrapper">
<!-- Background Layer (Outside Scale to cover full screen) -->
<div class="fixed-bg" :style="innerBgUrl ? { backgroundImage: `url(${innerBgUrl})` } : {}" />
<VScaleScreen width="1920" height="1080">
<div class="competition-layout">
<!-- Header -->
<header class="page-header text-[30px] text-[#333333] font-bold">
<span class="org-name" @click="handleBackHome">教育学会</span>
<span class="org-name">树人研究院</span>
</header>
<div class="glass-overlay">
<!-- title -->
<section v-if="showTitle" class="title-section">
<Transition name="fade-slide-down" appear>
<div v-if="showContent" class="title-section">
<div class="scroll-bg" :style="{ backgroundImage: `url(${titleBgUrl})` }">
<h1 class="main-title">
{{ title }}
</h1>
</div>
</div>
</Transition>
</section>
<!-- Main Content Slot -->
<main class="main-content">
<slot />
</main>
<!-- Footer Action -->
<footer
class="page-footer w-full flex items-center justify-between px-20"
:class="[actionPosition, btnTheme]"
>
<button
class="action-btn back-btn" :class="{ 'text-btn': backBtnText }"
:style="{ visibility: showBack ? 'visible' : 'hidden' }" @click="handleBack"
>
<span v-if="backBtnText" class="btn-text">{{ backBtnText }}</span>
<SvgIcon v-else icon="mdi:arrow-left" class="arrow-icon" />
</button>
<button
v-if="showNext" class="action-btn next-btn text-btn" :class="{ 'is-disabled': nextDisabled }"
@click="handleNext"
>
<span class="btn-text">{{ nextBtnText || '下一步' }}</span>
<!-- <SvgIcon v-else icon="mdi:arrow-right" class="arrow-icon" /> -->
</button>
</footer>
</div>
</div>
</VScaleScreen>
</div>
</template>
<style lang="scss" scoped>
.title-section {
margin: 10px auto;
width: 412px;
height: 165px;
.scroll-bg {
width: 100%;
height: 100%;
position: relative;
background: no-repeat center center;
background-size: 100% 100%;
display: flex;
align-items: center;
justify-content: center;
padding-bottom: 10px; // 微调垂直位置,因为卷轴可能有视觉重心偏移
.main-title {
font-size: 2.6rem;
color: $text-color;
margin: 0;
letter-spacing: 4px;
text-align: center;
}
}
}
.competition-layout-wrapper {
width: 100vw;
height: 100vh;
overflow: hidden;
position: relative;
background-color: #f0f2f5; // 给一个底色,防止背景图加载前白屏
}
.fixed-bg {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
z-index: 0; // 提升 z-index确保不被底层遮挡
pointer-events: none;
}
.competition-layout {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 70px;
overflow: hidden;
position: relative;
font-family: 'Noto Serif SC', serif;
z-index: 1; // 确保内容在背景之上
// Default background decoration (Bottom Wave)
&::before {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 40%;
background-repeat: no-repeat;
background-position: bottom;
background-size: cover;
z-index: 0;
pointer-events: none;
}
}
.glass-overlay {
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(2px);
border-radius: 30px;
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.1);
border: 1px solid rgba(255, 255, 255, 0.6);
display: flex;
flex-direction: column;
position: relative;
z-index: 1;
overflow: hidden;
}
.page-header {
position: absolute;
top: 0;
left: 0;
width: 100%;
display: flex;
justify-content: space-between;
padding: 20px 40px;
font-weight: 700;
color: $secondary-color;
z-index: 10;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center; // 让内容决定位置,通常是 margin-top
// margin: 40px auto;
z-index: 10;
padding-bottom: 20px;
width: 100%; // 确保内容宽度
}
.page-footer {
position: absolute;
bottom: 40px;
right: 0; // 默认在右下角
z-index: 20;
&.top {
bottom: auto;
top: 40px;
}
.action-btn {
width: 50px;
height: 50px;
border-radius: 50%;
border: none;
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.1s ease;
background: #eb5e55;
box-shadow: 0 8px 0 #fcc64f;
.arrow-icon {
font-size: 32px;
}
&:hover {
transform: translateY(-2px);
filter: brightness(1.05);
}
&:active {
transform: translateY(4px);
box-shadow: 0 2px 0 #fcc64f;
}
// 文字按钮通用样式
&.text-btn {
width: auto;
min-width: 180px;
padding: 0 30px;
border-radius: 40px;
.btn-text {
font-size: 24px;
font-weight: bold;
letter-spacing: 2px;
}
}
&.next-btn {
// 保留原有 next-btn 特定逻辑(如果有)
}
&.back-btn {
// 保持统一风格
}
// 禁用状态
&.is-disabled {
cursor: not-allowed;
// opacity: 0.8; // 移除透明度变化
// filter: grayscale(0.5); // 移除置灰效果
// box-shadow: none; // 保持阴影,或者根据需要调整
&:hover {
transform: none; // 禁止 hover 位移
// filter: grayscale(0.5);
}
&:active {
transform: none;
// box-shadow: none; // 保持阴影
}
}
}
// Light Theme Style (White bg, Red text/border, Yellow shadow)
&.light {
.action-btn {
background: white;
color: #eb5e55;
border: 2px solid #eb5e55;
box-shadow: 0 8px 0 #fcc64f; // 还原黄色立体阴影
&:hover {
background-color: #fff; // 保持白色
transform: translateY(-2px);
}
&:active {
transform: translateY(4px);
box-shadow: 0 2px 0 #fcc64f;
}
}
}
}
// Transitions
.fade-slide-down-enter-active,
.fade-slide-down-leave-active {
transition: all 0.8s ease;
}
.fade-slide-down-enter-from,
.fade-slide-down-leave-to {
opacity: 0;
transform: translateY(-30px);
}
// Responsive
@media (max-width: 768px) {
.page-header {
padding: 15px 20px;
font-size: 1rem;
}
}
</style>

View File

@ -0,0 +1,48 @@
<script setup lang="ts">
interface Props {
char?: string
width?: string | number
height?: string | number
fontSize?: string | number
showChar?: boolean
}
const _props = withDefaults(defineProps<Props>(), {
char: '',
width: '10.8rem', // 对应 w-42 (42 * 0.25rem = 10.5rem)
height: '9.5rem', // 对应 h-38 (38 * 0.25rem = 9.5rem)
// fontSize: '3.25rem', // 对应 text-4xl
showChar: true,
})
</script>
<template>
<div
class="relative flex select-none items-center justify-center border-2 border-red-500 bg-white"
:style="{ width: typeof width === 'number' ? `${width}px` : width, height: typeof height === 'number' ? `${height}px` : height }"
>
<!-- 田字格背景线 -->
<div class="pointer-events-none absolute inset-0 z-0 h-full w-full">
<!-- 横向虚线 -->
<div class="absolute left-0 top-1/2 h-[1px] w-full border-t border-red-400 border-dashed opacity-60" />
<!-- 纵向虚线 -->
<div class="absolute left-1/2 top-0 h-full w-[1px] border-l border-red-400 border-dashed opacity-60" />
<!-- 对角线 -->
<svg width="100%" height="100%" class="absolute inset-0 opacity-60">
<line x1="0" y1="0" x2="100%" y2="100%" stroke="#f87171" stroke-width="1" stroke-dasharray="4 2" />
<line x1="100%" y1="0" x2="0" y2="100%" stroke="#f87171" stroke-width="1" stroke-dasharray="4 2" />
</svg>
</div>
<!-- 文字内容 -->
<span
v-if="showChar"
class="z-10 text-6xl text-gray-800 font-bold font-sans"
>
{{ char }}
</span>
<slot />
</div>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,81 @@
import type { SelectOption } from 'naive-ui'
import { transformRecordToOption } from '@/utils/common'
export const enableStatusRecord: Record<Api.Common.EnableStatus, string> = {
1: '启用',
2: '禁用',
}
export const enableStatusOptions: SelectOption[] = transformRecordToOption(enableStatusRecord) as unknown as SelectOption[]
export const userGenderRecord: Record<Api.SystemManage.UserGender, string> = {
1: '男',
2: '女',
}
export const userGenderOptions: SelectOption[] = transformRecordToOption(userGenderRecord) as unknown as SelectOption[]
export const menuTypeRecord: Record<Api.SystemManage.MenuType, string> = {
1: '目录',
2: '菜单',
}
export const menuTypeOptions: SelectOption[] = transformRecordToOption(menuTypeRecord) as unknown as SelectOption[]
export const menuIconTypeRecord: Record<Api.SystemManage.IconType, string> = {
1: 'iconify',
2: '本地图标',
}
/**
* 题目分数类型
*/
export const scoreTypeRecord: Record<Api.Competition.QuestionScoreType, string> = {
1: '固定分数',
2: '答题个数',
}
/**
* 轮次类型
*/
export const roundTypeRecord: Record<Api.Competition.CompetitionRoundType, string> = {
1: '题包环节',
2: '加时环节',
}
export const uiTypeRecord: Record<Api.Competition.QuestionTemplateId, string> = {
TEMPLATE_DICTATION_HINT: '汉字听写-提示',
TEMPLATE_DICTATION_HOMOPHONE: '汉字听写-同音',
TEMPLATE_COMPONENT_ADD: '汉字加一加',
TEMPLATE_WORD_DICTATION: '词语听写',
TEMPLATE_IDIOM_TEXT_REQ: '成语-文字要求',
TEMPLATE_IDIOM_IMAGE: '成语-看图',
}
export const timeRecord: Record<Api.Competition.QuestionTimeType, string> = {
5: '5s',
10: '10s',
15: '15s',
20: '20s',
30: '30s',
45: '45s',
60: '60s',
90: '90s',
}
export const questionTypeRecord: Record<number, string> = {
0: '文字',
1: '图片',
}
export const questionTypeOptions: SelectOption[] = transformRecordToOption(questionTypeRecord, true) as unknown as SelectOption[]
export const timeOptions: SelectOption[] = transformRecordToOption(timeRecord) as unknown as SelectOption[]
export const uiTypeOptions: SelectOption[] = transformRecordToOption(uiTypeRecord) as unknown as SelectOption[]
export const roundTypeOptions: SelectOption[] = transformRecordToOption(roundTypeRecord) as unknown as SelectOption[]
export const scoreTypeOptions: SelectOption[] = transformRecordToOption(scoreTypeRecord) as unknown as SelectOption[]
export const menuIconTypeOptions: SelectOption[] = transformRecordToOption(menuIconTypeRecord) as unknown as SelectOption[]

View File

@ -0,0 +1,31 @@
export enum PublishStatus {
Unpublished = 0,
Published = 1,
Processing = 2,
Finished = 3,
}
export enum RankPublishStatus {
Unpublished = 0,
Published = 1,
}
export enum QuestionType {
SingleChoice = 'SingleChoice', // 单选题
MultipleChoice = 'MultipleChoice', // 多选题
TrueFalse = 'TrueFalse', // 判断题
Text = 'Text', // 文本类型
FillBlank = 'FillBlank', // 填空题
Image = 'image', // 图片类型
// 其他类型...
}
export enum QuestionCategoryEnum {
ChineseCharacterDictation1 = 'ChineseCharacterDictation1', // 汉字听写-提示 (jiu表示小鸟的叫声)
ChineseCharacterDictation2 = 'ChineseCharacterDictation2', // 根据拼音写汉字-提示 (tang)
CharacterRadicalAddition = 'CharacterRadicalAddition', // 汉字加一加 (车)
WordDictation = 'WordDictation', // 词语听写 (zhi re)
IdiomWriting1 = 'IdiomWriting1', // 成语-文字要求 (反义字)
IdiomWriting2 = 'IdiomWriting2', // 成语-文字要求 (看图写成语)
PoetryComprehension = 'PoetryComprehension', // 诗词理解-选择题
}

View File

@ -0,0 +1,36 @@
import { computed, onMounted } from 'vue'
import { useBusinessStore } from '@/store/modules/business'
/**
* 字典项 hook
* @param key 字典项 key
* @param valueType 字典项 value 类型,默认 number
*/
export function useDict(key: string, valueType: 'string' | 'number' = 'number') {
const store = useBusinessStore()
const data = computed(() => store.dictData[key] || [])
const loading = computed(() => store.loadingMap[key] || false)
const options = computed(() => {
if (data.value && data.value.length > 0) {
return data.value.map((item: any) => {
const rawValue = item.uI_Value || item.UI_Value || item.DicValue
return {
label: item.uI_Key || item.UI_Key || item.DicKey,
value: valueType === 'number' ? Number(rawValue) : String(rawValue),
}
})
}
return []
})
onMounted(() => {
store.getDict(key)
})
return {
data,
options,
loading,
}
}

View File

@ -19,6 +19,12 @@ export function useRouterPush(inSetup = true) {
const routerBack = router.back
/**
* 路由跳转
*
* @param key 路由名称
* @param options 路由参数
*/
async function routerPushByKey(key: RouteKey, options?: App.Global.RouterPushOptions) {
const { query, params } = options || {}

View File

@ -13,13 +13,13 @@ export type UseNaiveTableOptions<ResponseData, ApiData, Pagination extends boole
'pagination' | 'getColumnChecks' | 'getColumns'
> & {
/**
* get column visible
* 获取列的可见性
*
* @param column
*
* @default true
*
* @returns true if the column is visible, false otherwise
* @returns 如果列可见返回 true否则返回 false
*/
getColumnVisible?: (column: NaiveUI.TableColumn<ApiData>) => boolean
}
@ -28,8 +28,12 @@ const SELECTION_KEY = '__selection__'
const EXPAND_KEY = '__expand__'
/**
* NaiveUI 表格
* @param options 表格选项
*/
export function useNaiveTable<ResponseData, ApiData>(options: UseNaiveTableOptions<ResponseData, ApiData, false>) {
const scope = effectScope()
const scope = effectScope() // 表格作用域,用于管理表格的响应式数据
const appStore = useAppStore()
const result = useTable<ResponseData, ApiData, NaiveUI.TableColumn<ApiData>, false>({
@ -38,7 +42,7 @@ export function useNaiveTable<ResponseData, ApiData>(options: UseNaiveTableOptio
getColumns,
})
// calculate the total width of the table this is used for horizontal scrolling
// 计算表格的总宽度,用于水平滚动
const scrollX = computed(() => {
return result.columns.value.reduce((acc, column) => {
return acc + Number(column.width ?? column.minWidth ?? 120)
@ -69,7 +73,7 @@ type PaginationParams = Pick<PaginationProps, 'page' | 'pageSize'>
type UseNaivePaginatedTableOptions<ResponseData, ApiData> = UseNaiveTableOptions<ResponseData, ApiData, true> & {
paginationProps?: Omit<PaginationProps, 'page' | 'pageSize' | 'itemCount'>
/**
* whether to show the total count of the table
* 是否显示表格的总条数
*
* @default true
*/
@ -77,6 +81,10 @@ type UseNaivePaginatedTableOptions<ResponseData, ApiData> = UseNaiveTableOptions
onPaginationParamsChange?: (params: PaginationParams) => void | Promise<void>
}
/**
* NaiveUI 分页表格
* @param options 表格选项
*/
export function useNaivePaginatedTable<ResponseData, ApiData>(
options: UseNaivePaginatedTableOptions<ResponseData, ApiData>,
) {
@ -104,7 +112,7 @@ export function useNaivePaginatedTable<ResponseData, ApiData>(
...options.paginationProps,
}) as PaginationProps
// this is for mobile, if the system does not support mobile, you can use `pagination` directly
// 针对移动端,如果系统不支持移动端,可以直接使用 `pagination`
const mobilePagination = computed(() => {
const p: PaginationProps = {
...pagination,
@ -172,11 +180,28 @@ export function useNaivePaginatedTable<ResponseData, ApiData>(
}
}
/**
* NaiveUI 表格操作
* @param data 表格数据
* @param idKey 表格数据的主键字段
* @param getData 获取表格数据的函数
*/
export function useTableOperate<TableData>(
data: Ref<TableData[]>,
idKey: keyof TableData,
getData: () => Promise<void>,
) {
): {
drawerVisible: Ref<boolean> /** 抽屉是否可见 */
openDrawer: () => void /** 打开抽屉 */
closeDrawer: () => void /** 关闭抽屉 */
operateType: Ref<NaiveUI.TableOperateType> /** 操作类型 */
handleAdd: () => void /** 新增操作 */
editingData: Ref<TableData | null> /** 编辑行数据 */
handleEdit: (id: TableData[keyof TableData]) => void /** 编辑操作 */
checkedRowKeys: Ref<string[]> /** 表格的选中行 keys */
onBatchDeleted: () => Promise<void> /** 批量删除操作完成后的钩子 */
onDeleted: () => Promise<void> /** 删除操作完成后的钩子 */
} {
const { bool: drawerVisible, setTrue: openDrawer, setFalse: closeDrawer } = useBoolean()
const operateType = shallowRef<NaiveUI.TableOperateType>('add')
@ -186,7 +211,7 @@ export function useTableOperate<TableData>(
openDrawer()
}
/** the editing row data */
/** 编辑行数据 */
const editingData = shallowRef<TableData | null>(null)
function handleEdit(id: TableData[keyof TableData]) {
@ -197,10 +222,10 @@ export function useTableOperate<TableData>(
openDrawer()
}
/** the checked row keys of table */
/** 表格的选中行 keys */
const checkedRowKeys = shallowRef<string[]>([])
/** the hook after the batch delete operation is completed */
/** 批量删除操作完成后的钩子 */
async function onBatchDeleted() {
window.$message?.success($t('common.deleteSuccess'))
@ -209,7 +234,7 @@ export function useTableOperate<TableData>(
await getData()
}
/** the hook after the delete operation is completed */
/** 删除操作完成后的钩子 */
async function onDeleted() {
window.$message?.success($t('common.deleteSuccess'))
@ -230,17 +255,23 @@ export function useTableOperate<TableData>(
}
}
/**
* 默认的表格数据转换函数
*
* @param response 原始的 API 响应数据
* @returns 转换后的分页数据
*/
export function defaultTransform<ApiData>(
response: FlatResponseData<any, Api.Common.PaginatingQueryRecord<ApiData>>,
): PaginationData<ApiData> {
const { data, error } = response
if (!error) {
const { records, current, size, total } = data
const { data: list, currentPage: pageNum, pageSize: size, total = 0 } = data
return {
data: records,
pageNum: current,
data: list,
pageNum,
pageSize: size,
total,
}

View File

@ -17,7 +17,7 @@ interface Props {
<template>
<RouterLink to="/" class="w-full flex-center nowrap-hidden">
<SystemLogo class="size-32px" />
<SystemLogo class="size-42px" />
<h2 v-show="showTitle" class="pl-8px text-16px text-primary font-bold transition duration-300 ease-in-out">
{{ $t('system.title') }}
</h2>

View File

@ -82,6 +82,10 @@ const local: App.I18n.Schema = {
warning: 'Warning',
error: 'Error',
followPrimary: 'Follow Primary',
unpublished: 'Unpublished',
published: 'Published',
processing: 'Processing',
finished: 'Finished',
},
themeRadius: {
title: 'Theme Radius',
@ -228,11 +232,33 @@ const local: App.I18n.Schema = {
'404': 'Page Not Found',
'500': 'Server Error',
'iframe-page': 'Iframe',
'home': 'Home',
'competition': 'Competition',
'question-store': 'Question Store',
'template': 'Template',
'rank': 'Rank',
'rank_rank-detail': 'Rank Detail',
'rank_rank-list': 'Rank List',
'template_template-detail': 'Template Detail',
'template_template-list': 'Template List',
'user': 'User',
'results': 'Results',
'competition_competition-add': 'Competition Add',
'competition_competition-detail': 'Competition Detail',
'competition_competition-list': 'Competition List',
'dictionary': 'Dictionary',
'admin-home': 'Admin Home',
'user_analysis': 'User Analysis',
'user_cover': 'User Cover',
'user_draw': 'User Draw',
'user_game': 'User Game',
'user_teams': 'User Teams',
'user_rules': 'User Rules',
'user_groups': 'User Groups',
'user_home': 'User Home',
'results_results-list': 'Results List',
'results_results-detail': 'Results Detail',
'test': 'Test',
'user_rank-list': 'User Rank List',
},
page: {
login: {

View File

@ -82,6 +82,10 @@ const local: App.I18n.Schema = {
warning: '警告色',
error: '错误色',
followPrimary: '跟随主色',
unpublished: '未发布',
published: '已发布',
processing: '处理中',
finished: '已完成',
},
themeRadius: {
title: '主题圆角',
@ -224,11 +228,33 @@ const local: App.I18n.Schema = {
'404': '页面不存在',
'500': '服务器错误',
'iframe-page': '外链页面',
'home': '首页',
'competition': '比赛',
'competition': '比赛配置',
'question-store': '题库',
'template': '模板制作',
'rank': '名次排行',
'rank_rank-detail': '名次详情',
'rank_rank-list': '名次列表',
'template_template-detail': '模板详情',
'template_template-list': '模板列表',
'user': '用户管理',
'results': '实时结果',
'competition_competition-add': '比赛添加',
'competition_competition-detail': '比赛详情',
'competition_competition-list': '比赛列表',
'dictionary': '字典管理',
'admin-home': '管理员首页',
'user_analysis': '用户分析',
'user_cover': '用户封面',
'user_draw': '用户绘制',
'user_game': '用户游戏',
'user_teams': '用户队伍',
'user_rules': '用户规则',
'user_groups': '用户组',
'user_home': '用户首页',
'results_results-list': '实时结果列表',
'results_results-detail': '实时结果详情',
'test': 'Test',
'user_rank-list': '用户排名列表',
},
page: {
login: {

View File

@ -89,6 +89,10 @@ export function setupAppVersionNotification() {
}
}
/**
* 获取HTML构建时间
* @returns 构建时间字符串或null
*/
async function getHtmlBuildTime(): Promise<string | null> {
const baseUrl = import.meta.env.VITE_BASE_URL || '/'

View File

@ -20,8 +20,27 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
500: () => import("@/views/_builtin/500/index.vue"),
"iframe-page": () => import("@/views/_builtin/iframe-page/[url].vue"),
login: () => import("@/views/_builtin/login/index.vue"),
"admin-home": () => import("@/views/admin-home/index.vue"),
"competition_competition-add": () => import("@/views/competition/competition-add/index.vue"),
"competition_competition-detail": () => import("@/views/competition/competition-detail/index.vue"),
"competition_competition-list": () => import("@/views/competition/competition-list/index.vue"),
home: () => import("@/views/home/index.vue"),
dictionary: () => import("@/views/dictionary/index.vue"),
"question-store": () => import("@/views/question-store/index.vue"),
"rank_rank-detail": () => import("@/views/rank/rank-detail/index.vue"),
"rank_rank-list": () => import("@/views/rank/rank-list/index.vue"),
"results_results-detail": () => import("@/views/results/results-detail/index.vue"),
"results_results-list": () => import("@/views/results/results-list/index.vue"),
"template_template-detail": () => import("@/views/template/template-detail/index.vue"),
"template_template-list": () => import("@/views/template/template-list/index.vue"),
test: () => import("@/views/test/index.vue"),
user_analysis: () => import("@/views/user/analysis/index.vue"),
user_cover: () => import("@/views/user/cover/index.vue"),
user_draw: () => import("@/views/user/draw/index.vue"),
user_game: () => import("@/views/user/game/index.vue"),
user_groups: () => import("@/views/user/groups/index.vue"),
user_home: () => import("@/views/user/home/index.vue"),
"user_rank-list": () => import("@/views/user/rank-list/index.vue"),
"user_rank-pending": () => import("@/views/user/rank-pending/index.vue"),
user_rules: () => import("@/views/user/rules/index.vue"),
user_teams: () => import("@/views/user/teams/index.vue"),
};

View File

@ -39,13 +39,25 @@ export const generatedRoutes: GeneratedRoute[] = [
hideInMenu: true
}
},
{
name: 'admin-home',
path: '/admin-home',
component: 'layout.base$view.admin-home',
meta: {
title: 'admin-home',
i18nKey: 'route.admin-home',
hideInMenu: true
}
},
{
name: 'competition',
path: '/competition',
component: 'layout.base',
meta: {
title: 'competition',
i18nKey: 'route.competition'
i18nKey: 'route.competition',
icon: 'material-symbols:settings-motion-mode-outline-rounded',
order: 1
},
children: [
{
@ -82,14 +94,14 @@ export const generatedRoutes: GeneratedRoute[] = [
]
},
{
name: 'home',
path: '/home',
component: 'layout.base$view.home',
name: 'dictionary',
path: '/dictionary',
component: 'layout.base$view.dictionary',
meta: {
title: 'home',
i18nKey: 'route.home',
icon: 'mdi:monitor-dashboard',
order: 1
title: 'dictionary',
i18nKey: 'route.dictionary',
icon: 'material-symbols:settings',
order: 6
}
},
{
@ -116,5 +128,249 @@ export const generatedRoutes: GeneratedRoute[] = [
constant: true,
hideInMenu: true
}
},
{
name: 'question-store',
path: '/question-store',
component: 'layout.base$view.question-store',
meta: {
title: 'question-store',
i18nKey: 'route.question-store',
icon: 'solar:clipboard-text-outline',
order: 3
}
},
{
name: 'rank',
path: '/rank',
component: 'layout.base',
meta: {
title: 'rank',
i18nKey: 'route.rank',
icon: 'mdi:chart-line',
order: 5
},
children: [
{
name: 'rank_rank-detail',
path: '/rank/rank-detail',
component: 'view.rank_rank-detail',
meta: {
title: 'rank_rank-detail',
i18nKey: 'route.rank_rank-detail',
icon: 'mdi:chart-line-variant',
multiTab: true,
hideInMenu: true
}
},
{
name: 'rank_rank-list',
path: '/rank/rank-list',
component: 'view.rank_rank-list',
meta: {
title: 'rank_rank-list',
i18nKey: 'route.rank_rank-list',
icon: 'mdi:chart-line-variant',
order: 1
}
}
]
},
{
name: 'results',
path: '/results',
component: 'layout.base',
meta: {
title: 'results',
i18nKey: 'route.results',
icon: 'mdi:clipboard-check-multiple',
order: 2
},
children: [
{
name: 'results_results-detail',
path: '/results/results-detail',
component: 'view.results_results-detail',
meta: {
title: 'results_results-detail',
i18nKey: 'route.results_results-detail',
hideInMenu: true
}
},
{
name: 'results_results-list',
path: '/results/results-list',
component: 'view.results_results-list',
meta: {
title: 'results_results-list',
i18nKey: 'route.results_results-list'
}
}
]
},
{
name: 'template',
path: '/template',
component: 'layout.base',
meta: {
title: 'template',
i18nKey: 'route.template',
icon: 'material-symbols:settings-motion-mode-outline-rounded',
order: 4
},
children: [
{
name: 'template_template-detail',
path: '/template/template-detail',
component: 'view.template_template-detail',
meta: {
title: 'template_template-detail',
i18nKey: 'route.template_template-detail',
icon: 'material-symbols:settings-motion-mode-outline-rounded',
multiTab: true,
hideInMenu: true
}
},
{
name: 'template_template-list',
path: '/template/template-list',
component: 'view.template_template-list',
meta: {
title: 'template_template-list',
i18nKey: 'route.template_template-list',
icon: 'material-symbols:settings-motion-mode-outline-rounded',
order: 1
}
}
]
},
{
name: 'test',
path: '/test',
component: 'layout.base$view.test',
meta: {
title: 'test',
i18nKey: 'route.test'
}
},
{
name: 'user',
path: '/user',
component: 'layout.blank',
redirect: '/user/home',
meta: {
title: 'user',
i18nKey: 'route.user',
hideInMenu: true,
constant: true
},
children: [
{
name: 'user_analysis',
path: '/user/analysis',
component: 'view.user_analysis',
meta: {
title: 'user_analysis',
i18nKey: 'route.user_analysis',
hideInMenu: true,
constant: true
}
},
{
name: 'user_cover',
path: '/user/cover',
component: 'view.user_cover',
meta: {
title: 'user_cover',
i18nKey: 'route.user_cover',
hideInMenu: true,
constant: true
}
},
{
name: 'user_draw',
path: '/user/draw',
component: 'view.user_draw',
meta: {
title: 'user_draw',
i18nKey: 'route.user_draw',
hideInMenu: true,
constant: true
}
},
{
name: 'user_game',
path: '/user/game',
component: 'view.user_game',
meta: {
title: 'user_game',
i18nKey: 'route.user_game',
hideInMenu: true,
constant: true
}
},
{
name: 'user_groups',
path: '/user/groups',
component: 'view.user_groups',
meta: {
title: 'user_groups',
i18nKey: 'route.user_groups',
hideInMenu: true,
constant: true
}
},
{
name: 'user_home',
path: '/user/home',
component: 'view.user_home',
meta: {
title: 'user_home',
i18nKey: 'route.user_home',
hideInMenu: true,
constant: true
}
},
{
name: 'user_rank-list',
path: '/user/rank-list',
component: 'view.user_rank-list',
meta: {
title: 'user_rank-list',
i18nKey: 'route.user_rank-list'
}
},
{
name: 'user_rank-pending',
path: '/user/rank-pending',
component: 'view.user_rank-pending',
meta: {
title: 'user_rank-pending',
i18nKey: 'route.user_rank-pending'
}
},
{
name: 'user_rules',
path: '/user/rules',
component: 'view.user_rules',
meta: {
title: 'user_rules',
i18nKey: 'route.user_rules',
hideInMenu: true,
constant: true
}
},
{
name: 'user_teams',
path: '/user/teams',
component: 'view.user_teams',
meta: {
title: 'user_teams',
i18nKey: 'route.user_teams',
hideInMenu: true,
constant: true
}
}
]
}
];

View File

@ -166,13 +166,36 @@ const routeMap: RouteMap = {
"403": "/403",
"404": "/404",
"500": "/500",
"admin-home": "/admin-home",
"competition": "/competition",
"competition_competition-add": "/competition/competition-add",
"competition_competition-detail": "/competition/competition-detail",
"competition_competition-list": "/competition/competition-list",
"home": "/home",
"dictionary": "/dictionary",
"iframe-page": "/iframe-page/:url",
"login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?"
"login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?",
"question-store": "/question-store",
"rank": "/rank",
"rank_rank-detail": "/rank/rank-detail",
"rank_rank-list": "/rank/rank-list",
"results": "/results",
"results_results-detail": "/results/results-detail",
"results_results-list": "/results/results-list",
"template": "/template",
"template_template-detail": "/template/template-detail",
"template_template-list": "/template/template-list",
"test": "/test",
"user": "/user",
"user_analysis": "/user/analysis",
"user_cover": "/user/cover",
"user_draw": "/user/draw",
"user_game": "/user/game",
"user_groups": "/user/groups",
"user_home": "/user/home",
"user_rank-list": "/user/rank-list",
"user_rank-pending": "/user/rank-pending",
"user_rules": "/user/rules",
"user_teams": "/user/teams"
};
/**

View File

@ -12,9 +12,9 @@ import { useRouteStore } from '@/store/modules/route'
import { localStg } from '@/utils/storage'
/**
* create route guard
* 创建路由守卫
*
* @param router router instance
* @param router 路由实例
*/
export function createRouteGuard(router: Router) {
router.beforeEach(async (to, from, next) => {
@ -38,39 +38,43 @@ export function createRouteGuard(router: Router) {
const hasRole = authStore.userInfo.roles.some(role => routeRoles.includes(role))
const hasAuth = authStore.isStaticSuper || !routeRoles.length || hasRole
// if it is login route when logged in, then switch to the root page
// 如果已登录且访问的是登录页,则跳转到根页面
if (to.name === loginRoute && isLogin) {
next({ name: rootRoute })
return
}
// if the route does not need login, then it is allowed to access directly
// 如果路由不需要登录,则直接允许访问
if (!needLogin) {
handleRouteSwitch(to, from, next)
return
}
// the route need login but the user is not logged in, then switch to the login page
if (!isLogin) {
// 如果路由需要登录但用户未登录,则跳转到登录页
const isAuthRouteForceLogin = import.meta.env.VITE_AUTH_ROUTE_FORCE_LOGIN === 'Y'
const whiteList: string[] = ['user']
const isInWhiteList = whiteList.includes(to.name as string)
if (isAuthRouteForceLogin && !isLogin && !isInWhiteList) {
next({ name: loginRoute, query: { redirect: to.fullPath } })
return
}
// if the user is logged in but does not have authorization, then switch to the 403 page
// 如果用户已登录但没有权限,则跳转到 403 页面
if (!hasAuth) {
next({ name: noAuthorizationRoute })
return
}
// switch route normally
// 正常跳转路由
handleRouteSwitch(to, from, next)
})
}
/**
* initialize route
* 初始化路由
*
* @param to to route
* @param to 目标路由
*/
async function initRoute(to: RouteLocationNormalized): Promise<RouteLocationRaw | null> {
const routeStore = useRouteStore()
@ -78,12 +82,12 @@ async function initRoute(to: RouteLocationNormalized): Promise<RouteLocationRaw
const notFoundRoute: RouteKey = 'not-found'
const isNotFoundRoute = to.name === notFoundRoute
// if the constant route is not initialized, then initialize the constant route
// 如果常量路由未初始化,则初始化常量路由
if (!routeStore.isInitConstantRoute) {
await routeStore.initConstantRoute()
// the route is captured by the "not-found" route because the constant route is not initialized
// after the constant route is initialized, redirect to the original route
// 由于常量路由未初始化,路由被“未找到”路由捕获
// 常量路由初始化后,重定向到原始路由
const path = to.fullPath
const location: RouteLocationRaw = {
path,
@ -97,15 +101,19 @@ async function initRoute(to: RouteLocationNormalized): Promise<RouteLocationRaw
const isLogin = Boolean(localStg.get('token'))
if (!isLogin) {
// if the user is not logged in and the route is a constant route but not the "not-found" route, then it is allowed to access.
const isAuthRouteForceLogin = import.meta.env.VITE_AUTH_ROUTE_FORCE_LOGIN === 'Y'
const whiteList: string[] = ['user']
const isInWhiteList = whiteList.includes(to.name as string)
if (isAuthRouteForceLogin && !isLogin && !isInWhiteList) {
// 如果用户未登录且路由是常量路由但不是“未找到”路由,则允许访问。
if (to.meta.constant && !isNotFoundRoute) {
routeStore.onRouteSwitchWhenNotLoggedIn()
return null
}
// if the user is not logged in, then switch to the login page
// 如果用户未登录,则跳转到登录页
const loginRoute: RouteKey = 'login'
const query = getRouteQueryOfLoginRoute(to, routeStore.routeHome)
@ -118,11 +126,11 @@ async function initRoute(to: RouteLocationNormalized): Promise<RouteLocationRaw
}
if (!routeStore.isInitAuthRoute) {
// initialize the auth route
// 初始化权限路由
await routeStore.initAuthRoute()
// the route is captured by the "not-found" route because the auth route is not initialized
// after the auth route is initialized, redirect to the original route
// 由于权限路由未初始化,路由被“未找到”路由捕获
// 权限路由初始化后,重定向到原始路由
if (isNotFoundRoute) {
const rootRoute: RouteKey = 'root'
const path = to.redirectedFrom?.name === rootRoute ? '/' : to.fullPath
@ -140,13 +148,13 @@ async function initRoute(to: RouteLocationNormalized): Promise<RouteLocationRaw
routeStore.onRouteSwitchWhenLoggedIn()
// the auth route is initialized
// it is not the "not-found" route, then it is allowed to access
// 权限路由已初始化
// 如果不是“未找到”路由,则允许访问
if (!isNotFoundRoute) {
return null
}
// it is captured by the "not-found" route, then check whether the route exists
// 如果被“未找到”路由捕获,则检查路由是否存在
const exist = await routeStore.getIsAuthRouteExist(to.path as RoutePath)
const noPermissionRoute: RouteKey = '403'
@ -162,7 +170,7 @@ async function initRoute(to: RouteLocationNormalized): Promise<RouteLocationRaw
}
function handleRouteSwitch(to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) {
// route with href
// 外链路由
if (to.meta.href) {
window.open(to.meta.href, '_blank')

View File

@ -18,7 +18,7 @@ const historyCreatorMap: Record<Env.RouterHistoryMode, (base?: string) => Router
}
export const router = createRouter({
history: historyCreatorMap[VITE_ROUTER_HISTORY_MODE](VITE_BASE_URL),
history: historyCreatorMap[VITE_ROUTER_HISTORY_MODE](VITE_BASE_URL), // 路由模式
routes: createBuiltinVueRoutes(),
})

View File

@ -8,7 +8,28 @@ import { transformElegantRoutesToVueRoutes } from '../elegant/transform'
*
* @link https://github.com/reader-starjs/elegant-router?tab=readme-ov-file#custom-route
*/
const customRoutes: CustomRoute[] = []
const customRoutes: CustomRoute[] = [
{
name: 'root',
path: '/',
redirect: '/user/home',
meta: {
title: 'root',
constant: true,
hideInMenu: true,
},
} as unknown as CustomRoute,
{
name: 'admin',
path: '/admin',
redirect: '/competition/competition-list',
meta: {
title: 'competition/competition-list',
constant: true,
hideInMenu: true,
},
} as unknown as CustomRoute,
]
/** create routes when the auth route mode is static */
export function createStaticRoutes() {

View File

@ -6,20 +6,20 @@ import { request } from '../request'
* @param userName User name
* @param password Password
*/
export function fetchLogin(userName: string, password: string) {
export function fetchLogin(account: string, pwd: string) {
return request<Api.Auth.LoginToken>({
url: '/auth/login',
url: '/admin/v1/user/login',
method: 'post',
data: {
userName,
password,
account,
pwd,
},
})
}
/** Get user info */
export function fetchGetUserInfo() {
return request<Api.Auth.UserInfo>({ url: '/auth/getUserInfo' })
export function fetchGetUserInfo(userId: number) {
return request<Api.Auth.UserInfo>({ url: `admin/v1/manager/detail/${userId}` })
}
/**

View File

@ -0,0 +1,150 @@
import { request } from '../request'
/** 房间相关接口 */
export function fetchGetRoomList() {
return request<App.Service.Response<Api.Competition.Room[]>>({
url: '/Base/ActivityMain/GetActivity_RoomList',
method: 'get',
})
}
// 现在服务端把活动新建弄成了三个接口:
// 1. 创建活动
// 2. 创建活动房间
// 3. 创建活动队伍
// 但是业务要求最后一步创建
/** 创建活动基础信息 */
export function fetchCreateActivity(data: Api.Competition.CreateRoomRequest) {
return request<App.Service.Response<Api.Common.CommonResponse>>({
url: '/Base/ActivityMain/AddActivity',
method: 'post',
data,
})
}
/** 获取活动的基础信息 */
export function fetchGetActivityDetail(id: number) {
return request<App.Service.Response<Api.Competition.ActivityDetail>>({
url: `/Base/ActivityMain/GetActivityByID/?ID=${id}`,
method: 'get',
})
}
/** 创建活动队伍 */
export function fetchCreateTeamList(data: Api.Competition.CreateTeamListRequest[]) {
return request<App.Service.Response<Api.Common.CommonResponse>>({
url: '/Base/ActivityMain/AddActivity_TeamList',
method: 'post',
data,
})
}
/** 根据活动ID获取活动队伍列表 */
export function fetchGetTeamList(id: number) {
return request<App.Service.Response>({
url: `/Base/ActivityMain/GetActivity_TeamsByMainID/?MainID=${id}`,
method: 'get',
})
}
/** 创建活动题目 */
export function fetchCreateQuestion(data: Api.Competition.CreateQuestionRequest[]) {
return request<App.Service.Response<Api.Common.CommonResponse>>({
url: '/Base/ActivityMain/AddOrUpdateActivity_Question',
method: 'post',
data,
})
}
/** 根据活动ID获取活动题目列表 */
export function fetchGetQuestionList(id: number) {
return request<App.Service.Response<Api.Competition.QuestionListRecord[]>>({
url: `/Base/ActivityMain/GetActivity_QuestionByActivityID?ID=${id}`,
method: 'get',
})
}
/** 获取活动列表 */
export function fetchGetActivityList() {
return request<App.Service.Response>({
url: '/Base/ActivityMain/Activity_MainList',
method: 'get',
})
}
/** 新增活动 (包括基础信息、房间、队伍、题目) */
export function fetchCreateCompetition(data: any) {
return request<App.Service.Response<Api.Common.CommonResponse>>({
url: '/Base/ActivityMain/AddActivityAllData',
method: 'post',
data,
})
}
/** 根据活动id查询分组列表 */
export function fetchGetGroupList(id: string) {
return request<App.Service.Response>({
url: `/Base/ActivityMain/GetActivity_TeamGroupByMainID?ActivityID=${id}`,
method: 'get',
})
}
/** 根据组id查询队伍列表 */
export function fetchGetTeamListByGroupId(GroupID: number) {
return request<App.Service.Response<Api.Competition.TeamListRecord[]>>({
url: `/Base/ActivityMain/GetActivity_TeamsByTeamGroupID?GroupID=${GroupID}`,
method: 'get',
})
}
/** 更新活动基础信息 */
export function fetchUpdateActivity(data: Api.Competition.ActivityDetail) {
return request<App.Service.Response<Api.Common.CommonResponse>>({
url: '/Base/ActivityMain/UpdateActivity',
method: 'post',
data,
})
}
/** 更新活动队伍 */
export function fetchUpdateTeamList(data: Api.Competition.CreateTeamListRequest[]) {
return request<App.Service.Response<Api.Common.CommonResponse>>({
url: '/Base/ActivityMain/UpdateActivity_TeamList',
method: 'post',
data,
})
}
/** 更新活动题目 */
export function fetchUpdateQuestion(data: Api.Competition.QuestionListRecord[]) {
return request<App.Service.Response<Api.Common.CommonResponse>>({
url: '/Base/ActivityMain/AddOrUpdateActivity_Question',
method: 'post',
data,
})
}
/** 删除活动 */
export function fetchDeleteActivity(id: number) {
return request<App.Service.Response<Api.Common.CommonResponse>>({
url: `/Base/ActivityMain/DeleteActivity?ID=${id}`,
method: 'post',
})
}
/** 更新活动状态 */
export function fetchUpdatePublishStatus(id: number, status: number) {
return request<App.Service.Response<Api.Common.CommonResponse>>({
url: `/Base/ActivityMain/UpdateActivityStatus?ActivityID=${id}&status=${status}`,
method: 'post',
})
}
/** 复制活动 */
export function fetchCopyActivity(ActivityID: number) {
return request<App.Service.Response<Api.Common.CommonResponse>>({
url: `/Base/ActivityMain/CopyActivity?ActivityID=${ActivityID}`,
method: 'post',
})
}

View File

@ -0,0 +1,70 @@
import { request } from '../request'
/** Get dictionary list */
export function getDictionaryList(roomID?: number) {
return request<Api.Common.CommonResponse>({
url: '/Base/ActivityMain/GetDictionaryList',
method: 'get',
params: {
RoomId: roomID,
},
})
}
/** 新增字段项目 */
export function addOrUpdateDictionary(data: Api.Dictionary.AddOrUpdateParams) {
return request({
url: '/Base/ActivityMain/AddOrUpdateDictionary',
method: 'post',
data,
})
}
/** Delete dictionary */
export function deleteDictionary(DicID: number) {
return request({
url: `/Base/ActivityMain/DeleteDictionary/?DicID=${DicID}`,
method: 'post',
data: { DicID },
})
}
/** 根据字典ID查询字典类型 */
export function getDictionaryListUITypeByDicID(dicID: number) {
return request<Api.Common.CommonResponse>({
url: '/Base/ActivityMain/GetDictionaryListUITypeByDicID',
method: 'get',
params: {
DicID: dicID,
},
})
}
/** 根据字典ID新建下面的字典类型 */
export function addOrUpdateDictionaryUIType(data: Api.Dictionary.AddOrUpdateDictionaryUITypeParams) {
return request({
url: '/Base/ActivityMain/AddOrUpdateDictionaryUIType',
method: 'post',
data,
})
}
/** 删除字典项 */
export function deleteDictionaryItem(DicUIID: number) {
return request({
url: `/Base/ActivityMain/DeleteDictionaryUIType/?DicUIID=${DicUIID}`,
method: 'post',
data: { DicUIID },
})
}
/** 根据字典value查询字典项 */
export function getDictionaryItemListByDicID(DicValue: string) {
return request<Api.Common.CommonResponse>({
url: `/Base/ActivityMain/GetDictionaryListUITypeByDicValue/?DicValue=${DicValue}`,
method: 'get',
params: {
DicValue,
},
})
}

View File

@ -0,0 +1,105 @@
import { request } from '../request'
/**
* 游戏相关接口 步骤
* 1.GetQuestionByActivityIDAndStatus 抽题时候获取题目大纲信息
* 2.GetQuestionListDetailRound 获取抽题目详情
* 3.fetchSubmitQuestionResult点击开始答题后把基础信息数据传给service
*/
/** 根据活动id查询分组列表返回队伍分组id和名称是否已经参赛 */
export function fetchGetGroupListByActivityID(id: string, roundType: number = 0) {
return request<App.Service.Response<Api.Competition.ActivityTeamGroup[]>>({
url: `/Base/ActivityMain/GetActivity_TeamGroupByMainIDAndStatus?ActivityID=${id}&RoundType=${roundType}`,
method: 'get',
})
}
/** 抽题时候获取题目大纲信息 */
export function fetchGetQuestionOutline(ActivityID: number, status: number = 0) {
return request<App.Service.Response<Api.Competition.QuestionListRecord[]>>({
url: `/Base/ActivityMain/GetQuestionByActivityIDAndStatus/?ActivityID=${ActivityID}&status=${status}`,
method: 'post',
data: {
ActivityID,
status,
},
})
}
/** 抽题目详情 */
export function fetchGetQuestionDetail(ActivityID: string, questionID: number) {
return request<App.Service.Response<Api.Competition.QuestionListDetailRound>>({
url: `/Base/ActivityMain/GetQuestionListDetailRound/?MainID=${ActivityID}&questionID=${questionID}`,
method: 'post',
data: {
MainID: ActivityID,
questionID,
},
})
}
/** 提交抽题结果 */
export function fetchSubmitQuestionResult(data: Api.Competition.QuestionAddParams) {
return request<App.Service.Response<Api.Competition.QuestionListDetailRound>>({
url: `/Base/ActivityMain/AddActivity_TeamQuestionUse/?RoomID=${data.RoomID}&MainID=${data.MainID}&TeamGroupID=${data.TeamGroupID}&QuestionID=${data.QuestionID}&QuestionDetaiID=${data.QuestionDetaiID}&TeamGroupQuestionID=${data.TeamGroupQuestionID}`,
method: 'post',
data,
})
}
/** 获取游戏现场统计结果 */
export function fetchGetGameStatistics(data: { TeamGroupID: number, QuestionID: number }) {
return request<App.Service.Response<Api.Competition.TeamAnswerData[]>>({
url: `/Base/ActivityMain/GetTeamQuestionUseByTeamGroupIDAndQuestionID/?TeamGroupID=${data.TeamGroupID}&QuestionID=${data.QuestionID}`,
method: 'post',
data,
})
}
/** 获取游戏现场问题 */
export function fetchGetCurrentQuestion(data: Api.Competition.GetCurrentQuestionParams) {
return request<App.Service.Response<Api.Competition.CurrentQuestionResponse>>({
url: `/Base/ActivityMain/GetCurrentQuestion/?ActivityID=${data.ActivityID}&GroupID=${data.GroupID}&RoundType=${data.RoundType}`,
method: 'post',
data,
})
}
/** 定时刷,评委是否评完结果 判断是否可以下一步 */
export function fetchCheckNextStep(TeamGroup_QuestionID: number) {
return request<App.Service.Response<any>>({
url: `/Base/ActivityMain/GetCurrentQuestionUse/?TeamGroup_QuestionID=${TeamGroup_QuestionID}`,
method: 'post',
data: {
TeamGroup_QuestionID,
},
})
}
/** 根据活动id查询题目列表 */
export function fetchGetQuestionTableByActivityID(ActivityID: string) {
return request<App.Service.Response<Api.Competition.QuestionListDetailRound[]>>({
url: `/Base/ActivityMain/GetActivity_QuestionTableByActivityID/?ID=${ActivityID}`,
method: 'get',
})
}
/** 根据 roomid查询当前正在比赛的题目 */
export function fetchGetCurrentQuestionByRoomID(RoomID: number) {
return request<App.Service.Response<Api.Competition.GetCurrentQuestionInfo>>({
url: `/Base/ActivityMain/SelectTask?RoomID=${RoomID}`,
method: 'post',
data: {
RoomID,
},
})
}
/** 根据QuestionDetaiID查询题目详情 */
export function fetchGetQuestionDetailByID(QuestionDetailID: number) {
return request<App.Service.Response<Api.Competition.QuestionListDetailRound>>({
url: `/Base/ActivityMain/GetQuestionListDetailByID/?QuestionID=${QuestionDetailID}`,
method: 'get',
})
}

View File

@ -1,2 +1,4 @@
export * from './auth'
export * from './rank'
export * from './route'
export * from './template'

View File

@ -0,0 +1,116 @@
import { request } from '../request'
/** 获取所有题目列表 */
export function fetchGetQuestionListAll() {
return request<Api.Question.CommonRecord>({
url: '/Base/ActivityMain/GetQuestionListAll',
method: 'get',
})
}
/** 新增题目 */
export function fetchAddQuestion(data?: Api.Question.AddParams) {
return request<Api.Question.CommonRecord>({
url: '/Base/ActivityMain/AddBase_QuestionList',
method: 'post',
data: data || {},
})
}
/** 更新题目 */
export function fetchUpdateQuestion(data?: Api.Question.AddParams) {
return request<Api.Question.CommonRecord>({
url: '/Base/ActivityMain/UpdateQuestionList',
method: 'post',
data: data || {},
})
}
/** 删除题目 */
export function fetchDeleteQuestion(id: number) {
return request<Api.Question.CommonRecord>({
url: `/Base/ActivityMain/DeleteQuestionList/?Id=${id}`,
method: 'post',
})
}
/** 获取所有问题库列表(分页) */
export function fetchGetQuestionLibraryListAll(params: Api.Question.GetQuestionLibraryListAllParams) {
return request<Api.Question.CommonRecord>({
url: '/Base/ActivityMain/GetQuestionListDetail',
method: 'get',
params: {
QuestionId: params.questionId,
PageIndex: params.pageIndex,
PageSizes: params.pageSizes,
keyWords: params.keyWords,
},
})
}
/** 新增一条问题库 */
export function fetchAddQuestionLibrary(params: Api.Question.AddQuestionLibraryParams, file?: File | null) {
const formData = new FormData()
if (file) {
formData.append('Image', file)
}
return request<Api.Question.CommonRecord>({
url: '/Base/ActivityMain/AddQuestionListDetail',
method: 'post',
// params: {
// QuestionId: params.questionId,
// Name: params.name,
// Answer: params.answer,
// Type: params.type,
// IsGood: params.IsGood,
// ImageUrl: params.imageUrl,
// },
data: {
QuestionId: params.questionId,
Name: params.name,
Answer: params.answer,
Type: params.type,
IsGood: params.IsGood,
ImageUrl: params.imageUrl,
},
})
}
/** 更新一条问题库 */
export function fetchUpdateQuestionLibrary(params: Api.Question.AddQuestionLibraryParams, file?: File | null) {
const formData = new FormData()
if (file) {
formData.append('Image', file)
}
return request<Api.Question.CommonRecord>({
url: '/Base/ActivityMain/UpdateQuestionListDetail',
method: 'post',
// params: {
// Id: params.id,
// QuestionId: params.questionId,
// Name: params.name,
// Answer: params.answer,
// Type: params.type,
// IsGood: params.IsGood,
// ImageUrl: params.imageUrl,
// },
data: {
Id: params.id,
QuestionId: params.questionId,
Name: params.name,
Answer: params.answer,
Type: params.type,
IsGood: params.IsGood,
ImageUrl: params.imageUrl,
},
})
}
/** 删除一条问题库 */
export function fetchDeleteQuestionLibrary(ids: number[]) {
return request({
url: `/Base/ActivityMain/DeleteQuestionListDetail`,
method: 'post',
data: ids,
})
}

View File

@ -0,0 +1,62 @@
import { request } from '../request'
/** get user list */
export function fetchRankList(MainID: number, RoundType = 0) {
return request<Api.Rank.CommonRecord>({
url: `/Base/ActivityMain/GetTeamsTotal/list?MainID=${MainID}&RoundType=${RoundType}`,
method: 'get',
})
}
/** 获取队伍排名详情 */
export function fetchRankDetail(MainID: number, RoundType = 0) {
return request<Api.Rank.CommonRecord>({
url: `/Base/ActivityMain/GetTeamsTotal?MainID=${MainID}&RoundType=${RoundType}`,
method: 'get',
})
}
/** 根据队伍 id获取具体的题目分数 */
export function fetchTeamQuestionScore(TeamID: number) {
return request<Api.Rank.CommonRecord>({
url: `/Base/ActivityMain/GetTeamQuestionUseByTeamID?TeamID=${TeamID}`,
method: 'get',
})
}
/** 获取活动排行列表 */
export function fetchActivityRankList(PageIndex: number, PageSize: number = 10) {
return request<App.Service.Response<Api.Rank.ActivityRankList>>({
url: `/Base/ActivityMain/GetActivity_Main_TotalPager?PageIndex=${PageIndex}&PageSizes=${PageSize}`,
method: 'get',
})
}
/** 修改排名的队伍分数 */
export function updateTeamScore(data: Api.Rank.UpdateTeamScoreParams[], activityId: number) {
return request<App.Service.Response>({
url: `/Base/ActivityMain/UpdateTeamQuestionResultRecore/?ActivityID=${activityId}`,
method: 'post',
data,
})
}
/** 发布排名,更新状态 */
export function publishRank(Id: number, Status = 1) {
return request<App.Service.Response>({
url: `/Base/ActivityMain/UpdateResultRecoreStatus`,
method: 'post',
data: {
Id,
Status,
},
})
}
/** 获取用户排名列表 */
export function fetchUserRankList(MainID: number, RoundType: number = 0) {
return request<App.Service.Response<Api.Rank.ActivityRankResList>>({
url: `/Base/ActivityMain/GetTeamsTotalList?MainID=${MainID}&RoundType=${RoundType}`,
method: 'get',
})
}

View File

@ -0,0 +1,28 @@
import { request } from '../request'
/** 评委消费题目,表示用了该题目 */
export function updateCurrentQuestionUse(TeamGroupID: number) {
return request<App.Service.Response>({
url: `/Base/ActivityMain/UpdateCurrentQuestionUse?TeamGroupID=${TeamGroupID}`,
method: 'post',
data: { TeamGroupID },
})
}
/** 评委对其进行打分 */
export function updateScore(data: { Id: number, ResultPotins: number, QuestionDetailID: number, QuestionId: number }[]) {
return request<App.Service.Response>({
url: `/Base/ActivityMain/UpdateTeamQuestionResultList`,
method: 'post',
data,
})
}
/** 结束活动时候调用 */
export function endActivity(ActivityID: number) {
return request<App.Service.Response>({
url: `/Base/ActivityMain/AddOrUpdateActivity_Main_Total?ActivityID=${ActivityID}`,
method: 'post',
data: { ActivityID },
})
}

View File

@ -0,0 +1,55 @@
import { request } from '../request'
/** get role list */
export function fetchGetRoleList(params?: Api.SystemManage.RoleSearchParams) {
return request<Api.SystemManage.RoleList>({
url: '/systemManage/getRoleList',
method: 'get',
params,
})
}
/**
* get all roles
*
* these roles are all enabled
*/
export function fetchGetAllRoles() {
return request<Api.SystemManage.AllRole[]>({
url: '/systemManage/getAllRoles',
method: 'get',
})
}
/** get user list */
export function fetchGetUserList(params?: Api.SystemManage.UserSearchParams) {
return request<Api.SystemManage.UserList>({
url: '/admin/v1/class/pagelist',
method: 'post',
params,
})
}
/** get menu list */
export function fetchGetMenuList() {
return request<Api.SystemManage.MenuList>({
url: '/systemManage/getMenuList/v2',
method: 'get',
})
}
/** get all pages */
export function fetchGetAllPages() {
return request<string[]>({
url: '/systemManage/getAllPages',
method: 'get',
})
}
/** get menu tree */
export function fetchGetMenuTree() {
return request<Api.SystemManage.MenuTree[]>({
url: '/systemManage/getMenuTree',
method: 'get',
})
}

View File

@ -0,0 +1,43 @@
import { request } from '../request'
/**
* 获取模板列表
*/
export function fetchTemplateList(data?: Api.Template.TemplateSearchParams) {
return request<App.Service.Response<Api.Common.CommonResponse>>({
url: '/Base/ActivityMain/GetTemplete_Pag',
method: 'get',
params: data || {},
})
}
/**
* 新增模板, 更新模板
*/
export function fetchAddTemplate(data?: Api.Template.AddTemplateParams) {
return request<App.Service.Response<Api.Common.PaginatingQueryRecord<Api.Template.CommonRecord>>>({
url: '/Base/ActivityMain/AddOrUpdateTemplete_Pag',
method: 'post',
data: data || {},
})
}
/**
* 获取模板详情
*/
export function fetchTemplateDetail(id: number) {
return request<App.Service.Response<Api.Template.TemplateDetail>>({
url: `/Base/ActivityMain/GetTemplete_PagByID?id=${id}`,
method: 'get',
})
}
/**
* 删除模板
*/
export function fetchDeleteTemplate(id: number) {
return request<App.Service.Response<Api.Common.PaginatingQueryRecord<Api.Template.CommonRecord>>>({
url: `/Base/ActivityMain/DeleteTemplete_Pag?id=${id}`,
method: 'post',
})
}

View File

@ -0,0 +1,31 @@
import { request } from '../request'
export interface ReqDeleteAliOssFile {
key: string
}
export interface AliOssSTS {
AccessKeyId: string
AccessKeySecret: string
SecurityToken: string
Expiration: string
BucketName: string
Region: string
}
/** 得到上传的token */
export function getAliOssTokenAxios() {
return request<App.Service.Response<AliOssSTS>>({
url: `${import.meta.env.VITE_BASE_UPLOAD_URL}/admin/v1/oss/sts`,
method: 'get',
})
}
/** 删除阿里云oss 文件 */
export function deleteAliOssFileAxios(data: ReqDeleteAliOssFile) {
return request<App.Service.Response<boolean>>({
url: `${import.meta.env.VITE_BASE_UPLOAD_URL}/admin/v1/oss/delete`,
method: 'post',
data,
})
}

View File

@ -10,36 +10,50 @@ import { getAuthorization, handleExpiredRequest, showErrorMsg } from './shared'
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y'
const { baseURL, otherBaseURL } = getServiceBaseURL(import.meta.env, isHttpProxy)
/**
* 基础请求
*/
export const request = createFlatRequest(
{
baseURL,
headers: {
apifoxToken: 'XL299LiMEDZ0H5h3A29PxwQXdMJqWyY2',
'apifoxToken': 'XL299LiMEDZ0H5h3A29PxwQXdMJqWyY2', // 用于 apifox 调试
'Custom-Platform': 'pc',
},
},
{
defaultState: {
errMsgStack: [],
refreshTokenPromise: null,
errMsgStack: [], // 错误信息栈
refreshTokenPromise: null, // 刷新token Promise
} as RequestInstanceState,
// 响应数据转换
transform(response: AxiosResponse<App.Service.Response<any>>) {
return response.data.data
// 如果后端返回的data 是string 类型,需要转换为对象
if (typeof response.data.data === 'string') {
response.data.data = JSON.parse(response.data.data)
return response.data
}
return response.data
},
// 请求配置转换
async onRequest(config) {
const Authorization = getAuthorization()
Object.assign(config.headers, { Authorization })
return config
},
// 响应状态判断
isBackendSuccess(response) {
// when the backend response code is "0000"(default), it means the request is success
// to change this logic by yourself, you can modify the `VITE_SERVICE_SUCCESS_CODE` in `.env` file
// 当后端响应状态码为 "200"(默认值)时,表示请求成功
// 如果你想自行更改此逻辑,可以在 .env 文件中修改 VITE_SERVICE_SUCCESS_CODE
return String(response.data.code) === import.meta.env.VITE_SERVICE_SUCCESS_CODE
},
// 响应错误处理
async onBackendFail(response, instance) {
const authStore = useAuthStore()
const responseCode = String(response.data.code)
//
function handleLogout() {
authStore.resetStore()
}
@ -51,19 +65,19 @@ export const request = createFlatRequest(
request.state.errMsgStack = request.state.errMsgStack.filter(msg => msg !== response.data.msg)
}
// when the backend response code is in `logoutCodes`, it means the user will be logged out and redirected to login page
// 当后端响应状态码在 logoutCodes 中时,表示用户将被登出并重定向到登录页面
const logoutCodes = import.meta.env.VITE_SERVICE_LOGOUT_CODES?.split(',') || []
if (logoutCodes.includes(responseCode)) {
handleLogout()
return null
}
// when the backend response code is in `modalLogoutCodes`, it means the user will be logged out by displaying a modal
// 当后端响应状态码在 modalLogoutCodes 中时,表示将通过显示模态框来使用户登出
const modalLogoutCodes = import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES?.split(',') || []
if (modalLogoutCodes.includes(responseCode) && !request.state.errMsgStack?.includes(response.data.msg)) {
request.state.errMsgStack = [...(request.state.errMsgStack || []), response.data.msg]
// prevent the user from refreshing the page
// 防止用户刷新页面
window.addEventListener('beforeunload', handleLogout)
window.$dialog?.error({
@ -83,8 +97,8 @@ export const request = createFlatRequest(
return null
}
// when the backend response code is in `expiredTokenCodes`, it means the token is expired, and refresh token
// the api `refreshToken` can not return error code in `expiredTokenCodes`, otherwise it will be a dead loop, should return `logoutCodes` or `modalLogoutCodes`
// 当后端响应状态码在 expiredTokenCodes 中时,表示 token 已过期,需要刷新 token
// refreshToken 接口不能返回 expiredTokenCodes 中的错误码,否则会形成死循环,应该返回 logoutCodesmodalLogoutCodes
const expiredTokenCodes = import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES?.split(',') || []
if (expiredTokenCodes.includes(responseCode)) {
const success = await handleExpiredRequest(request.state)
@ -99,24 +113,24 @@ export const request = createFlatRequest(
return null
},
onError(error) {
// when the request is fail, you can show error message
// 当请求失败时,可以显示错误信息
let message = error.message
let backendErrorCode = ''
// get backend error message and code
// 获取后端错误信息和状态码
if (error.code === BACKEND_ERROR_CODE) {
message = error.response?.data?.msg || message
backendErrorCode = String(error.response?.data?.code || '')
}
// the error message is displayed in the modal
// 错误信息已在模态框中显示
const modalLogoutCodes = import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES?.split(',') || []
if (modalLogoutCodes.includes(backendErrorCode)) {
return
}
// when the token is expired, refresh token and retry request, so no need to show error message
// 当 token 过期时,刷新 token 并重试请求,因此不需要显示错误信息
const expiredTokenCodes = import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES?.split(',') || []
if (expiredTokenCodes.includes(backendErrorCode)) {
return
@ -127,39 +141,48 @@ export const request = createFlatRequest(
},
)
/**
* 演示请求
*/
export const demoRequest = createRequest(
{
baseURL: otherBaseURL.demo,
},
/**
* 演示请求响应数据转换
*/
{
// 响应数据转换
transform(response: AxiosResponse<App.Service.DemoResponse>) {
return response.data.result
},
// 请求配置转换
async onRequest(config) {
const { headers } = config
// set token
// 设置 token
const token = localStg.get('token')
const Authorization = token ? `Bearer ${token}` : null
Object.assign(headers, { Authorization })
return config
},
// 响应状态判断
isBackendSuccess(response) {
// when the backend response code is "200", it means the request is success
// you can change this logic by yourself
// 当后端响应状态码为 "200" 时,表示请求成功
// 你可以自行更改此逻辑
return response.data.status === '200'
},
async onBackendFail(_response) {
// when the backend response code is not "200", it means the request is fail
// for example: the token is expired, refresh token and retry request
// 当后端响应状态码不是 "200" 时,表示请求失败
// 例如token 过期,刷新 token 并重试请求
},
onError(error) {
// when the request is fail, you can show error message
// 当请求失败时,可以显示错误信息
let message = error.message
// show backend error message
// 显示后端错误信息
if (error.code === BACKEND_ERROR_CODE) {
message = error.response?.data?.message || message
}

View File

@ -0,0 +1,76 @@
## error 字段 不是后端接口直接返回的 ,而是前端请求工具 request (基于 @sa/axios 的 createFlatRequest 封装) 为了简化错误处理而增加的。
它的机制如下:
- 请求成功 error 为 null data 为后端返回的响应体(包含 code , msg , data , success 等字段)。
- 请求失败 (如网络断开、超时): error 会包含错误信息对象, data 为 null 。
这种写法让您可以直接解构返回值判断请求是否成功,避免了使用 `try-catch` 包裹。
总结:
- response (您重命名的 data ) :包含后端返回的 { success, code, msg, data } 。
- error :前端请求库生成的错误对象,用于判断 HTTP 请求层面的成败
参考:
- [@sa/axios 文档](https://bytedance.larkoffice.com/wiki/wikcnn33hQ3h3FkqCnqCn33h3Fk)
例子:
```typescript
import request from '@/service/request'
// 成功请求
const { data, error } = await request.get('/api/success')
if (error) {
console.error('请求失败:', error)
return
}
console.log('请求成功:', data)
// 失败请求(如网络断开)
const { data: failedData, error: failedError } = await request.get('/api/fail')
if (failedError) {
console.error('请求失败:', failedError)
return
}
console.log('请求成功:', failedData) // 不会执行到这里
/**
* 初始化模板详情
*/
async function initData() {
const id = Number(route.query.id)
if (id) {
try {
loading.value = true
const { data: response, error } = await fetchTemplateDetail(id)
if (!error && response) {
const { data, success } = response
if (success) {
const { Name, Width, Height, BackGroundUrl, TempContent } = data || {}
templateInfo.value = {
name: Name,
width: Width,
height: Height,
backGroundUrl: BackGroundUrl,
}
imgSrc.value = BackGroundUrl || ''
if (TempContent) {
try {
regions.value = JSON.parse(TempContent || '[]')
}
catch (e) {
console.error('Failed to parse tempContent', e)
}
}
}
}
}
finally {
loading.value = false
}
}
}
```

View File

@ -10,7 +10,9 @@ export function getAuthorization() {
return Authorization
}
/** refresh token */
/**
* 刷新token
*/
async function handleRefreshToken() {
const { resetStore } = useAuthStore()
@ -27,6 +29,9 @@ async function handleRefreshToken() {
return false
}
/**
* 处理过期token的请求
*/
export async function handleExpiredRequest(state: RequestInstanceState) {
if (!state.refreshTokenPromise) {
state.refreshTokenPromise = handleRefreshToken()
@ -41,6 +46,9 @@ export async function handleExpiredRequest(state: RequestInstanceState) {
return success
}
/**
* 显示错误信息
*/
export function showErrorMsg(state: RequestInstanceState, message: string) {
if (!state.errMsgStack?.length) {
state.errMsgStack = []

View File

@ -1,7 +1,7 @@
export interface RequestInstanceState {
/** the promise of refreshing token */
/** 刷新token Promise */
refreshTokenPromise: Promise<boolean> | null
/** the request error message stack */
/** 请求错误信息栈 */
errMsgStack: string[]
[key: string]: unknown
}

View File

@ -1,5 +1,6 @@
import type { App } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import { resetSetupStore } from './plugins'
/** Setup Vue store plugin pinia */
@ -7,6 +8,7 @@ export function setupStore(app: App) {
const store = createPinia()
store.use(resetSetupStore)
store.use(piniaPluginPersistedstate)
app.use(store)
}

View File

@ -0,0 +1,55 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useActivityInfoStore = defineStore('activity-info', () => {
const activityInfo = ref<Api.Competition.ActivityDetail | null>(null)
const activityId = ref<number | null>(null)
const groupId = ref<number | null>(null)
const teamId = ref<number | null>(null)
const teamIds = ref<number[]>([])
function setActivityInfo(info: Api.Competition.ActivityDetail) {
activityInfo.value = info
activityId.value = info.Id
}
function setActivityId(id: number) {
activityId.value = id
}
function setGroupId(id: number) {
groupId.value = id
}
function setTeamId(id: number) {
teamId.value = id
}
function setTeamIds(ids: number[]) {
teamIds.value = ids
}
function clearActivityInfo() {
activityInfo.value = null
activityId.value = null
groupId.value = null
teamId.value = null
teamIds.value = []
}
return {
teamIds,
activityInfo,
activityId,
groupId,
teamId,
setActivityInfo,
setActivityId,
setGroupId,
setTeamId,
setTeamIds,
clearActivityInfo,
}
}, {
persist: true,
})

View File

@ -9,7 +9,7 @@ import { fetchGetUserInfo, fetchLogin } from '@/service/api'
import { localStg } from '@/utils/storage'
import { useRouteStore } from '../route'
import { useTabStore } from '../tab'
import { clearAuthStorage, getToken } from './shared'
import { clearAuthStorage, getToken, getUserId } from './shared'
export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
const route = useRoute()
@ -98,7 +98,6 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
*/
async function login(userName: string, password: string, redirect = true) {
startLoading()
const { data: loginToken, error } = await fetchLogin(userName, password)
if (!error) {
@ -133,9 +132,10 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
// 1. stored in the localStorage, the later requests need it in headers
localStg.set('token', loginToken.token)
localStg.set('refreshToken', loginToken.refreshToken)
localStg.set('userId', loginToken.id)
// 2. get user info
const pass = await getUserInfo()
const pass = await getUserInfo(loginToken.id)
if (pass) {
token.value = loginToken.token
@ -146,8 +146,8 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
return false
}
async function getUserInfo() {
const { data: info, error } = await fetchGetUserInfo()
async function getUserInfo(userId: number) {
const { data: info, error } = await fetchGetUserInfo(userId)
if (!error) {
// update store
@ -161,9 +161,10 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
async function initUserInfo() {
const hasToken = getToken()
const userId = getUserId()
if (hasToken) {
const pass = await getUserInfo()
if (hasToken && userId) {
const pass = await getUserInfo(userId)
if (!pass) {
resetStore()

View File

@ -5,8 +5,14 @@ export function getToken() {
return localStg.get('token') || ''
}
export function getUserId() {
const userId = localStg.get('userId')
return userId || 0
}
/** Clear auth storage */
export function clearAuthStorage() {
localStg.remove('token')
localStg.remove('refreshToken')
localStg.remove('userId')
}

View File

@ -0,0 +1,59 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getDictionaryItemListByDicID, getDictionaryList } from '@/service/api/dictionary'
export const useBusinessStore = defineStore('business-store', () => {
const dictList = ref<Api.Dictionary.DictionaryItem[]>([])
const dictData = ref<Record<string, Api.Dictionary.DictionaryUIItem[]>>({})
const isInitialized = ref(false)
const loadingMap = ref<Record<string, boolean>>({})
/** 初始化字典 */
async function initDict() {
if (isInitialized.value)
return
const { data: res, error } = await getDictionaryList()
if (!error && res) {
dictList.value = res.data || []
isInitialized.value = true
}
}
/** 获取字典数据 */
async function getDict(key: string) {
// 已经加载过,直接返回
if (dictData.value[key] && dictData.value[key].length > 0)
return dictData.value[key]
if (loadingMap.value[key]) {
return []
}
loadingMap.value[key] = true
try {
const { data: res, error } = await getDictionaryItemListByDicID(key)
if (!error && res) {
const list = res.data || []
// 使用新对象赋值,确保触发响应式更新
dictData.value = {
...dictData.value,
[key]: list,
}
}
}
finally {
loadingMap.value[key] = false
}
// eslint-disable-next-line no-console
console.log(`获取字典数据 ${key}`, dictData.value[key])
return dictData.value[key]
}
return {
dictList,
dictData,
loadingMap,
initDict,
getDict,
}
})

View File

@ -0,0 +1,156 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { AudioController } from '@/utils/audio'
export const useCompetitionStore = defineStore('competition', () => {
// ==========================================
// State - 核心数据
// ==========================================
/** 从抽题页获取当前题目基础提纲信息 */
const currentQuestionMainInfo = ref<Api.Competition.CurrentQuestionResponse>({} as Api.Competition.CurrentQuestionResponse)
/** 当前选中的题目基础信息 (从抽题页获取) */
const currentQuestionInfo = ref<Api.Competition.QuestionListRecord>({} as Api.Competition.QuestionListRecord)
/** 当前题目的详细信息 (从详情接口获取) */
const currentQuestionDetail = ref<Api.Competition.QuestionListDetailRound>({} as Api.Competition.QuestionListDetailRound)
// ==========================================
// State - 流程控制 & 交互状态
// ==========================================
/** 倒计时剩余时间 (秒) */
const timeLeft = ref(0)
/** 定时器引用 */
const timerInterval = ref<number | null>(null)
/** 音效控制器 */
const audioController = new AudioController()
// ==========================================
// Actions - 数据操作
// ==========================================
/**
* 设置题目列表
*/
function setCurrentQuestionMainInfo(obj: Api.Competition.CurrentQuestionResponse) {
currentQuestionMainInfo.value = obj as Api.Competition.CurrentQuestionResponse
}
/**
* 清空设置题目列表
*/
function resetCurrentQuestionMainInfo() {
currentQuestionMainInfo.value = {} as Api.Competition.CurrentQuestionResponse
}
/**
* 设置当前题目基础信息
*/
function setCurrentQuestionInfo(info: Api.Competition.QuestionListRecord) {
currentQuestionInfo.value = info
}
/**
* 清空设置当前题目基础信息
*/
function resetCurrentQuestionInfo() {
currentQuestionInfo.value = {} as Api.Competition.QuestionListRecord
}
/**
* 设置当前题目详情
*/
function setCurrentQuestionDetail(detail: Api.Competition.QuestionListDetailRound) {
currentQuestionDetail.value = detail
}
/**
* 清空设置当前题目详情
*/
function resetCurrentQuestionDetail() {
currentQuestionDetail.value = {} as Api.Competition.QuestionListDetailRound
}
// ==========================================
// Actions - 流程控制
// ==========================================
/**
* 启动倒计时
* @param duration 持续时间(秒)
*/
function startTimer(duration: number) {
stopTimer()
timeLeft.value = duration
// 播放开始音效
audioController.play('start')
timerInterval.value = window.setInterval(() => {
if (timeLeft.value > 0) {
timeLeft.value--
// 剩余5秒播放倒计时音效
if (timeLeft.value <= 5) {
audioController.play('tick')
}
}
else {
stopTimer()
}
}, 1000)
}
/**
* 停止倒计时
*/
function stopTimer() {
if (timerInterval.value) {
clearInterval(timerInterval.value)
timerInterval.value = null
}
}
/**
* 重置当前题目流程状态 (不清除题目基础信息)
*/
function resetRoundState() {
currentQuestionDetail.value = {} as Api.Competition.QuestionListDetailRound
stopTimer()
timeLeft.value = 0
}
/**
* 初始化/重置所有数据
*/
function initData() {
currentQuestionMainInfo.value = {} as Api.Competition.CurrentQuestionResponse
currentQuestionInfo.value = {} as Api.Competition.QuestionListRecord
resetRoundState()
}
return {
// State
currentQuestionMainInfo,
currentQuestionInfo,
currentQuestionDetail,
timeLeft,
// Actions
setCurrentQuestionMainInfo,
setCurrentQuestionInfo,
setCurrentQuestionDetail,
startTimer,
stopTimer,
resetRoundState,
initData,
resetCurrentQuestionMainInfo,
resetCurrentQuestionInfo,
resetCurrentQuestionDetail,
}
}, {
persist: true,
})

View File

@ -30,27 +30,26 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
const { bool: isInitAuthRoute, setBool: setIsInitAuthRoute } = useBoolean()
/**
* Auth route mode
* 权限路由模式
*
* It recommends to use static mode in the development environment, and use dynamic mode in the production
* environment, if use static mode in development environment, the auth routes will be auto generated by plugin
* "@elegant-router/vue"
* 建议在开发环境中使用静态模式,在生产环境中使用动态模式
* 如果在开发环境中使用静态模式,权限路由将由插件 "@elegant-router/vue" 自动生成
*/
const authRouteMode = ref(import.meta.env.VITE_AUTH_ROUTE_MODE)
/** Home route key */
/** 首页路由 key */
const routeHome = ref(import.meta.env.VITE_ROUTE_HOME)
/**
* Set route home
* 设置首页路由
*
* @param routeKey Route key
* @param routeKey 路由 key
*/
function setRouteHome(routeKey: LastLevelRouteKey) {
routeHome.value = routeKey
}
/** constant routes */
/** 常量路由 */
const constantRoutes = shallowRef<ElegantConstRoute[]>([])
function addConstantRoutes(routes: ElegantConstRoute[]) {
@ -63,7 +62,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
constantRoutes.value = Array.from(constantRoutesMap.values())
}
/** auth routes */
/** 权限路由 */
const authRoutes = shallowRef<ElegantConstRoute[]>([])
function addAuthRoutes(routes: ElegantConstRoute[]) {
@ -78,44 +77,51 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
const removeRouteFns: (() => void)[] = []
/** Global menus */
/** 全局菜单 */
const menus = ref<App.Global.Menu[]>([])
const menusForBreadcrumb = ref<App.Global.Menu[]>([])
const searchMenus = computed(() => transformMenuToSearchMenus(menus.value))
/** Get global menus */
/**
* 获取全局菜单
*
* @param routes 路由
*/
function getGlobalMenus(routes: ElegantConstRoute[]) {
menus.value = getGlobalMenusByAuthRoutes(routes)
menus.value = getGlobalMenusByAuthRoutes(routes, true, false)
menusForBreadcrumb.value = getGlobalMenusByAuthRoutes(routes, false, true)
}
/** Update global menus by locale */
/** 根据语言更新全局菜单 */
function updateGlobalMenusByLocale() {
menus.value = updateLocaleOfGlobalMenus(menus.value)
menusForBreadcrumb.value = updateLocaleOfGlobalMenus(menusForBreadcrumb.value)
}
/** Cache routes */
/** 缓存路由 */
const cacheRoutes = ref<RouteKey[]>([])
/**
* Exclude cache routes
* 排除缓存路由
*
* for reset route cache
* 用于重置路由缓存
*/
const excludeCacheRoutes = ref<RouteKey[]>([])
/**
* Get cache routes
* 获取缓存路由
*
* @param routes Vue routes
* @param routes Vue 路由
*/
function getCacheRoutes(routes: RouteRecordRaw[]) {
cacheRoutes.value = getCacheRouteNames(routes)
}
/**
* Reset route cache
* 重置路由缓存
*
* @default
* @param routeKey
* @param routeKey 路由 key
*/
async function resetRouteCache(routeKey?: RouteKey) {
const routeName = routeKey || (router.currentRoute.value.name as RouteKey)
@ -127,10 +133,10 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
excludeCacheRoutes.value = []
}
/** Global breadcrumbs */
const breadcrumbs = computed(() => getBreadcrumbsByRoute(router.currentRoute.value, menus.value))
/** 全局面包屑 */
const breadcrumbs = computed(() => getBreadcrumbsByRoute(router.currentRoute.value, menusForBreadcrumb.value))
/** Reset store */
/** 重置 store */
async function resetStore() {
const routeStore = useRouteStore()
@ -138,34 +144,36 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
resetVueRoutes()
// after reset store, need to re-init constant route
// 重置 store 后,需要重新初始化常量路由
await initConstantRoute()
}
/** Reset vue routes */
/** 重置 vue 路由 */
function resetVueRoutes() {
removeRouteFns.forEach(fn => fn())
removeRouteFns.length = 0
}
/** init constant route */
/** 初始化常量路由 */
async function initConstantRoute() {
if (isInitConstantRoute.value)
return
// 静态路由
const staticRoute = createStaticRoutes()
if (authRouteMode.value === 'static') {
// 如果是静态路由模式,直接添加静态常量路由
if (authRouteMode.value === 'static') { // 静态路由模式
addConstantRoutes(staticRoute.constantRoutes)
}
else {
else { // 动态路由模式
const { data, error } = await fetchGetConstantRoutes()
if (!error) {
addConstantRoutes(data)
}
else {
// if fetch constant routes failed, use static constant routes
// 如果获取常量路由失败,使用静态常量路由
addConstantRoutes(staticRoute.constantRoutes)
}
}
@ -177,9 +185,9 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
tabStore.initHomeTab()
}
/** Init auth route */
/** 初始化权限路由 */
async function initAuthRoute() {
// check if user info is initialized
// 检查用户信息是否已初始化
if (!authStore.userInfo.userId) {
await authStore.initUserInfo()
}
@ -194,7 +202,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
tabStore.initHomeTab()
}
/** Init static auth route */
/** 初始化静态权限路由 */
function initStaticAuthRoute() {
const { authRoutes: staticAuthRoutes } = createStaticRoutes()
@ -212,7 +220,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
setIsInitAuthRoute(true)
}
/** Init dynamic auth route */
/** 初始化动态权限路由 */
async function initDynamicAuthRoute() {
const { data, error } = await fetchGetUserRoutes()
@ -230,12 +238,12 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
setIsInitAuthRoute(true)
}
else {
// if fetch user routes failed, reset store
// 如果获取用户路由失败,重置 store
authStore.resetStore()
}
}
/** handle constant and auth routes */
/** 处理常量路由和权限路由 */
function handleConstantAndAuthRoutes() {
const allRoutes = [...constantRoutes.value, ...authRoutes.value]
@ -253,9 +261,9 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
}
/**
* Add routes to vue router
* 添加路由到 vue router
*
* @param routes Vue routes
* @param routes Vue 路由
*/
function addRoutesToVueRouter(routes: RouteRecordRaw[]) {
routes.forEach((route) => {
@ -265,18 +273,18 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
}
/**
* Add remove route fn
* 添加删除路由函数
*
* @param fn
* @param fn 删除函数
*/
function addRemoveRouteFn(fn: () => void) {
removeRouteFns.push(fn)
}
/**
* Update root route redirect when auth route mode is dynamic
* 当权限路由模式为动态时,更新根路由重定向
*
* @param redirectKey Redirect route key
* @param redirectKey 重定向路由 key
*/
function handleUpdateRootRouteRedirect(redirectKey: LastLevelRouteKey) {
const redirect = getRoutePath(redirectKey)
@ -293,9 +301,9 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
}
/**
* Get is auth route exist
* 获取权限路由是否存在
*
* @param routePath Route path
* @param routePath 路由路径
*/
async function getIsAuthRouteExist(routePath: RouteMap[RouteKey]) {
const routeName = getRouteName(routePath)
@ -315,20 +323,20 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
}
/**
* Get selected menu key path
* 获取选中的菜单 key 路径
*
* @param selectedKey Selected menu key
* @param selectedKey 选中的菜单 key
*/
function getSelectedMenuKeyPath(selectedKey: string) {
return getSelectedMenuKeyPathByKey(selectedKey, menus.value)
}
async function onRouteSwitchWhenLoggedIn() {
// some global init logic when logged in and switch route
// 登录并切换路由时的一些全局初始化逻辑
}
async function onRouteSwitchWhenNotLoggedIn() {
// some global init logic if it does not need to be logged in
// 如果不需要登录时的一些全局初始化逻辑
}
return {

View File

@ -4,28 +4,28 @@ import { useSvgIcon } from '@/hooks/common/icon'
import { $t } from '@/locales'
/**
* Filter auth routes by roles
* 根据角色过滤权限路由
*
* @param routes Auth routes
* @param roles Roles
* @param routes 权限路由
* @param roles 角色
*/
export function filterAuthRoutesByRoles(routes: ElegantConstRoute[], roles: string[]) {
return routes.flatMap(route => filterAuthRouteByRoles(route, roles))
}
/**
* Filter auth route by roles
* 根据角色过滤权限路由
*
* @param route Auth route
* @param roles Roles
* @param route 权限路由
* @param roles 角色
*/
function filterAuthRouteByRoles(route: ElegantConstRoute, roles: string[]): ElegantConstRoute[] {
const routeRoles = (route.meta && route.meta.roles) || []
// if the route's "roles" is empty, then it is allowed to access
// 如果路由的 "roles" 为空,则允许访问
const isEmptyRoles = !routeRoles.length
// if the user's role is included in the route's "roles", then it is allowed to access
// 如果用户的角色包含在路由的 "roles" 中,则允许访问
const hasPermission = routeRoles.some(role => roles.includes(role))
const filterRoute = { ...route }
@ -34,7 +34,7 @@ function filterAuthRouteByRoles(route: ElegantConstRoute, roles: string[]): Eleg
filterRoute.children = filterRoute.children.flatMap(item => filterAuthRouteByRoles(item, roles))
}
// Exclude the route if it has no children after filtering
// 如果过滤后没有子路由,则排除该路由
if (filterRoute.children?.length === 0) {
return []
}
@ -43,9 +43,9 @@ function filterAuthRouteByRoles(route: ElegantConstRoute, roles: string[]): Eleg
}
/**
* sort route by order
* 根据 order 对路由进行排序
*
* @param route route
* @param route 路由
*/
function sortRouteByOrder(route: ElegantConstRoute) {
if (route.children?.length) {
@ -57,9 +57,9 @@ function sortRouteByOrder(route: ElegantConstRoute) {
}
/**
* sort routes by order
* 根据 order 对路由进行排序
*
* @param routes routes
* @param routes 路由
*/
export function sortRoutesByOrder(routes: ElegantConstRoute[]) {
routes.sort((next, prev) => (Number(next.meta?.order) || 0) - (Number(prev.meta?.order) || 0))
@ -69,19 +69,30 @@ export function sortRoutesByOrder(routes: ElegantConstRoute[]) {
}
/**
* Get global menus by auth routes
*
* @param routes Auth routes
* 根据权限路由获取全局菜单
* 当嵌套路由里面,有且仅有一个子路由时,将其提升到一级菜单,点击一级菜单时,跳转到子路由
* @param routes 权限路由
* @param shouldHoist 是否提升
* @param includeHidden 是否包含隐藏路由
*/
export function getGlobalMenusByAuthRoutes(routes: ElegantConstRoute[]) {
export function getGlobalMenusByAuthRoutes(routes: ElegantConstRoute[], shouldHoist = false, includeHidden = false) {
const menus: App.Global.Menu[] = []
routes.forEach((route) => {
if (!route.meta?.hideInMenu) {
if (includeHidden || !route.meta?.hideInMenu) {
const menu = getGlobalMenuByBaseRoute(route)
if (route.children?.some(child => !child.meta?.hideInMenu)) {
menu.children = getGlobalMenusByAuthRoutes(route.children)
if (route.children?.some(child => includeHidden || !child.meta?.hideInMenu)) {
menu.children = getGlobalMenusByAuthRoutes(route.children, shouldHoist, includeHidden)
}
// 如果只有一个子菜单,将其提升
if (shouldHoist && menu.children?.length === 1) {
const singleChild = menu.children[0]
menu.key = singleChild.key
menu.routeKey = singleChild.routeKey
menu.routePath = singleChild.routePath
menu.children = singleChild.children
}
menus.push(menu)
@ -92,7 +103,7 @@ export function getGlobalMenusByAuthRoutes(routes: ElegantConstRoute[]) {
}
/**
* Update locale of global menus
* 更新全局菜单的国际化
*
* @param menus
*/
@ -120,7 +131,7 @@ export function updateLocaleOfGlobalMenus(menus: App.Global.Menu[]) {
}
/**
* Get global menu by route
* 根据路由获取全局菜单
*
* @param route
*/
@ -145,15 +156,15 @@ function getGlobalMenuByBaseRoute(route: RouteLocationNormalizedLoaded | Elegant
}
/**
* Get cache route names
* 获取缓存路由名称
*
* @param routes Vue routes (two levels)
* @param routes Vue 路由 (两级)
*/
export function getCacheRouteNames(routes: RouteRecordRaw[]) {
const cacheNames: LastLevelRouteKey[] = []
routes.forEach((route) => {
// only get last two level route, which has component
// 只获取最后两级有组件的路由
route.children?.forEach((child) => {
if (child.component && child.meta?.keepAlive) {
cacheNames.push(child.name as LastLevelRouteKey)
@ -165,7 +176,7 @@ export function getCacheRouteNames(routes: RouteRecordRaw[]) {
}
/**
* Is route exist by route name
* 根据路由名称判断路由是否存在
*
* @param routeName
* @param routes
@ -175,7 +186,7 @@ export function isRouteExistByRouteName(routeName: RouteKey, routes: ElegantCons
}
/**
* Recursive get is route exist by route name
* 递归判断路由名称是否存在
*
* @param route
* @param routeName
@ -195,7 +206,7 @@ function recursiveGetIsRouteExistByRouteName(route: ElegantConstRoute, routeName
}
/**
* Get selected menu key path
* 获取选中的菜单 key 路径
*
* @param selectedKey
* @param menus
@ -219,10 +230,10 @@ export function getSelectedMenuKeyPathByKey(selectedKey: string, menus: App.Glob
}
/**
* Find menu path
* 查找菜单路径
*
* @param targetKey Target menu key
* @param menu Menu
* @param targetKey 目标菜单 key
* @param menu 菜单
*/
function findMenuPath(targetKey: string, menu: App.Global.Menu): string[] | null {
const path: string[] = []
@ -255,7 +266,7 @@ function findMenuPath(targetKey: string, menu: App.Global.Menu): string[] | null
}
/**
* Transform menu to breadcrumb
* 将菜单转换为面包屑
*
* @param menu
*/
@ -274,7 +285,7 @@ function transformMenuToBreadcrumb(menu: App.Global.Menu) {
}
/**
* Get breadcrumbs by route
* 根据路由获取面包屑
*
* @param route
* @param menus
@ -316,9 +327,9 @@ export function getBreadcrumbsByRoute(
}
/**
* Transform menu to searchMenus
* 将菜单转换为搜索菜单
*
* @param menus - menus
* @param menus - 菜单
* @param treeMap
*/
export function transformMenuToSearchMenus(menus: App.Global.Menu[], treeMap: App.Global.Menu[] = []) {

View File

@ -76,7 +76,7 @@ abbr:where([title]) {
Remove the default font size and weight for headings.
*/
h1,
/* h1,
h2,
h3,
h4,
@ -84,7 +84,7 @@ h5,
h6 {
font-size: inherit;
font-weight: inherit;
}
} */
/*
Reset links to optimize for opt-in styling instead of opt-out.

View File

@ -0,0 +1,12 @@
// Brand Colors
$primary-color: #4db6ac;
$secondary-color: #333333;
$accent-color: #d4af37;
// Text Colors
$text-color: #004d40;
$group-text-color: #5d4037;
// Backgrounds
$card-bg: #b2dfdb;
$tag-bg: #d7ccc8;

View File

@ -11,6 +11,10 @@ export const themeSettings: App.Theme.ThemeSetting = {
success: '#52c41a',
warning: '#faad14',
error: '#f5222d',
unpublished: '#6b7280', // gray-500
published: '#2563eb', // blue-600
processing: '#16a34a', // green-600
finished: '#dc2626', // red-600
},
isInfoFollowPrimary: true, // 是否开启信息类颜色跟随主题颜色
layout: { mode: 'horizontal', scrollMode: 'content' }, // 布局模式 mode: horizontal | vertical
@ -38,7 +42,7 @@ export const themeSettings: App.Theme.ThemeSetting = {
mixChildMenuWidth: 200, // 侧边栏混合子菜单宽度
autoSelectFirstMenu: false, // 是否自动选择第一个菜单
},
footer: { visible: true, fixed: false, height: 48, right: true }, // 是否固定底部
footer: { visible: false, fixed: false, height: 48, right: true }, // 是否固定底部
watermark: {
visible: false, // 是否显示水印
text: 'reader-starAdmin', // 水印文本

View File

@ -1,6 +1,16 @@
/** Create color palette vars */
function createColorPaletteVars() {
const colors: App.Theme.ThemeColorKey[] = ['primary', 'info', 'success', 'warning', 'error']
const colors: App.Theme.ThemeColorKey[] = [
'primary',
'info',
'success',
'warning',
'error',
'unpublished',
'published',
'processing',
'finished',
]
const colorPaletteNumbers: App.Theme.ColorPaletteNumber[] = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]
const colorPaletteVar = {} as App.Theme.ThemePaletteColor

View File

@ -0,0 +1,399 @@
/**
* Namespace Api
*
* All backend api type
*/
declare namespace Api {
namespace Competition {
/** common params of paginating */
interface PaginatingCommonParams {
/** current page number */
current: number
/** page size */
size: number
/** total count */
total: number
}
/** common params of paginating query list data */
interface PaginatingQueryRecord<T = any> extends PaginatingCommonParams {
records: T[]
}
/** common search params of table */
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'current' | 'size'>
/**
* enable status
*
* - "1": enabled
* - "2": disabled
*/
type EnableStatus = 1 | 2
/** question ui type */
type QuestionUiType = 1 | 2 | 3 | 4 /** 文本输入 | 单选 | 多选 | 下拉选择 */
/** question time type */
type QuestionTimeType = 5 | 10 | 15 | 20 | 30 | 45 | 60 | 90 /** 5s | 10s | 15s | 20s | 30s | 45s | 60s | 90s */
/** question template id */
type QuestionCategoryName =
| 'ChineseCharacterDictation1' // 汉字听写-提示 (jiu表示小鸟的叫声)
| 'ChineseCharacterDictation2' // 根据拼音写汉字-提示 (tang)
| 'CharacterRadicalAddition' // 汉字加一加 (车)
| 'WordDictation' // 词语听写 (zhi re)
| 'IdiomWriting1' // 成语-文字要求 (反义字)
| 'IdiomWriting2' // 成语-文字要求 (看图写成语)
// | 'TEMPLATE_IDIOM_IMAGE' // 成语-看图
| 'PoetryComprehension' // 诗词理解-选择题
/** question score type */
type QuestionScoreType = 0 | 1 /** 固定分数 | 答题个数 */
/** competition round type */
type CompetitionRoundType = 0 | 1 /** 题包环节 | 加时环节 */
/**
* competition publish status
*
* - 0: unpublished
* - 1: published
* - 2: processing
* - 3: finished
*/
enum PublishStatus {
Unpublished = 0,
Published = 1,
Processing = 2,
Finished = 3,
}
interface CompetitionItem {
Id: number
Name: string
StartTime: string
EndTime: string
TeamGroupNumber: number
PublishStatus: number
RoomID: number
}
/** room */
interface Room {
/** room id */
ID: number
/** room name */
Name: string
}
/** question add params */
interface QuestionAddParams {
RoomID: number
MainID: number
TeamGroupID: number
QuestionID: number
QuestionDetaiID: number
TeamGroupQuestionID: number
}
interface QuestionListDetailRound {
/** 题目 id */
Id: number
Name: string
Answers: string
QuestionId: number
IsGood: number
Image: string
ImageUrl: string
Answer: string
}
/** get current question params */
interface GetCurrentQuestionParams {
/** 活动 id */
ActivityID: number
/** 队伍分组 id */
GroupID: number
/** 轮次类型 */
RoundType: CompetitionRoundType
/** 是否已使用 */
isUse?: boolean
}
/** activity detail */
interface ActivityDetail {
/** 背景图片 */
BackgroundImg: string
/** 创建时间 */
CreatedTime: string
/** 结束时间 */
EndTime: string
/** 活动 id */
Id: number
/** 活动名称 */
Name: string
/** 发布状态 */
PublishStatus: number
/** 房间 id */
RoomID: number
/** 开始时间 */
StartTime: string
/** 队伍分组数量 */
TeamGroupNumber: number
/** 队伍数量 */
Teams: number
/** 活动副标题 */
ActivityTitle?: string
/** 普通赛事规则 */
ActivityContent?: string
/** 加时赛规则 */
ExtraTimeContent?: string
}
/** create room request */
interface CreateRoomRequest {
/** 活动 id */
id: number
/** 活动名称 */
name: string
/** 开始时间 */
startTime: string
/** 结束时间 */
endTime: string
/** 队伍数量 */
teams: number
/** 创建时间 */
createdTime: string
/** 房间 id */
roomID: number
/** 队伍分组数量 */
teamGroupNumber: number
/** 背景图片 */
backgroundImg: string
}
/** 当前题目响应 */
interface CurrentQuestionResponse {
/** 活动题目记录 */
Activity_Question: QuestionListRecord
/** 题目列表 */
QuestionList: {
/** 题目 id */
Id: number
/** 题目名称 */
Name: string
/** 题目内容 */
QuestionContent: string
/** 题目类型 */
QuestionType: string
}
/** 队伍分组题目 id */
TeamGroup_QuestionID: number
IsCompleted: boolean
}
/** 获取当前正在比赛的信息 */
interface GetCurrentQuestionInfo {
/** 答案 */
Answer: string
/** 结束时间 */
EndTime: string
/** 高度 */
Height: number
/** 分值 */
Point: number
/** 题目详情 id */
QuestionDetailID: number
QuestionDetaiID: number
/** 题目 id */
QuestionID: number
/** 题目规则 */
QuestionRule: number
/** 开始时间 */
StartTime: string
/** 任务模板列表 */
TaskTemplete: any[]
/** 队伍分组 id */
TeamGroupID: number
/** 队伍分组题目 id */
TeamGroupQuestionID: number
/** 队伍列表 */
TeamList: any[]
/** 宽度 */
Width: number
ActivityID: number
Answer: string
}
/** update room request */
interface UpdateRoomRequest {
/** 活动 id */
id: number
/** 活动名称 */
name: string
/** 开始时间 */
startTime: string
/** 结束时间 */
endTime: string
/** 队伍数量 */
teams: number
/** 队伍分组数量 */
teamGroupNumber: number
/** 活动副标题 */
activityTitle?: string
/** 普通赛事规则 */
activityContent?: string
/** 加时赛规则 */
extraTimeContent?: string
}
/** create team list request */
interface CreateTeamListRequest {
/** 队伍 id */
id: number
/** 主 id */
mainId: number
/** 队伍编号 */
number: string
/** 队伍名称 */
name: string
/** 笔序列号 */
penSerial: string
/** 队员名单 */
nameList: string
/** 学校名称 */
schoolName: string
/** 队伍分组 id */
teamGroupId: number
/** 队伍头像 */
headImg?: string
}
interface TeamListRecord {
/** 队伍 id */
Id: number
/** 主 id */
MainId: number
/** 队伍编号 */
Number: string
/** 队伍名称 */
Name: string
/** 笔序列号 */
PenSerial: string
/** 队员名单 */
NameList: string
/** 学校名称 */
SchoolName: string
/** 队伍分组 id */
TeamGroupId: number
/** 队伍头像 */
HeadImg?: string
}
interface TeamAnswerData {
Id: number
MainId: number
TeamGroupId: number
TeamId: number
QuestionId: number
QuestionDetailId: number | null
UserAnswerPicture: string
UserAnswerFont: string // JSON string
TeamName: string
Points: number
ResultPotins: number
Status: number
// 兼容字段(用于兜底)
AnswerValuePicture?: string
Guid?: string
QuestionDetaiID: number | null
}
interface QuestionListRecord {
/** 活动题目名称 */
ActitvityQuestionName: string
/** 活动 id */
ActivityID: number
/** 主键 id */
ID: number
/** 分值 */
Point: number
/** 题目 id */
QuestionID: number
/** 题目序号 */
QuestionIndex: number
/** 题目规则 */
QuestionRule: number
/** 题目副标题 */
QuestionSubTitle: string
/** 答题时间(秒) */
QuestionTime: number
/** 赛题包类型 */
RoundType: number
/** 题目模板 id */
TemplateID: number
/** UI 类型 */
UIType: QuestionCategoryName
}
interface ActivityTeamGroup {
/** 队伍分组 id */
Id: number
/** 是否已结束 */
IsEnd: boolean
/** 主活动 id */
MainId: number
/** 分组名称 */
Name: string
/** 图标 */
icon: string
/** 队伍分组 id兼容字段 */
id: number
/** 分组名称(兼容字段) */
name: string
}
interface CreateQuestionRequest {
/** 题目 id */
id: number
/** 活动 id */
activityID: number
/** 问题 id */
questionID: number
/** 题目序号 */
questionIndex: number
/** 活动题目名称 */
actitvityQuestionName: string
/** 答题时间(秒) */
questionTime: number
/** 题目规则 */
questionRule: number
/** UI 类型 */
uiType: string
/** 分值 */
point: number
/** 题目副标题 */
questionSubTitle: string
/** 题目模板 id */
templateID: number
/** 赛题包类型 */
roundType: number
}
/** common record */
type CommonRecord<T = any> = {
/** record id */
id: number
/** record creator */
createBy: string
/** record create time */
createTime: string
/** record updater */
updateBy: string
/** record update time */
updateTime: string
/** record status */
status: EnableStatus | null
} & T
}
}

View File

@ -8,6 +8,7 @@ declare namespace Api {
interface LoginToken {
token: string
refreshToken: string
id: number
}
interface UserInfo {

View File

@ -7,21 +7,29 @@ declare namespace Api {
namespace Common {
/** common params of paginating */
interface PaginatingCommonParams {
/** current page number */
current: number
/** page size */
size: number
/** currentPage page number */
currentPage: number
/** page pageSize */
pageSize: number
/** total count */
total: number
total?: number
}
/** common params of paginating query list data */
interface PaginatingQueryRecord<T = any> extends PaginatingCommonParams {
records: T[]
data: T[]
}
/** common search params of table */
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'current' | 'size'>
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'currentPage' | 'pageSize'>
/** common response */
export interface CommonResponse {
success: boolean
code: number
msg: string
data: any
}
/**
* enable status

View File

@ -0,0 +1,37 @@
/**
* Namespace Api
*
* All backend api type
*/
declare namespace Api {
namespace Dictionary {
interface DictionaryItem {
Id: number
DicKey: string
DicValue: string
bakValue: string
}
interface DictionaryUIItem {
id: number
uI_Key: string
uI_Value: string
bakValue: string
dictionaryID: number
}
type AddOrUpdateParams = DictionaryItem
interface AddOrUpdateDictionaryUITypeParams {
id?: number
uI_Key: string
uI_Value: string
bakValue: string
dictionaryID: number
}
interface SearchParams {
RoomID: number
}
}
}

View File

@ -0,0 +1,98 @@
/**
* Namespace Api
*
* All backend api type
*/
declare namespace Api {
namespace Question {
/** common params of paginating */
interface PaginatingCommonParams {
/** pageIndex page number */
pageIndex: number
/** page size */
pageSizes: number
/** total count */
total: number
}
/** common params of paginating query list data */
interface PaginatingQueryRecord<T = any> extends PaginatingCommonParams {
data: T[]
}
/** get question library list all params */
interface GetQuestionLibraryListAllParams {
/** question id */
questionId: number
/** pageIndex page number */
pageIndex: number
/** page size */
pageSizes: number
/** search keyword */
keyWords?: string
}
/** add params */
interface AddParams {
/** question id */
id: number
/** question name */
name: string
/** question content */
questionContent: string
/** question type */
questionType: QuestionType
}
/** add question library params */
interface AddQuestionLibraryParams {
/** question library id */
id: number
/** question library name */
name: string
/** question library content */
answer: string
/** question id */
questionId: number
/** question library image url */
imageUrl: string
/** question library type */
type: QuestionScoreType
/** is priority 0: no 1: yes */
IsGood: number
}
/** common search params of table */
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'current' | 'size'>
/**
* enable status
*
* - "1": enabled
* - "2": disabled
*/
type EnableStatus = '1' | '2'
/** question score type */
type QuestionScoreType = '1' | '2' /** 固定分数 | 答题个数 */
/** competition round type */
type CompetitionRoundType = '1' | '2' /** 题包环节 | 加时环节 */
/** common record */
type CommonRecord<T = any> = {
/** record id */
id: number
/** record creator */
createBy: string
/** record create time */
createTime: string
/** record updater */
updateBy: string
/** record update time */
updateTime: string
/** record status */
status: EnableStatus | null
} & T
}
}

115
apps/admin/src/typings/api/rank.d.ts vendored Normal file
View File

@ -0,0 +1,115 @@
/**
* Namespace Api
*
* All backend api type
*/
declare namespace Api {
namespace Rank {
/** common params of paginating */
interface PaginatingCommonParams {
/** currentPage page number */
currentPage: number
/** page pageSize */
pageSize: number
/** total count */
total?: number
}
/** user search params */
interface UserSearchParams extends PaginatingCommonParams {
/** competition activity name */
competitionActivityName?: string
/** publish status */
publishStatus?: EnableStatus
}
/** common params of paginating query list data */
interface PaginatingQueryRecord<T = any> extends PaginatingCommonParams {
records: T[]
}
/** common search params of table */
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'currentPage' | 'pageSize'>
interface ActivityRankResList extends Array<ActivityRankItem> {}
interface ActivityRankItem {
TeamID: number
Name: string
TotalPoint: number
Questions: QuestionScore[]
}
interface QuestionScore {
QuestionNO: number
ResultPotin: number
}
interface ActivityRankList {
Items: ActivityRank[]
TotalRecords: number
PageNumber: number
PageSize: number
TotalPages: number
}
interface UpdateTeamScoreParams {
/** 主活动 ID */
ActivityID: number
Id: number
/** 结果得分 */
ResultPotins: number
}
/** activity rank */
interface ActivityRank {
/** activity id */
Id: number
/** activity id (MainID) */
ActivityId: number
/** activity name */
ActivityName: string
/** start time */
StartTime: string
/** team number */
TeamNum: number
/** max point */
MaxPoint: number
/** last update time */
LastUpdatetime: string
/** is publish */
Status: number
}
/**
* enable status
*
* - "1": enabled
* - "2": disabled
*/
type EnableStatus = '1' | '2'
/** question score type */
type QuestionScoreType = '1' | '2' /** 固定分数 | 答题个数 */
/** competition round type */
type CompetitionRoundType = '1' | '2' /** 题包环节 | 加时环节 */
/** common record */
type CommonRecord<T = any> = {
/** record id */
id: number
/** record creator */
createBy: string
/** record create time */
createTime: string
/** record updater */
updateBy: string
/** record update time */
updateTime: string
/** record status */
status: EnableStatus | null
} & T
}
}

View File

@ -0,0 +1,139 @@
declare namespace Api {
/**
* namespace SystemManage
*
* backend api module: "systemManage"
*/
namespace SystemManage {
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'current' | 'size'>
/** role */
type Role = Common.CommonRecord<{
/** role name */
roleName: string
/** role code */
roleCode: string
/** role description */
roleDesc: string
}>
/** role search params */
type RoleSearchParams = CommonType.RecordNullable<
Pick<Api.SystemManage.Role, 'roleName' | 'roleCode' | 'status'> & CommonSearchParams
>
/** role list */
type RoleList = Common.PaginatingQueryRecord<Role>
/** all role */
type AllRole = Pick<Role, 'id' | 'roleName' | 'roleCode'>
/**
* user gender
*
* - "1": "male"
* - "2": "female"
*/
type UserGender = '1' | '2'
/** user */
type User = Common.CommonRecord<{
/** user name */
userName: string
/** user gender */
userGender: UserGender | null
/** user nick name */
nickName: string
/** user phone */
userPhone: string
/** user email */
userEmail: string
/** user role code collection */
userRoles: string[]
}>
/** user search params */
type UserSearchParams = CommonType.RecordNullable<
Pick<Api.SystemManage.User, 'userName' | 'userGender' | 'nickName' | 'userPhone' | 'userEmail' | 'status'> &
CommonSearchParams
>
/** user list */
type UserList = Common.PaginatingQueryRecord<User>
/**
* menu type
*
* - "1": directory
* - "2": menu
*/
type MenuType = '1' | '2'
interface MenuButton {
/**
* button code
*
* it can be used to control the button permission
*/
code: string
/** button description */
desc: string
}
/**
* icon type
*
* - "1": iconify icon
* - "2": local icon
*/
type IconType = '1' | '2'
type MenuPropsOfRoute = Pick<
import('vue-router').RouteMeta,
| 'i18nKey'
| 'keepAlive'
| 'constant'
| 'order'
| 'href'
| 'hideInMenu'
| 'activeMenu'
| 'multiTab'
| 'fixedIndexInTab'
| 'query'
>
type Menu = Common.CommonRecord<{
/** parent menu id */
parentId: number
/** menu type */
menuType: MenuType
/** menu name */
menuName: string
/** route name */
routeName: string
/** route path */
routePath: string
/** component */
component?: string
/** iconify icon name or local icon name */
icon: string
/** icon type */
iconType: IconType
/** buttons */
buttons?: MenuButton[] | null
/** children menu */
children?: Menu[] | null
}> &
MenuPropsOfRoute
/** menu list */
type MenuList = Common.PaginatingQueryRecord<Menu>
interface MenuTree {
id: number
label: string
pId: number
children?: MenuTree[]
}
}
}

109
apps/admin/src/typings/api/template.d.ts vendored Normal file
View File

@ -0,0 +1,109 @@
/**
* Namespace Api
*
* All backend api type
*/
declare namespace Api {
namespace Template {
/** common params of paginating */
interface PaginatingCommonParams {
/** currentPage page number */
currentPage: number
/** page pageSize */
pageSize: number
/** total count */
total?: number
}
interface AddTemplateParams {
/** template id */
id: number | null
/** page number */
pageNo: number | null | string
/** template name */
name: string
/** background url */
backGroundUrl: string
/** width */
width: number
/** height */
height: number
/** template content */
tempContent: string
}
/** template detail */
interface TemplateDetail {
/** template id */
ID: number
/** template name */
Name: string
/** width */
Width: number
/** height */
Height: number
/** background url */
BackGroundUrl: string
/** template content */
TempContent: string
/** page number */
PageNo: string
/** competition id */
CompetitionId?: number
}
/** template search params */
interface TemplateSearchParams extends PaginatingCommonParams {
/** competition activity name */
competitionActivityName?: string
/** publish status */
publishStatus?: EnableStatus
/** template name */
templateName?: string
/** competition id */
competitionId?: string
/** size */
size?: string
/** status */
status?: EnableStatus | null
}
/** common params of paginating query list data */
interface PaginatingQueryRecord<T = any> extends PaginatingCommonParams {
records: T[]
}
/** common search params of table */
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'currentPage' | 'pageSize'>
/**
* enable status
*
* - "1": enabled
* - "2": disabled
*/
type EnableStatus = '1' | '2'
/** question score type */
type QuestionScoreType = '1' | '2' /** 固定分数 | 答题个数 */
/** competition round type */
type CompetitionRoundType = '1' | '2' /** 题包环节 | 加时环节 */
/** common record */
type CommonRecord<T = any> = {
/** record id */
id: number
/** record creator */
createBy: string
/** record create time */
createTime: string
/** record updater */
updateBy: string
/** record update time */
updateTime: string
/** record status */
status: EnableStatus | null
} & T
}
}

View File

@ -140,6 +140,10 @@ declare namespace App {
success: string
warning: string
error: string
unpublished: string
published: string
processing: string
finished: string
}
interface ThemeColor extends OtherColor {
@ -194,7 +198,7 @@ declare namespace App {
/** The router push options */
interface RouterPushOptions {
query?: Record<string, string>
query?: Record<string, string | number>
params?: Record<string, string>
}
@ -633,11 +637,13 @@ declare namespace App {
/** The backend service response data */
interface Response<T = unknown> {
/** The backend service response code */
code: string
code: number
/** The backend service response message */
msg: string
/** The backend service response data */
data: T
/** The backend service response success status */
success?: boolean
}
/** The demo backend service response data */

View File

@ -15,36 +15,85 @@ declare module 'vue' {
AppProvider: typeof import('./../components/common/app-provider.vue')['default']
BetterScroll: typeof import('./../components/custom/better-scroll.vue')['default']
ButtonIcon: typeof import('./../components/custom/button-icon.vue')['default']
copy: typeof import('./../components/custom/wave-bg copy.vue')['default']
CompetitionLayout: typeof import('./../components/custom/user/CompetitionLayout.vue')['default']
CountTo: typeof import('./../components/custom/count-to.vue')['default']
DarkModeContainer: typeof import('./../components/common/dark-mode-container.vue')['default']
ExceptionBase: typeof import('./../components/common/exception-base.vue')['default']
FullScreen: typeof import('./../components/common/full-screen.vue')['default']
IconAntDesignEnterOutlined: typeof import('~icons/ant-design/enter-outlined')['default']
IconAntDesignReloadOutlined: typeof import('~icons/ant-design/reload-outlined')['default']
IconAntDesignSettingOutlined: typeof import('~icons/ant-design/setting-outlined')['default']
IconGridiconsFullscreen: typeof import('~icons/gridicons/fullscreen')['default']
IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
'IconIc:baselineArrowRightAlt': typeof import('~icons/ic/baseline-arrow-right-alt')['default']
'IconIc:baselineDeleteForever': typeof import('~icons/ic/baseline-delete-forever')['default']
'IconIc:roundPublish': typeof import('~icons/ic/round-publish')['default']
'IconIc:roundStopCircle': typeof import('~icons/ic/round-stop-circle')['default']
'IconIc:twotoneRule': typeof import('~icons/ic/twotone-rule')['default']
IconIcBaselineAdd: typeof import('~icons/ic/baseline-add')['default']
IconIcBaselineAddPlus: typeof import('~icons/ic/baseline-add-plus')['default']
IconIcBaselineArrowBack: typeof import('~icons/ic/baseline-arrow-back')['default']
IconIcBaselineArrowForward: typeof import('~icons/ic/baseline-arrow-forward')['default']
IconIcBaselineCalendarMonth: typeof import('~icons/ic/baseline-calendar-month')['default']
IconIcBaselineCheck: typeof import('~icons/ic/baseline-check')['default']
IconIcBaselineClose: typeof import('~icons/ic/baseline-close')['default']
IconIcBaselineContentCopy: typeof import('~icons/ic/baseline-content-copy')['default']
IconIcBaselineDelete: typeof import('~icons/ic/baseline-delete')['default']
IconIcBaselineDownload: typeof import('~icons/ic/baseline-download')['default']
IconIcBaselineEdit: typeof import('~icons/ic/baseline-edit')['default']
IconIcBaselineFlash: typeof import('~icons/ic/baseline-flash')['default']
IconIcBaselineGroup: typeof import('~icons/ic/baseline-group')['default']
IconIcBaselineRefresh: typeof import('~icons/ic/baseline-refresh')['default']
IconIcBaselineRocketLaunch: typeof import('~icons/ic/baseline-rocket-launch')['default']
IconIcBaselineSave: typeof import('~icons/ic/baseline-save')['default']
IconIcBaselineScan: typeof import('~icons/ic/baseline-scan')['default']
IconIcBaselineSettingsBackupRestore: typeof import('~icons/ic/baseline-settings-backup-restore')['default']
IconIcBaselineUpload: typeof import('~icons/ic/baseline-upload')['default']
IconIcBaselineUploadFile: typeof import('~icons/ic/baseline-upload-file')['default']
IconIcCarbonUserAvatarFilledAlt: typeof import('~icons/ic/carbon-user-avatar-filled-alt')['default']
IconIcOutlineAccessTime: typeof import('~icons/ic/outline-access-time')['default']
IconIcOutlineAddToPhotos: typeof import('~icons/ic/outline-add-to-photos')['default']
IconIcOutlineArchive: typeof import('~icons/ic/outline-archive')['default']
IconIcOutlineAttachMoney: typeof import('~icons/ic/outline-attach-money')['default']
IconIcOutlineDelete: typeof import('~icons/ic/outline-delete')['default']
IconIcOutlineFolderOff: typeof import('~icons/ic/outline-folder-off')['default']
IconIcOutlineFormatListNumbered: typeof import('~icons/ic/outline-format-list-numbered')['default']
IconIcOutlineTimer: typeof import('~icons/ic/outline-timer')['default']
IconIcOutlineTitle: typeof import('~icons/ic/outline-title')['default']
IconIcRoundAccessTime: typeof import('~icons/ic/round-access-time')['default']
IconIcRoundArrowBack: typeof import('~icons/ic/round-arrow-back')['default']
IconIcRoundArrowForward: typeof import('~icons/ic/round-arrow-forward')['default']
IconIcRoundArrowLeft: typeof import('~icons/ic/round-arrow-left')['default']
IconIcRoundArrowRight: typeof import('~icons/ic/round-arrow-right')['default']
IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
IconIcRoundLibraryAdd: typeof import('~icons/ic/round-library-add')['default']
IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
IconLocalActivity: typeof import('~icons/local/activity')['default']
IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default']
IconIcRoundSearch: typeof import('~icons/ic/round-search')['default']
IconIcRoundStarBorder: typeof import('~icons/ic/round-star-border')['default']
IconIcRoundUpload: typeof import('~icons/ic/round-upload')['default']
IconLocalBanner: typeof import('~icons/local/banner')['default']
IconLocalReadLogo: typeof import('~icons/local/read-logo')['default']
IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
IconMdiArrowUpThin: typeof import('~icons/mdi/arrow-up-thin')['default']
IconMdiDelete: typeof import('~icons/mdi/delete')['default']
IconMdiDrag: typeof import('~icons/mdi/drag')['default']
IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
IconMdiPlus: typeof import('~icons/mdi/plus')['default']
IconMdiRefresh: typeof import('~icons/mdi/refresh')['default']
IconSolarAddSquareLinear: typeof import('~icons/solar/add-square-linear')['default']
IconSolarArchiveUpMinimlisticOutline: typeof import('~icons/solar/archive-up-minimlistic-outline')['default']
IconSolarCloudUploadLinear: typeof import('~icons/solar/cloud-upload-linear')['default']
IconSolarPen2Linear: typeof import('~icons/solar/pen2-linear')['default']
IconSolarQuestionCircleLinear: typeof import('~icons/solar/question-circle-linear')['default']
IconSolarQuestionCircleOutline: typeof import('~icons/solar/question-circle-outline')['default']
IconSolarTrashBinMinimalisticOutline: typeof import('~icons/solar/trash-bin-minimalistic-outline')['default']
'IconStreamlineSharp:typeAreaRemix': typeof import('~icons/streamline-sharp/type-area-remix')['default']
IconTooltip: typeof import('./../components/common/icon-tooltip.vue')['default']
IconUilSearch: typeof import('~icons/uil/search')['default']
LangSwitch: typeof import('./../components/common/lang-switch.vue')['default']
LookForward: typeof import('./../components/custom/look-forward.vue')['default']
MathIframeDialog: typeof import('./../components/common/rest-basic-editor/components/math-iframe-dialog.vue')['default']
MenuToggler: typeof import('./../components/common/menu-toggler.vue')['default']
NAlert: typeof import('naive-ui')['NAlert']
NBadge: typeof import('naive-ui')['NBadge']
@ -53,21 +102,31 @@ declare module 'vue' {
NButton: typeof import('naive-ui')['NButton']
NCard: typeof import('naive-ui')['NCard']
NCheckbox: typeof import('naive-ui')['NCheckbox']
NCollapse: typeof import('naive-ui')['NCollapse']
NCollapseItem: typeof import('naive-ui')['NCollapseItem']
NColorPicker: typeof import('naive-ui')['NColorPicker']
NDataTable: typeof import('naive-ui')['NDataTable']
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
NDivider: typeof import('naive-ui')['NDivider']
NDrawer: typeof import('naive-ui')['NDrawer']
NDrawerContent: typeof import('naive-ui')['NDrawerContent']
NDropdown: typeof import('naive-ui')['NDropdown']
NEditor: typeof import('naive-ui')['NEditor']
NEmpty: typeof import('naive-ui')['NEmpty']
NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem']
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
NGi: typeof import('naive-ui')['NGi']
NGrid: typeof import('naive-ui')['NGrid']
NIcon: typeof import('naive-ui')['NIcon']
NImage: typeof import('naive-ui')['NImage']
NImageGroup: typeof import('naive-ui')['NImageGroup']
NInput: typeof import('naive-ui')['NInput']
NInputGroup: typeof import('naive-ui')['NInputGroup']
NInputGroupLabel: typeof import('naive-ui')['NInputGroupLabel']
NInputNumber: typeof import('naive-ui')['NInputNumber']
NLayout: typeof import('naive-ui')['NLayout']
NLayoutContent: typeof import('naive-ui')['NLayoutContent']
NLayoutHeader: typeof import('naive-ui')['NLayoutHeader']
NList: typeof import('naive-ui')['NList']
NListItem: typeof import('naive-ui')['NListItem']
NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider']
@ -75,20 +134,40 @@ declare module 'vue' {
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NModal: typeof import('naive-ui')['NModal']
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
NPopover: typeof import('naive-ui')['NPopover']
NProgress: typeof import('naive-ui')['NProgress']
NRadio: typeof import('naive-ui')['NRadio']
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSelect: typeof import('naive-ui')['NSelect']
NSelectOption: typeof import('naive-ui')['NSelectOption']
NSpace: typeof import('naive-ui')['NSpace']
NSpin: typeof import('naive-ui')['NSpin']
NStatistic: typeof import('naive-ui')['NStatistic']
NSwitch: typeof import('naive-ui')['NSwitch']
NTab: typeof import('naive-ui')['NTab']
NTable: typeof import('naive-ui')['NTable']
NTabs: typeof import('naive-ui')['NTabs']
NTag: typeof import('naive-ui')['NTag']
NTextarea: typeof import('naive-ui')['NTextarea']
NThing: typeof import('naive-ui')['NThing']
NTooltip: typeof import('naive-ui')['NTooltip']
NTree: typeof import('naive-ui')['NTree']
NTreeSelect: typeof import('naive-ui')['NTreeSelect']
NUpload: typeof import('naive-ui')['NUpload']
NWatermark: typeof import('naive-ui')['NWatermark']
OssImageUpload: typeof import('./../components/common/oss-image-upload/index.vue')['default']
PageHeader: typeof import('./../components/common/page-header.vue')['default']
PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
ReloadButton: typeof import('./../components/common/reload-button.vue')['default']
RestBasicEditor: typeof import('./../components/common/rest-basic-editor/index.vue')['default']
RestDraggableResizable: typeof import('./../components/common/rest-draggable-resizable/rest-draggable-resizable.vue')['default']
RestHoverAciton: typeof import('./../components/common/rest-hover-aciton/rest-hover-aciton.vue')['default']
RestImgViewer: typeof import('./../components/common/rest-img-viewer/rest-img-viewer.vue')['default']
RestSelectFile: typeof import('./../components/common/rest-select-file/rest-select-file.vue')['default']
RestUpload: typeof import('./../components/common/rest-upload/rest-upload.vue')['default']
RestVideoViewer: typeof import('./../components/common/rest-video-viewer/rest-video-viewer.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SoybeanAvatar: typeof import('./../components/custom/soybean-avatar.vue')['default']
@ -97,6 +176,10 @@ declare module 'vue' {
TableColumnSetting: typeof import('./../components/advanced/table-column-setting.vue')['default']
TableHeaderOperation: typeof import('./../components/advanced/table-header-operation.vue')['default']
ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default']
TianZiGe: typeof import('./../components/custom/user/TianZiGe.vue')['default']
Typeit: typeof import('./../components/custom/typeit/index.vue')['default']
UpFileDialog: typeof import('./../components/common/rest-basic-editor/components/up-file-dialog.vue')['default']
WangEditor: typeof import('./../components/common/wang-editor.vue')['default']
WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']
}
}
@ -106,36 +189,85 @@ declare global {
const AppProvider: typeof import('./../components/common/app-provider.vue')['default']
const BetterScroll: typeof import('./../components/custom/better-scroll.vue')['default']
const ButtonIcon: typeof import('./../components/custom/button-icon.vue')['default']
const copy: typeof import('./../components/custom/wave-bg copy.vue')['default']
const CompetitionLayout: typeof import('./../components/custom/user/CompetitionLayout.vue')['default']
const CountTo: typeof import('./../components/custom/count-to.vue')['default']
const DarkModeContainer: typeof import('./../components/common/dark-mode-container.vue')['default']
const ExceptionBase: typeof import('./../components/common/exception-base.vue')['default']
const FullScreen: typeof import('./../components/common/full-screen.vue')['default']
const IconAntDesignEnterOutlined: typeof import('~icons/ant-design/enter-outlined')['default']
const IconAntDesignReloadOutlined: typeof import('~icons/ant-design/reload-outlined')['default']
const IconAntDesignSettingOutlined: typeof import('~icons/ant-design/setting-outlined')['default']
const IconGridiconsFullscreen: typeof import('~icons/gridicons/fullscreen')['default']
const IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
const 'IconIc:baselineArrowRightAlt': typeof import('~icons/ic/baseline-arrow-right-alt')['default']
const 'IconIc:baselineDeleteForever': typeof import('~icons/ic/baseline-delete-forever')['default']
const 'IconIc:roundPublish': typeof import('~icons/ic/round-publish')['default']
const 'IconIc:roundStopCircle': typeof import('~icons/ic/round-stop-circle')['default']
const 'IconIc:twotoneRule': typeof import('~icons/ic/twotone-rule')['default']
const IconIcBaselineAdd: typeof import('~icons/ic/baseline-add')['default']
const IconIcBaselineAddPlus: typeof import('~icons/ic/baseline-add-plus')['default']
const IconIcBaselineArrowBack: typeof import('~icons/ic/baseline-arrow-back')['default']
const IconIcBaselineArrowForward: typeof import('~icons/ic/baseline-arrow-forward')['default']
const IconIcBaselineCalendarMonth: typeof import('~icons/ic/baseline-calendar-month')['default']
const IconIcBaselineCheck: typeof import('~icons/ic/baseline-check')['default']
const IconIcBaselineClose: typeof import('~icons/ic/baseline-close')['default']
const IconIcBaselineContentCopy: typeof import('~icons/ic/baseline-content-copy')['default']
const IconIcBaselineDelete: typeof import('~icons/ic/baseline-delete')['default']
const IconIcBaselineDownload: typeof import('~icons/ic/baseline-download')['default']
const IconIcBaselineEdit: typeof import('~icons/ic/baseline-edit')['default']
const IconIcBaselineFlash: typeof import('~icons/ic/baseline-flash')['default']
const IconIcBaselineGroup: typeof import('~icons/ic/baseline-group')['default']
const IconIcBaselineRefresh: typeof import('~icons/ic/baseline-refresh')['default']
const IconIcBaselineRocketLaunch: typeof import('~icons/ic/baseline-rocket-launch')['default']
const IconIcBaselineSave: typeof import('~icons/ic/baseline-save')['default']
const IconIcBaselineScan: typeof import('~icons/ic/baseline-scan')['default']
const IconIcBaselineSettingsBackupRestore: typeof import('~icons/ic/baseline-settings-backup-restore')['default']
const IconIcBaselineUpload: typeof import('~icons/ic/baseline-upload')['default']
const IconIcBaselineUploadFile: typeof import('~icons/ic/baseline-upload-file')['default']
const IconIcCarbonUserAvatarFilledAlt: typeof import('~icons/ic/carbon-user-avatar-filled-alt')['default']
const IconIcOutlineAccessTime: typeof import('~icons/ic/outline-access-time')['default']
const IconIcOutlineAddToPhotos: typeof import('~icons/ic/outline-add-to-photos')['default']
const IconIcOutlineArchive: typeof import('~icons/ic/outline-archive')['default']
const IconIcOutlineAttachMoney: typeof import('~icons/ic/outline-attach-money')['default']
const IconIcOutlineDelete: typeof import('~icons/ic/outline-delete')['default']
const IconIcOutlineFolderOff: typeof import('~icons/ic/outline-folder-off')['default']
const IconIcOutlineFormatListNumbered: typeof import('~icons/ic/outline-format-list-numbered')['default']
const IconIcOutlineTimer: typeof import('~icons/ic/outline-timer')['default']
const IconIcOutlineTitle: typeof import('~icons/ic/outline-title')['default']
const IconIcRoundAccessTime: typeof import('~icons/ic/round-access-time')['default']
const IconIcRoundArrowBack: typeof import('~icons/ic/round-arrow-back')['default']
const IconIcRoundArrowForward: typeof import('~icons/ic/round-arrow-forward')['default']
const IconIcRoundArrowLeft: typeof import('~icons/ic/round-arrow-left')['default']
const IconIcRoundArrowRight: typeof import('~icons/ic/round-arrow-right')['default']
const IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
const IconIcRoundLibraryAdd: typeof import('~icons/ic/round-library-add')['default']
const IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
const IconLocalActivity: typeof import('~icons/local/activity')['default']
const IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default']
const IconIcRoundSearch: typeof import('~icons/ic/round-search')['default']
const IconIcRoundStarBorder: typeof import('~icons/ic/round-star-border')['default']
const IconIcRoundUpload: typeof import('~icons/ic/round-upload')['default']
const IconLocalBanner: typeof import('~icons/local/banner')['default']
const IconLocalReadLogo: typeof import('~icons/local/read-logo')['default']
const IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
const IconMdiArrowUpThin: typeof import('~icons/mdi/arrow-up-thin')['default']
const IconMdiDelete: typeof import('~icons/mdi/delete')['default']
const IconMdiDrag: typeof import('~icons/mdi/drag')['default']
const IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
const IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
const IconMdiPlus: typeof import('~icons/mdi/plus')['default']
const IconMdiRefresh: typeof import('~icons/mdi/refresh')['default']
const IconSolarAddSquareLinear: typeof import('~icons/solar/add-square-linear')['default']
const IconSolarArchiveUpMinimlisticOutline: typeof import('~icons/solar/archive-up-minimlistic-outline')['default']
const IconSolarCloudUploadLinear: typeof import('~icons/solar/cloud-upload-linear')['default']
const IconSolarPen2Linear: typeof import('~icons/solar/pen2-linear')['default']
const IconSolarQuestionCircleLinear: typeof import('~icons/solar/question-circle-linear')['default']
const IconSolarQuestionCircleOutline: typeof import('~icons/solar/question-circle-outline')['default']
const IconSolarTrashBinMinimalisticOutline: typeof import('~icons/solar/trash-bin-minimalistic-outline')['default']
const 'IconStreamlineSharp:typeAreaRemix': typeof import('~icons/streamline-sharp/type-area-remix')['default']
const IconTooltip: typeof import('./../components/common/icon-tooltip.vue')['default']
const IconUilSearch: typeof import('~icons/uil/search')['default']
const LangSwitch: typeof import('./../components/common/lang-switch.vue')['default']
const LookForward: typeof import('./../components/custom/look-forward.vue')['default']
const MathIframeDialog: typeof import('./../components/common/rest-basic-editor/components/math-iframe-dialog.vue')['default']
const MenuToggler: typeof import('./../components/common/menu-toggler.vue')['default']
const NAlert: typeof import('naive-ui')['NAlert']
const NBadge: typeof import('naive-ui')['NBadge']
@ -144,21 +276,31 @@ declare global {
const NButton: typeof import('naive-ui')['NButton']
const NCard: typeof import('naive-ui')['NCard']
const NCheckbox: typeof import('naive-ui')['NCheckbox']
const NCollapse: typeof import('naive-ui')['NCollapse']
const NCollapseItem: typeof import('naive-ui')['NCollapseItem']
const NColorPicker: typeof import('naive-ui')['NColorPicker']
const NDataTable: typeof import('naive-ui')['NDataTable']
const NDialogProvider: typeof import('naive-ui')['NDialogProvider']
const NDivider: typeof import('naive-ui')['NDivider']
const NDrawer: typeof import('naive-ui')['NDrawer']
const NDrawerContent: typeof import('naive-ui')['NDrawerContent']
const NDropdown: typeof import('naive-ui')['NDropdown']
const NEditor: typeof import('naive-ui')['NEditor']
const NEmpty: typeof import('naive-ui')['NEmpty']
const NForm: typeof import('naive-ui')['NForm']
const NFormItem: typeof import('naive-ui')['NFormItem']
const NFormItemGi: typeof import('naive-ui')['NFormItemGi']
const NGi: typeof import('naive-ui')['NGi']
const NGrid: typeof import('naive-ui')['NGrid']
const NIcon: typeof import('naive-ui')['NIcon']
const NImage: typeof import('naive-ui')['NImage']
const NImageGroup: typeof import('naive-ui')['NImageGroup']
const NInput: typeof import('naive-ui')['NInput']
const NInputGroup: typeof import('naive-ui')['NInputGroup']
const NInputGroupLabel: typeof import('naive-ui')['NInputGroupLabel']
const NInputNumber: typeof import('naive-ui')['NInputNumber']
const NLayout: typeof import('naive-ui')['NLayout']
const NLayoutContent: typeof import('naive-ui')['NLayoutContent']
const NLayoutHeader: typeof import('naive-ui')['NLayoutHeader']
const NList: typeof import('naive-ui')['NList']
const NListItem: typeof import('naive-ui')['NListItem']
const NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider']
@ -166,20 +308,40 @@ declare global {
const NMessageProvider: typeof import('naive-ui')['NMessageProvider']
const NModal: typeof import('naive-ui')['NModal']
const NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
const NPopconfirm: typeof import('naive-ui')['NPopconfirm']
const NPopover: typeof import('naive-ui')['NPopover']
const NProgress: typeof import('naive-ui')['NProgress']
const NRadio: typeof import('naive-ui')['NRadio']
const NRadioGroup: typeof import('naive-ui')['NRadioGroup']
const NScrollbar: typeof import('naive-ui')['NScrollbar']
const NSelect: typeof import('naive-ui')['NSelect']
const NSelectOption: typeof import('naive-ui')['NSelectOption']
const NSpace: typeof import('naive-ui')['NSpace']
const NSpin: typeof import('naive-ui')['NSpin']
const NStatistic: typeof import('naive-ui')['NStatistic']
const NSwitch: typeof import('naive-ui')['NSwitch']
const NTab: typeof import('naive-ui')['NTab']
const NTable: typeof import('naive-ui')['NTable']
const NTabs: typeof import('naive-ui')['NTabs']
const NTag: typeof import('naive-ui')['NTag']
const NTextarea: typeof import('naive-ui')['NTextarea']
const NThing: typeof import('naive-ui')['NThing']
const NTooltip: typeof import('naive-ui')['NTooltip']
const NTree: typeof import('naive-ui')['NTree']
const NTreeSelect: typeof import('naive-ui')['NTreeSelect']
const NUpload: typeof import('naive-ui')['NUpload']
const NWatermark: typeof import('naive-ui')['NWatermark']
const OssImageUpload: typeof import('./../components/common/oss-image-upload/index.vue')['default']
const PageHeader: typeof import('./../components/common/page-header.vue')['default']
const PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
const ReloadButton: typeof import('./../components/common/reload-button.vue')['default']
const RestBasicEditor: typeof import('./../components/common/rest-basic-editor/index.vue')['default']
const RestDraggableResizable: typeof import('./../components/common/rest-draggable-resizable/rest-draggable-resizable.vue')['default']
const RestHoverAciton: typeof import('./../components/common/rest-hover-aciton/rest-hover-aciton.vue')['default']
const RestImgViewer: typeof import('./../components/common/rest-img-viewer/rest-img-viewer.vue')['default']
const RestSelectFile: typeof import('./../components/common/rest-select-file/rest-select-file.vue')['default']
const RestUpload: typeof import('./../components/common/rest-upload/rest-upload.vue')['default']
const RestVideoViewer: typeof import('./../components/common/rest-video-viewer/rest-video-viewer.vue')['default']
const RouterLink: typeof import('vue-router')['RouterLink']
const RouterView: typeof import('vue-router')['RouterView']
const SoybeanAvatar: typeof import('./../components/custom/soybean-avatar.vue')['default']
@ -188,5 +350,9 @@ declare global {
const TableColumnSetting: typeof import('./../components/advanced/table-column-setting.vue')['default']
const TableHeaderOperation: typeof import('./../components/advanced/table-header-operation.vue')['default']
const ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default']
const TianZiGe: typeof import('./../components/custom/user/TianZiGe.vue')['default']
const Typeit: typeof import('./../components/custom/typeit/index.vue')['default']
const UpFileDialog: typeof import('./../components/common/rest-basic-editor/components/up-file-dialog.vue')['default']
const WangEditor: typeof import('./../components/common/wang-editor.vue')['default']
const WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']
}

View File

@ -20,13 +20,36 @@ declare module "@elegant-router/types" {
"403": "/403";
"404": "/404";
"500": "/500";
"admin-home": "/admin-home";
"competition": "/competition";
"competition_competition-add": "/competition/competition-add";
"competition_competition-detail": "/competition/competition-detail";
"competition_competition-list": "/competition/competition-list";
"home": "/home";
"dictionary": "/dictionary";
"iframe-page": "/iframe-page/:url";
"login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?";
"question-store": "/question-store";
"rank": "/rank";
"rank_rank-detail": "/rank/rank-detail";
"rank_rank-list": "/rank/rank-list";
"results": "/results";
"results_results-detail": "/results/results-detail";
"results_results-list": "/results/results-list";
"template": "/template";
"template_template-detail": "/template/template-detail";
"template_template-list": "/template/template-list";
"test": "/test";
"user": "/user";
"user_analysis": "/user/analysis";
"user_cover": "/user/cover";
"user_draw": "/user/draw";
"user_game": "/user/game";
"user_groups": "/user/groups";
"user_home": "/user/home";
"user_rank-list": "/user/rank-list";
"user_rank-pending": "/user/rank-pending";
"user_rules": "/user/rules";
"user_teams": "/user/teams";
};
/**
@ -61,10 +84,17 @@ declare module "@elegant-router/types" {
| "403"
| "404"
| "500"
| "admin-home"
| "competition"
| "home"
| "dictionary"
| "iframe-page"
| "login"
| "question-store"
| "rank"
| "results"
| "template"
| "test"
| "user"
>;
/**
@ -86,10 +116,29 @@ declare module "@elegant-router/types" {
| "500"
| "iframe-page"
| "login"
| "admin-home"
| "competition_competition-add"
| "competition_competition-detail"
| "competition_competition-list"
| "home"
| "dictionary"
| "question-store"
| "rank_rank-detail"
| "rank_rank-list"
| "results_results-detail"
| "results_results-list"
| "template_template-detail"
| "template_template-list"
| "test"
| "user_analysis"
| "user_cover"
| "user_draw"
| "user_game"
| "user_groups"
| "user_home"
| "user_rank-list"
| "user_rank-pending"
| "user_rules"
| "user_teams"
>;
/**

View File

@ -39,5 +39,8 @@ declare namespace StorageType {
}
/** The last login user id */
lastLoginUserId: string
/** The user id */
userId: number
}
}

View File

@ -79,6 +79,8 @@ declare namespace Env {
* - Dynamic: the auth routes is generated in back-end
*/
readonly VITE_AUTH_ROUTE_MODE: 'static' | 'dynamic'
/** Whether to enforce login */
readonly VITE_AUTH_ROUTE_FORCE_LOGIN?: CommonType.YesOrNo
/**
* The home route key
*

View File

@ -0,0 +1,8 @@
declare module '@wangeditor/editor-for-vue' {
import type { DefineComponent } from 'vue'
const Editor: DefineComponent<Record<string, any>, Record<string, any>, any>
const Toolbar: DefineComponent<Record<string, any>, Record<string, any>, any>
export { Editor, Toolbar }
}

View File

@ -0,0 +1,121 @@
export class AudioController {
private audioContext: AudioContext | null = null
private soundFiles: Record<string, string> = {}
private audioCache: Record<string, HTMLAudioElement> = {}
constructor(soundFiles: Record<string, string> = {}) {
this.soundFiles = soundFiles
}
private initAudioContext() {
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
}
}
/**
* 播放指定类型的音效
* 优先使用预设的音频文件,如果没有配置或加载失败,则使用 Web Audio API 合成音效
*/
async play(type: 'start' | 'tick' | 'flip') {
// 尝试播放音频文件
if (this.soundFiles[type]) {
try {
await this.playFile(type)
return
}
catch (error) {
console.warn(`Failed to play audio file for ${type}, falling back to synth.`, error)
}
}
// 回退到合成音效
this.playSynth(type)
}
private playFile(type: string): Promise<void> {
return new Promise((resolve, reject) => {
const url = this.soundFiles[type]
if (!url) {
reject(new Error('No file url'))
return
}
// 使用缓存的 Audio 对象
if (!this.audioCache[type]) {
this.audioCache[type] = new Audio(url)
}
const audio = this.audioCache[type]
audio.currentTime = 0
audio.play()
.then(() => resolve())
.catch(e => reject(e))
})
}
private playSynth(type: 'start' | 'tick' | 'flip') {
this.initAudioContext()
if (!this.audioContext)
return
const ctxTime = this.audioContext.currentTime
const gain = this.audioContext.createGain()
gain.connect(this.audioContext.destination)
if (type === 'start') {
const osc = this.audioContext.createOscillator()
osc.connect(gain)
// 开始音效:高音短促提示
osc.type = 'sine'
osc.frequency.setValueAtTime(880, ctxTime)
osc.frequency.exponentialRampToValueAtTime(440, ctxTime + 0.3)
gain.gain.setValueAtTime(0.5, ctxTime)
gain.gain.exponentialRampToValueAtTime(0.01, ctxTime + 0.3)
osc.start(ctxTime)
osc.stop(ctxTime + 0.3)
}
else if (type === 'tick') {
const osc = this.audioContext.createOscillator()
osc.connect(gain)
// 倒计时音效:急促的滴答声
osc.type = 'triangle'
osc.frequency.setValueAtTime(600, ctxTime)
gain.gain.setValueAtTime(0.3, ctxTime)
gain.gain.exponentialRampToValueAtTime(0.01, ctxTime + 0.1)
osc.start(ctxTime)
osc.stop(ctxTime + 0.1)
}
else if (type === 'flip') {
// 翻书音效:使用白噪声模拟纸张摩擦
const bufferSize = this.audioContext.sampleRate * 0.5 // 0.5秒缓冲
const buffer = this.audioContext.createBuffer(1, bufferSize, this.audioContext.sampleRate)
const data = buffer.getChannelData(0)
// 生成白噪声
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1
}
const noise = this.audioContext.createBufferSource()
noise.buffer = buffer
// 滤波器:低通滤波器,模拟纸张的闷声
const filter = this.audioContext.createBiquadFilter()
filter.type = 'lowpass'
filter.frequency.setValueAtTime(400, ctxTime)
filter.frequency.exponentialRampToValueAtTime(3000, ctxTime + 0.1) // 频率快速扫过,模拟快速翻动
noise.connect(filter)
filter.connect(gain)
// 音量包络:快速淡入淡出
gain.gain.setValueAtTime(0, ctxTime)
gain.gain.linearRampToValueAtTime(0.8, ctxTime + 0.05)
gain.gain.exponentialRampToValueAtTime(0.01, ctxTime + 0.3)
noise.start(ctxTime)
noise.stop(ctxTime + 0.3)
}
}
}

View File

@ -17,10 +17,11 @@ import { $t } from '@/locales'
* ```;
*
* @param record
* @param isNumberKey
*/
export function transformRecordToOption<T extends Record<string, string>>(record: T) {
export function transformRecordToOption<T extends Record<string, string>>(record: T, isNumberKey = false) {
return Object.entries(record).map(([value, label]) => ({
value,
value: isNumberKey ? Number(value) : value,
label,
})) as CommonType.Option<keyof T, T[keyof T]>[]
}
@ -56,3 +57,34 @@ export function toggleHtmlClass(className: string) {
remove,
}
}
/**
* Join path segments
* @param paths
*/
export function browserPathJoin(...paths: string[]) {
return paths.join('/').replace(/\/+/g, '/')
}
/**
* 版本比较
* @param v1
* @param v2
* @param separator 分隔符 默认.
* @returns 1 v1>v2 | -1 v1<v2 | 0 v1==v2
*/
export function compareVersion(v1: string, v2: string, separator = '.') {
const s1 = v1.split(separator)
const s2 = v2.split(separator)
const len = Math.max(s1.length, s2.length)
for (let i = 0; i < len; i++) {
const num1 = Number.parseInt(s1[i] || '0')
const num2 = Number.parseInt(s2[i] || '0')
if (num1 > num2)
return 1
if (num1 < num2)
return -1
}
return 0
}

View File

@ -0,0 +1,335 @@
import { isObject } from './verify'
export interface GroupKey<T extends Record<string, any>> {
/** 子节点的key */
children: string
/** 根据那个字段分组 */
groupKey: keyof T
/** 组的id默认于groupKey相同 */
value?: keyof T
/** 组名的key默认于groupKey相同 */
label: keyof T
}
export interface GroupProps { valueKey?: string, labelKey?: string, childrenKey?: string }
/**
* 数组对象去重 (根据某个字段去重)
* @param arr - 需要去重的数组
* @param key - 需要根据那个字段去重
*/
export function filterRepeat<T extends Record<number | string | symbol, unknown>, K extends keyof T>(arr: T[], key: K): T[] {
const res = new Map<unknown, 1>()
return arr.filter(item => !res.has(item[key]) && res.set(item[key], 1))
}
/**
* 根据key合并数组去掉重复
* 有重复的就保留arr2的
*/
export function mergeArrFun<T extends Record<string, unknown>, K extends keyof T>(arr1: T[], arr2: T[], key: K): T[] {
const res = new Map<unknown, T>()
arr1.forEach((item) => {
res.set(item[key], item)
})
arr2.forEach((item) => {
res.set(item[key], item)
})
const arr: T[] = []
res.forEach((val) => {
arr.push(val)
})
return arr
}
/**
* 根据路径获取对象的值
* @param obj - 对象
* @param path - 如 'a.c'只支持点路径,不支持 'a[0]'
*/
export function getValueByPath(obj: Record<string, unknown>, path: string): unknown {
const paths = path.split('.')
let res: Record<string, unknown> = obj
let result: unknown = ''
paths.forEach((item: string) => {
if (item in res && typeof res[item] === 'object') {
res = res[item] as Record<string, unknown>
}
else {
result = res[item]
}
})
return result
}
/**
* 根据路径 或一个路径数组得到一个由该路径值组成的一个字符串 (多由于唯一字段)
* @param obj - 对象
* @param paths - 如 ['a.c','a' 'a.c.d']只支持点路径,不支持 ['a[0]']
*/
export function getOnlyValue(obj: Record<string, unknown>, paths: string[] | string): string {
if (Array.isArray(paths)) {
let onlyValue = ''
paths.forEach((key) => {
onlyValue += `_${String(getValueByPath(obj, key)) || ''}`
})
return onlyValue
}
else {
return obj[paths] as string
}
}
/**
* 删除指定的元素并返回删除后的数组 [用于需要返回新数组而不是直接修改原数组的情况]
*/
export function mySplice<T>(array: T[], index: number): T[] {
return array.slice(0, index).concat(array.slice(index + 1))
}
/**
* 在数组后面添加一个元素并返回添加后的数组 [用于需要返回新数组而不是直接修改原数组的情况]
*/
export function myPush<T>(array: T[], item: T): T[] {
return array.concat([item])
}
/**
* 特定的值删除数组中的元素并返回删除后的数组 [用于需要返回新数组而不是直接修改原数组的情况]
*/
export function myDelItem<T>(array: T[], item: T): T[] {
const index = array.indexOf(item)
return index >= 0 ? mySplice(array, index) : array
}
/**
* 得到两个数组的交集
*/
export function myGetIntersection<T>(arr1: T[], arr2: T[]): T[] {
if (Array.isArray(arr1) && Array.isArray(arr2)) {
return arr1.filter(item => arr2.includes(item))
}
else {
return []
}
}
/**
* 判断两个数组是否有交集
*/
export function myHaveIntersection<T = unknown>(arr1: T[], arr2: T[]): boolean {
if (Array.isArray(arr1) && Array.isArray(arr2)) {
const index = arr1.findIndex(item => arr2.includes(item))
return index >= 0
}
else {
return false
}
}
/**
* 判断两个对象内容是否相等
* @param obj1 - 需要判断的对象1
* @param obj2 - 需要判断的对象2
* @param notComparisonParam - notComparisonParam 不判断对象中的那些字段 [字符串数组]
*/
export function myEqual(obj1: unknown, obj2: unknown, notComparisonParam: string[] = []): boolean {
// 判断两个对象是否指向同一内存或者全等,指向同一内存或全等返回true
if (obj1 === obj2) {
return true
}
// 类型相同才比较,不同则直接返回false
const obj1Type = typeof obj1
const obj2Type = typeof obj2
// 注意 null 的typeof 也为 'object'
if (obj1Type === obj2Type && obj1 !== null && obj2 !== null) {
if (isObject(obj1) && isObject(obj2)) {
const a = JSON.parse(JSON.stringify(obj1))
const b = JSON.parse(JSON.stringify(obj2))
notComparisonParam.forEach((item) => {
delete a[item]
delete b[item]
})
// 获取两个对象键值数组
const aProps = Object.getOwnPropertyNames(a)
const bProps = Object.getOwnPropertyNames(b)
// 判断两个对象键值数组长度是否一致,不一致返回false
if (aProps.length !== bProps.length) {
return false
}
// 遍历对象的键值
for (const prop in a) {
// 判断a的键值,在b中是否存在,不存在,返回false
if (Object.hasOwn(b, prop)) {
// 判断a的键值是否为对象,是则递归,不是对象直接判断键值是否相等,不相等返回false
if (isObject(a[prop])) {
if (!myEqual(a[prop], b[prop])) {
return false
}
}
else if (a[prop] !== b[prop]) {
return false
}
}
else {
return false
}
}
return true
}
else {
// 不是对象且上面已经判断了不全等则返回false
return false
}
}
else {
return false
}
}
/**
* 把字符串转为Json对象
*/
export function myStrToJson<T = Record<string, unknown>>(obj: T | string): T | undefined {
if (typeof obj === 'string') {
try {
return JSON.parse(obj) as T
// oxlint-disable-next-line no-unused-vars
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (err) {
return undefined
}
}
else {
return undefined
}
}
/**
* 反转对象的键值对(不会改变原对象)
*/
export function invertObjKeyValues<T extends Record<string, string>>(obj: T) {
const newObj: Record<string, string> = {}
for (const key in obj) {
const val = obj[key]
if (val) {
newObj[val] = key
}
}
return newObj
}
/**
* 判断两个对象内容是否相等
* @param obj1 - 需要判断的对象1
* @param obj2 - 需要判断的对象2
* @param notComparisonParam - notComparisonParam 不判断对象中的那些字段 [字符串数组]
*/
export function isEqual(obj1: unknown, obj2: unknown, notComparisonParam: string[] = []): boolean {
// 判断两个对象是否指向同一内存或者全等,指向同一内存或全等返回true
if (obj1 === obj2) {
return true
}
// 类型相同才比较,不同则直接返回false
const obj1Type = typeof obj1
const obj2Type = typeof obj2
// 注意 null 的typeof 也为 'object'
if (obj1Type === obj2Type && obj1 !== null && obj2 !== null) {
if (isObject(obj1) && isObject(obj2)) {
const a = JSON.parse(JSON.stringify(obj1))
const b = JSON.parse(JSON.stringify(obj2))
notComparisonParam.forEach((item) => {
delete a[item]
delete b[item]
})
// 获取两个对象键值数组
const aProps = Object.getOwnPropertyNames(a)
const bProps = Object.getOwnPropertyNames(b)
// 判断两个对象键值数组长度是否一致,不一致返回false
if (aProps.length !== bProps.length) {
return false
}
// 遍历对象的键值
for (const prop in a) {
// 判断a的键值,在b中是否存在,不存在,返回false
if (Object.hasOwn(b, prop)) {
// 判断a的键值是否为对象,是则递归,不是对象直接判断键值是否相等,不相等返回false
if (isObject(a[prop])) {
if (!isEqual(a[prop], b[prop])) {
return false
}
}
else if (a[prop] !== b[prop]) {
return false
}
}
else {
return false
}
}
return true
}
else {
// 不是对象且上面已经判断了不全等则返回false
return false
}
}
else {
return false
}
}
let my__OnlyId = 10000
/** 得到唯一id */
export function getOnlyId() {
my__OnlyId++
return my__OnlyId
}
/**
* 递归分组函数
* @param data 原始数据数组
* @param keys 分组键数组
* @returns 分组后的树形结构
*/
export function groupByKeys<T extends Record<number | string | symbol, any>>(data: T[], groupKeys: GroupKey<T>[]): any[] {
// 递归分组函数
const groupRecursively = (items: T[], keys: GroupKey<T>[]): any[] => {
if (keys.length === 0) {
return []
}
// 获取当前分组键和剩余键
const [currentKey, ...remainingKeys] = keys
const { groupKey, value, label, children = 'children' } = currentKey!
const valueKey = value || groupKey
const labelKey = label || groupKey
// 使用 reduce 按当前键分组
const groupedMap = items.reduce((acc: Map<any, T[]>, item) => {
const keyValue = item[valueKey]
if (!acc.has(keyValue)) {
acc.set(keyValue, [])
}
acc.get(keyValue)!.push(item)
return acc
}, new Map())
// 构建分组节点
return Array.from(groupedMap.entries()).map(([keyValue, groupItems]) => {
// 获取当前分组的标签值(取第一条数据的对应字段)
const labelValue = groupItems[0]![labelKey]
// 递归处理下一级分组
const list: T[] = groupRecursively(groupItems, remainingKeys)
// 构建当前节点
return {
[valueKey]: keyValue,
[labelKey]: labelValue,
[children]: list,
}
})
}
return groupRecursively(data, groupKeys)
}

View File

@ -0,0 +1,49 @@
import path from 'path-browserify'
import { nextTick } from 'vue'
export function getCurrentYear(): Date {
return new Date()
}
/**
* 得到随机数
* @param min 最小值
* @param max 最大值
* @returns
*/
export function getTrueRandomInt(min: number, max: number) {
const range = max - min + 1
const maxSafe = 0xFFFFFFFF // 32位最大无符号整数 (2^32 - 1)
let randomValue = 0
do {
const buffer = new Uint32Array(1)
window.crypto.getRandomValues(buffer)
randomValue = (buffer[0]! / (maxSafe + 1)) * range // [0, range)
} while (randomValue >= range) // 拒绝采样避免偏差
return Math.floor(randomValue) + min
}
/**
* 路径拼接
*/
export function browserPathJoin(base: string, ...paths: string[]) {
const [protocol, ...rest] = base.split('://')
if (rest.length > 0) {
const pathPart = rest.join('://').replace(/\/+/g, '/')
return `${protocol}://${path.join(pathPart, ...paths)}`
}
return path.join(base, ...paths)
}
/**
* 下一个tick后执行
*/
export function nextTickSleep() {
return new Promise((resolve) => {
nextTick(() => {
resolve(true)
})
})
}

View File

@ -0,0 +1,26 @@
/* eslint-disable ts/no-unsafe-function-type */
export class EventBus {
private listeners: Record<string, Function[]> = {}
on(event: string, callback: Function) {
if (!this.listeners[event]) {
this.listeners[event] = []
}
this.listeners[event].push(callback)
}
off(event: string, callback: Function) {
if (!this.listeners[event])
return
this.listeners[event] = this.listeners[event].filter(cb => cb !== callback)
}
emit(event: string, data?: any) {
if (!this.listeners[event])
return
this.listeners[event].forEach(cb => cb(data))
}
}
export const $mitt = new EventBus()
export const open_book_topic_edit = 'open_book_topic_edit'

View File

@ -0,0 +1,339 @@
/* eslint-disable no-console */
import { snapdom } from '@zumer/snapdom'
import { getTrueRandomInt } from './date'
import { getSnowflake } from './rest'
/** 通过 url 判断是否为 图片链接 */
export function isImageUrl(url: string) {
const str = url.split('?')[0]!.substring(url.lastIndexOf('.') + 1) || ''
return ['jpg', 'jpeg', 'png', 'webp', 'svg', 'gif'].includes(str.toLocaleLowerCase())
}
/** 通过 url 判断是否为 图片链接 */
export function isVideoUrl(url: string) {
const str = url.split('?')[0]!.substring(url.lastIndexOf('.') + 1) || ''
return ['m4v', 'mov', '3gp', '3g2', 'mp4', 'flv', 'f4v', 'webm', 'wmv', 'avi', 'asf'].includes(str.toLocaleLowerCase())
}
/** 得到 */
export function getAssetsFile(url: string) {
return new URL(url, import.meta.url).href
}
/**
* 打开文件选择器并返回用户选择的文件
* @param accept 文件类型过滤(如 "image/*"、"application/pdf"
* @param multiple 是否支持多选(默认 false
* @returns Promise<File[]> 返回 File 对象数组
*/
export function selectFiles(accept = '*/*', multiple = false): Promise<File[]> {
return new Promise((resolve, reject) => {
// 1. 创建隐藏的文件输入元素
const input = document.createElement('input')
input.type = 'file'
input.accept = accept
input.multiple = multiple
input.style.display = 'none'
// 2. 监听文件选择事件
input.addEventListener('change', () => {
if (!input.files || input.files.length === 0) {
reject(new Error('未选择文件'))
return
}
// 3. 转换为 File 对象数组
const files = Array.from(input.files)
resolve(files)
// 4. 清理DOM
document.body.removeChild(input)
})
// 5. 触发文件选择弹窗
document.body.appendChild(input)
input.click()
})
}
/**
* Base64字符串转换为File对象
* @param dataUrl Base64字符串
* @param filename 文件名
* @returns File对象
*/
export function base64ToFile(dataUrl: string, filename: string): File {
// 拆分 Data URL
const arr = dataUrl.split(',')
const mimeMatch = arr[0]?.match(/:(.*?);/) || null
if (!mimeMatch || !mimeMatch[1] || !arr[1]) {
throw new Error('无效的Base64字符串')
}
const mime = mimeMatch[1] // 提取 MIME 类型(如 "image/png"
const bstr = atob(arr[1]) // Base64 解码
const n = bstr.length
const u8arr = new Uint8Array(n)
// 将解码后的二进制数据存入 Uint8Array
for (let i = 0; i < n; i++) {
u8arr[i] = bstr.charCodeAt(i)
}
// 生成 File 对象
return new File([u8arr], filename, { type: mime })
}
/**
* 将图片file转换为Base64对象
*/
export function fileToBase64(file: File): Promise<{ id: string, img: string }> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = (e) => {
const date = new Date().getTime()
const num = getTrueRandomInt(100000000, 99999999999)
if (file.size > 1024 * 40) {
// 只有大于40kb才压缩
const img = new Image()
img.src = String(e.target?.result || '')
img.onload = async () => {
const Base64Url = await compressImg(img, file.type)
resolve({ id: `id_${date}_${num}`, img: Base64Url })
}
img.onerror = () => {
reject(new Error('加载图片失败,002'))
}
}
else {
resolve({ id: `id_${date}_${num}`, img: String(e.target?.result || '') })
}
}
reader.onerror = () => {
reject(new Error('加载图片失败,001'))
}
})
}
/**
* 压缩图片
* @param img - 被压缩的img对象
* @param imgType - 图片类型
* @param mx - 触发压缩的图片最大宽度限制
* @param mh - 触发压缩的图片最大高度限制
* @param quality - 清晰度 0到1之间
*/
export function compressImg(img: HTMLImageElement, imgType = 'image/jpeg', mx = 720, mh = 1280, quality = 0.8): Promise<string> {
return new Promise((resolve) => {
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
const { width: originWidth, height: originHeight } = img
// 最大尺寸限制
const maxWidth = mx
const maxHeight = mh
// 目标尺寸
let targetWidth = originWidth
let targetHeight = originHeight
if (originWidth > maxWidth || originHeight > maxHeight) {
if (originWidth / originHeight > 1) {
// 宽图片
targetWidth = maxWidth
targetHeight = Math.round(maxWidth * (originHeight / originWidth))
}
else {
// 高图片
targetHeight = maxHeight
targetWidth = Math.round(maxHeight * (originWidth / originHeight))
}
}
canvas.width = targetWidth
canvas.height = targetHeight
if (context) {
context.clearRect(0, 0, targetWidth, targetHeight)
// 图片绘制
context.drawImage(img, 0, 0, targetWidth, targetHeight)
}
const dataURL = canvas.toDataURL(imgType, quality) // 转换图片为dataURL
// const fun = (blob) => {
// resolve(blob);
// };
// 转换为bolb对象
// canvas.toBlob(fun, imgType, 0.7);
resolve(dataURL)
})
}
/**
* 获取视频第一帧
* @param videoSource - 视频源可以是URL或Blob
* @returns - 第一帧的File对象
*/
export function getFirstFrameOfVideo(
videoSource: Blob | File | string,
): Promise<{ firstFrame: File, duration: number, videoWidth: number, videoHeight: number }> {
return new Promise((resolve, reject) => {
let url = '' // 使用createObjectURL创建的URL
// 创建视频元素
const video = document.createElement('video')
video.crossOrigin = 'Anonymous'
video.setAttribute('playsinline', '')
video.muted = true
// 事件监听:当视频可播放时处理
video.addEventListener('seeked', () => {
const canvas = document.createElement('canvas')
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const context = canvas.getContext('2d')
if (context) {
context.drawImage(video, 0, 0, canvas.width, canvas.height)
const blobCallback = (blob: Blob | null) => {
if (blob) {
const name = `${getSnowflake()}_${Math.random().toString(32).substring(2)}.png`
// 将 Blob 转换为 File
const firstFrame = new File([blob], name, { type: blob.type })
resolve({ firstFrame, videoWidth: video.videoWidth, videoHeight: video.videoHeight, duration: video.duration })
url && URL.revokeObjectURL(url) // 使用createObjectURL创建的URL应在不再需要时通过revokeObjectURL释放以避免内存泄漏。
}
else {
reject(new Error('获取地一帧失败,BD002'))
}
}
canvas.toBlob(blobCallback, 'image/jpeg', 0.7)
}
else {
reject(new Error('获取地一帧失败,BD001'))
}
})
// 错误处理
video.addEventListener('error', () => {
reject(new Error('获取地一帧失败,BD003'))
})
// 设置视频源
if (videoSource instanceof Blob || (videoSource as any) instanceof File) {
// Blob 或 File
url = URL.createObjectURL(videoSource as Blob | File)
video.src = url
video.load()
}
else if (typeof videoSource === 'string') {
video.src = videoSource
video.load()
}
else {
reject(new Error('不支持此类型'))
}
video.currentTime = 0.01
})
}
/**
* 得到图片的宽高
* @param file - 图片,可以是 url 或者File
*/
export function getImageWidthHeight(file: Blob | File | string): Promise<{ width: number, height: number }> {
return new Promise<{ width: number, height: number }>((resolve, reject) => {
const img = new Image()
img.onload = () => {
const size = { width: img.width, height: img.height }
resolve(size)
}
img.onerror = () => {
reject(new Error('读取图片失败,BD0001'))
}
if (typeof file === 'string') {
img.src = file
}
else if (file instanceof File || file instanceof Blob) {
img.src = URL.createObjectURL(file)
}
else {
reject(new Error('读取图片失败,BD0002'))
}
})
}
/**
* html 转换为 jpg图片
*/
export async function htmlToJpgImgFile(html: string, op: { width: number }): Promise<File> {
const type: 'jpeg' | 'jpg' | 'png' | 'svg' | 'webp' = 'jpg'
// 创建临时容器
const container = document.createElement('div')
container.style.width = `${op.width}px`
container.style.position = 'absolute'
container.style.left = '101vw'
container.style.top = '101vh'
container.style.zIndex = '-1'
container.style.backgroundColor = '#ffffff'
container.innerHTML = html
document.body.appendChild(container)
try {
// 等待图片加载完成
const images = container.querySelectorAll('img')
const imageLoadPromises = Array.from(images).map((img) => {
if (img.complete) {
return Promise.resolve()
}
return new Promise((resolve) => {
img.onload = () => resolve(null)
img.onerror = () => resolve(null) // 即使图片加载失败也继续
// 设置超时,防止某些图片一直加载不成功
setTimeout(() => resolve(null), 5000)
})
})
await Promise.all(imageLoadPromises)
console.log('所有图片加载完成,图片数量:', images.length)
// 额外等待确保DOM完全渲染
await new Promise((resolve) => {
setTimeout(() => {
requestAnimationFrame(resolve)
}, 100) // 增加等待时间
})
const name = `${getSnowflake()}_${Math.random().toString(32).substring(2)}.jpg`
console.log('开始截图,容器宽度:', container.offsetWidth, '高度:', container.offsetHeight)
// 使用 snapdom 进行 DOM 快照
const res = await snapdom(container, {
width: op.width,
scale: 1,
type,
backgroundColor: '#ffffff',
})
const blob = await res.toBlob({ type, backgroundColor: '#ffffff' })
console.log('截图完成blob大小:', (blob.size / 1024).toFixed(2), 'KB')
// 将blob转换为base64以便在浏览器查看
const reader = new FileReader()
const base64Promise = new Promise<string>((resolve) => {
reader.onloadend = () => {
resolve(reader.result as string)
}
reader.readAsDataURL(blob)
})
// eslint-disable-next-line unused-imports/no-unused-vars
const base64 = await base64Promise
// console.log("blob=====", blob);
// console.log("图片base64=====", base64);
// console.log("👆 复制上面的base64到浏览器地址栏查看图片");
return new File([blob], name, { type: blob.type })
}
catch (error) {
console.error('htmlToJpgImgFile 错误:', error)
return Promise.reject(error)
}
finally {
// document.body.removeChild(container);
}
}

View File

@ -0,0 +1,42 @@
import OSS, { type Checkpoint } from 'ali-oss'
import { type AliOssSTS, getAliOssTokenAxios } from '@/service/api/upload'
// 初始化OSS客户端
export function initOSSClient(token: AliOssSTS) {
return new OSS({
region: token.Region || 'oss-cn-shenzhen',
accessKeyId: token.AccessKeyId || '',
accessKeySecret: token.AccessKeySecret || '',
stsToken: token.SecurityToken || '',
bucket: token.BucketName || '',
refreshSTSTokenInterval: 600000, // 10分钟刷新一次token
refreshSTSToken: async () => {
// 这里可以添加获取新token的逻辑
const res = await getAliOssTokenAxios()
return {
accessKeyId: res.data?.data?.AccessKeyId || '',
accessKeySecret: res.data?.data?.AccessKeySecret || '',
stsToken: res.data?.data?.SecurityToken || '',
bucket: res.data?.data?.BucketName || '',
}
},
})
}
/**
* 上传文件到OSS (注意进度从 0 开始到 1 结束)
*/
export async function uploadFileToOSS(client: OSS, file: File, path: string, progress?: ((progress: number, checkpoint?: Checkpoint, http?: any) => any) | undefined) {
try {
const result = await client.multipartUpload(path, file, {
parallel: 5, // 并发分片数
partSize: 1024 * 1024 * 5, // 分片大小5MB
progress,
})
return result
}
catch (error) {
console.error('上传文件失败:', error)
throw error
}
}

View File

@ -0,0 +1,104 @@
import { Snowflake } from '@sapphire/snowflake'
/**
* 得到雪花ID
*/
export function getSnowflake(): bigint {
const epoch = new Date('2025-07-01T00:00:00.000Z')
const snowflake = new Snowflake(epoch)
snowflake.workerId = 1
return snowflake.generate()
}
/**
* 并发控制池,限制同时执行的异步任务数量
* @param tasks 异步任务数组(每个任务为返回 Promise 的函数)
* @param concurrency 最大并发数
* @returns Promise解析为所有任务结果的数组
*/
export async function concurrentPool<T>(tasks: (() => Promise<T>)[], concurrency: number): Promise<T[]> {
// 存储所有任务的结果
const results: T[] = []
// 存储当前正在执行的任务
const executing: Promise<void>[] = []
let index = 0 // 任务索引,用于按顺序添加任务
// 创建执行器函数
const execute = async (taskIndex: number): Promise<void> => {
try {
// 执行任务并获取结果
const result = await tasks[taskIndex]!()
results[taskIndex] = result // 按原始顺序存储结果
}
catch (error) {
results[taskIndex] = error as any // 捕获错误(可根据需求调整)
}
finally {
// 无论成功失败,任务完成后从执行队列移除
const executingIndex = executing.findIndex(p => p === executing[taskIndex])
if (executingIndex !== -1) {
executing.splice(executingIndex, 1)
}
}
}
// 启动初始并发任务
while (index < Math.min(concurrency, tasks.length)) {
const taskPromise = execute(index)
executing.push(taskPromise)
index++
}
// 动态管理任务池
while (index < tasks.length) {
if (executing.length < concurrency) {
const taskPromise = execute(index)
executing.push(taskPromise)
index++
}
else {
// 等待任意一个任务完成
await Promise.race(executing)
}
}
// 等待所有剩余任务完成
await Promise.all(executing)
return results
}
/**
* 判断连个版本号大于小于或者等于
* @param v1 - 版本号1
* @param v2 - 版本号2
* @returns -
* 当 v1 大于 v2 时返回 1
* 当 v1 等于 v2 时返回 0
* 当 v1 小于 v2 时返回 -1
*/
export function compareVersion(v1: string, v2: string, operator: '_' | '-' | '.' = '.'): -1 | 0 | 1 {
if (v1 === v2) {
return 0
}
const vs1 = v1.split(operator).map(a => Number.parseInt(a))
const vs2 = v2.split(operator).map(a => Number.parseInt(a))
const length = Math.min(vs1.length, vs2.length)
for (let i = 0; i < length; i++) {
const s1 = vs1[i] || 0
const s2 = vs2[i] || 0
if (s1 > s2) {
return 1
}
else if (s1 < s2) {
return -1
}
}
if (length === vs1.length) {
return -1
}
else {
return 1
}
}

View File

@ -1,9 +1,9 @@
import json5 from 'json5'
/**
* Create service config by current env
* 根据当前环境创建服务配置
*
* @param env The current env
* @param env 当前环境
*/
export function createServiceConfig(env: Env.ImportMeta) {
const { VITE_SERVICE_BASE_URL, VITE_OTHER_SERVICE_BASE_URL } = env
@ -13,11 +13,11 @@ export function createServiceConfig(env: Env.ImportMeta) {
other = json5.parse(VITE_OTHER_SERVICE_BASE_URL)
}
catch {
console.error('VITE_OTHER_SERVICE_BASE_URL is not a valid json5 string')
console.error('VITE_OTHER_SERVICE_BASE_URL 不是有效的 json5 字符串')
}
const httpConfig: App.Service.SimpleServiceConfig = {
baseURL: VITE_SERVICE_BASE_URL,
baseURL: VITE_SERVICE_BASE_URL, // 服务基础URL
other,
}
@ -27,24 +27,23 @@ export function createServiceConfig(env: Env.ImportMeta) {
return {
key,
baseURL: httpConfig.other[key],
proxyPattern: createProxyPattern(key),
proxyPattern: createProxyPattern(key), // 其他服务基础URL的代理模式
}
})
const config: App.Service.ServiceConfig = {
baseURL: httpConfig.baseURL,
proxyPattern: createProxyPattern(),
baseURL: httpConfig.baseURL, // 服务基础URL
proxyPattern: createProxyPattern(), // 服务基础URL的代理模式
other: otherConfig,
}
return config
}
/**
* get backend service base url
* 获取后端服务基础地址
*
* @param env - the current env
* @param isProxy - if use proxy
* @param env - 当前环境
* @param isProxy - 是否使用代理
*/
export function getServiceBaseURL(env: Env.ImportMeta, isProxy: boolean) {
const { baseURL, other } = createServiceConfig(env)
@ -62,9 +61,9 @@ export function getServiceBaseURL(env: Env.ImportMeta, isProxy: boolean) {
}
/**
* Get proxy pattern of backend service base url
* 获取后端服务基础地址的代理模式
*
* @param key If not set, will use the default key
* @param key 如果未设置,将使用默认键
*/
function createProxyPattern(key?: App.Service.OtherBaseURLKey) {
if (!key) {

View File

@ -0,0 +1,105 @@
// 定义文件上传任务类型
interface UploadTask<R, K> {
myFlie: R
resolve: (res: K) => void
reject: (error: Error) => void
}
type UploadFile<R = any, K = any> = (myFlie: R) => Promise<K>
/**
* 文件上传服务类(单例模式),
*/
export class FileUploadService<R, K> {
/** 当前上传的数量 */
private currentUploads = 0
/** 一次最多上传多少个,剩下的加入队列 */
private concurrency = 5
/** 上传队列 */
private uploadTaskList: UploadTask<R, K>[] = []
/** 上传的方法 */
private uploadFile: UploadFile<R, K>
/**
* 私有构造函数
*/
public constructor(_uploadFile: UploadFile, _concurrency?: number) {
this.currentUploads = 0
this.concurrency = _concurrency || 5 // 默认并发数为2可以根据需要调整
this.uploadTaskList = []
this.uploadFile = _uploadFile
}
/**
* 执行文件上传
* @param filesToUpload - 要上传的文件对象数组
* @param concurrency - 并发上传数,默认为构造函数中设置的并发数
*/
public upload(filesToUpload: R[], concurrency: number = this.concurrency): Promise<PromiseSettledResult<Awaited<K>>[]> {
return new Promise<PromiseSettledResult<Awaited<K>>[]>((resolve, reject) => {
const uploadPromises = filesToUpload.map(file => this.addUploadTask(file))
this.concurrency = concurrency // 更新并发数
// 返回所有上传任务的 Promise
Promise.allSettled(uploadPromises)
.then((res) => {
resolve(res) // 所有任务完成后调用总体解决函数
})
.catch((err) => {
console.error('文件上传过程中出现错误:')
reject(err) // 如果有任务失败,直接调用总体拒绝函数
})
})
}
/**
* 添加上传任务到队列
* @param file - 要上传的文件对象
*/
private addUploadTask(myFlie: R): Promise<K> {
return new Promise<K>((resolve, reject) => {
if (this.currentUploads < this.concurrency) {
// 如果当前上传数小于并发数,则直接上传
this.currentUploads++
this.uploadFile(myFlie)
.then((res: K) => {
this.currentUploads--
resolve(res)
this.processQueue()
})
.catch((error) => {
this.currentUploads--
console.error(`上传失败,文件:`, myFlie, '错误====', error)
reject(error)
this.processQueue()
})
}
else {
// 如果当前上传数达到并发数,则加入队列等待
this.uploadTaskList.push({ myFlie, resolve, reject })
}
})
}
/**
* 处理上传队列,依次执行上传任务
*/
private processQueue(): void {
if (this.uploadTaskList.length > 0) {
const { myFlie, resolve, reject } = this.uploadTaskList.shift()!
this.addUploadTask(myFlie)
.then((res: K) => {
resolve(res) // 在完成上传后,调用任务的 resolve
})
.catch((err) => {
reject(err)
})
}
}
/**
* 停止上传
*/
public abort(): void {
this.uploadTaskList = []
}
}

View File

@ -0,0 +1,340 @@
export type $InputVerify =
| 'add_float'
| 'add_int_or_zero'
| 'add_int'
| 'all_float'
| 'all_int_or_zero'
| 'all_int'
| 'minus_float'
| 'minus_int_or_zero'
| 'minus_int'
/** ********************整数**********************整数 */
/** ********************整数**********************整数 */
/** ********************整数**********************整数 */
// 正整数(不含0)
export const add_int_numb_RegEx = /^[1-9]\d*$/
// 负整数(不含0)
export const minus_int_numb_RegEx = /^-[1-9]\d*$/
// 整数 ( 不含0(正负都可以)
export const all_int_numb_RegEx = /^-?[1-9]\d*$/
// 正整数(含0)
export const add_int_or_zero_numb_RegEx = /^[1-9]\d*$|^0$/
// 负整数 (含0)
export const minus_int_or_zero_numb_RegEx = /^-[1-9]\d*$|^0$/
// 正负整数 (含0)
export const all_int_or_zero_numb_RegEx = /^-?[1-9]\d*$|^0$/
/** ********************浮点数**********************浮点数 */
/** ********************浮点数**********************浮点数 */
/** ********************浮点数**********************浮点数 */
// 正浮点数 (不含0)
export const add_float_numb_RegEx = /(^[1-9]\d*|^0)\.\d+$/
// 负浮点数(不含0)
export const minus_float_numb_RegEx = /(^-[1-9]\d*|^-0)\.\d+$/
// 正浮点数 (含0)
export const add_float_ro_zero_numb_RegEx = /(^[1-9]\d*|^0)\.\d+$|^0$/
// 负浮点数(含0)
export const minus_float_ro_zero_numb_RegEx = /(^-[1-9]\d*|^-0)\.\d+$|^0$/
// 浮点数(包含正负浮点数不含0)
export const all_float_numb_RegEx = /(^-?[1-9]\d*|^-?0)\.\d+$/
// 浮点数(包含正负浮点数和0)
export const all_float_or_zero_numb_RegEx = /(^-?[1-9]\d*|^-?0)\.\d+$|^0$/
// 所有合法数字 (整数小数正数负数0) 【不推荐用正则验证直接用isNaN(numb)并判断大于小于就可以了】
export const all_numb = /(^-?[1-9]\d*|^-?0)(\.\d+)?$/
/** ********************数字输入时正则**********************数字输入时正则 */
/** ********************数字输入时正则**********************数字输入时正则 */
/** ********************数字输入时正则**********************数字输入时正则 */
// 正整数 (不含0) 主要用于输入时验证
export const add_int_numb_input_RegEx = /^[1-9]\d*/
// 负整数(不含0) 主要用于输入时验证
export const minus_int_numb_input_RegEx = /^-?[1-9]\d*/
// 正负整数(不含0) 主要用于输入时验证
export const all_int_numb_input_RegEx = /^-?[1-9]\d*/
// 正整数 (含0) 主要用于输入时验证
export const add_int_or_zero_numb_input_RegEx = /^[1-9]\d*$|^0$/
// 负整数 (含0) 主要用于输入时验证
export const minus_int_or_zero_numb_input_RegEx = /^-?[1-9]\d*$|^0/
// 正负整数(含0) 主要用于输入时验证
export const all_int_or_zero_numb_input_RegEx = /^-?\d*/
// 正浮点数 主要用于输入时验证
export const add_float_numb_input_RegEx = /^\d+\.?\d*/
// 负浮点数 主要用于输入时验证
export const minus_float_numb_input_RegEx = /^-?\d*\.?\d*/
// 正负浮点数 主要用于输入时验证 【这里就可以验证合法的数字输入了包含 0. - -0. 】
export const all_float_numb_input_RegEx = /^-?\d*\.?\d*/
/** *******************数字验证****************数字验证 */
/** *******************数字验证****************数字验证 */
/** *******************数字验证****************数字验证 */
/**
* 正整数 (不含0)
*/
export function verifyAddIntNumb(str: string): boolean {
return Boolean(add_int_numb_RegEx.test(str))
}
/**
* 负整数(不含0)
*/
export function verifyMinusIntNumb(str: string): boolean {
return Boolean(minus_int_numb_RegEx.test(str))
}
/**
* 正负整数(不含0)
*/
export function verifyAllIntNumb(str: string): boolean {
return Boolean(all_int_numb_RegEx.test(str))
}
/**
* 正整数 (含0)
*/
export function verifyAddIntRoZeroNumb(str: string): boolean {
return Boolean(add_int_or_zero_numb_RegEx.test(str))
}
/**
* 负整数 (含0)
*/
export function verifyMinusIntRoZeroNumb(str: string): boolean {
return Boolean(minus_int_or_zero_numb_RegEx.test(str))
}
/**
* 正 负 整数(含0)
*/
export function verifyAllIntRoZeroNumb(str: string): boolean {
return Boolean(all_int_or_zero_numb_RegEx.test(str))
}
/**
* 正浮点数
*/
export function verifyAddFloatNumb(str: string): boolean {
return Boolean(add_float_numb_RegEx.test(str))
}
/**
* 负浮点数
*/
export function verifyMinusFloatNumb(str: string): boolean {
return Boolean(minus_float_numb_RegEx.test(str))
}
/**
* 正负浮点数
*/
export function verifyAllsFloatNumb(str: string): boolean {
return Boolean(all_float_numb_RegEx.test(str))
}
/** *******************输入时验证****************输入时验证 */
/** *******************输入时验证****************输入时验证 */
/** *******************输入时验证****************输入时验证 */
/**
* 正整数 (不含0) 主要用于输入时验证
*/
export function verifyAddIntNumbInput(str: number | string): string {
let numb = String(str)
numb = String(Number.parseInt(numb))
const numbArr = numb.match(add_int_numb_input_RegEx)
numb = Array.isArray(numbArr) ? numbArr[0] : ''
numb = numb.replace(/^0+/g, '0')
return numb
}
/**
* 负整数(不含0) 主要用于输入时验证
*/
export function verifyMinusIntNumbInput(str: number | string): string {
let numb = String(str)
numb = String(Number.parseInt(numb))
const numbArr = numb.match(minus_int_numb_input_RegEx)
numb = Array.isArray(numbArr) ? numbArr[0] : ''
numb = numb.replace(/^0+/g, '0')
numb = numb !== '' && numb !== '0' && numb.startsWith('-') ? numb : `-${numb}`
return numb
}
/**
* 正负整数(不含0) 主要用于输入时验证
*/
export function verifyAllIntNumbInput(str: number | string): string {
let numb = String(str)
numb = String(Number.parseInt(numb))
const numbArr = numb.match(all_int_numb_input_RegEx)
numb = Array.isArray(numbArr) ? numbArr[0] : ''
numb = numb.replace(/^0+/g, '0')
return numb
}
/**
* 正整数 (含0) 主要用于输入时验证
*/
export function verifyAddIntRoZeroNumbInput(str: number | string): string {
let numb = String(str)
numb = String(Number.parseInt(numb))
const numbArr = numb.match(minus_int_or_zero_numb_input_RegEx)
numb = Array.isArray(numbArr) ? numbArr[0] : ''
numb = numb.replace(/^0+/g, '0')
return numb
}
/**
* 负整数 (含0) 主要用于输入时验证
*/
export function verifyMinusIntRoZeroNumbInput(str: number | string): string {
const numbArr = String(str).match(minus_int_or_zero_numb_input_RegEx)
let numb = Array.isArray(numbArr) ? numbArr[0] : ''
numb = numb.replace(/^0+/g, '0')
numb = numb.replace(/^-0+/g, '0')
numb = numb !== '' && numb !== '0' && numb.startsWith('-') ? numb : `-${numb}`
return numb
}
/**
* 正 负 整数(含0) 主要用于输入时验证
*/
export function verifyAllIntRoZeroNumbInput(str: number | string): string {
const numbArr = String(str).match(all_int_or_zero_numb_input_RegEx)
let numb = Array.isArray(numbArr) ? numbArr[0] : ''
numb = numb.replace(/^0+/g, '0')
numb = numb.replace(/^-0+/g, '-')
numb = !Number.isNaN(Number(numb)) ? String(Number(numb)) : numb
return numb
}
/**
* 正浮点数 主要用于输入时验证
*/
export function verifyAddFloatNumbInput(str: number | string): string {
let numb = str
if (str === '.') {
numb = '0.'
}
else {
const numbArr = String(numb).match(add_float_numb_input_RegEx)
numb = Array.isArray(numbArr) ? numbArr[0] : ''
numb = numb.replace(/^0+/g, '0')
numb = numb.replace(/^0+\./g, '0.')
numb = numb.match(/^0+[1-9]+/) ? numb.replace(/^0+/g, '') : numb
}
return numb
}
/**
* 负浮点数 主要用于输入时验证
*/
export function verifyMinusFloatNumbInput(str: number | string): string {
let numb = str
if (str === '.') {
numb = '-0.'
}
else {
const numbArr = String(str).match(minus_float_numb_input_RegEx)
numb = Array.isArray(numbArr) ? numbArr[0] : ''
numb = numb.replace(/^0+/g, '0')
numb = numb.replace(/^-0+/g, '-0')
numb = numb.replace(/^0+\./g, '0.')
numb = numb.replace(/^-\./g, '-0.')
numb = numb.match(/^0+[1-9]+/) ? numb.replace(/^0+/g, '') : numb
numb = numb !== '' && numb !== '0' && numb.startsWith('-') ? numb : `-${numb}`
}
return numb
}
/**
* 正负浮点数 主要用于输入时验证
*/
export function verifyAllFloatNumbInput(str: number | string): string {
let numb = str
if (str === '.') {
numb = '0.'
}
else {
const numbArr = String(str).match(all_float_numb_input_RegEx)
numb = Array.isArray(numbArr) ? numbArr[0] : ''
numb = numb.replace(/^0+/g, '0')
numb = numb.replace(/^-0+/g, '-0')
numb = numb.replace(/^0+\./g, '0.')
numb = numb.replace(/^-\./g, '-0.')
numb = numb.match(/^0+[1-9]+/) ? numb.replace(/^0+/g, '') : numb
}
return numb
}
/**
* 验证数字
* @param verify - 验证类型
* @param numb - 需要验证的数字
* @param oldNumb - 修改之前的
*/
export function verifyNumbInput(verify: $InputVerify | undefined, numb: number | string, oldNumb: number | string) {
let newNumb = Number.isNaN(Number(numb)) && numb !== '.' && numb !== '-' ? oldNumb : numb
if (verify === 'add_int') {
// 正整数 (不含0) 主要用于输入时验证
newNumb = verifyAddIntNumbInput(newNumb)
}
else if (verify === 'minus_int') {
// 负整数(不含0) 主要用于输入时验证
newNumb = verifyMinusIntNumbInput(newNumb)
}
else if (verify === 'all_int') {
// 正负整数(不含0) 主要用于输入时验证
newNumb = verifyAllIntNumbInput(newNumb)
}
else if (verify === 'add_int_or_zero') {
// 正整数 (含0) 主要用于输入时验证
newNumb = verifyAddIntRoZeroNumbInput(newNumb)
}
else if (verify === 'minus_int_or_zero') {
// 负整数 (含0) 主要用于输入时验证
newNumb = verifyMinusIntRoZeroNumbInput(newNumb)
}
else if (verify === 'all_int_or_zero') {
// 正负整数(含0) 主要用于输入时验证
newNumb = verifyAllIntRoZeroNumbInput(newNumb)
}
else if (verify === 'add_float') {
// 正浮点数 主要用于输入时验证
newNumb = verifyAddFloatNumbInput(newNumb)
}
else if (verify === 'minus_float') {
// 负浮点数 主要用于输入时验证
newNumb = verifyMinusFloatNumbInput(newNumb)
}
else if (verify === 'all_float') {
// 正负浮点数 主要用于输入时验证
newNumb = verifyAllFloatNumbInput(newNumb)
}
if (newNumb !== '') {
return newNumb
}
else {
return !Number.isNaN(Number(oldNumb)) ? oldNumb : ''
}
}

View File

@ -0,0 +1,162 @@
/** 双字节字符(包括汉字在内,即占两个英文字符位置的字符) */
export const double_byte_RegEx = /[^\x00-\xFF]/ // eslint-disable-line no-control-regex
/** 中文字符 */
export const chinese_RegEx = /[\u4E00-\u9FA5]/
/** 密码至少包含字母、数字、特殊字符中的两种且不能出现4个大小连续或相同的数字 */
export const password_RegEx
// eslint-disable-next-line regexp/optimal-quantifier-concatenation
= /^(?!.*(.)\1{3})(?!.*(0123|1234|2345|3456|4567|5678|6789|9876|8765|7654|6543|5432|4321))(?=.*[a-z])(?=.*\d|.*[!@#¥%&*.,])[a-z\d!@#¥%&*.,]+$/i
/** 密码至少包含字母、数字、特殊字符中的两种且不能出现4个大小连续或相同的数字且为 8到 24 位 */
export const password_RegEx2
// eslint-disable-next-line regexp/optimal-quantifier-concatenation
= /^(?!.*(.)\1{3})(?!.*(0123|1234|2345|3456|4567|5678|6789|9876|8765|7654|6543|5432|4321))(?=.*[a-z])(?=.*\d|.*[!@#¥%&*.,])[a-z\d!@#¥%&*.,]{8,24}$/i
/** 验证电话号 */
export const tel_RegEx = /^1\d{10}$/
/** 邮箱 */
export const email_RegEx = /^([\w-]+)@([\w-]+\.?)*\w\.[a-z]{2,10}$/i
/** 网址(不带参数) */
export const url_RegEx = /^(https?:\/\/)([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(\/\S*)?$/
/** 网址(带参数) */
export const url_RegEx2 = /^(https?:\/\/)([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(\/[^\s?#]*)?(\?[^?\s#]*)?(#\S*)?$/
/** 身份证 */
export const IDcard_RegEx = /^(\d{6})(\d{4})(\d{2})(\d{2})(\d{3})([0-9X])$/i
/** 视频 */
// eslint-disable-next-line regexp/no-dupe-disjunctions
export const video_RegEx = /\.(mp4|mpg|mpeg|dat|asf|avi|rm|rmvb|mov|wmv|flv|mkv|m3u8)/i
/**
* 验证密码合法性
*/
export function verifyPassword(str: string): boolean {
return Boolean(password_RegEx.test(str))
}
/**
* 验证电话号
*/
export function verifyTel(str: string): boolean {
return Boolean(tel_RegEx.test(str))
}
/**
* 验证邮箱
*/
export function verifyEmail(str: string): boolean {
return Boolean(email_RegEx.test(str))
}
/**
* 验证身份证
*/
export function verifyIDcard(str: string): boolean {
if (!IDcard_RegEx.test(str)) {
return false
}
try {
// 出生年月日校验
const year = str.slice(6, 10) // 身份证年
const month = str.slice(10, 12) // 身份证月
const date = str.slice(12, 14) // 身份证日
const d = new Date(`${year}/${month}/${date}`)
const dY = Number(d.getFullYear())
const dM = Number(d.getMonth()) + 1
const dD = Number(d.getDate())
if (Number(year) === dY && Number(month) === dM && Number(date) === dD) {
// 限制起始年份为1850且不能超过今天
if (d.getTime() <= new Date().getTime() && dY >= 1850) {
return true
}
else {
return false
}
}
else {
return false
}
// oxlint-disable-next-line no-unused-vars
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (_) {
return false
}
}
/**
* 验证网址
* @param str - 网址
* @param isParam - 可以携带参数 默认为 true
*/
export function verifyUrl(str: string, isParam = true): boolean {
return Boolean(isParam ? url_RegEx2.test(str) : url_RegEx.test(str))
}
/**
* 是否为纯对象 不包含 Array,undefined function 等等
*/
export function isObject(obj: unknown): obj is Record<number | string | symbol, unknown> {
return Object.prototype.toString.call(obj) === '[object Object]'
}
/**
* 是否为dom
*/
export function isDOM(obj: unknown): obj is HTMLElement {
if (typeof HTMLElement === 'object') {
return obj instanceof HTMLElement
}
else if (obj && typeof obj === 'object') {
return (obj as Record<string, unknown>).nodeType === 1 && typeof (obj as Record<string, unknown>).nodeName === 'string'
}
else {
return false
}
}
/**
* 是否为日期对象
*/
export function isDate(obj: unknown): obj is Date {
return Object.prototype.toString.call(obj) === '[object Date]'
}
/**
* 是否为Math对象
*/
export function iIsMath(obj: unknown): obj is Math {
return Object.prototype.toString.call(obj) === '[object Math]'
}
/**
* 是否为RegExp对象
*/
export function isRegExp(obj: unknown): obj is RegExp {
return Object.prototype.toString.call(obj) === '[object RegExp]'
}
/**
* 是否为json对象
*/
export function isJson(obj: unknown): obj is Record<string, unknown> | unknown[] {
return Boolean(isObject(obj) || Array.isArray(obj))
}
/**
* 是否为空对象(传入对象为假也代表是空对象,如false,null,0等)
*/
export function isNullObj(obj: unknown): boolean {
return Boolean(!obj || JSON.stringify(obj) === '{}')
}
/**
* 判断是否为小屏幕
*/
export function isSmallScreen(): boolean {
return document.documentElement.clientWidth < 900
}

Some files were not shown because too many files have changed in this diff Show More