52 Commits

Author SHA1 Message Date
rjl
3d3f7afcd2 style(results): Remove unnecessary blank line in results detail view
- Remove extra blank line between variable declarations for cleaner code formatting
- Improve code consistency and readability in the component
2026-03-20 17:05:39 +08:00
rjl
18e3f6f7f2 feat(admin): 优化大屏开发体验并完善用户界面组件
- 新增大屏开发调试指南文档,包含设计基准、调试方法和字体配置说明
- 添加字体大小预览工具,支持多分辨率视口切换(2048x1080/1920x1080/3840x2160)
- 新增字体资源 README 文档和团队标题背景图片
- 优化用户界面背景图片(title-bg3.png、title-bg4.png)
- 更新 ActionButton 和 CompetitionLayout 组件样式
- 完善用户模块各页面布局(分析、封面、抽签、游戏、规则、团队)
- 优化题目渲染器组件和成绩详情页面
- 更新 UnoCSS 配置以支持大屏字体缩放方案
2026-03-20 17:05:23 +08:00
rjl
e061f46e66 chore(config): 优化Kiro配置并调整UI组件样式
- 更新Kiro AI助手配置,新增强制语言和中文模式设置
- 添加用户排名背景框图片资源
- 优化CompetitionLayout组件标题样式(字体大小和字间距调整)
- 优化用户封面页面卡片布局,根据屏幕尺寸动态调整顶部padding
- 删除联想对对碰题库数据文件
2026-03-19 14:52:36 +08:00
rjl
7d49143ae0 feat(admin): 新增UUID生成工具并优化模板管理
- 新增 uuid.ts 工具函数,提供原生和兼容性 UUID 生成方案
- 更新模板详情页面,使用 generateUUID() 替代 crypto.randomUUID()
- 模板列表新增页码字段显示
- 优化模板列表组件代码格式,改进可读性
- 更新 Git 提交消息规范文档,强制使用中文描述和具体要求
2026-03-19 11:16:01 +08:00
rjl
d1f6044a6c chore(config): 配置 Kiro AI 助手并规范化 Git 提交消息
- 新增 .kiro/settings/kiro.json 配置文件,设置 AI 助手语言为中文
- 新增 .kiro/steering/chinese-commit-messages.md 文档,规范化项目 Git 提交消息格式
- 更新 .vscode/settings.json,添加 Kiro AI 助手和 Git 相关配置
- 优化 QuestionConfigCard.vue 中时间选项的排序逻辑
- 重新启用 cover/index.vue 中的 watch 监听器并优化代码格式
- 优化 teams/index.vue 的代码结构和可读性
2026-03-19 09:35:34 +08:00
rjl
9a80fd9fa1 feat(question-importer): Replace poetry knowledge with association game questions
- Add new association game question bank (联想对对碰.json) with 25 classical poetry matching questions
- Remove outdated poetry knowledge question set (诗词常识.json)
- Update store with new association game data
- Expand question variety to include poetry-based word association challenges
2026-03-18 17:52:44 +08:00
rjl
fe4d8f6d62 chore(admin): Update build configuration and ignore analysis reports
- Add report.html files to .gitignore to exclude analysis reports from version control
- Update build time configuration to include milliseconds for unique timestamps
- Add explanatory comment for build time millisecond precision
- Regenerate bundle analysis report with updated build configuration
2026-03-18 17:22:02 +08:00
rjl
1c8035d935 feat(admin): 优化字典管理界面并更新构建报告
- 优化字典列表页面交互与展示逻辑
- 改进字典类型模态框组件功能
- 更新构建分析报告数据
2026-03-18 17:14:07 +08:00
rjl
2fad025496 feat(admin): 新增品牌logo资源并优化用户界面组件
- 新增重庆、树人、文字logo资源(PNG和WebP格式)
- 新增用户界面背景图片资源(内容背景、对框、主页背景等)
- 优化ActionButton组件样式(自适应宽度、禁用状态样式)
- 更新CompetitionLayout组件header布局,集成新logo资源
- 优化TianZiGe组件功能和交互体验
- 更新竞赛相关页面组件(QuestionConfig、draw、QuestionRenderer等)
- 更新排名列表页面样式和功能
- 更新诗词常识题库数据
- 完善Competition类型定义
2026-03-18 16:58:13 +08:00
rjl
02b4720b34 feat(admin): 优化用户界面组件并新增自定义按钮样式
- 新增按钮背景图片资源(btn-bg.png、progress-title.png、title-bg3.png、title-bg4.png)
- 扩展 ActionButton 组件支持自定义按钮类型,新增 custom 类型用于背景图片驱动的按钮
- 添加 btnBg 和 fontSize 属性到 ActionButton,支持动态背景和字体大小配置
- 重构 ActionButton 样式结构,分离 standard-btn 和 custom-btn 两种按钮样式
- 优化按钮禁用状态的样式处理和交互反馈
- 更新 CompetitionLayout 组件支持新的标题背景类型(titleBg3、titleBg4)
- 新增 titleTextColor 和 fontSize 属性到 CompetitionLayout,增强标题和按钮的定制化能力
- 更新所有用户页面组件(cover、draw、game、groups、rules、teams)以适配新的按钮和布局配置
2026-03-17 17:51:12 +08:00
rjl
8964fcdea7 feat(admin): 优化用户界面资源并更新题库数据
- 添加首页背景图片资源 (home-bg.webp)
- 更新用户头像资源文件 (ydzx.png)
- 优化竞赛布局组件交互体验
- 改进题目渲染器显示效果
- 更新题库数据:新增诗词常识题库
- 移除联想对对碰题库
- 更新汉字听写、诗词理解选择题、逆向接诗句等题库内容
- 调整VSCode配置并禁用MCP代理功能
2026-03-17 15:28:11 +08:00
rjl
771fb807eb feat(admin): 新增题目类型、优化题目库管理并添加拼音助手
- 新增6种题目类型枚举:逆向接诗句、场景猜诗句、联想对对碰、字词飞花令、诗词常识、换字组成语
- 新增QuestionDrawer组件,支持富文本编辑和拼音转换功能
- 优化CategoryTree组件,增加分类选择本地存储和自动填充题目类型
- 更新question API接口,简化新增和更新题目库的参数传递
- 扩展QuestionRenderer组件,支持新增的题目类型渲染
- 添加多个题目库JSON文件,包含成语、诗词常识、字词飞花令等题目数据
- 调整环境配置,更新开发环境API地址
2026-03-16 17:59:49 +08:00
rjl
782c61a38c chore(admin): 优化PWA配置并更新资源文件
- 增强PWA开发环境支持,添加devOptions配置以便调试
- 优化Workbox缓存策略,排除大型SVG文件预缓存
- 添加运行时缓存规则支持SVG、图片等资源的CacheFirst策略
- 更新PWA manifest图标路径,使用public文件夹中的新图标资源
- 添加Service Worker注册文件和Workbox库文件
- 替换favicon.svg为favicon.ico,新增多种尺寸的PWA图标
- 生成PWA构建报告文件
- 优化manifest描述信息
2026-03-16 10:55:53 +08:00
rjl
40a6850c3f chore(admin): 优化构建配置并升级资源格式
- 新增构建插件:打包分析、压缩、自动部署、图片优化和PWA支持
- 将SVG资源转换为WebP格式以提升加载性能
- 添加新的背景图片资源用于用户界面优化
- 更新环境配置:调整基础URL和服务地址
- 优化用户界面组件以适配新的资源格式
- 更新package.json依赖以支持新的构建功能
- 改进构建流程以支持自动部署和性能监控
2026-03-13 17:52:13 +08:00
rjl
9d801ba6b4 refactor(admin): 移除待定排名页面并更新配置
- 删除待定排名页面及相关路由配置
- 更新环境变量配置,调整API地址和主页路由
- 添加vite-auto-deploy依赖和nginx配置文件
- 更新组件类型定义,移除未使用的组件
- 调整页面标题和图片资源
2026-03-13 09:39:49 +08:00
rjl
11f448cedc feat(admin): 优化用户界面交互与组件功能
- 在抽题页面添加开始抽题按钮并绑定事件
- 调整队伍列表动画延迟和分组页面样式
- 修复结果发布条件逻辑,允许无结束时间时发布
- 增强TypeIt组件,支持动态配置和实例管理
- 更新Competition API类型定义,使用枚举类型
- 优化CompetitionLayout组件按钮显示时机
- 修正分析页面API调用参数类型
- 在拼写检查词典中添加Typeit
2026-03-12 14:33:30 +08:00
rjl
41f7ba7b8a feat(admin): 优化用户界面动画与交互体验
- 调整倒计时音效触发时机为剩余5秒并添加新音频文件
- 为SvgIcon组件增加size属性以支持自定义图标尺寸
- 优化ActionButton组件的动画效果和视觉反馈
- 修复游戏页面组件卸载时未停止计时器的问题
- 重构音频控制器,改进音频预加载和播放逻辑
- 优化团队选择页面的卷轴动画和布局跳转逻辑
- 改进小组选择页面的视觉样式和响应式布局
- 增强CompetitionLayout组件的标题动画和内容显示逻辑
2026-03-11 17:58:57 +08:00
rjl
b9c5886437 feat: 优化竞赛流程并修复题目禁用功能
- 调整倒计时音效触发时间为剩余10秒,并更新音频文件
- 修复题目禁用功能,将IsDisabled类型从number改为boolean
- 优化抽题页面,替换为静态图片并禁用放大预览
- 为卡片添加3D悬停效果,提升交互体验
- 在结果发布前检查倒计时是否结束,防止提前发布
- 优化团队选择页面样式,更新图标和背景
- 修复Typewriter组件内容重复累加的问题
- 更新图表标签从"正确数量"改为"预估得分"
- 优化竞赛布局,统一"下一步"按钮文本
2026-03-10 17:55:45 +08:00
rjl
11fb5481d3 feat(competition): 支持决赛环节并添加加时赛管理功能
- 扩展竞赛环节类型,支持决赛环节(RoundType=3)
- 新增加时赛创建功能,支持多分组队伍选择
- 添加题目库禁用/启用功能,支持批量操作
- 优化用户端竞赛流程,区分常规赛和加时赛
- 重构操作按钮组件,提高代码复用性
2026-03-09 17:34:40 +08:00
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
262 changed files with 32842 additions and 1278 deletions

