Compare commits
46 Commits
main
...
fe4d8f6d62
| Author | SHA1 | Date | |
|---|---|---|---|
| fe4d8f6d62 | |||
| 1c8035d935 | |||
| 2fad025496 | |||
| 02b4720b34 | |||
| 8964fcdea7 | |||
| 771fb807eb | |||
| 782c61a38c | |||
| 40a6850c3f | |||
| 9d801ba6b4 | |||
| 11f448cedc | |||
| 41f7ba7b8a | |||
| b9c5886437 | |||
| 11fb5481d3 | |||
| 8469e16638 | |||
| e8cd6732d7 | |||
| ed3c815cd7 | |||
| 94af0bec6a | |||
| 81643c61ae | |||
| 2b50bd2c71 | |||
| e70409082a | |||
| f1ff053b68 | |||
| f3136cd9ad | |||
| 61a7412fc1 | |||
| 0fd8ef527a | |||
| b71a758474 | |||
| ca42c6a876 | |||
| ce5ee2a289 | |||
| e1a19c48e0 | |||
| b39761d58e | |||
| beaaee72b4 | |||
| 82b1dd7ebe | |||
| ba38accdb9 | |||
| 16d7ecd172 | |||
| 78e51d96f6 | |||
| 133d9a903a | |||
| c3d23224ba | |||
| f6d2a643e8 | |||
| 5a48a65ef8 | |||
| 264f600250 | |||
| f1dacf7c66 | |||
| 697d606611 | |||
| d950bb4018 | |||
| 89773bd49b | |||
| 3270e24c25 | |||
| e6346a771e | |||
| 764b5727ba |
4
.gitignore
vendored
@ -7,6 +7,10 @@ dist-ssr
|
||||
apps/*/dist
|
||||
packages/*/dist
|
||||
|
||||
# --- 分析报告文件 ---
|
||||
report.html
|
||||
apps/*/report.html
|
||||
|
||||
# --- 日志文件 ---
|
||||
logs
|
||||
*.log
|
||||
|
||||
8
.vscode/settings.json
vendored
@ -36,5 +36,11 @@
|
||||
"./apps/user",
|
||||
"./packages/eslint-config"
|
||||
],
|
||||
"totvsLanguageServer.welcomePage": false // `
|
||||
"totvsLanguageServer.welcomePage": false,
|
||||
"cSpell.words": [
|
||||
"activityinfo",
|
||||
"Echarts",
|
||||
"Typeit"
|
||||
],
|
||||
"kiroAgent.configureMCP": "Disabled" // `
|
||||
}
|
||||
|
||||
@ -1,9 +1,17 @@
|
||||
# 应用的基础URL,默认为 "/"
|
||||
# 如果使用子目录,必须以 "/" 结尾,例如 "/admin/" 而不是 "/admin"
|
||||
VITE_BASE_URL=/
|
||||
VITE_BASE_URL='/'
|
||||
|
||||
# VITE_BASE_URL="https://api.qyzhjy.com"
|
||||
|
||||
VITE_APP_TITLE="阅读比赛配置后台"
|
||||
|
||||
# 阿里云OSS地址前缀(用于拼接完整地址)
|
||||
VITE_BASE_OSS_URL = https://oss.qyzhjy.com/
|
||||
|
||||
# 上传图片的URL
|
||||
VITE_BASE_UPLOAD_URL="http://192.168.20.150:8080"
|
||||
|
||||
VITE_APP_DESC="阅读比赛配置"
|
||||
|
||||
# 图标名称的前缀
|
||||
@ -16,23 +24,26 @@ VITE_ICON_LOCAL_PREFIX=icon-local
|
||||
# 认证路由模式: static (静态) | dynamic (动态)
|
||||
VITE_AUTH_ROUTE_MODE=static
|
||||
|
||||
# 是否强制登录: Y (开启) | N (关闭)
|
||||
VITE_AUTH_ROUTE_FORCE_LOGIN=N
|
||||
|
||||
# 静态认证路由的主页
|
||||
VITE_ROUTE_HOME=home
|
||||
VITE_ROUTE_HOME=user_home
|
||||
|
||||
# 默认菜单图标
|
||||
VITE_MENU_ICON=mdi:menu
|
||||
|
||||
# 在开发模式下是否启用http代理
|
||||
VITE_HTTP_PROXY=Y
|
||||
VITE_HTTP_PROXY=Y # Y 开启后,所有请求都将通过代理服务器,N 关闭后,所有请求都将直接发送到后端服务
|
||||
|
||||
# vue-router 模式: hash | history | memory
|
||||
VITE_ROUTER_HISTORY_MODE=history
|
||||
|
||||
# 后端服务成功代码,收到此代码表示请求成功
|
||||
VITE_SERVICE_SUCCESS_CODE=0000
|
||||
VITE_SERVICE_SUCCESS_CODE=200
|
||||
|
||||
# 后端服务登出代码,收到此代码将登出并重定向到登录页
|
||||
VITE_SERVICE_LOGOUT_CODES=8888,8889
|
||||
VITE_SERVICE_LOGOUT_CODES=401,403
|
||||
|
||||
# 后端服务模态框登出代码,收到此代码将通过显示模态框登出
|
||||
VITE_SERVICE_MODAL_LOGOUT_CODES=7777,7778
|
||||
|
||||
12
apps/admin/.env.dev
Normal file
@ -0,0 +1,12 @@
|
||||
# backend service base url, test environment
|
||||
# VITE_SERVICE_BASE_URL=https://mock.apifox.cn/m1/3109515-0-default
|
||||
|
||||
# VITE_SERVICE_BASE_URL=http://192.168.5.140:9999
|
||||
|
||||
# VITE_SERVICE_BASE_URL=http://api.ydzx.qyzhjy.com
|
||||
|
||||
VITE_SERVICE_BASE_URL=http://172.16.10.130:5000
|
||||
|
||||
VITE_OTHER_SERVICE_BASE_URL= `{
|
||||
"demo": "http://localhost:9528"
|
||||
}`
|
||||
@ -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= `{
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
# backend service base url, test environment
|
||||
VITE_SERVICE_BASE_URL=https://mock.apifox.cn/m1/3109515-0-default
|
||||
|
||||
# other backend service base url, test environment
|
||||
VITE_OTHER_SERVICE_BASE_URL= `{
|
||||
"demo": "http://localhost:9528"
|
||||
}`
|
||||
@ -4,10 +4,10 @@ import { bgRed, bgYellow, green, lightBlue } from 'kolorist'
|
||||
import { createServiceConfig } from '../../src/utils/service'
|
||||
|
||||
/**
|
||||
* Set http proxy
|
||||
* 设置 HTTP 代理
|
||||
*
|
||||
* @param env - The current env
|
||||
* @param enable - If enable http proxy
|
||||
* @param env - 当前环境变量
|
||||
* @param enable - 是否启用 HTTP 代理
|
||||
*/
|
||||
export function createViteProxy(env: Env.ImportMeta, enable: boolean) {
|
||||
const isEnableHttpProxy = enable && env.VITE_HTTP_PROXY === 'Y'
|
||||
@ -28,27 +28,34 @@ export function createViteProxy(env: Env.ImportMeta, enable: boolean) {
|
||||
return proxy
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 HTTP 代理项
|
||||
*
|
||||
* @param item - 服务配置项
|
||||
* @param enableLog - 是否启用日志记录
|
||||
*/
|
||||
function createProxyItem(item: App.Service.ServiceConfigItem, enableLog: boolean) {
|
||||
const proxy: Record<string, ProxyOptions> = {}
|
||||
|
||||
proxy[item.proxyPattern] = {
|
||||
target: item.baseURL,
|
||||
changeOrigin: true,
|
||||
secure: false, // 如果是https接口,需要配置这个参数
|
||||
configure: (_proxy, options) => {
|
||||
_proxy.on('proxyReq', (_proxyReq, req, _res) => {
|
||||
if (!enableLog)
|
||||
return
|
||||
|
||||
const requestUrl = `${lightBlue('[proxy url]')}: ${bgYellow(` ${req.method} `)} ${green(`${item.proxyPattern}${req.url}`)}`
|
||||
const requestUrl = `${lightBlue('[代理地址]')}: ${bgYellow(` ${req.method} `)} ${green(`${item.proxyPattern}${req.url}`)}`
|
||||
|
||||
const proxyUrl = `${lightBlue('[real request url]')}: ${green(`${options.target}${req.url}`)}`
|
||||
const proxyUrl = `${lightBlue('[真实请求地址]')}: ${green(`${options.target}${req.url}`)}`
|
||||
|
||||
consola.log(`${requestUrl}\n${proxyUrl}`)
|
||||
})
|
||||
_proxy.on('error', (_err, req, _res) => {
|
||||
if (!enableLog)
|
||||
return
|
||||
consola.log(bgRed(`Error: ${req.method} `), green(`${options.target}${req.url}`))
|
||||
consola.log(bgRed(`错误: ${req.method} `), green(`${options.target}${req.url}`))
|
||||
})
|
||||
},
|
||||
rewrite: path => path.replace(new RegExp(`^${item.proxyPattern}`), ''),
|
||||
|
||||
@ -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
apps/admin/build/plugins/analyzer.ts
Normal 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
apps/admin/build/plugins/compression.ts
Normal 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
apps/admin/build/plugins/deploy.ts
Normal 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',
|
||||
},
|
||||
})
|
||||
}
|
||||
51
apps/admin/build/plugins/image-optimizer.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
|
||||
|
||||
export function setupImageOptimizer() {
|
||||
return ViteImageOptimizer({
|
||||
logStats: true,
|
||||
|
||||
// SVG 优化配置
|
||||
svg: {
|
||||
multipass: true,
|
||||
plugins: [
|
||||
{
|
||||
name: 'preset-default',
|
||||
params: {
|
||||
overrides: {
|
||||
removeViewBox: false,
|
||||
cleanupIds: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
'sortAttrs',
|
||||
],
|
||||
},
|
||||
|
||||
// PNG 高质量压缩
|
||||
png: {
|
||||
quality: 90,
|
||||
compressionLevel: 9,
|
||||
},
|
||||
|
||||
// JPEG 优化
|
||||
jpeg: {
|
||||
quality: 85,
|
||||
mozjpeg: true,
|
||||
},
|
||||
jpg: {
|
||||
quality: 85,
|
||||
mozjpeg: true,
|
||||
},
|
||||
|
||||
// WebP 转换
|
||||
webp: {
|
||||
quality: 80,
|
||||
lossless: false,
|
||||
force: true,
|
||||
},
|
||||
|
||||
includePublic: true,
|
||||
cache: true,
|
||||
cacheLocation: 'node_modules/.cache/image-optimizer',
|
||||
})
|
||||
}
|
||||
@ -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
apps/admin/build/plugins/pwa.ts
Normal 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' },
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
<html lang="zh-cmn-Hans">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.svg" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>%VITE_APP_TITLE%</title>
|
||||
|
||||
34
apps/admin/nginx.conf
Normal file
@ -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;
|
||||
# }
|
||||
}
|
||||
@ -9,14 +9,14 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build --mode prod",
|
||||
"build:test": "vite build --mode test",
|
||||
"build:dev": "vite build --mode dev",
|
||||
"cleanup": "sa cleanup",
|
||||
"commit": "sa git-commit",
|
||||
"commit:zh": "sa git-commit -l=zh-cn",
|
||||
"dev": "vite --mode test",
|
||||
"dev": "vite --mode dev",
|
||||
"dev:prod": "vite --mode prod",
|
||||
"gen-route": "sa gen-route",
|
||||
"lint": "eslint . --fix",
|
||||
"lint": "eslint src --fix",
|
||||
"prepare": "simple-git-hooks",
|
||||
"preview": "vite preview",
|
||||
"release": "sa release",
|
||||
@ -25,13 +25,20 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-scroll/core": "2.5.1",
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
"@iconify/vue": "5.0.0",
|
||||
"@opentiny/fluent-editor": "^4.0.1",
|
||||
"@sa/axios": "workspace:*",
|
||||
"@sa/color": "workspace:*",
|
||||
"@sa/hooks": "workspace:*",
|
||||
"@sa/materials": "workspace:*",
|
||||
"@sa/utils": "workspace:*",
|
||||
"@sapphire/snowflake": "^3.5.5",
|
||||
"@vueuse/core": "14.1.0",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"@zumer/snapdom": "^2.0.1",
|
||||
"ali-oss": "^6.23.0",
|
||||
"clipboard": "2.0.11",
|
||||
"dayjs": "1.11.19",
|
||||
"defu": "6.1.4",
|
||||
@ -39,20 +46,30 @@
|
||||
"json5": "2.2.3",
|
||||
"naive-ui": "2.43.2",
|
||||
"nprogress": "0.2.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"pdfjs-dist": "^5.4.530",
|
||||
"pinia": "3.0.4",
|
||||
"pinyin-pro": "^3.28.0",
|
||||
"quill-toolbar-tip": "^0.1.0",
|
||||
"tailwind-merge": "3.4.0",
|
||||
"typeit": "^8.8.7",
|
||||
"v-scale-screen": "^2.3.0",
|
||||
"vue": "3.5.26",
|
||||
"vue-draggable-plus": "0.6.0",
|
||||
"vue-i18n": "11.2.7",
|
||||
"vue-router": "4.6.4"
|
||||
"vue-router": "4.6.4",
|
||||
"vue3-draggable-resizable": "^1.6.5",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@elegant-router/vue": "0.3.8",
|
||||
"@iconify/json": "2.2.417",
|
||||
"@sa/scripts": "workspace:*",
|
||||
"@sa/uno-preset": "workspace:*",
|
||||
"@types/ali-oss": "^6.16.13",
|
||||
"@types/node": "25.0.3",
|
||||
"@types/nprogress": "0.2.3",
|
||||
"@types/path-browserify": "^1.0.3",
|
||||
"@unocss/eslint-config": "66.5.10",
|
||||
"@unocss/preset-icons": "66.5.10",
|
||||
"@unocss/preset-uno": "66.5.10",
|
||||
@ -65,6 +82,7 @@
|
||||
"eslint": "9.39.2",
|
||||
"eslint-plugin-vue": "10.6.2",
|
||||
"kolorist": "1.8.0",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"sass": "1.97.1",
|
||||
"simple-git-hooks": "2.13.1",
|
||||
"tsx": "4.21.0",
|
||||
@ -72,7 +90,11 @@
|
||||
"unplugin-icons": "22.5.0",
|
||||
"unplugin-vue-components": "30.0.0",
|
||||
"vite": "7.3.0",
|
||||
"vite-auto-deploy": "^2.0.34",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-plugin-image-optimizer": "^2.0.3",
|
||||
"vite-plugin-progress": "0.0.7",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vite-plugin-svg-icons": "2.0.1",
|
||||
"vite-plugin-vue-devtools": "8.0.5",
|
||||
"vue-eslint-parser": "10.2.0",
|
||||
|
||||
BIN
apps/admin/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
@ -1,60 +0,0 @@
|
||||
<svg width="100%" height="100%" version="1.1" viewBox="0 0 1000 1000" xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g>
|
||||
<path
|
||||
d="M 200,866 C 100,866 50,779.4 100,692.8 L 200,519.6 C 220,485 240,490 265,499.6 S 360,542.68 360,542.68 C 480.5,601 498,642.5 500,720 C 498,811 462,856 420,866"
|
||||
fill="url(#LinearGradient)" fill-rule="nonzero" opacity="1" stroke="none" />
|
||||
<path
|
||||
d="M 420,866 C 455,861 478,846 500,827 C 614,696 615,597 500,517 C 394,444 333,374 380,207.82 L 260,415.67 C 240.22,450 254.37,465.1 275.28,481.79 S 360,542.68 360,542.68 C 480.5,601 498,642.5 500,720 C 498,811 462,856 420,866"
|
||||
fill="url(#LinearGradient_2)" fill-rule="nonzero" opacity="1" stroke="none" />
|
||||
<path d="M 500,517 C 394,444 333,374 380,207.82 L 400,173.2 C 367,295 421,350 603,428 C 572,440 524,474 500,517"
|
||||
fill="url(#LinearGradient_3)" fill-rule="nonzero" opacity="1" stroke="none" />
|
||||
<path d="M 500,827 L 660,660 C 738,589 710,482 603,428 C 572,440 524,474 500,517 C 615,597 614,696 500,827"
|
||||
fill="url(#LinearGradient_4)" fill-rule="nonzero" opacity="1" stroke="none" />
|
||||
<path d="M 400,173.2 C 367,295 421,350 603,428 C 690,389, 750,445 788,500 L 600,173.2 C 550,86.6 450,86.6 400,173.2"
|
||||
fill="url(#LinearGradient_5)" fill-rule="nonzero" opacity="1" stroke="none" />
|
||||
<path
|
||||
d="M 500,827 L 660,660 C 738,589 710,482 603,428 C 690,389, 750,445 788,500 C 816,554 797,606 750,640 L 500,827"
|
||||
fill="url(#LinearGradient_6)" fill-rule="nonzero" opacity="1" stroke="none" />
|
||||
<path
|
||||
d="M 788,500 C 816,554 797,606 750,640 L 500,827 C 497,851 513,862 540,866 L 800,866 C 900,866 950,779.4 900,692.8 L 788,500"
|
||||
fill="url(#LinearGradient_7)" fill-rule="nonzero" opacity="1" stroke="none" />
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient gradientTransform="matrix(104.391 -73.3432 73.3432 104.391 277.441 710.122)"
|
||||
gradientUnits="userSpaceOnUse" id="LinearGradient" x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="0" stop-color="#373ebf" />
|
||||
<stop offset="1" stop-color="#5058e6" />
|
||||
</linearGradient>
|
||||
<linearGradient gradientTransform="matrix(-173.747 557.324 -557.324 -173.747 508.829 258.172)"
|
||||
gradientUnits="userSpaceOnUse" id="LinearGradient_2" x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="0" stop-color="#c2d6ff" />
|
||||
<stop offset="1" stop-color="#646cff" />
|
||||
</linearGradient>
|
||||
<linearGradient gradientTransform="matrix(157.951 295.666 -295.666 157.951 382.944 193.642)"
|
||||
gradientUnits="userSpaceOnUse" id="LinearGradient_3" x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="0" stop-color="#5058e6" />
|
||||
<stop offset="1" stop-color="#373ebf" />
|
||||
</linearGradient>
|
||||
<linearGradient gradientTransform="matrix(-44.3023 219.578 -219.578 -44.3023 619.69 469.652)"
|
||||
gradientUnits="userSpaceOnUse" id="LinearGradient_4" x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="0" stop-color="#91a7ff" />
|
||||
<stop offset="1" stop-color="#5058e6" />
|
||||
</linearGradient>
|
||||
<linearGradient gradientTransform="matrix(125.52 334.256 -334.256 125.52 539.723 235.139)"
|
||||
gradientUnits="userSpaceOnUse" id="LinearGradient_5" x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="0" stop-color="#646cff" />
|
||||
<stop offset="1" stop-color="#c2d6ff" />
|
||||
</linearGradient>
|
||||
<linearGradient gradientTransform="matrix(-241.23 357.206 -357.206 -241.23 754.054 449.312)"
|
||||
gradientUnits="userSpaceOnUse" id="LinearGradient_6" x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="0" stop-color="#c2d6ff" />
|
||||
<stop offset="1" stop-color="#646cff" />
|
||||
</linearGradient>
|
||||
<linearGradient gradientTransform="matrix(125.978 210.065 -210.065 125.978 596.433 613.665)"
|
||||
gradientUnits="userSpaceOnUse" id="LinearGradient_7" x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="0" stop-color="#373ebf" />
|
||||
<stop offset="1" stop-color="#5058e6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.8 KiB |
BIN
apps/admin/public/images/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
apps/admin/public/images/favicon.ico
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
apps/admin/public/images/favicon.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
1
apps/admin/public/images/nonsupport.svg
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/admin/public/images/pwa_logo_192.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
apps/admin/public/images/pwa_logo_512.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
4950
apps/admin/report.html
Normal file
@ -4,6 +4,7 @@ import { darkTheme, NConfigProvider } from 'naive-ui'
|
||||
import { computed } from 'vue'
|
||||
import { naiveDateLocales, naiveLocales } from './locales/naive'
|
||||
import { useAppStore } from './store/modules/app'
|
||||
import { useBusinessStore } from './store/modules/business'
|
||||
import { useThemeStore } from './store/modules/theme'
|
||||
|
||||
defineOptions({
|
||||
@ -12,6 +13,9 @@ defineOptions({
|
||||
|
||||
const appStore = useAppStore()
|
||||
const themeStore = useThemeStore()
|
||||
const businessStore = useBusinessStore()
|
||||
/* 初始化字典 */
|
||||
businessStore.initDict()
|
||||
|
||||
const naiveDarkTheme = computed(() => (themeStore.darkMode ? darkTheme : undefined))
|
||||
|
||||
|
||||
BIN
apps/admin/src/assets/audio/boy-10s.mp3
Normal file
BIN
apps/admin/src/assets/audio/boy-5s.mp3
Normal file
BIN
apps/admin/src/assets/audio/girl-10.mp3
Normal file
BIN
apps/admin/src/assets/audio/start3.mp3
Normal file
BIN
apps/admin/src/assets/audio/system-10.mp3
Normal file
BIN
apps/admin/src/assets/imgs/add-f1.png
Normal file
|
After Width: | Height: | Size: 656 KiB |
BIN
apps/admin/src/assets/imgs/logo/cq-logo.png
Normal file
|
After Width: | Height: | Size: 578 KiB |
BIN
apps/admin/src/assets/imgs/logo/cq-logo.webp
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
apps/admin/src/assets/imgs/logo/qw-logo.png
Normal file
|
After Width: | Height: | Size: 487 KiB |
BIN
apps/admin/src/assets/imgs/logo/qw-logo.webp
Normal file
|
After Width: | Height: | Size: 184 KiB |
BIN
apps/admin/src/assets/imgs/logo/text-logo.png
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
apps/admin/src/assets/imgs/logo/text-logo.webp
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
apps/admin/src/assets/imgs/user/3 310372448.png
Normal file
|
After Width: | Height: | Size: 487 KiB |
BIN
apps/admin/src/assets/imgs/user/btn-bg.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
apps/admin/src/assets/imgs/user/content-bg.png
Normal file
|
After Width: | Height: | Size: 181 KiB |
9
apps/admin/src/assets/imgs/user/cover-back.svg
Normal file
|
After Width: | Height: | Size: 8.8 MiB |
BIN
apps/admin/src/assets/imgs/user/cover-back.webp
Normal file
|
After Width: | Height: | Size: 304 KiB |
BIN
apps/admin/src/assets/imgs/user/cover-bg-active.jpg
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
9
apps/admin/src/assets/imgs/user/cover-bg-active.svg
Normal file
|
After Width: | Height: | Size: 11 MiB |
BIN
apps/admin/src/assets/imgs/user/draw.webp
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
apps/admin/src/assets/imgs/user/dui-kuang.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/admin/src/assets/imgs/user/dui-text-bg.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
apps/admin/src/assets/imgs/user/event-card-bg-icon.webp
Normal file
|
After Width: | Height: | Size: 740 KiB |
BIN
apps/admin/src/assets/imgs/user/event-card-bg.webp
Normal file
|
After Width: | Height: | Size: 445 KiB |
35
apps/admin/src/assets/imgs/user/group-type-1.svg
Normal file
|
After Width: | Height: | Size: 790 KiB |
23
apps/admin/src/assets/imgs/user/group-type-2.svg
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
apps/admin/src/assets/imgs/user/home-bg.jpg
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
9
apps/admin/src/assets/imgs/user/home-bg.svg
Normal file
|
After Width: | Height: | Size: 19 MiB |
BIN
apps/admin/src/assets/imgs/user/home-bg.webp
Normal file
|
After Width: | Height: | Size: 797 KiB |
BIN
apps/admin/src/assets/imgs/user/home-bg1.webp
Normal file
|
After Width: | Height: | Size: 811 KiB |
BIN
apps/admin/src/assets/imgs/user/progress-title.png
Normal file
|
After Width: | Height: | Size: 200 KiB |
BIN
apps/admin/src/assets/imgs/user/q5-img.png
Normal file
|
After Width: | Height: | Size: 156 KiB |
32
apps/admin/src/assets/imgs/user/rank-title.svg
Normal file
|
After Width: | Height: | Size: 429 KiB |
BIN
apps/admin/src/assets/imgs/user/read-logo 1.webp
Normal file
|
After Width: | Height: | Size: 172 KiB |
BIN
apps/admin/src/assets/imgs/user/read-logo.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
BIN
apps/admin/src/assets/imgs/user/short-content-bg.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
apps/admin/src/assets/imgs/user/star-icon.webp
Normal file
|
After Width: | Height: | Size: 126 KiB |
46
apps/admin/src/assets/imgs/user/team-bg-animation.svg
Normal file
|
After Width: | Height: | Size: 16 MiB |
BIN
apps/admin/src/assets/imgs/user/team-bg-animation.webp
Normal file
|
After Width: | Height: | Size: 907 KiB |
BIN
apps/admin/src/assets/imgs/user/team-bg.webp
Normal file
|
After Width: | Height: | Size: 907 KiB |
14
apps/admin/src/assets/imgs/user/team-title-bg.svg
Normal file
|
After Width: | Height: | Size: 4.0 MiB |
BIN
apps/admin/src/assets/imgs/user/team-title-icon.webp
Normal file
|
After Width: | Height: | Size: 121 KiB |
15
apps/admin/src/assets/imgs/user/tianzige.svg
Normal file
|
After Width: | Height: | Size: 12 MiB |
BIN
apps/admin/src/assets/imgs/user/title-bg.webp
Normal file
|
After Width: | Height: | Size: 549 KiB |
BIN
apps/admin/src/assets/imgs/user/title-bg2.webp
Normal file
|
After Width: | Height: | Size: 449 KiB |
BIN
apps/admin/src/assets/imgs/user/title-bg3.png
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
apps/admin/src/assets/imgs/user/title-bg4.png
Normal file
|
After Width: | Height: | Size: 306 KiB |
BIN
apps/admin/src/assets/imgs/ydzx.png
Normal file
|
After Width: | Height: | Size: 468 KiB |
BIN
apps/admin/src/assets/imgs/副本第九届“阅读之星”题目示例 (1).xlsx
Normal file
@ -10,10 +10,10 @@ const ContextHolder = defineComponent({
|
||||
name: 'ContextHolder',
|
||||
setup() {
|
||||
function register() {
|
||||
window.$loadingBar = useLoadingBar()
|
||||
window.$dialog = useDialog()
|
||||
window.$message = useMessage()
|
||||
window.$notification = useNotification()
|
||||
window.$loadingBar = useLoadingBar() // 注册全局 loading bar
|
||||
window.$dialog = useDialog()// 注册全局 dialog
|
||||
window.$message = useMessage()// 注册全局 message
|
||||
window.$notification = useNotification() // 注册全局 notification
|
||||
}
|
||||
|
||||
register()
|
||||
|
||||
153
apps/admin/src/components/common/oss-image-upload/index.vue
Normal file
@ -0,0 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
import type { UploadCustomRequestOptions } from 'naive-ui'
|
||||
import { NButton, NSpin, NUpload, useMessage } from 'naive-ui'
|
||||
import { ref } from 'vue'
|
||||
import { getAliOssTokenAxios } from '@/service/api/upload'
|
||||
import { browserPathJoin } from '@/utils/date'
|
||||
import { initOSSClient, uploadFileToOSS } from '@/utils/oss'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 图片地址 */
|
||||
modelValue?: string
|
||||
/** 上传路径 */
|
||||
path?: string // 上传路径,默认 temp/ 目录,同名文件会覆盖
|
||||
/** 提示文字 */
|
||||
hint?: string
|
||||
/** 最大文件大小 (KB) */
|
||||
maxSize?: number
|
||||
/** 接受的文件类型 */
|
||||
accept?: string
|
||||
/** 模式: default-大图上传(带文字), mini-小图上传(仅图标) */
|
||||
variant?: 'default' | 'mini'
|
||||
}>(), {
|
||||
hint: '支持 JPG/PNG 格式',
|
||||
accept: 'image/*',
|
||||
variant: 'default',
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const loading = ref(false)
|
||||
const message = useMessage()
|
||||
|
||||
async function customRequest({ file, onFinish, onError }: UploadCustomRequestOptions) {
|
||||
try {
|
||||
// 校验文件大小
|
||||
if (props.maxSize && file.file) {
|
||||
const sizeKB = file.file.size / 1024
|
||||
if (sizeKB > props.maxSize) {
|
||||
const maxSizeText = props.maxSize >= 1024 ? `${(props.maxSize / 1024).toFixed(2)}MB` : `${props.maxSize}KB`
|
||||
throw new Error(`文件大小不能超过 ${maxSizeText}`)
|
||||
}
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
// 1. 获取上传凭证
|
||||
const { data: tokenData, error: tokenError } = await getAliOssTokenAxios()
|
||||
if (tokenError || !tokenData) {
|
||||
throw new Error('获取上传凭证失败')
|
||||
}
|
||||
|
||||
// 2. 初始化 OSS 客户端
|
||||
const client = initOSSClient(tokenData.data || {})
|
||||
|
||||
// 3. 准备路径
|
||||
let path = props.path || `temp/${Date.now()}`
|
||||
path = browserPathJoin(path, file.name)
|
||||
|
||||
// 4. 上传文件
|
||||
await uploadFileToOSS(client, file.file as File, path)
|
||||
|
||||
// 5. 获取 URL
|
||||
const url = browserPathJoin(import.meta.env.VITE_BASE_OSS_URL, path)
|
||||
|
||||
emit('update:modelValue', url)
|
||||
onFinish()
|
||||
}
|
||||
catch (error: any) {
|
||||
message.error(error.message || '上传失败')
|
||||
onError()
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemove() {
|
||||
emit('update:modelValue', '')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NUpload
|
||||
:accept="accept"
|
||||
:show-file-list="false"
|
||||
:custom-request="customRequest"
|
||||
class="block"
|
||||
:class="{ 'w-full': props.variant === 'default' }"
|
||||
>
|
||||
<div
|
||||
class="relative flex flex-col cursor-pointer items-center justify-center overflow-hidden transition-all hover:bg-gray-100"
|
||||
:class="[
|
||||
props.variant === 'default' ? 'h-[400px] w-full rounded-3xl bg-[#F5F8FF]' : 'h-full w-full bg-[#F5F8FF] rounded-lg',
|
||||
!modelValue && props.variant === 'default' ? 'border-2 border-dashed border-gray-300' : '',
|
||||
!modelValue && props.variant === 'mini' ? 'border border-dashed border-gray-300' : '',
|
||||
]"
|
||||
>
|
||||
<div v-if="loading" class="absolute inset-0 z-50 flex items-center justify-center bg-white/50">
|
||||
<NSpin :size="props.variant === 'mini' ? 'small' : 'large'" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="modelValue"
|
||||
class="group absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity hover:opacity-100"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<!-- NUpload 的 trigger 会自动处理点击事件 -->
|
||||
<NButton v-if="props.variant === 'default'" ghost color="#fff" size="small">
|
||||
更换
|
||||
</NButton>
|
||||
<div v-else class="cursor-pointer text-xs text-white">
|
||||
更换
|
||||
</div>
|
||||
<NButton v-if="props.variant === 'default'" ghost color="#fff" size="small" @click.stop="handleRemove">
|
||||
删除
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<img
|
||||
v-if="modelValue"
|
||||
:src="modelValue"
|
||||
class="h-full w-full object-cover"
|
||||
alt="uploaded"
|
||||
>
|
||||
|
||||
<div v-else class="flex flex-col items-center justify-center text-gray-400">
|
||||
<template v-if="props.variant === 'default'">
|
||||
<slot name="icon">
|
||||
<icon-ic-baseline-upload class="mb-4 text-6xl" />
|
||||
</slot>
|
||||
<div class="text-lg text-gray-600 font-medium">
|
||||
点击上传
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-gray-400">
|
||||
{{ hint }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<icon-ic-baseline-upload class="text-xl" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</NUpload>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.n-upload-trigger) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
488
apps/admin/src/components/common/rest-upload/rest-upload.vue
Normal file
@ -0,0 +1,488 @@
|
||||
<!-- eslint-disable no-console -->
|
||||
<script setup lang="ts"
|
||||
generic="K extends string | string[] | Record<string, unknown> | Record<string, unknown>[] | null | undefined"
|
||||
>
|
||||
import type OSS from 'ali-oss'
|
||||
import type { UploadFileInfo } from 'naive-ui'
|
||||
import type { PropType, Ref } from 'vue'
|
||||
import { useAutoAnimate } from '@formkit/auto-animate/vue'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import { getAliOssTokenAxios } from '@/service/api/upload'
|
||||
import { getOnlyId } from '@/utils/data'
|
||||
import { browserPathJoin } from '@/utils/date'
|
||||
import { fileToBase64, getFirstFrameOfVideo } from '@/utils/file'
|
||||
import { initOSSClient, uploadFileToOSS } from '@/utils/oss'
|
||||
import { FileUploadService } from '@/utils/upload-util'
|
||||
import { isObject } from '@/utils/verify'
|
||||
import { verifyAddIntRoZeroNumb } from '@/utils/verify-number'
|
||||
|
||||
export interface FileItem {
|
||||
/** 唯一 ID */
|
||||
__id: number | string
|
||||
/** 浏览器中的文件对象 */
|
||||
file?: File
|
||||
/** 进度 */
|
||||
percentage: number
|
||||
/** 当前 index */
|
||||
index: number
|
||||
/** 上传状态; 0:正在上传; 1:上传成功; -1:上传失败; */
|
||||
uploadStatus: -1 | 0 | 1
|
||||
/** 错误原因 */
|
||||
errMsg?: string
|
||||
/** url */
|
||||
url: string
|
||||
/** 海报 */
|
||||
poster: string
|
||||
}
|
||||
|
||||
export interface CustomKeys {
|
||||
/** 绑定上传文件链接的 key(即modelValue为对象或对象数组时绑定的上传文件链接的key) */
|
||||
bindFileUrlKey?: string
|
||||
/** 绑定返回海报链接的 key(即modelValue为对象或对象数组时绑定的海报链接的key) */
|
||||
bindPosterUrlKey?: string
|
||||
/** 上传成功后服务器返回链接中文件链接的 key */
|
||||
upFileUrlKey?: string
|
||||
/** 上传成功后服务器返回海报字段的的 key */
|
||||
upPosterUrlKey?: string
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
/** 上传图片的URL */
|
||||
path: { type: String, default: undefined },
|
||||
/** 自定义 key */
|
||||
customKeys: { type: Object as PropType<CustomKeys>, default: (): CustomKeys => ({}) },
|
||||
/** 提示文字 */
|
||||
hint: { type: String, default: '点击上传' },
|
||||
/** 图片对象数组 */
|
||||
modelValue: { type: [Array, String, Object, null, undefined] as PropType<K>, default: () => [] },
|
||||
/** 宽度 */
|
||||
width: { type: [String, Number], default: 100 },
|
||||
/** 高度 */
|
||||
height: { type: [String, Number], default: 100 },
|
||||
/** 最多上传几张图片 */
|
||||
limit: { type: Number, default: 1 },
|
||||
/** 限制可以选择的文件类型(根据后缀限制) 如:['.png', 'jpg'] */
|
||||
accept: { type: Array as PropType<`.${string}`[]>, default: () => ['.png', '.jpg'] },
|
||||
/** 加载状态 */
|
||||
loading: { type: Boolean, default: false },
|
||||
/** 选择图片完成后的回调 */
|
||||
afterSelectFile: { type: Function as PropType<(data: File[]) => File[] | Promise<File[]>>, default: undefined },
|
||||
/** 上传成功后的回调 */
|
||||
afterUpload: { type: Function as PropType<(data: OSS.MultipartUploadResult) => Record<string, unknown> | string>, default: undefined },
|
||||
/** 限制视频最大允许上传多少 kb, */
|
||||
maxVideoSize: { type: Number, default: undefined },
|
||||
/** 限制图片最小允许上传多少 kb,默认 1 */
|
||||
minImageSize: { type: Number, default: 1 },
|
||||
/** 限制图片最大允许上传多少 kb,默认 800 */
|
||||
maxImageSize: { type: Number, default: undefined },
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
(e: 'change', list: FileItem[]): void
|
||||
(e: 'update:loading', state: boolean): void
|
||||
(e: 'update:modelValue', list: any): void
|
||||
(e: 'error', myFile: FileItem): void
|
||||
}>()
|
||||
|
||||
const [parent] = useAutoAnimate()
|
||||
const upLoading = ref(props.loading)
|
||||
|
||||
// 视频预览状态
|
||||
const showVideoPreview = ref(false)
|
||||
const videoPreviewSrc = ref('')
|
||||
const videoPreviewPoster = ref('')
|
||||
|
||||
const bindFileUrlKey = computed(() => props.customKeys.bindFileUrlKey || 'url')
|
||||
const bindPosterUrlKey = computed(() => props.customKeys.bindPosterUrlKey || 'frameUrl')
|
||||
const upFileUrlKey = computed(() => props.customKeys.upFileUrlKey || 'url')
|
||||
const upPosterUrlKey = computed(() => props.customKeys.upPosterUrlKey || 'frameUrl')
|
||||
|
||||
const newWidth = computed(() => (verifyAddIntRoZeroNumb(`${props.width}`) || typeof props.width === 'number' ? `${props.width}px` : props.width))
|
||||
const newHeight = computed(() => (verifyAddIntRoZeroNumb(`${props.height}`) || typeof props.height === 'number' ? `${props.height}px` : props.height))
|
||||
|
||||
/** 全部的文件(包含上传和未上传的) */
|
||||
const fileList: Ref<FileItem[]> = ref(getDefFiles(props.modelValue))
|
||||
/** 得到上传成功的文件(包含本次上传成功和之前上传成功的) */
|
||||
const uploadedList = shallowRef<Record<string, string>[] | string[]>(getUploadedList())
|
||||
/** 最终需要得到文件(根据是多选还是单选整理成需要的文件) */
|
||||
const uploadedFiles = computed(() => (props.limit > 1 ? (uploadedList.value as K) : (uploadedList.value[0] as K)))
|
||||
/** 现在还可以添加多少个 */
|
||||
const efficientNumb = computed(() => props.limit - uploadedList.value.length - fileList.value.length)
|
||||
|
||||
// NUpload 文件列表,用于选择后清除
|
||||
const nUploadFileList = ref<UploadFileInfo[]>([])
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
fileList.value = getDefFiles(val)
|
||||
},
|
||||
)
|
||||
watch(
|
||||
() => upLoading.value,
|
||||
(val) => {
|
||||
emit('update:loading', val)
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => uploadedFiles.value,
|
||||
(val) => {
|
||||
emit('update:modelValue', val)
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* 视频预览
|
||||
*/
|
||||
function previewVideoClick(src: string, poster: string) {
|
||||
if (src) {
|
||||
videoPreviewSrc.value = src
|
||||
videoPreviewPoster.value = poster
|
||||
showVideoPreview.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 (已经上传的)
|
||||
*/
|
||||
function deleteClick(index: number) {
|
||||
fileList.value.splice(index, 1)
|
||||
emit('change', fileList.value)
|
||||
uploadedList.value = getUploadedList()
|
||||
}
|
||||
/**
|
||||
* 重新上传
|
||||
*/
|
||||
async function reUploadClick(index: number) {
|
||||
try {
|
||||
upLoading.value = true
|
||||
await uploadFile(fileList.value[index]!)
|
||||
upLoading.value = false
|
||||
emit('change', fileList.value)
|
||||
uploadedList.value = getUploadedList()
|
||||
}
|
||||
catch (error) {
|
||||
upLoading.value = false
|
||||
uploadedList.value = getUploadedList()
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('重新上传失败====', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置
|
||||
*/
|
||||
function reset() {
|
||||
fileList.value = []
|
||||
uploadedList.value = []
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
*/
|
||||
function handleUploadChange(options: { fileList: UploadFileInfo[] }) {
|
||||
// 获取新添加的文件
|
||||
const newFiles = options.fileList.map(item => item.file).filter(Boolean) as File[]
|
||||
|
||||
// 清除 NUpload 列表以避免重复并允许重新选择
|
||||
nUploadFileList.value = []
|
||||
|
||||
if (newFiles.length > 0) {
|
||||
uploadFileList(newFiles)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理上传所有图片
|
||||
*/
|
||||
async function uploadFileList(files: File[] | null) {
|
||||
try {
|
||||
if (files && files.length > 0) {
|
||||
let processFiles = files
|
||||
if (props.afterSelectFile) {
|
||||
processFiles = await props.afterSelectFile(files)
|
||||
}
|
||||
|
||||
const promiseArr: FileItem[] = []
|
||||
// 使用临时列表以避免循环期间 fileList 发生变化的问题(尽管 push 是安全的)
|
||||
// 但我们需要将其推入 fileList 以立即显示它们
|
||||
for (let i = 0, len = processFiles.length; i < len; i++) {
|
||||
if (efficientNumb.value > 0 && processFiles[i]) {
|
||||
const newItem: FileItem = {
|
||||
file: processFiles[i]!,
|
||||
percentage: 0,
|
||||
uploadStatus: 0,
|
||||
index: fileList.value.length,
|
||||
__id: getOnlyId(),
|
||||
url: '',
|
||||
poster: '',
|
||||
}
|
||||
fileList.value.push(newItem)
|
||||
// 必须获取响应式对象,否则进度条不会更新
|
||||
promiseArr.push(fileList.value[fileList.value.length - 1])
|
||||
}
|
||||
}
|
||||
|
||||
if (promiseArr.length > 0) {
|
||||
upLoading.value = true
|
||||
const uploadService = new FileUploadService(uploadFile, 5)
|
||||
await uploadService.upload(promiseArr)
|
||||
upLoading.value = false
|
||||
emit('change', fileList.value)
|
||||
uploadedList.value = getUploadedList()
|
||||
}
|
||||
else {
|
||||
window.$message?.warning('暂无可上传文件或已达上限')
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
uploadedList.value = getUploadedList()
|
||||
window.$message?.error('上传失败')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
*/
|
||||
async function uploadFile(myFile: FileItem): Promise<FileItem> {
|
||||
try {
|
||||
const fileSize = myFile.file!.size / 1024
|
||||
if (myFile.file!.type.includes('image')) {
|
||||
if (typeof props.maxImageSize === 'number' && fileSize > props.maxImageSize) {
|
||||
const msg = getRestrictSizeMsg(props.maxImageSize, 'max')
|
||||
myFile.uploadStatus = -1
|
||||
myFile.errMsg = msg
|
||||
return Promise.reject(new Error(msg))
|
||||
}
|
||||
else if (fileSize < props.minImageSize) {
|
||||
const msg = getRestrictSizeMsg(props.minImageSize, 'min')
|
||||
myFile.uploadStatus = -1
|
||||
myFile.errMsg = msg
|
||||
return Promise.reject(new Error(msg))
|
||||
}
|
||||
}
|
||||
if (typeof props.maxVideoSize === 'number' && myFile.file!.type.includes('video') && fileSize > props.maxVideoSize) {
|
||||
const msg = getRestrictSizeMsg(props.maxVideoSize, 'max')
|
||||
myFile.uploadStatus = -1
|
||||
myFile.errMsg = msg
|
||||
return Promise.reject(new Error(msg))
|
||||
}
|
||||
const { data: tokenData, error: tokenError } = await getAliOssTokenAxios()
|
||||
if (tokenError || !tokenData) {
|
||||
throw new Error('获取上传凭证失败')
|
||||
}
|
||||
// 初始化OSS客户端
|
||||
const client = initOSSClient(tokenData?.data || {})
|
||||
let path = typeof props.path === 'string' ? '' : `temp/${Date.now()}`
|
||||
path = browserPathJoin(path, myFile.file!.name)
|
||||
// 上传文件
|
||||
const res: OSS.MultipartUploadResult = await uploadFileToOSS(client, myFile.file!, path, (progress) => {
|
||||
myFile.percentage = Math.floor((progress || 0) * 10000) / 100
|
||||
})
|
||||
const url = browserPathJoin(import.meta.env.VITE_BASE_OSS_URL, path)
|
||||
const data = typeof props.afterUpload === 'function' ? props.afterUpload(res) : res
|
||||
if (isObject(data)) {
|
||||
const newData = data as Record<string, string>
|
||||
myFile.url = upFileUrlKey.value in newData ? String(newData[upFileUrlKey.value] || url) : url
|
||||
myFile.poster = upPosterUrlKey.value in newData ? String(newData[upPosterUrlKey.value] || '') : ''
|
||||
if (myFile.file!.type.includes('video') && !myFile.poster) {
|
||||
try {
|
||||
const info = await getFirstFrameOfVideo(myFile.file!)
|
||||
myFile.poster = (await fileToBase64(info.firstFrame)).img || ''
|
||||
}
|
||||
catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('error====', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
myFile.url = url
|
||||
}
|
||||
myFile.uploadStatus = 1
|
||||
return Promise.resolve(myFile)
|
||||
}
|
||||
catch (error: any) {
|
||||
console.log('上传错误====', error)
|
||||
myFile.uploadStatus = -1
|
||||
myFile.errMsg = typeof error.msg === 'string' ? error.msg : '上传失败'
|
||||
emit('error', error)
|
||||
return Promise.reject(new Error(myFile.errMsg))
|
||||
}
|
||||
}
|
||||
|
||||
/** 得到初始已上传的文件 */
|
||||
function getDefFiles(data: unknown): FileItem[] {
|
||||
const val: unknown[] = Array.isArray(data) ? data : data ? [data] : []
|
||||
const newVal: FileItem[] = val.map((item, index: number) => {
|
||||
const _item: FileItem = isObject(item)
|
||||
? {
|
||||
url: String(item[bindFileUrlKey.value] || ''),
|
||||
poster: String(item[bindPosterUrlKey.value] || ''),
|
||||
uploadStatus: 1,
|
||||
index,
|
||||
__id: getOnlyId(),
|
||||
percentage: 0,
|
||||
}
|
||||
: { url: String(item), poster: '', uploadStatus: 1, index, __id: getOnlyId(), percentage: 0 }
|
||||
return _item
|
||||
})
|
||||
return newVal
|
||||
}
|
||||
/** 得到已上传成功的文件 */
|
||||
function getUploadedList() {
|
||||
const list = fileList.value.filter(item => item.uploadStatus === 1 && item.url)
|
||||
// 不能用 bindFileUrlKey 代替
|
||||
if (props.customKeys.bindFileUrlKey) {
|
||||
return list.map((item) => {
|
||||
const obj: Record<string, string> = {}
|
||||
obj[bindFileUrlKey.value] = item.url
|
||||
obj[bindPosterUrlKey.value] = item.poster
|
||||
return obj
|
||||
})
|
||||
}
|
||||
else {
|
||||
return list.map(item => item.url)
|
||||
}
|
||||
}
|
||||
/** 得到不符合大小限制的提示语 */
|
||||
function getRestrictSizeMsg(maxSize: number, type: 'max' | 'min') {
|
||||
const maxSizeText = maxSize >= 1024 ? `${Number((maxSize / 1024).toFixed(2))}M` : `${maxSize}KB`
|
||||
return type === 'max' ? `文件大小不能超过${maxSizeText}` : `文件大小不能小于${maxSizeText}`
|
||||
}
|
||||
|
||||
defineExpose({ reset, getLoadState: () => upLoading.value })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rest-upload-container">
|
||||
<NImageGroup>
|
||||
<div ref="parent" class="flex flex-wrap gap-4">
|
||||
<!-- 文件列表 -->
|
||||
<div
|
||||
v-for="(item, index) in fileList" :key="item.__id"
|
||||
class="group relative overflow-hidden border border-gray-200 rounded-lg transition-all duration-300 hover:border-primary"
|
||||
:style="{ width: newWidth, height: newHeight }"
|
||||
>
|
||||
<!-- 图片预览 -->
|
||||
<div
|
||||
v-if="!item.url.includes('.mp4') && !item.url.includes('.webm') && (item.file?.type?.includes('image') || !item.file)"
|
||||
class="h-full w-full flex items-center justify-center bg-gray-50"
|
||||
>
|
||||
<NImage
|
||||
:src="item.url" class="h-full w-full flex items-center justify-center"
|
||||
:img-props="{ style: { width: '100%', height: '100%', objectFit: 'contain' } }" object-fit="cover"
|
||||
>
|
||||
<template #error>
|
||||
<div class="h-full w-full flex items-center justify-center bg-gray-50 text-gray-400">
|
||||
<SvgIcon icon="mdi:image-broken-variant" class="text-2xl" />
|
||||
</div>
|
||||
</template>
|
||||
</NImage>
|
||||
</div>
|
||||
|
||||
<!-- 视频预览 -->
|
||||
<div
|
||||
v-else class="h-full w-full flex items-center justify-center bg-black bg-cover bg-center bg-no-repeat"
|
||||
:style="{ backgroundImage: item.poster ? `url(${item.poster})` : undefined }"
|
||||
>
|
||||
<SvgIcon icon="mdi:play-circle-outline" class="text-4xl text-white opacity-80" />
|
||||
</div>
|
||||
|
||||
<!-- 进度覆盖层 -->
|
||||
<div
|
||||
v-if="item.uploadStatus === 0"
|
||||
class="absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/50 p-1"
|
||||
>
|
||||
<NProgress
|
||||
type="circle" :percentage="item.percentage" :color="{ stops: ['#E3F2FD', '#2080f0'] }"
|
||||
style="width: 60px; height: 60px"
|
||||
>
|
||||
<template #default>
|
||||
<span class="text-xs text-white">{{ item.percentage }}%</span>
|
||||
</template>
|
||||
</NProgress>
|
||||
</div>
|
||||
|
||||
<!-- 错误覆盖层 -->
|
||||
<div
|
||||
v-if="item.uploadStatus === -1"
|
||||
class="absolute inset-0 z-20 flex flex-col items-center justify-center bg-red-50/90 p-2 text-center"
|
||||
>
|
||||
<span class="mb-1 text-xs text-red-500">{{ item.errMsg || '上传失败' }}</span>
|
||||
<NButton size="tiny" type="error" ghost @click="reUploadClick(index)">
|
||||
重试
|
||||
</NButton>
|
||||
<div class="absolute right-1 top-1 cursor-pointer" @click="deleteClick(index)">
|
||||
<SvgIcon icon="mdi:close" class="text-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作覆盖层(悬停) -->
|
||||
<div
|
||||
v-if="item.uploadStatus === 1"
|
||||
class="pointer-events-none absolute inset-0 z-10 flex items-center justify-center gap-2 bg-black/0 opacity-0 transition-all duration-300 group-hover:bg-black/50 group-hover:opacity-100"
|
||||
>
|
||||
<NButton
|
||||
v-if="item.url.includes('.mp4') || item.url.includes('.webm') || item.file?.type?.includes('video')"
|
||||
circle secondary type="info" class="pointer-events-auto"
|
||||
@click="previewVideoClick(item.url, item.poster)"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon icon="mdi:eye" />
|
||||
</template>
|
||||
</NButton>
|
||||
<NButton v-else circle secondary type="info" class="pointer-events-none">
|
||||
<template #icon>
|
||||
<SvgIcon icon="mdi:eye" />
|
||||
</template>
|
||||
</NButton>
|
||||
|
||||
<NButton circle secondary type="error" class="pointer-events-auto" @click="deleteClick(index)">
|
||||
<template #icon>
|
||||
<SvgIcon icon="mdi:delete" />
|
||||
</template>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上传触发器 -->
|
||||
<div v-if="efficientNumb > 0" :style="{ width: newWidth, height: newHeight }">
|
||||
<NUpload
|
||||
:accept="accept.join(',')" :multiple="limit > 1" :max="limit" :show-file-list="false"
|
||||
:file-list="nUploadFileList" :default-upload="false" class="h-full w-full" @change="handleUploadChange"
|
||||
>
|
||||
<template #default>
|
||||
<div
|
||||
class="h-full w-full flex flex-col cursor-pointer items-center justify-center border border-gray-300 rounded-lg border-dashed bg-gray-50 transition-colors hover:border-primary hover:bg-primary-50"
|
||||
:style="{ width: newWidth, height: newHeight }"
|
||||
>
|
||||
<SvgIcon icon="mdi:plus" class="text-3xl text-gray-400" />
|
||||
<span class="mt-1 text-xs text-gray-400">{{ hint }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</NUpload>
|
||||
</div>
|
||||
</div>
|
||||
</NImageGroup>
|
||||
|
||||
<!-- 视频预览弹窗 -->
|
||||
<NModal v-model:show="showVideoPreview" preset="card" title="视频预览" class="max-w-full w-[800px]">
|
||||
<div class="flex items-center justify-center rounded-lg bg-black/5 p-4">
|
||||
<video
|
||||
v-if="videoPreviewSrc" :src="videoPreviewSrc" :poster="videoPreviewPoster" controls
|
||||
class="max-h-[600px] max-w-full w-auto"
|
||||
/>
|
||||
</div>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rest-upload-container {
|
||||
/* 确保容器不会塌陷 */
|
||||
min-height: v-bind(newHeight);
|
||||
}
|
||||
</style>
|
||||
128
apps/admin/src/components/common/wang-editor.vue
Normal file
@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
import { nextTick, onBeforeUnmount, onMounted, shallowRef } from 'vue'
|
||||
import { getAliOssTokenAxios } from '@/service/api/upload'
|
||||
import { browserPathJoin } from '@/utils/date'
|
||||
import { initOSSClient, uploadFileToOSS } from '@/utils/oss'
|
||||
import '@wangeditor/editor/dist/css/style.css'
|
||||
|
||||
interface Props {
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
mode?: 'default' | 'simple'
|
||||
height?: string
|
||||
path?: string
|
||||
excludeKeys?: string[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: '',
|
||||
placeholder: '请输入内容...',
|
||||
mode: 'default',
|
||||
height: '300px',
|
||||
path: 'temp',
|
||||
excludeKeys: () => [],
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
// Editor Logic
|
||||
const editorRef = shallowRef()
|
||||
const toolbarConfig = {
|
||||
excludeKeys: props.excludeKeys || [],
|
||||
}
|
||||
|
||||
type InsertFnType = (url: string, alt: string, href: string) => void
|
||||
|
||||
async function customUpload(file: File, insertFn: InsertFnType) {
|
||||
try {
|
||||
const { data: tokenData, error: tokenError } = await getAliOssTokenAxios()
|
||||
if (tokenError || !tokenData) {
|
||||
window.$message?.error('获取上传凭证失败')
|
||||
return
|
||||
}
|
||||
const client = initOSSClient(tokenData?.data || {})
|
||||
const path = `${props.path}/${Date.now()}/${file.name}`
|
||||
|
||||
await uploadFileToOSS(client, file, path)
|
||||
|
||||
const url = browserPathJoin(import.meta.env.VITE_BASE_OSS_URL, path)
|
||||
insertFn(url, file.name, url)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('上传失败', error)
|
||||
window.$message?.error('上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
const editorConfig = {
|
||||
placeholder: props.placeholder,
|
||||
MENU_CONF: {
|
||||
uploadImage: {
|
||||
customUpload,
|
||||
},
|
||||
uploadVideo: {
|
||||
customUpload,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// 监听 placeholder 变化
|
||||
// watch(() => props.placeholder, (_newVal) => {
|
||||
// if (editorRef.value) {
|
||||
// // 似乎 wangeditor 不支持动态修改 placeholder,这里作为占位
|
||||
// }
|
||||
// })
|
||||
onMounted(() => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(toolbarConfig, 'toolbarConfig')
|
||||
nextTick(() => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(editorRef.value)
|
||||
})
|
||||
})
|
||||
|
||||
function handleCreated(editor: any) {
|
||||
editorRef.value = editor
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Toolbar keys:', editor.getAllMenuKeys())
|
||||
}
|
||||
|
||||
function handleChange(editor: any) {
|
||||
emit('update:modelValue', editor.getHtml())
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const editor = editorRef.value
|
||||
if (editor == null)
|
||||
return
|
||||
editor.destroy()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overflow-hidden border border-gray-200 rounded-lg bg-white">
|
||||
<Toolbar
|
||||
style="border-bottom: 1px solid #eee"
|
||||
:editor="editorRef"
|
||||
:default-config="toolbarConfig"
|
||||
:mode="mode"
|
||||
/>
|
||||
<div :style="{ height: props.height }">
|
||||
<Editor
|
||||
:model-value="modelValue"
|
||||
:style="{ height: '100%', overflowY: 'hidden' }"
|
||||
:default-config="editorConfig"
|
||||
:mode="mode"
|
||||
@on-created="handleCreated"
|
||||
@on-change="handleChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.w-e-text-container) {
|
||||
background-color: transparent;
|
||||
}
|
||||
</style>
|
||||
126
apps/admin/src/components/custom/README.md
Normal file
@ -0,0 +1,126 @@
|
||||
# SvgIcon 图标组件使用指南
|
||||
|
||||
`SvgIcon` 是本项目统一使用的图标组件,底层基于 `@iconify/vue`,支持渲染 **Iconify 开源图标** 和 **本地 SVG 图标**。
|
||||
|
||||
## 1. 快速开始
|
||||
|
||||
### 1.1 使用 Iconify 图标 (推荐)
|
||||
|
||||
本项目集成了 [Iconify](https://iconify.design/),可直接使用海量开源图标库(如 Material Design, Carbon, Phosphor 等)。
|
||||
|
||||
**语法**:
|
||||
|
||||
```vue
|
||||
<SvgIcon icon="图集名:图标名" />
|
||||
```
|
||||
|
||||
**示例**:
|
||||
|
||||
```vue
|
||||
<!-- Material Design Icons -->
|
||||
<SvgIcon icon="mdi:home" class="text-xl text-blue-500" />
|
||||
|
||||
<!-- Carbon Icons -->
|
||||
<SvgIcon icon="carbon:user" />
|
||||
```
|
||||
|
||||
### 1.2 使用本地 SVG 图标
|
||||
|
||||
当需要使用设计师提供的自定义图标或彩色图标时。
|
||||
|
||||
1. **存放**:将 `.svg` 文件放入 `src/assets/svg-icon/` 目录。
|
||||
2. **引用**:使用 `local-icon` 属性引用文件名(不含 `.svg` 后缀)。
|
||||
|
||||
**语法**:
|
||||
|
||||
```vue
|
||||
<SvgIcon local-icon="文件名" />
|
||||
```
|
||||
|
||||
**示例**:
|
||||
假设文件位于 `src/assets/svg-icon/custom-logo.svg`:
|
||||
|
||||
```vue
|
||||
<SvgIcon local-icon="custom-logo" class="text-32px" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 属性说明 (Props)
|
||||
|
||||
该组件定义在 `src/components/custom/svg-icon.vue`。
|
||||
|
||||
| 属性名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
| :---------- | :------- | :--- | :----- | :------------------------------------------- |
|
||||
| `icon` | `string` | 否 | - | Iconify 图标名称,格式为 `collection:name`。 |
|
||||
| `localIcon` | `string` | 否 | - | 本地 SVG 文件名。**优先级高于 `icon`**。 |
|
||||
|
||||
> **提示**:
|
||||
>
|
||||
> - 组件设置了 `inheritAttrs: false`,但会手动绑定 `class` 和 `style` 到根元素。
|
||||
> - 你可以通过 Tailwind CSS 类名(如 `text-xl`, `text-red-500`)直接控制图标的大小和颜色。
|
||||
|
||||
---
|
||||
|
||||
## 3. 如何查找与使用图标
|
||||
|
||||
推荐使用 [Icones.js.org](https://icones.js.org/) 图标搜索引擎。
|
||||
|
||||
### 3.1 查找步骤
|
||||
|
||||
1. 打开 [Icones.js.org](https://icones.js.org/)。
|
||||
2. 输入关键词搜索(如 `user`, `setting`)。
|
||||
3. 点击选中的图标,复制底部的 **ID**(例如 `mdi:shield-airplane-outline`)。
|
||||
|
||||
### 3.2 使用示例对照
|
||||
|
||||
| Icones 图标 ID | 在本项目中的写法 (推荐) |
|
||||
| :--------------------------------------------- | :---------------------------------------------------------------- |
|
||||
| `mdi:shield-airplane-outline` | `<SvgIcon icon="mdi:shield-airplane-outline" />` |
|
||||
| `material-symbols:android-wifi-4-bar-question` | `<SvgIcon icon="material-symbols:android-wifi-4-bar-question" />` |
|
||||
| `solar:minimize-square-minimalistic-outline` | `<SvgIcon icon="solar:minimize-square-minimalistic-outline" />` |
|
||||
| `tabler:align-box-top-right` | `<SvgIcon icon="tabler:align-box-top-right" />` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 常见问题与进阶
|
||||
|
||||
### 4.1 什么是 `icon-ic-baseline-refresh`?
|
||||
|
||||
在项目中(如 `competition-add/index.vue`)你可能会看到这种写法:
|
||||
|
||||
```vue
|
||||
<icon-ic-baseline-refresh class="text-icon" />
|
||||
```
|
||||
|
||||
这是 `unplugin-icons` 插件提供的**自动组件导入**功能。
|
||||
|
||||
- **命名规则**:`{Prefix}-{Collection}-{Name}`
|
||||
- **配置来源**:`.env` 文件中的 `VITE_ICON_PREFIX=icon`。
|
||||
- **解析**:`icon-ic-baseline-refresh` 对应图集 `ic` 下的 `baseline-refresh` 图标。
|
||||
|
||||
**建议**:虽然支持这种写法,但为了统一性和灵活性(支持动态变量),**推荐统一使用 `<SvgIcon />` 组件**。
|
||||
|
||||
### 4.2 在 Render 函数中使用 (TSX)
|
||||
|
||||
在 Naive UI 的 `NTree`, `NDataTable` 或 `NMenu` 等需要渲染函数的场景中:
|
||||
|
||||
```ts
|
||||
import { h } from 'vue'
|
||||
import SvgIcon from '@/components/custom/svg-icon.vue'
|
||||
|
||||
// 渲染 Iconify 图标
|
||||
function renderIcon() {
|
||||
return h(SvgIcon, {
|
||||
icon: 'carbon:folder',
|
||||
class: 'text-gray-500'
|
||||
})
|
||||
}
|
||||
|
||||
// 渲染本地图标
|
||||
function renderLocalIcon() {
|
||||
return h(SvgIcon, {
|
||||
localIcon: 'custom-logo'
|
||||
})
|
||||
}
|
||||
```
|
||||
@ -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>
|
||||
|
||||
|
||||
85
apps/admin/src/components/custom/typeit/index.vue
Normal file
@ -0,0 +1,85 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Options } from 'typeit'
|
||||
import type { El } from 'typeit/dist/types'
|
||||
import TypeIt from 'typeit'
|
||||
import { onBeforeUnmount, onMounted, shallowRef, watch } from 'vue'
|
||||
|
||||
defineOptions({ name: 'TypeIt' })
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
speed: 100,
|
||||
loop: false,
|
||||
cursor: true,
|
||||
lifeLike: true,
|
||||
strings: () => [],
|
||||
})
|
||||
|
||||
interface Props {
|
||||
/** 打字速度 */
|
||||
speed?: number
|
||||
/** 是否循环 */
|
||||
loop?: boolean
|
||||
/** 是否显示光标 */
|
||||
cursor?: boolean
|
||||
/** 打字内容 */
|
||||
strings?: string | string[]
|
||||
/** 像人一样打字 */
|
||||
lifeLike?: boolean
|
||||
/** 开始前的延迟 */
|
||||
startDelay?: number
|
||||
/** 下一段文字的延迟 */
|
||||
nextStringDelay?: number
|
||||
/** 循环延迟 */
|
||||
loopDelay?: number
|
||||
/** 兼容旧代码或用户自定义延迟参数 */
|
||||
typeDelay?: number
|
||||
}
|
||||
|
||||
const textRef = shallowRef<El>()
|
||||
const typeItInstance = shallowRef<TypeIt | null>(null)
|
||||
|
||||
function init() {
|
||||
if (!textRef.value)
|
||||
return
|
||||
|
||||
if (typeItInstance.value) {
|
||||
typeItInstance.value.destroy()
|
||||
}
|
||||
|
||||
// 清空内容,防止重叠
|
||||
textRef.value.textContent = ''
|
||||
|
||||
const options: Options = {
|
||||
strings: props.strings,
|
||||
lifeLike: props.lifeLike,
|
||||
speed: props.speed,
|
||||
loop: props.loop,
|
||||
cursor: props.cursor,
|
||||
startDelay: props.startDelay,
|
||||
nextStringDelay: props.nextStringDelay ?? props.typeDelay,
|
||||
loopDelay: props.loopDelay,
|
||||
}
|
||||
|
||||
typeItInstance.value = new TypeIt(textRef.value, options).go()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
init()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (typeItInstance.value) {
|
||||
typeItInstance.value.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => [props.strings, props.speed, props.loop], () => {
|
||||
init()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span ref="textRef" class="typeit-container" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
161
apps/admin/src/components/custom/user/ActionButton.vue
Normal file
@ -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>
|
||||
527
apps/admin/src/components/custom/user/CompetitionLayout.vue
Normal file
@ -0,0 +1,527 @@
|
||||
<!-- eslint-disable unused-imports/no-unused-vars -->
|
||||
<script lang="ts" setup>
|
||||
import VScaleScreen from 'v-scale-screen'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import cqLogo from '@/assets/imgs/logo/cq-logo.webp'
|
||||
import srLogo from '@/assets/imgs/logo/qw-logo.webp'
|
||||
import qwLogo from '@/assets/imgs/logo/text-logo.webp'
|
||||
import _defaultBg from '@/assets/imgs/user/home-bg1.webp'
|
||||
import titleBg2 from '@/assets/imgs/user/title-bg2.webp'
|
||||
import titleBg3 from '@/assets/imgs/user/title-bg3.png'
|
||||
import titleBg4 from '@/assets/imgs/user/title-bg4.png'
|
||||
import _defaultTitleBg from '@/assets/imgs/user/title-bg.webp'
|
||||
import { useRouterPush } from '@/hooks/common/router'
|
||||
|
||||
import ActionButton from './ActionButton.vue'
|
||||
|
||||
interface Props {
|
||||
/** 是否显示返回按钮 */
|
||||
showBack?: boolean
|
||||
/** 是否显示下一步按钮 */
|
||||
showNext?: boolean
|
||||
/** 背景图片URL,如果不传则尝试内部获取或使用默认 */
|
||||
bgUrl?: string
|
||||
/** 下一步按钮的文本,如果存在则显示为文字按钮,否则显示为图标按钮 */
|
||||
nextBtnText?: string
|
||||
/** 页面标题 */
|
||||
title?: string
|
||||
/** 是否显示标题 */
|
||||
showTitle?: boolean
|
||||
/** 按钮操作栏位置:'bottom' | 'top',默认 'bottom' */
|
||||
actionPosition?: 'bottom' | 'top'
|
||||
/** 返回按钮的文本,如果存在则显示为文字按钮,否则显示为图标按钮 */
|
||||
backBtnText?: string
|
||||
/** 按钮主题:'primary' (红色填充) | 'light' (白色背景红字),默认 'primary' */
|
||||
btnTheme?: 'primary' | 'light'
|
||||
/** 是否禁用下一步按钮 */
|
||||
nextDisabled?: boolean
|
||||
/** 标题区域背景图片URL,不传则使用默认 title-bg.webp */
|
||||
titleBgType?: number
|
||||
/** 是否显示第二个下一步按钮 */
|
||||
showNextBtn2?: boolean
|
||||
/** 第二个按钮的文本 */
|
||||
nextBtnText2?: string
|
||||
/** title 颜色 */
|
||||
titleTextColor?: string
|
||||
/* 按钮字体大小 */
|
||||
fontSize?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showBack: true,
|
||||
showNext: true,
|
||||
bgUrl: undefined,
|
||||
nextBtnText: undefined,
|
||||
title: undefined,
|
||||
showTitle: true,
|
||||
actionPosition: 'bottom',
|
||||
backBtnText: undefined,
|
||||
btnTheme: 'primary',
|
||||
nextDisabled: false,
|
||||
titleBgType: 1,
|
||||
showNextBtn2: false,
|
||||
nextBtnText2: undefined,
|
||||
titleTextColor: '#000000',
|
||||
fontSize: 30,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'back'): void
|
||||
(e: 'next'): void
|
||||
(e: 'next2'): void
|
||||
}>()
|
||||
|
||||
const titleBgUrlMapping: Record<number, string> = {
|
||||
1: _defaultTitleBg,
|
||||
2: titleBg2,
|
||||
3: titleBg3,
|
||||
4: titleBg4,
|
||||
}
|
||||
|
||||
const { routerPushByKey } = useRouterPush()
|
||||
|
||||
const titleBgUrl = computed(() => titleBgUrlMapping[props.titleBgType] || _defaultBg)
|
||||
const titleBgLoaded = ref(false)
|
||||
const currentTitleBg = ref('')
|
||||
const imageLoadStatus = ref<Record<string, boolean>>({})
|
||||
|
||||
// const router = useRouter()
|
||||
const innerBgUrl = ref(_defaultBg)
|
||||
|
||||
async function fetchSystemConfig() {
|
||||
// 如果外部传入了 bgUrl,则优先使用外部的
|
||||
if (props.bgUrl) {
|
||||
innerBgUrl.value = props.bgUrl
|
||||
}
|
||||
|
||||
// 模拟接口请求系统配置
|
||||
// const res = await fetchConfig()
|
||||
// if (res.bgUrl) innerBgUrl.value = res.bgUrl
|
||||
|
||||
// 这里的逻辑保持与原 Home 页面一致:如果没有配置,则使用默认
|
||||
if (!innerBgUrl.value)
|
||||
innerBgUrl.value = _defaultBg
|
||||
}
|
||||
|
||||
const showContent = ref(false)
|
||||
const showSlotContent = ref(false)
|
||||
const showButton = ref(false)
|
||||
|
||||
function handleBack() {
|
||||
emit('back')
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
if (props.nextDisabled)
|
||||
return
|
||||
emit('next')
|
||||
}
|
||||
|
||||
function handleNext2() {
|
||||
emit('next2')
|
||||
}
|
||||
|
||||
/** 点击返回首页 */
|
||||
function handleBackHome() {
|
||||
routerPushByKey('user_home')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 预加载所有标题背景图,并跟踪加载状态
|
||||
const preloadAllImages = () => {
|
||||
const promises = Object.entries(titleBgUrlMapping).map(([_key, url]) => {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
imageLoadStatus.value[url] = true
|
||||
resolve(true)
|
||||
}
|
||||
img.onerror = () => {
|
||||
imageLoadStatus.value[url] = false
|
||||
resolve(false)
|
||||
}
|
||||
img.src = url
|
||||
})
|
||||
})
|
||||
|
||||
// 也预加载默认背景
|
||||
const defaultBgPromise = new Promise((resolve) => {
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
imageLoadStatus.value[_defaultBg] = true
|
||||
resolve(true)
|
||||
}
|
||||
img.onerror = () => {
|
||||
imageLoadStatus.value[_defaultBg] = false
|
||||
resolve(false)
|
||||
}
|
||||
img.src = _defaultBg
|
||||
})
|
||||
|
||||
return Promise.all([...promises, defaultBgPromise])
|
||||
}
|
||||
|
||||
// 开始预加载
|
||||
preloadAllImages()
|
||||
.then(() => {
|
||||
// 所有图片预加载完成后,设置当前背景
|
||||
currentTitleBg.value = titleBgUrl.value
|
||||
titleBgLoaded.value = true
|
||||
|
||||
fetchSystemConfig()
|
||||
setTimeout(() => {
|
||||
showContent.value = true
|
||||
}, 100)
|
||||
})
|
||||
.catch(() => {
|
||||
// 即使预加载失败也继续
|
||||
currentTitleBg.value = titleBgUrl.value
|
||||
titleBgLoaded.value = true
|
||||
|
||||
fetchSystemConfig()
|
||||
setTimeout(() => {
|
||||
showContent.value = true
|
||||
}, 100)
|
||||
})
|
||||
|
||||
// 延迟显示内容,等待标题卷轴展开
|
||||
const delay = props.showTitle ? 500 : 100
|
||||
setTimeout(() => {
|
||||
showSlotContent.value = true
|
||||
}, delay)
|
||||
|
||||
// 延迟显示按钮,等待内容显示
|
||||
setTimeout(() => {
|
||||
showButton.value = true
|
||||
}, delay + 500)
|
||||
})
|
||||
|
||||
// 监听 titleBgUrl 变化,确保图片切换平滑
|
||||
watch(titleBgUrl, (newUrl) => {
|
||||
if (newUrl && imageLoadStatus.value[newUrl]) {
|
||||
// 如果图片已经加载过,直接切换
|
||||
currentTitleBg.value = newUrl
|
||||
}
|
||||
else if (newUrl) {
|
||||
// 如果图片还没加载,预加载它
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
imageLoadStatus.value[newUrl] = true
|
||||
currentTitleBg.value = newUrl
|
||||
}
|
||||
img.onerror = () => {
|
||||
imageLoadStatus.value[newUrl] = false
|
||||
// 加载失败时使用默认背景
|
||||
currentTitleBg.value = _defaultBg
|
||||
}
|
||||
img.src = newUrl
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="competition-layout-wrapper">
|
||||
<!-- Background Layer (Outside Scale to cover full screen) -->
|
||||
<div class="fixed-bg" :style="innerBgUrl ? { backgroundImage: `url(${innerBgUrl})` } : {}" />
|
||||
|
||||
<VScaleScreen width="2048" height="1080">
|
||||
<div class="competition-layout">
|
||||
<!-- Header -->
|
||||
<header class="page-header flex items-center px-8 text-[30px] text-[#333333] font-bold">
|
||||
<!-- 左侧logo -->
|
||||
<div class="flex cursor-pointer items-center" @click="handleBackHome">
|
||||
<img :src="cqLogo" alt="重庆logo" class="h-20 w-auto">
|
||||
</div>
|
||||
|
||||
<!-- 中间文字 -->
|
||||
<div class="flex flex-1 justify-center">
|
||||
<img :src="qwLogo" alt="重庆logo" class="h-14 w-auto">
|
||||
</div>
|
||||
|
||||
<!-- 右侧logo组 -->
|
||||
<div class="flex items-center justify-end gap-4">
|
||||
<img :src="srLogo" alt="树人logo" class="h-19 w-auto">
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="glass-overlay">
|
||||
<!-- title -->
|
||||
<section v-if="showTitle" class="title-section">
|
||||
<Transition name="scroll-unfold" appear>
|
||||
<div v-if="showContent" class="title-wrapper">
|
||||
<div class="scroll-bg">
|
||||
<!-- 预渲染所有背景图,用 opacity 切换避免加载抖动 -->
|
||||
<img
|
||||
v-for="(url, _key) in titleBgUrlMapping" :key="_key" :src="url" class="scroll-bg-img" :class="{
|
||||
active: titleBgUrl === url && imageLoadStatus[url] !== false,
|
||||
loading: imageLoadStatus[url] === undefined,
|
||||
}" :style="{ visibility: imageLoadStatus[url] === false ? 'hidden' : 'visible' }" aria-hidden="true"
|
||||
@load="imageLoadStatus[url] = true" @error="imageLoadStatus[url] = false"
|
||||
>
|
||||
<Transition name="fade-in-delay" appear>
|
||||
<h1 v-if="showContent" class="main-title" :style="{ color: titleTextColor }">
|
||||
{{ title }}
|
||||
</h1>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</section>
|
||||
|
||||
<!-- Main Content Slot -->
|
||||
<main class="main-content">
|
||||
<div v-if="showSlotContent" class="h-full w-full flex flex-col items-center justify-center">
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Footer Action -->
|
||||
<footer
|
||||
v-if="showButton" class="page-footer w-full flex items-center justify-between px-20"
|
||||
:class="[actionPosition, btnTheme]"
|
||||
>
|
||||
<div class="left">
|
||||
<ActionButton
|
||||
class="back-btn" :style="{ visibility: showBack ? 'visible' : 'hidden' }" type="custom"
|
||||
:text="backBtnText || ''" :theme="btnTheme" :font-size="fontSize" @click="handleBack"
|
||||
>
|
||||
<SvgIcon v-if="!backBtnText" icon="mdi:arrow-left" size="40" class="arrow-icon" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
<div class="right">
|
||||
<ActionButton
|
||||
v-if="showNext" type="custom" :text="nextBtnText || '下一步'" :theme="btnTheme"
|
||||
:disabled="nextDisabled" :font-size="fontSize" @click="handleNext"
|
||||
>
|
||||
<SvgIcon v-if="!nextBtnText" icon="mdi:arrow-right" class="arrow-icon" size="40" />
|
||||
</ActionButton>
|
||||
|
||||
<ActionButton
|
||||
v-if="showNextBtn2" :type="nextBtnText2 ? 'text' : 'button'" :text="nextBtnText2 || '下一步'"
|
||||
:theme="btnTheme" :disabled="nextDisabled" @click="handleNext2"
|
||||
>
|
||||
<SvgIcon v-if="!nextBtnText2" icon="mdi:arrow-right" class="arrow-icon" size="40" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</VScaleScreen>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.title-section {
|
||||
margin: 10px auto;
|
||||
width: 412px;
|
||||
height: 165px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
/* Ensure wrapper stays centered during expansion */
|
||||
|
||||
.title-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
/* Keep the background image centered */
|
||||
}
|
||||
|
||||
.scroll-bg {
|
||||
width: 400px !important;
|
||||
/* Fixed width to prevent squishing during animation */
|
||||
height: 155px !important;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-bottom: 10px;
|
||||
flex-shrink: 0;
|
||||
/* Prevent shrinking */
|
||||
|
||||
.scroll-bg-img {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
pointer-events: none;
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.loading {
|
||||
opacity: 0;
|
||||
filter: blur(2px);
|
||||
}
|
||||
}
|
||||
|
||||
.main-title {
|
||||
font-size: 2.5rem;
|
||||
color: $text-color;
|
||||
margin: 0;
|
||||
letter-spacing: 4px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
/* Prevent text wrapping */
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.competition-layout-wrapper {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background-color: #f0f2f5; // 给一个底色,防止背景图加载前白屏
|
||||
}
|
||||
|
||||
.fixed-bg {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
z-index: 0; // 提升 z-index,确保不被底层遮挡
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.competition-layout {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 70px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
font-family: 'Noto Serif SC', serif;
|
||||
z-index: 2; // 确保内容在背景之上
|
||||
// margin: 10px auto;
|
||||
margin-top: 28px;
|
||||
|
||||
// 底部波浪背景
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 40%;
|
||||
background-repeat: no-repeat;
|
||||
background-position: bottom;
|
||||
background-size: cover;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.glass-overlay {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
backdrop-filter: blur(2px);
|
||||
border-radius: 30px;
|
||||
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 20px 40px;
|
||||
font-weight: 700;
|
||||
color: $secondary-color;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center; // 让内容决定位置,通常是 margin-top
|
||||
// margin: 40px auto;
|
||||
z-index: 10;
|
||||
padding-bottom: 20px;
|
||||
width: 100%; // 确保内容宽度
|
||||
}
|
||||
|
||||
.page-footer {
|
||||
position: absolute;
|
||||
bottom: 40px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
z-index: 20;
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
&.top {
|
||||
bottom: auto;
|
||||
top: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
// Transitions
|
||||
.scroll-unfold-enter-active {
|
||||
transition: width 1.5s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
}
|
||||
|
||||
.scroll-unfold-enter-from {
|
||||
width: 0 !important;
|
||||
}
|
||||
|
||||
.fade-in-delay-enter-active {
|
||||
transition: opacity 0.8s ease;
|
||||
transition-delay: 1s;
|
||||
/* Wait for scroll to unfold */
|
||||
}
|
||||
|
||||
.fade-in-delay-enter-from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.fade-slide-down-enter-active,
|
||||
.fade-slide-down-leave-active {
|
||||
transition: all 0.8s ease;
|
||||
}
|
||||
|
||||
.fade-slide-down-enter-from,
|
||||
.fade-slide-down-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-30px);
|
||||
}
|
||||
|
||||
// Responsive
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
padding: 15px 20px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
45
apps/admin/src/components/custom/user/TianZiGe.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
char?: string
|
||||
width?: string | number
|
||||
height?: string | number
|
||||
fontSize?: string | number
|
||||
showChar?: boolean
|
||||
}
|
||||
|
||||
const _props = withDefaults(defineProps<Props>(), {
|
||||
char: '',
|
||||
width: '10.8rem', // 对应 w-42 (42 * 0.25rem = 10.5rem)
|
||||
height: '9.5rem', // 对应 h-38 (38 * 0.25rem = 9.5rem)
|
||||
// fontSize: '3.25rem', // 对应 text-4xl
|
||||
showChar: true,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex select-none items-center justify-center border-2 border-red-500 bg-white"
|
||||
:style="{ width: typeof width === 'number' ? `${width}px` : width, height: typeof height === 'number' ? `${height}px` : height }"
|
||||
>
|
||||
<!-- 田字格背景线 -->
|
||||
<div class="pointer-events-none absolute inset-0 z-0 h-full w-full">
|
||||
<!-- 横向虚线 -->
|
||||
<div class="absolute left-0 top-1/2 h-[1px] w-full border-t border-red-400 border-dashed opacity-60" />
|
||||
<!-- 纵向虚线 -->
|
||||
<div class="absolute left-1/2 top-0 h-full w-[1px] border-l border-red-400 border-dashed opacity-60" />
|
||||
<!-- 对角线 -->
|
||||
<svg width="100%" height="100%" class="absolute inset-0 opacity-60">
|
||||
<line x1="0" y1="0" x2="100%" y2="100%" stroke="#f87171" stroke-width="1" stroke-dasharray="4 2" />
|
||||
<line x1="100%" y1="0" x2="0" y2="100%" stroke="#f87171" stroke-width="1" stroke-dasharray="4 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<!-- 文字内容 -->
|
||||
<span
|
||||
v-if="showChar"
|
||||
class="z-10 text-6xl text-gray-800 font-bold font-sans"
|
||||
>
|
||||
{{ char }}
|
||||
</span>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
81
apps/admin/src/constants/business.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import type { SelectOption } from 'naive-ui'
|
||||
import { transformRecordToOption } from '@/utils/common'
|
||||
|
||||
export const enableStatusRecord: Record<Api.Common.EnableStatus, string> = {
|
||||
1: '启用',
|
||||
2: '禁用',
|
||||
}
|
||||
|
||||
export const enableStatusOptions: SelectOption[] = transformRecordToOption(enableStatusRecord) as unknown as SelectOption[]
|
||||
|
||||
export const userGenderRecord: Record<Api.SystemManage.UserGender, string> = {
|
||||
1: '男',
|
||||
2: '女',
|
||||
}
|
||||
|
||||
export const userGenderOptions: SelectOption[] = transformRecordToOption(userGenderRecord) as unknown as SelectOption[]
|
||||
|
||||
export const menuTypeRecord: Record<Api.SystemManage.MenuType, string> = {
|
||||
1: '目录',
|
||||
2: '菜单',
|
||||
}
|
||||
|
||||
export const menuTypeOptions: SelectOption[] = transformRecordToOption(menuTypeRecord) as unknown as SelectOption[]
|
||||
|
||||
export const menuIconTypeRecord: Record<Api.SystemManage.IconType, string> = {
|
||||
1: 'iconify',
|
||||
2: '本地图标',
|
||||
}
|
||||
|
||||
/**
|
||||
* 题目分数类型
|
||||
*/
|
||||
export const scoreTypeRecord: Record<Api.Competition.QuestionScoreType, string> = {
|
||||
1: '固定分数',
|
||||
2: '答题个数',
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮次类型
|
||||
*/
|
||||
export const roundTypeRecord: Record<Api.Competition.CompetitionRoundType, string> = {
|
||||
1: '题包环节',
|
||||
2: '加时环节',
|
||||
}
|
||||
|
||||
export const uiTypeRecord: Record<Api.Competition.QuestionTemplateId, string> = {
|
||||
TEMPLATE_DICTATION_HINT: '汉字听写-提示',
|
||||
TEMPLATE_DICTATION_HOMOPHONE: '汉字听写-同音',
|
||||
TEMPLATE_COMPONENT_ADD: '汉字加一加',
|
||||
TEMPLATE_WORD_DICTATION: '词语听写',
|
||||
TEMPLATE_IDIOM_TEXT_REQ: '成语-文字要求',
|
||||
TEMPLATE_IDIOM_IMAGE: '成语-看图',
|
||||
}
|
||||
|
||||
export const timeRecord: Record<Api.Competition.QuestionTimeType, string> = {
|
||||
5: '5s',
|
||||
10: '10s',
|
||||
15: '15s',
|
||||
20: '20s',
|
||||
30: '30s',
|
||||
45: '45s',
|
||||
60: '60s',
|
||||
90: '90s',
|
||||
}
|
||||
|
||||
export const questionTypeRecord: Record<number, string> = {
|
||||
0: '文字',
|
||||
1: '图片',
|
||||
}
|
||||
|
||||
export const questionTypeOptions: SelectOption[] = transformRecordToOption(questionTypeRecord, true) as unknown as SelectOption[]
|
||||
|
||||
export const timeOptions: SelectOption[] = transformRecordToOption(timeRecord) as unknown as SelectOption[]
|
||||
|
||||
export const uiTypeOptions: SelectOption[] = transformRecordToOption(uiTypeRecord) as unknown as SelectOption[]
|
||||
|
||||
export const roundTypeOptions: SelectOption[] = transformRecordToOption(roundTypeRecord) as unknown as SelectOption[]
|
||||
|
||||
export const scoreTypeOptions: SelectOption[] = transformRecordToOption(scoreTypeRecord) as unknown as SelectOption[]
|
||||
|
||||
export const menuIconTypeOptions: SelectOption[] = transformRecordToOption(menuIconTypeRecord) as unknown as SelectOption[]
|
||||
37
apps/admin/src/enum/business.ts
Normal file
@ -0,0 +1,37 @@
|
||||
export enum PublishStatus {
|
||||
Unpublished = 0,
|
||||
Published = 1,
|
||||
Processing = 2,
|
||||
Finished = 3,
|
||||
}
|
||||
|
||||
export enum RankPublishStatus {
|
||||
Unpublished = 0,
|
||||
Published = 1,
|
||||
}
|
||||
|
||||
export enum QuestionType {
|
||||
SingleChoice = 'SingleChoice', // 单选题
|
||||
MultipleChoice = 'MultipleChoice', // 多选题
|
||||
TrueFalse = 'TrueFalse', // 判断题
|
||||
Text = 'Text', // 文本类型
|
||||
FillBlank = 'FillBlank', // 填空题
|
||||
Image = 'image', // 图片类型
|
||||
// 其他类型...
|
||||
}
|
||||
|
||||
export enum QuestionCategoryEnum {
|
||||
ChineseCharacterDictation1 = 'ChineseCharacterDictation1', // 1. 汉字听写-提示 (jiu表示小鸟的叫声)
|
||||
ChineseCharacterDictation2 = 'ChineseCharacterDictation2', // 2. 根据拼音写汉字-提示 (tang)
|
||||
CharacterRadicalAddition = 'CharacterRadicalAddition', // 3. 汉字加一加 (车)
|
||||
WordDictation = 'WordDictation', // 4. 词语听写 (zhi re)
|
||||
IdiomWriting1 = 'IdiomWriting1', // 5. 成语-文字要求
|
||||
IdiomWriting2 = 'IdiomWriting2', // 6. 成语-文字要求 (看图写成语)
|
||||
PoetryComprehension = 'PoetryComprehension', // 7. 诗词理解-选择题
|
||||
ReversePoemMatching = 'ReversePoemMatching', // 8. 逆向接诗句
|
||||
SceneBasedPoemGuessing = 'SceneBasedPoemGuessing', // 9. 场景猜诗句
|
||||
AssociationMatching = 'AssociationMatching', // 10. 联想对对碰
|
||||
CharacterWordFlowerDrinkingGame = 'CharacterWordFlowerDrinkingGame', // 11. 字词飞花令
|
||||
PoetryCommonKnowledge = 'PoetryCommonKnowledge', // 12. 诗词常识
|
||||
ChangeWords = 'ChangeWords', // 13. 换字组成语
|
||||
}
|
||||
36
apps/admin/src/hooks/business/useDict.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useBusinessStore } from '@/store/modules/business'
|
||||
|
||||
/**
|
||||
* 字典项 hook
|
||||
* @param key 字典项 key
|
||||
* @param valueType 字典项 value 类型,默认 number
|
||||
*/
|
||||
export function useDict(key: string, valueType: 'string' | 'number' = 'number') {
|
||||
const store = useBusinessStore()
|
||||
|
||||
const data = computed(() => store.dictData[key] || [])
|
||||
const loading = computed(() => store.loadingMap[key] || false)
|
||||
const options = computed(() => {
|
||||
if (data.value && data.value.length > 0) {
|
||||
return data.value.map((item: any) => {
|
||||
const rawValue = item.uI_Value || item.UI_Value || item.DicValue
|
||||
return {
|
||||
label: item.uI_Key || item.UI_Key || item.DicKey,
|
||||
value: valueType === 'number' ? Number(rawValue) : String(rawValue),
|
||||
}
|
||||
})
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
store.getDict(key)
|
||||
})
|
||||
|
||||
return {
|
||||
data,
|
||||
options,
|
||||
loading,
|
||||
}
|
||||
}
|
||||
@ -19,7 +19,13 @@ export function useRouterPush(inSetup = true) {
|
||||
|
||||
const routerBack = router.back
|
||||
|
||||
async function routerPushByKey(key: RouteKey, options?: App.Global.RouterPushOptions) {
|
||||
/**
|
||||
* 路由跳转
|
||||
*
|
||||
* @param key 路由名称
|
||||
* @param options 路由参数
|
||||
*/
|
||||
async function routerPushByKey(key: RouteKey, options?: App.Global.RouterPushOptions, newWindow?: boolean) {
|
||||
const { query, params } = options || {}
|
||||
|
||||
const routeLocation: RouteLocationRaw = {
|
||||
@ -34,7 +40,13 @@ export function useRouterPush(inSetup = true) {
|
||||
routeLocation.params = params
|
||||
}
|
||||
|
||||
return routerPush(routeLocation)
|
||||
if (newWindow) {
|
||||
const routeData = router.resolve(routeLocation)
|
||||
window.open(routeData.href, '_blank')
|
||||
}
|
||||
else {
|
||||
return routerPush(routeLocation)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -13,13 +13,13 @@ export type UseNaiveTableOptions<ResponseData, ApiData, Pagination extends boole
|
||||
'pagination' | 'getColumnChecks' | 'getColumns'
|
||||
> & {
|
||||
/**
|
||||
* get column visible
|
||||
* 获取列的可见性
|
||||
*
|
||||
* @param column
|
||||
*
|
||||
* @default true
|
||||
*
|
||||
* @returns true if the column is visible, false otherwise
|
||||
* @returns 如果列可见返回 true,否则返回 false
|
||||
*/
|
||||
getColumnVisible?: (column: NaiveUI.TableColumn<ApiData>) => boolean
|
||||
}
|
||||
@ -28,8 +28,12 @@ const SELECTION_KEY = '__selection__'
|
||||
|
||||
const EXPAND_KEY = '__expand__'
|
||||
|
||||
/**
|
||||
* NaiveUI 表格
|
||||
* @param options 表格选项
|
||||
*/
|
||||
export function useNaiveTable<ResponseData, ApiData>(options: UseNaiveTableOptions<ResponseData, ApiData, false>) {
|
||||
const scope = effectScope()
|
||||
const scope = effectScope() // 表格作用域,用于管理表格的响应式数据
|
||||
const appStore = useAppStore()
|
||||
|
||||
const result = useTable<ResponseData, ApiData, NaiveUI.TableColumn<ApiData>, false>({
|
||||
@ -38,7 +42,7 @@ export function useNaiveTable<ResponseData, ApiData>(options: UseNaiveTableOptio
|
||||
getColumns,
|
||||
})
|
||||
|
||||
// calculate the total width of the table this is used for horizontal scrolling
|
||||
// 计算表格的总宽度,用于水平滚动
|
||||
const scrollX = computed(() => {
|
||||
return result.columns.value.reduce((acc, column) => {
|
||||
return acc + Number(column.width ?? column.minWidth ?? 120)
|
||||
@ -69,7 +73,7 @@ type PaginationParams = Pick<PaginationProps, 'page' | 'pageSize'>
|
||||
type UseNaivePaginatedTableOptions<ResponseData, ApiData> = UseNaiveTableOptions<ResponseData, ApiData, true> & {
|
||||
paginationProps?: Omit<PaginationProps, 'page' | 'pageSize' | 'itemCount'>
|
||||
/**
|
||||
* whether to show the total count of the table
|
||||
* 是否显示表格的总条数
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
@ -77,6 +81,10 @@ type UseNaivePaginatedTableOptions<ResponseData, ApiData> = UseNaiveTableOptions
|
||||
onPaginationParamsChange?: (params: PaginationParams) => void | Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* NaiveUI 分页表格
|
||||
* @param options 表格选项
|
||||
*/
|
||||
export function useNaivePaginatedTable<ResponseData, ApiData>(
|
||||
options: UseNaivePaginatedTableOptions<ResponseData, ApiData>,
|
||||
) {
|
||||
@ -104,7 +112,7 @@ export function useNaivePaginatedTable<ResponseData, ApiData>(
|
||||
...options.paginationProps,
|
||||
}) as PaginationProps
|
||||
|
||||
// this is for mobile, if the system does not support mobile, you can use `pagination` directly
|
||||
// 针对移动端,如果系统不支持移动端,可以直接使用 `pagination`
|
||||
const mobilePagination = computed(() => {
|
||||
const p: PaginationProps = {
|
||||
...pagination,
|
||||
@ -172,11 +180,28 @@ export function useNaivePaginatedTable<ResponseData, ApiData>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NaiveUI 表格操作
|
||||
* @param data 表格数据
|
||||
* @param idKey 表格数据的主键字段
|
||||
* @param getData 获取表格数据的函数
|
||||
*/
|
||||
export function useTableOperate<TableData>(
|
||||
data: Ref<TableData[]>,
|
||||
idKey: keyof TableData,
|
||||
getData: () => Promise<void>,
|
||||
) {
|
||||
): {
|
||||
drawerVisible: Ref<boolean> /** 抽屉是否可见 */
|
||||
openDrawer: () => void /** 打开抽屉 */
|
||||
closeDrawer: () => void /** 关闭抽屉 */
|
||||
operateType: Ref<NaiveUI.TableOperateType> /** 操作类型 */
|
||||
handleAdd: () => void /** 新增操作 */
|
||||
editingData: Ref<TableData | null> /** 编辑行数据 */
|
||||
handleEdit: (id: TableData[keyof TableData]) => void /** 编辑操作 */
|
||||
checkedRowKeys: Ref<string[]> /** 表格的选中行 keys */
|
||||
onBatchDeleted: () => Promise<void> /** 批量删除操作完成后的钩子 */
|
||||
onDeleted: () => Promise<void> /** 删除操作完成后的钩子 */
|
||||
} {
|
||||
const { bool: drawerVisible, setTrue: openDrawer, setFalse: closeDrawer } = useBoolean()
|
||||
|
||||
const operateType = shallowRef<NaiveUI.TableOperateType>('add')
|
||||
@ -186,7 +211,7 @@ export function useTableOperate<TableData>(
|
||||
openDrawer()
|
||||
}
|
||||
|
||||
/** the editing row data */
|
||||
/** 编辑行数据 */
|
||||
const editingData = shallowRef<TableData | null>(null)
|
||||
|
||||
function handleEdit(id: TableData[keyof TableData]) {
|
||||
@ -197,10 +222,10 @@ export function useTableOperate<TableData>(
|
||||
openDrawer()
|
||||
}
|
||||
|
||||
/** the checked row keys of table */
|
||||
/** 表格的选中行 keys */
|
||||
const checkedRowKeys = shallowRef<string[]>([])
|
||||
|
||||
/** the hook after the batch delete operation is completed */
|
||||
/** 批量删除操作完成后的钩子 */
|
||||
async function onBatchDeleted() {
|
||||
window.$message?.success($t('common.deleteSuccess'))
|
||||
|
||||
@ -209,7 +234,7 @@ export function useTableOperate<TableData>(
|
||||
await getData()
|
||||
}
|
||||
|
||||
/** the hook after the delete operation is completed */
|
||||
/** 删除操作完成后的钩子 */
|
||||
async function onDeleted() {
|
||||
window.$message?.success($t('common.deleteSuccess'))
|
||||
|
||||
@ -230,17 +255,23 @@ export function useTableOperate<TableData>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认的表格数据转换函数
|
||||
*
|
||||
* @param response 原始的 API 响应数据
|
||||
* @returns 转换后的分页数据
|
||||
*/
|
||||
export function defaultTransform<ApiData>(
|
||||
response: FlatResponseData<any, Api.Common.PaginatingQueryRecord<ApiData>>,
|
||||
): PaginationData<ApiData> {
|
||||
const { data, error } = response
|
||||
|
||||
if (!error) {
|
||||
const { records, current, size, total } = data
|
||||
const { data: list, currentPage: pageNum, pageSize: size, total = 0 } = data
|
||||
|
||||
return {
|
||||
data: records,
|
||||
pageNum: current,
|
||||
data: list,
|
||||
pageNum,
|
||||
pageSize: size,
|
||||
total,
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ interface Props {
|
||||
|
||||
<template>
|
||||
<RouterLink to="/" class="w-full flex-center nowrap-hidden">
|
||||
<SystemLogo class="size-32px" />
|
||||
<SystemLogo class="size-42px" />
|
||||
<h2 v-show="showTitle" class="pl-8px text-16px text-primary font-bold transition duration-300 ease-in-out">
|
||||
{{ $t('system.title') }}
|
||||
</h2>
|
||||
|
||||
@ -82,6 +82,10 @@ const local: App.I18n.Schema = {
|
||||
warning: 'Warning',
|
||||
error: 'Error',
|
||||
followPrimary: 'Follow Primary',
|
||||
unpublished: 'Unpublished',
|
||||
published: 'Published',
|
||||
processing: 'Processing',
|
||||
finished: 'Finished',
|
||||
},
|
||||
themeRadius: {
|
||||
title: 'Theme Radius',
|
||||
@ -228,11 +232,33 @@ const local: App.I18n.Schema = {
|
||||
'404': 'Page Not Found',
|
||||
'500': 'Server Error',
|
||||
'iframe-page': 'Iframe',
|
||||
'home': 'Home',
|
||||
'competition': 'Competition',
|
||||
'question-store': 'Question Store',
|
||||
'template': 'Template',
|
||||
'rank': 'Rank',
|
||||
'rank_rank-detail': 'Rank Detail',
|
||||
'rank_rank-list': 'Rank List',
|
||||
'template_template-detail': 'Template Detail',
|
||||
'template_template-list': 'Template List',
|
||||
'user': 'User',
|
||||
'results': 'Results',
|
||||
'competition_competition-add': 'Competition Add',
|
||||
'competition_competition-detail': 'Competition Detail',
|
||||
'competition_competition-list': 'Competition List',
|
||||
'dictionary': 'Dictionary',
|
||||
'admin-home': 'Admin Home',
|
||||
'user_analysis': 'User Analysis',
|
||||
'user_cover': 'User Cover',
|
||||
'user_draw': 'User Draw',
|
||||
'user_game': 'User Game',
|
||||
'user_teams': 'User Teams',
|
||||
'user_rules': 'User Rules',
|
||||
'user_groups': 'User Groups',
|
||||
'user_home': 'User Home',
|
||||
'results_results-list': 'Results List',
|
||||
'results_results-detail': 'Results Detail',
|
||||
'test': 'Test',
|
||||
'user_rank-list': 'User Rank List',
|
||||
},
|
||||
page: {
|
||||
login: {
|
||||
|
||||
@ -82,6 +82,10 @@ const local: App.I18n.Schema = {
|
||||
warning: '警告色',
|
||||
error: '错误色',
|
||||
followPrimary: '跟随主色',
|
||||
unpublished: '未发布',
|
||||
published: '已发布',
|
||||
processing: '处理中',
|
||||
finished: '已完成',
|
||||
},
|
||||
themeRadius: {
|
||||
title: '主题圆角',
|
||||
@ -224,11 +228,33 @@ const local: App.I18n.Schema = {
|
||||
'404': '页面不存在',
|
||||
'500': '服务器错误',
|
||||
'iframe-page': '外链页面',
|
||||
'home': '首页',
|
||||
'competition': '比赛',
|
||||
'competition': '比赛配置',
|
||||
'question-store': '题库',
|
||||
'template': '模板制作',
|
||||
'rank': '名次排行',
|
||||
'rank_rank-detail': '名次详情',
|
||||
'rank_rank-list': '名次列表',
|
||||
'template_template-detail': '模板详情',
|
||||
'template_template-list': '模板列表',
|
||||
'user': '用户管理',
|
||||
'results': '实时结果',
|
||||
'competition_competition-add': '比赛添加',
|
||||
'competition_competition-detail': '比赛详情',
|
||||
'competition_competition-list': '比赛列表',
|
||||
'dictionary': '字典管理',
|
||||
'admin-home': '管理员首页',
|
||||
'user_analysis': '用户分析',
|
||||
'user_cover': '用户封面',
|
||||
'user_draw': '用户绘制',
|
||||
'user_game': '用户游戏',
|
||||
'user_teams': '用户队伍',
|
||||
'user_rules': '用户规则',
|
||||
'user_groups': '用户组',
|
||||
'user_home': '用户首页',
|
||||
'results_results-list': '实时结果列表',
|
||||
'results_results-detail': '实时结果详情',
|
||||
'test': 'Test',
|
||||
'user_rank-list': '用户排名列表',
|
||||
},
|
||||
page: {
|
||||
login: {
|
||||
|
||||
@ -89,6 +89,10 @@ export function setupAppVersionNotification() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取HTML构建时间
|
||||
* @returns 构建时间字符串或null
|
||||
*/
|
||||
async function getHtmlBuildTime(): Promise<string | null> {
|
||||
const baseUrl = import.meta.env.VITE_BASE_URL || '/'
|
||||
|
||||
|
||||
@ -20,8 +20,26 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
|
||||
500: () => import("@/views/_builtin/500/index.vue"),
|
||||
"iframe-page": () => import("@/views/_builtin/iframe-page/[url].vue"),
|
||||
login: () => import("@/views/_builtin/login/index.vue"),
|
||||
"admin-home": () => import("@/views/admin-home/index.vue"),
|
||||
"competition_competition-add": () => import("@/views/competition/competition-add/index.vue"),
|
||||
"competition_competition-detail": () => import("@/views/competition/competition-detail/index.vue"),
|
||||
"competition_competition-list": () => import("@/views/competition/competition-list/index.vue"),
|
||||
home: () => import("@/views/home/index.vue"),
|
||||
dictionary: () => import("@/views/dictionary/index.vue"),
|
||||
"question-store": () => import("@/views/question-store/index.vue"),
|
||||
"rank_rank-detail": () => import("@/views/rank/rank-detail/index.vue"),
|
||||
"rank_rank-list": () => import("@/views/rank/rank-list/index.vue"),
|
||||
"results_results-detail": () => import("@/views/results/results-detail/index.vue"),
|
||||
"results_results-list": () => import("@/views/results/results-list/index.vue"),
|
||||
"template_template-detail": () => import("@/views/template/template-detail/index.vue"),
|
||||
"template_template-list": () => import("@/views/template/template-list/index.vue"),
|
||||
test: () => import("@/views/test/index.vue"),
|
||||
user_analysis: () => import("@/views/user/analysis/index.vue"),
|
||||
user_cover: () => import("@/views/user/cover/index.vue"),
|
||||
user_draw: () => import("@/views/user/draw/index.vue"),
|
||||
user_game: () => import("@/views/user/game/index.vue"),
|
||||
user_groups: () => import("@/views/user/groups/index.vue"),
|
||||
user_home: () => import("@/views/user/home/index.vue"),
|
||||
"user_rank-list": () => import("@/views/user/rank-list/index.vue"),
|
||||
user_rules: () => import("@/views/user/rules/index.vue"),
|
||||
user_teams: () => import("@/views/user/teams/index.vue"),
|
||||
};
|
||||
|
||||
@ -39,13 +39,25 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
hideInMenu: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'admin-home',
|
||||
path: '/admin-home',
|
||||
component: 'layout.base$view.admin-home',
|
||||
meta: {
|
||||
title: 'admin-home',
|
||||
i18nKey: 'route.admin-home',
|
||||
hideInMenu: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'competition',
|
||||
path: '/competition',
|
||||
component: 'layout.base',
|
||||
meta: {
|
||||
title: 'competition',
|
||||
i18nKey: 'route.competition'
|
||||
i18nKey: 'route.competition',
|
||||
icon: 'material-symbols:settings-motion-mode-outline-rounded',
|
||||
order: 1
|
||||
},
|
||||
children: [
|
||||
{
|
||||
@ -82,14 +94,14 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'home',
|
||||
path: '/home',
|
||||
component: 'layout.base$view.home',
|
||||
name: 'dictionary',
|
||||
path: '/dictionary',
|
||||
component: 'layout.base$view.dictionary',
|
||||
meta: {
|
||||
title: 'home',
|
||||
i18nKey: 'route.home',
|
||||
icon: 'mdi:monitor-dashboard',
|
||||
order: 1
|
||||
title: 'dictionary',
|
||||
i18nKey: 'route.dictionary',
|
||||
icon: 'material-symbols:settings',
|
||||
order: 6
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -116,5 +128,243 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
constant: true,
|
||||
hideInMenu: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'question-store',
|
||||
path: '/question-store',
|
||||
component: 'layout.base$view.question-store',
|
||||
meta: {
|
||||
title: 'question-store',
|
||||
i18nKey: 'route.question-store',
|
||||
icon: 'solar:clipboard-text-outline',
|
||||
order: 3
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'rank',
|
||||
path: '/rank',
|
||||
component: 'layout.base',
|
||||
meta: {
|
||||
title: 'rank',
|
||||
i18nKey: 'route.rank',
|
||||
icon: 'mdi:chart-line',
|
||||
order: 5
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'rank_rank-detail',
|
||||
path: '/rank/rank-detail',
|
||||
component: 'view.rank_rank-detail',
|
||||
meta: {
|
||||
title: 'rank_rank-detail',
|
||||
i18nKey: 'route.rank_rank-detail',
|
||||
icon: 'mdi:chart-line-variant',
|
||||
multiTab: true,
|
||||
hideInMenu: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'rank_rank-list',
|
||||
path: '/rank/rank-list',
|
||||
component: 'view.rank_rank-list',
|
||||
meta: {
|
||||
title: 'rank_rank-list',
|
||||
i18nKey: 'route.rank_rank-list',
|
||||
icon: 'mdi:chart-line-variant',
|
||||
order: 1
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'results',
|
||||
path: '/results',
|
||||
component: 'layout.base',
|
||||
meta: {
|
||||
title: 'results',
|
||||
i18nKey: 'route.results',
|
||||
icon: 'mdi:clipboard-check-multiple',
|
||||
order: 2
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'results_results-detail',
|
||||
path: '/results/results-detail',
|
||||
component: 'view.results_results-detail',
|
||||
meta: {
|
||||
title: 'results_results-detail',
|
||||
i18nKey: 'route.results_results-detail',
|
||||
hideInMenu: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'results_results-list',
|
||||
path: '/results/results-list',
|
||||
component: 'view.results_results-list',
|
||||
meta: {
|
||||
title: 'results_results-list',
|
||||
i18nKey: 'route.results_results-list'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'template',
|
||||
path: '/template',
|
||||
component: 'layout.base',
|
||||
meta: {
|
||||
title: 'template',
|
||||
i18nKey: 'route.template',
|
||||
icon: 'material-symbols:settings-motion-mode-outline-rounded',
|
||||
order: 4
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'template_template-detail',
|
||||
path: '/template/template-detail',
|
||||
component: 'view.template_template-detail',
|
||||
meta: {
|
||||
title: 'template_template-detail',
|
||||
i18nKey: 'route.template_template-detail',
|
||||
icon: 'material-symbols:settings-motion-mode-outline-rounded',
|
||||
multiTab: true,
|
||||
hideInMenu: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'template_template-list',
|
||||
path: '/template/template-list',
|
||||
component: 'view.template_template-list',
|
||||
meta: {
|
||||
title: 'template_template-list',
|
||||
i18nKey: 'route.template_template-list',
|
||||
icon: 'material-symbols:settings-motion-mode-outline-rounded',
|
||||
order: 1
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'test',
|
||||
path: '/test',
|
||||
component: 'layout.base$view.test',
|
||||
meta: {
|
||||
title: '测试',
|
||||
i18nKey: 'route.test',
|
||||
icon: 'material-symbols:settings',
|
||||
order: 7,
|
||||
hideInMenu: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'user',
|
||||
path: '/user',
|
||||
component: 'layout.blank',
|
||||
redirect: '/user/home',
|
||||
meta: {
|
||||
title: 'user',
|
||||
i18nKey: 'route.user',
|
||||
hideInMenu: true,
|
||||
constant: true
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'user_analysis',
|
||||
path: '/user/analysis',
|
||||
component: 'view.user_analysis',
|
||||
meta: {
|
||||
title: 'user_analysis',
|
||||
i18nKey: 'route.user_analysis',
|
||||
hideInMenu: true,
|
||||
constant: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'user_cover',
|
||||
path: '/user/cover',
|
||||
component: 'view.user_cover',
|
||||
meta: {
|
||||
title: 'user_cover',
|
||||
i18nKey: 'route.user_cover',
|
||||
hideInMenu: true,
|
||||
constant: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'user_draw',
|
||||
path: '/user/draw',
|
||||
component: 'view.user_draw',
|
||||
meta: {
|
||||
title: 'user_draw',
|
||||
i18nKey: 'route.user_draw',
|
||||
hideInMenu: true,
|
||||
constant: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'user_game',
|
||||
path: '/user/game',
|
||||
component: 'view.user_game',
|
||||
meta: {
|
||||
title: 'user_game',
|
||||
i18nKey: 'route.user_game',
|
||||
hideInMenu: true,
|
||||
constant: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'user_groups',
|
||||
path: '/user/groups',
|
||||
component: 'view.user_groups',
|
||||
meta: {
|
||||
title: 'user_groups',
|
||||
i18nKey: 'route.user_groups',
|
||||
hideInMenu: true,
|
||||
constant: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'user_home',
|
||||
path: '/user/home',
|
||||
component: 'view.user_home',
|
||||
meta: {
|
||||
title: 'user_home',
|
||||
i18nKey: 'route.user_home',
|
||||
hideInMenu: true,
|
||||
constant: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'user_rank-list',
|
||||
path: '/user/rank-list',
|
||||
component: 'view.user_rank-list',
|
||||
meta: {
|
||||
title: 'user_rank-list',
|
||||
i18nKey: 'route.user_rank-list'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'user_rules',
|
||||
path: '/user/rules',
|
||||
component: 'view.user_rules',
|
||||
meta: {
|
||||
title: 'user_rules',
|
||||
i18nKey: 'route.user_rules',
|
||||
hideInMenu: true,
|
||||
constant: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'user_teams',
|
||||
path: '/user/teams',
|
||||
component: 'view.user_teams',
|
||||
meta: {
|
||||
title: 'user_teams',
|
||||
i18nKey: 'route.user_teams',
|
||||
hideInMenu: true,
|
||||
constant: true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@ -166,13 +166,35 @@ const routeMap: RouteMap = {
|
||||
"403": "/403",
|
||||
"404": "/404",
|
||||
"500": "/500",
|
||||
"admin-home": "/admin-home",
|
||||
"competition": "/competition",
|
||||
"competition_competition-add": "/competition/competition-add",
|
||||
"competition_competition-detail": "/competition/competition-detail",
|
||||
"competition_competition-list": "/competition/competition-list",
|
||||
"home": "/home",
|
||||
"dictionary": "/dictionary",
|
||||
"iframe-page": "/iframe-page/:url",
|
||||
"login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?"
|
||||
"login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?",
|
||||
"question-store": "/question-store",
|
||||
"rank": "/rank",
|
||||
"rank_rank-detail": "/rank/rank-detail",
|
||||
"rank_rank-list": "/rank/rank-list",
|
||||
"results": "/results",
|
||||
"results_results-detail": "/results/results-detail",
|
||||
"results_results-list": "/results/results-list",
|
||||
"template": "/template",
|
||||
"template_template-detail": "/template/template-detail",
|
||||
"template_template-list": "/template/template-list",
|
||||
"test": "/test",
|
||||
"user": "/user",
|
||||
"user_analysis": "/user/analysis",
|
||||
"user_cover": "/user/cover",
|
||||
"user_draw": "/user/draw",
|
||||
"user_game": "/user/game",
|
||||
"user_groups": "/user/groups",
|
||||
"user_home": "/user/home",
|
||||
"user_rank-list": "/user/rank-list",
|
||||
"user_rules": "/user/rules",
|
||||
"user_teams": "/user/teams"
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -12,9 +12,9 @@ import { useRouteStore } from '@/store/modules/route'
|
||||
import { localStg } from '@/utils/storage'
|
||||
|
||||
/**
|
||||
* create route guard
|
||||
* 创建路由守卫
|
||||
*
|
||||
* @param router router instance
|
||||
* @param router 路由实例
|
||||
*/
|
||||
export function createRouteGuard(router: Router) {
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
@ -38,39 +38,43 @@ export function createRouteGuard(router: Router) {
|
||||
const hasRole = authStore.userInfo.roles.some(role => routeRoles.includes(role))
|
||||
const hasAuth = authStore.isStaticSuper || !routeRoles.length || hasRole
|
||||
|
||||
// if it is login route when logged in, then switch to the root page
|
||||
// 如果已登录且访问的是登录页,则跳转到根页面
|
||||
if (to.name === loginRoute && isLogin) {
|
||||
next({ name: rootRoute })
|
||||
return
|
||||
}
|
||||
|
||||
// if the route does not need login, then it is allowed to access directly
|
||||
// 如果路由不需要登录,则直接允许访问
|
||||
if (!needLogin) {
|
||||
handleRouteSwitch(to, from, next)
|
||||
return
|
||||
}
|
||||
|
||||
// the route need login but the user is not logged in, then switch to the login page
|
||||
if (!isLogin) {
|
||||
// 如果路由需要登录但用户未登录,则跳转到登录页
|
||||
const isAuthRouteForceLogin = import.meta.env.VITE_AUTH_ROUTE_FORCE_LOGIN === 'Y'
|
||||
const whiteList: string[] = ['user']
|
||||
const isInWhiteList = whiteList.includes(to.name as string)
|
||||
|
||||
if (isAuthRouteForceLogin && !isLogin && !isInWhiteList) {
|
||||
next({ name: loginRoute, query: { redirect: to.fullPath } })
|
||||
return
|
||||
}
|
||||
|
||||
// if the user is logged in but does not have authorization, then switch to the 403 page
|
||||
// 如果用户已登录但没有权限,则跳转到 403 页面
|
||||
if (!hasAuth) {
|
||||
next({ name: noAuthorizationRoute })
|
||||
return
|
||||
}
|
||||
|
||||
// switch route normally
|
||||
// 正常跳转路由
|
||||
handleRouteSwitch(to, from, next)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* initialize route
|
||||
* 初始化路由
|
||||
*
|
||||
* @param to to route
|
||||
* @param to 目标路由
|
||||
*/
|
||||
async function initRoute(to: RouteLocationNormalized): Promise<RouteLocationRaw | null> {
|
||||
const routeStore = useRouteStore()
|
||||
@ -78,12 +82,12 @@ async function initRoute(to: RouteLocationNormalized): Promise<RouteLocationRaw
|
||||
const notFoundRoute: RouteKey = 'not-found'
|
||||
const isNotFoundRoute = to.name === notFoundRoute
|
||||
|
||||
// if the constant route is not initialized, then initialize the constant route
|
||||
// 如果常量路由未初始化,则初始化常量路由
|
||||
if (!routeStore.isInitConstantRoute) {
|
||||
await routeStore.initConstantRoute()
|
||||
|
||||
// the route is captured by the "not-found" route because the constant route is not initialized
|
||||
// after the constant route is initialized, redirect to the original route
|
||||
// 由于常量路由未初始化,路由被“未找到”路由捕获
|
||||
// 常量路由初始化后,重定向到原始路由
|
||||
const path = to.fullPath
|
||||
const location: RouteLocationRaw = {
|
||||
path,
|
||||
@ -97,15 +101,19 @@ async function initRoute(to: RouteLocationNormalized): Promise<RouteLocationRaw
|
||||
|
||||
const isLogin = Boolean(localStg.get('token'))
|
||||
|
||||
if (!isLogin) {
|
||||
// if the user is not logged in and the route is a constant route but not the "not-found" route, then it is allowed to access.
|
||||
const isAuthRouteForceLogin = import.meta.env.VITE_AUTH_ROUTE_FORCE_LOGIN === 'Y'
|
||||
const whiteList: string[] = ['user']
|
||||
const isInWhiteList = whiteList.includes(to.name as string)
|
||||
|
||||
if (isAuthRouteForceLogin && !isLogin && !isInWhiteList) {
|
||||
// 如果用户未登录且路由是常量路由但不是“未找到”路由,则允许访问。
|
||||
if (to.meta.constant && !isNotFoundRoute) {
|
||||
routeStore.onRouteSwitchWhenNotLoggedIn()
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// if the user is not logged in, then switch to the login page
|
||||
// 如果用户未登录,则跳转到登录页
|
||||
const loginRoute: RouteKey = 'login'
|
||||
const query = getRouteQueryOfLoginRoute(to, routeStore.routeHome)
|
||||
|
||||
@ -118,11 +126,11 @@ async function initRoute(to: RouteLocationNormalized): Promise<RouteLocationRaw
|
||||
}
|
||||
|
||||
if (!routeStore.isInitAuthRoute) {
|
||||
// initialize the auth route
|
||||
// 初始化权限路由
|
||||
await routeStore.initAuthRoute()
|
||||
|
||||
// the route is captured by the "not-found" route because the auth route is not initialized
|
||||
// after the auth route is initialized, redirect to the original route
|
||||
// 由于权限路由未初始化,路由被“未找到”路由捕获
|
||||
// 权限路由初始化后,重定向到原始路由
|
||||
if (isNotFoundRoute) {
|
||||
const rootRoute: RouteKey = 'root'
|
||||
const path = to.redirectedFrom?.name === rootRoute ? '/' : to.fullPath
|
||||
@ -140,13 +148,13 @@ async function initRoute(to: RouteLocationNormalized): Promise<RouteLocationRaw
|
||||
|
||||
routeStore.onRouteSwitchWhenLoggedIn()
|
||||
|
||||
// the auth route is initialized
|
||||
// it is not the "not-found" route, then it is allowed to access
|
||||
// 权限路由已初始化
|
||||
// 如果不是“未找到”路由,则允许访问
|
||||
if (!isNotFoundRoute) {
|
||||
return null
|
||||
}
|
||||
|
||||
// it is captured by the "not-found" route, then check whether the route exists
|
||||
// 如果被“未找到”路由捕获,则检查路由是否存在
|
||||
const exist = await routeStore.getIsAuthRouteExist(to.path as RoutePath)
|
||||
const noPermissionRoute: RouteKey = '403'
|
||||
|
||||
@ -162,7 +170,7 @@ async function initRoute(to: RouteLocationNormalized): Promise<RouteLocationRaw
|
||||
}
|
||||
|
||||
function handleRouteSwitch(to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) {
|
||||
// route with href
|
||||
// 外链路由
|
||||
if (to.meta.href) {
|
||||
window.open(to.meta.href, '_blank')
|
||||
|
||||
|
||||