13 Commits
Author SHA1 Message Date
ranjl fe4d8f6d62 chore(admin): Update build configuration and ignore analysis reports
- Add report.html files to .gitignore to exclude analysis reports from version control
- Update build time configuration to include milliseconds for unique timestamps
- Add explanatory comment for build time millisecond precision
- Regenerate bundle analysis report with updated build configuration
2026-03-18 17:22:02 +08:00
ranjl 1c8035d935 feat(admin): 优化字典管理界面并更新构建报告
- 优化字典列表页面交互与展示逻辑
- 改进字典类型模态框组件功能
- 更新构建分析报告数据
2026-03-18 17:14:07 +08:00
ranjl 2fad025496 feat(admin): 新增品牌logo资源并优化用户界面组件
- 新增重庆、树人、文字logo资源(PNG和WebP格式)
- 新增用户界面背景图片资源(内容背景、对框、主页背景等)
- 优化ActionButton组件样式(自适应宽度、禁用状态样式)
- 更新CompetitionLayout组件header布局,集成新logo资源
- 优化TianZiGe组件功能和交互体验
- 更新竞赛相关页面组件(QuestionConfig、draw、QuestionRenderer等)
- 更新排名列表页面样式和功能
- 更新诗词常识题库数据
- 完善Competition类型定义
2026-03-18 16:58:13 +08:00
ranjl 02b4720b34 feat(admin): 优化用户界面组件并新增自定义按钮样式
- 新增按钮背景图片资源(btn-bg.png、progress-title.png、title-bg3.png、title-bg4.png)
- 扩展 ActionButton 组件支持自定义按钮类型,新增 custom 类型用于背景图片驱动的按钮
- 添加 btnBg 和 fontSize 属性到 ActionButton,支持动态背景和字体大小配置
- 重构 ActionButton 样式结构,分离 standard-btn 和 custom-btn 两种按钮样式
- 优化按钮禁用状态的样式处理和交互反馈
- 更新 CompetitionLayout 组件支持新的标题背景类型(titleBg3、titleBg4)
- 新增 titleTextColor 和 fontSize 属性到 CompetitionLayout,增强标题和按钮的定制化能力
- 更新所有用户页面组件(cover、draw、game、groups、rules、teams)以适配新的按钮和布局配置
2026-03-17 17:51:12 +08:00
ranjl 8964fcdea7 feat(admin): 优化用户界面资源并更新题库数据
- 添加首页背景图片资源 (home-bg.webp)
- 更新用户头像资源文件 (ydzx.png)
- 优化竞赛布局组件交互体验
- 改进题目渲染器显示效果
- 更新题库数据:新增诗词常识题库
- 移除联想对对碰题库
- 更新汉字听写、诗词理解选择题、逆向接诗句等题库内容
- 调整VSCode配置并禁用MCP代理功能
2026-03-17 15:28:11 +08:00
ranjl 771fb807eb feat(admin): 新增题目类型、优化题目库管理并添加拼音助手
- 新增6种题目类型枚举:逆向接诗句、场景猜诗句、联想对对碰、字词飞花令、诗词常识、换字组成语
- 新增QuestionDrawer组件,支持富文本编辑和拼音转换功能
- 优化CategoryTree组件,增加分类选择本地存储和自动填充题目类型
- 更新question API接口,简化新增和更新题目库的参数传递
- 扩展QuestionRenderer组件,支持新增的题目类型渲染
- 添加多个题目库JSON文件,包含成语、诗词常识、字词飞花令等题目数据
- 调整环境配置,更新开发环境API地址
2026-03-16 17:59:49 +08:00
ranjl 782c61a38c chore(admin): 优化PWA配置并更新资源文件
- 增强PWA开发环境支持,添加devOptions配置以便调试
- 优化Workbox缓存策略,排除大型SVG文件预缓存
- 添加运行时缓存规则支持SVG、图片等资源的CacheFirst策略
- 更新PWA manifest图标路径,使用public文件夹中的新图标资源
- 添加Service Worker注册文件和Workbox库文件
- 替换favicon.svg为favicon.ico,新增多种尺寸的PWA图标
- 生成PWA构建报告文件
- 优化manifest描述信息
2026-03-16 10:55:53 +08:00
ranjl 40a6850c3f chore(admin): 优化构建配置并升级资源格式
- 新增构建插件:打包分析、压缩、自动部署、图片优化和PWA支持
- 将SVG资源转换为WebP格式以提升加载性能
- 添加新的背景图片资源用于用户界面优化
- 更新环境配置:调整基础URL和服务地址
- 优化用户界面组件以适配新的资源格式
- 更新package.json依赖以支持新的构建功能
- 改进构建流程以支持自动部署和性能监控
2026-03-13 17:52:13 +08:00
ranjl 9d801ba6b4 refactor(admin): 移除待定排名页面并更新配置
- 删除待定排名页面及相关路由配置
- 更新环境变量配置,调整API地址和主页路由
- 添加vite-auto-deploy依赖和nginx配置文件
- 更新组件类型定义,移除未使用的组件
- 调整页面标题和图片资源
2026-03-13 09:39:49 +08:00
ranjl 11f448cedc feat(admin): 优化用户界面交互与组件功能
- 在抽题页面添加开始抽题按钮并绑定事件
- 调整队伍列表动画延迟和分组页面样式
- 修复结果发布条件逻辑,允许无结束时间时发布
- 增强TypeIt组件,支持动态配置和实例管理
- 更新Competition API类型定义,使用枚举类型
- 优化CompetitionLayout组件按钮显示时机
- 修正分析页面API调用参数类型
- 在拼写检查词典中添加Typeit
2026-03-12 14:33:30 +08:00
ranjl 41f7ba7b8a feat(admin): 优化用户界面动画与交互体验
- 调整倒计时音效触发时机为剩余5秒并添加新音频文件
- 为SvgIcon组件增加size属性以支持自定义图标尺寸
- 优化ActionButton组件的动画效果和视觉反馈
- 修复游戏页面组件卸载时未停止计时器的问题
- 重构音频控制器,改进音频预加载和播放逻辑
- 优化团队选择页面的卷轴动画和布局跳转逻辑
- 改进小组选择页面的视觉样式和响应式布局
- 增强CompetitionLayout组件的标题动画和内容显示逻辑
2026-03-11 17:58:57 +08:00
ranjl b9c5886437 feat: 优化竞赛流程并修复题目禁用功能
- 调整倒计时音效触发时间为剩余10秒,并更新音频文件
- 修复题目禁用功能,将IsDisabled类型从number改为boolean
- 优化抽题页面,替换为静态图片并禁用放大预览
- 为卡片添加3D悬停效果,提升交互体验
- 在结果发布前检查倒计时是否结束,防止提前发布
- 优化团队选择页面样式,更新图标和背景
- 修复Typewriter组件内容重复累加的问题
- 更新图表标签从"正确数量"改为"预估得分"
- 优化竞赛布局,统一"下一步"按钮文本
2026-03-10 17:55:45 +08:00
ranjl 11fb5481d3 feat(competition): 支持决赛环节并添加加时赛管理功能
- 扩展竞赛环节类型,支持决赛环节(RoundType=3)
- 新增加时赛创建功能,支持多分组队伍选择
- 添加题目库禁用/启用功能,支持批量操作
- 优化用户端竞赛流程,区分常规赛和加时赛
- 重构操作按钮组件,提高代码复用性
2026-03-09 17:34:40 +08:00
136 changed files with 11171 additions and 3739 deletions
+4
View File
@@ -7,6 +7,10 @@ dist-ssr
apps/*/dist
packages/*/dist
# --- 分析报告文件 ---
report.html
apps/*/report.html
# --- 日志文件 ---
logs
*.log
+4 -2
View File
@@ -39,6 +39,8 @@
"totvsLanguageServer.welcomePage": false,
"cSpell.words": [
"activityinfo",
"Echarts"
] // `
"Echarts",
"Typeit"
],
"kiroAgent.configureMCP": "Disabled" // `
}
+2 -2
View File
@@ -1,6 +1,6 @@
# 应用的基础URL,默认为 "/"
# 如果使用子目录,必须以 "/" 结尾,例如 "/admin/" 而不是 "/admin"
# VITE_BASE_URL='/admin'
VITE_BASE_URL='/'
# VITE_BASE_URL="https://api.qyzhjy.com"
@@ -28,7 +28,7 @@ VITE_AUTH_ROUTE_MODE=static
VITE_AUTH_ROUTE_FORCE_LOGIN=N
# 静态认证路由的主页
VITE_ROUTE_HOME=home
VITE_ROUTE_HOME=user_home
# 默认菜单图标
VITE_MENU_ICON=mdi:menu
+2 -2
View File
@@ -3,9 +3,9 @@
# 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=https://172.16.10.130:5000
VITE_SERVICE_BASE_URL=http://172.16.10.130:5000
VITE_OTHER_SERVICE_BASE_URL= `{
"demo": "http://localhost:9528"
+1 -1
View File
@@ -1,5 +1,5 @@
# backend service base url, prod environment
VITE_SERVICE_BASE_URL=https://mock.apifox.cn/m1/3109515-0-default
VITE_SERVICE_BASE_URL=http://api.ydzx.qyzhjy.com
# other backend service base url, prod environment
VITE_OTHER_SERVICE_BASE_URL= `{
+3 -1
View File
@@ -6,7 +6,9 @@ export function getBuildTime() {
dayjs.extend(utc)
dayjs.extend(timezone)
const buildTime = dayjs.tz(Date.now(), 'Asia/Shanghai').format('YYYY-MM-DD HH:mm:ss')
// 添加随机毫秒确保每次构建时间唯一
const now = Date.now()
const buildTime = dayjs.tz(now, 'Asia/Shanghai').format('YYYY-MM-DD HH:mm:ss.SSS')
return buildTime
}
+11
View File
@@ -0,0 +1,11 @@
import type { PluginOption } from 'vite'
import { visualizer } from 'rollup-plugin-visualizer'
export function setupAnalyzer() {
return visualizer({
open: true,
gzipSize: true,
brotliSize: true,
filename: 'report.html',
}) as PluginOption
}
+11
View File
@@ -0,0 +1,11 @@
import viteCompression from 'vite-plugin-compression'
export function setupCompression() {
return viteCompression({
verbose: true,
disable: false,
threshold: 10240,
algorithm: 'gzip',
ext: '.gz',
})
}
+21
View File
@@ -0,0 +1,21 @@
import { viteAutoDeployPlugin } from 'vite-auto-deploy'
import { name } from '../../package.json'
export function setupDeploy(isBuild: boolean) {
return viteAutoDeployPlugin({
/** 上传地址 */
uploadUrl: 'http://47.109.17.238:8200/api/upload/code/deploy',
/** 项目名称 (如果多个项目都是用的同上传地址时,服务端可用此字段区分是哪一个项目) */
projectKey: name,
/** 上传完成后,是否保留压缩包到本地 */
saveLocalZip: true,
/** 是否开启自动部署 */
autoDeploy: isBuild,
headers: {
'Custom-Timestamp': new Date().getTime().toString(),
'Custom-Run-Platform': 'app',
'Custom-Device-Id': Math.random().toString(36).substring(2),
'Custom-Os-Name': 'windows',
},
})
}
@@ -0,0 +1,51 @@
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
export function setupImageOptimizer() {
return ViteImageOptimizer({
logStats: true,
// SVG 优化配置
svg: {
multipass: true,
plugins: [
{
name: 'preset-default',
params: {
overrides: {
removeViewBox: false,
cleanupIds: false,
},
},
},
'sortAttrs',
],
},
// PNG 高质量压缩
png: {
quality: 90,
compressionLevel: 9,
},
// JPEG 优化
jpeg: {
quality: 85,
mozjpeg: true,
},
jpg: {
quality: 85,
mozjpeg: true,
},
// WebP 转换
webp: {
quality: 80,
lossless: false,
force: true,
},
includePublic: true,
cache: true,
cacheLocation: 'node_modules/.cache/image-optimizer',
})
}
+18 -1
View File
@@ -2,14 +2,20 @@ import type { PluginOption } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import progress from 'vite-plugin-progress'
import { setupAnalyzer } from './analyzer'
import { setupCompression } from './compression'
import { setupDeploy } from './deploy'
import { setupDevtoolsPlugin } from './devtools'
import { setupHtmlPlugin } from './html'
import { setupImageOptimizer } from './image-optimizer'
import { setupPWA } from './pwa'
import { setupElegantRouter } from './router'
import { setupUnocss } from './unocss'
import { setupUnplugin } from './unplugin'
export function setupVitePlugins(viteEnv: Env.ImportMeta, buildTime: string) {
export function setupVitePlugins(viteEnv: Env.ImportMeta, buildTime: string, isBuild: boolean) {
const plugins: PluginOption = [
// 基础插件
vue(),
vueJsx(),
setupDevtoolsPlugin(viteEnv),
@@ -18,7 +24,18 @@ export function setupVitePlugins(viteEnv: Env.ImportMeta, buildTime: string) {
...setupUnplugin(viteEnv),
progress(),
setupHtmlPlugin(buildTime),
// 优化插件
setupImageOptimizer(),
setupCompression(),
setupPWA(),
setupDeploy(isBuild),
]
// 构建时添加打包分析插件
if (isBuild) {
plugins.push(setupAnalyzer())
}
return plugins
}
+76
View File
@@ -0,0 +1,76 @@
import type { PluginOption } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
export function setupPWA(): PluginOption {
return VitePWA({
registerType: 'autoUpdate',
// 添加 devOptions 以便在开发环境调试
devOptions: {
enabled: false, // 开发环境启用
type: 'module',
},
workbox: {
// 排除大型 SVG 文件,避免预缓存
globIgnores: [
'**/team-title-bg*.svg',
'**/cover-bg-active*.svg',
'**/*-bg-animation*.svg',
],
globPatterns: ['**/*.{js,css,html,ico,png,webp,jpg,jpeg}', '**/*.svg'],
runtimeCaching: [
// 字体缓存
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year
},
},
},
// 大型 SVG 文件运行时缓存
{
urlPattern: /.*\.(svg)$/,
handler: 'CacheFirst',
options: {
cacheName: 'svg-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days
},
},
},
// 图片资源缓存
{
urlPattern: /.*\.(png|jpg|jpeg|webp|gif)$/,
handler: 'CacheFirst',
options: {
cacheName: 'images-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days
},
},
},
],
},
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'masked-icon.svg'],
manifest: {
name: '阅读之星竞赛系统',
short_name: '阅读之星',
description: '树人研究院阅读之星竞赛系统',
theme_color: '#ffffff',
background_color: '#ffffff',
display: 'standalone',
orientation: 'landscape',
icons: [
{ src: 'images/pwa_logo_192.png', sizes: '192x192', type: 'image/png' },
{ src: 'images/pwa_logo_512.png', sizes: '512x512', type: 'image/png' },
{ src: 'images/pwa_logo_512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: 'images/pwa_logo_512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
},
})
}
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="zh-cmn-Hans">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>%VITE_APP_TITLE%</title>
+34
View File
@@ -0,0 +1,34 @@
server {
listen 80;
server_name localhost;
# 设置根目录为打包后的 dist 文件夹
root /usr/share/nginx/html;
index index.html;
# 开启 gzip 压缩
gzip on;
gzip_min_length 1k;
gzip_comp_level 6;
gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png;
gzip_vary on;
gzip_disable "MSIE [1-6]\.";
# 核心配置:处理 SPA 路由 fallback
location / {
try_files $uri $uri/ /index.html;
}
# 静态资源缓存配置(可选)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 30d;
add_header Cache-Control "public, no-transform";
}
# 代理接口配置(如果需要)
# location /api/ {
# proxy_pass http://backend-api.com/;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# }
}
+6 -1
View File
@@ -16,7 +16,7 @@
"dev": "vite --mode dev",
"dev:prod": "vite --mode prod",
"gen-route": "sa gen-route",
"lint": "eslint . --fix",
"lint": "eslint src --fix",
"prepare": "simple-git-hooks",
"preview": "vite preview",
"release": "sa release",
@@ -82,6 +82,7 @@
"eslint": "9.39.2",
"eslint-plugin-vue": "10.6.2",
"kolorist": "1.8.0",
"rollup-plugin-visualizer": "^7.0.1",
"sass": "1.97.1",
"simple-git-hooks": "2.13.1",
"tsx": "4.21.0",
@@ -89,7 +90,11 @@
"unplugin-icons": "22.5.0",
"unplugin-vue-components": "30.0.0",
"vite": "7.3.0",
"vite-auto-deploy": "^2.0.34",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-image-optimizer": "^2.0.3",
"vite-plugin-progress": "0.0.7",
"vite-plugin-pwa": "^1.2.0",
"vite-plugin-svg-icons": "2.0.1",
"vite-plugin-vue-devtools": "8.0.5",
"vue-eslint-parser": "10.2.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

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

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 487 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 487 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 740 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 790 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 797 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 811 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