4
.gitignore vendored
View File

@ -7,6 +7,10 @@ dist-ssr
apps/*/dist
packages/*/dist
# --- 分析报告文件 ---
report.html
apps/*/report.html
# --- 日志文件 ---
logs
*.log

9
.kiro/settings/kiro.json Normal file
View File

@ -0,0 +1,9 @@
{
"kiro.ai.language": "zh-CN",
"kiro.ai.commitMessage.language": "chinese",
"kiro.ai.commitMessage.forceLanguage": "zh-CN",
"kiro.git.commitMessageLanguage": "zh-CN",
"kiro.assistant.locale": "zh-CN",
"kiro.assistant.language": "chinese",
"kiro.assistant.alwaysUseChinese": true
}

View File

@ -0,0 +1,110 @@
---
inclusion: auto
---
# Git 提交消息规范
**重要:所有提交消息必须使用中文!**
本项目严格遵循 [Conventional Commits](https://www.conventionalcommits.org/) 规范。
## 提交消息格式
```
<type>(<scope>): <subject>
<body>
<footer>
```
### 必需部分
#### Type (类型)
提交的类型必须是以下之一:
- **feat**: 新功能 (feature)
- **fix**: 修复 bug
- **docs**: 仅文档更改
- **style**: 不影响代码含义的更改(空格、格式化、缺少分号等)
- **refactor**: 既不修复 bug 也不添加功能的代码更改
- **perf**: 提高性能的代码更改
- **test**: 添加缺失的测试或更正现有测试
- **build**: 影响构建系统或外部依赖的更改示例范围webpack, npm, vite
- **ci**: 对 CI 配置文件和脚本的更改示例范围GitHub Actions, GitLab CI
- **chore**: 其他不修改 src 或测试文件的更改
- **revert**: 回退之前的提交
#### Subject (主题)
- **必须使用中文描述**
- 使用简洁的语言描述本次提交的更改
- 不超过 50 个字符
- 首字母小写
- 结尾不加句号
### 可选部分
#### Scope (范围)
用于说明提交影响的范围,例如:
- **component**: 组件相关
- **api**: API 相关
- **router**: 路由相关
- **store**: 状态管理相关
- **utils**: 工具函数相关
- **styles**: 样式相关
- **deps**: 依赖项相关
#### Body (正文)
- 详细描述本次提交的动机和更改内容
- 可以分多行
- 应该说明"是什么"和"为什么",而不是"怎么做"
#### Footer (页脚)
- 用于关闭 Issue 或描述破坏性变更
- Breaking Changes 必须在页脚中以 `BREAKING CHANGE:` 开头
- 关闭 Issue 使用 `Closes #issue号`
## 示例(必须使用中文)
### 简单的功能添加
```
feat(article): 添加文章搜索功能
```
### 修复 bug
```
fix(login): 修复登录状态未持久化的问题
```
### 包含详细描述
```
feat(editor): 支持 markdown 实时预览
添加了一个新的编辑器组件,支持 markdown 的实时预览功能。
用户在编辑时可以同时看到渲染后的效果,提升编辑体验。
Closes #123
```
### 破坏性变更
```
feat(api): 重构用户认证 api
BREAKING CHANGE: 用户认证接口从 /api/auth 迁移到 /api/v2/auth
旧接口将在下一个版本中移除。客户端需要更新 API 端点。
```
## 重要规则
1. **严格遵循格式**: `<type>(<scope>): <subject>`
2. **type 必须小写**: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
3. **subject 必须使用中文**: 不要使用英文描述
4. **subject 首字母小写**: 不要大写开头
5. **subject 不加句号**: 结尾不要加标点符号
6. **不超过 50 字符**: subject 部分要简洁
7. **破坏性变更**: 必须在 body 中以 `BREAKING CHANGE:` 开头说明
## 生成提交消息时的要求
- 始终使用中文
- 动词开头(如:添加、修复、更新、优化、重构)
- 描述要具体,避免模糊的表述
- 如果有多个更改,选择最主要的作为 subject

20
.vscode/settings.json vendored
View File

@ -36,5 +36,23 @@
"./apps/user",
"./packages/eslint-config"
],
"totvsLanguageServer.welcomePage": false // `
"totvsLanguageServer.welcomePage": false,
"cSpell.words": [
"activityinfo",
"Echarts",
"Typeit"
],
// Kiro AI 助手配置
"kiroAgent.configureMCP": "Disabled",
"kiro.ai.language": "zh-CN",
"kiro.ai.commitMessage.language": "chinese",
"kiro.git.commitMessageLanguage": "zh-CN",
"kiro.assistant.locale": "zh-CN",
// Git 配置
"git.inputValidationLength": 72,
// GitHub Copilot 语言设置(如果安装了)
"github.copilot.chat.localeOverride": "zh-CN"
}

View File

@ -1,9 +1,17 @@
# 应用的基础URL默认为 "/"
# 如果使用子目录,必须以 "/" 结尾,例如 "/admin/" 而不是 "/admin"
VITE_BASE_URL=/
VITE_BASE_URL='/'
# 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,23 +24,26 @@ 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
VITE_ROUTE_HOME=user_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=http://api.ydzx.qyzhjy.com
VITE_SERVICE_BASE_URL=http://172.16.10.130:5000
VITE_OTHER_SERVICE_BASE_URL= `{
"demo": "http://localhost:9528"
}`

View File

@ -1,5 +1,5 @@
# backend service base url, prod environment
VITE_SERVICE_BASE_URL=https://mock.apifox.cn/m1/3109515-0-default
VITE_SERVICE_BASE_URL=http://api.ydzx.qyzhjy.com
# other backend service base url, prod environment
VITE_OTHER_SERVICE_BASE_URL= `{

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

@ -0,0 +1,110 @@
# 大屏开发调试指南
## 项目配置
- **设计基准**: 2048 x 1080
- **缩放方案**: VScaleScreen
- **目标设备**: 超级大屏(投屏显示)
## 开发调试方法
### 方法 1: Chrome DevTools 自定义设备(推荐)
1. 打开 Chrome DevTools (F12)
2. 点击设备模拟按钮 (Ctrl/Cmd + Shift + M)
3. 在设备下拉菜单中选择 "Edit..."
4. 添加自定义设备:
- Device Name: `Large Screen 2048x1080`
- Width: `2048`
- Height: `1080`
- Device pixel ratio: `1`
- User agent string: (保持默认)
5. 选择刚创建的设备进行调试
### 方法 2: 浏览器窗口缩放
1. 将浏览器窗口调整为合适大小
2. 使用浏览器缩放功能 (Ctrl/Cmd + 鼠标滚轮)
3. 调整到能看到完整布局的比例
### 方法 3: 响应式设计模式
在 DevTools 中:
1. 选择 "Responsive" 模式
2. 手动输入宽度: `2048`
3. 手动输入高度: `1080`
## 字体大小说明
当前字体配置已针对 2048x1080 优化:
| Class | 实际大小 | 适用场景 |
|-------|---------|---------|
| `text-xs` | 12.8px | 辅助文字、标注 |
| `text-sm` | 15.2px | 次要文字 |
| `text-base` | 17.6px | 正文 |
| `text-lg` | 20.8px | 小标题 |
| `text-xl` | 24px | 中标题 |
| `text-2xl` | 28px | 大标题 |
| `text-3xl` | 33.6px | 特大标题 |
| `text-4xl` | 40px | 主标题 |
| `text-5xl` | 51.2px | 超大标题 |
| `text-6xl` | 64px | 巨大标题 |
## VScaleScreen 工作原理
```
设计基准 (2048x1080)
VScaleScreen 自动缩放
实际显示设备 (任意分辨率)
```
**示例:**
- 在 1920x1080 屏幕上:内容会略微缩小
- 在 3840x2160 (4K) 屏幕上:内容会放大约 2 倍
- 在更大的 LED 屏上:内容会等比例放大
## 常见问题
### Q: 在我的电脑上字体看起来太大了?
A: 这是正常的!因为你的设计基准 (2048x1080) 比普通电脑屏幕大。请使用上述方法在正确的分辨率下调试。
### Q: 如何确认实际大屏的分辨率?
A:
1. 询问现场技术人员
2. 常见大屏分辨率:
- 1920x1080 (Full HD)
- 2560x1440 (2K)
- 3840x2160 (4K)
- 7680x4320 (8K)
### Q: 需要修改 VScaleScreen 的尺寸吗?
A: 通常不需要。保持 2048x1080 即可VScaleScreen 会自动适配。除非:
- 实际大屏比例不是 16:9
- 需要针对特定分辨率优化
### Q: 如何微调字体大小?
A: 修改 `uno.config.ts` 中的 `fontSize` 配置,然后重启开发服务器。
## 测试清单
部署前请确认:
- [ ] 在 2048x1080 分辨率下所有文字清晰可读
- [ ] 所有布局元素位置正确
- [ ] 动画效果流畅
- [ ] 图片清晰不模糊(建议使用 2x 或 3x 分辨率的图片)
- [ ] 在实际大屏上测试(如果可能)
## 性能优化建议
1. **图片资源**: 使用 WebP 格式,准备高分辨率版本
2. **字体**: 使用 Web Font确保在大屏上渲染清晰
3. **动画**: 使用 CSS transform 和 opacity避免触发重排
4. **测试**: 在接近实际大屏的设备上测试性能
## 联系与支持
如有问题,请联系开发团队。

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

@ -6,7 +6,9 @@ export function getBuildTime() {
dayjs.extend(utc)
dayjs.extend(timezone)
const buildTime = dayjs.tz(Date.now(), 'Asia/Shanghai').format('YYYY-MM-DD HH:mm:ss')
// 添加随机毫秒确保每次构建时间唯一
const now = Date.now()
const buildTime = dayjs.tz(now, 'Asia/Shanghai').format('YYYY-MM-DD HH:mm:ss.SSS')
return buildTime
}

View File

@ -0,0 +1,11 @@
import type { PluginOption } from 'vite'
import { visualizer } from 'rollup-plugin-visualizer'
export function setupAnalyzer() {
return visualizer({
open: true,
gzipSize: true,
brotliSize: true,
filename: 'report.html',
}) as PluginOption
}

View File

@ -0,0 +1,11 @@
import viteCompression from 'vite-plugin-compression'
export function setupCompression() {
return viteCompression({
verbose: true,
disable: false,
threshold: 10240,
algorithm: 'gzip',
ext: '.gz',
})
}

View File

@ -0,0 +1,21 @@
import { viteAutoDeployPlugin } from 'vite-auto-deploy'
import { name } from '../../package.json'
export function setupDeploy(isBuild: boolean) {
return viteAutoDeployPlugin({
/** 上传地址 */
uploadUrl: 'http://47.109.17.238:8200/api/upload/code/deploy',
/** 项目名称 (如果多个项目都是用的同上传地址时,服务端可用此字段区分是哪一个项目) */
projectKey: name,
/** 上传完成后,是否保留压缩包到本地 */
saveLocalZip: true,
/** 是否开启自动部署 */
autoDeploy: isBuild,
headers: {
'Custom-Timestamp': new Date().getTime().toString(),
'Custom-Run-Platform': 'app',
'Custom-Device-Id': Math.random().toString(36).substring(2),
'Custom-Os-Name': 'windows',
},
})
}

