22 Commits
Author SHA1 Message Date
ranjl f4c3734639 fix: 修复翻牌音效播放失败问题
移除 AudioController 依赖,直接使用 Audio 对象播放新增的 checkout.mp3 音频文件,避免因 AudioController 实例化或配置问题导致的音效播放失败。
2026-04-10 15:33:43 +08:00
ranjl 38a243af15 feat(admin): 添加答题前3秒倒计时动画和音效
在用户点击"开始答题"后,增加3秒倒计时动画和音效提示,提升答题体验
- 添加倒计时遮罩层和动画效果
- 播放倒计时音效文件
- 修改开始答题按钮状态为"准备中..."
- 调整环境配置中的API地址
2026-04-10 14:41:30 +08:00
ranjl 0d03fdd782 fix: 优化图片更新逻辑防止闪烁并调整样式
- 在结果详情页和用户分析页引入图片缓存和静默更新机制,通过比较图片大小避免不必要的更新,防止界面闪烁
- 调整用户分析页的布局样式,优化团队名称和评分信息的显示,避免内容溢出
- 禁用折线图动画以提升性能,并更新环境配置中的服务地址
2026-04-02 17:42:19 +08:00
ranjl a76263375a fix: 修复UI样式和轮询逻辑问题
- 修复ActionButton禁用状态颜色错误
- 调整CompetitionLayout布局和z-index层级
- 修正analysis页面评委评分显示位置
- 优化QuestionRenderer字体大小和间距
- 改进cover页面卡片翻转动画效果
- 修复results-detail页面轮询逻辑和时间同步
2026-04-02 15:14:50 +08:00
ranjl 9bc58c6270 style(图表): 优化柱状图动画与视觉样式
- 启用平滑动画提升交互体验
- 调整颜色方案增强视觉层次
- 添加阴影效果和间距提升可读性
- 优化标签位置和字体大小
2026-04-01 15:13:23 +08:00
ranjl 7b505ec4aa style(admin): 调整侧边栏宽度和文本样式
- 将问题库侧边栏默认宽度从525px调整为535px
- 统一标签页和分类树中的文本字号大小
- 优化文件夹图标的显示尺寸
2026-03-31 10:56:27 +08:00
ranjl a7cf35e6bf chore(admin): 更新上传图片的基础URL为生产环境
将开发环境的上传地址替换为生产环境的API域名,确保生产构建使用正确的服务端点。
2026-03-31 10:26:47 +08:00
ranjl c617307c79 feat(admin): 更新用户界面组件并优化样式
- 移除 uno.config.ts 中的注释以简化字体配置
- 更新 .env.dev 文件中的服务基础 URL
- 新增用户排名标题背景图片并优化相关组件样式
- 调整 ActionButton 组件的最小宽度
- 修改 CompetitionLayout 组件中的图像和样式以提升视觉效果
- 更新全局 CSS 文件以增加基础字体大小
- 优化 results-detail 视图中的发布逻辑延迟
- 调整用户分析和封面页面的文本样式
- 更新团队页面的团队名称样式以适应设计规范
2026-03-26 15:18:45 +08:00
ranjl 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
ranjl 18e3f6f7f2 feat(admin): 优化大屏开发体验并完善用户界面组件
- 新增大屏开发调试指南文档,包含设计基准、调试方法和字体配置说明
- 添加字体大小预览工具,支持多分辨率视口切换(2048x1080/1920x1080/3840x2160)
- 新增字体资源 README 文档和团队标题背景图片
- 优化用户界面背景图片(title-bg3.png、title-bg4.png)
- 更新 ActionButton 和 CompetitionLayout 组件样式
- 完善用户模块各页面布局(分析、封面、抽签、游戏、规则、团队)
- 优化题目渲染器组件和成绩详情页面
- 更新 UnoCSS 配置以支持大屏字体缩放方案
2026-03-20 17:05:23 +08:00
ranjl e061f46e66 chore(config): 优化Kiro配置并调整UI组件样式
- 更新Kiro AI助手配置,新增强制语言和中文模式设置
- 添加用户排名背景框图片资源
- 优化CompetitionLayout组件标题样式(字体大小和字间距调整)
- 优化用户封面页面卡片布局,根据屏幕尺寸动态调整顶部padding
- 删除联想对对碰题库数据文件
2026-03-19 14:52:36 +08:00
ranjl 7d49143ae0 feat(admin): 新增UUID生成工具并优化模板管理
- 新增 uuid.ts 工具函数,提供原生和兼容性 UUID 生成方案
- 更新模板详情页面,使用 generateUUID() 替代 crypto.randomUUID()
- 模板列表新增页码字段显示
- 优化模板列表组件代码格式,改进可读性
- 更新 Git 提交消息规范文档,强制使用中文描述和具体要求
2026-03-19 11:16:01 +08:00
ranjl 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
ranjl 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
ranjl 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
ranjl 1c8035d935 feat(admin): 优化字典管理界面并更新构建报告
- 优化字典列表页面交互与展示逻辑
- 改进字典类型模态框组件功能
- 更新构建分析报告数据
2026-03-18 17:14:07 +08:00
ranjl 2fad025496 feat(admin): 新增品牌logo资源并优化用户界面组件
- 新增重庆、树人、文字logo资源(PNG和WebP格式)
- 新增用户界面背景图片资源(内容背景、对框、主页背景等)
- 优化ActionButton组件样式(自适应宽度、禁用状态样式)
- 更新CompetitionLayout组件header布局,集成新logo资源
- 优化TianZiGe组件功能和交互体验
- 更新竞赛相关页面组件(QuestionConfig、draw、QuestionRenderer等)
- 更新排名列表页面样式和功能
- 更新诗词常识题库数据
- 完善Competition类型定义
2026-03-18 16:58:13 +08:00
ranjl 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
ranjl 8964fcdea7 feat(admin): 优化用户界面资源并更新题库数据
- 添加首页背景图片资源 (home-bg.webp)
- 更新用户头像资源文件 (ydzx.png)
- 优化竞赛布局组件交互体验
- 改进题目渲染器显示效果
- 更新题库数据:新增诗词常识题库
- 移除联想对对碰题库
- 更新汉字听写、诗词理解选择题、逆向接诗句等题库内容
- 调整VSCode配置并禁用MCP代理功能
2026-03-17 15:28:11 +08:00
ranjl 771fb807eb feat(admin): 新增题目类型、优化题目库管理并添加拼音助手
- 新增6种题目类型枚举:逆向接诗句、场景猜诗句、联想对对碰、字词飞花令、诗词常识、换字组成语
- 新增QuestionDrawer组件,支持富文本编辑和拼音转换功能
- 优化CategoryTree组件,增加分类选择本地存储和自动填充题目类型
- 更新question API接口,简化新增和更新题目库的参数传递
- 扩展QuestionRenderer组件,支持新增的题目类型渲染
- 添加多个题目库JSON文件,包含成语、诗词常识、字词飞花令等题目数据
- 调整环境配置,更新开发环境API地址
2026-03-16 17:59:49 +08:00
ranjl 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
ranjl 40a6850c3f chore(admin): 优化构建配置并升级资源格式
- 新增构建插件:打包分析、压缩、自动部署、图片优化和PWA支持
- 将SVG资源转换为WebP格式以提升加载性能
- 添加新的背景图片资源用于用户界面优化
- 更新环境配置:调整基础URL和服务地址
- 优化用户界面组件以适配新的资源格式
- 更新package.json依赖以支持新的构建功能
- 改进构建流程以支持自动部署和性能监控
2026-03-13 17:52:13 +08:00
125 changed files with 10978 additions and 3630 deletions
+4
View File
@@ -7,6 +7,10 @@ dist-ssr
apps/*/dist
packages/*/dist
# --- 分析报告文件 ---
report.html
apps/*/report.html
# --- 日志文件 ---
logs
*.log
+9
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
}
+110
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
+14 -1
View File
@@ -41,5 +41,18 @@
"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"
}
+2 -2
View File
@@ -1,6 +1,6 @@
# 应用的基础URL,默认为 "/"
# 如果使用子目录,必须以 "/" 结尾,例如 "/admin/" 而不是 "/admin"
# VITE_BASE_URL='/admin'
VITE_BASE_URL='/'
# VITE_BASE_URL="https://api.qyzhjy.com"
@@ -10,7 +10,7 @@ VITE_APP_TITLE="阅读比赛配置后台"
VITE_BASE_OSS_URL = https://oss.qyzhjy.com/
# 上传图片的URL
VITE_BASE_UPLOAD_URL="http://192.168.20.150:8080"
VITE_BASE_UPLOAD_URL="https://api.qyzhjy.com"
VITE_APP_DESC="阅读比赛配置"
+3 -2
View File
@@ -3,9 +3,10 @@
# VITE_SERVICE_BASE_URL=http://192.168.5.140:9999
# VITE_SERVICE_BASE_URL=https://api.qyzhjy.com
# VITE_SERVICE_BASE_URL=http://api.ydzx.qyzhjy.com
VITE_SERVICE_BASE_URL=http://api.ydzx.qyzhjy.com
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"
+110
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. **测试**: 在接近实际大屏的设备上测试性能
## 联系与支持
如有问题,请联系开发团队。
+3 -1
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
}
+11
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
}
+11
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',
})
}
+21
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',
},
})
}
@@ -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',
})
}
+18 -1
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
}
+76
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' },
],
},
})
}
+239
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>
+1 -1
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>
+5 -1
View File
@@ -16,7 +16,7 @@
"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",
@@ -82,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",
@@ -90,7 +91,10 @@
"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

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

File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
+33
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: 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

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

Before

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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 740 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

After

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 907 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 16 MiB

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

Before

Width:  |  Height:  |  Size: 5.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 3.0 MiB

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

Before

Width:  |  Height:  |  Size: 468 KiB

After

Width:  |  Height:  |  Size: 468 KiB

@@ -1,26 +1,42 @@
<script setup lang="ts">
// import { defineProps, defineEmits } from 'vue';
import { computed } from 'vue'
import defaultBtnBg from '@/assets/imgs/user/btn-bg.png'
const props = defineProps({
text: {
type: String,
default: '下一步',
},
disabled: {
type: Boolean,
default: false,
},
theme: {
type: String as () => 'primary' | 'light',
default: 'primary',
},
type: {
type: String as () => 'button' | 'text',
default: 'button',
},
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 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) {
@@ -32,45 +48,76 @@ function handleClick() {
<template>
<button
class="action-btn"
:class="[theme, { 'is-disabled': disabled, 'text-btn': type === 'text' }]"
:class="[
isCustom ? 'custom-btn' : 'standard-btn',
theme,
type,
{ 'is-disabled': disabled },
]"
@click="handleClick"
>
<span v-if="type === 'text'" class="btn-text">{{ text }}</span>
<span v-if="type === 'text' || type === 'custom'" class="btn-text">{{ text }}</span>
<slot v-else />
</button>
</template>
<style lang="scss" scoped>
.action-btn {
width: 50px;
height: 50px;
border-radius: 50%;
border: none;
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.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;
.arrow-icon {
font-size: 32px;
}
&:hover {
&:hover:not(.is-disabled) {
transform: translateY(-2px);
filter: brightness(1.05);
box-shadow: 0 10px 0 #fcc64f;
}
&:active {
&:active:not(.is-disabled) {
transform: translateY(4px);
box-shadow: 0 2px 0 #fcc64f;
}
&.text-btn {
&.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;
@@ -82,35 +129,39 @@ function handleClick() {
letter-spacing: 2px;
}
}
&.is-disabled {
cursor: not-allowed;
&:hover {
transform: none;
}
&:active {
transform: none;
}
}
}
.light {
background: white;
color: #eb5e55;
border: 2px solid #eb5e55;
box-shadow: 0 8px 0 #fcc64f;
// Custom button: type="custom" - background controlled by btnBg prop
.custom-btn {
background: v-bind(customBg);
background-color: transparent;
min-width: 220px;
width: auto;
height: 60px;
padding: 0 20px;
box-shadow: none;
color: rgb(0, 94, 174);
font-weight: bold;
&:hover {
background-color: #fff;
transform: translateY(-3px);
box-shadow: 0 10px 0 #fcc64f;
.btn-text {
font-size: v-bind(fontSizeValue);
font-weight: bold;
letter-spacing: 2px;
margin-bottom: 7px;
}
&:active {
transform: translateY(4px);
box-shadow: 0 2px 0 #fcc64f;
&.is-disabled {
color: red;
// filter: grayscale(0.5);
opacity: 0.6;
}
&:hover:not(.is-disabled) {
transform: scale(1.05);
}
&:active:not(.is-disabled) {
transform: scale(0.95);
}
}
</style>
@@ -1,9 +1,15 @@
<!-- eslint-disable unused-imports/no-unused-vars -->
<script lang="ts" setup>
import VScaleScreen from 'v-scale-screen'
import { computed, onMounted, ref } from 'vue'
import _defaultBg from '@/assets/imgs/user/home-bg.svg'
import titleBg2 from '@/assets/imgs/user/title-bg2.svg'
import _defaultTitleBg from '@/assets/imgs/user/title-bg.svg'
import { 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'
@@ -29,12 +35,16 @@ interface Props {
btnTheme?: 'primary' | 'light'
/** 是否禁用下一步按钮 */
nextDisabled?: boolean
/** 标题区域背景图片URL,不传则使用默认 title-bg.svg */
/** 标题区域背景图片URL,不传则使用默认 title-bg.webp */
titleBgType?: number
/** 是否显示第二个下一步按钮 */
showNextBtn2?: boolean
/** 第二个按钮的文本 */
nextBtnText2?: string
/** title 颜色 */
titleTextColor?: string
/* 按钮字体大小 */
fontSize?: number
}
const props = withDefaults(defineProps<Props>(), {
@@ -51,6 +61,8 @@ const props = withDefaults(defineProps<Props>(), {
titleBgType: 1,
showNextBtn2: false,
nextBtnText2: undefined,
titleTextColor: '#000000',
fontSize: 30,
})
const emit = defineEmits<{
@@ -62,11 +74,16 @@ const emit = defineEmits<{
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)
@@ -110,10 +127,62 @@ function handleBackHome() {
}
onMounted(() => {
fetchSystemConfig()
setTimeout(() => {
showContent.value = true
}, 100)
// 预加载所有标题背景图,并跟踪加载状态
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
@@ -126,6 +195,28 @@ onMounted(() => {
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>
@@ -133,12 +224,24 @@ onMounted(() => {
<!-- Background Layer (Outside Scale to cover full screen) -->
<div class="fixed-bg" :style="innerBgUrl ? { backgroundImage: `url(${innerBgUrl})` } : {}" />
<VScaleScreen width="1920" height="1080">
<VScaleScreen width="2048" height="1080">
<div class="competition-layout">
<!-- Header -->
<header class="page-header text-[30px] text-[#333333] font-bold">
<span class="org-name" @click="handleBackHome">教育学会</span>
<span class="org-name">树人研究院</span>
<header class="page-header flex items-center px-8 text-[30px] text-[#333333] font-bold">
<!-- 左侧logo -->
<div class="!z-index-99 flex cursor-pointer items-center" @click="handleBackHome">
<img :src="cqLogo" alt="重庆logo" class="h-18 w-auto">
</div>
<!-- 中间文字 -->
<div class="!z-index-99 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">
@@ -146,9 +249,23 @@ onMounted(() => {
<section v-if="showTitle" class="title-section">
<Transition name="scroll-unfold" appear>
<div v-if="showContent" class="title-wrapper">
<div class="scroll-bg" :style="{ backgroundImage: `url(${titleBgUrl})` }">
<div
class="scroll-bg"
:style="{ width: props.titleBgType === 3 ? '400px' : props.titleBgType === 4 ? '420px' : '480px' }"
>
<!-- 预渲染所有背景图 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">
<h1
v-if="showContent" class="main-title"
:style="{ color: titleTextColor, letterSpacing: props.titleBgType === 2 ? '6px' : props.titleBgType === 4 ? '1px' : '4px', fontSize: props.titleBgType === 4 ? '38px' : '' }"
>
{{ title }}
</h1>
</Transition>
@@ -166,32 +283,31 @@ onMounted(() => {
<!-- Footer Action -->
<footer
v-if="showButton"
class="page-footer w-full flex items-center justify-between px-20"
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="backBtnText ? 'text' : 'button'" :text="backBtnText || ''" :theme="btnTheme" @click="handleBack"
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" />
<SvgIcon v-if="!backBtnText" icon="mdi:arrow-left" size="42" class="arrow-icon" />
</ActionButton>
</div>
<div class="right">
<ActionButton
v-if="showNext" :type="nextBtnText ? 'text' : 'button'" :text="nextBtnText || '下一步'"
:theme="btnTheme" :disabled="nextDisabled" @click="handleNext"
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" />
<SvgIcon v-if="!nextBtnText" icon="mdi:arrow-right" class="arrow-icon" size="42" />
</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" />
<SvgIcon v-if="!nextBtnText2" icon="mdi:arrow-right" class="arrow-icon" size="42" />
</ActionButton>
</div>
</footer>
@@ -207,35 +323,61 @@ onMounted(() => {
width: 412px;
height: 165px;
display: flex;
justify-content: center; /* Ensure wrapper stays centered during expansion */
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 */
justify-content: center;
/* Keep the background image centered */
}
.scroll-bg {
width: 412px !important; /* Fixed width to prevent squishing during animation */
height: 165px !important;
width: 400px;
/* Fixed width to prevent squishing during animation */
height: 180px !important;
position: relative;
background: no-repeat center center;
background-size: 100% 100%;
display: flex;
align-items: center;
justify-content: center;
padding-bottom: 10px;
flex-shrink: 0; /* Prevent shrinking */
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.5rem;
font-size: 2.66rem;
color: $text-color;
margin: 0;
letter-spacing: 4px;
letter-spacing: 2px;
text-align: center;
white-space: nowrap; /* Prevent text wrapping */
white-space: nowrap;
/* Prevent text wrapping */
position: relative;
z-index: 1;
}
}
}
@@ -271,9 +413,11 @@ onMounted(() => {
overflow: hidden;
position: relative;
font-family: 'Noto Serif SC', serif;
z-index: 1; // 确保内容在背景之上
z-index: 2; // 确保内容在背景之上
// margin: 10px auto;
margin-top: 28px;
// Default background decoration (Bottom Wave)
// 底部波浪背景
&::before {
content: '';
position: absolute;
@@ -292,7 +436,7 @@ onMounted(() => {
.glass-overlay {
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.5);
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);
@@ -306,15 +450,15 @@ onMounted(() => {
.page-header {
position: absolute;
top: 0;
top: -58px;
left: 0;
width: 100%;
height: 196px;
display: flex;
justify-content: space-between;
padding: 20px 40px;
padding: 20px 44px;
font-weight: 700;
color: $secondary-color;
z-index: 10;
}
.main-content {
@@ -360,7 +504,8 @@ onMounted(() => {
.fade-in-delay-enter-active {
transition: opacity 0.8s ease;
transition-delay: 1s; /* Wait for scroll to unfold */
transition-delay: 1s;
/* Wait for scroll to unfold */
}
.fade-in-delay-enter-from {
@@ -43,6 +43,3 @@ const _props = withDefaults(defineProps<Props>(), {
<slot />
</div>
</template>
<style scoped>
</style>
+13 -7
View File
@@ -21,11 +21,17 @@ export enum QuestionType {
}
export enum QuestionCategoryEnum {
ChineseCharacterDictation1 = 'ChineseCharacterDictation1', // 汉字听写-提示 (jiu表示小鸟的叫声)
ChineseCharacterDictation2 = 'ChineseCharacterDictation2', // 根据拼音写汉字-提示 (tang)
CharacterRadicalAddition = 'CharacterRadicalAddition', // 汉字加一加 (车)
WordDictation = 'WordDictation', // 词语听写 (zhi re)
IdiomWriting1 = 'IdiomWriting1', // 成语-文字要求 (反义字)
IdiomWriting2 = 'IdiomWriting2', // 成语-文字要求 (看图写成语)
PoetryComprehension = 'PoetryComprehension', // 诗词理解-选择题
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. 换字组成语
}
@@ -214,7 +214,7 @@ init()
<template #prefix>
<SvgIcon :icon="tab.icon" :local-icon="tab.localIcon" class="inline-block align-text-bottom text-16px" />
</template>
<div class="max-w-240px ellipsis-text">
<div class="max-w-240px ellipsis-text text-14px">
{{ tab.label }}
</div>
</PageTab>
+4 -46
View File
@@ -49,31 +49,11 @@ export function fetchGetQuestionLibraryListAll(params: Api.Question.GetQuestionL
}
/** 新增一条问题库 */
export function fetchAddQuestionLibrary(params: Api.Question.AddQuestionLibraryParams, file?: File | null) {
const formData = new FormData()
if (file) {
formData.append('Image', file)
}
export function fetchAddQuestionLibrary(params: Api.Question.AddQuestionLibraryParams) {
return request<Api.Question.CommonRecord>({
url: '/Base/ActivityMain/AddQuestionListDetail',
method: 'post',
// params: {
// QuestionId: params.questionId,
// Name: params.name,
// Answer: params.answer,
// Type: params.type,
// IsGood: params.IsGood,
// ImageUrl: params.imageUrl,
// },
data: {
QuestionId: params.questionId,
Name: params.name,
Answer: params.answer,
Type: params.type,
IsGood: params.IsGood,
ImageUrl: params.imageUrl,
IsDisabled: params.IsDisabled,
},
data: params,
})
}
@@ -87,33 +67,11 @@ export function fetchDisableQuestionLibrary(params: Api.Question.DisableQuestion
}
/** 更新一条问题库 */
export function fetchUpdateQuestionLibrary(params: Api.Question.AddQuestionLibraryParams, file?: File | null) {
const formData = new FormData()
if (file) {
formData.append('Image', file)
}
export function fetchUpdateQuestionLibrary(params: Api.Question.UpdateQuestionLibraryParams) {
return request<Api.Question.CommonRecord>({
url: '/Base/ActivityMain/UpdateQuestionListDetail',
method: 'post',
// params: {
// Id: params.id,
// QuestionId: params.questionId,
// Name: params.name,
// Answer: params.answer,
// Type: params.type,
// IsGood: params.IsGood,
// ImageUrl: params.imageUrl,
// },
data: {
Id: params.id,
QuestionId: params.questionId,
Name: params.name,
Answer: params.answer,
Type: params.type,
IsGood: params.IsGood,
ImageUrl: params.imageUrl,
IsDisabled: params.IsDisabled,
},
data: params,
})
}
+1
View File
@@ -7,6 +7,7 @@ body,
#app {
height: 100%;
text-autospace: normal;
font-size: 18px;
}
html {
+2
View File
@@ -106,6 +106,8 @@ declare namespace Api {
Image: string
ImageUrl: string
Answer: string
KeyWordsIndex?: number
KeyWord?: string
}
/** get current question params */
+11 -1
View File
@@ -65,11 +65,21 @@ declare namespace Api {
/** question library image url */
imageUrl: string
/** question library type */
type: QuestionScoreType
// type?: QuestionScoreType
/** is priority 0: no 1: yes */
IsGood: number
/** is disabled */
IsDisabled: boolean
/** keyword */
KeyWord: string
/** keyword index */
KeyWordsIndex: number
}
/** update question library params */
interface UpdateQuestionLibraryParams extends AddQuestionLibraryParams {
/** question library id */
id: number
}
/** common search params of table */
+16
View File
@@ -0,0 +1,16 @@
/**
* 生成UUID的兼容函数
* 优先使用原生 crypto.randomUUID,如果不支持则使用兼容方案
*/
export function generateUUID(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID()
}
// 兼容性回退方案 - 生成符合 UUID v4 格式的字符串
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0
const v = c === 'x' ? r : (r & 0x3 | 0x8)
return v.toString(16)
})
}
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { NButton, NForm, NFormItem, NInputNumber, NModal, NSelect, type SelectOption } from 'naive-ui'
import { onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { VueDraggable } from 'vue-draggable-plus'
import { useDict } from '@/hooks/business/useDict'
import { fetchGetQuestionListAll } from '@/service/api/question'
@@ -29,7 +29,10 @@ const props = defineProps<{
const { options: roundTypeOptions } = useDict('round_type')
const { options: scoreTypeOptions } = useDict('score_type')
const { options: timeOptions } = useDict('time_out')
const { options: timeOptionsRaw } = useDict('time_out')
const timeOptions = computed(() =>
[...timeOptionsRaw.value].sort((a, b) => Number(a.value) - Number(b.value)),
)
// const { options: uiTypeOptions } = useDict('ui_type', 'string')
const { options: uiTypeOptions } = useDict('question_category_name', 'string')
@@ -41,8 +41,10 @@ const modelValue = ref<{
const { options: roundTypeOptions } = useDict('round_type')
const { options: scoreTypeOptions } = useDict('score_type')
const { options: timeOptions } = useDict('time_out')
// const { options: uiTypeOptions } = useDict('ui_type', 'string')
const { options: timeOptionsRaw } = useDict('time_out')
const timeOptions = computed(() =>
[...timeOptionsRaw.value].sort((a, b) => Number(a.value) - Number(b.value)),
)
const { options: uiTypeOptions } = useDict('question_category_name', 'string')
const hookProps = reactive({
@@ -23,7 +23,6 @@ function handleAdd() {
}
async function handleCopy(item: Api.Competition.CompetitionItem) {
// TODO: 实现复制逻辑
try {
const { error } = await fetchCopyActivity(item.Id)
if (error) {
@@ -163,7 +162,3 @@ onMounted(() => fetchActivityList())
<CompetitionAddModal v-model:show="showAddModal" @success="fetchActivityList" />
</NSpace>
</template>
<style scoped>
/* 针对不同屏幕微调间距 */
</style>
@@ -57,6 +57,14 @@ async function fetchData() {
// 列定义
const columns: DataTableColumns<Api.Dictionary.DictionaryItem> = [
{
title: '序号',
key: 'index',
width: 80,
render(_, index) {
return index + 1
},
},
{ title: 'ID', key: 'Id' },
{
title: '字典键 (Key)',
@@ -59,6 +59,14 @@ watch(() => props.visible, (val) => {
})
const columns: DataTableColumns<any> = [
{
title: '序号',
key: 'index',
width: 80,
render(_, index) {
return index + 1
},
},
{ title: 'ID', key: 'ID', width: 80 },
{ title: '键 (Key)', key: 'UI_Key' },
{ title: '值 (Value)', key: 'UI_Value', width: 100 },
+11 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue'
import { onMounted, ref } from 'vue'
import CategoryTree from './modules/CategoryTree.vue'
import QuestionList from './modules/QuestionList.vue'
@@ -8,7 +8,8 @@ defineOptions({ name: 'QuestionStore' })
const currentCategory = ref<any>(null)
// Resize logic
const siderWidth = ref(450)
const siderWidth = ref(535)
const LOCAL_STORAGE_KEY = 'question-store-sider-width'
const minWidth = 300
const maxWidth = 800
const isDragging = ref(false)
@@ -43,7 +44,15 @@ function onMouseUp() {
document.removeEventListener('mouseup', onMouseUp)
document.body.style.userSelect = ''
document.body.style.cursor = ''
localStorage.setItem(LOCAL_STORAGE_KEY, siderWidth.value.toString())
}
onMounted(() => {
const savedWidth = localStorage.getItem(LOCAL_STORAGE_KEY)
if (savedWidth) {
siderWidth.value = Number(savedWidth)
}
})
</script>
<template>
@@ -6,6 +6,7 @@ import {
NInput,
NModal,
NSelect,
type SelectOption,
useDialog,
useMessage,
} from 'naive-ui'
@@ -20,9 +21,10 @@ const emit = defineEmits<{
(e: 'dragStart', event: MouseEvent): void
}>()
const { options: questionTypeOptions } = useDict('question_type', 'string')
const { options: categoryOptions } = useDict('question_category_name', 'string')
const LOCAL_STORAGE_KEY = 'question-store-selected-category-id'
const message = useMessage()
const dialog = useDialog()
@@ -46,6 +48,7 @@ const currentOperationNode = ref<CategoryItem | null>(null)
watch(selectedKey, (newKey) => {
if (!newKey) {
emit('update:category', null)
localStorage.removeItem(LOCAL_STORAGE_KEY)
return
}
const node = categoryList.value.find(item => item.Id === newKey)
@@ -53,9 +56,11 @@ watch(selectedKey, (newKey) => {
emit('update:category', {
...node,
})
localStorage.setItem(LOCAL_STORAGE_KEY, newKey.toString())
}
else {
emit('update:category', null)
localStorage.removeItem(LOCAL_STORAGE_KEY)
}
})
@@ -63,6 +68,16 @@ function handleSelect(key: number) {
selectedKey.value = key
}
/**
* 分类名称选择更新
* @param _value
* @param option
*/
function handleCategoryNameChange(_value: string, option: SelectOption) {
if (option)
categoryForm.value.questionType = option.value as QuestionType
}
function handleAddCategory() {
categoryOperation.value = 'add'
categoryForm.value.questionContent = ''
@@ -100,7 +115,7 @@ function handleDeleteCategory(item: CategoryItem) {
selectedKey.value = null
}
// 获取最新分类列表
await fetchCategoryList()
await loadAndSelectCategory()
message.success('删除成功')
}
},
@@ -128,7 +143,7 @@ async function submitCategory() {
message.error('添加分类失败')
return
}
await fetchCategoryList()
await loadAndSelectCategory()
message.success('分类添加成功')
}
else if (categoryOperation.value === 'edit' && currentOperationNode.value) {
@@ -144,7 +159,7 @@ async function submitCategory() {
}
currentOperationNode.value.QuestionContent = categoryForm.value.questionContent
message.success('分类修改成功')
await fetchCategoryList()
await loadAndSelectCategory()
// 更新选中分类的信息
if (selectedKey.value === currentOperationNode.value.Id) {
const node = currentOperationNode.value
@@ -160,21 +175,35 @@ async function submitCategory() {
}
/**
* 获取分类列表
* 获取并选择分类列表
*/
async function fetchCategoryList() {
async function loadAndSelectCategory() {
try {
const { data } = await fetchGetQuestionListAll()
categoryList.value = data?.data || []
// Ensure Id is always a number to prevent type mismatch issues
categoryList.value = (data?.data || []).map((c: any) => ({ ...c, Id: Number(c.Id) }))
const savedKeyRaw = localStorage.getItem(LOCAL_STORAGE_KEY)
const savedKey = savedKeyRaw ? Number(savedKeyRaw) : null
const savedCategoryExists = savedKey ? categoryList.value.some(c => c.Id === savedKey) : false
if (savedKey && savedCategoryExists) {
selectedKey.value = savedKey
}
else if (categoryList.value.length > 0) {
selectedKey.value = categoryList.value[0].Id
}
else {
selectedKey.value = null
}
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (error) {
message.error('获取分类列表失败')
catch (error: any) {
message.error(error.message || '获取分类列表失败')
}
}
onMounted(() => {
fetchCategoryList()
loadAndSelectCategory()
})
</script>
@@ -202,10 +231,10 @@ onMounted(() => {
>
<div class="flex items-center gap-2 overflow-hidden">
<SvgIcon
icon="carbon:folder" class="flex-shrink-0 text-lg"
icon="carbon:folder" class="flex-shrink-0 text-sm"
:class="selectedKey === item.Id ? 'text-primary' : 'text-gray-400'"
/>
<span class="truncate font-medium">{{ item.Name }} - {{ item.QuestionContent }}</span>
<span class="truncate text-16px font-medium">{{ item.Name }} - {{ item.QuestionContent }}</span>
</div>
<!-- Action Buttons -->
@@ -243,11 +272,12 @@ onMounted(() => {
:options="categoryOptions"
value-field="label"
placeholder="请选择分类名称"
@update:value="handleCategoryNameChange"
/>
</NFormItem>
<!-- 题目类型 -->
<NFormItem label="题目类型">
<NSelect v-model:value="categoryForm.questionType" :options="questionTypeOptions" placeholder="请选择题目类型" />
<NInput v-model:value="categoryForm.questionType" placeholder="请选择分类名称自动带出" :readonly="true" />
</NFormItem>
<!-- 分类描述 -->
<NFormItem label="分类描述">
@@ -0,0 +1,282 @@
<script setup lang="ts">
import type { FormInst, FormItemRule } from 'naive-ui'
import Clipboard from 'clipboard'
import {
NButton,
NCard,
NDrawer,
NDrawerContent,
NForm,
NFormItem,
NInput,
NInputNumber,
NRadio,
NRadioGroup,
} from 'naive-ui'
import { pinyin } from 'pinyin-pro'
import { computed, onUnmounted, ref, watch } from 'vue'
import OssImageUpload from '@/components/common/oss-image-upload/index.vue'
import WangEditor from '@/components/common/wang-editor.vue'
import { QuestionCategoryEnum, QuestionType } from '@/enum/business'
import { fetchAddQuestionLibrary, fetchUpdateQuestionLibrary } from '@/service/api/question'
const props = defineProps<{
show: boolean
operation: 'add' | 'edit'
questionData?: any
currentCategory: any
}>()
const emit = defineEmits<{
(e: 'update:show', value: boolean): void
(e: 'success'): void
}>()
const showQuestionModal = computed({
get: () => props.show,
set: val => emit('update:show', val),
})
// 拼音转换工具
const pinyinTool = ref({
input: '',
output: '',
})
// 监听输入变化自动转换
watch(() => pinyinTool.value.input, (val) => {
if (val) {
pinyinTool.value.output = pinyin(val)
}
else {
pinyinTool.value.output = ''
}
})
// 复制拼音
const copyBtnRef = ref<any>(null)
let clipboard: Clipboard | null = null
watch(copyBtnRef, (inst) => {
if (clipboard) {
clipboard.destroy()
clipboard = null
}
if (inst) {
const domEl = inst.$el || inst
clipboard = new Clipboard(domEl)
clipboard.on('success', () => {
window?.$message?.success('拼音已复制到剪贴板')
})
clipboard.on('error', () => {
window?.$message?.error('复制失败,请手动复制')
})
}
})
onUnmounted(() => {
clipboard?.destroy()
})
const questionForm = ref({
name: '',
answer: '',
type: QuestionCategoryEnum.ChineseCharacterDictation1 as QuestionCategoryEnum | number,
imageUrl: '',
IsGood: 0,
IsDisabled: false,
KeyWord: '',
KeyWordsIndex: 0,
})
const questionFormRef = ref<FormInst | null>(null)
const rules = computed(() => {
const dynamicRules: any = {
answer: [{ required: true, message: '请输入题目答案', trigger: ['blur'] }],
type: [{ required: true, message: '请选择题目类型', trigger: ['change'] }],
}
// 题目正文内容和图片至少要有一个
if (!questionForm.value.imageUrl) {
dynamicRules.name = [{ required: true, message: '请输入题目正文内容或上传图片', trigger: ['blur'] }]
}
// 只有“联想对对碰”类型才需要关键词
if (questionForm.value.type === QuestionCategoryEnum.AssociationMatching) {
dynamicRules.KeyWord = [{ required: true, message: '请输入关键词', trigger: ['blur'] }]
dynamicRules.KeyWordsIndex = [
{
required: true,
validator: (_rule: FormItemRule, value: number) => {
return typeof value === 'number'
},
message: '请输入关键词索引',
trigger: ['blur', 'change'],
},
]
}
return dynamicRules
})
watch(() => props.show, (val) => {
if (val) {
if (props.operation === 'add') {
questionForm.value = {
name: '',
answer: '',
type: props.currentCategory?.QuestionType || QuestionType.SingleChoice,
imageUrl: '',
IsGood: 0,
IsDisabled: false,
KeyWord: '',
KeyWordsIndex: 0,
}
pinyinTool.value.input = ''
pinyinTool.value.output = ''
}
else if (props.operation === 'edit' && props.questionData) {
const question = props.questionData
questionForm.value = {
name: question.Name,
answer: question.Answer,
type: Number(question.Type),
KeyWord: question.KeyWord || '',
KeyWordsIndex: question.KeyWordsIndex || 0,
imageUrl: question.ImageUrl || '',
IsGood: question.IsPriority || 0,
IsDisabled: !!question.IsDisabled,
}
pinyinTool.value.input = ''
pinyinTool.value.output = ''
}
}
})
async function submitQuestion() {
const valid = await questionFormRef.value?.validate()
if (!valid) {
window?.$message?.error('请填写完整信息')
return
}
const params = {
...questionForm.value,
questionId: props.currentCategory.Id,
// type: String(questionForm.value.type) as Api.Question.QuestionScoreType,
}
if (questionForm.value.type !== QuestionCategoryEnum.AssociationMatching) {
params.KeyWord = ''
params.KeyWordsIndex = 0
}
if (props.operation === 'add') {
const { error } = await fetchAddQuestionLibrary({ ...params, id: 0 })
if (!error) {
window?.$message?.success('题目添加成功')
emit('success')
}
else {
window?.$message?.error('添加失败')
}
}
else if (props.operation === 'edit' && props.questionData) {
const { error } = await fetchUpdateQuestionLibrary({ ...params, id: props.questionData.Id })
if (!error) {
window?.$message?.success('题目修改成功')
emit('success')
}
else {
window?.$message?.error('修改失败')
}
}
}
</script>
<template>
<NDrawer v-model:show="showQuestionModal" :width="800">
<NDrawerContent :title="props.operation === 'edit' ? '编辑题目详情' : '新增题目详情'">
<div class="mb-4">
<NCard size="small" title="🛠️ 拼音助手 (输入汉字获取带音标拼音)" class="bg-gray-50/50">
<div class="flex gap-2">
<NInput v-model:value="pinyinTool.input" placeholder="输入汉字..." class="flex-1" />
<div class="flex flex-1 items-center justify-between border border-gray-200 rounded bg-white px-3 py-1">
<span class="text-gray-600">{{ pinyinTool.output || '拼音结果将显示在这里' }}</span>
<textarea id="pinyinCopyTarget" v-model="pinyinTool.output" class="absolute opacity-0 -z-1" />
<div v-if="pinyinTool.output" ref="copyBtnRef" data-clipboard-target="#pinyinCopyTarget">
<NButton size="tiny" type="primary" secondary>
复制
</NButton>
</div>
</div>
</div>
</NCard>
</div>
<NForm ref="questionFormRef" label-placement="top" :rules="rules" :model="questionForm" size="small">
<NFormItem label="题目正文内容" path="name">
<div
v-if="[QuestionCategoryEnum.PoetryComprehension, QuestionCategoryEnum.PoetryCommonKnowledge, QuestionCategoryEnum.ReversePoemMatching, QuestionCategoryEnum.ChineseCharacterDictation1].includes(props.currentCategory?.QuestionType)"
class="w-full overflow-hidden border border-gray-200 rounded-lg"
>
<WangEditor
v-model="questionForm.name" placeholder="请输入题目内容..." height="300px"
:exclude-keys="['header1', 'fontSize', 'fontFamily', 'lineHeight', 'justifyLeft', 'justifyRight', 'justifyCenter', 'justifyJustify', 'group-image', 'group-video', 'insertTable', 'headerSelect']"
/>
</div>
<NInput v-else v-model:value="questionForm.name" type="textarea" placeholder="请输入题目内容" :rows="3" />
</NFormItem>
<NFormItem label="是否优先使用" path="IsGood">
<NRadioGroup v-model:value="questionForm.IsGood">
<NRadio :value="0">
</NRadio>
<NRadio :value="1">
</NRadio>
</NRadioGroup>
</NFormItem>
<NFormItem label="是否禁用" path="IsDisabled">
<NRadioGroup v-model:value="questionForm.IsDisabled">
<NRadio :value="false">
</NRadio>
<NRadio :value="true">
</NRadio>
</NRadioGroup>
</NFormItem>
<NFormItem label="上传图片">
<OssImageUpload v-model="questionForm.imageUrl" />
</NFormItem>
<template v-if="currentCategory?.QuestionType === QuestionCategoryEnum.AssociationMatching">
<NFormItem label="关键词" path="KeyWord">
<NInput v-model:value="questionForm.KeyWord" placeholder="请输入关键词" />
</NFormItem>
<NFormItem label="关键词索引" path="KeyWordsIndex">
<NInputNumber v-model:value="questionForm.KeyWordsIndex" placeholder="请输入关键词索引" />
</NFormItem>
</template>
<NFormItem label="参考标准答案" path="answer">
<NInput v-model:value="questionForm.answer" placeholder="正确答案" />
</NFormItem>
</NForm>
<template #footer>
<div class="flex justify-end gap-3">
<NButton @click="showQuestionModal = false">
取消并返回
</NButton>
<NButton type="primary" @click="submitQuestion">
保存并入库
</NButton>
</div>
</template>
</NDrawerContent>
</NDrawer>
</template>
@@ -1,84 +1,25 @@
<script setup lang="ts">
import type { FormInst, UploadCustomRequestOptions, UploadFileInfo } from 'naive-ui'
import Clipboard from 'clipboard'
import {
NBreadcrumb,
NBreadcrumbItem,
NButton,
NCard,
NCheckbox,
NDrawer,
NDrawerContent,
NEmpty,
NForm,
NFormItem,
NImage,
NInput,
NPagination,
NRadio,
NRadioGroup,
NTag,
NUpload,
} from 'naive-ui'
import { pinyin } from 'pinyin-pro'
import { computed, onUnmounted, ref, watch } from 'vue'
import WangEditor from '@/components/common/wang-editor.vue'
import { computed, ref, watch } from 'vue'
import SvgIcon from '@/components/custom/svg-icon.vue'
import { QuestionCategoryEnum, QuestionType } from '@/enum/business'
import { fetchAddQuestionLibrary, fetchDeleteQuestionLibrary, fetchDisableQuestionLibrary, fetchGetQuestionLibraryListAll, fetchUpdateQuestionLibrary } from '@/service/api/question'
/**
* 为了user端题目展示的效果以及布局可控,指定规则:
* 1.普通类型 =》用存文字存 ['汉字加一加','词语听写''汉字听写','成语写一写']
* 2.复杂样式 =》用富文本 ['诗词理解']
*
*/
import { fetchDeleteQuestionLibrary, fetchDisableQuestionLibrary, fetchGetQuestionLibraryListAll } from '@/service/api/question'
import QuestionDrawer from './QuestionDrawer.vue'
const props = defineProps<{
currentCategory: any
}>()
// 拼音转换工具
const pinyinTool = ref({
input: '',
output: '',
})
// 监听输入变化自动转换
watch(() => pinyinTool.value.input, (val) => {
if (val) {
pinyinTool.value.output = pinyin(val)
}
else {
pinyinTool.value.output = ''
}
})
// 复制拼音
const copyBtnRef = ref<any>(null)
let clipboard: Clipboard | null = null
watch(copyBtnRef, (inst) => {
if (clipboard) {
clipboard.destroy()
clipboard = null
}
if (inst) {
const domEl = inst.$el || inst
clipboard = new Clipboard(domEl)
clipboard.on('success', () => {
window?.$message?.success('拼音已复制到剪贴板')
})
clipboard.on('error', () => {
window?.$message?.error('复制失败,请手动复制')
})
}
})
onUnmounted(() => {
clipboard?.destroy()
})
// 题目列表数据
const questionList = ref<any[]>([])
const loading = ref(false)
@@ -106,19 +47,7 @@ const pagination = ref({
const searchText = ref('')
const showQuestionModal = ref(false)
const questionOperation = ref<'add' | 'edit'>('add')
const currentQuestionId = ref<number | null>(null)
const questionForm = ref({
name: '',
answer: '',
type: QuestionType.SingleChoice as QuestionType | number, // 题目类型
imageUrl: '',
IsGood: 0,
IsDisabled: false,
})
const fileList = ref<UploadFileInfo[]>([])
const questionFormRef = ref<FormInst | null>(null)
const currentQuestion = ref<any>(null)
// 批量选择
const selectedQuestionIds = ref<number[]>([])
@@ -203,12 +132,6 @@ async function handleBatchDisable(disabled: boolean) {
}
}
const rules = {
name: [{ required: true, message: '请输入题目正文内容', trigger: ['blur'] }],
answer: [{ required: true, message: '请输入题目答案', trigger: ['blur'] }],
type: [{ required: true, message: '请选择题目类型', trigger: ['change'] }],
}
const hasCategory = computed(() => {
return !!props.currentCategory
})
@@ -224,14 +147,6 @@ watch(() => props.currentCategory, (newVal) => {
}
}, { immediate: true })
function customRequest({ onFinish }: UploadCustomRequestOptions) {
onFinish()
}
function handleUploadChange(data: { fileList: UploadFileInfo[] }) {
fileList.value = data.fileList
}
async function fetchData(id: number) {
loading.value = true
const { data, error } = await fetchGetQuestionLibraryListAll({
@@ -260,49 +175,15 @@ function handleAddQuestion() {
return
}
questionOperation.value = 'add'
currentQuestionId.value = null
questionForm.value = {
name: '',
answer: '',
type: props.currentCategory?.QuestionType || QuestionType.SingleChoice,
imageUrl: '',
IsGood: 0,
IsDisabled: false,
}
fileList.value = []
currentQuestion.value = null
showQuestionModal.value = true
pinyinTool.value.input = ''
pinyinTool.value.output = ''
}
/**
* 处理编辑题目
* @param id 题目ID
*/
function handleEditQuestion(id: number) {
pinyinTool.value.input = ''
pinyinTool.value.output = ''
const question = questionList.value.find(q => q.Id === id)
if (question) {
questionOperation.value = 'edit'
currentQuestionId.value = id
questionForm.value = {
name: question.Name,
answer: question.Answer,
type: question.Type,
imageUrl: question.ImageUrl || '',
IsGood: question.IsPriority || 0,
IsDisabled: !!question.IsDisabled,
}
fileList.value = []
if (question.ImageUrl) {
fileList.value = [{
id: 'existing',
name: 'Existing Image',
status: 'finished',
url: question.ImageUrl,
}]
}
currentQuestion.value = question
showQuestionModal.value = true
}
}
@@ -328,69 +209,10 @@ function handleDeleteQuestion(id: number) {
})
}
async function submitQuestion() {
const valid = await questionFormRef.value?.validate()
if (!questionForm.value.name) {
if (!valid) {
window?.$message?.error('请填写完整信息')
return
}
}
const commonParams = {
name: questionForm.value.name,
answer: questionForm.value.answer,
imageUrl: '',
type: questionForm.value.type as any,
questionId: props.currentCategory.Id,
IsGood: questionForm.value.IsGood,
// 传给后端为 boolean
IsDisabled: questionForm.value.IsDisabled,
}
let fileToUpload: File | null = null
if (questionForm.value.type === QuestionType.Image) { // 图片类型
if (fileList.value.length > 0 && fileList.value[0].file) {
fileToUpload = fileList.value[0].file
}
// else if (questionOperation.value === 'add' && fileList.value.length === 0) {
// window?.$message?.error('请上传图片')
// return
// }
// else if (questionOperation.value === 'edit' && fileList.value.length === 0) {
// window?.$message?.error('请上传图片')
// return
// }
}
if (questionOperation.value === 'add') {
const { error } = await fetchAddQuestionLibrary({
...commonParams,
id: 0,
}, fileToUpload)
if (!error) {
window?.$message?.success('题目添加成功')
showQuestionModal.value = false
fetchData(props.currentCategory.Id)
}
else {
window?.$message?.error('添加失败')
}
}
else if (questionOperation.value === 'edit' && currentQuestionId.value) {
const { error } = await fetchUpdateQuestionLibrary({
...commonParams,
id: currentQuestionId.value,
}, fileToUpload)
if (!error) {
window?.$message?.success('题目修改成功')
showQuestionModal.value = false
fetchData(props.currentCategory.Id)
}
else {
window?.$message?.error('修改失败')
}
function onDrawerSuccess() {
showQuestionModal.value = false
if (props.currentCategory?.Id) {
fetchData(props.currentCategory.Id)
}
}
</script>
@@ -556,102 +378,13 @@ async function submitQuestion() {
</template>
</div>
<!-- Add Question Drawer -->
<NDrawer v-model:show="showQuestionModal" :width="800">
<NDrawerContent :title="questionOperation === 'edit' ? '编辑题目详情' : '新增题目详情'">
<div class="mb-4">
<NCard size="small" title="🛠️ 拼音助手 (输入汉字获取带音标拼音)" class="bg-gray-50/50">
<div class="flex gap-2">
<NInput v-model:value="pinyinTool.input" placeholder="输入汉字..." class="flex-1" />
<div class="flex flex-1 items-center justify-between border border-gray-200 rounded bg-white px-3 py-1">
<span class="text-gray-600">{{ pinyinTool.output || '拼音结果将显示在这里' }}</span>
<textarea id="pinyinCopyTarget" v-model="pinyinTool.output" class="absolute opacity-0 -z-1" />
<div v-if="pinyinTool.output" ref="copyBtnRef" data-clipboard-target="#pinyinCopyTarget">
<NButton size="tiny" type="primary" secondary>
复制
</NButton>
</div>
</div>
</div>
</NCard>
</div>
<NForm ref="questionFormRef" label-placement="top" :rules="rules" :model="questionForm" size="small">
<NFormItem label="题目正文内容" path="name">
<div
v-if="[QuestionCategoryEnum.PoetryComprehension].includes(props.currentCategory?.QuestionType)"
class="w-full overflow-hidden border border-gray-200 rounded-lg"
>
<WangEditor
v-model="questionForm.name" placeholder="请输入题目内容..." height="300px"
:exclude-keys="['header1', 'fontSize', 'fontFamily', 'lineHeight', 'justifyLeft', 'justifyRight', 'justifyCenter', 'justifyJustify', 'group-image', 'group-video', 'insertTable', 'headerSelect']"
/>
</div>
<NInput v-else v-model:value="questionForm.name" type="textarea" placeholder="请输入题目内容" :rows="3" />
</NFormItem>
<!-- <div class="grid grid-cols-2 gap-4">
<NFormItem label="参考分值 (Pts)">
<NInputNumber v-model:value="questionForm.score" class="w-full" :min="1" />
</NFormItem>
<NFormItem label="限时 (S)">
<NInputNumber v-model:value="questionForm.time" class="w-full" :min="1" />
</NFormItem>
</div> -->
<!-- 类型 -->
<!-- <NFormItem label="类型" path="type">
<NSelect v-model:value="questionForm.type" class="w-full" :options="questionTypeOptionsDict" />
</NFormItem> -->
<!-- 是否优先使用 -->
<NFormItem label="是否优先使用" path="IsGood">
<NRadioGroup v-model:value="questionForm.IsGood">
<NRadio :value="0">
</NRadio>
<NRadio :value="1">
</NRadio>
</NRadioGroup>
</NFormItem>
<!-- 是否禁用 -->
<NFormItem label="是否禁用" path="IsDisabled">
<NRadioGroup v-model:value="questionForm.IsDisabled">
<NRadio :value="false">
</NRadio>
<NRadio :value="true">
</NRadio>
</NRadioGroup>
</NFormItem>
<!-- 上传图片 -->
<NFormItem v-if="questionForm.type === QuestionType.Image" label="上传图片">
<NUpload
v-model:file-list="fileList" accept="image/*" :max="1" list-type="image-card"
:custom-request="customRequest" @change="handleUploadChange"
>
点击上传
</NUpload>
</NFormItem>
<NFormItem label="参考标准答案" path="answer">
<NInput v-model:value="questionForm.answer" placeholder="正确答案" />
</NFormItem>
</NForm>
<template #footer>
<div class="flex justify-end gap-3">
<NButton @click="showQuestionModal = false">
取消并返回
</NButton>
<NButton type="primary" @click="submitQuestion">
保存并入库
</NButton>
</div>
</template>
</NDrawerContent>
</NDrawer>
<QuestionDrawer
v-model:show="showQuestionModal"
:operation="questionOperation"
:question-data="currentQuestion"
:current-category="currentCategory"
@success="onDrawerSuccess"
/>
</div>
</template>
@@ -151,7 +151,7 @@ function edit(item: any) {
<template #header>
<div class="flex items-center justify-between">
<div class="mid-title">
<div class="text-16px font-bold">
<div class="text-2xl font-bold">
{{ rankTitle }}
</div>
<div class="mt-4px text-12px text-gray-500">
@@ -1,7 +1,7 @@
<!-- eslint-disable no-console -->
<script lang="ts" setup>
import { Icon } from '@iconify/vue'
import { useStorage } from '@vueuse/core'
import { useIntervalFn, useStorage } from '@vueuse/core'
import { NButton, NCard, NImage, NInputNumber, NTag, useMessage } from 'naive-ui'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
@@ -32,6 +32,10 @@ const currentQuestion = ref<Api.Competition.GetCurrentQuestionInfo>() // 当前
const questionInfo = ref<Api.Competition.QuestionListDetailRound>() // 题目详情
const currentQuestionDetailID = ref<number | string>('') // 当前选中的题目详情 ID
const nowTs = ref(Date.now())
useIntervalFn(() => {
nowTs.value = Date.now()
}, 1000)
// 判断当前activityId是正在比赛的活动
const isActiveActivityId = computed(() => {
if (!currentQuestion.value)
@@ -241,9 +245,9 @@ async function fetchQuestions() {
}
/**
* 获取当前正在比赛的题目
* 获取当前正在比赛的题目(不触发同步选中逻辑)
*/
async function fetchCurrentQuestion() {
async function fetchCurrentQuestion(shouldSync = true) {
if (!roomId.value)
return
try {
@@ -253,15 +257,55 @@ async function fetchCurrentQuestion() {
return
}
currentQuestion.value = data?.data || {}
const endTime = new Date(currentQuestion.value.EndTime)
console.warn('current question', currentQuestion.value)
// 拉取当前题目信息后尝试同步选中题
syncCurrentQuestionSelection()
console.log('当前题目结束时间是:', endTime.toLocaleString())
// 只在初始化时同步选中题,轮询时不同步
if (shouldSync) {
syncCurrentQuestionSelection()
}
}
catch (error: any) {
message.error(error.message || '获取当前题目失败')
}
}
const blobSizeCache = new Map<string, number>()
const objectUrlCache = new Map<string, string>()
async function checkAndUpdateImage(uniqueId: string, originalUrl: string, item: any) {
try {
const timestamp = Date.now()
const separator = originalUrl.includes('?') ? '&' : '?'
const fetchUrl = `${originalUrl}${separator}_t=${timestamp}`
const response = await fetch(fetchUrl)
if (!response.ok)
return
const blob = await response.blob()
const prevSize = blobSizeCache.get(uniqueId)
if (prevSize === blob.size) {
return // 内容大小未变,认为图片没变,不更新 src,避免闪烁
}
blobSizeCache.set(uniqueId, blob.size)
const objectUrl = URL.createObjectURL(blob)
const oldUrl = objectUrlCache.get(uniqueId)
if (oldUrl) {
URL.revokeObjectURL(oldUrl)
}
objectUrlCache.set(uniqueId, objectUrl)
// 替换为本地 Blob URL,由于数据已在内存中,更新时不会闪烁
item.AnswerValuePicture = objectUrl
}
catch (e) {
console.error('Silent image update failed:', e)
}
}
/**
* 获取答题卡数据
*/
@@ -363,13 +407,22 @@ async function fetchAnswerData() {
// 强制更新图片 URL,防止缓存
// 优先使用 ans 中的图片,如果没有则回退到 answerData 中的图片
const imageUrl = ans.AnswerValuePicture || (parsedAnswers.length === 1 && parsedAnswers[0] === ans ? answerData.UserAnswerPicture : '')
const uniqueId = `${answerData.TeamId}-${ans.Guid}`
let finalImageUrl = imageUrl
if (imageUrl) {
if (oldAns && oldAns._originalUrl === imageUrl) {
finalImageUrl = oldAns.AnswerValuePicture
}
else {
finalImageUrl = imageUrl
}
}
const cleanBaseUrl = ''
const timestamp = new Date().getTime()
finalImageUrl = `${finalImageUrl}?_t=${timestamp}`
console.log(finalImageUrl, 'finalImageUrl')
// 判断是否手动干预:
// 1. 本地存储有状态 (hasStoredStatus) 或有分数 (hasStoredScore)
// 2. 内存中有手动标记 (oldAns._isManual)
@@ -399,6 +452,7 @@ async function fetchAnswerData() {
QuestionDetailID: answerData.QuestionDetaiID, // [新增] 注意后端字段拼写
AnswerText: ans.AnswerText,
AnswerValuePicture: finalImageUrl,
_originalUrl: imageUrl,
_baseImgUrl: cleanBaseUrl,
_t: timestamp, // 保存时间戳
Score: finalScore,
@@ -407,17 +461,21 @@ async function fetchAnswerData() {
_aiStatus: aiStatus,
_aiScore: aiScore,
}
if (imageUrl) {
checkAndUpdateImage(uniqueId, imageUrl, resultItem)
}
return resultItem
}
// 如果没有手动编辑
return {
const resultItem = {
...ans,
TeamId: answerData.TeamId, // [新增] 注入 TeamId
QuestionId: answerData.QuestionId, // [新增]
QuestionDetailID: answerData.QuestionDetaiID, // [新增] 注意后端字段拼写
AnswerText: ans.AnswerText,
AnswerValuePicture: finalImageUrl,
_originalUrl: imageUrl,
_baseImgUrl: cleanBaseUrl,
_t: timestamp, // 保存时间戳
Status: ans.Status,
@@ -426,6 +484,10 @@ async function fetchAnswerData() {
Score: null, // 默认 null
_isManual: false,
}
if (imageUrl) {
checkAndUpdateImage(uniqueId, imageUrl, resultItem)
}
return resultItem
})
return {
@@ -467,8 +529,8 @@ async function handleNexCurrentQuestion() {
function handlePublish() {
const endTimeStr = (currentQuestion.value as any)?.EndTime
if (endTimeStr && Date.now() < new Date(endTimeStr).getTime()) {
window.$message?.warning('倒计时未结束,暂不能发布')
if (endTimeStr && Date.now() < new Date(endTimeStr).getTime() + 1000) {
window.$message?.warning('倒计时未结束(需等待5秒延迟),暂不能发布')
return
}
if (currentQuestion.value?.TeamGroupQuestionID) {
@@ -578,12 +640,12 @@ function handlePublish() {
* 消费并发布当前题目
*/
const pollingTimer = ref<NodeJS.Timeout | null>(null)
const nowTs = ref(Date.now())
const canPublish = computed(() => {
const endTime = (currentQuestion.value as any)?.EndTime
if (!endTime)
return true
const end = new Date(endTime).getTime()
// 结束时间 + 5秒延迟才能发布
const end = new Date(endTime).getTime() + 2000
return nowTs.value >= end
})
@@ -593,8 +655,8 @@ function startPolling() {
// 检查是否已经结束
const endTime = (currentQuestion.value as any)?.EndTime
if (endTime) {
// 增加 8s 缓冲时间
const end = new Date(endTime).getTime() + 12000
// 增加 5s 缓冲时间
const end = new Date(endTime).getTime() + 3000
const now = new Date().getTime()
if (now > end) {
console.log('当前题目已结束(含12s缓冲),仅获取一次最终结果,不启动轮询', endTime)
@@ -611,22 +673,23 @@ function startPolling() {
}
fetchAnswerData() // 立即获取
nowTs.value = Date.now()
pollingTimer.value = setInterval(() => {
// 每次轮询前再次检查是否过期
const currentEndTime = (currentQuestion.value as any)?.EndTime
if (currentEndTime) {
// 增加 8s 缓冲时间
const endTs = new Date(currentEndTime).getTime() + 12000
// 增加 1s 缓冲时间
const endTs = new Date(currentEndTime).getTime() + 1000
if (Date.now() > endTs) {
console.log('题目时间已到(含12s缓冲),停止轮询', currentEndTime)
console.log('题目时间已到(含1s缓冲),停止轮询', currentEndTime)
stopPolling()
return
}
}
nowTs.value = Date.now()
// 轮询时同时更新当前题目信息(获取最新的 EndTime),但不触发同步
fetchCurrentQuestion(false)
fetchAnswerData()
}, 3000)
}, 2000)
}
function stopPolling() {
@@ -681,7 +744,7 @@ watch(() => currentQuestionDetailID.value, (newVal) => {
onMounted(async () => {
// 并行拉取分组、题目与当前题目,完成后做一次同步
await Promise.all([fetchGroups(), fetchQuestions(), fetchCurrentQuestion()])
await Promise.all([fetchGroups(), fetchQuestions(), fetchCurrentQuestion(true)])
syncCurrentQuestionSelection()
startPolling()
})
@@ -802,27 +865,29 @@ onUnmounted(() => {
</div>
</div>
<!-- 图片展示 -->
<div class="flex items-center justify-center">
<div class="relative min-h-30 flex items-center justify-center">
<NImage
:src="item.AnswerValuePicture || item.imageUrl" object-fit="contain"
class="max-h-[300px] w-full"
:src="item.AnswerValuePicture || item.imageUrl"
object-fit="contain"
class="pointer-events-auto max-h-[300px] w-full cursor-pointer"
show-toolbar-tooltip
/>
</div>
<!-- Toggle Button Overlay (Center) -->
<div
class="absolute inset-0 flex flex-col items-center justify-center gap-2 opacity-0 transition-opacity duration-200 hover:opacity-100"
class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-2 opacity-0 transition-opacity duration-200 hover:opacity-100"
>
<!-- 如果未校准显示两个按钮 -->
<template v-if="!item._isManual">
<NButton
strong secondary round size="medium" type="success" class="shadow-xl"
strong secondary round size="medium" type="success" class="pointer-events-auto shadow-xl"
@click="setStatus(team, item, 1)"
>
确认正确 (绿)
</NButton>
<NButton
strong secondary round size="medium" type="error" class="shadow-xl"
strong secondary round size="medium" type="error" class="pointer-events-auto shadow-xl"
@click="setStatus(team, item, 0)"
>
确认错误 ()
@@ -833,7 +898,7 @@ onUnmounted(() => {
<template v-else>
<NButton
strong secondary round size="large" :type="item.Status === 1 ? 'error' : 'success'"
class="shadow-xl" @click="setStatus(team, item, item.Status === 1 ? 0 : 1)"
class="pointer-events-auto shadow-xl" @click="setStatus(team, item, item.Status === 1 ? 0 : 1)"
>
切换为 {{ item.Status === 1 ? '错误 (红)' : '正确 (绿)' }}
</NButton>
@@ -885,8 +950,10 @@ onUnmounted(() => {
<div class="h-full flex items-center justify-center">
<NImage
v-if="item.AnswerValuePicture || item.imageUrl"
:src="item.AnswerValuePicture || item.imageUrl" object-fit="contain"
class="max-h-[800px] w-full"
:src="item.AnswerValuePicture || item.imageUrl"
object-fit="contain"
class="pointer-events-auto max-h-[800px] w-full cursor-pointer"
show-toolbar-tooltip
/>
<div v-else class="text-gray-300">
<div class="i-carbon-no-image text-6xl" />
@@ -7,6 +7,7 @@ import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useRouterPush } from '@/hooks/common/router'
import { fetchAddTemplate, fetchTemplateDetail } from '@/service/api/template'
import { generateUUID } from '@/utils/uuid'
import { useUpload } from './hooks/useUpload'
import TemplateCanvas from './modules/TemplateCanvas.vue'
import TemplateSettings from './modules/TemplateSettings.vue'
@@ -64,7 +65,7 @@ function handleUploadTrigger() {
* 处理区域生成
*/
function handleGenerate() {
const groupId = crypto.randomUUID()
const groupId = generateUUID()
const newRegions: Region[] = []
// 计算起始编号:查找现有的最大 Qx 编号
@@ -85,7 +86,7 @@ function handleGenerate() {
for (let c = 0; c < genSettings.value.cols; c++) {
const index = maxNum + r * genSettings.value.cols + c + 1 // 计算当前区域的编号
newRegions.push({
id: crypto.randomUUID(),
id: generateUUID(),
groupId,
x: currentX + c * (genSettings.value.w + genSettings.value.gapX),
y: currentY + r * (genSettings.value.h + genSettings.value.gapY),
@@ -66,6 +66,11 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
title: '模板ID',
align: 'center',
},
{
key: 'PageNo',
title: '页码',
align: 'center',
},
{
key: 'competitionId',
title: '关联比赛',
+74 -12
View File
@@ -117,6 +117,42 @@ async function checkNextStep() {
}
}
const blobSizeCache = new Map<string, number>()
const objectUrlCache = new Map<string, string>()
async function checkAndUpdateImage(uniqueId: string, originalUrl: string, team: any) {
try {
const timestamp = Date.now()
const separator = originalUrl.includes('?') ? '&' : '?'
const fetchUrl = `${originalUrl}${separator}_t=${timestamp}`
const response = await fetch(fetchUrl)
if (!response.ok)
return
const blob = await response.blob()
const prevSize = blobSizeCache.get(uniqueId)
if (prevSize === blob.size) {
return // 内容大小未变,认为图片没变,不更新 src,避免闪烁
}
blobSizeCache.set(uniqueId, blob.size)
const objectUrl = URL.createObjectURL(blob)
const oldUrl = objectUrlCache.get(uniqueId)
if (oldUrl) {
URL.revokeObjectURL(oldUrl)
}
objectUrlCache.set(uniqueId, objectUrl)
// 替换为本地 Blob URL,由于数据已在内存中,更新时不会闪烁
team.UserAnswerPicture = objectUrl
}
catch (e) {
console.error('Silent image update failed:', e)
}
}
/** 获取游戏现场统计结果 */
async function fetchGetGameStatisticsData() {
if (store.currentQuestionInfo && store.currentQuestionDetail) {
@@ -127,7 +163,30 @@ async function fetchGetGameStatisticsData() {
QuestionDetaiID: Number(QuestionDetailID.value),
}
const { data } = await fetchGetGameStatistics(_params)
teams.value = (data?.data as any[]) || []
const rawTeams = (data?.data as any[]) || []
teams.value = rawTeams.map((team) => {
const uniqueId = team.TeamID || team.TeamName || Math.random().toString()
team._uniqueId = uniqueId
if (team.UserAnswerPicture) {
const originalUrl = team.UserAnswerPicture
const oldTeam = teams.value.find(t => t._uniqueId === uniqueId)
if (oldTeam && oldTeam.UserAnswerPicture && oldTeam._originalUrl === originalUrl) {
// 保持原有的 URL,避免每次轮询都刷新
team.UserAnswerPicture = oldTeam.UserAnswerPicture
}
else {
team.UserAnswerPicture = originalUrl
}
team._originalUrl = originalUrl
// 后台静默获取最新图片并对比
checkAndUpdateImage(uniqueId, originalUrl, team)
}
return team
})
}
catch (error: any) {
console.error('Failed to fetch game statistics', error)
@@ -150,15 +209,15 @@ onBeforeUnmount(() => {
<template>
<CompetitionLayout
:show-title="true" :show-back="false" :show-next="showNextBtn" title="答题图解" action-position="top"
back-btn-text="返回" :next-btn-text="isCompleted ? '该组已完成回到选组' : '下一题'" btn-theme="light" @back="handleBack"
:show-title="true" :show-back="false" :show-next="showNextBtn" :title-bg-type="3" title-text-color="#ffffff" title="答题图解" action-position="top"
back-btn-text="返回" :next-btn-text="isCompleted ? '已完成选组' : '下一题'" btn-theme="light" @back="handleBack"
@next="handleNext"
>
<div class="relative h-full w-full flex flex-col items-center px-12 pt-10 font-sans">
<div class="relative h-full w-full flex flex-col items-center px-10 pt-10px font-sans">
<!-- Content -->
<div class="w-full flex flex-col flex-1 overflow-hidden">
<div class="mb-2 text-xl text-red-600 font-bold">
备注评委正在评分中请等待
备注评委正在评分中请等待...
</div>
<div v-if="teams && teams.length > 0" class="grid grid-cols-4 h-full gap-6 pb-8">
@@ -167,14 +226,17 @@ onBeforeUnmount(() => {
class="flex flex-col border-2 border-gray-200 rounded-xl bg-white/90 p-4 shadow-sm"
>
<div
class="mb-3 flex items-center gap-2 rounded-full from-sky-300 to-cyan-200 bg-gradient-to-r px-4 py-2 shadow-sm"
class="mb-3 flex items-center justify-between gap-2 rounded-full from-sky-300 to-cyan-200 bg-gradient-to-r px-4 py-2 shadow-sm"
>
<span class="text-lg text-white font-bold drop-shadow">{{ team.TeamName }}</span>
<span class="ml-2 rounded-full bg-white/40 px-2 py-0.5 text-sm">正确 <span class="text-md text-blue">{{
team.correctCount }}</span> </span>
<span class="text-s rounded-full bg-white/40 px-2 py-0.5"> <span class="text-md text-blue">{{
team.Points
}}</span></span>
<span class="overflow-hidden text-ellipsis whitespace-nowrap text-lg text-white font-bold drop-shadow">{{ team.TeamName }}</span>
<div class="flex shrink-0 gap-2">
<span class="whitespace-nowrap rounded-full bg-green-100/90 px-3 py-1.5 text-sm">
评委评 <span class="text-lg text-orange-600 font-bold">{{ team.ResultPotins }}</span>
</span>
<span class="whitespace-nowrap rounded-full bg-orange-100/60 px-3 py-1.5 text-sm">
AI评分 <span class="text-md text-orange-600 font-bold">{{ team.Points }}</span>
</span>
</div>
</div>
<!-- Answer Image Area -->
+42 -32
View File
@@ -1,20 +1,19 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import checkoutAudio from '@/assets/audio/checkout.mp3'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { PublishStatus } from '@/enum/business'
import { useRouterPush } from '@/hooks/common/router'
import { fetchUpdatePublishStatus } from '@/service/api/competition'
import { fetchGetQuestionTableByActivityID } from '@/service/api/game'
import { useActivityInfoStore } from '@/store/modules/activityinfo'
import { AudioController } from '@/utils/audio'
import { filterRepeat } from '@/utils/data'
const activityInfoStore = useActivityInfoStore()
const activityInfo = computed(() => activityInfoStore.activityInfo)
const { routerPushByKey, routerBack } = useRouterPush()
const audioController = new AudioController()
interface QuestionItem {
id: number
@@ -30,12 +29,12 @@ const activityName = computed(() => activityInfo.value?.ActivityTitle || '星·
const flippedCards = ref<Set<number>>(new Set())
// 计算是否所有卡片都已翻转 (当前显示4张卡片)
// eslint-disable-next-line unused-imports/no-unused-vars
const isAllFlipped = computed(() => flippedCards.value.size === questions.value.length)
// 控制下一步按钮的显示(带延迟)
const showNextButton = ref(true)
// eslint-disable-next-line prefer-const, unused-imports/no-unused-vars
let timer: ReturnType<typeof setTimeout> | null = null
// 获取路由参数
@@ -77,21 +76,21 @@ async function getQuestions() {
}
}
// 测试先注释掉
// watch(isAllFlipped, (val) => {
// if (timer) {
// clearTimeout(timer)
// timer = null
// }
watch(isAllFlipped, (val) => {
if (timer) {
clearTimeout(timer)
timer = null
}
// if (val) {
// timer = setTimeout(() => {
// showNextButton.value = true
// }, 1500)
// }
// else {
// showNextButton.value = false
// }
// })
if (val) {
timer = setTimeout(() => {
showNextButton.value = true
}, 1500)
}
else {
showNextButton.value = false
}
})
function handleBack() {
routerBack()
@@ -128,7 +127,8 @@ function toggleFlip(index: number) {
}
else {
flippedCards.value.add(index)
audioController.play('flip') // 翻牌动效
const audio = new Audio(checkoutAudio)
audio.play().catch(e => console.error('Play audio failed', e))
}
// 每次点击都播放翻牌音效(无论翻开还是翻回去)
// audioController.play('flip')
@@ -150,6 +150,16 @@ async function initSyncRouteParams() {
const showCards = ref(false)
// 计算每排卡片数量(根据不同屏幕尺寸)
// 默认按最小屏幕(2列)计算,如果超过2排则调整padding
const needCompactPadding = computed(() => {
// 假设最小屏幕每排2个卡片(w-[44%])
// const cardsPerRow = 2
// const totalRows = Math.ceil(questions.value.length / cardsPerRow)
// return totalRows >= 2
return questions.value.length > 7 // 如果超过8张卡片就使用紧凑间距
})
// 3D 悬停效果逻辑
const cardStyles = ref<Record<number, any>>({})
@@ -196,11 +206,11 @@ onMounted(async () => {
<template>
<CompetitionLayout
:show-back="true" :show-next="showNextButton" next-btn-text="下一步" :title="activityName"
:title-bg-type="2" @back="handleBack" @next="handleNext"
:title-bg-type="2" back-btn-text="返回" @back="handleBack" @next="handleNext"
>
<div class="h-screen w-full overflow-y-auto font-sans">
<div class="relative z-10 min-h-full w-full">
<div class="min-h-full w-full flex flex-col items-center pb-12 pt-24">
<div class="min-h-full w-full flex flex-col items-center pb-12" :class="needCompactPadding ? 'pt-4' : 'pt-24'">
<!-- 卡片列表 -->
<TransitionGroup
tag="div" name="card-anim"
@@ -230,7 +240,7 @@ onMounted(async () => {
<!-- 文字内容 -->
<div class="relative z-10 transform text-center">
<div
class="card-text text-2xl text-[#333333] font-bold leading-tight font-serif drop-shadow-sm"
class="card-text text-26px text-[#333333] font-bold leading-tight font-serif drop-shadow-sm"
>
<span class="block">{{ node.moduleName.slice(0, 2) }}</span>
<span class="block">{{ node.moduleName.slice(2) }}</span>
@@ -278,7 +288,7 @@ onMounted(async () => {
// [过冲状态]
// 飞到眼前一点点 (Z=50px)
// 角度稍微回弹 (rotateY -15deg)
transform: translate3d(0, -20px, 50px) rotateY(-15deg) rotateX(-10deg) scale(1.05);
transform: translate3d(0, -20px, 50px) rotateY(-15deg) rotateX(-10deg) scale(1.2);
}
100% {
@@ -318,8 +328,8 @@ onMounted(async () => {
// [翻转状态]
// rotateY(180deg): 绕Y轴旋转180度,实现翻面
// scale(1.05): 翻转同时放大1.05倍,产生"向观众逼近"的视觉冲击力
transform: rotateY(180deg) scale(1.06);
box-shadow: 0 20px 40px -5px rgba(0, 0, 0, 0.3); // [动态阴影] 翻转浮起时阴影更深、更扩散
transform: rotateY(180deg) scale(1.08);
box-shadow: 0 10px 20px -5px rgba(0, 0, 0, 0.3); // [动态阴影] 翻转浮起时阴影更深、更扩散
}
}
@@ -334,7 +344,7 @@ onMounted(async () => {
border-radius: 20px;
overflow: hidden;
// border: 1px solid rgba(255, 255, 255, 0.5); // 增加微妙的边框
// border: 1px solid rgba(231, 215, 144, 0.5); // 增加微妙的边框
}
// 正面(实际是卡背图案)
@@ -344,7 +354,7 @@ onMounted(async () => {
.card-back-design {
width: 100%;
height: 100%;
background: url('@/assets/imgs/user/cover-back.svg') no-repeat center center; // 背景面
background: url('@/assets/imgs/user/cover-back.webp') no-repeat center center; // 背景面
background-size: contain; // 保持比例
border-radius: 20px;
display: flex;
@@ -357,7 +367,7 @@ onMounted(async () => {
// 背面(实际是内容面)
.flip-card-back {
// background-color: #fff;
transform: rotateY(180deg);
transform: rotateY(200deg);
// border: 4px solid #fff;
.card-content-design {
@@ -399,7 +409,7 @@ onMounted(async () => {
// animation-delay: 0.2s; 确保卡片翻转到一半(正对屏幕)时才开始闪光,
// 模拟光线随着角度变化掠过卡片表面的物理效果
animation: shine-sweep 3s ease-out forwards;
animation-delay: 0.2s;
animation-delay: 1s;
}
}
@@ -445,14 +455,14 @@ onMounted(async () => {
10% {
// 刚进入时瞬间变亮
opacity: 0.8;
opacity: 0.8; // 0.8 是一个半透明值,模拟光束的透明度,值越大越透明
}
100% {
// 扫出视野
top: 50%;
left: 150%;
opacity: 0;
opacity: 0.6;
}
}
+11 -5
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import drawImg from '@/assets/imgs/user/draw.svg'
import drawImg from '@/assets/imgs/user/draw.webp'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { useRouterPush } from '@/hooks/common/router'
import { fetchGetCurrentQuestion } from '@/service/api/game'
@@ -132,16 +132,19 @@ function handleBack() {
const questionMainTitle = computed(() => question.value?.QuestionList?.Name || '')
const questionTitle = computed(() => question.value?.QuestionList?.QuestionContent || question.value?.QuestionList?.Name || '')
const titleBgTypeNum = computed(() => RoundType.value === 0 ? 3 : 4)
</script>
<template>
<CompetitionLayout
:show-back="true" :show-next="true" :title="questionMainTitle || '抽题'" next-btn-text="开始抽题"
:show-back="true" :show-next="true" back-btn-text="返回" :title="questionMainTitle || '抽题'" next-btn-text="开始抽题"
:title-bg-type="titleBgTypeNum || 3" title-text-color="#ffffff"
@next="handleCardClick(question as Api.Competition.CurrentQuestionResponse)" @back="handleBack"
>
<div class="h-full w-full flex flex-col items-center pt-10 font-sans">
<div v-if="question" class="mb-10 max-w-4xl text-center">
<div class="text-2xl text-gray-800 font-medium leading-relaxed tracking-wide">
<div v-if="question" class="mb-10 max-w-85% text-center">
<div class="text-4xl text-gray-800 font-medium leading-relaxed tracking-wide">
{{ questionTitle || '' }}
</div>
</div>
@@ -169,7 +172,10 @@ const questionTitle = computed(() => question.value?.QuestionList?.QuestionConte
}}</span>
</div> -->
<!-- 禁用放大 -->
<NImage :src="drawImg" class="h-full w-full object-cover" :preview-disabled="true" />
<NImage
:src="drawImg" class="h-full w-full object-cover" width="300" height="300"
:preview-disabled="true"
/>
</div>
</div>
</div>
+84 -27
View File
@@ -2,6 +2,7 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import start3Audio from '@/assets/audio/timeout3.mp3'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { useRouterPush } from '@/hooks/common/router'
import { fetchGetGameStatistics, fetchGetQuestionDetail, fetchSubmitQuestionResult } from '@/service/api/game'
@@ -20,11 +21,15 @@ const { activityId, groupId, activityInfo, teamId, teamIds } = storeToRefs(activ
const currentQuestionInfo = computed(() => store.currentQuestionInfo)
const currentQuestionDetail = computed(() => store.currentQuestionDetail)
const currentQuestionMainInfo = computed(() => store.currentQuestionMainInfo)
const RoundType = computed(() => store.currentQuestionMainInfo?.Activity_Question?.RoundType)
const loading = ref(false)
// 本地倒计时控制(为了在页面上显示“开始答题”还是倒计时)
const isStarted = ref(false)
const isPreparing = ref(false) // 是否正在准备中(321倒计时)
const showCountdown = ref(false) // 控制倒计时弹窗显示
const countdownNum = ref(3) // 倒计时数字
const pollingTimer = ref<number | null>(null) // 轮询定时器
console.log(activityInfo.value, 'activityInfo.value')
@@ -129,19 +134,10 @@ async function fetchGetQuestionDetailData() {
async function handleNext() {
// 点击开始答题
if (!isStarted.value) {
isStarted.value = true
if (!isStarted.value && !isPreparing.value) {
isPreparing.value = true
// 获取题目时间,优先从 currentQuestionInfo 获取,兼容大小写
// 并在获取失败时尝试从 detail 获取(如果后端在详情接口也返回了时间)
const infoTime = store.currentQuestionInfo?.QuestionTime || (store.currentQuestionInfo as any)?.questionTime
const detailTime = (store.currentQuestionDetail as any)?.QuestionTime || (store.currentQuestionDetail as any)?.questionTime
const duration = Number(infoTime || detailTime || 120)
console.log('开始倒计时,时长:', duration, 'InfoTime:', infoTime, 'DetailTime:', detailTime)
store.startTimer(duration)
// 提交题目使用记录
// 提交题目使用记录 (调用开始接口)
try {
if (store.currentQuestionInfo && store.currentQuestionDetail) {
const params: Api.Competition.QuestionAddParams = {
@@ -152,19 +148,45 @@ async function handleNext() {
QuestionDetaiID: store.currentQuestionDetail.Id || 0, // 详情 ID
TeamGroupQuestionID: store.currentQuestionMainInfo?.TeamGroup_QuestionID || 0, // 题目在分组中的 ID
}
console.log(params, 'params')
await fetchSubmitQuestionResult(params)
// 立即拉取一次,然后开启轮询
await fetchGetGameStatisticsData(params)
startPolling(params)
await fetchSubmitQuestionResult(params) // 提交题目使用记录
// 调用接口成功后,显示 321 倒计时弹窗并播放音乐
showCountdown.value = true
countdownNum.value = 3
const audio = new Audio(start3Audio)
audio.play().catch(e => console.error('Play audio failed', e))
const timer = setInterval(() => {
countdownNum.value--
if (countdownNum.value === 0) {
clearInterval(timer)
showCountdown.value = false
isPreparing.value = false
isStarted.value = true
// 并在获取失败时尝试从 detail 获取(如果后端在详情接口也返回了时间)
const infoTime = store.currentQuestionInfo?.QuestionTime || (store.currentQuestionInfo as any)?.questionTime
const detailTime = (store.currentQuestionDetail as any)?.QuestionTime || (store.currentQuestionDetail as any)?.questionTime
const duration = Number(infoTime || detailTime || 120)
console.log('开始倒计时,时长:', duration, 'InfoTime:', infoTime, 'DetailTime:', detailTime)
store.startTimer(duration)
// 立即拉取一次,然后开启轮询
fetchGetGameStatisticsData(params)
startPolling(params)
}
}, 1000)
}
}
catch (error) {
console.error('Failed to submit question result', error)
isPreparing.value = false
// 不阻塞流程,仅记录错误
}
}
else {
else if (isStarted.value) {
// 备用逻辑:如果已经在答题中(理论上不会触发,因为上面已经跳转),直接跳转
store.stopTimer()
stopPolling()
@@ -228,7 +250,16 @@ async function fetchGetGameStatisticsData(_params: Api.Competition.QuestionAddPa
correct: item.Points || 0, // 预估得分 (使用外层 Points)
})
})
chartData.value = Array.from(teamMap.values())
const newChartData = Array.from(teamMap.values())
// 只有数据真正变化时才更新
const currentDataStr = JSON.stringify(chartData.value)
const newDataStr = JSON.stringify(newChartData)
if (currentDataStr !== newDataStr) {
chartData.value = newChartData
}
}
}
catch (error) {
@@ -237,13 +268,16 @@ async function fetchGetGameStatisticsData(_params: Api.Competition.QuestionAddPa
}
}
}
const titleBgTypeNum = computed(() => RoundType.value === 0 ? 3 : 4) || 3
</script>
<template>
<CompetitionLayout
action-position="top" :show-back="false" :show-next="true" :title="mainTitle || ''"
back-btn-text="返回" :next-btn-text="isStarted ? formattedTime : '开始答题'" :next-disabled="isStarted" btn-theme="light"
@back="handleBack" @next="handleNext"
:title-bg-type="titleBgTypeNum" title-text-color="#ffffff" back-btn-text="返回"
:next-btn-text="(isStarted || isPreparing) ? (isPreparing ? '准备中...' : formattedTime) : '开始答题'"
:next-disabled="isStarted || isPreparing" btn-theme="light" @back="handleBack" @next="handleNext"
>
<div class="relative h-full w-full flex flex-col items-center overflow-hidden px-12 font-sans">
<!-- 题目说明 -->
@@ -257,6 +291,18 @@ async function fetchGetGameStatisticsData(_params: Api.Competition.QuestionAddPa
</div>
</div>
<!-- 321 倒计时遮罩层 -->
<div
v-if="showCountdown"
class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm"
>
<Transition name="scale" mode="out-in">
<div :key="countdownNum" class="text-[300px] color-#dd0315 font-bold">
{{ countdownNum }}
</div>
</Transition>
</div>
<!-- 隐藏的 Next 按钮 (方便演示右上角小区域) -->
<div class="absolute right-0 top-0 z-50 h-20 w-20 cursor-pointer" title="Next Step (Debug)" @click="handleNext">
<div class="h-full w-full flex items-center justify-center">
@@ -271,12 +317,11 @@ async function fetchGetGameStatisticsData(_params: Api.Competition.QuestionAddPa
class="relative z-20 h-64 max-w-5xl w-full flex flex-col shrink-0 border-2 border-blue-300 rounded-xl bg-white/90 p-4 shadow-lg backdrop-blur"
>
<!-- 图表标题栏 -->
<div v-if="isStarted" class="mb-4 flex items-center justify-between px-2">
<div class="flex items-center gap-2">
<div class="h-3 w-3 rotate-45 bg-orange-400" />
<span class="text-lg text-blue-800 font-bold">答题进度</span>
<span class="text-md text-gray-400">仅为ai判定不代表最终结果</span>
</div>
<div v-if="isStarted" class="z-index-999 relative mb-4">
<img
src="@/assets/imgs/user/rank-bg-kuang.png" alt="答题进度"
class="z-index-999 absolute left--30px top--25px h-auto max-h-16 w-auto"
>
</div>
<!-- 柱状图ECharts -->
@@ -294,4 +339,16 @@ async function fetchGetGameStatisticsData(_params: Api.Competition.QuestionAddPa
/* red-500 */
font-weight: bold;
}
/* 321 倒计时动画 */
.scale-enter-active,
.scale-leave-active {
transition: all 0.3s ease;
}
.scale-enter-from,
.scale-leave-to {
opacity: 0;
transform: scale(0.5);
}
</style>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import * as echarts from 'echarts/core'
import { watch } from 'vue'
import { ref, watch } from 'vue'
import { type ECOption, useEcharts } from '@/hooks/common/echarts'
@@ -15,6 +15,21 @@ const props = defineProps<{
height?: number
}>()
// 缓存上一次的数据,用于深度比较
const lastDataCache = ref<string>('')
/**
* 深度比较数据是否发生变化
*/
function isDataChanged(newData: BarDatum[]): boolean {
const newDataStr = JSON.stringify(newData)
if (newDataStr === lastDataCache.value) {
return false
}
lastDataCache.value = newDataStr
return true
}
/**
* 构建柱状图配置
* 说明:根据传入的数据生成双系列柱状图(答题数量/正确数量)
@@ -25,9 +40,17 @@ function buildOption(list: BarDatum[]): ECOption {
const correct = list.map(i => i.correct)
return {
// 启用动画,让柱子变化更平滑
animation: false,
animationDuration: 800,
animationEasing: 'linear', // 动画缓动函数,平滑过渡,cubicOutlinearquadOutquadIn
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderColor: '#3b82f6',
borderWidth: 1,
textStyle: { color: '#1e3a8a' },
},
legend: {
data: ['答题数量', '预估得分'],
@@ -35,17 +58,18 @@ function buildOption(list: BarDatum[]): ECOption {
right: 0,
icon: 'circle',
textStyle: {
color: '#666',
color: '#64748b',
fontSize: 12,
},
},
grid: { left: 16, right: 16, top: 40, bottom: 0, containLabel: true },
grid: { left: 16, right: 16, top: 70, bottom: 0, containLabel: true },
xAxis: {
type: 'category',
data: categories,
axisTick: { show: false }, // 隐藏 x 轴刻度线
axisLine: { show: false }, // 隐藏 x 轴线
axisLabel: {
color: '#003366', // 深蓝色字体
color: '#1e3a8a', // 深蓝色字体
fontWeight: 'bold',
fontSize: 14,
margin: 16,
@@ -65,6 +89,7 @@ function buildOption(list: BarDatum[]): ECOption {
type: 'bar',
data: total,
barWidth: 24,
barGap: '40%', // 柱子间的间隙
itemStyle: {
// 从上到下:浅蓝 -> 深蓝
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
@@ -72,14 +97,17 @@ function buildOption(list: BarDatum[]): ECOption {
{ offset: 1, color: '#3b82f6' }, // blue-500 (底部深色)
]),
borderRadius: [4, 4, 0, 0],
shadowBlur: 10,
shadowColor: 'rgba(59, 130, 246, 0.2)',
shadowOffsetY: 4,
},
label: {
show: true,
position: 'top',
color: '#003366', // 深蓝色
color: '#1e3a8a', // 深蓝色
fontWeight: 'bold',
fontSize: 18,
offset: [0, 2],
fontSize: 16,
offset: [0, -4],
},
},
{
@@ -94,14 +122,17 @@ function buildOption(list: BarDatum[]): ECOption {
{ offset: 1, color: '#f97316' }, // orange-500 (底部深色)
]),
borderRadius: [4, 4, 0, 0],
shadowBlur: 10,
shadowColor: 'rgba(249, 115, 22, 0.2)',
shadowOffsetY: 4,
},
label: {
show: true,
position: 'top',
color: '#ea580c', // orange-600
fontWeight: 'bold',
fontSize: 18,
offset: [0, 2],
fontSize: 16,
offset: [0, -4],
},
},
],
@@ -113,12 +144,15 @@ const { domRef, updateOptions } = useEcharts<ECOption>(() => buildOption(props.d
/**
* 监听数据变化并更新图表
* 说明:当父组件 chartData 更新时,动态刷新柱状图
* 说明:只有当数据真正发生变化时才更新图表,避免不必要的重绘
*/
watch(
() => props.data,
(val) => {
updateOptions(() => buildOption(val || []))
(newData) => {
// 只有数据真正变化时才更新
if (isDataChanged(newData || [])) {
updateOptions(() => buildOption(newData || []))
}
},
{ deep: true },
)
+2 -2
View File
@@ -3,7 +3,7 @@ import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import groupType1 from '@/assets/imgs/user/group-type-1.svg'
import groupType2 from '@/assets/imgs/user/group-type-2.svg'
import startIcon from '@/assets/imgs/user/star-icon.svg'
import startIcon from '@/assets/imgs/user/star-icon.webp'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { useRouterPush } from '@/hooks/common/router'
import { fetchGetGroupListByActivityID } from '@/service/api/game'
@@ -83,7 +83,7 @@ onMounted(() => {
<template>
<CompetitionLayout
:show-back="true" :show-next="isAllEnd" next-btn-text="查看常规赛结果" next-btn-text2="查看加时赛结果"
:show-back="true" back-btn-text="返回" :show-next="isAllEnd" next-btn-text="查看常规赛结果" font-size="22" next-btn-text2="查看加时赛结果"
:show-next-btn2="hasExtraTime && isAllEnd" :title-bg-type="2" title="展示组别" @back="handleBack" @next="handleNext"
@next2="handleNext2"
>

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