@@ -10,6 +10,16 @@
<rect x="-406.921" y="-212.194" width="1977.2" height="1318.13" fill="url(#pattern0_1099_5207)"/>
</g>
<g filter="url(#filter0_d_1099_5207)">
<animateTransform
attributeName="transform"
type="translate"
values="0 0; 0 -15; 0 0"
dur="4s"
repeatCount="indefinite"
calcMode="spline"
keyTimes="0; 0.5; 1"
keySplines="0.45 0 0.55 1; 0.45 0 0.55 1"
/>
<rect x="-66" y="-25" width="1364.67" height="806" fill="url(#pattern1_1099_5207)" shape-rendering="crispEdges"/>
</g>
</g>

Before

Width:  |  Height:  |  Size: 16 MiB

After

Width:  |  Height:  |  Size: 16 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 907 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 907 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Before

Width:  |  Height:  |  Size: 468 KiB

After

Width:  |  Height:  |  Size: 468 KiB

@@ -17,6 +17,8 @@ interface Props {
icon?: string
/** 本地 svg 图标名称 */
localIcon?: string
/** 图标大小 */
size?: string | number
}
const attrs = useAttrs()
@@ -44,13 +46,13 @@ const renderLocalIcon = computed(() => props.localIcon || !props.icon)
<template>
<!-- 渲染本地 svg 图标 -->
<template v-if="renderLocalIcon">
<svg aria-hidden="true" width="1em" height="1em" v-bind="bindAttrs">
<svg aria-hidden="true" :width="size || '1em'" :height="size || '1em'" v-bind="bindAttrs">
<use :xlink:href="symbolId" fill="currentColor" />
</svg>
</template>
<template v-else>
<!-- 渲染 iconify 图标 -->
<Icon v-if="icon" :icon="icon" v-bind="bindAttrs" />
<Icon v-if="icon" :icon="icon" :width="size" :height="size" v-bind="bindAttrs" />
</template>
</template>
@@ -2,44 +2,84 @@
import type { Options } from 'typeit'
import type { El } from 'typeit/dist/types'
import TypeIt from 'typeit'
import { onMounted, shallowRef } from 'vue'
import { onBeforeUnmount, onMounted, shallowRef, watch } from 'vue'
defineOptions({ name: 'TypeIt' })
const props = withDefaults(defineProps<Props>(), {
speed: 100,
loop: false,
cursor: true,
lifeLike: true,
strings: () => [],
})
interface Props {
/** 打字速度 */
speed?: number
/** 是否循环 */
loop?: boolean
/** 是否显示光标 */
cursor?: boolean
/** 打字内容 */
strings?: string | string[]
/** 像人一样打字 */
lifeLike?: boolean
/** 开始前的延迟 */
startDelay?: number
/** 下一段文字的延迟 */
nextStringDelay?: number
/** 循环延迟 */
loopDelay?: number
/** 兼容旧代码或用户自定义延迟参数 */
typeDelay?: number
}
const textRef = shallowRef<El>()
const typeItInstance = shallowRef<TypeIt | null>(null)
function init() {
if (!textRef.value)
return
const options: Options = {
strings: 'SoybeanAdmin是一个清新优雅、高颜值且功能强大的后台管理模板',
lifeLike: true,
speed: 120,
loop: true,
if (typeItInstance.value) {
typeItInstance.value.destroy()
}
const initTypeIt = new TypeIt(textRef.value, options)
// 清空内容,防止重叠
textRef.value.textContent = ''
initTypeIt.go()
const options: Options = {
strings: props.strings,
lifeLike: props.lifeLike,
speed: props.speed,
loop: props.loop,
cursor: props.cursor,
startDelay: props.startDelay,
nextStringDelay: props.nextStringDelay ?? props.typeDelay,
loopDelay: props.loopDelay,
}
typeItInstance.value = new TypeIt(textRef.value, options).go()
}
onMounted(() => {
init()
})
onBeforeUnmount(() => {
if (typeItInstance.value) {
typeItInstance.value.destroy()
}
})
watch(() => [props.strings, props.speed, props.loop], () => {
init()
})
</script>
<template>
<div>
<NCard title="打字机 插件" :bordered="false" class="h-full card-wrapper">
<NSpace :vertical="true">
<GithubLink link="https://github.com/alexmacarthur/typeit" />
<WebSiteLink label="文档地址:" link="https://www.typeitjs.com/docs/vanilla/usage/" />
</NSpace>
<NDivider title-placement="left">
基本示例
</NDivider>
<span ref="textRef" class="text-18px" />
</NCard>
</div>
<span ref="textRef" class="typeit-container" />
</template>
<style scoped></style>
@@ -0,0 +1,161 @@
<script setup lang="ts">
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' | 'custom',
default: 'button',
},
btnBg: {
type: String,
default: defaultBtnBg,
},
fontSize: {
type: Number,
default: 30,
},
})
const emits = defineEmits(['click'])
const isCustom = computed(() => props.type === 'custom')
const customBg = computed(() => `url(${props.btnBg}) no-repeat center / 100% 100%`)
function handleClick() {
if (!props.disabled) {
emits('click')
}
}
</script>
<template>
<button
class="action-btn"
:class="[
isCustom ? 'custom-btn' : 'standard-btn',
theme,
type,
{ 'is-disabled': disabled },
]"
@click="handleClick"
>
<span v-if="type === 'text' || type === 'custom'" class="btn-text">{{ text }}</span>
<slot v-else />
</button>
</template>
<style lang="scss" scoped>
.action-btn {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
border: none;
outline: none;
font-family: inherit;
&.is-disabled {
cursor: not-allowed;
filter: grayscale(1);
opacity: 0.7;
&:hover,
&:active {
transform: none !important;
box-shadow: inherit !important;
}
}
}
// standard: type="button" | type="text"
.standard-btn {
width: 50px;
height: 50px;
border-radius: 50%;
color: white;
background: #eb5e55;
box-shadow: 0 8px 0 #fcc64f;
&:hover {
transform: translateY(-2px);
filter: brightness(1.05);
box-shadow: 0 10px 0 #fcc64f;
}
&:active {
transform: translateY(4px);
box-shadow: 0 2px 0 #fcc64f;
}
&.light {
background: white;
color: #eb5e55;
border: 2px solid #eb5e55;
&:hover {
background-color: #fff;
}
}
&.text {
width: auto;
min-width: 180px;
padding: 0 30px;
border-radius: 40px;
.btn-text {
font-size: 30px;
font-weight: bold;
letter-spacing: 2px;
}
}
}
// custom: type="custom",背景完全由 btnBg 决定
.custom-btn {
background: v-bind(customBg);
background-color: transparent;
min-width: 210px;
width: auto;
height: 60px;
padding: 0 20px;
box-shadow: none;
color: rgb(0, 94, 174);
.btn-text {
font-size: v-bind('`${fontSize}px`');
font-weight: bold;
letter-spacing: 2px;
margin-bottom: 7px;
}
&.is-disabled {
color: #eb5e55;
filter: none;
opacity: 1;
}
&:hover {
transform: scale(1.05);
}
&:active {
transform: scale(0.95);
}
}
</style>
@@ -1,11 +1,19 @@
<!-- 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'
interface Props {
/** 是否显示返回按钮 */
showBack?: boolean
@@ -27,8 +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>(), {
@@ -43,21 +59,31 @@ const props = withDefaults(defineProps<Props>(), {
btnTheme: 'primary',
nextDisabled: false,
titleBgType: 1,
showNextBtn2: false,
nextBtnText2: undefined,
titleTextColor: '#000000',
fontSize: 30,
})
const emit = defineEmits<{
(e: 'back'): void
(e: 'next'): void
(e: 'next2'): void
}>()
const titleBgUrlMapping: Record<number, string> = {
1: _defaultTitleBg,
2: titleBg2,
3: titleBg3,
4: titleBg4,
}
const { routerPushByKey } = useRouterPush()
const titleBgUrl = computed(() => titleBgUrlMapping[props.titleBgType] || _defaultBg)
const titleBgLoaded = ref(false)
const currentTitleBg = ref('')
const imageLoadStatus = ref<Record<string, boolean>>({})
// const router = useRouter()
const innerBgUrl = ref(_defaultBg)
@@ -78,6 +104,8 @@ async function fetchSystemConfig() {
}
const showContent = ref(false)
const showSlotContent = ref(false)
const showButton = ref(false)
function handleBack() {
emit('back')
@@ -89,16 +117,105 @@ function handleNext() {
emit('next')
}
function handleNext2() {
emit('next2')
}
/** 点击返回首页 */
function handleBackHome() {
routerPushByKey('user_home')
}
onMounted(() => {
fetchSystemConfig()
// 预加载所有标题背景图,并跟踪加载状态
const preloadAllImages = () => {
const promises = Object.entries(titleBgUrlMapping).map(([_key, url]) => {
return new Promise((resolve) => {
const img = new Image()
img.onload = () => {
imageLoadStatus.value[url] = true
resolve(true)
}
img.onerror = () => {
imageLoadStatus.value[url] = false
resolve(false)
}
img.src = url
})
})
// 也预加载默认背景
const defaultBgPromise = new Promise((resolve) => {
const img = new Image()
img.onload = () => {
imageLoadStatus.value[_defaultBg] = true
resolve(true)
}
img.onerror = () => {
imageLoadStatus.value[_defaultBg] = false
resolve(false)
}
img.src = _defaultBg
})
return Promise.all([...promises, defaultBgPromise])
}
// 开始预加载
preloadAllImages()
.then(() => {
// 所有图片预加载完成后,设置当前背景
currentTitleBg.value = titleBgUrl.value
titleBgLoaded.value = true
fetchSystemConfig()
setTimeout(() => {
showContent.value = true
}, 100)
})
.catch(() => {
// 即使预加载失败也继续
currentTitleBg.value = titleBgUrl.value
titleBgLoaded.value = true
fetchSystemConfig()
setTimeout(() => {
showContent.value = true
}, 100)
})
// 延迟显示内容,等待标题卷轴展开
const delay = props.showTitle ? 500 : 100
setTimeout(() => {
showContent.value = true
}, 100)
showSlotContent.value = true
}, delay)
// 延迟显示按钮,等待内容显示
setTimeout(() => {
showButton.value = true
}, delay + 500)
})
// 监听 titleBgUrl 变化,确保图片切换平滑
watch(titleBgUrl, (newUrl) => {
if (newUrl && imageLoadStatus.value[newUrl]) {
// 如果图片已经加载过,直接切换
currentTitleBg.value = newUrl
}
else if (newUrl) {
// 如果图片还没加载,预加载它
const img = new Image()
img.onload = () => {
imageLoadStatus.value[newUrl] = true
currentTitleBg.value = newUrl
}
img.onerror = () => {
imageLoadStatus.value[newUrl] = false
// 加载失败时使用默认背景
currentTitleBg.value = _defaultBg
}
img.src = newUrl
}
})
</script>
@@ -107,23 +224,45 @@ 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="flex cursor-pointer items-center" @click="handleBackHome">
<img :src="cqLogo" alt="重庆logo" class="h-20 w-auto">
</div>
<!-- 中间文字 -->
<div class="flex flex-1 justify-center">
<img :src="qwLogo" alt="重庆logo" class="h-14 w-auto">
</div>
<!-- 右侧logo组 -->
<div class="flex items-center justify-end gap-4">
<img :src="srLogo" alt="树人logo" class="h-19 w-auto">
</div>
</header>
<div class="glass-overlay">
<!-- title -->
<section v-if="showTitle" class="title-section">
<Transition name="fade-slide-down" appear>
<div v-if="showContent" class="title-section">
<div class="scroll-bg" :style="{ backgroundImage: `url(${titleBgUrl})` }">
<h1 class="main-title">
{{ title }}
</h1>
<Transition name="scroll-unfold" appear>
<div v-if="showContent" class="title-wrapper">
<div class="scroll-bg">
<!-- 预渲染所有背景图 opacity 切换避免加载抖动 -->
<img
v-for="(url, _key) in titleBgUrlMapping" :key="_key" :src="url" class="scroll-bg-img" :class="{
active: titleBgUrl === url && imageLoadStatus[url] !== false,
loading: imageLoadStatus[url] === undefined,
}" :style="{ visibility: imageLoadStatus[url] === false ? 'hidden' : 'visible' }" aria-hidden="true"
@load="imageLoadStatus[url] = true" @error="imageLoadStatus[url] = false"
>
<Transition name="fade-in-delay" appear>
<h1 v-if="showContent" class="main-title" :style="{ color: titleTextColor }">
{{ title }}
</h1>
</Transition>
</div>
</div>
</Transition>
@@ -131,29 +270,40 @@ onMounted(() => {
<!-- Main Content Slot -->
<main class="main-content">
<slot />
<div v-if="showSlotContent" class="h-full w-full flex flex-col items-center justify-center">
<slot />
</div>
</main>
<!-- Footer Action -->
<footer
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]"
>
<button
class="action-btn back-btn" :class="{ 'text-btn': backBtnText }"
:style="{ visibility: showBack ? 'visible' : 'hidden' }" @click="handleBack"
>
<span v-if="backBtnText" class="btn-text">{{ backBtnText }}</span>
<SvgIcon v-else icon="mdi:arrow-left" class="arrow-icon" />
</button>
<div class="left">
<ActionButton
class="back-btn" :style="{ visibility: showBack ? 'visible' : 'hidden' }" type="custom"
:text="backBtnText || ''" :theme="btnTheme" :font-size="fontSize" @click="handleBack"
>
<SvgIcon v-if="!backBtnText" icon="mdi:arrow-left" size="40" class="arrow-icon" />
</ActionButton>
</div>
<button
v-if="showNext" class="action-btn next-btn text-btn" :class="{ 'is-disabled': nextDisabled }"
@click="handleNext"
>
<span class="btn-text">{{ nextBtnText || '下一步' }}</span>
<!-- <SvgIcon v-else icon="mdi:arrow-right" class="arrow-icon" /> -->
</button>
<div class="right">
<ActionButton
v-if="showNext" type="custom" :text="nextBtnText || '下一步'" :theme="btnTheme"
:disabled="nextDisabled" :font-size="fontSize" @click="handleNext"
>
<SvgIcon v-if="!nextBtnText" icon="mdi:arrow-right" class="arrow-icon" size="40" />
</ActionButton>
<ActionButton
v-if="showNextBtn2" :type="nextBtnText2 ? 'text' : 'button'" :text="nextBtnText2 || '下一步'"
:theme="btnTheme" :disabled="nextDisabled" @click="handleNext2"
>
<SvgIcon v-if="!nextBtnText2" icon="mdi:arrow-right" class="arrow-icon" size="40" />
</ActionButton>
</div>
</footer>
</div>
</div>
@@ -166,24 +316,62 @@ onMounted(() => {
margin: 10px auto;
width: 412px;
height: 165px;
display: flex;
justify-content: center;
/* Ensure wrapper stays centered during expansion */
.scroll-bg {
.title-wrapper {
width: 100%;
height: 100%;
overflow: hidden;
display: flex;
justify-content: center;
/* Keep the background image centered */
}
.scroll-bg {
width: 400px !important;
/* Fixed width to prevent squishing during animation */
height: 155px !important;
position: relative;
background: no-repeat center center;
background-size: 100% 100%;
display: flex;
align-items: center;
justify-content: center;
padding-bottom: 10px; // 微调垂直位置,因为卷轴可能有视觉重心偏移
padding-bottom: 10px;
flex-shrink: 0;
/* Prevent shrinking */
.scroll-bg-img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: contain;
opacity: 0;
transition: opacity 0.5s ease;
pointer-events: none;
&.active {
opacity: 1;
}
&.loading {
opacity: 0;
filter: blur(2px);
}
}
.main-title {
font-size: 2.6rem;
font-size: 2.5rem;
color: $text-color;
margin: 0;
letter-spacing: 4px;
text-align: center;
white-space: nowrap;
/* Prevent text wrapping */
position: relative;
z-index: 1;
}
}
}
@@ -219,9 +407,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;
@@ -240,7 +430,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);
@@ -254,7 +444,7 @@ onMounted(() => {
.page-header {
position: absolute;
top: 0;
top: -20px;
left: 0;
width: 100%;
display: flex;
@@ -280,105 +470,42 @@ onMounted(() => {
.page-footer {
position: absolute;
bottom: 40px;
right: 0; // 默认在右下角
width: 100%;
display: flex;
justify-content: space-between;
padding: 0 20px;
z-index: 20;
.right {
display: flex;
gap: 10px;
}
&.top {
bottom: auto;
top: 40px;
}
.action-btn {
width: 50px;
height: 50px;
border-radius: 50%;
border: none;
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.1s ease;
background: #eb5e55;
box-shadow: 0 8px 0 #fcc64f;
.arrow-icon {
font-size: 32px;
}
&:hover {
transform: translateY(-2px);
filter: brightness(1.05);
}
&:active {
transform: translateY(4px);
box-shadow: 0 2px 0 #fcc64f;
}
// 文字按钮通用样式
&.text-btn {
width: auto;
min-width: 180px;
padding: 0 30px;
border-radius: 40px;
.btn-text {
font-size: 24px;
font-weight: bold;
letter-spacing: 2px;
}
}
&.next-btn {
// 保留原有 next-btn 特定逻辑(如果有)
}
&.back-btn {
// 保持统一风格
}
// 禁用状态
&.is-disabled {
cursor: not-allowed;
// opacity: 0.8; // 移除透明度变化
// filter: grayscale(0.5); // 移除置灰效果
// box-shadow: none; // 保持阴影,或者根据需要调整
&:hover {
transform: none; // 禁止 hover 位移
// filter: grayscale(0.5);
}
&:active {
transform: none;
// box-shadow: none; // 保持阴影
}
}
}
// Light Theme Style (White bg, Red text/border, Yellow shadow)
&.light {
.action-btn {
background: white;
color: #eb5e55;
border: 2px solid #eb5e55;
box-shadow: 0 8px 0 #fcc64f; // 还原黄色立体阴影
&:hover {
background-color: #fff; // 保持白色
transform: translateY(-2px);
}
&:active {
transform: translateY(4px);
box-shadow: 0 2px 0 #fcc64f;
}
}
}
}
// Transitions
.scroll-unfold-enter-active {
transition: width 1.5s cubic-bezier(0.25, 1, 0.5, 1);
}
.scroll-unfold-enter-from {
width: 0 !important;
}
.fade-in-delay-enter-active {
transition: opacity 0.8s ease;
transition-delay: 1s;
/* Wait for scroll to unfold */
}
.fade-in-delay-enter-from {
opacity: 0;
}
.fade-slide-down-enter-active,
.fade-slide-down-leave-active {
transition: all 0.8s ease;
@@ -43,6 +43,3 @@ const _props = withDefaults(defineProps<Props>(), {
<slot />
</div>
</template>
<style scoped>
</style>
+13 -7
View File
@@ -21,11 +21,17 @@ export enum QuestionType {
}
export enum QuestionCategoryEnum {
ChineseCharacterDictation1 = 'ChineseCharacterDictation1', // 汉字听写-提示 (jiu表示小鸟的叫声)
ChineseCharacterDictation2 = 'ChineseCharacterDictation2', // 根据拼音写汉字-提示 (tang)
CharacterRadicalAddition = 'CharacterRadicalAddition', // 汉字加一加 (车)
WordDictation = 'WordDictation', // 词语听写 (zhi re)
IdiomWriting1 = 'IdiomWriting1', // 成语-文字要求 (反义字)
IdiomWriting2 = 'IdiomWriting2', // 成语-文字要求 (看图写成语)
PoetryComprehension = 'PoetryComprehension', // 诗词理解-选择题
ChineseCharacterDictation1 = 'ChineseCharacterDictation1', // 1. 汉字听写-提示 (jiu表示小鸟的叫声)
ChineseCharacterDictation2 = 'ChineseCharacterDictation2', // 2. 根据拼音写汉字-提示 (tang)
CharacterRadicalAddition = 'CharacterRadicalAddition', // 3. 汉字加一加 (车)
WordDictation = 'WordDictation', // 4. 词语听写 (zhi re)
IdiomWriting1 = 'IdiomWriting1', // 5. 成语-文字要求
IdiomWriting2 = 'IdiomWriting2', // 6. 成语-文字要求 (看图写成语)
PoetryComprehension = 'PoetryComprehension', // 7. 诗词理解-选择题
ReversePoemMatching = 'ReversePoemMatching', // 8. 逆向接诗句
SceneBasedPoemGuessing = 'SceneBasedPoemGuessing', // 9. 场景猜诗句
AssociationMatching = 'AssociationMatching', // 10. 联想对对碰
CharacterWordFlowerDrinkingGame = 'CharacterWordFlowerDrinkingGame', // 11. 字词飞花令
PoetryCommonKnowledge = 'PoetryCommonKnowledge', // 12. 诗词常识
ChangeWords = 'ChangeWords', // 13. 换字组成语
}
+8 -2
View File
@@ -25,7 +25,7 @@ export function useRouterPush(inSetup = true) {
* @param key 路由名称
* @param options 路由参数
*/
async function routerPushByKey(key: RouteKey, options?: App.Global.RouterPushOptions) {
async function routerPushByKey(key: RouteKey, options?: App.Global.RouterPushOptions, newWindow?: boolean) {
const { query, params } = options || {}
const routeLocation: RouteLocationRaw = {
@@ -40,7 +40,13 @@ export function useRouterPush(inSetup = true) {
routeLocation.params = params
}
return routerPush(routeLocation)
if (newWindow) {
const routeData = router.resolve(routeLocation)
window.open(routeData.href, '_blank')
}
else {
return routerPush(routeLocation)
}
}
/**
-1
View File
@@ -40,7 +40,6 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
user_groups: () => import("@/views/user/groups/index.vue"),
user_home: () => import("@/views/user/home/index.vue"),
"user_rank-list": () => import("@/views/user/rank-list/index.vue"),
"user_rank-pending": () => import("@/views/user/rank-pending/index.vue"),
user_rules: () => import("@/views/user/rules/index.vue"),
user_teams: () => import("@/views/user/teams/index.vue"),
};
+5 -11
View File
@@ -249,8 +249,11 @@ export const generatedRoutes: GeneratedRoute[] = [
path: '/test',
component: 'layout.base$view.test',
meta: {
title: 'test',
i18nKey: 'route.test'
title: '测试',
i18nKey: 'route.test',
icon: 'material-symbols:settings',
order: 7,
hideInMenu: true
}
},
{
@@ -340,15 +343,6 @@ export const generatedRoutes: GeneratedRoute[] = [
i18nKey: 'route.user_rank-list'
}
},
{
name: 'user_rank-pending',
path: '/user/rank-pending',
component: 'view.user_rank-pending',
meta: {
title: 'user_rank-pending',
i18nKey: 'route.user_rank-pending'
}
},
{
name: 'user_rules',
path: '/user/rules',
@@ -193,7 +193,6 @@ const routeMap: RouteMap = {
"user_groups": "/user/groups",
"user_home": "/user/home",
"user_rank-list": "/user/rank-list",
"user_rank-pending": "/user/rank-pending",
"user_rules": "/user/rules",
"user_teams": "/user/teams"
};
+2 -2
View File
@@ -58,9 +58,9 @@ export function fetchCreateQuestion(data: Api.Competition.CreateQuestionRequest[
}
/** 根据活动ID获取活动题目列表 */
export function fetchGetQuestionList(id: number) {
export function fetchGetQuestionList(id: number, roundType = 0) {
return request<App.Service.Response<Api.Competition.QuestionListRecord[]>>({
url: `/Base/ActivityMain/GetActivity_QuestionByActivityID?ID=${id}`,
url: `/Base/ActivityMain/GetActivity_QuestionByActivityID?ID=${id}&RoundType=${roundType}`,
method: 'get',
})
}
+11 -2
View File
@@ -78,9 +78,9 @@ export function fetchCheckNextStep(TeamGroup_QuestionID: number) {
}
/** 根据活动id查询题目列表 */
export function fetchGetQuestionTableByActivityID(ActivityID: string) {
export function fetchGetQuestionTableByActivityID(ActivityID: string, RoundType: number = 0) {
return request<App.Service.Response<Api.Competition.QuestionListDetailRound[]>>({
url: `/Base/ActivityMain/GetActivity_QuestionTableByActivityID/?ID=${ActivityID}`,
url: `/Base/ActivityMain/GetActivity_QuestionTableByActivityID/?ID=${ActivityID}&RoundType=${RoundType}`,
method: 'get',
})
}
@@ -103,3 +103,12 @@ export function fetchGetQuestionDetailByID(QuestionDetailID: number) {
method: 'get',
})
}
/** 加时赛选择组小队 */
export function fetchAddExtraTime(data: Api.Competition.AddExtraTimeParams) {
return request<App.Service.Response<any>>({
url: `/Base/ActivityMain/AddOverTimeTeamGroup`,
method: 'post',
data,
})
}
+13 -44
View File
@@ -49,60 +49,29 @@ 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,
},
data: params,
})
}
/** 禁用/启用 一条问题库 */
export function fetchDisableQuestionLibrary(params: Api.Question.DisableQuestionLibraryParams[]) {
return request<Api.Question.CommonRecord>({
url: '/Base/ActivityMain/UpdateQuestionListDetailList',
method: 'post',
data: params,
})
}
/** 更新一条问题库 */
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,
},
data: params,
})
}
@@ -93,9 +93,9 @@ export const useCompetitionStore = defineStore('competition', () => {
timerInterval.value = window.setInterval(() => {
if (timeLeft.value > 0) {
timeLeft.value--
// 剩余5秒播放倒计时音效
if (timeLeft.value <= 5) {
audioController.play('tick')
// 5秒播放倒计时音效
if (timeLeft.value === 5) {
audioController.play('boy5')
}
}
else {
+18 -2
View File
@@ -52,7 +52,7 @@ declare namespace Api {
type QuestionScoreType = 0 | 1 /** 固定分数 | 答题个数 */
/** competition round type */
type CompetitionRoundType = 0 | 1 /** 题包环节 | 加时环节 */
type CompetitionRoundType = 0 | 1 | 3 /** 题包环节 | 加时环节 | 决赛环节 */
/**
* competition publish status
@@ -106,6 +106,8 @@ declare namespace Api {
Image: string
ImageUrl: string
Answer: string
KeyWordsIndex?: number
KeyWord?: string
}
/** get current question params */
@@ -329,7 +331,7 @@ declare namespace Api {
/** 答题时间(秒) */
QuestionTime: number
/** 赛题包类型 */
RoundType: number
RoundType: CompetitionRoundType
/** 题目模板 id */
TemplateID: number
/** UI 类型 */
@@ -351,6 +353,7 @@ declare namespace Api {
id: number
/** 分组名称(兼容字段) */
name: string
RoundType: number
}
interface CreateQuestionRequest {
@@ -380,6 +383,19 @@ declare namespace Api {
roundType: number
}
interface AddExtraTimeParams extends Array<ExtraTimeItem> {}
interface ExtraTimeItem {
GroupName: string
ActivityID: number
TeamInfos: TeamInfo[]
}
interface TeamInfo {
TeamID: number
TeamName: string
}
/** common record */
type CommonRecord<T = any> = {
/** record id */
+21 -1
View File
@@ -20,6 +20,14 @@ declare namespace Api {
data: T[]
}
/** disable question library params */
interface DisableQuestionLibraryParams {
/** question library id */
Id: number
/** is disabled 0: no 1: yes */
IsDisabled: boolean
}
/** get question library list all params */
interface GetQuestionLibraryListAllParams {
/** question id */
@@ -57,9 +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 */
+2 -92
View File
@@ -12,6 +12,7 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ActionButton: typeof import('./../components/custom/user/ActionButton.vue')['default']
AppProvider: typeof import('./../components/common/app-provider.vue')['default']
BetterScroll: typeof import('./../components/custom/better-scroll.vue')['default']
ButtonIcon: typeof import('./../components/custom/button-icon.vue')['default']
@@ -30,8 +31,6 @@ declare module 'vue' {
'IconIc:roundPublish': typeof import('~icons/ic/round-publish')['default']
'IconIc:roundStopCircle': typeof import('~icons/ic/round-stop-circle')['default']
'IconIc:twotoneRule': typeof import('~icons/ic/twotone-rule')['default']
IconIcBaselineAdd: typeof import('~icons/ic/baseline-add')['default']
IconIcBaselineAddPlus: typeof import('~icons/ic/baseline-add-plus')['default']
IconIcBaselineArrowBack: typeof import('~icons/ic/baseline-arrow-back')['default']
IconIcBaselineArrowForward: typeof import('~icons/ic/baseline-arrow-forward')['default']
IconIcBaselineCalendarMonth: typeof import('~icons/ic/baseline-calendar-month')['default']
@@ -40,60 +39,37 @@ declare module 'vue' {
IconIcBaselineContentCopy: typeof import('~icons/ic/baseline-content-copy')['default']
IconIcBaselineDelete: typeof import('~icons/ic/baseline-delete')['default']
IconIcBaselineDownload: typeof import('~icons/ic/baseline-download')['default']
IconIcBaselineEdit: typeof import('~icons/ic/baseline-edit')['default']
IconIcBaselineFlash: typeof import('~icons/ic/baseline-flash')['default']
IconIcBaselineGroup: typeof import('~icons/ic/baseline-group')['default']
IconIcBaselineRefresh: typeof import('~icons/ic/baseline-refresh')['default']
IconIcBaselineRocketLaunch: typeof import('~icons/ic/baseline-rocket-launch')['default']
IconIcBaselineSave: typeof import('~icons/ic/baseline-save')['default']
IconIcBaselineScan: typeof import('~icons/ic/baseline-scan')['default']
IconIcBaselineSettingsBackupRestore: typeof import('~icons/ic/baseline-settings-backup-restore')['default']
IconIcBaselineUpload: typeof import('~icons/ic/baseline-upload')['default']
IconIcBaselineUploadFile: typeof import('~icons/ic/baseline-upload-file')['default']
IconIcCarbonUserAvatarFilledAlt: typeof import('~icons/ic/carbon-user-avatar-filled-alt')['default']
IconIcOutlineAccessTime: typeof import('~icons/ic/outline-access-time')['default']
IconIcOutlineAddToPhotos: typeof import('~icons/ic/outline-add-to-photos')['default']
IconIcOutlineArchive: typeof import('~icons/ic/outline-archive')['default']
IconIcOutlineAttachMoney: typeof import('~icons/ic/outline-attach-money')['default']
IconIcOutlineDelete: typeof import('~icons/ic/outline-delete')['default']
IconIcOutlineFolderOff: typeof import('~icons/ic/outline-folder-off')['default']
IconIcOutlineFormatListNumbered: typeof import('~icons/ic/outline-format-list-numbered')['default']
IconIcOutlineTimer: typeof import('~icons/ic/outline-timer')['default']
IconIcOutlineTitle: typeof import('~icons/ic/outline-title')['default']
IconIcRoundAccessTime: typeof import('~icons/ic/round-access-time')['default']
IconIcRoundArrowBack: typeof import('~icons/ic/round-arrow-back')['default']
IconIcRoundArrowForward: typeof import('~icons/ic/round-arrow-forward')['default']
IconIcRoundArrowLeft: typeof import('~icons/ic/round-arrow-left')['default']
IconIcRoundArrowRight: typeof import('~icons/ic/round-arrow-right')['default']
IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
IconIcRoundLibraryAdd: typeof import('~icons/ic/round-library-add')['default']
IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default']
IconIcRoundSearch: typeof import('~icons/ic/round-search')['default']
IconIcRoundStarBorder: typeof import('~icons/ic/round-star-border')['default']
IconIcRoundUpload: typeof import('~icons/ic/round-upload')['default']
IconLocalBanner: typeof import('~icons/local/banner')['default']
IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
IconMdiArrowUpThin: typeof import('~icons/mdi/arrow-up-thin')['default']
IconMdiDelete: typeof import('~icons/mdi/delete')['default']
IconMdiDrag: typeof import('~icons/mdi/drag')['default']
IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
IconMdiPlus: typeof import('~icons/mdi/plus')['default']
IconMdiRefresh: typeof import('~icons/mdi/refresh')['default']
IconSolarAddSquareLinear: typeof import('~icons/solar/add-square-linear')['default']
IconSolarArchiveUpMinimlisticOutline: typeof import('~icons/solar/archive-up-minimlistic-outline')['default']
IconSolarCloudUploadLinear: typeof import('~icons/solar/cloud-upload-linear')['default']
IconSolarPen2Linear: typeof import('~icons/solar/pen2-linear')['default']
IconSolarQuestionCircleLinear: typeof import('~icons/solar/question-circle-linear')['default']
IconSolarQuestionCircleOutline: typeof import('~icons/solar/question-circle-outline')['default']
IconSolarTrashBinMinimalisticOutline: typeof import('~icons/solar/trash-bin-minimalistic-outline')['default']
'IconStreamlineSharp:typeAreaRemix': typeof import('~icons/streamline-sharp/type-area-remix')['default']
IconTooltip: typeof import('./../components/common/icon-tooltip.vue')['default']
IconUilSearch: typeof import('~icons/uil/search')['default']
LangSwitch: typeof import('./../components/common/lang-switch.vue')['default']
LookForward: typeof import('./../components/custom/look-forward.vue')['default']
MathIframeDialog: typeof import('./../components/common/rest-basic-editor/components/math-iframe-dialog.vue')['default']
MenuToggler: typeof import('./../components/common/menu-toggler.vue')['default']
NAlert: typeof import('naive-ui')['NAlert']
NBadge: typeof import('naive-ui')['NBadge']
@@ -111,7 +87,6 @@ declare module 'vue' {
NDrawer: typeof import('naive-ui')['NDrawer']
NDrawerContent: typeof import('naive-ui')['NDrawerContent']
NDropdown: typeof import('naive-ui')['NDropdown']
NEditor: typeof import('naive-ui')['NEditor']
NEmpty: typeof import('naive-ui')['NEmpty']
NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem']
@@ -119,14 +94,9 @@ declare module 'vue' {
NGi: typeof import('naive-ui')['NGi']
NGrid: typeof import('naive-ui')['NGrid']
NImage: typeof import('naive-ui')['NImage']
NImageGroup: typeof import('naive-ui')['NImageGroup']
NInput: typeof import('naive-ui')['NInput']
NInputGroup: typeof import('naive-ui')['NInputGroup']
NInputGroupLabel: typeof import('naive-ui')['NInputGroupLabel']
NInputNumber: typeof import('naive-ui')['NInputNumber']
NLayout: typeof import('naive-ui')['NLayout']
NLayoutContent: typeof import('naive-ui')['NLayoutContent']
NLayoutHeader: typeof import('naive-ui')['NLayoutHeader']
NList: typeof import('naive-ui')['NList']
NListItem: typeof import('naive-ui')['NListItem']
NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider']
@@ -136,38 +106,24 @@ declare module 'vue' {
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
NPopover: typeof import('naive-ui')['NPopover']
NProgress: typeof import('naive-ui')['NProgress']
NRadio: typeof import('naive-ui')['NRadio']
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSelect: typeof import('naive-ui')['NSelect']
NSelectOption: typeof import('naive-ui')['NSelectOption']
NSpace: typeof import('naive-ui')['NSpace']
NSpin: typeof import('naive-ui')['NSpin']
NStatistic: typeof import('naive-ui')['NStatistic']
NSwitch: typeof import('naive-ui')['NSwitch']
NTab: typeof import('naive-ui')['NTab']
NTable: typeof import('naive-ui')['NTable']
NTabs: typeof import('naive-ui')['NTabs']
NTag: typeof import('naive-ui')['NTag']
NTextarea: typeof import('naive-ui')['NTextarea']
NThing: typeof import('naive-ui')['NThing']
NTooltip: typeof import('naive-ui')['NTooltip']
NTree: typeof import('naive-ui')['NTree']
NTreeSelect: typeof import('naive-ui')['NTreeSelect']
NUpload: typeof import('naive-ui')['NUpload']
NWatermark: typeof import('naive-ui')['NWatermark']
OssImageUpload: typeof import('./../components/common/oss-image-upload/index.vue')['default']
PageHeader: typeof import('./../components/common/page-header.vue')['default']
PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
ReloadButton: typeof import('./../components/common/reload-button.vue')['default']
RestBasicEditor: typeof import('./../components/common/rest-basic-editor/index.vue')['default']
RestDraggableResizable: typeof import('./../components/common/rest-draggable-resizable/rest-draggable-resizable.vue')['default']
RestHoverAciton: typeof import('./../components/common/rest-hover-aciton/rest-hover-aciton.vue')['default']
RestImgViewer: typeof import('./../components/common/rest-img-viewer/rest-img-viewer.vue')['default']
RestSelectFile: typeof import('./../components/common/rest-select-file/rest-select-file.vue')['default']
RestUpload: typeof import('./../components/common/rest-upload/rest-upload.vue')['default']
RestVideoViewer: typeof import('./../components/common/rest-video-viewer/rest-video-viewer.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SoybeanAvatar: typeof import('./../components/custom/soybean-avatar.vue')['default']
@@ -178,7 +134,6 @@ declare module 'vue' {
ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default']
TianZiGe: typeof import('./../components/custom/user/TianZiGe.vue')['default']
Typeit: typeof import('./../components/custom/typeit/index.vue')['default']
UpFileDialog: typeof import('./../components/common/rest-basic-editor/components/up-file-dialog.vue')['default']
WangEditor: typeof import('./../components/common/wang-editor.vue')['default']
WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']
}
@@ -186,6 +141,7 @@ declare module 'vue' {
// For TSX support
declare global {
const ActionButton: typeof import('./../components/custom/user/ActionButton.vue')['default']
const AppProvider: typeof import('./../components/common/app-provider.vue')['default']
const BetterScroll: typeof import('./../components/custom/better-scroll.vue')['default']
const ButtonIcon: typeof import('./../components/custom/button-icon.vue')['default']
@@ -204,8 +160,6 @@ declare global {
const 'IconIc:roundPublish': typeof import('~icons/ic/round-publish')['default']
const 'IconIc:roundStopCircle': typeof import('~icons/ic/round-stop-circle')['default']
const 'IconIc:twotoneRule': typeof import('~icons/ic/twotone-rule')['default']
const IconIcBaselineAdd: typeof import('~icons/ic/baseline-add')['default']
const IconIcBaselineAddPlus: typeof import('~icons/ic/baseline-add-plus')['default']
const IconIcBaselineArrowBack: typeof import('~icons/ic/baseline-arrow-back')['default']
const IconIcBaselineArrowForward: typeof import('~icons/ic/baseline-arrow-forward')['default']
const IconIcBaselineCalendarMonth: typeof import('~icons/ic/baseline-calendar-month')['default']
@@ -214,60 +168,37 @@ declare global {
const IconIcBaselineContentCopy: typeof import('~icons/ic/baseline-content-copy')['default']
const IconIcBaselineDelete: typeof import('~icons/ic/baseline-delete')['default']
const IconIcBaselineDownload: typeof import('~icons/ic/baseline-download')['default']
const IconIcBaselineEdit: typeof import('~icons/ic/baseline-edit')['default']
const IconIcBaselineFlash: typeof import('~icons/ic/baseline-flash')['default']
const IconIcBaselineGroup: typeof import('~icons/ic/baseline-group')['default']
const IconIcBaselineRefresh: typeof import('~icons/ic/baseline-refresh')['default']
const IconIcBaselineRocketLaunch: typeof import('~icons/ic/baseline-rocket-launch')['default']
const IconIcBaselineSave: typeof import('~icons/ic/baseline-save')['default']
const IconIcBaselineScan: typeof import('~icons/ic/baseline-scan')['default']
const IconIcBaselineSettingsBackupRestore: typeof import('~icons/ic/baseline-settings-backup-restore')['default']
const IconIcBaselineUpload: typeof import('~icons/ic/baseline-upload')['default']
const IconIcBaselineUploadFile: typeof import('~icons/ic/baseline-upload-file')['default']
const IconIcCarbonUserAvatarFilledAlt: typeof import('~icons/ic/carbon-user-avatar-filled-alt')['default']
const IconIcOutlineAccessTime: typeof import('~icons/ic/outline-access-time')['default']
const IconIcOutlineAddToPhotos: typeof import('~icons/ic/outline-add-to-photos')['default']
const IconIcOutlineArchive: typeof import('~icons/ic/outline-archive')['default']
const IconIcOutlineAttachMoney: typeof import('~icons/ic/outline-attach-money')['default']
const IconIcOutlineDelete: typeof import('~icons/ic/outline-delete')['default']
const IconIcOutlineFolderOff: typeof import('~icons/ic/outline-folder-off')['default']
const IconIcOutlineFormatListNumbered: typeof import('~icons/ic/outline-format-list-numbered')['default']
const IconIcOutlineTimer: typeof import('~icons/ic/outline-timer')['default']
const IconIcOutlineTitle: typeof import('~icons/ic/outline-title')['default']
const IconIcRoundAccessTime: typeof import('~icons/ic/round-access-time')['default']
const IconIcRoundArrowBack: typeof import('~icons/ic/round-arrow-back')['default']
const IconIcRoundArrowForward: typeof import('~icons/ic/round-arrow-forward')['default']
const IconIcRoundArrowLeft: typeof import('~icons/ic/round-arrow-left')['default']
const IconIcRoundArrowRight: typeof import('~icons/ic/round-arrow-right')['default']
const IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
const IconIcRoundLibraryAdd: typeof import('~icons/ic/round-library-add')['default']
const IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
const IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default']
const IconIcRoundSearch: typeof import('~icons/ic/round-search')['default']
const IconIcRoundStarBorder: typeof import('~icons/ic/round-star-border')['default']
const IconIcRoundUpload: typeof import('~icons/ic/round-upload')['default']
const IconLocalBanner: typeof import('~icons/local/banner')['default']
const IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
const IconMdiArrowUpThin: typeof import('~icons/mdi/arrow-up-thin')['default']
const IconMdiDelete: typeof import('~icons/mdi/delete')['default']
const IconMdiDrag: typeof import('~icons/mdi/drag')['default']
const IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
const IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
const IconMdiPlus: typeof import('~icons/mdi/plus')['default']
const IconMdiRefresh: typeof import('~icons/mdi/refresh')['default']
const IconSolarAddSquareLinear: typeof import('~icons/solar/add-square-linear')['default']
const IconSolarArchiveUpMinimlisticOutline: typeof import('~icons/solar/archive-up-minimlistic-outline')['default']
const IconSolarCloudUploadLinear: typeof import('~icons/solar/cloud-upload-linear')['default']
const IconSolarPen2Linear: typeof import('~icons/solar/pen2-linear')['default']
const IconSolarQuestionCircleLinear: typeof import('~icons/solar/question-circle-linear')['default']
const IconSolarQuestionCircleOutline: typeof import('~icons/solar/question-circle-outline')['default']
const IconSolarTrashBinMinimalisticOutline: typeof import('~icons/solar/trash-bin-minimalistic-outline')['default']
const 'IconStreamlineSharp:typeAreaRemix': typeof import('~icons/streamline-sharp/type-area-remix')['default']
const IconTooltip: typeof import('./../components/common/icon-tooltip.vue')['default']
const IconUilSearch: typeof import('~icons/uil/search')['default']
const LangSwitch: typeof import('./../components/common/lang-switch.vue')['default']
const LookForward: typeof import('./../components/custom/look-forward.vue')['default']
const MathIframeDialog: typeof import('./../components/common/rest-basic-editor/components/math-iframe-dialog.vue')['default']
const MenuToggler: typeof import('./../components/common/menu-toggler.vue')['default']
const NAlert: typeof import('naive-ui')['NAlert']
const NBadge: typeof import('naive-ui')['NBadge']
@@ -285,7 +216,6 @@ declare global {
const NDrawer: typeof import('naive-ui')['NDrawer']
const NDrawerContent: typeof import('naive-ui')['NDrawerContent']
const NDropdown: typeof import('naive-ui')['NDropdown']
const NEditor: typeof import('naive-ui')['NEditor']
const NEmpty: typeof import('naive-ui')['NEmpty']
const NForm: typeof import('naive-ui')['NForm']
const NFormItem: typeof import('naive-ui')['NFormItem']
@@ -293,14 +223,9 @@ declare global {
const NGi: typeof import('naive-ui')['NGi']
const NGrid: typeof import('naive-ui')['NGrid']
const NImage: typeof import('naive-ui')['NImage']
const NImageGroup: typeof import('naive-ui')['NImageGroup']
const NInput: typeof import('naive-ui')['NInput']
const NInputGroup: typeof import('naive-ui')['NInputGroup']
const NInputGroupLabel: typeof import('naive-ui')['NInputGroupLabel']
const NInputNumber: typeof import('naive-ui')['NInputNumber']
const NLayout: typeof import('naive-ui')['NLayout']
const NLayoutContent: typeof import('naive-ui')['NLayoutContent']
const NLayoutHeader: typeof import('naive-ui')['NLayoutHeader']
const NList: typeof import('naive-ui')['NList']
const NListItem: typeof import('naive-ui')['NListItem']
const NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider']
@@ -310,38 +235,24 @@ declare global {
const NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
const NPopconfirm: typeof import('naive-ui')['NPopconfirm']
const NPopover: typeof import('naive-ui')['NPopover']
const NProgress: typeof import('naive-ui')['NProgress']
const NRadio: typeof import('naive-ui')['NRadio']
const NRadioGroup: typeof import('naive-ui')['NRadioGroup']
const NScrollbar: typeof import('naive-ui')['NScrollbar']
const NSelect: typeof import('naive-ui')['NSelect']
const NSelectOption: typeof import('naive-ui')['NSelectOption']
const NSpace: typeof import('naive-ui')['NSpace']
const NSpin: typeof import('naive-ui')['NSpin']
const NStatistic: typeof import('naive-ui')['NStatistic']
const NSwitch: typeof import('naive-ui')['NSwitch']
const NTab: typeof import('naive-ui')['NTab']
const NTable: typeof import('naive-ui')['NTable']
const NTabs: typeof import('naive-ui')['NTabs']
const NTag: typeof import('naive-ui')['NTag']
const NTextarea: typeof import('naive-ui')['NTextarea']
const NThing: typeof import('naive-ui')['NThing']
const NTooltip: typeof import('naive-ui')['NTooltip']
const NTree: typeof import('naive-ui')['NTree']
const NTreeSelect: typeof import('naive-ui')['NTreeSelect']
const NUpload: typeof import('naive-ui')['NUpload']
const NWatermark: typeof import('naive-ui')['NWatermark']
const OssImageUpload: typeof import('./../components/common/oss-image-upload/index.vue')['default']
const PageHeader: typeof import('./../components/common/page-header.vue')['default']
const PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
const ReloadButton: typeof import('./../components/common/reload-button.vue')['default']
const RestBasicEditor: typeof import('./../components/common/rest-basic-editor/index.vue')['default']
const RestDraggableResizable: typeof import('./../components/common/rest-draggable-resizable/rest-draggable-resizable.vue')['default']
const RestHoverAciton: typeof import('./../components/common/rest-hover-aciton/rest-hover-aciton.vue')['default']
const RestImgViewer: typeof import('./../components/common/rest-img-viewer/rest-img-viewer.vue')['default']
const RestSelectFile: typeof import('./../components/common/rest-select-file/rest-select-file.vue')['default']
const RestUpload: typeof import('./../components/common/rest-upload/rest-upload.vue')['default']
const RestVideoViewer: typeof import('./../components/common/rest-video-viewer/rest-video-viewer.vue')['default']
const RouterLink: typeof import('vue-router')['RouterLink']
const RouterView: typeof import('vue-router')['RouterView']
const SoybeanAvatar: typeof import('./../components/custom/soybean-avatar.vue')['default']
@@ -352,7 +263,6 @@ declare global {
const ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default']
const TianZiGe: typeof import('./../components/custom/user/TianZiGe.vue')['default']
const Typeit: typeof import('./../components/custom/typeit/index.vue')['default']
const UpFileDialog: typeof import('./../components/common/rest-basic-editor/components/up-file-dialog.vue')['default']
const WangEditor: typeof import('./../components/common/wang-editor.vue')['default']
const WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']
}
-2
View File
@@ -47,7 +47,6 @@ declare module "@elegant-router/types" {
"user_groups": "/user/groups";
"user_home": "/user/home";
"user_rank-list": "/user/rank-list";
"user_rank-pending": "/user/rank-pending";
"user_rules": "/user/rules";
"user_teams": "/user/teams";
};
@@ -136,7 +135,6 @@ declare module "@elegant-router/types" {
| "user_groups"
| "user_home"
| "user_rank-list"
| "user_rank-pending"
| "user_rules"
| "user_teams"
>;
+114 -64
View File
@@ -1,23 +1,58 @@
import boy5 from '@/assets/audio/boy-5s.mp3'
import system10 from '@/assets/audio/boy-10s.mp3'
export type AudioType = 'start' | 'tick10' | 'flip' | 'boy5'
export class AudioController {
private audioContext: AudioContext | null = null
private static audioContext: AudioContext | null = null
private soundFiles: Record<string, string> = {}
private audioCache: Record<string, HTMLAudioElement> = {}
constructor(soundFiles: Record<string, string> = {}) {
this.soundFiles = soundFiles
this.soundFiles = {
tick10: system10,
boy5,
...soundFiles,
}
// 预加载关键音频
this.preload(['tick10', 'boy5'])
}
private initAudioContext() {
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
if (!AudioController.audioContext) {
const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext
if (AudioContextClass) {
AudioController.audioContext = new AudioContextClass()
}
}
// 尝试恢复上下文(如果被挂起)
if (AudioController.audioContext?.state === 'suspended') {
AudioController.audioContext.resume().catch(() => {})
}
}
/**
* 预加载音频文件
*/
preload(types: AudioType[]) {
types.forEach((type) => {
const url = this.soundFiles[type]
if (url && !this.audioCache[type]) {
const audio = new Audio(url)
audio.preload = 'auto'
this.audioCache[type] = audio
}
})
}
/**
* 播放指定类型的音效
* 优先使用预设的音频文件,如果没有配置或加载失败,则使用 Web Audio API 合成音效
*/
async play(type: 'start' | 'tick' | 'flip') {
async play(type: AudioType) {
// 确保音频上下文已初始化
this.initAudioContext()
// 尝试播放音频文件
if (this.soundFiles[type]) {
try {
@@ -54,68 +89,83 @@ export class AudioController {
})
}
private playSynth(type: 'start' | 'tick' | 'flip') {
this.initAudioContext()
if (!this.audioContext)
private playSynth(type: AudioType) {
if (!AudioController.audioContext)
return
const ctxTime = this.audioContext.currentTime
const gain = this.audioContext.createGain()
gain.connect(this.audioContext.destination)
const ctx = AudioController.audioContext
const ctxTime = ctx.currentTime
const gain = ctx.createGain()
gain.connect(ctx.destination)
if (type === 'start') {
const osc = this.audioContext.createOscillator()
osc.connect(gain)
// 开始音效:高音短促提示
osc.type = 'sine'
osc.frequency.setValueAtTime(880, ctxTime)
osc.frequency.exponentialRampToValueAtTime(440, ctxTime + 0.3)
gain.gain.setValueAtTime(0.5, ctxTime)
gain.gain.exponentialRampToValueAtTime(0.01, ctxTime + 0.3)
osc.start(ctxTime)
osc.stop(ctxTime + 0.3)
}
else if (type === 'tick') {
const osc = this.audioContext.createOscillator()
osc.connect(gain)
// 倒计时音效:急促的滴答声
osc.type = 'triangle'
osc.frequency.setValueAtTime(600, ctxTime)
gain.gain.setValueAtTime(0.3, ctxTime)
gain.gain.exponentialRampToValueAtTime(0.01, ctxTime + 0.1)
osc.start(ctxTime)
osc.stop(ctxTime + 0.1)
}
else if (type === 'flip') {
// 翻书音效:使用白噪声模拟纸张摩擦
const bufferSize = this.audioContext.sampleRate * 0.5 // 0.5秒缓冲
const buffer = this.audioContext.createBuffer(1, bufferSize, this.audioContext.sampleRate)
const data = buffer.getChannelData(0)
// 生成白噪声
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1
}
const noise = this.audioContext.createBufferSource()
noise.buffer = buffer
// 滤波器:低通滤波器,模拟纸张的闷声
const filter = this.audioContext.createBiquadFilter()
filter.type = 'lowpass'
filter.frequency.setValueAtTime(400, ctxTime)
filter.frequency.exponentialRampToValueAtTime(3000, ctxTime + 0.1) // 频率快速扫过,模拟快速翻动
noise.connect(filter)
filter.connect(gain)
// 音量包络:快速淡入淡出
gain.gain.setValueAtTime(0, ctxTime)
gain.gain.linearRampToValueAtTime(0.8, ctxTime + 0.05)
gain.gain.exponentialRampToValueAtTime(0.01, ctxTime + 0.3)
noise.start(ctxTime)
noise.stop(ctxTime + 0.3)
switch (type) {
case 'start':
this.playStartSound(ctx, gain, ctxTime)
break
case 'tick10':
case 'boy5':
this.playTickSound(ctx, gain, ctxTime)
break
case 'flip':
this.playFlipSound(ctx, gain, ctxTime)
break
}
}
private playStartSound(ctx: AudioContext, gain: GainNode, time: number) {
const osc = ctx.createOscillator()
osc.connect(gain)
// 开始音效:高音短促提示
osc.type = 'sine'
osc.frequency.setValueAtTime(880, time)
osc.frequency.exponentialRampToValueAtTime(440, time + 0.3)
gain.gain.setValueAtTime(0.5, time)
gain.gain.exponentialRampToValueAtTime(0.01, time + 0.3)
osc.start(time)
osc.stop(time + 0.3)
}
private playTickSound(ctx: AudioContext, gain: GainNode, time: number) {
const osc = ctx.createOscillator()
osc.connect(gain)
// 倒计时音效:急促的滴答声
osc.type = 'triangle'
osc.frequency.setValueAtTime(600, time)
gain.gain.setValueAtTime(0.3, time)
gain.gain.exponentialRampToValueAtTime(0.01, time + 0.1)
osc.start(time)
osc.stop(time + 0.1)
}
private playFlipSound(ctx: AudioContext, gain: GainNode, time: number) {
// 翻书音效:使用白噪声模拟纸张摩擦
const bufferSize = ctx.sampleRate * 0.5 // 0.5秒缓冲
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate)
const data = buffer.getChannelData(0)
// 生成白噪声
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1
}
const noise = ctx.createBufferSource()
noise.buffer = buffer
// 滤波器:低通滤波器,模拟纸张的闷声
const filter = ctx.createBiquadFilter()
filter.type = 'lowpass'
filter.frequency.setValueAtTime(400, time)
filter.frequency.exponentialRampToValueAtTime(3000, time + 0.1) // 频率快速扫过,模拟快速翻动
noise.connect(filter)
filter.connect(gain)
// 音量包络:快速淡入淡出
gain.gain.setValueAtTime(0, time)
gain.gain.linearRampToValueAtTime(0.8, time + 0.05)
gain.gain.exponentialRampToValueAtTime(0.01, time + 0.3)
noise.start(time)
noise.stop(time + 0.3)
}
}
@@ -44,7 +44,7 @@ const statisticData = computed<StatisticData[]>(() => [
<NGi span="24 s:24 m:18">
<div class="flex-y-center">
<div class="size-72px shrink-0 overflow-hidden rd-1/2">
<img src="@/assets/imgs/reader-star.jpg" class="size-full">
<img src="@/assets/imgs/user/read-logo.png" class="size-full">
</div>
<div class="pl-12px">
<h3 class="text-18px font-semibold">
@@ -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')
@@ -103,7 +103,7 @@ async function fetchTeamListByGroupId(GroupID: number) {
/** 获取活动题目列表 */
async function getQuestionList(id: number) {
const { data, error } = await fetchGetQuestionList(id)
const { data, error } = await fetchGetQuestionList(id, 3)
if (!error) {
questionsList.value = data?.data || []
// eslint-disable-next-line no-console
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { NButton, NCard, NForm, NFormItem, NInput, NInputNumber, NModal, NSelect, NTag, type SelectOption } from 'naive-ui'
import { NButton, NCard, NForm, NFormItem, NInputNumber, NModal, NSelect, NTag, type SelectOption } from 'naive-ui'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { VueDraggable } from 'vue-draggable-plus'
import { PublishStatus } from '@/enum/business'
@@ -166,7 +166,7 @@ onMounted(() => {
}
})
function getRoundTypeName(roundType: string) {
function getRoundTypeName(roundType: number) {
const option = roundTypeOptions.value?.find((item: any) => item.value === roundType)
return option?.label || '未知类型'
}
@@ -268,14 +268,14 @@ defineExpose({ validate, reset, modelValue })
<div class="flex items-center gap-4">
<div class="h-8 w-1.5 rounded-full bg-blue-500" />
<NTag type="primary" class="!text-blue-500">
{{ getRoundTypeName(round.roundType) }}
{{ getRoundTypeName(Number(round.roundType)) }}
</NTag>
<div class="flex items-center gap-3">
<NInput
<!-- <NInput
v-model:value="round.name" placeholder="请输入环节名称" :bordered="false"
:disabled="!isEdit"
class="border transition-all !w-80 !border-transparent !rounded-lg !bg-transparent !text-xl !font-bold focus-within:shadow-sm focus-within:!border-blue-500 hover:!border-gray-200 focus-within:!bg-white"
/>
/> -->
<div class="flex items-center gap-2">
<div
class="flex items-center gap-1 rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-500 font-bold"
@@ -23,7 +23,6 @@ function handleAdd() {
}
async function handleCopy(item: Api.Competition.CompetitionItem) {
// TODO: 实现复制逻辑
try {
const { error } = await fetchCopyActivity(item.Id)
if (error) {
@@ -163,7 +162,3 @@ onMounted(() => fetchActivityList())
<CompetitionAddModal v-model:show="showAddModal" @success="fetchActivityList" />
</NSpace>
</template>
<style scoped>
/* 针对不同屏幕微调间距 */
</style>
@@ -57,6 +57,14 @@ async function fetchData() {
// 列定义
const columns: DataTableColumns<Api.Dictionary.DictionaryItem> = [
{
title: '序号',
key: 'index',
width: 80,
render(_, index) {
return index + 1
},
},
{ title: 'ID', key: 'Id' },
{
title: '字典键 (Key)',
@@ -59,6 +59,14 @@ watch(() => props.visible, (val) => {
})
const columns: DataTableColumns<any> = [
{
title: '序号',
key: 'index',
width: 80,
render(_, index) {
return index + 1
},
},
{ title: 'ID', key: 'ID', width: 80 },
{ title: '键 (Key)', key: 'UI_Key' },
{ title: '值 (Value)', key: 'UI_Value', width: 100 },
+11 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue'
import { onMounted, ref } from 'vue'
import CategoryTree from './modules/CategoryTree.vue'
import QuestionList from './modules/QuestionList.vue'
@@ -8,7 +8,8 @@ defineOptions({ name: 'QuestionStore' })
const currentCategory = ref<any>(null)
// Resize logic
const siderWidth = ref(450)
const siderWidth = ref(525)
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>
@@ -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, 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,18 +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,
})
const fileList = ref<UploadFileInfo[]>([])
const questionFormRef = ref<FormInst | null>(null)
const currentQuestion = ref<any>(null)
// 批量选择
const selectedQuestionIds = ref<number[]>([])
@@ -177,10 +107,29 @@ function handleBatchDelete() {
})
}
const rules = {
name: [{ required: true, message: '请输入题目正文内容', trigger: ['blur'] }],
answer: [{ required: true, message: '请输入题目答案', trigger: ['blur'] }],
type: [{ required: true, message: '请选择题目类型', trigger: ['change'] }],
async function handleBatchDisable(disabled: boolean) {
if (selectedQuestionIds.value.length === 0) {
window?.$message?.warning('请先选择要操作的题目')
return
}
const payload = selectedQuestionIds.value.map(id => ({
id,
Id: id,
IsDisabled: disabled,
}))
const { error } = await fetchDisableQuestionLibrary(payload)
if (!error) {
window?.$message?.success(`批量${disabled ? '禁用' : '启用'}成功`)
selectedQuestionIds.value = []
if (props.currentCategory?.Id) {
fetchData(props.currentCategory.Id)
}
}
else {
window?.$message?.error(`批量${disabled ? '禁用' : '启用'}失败`)
}
}
const hasCategory = computed(() => {
@@ -198,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({
@@ -234,47 +175,15 @@ function handleAddQuestion() {
return
}
questionOperation.value = 'add'
currentQuestionId.value = null
questionForm.value = {
name: '',
answer: '',
type: props.currentCategory?.QuestionType || QuestionType.SingleChoice,
imageUrl: '',
IsGood: 0,
}
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,
}
fileList.value = []
if (question.ImageUrl) {
fileList.value = [{
id: 'existing',
name: 'Existing Image',
status: 'finished',
url: question.ImageUrl,
}]
}
currentQuestion.value = question
showQuestionModal.value = true
}
}
@@ -300,67 +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,
}
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>
@@ -385,11 +237,7 @@ async function submitQuestion() {
<!-- 搜索框 -->
<div v-if="currentCategory" class="mt-2 flex items-center justify-between">
<div class="flex items-center gap-3">
<NCheckbox
:checked="isAllSelected"
:indeterminate="isIndeterminate"
@update:checked="handleSelectAll"
>
<NCheckbox :checked="isAllSelected" :indeterminate="isIndeterminate" @update:checked="handleSelectAll">
全选 ({{ selectedQuestionIds.length }})
</NCheckbox>
<NInput v-model:value="searchText" placeholder="输入题目关键字在当前分类下搜索..." class="!w-80" @keyup.enter="handleSearch">
@@ -400,6 +248,24 @@ async function submitQuestion() {
</div>
<div class="flex gap-3">
<NButton
v-if="selectedQuestionIds.length > 0" type="warning" secondary
@click="() => handleBatchDisable(true)"
>
<template #icon>
<SvgIcon icon="carbon:view-off" />
</template>
批量禁用
</NButton>
<NButton
v-if="selectedQuestionIds.length > 0" type="success" secondary
@click="() => handleBatchDisable(false)"
>
<template #icon>
<SvgIcon icon="carbon:view" />
</template>
批量启用
</NButton>
<NButton v-if="selectedQuestionIds.length > 0" type="error" secondary @click="handleBatchDelete">
<template #icon>
<SvgIcon icon="carbon:trash-can" />
@@ -440,8 +306,8 @@ async function submitQuestion() {
<NCard
v-for="q in questionList" :key="q.Id" size="small" hoverable class="rounded-xl transition-all" :class="{
'ring-2 ring-primary-500 bg-primary-50': selectedQuestionIds.includes(q.Id),
}"
@click="handleSelect(q.Id, !selectedQuestionIds.includes(q.Id))"
'opacity-50 grayscale': q.IsDisabled === 1,
}" @click="handleSelect(q.Id, !selectedQuestionIds.includes(q.Id))"
>
<template #header>
<div class="flex items-center gap-2" @click.stop>
@@ -455,6 +321,9 @@ async function submitQuestion() {
<NTag size="small" :type="q.IsGood === 1 ? 'info' : 'default'" :bordered="false">
{{ q.IsGood === 1 ? '优先' : '随机' }}
</NTag>
<NTag v-if="q.IsDisabled" size="small" type="error" :bordered="false">
已禁用
</NTag>
</div>
</template>
<template #header-extra>
@@ -509,88 +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 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>
@@ -150,8 +150,8 @@ function edit(item: any) {
<NCard :bordered="false" page-size="small" class="card-wrapper sm:flex-1-hidden">
<template #header>
<div class="flex items-center justify-between">
<div>
<div class="text-16px font-bold">
<div class="mid-title">
<div class="text-2xl font-bold">
{{ rankTitle }}
</div>
<div class="mt-4px text-12px text-gray-500">

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