View File

@ -0,0 +1,51 @@
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
export function setupImageOptimizer() {
return ViteImageOptimizer({
logStats: true,
// SVG 优化配置
svg: {
multipass: true,
plugins: [
{
name: 'preset-default',
params: {
overrides: {
removeViewBox: false,
cleanupIds: false,
},
},
},
'sortAttrs',
],
},
// PNG 高质量压缩
png: {
quality: 90,
compressionLevel: 9,
},
// JPEG 优化
jpeg: {
quality: 85,
mozjpeg: true,
},
jpg: {
quality: 85,
mozjpeg: true,
},
// WebP 转换
webp: {
quality: 80,
lossless: false,
force: true,
},
includePublic: true,
cache: true,
cacheLocation: 'node_modules/.cache/image-optimizer',
})
}

View File

@ -2,14 +2,20 @@ import type { PluginOption } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import progress from 'vite-plugin-progress'
import { setupAnalyzer } from './analyzer'
import { setupCompression } from './compression'
import { setupDeploy } from './deploy'
import { setupDevtoolsPlugin } from './devtools'
import { setupHtmlPlugin } from './html'
import { setupImageOptimizer } from './image-optimizer'
import { setupPWA } from './pwa'
import { setupElegantRouter } from './router'
import { setupUnocss } from './unocss'
import { setupUnplugin } from './unplugin'
export function setupVitePlugins(viteEnv: Env.ImportMeta, buildTime: string) {
export function setupVitePlugins(viteEnv: Env.ImportMeta, buildTime: string, isBuild: boolean) {
const plugins: PluginOption = [
// 基础插件
vue(),
vueJsx(),
setupDevtoolsPlugin(viteEnv),
@ -18,7 +24,18 @@ export function setupVitePlugins(viteEnv: Env.ImportMeta, buildTime: string) {
...setupUnplugin(viteEnv),
progress(),
setupHtmlPlugin(buildTime),
// 优化插件
setupImageOptimizer(),
setupCompression(),
setupPWA(),
setupDeploy(isBuild),
]
// 构建时添加打包分析插件
if (isBuild) {
plugins.push(setupAnalyzer())
}
return plugins
}

View File

@ -0,0 +1,76 @@
import type { PluginOption } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
export function setupPWA(): PluginOption {
return VitePWA({
registerType: 'autoUpdate',
// 添加 devOptions 以便在开发环境调试
devOptions: {
enabled: false, // 开发环境启用
type: 'module',
},
workbox: {
// 排除大型 SVG 文件,避免预缓存
globIgnores: [
'**/team-title-bg*.svg',
'**/cover-bg-active*.svg',
'**/*-bg-animation*.svg',
],
globPatterns: ['**/*.{js,css,html,ico,png,webp,jpg,jpeg}', '**/*.svg'],
runtimeCaching: [
// 字体缓存
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year
},
},
},
// 大型 SVG 文件运行时缓存
{
urlPattern: /.*\.(svg)$/,
handler: 'CacheFirst',
options: {
cacheName: 'svg-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days
},
},
},
// 图片资源缓存
{
urlPattern: /.*\.(png|jpg|jpeg|webp|gif)$/,
handler: 'CacheFirst',
options: {
cacheName: 'images-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days
},
},
},
],
},
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'masked-icon.svg'],
manifest: {
name: '阅读之星竞赛系统',
short_name: '阅读之星',
description: '树人研究院阅读之星竞赛系统',
theme_color: '#ffffff',
background_color: '#ffffff',
display: 'standalone',
orientation: 'landscape',
icons: [
{ src: 'images/pwa_logo_192.png', sizes: '192x192', type: 'image/png' },
{ src: 'images/pwa_logo_512.png', sizes: '512x512', type: 'image/png' },
{ src: 'images/pwa_logo_512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: 'images/pwa_logo_512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
},
})
}

