Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4c3734639 | ||
|
|
38a243af15 | ||
|
|
0d03fdd782 | ||
|
|
a76263375a | ||
|
|
9bc58c6270 | ||
|
|
7b505ec4aa | ||
|
|
a7cf35e6bf | ||
|
|
c617307c79 | ||
|
|
3d3f7afcd2 | ||
|
|
18e3f6f7f2 | ||
|
|
e061f46e66 | ||
|
|
7d49143ae0 | ||
|
|
d1f6044a6c | ||
|
|
9a80fd9fa1 | ||
|
|
fe4d8f6d62 | ||
|
|
1c8035d935 | ||
|
|
2fad025496 | ||
|
|
02b4720b34 | ||
|
|
8964fcdea7 | ||
|
|
771fb807eb | ||
|
|
782c61a38c | ||
|
|
40a6850c3f |
@@ -7,6 +7,10 @@ dist-ssr
|
||||
apps/*/dist
|
||||
packages/*/dist
|
||||
|
||||
# --- 分析报告文件 ---
|
||||
report.html
|
||||
apps/*/report.html
|
||||
|
||||
# --- 日志文件 ---
|
||||
logs
|
||||
*.log
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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. **测试**: 在接近实际大屏的设备上测试性能
|
||||
|
||||
## 联系与支持
|
||||
|
||||
如有问题,请联系开发团队。
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -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 |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
@@ -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
|
||||
|
After Width: | Height: | Size: 578 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 487 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 487 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 304 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 3.8 MiB |
|
After Width: | Height: | Size: 740 KiB |
|
Before Width: | Height: | Size: 7.2 MiB |
|
After Width: | Height: | Size: 445 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 797 KiB |
|
After Width: | Height: | Size: 811 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 10 MiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 907 KiB |
|
Before Width: | Height: | Size: 16 MiB |
|
After Width: | Height: | Size: 907 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 5.5 MiB |
|
After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 549 KiB |
|
Before Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 449 KiB |
|
After Width: | Height: | Size: 240 KiB |
|
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ body,
|
||||
#app {
|
||||
height: 100%;
|
||||
text-autospace: normal;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
html {
|
||||
|
||||
@@ -106,6 +106,8 @@ declare namespace Api {
|
||||
Image: string
|
||||
ImageUrl: string
|
||||
Answer: string
|
||||
KeyWordsIndex?: number
|
||||
KeyWord?: string
|
||||
}
|
||||
|
||||
/** get current question params */
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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: '关联比赛',
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', // 动画缓动函数,平滑过渡,cubicOut|linear|quadOut|quadIn
|
||||
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 },
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||