View File

@ -0,0 +1,239 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>字体大小预览 - 2048x1080</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei',
sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40px;
min-height: 100vh;
}
.container {
max-width: 2048px;
margin: 0 auto;
background: white;
border-radius: 20px;
padding: 60px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
h1 {
text-align: center;
color: #333;
margin-bottom: 20px;
font-size: 3rem;
}
.info {
text-align: center;
color: #666;
margin-bottom: 60px;
font-size: 1.2rem;
}
.preview-item {
display: flex;
align-items: center;
padding: 20px;
margin-bottom: 20px;
background: #f8f9fa;
border-radius: 10px;
border-left: 4px solid #667eea;
}
.label {
width: 200px;
font-weight: bold;
color: #667eea;
font-size: 1.1rem;
}
.size-info {
width: 150px;
color: #999;
font-size: 0.95rem;
}
.sample {
flex: 1;
color: #333;
}
/* UnoCSS 字体大小映射 */
.text-xs {
font-size: 0.8rem;
}
.text-sm {
font-size: 0.95rem;
}
.text-base {
font-size: 1.1rem;
}
.text-lg {
font-size: 1.3rem;
}
.text-xl {
font-size: 1.5rem;
}
.text-2xl {
font-size: 1.75rem;
}
.text-3xl {
font-size: 2.1rem;
}
.text-4xl {
font-size: 2.5rem;
}
.text-5xl {
font-size: 3.2rem;
}
.text-6xl {
font-size: 4rem;
}
.text-7xl {
font-size: 5rem;
}
.text-8xl {
font-size: 6.5rem;
}
.text-9xl {
font-size: 8.5rem;
.controls {
position: fixed;
top: 20px;
right: 20px;
background: white;
padding: 20px;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.controls button {
display: block;
width: 100%;
padding: 10px 20px;
margin-bottom: 10px;
background: #667eea;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 1rem;
}
.controls button:hover {
background: #5568d3;
}
.current-size {
text-align: center;
color: #666;
font-size: 0.9rem;
margin-top: 10px;
}
</style>
</head>
<body>
<div class="controls">
<button onclick="setViewport(2048, 1080)">2048x1080 (设计基准)</button>
<button onclick="setViewport(1920, 1080)">1920x1080 (Full HD)</button>
<button onclick="setViewport(3840, 2160)">3840x2160 (4K)</button>
<div class="current-size" id="currentSize">当前窗口: <span id="sizeDisplay"></span></div>
</div>
<div class="container">
<h1>字体大小预览</h1>
<div class="info">设计基准: 2048 x 1080 | 使用 VScaleScreen 自动缩放</div>
<div class="preview-item">
<div class="label">text-xs</div>
<div class="size-info">0.8rem (12.8px)</div>
<div class="sample text-xs">辅助文字、标注信息 - The quick brown fox jumps over the lazy dog</div>
</div>
<div class="preview-item">
<div class="label">text-sm</div>
<div class="size-info">0.95rem (15.2px)</div>
<div class="sample text-sm">次要文字内容 - The quick brown fox jumps over the lazy dog</div>
</div>
<div class="preview-item">
<div class="label">text-base</div>
<div class="size-info">1.1rem (17.6px)</div>
<div class="sample text-base">正文内容 - The quick brown fox jumps over the lazy dog</div>
</div>
<div class="preview-item">
<div class="label">text-lg</div>
<div class="size-info">1.3rem (20.8px)</div>
<div class="sample text-lg">小标题 - The quick brown fox jumps over the lazy dog</div>
</div>
<div class="preview-item">
<div class="label">text-xl</div>
<div class="size-info">1.5rem (24px)</div>
<div class="sample text-xl">中标题 - The quick brown fox jumps over the lazy dog</div>
</div>
<div class="preview-item">
<div class="label">text-2xl</div>
<div class="size-info">1.75rem (28px)</div>
<div class="sample text-2xl">大标题 - 重庆市教育学会</div>
</div>
<div class="preview-item">
<div class="label">text-3xl</div>
<div class="size-info">2.1rem (33.6px)</div>
<div class="sample text-3xl">特大标题 - 阅读之星展示活动</div>
</div>
<div class="preview-item">
<div class="label">text-4xl</div>
<div class="size-info">2.5rem (40px)</div>
<div class="sample text-4xl">主标题 - 队伍名单</div>
</div>
<div class="preview-item">
<div class="label">text-5xl</div>
<div class="size-info">3.2rem (51.2px)</div>
<div class="sample text-5xl">超大标题 - 星辞海遨游</div>
</div>
<div class="preview-item">
<div class="label">text-6xl</div>
<div class="size-info">4rem (64px)</div>
<div class="sample text-6xl">巨大标题 - 开始答题</div>
</div>
</div>
<script>
function setViewport(width, height) {
// 这个函数只是提示,实际需要在浏览器 DevTools 中手动设置
alert(
`请在浏览器 DevTools 中设置视口为 ${width}x${height}\n\n步骤:\n1. 按 F12 打开 DevTools\n2. 按 Ctrl+Shift+M 进入响应式模式\n3. 输入宽度: ${width}\n4. 输入高度: ${height}`,
)
function updateSize() {
const width = window.innerWidth
const height = window.innerHeight
document.getElementById('sizeDisplay').textContent = `${width}x${height}`
}
updateSize();
window.addEventListener('resize', updateSize)
</script>
</body>
</html>

View File

@ -2,7 +2,7 @@
<html lang="zh-cmn-Hans">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>%VITE_APP_TITLE%</title>

34
apps/admin/nginx.conf Normal file
View File

@ -0,0 +1,34 @@
server {
listen 80;
server_name localhost;
# 设置根目录为打包后的 dist 文件夹
root /usr/share/nginx/html;
index index.html;
# 开启 gzip 压缩
gzip on;
gzip_min_length 1k;
gzip_comp_level 6;
gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png;
gzip_vary on;
gzip_disable "MSIE [1-6]\.";
# 核心配置:处理 SPA 路由 fallback
location / {
try_files $uri $uri/ /index.html;
}
# 静态资源缓存配置(可选)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 30d;
add_header Cache-Control "public, no-transform";
}
# 代理接口配置(如果需要)
# location /api/ {
# proxy_pass http://backend-api.com/;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# }
}

View File

@ -9,14 +9,14 @@
},
"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",
"lint": "eslint src --fix",
"prepare": "simple-git-hooks",
"preview": "vite preview",
"release": "sa release",
@ -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",
@ -65,6 +82,7 @@
"eslint": "9.39.2",
"eslint-plugin-vue": "10.6.2",
"kolorist": "1.8.0",
"rollup-plugin-visualizer": "^7.0.1",
"sass": "1.97.1",
"simple-git-hooks": "2.13.1",
"tsx": "4.21.0",
@ -72,7 +90,11 @@
"unplugin-icons": "22.5.0",
"unplugin-vue-components": "30.0.0",
"vite": "7.3.0",
"vite-auto-deploy": "^2.0.34",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-image-optimizer": "^2.0.3",
"vite-plugin-progress": "0.0.7",
"vite-plugin-pwa": "^1.2.0",
"vite-plugin-svg-icons": "2.0.1",
"vite-plugin-vue-devtools": "8.0.5",
"vue-eslint-parser": "10.2.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@ -1,60 +0,0 @@
<svg width="100%" height="100%" version="1.1" viewBox="0 0 1000 1000" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
<g>
<path
d="M 200,866 C 100,866 50,779.4 100,692.8 L 200,519.6 C 220,485 240,490 265,499.6 S 360,542.68 360,542.68 C 480.5,601 498,642.5 500,720 C 498,811 462,856 420,866"
fill="url(#LinearGradient)" fill-rule="nonzero" opacity="1" stroke="none" />
<path
d="M 420,866 C 455,861 478,846 500,827 C 614,696 615,597 500,517 C 394,444 333,374 380,207.82 L 260,415.67 C 240.22,450 254.37,465.1 275.28,481.79 S 360,542.68 360,542.68 C 480.5,601 498,642.5 500,720 C 498,811 462,856 420,866"
fill="url(#LinearGradient_2)" fill-rule="nonzero" opacity="1" stroke="none" />
<path d="M 500,517 C 394,444 333,374 380,207.82 L 400,173.2 C 367,295 421,350 603,428 C 572,440 524,474 500,517"
fill="url(#LinearGradient_3)" fill-rule="nonzero" opacity="1" stroke="none" />
<path d="M 500,827 L 660,660 C 738,589 710,482 603,428 C 572,440 524,474 500,517 C 615,597 614,696 500,827"
fill="url(#LinearGradient_4)" fill-rule="nonzero" opacity="1" stroke="none" />
<path d="M 400,173.2 C 367,295 421,350 603,428 C 690,389, 750,445 788,500 L 600,173.2 C 550,86.6 450,86.6 400,173.2"
fill="url(#LinearGradient_5)" fill-rule="nonzero" opacity="1" stroke="none" />
<path
d="M 500,827 L 660,660 C 738,589 710,482 603,428 C 690,389, 750,445 788,500 C 816,554 797,606 750,640 L 500,827"
fill="url(#LinearGradient_6)" fill-rule="nonzero" opacity="1" stroke="none" />
<path
d="M 788,500 C 816,554 797,606 750,640 L 500,827 C 497,851 513,862 540,866 L 800,866 C 900,866 950,779.4 900,692.8 L 788,500"
fill="url(#LinearGradient_7)" fill-rule="nonzero" opacity="1" stroke="none" />
</g>
<defs>
<linearGradient gradientTransform="matrix(104.391 -73.3432 73.3432 104.391 277.441 710.122)"
gradientUnits="userSpaceOnUse" id="LinearGradient" x1="0" x2="1" y1="0" y2="0">
<stop offset="0" stop-color="#373ebf" />
<stop offset="1" stop-color="#5058e6" />
</linearGradient>
<linearGradient gradientTransform="matrix(-173.747 557.324 -557.324 -173.747 508.829 258.172)"
gradientUnits="userSpaceOnUse" id="LinearGradient_2" x1="0" x2="1" y1="0" y2="0">
<stop offset="0" stop-color="#c2d6ff" />
<stop offset="1" stop-color="#646cff" />
</linearGradient>
<linearGradient gradientTransform="matrix(157.951 295.666 -295.666 157.951 382.944 193.642)"
gradientUnits="userSpaceOnUse" id="LinearGradient_3" x1="0" x2="1" y1="0" y2="0">
<stop offset="0" stop-color="#5058e6" />
<stop offset="1" stop-color="#373ebf" />
</linearGradient>
<linearGradient gradientTransform="matrix(-44.3023 219.578 -219.578 -44.3023 619.69 469.652)"
gradientUnits="userSpaceOnUse" id="LinearGradient_4" x1="0" x2="1" y1="0" y2="0">
<stop offset="0" stop-color="#91a7ff" />
<stop offset="1" stop-color="#5058e6" />
</linearGradient>
<linearGradient gradientTransform="matrix(125.52 334.256 -334.256 125.52 539.723 235.139)"
gradientUnits="userSpaceOnUse" id="LinearGradient_5" x1="0" x2="1" y1="0" y2="0">
<stop offset="0" stop-color="#646cff" />
<stop offset="1" stop-color="#c2d6ff" />
</linearGradient>
<linearGradient gradientTransform="matrix(-241.23 357.206 -357.206 -241.23 754.054 449.312)"
gradientUnits="userSpaceOnUse" id="LinearGradient_6" x1="0" x2="1" y1="0" y2="0">
<stop offset="0" stop-color="#c2d6ff" />
<stop offset="1" stop-color="#646cff" />
</linearGradient>
<linearGradient gradientTransform="matrix(125.978 210.065 -210.065 125.978 596.433 613.665)"
gradientUnits="userSpaceOnUse" id="LinearGradient_7" x1="0" x2="1" y1="0" y2="0">
<stop offset="0" stop-color="#373ebf" />
<stop offset="1" stop-color="#5058e6" />
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

4950
apps/admin/report.html Normal file

File diff suppressed because one or more lines are too long

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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,33 @@
# 拼音字体说明
## 需要的字体文件
为了正确显示拼音声调符号,需要使用支持拼音的字体。
### 推荐字体:
1. **GB Pinyinok-B** - 专业拼音字体
2. **方正楷体拼音** - 楷体风格拼音字体
3. **华文楷体拼音** - 华文系列拼音字体
### 字体文件放置位置:
```
apps/admin/src/assets/fonts/
├── GBPinyinok-B.ttf
├── GBPinyinok-B.woff2
└── GBPinyinok-B.woff
```
### 如何获取字体:
1. 从系统字体库复制macOS: /Library/Fonts/
2. 从字体网站下载(需要商业授权)
3. 使用开源拼音字体
### 字体格式转换:
如果只有 .ttf 文件,可以使用在线工具转换为 woff2 格式:
- https://cloudconvert.com/ttf-to-woff2
- https://www.fontsquirrel.com/tools/webfont-generator
### 注意事项:
- 确保字体文件有合法的使用授权
- woff2 格式文件最小,加载最快
- 字体文件较大时考虑使用 CDN

Binary file not shown.

After

Width:  |  Height:  |  Size: 656 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 487 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 487 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 740 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 790 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 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: 797 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 811 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 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: 172 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 907 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 907 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

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: 121 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

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

@ -17,6 +17,8 @@ interface Props {
icon?: string
/** 本地 svg 图标名称 */
localIcon?: string
/** 图标大小 */
size?: string | number
}
const attrs = useAttrs()
@ -44,13 +46,13 @@ const renderLocalIcon = computed(() => props.localIcon || !props.icon)
<template>
<!-- 渲染本地 svg 图标 -->
<template v-if="renderLocalIcon">
<svg aria-hidden="true" width="1em" height="1em" v-bind="bindAttrs">
<svg aria-hidden="true" :width="size || '1em'" :height="size || '1em'" v-bind="bindAttrs">
<use :xlink:href="symbolId" fill="currentColor" />
</svg>
</template>
<template v-else>
<!-- 渲染 iconify 图标 -->
<Icon v-if="icon" :icon="icon" v-bind="bindAttrs" />
<Icon v-if="icon" :icon="icon" :width="size" :height="size" v-bind="bindAttrs" />
</template>
</template>

View File

@ -0,0 +1,85 @@
<script lang="ts" setup>
import type { Options } from 'typeit'
import type { El } from 'typeit/dist/types'
import TypeIt from 'typeit'
import { onBeforeUnmount, onMounted, shallowRef, watch } from 'vue'
defineOptions({ name: 'TypeIt' })
const props = withDefaults(defineProps<Props>(), {
speed: 100,
loop: false,
cursor: true,
lifeLike: true,
strings: () => [],
})
interface Props {
/** 打字速度 */
speed?: number
/** 是否循环 */
loop?: boolean
/** 是否显示光标 */
cursor?: boolean
/** 打字内容 */
strings?: string | string[]
/** 像人一样打字 */
lifeLike?: boolean
/** 开始前的延迟 */
startDelay?: number
/** 下一段文字的延迟 */
nextStringDelay?: number
/** 循环延迟 */
loopDelay?: number
/** 兼容旧代码或用户自定义延迟参数 */
typeDelay?: number
}
const textRef = shallowRef<El>()
const typeItInstance = shallowRef<TypeIt | null>(null)
function init() {
if (!textRef.value)
return
if (typeItInstance.value) {
typeItInstance.value.destroy()
}
// 清空内容,防止重叠
textRef.value.textContent = ''
const options: Options = {
strings: props.strings,
lifeLike: props.lifeLike,
speed: props.speed,
loop: props.loop,
cursor: props.cursor,
startDelay: props.startDelay,
nextStringDelay: props.nextStringDelay ?? props.typeDelay,
loopDelay: props.loopDelay,
}
typeItInstance.value = new TypeIt(textRef.value, options).go()
}
onMounted(() => {
init()
})
onBeforeUnmount(() => {
if (typeItInstance.value) {
typeItInstance.value.destroy()
}
})
watch(() => [props.strings, props.speed, props.loop], () => {
init()
})
</script>
<template>
<span ref="textRef" class="typeit-container" />
</template>
<style scoped></style>

View File

@ -0,0 +1,167 @@
<script setup lang="ts">
import { computed } from 'vue'
import defaultBtnBg from '@/assets/imgs/user/btn-bg.png'
interface Props {
text?: string
disabled?: boolean
theme?: 'primary' | 'light'
type?: 'button' | 'text' | 'custom'
btnBg?: string
fontSize?: number | string
}
const props = withDefaults(defineProps<Props>(), {
text: '下一步',
disabled: false,
theme: 'primary',
type: 'button',
btnBg: defaultBtnBg,
fontSize: '2.4rem',
})
const emits = defineEmits<{
click: []
}>()
const isCustom = computed(() => props.type === 'custom')
const customBg = computed(() => `url(${props.btnBg}) no-repeat center / 100% 100%`)
const fontSizeValue = computed(() => {
// 如果已经是带单位的字符串(如 '2.4rem', '30px'),直接返回
if (typeof props.fontSize === 'string' && /^[\d.]+(?:px|rem|em)$/.test(props.fontSize)) {
return props.fontSize
}
// 如果是纯数字或数字字符串,添加 px 单位
const size = typeof props.fontSize === 'number' ? props.fontSize : Number.parseFloat(props.fontSize)
return Number.isNaN(size) ? '2.4rem' : `${size}px`
})
function handleClick() {
if (!props.disabled) {
emits('click')
}
}
</script>
<template>
<button
class="action-btn"
:class="[
isCustom ? 'custom-btn' : 'standard-btn',
theme,
type,
{ 'is-disabled': disabled },
]"
@click="handleClick"
>
<span v-if="type === 'text' || type === 'custom'" class="btn-text">{{ text }}</span>
<slot v-else />
</button>
</template>
<style lang="scss" scoped>
.action-btn {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
border: none;
outline: none;
font-family: inherit;
user-select: none;
&.is-disabled {
cursor: not-allowed;
// filter: grayscale(1);
opacity: 0.7;
pointer-events: none;
&:hover,
&:active {
transform: none !important;
box-shadow: inherit !important;
}
}
}
// Standard button styles: type="button" | type="text"
.standard-btn {
width: 50px;
height: 50px;
border-radius: 50%;
color: white;
background: #eb5e55;
box-shadow: 0 8px 0 #fcc64f;
&:hover:not(.is-disabled) {
transform: translateY(-2px);
filter: brightness(1.05);
box-shadow: 0 10px 0 #fcc64f;
}
&:active:not(.is-disabled) {
transform: translateY(4px);
box-shadow: 0 2px 0 #fcc64f;
}
&.light {
background: white;
color: #eb5e55;
border: 2px solid #eb5e55;
&:hover:not(.is-disabled) {
background-color: #fff;
}
}
&.text {
width: auto;
min-width: 180px;
padding: 0 30px;
border-radius: 40px;
.btn-text {
font-size: 30px;
font-weight: bold;
letter-spacing: 2px;
}
}
}
// Custom button: type="custom" - background controlled by btnBg prop
.custom-btn {
background: v-bind(customBg);
background-color: transparent;
min-width: 210px;
width: auto;
height: 60px;
padding: 0 20px;
box-shadow: none;
color: rgb(0, 94, 174);
font-weight: bold;
.btn-text {
font-size: v-bind(fontSizeValue);
font-weight: bold;
letter-spacing: 2px;
margin-bottom: 7px;
}
&.is-disabled {
color: #eb5e55;
filter: grayscale(0.5);
opacity: 0.6;
}
&:hover:not(.is-disabled) {
transform: scale(1.05);
}
&:active:not(.is-disabled) {
transform: scale(0.95);
}
}
</style>

View File

@ -0,0 +1,527 @@
<!-- eslint-disable unused-imports/no-unused-vars -->
<script lang="ts" setup>
import VScaleScreen from 'v-scale-screen'
import { computed, onMounted, ref, watch } from 'vue'
import cqLogo from '@/assets/imgs/logo/cq-logo.webp'
import srLogo from '@/assets/imgs/logo/qw-logo.webp'
import qwLogo from '@/assets/imgs/logo/text-logo.webp'
import _defaultBg from '@/assets/imgs/user/home-bg1.webp'
import titleBg2 from '@/assets/imgs/user/title-bg2.webp'
import titleBg3 from '@/assets/imgs/user/title-bg3.png'
import titleBg4 from '@/assets/imgs/user/title-bg4.png'
import _defaultTitleBg from '@/assets/imgs/user/title-bg.webp'
import { useRouterPush } from '@/hooks/common/router'
import ActionButton from './ActionButton.vue'
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.webp */
titleBgType?: number
/** 是否显示第二个下一步按钮 */
showNextBtn2?: boolean
/** 第二个按钮的文本 */
nextBtnText2?: string
/** title 颜色 */
titleTextColor?: string
/* 按钮字体大小 */
fontSize?: 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,
showNextBtn2: false,
nextBtnText2: undefined,
titleTextColor: '#000000',
fontSize: 30,
})
const emit = defineEmits<{
(e: 'back'): void
(e: 'next'): void
(e: 'next2'): void
}>()
const titleBgUrlMapping: Record<number, string> = {
1: _defaultTitleBg,
2: titleBg2,
3: titleBg3,
4: titleBg4,
}
const { routerPushByKey } = useRouterPush()
const titleBgUrl = computed(() => titleBgUrlMapping[props.titleBgType] || _defaultBg)
const titleBgLoaded = ref(false)
const currentTitleBg = ref('')
const imageLoadStatus = ref<Record<string, boolean>>({})
// 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)
const showSlotContent = ref(false)
const showButton = ref(false)
function handleBack() {
emit('back')
}
function handleNext() {
if (props.nextDisabled)
return
emit('next')
}
function handleNext2() {
emit('next2')
}
/** 点击返回首页 */
function handleBackHome() {
routerPushByKey('user_home')
}
onMounted(() => {
// 预加载所有标题背景图,并跟踪加载状态
const preloadAllImages = () => {
const promises = Object.entries(titleBgUrlMapping).map(([_key, url]) => {
return new Promise((resolve) => {
const img = new Image()
img.onload = () => {
imageLoadStatus.value[url] = true
resolve(true)
}
img.onerror = () => {
imageLoadStatus.value[url] = false
resolve(false)
}
img.src = url
})
})
// 也预加载默认背景
const defaultBgPromise = new Promise((resolve) => {
const img = new Image()
img.onload = () => {
imageLoadStatus.value[_defaultBg] = true
resolve(true)
}
img.onerror = () => {
imageLoadStatus.value[_defaultBg] = false
resolve(false)
}
img.src = _defaultBg
})
return Promise.all([...promises, defaultBgPromise])
}
// 开始预加载
preloadAllImages()
.then(() => {
// 所有图片预加载完成后,设置当前背景
currentTitleBg.value = titleBgUrl.value
titleBgLoaded.value = true
fetchSystemConfig()
setTimeout(() => {
showContent.value = true
}, 100)
})
.catch(() => {
// 即使预加载失败也继续
currentTitleBg.value = titleBgUrl.value
titleBgLoaded.value = true
fetchSystemConfig()
setTimeout(() => {
showContent.value = true
}, 100)
})
// 延迟显示内容,等待标题卷轴展开
const delay = props.showTitle ? 500 : 100
setTimeout(() => {
showSlotContent.value = true
}, delay)
// 延迟显示按钮,等待内容显示
setTimeout(() => {
showButton.value = true
}, delay + 500)
})
// 监听 titleBgUrl 变化,确保图片切换平滑
watch(titleBgUrl, (newUrl) => {
if (newUrl && imageLoadStatus.value[newUrl]) {
// 如果图片已经加载过,直接切换
currentTitleBg.value = newUrl
}
else if (newUrl) {
// 如果图片还没加载,预加载它
const img = new Image()
img.onload = () => {
imageLoadStatus.value[newUrl] = true
currentTitleBg.value = newUrl
}
img.onerror = () => {
imageLoadStatus.value[newUrl] = false
// 加载失败时使用默认背景
currentTitleBg.value = _defaultBg
}
img.src = newUrl
}
})
</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="2048" height="1080">
<div class="competition-layout">
<!-- Header -->
<header class="page-header flex items-center px-8 text-[30px] text-[#333333] font-bold">
<!-- 左侧logo -->
<div class="flex cursor-pointer items-center" @click="handleBackHome">
<img :src="cqLogo" alt="重庆logo" class="h-20 w-auto">
</div>
<!-- 中间文字 -->
<div class="flex flex-1 justify-center">
<img :src="qwLogo" alt="重庆logo" class="h-14 w-auto">
</div>
<!-- 右侧logo组 -->
<div class="flex items-center justify-end gap-4">
<img :src="srLogo" alt="树人logo" class="h-19 w-auto">
</div>
</header>
<div class="glass-overlay">
<!-- title -->
<section v-if="showTitle" class="title-section">
<Transition name="scroll-unfold" appear>
<div v-if="showContent" class="title-wrapper">
<div class="scroll-bg">
<!-- 预渲染所有背景图 opacity 切换避免加载抖动 -->
<img
v-for="(url, _key) in titleBgUrlMapping" :key="_key" :src="url" class="scroll-bg-img" :class="{
active: titleBgUrl === url && imageLoadStatus[url] !== false,
loading: imageLoadStatus[url] === undefined,
}" :style="{ visibility: imageLoadStatus[url] === false ? 'hidden' : 'visible' }" aria-hidden="true"
@load="imageLoadStatus[url] = true" @error="imageLoadStatus[url] = false"
>
<Transition name="fade-in-delay" appear>
<h1 v-if="showContent" class="main-title" :style="{ color: titleTextColor }">
{{ title }}
</h1>
</Transition>
</div>
</div>
</Transition>
</section>
<!-- Main Content Slot -->
<main class="main-content">
<div v-if="showSlotContent" class="h-full w-full flex flex-col items-center justify-center">
<slot />
</div>
</main>
<!-- Footer Action -->
<footer
v-if="showButton" class="page-footer w-full flex items-center justify-between px-20"
:class="[actionPosition, btnTheme]"
>
<div class="left">
<ActionButton
class="back-btn" :style="{ visibility: showBack ? 'visible' : 'hidden' }" type="custom"
:text="backBtnText || ''" :theme="btnTheme" :font-size="fontSize" @click="handleBack"
>
<SvgIcon v-if="!backBtnText" icon="mdi:arrow-left" size="40" class="arrow-icon" />
</ActionButton>
</div>
<div class="right">
<ActionButton
v-if="showNext" type="custom" :text="nextBtnText || '下一步'" :theme="btnTheme"
:disabled="nextDisabled" :font-size="fontSize" @click="handleNext"
>
<SvgIcon v-if="!nextBtnText" icon="mdi:arrow-right" class="arrow-icon" size="40" />
</ActionButton>
<ActionButton
v-if="showNextBtn2" :type="nextBtnText2 ? 'text' : 'button'" :text="nextBtnText2 || '下一步'"
:theme="btnTheme" :disabled="nextDisabled" @click="handleNext2"
>
<SvgIcon v-if="!nextBtnText2" icon="mdi:arrow-right" class="arrow-icon" size="40" />
</ActionButton>
</div>
</footer>
</div>
</div>
</VScaleScreen>
</div>
</template>
<style lang="scss" scoped>
.title-section {
margin: 10px auto;
width: 412px;
height: 165px;
display: flex;
justify-content: center;
/* Ensure wrapper stays centered during expansion */
.title-wrapper {
width: 100%;
height: 100%;
overflow: hidden;
display: flex;
justify-content: center;
/* Keep the background image centered */
}
.scroll-bg {
width: 400px !important;
/* Fixed width to prevent squishing during animation */
height: 155px !important;
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding-bottom: 10px;
flex-shrink: 0;
/* Prevent shrinking */
.scroll-bg-img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: contain;
opacity: 0;
transition: opacity 0.5s ease;
pointer-events: none;
&.active {
opacity: 1;
}
&.loading {
opacity: 0;
filter: blur(2px);
}
}
.main-title {
font-size: 2.6rem;
color: $text-color;
margin: 0;
letter-spacing: 3px;
text-align: center;
white-space: nowrap;
/* Prevent text wrapping */
position: relative;
z-index: 1;
}
}
}
.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: 2; // 确保内容在背景之上
// margin: 10px auto;
margin-top: 28px;
// 底部波浪背景
&::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.6);
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: -20px;
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;
width: 100%;
display: flex;
justify-content: space-between;
padding: 0 20px;
z-index: 20;
.right {
display: flex;
gap: 10px;
}
&.top {
bottom: auto;
top: 40px;
}
}
// Transitions
.scroll-unfold-enter-active {
transition: width 1.5s cubic-bezier(0.25, 1, 0.5, 1);
}
.scroll-unfold-enter-from {
width: 0 !important;
}
.fade-in-delay-enter-active {
transition: opacity 0.8s ease;
transition-delay: 1s;
/* Wait for scroll to unfold */
}
.fade-in-delay-enter-from {
opacity: 0;
}
.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,45 @@
<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>

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,37 @@
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', // 1. 汉字听写-提示 (jiu表示小鸟的叫声)
ChineseCharacterDictation2 = 'ChineseCharacterDictation2', // 2. 根据拼音写汉字-提示 (tang)
CharacterRadicalAddition = 'CharacterRadicalAddition', // 3. 汉字加一加 (车)
WordDictation = 'WordDictation', // 4. 词语听写 (zhi re)
IdiomWriting1 = 'IdiomWriting1', // 5. 成语-文字要求
IdiomWriting2 = 'IdiomWriting2', // 6. 成语-文字要求 (看图写成语)
PoetryComprehension = 'PoetryComprehension', // 7. 诗词理解-选择题
ReversePoemMatching = 'ReversePoemMatching', // 8. 逆向接诗句
SceneBasedPoemGuessing = 'SceneBasedPoemGuessing', // 9. 场景猜诗句
AssociationMatching = 'AssociationMatching', // 10. 联想对对碰
CharacterWordFlowerDrinkingGame = 'CharacterWordFlowerDrinkingGame', // 11. 字词飞花令
PoetryCommonKnowledge = 'PoetryCommonKnowledge', // 12. 诗词常识
ChangeWords = 'ChangeWords', // 13. 换字组成语
}

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,7 +19,13 @@ export function useRouterPush(inSetup = true) {
const routerBack = router.back
async function routerPushByKey(key: RouteKey, options?: App.Global.RouterPushOptions) {
/**
* 路由跳转
*
* @param key 路由名称
* @param options 路由参数
*/
async function routerPushByKey(key: RouteKey, options?: App.Global.RouterPushOptions, newWindow?: boolean) {
const { query, params } = options || {}
const routeLocation: RouteLocationRaw = {
@ -34,7 +40,13 @@ export function useRouterPush(inSetup = true) {
routeLocation.params = params
}
return routerPush(routeLocation)
if (newWindow) {
const routeData = router.resolve(routeLocation)
window.open(routeData.href, '_blank')
}
else {
return routerPush(routeLocation)
}
}
/**

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>

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