Compare commits
33 Commits
main
...
8469e16638
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
6
.vscode/settings.json
vendored
@ -36,5 +36,9 @@
|
||||
"./apps/user",
|
||||
"./packages/eslint-config"
|
||||
],
|
||||
"totvsLanguageServer.welcomePage": false // `
|
||||
"totvsLanguageServer.welcomePage": false,
|
||||
"cSpell.words": [
|
||||
"activityinfo",
|
||||
"Echarts"
|
||||
] // `
|
||||
}
|
||||
|
||||
@ -1,9 +1,17 @@
|
||||
# 应用的基础URL,默认为 "/"
|
||||
# 如果使用子目录,必须以 "/" 结尾,例如 "/admin/" 而不是 "/admin"
|
||||
VITE_BASE_URL=/
|
||||
# VITE_BASE_URL='/admin'
|
||||
|
||||
# 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,6 +24,9 @@ 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
|
||||
|
||||
@ -23,16 +34,16 @@ VITE_ROUTE_HOME=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=https://api.qyzhjy.com
|
||||
|
||||
VITE_SERVICE_BASE_URL=https://172.16.10.130:5000
|
||||
|
||||
VITE_OTHER_SERVICE_BASE_URL= `{
|
||||
"demo": "http://localhost:9528"
|
||||
}`
|
||||
@ -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}`), ''),
|
||||
|
||||
@ -9,11 +9,11 @@
|
||||
},
|
||||
"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",
|
||||
@ -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",
|
||||
|
||||
@ -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/imgs/add-f1.png
Normal file
|
After Width: | Height: | Size: 656 KiB |
9
apps/admin/src/assets/imgs/user/cover-back.svg
Normal file
|
After Width: | Height: | Size: 8.8 MiB |
9
apps/admin/src/assets/imgs/user/cover-bg-active.svg
Normal file
|
After Width: | Height: | Size: 11 MiB |
9
apps/admin/src/assets/imgs/user/event-card-bg-icon.svg
Normal file
|
After Width: | Height: | Size: 3.8 MiB |
9
apps/admin/src/assets/imgs/user/event-card-bg.svg
Normal file
|
After Width: | Height: | Size: 7.2 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/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.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
48
apps/admin/src/assets/imgs/user/star-icon.svg
Normal file
|
After Width: | Height: | Size: 5.2 MiB |
36
apps/admin/src/assets/imgs/user/team-bg.svg
Normal file
|
After Width: | Height: | Size: 16 MiB |
15
apps/admin/src/assets/imgs/user/tianzige.svg
Normal file
|
After Width: | Height: | Size: 12 MiB |
9
apps/admin/src/assets/imgs/user/title-bg.svg
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
21
apps/admin/src/assets/imgs/user/title-bg2.svg
Normal file
|
After Width: | Height: | Size: 4.0 MiB |
BIN
apps/admin/src/assets/imgs/副本第九届“阅读之星”题目示例 (1).xlsx
Normal file
BIN
apps/admin/src/assets/imgs/微信图片_20260204103609_668_5591.png
Normal file
|
After Width: | Height: | Size: 468 KiB |
@ -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'
|
||||
})
|
||||
}
|
||||
```
|
||||
45
apps/admin/src/components/custom/typeit/index.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Options } from 'typeit'
|
||||
import type { El } from 'typeit/dist/types'
|
||||
import TypeIt from 'typeit'
|
||||
import { onMounted, shallowRef } from 'vue'
|
||||
|
||||
const textRef = shallowRef<El>()
|
||||
|
||||
function init() {
|
||||
if (!textRef.value)
|
||||
return
|
||||
|
||||
const options: Options = {
|
||||
strings: 'SoybeanAdmin是一个清新优雅、高颜值且功能强大的后台管理模板',
|
||||
lifeLike: true,
|
||||
speed: 120,
|
||||
loop: true,
|
||||
}
|
||||
|
||||
const initTypeIt = new TypeIt(textRef.value, options)
|
||||
|
||||
initTypeIt.go()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
init()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<NCard title="打字机 插件" :bordered="false" class="h-full card-wrapper">
|
||||
<NSpace :vertical="true">
|
||||
<GithubLink link="https://github.com/alexmacarthur/typeit" />
|
||||
<WebSiteLink label="文档地址:" link="https://www.typeitjs.com/docs/vanilla/usage/" />
|
||||
</NSpace>
|
||||
<NDivider title-placement="left">
|
||||
基本示例
|
||||
</NDivider>
|
||||
<span ref="textRef" class="text-18px" />
|
||||
</NCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
400
apps/admin/src/components/custom/user/CompetitionLayout.vue
Normal file
@ -0,0 +1,400 @@
|
||||
<script lang="ts" setup>
|
||||
import VScaleScreen from 'v-scale-screen'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import _defaultBg from '@/assets/imgs/user/home-bg.svg'
|
||||
import titleBg2 from '@/assets/imgs/user/title-bg2.svg'
|
||||
import _defaultTitleBg from '@/assets/imgs/user/title-bg.svg'
|
||||
import { useRouterPush } from '@/hooks/common/router'
|
||||
|
||||
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.svg */
|
||||
titleBgType?: 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,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'back'): void
|
||||
(e: 'next'): void
|
||||
}>()
|
||||
|
||||
const titleBgUrlMapping: Record<number, string> = {
|
||||
1: _defaultTitleBg,
|
||||
2: titleBg2,
|
||||
}
|
||||
|
||||
const { routerPushByKey } = useRouterPush()
|
||||
|
||||
const titleBgUrl = computed(() => titleBgUrlMapping[props.titleBgType] || _defaultBg)
|
||||
|
||||
// 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)
|
||||
|
||||
function handleBack() {
|
||||
emit('back')
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
if (props.nextDisabled)
|
||||
return
|
||||
emit('next')
|
||||
}
|
||||
|
||||
/** 点击返回首页 */
|
||||
function handleBackHome() {
|
||||
routerPushByKey('user_home')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchSystemConfig()
|
||||
setTimeout(() => {
|
||||
showContent.value = true
|
||||
}, 100)
|
||||
})
|
||||
</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="1920" height="1080">
|
||||
<div class="competition-layout">
|
||||
<!-- Header -->
|
||||
<header class="page-header text-[30px] text-[#333333] font-bold">
|
||||
<span class="org-name" @click="handleBackHome">教育学会</span>
|
||||
<span class="org-name">树人研究院</span>
|
||||
</header>
|
||||
|
||||
<div class="glass-overlay">
|
||||
<!-- title -->
|
||||
<section v-if="showTitle" class="title-section">
|
||||
<Transition name="fade-slide-down" appear>
|
||||
<div v-if="showContent" class="title-section">
|
||||
<div class="scroll-bg" :style="{ backgroundImage: `url(${titleBgUrl})` }">
|
||||
<h1 class="main-title">
|
||||
{{ title }}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</section>
|
||||
|
||||
<!-- Main Content Slot -->
|
||||
<main class="main-content">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<!-- Footer Action -->
|
||||
<footer
|
||||
class="page-footer w-full flex items-center justify-between px-20"
|
||||
:class="[actionPosition, btnTheme]"
|
||||
>
|
||||
<button
|
||||
class="action-btn back-btn" :class="{ 'text-btn': backBtnText }"
|
||||
:style="{ visibility: showBack ? 'visible' : 'hidden' }" @click="handleBack"
|
||||
>
|
||||
<span v-if="backBtnText" class="btn-text">{{ backBtnText }}</span>
|
||||
<SvgIcon v-else icon="mdi:arrow-left" class="arrow-icon" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="showNext" class="action-btn next-btn text-btn" :class="{ 'is-disabled': nextDisabled }"
|
||||
@click="handleNext"
|
||||
>
|
||||
<span class="btn-text">{{ nextBtnText || '下一步' }}</span>
|
||||
<!-- <SvgIcon v-else icon="mdi:arrow-right" class="arrow-icon" /> -->
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</VScaleScreen>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.title-section {
|
||||
margin: 10px auto;
|
||||
width: 412px;
|
||||
height: 165px;
|
||||
|
||||
.scroll-bg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
background: no-repeat center center;
|
||||
background-size: 100% 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-bottom: 10px; // 微调垂直位置,因为卷轴可能有视觉重心偏移
|
||||
|
||||
.main-title {
|
||||
font-size: 2.6rem;
|
||||
color: $text-color;
|
||||
margin: 0;
|
||||
letter-spacing: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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: 1; // 确保内容在背景之上
|
||||
|
||||
// Default background decoration (Bottom Wave)
|
||||
&::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.5);
|
||||
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: 0;
|
||||
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;
|
||||
right: 0; // 默认在右下角
|
||||
z-index: 20;
|
||||
|
||||
&.top {
|
||||
bottom: auto;
|
||||
top: 40px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
background: #eb5e55;
|
||||
box-shadow: 0 8px 0 #fcc64f;
|
||||
|
||||
.arrow-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(4px);
|
||||
box-shadow: 0 2px 0 #fcc64f;
|
||||
}
|
||||
|
||||
// 文字按钮通用样式
|
||||
&.text-btn {
|
||||
width: auto;
|
||||
min-width: 180px;
|
||||
padding: 0 30px;
|
||||
border-radius: 40px;
|
||||
|
||||
.btn-text {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&.next-btn {
|
||||
// 保留原有 next-btn 特定逻辑(如果有)
|
||||
}
|
||||
|
||||
&.back-btn {
|
||||
// 保持统一风格
|
||||
}
|
||||
|
||||
// 禁用状态
|
||||
&.is-disabled {
|
||||
cursor: not-allowed;
|
||||
// opacity: 0.8; // 移除透明度变化
|
||||
// filter: grayscale(0.5); // 移除置灰效果
|
||||
// box-shadow: none; // 保持阴影,或者根据需要调整
|
||||
|
||||
&:hover {
|
||||
transform: none; // 禁止 hover 位移
|
||||
// filter: grayscale(0.5);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: none;
|
||||
// box-shadow: none; // 保持阴影
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Light Theme Style (White bg, Red text/border, Yellow shadow)
|
||||
&.light {
|
||||
.action-btn {
|
||||
background: white;
|
||||
color: #eb5e55;
|
||||
border: 2px solid #eb5e55;
|
||||
box-shadow: 0 8px 0 #fcc64f; // 还原黄色立体阴影
|
||||
|
||||
&:hover {
|
||||
background-color: #fff; // 保持白色
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(4px);
|
||||
box-shadow: 0 2px 0 #fcc64f;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Transitions
|
||||
.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>
|
||||
48
apps/admin/src/components/custom/user/TianZiGe.vue
Normal file
@ -0,0 +1,48 @@
|
||||
<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>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
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[]
|
||||
31
apps/admin/src/enum/business.ts
Normal file
@ -0,0 +1,31 @@
|
||||
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', // 汉字听写-提示 (jiu表示小鸟的叫声)
|
||||
ChineseCharacterDictation2 = 'ChineseCharacterDictation2', // 根据拼音写汉字-提示 (tang)
|
||||
CharacterRadicalAddition = 'CharacterRadicalAddition', // 汉字加一加 (车)
|
||||
WordDictation = 'WordDictation', // 词语听写 (zhi re)
|
||||
IdiomWriting1 = 'IdiomWriting1', // 成语-文字要求 (反义字)
|
||||
IdiomWriting2 = 'IdiomWriting2', // 成语-文字要求 (看图写成语)
|
||||
PoetryComprehension = 'PoetryComprehension', // 诗词理解-选择题
|
||||
}
|
||||
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,6 +19,12 @@ export function useRouterPush(inSetup = true) {
|
||||
|
||||
const routerBack = router.back
|
||||
|
||||
/**
|
||||
* 路由跳转
|
||||
*
|
||||
* @param key 路由名称
|
||||
* @param options 路由参数
|
||||
*/
|
||||
async function routerPushByKey(key: RouteKey, options?: App.Global.RouterPushOptions) {
|
||||
const { query, params } = options || {}
|
||||
|
||||
|
||||
@ -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,27 @@ 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_rank-pending": () => import("@/views/user/rank-pending/index.vue"),
|
||||
user_rules: () => import("@/views/user/rules/index.vue"),
|
||||
user_teams: () => import("@/views/user/teams/index.vue"),
|
||||
};
|
||||
|
||||
@ -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,249 @@ 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: 'test',
|
||||
i18nKey: 'route.test'
|
||||
}
|
||||
},
|
||||
{
|
||||
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_rank-pending',
|
||||
path: '/user/rank-pending',
|
||||
component: 'view.user_rank-pending',
|
||||
meta: {
|
||||
title: 'user_rank-pending',
|
||||
i18nKey: 'route.user_rank-pending'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'user_rules',
|
||||
path: '/user/rules',
|
||||
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,36 @@ 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_rank-pending": "/user/rank-pending",
|
||||
"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')
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@ const historyCreatorMap: Record<Env.RouterHistoryMode, (base?: string) => Router
|
||||
}
|
||||
|
||||
export const router = createRouter({
|
||||
history: historyCreatorMap[VITE_ROUTER_HISTORY_MODE](VITE_BASE_URL),
|
||||
history: historyCreatorMap[VITE_ROUTER_HISTORY_MODE](VITE_BASE_URL), // 路由模式
|
||||
routes: createBuiltinVueRoutes(),
|
||||
})
|
||||
|
||||
|
||||
@ -8,7 +8,28 @@ import { transformElegantRoutesToVueRoutes } from '../elegant/transform'
|
||||
*
|
||||
* @link https://github.com/reader-starjs/elegant-router?tab=readme-ov-file#custom-route
|
||||
*/
|
||||
const customRoutes: CustomRoute[] = []
|
||||
const customRoutes: CustomRoute[] = [
|
||||
{
|
||||
name: 'root',
|
||||
path: '/',
|
||||
redirect: '/user/home',
|
||||
meta: {
|
||||
title: 'root',
|
||||
constant: true,
|
||||
hideInMenu: true,
|
||||
},
|
||||
} as unknown as CustomRoute,
|
||||
{
|
||||
name: 'admin',
|
||||
path: '/admin',
|
||||
redirect: '/competition/competition-list',
|
||||
meta: {
|
||||
title: 'competition/competition-list',
|
||||
constant: true,
|
||||
hideInMenu: true,
|
||||
},
|
||||
} as unknown as CustomRoute,
|
||||
]
|
||||
|
||||
/** create routes when the auth route mode is static */
|
||||
export function createStaticRoutes() {
|
||||
|
||||
@ -6,20 +6,20 @@ import { request } from '../request'
|
||||
* @param userName User name
|
||||
* @param password Password
|
||||
*/
|
||||
export function fetchLogin(userName: string, password: string) {
|
||||
export function fetchLogin(account: string, pwd: string) {
|
||||
return request<Api.Auth.LoginToken>({
|
||||
url: '/auth/login',
|
||||
url: '/admin/v1/user/login',
|
||||
method: 'post',
|
||||
data: {
|
||||
userName,
|
||||
password,
|
||||
account,
|
||||
pwd,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Get user info */
|
||||
export function fetchGetUserInfo() {
|
||||
return request<Api.Auth.UserInfo>({ url: '/auth/getUserInfo' })
|
||||
export function fetchGetUserInfo(userId: number) {
|
||||
return request<Api.Auth.UserInfo>({ url: `admin/v1/manager/detail/${userId}` })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
150
apps/admin/src/service/api/competition.ts
Normal file
@ -0,0 +1,150 @@
|
||||
import { request } from '../request'
|
||||
|
||||
/** 房间相关接口 */
|
||||
export function fetchGetRoomList() {
|
||||
return request<App.Service.Response<Api.Competition.Room[]>>({
|
||||
url: '/Base/ActivityMain/GetActivity_RoomList',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
// 现在服务端把活动新建弄成了三个接口:
|
||||
// 1. 创建活动
|
||||
// 2. 创建活动房间
|
||||
// 3. 创建活动队伍
|
||||
// 但是业务要求最后一步创建
|
||||
|
||||
/** 创建活动基础信息 */
|
||||
export function fetchCreateActivity(data: Api.Competition.CreateRoomRequest) {
|
||||
return request<App.Service.Response<Api.Common.CommonResponse>>({
|
||||
url: '/Base/ActivityMain/AddActivity',
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取活动的基础信息 */
|
||||
export function fetchGetActivityDetail(id: number) {
|
||||
return request<App.Service.Response<Api.Competition.ActivityDetail>>({
|
||||
url: `/Base/ActivityMain/GetActivityByID/?ID=${id}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 创建活动队伍 */
|
||||
export function fetchCreateTeamList(data: Api.Competition.CreateTeamListRequest[]) {
|
||||
return request<App.Service.Response<Api.Common.CommonResponse>>({
|
||||
url: '/Base/ActivityMain/AddActivity_TeamList',
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/** 根据活动ID获取活动队伍列表 */
|
||||
export function fetchGetTeamList(id: number) {
|
||||
return request<App.Service.Response>({
|
||||
url: `/Base/ActivityMain/GetActivity_TeamsByMainID/?MainID=${id}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 创建活动题目 */
|
||||
export function fetchCreateQuestion(data: Api.Competition.CreateQuestionRequest[]) {
|
||||
return request<App.Service.Response<Api.Common.CommonResponse>>({
|
||||
url: '/Base/ActivityMain/AddOrUpdateActivity_Question',
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/** 根据活动ID获取活动题目列表 */
|
||||
export function fetchGetQuestionList(id: number) {
|
||||
return request<App.Service.Response<Api.Competition.QuestionListRecord[]>>({
|
||||
url: `/Base/ActivityMain/GetActivity_QuestionByActivityID?ID=${id}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取活动列表 */
|
||||
export function fetchGetActivityList() {
|
||||
return request<App.Service.Response>({
|
||||
url: '/Base/ActivityMain/Activity_MainList',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 新增活动 (包括基础信息、房间、队伍、题目) */
|
||||
export function fetchCreateCompetition(data: any) {
|
||||
return request<App.Service.Response<Api.Common.CommonResponse>>({
|
||||
url: '/Base/ActivityMain/AddActivityAllData',
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/** 根据活动id查询分组列表 */
|
||||
export function fetchGetGroupList(id: string) {
|
||||
return request<App.Service.Response>({
|
||||
url: `/Base/ActivityMain/GetActivity_TeamGroupByMainID?ActivityID=${id}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 根据组id查询队伍列表 */
|
||||
export function fetchGetTeamListByGroupId(GroupID: number) {
|
||||
return request<App.Service.Response<Api.Competition.TeamListRecord[]>>({
|
||||
url: `/Base/ActivityMain/GetActivity_TeamsByTeamGroupID?GroupID=${GroupID}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 更新活动基础信息 */
|
||||
export function fetchUpdateActivity(data: Api.Competition.ActivityDetail) {
|
||||
return request<App.Service.Response<Api.Common.CommonResponse>>({
|
||||
url: '/Base/ActivityMain/UpdateActivity',
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/** 更新活动队伍 */
|
||||
export function fetchUpdateTeamList(data: Api.Competition.CreateTeamListRequest[]) {
|
||||
return request<App.Service.Response<Api.Common.CommonResponse>>({
|
||||
url: '/Base/ActivityMain/UpdateActivity_TeamList',
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/** 更新活动题目 */
|
||||
export function fetchUpdateQuestion(data: Api.Competition.QuestionListRecord[]) {
|
||||
return request<App.Service.Response<Api.Common.CommonResponse>>({
|
||||
url: '/Base/ActivityMain/AddOrUpdateActivity_Question',
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/** 删除活动 */
|
||||
export function fetchDeleteActivity(id: number) {
|
||||
return request<App.Service.Response<Api.Common.CommonResponse>>({
|
||||
url: `/Base/ActivityMain/DeleteActivity?ID=${id}`,
|
||||
method: 'post',
|
||||
})
|
||||
}
|
||||
|
||||
/** 更新活动状态 */
|
||||
export function fetchUpdatePublishStatus(id: number, status: number) {
|
||||
return request<App.Service.Response<Api.Common.CommonResponse>>({
|
||||
url: `/Base/ActivityMain/UpdateActivityStatus?ActivityID=${id}&status=${status}`,
|
||||
method: 'post',
|
||||
})
|
||||
}
|
||||
|
||||
/** 复制活动 */
|
||||
export function fetchCopyActivity(ActivityID: number) {
|
||||
return request<App.Service.Response<Api.Common.CommonResponse>>({
|
||||
url: `/Base/ActivityMain/CopyActivity?ActivityID=${ActivityID}`,
|
||||
method: 'post',
|
||||
})
|
||||
}
|
||||
70
apps/admin/src/service/api/dictionary.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { request } from '../request'
|
||||
|
||||
/** Get dictionary list */
|
||||
export function getDictionaryList(roomID?: number) {
|
||||
return request<Api.Common.CommonResponse>({
|
||||
url: '/Base/ActivityMain/GetDictionaryList',
|
||||
method: 'get',
|
||||
params: {
|
||||
RoomId: roomID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 新增字段项目 */
|
||||
export function addOrUpdateDictionary(data: Api.Dictionary.AddOrUpdateParams) {
|
||||
return request({
|
||||
url: '/Base/ActivityMain/AddOrUpdateDictionary',
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/** Delete dictionary */
|
||||
export function deleteDictionary(DicID: number) {
|
||||
return request({
|
||||
url: `/Base/ActivityMain/DeleteDictionary/?DicID=${DicID}`,
|
||||
method: 'post',
|
||||
data: { DicID },
|
||||
})
|
||||
}
|
||||
|
||||
/** 根据字典ID查询字典类型 */
|
||||
export function getDictionaryListUITypeByDicID(dicID: number) {
|
||||
return request<Api.Common.CommonResponse>({
|
||||
url: '/Base/ActivityMain/GetDictionaryListUITypeByDicID',
|
||||
method: 'get',
|
||||
params: {
|
||||
DicID: dicID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 根据字典ID新建下面的字典类型 */
|
||||
export function addOrUpdateDictionaryUIType(data: Api.Dictionary.AddOrUpdateDictionaryUITypeParams) {
|
||||
return request({
|
||||
url: '/Base/ActivityMain/AddOrUpdateDictionaryUIType',
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/** 删除字典项 */
|
||||
export function deleteDictionaryItem(DicUIID: number) {
|
||||
return request({
|
||||
url: `/Base/ActivityMain/DeleteDictionaryUIType/?DicUIID=${DicUIID}`,
|
||||
method: 'post',
|
||||
data: { DicUIID },
|
||||
})
|
||||
}
|
||||
|
||||
/** 根据字典value查询字典项 */
|
||||
export function getDictionaryItemListByDicID(DicValue: string) {
|
||||
return request<Api.Common.CommonResponse>({
|
||||
url: `/Base/ActivityMain/GetDictionaryListUITypeByDicValue/?DicValue=${DicValue}`,
|
||||
method: 'get',
|
||||
params: {
|
||||
DicValue,
|
||||
},
|
||||
})
|
||||
}
|
||||
105
apps/admin/src/service/api/game.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { request } from '../request'
|
||||
|
||||
/**
|
||||
* 游戏相关接口 步骤
|
||||
* 1.GetQuestionByActivityIDAndStatus 抽题时候获取题目大纲信息
|
||||
* 2.GetQuestionListDetailRound 获取抽题目详情
|
||||
* 3.fetchSubmitQuestionResult点击开始答题后,把基础信息数据传给service
|
||||
*/
|
||||
|
||||
/** 根据活动id查询分组列表,返回队伍分组id和名称是否已经参赛 */
|
||||
export function fetchGetGroupListByActivityID(id: string, roundType: number = 0) {
|
||||
return request<App.Service.Response<Api.Competition.ActivityTeamGroup[]>>({
|
||||
url: `/Base/ActivityMain/GetActivity_TeamGroupByMainIDAndStatus?ActivityID=${id}&RoundType=${roundType}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 抽题时候获取题目大纲信息 */
|
||||
export function fetchGetQuestionOutline(ActivityID: number, status: number = 0) {
|
||||
return request<App.Service.Response<Api.Competition.QuestionListRecord[]>>({
|
||||
url: `/Base/ActivityMain/GetQuestionByActivityIDAndStatus/?ActivityID=${ActivityID}&status=${status}`,
|
||||
method: 'post',
|
||||
data: {
|
||||
ActivityID,
|
||||
status,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 抽题目详情 */
|
||||
export function fetchGetQuestionDetail(ActivityID: string, questionID: number) {
|
||||
return request<App.Service.Response<Api.Competition.QuestionListDetailRound>>({
|
||||
url: `/Base/ActivityMain/GetQuestionListDetailRound/?MainID=${ActivityID}&questionID=${questionID}`,
|
||||
method: 'post',
|
||||
data: {
|
||||
MainID: ActivityID,
|
||||
questionID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 提交抽题结果 */
|
||||
export function fetchSubmitQuestionResult(data: Api.Competition.QuestionAddParams) {
|
||||
return request<App.Service.Response<Api.Competition.QuestionListDetailRound>>({
|
||||
url: `/Base/ActivityMain/AddActivity_TeamQuestionUse/?RoomID=${data.RoomID}&MainID=${data.MainID}&TeamGroupID=${data.TeamGroupID}&QuestionID=${data.QuestionID}&QuestionDetaiID=${data.QuestionDetaiID}&TeamGroupQuestionID=${data.TeamGroupQuestionID}`,
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取游戏现场统计结果 */
|
||||
export function fetchGetGameStatistics(data: { TeamGroupID: number, QuestionID: number }) {
|
||||
return request<App.Service.Response<Api.Competition.TeamAnswerData[]>>({
|
||||
url: `/Base/ActivityMain/GetTeamQuestionUseByTeamGroupIDAndQuestionID/?TeamGroupID=${data.TeamGroupID}&QuestionID=${data.QuestionID}`,
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取游戏现场问题 */
|
||||
export function fetchGetCurrentQuestion(data: Api.Competition.GetCurrentQuestionParams) {
|
||||
return request<App.Service.Response<Api.Competition.CurrentQuestionResponse>>({
|
||||
url: `/Base/ActivityMain/GetCurrentQuestion/?ActivityID=${data.ActivityID}&GroupID=${data.GroupID}&RoundType=${data.RoundType}`,
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/** 定时刷,评委是否评完结果 判断是否可以下一步 */
|
||||
export function fetchCheckNextStep(TeamGroup_QuestionID: number) {
|
||||
return request<App.Service.Response<any>>({
|
||||
url: `/Base/ActivityMain/GetCurrentQuestionUse/?TeamGroup_QuestionID=${TeamGroup_QuestionID}`,
|
||||
method: 'post',
|
||||
data: {
|
||||
TeamGroup_QuestionID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 根据活动id查询题目列表 */
|
||||
export function fetchGetQuestionTableByActivityID(ActivityID: string) {
|
||||
return request<App.Service.Response<Api.Competition.QuestionListDetailRound[]>>({
|
||||
url: `/Base/ActivityMain/GetActivity_QuestionTableByActivityID/?ID=${ActivityID}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 根据 roomid查询当前正在比赛的题目 */
|
||||
export function fetchGetCurrentQuestionByRoomID(RoomID: number) {
|
||||
return request<App.Service.Response<Api.Competition.GetCurrentQuestionInfo>>({
|
||||
url: `/Base/ActivityMain/SelectTask?RoomID=${RoomID}`,
|
||||
method: 'post',
|
||||
data: {
|
||||
RoomID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 根据QuestionDetaiID查询题目详情 */
|
||||
export function fetchGetQuestionDetailByID(QuestionDetailID: number) {
|
||||
return request<App.Service.Response<Api.Competition.QuestionListDetailRound>>({
|
||||
url: `/Base/ActivityMain/GetQuestionListDetailByID/?QuestionID=${QuestionDetailID}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
@ -1,2 +1,4 @@
|
||||
export * from './auth'
|
||||
export * from './rank'
|
||||
export * from './route'
|
||||
export * from './template'
|
||||
|
||||
116
apps/admin/src/service/api/question.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { request } from '../request'
|
||||
|
||||
/** 获取所有题目列表 */
|
||||
export function fetchGetQuestionListAll() {
|
||||
return request<Api.Question.CommonRecord>({
|
||||
url: '/Base/ActivityMain/GetQuestionListAll',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 新增题目 */
|
||||
export function fetchAddQuestion(data?: Api.Question.AddParams) {
|
||||
return request<Api.Question.CommonRecord>({
|
||||
url: '/Base/ActivityMain/AddBase_QuestionList',
|
||||
method: 'post',
|
||||
data: data || {},
|
||||
})
|
||||
}
|
||||
|
||||
/** 更新题目 */
|
||||
export function fetchUpdateQuestion(data?: Api.Question.AddParams) {
|
||||
return request<Api.Question.CommonRecord>({
|
||||
url: '/Base/ActivityMain/UpdateQuestionList',
|
||||
method: 'post',
|
||||
data: data || {},
|
||||
})
|
||||
}
|
||||
|
||||
/** 删除题目 */
|
||||
export function fetchDeleteQuestion(id: number) {
|
||||
return request<Api.Question.CommonRecord>({
|
||||
url: `/Base/ActivityMain/DeleteQuestionList/?Id=${id}`,
|
||||
method: 'post',
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取所有问题库列表(分页) */
|
||||
export function fetchGetQuestionLibraryListAll(params: Api.Question.GetQuestionLibraryListAllParams) {
|
||||
return request<Api.Question.CommonRecord>({
|
||||
url: '/Base/ActivityMain/GetQuestionListDetail',
|
||||
method: 'get',
|
||||
params: {
|
||||
QuestionId: params.questionId,
|
||||
PageIndex: params.pageIndex,
|
||||
PageSizes: params.pageSizes,
|
||||
keyWords: params.keyWords,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 新增一条问题库 */
|
||||
export function fetchAddQuestionLibrary(params: Api.Question.AddQuestionLibraryParams, file?: File | null) {
|
||||
const formData = new FormData()
|
||||
if (file) {
|
||||
formData.append('Image', file)
|
||||
}
|
||||
return request<Api.Question.CommonRecord>({
|
||||
url: '/Base/ActivityMain/AddQuestionListDetail',
|
||||
method: 'post',
|
||||
// params: {
|
||||
// QuestionId: params.questionId,
|
||||
// Name: params.name,
|
||||
// Answer: params.answer,
|
||||
// Type: params.type,
|
||||
// IsGood: params.IsGood,
|
||||
// ImageUrl: params.imageUrl,
|
||||
// },
|
||||
data: {
|
||||
QuestionId: params.questionId,
|
||||
Name: params.name,
|
||||
Answer: params.answer,
|
||||
Type: params.type,
|
||||
IsGood: params.IsGood,
|
||||
ImageUrl: params.imageUrl,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 更新一条问题库 */
|
||||
export function fetchUpdateQuestionLibrary(params: Api.Question.AddQuestionLibraryParams, file?: File | null) {
|
||||
const formData = new FormData()
|
||||
if (file) {
|
||||
formData.append('Image', file)
|
||||
}
|
||||
return request<Api.Question.CommonRecord>({
|
||||
url: '/Base/ActivityMain/UpdateQuestionListDetail',
|
||||
method: 'post',
|
||||
// params: {
|
||||
// Id: params.id,
|
||||
// QuestionId: params.questionId,
|
||||
// Name: params.name,
|
||||
// Answer: params.answer,
|
||||
// Type: params.type,
|
||||
// IsGood: params.IsGood,
|
||||
// ImageUrl: params.imageUrl,
|
||||
// },
|
||||
data: {
|
||||
Id: params.id,
|
||||
QuestionId: params.questionId,
|
||||
Name: params.name,
|
||||
Answer: params.answer,
|
||||
Type: params.type,
|
||||
IsGood: params.IsGood,
|
||||
ImageUrl: params.imageUrl,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 删除一条问题库 */
|
||||
export function fetchDeleteQuestionLibrary(ids: number[]) {
|
||||
return request({
|
||||
url: `/Base/ActivityMain/DeleteQuestionListDetail`,
|
||||
method: 'post',
|
||||
data: ids,
|
||||
})
|
||||
}
|
||||
62
apps/admin/src/service/api/rank.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { request } from '../request'
|
||||
|
||||
/** get user list */
|
||||
export function fetchRankList(MainID: number, RoundType = 0) {
|
||||
return request<Api.Rank.CommonRecord>({
|
||||
url: `/Base/ActivityMain/GetTeamsTotal/list?MainID=${MainID}&RoundType=${RoundType}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取队伍排名详情 */
|
||||
export function fetchRankDetail(MainID: number, RoundType = 0) {
|
||||
return request<Api.Rank.CommonRecord>({
|
||||
url: `/Base/ActivityMain/GetTeamsTotal?MainID=${MainID}&RoundType=${RoundType}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 根据队伍 id获取具体的题目分数 */
|
||||
export function fetchTeamQuestionScore(TeamID: number) {
|
||||
return request<Api.Rank.CommonRecord>({
|
||||
url: `/Base/ActivityMain/GetTeamQuestionUseByTeamID?TeamID=${TeamID}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取活动排行列表 */
|
||||
export function fetchActivityRankList(PageIndex: number, PageSize: number = 10) {
|
||||
return request<App.Service.Response<Api.Rank.ActivityRankList>>({
|
||||
url: `/Base/ActivityMain/GetActivity_Main_TotalPager?PageIndex=${PageIndex}&PageSizes=${PageSize}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 修改排名的队伍分数 */
|
||||
export function updateTeamScore(data: Api.Rank.UpdateTeamScoreParams[], activityId: number) {
|
||||
return request<App.Service.Response>({
|
||||
url: `/Base/ActivityMain/UpdateTeamQuestionResultRecore/?ActivityID=${activityId}`,
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/** 发布排名,更新状态 */
|
||||
export function publishRank(Id: number, Status = 1) {
|
||||
return request<App.Service.Response>({
|
||||
url: `/Base/ActivityMain/UpdateResultRecoreStatus`,
|
||||
method: 'post',
|
||||
data: {
|
||||
Id,
|
||||
Status,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取用户排名列表 */
|
||||
export function fetchUserRankList(MainID: number, RoundType: number = 0) {
|
||||
return request<App.Service.Response<Api.Rank.ActivityRankResList>>({
|
||||
url: `/Base/ActivityMain/GetTeamsTotalList?MainID=${MainID}&RoundType=${RoundType}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
28
apps/admin/src/service/api/result.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { request } from '../request'
|
||||
|
||||
/** 评委消费题目,表示用了该题目 */
|
||||
export function updateCurrentQuestionUse(TeamGroupID: number) {
|
||||
return request<App.Service.Response>({
|
||||
url: `/Base/ActivityMain/UpdateCurrentQuestionUse?TeamGroupID=${TeamGroupID}`,
|
||||
method: 'post',
|
||||
data: { TeamGroupID },
|
||||
})
|
||||
}
|
||||
|
||||
/** 评委对其进行打分 */
|
||||
export function updateScore(data: { Id: number, ResultPotins: number, QuestionDetailID: number, QuestionId: number }[]) {
|
||||
return request<App.Service.Response>({
|
||||
url: `/Base/ActivityMain/UpdateTeamQuestionResultList`,
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/** 结束活动时候调用 */
|
||||
export function endActivity(ActivityID: number) {
|
||||
return request<App.Service.Response>({
|
||||
url: `/Base/ActivityMain/AddOrUpdateActivity_Main_Total?ActivityID=${ActivityID}`,
|
||||
method: 'post',
|
||||
data: { ActivityID },
|
||||
})
|
||||
}
|
||||
55
apps/admin/src/service/api/system-manage.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { request } from '../request'
|
||||
|
||||
/** get role list */
|
||||
export function fetchGetRoleList(params?: Api.SystemManage.RoleSearchParams) {
|
||||
return request<Api.SystemManage.RoleList>({
|
||||
url: '/systemManage/getRoleList',
|
||||
method: 'get',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* get all roles
|
||||
*
|
||||
* these roles are all enabled
|
||||
*/
|
||||
export function fetchGetAllRoles() {
|
||||
return request<Api.SystemManage.AllRole[]>({
|
||||
url: '/systemManage/getAllRoles',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** get user list */
|
||||
export function fetchGetUserList(params?: Api.SystemManage.UserSearchParams) {
|
||||
return request<Api.SystemManage.UserList>({
|
||||
url: '/admin/v1/class/pagelist',
|
||||
method: 'post',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
/** get menu list */
|
||||
export function fetchGetMenuList() {
|
||||
return request<Api.SystemManage.MenuList>({
|
||||
url: '/systemManage/getMenuList/v2',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** get all pages */
|
||||
export function fetchGetAllPages() {
|
||||
return request<string[]>({
|
||||
url: '/systemManage/getAllPages',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** get menu tree */
|
||||
export function fetchGetMenuTree() {
|
||||
return request<Api.SystemManage.MenuTree[]>({
|
||||
url: '/systemManage/getMenuTree',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
43
apps/admin/src/service/api/template.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { request } from '../request'
|
||||
|
||||
/**
|
||||
* 获取模板列表
|
||||
*/
|
||||
export function fetchTemplateList(data?: Api.Template.TemplateSearchParams) {
|
||||
return request<App.Service.Response<Api.Common.CommonResponse>>({
|
||||
url: '/Base/ActivityMain/GetTemplete_Pag',
|
||||
method: 'get',
|
||||
params: data || {},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增模板, 更新模板
|
||||
*/
|
||||
export function fetchAddTemplate(data?: Api.Template.AddTemplateParams) {
|
||||
return request<App.Service.Response<Api.Common.PaginatingQueryRecord<Api.Template.CommonRecord>>>({
|
||||
url: '/Base/ActivityMain/AddOrUpdateTemplete_Pag',
|
||||
method: 'post',
|
||||
data: data || {},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模板详情
|
||||
*/
|
||||
export function fetchTemplateDetail(id: number) {
|
||||
return request<App.Service.Response<Api.Template.TemplateDetail>>({
|
||||
url: `/Base/ActivityMain/GetTemplete_PagByID?id=${id}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除模板
|
||||
*/
|
||||
export function fetchDeleteTemplate(id: number) {
|
||||
return request<App.Service.Response<Api.Common.PaginatingQueryRecord<Api.Template.CommonRecord>>>({
|
||||
url: `/Base/ActivityMain/DeleteTemplete_Pag?id=${id}`,
|
||||
method: 'post',
|
||||
})
|
||||
}
|
||||
31
apps/admin/src/service/api/upload.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { request } from '../request'
|
||||
|
||||
export interface ReqDeleteAliOssFile {
|
||||
key: string
|
||||
}
|
||||
|
||||
export interface AliOssSTS {
|
||||
AccessKeyId: string
|
||||
AccessKeySecret: string
|
||||
SecurityToken: string
|
||||
Expiration: string
|
||||
BucketName: string
|
||||
Region: string
|
||||
}
|
||||
|
||||
/** 得到上传的token */
|
||||
export function getAliOssTokenAxios() {
|
||||
return request<App.Service.Response<AliOssSTS>>({
|
||||
url: `${import.meta.env.VITE_BASE_UPLOAD_URL}/admin/v1/oss/sts`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 删除阿里云oss 文件 */
|
||||
export function deleteAliOssFileAxios(data: ReqDeleteAliOssFile) {
|
||||
return request<App.Service.Response<boolean>>({
|
||||
url: `${import.meta.env.VITE_BASE_UPLOAD_URL}/admin/v1/oss/delete`,
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
@ -10,36 +10,50 @@ import { getAuthorization, handleExpiredRequest, showErrorMsg } from './shared'
|
||||
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y'
|
||||
const { baseURL, otherBaseURL } = getServiceBaseURL(import.meta.env, isHttpProxy)
|
||||
|
||||
/**
|
||||
* 基础请求
|
||||
*/
|
||||
export const request = createFlatRequest(
|
||||
{
|
||||
baseURL,
|
||||
headers: {
|
||||
apifoxToken: 'XL299LiMEDZ0H5h3A29PxwQXdMJqWyY2',
|
||||
'apifoxToken': 'XL299LiMEDZ0H5h3A29PxwQXdMJqWyY2', // 用于 apifox 调试
|
||||
'Custom-Platform': 'pc',
|
||||
},
|
||||
},
|
||||
{
|
||||
defaultState: {
|
||||
errMsgStack: [],
|
||||
refreshTokenPromise: null,
|
||||
errMsgStack: [], // 错误信息栈
|
||||
refreshTokenPromise: null, // 刷新token Promise
|
||||
} as RequestInstanceState,
|
||||
// 响应数据转换
|
||||
transform(response: AxiosResponse<App.Service.Response<any>>) {
|
||||
return response.data.data
|
||||
// 如果后端返回的data 是string 类型,需要转换为对象
|
||||
if (typeof response.data.data === 'string') {
|
||||
response.data.data = JSON.parse(response.data.data)
|
||||
return response.data
|
||||
}
|
||||
return response.data
|
||||
},
|
||||
// 请求配置转换
|
||||
async onRequest(config) {
|
||||
const Authorization = getAuthorization()
|
||||
Object.assign(config.headers, { Authorization })
|
||||
|
||||
return config
|
||||
},
|
||||
// 响应状态判断
|
||||
isBackendSuccess(response) {
|
||||
// when the backend response code is "0000"(default), it means the request is success
|
||||
// to change this logic by yourself, you can modify the `VITE_SERVICE_SUCCESS_CODE` in `.env` file
|
||||
// 当后端响应状态码为 "200"(默认值)时,表示请求成功
|
||||
// 如果你想自行更改此逻辑,可以在 .env 文件中修改 VITE_SERVICE_SUCCESS_CODE
|
||||
return String(response.data.code) === import.meta.env.VITE_SERVICE_SUCCESS_CODE
|
||||
},
|
||||
// 响应错误处理
|
||||
async onBackendFail(response, instance) {
|
||||
const authStore = useAuthStore()
|
||||
const responseCode = String(response.data.code)
|
||||
|
||||
//
|
||||
function handleLogout() {
|
||||
authStore.resetStore()
|
||||
}
|
||||
@ -51,19 +65,19 @@ export const request = createFlatRequest(
|
||||
request.state.errMsgStack = request.state.errMsgStack.filter(msg => msg !== response.data.msg)
|
||||
}
|
||||
|
||||
// when the backend response code is in `logoutCodes`, it means the user will be logged out and redirected to login page
|
||||
// 当后端响应状态码在 logoutCodes 中时,表示用户将被登出并重定向到登录页面
|
||||
const logoutCodes = import.meta.env.VITE_SERVICE_LOGOUT_CODES?.split(',') || []
|
||||
if (logoutCodes.includes(responseCode)) {
|
||||
handleLogout()
|
||||
return null
|
||||
}
|
||||
|
||||
// when the backend response code is in `modalLogoutCodes`, it means the user will be logged out by displaying a modal
|
||||
// 当后端响应状态码在 modalLogoutCodes 中时,表示将通过显示模态框来使用户登出
|
||||
const modalLogoutCodes = import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES?.split(',') || []
|
||||
if (modalLogoutCodes.includes(responseCode) && !request.state.errMsgStack?.includes(response.data.msg)) {
|
||||
request.state.errMsgStack = [...(request.state.errMsgStack || []), response.data.msg]
|
||||
|
||||
// prevent the user from refreshing the page
|
||||
// 防止用户刷新页面
|
||||
window.addEventListener('beforeunload', handleLogout)
|
||||
|
||||
window.$dialog?.error({
|
||||
@ -83,8 +97,8 @@ export const request = createFlatRequest(
|
||||
return null
|
||||
}
|
||||
|
||||
// when the backend response code is in `expiredTokenCodes`, it means the token is expired, and refresh token
|
||||
// the api `refreshToken` can not return error code in `expiredTokenCodes`, otherwise it will be a dead loop, should return `logoutCodes` or `modalLogoutCodes`
|
||||
// 当后端响应状态码在 expiredTokenCodes 中时,表示 token 已过期,需要刷新 token
|
||||
// refreshToken 接口不能返回 expiredTokenCodes 中的错误码,否则会形成死循环,应该返回 logoutCodes 或 modalLogoutCodes
|
||||
const expiredTokenCodes = import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES?.split(',') || []
|
||||
if (expiredTokenCodes.includes(responseCode)) {
|
||||
const success = await handleExpiredRequest(request.state)
|
||||
@ -99,24 +113,24 @@ export const request = createFlatRequest(
|
||||
return null
|
||||
},
|
||||
onError(error) {
|
||||
// when the request is fail, you can show error message
|
||||
// 当请求失败时,可以显示错误信息
|
||||
|
||||
let message = error.message
|
||||
let backendErrorCode = ''
|
||||
|
||||
// get backend error message and code
|
||||
// 获取后端错误信息和状态码
|
||||
if (error.code === BACKEND_ERROR_CODE) {
|
||||
message = error.response?.data?.msg || message
|
||||
backendErrorCode = String(error.response?.data?.code || '')
|
||||
}
|
||||
|
||||
// the error message is displayed in the modal
|
||||
// 错误信息已在模态框中显示
|
||||
const modalLogoutCodes = import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES?.split(',') || []
|
||||
if (modalLogoutCodes.includes(backendErrorCode)) {
|
||||
return
|
||||
}
|
||||
|
||||
// when the token is expired, refresh token and retry request, so no need to show error message
|
||||
// 当 token 过期时,刷新 token 并重试请求,因此不需要显示错误信息
|
||||
const expiredTokenCodes = import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES?.split(',') || []
|
||||
if (expiredTokenCodes.includes(backendErrorCode)) {
|
||||
return
|
||||
@ -127,39 +141,48 @@ export const request = createFlatRequest(
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* 演示请求
|
||||
*/
|
||||
export const demoRequest = createRequest(
|
||||
{
|
||||
baseURL: otherBaseURL.demo,
|
||||
},
|
||||
/**
|
||||
* 演示请求响应数据转换
|
||||
*/
|
||||
{
|
||||
// 响应数据转换
|
||||
transform(response: AxiosResponse<App.Service.DemoResponse>) {
|
||||
return response.data.result
|
||||
},
|
||||
// 请求配置转换
|
||||
async onRequest(config) {
|
||||
const { headers } = config
|
||||
|
||||
// set token
|
||||
// 设置 token
|
||||
const token = localStg.get('token')
|
||||
const Authorization = token ? `Bearer ${token}` : null
|
||||
Object.assign(headers, { Authorization })
|
||||
|
||||
return config
|
||||
},
|
||||
// 响应状态判断
|
||||
isBackendSuccess(response) {
|
||||
// when the backend response code is "200", it means the request is success
|
||||
// you can change this logic by yourself
|
||||
// 当后端响应状态码为 "200" 时,表示请求成功
|
||||
// 你可以自行更改此逻辑
|
||||
return response.data.status === '200'
|
||||
},
|
||||
async onBackendFail(_response) {
|
||||
// when the backend response code is not "200", it means the request is fail
|
||||
// for example: the token is expired, refresh token and retry request
|
||||
// 当后端响应状态码不是 "200" 时,表示请求失败
|
||||
// 例如:token 过期,刷新 token 并重试请求
|
||||
},
|
||||
onError(error) {
|
||||
// when the request is fail, you can show error message
|
||||
// 当请求失败时,可以显示错误信息
|
||||
|
||||
let message = error.message
|
||||
|
||||
// show backend error message
|
||||
// 显示后端错误信息
|
||||
if (error.code === BACKEND_ERROR_CODE) {
|
||||
message = error.response?.data?.message || message
|
||||
}
|
||||
|
||||
76
apps/admin/src/service/request/readme.md
Normal file
@ -0,0 +1,76 @@
|
||||
## error 字段 不是后端接口直接返回的 ,而是前端请求工具 request (基于 @sa/axios 的 createFlatRequest 封装) 为了简化错误处理而增加的。
|
||||
|
||||
它的机制如下:
|
||||
|
||||
- 请求成功 : error 为 null , data 为后端返回的响应体(包含 code , msg , data , success 等字段)。
|
||||
- 请求失败 (如网络断开、超时): error 会包含错误信息对象, data 为 null 。
|
||||
这种写法让您可以直接解构返回值判断请求是否成功,避免了使用 `try-catch` 包裹。
|
||||
|
||||
总结:
|
||||
|
||||
- response (您重命名的 data ) :包含后端返回的 { success, code, msg, data } 。
|
||||
- error :前端请求库生成的错误对象,用于判断 HTTP 请求层面的成败
|
||||
|
||||
参考:
|
||||
|
||||
- [@sa/axios 文档](https://bytedance.larkoffice.com/wiki/wikcnn33hQ3h3FkqCnqCn33h3Fk)
|
||||
|
||||
例子:
|
||||
|
||||
```typescript
|
||||
import request from '@/service/request'
|
||||
|
||||
// 成功请求
|
||||
const { data, error } = await request.get('/api/success')
|
||||
if (error) {
|
||||
console.error('请求失败:', error)
|
||||
return
|
||||
}
|
||||
console.log('请求成功:', data)
|
||||
|
||||
// 失败请求(如网络断开)
|
||||
const { data: failedData, error: failedError } = await request.get('/api/fail')
|
||||
if (failedError) {
|
||||
console.error('请求失败:', failedError)
|
||||
return
|
||||
}
|
||||
console.log('请求成功:', failedData) // 不会执行到这里
|
||||
|
||||
/**
|
||||
* 初始化模板详情
|
||||
*/
|
||||
async function initData() {
|
||||
const id = Number(route.query.id)
|
||||
if (id) {
|
||||
try {
|
||||
loading.value = true
|
||||
const { data: response, error } = await fetchTemplateDetail(id)
|
||||
if (!error && response) {
|
||||
const { data, success } = response
|
||||
if (success) {
|
||||
const { Name, Width, Height, BackGroundUrl, TempContent } = data || {}
|
||||
templateInfo.value = {
|
||||
name: Name,
|
||||
width: Width,
|
||||
height: Height,
|
||||
backGroundUrl: BackGroundUrl,
|
||||
}
|
||||
imgSrc.value = BackGroundUrl || ''
|
||||
|
||||
if (TempContent) {
|
||||
try {
|
||||
regions.value = JSON.parse(TempContent || '[]')
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Failed to parse tempContent', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@ -10,7 +10,9 @@ export function getAuthorization() {
|
||||
return Authorization
|
||||
}
|
||||
|
||||
/** refresh token */
|
||||
/**
|
||||
* 刷新token
|
||||
*/
|
||||
async function handleRefreshToken() {
|
||||
const { resetStore } = useAuthStore()
|
||||
|
||||
@ -27,6 +29,9 @@ async function handleRefreshToken() {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理过期token的请求
|
||||
*/
|
||||
export async function handleExpiredRequest(state: RequestInstanceState) {
|
||||
if (!state.refreshTokenPromise) {
|
||||
state.refreshTokenPromise = handleRefreshToken()
|
||||
@ -41,6 +46,9 @@ export async function handleExpiredRequest(state: RequestInstanceState) {
|
||||
return success
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示错误信息
|
||||
*/
|
||||
export function showErrorMsg(state: RequestInstanceState, message: string) {
|
||||
if (!state.errMsgStack?.length) {
|
||||
state.errMsgStack = []
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
export interface RequestInstanceState {
|
||||
/** the promise of refreshing token */
|
||||
/** 刷新token Promise */
|
||||
refreshTokenPromise: Promise<boolean> | null
|
||||
/** the request error message stack */
|
||||
/** 请求错误信息栈 */
|
||||
errMsgStack: string[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { App } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
import { resetSetupStore } from './plugins'
|
||||
|
||||
/** Setup Vue store plugin pinia */
|
||||
@ -7,6 +8,7 @@ export function setupStore(app: App) {
|
||||
const store = createPinia()
|
||||
|
||||
store.use(resetSetupStore)
|
||||
store.use(piniaPluginPersistedstate)
|
||||
|
||||
app.use(store)
|
||||
}
|
||||
|
||||
55
apps/admin/src/store/modules/activityinfo/index.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useActivityInfoStore = defineStore('activity-info', () => {
|
||||
const activityInfo = ref<Api.Competition.ActivityDetail | null>(null)
|
||||
const activityId = ref<number | null>(null)
|
||||
const groupId = ref<number | null>(null)
|
||||
const teamId = ref<number | null>(null)
|
||||
const teamIds = ref<number[]>([])
|
||||
|
||||
function setActivityInfo(info: Api.Competition.ActivityDetail) {
|
||||
activityInfo.value = info
|
||||
activityId.value = info.Id
|
||||
}
|
||||
|
||||
function setActivityId(id: number) {
|
||||
activityId.value = id
|
||||
}
|
||||
|
||||
function setGroupId(id: number) {
|
||||
groupId.value = id
|
||||
}
|
||||
|
||||
function setTeamId(id: number) {
|
||||
teamId.value = id
|
||||
}
|
||||
|
||||
function setTeamIds(ids: number[]) {
|
||||
teamIds.value = ids
|
||||
}
|
||||
|
||||
function clearActivityInfo() {
|
||||
activityInfo.value = null
|
||||
activityId.value = null
|
||||
groupId.value = null
|
||||
teamId.value = null
|
||||
teamIds.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
teamIds,
|
||||
activityInfo,
|
||||
activityId,
|
||||
groupId,
|
||||
teamId,
|
||||
setActivityInfo,
|
||||
setActivityId,
|
||||
setGroupId,
|
||||
setTeamId,
|
||||
setTeamIds,
|
||||
clearActivityInfo,
|
||||
}
|
||||
}, {
|
||||
persist: true,
|
||||
})
|
||||
@ -9,7 +9,7 @@ import { fetchGetUserInfo, fetchLogin } from '@/service/api'
|
||||
import { localStg } from '@/utils/storage'
|
||||
import { useRouteStore } from '../route'
|
||||
import { useTabStore } from '../tab'
|
||||
import { clearAuthStorage, getToken } from './shared'
|
||||
import { clearAuthStorage, getToken, getUserId } from './shared'
|
||||
|
||||
export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
const route = useRoute()
|
||||
@ -98,7 +98,6 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
*/
|
||||
async function login(userName: string, password: string, redirect = true) {
|
||||
startLoading()
|
||||
|
||||
const { data: loginToken, error } = await fetchLogin(userName, password)
|
||||
|
||||
if (!error) {
|
||||
@ -133,9 +132,10 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
// 1. stored in the localStorage, the later requests need it in headers
|
||||
localStg.set('token', loginToken.token)
|
||||
localStg.set('refreshToken', loginToken.refreshToken)
|
||||
localStg.set('userId', loginToken.id)
|
||||
|
||||
// 2. get user info
|
||||
const pass = await getUserInfo()
|
||||
const pass = await getUserInfo(loginToken.id)
|
||||
|
||||
if (pass) {
|
||||
token.value = loginToken.token
|
||||
@ -146,8 +146,8 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
return false
|
||||
}
|
||||
|
||||
async function getUserInfo() {
|
||||
const { data: info, error } = await fetchGetUserInfo()
|
||||
async function getUserInfo(userId: number) {
|
||||
const { data: info, error } = await fetchGetUserInfo(userId)
|
||||
|
||||
if (!error) {
|
||||
// update store
|
||||
@ -161,9 +161,10 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
|
||||
async function initUserInfo() {
|
||||
const hasToken = getToken()
|
||||
const userId = getUserId()
|
||||
|
||||
if (hasToken) {
|
||||
const pass = await getUserInfo()
|
||||
if (hasToken && userId) {
|
||||
const pass = await getUserInfo(userId)
|
||||
|
||||
if (!pass) {
|
||||
resetStore()
|
||||
|
||||
@ -5,8 +5,14 @@ export function getToken() {
|
||||
return localStg.get('token') || ''
|
||||
}
|
||||
|
||||
export function getUserId() {
|
||||
const userId = localStg.get('userId')
|
||||
return userId || 0
|
||||
}
|
||||
|
||||
/** Clear auth storage */
|
||||
export function clearAuthStorage() {
|
||||
localStg.remove('token')
|
||||
localStg.remove('refreshToken')
|
||||
localStg.remove('userId')
|
||||
}
|
||||
|
||||
59
apps/admin/src/store/modules/business/index.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { getDictionaryItemListByDicID, getDictionaryList } from '@/service/api/dictionary'
|
||||
|
||||
export const useBusinessStore = defineStore('business-store', () => {
|
||||
const dictList = ref<Api.Dictionary.DictionaryItem[]>([])
|
||||
const dictData = ref<Record<string, Api.Dictionary.DictionaryUIItem[]>>({})
|
||||
const isInitialized = ref(false)
|
||||
const loadingMap = ref<Record<string, boolean>>({})
|
||||
|
||||
/** 初始化字典 */
|
||||
async function initDict() {
|
||||
if (isInitialized.value)
|
||||
return
|
||||
const { data: res, error } = await getDictionaryList()
|
||||
if (!error && res) {
|
||||
dictList.value = res.data || []
|
||||
isInitialized.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取字典数据 */
|
||||
async function getDict(key: string) {
|
||||
// 已经加载过,直接返回
|
||||
if (dictData.value[key] && dictData.value[key].length > 0)
|
||||
return dictData.value[key]
|
||||
|
||||
if (loadingMap.value[key]) {
|
||||
return []
|
||||
}
|
||||
|
||||
loadingMap.value[key] = true
|
||||
try {
|
||||
const { data: res, error } = await getDictionaryItemListByDicID(key)
|
||||
if (!error && res) {
|
||||
const list = res.data || []
|
||||
// 使用新对象赋值,确保触发响应式更新
|
||||
dictData.value = {
|
||||
...dictData.value,
|
||||
[key]: list,
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
loadingMap.value[key] = false
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`获取字典数据 ${key}`, dictData.value[key])
|
||||
return dictData.value[key]
|
||||
}
|
||||
|
||||
return {
|
||||
dictList,
|
||||
dictData,
|
||||
loadingMap,
|
||||
initDict,
|
||||
getDict,
|
||||
}
|
||||
})
|
||||
156
apps/admin/src/store/modules/competition/index.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { AudioController } from '@/utils/audio'
|
||||
|
||||
export const useCompetitionStore = defineStore('competition', () => {
|
||||
// ==========================================
|
||||
// State - 核心数据
|
||||
// ==========================================
|
||||
|
||||
/** 从抽题页获取当前题目基础提纲信息 */
|
||||
const currentQuestionMainInfo = ref<Api.Competition.CurrentQuestionResponse>({} as Api.Competition.CurrentQuestionResponse)
|
||||
|
||||
/** 当前选中的题目基础信息 (从抽题页获取) */
|
||||
const currentQuestionInfo = ref<Api.Competition.QuestionListRecord>({} as Api.Competition.QuestionListRecord)
|
||||
|
||||
/** 当前题目的详细信息 (从详情接口获取) */
|
||||
const currentQuestionDetail = ref<Api.Competition.QuestionListDetailRound>({} as Api.Competition.QuestionListDetailRound)
|
||||
|
||||
// ==========================================
|
||||
// State - 流程控制 & 交互状态
|
||||
// ==========================================
|
||||
|
||||
/** 倒计时剩余时间 (秒) */
|
||||
const timeLeft = ref(0)
|
||||
|
||||
/** 定时器引用 */
|
||||
const timerInterval = ref<number | null>(null)
|
||||
|
||||
/** 音效控制器 */
|
||||
const audioController = new AudioController()
|
||||
|
||||
// ==========================================
|
||||
// Actions - 数据操作
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* 设置题目列表
|
||||
*/
|
||||
function setCurrentQuestionMainInfo(obj: Api.Competition.CurrentQuestionResponse) {
|
||||
currentQuestionMainInfo.value = obj as Api.Competition.CurrentQuestionResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空设置题目列表
|
||||
*/
|
||||
function resetCurrentQuestionMainInfo() {
|
||||
currentQuestionMainInfo.value = {} as Api.Competition.CurrentQuestionResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前题目基础信息
|
||||
*/
|
||||
function setCurrentQuestionInfo(info: Api.Competition.QuestionListRecord) {
|
||||
currentQuestionInfo.value = info
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空设置当前题目基础信息
|
||||
*/
|
||||
function resetCurrentQuestionInfo() {
|
||||
currentQuestionInfo.value = {} as Api.Competition.QuestionListRecord
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前题目详情
|
||||
*/
|
||||
function setCurrentQuestionDetail(detail: Api.Competition.QuestionListDetailRound) {
|
||||
currentQuestionDetail.value = detail
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空设置当前题目详情
|
||||
*/
|
||||
function resetCurrentQuestionDetail() {
|
||||
currentQuestionDetail.value = {} as Api.Competition.QuestionListDetailRound
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Actions - 流程控制
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* 启动倒计时
|
||||
* @param duration 持续时间(秒)
|
||||
*/
|
||||
function startTimer(duration: number) {
|
||||
stopTimer()
|
||||
timeLeft.value = duration
|
||||
|
||||
// 播放开始音效
|
||||
audioController.play('start')
|
||||
|
||||
timerInterval.value = window.setInterval(() => {
|
||||
if (timeLeft.value > 0) {
|
||||
timeLeft.value--
|
||||
// 剩余5秒播放倒计时音效
|
||||
if (timeLeft.value <= 5) {
|
||||
audioController.play('tick')
|
||||
}
|
||||
}
|
||||
else {
|
||||
stopTimer()
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止倒计时
|
||||
*/
|
||||
function stopTimer() {
|
||||
if (timerInterval.value) {
|
||||
clearInterval(timerInterval.value)
|
||||
timerInterval.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置当前题目流程状态 (不清除题目基础信息)
|
||||
*/
|
||||
function resetRoundState() {
|
||||
currentQuestionDetail.value = {} as Api.Competition.QuestionListDetailRound
|
||||
stopTimer()
|
||||
timeLeft.value = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化/重置所有数据
|
||||
*/
|
||||
function initData() {
|
||||
currentQuestionMainInfo.value = {} as Api.Competition.CurrentQuestionResponse
|
||||
currentQuestionInfo.value = {} as Api.Competition.QuestionListRecord
|
||||
resetRoundState()
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
currentQuestionMainInfo,
|
||||
currentQuestionInfo,
|
||||
currentQuestionDetail,
|
||||
timeLeft,
|
||||
|
||||
// Actions
|
||||
setCurrentQuestionMainInfo,
|
||||
setCurrentQuestionInfo,
|
||||
setCurrentQuestionDetail,
|
||||
startTimer,
|
||||
stopTimer,
|
||||
resetRoundState,
|
||||
initData,
|
||||
resetCurrentQuestionMainInfo,
|
||||
resetCurrentQuestionInfo,
|
||||
resetCurrentQuestionDetail,
|
||||
}
|
||||
}, {
|
||||
persist: true,
|
||||
})
|
||||
@ -30,27 +30,26 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
const { bool: isInitAuthRoute, setBool: setIsInitAuthRoute } = useBoolean()
|
||||
|
||||
/**
|
||||
* Auth route mode
|
||||
* 权限路由模式
|
||||
*
|
||||
* It recommends to use static mode in the development environment, and use dynamic mode in the production
|
||||
* environment, if use static mode in development environment, the auth routes will be auto generated by plugin
|
||||
* "@elegant-router/vue"
|
||||
* 建议在开发环境中使用静态模式,在生产环境中使用动态模式
|
||||
* 如果在开发环境中使用静态模式,权限路由将由插件 "@elegant-router/vue" 自动生成
|
||||
*/
|
||||
const authRouteMode = ref(import.meta.env.VITE_AUTH_ROUTE_MODE)
|
||||
|
||||
/** Home route key */
|
||||
/** 首页路由 key */
|
||||
const routeHome = ref(import.meta.env.VITE_ROUTE_HOME)
|
||||
|
||||
/**
|
||||
* Set route home
|
||||
* 设置首页路由
|
||||
*
|
||||
* @param routeKey Route key
|
||||
* @param routeKey 路由 key
|
||||
*/
|
||||
function setRouteHome(routeKey: LastLevelRouteKey) {
|
||||
routeHome.value = routeKey
|
||||
}
|
||||
|
||||
/** constant routes */
|
||||
/** 常量路由 */
|
||||
const constantRoutes = shallowRef<ElegantConstRoute[]>([])
|
||||
|
||||
function addConstantRoutes(routes: ElegantConstRoute[]) {
|
||||
@ -63,7 +62,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
constantRoutes.value = Array.from(constantRoutesMap.values())
|
||||
}
|
||||
|
||||
/** auth routes */
|
||||
/** 权限路由 */
|
||||
const authRoutes = shallowRef<ElegantConstRoute[]>([])
|
||||
|
||||
function addAuthRoutes(routes: ElegantConstRoute[]) {
|
||||
@ -78,44 +77,51 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
|
||||
const removeRouteFns: (() => void)[] = []
|
||||
|
||||
/** Global menus */
|
||||
/** 全局菜单 */
|
||||
const menus = ref<App.Global.Menu[]>([])
|
||||
const menusForBreadcrumb = ref<App.Global.Menu[]>([])
|
||||
const searchMenus = computed(() => transformMenuToSearchMenus(menus.value))
|
||||
|
||||
/** Get global menus */
|
||||
/**
|
||||
* 获取全局菜单
|
||||
*
|
||||
* @param routes 路由
|
||||
*/
|
||||
function getGlobalMenus(routes: ElegantConstRoute[]) {
|
||||
menus.value = getGlobalMenusByAuthRoutes(routes)
|
||||
menus.value = getGlobalMenusByAuthRoutes(routes, true, false)
|
||||
menusForBreadcrumb.value = getGlobalMenusByAuthRoutes(routes, false, true)
|
||||
}
|
||||
|
||||
/** Update global menus by locale */
|
||||
/** 根据语言更新全局菜单 */
|
||||
function updateGlobalMenusByLocale() {
|
||||
menus.value = updateLocaleOfGlobalMenus(menus.value)
|
||||
menusForBreadcrumb.value = updateLocaleOfGlobalMenus(menusForBreadcrumb.value)
|
||||
}
|
||||
|
||||
/** Cache routes */
|
||||
/** 缓存路由 */
|
||||
const cacheRoutes = ref<RouteKey[]>([])
|
||||
|
||||
/**
|
||||
* Exclude cache routes
|
||||
* 排除缓存路由
|
||||
*
|
||||
* for reset route cache
|
||||
* 用于重置路由缓存
|
||||
*/
|
||||
const excludeCacheRoutes = ref<RouteKey[]>([])
|
||||
|
||||
/**
|
||||
* Get cache routes
|
||||
* 获取缓存路由
|
||||
*
|
||||
* @param routes Vue routes
|
||||
* @param routes Vue 路由
|
||||
*/
|
||||
function getCacheRoutes(routes: RouteRecordRaw[]) {
|
||||
cacheRoutes.value = getCacheRouteNames(routes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset route cache
|
||||
* 重置路由缓存
|
||||
*
|
||||
* @default
|
||||
* @param routeKey
|
||||
* @param routeKey 路由 key
|
||||
*/
|
||||
async function resetRouteCache(routeKey?: RouteKey) {
|
||||
const routeName = routeKey || (router.currentRoute.value.name as RouteKey)
|
||||
@ -127,10 +133,10 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
excludeCacheRoutes.value = []
|
||||
}
|
||||
|
||||
/** Global breadcrumbs */
|
||||
const breadcrumbs = computed(() => getBreadcrumbsByRoute(router.currentRoute.value, menus.value))
|
||||
/** 全局面包屑 */
|
||||
const breadcrumbs = computed(() => getBreadcrumbsByRoute(router.currentRoute.value, menusForBreadcrumb.value))
|
||||
|
||||
/** Reset store */
|
||||
/** 重置 store */
|
||||
async function resetStore() {
|
||||
const routeStore = useRouteStore()
|
||||
|
||||
@ -138,34 +144,36 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
|
||||
resetVueRoutes()
|
||||
|
||||
// after reset store, need to re-init constant route
|
||||
// 重置 store 后,需要重新初始化常量路由
|
||||
await initConstantRoute()
|
||||
}
|
||||
|
||||
/** Reset vue routes */
|
||||
/** 重置 vue 路由 */
|
||||
function resetVueRoutes() {
|
||||
removeRouteFns.forEach(fn => fn())
|
||||
removeRouteFns.length = 0
|
||||
}
|
||||
|
||||
/** init constant route */
|
||||
/** 初始化常量路由 */
|
||||
async function initConstantRoute() {
|
||||
if (isInitConstantRoute.value)
|
||||
return
|
||||
|
||||
// 静态路由
|
||||
const staticRoute = createStaticRoutes()
|
||||
|
||||
if (authRouteMode.value === 'static') {
|
||||
// 如果是静态路由模式,直接添加静态常量路由
|
||||
if (authRouteMode.value === 'static') { // 静态路由模式
|
||||
addConstantRoutes(staticRoute.constantRoutes)
|
||||
}
|
||||
else {
|
||||
else { // 动态路由模式
|
||||
const { data, error } = await fetchGetConstantRoutes()
|
||||
|
||||
if (!error) {
|
||||
addConstantRoutes(data)
|
||||
}
|
||||
else {
|
||||
// if fetch constant routes failed, use static constant routes
|
||||
// 如果获取常量路由失败,使用静态常量路由
|
||||
addConstantRoutes(staticRoute.constantRoutes)
|
||||
}
|
||||
}
|
||||
@ -177,9 +185,9 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
tabStore.initHomeTab()
|
||||
}
|
||||
|
||||
/** Init auth route */
|
||||
/** 初始化权限路由 */
|
||||
async function initAuthRoute() {
|
||||
// check if user info is initialized
|
||||
// 检查用户信息是否已初始化
|
||||
if (!authStore.userInfo.userId) {
|
||||
await authStore.initUserInfo()
|
||||
}
|
||||
@ -194,7 +202,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
tabStore.initHomeTab()
|
||||
}
|
||||
|
||||
/** Init static auth route */
|
||||
/** 初始化静态权限路由 */
|
||||
function initStaticAuthRoute() {
|
||||
const { authRoutes: staticAuthRoutes } = createStaticRoutes()
|
||||
|
||||
@ -212,7 +220,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
setIsInitAuthRoute(true)
|
||||
}
|
||||
|
||||
/** Init dynamic auth route */
|
||||
/** 初始化动态权限路由 */
|
||||
async function initDynamicAuthRoute() {
|
||||
const { data, error } = await fetchGetUserRoutes()
|
||||
|
||||
@ -230,12 +238,12 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
setIsInitAuthRoute(true)
|
||||
}
|
||||
else {
|
||||
// if fetch user routes failed, reset store
|
||||
// 如果获取用户路由失败,重置 store
|
||||
authStore.resetStore()
|
||||
}
|
||||
}
|
||||
|
||||
/** handle constant and auth routes */
|
||||
/** 处理常量路由和权限路由 */
|
||||
function handleConstantAndAuthRoutes() {
|
||||
const allRoutes = [...constantRoutes.value, ...authRoutes.value]
|
||||
|
||||
@ -253,9 +261,9 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Add routes to vue router
|
||||
* 添加路由到 vue router
|
||||
*
|
||||
* @param routes Vue routes
|
||||
* @param routes Vue 路由
|
||||
*/
|
||||
function addRoutesToVueRouter(routes: RouteRecordRaw[]) {
|
||||
routes.forEach((route) => {
|
||||
@ -265,18 +273,18 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Add remove route fn
|
||||
* 添加删除路由函数
|
||||
*
|
||||
* @param fn
|
||||
* @param fn 删除函数
|
||||
*/
|
||||
function addRemoveRouteFn(fn: () => void) {
|
||||
removeRouteFns.push(fn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update root route redirect when auth route mode is dynamic
|
||||
* 当权限路由模式为动态时,更新根路由重定向
|
||||
*
|
||||
* @param redirectKey Redirect route key
|
||||
* @param redirectKey 重定向路由 key
|
||||
*/
|
||||
function handleUpdateRootRouteRedirect(redirectKey: LastLevelRouteKey) {
|
||||
const redirect = getRoutePath(redirectKey)
|
||||
@ -293,9 +301,9 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get is auth route exist
|
||||
* 获取权限路由是否存在
|
||||
*
|
||||
* @param routePath Route path
|
||||
* @param routePath 路由路径
|
||||
*/
|
||||
async function getIsAuthRouteExist(routePath: RouteMap[RouteKey]) {
|
||||
const routeName = getRouteName(routePath)
|
||||
@ -315,20 +323,20 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get selected menu key path
|
||||
* 获取选中的菜单 key 路径
|
||||
*
|
||||
* @param selectedKey Selected menu key
|
||||
* @param selectedKey 选中的菜单 key
|
||||
*/
|
||||
function getSelectedMenuKeyPath(selectedKey: string) {
|
||||
return getSelectedMenuKeyPathByKey(selectedKey, menus.value)
|
||||
}
|
||||
|
||||
async function onRouteSwitchWhenLoggedIn() {
|
||||
// some global init logic when logged in and switch route
|
||||
// 登录并切换路由时的一些全局初始化逻辑
|
||||
}
|
||||
|
||||
async function onRouteSwitchWhenNotLoggedIn() {
|
||||
// some global init logic if it does not need to be logged in
|
||||
// 如果不需要登录时的一些全局初始化逻辑
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@ -4,28 +4,28 @@ import { useSvgIcon } from '@/hooks/common/icon'
|
||||
import { $t } from '@/locales'
|
||||
|
||||
/**
|
||||
* Filter auth routes by roles
|
||||
* 根据角色过滤权限路由
|
||||
*
|
||||
* @param routes Auth routes
|
||||
* @param roles Roles
|
||||
* @param routes 权限路由
|
||||
* @param roles 角色
|
||||
*/
|
||||
export function filterAuthRoutesByRoles(routes: ElegantConstRoute[], roles: string[]) {
|
||||
return routes.flatMap(route => filterAuthRouteByRoles(route, roles))
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter auth route by roles
|
||||
* 根据角色过滤权限路由
|
||||
*
|
||||
* @param route Auth route
|
||||
* @param roles Roles
|
||||
* @param route 权限路由
|
||||
* @param roles 角色
|
||||
*/
|
||||
function filterAuthRouteByRoles(route: ElegantConstRoute, roles: string[]): ElegantConstRoute[] {
|
||||
const routeRoles = (route.meta && route.meta.roles) || []
|
||||
|
||||
// if the route's "roles" is empty, then it is allowed to access
|
||||
// 如果路由的 "roles" 为空,则允许访问
|
||||
const isEmptyRoles = !routeRoles.length
|
||||
|
||||
// if the user's role is included in the route's "roles", then it is allowed to access
|
||||
// 如果用户的角色包含在路由的 "roles" 中,则允许访问
|
||||
const hasPermission = routeRoles.some(role => roles.includes(role))
|
||||
|
||||
const filterRoute = { ...route }
|
||||
@ -34,7 +34,7 @@ function filterAuthRouteByRoles(route: ElegantConstRoute, roles: string[]): Eleg
|
||||
filterRoute.children = filterRoute.children.flatMap(item => filterAuthRouteByRoles(item, roles))
|
||||
}
|
||||
|
||||
// Exclude the route if it has no children after filtering
|
||||
// 如果过滤后没有子路由,则排除该路由
|
||||
if (filterRoute.children?.length === 0) {
|
||||
return []
|
||||
}
|
||||
@ -43,9 +43,9 @@ function filterAuthRouteByRoles(route: ElegantConstRoute, roles: string[]): Eleg
|
||||
}
|
||||
|
||||
/**
|
||||
* sort route by order
|
||||
* 根据 order 对路由进行排序
|
||||
*
|
||||
* @param route route
|
||||
* @param route 路由
|
||||
*/
|
||||
function sortRouteByOrder(route: ElegantConstRoute) {
|
||||
if (route.children?.length) {
|
||||
@ -57,9 +57,9 @@ function sortRouteByOrder(route: ElegantConstRoute) {
|
||||
}
|
||||
|
||||
/**
|
||||
* sort routes by order
|
||||
* 根据 order 对路由进行排序
|
||||
*
|
||||
* @param routes routes
|
||||
* @param routes 路由
|
||||
*/
|
||||
export function sortRoutesByOrder(routes: ElegantConstRoute[]) {
|
||||
routes.sort((next, prev) => (Number(next.meta?.order) || 0) - (Number(prev.meta?.order) || 0))
|
||||
@ -69,19 +69,30 @@ export function sortRoutesByOrder(routes: ElegantConstRoute[]) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get global menus by auth routes
|
||||
*
|
||||
* @param routes Auth routes
|
||||
* 根据权限路由获取全局菜单
|
||||
* 当嵌套路由里面,有且仅有一个子路由时,将其提升到一级菜单,点击一级菜单时,跳转到子路由
|
||||
* @param routes 权限路由
|
||||
* @param shouldHoist 是否提升
|
||||
* @param includeHidden 是否包含隐藏路由
|
||||
*/
|
||||
export function getGlobalMenusByAuthRoutes(routes: ElegantConstRoute[]) {
|
||||
export function getGlobalMenusByAuthRoutes(routes: ElegantConstRoute[], shouldHoist = false, includeHidden = false) {
|
||||
const menus: App.Global.Menu[] = []
|
||||
|
||||
routes.forEach((route) => {
|
||||
if (!route.meta?.hideInMenu) {
|
||||
if (includeHidden || !route.meta?.hideInMenu) {
|
||||
const menu = getGlobalMenuByBaseRoute(route)
|
||||
|
||||
if (route.children?.some(child => !child.meta?.hideInMenu)) {
|
||||
menu.children = getGlobalMenusByAuthRoutes(route.children)
|
||||
if (route.children?.some(child => includeHidden || !child.meta?.hideInMenu)) {
|
||||
menu.children = getGlobalMenusByAuthRoutes(route.children, shouldHoist, includeHidden)
|
||||
}
|
||||
|
||||
// 如果只有一个子菜单,将其提升
|
||||
if (shouldHoist && menu.children?.length === 1) {
|
||||
const singleChild = menu.children[0]
|
||||
menu.key = singleChild.key
|
||||
menu.routeKey = singleChild.routeKey
|
||||
menu.routePath = singleChild.routePath
|
||||
menu.children = singleChild.children
|
||||
}
|
||||
|
||||
menus.push(menu)
|
||||
@ -92,7 +103,7 @@ export function getGlobalMenusByAuthRoutes(routes: ElegantConstRoute[]) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update locale of global menus
|
||||
* 更新全局菜单的国际化
|
||||
*
|
||||
* @param menus
|
||||
*/
|
||||
@ -120,7 +131,7 @@ export function updateLocaleOfGlobalMenus(menus: App.Global.Menu[]) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get global menu by route
|
||||
* 根据路由获取全局菜单
|
||||
*
|
||||
* @param route
|
||||
*/
|
||||
@ -145,15 +156,15 @@ function getGlobalMenuByBaseRoute(route: RouteLocationNormalizedLoaded | Elegant
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache route names
|
||||
* 获取缓存路由名称
|
||||
*
|
||||
* @param routes Vue routes (two levels)
|
||||
* @param routes Vue 路由 (两级)
|
||||
*/
|
||||
export function getCacheRouteNames(routes: RouteRecordRaw[]) {
|
||||
const cacheNames: LastLevelRouteKey[] = []
|
||||
|
||||
routes.forEach((route) => {
|
||||
// only get last two level route, which has component
|
||||
// 只获取最后两级有组件的路由
|
||||
route.children?.forEach((child) => {
|
||||
if (child.component && child.meta?.keepAlive) {
|
||||
cacheNames.push(child.name as LastLevelRouteKey)
|
||||
@ -165,7 +176,7 @@ export function getCacheRouteNames(routes: RouteRecordRaw[]) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Is route exist by route name
|
||||
* 根据路由名称判断路由是否存在
|
||||
*
|
||||
* @param routeName
|
||||
* @param routes
|
||||
@ -175,7 +186,7 @@ export function isRouteExistByRouteName(routeName: RouteKey, routes: ElegantCons
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursive get is route exist by route name
|
||||
* 递归判断路由名称是否存在
|
||||
*
|
||||
* @param route
|
||||
* @param routeName
|
||||
@ -195,7 +206,7 @@ function recursiveGetIsRouteExistByRouteName(route: ElegantConstRoute, routeName
|
||||
}
|
||||
|
||||
/**
|
||||
* Get selected menu key path
|
||||
* 获取选中的菜单 key 路径
|
||||
*
|
||||
* @param selectedKey
|
||||
* @param menus
|
||||
@ -219,10 +230,10 @@ export function getSelectedMenuKeyPathByKey(selectedKey: string, menus: App.Glob
|
||||
}
|
||||
|
||||
/**
|
||||
* Find menu path
|
||||
* 查找菜单路径
|
||||
*
|
||||
* @param targetKey Target menu key
|
||||
* @param menu Menu
|
||||
* @param targetKey 目标菜单 key
|
||||
* @param menu 菜单
|
||||
*/
|
||||
function findMenuPath(targetKey: string, menu: App.Global.Menu): string[] | null {
|
||||
const path: string[] = []
|
||||
@ -255,7 +266,7 @@ function findMenuPath(targetKey: string, menu: App.Global.Menu): string[] | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform menu to breadcrumb
|
||||
* 将菜单转换为面包屑
|
||||
*
|
||||
* @param menu
|
||||
*/
|
||||
@ -274,7 +285,7 @@ function transformMenuToBreadcrumb(menu: App.Global.Menu) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get breadcrumbs by route
|
||||
* 根据路由获取面包屑
|
||||
*
|
||||
* @param route
|
||||
* @param menus
|
||||
@ -316,9 +327,9 @@ export function getBreadcrumbsByRoute(
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform menu to searchMenus
|
||||
* 将菜单转换为搜索菜单
|
||||
*
|
||||
* @param menus - menus
|
||||
* @param menus - 菜单
|
||||
* @param treeMap
|
||||
*/
|
||||
export function transformMenuToSearchMenus(menus: App.Global.Menu[], treeMap: App.Global.Menu[] = []) {
|
||||
|
||||
@ -76,7 +76,7 @@ abbr:where([title]) {
|
||||
Remove the default font size and weight for headings.
|
||||
*/
|
||||
|
||||
h1,
|
||||
/* h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
@ -84,7 +84,7 @@ h5,
|
||||
h6 {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
} */
|
||||
|
||||
/*
|
||||
Reset links to optimize for opt-in styling instead of opt-out.
|
||||
|
||||
12
apps/admin/src/styles/scss/variables.scss
Normal file
@ -0,0 +1,12 @@
|
||||
// Brand Colors
|
||||
$primary-color: #4db6ac;
|
||||
$secondary-color: #333333;
|
||||
$accent-color: #d4af37;
|
||||
|
||||
// Text Colors
|
||||
$text-color: #004d40;
|
||||
$group-text-color: #5d4037;
|
||||
|
||||
// Backgrounds
|
||||
$card-bg: #b2dfdb;
|
||||
$tag-bg: #d7ccc8;
|
||||
@ -11,6 +11,10 @@ export const themeSettings: App.Theme.ThemeSetting = {
|
||||
success: '#52c41a',
|
||||
warning: '#faad14',
|
||||
error: '#f5222d',
|
||||
unpublished: '#6b7280', // gray-500
|
||||
published: '#2563eb', // blue-600
|
||||
processing: '#16a34a', // green-600
|
||||
finished: '#dc2626', // red-600
|
||||
},
|
||||
isInfoFollowPrimary: true, // 是否开启信息类颜色跟随主题颜色
|
||||
layout: { mode: 'horizontal', scrollMode: 'content' }, // 布局模式 mode: horizontal | vertical
|
||||
@ -38,7 +42,7 @@ export const themeSettings: App.Theme.ThemeSetting = {
|
||||
mixChildMenuWidth: 200, // 侧边栏混合子菜单宽度
|
||||
autoSelectFirstMenu: false, // 是否自动选择第一个菜单
|
||||
},
|
||||
footer: { visible: true, fixed: false, height: 48, right: true }, // 是否固定底部
|
||||
footer: { visible: false, fixed: false, height: 48, right: true }, // 是否固定底部
|
||||
watermark: {
|
||||
visible: false, // 是否显示水印
|
||||
text: 'reader-starAdmin', // 水印文本
|
||||
|
||||
@ -1,6 +1,16 @@
|
||||
/** Create color palette vars */
|
||||
function createColorPaletteVars() {
|
||||
const colors: App.Theme.ThemeColorKey[] = ['primary', 'info', 'success', 'warning', 'error']
|
||||
const colors: App.Theme.ThemeColorKey[] = [
|
||||
'primary',
|
||||
'info',
|
||||
'success',
|
||||
'warning',
|
||||
'error',
|
||||
'unpublished',
|
||||
'published',
|
||||
'processing',
|
||||
'finished',
|
||||
]
|
||||
const colorPaletteNumbers: App.Theme.ColorPaletteNumber[] = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]
|
||||
|
||||
const colorPaletteVar = {} as App.Theme.ThemePaletteColor
|
||||
|
||||
399
apps/admin/src/typings/api/Competition.d.ts
vendored
Normal file
@ -0,0 +1,399 @@
|
||||
/**
|
||||
* Namespace Api
|
||||
*
|
||||
* All backend api type
|
||||
*/
|
||||
declare namespace Api {
|
||||
namespace Competition {
|
||||
/** common params of paginating */
|
||||
interface PaginatingCommonParams {
|
||||
/** current page number */
|
||||
current: number
|
||||
/** page size */
|
||||
size: number
|
||||
/** total count */
|
||||
total: number
|
||||
}
|
||||
|
||||
/** common params of paginating query list data */
|
||||
interface PaginatingQueryRecord<T = any> extends PaginatingCommonParams {
|
||||
records: T[]
|
||||
}
|
||||
|
||||
/** common search params of table */
|
||||
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'current' | 'size'>
|
||||
|
||||
/**
|
||||
* enable status
|
||||
*
|
||||
* - "1": enabled
|
||||
* - "2": disabled
|
||||
*/
|
||||
type EnableStatus = 1 | 2
|
||||
|
||||
/** question ui type */
|
||||
type QuestionUiType = 1 | 2 | 3 | 4 /** 文本输入 | 单选 | 多选 | 下拉选择 */
|
||||
|
||||
/** question time type */
|
||||
type QuestionTimeType = 5 | 10 | 15 | 20 | 30 | 45 | 60 | 90 /** 5s | 10s | 15s | 20s | 30s | 45s | 60s | 90s */
|
||||
|
||||
/** question template id */
|
||||
type QuestionCategoryName =
|
||||
| 'ChineseCharacterDictation1' // 汉字听写-提示 (jiu表示小鸟的叫声)
|
||||
| 'ChineseCharacterDictation2' // 根据拼音写汉字-提示 (tang)
|
||||
| 'CharacterRadicalAddition' // 汉字加一加 (车)
|
||||
| 'WordDictation' // 词语听写 (zhi re)
|
||||
| 'IdiomWriting1' // 成语-文字要求 (反义字)
|
||||
| 'IdiomWriting2' // 成语-文字要求 (看图写成语)
|
||||
// | 'TEMPLATE_IDIOM_IMAGE' // 成语-看图
|
||||
| 'PoetryComprehension' // 诗词理解-选择题
|
||||
|
||||
/** question score type */
|
||||
type QuestionScoreType = 0 | 1 /** 固定分数 | 答题个数 */
|
||||
|
||||
/** competition round type */
|
||||
type CompetitionRoundType = 0 | 1 /** 题包环节 | 加时环节 */
|
||||
|
||||
/**
|
||||
* competition publish status
|
||||
*
|
||||
* - 0: unpublished
|
||||
* - 1: published
|
||||
* - 2: processing
|
||||
* - 3: finished
|
||||
*/
|
||||
enum PublishStatus {
|
||||
Unpublished = 0,
|
||||
Published = 1,
|
||||
Processing = 2,
|
||||
Finished = 3,
|
||||
}
|
||||
|
||||
interface CompetitionItem {
|
||||
Id: number
|
||||
Name: string
|
||||
StartTime: string
|
||||
EndTime: string
|
||||
TeamGroupNumber: number
|
||||
PublishStatus: number
|
||||
RoomID: number
|
||||
}
|
||||
|
||||
/** room */
|
||||
interface Room {
|
||||
/** room id */
|
||||
ID: number
|
||||
/** room name */
|
||||
Name: string
|
||||
}
|
||||
|
||||
/** question add params */
|
||||
interface QuestionAddParams {
|
||||
RoomID: number
|
||||
MainID: number
|
||||
TeamGroupID: number
|
||||
QuestionID: number
|
||||
QuestionDetaiID: number
|
||||
TeamGroupQuestionID: number
|
||||
}
|
||||
interface QuestionListDetailRound {
|
||||
/** 题目 id */
|
||||
Id: number
|
||||
Name: string
|
||||
Answers: string
|
||||
QuestionId: number
|
||||
IsGood: number
|
||||
Image: string
|
||||
ImageUrl: string
|
||||
Answer: string
|
||||
}
|
||||
|
||||
/** get current question params */
|
||||
interface GetCurrentQuestionParams {
|
||||
/** 活动 id */
|
||||
ActivityID: number
|
||||
/** 队伍分组 id */
|
||||
GroupID: number
|
||||
/** 轮次类型 */
|
||||
RoundType: CompetitionRoundType
|
||||
/** 是否已使用 */
|
||||
isUse?: boolean
|
||||
}
|
||||
|
||||
/** activity detail */
|
||||
interface ActivityDetail {
|
||||
/** 背景图片 */
|
||||
BackgroundImg: string
|
||||
/** 创建时间 */
|
||||
CreatedTime: string
|
||||
/** 结束时间 */
|
||||
EndTime: string
|
||||
/** 活动 id */
|
||||
Id: number
|
||||
/** 活动名称 */
|
||||
Name: string
|
||||
/** 发布状态 */
|
||||
PublishStatus: number
|
||||
/** 房间 id */
|
||||
RoomID: number
|
||||
/** 开始时间 */
|
||||
StartTime: string
|
||||
/** 队伍分组数量 */
|
||||
TeamGroupNumber: number
|
||||
/** 队伍数量 */
|
||||
Teams: number
|
||||
/** 活动副标题 */
|
||||
ActivityTitle?: string
|
||||
/** 普通赛事规则 */
|
||||
ActivityContent?: string
|
||||
/** 加时赛规则 */
|
||||
ExtraTimeContent?: string
|
||||
}
|
||||
|
||||
/** create room request */
|
||||
interface CreateRoomRequest {
|
||||
/** 活动 id */
|
||||
id: number
|
||||
/** 活动名称 */
|
||||
name: string
|
||||
/** 开始时间 */
|
||||
startTime: string
|
||||
/** 结束时间 */
|
||||
endTime: string
|
||||
/** 队伍数量 */
|
||||
teams: number
|
||||
/** 创建时间 */
|
||||
createdTime: string
|
||||
/** 房间 id */
|
||||
roomID: number
|
||||
/** 队伍分组数量 */
|
||||
teamGroupNumber: number
|
||||
/** 背景图片 */
|
||||
backgroundImg: string
|
||||
}
|
||||
|
||||
/** 当前题目响应 */
|
||||
interface CurrentQuestionResponse {
|
||||
/** 活动题目记录 */
|
||||
Activity_Question: QuestionListRecord
|
||||
/** 题目列表 */
|
||||
QuestionList: {
|
||||
/** 题目 id */
|
||||
Id: number
|
||||
/** 题目名称 */
|
||||
Name: string
|
||||
/** 题目内容 */
|
||||
QuestionContent: string
|
||||
/** 题目类型 */
|
||||
QuestionType: string
|
||||
}
|
||||
/** 队伍分组题目 id */
|
||||
TeamGroup_QuestionID: number
|
||||
IsCompleted: boolean
|
||||
}
|
||||
|
||||
/** 获取当前正在比赛的信息 */
|
||||
interface GetCurrentQuestionInfo {
|
||||
/** 答案 */
|
||||
Answer: string
|
||||
/** 结束时间 */
|
||||
EndTime: string
|
||||
/** 高度 */
|
||||
Height: number
|
||||
/** 分值 */
|
||||
Point: number
|
||||
/** 题目详情 id */
|
||||
QuestionDetailID: number
|
||||
QuestionDetaiID: number
|
||||
/** 题目 id */
|
||||
QuestionID: number
|
||||
/** 题目规则 */
|
||||
QuestionRule: number
|
||||
/** 开始时间 */
|
||||
StartTime: string
|
||||
/** 任务模板列表 */
|
||||
TaskTemplete: any[]
|
||||
/** 队伍分组 id */
|
||||
TeamGroupID: number
|
||||
/** 队伍分组题目 id */
|
||||
TeamGroupQuestionID: number
|
||||
/** 队伍列表 */
|
||||
TeamList: any[]
|
||||
/** 宽度 */
|
||||
Width: number
|
||||
ActivityID: number
|
||||
Answer: string
|
||||
}
|
||||
|
||||
/** update room request */
|
||||
interface UpdateRoomRequest {
|
||||
/** 活动 id */
|
||||
id: number
|
||||
/** 活动名称 */
|
||||
name: string
|
||||
/** 开始时间 */
|
||||
startTime: string
|
||||
/** 结束时间 */
|
||||
endTime: string
|
||||
/** 队伍数量 */
|
||||
teams: number
|
||||
/** 队伍分组数量 */
|
||||
teamGroupNumber: number
|
||||
/** 活动副标题 */
|
||||
activityTitle?: string
|
||||
/** 普通赛事规则 */
|
||||
activityContent?: string
|
||||
/** 加时赛规则 */
|
||||
extraTimeContent?: string
|
||||
}
|
||||
|
||||
/** create team list request */
|
||||
interface CreateTeamListRequest {
|
||||
/** 队伍 id */
|
||||
id: number
|
||||
/** 主 id */
|
||||
mainId: number
|
||||
/** 队伍编号 */
|
||||
number: string
|
||||
/** 队伍名称 */
|
||||
name: string
|
||||
/** 笔序列号 */
|
||||
penSerial: string
|
||||
/** 队员名单 */
|
||||
nameList: string
|
||||
/** 学校名称 */
|
||||
schoolName: string
|
||||
/** 队伍分组 id */
|
||||
teamGroupId: number
|
||||
/** 队伍头像 */
|
||||
headImg?: string
|
||||
}
|
||||
|
||||
interface TeamListRecord {
|
||||
/** 队伍 id */
|
||||
Id: number
|
||||
/** 主 id */
|
||||
MainId: number
|
||||
/** 队伍编号 */
|
||||
Number: string
|
||||
/** 队伍名称 */
|
||||
Name: string
|
||||
/** 笔序列号 */
|
||||
PenSerial: string
|
||||
/** 队员名单 */
|
||||
NameList: string
|
||||
/** 学校名称 */
|
||||
SchoolName: string
|
||||
/** 队伍分组 id */
|
||||
TeamGroupId: number
|
||||
/** 队伍头像 */
|
||||
HeadImg?: string
|
||||
}
|
||||
|
||||
interface TeamAnswerData {
|
||||
Id: number
|
||||
MainId: number
|
||||
TeamGroupId: number
|
||||
TeamId: number
|
||||
QuestionId: number
|
||||
QuestionDetailId: number | null
|
||||
UserAnswerPicture: string
|
||||
UserAnswerFont: string // JSON string
|
||||
TeamName: string
|
||||
Points: number
|
||||
ResultPotins: number
|
||||
Status: number
|
||||
// 兼容字段(用于兜底)
|
||||
AnswerValuePicture?: string
|
||||
Guid?: string
|
||||
QuestionDetaiID: number | null
|
||||
}
|
||||
|
||||
interface QuestionListRecord {
|
||||
/** 活动题目名称 */
|
||||
ActitvityQuestionName: string
|
||||
/** 活动 id */
|
||||
ActivityID: number
|
||||
/** 主键 id */
|
||||
ID: number
|
||||
/** 分值 */
|
||||
Point: number
|
||||
/** 题目 id */
|
||||
QuestionID: number
|
||||
/** 题目序号 */
|
||||
QuestionIndex: number
|
||||
/** 题目规则 */
|
||||
QuestionRule: number
|
||||
/** 题目副标题 */
|
||||
QuestionSubTitle: string
|
||||
/** 答题时间(秒) */
|
||||
QuestionTime: number
|
||||
/** 赛题包类型 */
|
||||
RoundType: number
|
||||
/** 题目模板 id */
|
||||
TemplateID: number
|
||||
/** UI 类型 */
|
||||
UIType: QuestionCategoryName
|
||||
}
|
||||
|
||||
interface ActivityTeamGroup {
|
||||
/** 队伍分组 id */
|
||||
Id: number
|
||||
/** 是否已结束 */
|
||||
IsEnd: boolean
|
||||
/** 主活动 id */
|
||||
MainId: number
|
||||
/** 分组名称 */
|
||||
Name: string
|
||||
/** 图标 */
|
||||
icon: string
|
||||
/** 队伍分组 id(兼容字段) */
|
||||
id: number
|
||||
/** 分组名称(兼容字段) */
|
||||
name: string
|
||||
}
|
||||
|
||||
interface CreateQuestionRequest {
|
||||
/** 题目 id */
|
||||
id: number
|
||||
/** 活动 id */
|
||||
activityID: number
|
||||
/** 问题 id */
|
||||
questionID: number
|
||||
/** 题目序号 */
|
||||
questionIndex: number
|
||||
/** 活动题目名称 */
|
||||
actitvityQuestionName: string
|
||||
/** 答题时间(秒) */
|
||||
questionTime: number
|
||||
/** 题目规则 */
|
||||
questionRule: number
|
||||
/** UI 类型 */
|
||||
uiType: string
|
||||
/** 分值 */
|
||||
point: number
|
||||
/** 题目副标题 */
|
||||
questionSubTitle: string
|
||||
/** 题目模板 id */
|
||||
templateID: number
|
||||
/** 赛题包类型 */
|
||||
roundType: number
|
||||
}
|
||||
|
||||
/** common record */
|
||||
type CommonRecord<T = any> = {
|
||||
/** record id */
|
||||
id: number
|
||||
/** record creator */
|
||||
createBy: string
|
||||
/** record create time */
|
||||
createTime: string
|
||||
/** record updater */
|
||||
updateBy: string
|
||||
/** record update time */
|
||||
updateTime: string
|
||||
/** record status */
|
||||
status: EnableStatus | null
|
||||
} & T
|
||||
}
|
||||
}
|
||||
1
apps/admin/src/typings/api/auth.d.ts
vendored
@ -8,6 +8,7 @@ declare namespace Api {
|
||||
interface LoginToken {
|
||||
token: string
|
||||
refreshToken: string
|
||||
id: number
|
||||
}
|
||||
|
||||
interface UserInfo {
|
||||
|
||||
22
apps/admin/src/typings/api/common.d.ts
vendored
@ -7,21 +7,29 @@ declare namespace Api {
|
||||
namespace Common {
|
||||
/** common params of paginating */
|
||||
interface PaginatingCommonParams {
|
||||
/** current page number */
|
||||
current: number
|
||||
/** page size */
|
||||
size: number
|
||||
/** currentPage page number */
|
||||
currentPage: number
|
||||
/** page pageSize */
|
||||
pageSize: number
|
||||
/** total count */
|
||||
total: number
|
||||
total?: number
|
||||
}
|
||||
|
||||
/** common params of paginating query list data */
|
||||
interface PaginatingQueryRecord<T = any> extends PaginatingCommonParams {
|
||||
records: T[]
|
||||
data: T[]
|
||||
}
|
||||
|
||||
/** common search params of table */
|
||||
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'current' | 'size'>
|
||||
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'currentPage' | 'pageSize'>
|
||||
|
||||
/** common response */
|
||||
export interface CommonResponse {
|
||||
success: boolean
|
||||
code: number
|
||||
msg: string
|
||||
data: any
|
||||
}
|
||||
|
||||
/**
|
||||
* enable status
|
||||
|
||||
37
apps/admin/src/typings/api/dictionary.d.ts
vendored
Normal file
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Namespace Api
|
||||
*
|
||||
* All backend api type
|
||||
*/
|
||||
declare namespace Api {
|
||||
namespace Dictionary {
|
||||
interface DictionaryItem {
|
||||
Id: number
|
||||
DicKey: string
|
||||
DicValue: string
|
||||
bakValue: string
|
||||
}
|
||||
|
||||
interface DictionaryUIItem {
|
||||
id: number
|
||||
uI_Key: string
|
||||
uI_Value: string
|
||||
bakValue: string
|
||||
dictionaryID: number
|
||||
}
|
||||
|
||||
type AddOrUpdateParams = DictionaryItem
|
||||
|
||||
interface AddOrUpdateDictionaryUITypeParams {
|
||||
id?: number
|
||||
uI_Key: string
|
||||
uI_Value: string
|
||||
bakValue: string
|
||||
dictionaryID: number
|
||||
}
|
||||
|
||||
interface SearchParams {
|
||||
RoomID: number
|
||||
}
|
||||
}
|
||||
}
|
||||
98
apps/admin/src/typings/api/question.d.ts
vendored
Normal file
@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Namespace Api
|
||||
*
|
||||
* All backend api type
|
||||
*/
|
||||
declare namespace Api {
|
||||
namespace Question {
|
||||
/** common params of paginating */
|
||||
interface PaginatingCommonParams {
|
||||
/** pageIndex page number */
|
||||
pageIndex: number
|
||||
/** page size */
|
||||
pageSizes: number
|
||||
/** total count */
|
||||
total: number
|
||||
}
|
||||
|
||||
/** common params of paginating query list data */
|
||||
interface PaginatingQueryRecord<T = any> extends PaginatingCommonParams {
|
||||
data: T[]
|
||||
}
|
||||
|
||||
/** get question library list all params */
|
||||
interface GetQuestionLibraryListAllParams {
|
||||
/** question id */
|
||||
questionId: number
|
||||
/** pageIndex page number */
|
||||
pageIndex: number
|
||||
/** page size */
|
||||
pageSizes: number
|
||||
/** search keyword */
|
||||
keyWords?: string
|
||||
}
|
||||
|
||||
/** add params */
|
||||
interface AddParams {
|
||||
/** question id */
|
||||
id: number
|
||||
/** question name */
|
||||
name: string
|
||||
/** question content */
|
||||
questionContent: string
|
||||
/** question type */
|
||||
questionType: QuestionType
|
||||
}
|
||||
|
||||
/** add question library params */
|
||||
interface AddQuestionLibraryParams {
|
||||
/** question library id */
|
||||
id: number
|
||||
/** question library name */
|
||||
name: string
|
||||
/** question library content */
|
||||
answer: string
|
||||
/** question id */
|
||||
questionId: number
|
||||
/** question library image url */
|
||||
imageUrl: string
|
||||
/** question library type */
|
||||
type: QuestionScoreType
|
||||
/** is priority 0: no 1: yes */
|
||||
IsGood: number
|
||||
}
|
||||
|
||||
/** common search params of table */
|
||||
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'current' | 'size'>
|
||||
|
||||
/**
|
||||
* enable status
|
||||
*
|
||||
* - "1": enabled
|
||||
* - "2": disabled
|
||||
*/
|
||||
type EnableStatus = '1' | '2'
|
||||
|
||||
/** question score type */
|
||||
type QuestionScoreType = '1' | '2' /** 固定分数 | 答题个数 */
|
||||
|
||||
/** competition round type */
|
||||
type CompetitionRoundType = '1' | '2' /** 题包环节 | 加时环节 */
|
||||
|
||||
/** common record */
|
||||
type CommonRecord<T = any> = {
|
||||
/** record id */
|
||||
id: number
|
||||
/** record creator */
|
||||
createBy: string
|
||||
/** record create time */
|
||||
createTime: string
|
||||
/** record updater */
|
||||
updateBy: string
|
||||
/** record update time */
|
||||
updateTime: string
|
||||
/** record status */
|
||||
status: EnableStatus | null
|
||||
} & T
|
||||
}
|
||||
}
|
||||
115
apps/admin/src/typings/api/rank.d.ts
vendored
Normal file
@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Namespace Api
|
||||
*
|
||||
* All backend api type
|
||||
*/
|
||||
declare namespace Api {
|
||||
namespace Rank {
|
||||
/** common params of paginating */
|
||||
interface PaginatingCommonParams {
|
||||
/** currentPage page number */
|
||||
currentPage: number
|
||||
/** page pageSize */
|
||||
pageSize: number
|
||||
/** total count */
|
||||
total?: number
|
||||
}
|
||||
|
||||
/** user search params */
|
||||
interface UserSearchParams extends PaginatingCommonParams {
|
||||
/** competition activity name */
|
||||
competitionActivityName?: string
|
||||
/** publish status */
|
||||
publishStatus?: EnableStatus
|
||||
}
|
||||
|
||||
/** common params of paginating query list data */
|
||||
interface PaginatingQueryRecord<T = any> extends PaginatingCommonParams {
|
||||
records: T[]
|
||||
}
|
||||
|
||||
/** common search params of table */
|
||||
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'currentPage' | 'pageSize'>
|
||||
|
||||
interface ActivityRankResList extends Array<ActivityRankItem> {}
|
||||
|
||||
interface ActivityRankItem {
|
||||
TeamID: number
|
||||
Name: string
|
||||
TotalPoint: number
|
||||
Questions: QuestionScore[]
|
||||
}
|
||||
|
||||
interface QuestionScore {
|
||||
QuestionNO: number
|
||||
ResultPotin: number
|
||||
}
|
||||
|
||||
interface ActivityRankList {
|
||||
Items: ActivityRank[]
|
||||
TotalRecords: number
|
||||
PageNumber: number
|
||||
PageSize: number
|
||||
TotalPages: number
|
||||
}
|
||||
|
||||
interface UpdateTeamScoreParams {
|
||||
/** 主活动 ID */
|
||||
ActivityID: number
|
||||
|
||||
Id: number
|
||||
/** 结果得分 */
|
||||
ResultPotins: number
|
||||
}
|
||||
|
||||
/** activity rank */
|
||||
interface ActivityRank {
|
||||
/** activity id */
|
||||
Id: number
|
||||
/** activity id (MainID) */
|
||||
ActivityId: number
|
||||
/** activity name */
|
||||
ActivityName: string
|
||||
/** start time */
|
||||
StartTime: string
|
||||
/** team number */
|
||||
TeamNum: number
|
||||
/** max point */
|
||||
MaxPoint: number
|
||||
/** last update time */
|
||||
LastUpdatetime: string
|
||||
/** is publish */
|
||||
Status: number
|
||||
}
|
||||
|
||||
/**
|
||||
* enable status
|
||||
*
|
||||
* - "1": enabled
|
||||
* - "2": disabled
|
||||
*/
|
||||
type EnableStatus = '1' | '2'
|
||||
|
||||
/** question score type */
|
||||
type QuestionScoreType = '1' | '2' /** 固定分数 | 答题个数 */
|
||||
|
||||
/** competition round type */
|
||||
type CompetitionRoundType = '1' | '2' /** 题包环节 | 加时环节 */
|
||||
|
||||
/** common record */
|
||||
type CommonRecord<T = any> = {
|
||||
/** record id */
|
||||
id: number
|
||||
/** record creator */
|
||||
createBy: string
|
||||
/** record create time */
|
||||
createTime: string
|
||||
/** record updater */
|
||||
updateBy: string
|
||||
/** record update time */
|
||||
updateTime: string
|
||||
/** record status */
|
||||
status: EnableStatus | null
|
||||
} & T
|
||||
}
|
||||
}
|
||||
139
apps/admin/src/typings/api/system-manage.d.ts
vendored
Normal file
@ -0,0 +1,139 @@
|
||||
declare namespace Api {
|
||||
/**
|
||||
* namespace SystemManage
|
||||
*
|
||||
* backend api module: "systemManage"
|
||||
*/
|
||||
namespace SystemManage {
|
||||
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'current' | 'size'>
|
||||
|
||||
/** role */
|
||||
type Role = Common.CommonRecord<{
|
||||
/** role name */
|
||||
roleName: string
|
||||
/** role code */
|
||||
roleCode: string
|
||||
/** role description */
|
||||
roleDesc: string
|
||||
}>
|
||||
|
||||
/** role search params */
|
||||
type RoleSearchParams = CommonType.RecordNullable<
|
||||
Pick<Api.SystemManage.Role, 'roleName' | 'roleCode' | 'status'> & CommonSearchParams
|
||||
>
|
||||
|
||||
/** role list */
|
||||
type RoleList = Common.PaginatingQueryRecord<Role>
|
||||
|
||||
/** all role */
|
||||
type AllRole = Pick<Role, 'id' | 'roleName' | 'roleCode'>
|
||||
|
||||
/**
|
||||
* user gender
|
||||
*
|
||||
* - "1": "male"
|
||||
* - "2": "female"
|
||||
*/
|
||||
type UserGender = '1' | '2'
|
||||
|
||||
/** user */
|
||||
type User = Common.CommonRecord<{
|
||||
/** user name */
|
||||
userName: string
|
||||
/** user gender */
|
||||
userGender: UserGender | null
|
||||
/** user nick name */
|
||||
nickName: string
|
||||
/** user phone */
|
||||
userPhone: string
|
||||
/** user email */
|
||||
userEmail: string
|
||||
/** user role code collection */
|
||||
userRoles: string[]
|
||||
}>
|
||||
|
||||
/** user search params */
|
||||
type UserSearchParams = CommonType.RecordNullable<
|
||||
Pick<Api.SystemManage.User, 'userName' | 'userGender' | 'nickName' | 'userPhone' | 'userEmail' | 'status'> &
|
||||
CommonSearchParams
|
||||
>
|
||||
|
||||
/** user list */
|
||||
type UserList = Common.PaginatingQueryRecord<User>
|
||||
|
||||
/**
|
||||
* menu type
|
||||
*
|
||||
* - "1": directory
|
||||
* - "2": menu
|
||||
*/
|
||||
type MenuType = '1' | '2'
|
||||
|
||||
interface MenuButton {
|
||||
/**
|
||||
* button code
|
||||
*
|
||||
* it can be used to control the button permission
|
||||
*/
|
||||
code: string
|
||||
/** button description */
|
||||
desc: string
|
||||
}
|
||||
|
||||
/**
|
||||
* icon type
|
||||
*
|
||||
* - "1": iconify icon
|
||||
* - "2": local icon
|
||||
*/
|
||||
type IconType = '1' | '2'
|
||||
|
||||
type MenuPropsOfRoute = Pick<
|
||||
import('vue-router').RouteMeta,
|
||||
| 'i18nKey'
|
||||
| 'keepAlive'
|
||||
| 'constant'
|
||||
| 'order'
|
||||
| 'href'
|
||||
| 'hideInMenu'
|
||||
| 'activeMenu'
|
||||
| 'multiTab'
|
||||
| 'fixedIndexInTab'
|
||||
| 'query'
|
||||
>
|
||||
|
||||
type Menu = Common.CommonRecord<{
|
||||
/** parent menu id */
|
||||
parentId: number
|
||||
/** menu type */
|
||||
menuType: MenuType
|
||||
/** menu name */
|
||||
menuName: string
|
||||
/** route name */
|
||||
routeName: string
|
||||
/** route path */
|
||||
routePath: string
|
||||
/** component */
|
||||
component?: string
|
||||
/** iconify icon name or local icon name */
|
||||
icon: string
|
||||
/** icon type */
|
||||
iconType: IconType
|
||||
/** buttons */
|
||||
buttons?: MenuButton[] | null
|
||||
/** children menu */
|
||||
children?: Menu[] | null
|
||||
}> &
|
||||
MenuPropsOfRoute
|
||||
|
||||
/** menu list */
|
||||
type MenuList = Common.PaginatingQueryRecord<Menu>
|
||||
|
||||
interface MenuTree {
|
||||
id: number
|
||||
label: string
|
||||
pId: number
|
||||
children?: MenuTree[]
|
||||
}
|
||||
}
|
||||
}
|
||||
109
apps/admin/src/typings/api/template.d.ts
vendored
Normal file
@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Namespace Api
|
||||
*
|
||||
* All backend api type
|
||||
*/
|
||||
declare namespace Api {
|
||||
namespace Template {
|
||||
/** common params of paginating */
|
||||
interface PaginatingCommonParams {
|
||||
/** currentPage page number */
|
||||
currentPage: number
|
||||
/** page pageSize */
|
||||
pageSize: number
|
||||
/** total count */
|
||||
total?: number
|
||||
}
|
||||
|
||||
interface AddTemplateParams {
|
||||
/** template id */
|
||||
id: number | null
|
||||
/** page number */
|
||||
pageNo: number | null | string
|
||||
/** template name */
|
||||
name: string
|
||||
/** background url */
|
||||
backGroundUrl: string
|
||||
/** width */
|
||||
width: number
|
||||
/** height */
|
||||
height: number
|
||||
/** template content */
|
||||
tempContent: string
|
||||
}
|
||||
|
||||
/** template detail */
|
||||
interface TemplateDetail {
|
||||
/** template id */
|
||||
ID: number
|
||||
/** template name */
|
||||
Name: string
|
||||
/** width */
|
||||
Width: number
|
||||
/** height */
|
||||
Height: number
|
||||
/** background url */
|
||||
BackGroundUrl: string
|
||||
/** template content */
|
||||
TempContent: string
|
||||
/** page number */
|
||||
PageNo: string
|
||||
/** competition id */
|
||||
CompetitionId?: number
|
||||
}
|
||||
|
||||
/** template search params */
|
||||
interface TemplateSearchParams extends PaginatingCommonParams {
|
||||
/** competition activity name */
|
||||
competitionActivityName?: string
|
||||
/** publish status */
|
||||
publishStatus?: EnableStatus
|
||||
/** template name */
|
||||
templateName?: string
|
||||
/** competition id */
|
||||
competitionId?: string
|
||||
/** size */
|
||||
size?: string
|
||||
/** status */
|
||||
status?: EnableStatus | null
|
||||
}
|
||||
|
||||
/** common params of paginating query list data */
|
||||
interface PaginatingQueryRecord<T = any> extends PaginatingCommonParams {
|
||||
records: T[]
|
||||
}
|
||||
|
||||
/** common search params of table */
|
||||
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'currentPage' | 'pageSize'>
|
||||
|
||||
/**
|
||||
* enable status
|
||||
*
|
||||
* - "1": enabled
|
||||
* - "2": disabled
|
||||
*/
|
||||
type EnableStatus = '1' | '2'
|
||||
|
||||
/** question score type */
|
||||
type QuestionScoreType = '1' | '2' /** 固定分数 | 答题个数 */
|
||||
|
||||
/** competition round type */
|
||||
type CompetitionRoundType = '1' | '2' /** 题包环节 | 加时环节 */
|
||||
|
||||
/** common record */
|
||||
type CommonRecord<T = any> = {
|
||||
/** record id */
|
||||
id: number
|
||||
/** record creator */
|
||||
createBy: string
|
||||
/** record create time */
|
||||
createTime: string
|
||||
/** record updater */
|
||||
updateBy: string
|
||||
/** record update time */
|
||||
updateTime: string
|
||||
/** record status */
|
||||
status: EnableStatus | null
|
||||
} & T
|
||||
}
|
||||
}
|
||||
10
apps/admin/src/typings/app.d.ts
vendored
@ -140,6 +140,10 @@ declare namespace App {
|
||||
success: string
|
||||
warning: string
|
||||
error: string
|
||||
unpublished: string
|
||||
published: string
|
||||
processing: string
|
||||
finished: string
|
||||
}
|
||||
|
||||
interface ThemeColor extends OtherColor {
|
||||
@ -194,7 +198,7 @@ declare namespace App {
|
||||
|
||||
/** The router push options */
|
||||
interface RouterPushOptions {
|
||||
query?: Record<string, string>
|
||||
query?: Record<string, string | number>
|
||||
params?: Record<string, string>
|
||||
}
|
||||
|
||||
@ -633,11 +637,13 @@ declare namespace App {
|
||||
/** The backend service response data */
|
||||
interface Response<T = unknown> {
|
||||
/** The backend service response code */
|
||||
code: string
|
||||
code: number
|
||||
/** The backend service response message */
|
||||
msg: string
|
||||
/** The backend service response data */
|
||||
data: T
|
||||
/** The backend service response success status */
|
||||
success?: boolean
|
||||
}
|
||||
|
||||
/** The demo backend service response data */
|
||||
|
||||
182
apps/admin/src/typings/components.d.ts
vendored
@ -15,36 +15,85 @@ declare module 'vue' {
|
||||
AppProvider: typeof import('./../components/common/app-provider.vue')['default']
|
||||
BetterScroll: typeof import('./../components/custom/better-scroll.vue')['default']
|
||||
ButtonIcon: typeof import('./../components/custom/button-icon.vue')['default']
|
||||
copy: typeof import('./../components/custom/wave-bg copy.vue')['default']
|
||||
CompetitionLayout: typeof import('./../components/custom/user/CompetitionLayout.vue')['default']
|
||||
CountTo: typeof import('./../components/custom/count-to.vue')['default']
|
||||
DarkModeContainer: typeof import('./../components/common/dark-mode-container.vue')['default']
|
||||
ExceptionBase: typeof import('./../components/common/exception-base.vue')['default']
|
||||
FullScreen: typeof import('./../components/common/full-screen.vue')['default']
|
||||
IconAntDesignEnterOutlined: typeof import('~icons/ant-design/enter-outlined')['default']
|
||||
IconAntDesignReloadOutlined: typeof import('~icons/ant-design/reload-outlined')['default']
|
||||
IconAntDesignSettingOutlined: typeof import('~icons/ant-design/setting-outlined')['default']
|
||||
IconGridiconsFullscreen: typeof import('~icons/gridicons/fullscreen')['default']
|
||||
IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
|
||||
'IconIc:baselineArrowRightAlt': typeof import('~icons/ic/baseline-arrow-right-alt')['default']
|
||||
'IconIc:baselineDeleteForever': typeof import('~icons/ic/baseline-delete-forever')['default']
|
||||
'IconIc:roundPublish': typeof import('~icons/ic/round-publish')['default']
|
||||
'IconIc:roundStopCircle': typeof import('~icons/ic/round-stop-circle')['default']
|
||||
'IconIc:twotoneRule': typeof import('~icons/ic/twotone-rule')['default']
|
||||
IconIcBaselineAdd: typeof import('~icons/ic/baseline-add')['default']
|
||||
IconIcBaselineAddPlus: typeof import('~icons/ic/baseline-add-plus')['default']
|
||||
IconIcBaselineArrowBack: typeof import('~icons/ic/baseline-arrow-back')['default']
|
||||
IconIcBaselineArrowForward: typeof import('~icons/ic/baseline-arrow-forward')['default']
|
||||
IconIcBaselineCalendarMonth: typeof import('~icons/ic/baseline-calendar-month')['default']
|
||||
IconIcBaselineCheck: typeof import('~icons/ic/baseline-check')['default']
|
||||
IconIcBaselineClose: typeof import('~icons/ic/baseline-close')['default']
|
||||
IconIcBaselineContentCopy: typeof import('~icons/ic/baseline-content-copy')['default']
|
||||
IconIcBaselineDelete: typeof import('~icons/ic/baseline-delete')['default']
|
||||
IconIcBaselineDownload: typeof import('~icons/ic/baseline-download')['default']
|
||||
IconIcBaselineEdit: typeof import('~icons/ic/baseline-edit')['default']
|
||||
IconIcBaselineFlash: typeof import('~icons/ic/baseline-flash')['default']
|
||||
IconIcBaselineGroup: typeof import('~icons/ic/baseline-group')['default']
|
||||
IconIcBaselineRefresh: typeof import('~icons/ic/baseline-refresh')['default']
|
||||
IconIcBaselineRocketLaunch: typeof import('~icons/ic/baseline-rocket-launch')['default']
|
||||
IconIcBaselineSave: typeof import('~icons/ic/baseline-save')['default']
|
||||
IconIcBaselineScan: typeof import('~icons/ic/baseline-scan')['default']
|
||||
IconIcBaselineSettingsBackupRestore: typeof import('~icons/ic/baseline-settings-backup-restore')['default']
|
||||
IconIcBaselineUpload: typeof import('~icons/ic/baseline-upload')['default']
|
||||
IconIcBaselineUploadFile: typeof import('~icons/ic/baseline-upload-file')['default']
|
||||
IconIcCarbonUserAvatarFilledAlt: typeof import('~icons/ic/carbon-user-avatar-filled-alt')['default']
|
||||
IconIcOutlineAccessTime: typeof import('~icons/ic/outline-access-time')['default']
|
||||
IconIcOutlineAddToPhotos: typeof import('~icons/ic/outline-add-to-photos')['default']
|
||||
IconIcOutlineArchive: typeof import('~icons/ic/outline-archive')['default']
|
||||
IconIcOutlineAttachMoney: typeof import('~icons/ic/outline-attach-money')['default']
|
||||
IconIcOutlineDelete: typeof import('~icons/ic/outline-delete')['default']
|
||||
IconIcOutlineFolderOff: typeof import('~icons/ic/outline-folder-off')['default']
|
||||
IconIcOutlineFormatListNumbered: typeof import('~icons/ic/outline-format-list-numbered')['default']
|
||||
IconIcOutlineTimer: typeof import('~icons/ic/outline-timer')['default']
|
||||
IconIcOutlineTitle: typeof import('~icons/ic/outline-title')['default']
|
||||
IconIcRoundAccessTime: typeof import('~icons/ic/round-access-time')['default']
|
||||
IconIcRoundArrowBack: typeof import('~icons/ic/round-arrow-back')['default']
|
||||
IconIcRoundArrowForward: typeof import('~icons/ic/round-arrow-forward')['default']
|
||||
IconIcRoundArrowLeft: typeof import('~icons/ic/round-arrow-left')['default']
|
||||
IconIcRoundArrowRight: typeof import('~icons/ic/round-arrow-right')['default']
|
||||
IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
|
||||
IconIcRoundLibraryAdd: typeof import('~icons/ic/round-library-add')['default']
|
||||
IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
|
||||
IconLocalActivity: typeof import('~icons/local/activity')['default']
|
||||
IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default']
|
||||
IconIcRoundSearch: typeof import('~icons/ic/round-search')['default']
|
||||
IconIcRoundStarBorder: typeof import('~icons/ic/round-star-border')['default']
|
||||
IconIcRoundUpload: typeof import('~icons/ic/round-upload')['default']
|
||||
IconLocalBanner: typeof import('~icons/local/banner')['default']
|
||||
IconLocalReadLogo: typeof import('~icons/local/read-logo')['default']
|
||||
IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
|
||||
IconMdiArrowUpThin: typeof import('~icons/mdi/arrow-up-thin')['default']
|
||||
IconMdiDelete: typeof import('~icons/mdi/delete')['default']
|
||||
IconMdiDrag: typeof import('~icons/mdi/drag')['default']
|
||||
IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
|
||||
IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
|
||||
IconMdiPlus: typeof import('~icons/mdi/plus')['default']
|
||||
IconMdiRefresh: typeof import('~icons/mdi/refresh')['default']
|
||||
IconSolarAddSquareLinear: typeof import('~icons/solar/add-square-linear')['default']
|
||||
IconSolarArchiveUpMinimlisticOutline: typeof import('~icons/solar/archive-up-minimlistic-outline')['default']
|
||||
IconSolarCloudUploadLinear: typeof import('~icons/solar/cloud-upload-linear')['default']
|
||||
IconSolarPen2Linear: typeof import('~icons/solar/pen2-linear')['default']
|
||||
IconSolarQuestionCircleLinear: typeof import('~icons/solar/question-circle-linear')['default']
|
||||
IconSolarQuestionCircleOutline: typeof import('~icons/solar/question-circle-outline')['default']
|
||||
IconSolarTrashBinMinimalisticOutline: typeof import('~icons/solar/trash-bin-minimalistic-outline')['default']
|
||||
'IconStreamlineSharp:typeAreaRemix': typeof import('~icons/streamline-sharp/type-area-remix')['default']
|
||||
IconTooltip: typeof import('./../components/common/icon-tooltip.vue')['default']
|
||||
IconUilSearch: typeof import('~icons/uil/search')['default']
|
||||
LangSwitch: typeof import('./../components/common/lang-switch.vue')['default']
|
||||
LookForward: typeof import('./../components/custom/look-forward.vue')['default']
|
||||
MathIframeDialog: typeof import('./../components/common/rest-basic-editor/components/math-iframe-dialog.vue')['default']
|
||||
MenuToggler: typeof import('./../components/common/menu-toggler.vue')['default']
|
||||
NAlert: typeof import('naive-ui')['NAlert']
|
||||
NBadge: typeof import('naive-ui')['NBadge']
|
||||
@ -53,21 +102,31 @@ declare module 'vue' {
|
||||
NButton: typeof import('naive-ui')['NButton']
|
||||
NCard: typeof import('naive-ui')['NCard']
|
||||
NCheckbox: typeof import('naive-ui')['NCheckbox']
|
||||
NCollapse: typeof import('naive-ui')['NCollapse']
|
||||
NCollapseItem: typeof import('naive-ui')['NCollapseItem']
|
||||
NColorPicker: typeof import('naive-ui')['NColorPicker']
|
||||
NDataTable: typeof import('naive-ui')['NDataTable']
|
||||
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
|
||||
NDivider: typeof import('naive-ui')['NDivider']
|
||||
NDrawer: typeof import('naive-ui')['NDrawer']
|
||||
NDrawerContent: typeof import('naive-ui')['NDrawerContent']
|
||||
NDropdown: typeof import('naive-ui')['NDropdown']
|
||||
NEditor: typeof import('naive-ui')['NEditor']
|
||||
NEmpty: typeof import('naive-ui')['NEmpty']
|
||||
NForm: typeof import('naive-ui')['NForm']
|
||||
NFormItem: typeof import('naive-ui')['NFormItem']
|
||||
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
|
||||
NGi: typeof import('naive-ui')['NGi']
|
||||
NGrid: typeof import('naive-ui')['NGrid']
|
||||
NIcon: typeof import('naive-ui')['NIcon']
|
||||
NImage: typeof import('naive-ui')['NImage']
|
||||
NImageGroup: typeof import('naive-ui')['NImageGroup']
|
||||
NInput: typeof import('naive-ui')['NInput']
|
||||
NInputGroup: typeof import('naive-ui')['NInputGroup']
|
||||
NInputGroupLabel: typeof import('naive-ui')['NInputGroupLabel']
|
||||
NInputNumber: typeof import('naive-ui')['NInputNumber']
|
||||
NLayout: typeof import('naive-ui')['NLayout']
|
||||
NLayoutContent: typeof import('naive-ui')['NLayoutContent']
|
||||
NLayoutHeader: typeof import('naive-ui')['NLayoutHeader']
|
||||
NList: typeof import('naive-ui')['NList']
|
||||
NListItem: typeof import('naive-ui')['NListItem']
|
||||
NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider']
|
||||
@ -75,20 +134,40 @@ declare module 'vue' {
|
||||
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
|
||||
NModal: typeof import('naive-ui')['NModal']
|
||||
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
|
||||
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
|
||||
NPopover: typeof import('naive-ui')['NPopover']
|
||||
NProgress: typeof import('naive-ui')['NProgress']
|
||||
NRadio: typeof import('naive-ui')['NRadio']
|
||||
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
|
||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||
NSelect: typeof import('naive-ui')['NSelect']
|
||||
NSelectOption: typeof import('naive-ui')['NSelectOption']
|
||||
NSpace: typeof import('naive-ui')['NSpace']
|
||||
NSpin: typeof import('naive-ui')['NSpin']
|
||||
NStatistic: typeof import('naive-ui')['NStatistic']
|
||||
NSwitch: typeof import('naive-ui')['NSwitch']
|
||||
NTab: typeof import('naive-ui')['NTab']
|
||||
NTable: typeof import('naive-ui')['NTable']
|
||||
NTabs: typeof import('naive-ui')['NTabs']
|
||||
NTag: typeof import('naive-ui')['NTag']
|
||||
NTextarea: typeof import('naive-ui')['NTextarea']
|
||||
NThing: typeof import('naive-ui')['NThing']
|
||||
NTooltip: typeof import('naive-ui')['NTooltip']
|
||||
NTree: typeof import('naive-ui')['NTree']
|
||||
NTreeSelect: typeof import('naive-ui')['NTreeSelect']
|
||||
NUpload: typeof import('naive-ui')['NUpload']
|
||||
NWatermark: typeof import('naive-ui')['NWatermark']
|
||||
OssImageUpload: typeof import('./../components/common/oss-image-upload/index.vue')['default']
|
||||
PageHeader: typeof import('./../components/common/page-header.vue')['default']
|
||||
PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
|
||||
ReloadButton: typeof import('./../components/common/reload-button.vue')['default']
|
||||
RestBasicEditor: typeof import('./../components/common/rest-basic-editor/index.vue')['default']
|
||||
RestDraggableResizable: typeof import('./../components/common/rest-draggable-resizable/rest-draggable-resizable.vue')['default']
|
||||
RestHoverAciton: typeof import('./../components/common/rest-hover-aciton/rest-hover-aciton.vue')['default']
|
||||
RestImgViewer: typeof import('./../components/common/rest-img-viewer/rest-img-viewer.vue')['default']
|
||||
RestSelectFile: typeof import('./../components/common/rest-select-file/rest-select-file.vue')['default']
|
||||
RestUpload: typeof import('./../components/common/rest-upload/rest-upload.vue')['default']
|
||||
RestVideoViewer: typeof import('./../components/common/rest-video-viewer/rest-video-viewer.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SoybeanAvatar: typeof import('./../components/custom/soybean-avatar.vue')['default']
|
||||
@ -97,6 +176,10 @@ declare module 'vue' {
|
||||
TableColumnSetting: typeof import('./../components/advanced/table-column-setting.vue')['default']
|
||||
TableHeaderOperation: typeof import('./../components/advanced/table-header-operation.vue')['default']
|
||||
ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default']
|
||||
TianZiGe: typeof import('./../components/custom/user/TianZiGe.vue')['default']
|
||||
Typeit: typeof import('./../components/custom/typeit/index.vue')['default']
|
||||
UpFileDialog: typeof import('./../components/common/rest-basic-editor/components/up-file-dialog.vue')['default']
|
||||
WangEditor: typeof import('./../components/common/wang-editor.vue')['default']
|
||||
WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']
|
||||
}
|
||||
}
|
||||
@ -106,36 +189,85 @@ declare global {
|
||||
const AppProvider: typeof import('./../components/common/app-provider.vue')['default']
|
||||
const BetterScroll: typeof import('./../components/custom/better-scroll.vue')['default']
|
||||
const ButtonIcon: typeof import('./../components/custom/button-icon.vue')['default']
|
||||
const copy: typeof import('./../components/custom/wave-bg copy.vue')['default']
|
||||
const CompetitionLayout: typeof import('./../components/custom/user/CompetitionLayout.vue')['default']
|
||||
const CountTo: typeof import('./../components/custom/count-to.vue')['default']
|
||||
const DarkModeContainer: typeof import('./../components/common/dark-mode-container.vue')['default']
|
||||
const ExceptionBase: typeof import('./../components/common/exception-base.vue')['default']
|
||||
const FullScreen: typeof import('./../components/common/full-screen.vue')['default']
|
||||
const IconAntDesignEnterOutlined: typeof import('~icons/ant-design/enter-outlined')['default']
|
||||
const IconAntDesignReloadOutlined: typeof import('~icons/ant-design/reload-outlined')['default']
|
||||
const IconAntDesignSettingOutlined: typeof import('~icons/ant-design/setting-outlined')['default']
|
||||
const IconGridiconsFullscreen: typeof import('~icons/gridicons/fullscreen')['default']
|
||||
const IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
|
||||
const 'IconIc:baselineArrowRightAlt': typeof import('~icons/ic/baseline-arrow-right-alt')['default']
|
||||
const 'IconIc:baselineDeleteForever': typeof import('~icons/ic/baseline-delete-forever')['default']
|
||||
const 'IconIc:roundPublish': typeof import('~icons/ic/round-publish')['default']
|
||||
const 'IconIc:roundStopCircle': typeof import('~icons/ic/round-stop-circle')['default']
|
||||
const 'IconIc:twotoneRule': typeof import('~icons/ic/twotone-rule')['default']
|
||||
const IconIcBaselineAdd: typeof import('~icons/ic/baseline-add')['default']
|
||||
const IconIcBaselineAddPlus: typeof import('~icons/ic/baseline-add-plus')['default']
|
||||
const IconIcBaselineArrowBack: typeof import('~icons/ic/baseline-arrow-back')['default']
|
||||
const IconIcBaselineArrowForward: typeof import('~icons/ic/baseline-arrow-forward')['default']
|
||||
const IconIcBaselineCalendarMonth: typeof import('~icons/ic/baseline-calendar-month')['default']
|
||||
const IconIcBaselineCheck: typeof import('~icons/ic/baseline-check')['default']
|
||||
const IconIcBaselineClose: typeof import('~icons/ic/baseline-close')['default']
|
||||
const IconIcBaselineContentCopy: typeof import('~icons/ic/baseline-content-copy')['default']
|
||||
const IconIcBaselineDelete: typeof import('~icons/ic/baseline-delete')['default']
|
||||
const IconIcBaselineDownload: typeof import('~icons/ic/baseline-download')['default']
|
||||
const IconIcBaselineEdit: typeof import('~icons/ic/baseline-edit')['default']
|
||||
const IconIcBaselineFlash: typeof import('~icons/ic/baseline-flash')['default']
|
||||
const IconIcBaselineGroup: typeof import('~icons/ic/baseline-group')['default']
|
||||
const IconIcBaselineRefresh: typeof import('~icons/ic/baseline-refresh')['default']
|
||||
const IconIcBaselineRocketLaunch: typeof import('~icons/ic/baseline-rocket-launch')['default']
|
||||
const IconIcBaselineSave: typeof import('~icons/ic/baseline-save')['default']
|
||||
const IconIcBaselineScan: typeof import('~icons/ic/baseline-scan')['default']
|
||||
const IconIcBaselineSettingsBackupRestore: typeof import('~icons/ic/baseline-settings-backup-restore')['default']
|
||||
const IconIcBaselineUpload: typeof import('~icons/ic/baseline-upload')['default']
|
||||
const IconIcBaselineUploadFile: typeof import('~icons/ic/baseline-upload-file')['default']
|
||||
const IconIcCarbonUserAvatarFilledAlt: typeof import('~icons/ic/carbon-user-avatar-filled-alt')['default']
|
||||
const IconIcOutlineAccessTime: typeof import('~icons/ic/outline-access-time')['default']
|
||||
const IconIcOutlineAddToPhotos: typeof import('~icons/ic/outline-add-to-photos')['default']
|
||||
const IconIcOutlineArchive: typeof import('~icons/ic/outline-archive')['default']
|
||||
const IconIcOutlineAttachMoney: typeof import('~icons/ic/outline-attach-money')['default']
|
||||
const IconIcOutlineDelete: typeof import('~icons/ic/outline-delete')['default']
|
||||
const IconIcOutlineFolderOff: typeof import('~icons/ic/outline-folder-off')['default']
|
||||
const IconIcOutlineFormatListNumbered: typeof import('~icons/ic/outline-format-list-numbered')['default']
|
||||
const IconIcOutlineTimer: typeof import('~icons/ic/outline-timer')['default']
|
||||
const IconIcOutlineTitle: typeof import('~icons/ic/outline-title')['default']
|
||||
const IconIcRoundAccessTime: typeof import('~icons/ic/round-access-time')['default']
|
||||
const IconIcRoundArrowBack: typeof import('~icons/ic/round-arrow-back')['default']
|
||||
const IconIcRoundArrowForward: typeof import('~icons/ic/round-arrow-forward')['default']
|
||||
const IconIcRoundArrowLeft: typeof import('~icons/ic/round-arrow-left')['default']
|
||||
const IconIcRoundArrowRight: typeof import('~icons/ic/round-arrow-right')['default']
|
||||
const IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
|
||||
const IconIcRoundLibraryAdd: typeof import('~icons/ic/round-library-add')['default']
|
||||
const IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
|
||||
const IconLocalActivity: typeof import('~icons/local/activity')['default']
|
||||
const IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default']
|
||||
const IconIcRoundSearch: typeof import('~icons/ic/round-search')['default']
|
||||
const IconIcRoundStarBorder: typeof import('~icons/ic/round-star-border')['default']
|
||||
const IconIcRoundUpload: typeof import('~icons/ic/round-upload')['default']
|
||||
const IconLocalBanner: typeof import('~icons/local/banner')['default']
|
||||
const IconLocalReadLogo: typeof import('~icons/local/read-logo')['default']
|
||||
const IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
|
||||
const IconMdiArrowUpThin: typeof import('~icons/mdi/arrow-up-thin')['default']
|
||||
const IconMdiDelete: typeof import('~icons/mdi/delete')['default']
|
||||
const IconMdiDrag: typeof import('~icons/mdi/drag')['default']
|
||||
const IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
|
||||
const IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
|
||||
const IconMdiPlus: typeof import('~icons/mdi/plus')['default']
|
||||
const IconMdiRefresh: typeof import('~icons/mdi/refresh')['default']
|
||||
const IconSolarAddSquareLinear: typeof import('~icons/solar/add-square-linear')['default']
|
||||
const IconSolarArchiveUpMinimlisticOutline: typeof import('~icons/solar/archive-up-minimlistic-outline')['default']
|
||||
const IconSolarCloudUploadLinear: typeof import('~icons/solar/cloud-upload-linear')['default']
|
||||
const IconSolarPen2Linear: typeof import('~icons/solar/pen2-linear')['default']
|
||||
const IconSolarQuestionCircleLinear: typeof import('~icons/solar/question-circle-linear')['default']
|
||||
const IconSolarQuestionCircleOutline: typeof import('~icons/solar/question-circle-outline')['default']
|
||||
const IconSolarTrashBinMinimalisticOutline: typeof import('~icons/solar/trash-bin-minimalistic-outline')['default']
|
||||
const 'IconStreamlineSharp:typeAreaRemix': typeof import('~icons/streamline-sharp/type-area-remix')['default']
|
||||
const IconTooltip: typeof import('./../components/common/icon-tooltip.vue')['default']
|
||||
const IconUilSearch: typeof import('~icons/uil/search')['default']
|
||||
const LangSwitch: typeof import('./../components/common/lang-switch.vue')['default']
|
||||
const LookForward: typeof import('./../components/custom/look-forward.vue')['default']
|
||||
const MathIframeDialog: typeof import('./../components/common/rest-basic-editor/components/math-iframe-dialog.vue')['default']
|
||||
const MenuToggler: typeof import('./../components/common/menu-toggler.vue')['default']
|
||||
const NAlert: typeof import('naive-ui')['NAlert']
|
||||
const NBadge: typeof import('naive-ui')['NBadge']
|
||||
@ -144,21 +276,31 @@ declare global {
|
||||
const NButton: typeof import('naive-ui')['NButton']
|
||||
const NCard: typeof import('naive-ui')['NCard']
|
||||
const NCheckbox: typeof import('naive-ui')['NCheckbox']
|
||||
const NCollapse: typeof import('naive-ui')['NCollapse']
|
||||
const NCollapseItem: typeof import('naive-ui')['NCollapseItem']
|
||||
const NColorPicker: typeof import('naive-ui')['NColorPicker']
|
||||
const NDataTable: typeof import('naive-ui')['NDataTable']
|
||||
const NDialogProvider: typeof import('naive-ui')['NDialogProvider']
|
||||
const NDivider: typeof import('naive-ui')['NDivider']
|
||||
const NDrawer: typeof import('naive-ui')['NDrawer']
|
||||
const NDrawerContent: typeof import('naive-ui')['NDrawerContent']
|
||||
const NDropdown: typeof import('naive-ui')['NDropdown']
|
||||
const NEditor: typeof import('naive-ui')['NEditor']
|
||||
const NEmpty: typeof import('naive-ui')['NEmpty']
|
||||
const NForm: typeof import('naive-ui')['NForm']
|
||||
const NFormItem: typeof import('naive-ui')['NFormItem']
|
||||
const NFormItemGi: typeof import('naive-ui')['NFormItemGi']
|
||||
const NGi: typeof import('naive-ui')['NGi']
|
||||
const NGrid: typeof import('naive-ui')['NGrid']
|
||||
const NIcon: typeof import('naive-ui')['NIcon']
|
||||
const NImage: typeof import('naive-ui')['NImage']
|
||||
const NImageGroup: typeof import('naive-ui')['NImageGroup']
|
||||
const NInput: typeof import('naive-ui')['NInput']
|
||||
const NInputGroup: typeof import('naive-ui')['NInputGroup']
|
||||
const NInputGroupLabel: typeof import('naive-ui')['NInputGroupLabel']
|
||||
const NInputNumber: typeof import('naive-ui')['NInputNumber']
|
||||
const NLayout: typeof import('naive-ui')['NLayout']
|
||||
const NLayoutContent: typeof import('naive-ui')['NLayoutContent']
|
||||
const NLayoutHeader: typeof import('naive-ui')['NLayoutHeader']
|
||||
const NList: typeof import('naive-ui')['NList']
|
||||
const NListItem: typeof import('naive-ui')['NListItem']
|
||||
const NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider']
|
||||
@ -166,20 +308,40 @@ declare global {
|
||||
const NMessageProvider: typeof import('naive-ui')['NMessageProvider']
|
||||
const NModal: typeof import('naive-ui')['NModal']
|
||||
const NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
|
||||
const NPopconfirm: typeof import('naive-ui')['NPopconfirm']
|
||||
const NPopover: typeof import('naive-ui')['NPopover']
|
||||
const NProgress: typeof import('naive-ui')['NProgress']
|
||||
const NRadio: typeof import('naive-ui')['NRadio']
|
||||
const NRadioGroup: typeof import('naive-ui')['NRadioGroup']
|
||||
const NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||
const NSelect: typeof import('naive-ui')['NSelect']
|
||||
const NSelectOption: typeof import('naive-ui')['NSelectOption']
|
||||
const NSpace: typeof import('naive-ui')['NSpace']
|
||||
const NSpin: typeof import('naive-ui')['NSpin']
|
||||
const NStatistic: typeof import('naive-ui')['NStatistic']
|
||||
const NSwitch: typeof import('naive-ui')['NSwitch']
|
||||
const NTab: typeof import('naive-ui')['NTab']
|
||||
const NTable: typeof import('naive-ui')['NTable']
|
||||
const NTabs: typeof import('naive-ui')['NTabs']
|
||||
const NTag: typeof import('naive-ui')['NTag']
|
||||
const NTextarea: typeof import('naive-ui')['NTextarea']
|
||||
const NThing: typeof import('naive-ui')['NThing']
|
||||
const NTooltip: typeof import('naive-ui')['NTooltip']
|
||||
const NTree: typeof import('naive-ui')['NTree']
|
||||
const NTreeSelect: typeof import('naive-ui')['NTreeSelect']
|
||||
const NUpload: typeof import('naive-ui')['NUpload']
|
||||
const NWatermark: typeof import('naive-ui')['NWatermark']
|
||||
const OssImageUpload: typeof import('./../components/common/oss-image-upload/index.vue')['default']
|
||||
const PageHeader: typeof import('./../components/common/page-header.vue')['default']
|
||||
const PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
|
||||
const ReloadButton: typeof import('./../components/common/reload-button.vue')['default']
|
||||
const RestBasicEditor: typeof import('./../components/common/rest-basic-editor/index.vue')['default']
|
||||
const RestDraggableResizable: typeof import('./../components/common/rest-draggable-resizable/rest-draggable-resizable.vue')['default']
|
||||
const RestHoverAciton: typeof import('./../components/common/rest-hover-aciton/rest-hover-aciton.vue')['default']
|
||||
const RestImgViewer: typeof import('./../components/common/rest-img-viewer/rest-img-viewer.vue')['default']
|
||||
const RestSelectFile: typeof import('./../components/common/rest-select-file/rest-select-file.vue')['default']
|
||||
const RestUpload: typeof import('./../components/common/rest-upload/rest-upload.vue')['default']
|
||||
const RestVideoViewer: typeof import('./../components/common/rest-video-viewer/rest-video-viewer.vue')['default']
|
||||
const RouterLink: typeof import('vue-router')['RouterLink']
|
||||
const RouterView: typeof import('vue-router')['RouterView']
|
||||
const SoybeanAvatar: typeof import('./../components/custom/soybean-avatar.vue')['default']
|
||||
@ -188,5 +350,9 @@ declare global {
|
||||
const TableColumnSetting: typeof import('./../components/advanced/table-column-setting.vue')['default']
|
||||
const TableHeaderOperation: typeof import('./../components/advanced/table-header-operation.vue')['default']
|
||||
const ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default']
|
||||
const TianZiGe: typeof import('./../components/custom/user/TianZiGe.vue')['default']
|
||||
const Typeit: typeof import('./../components/custom/typeit/index.vue')['default']
|
||||
const UpFileDialog: typeof import('./../components/common/rest-basic-editor/components/up-file-dialog.vue')['default']
|
||||
const WangEditor: typeof import('./../components/common/wang-editor.vue')['default']
|
||||
const WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']
|
||||
}
|
||||
55
apps/admin/src/typings/elegant-router.d.ts
vendored
@ -20,13 +20,36 @@ declare module "@elegant-router/types" {
|
||||
"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)?";
|
||||
"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_rank-pending": "/user/rank-pending";
|
||||
"user_rules": "/user/rules";
|
||||
"user_teams": "/user/teams";
|
||||
};
|
||||
|
||||
/**
|
||||
@ -61,10 +84,17 @@ declare module "@elegant-router/types" {
|
||||
| "403"
|
||||
| "404"
|
||||
| "500"
|
||||
| "admin-home"
|
||||
| "competition"
|
||||
| "home"
|
||||
| "dictionary"
|
||||
| "iframe-page"
|
||||
| "login"
|
||||
| "question-store"
|
||||
| "rank"
|
||||
| "results"
|
||||
| "template"
|
||||
| "test"
|
||||
| "user"
|
||||
>;
|
||||
|
||||
/**
|
||||
@ -86,10 +116,29 @@ declare module "@elegant-router/types" {
|
||||
| "500"
|
||||
| "iframe-page"
|
||||
| "login"
|
||||
| "admin-home"
|
||||
| "competition_competition-add"
|
||||
| "competition_competition-detail"
|
||||
| "competition_competition-list"
|
||||
| "home"
|
||||
| "dictionary"
|
||||
| "question-store"
|
||||
| "rank_rank-detail"
|
||||
| "rank_rank-list"
|
||||
| "results_results-detail"
|
||||
| "results_results-list"
|
||||
| "template_template-detail"
|
||||
| "template_template-list"
|
||||
| "test"
|
||||
| "user_analysis"
|
||||
| "user_cover"
|
||||
| "user_draw"
|
||||
| "user_game"
|
||||
| "user_groups"
|
||||
| "user_home"
|
||||
| "user_rank-list"
|
||||
| "user_rank-pending"
|
||||
| "user_rules"
|
||||
| "user_teams"
|
||||
>;
|
||||
|
||||
/**
|
||||
|
||||
3
apps/admin/src/typings/storage.d.ts
vendored
@ -39,5 +39,8 @@ declare namespace StorageType {
|
||||
}
|
||||
/** The last login user id */
|
||||
lastLoginUserId: string
|
||||
/** The user id */
|
||||
userId: number
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
2
apps/admin/src/typings/vite-env.d.ts
vendored
@ -79,6 +79,8 @@ declare namespace Env {
|
||||
* - Dynamic: the auth routes is generated in back-end
|
||||
*/
|
||||
readonly VITE_AUTH_ROUTE_MODE: 'static' | 'dynamic'
|
||||
/** Whether to enforce login */
|
||||
readonly VITE_AUTH_ROUTE_FORCE_LOGIN?: CommonType.YesOrNo
|
||||
/**
|
||||
* The home route key
|
||||
*
|
||||
|
||||
8
apps/admin/src/typings/wangeditor.d.ts
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
declare module '@wangeditor/editor-for-vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
|
||||
const Editor: DefineComponent<Record<string, any>, Record<string, any>, any>
|
||||
const Toolbar: DefineComponent<Record<string, any>, Record<string, any>, any>
|
||||
|
||||
export { Editor, Toolbar }
|
||||
}
|
||||
121
apps/admin/src/utils/audio.ts
Normal file
@ -0,0 +1,121 @@
|
||||
export class AudioController {
|
||||
private audioContext: AudioContext | null = null
|
||||
private soundFiles: Record<string, string> = {}
|
||||
private audioCache: Record<string, HTMLAudioElement> = {}
|
||||
|
||||
constructor(soundFiles: Record<string, string> = {}) {
|
||||
this.soundFiles = soundFiles
|
||||
}
|
||||
|
||||
private initAudioContext() {
|
||||
if (!this.audioContext) {
|
||||
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放指定类型的音效
|
||||
* 优先使用预设的音频文件,如果没有配置或加载失败,则使用 Web Audio API 合成音效
|
||||
*/
|
||||
async play(type: 'start' | 'tick' | 'flip') {
|
||||
// 尝试播放音频文件
|
||||
if (this.soundFiles[type]) {
|
||||
try {
|
||||
await this.playFile(type)
|
||||
return
|
||||
}
|
||||
catch (error) {
|
||||
console.warn(`Failed to play audio file for ${type}, falling back to synth.`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到合成音效
|
||||
this.playSynth(type)
|
||||
}
|
||||
|
||||
private playFile(type: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = this.soundFiles[type]
|
||||
if (!url) {
|
||||
reject(new Error('No file url'))
|
||||
return
|
||||
}
|
||||
|
||||
// 使用缓存的 Audio 对象
|
||||
if (!this.audioCache[type]) {
|
||||
this.audioCache[type] = new Audio(url)
|
||||
}
|
||||
|
||||
const audio = this.audioCache[type]
|
||||
audio.currentTime = 0
|
||||
audio.play()
|
||||
.then(() => resolve())
|
||||
.catch(e => reject(e))
|
||||
})
|
||||
}
|
||||
|
||||
private playSynth(type: 'start' | 'tick' | 'flip') {
|
||||
this.initAudioContext()
|
||||
if (!this.audioContext)
|
||||
return
|
||||
|
||||
const ctxTime = this.audioContext.currentTime
|
||||
const gain = this.audioContext.createGain()
|
||||
gain.connect(this.audioContext.destination)
|
||||
|
||||
if (type === 'start') {
|
||||
const osc = this.audioContext.createOscillator()
|
||||
osc.connect(gain)
|
||||
// 开始音效:高音短促提示
|
||||
osc.type = 'sine'
|
||||
osc.frequency.setValueAtTime(880, ctxTime)
|
||||
osc.frequency.exponentialRampToValueAtTime(440, ctxTime + 0.3)
|
||||
gain.gain.setValueAtTime(0.5, ctxTime)
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, ctxTime + 0.3)
|
||||
osc.start(ctxTime)
|
||||
osc.stop(ctxTime + 0.3)
|
||||
}
|
||||
else if (type === 'tick') {
|
||||
const osc = this.audioContext.createOscillator()
|
||||
osc.connect(gain)
|
||||
// 倒计时音效:急促的滴答声
|
||||
osc.type = 'triangle'
|
||||
osc.frequency.setValueAtTime(600, ctxTime)
|
||||
gain.gain.setValueAtTime(0.3, ctxTime)
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, ctxTime + 0.1)
|
||||
osc.start(ctxTime)
|
||||
osc.stop(ctxTime + 0.1)
|
||||
}
|
||||
else if (type === 'flip') {
|
||||
// 翻书音效:使用白噪声模拟纸张摩擦
|
||||
const bufferSize = this.audioContext.sampleRate * 0.5 // 0.5秒缓冲
|
||||
const buffer = this.audioContext.createBuffer(1, bufferSize, this.audioContext.sampleRate)
|
||||
const data = buffer.getChannelData(0)
|
||||
|
||||
// 生成白噪声
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
data[i] = Math.random() * 2 - 1
|
||||
}
|
||||
|
||||
const noise = this.audioContext.createBufferSource()
|
||||
noise.buffer = buffer
|
||||
|
||||
// 滤波器:低通滤波器,模拟纸张的闷声
|
||||
const filter = this.audioContext.createBiquadFilter()
|
||||
filter.type = 'lowpass'
|
||||
filter.frequency.setValueAtTime(400, ctxTime)
|
||||
filter.frequency.exponentialRampToValueAtTime(3000, ctxTime + 0.1) // 频率快速扫过,模拟快速翻动
|
||||
|
||||
noise.connect(filter)
|
||||
filter.connect(gain)
|
||||
|
||||
// 音量包络:快速淡入淡出
|
||||
gain.gain.setValueAtTime(0, ctxTime)
|
||||
gain.gain.linearRampToValueAtTime(0.8, ctxTime + 0.05)
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, ctxTime + 0.3)
|
||||
|
||||
noise.start(ctxTime)
|
||||
noise.stop(ctxTime + 0.3)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -17,10 +17,11 @@ import { $t } from '@/locales'
|
||||
* ```;
|
||||
*
|
||||
* @param record
|
||||
* @param isNumberKey
|
||||
*/
|
||||
export function transformRecordToOption<T extends Record<string, string>>(record: T) {
|
||||
export function transformRecordToOption<T extends Record<string, string>>(record: T, isNumberKey = false) {
|
||||
return Object.entries(record).map(([value, label]) => ({
|
||||
value,
|
||||
value: isNumberKey ? Number(value) : value,
|
||||
label,
|
||||
})) as CommonType.Option<keyof T, T[keyof T]>[]
|
||||
}
|
||||
@ -56,3 +57,34 @@ export function toggleHtmlClass(className: string) {
|
||||
remove,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Join path segments
|
||||
* @param paths
|
||||
*/
|
||||
export function browserPathJoin(...paths: string[]) {
|
||||
return paths.join('/').replace(/\/+/g, '/')
|
||||
}
|
||||
|
||||
/**
|
||||
* 版本比较
|
||||
* @param v1
|
||||
* @param v2
|
||||
* @param separator 分隔符 默认.
|
||||
* @returns 1 v1>v2 | -1 v1<v2 | 0 v1==v2
|
||||
*/
|
||||
export function compareVersion(v1: string, v2: string, separator = '.') {
|
||||
const s1 = v1.split(separator)
|
||||
const s2 = v2.split(separator)
|
||||
const len = Math.max(s1.length, s2.length)
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
const num1 = Number.parseInt(s1[i] || '0')
|
||||
const num2 = Number.parseInt(s2[i] || '0')
|
||||
if (num1 > num2)
|
||||
return 1
|
||||
if (num1 < num2)
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
335
apps/admin/src/utils/data.ts
Normal file
@ -0,0 +1,335 @@
|
||||
import { isObject } from './verify'
|
||||
|
||||
export interface GroupKey<T extends Record<string, any>> {
|
||||
/** 子节点的key */
|
||||
children: string
|
||||
/** 根据那个字段分组 */
|
||||
groupKey: keyof T
|
||||
/** 组的id,默认于groupKey相同 */
|
||||
value?: keyof T
|
||||
/** 组名的key,默认于groupKey相同 */
|
||||
label: keyof T
|
||||
}
|
||||
export interface GroupProps { valueKey?: string, labelKey?: string, childrenKey?: string }
|
||||
|
||||
/**
|
||||
* 数组对象去重 (根据某个字段去重)
|
||||
* @param arr - 需要去重的数组
|
||||
* @param key - 需要根据那个字段去重
|
||||
*/
|
||||
export function filterRepeat<T extends Record<number | string | symbol, unknown>, K extends keyof T>(arr: T[], key: K): T[] {
|
||||
const res = new Map<unknown, 1>()
|
||||
return arr.filter(item => !res.has(item[key]) && res.set(item[key], 1))
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据key合并数组去掉重复
|
||||
* 有重复的就保留arr2的
|
||||
*/
|
||||
export function mergeArrFun<T extends Record<string, unknown>, K extends keyof T>(arr1: T[], arr2: T[], key: K): T[] {
|
||||
const res = new Map<unknown, T>()
|
||||
arr1.forEach((item) => {
|
||||
res.set(item[key], item)
|
||||
})
|
||||
arr2.forEach((item) => {
|
||||
res.set(item[key], item)
|
||||
})
|
||||
const arr: T[] = []
|
||||
|
||||
res.forEach((val) => {
|
||||
arr.push(val)
|
||||
})
|
||||
return arr
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据路径获取对象的值
|
||||
* @param obj - 对象
|
||||
* @param path - 如 'a.c'只支持点路径,不支持 'a[0]'
|
||||
*/
|
||||
export function getValueByPath(obj: Record<string, unknown>, path: string): unknown {
|
||||
const paths = path.split('.')
|
||||
let res: Record<string, unknown> = obj
|
||||
let result: unknown = ''
|
||||
paths.forEach((item: string) => {
|
||||
if (item in res && typeof res[item] === 'object') {
|
||||
res = res[item] as Record<string, unknown>
|
||||
}
|
||||
else {
|
||||
result = res[item]
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据路径 或一个路径数组得到一个由该路径值组成的一个字符串 (多由于唯一字段)
|
||||
* @param obj - 对象
|
||||
* @param paths - 如 ['a.c','a' 'a.c.d']只支持点路径,不支持 ['a[0]']
|
||||
*/
|
||||
export function getOnlyValue(obj: Record<string, unknown>, paths: string[] | string): string {
|
||||
if (Array.isArray(paths)) {
|
||||
let onlyValue = ''
|
||||
paths.forEach((key) => {
|
||||
onlyValue += `_${String(getValueByPath(obj, key)) || ''}`
|
||||
})
|
||||
return onlyValue
|
||||
}
|
||||
else {
|
||||
return obj[paths] as string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除指定的元素并返回删除后的数组 [用于需要返回新数组而不是直接修改原数组的情况]
|
||||
*/
|
||||
export function mySplice<T>(array: T[], index: number): T[] {
|
||||
return array.slice(0, index).concat(array.slice(index + 1))
|
||||
}
|
||||
|
||||
/**
|
||||
* 在数组后面添加一个元素并返回添加后的数组 [用于需要返回新数组而不是直接修改原数组的情况]
|
||||
*/
|
||||
export function myPush<T>(array: T[], item: T): T[] {
|
||||
return array.concat([item])
|
||||
}
|
||||
|
||||
/**
|
||||
* 特定的值删除数组中的元素并返回删除后的数组 [用于需要返回新数组而不是直接修改原数组的情况]
|
||||
*/
|
||||
export function myDelItem<T>(array: T[], item: T): T[] {
|
||||
const index = array.indexOf(item)
|
||||
return index >= 0 ? mySplice(array, index) : array
|
||||
}
|
||||
|
||||
/**
|
||||
* 得到两个数组的交集
|
||||
*/
|
||||
export function myGetIntersection<T>(arr1: T[], arr2: T[]): T[] {
|
||||
if (Array.isArray(arr1) && Array.isArray(arr2)) {
|
||||
return arr1.filter(item => arr2.includes(item))
|
||||
}
|
||||
else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断两个数组是否有交集
|
||||
*/
|
||||
export function myHaveIntersection<T = unknown>(arr1: T[], arr2: T[]): boolean {
|
||||
if (Array.isArray(arr1) && Array.isArray(arr2)) {
|
||||
const index = arr1.findIndex(item => arr2.includes(item))
|
||||
return index >= 0
|
||||
}
|
||||
else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断两个对象内容是否相等
|
||||
* @param obj1 - 需要判断的对象1
|
||||
* @param obj2 - 需要判断的对象2
|
||||
* @param notComparisonParam - notComparisonParam 不判断对象中的那些字段 [字符串数组]
|
||||
*/
|
||||
export function myEqual(obj1: unknown, obj2: unknown, notComparisonParam: string[] = []): boolean {
|
||||
// 判断两个对象是否指向同一内存或者全等,指向同一内存或全等返回true
|
||||
if (obj1 === obj2) {
|
||||
return true
|
||||
}
|
||||
// 类型相同才比较,不同则直接返回false
|
||||
const obj1Type = typeof obj1
|
||||
const obj2Type = typeof obj2
|
||||
// 注意 null 的typeof 也为 'object'
|
||||
if (obj1Type === obj2Type && obj1 !== null && obj2 !== null) {
|
||||
if (isObject(obj1) && isObject(obj2)) {
|
||||
const a = JSON.parse(JSON.stringify(obj1))
|
||||
const b = JSON.parse(JSON.stringify(obj2))
|
||||
notComparisonParam.forEach((item) => {
|
||||
delete a[item]
|
||||
delete b[item]
|
||||
})
|
||||
// 获取两个对象键值数组
|
||||
const aProps = Object.getOwnPropertyNames(a)
|
||||
const bProps = Object.getOwnPropertyNames(b)
|
||||
// 判断两个对象键值数组长度是否一致,不一致返回false
|
||||
if (aProps.length !== bProps.length) {
|
||||
return false
|
||||
}
|
||||
// 遍历对象的键值
|
||||
for (const prop in a) {
|
||||
// 判断a的键值,在b中是否存在,不存在,返回false
|
||||
if (Object.hasOwn(b, prop)) {
|
||||
// 判断a的键值是否为对象,是则递归,不是对象直接判断键值是否相等,不相等返回false
|
||||
if (isObject(a[prop])) {
|
||||
if (!myEqual(a[prop], b[prop])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
else if (a[prop] !== b[prop]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
else {
|
||||
// 不是对象且上面已经判断了不全等则返回false
|
||||
return false
|
||||
}
|
||||
}
|
||||
else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把字符串转为Json对象
|
||||
*/
|
||||
export function myStrToJson<T = Record<string, unknown>>(obj: T | string): T | undefined {
|
||||
if (typeof obj === 'string') {
|
||||
try {
|
||||
return JSON.parse(obj) as T
|
||||
// oxlint-disable-next-line no-unused-vars
|
||||
}
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
catch (err) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 反转对象的键值对(不会改变原对象)
|
||||
*/
|
||||
export function invertObjKeyValues<T extends Record<string, string>>(obj: T) {
|
||||
const newObj: Record<string, string> = {}
|
||||
for (const key in obj) {
|
||||
const val = obj[key]
|
||||
if (val) {
|
||||
newObj[val] = key
|
||||
}
|
||||
}
|
||||
return newObj
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断两个对象内容是否相等
|
||||
* @param obj1 - 需要判断的对象1
|
||||
* @param obj2 - 需要判断的对象2
|
||||
* @param notComparisonParam - notComparisonParam 不判断对象中的那些字段 [字符串数组]
|
||||
*/
|
||||
export function isEqual(obj1: unknown, obj2: unknown, notComparisonParam: string[] = []): boolean {
|
||||
// 判断两个对象是否指向同一内存或者全等,指向同一内存或全等返回true
|
||||
if (obj1 === obj2) {
|
||||
return true
|
||||
}
|
||||
// 类型相同才比较,不同则直接返回false
|
||||
const obj1Type = typeof obj1
|
||||
const obj2Type = typeof obj2
|
||||
// 注意 null 的typeof 也为 'object'
|
||||
if (obj1Type === obj2Type && obj1 !== null && obj2 !== null) {
|
||||
if (isObject(obj1) && isObject(obj2)) {
|
||||
const a = JSON.parse(JSON.stringify(obj1))
|
||||
const b = JSON.parse(JSON.stringify(obj2))
|
||||
notComparisonParam.forEach((item) => {
|
||||
delete a[item]
|
||||
delete b[item]
|
||||
})
|
||||
// 获取两个对象键值数组
|
||||
const aProps = Object.getOwnPropertyNames(a)
|
||||
const bProps = Object.getOwnPropertyNames(b)
|
||||
// 判断两个对象键值数组长度是否一致,不一致返回false
|
||||
if (aProps.length !== bProps.length) {
|
||||
return false
|
||||
}
|
||||
// 遍历对象的键值
|
||||
for (const prop in a) {
|
||||
// 判断a的键值,在b中是否存在,不存在,返回false
|
||||
if (Object.hasOwn(b, prop)) {
|
||||
// 判断a的键值是否为对象,是则递归,不是对象直接判断键值是否相等,不相等返回false
|
||||
if (isObject(a[prop])) {
|
||||
if (!isEqual(a[prop], b[prop])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
else if (a[prop] !== b[prop]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
else {
|
||||
// 不是对象且上面已经判断了不全等则返回false
|
||||
return false
|
||||
}
|
||||
}
|
||||
else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
let my__OnlyId = 10000
|
||||
|
||||
/** 得到唯一id */
|
||||
export function getOnlyId() {
|
||||
my__OnlyId++
|
||||
return my__OnlyId
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归分组函数
|
||||
* @param data 原始数据数组
|
||||
* @param keys 分组键数组
|
||||
* @returns 分组后的树形结构
|
||||
*/
|
||||
export function groupByKeys<T extends Record<number | string | symbol, any>>(data: T[], groupKeys: GroupKey<T>[]): any[] {
|
||||
// 递归分组函数
|
||||
const groupRecursively = (items: T[], keys: GroupKey<T>[]): any[] => {
|
||||
if (keys.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// 获取当前分组键和剩余键
|
||||
const [currentKey, ...remainingKeys] = keys
|
||||
const { groupKey, value, label, children = 'children' } = currentKey!
|
||||
const valueKey = value || groupKey
|
||||
const labelKey = label || groupKey
|
||||
|
||||
// 使用 reduce 按当前键分组
|
||||
const groupedMap = items.reduce((acc: Map<any, T[]>, item) => {
|
||||
const keyValue = item[valueKey]
|
||||
if (!acc.has(keyValue)) {
|
||||
acc.set(keyValue, [])
|
||||
}
|
||||
acc.get(keyValue)!.push(item)
|
||||
return acc
|
||||
}, new Map())
|
||||
// 构建分组节点
|
||||
return Array.from(groupedMap.entries()).map(([keyValue, groupItems]) => {
|
||||
// 获取当前分组的标签值(取第一条数据的对应字段)
|
||||
const labelValue = groupItems[0]![labelKey]
|
||||
|
||||
// 递归处理下一级分组
|
||||
const list: T[] = groupRecursively(groupItems, remainingKeys)
|
||||
// 构建当前节点
|
||||
return {
|
||||
[valueKey]: keyValue,
|
||||
[labelKey]: labelValue,
|
||||
[children]: list,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return groupRecursively(data, groupKeys)
|
||||
}
|
||||
49
apps/admin/src/utils/date.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import path from 'path-browserify'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
export function getCurrentYear(): Date {
|
||||
return new Date()
|
||||
}
|
||||
|
||||
/**
|
||||
* 得到随机数
|
||||
* @param min 最小值
|
||||
* @param max 最大值
|
||||
* @returns
|
||||
*/
|
||||
export function getTrueRandomInt(min: number, max: number) {
|
||||
const range = max - min + 1
|
||||
const maxSafe = 0xFFFFFFFF // 32位最大无符号整数 (2^32 - 1)
|
||||
let randomValue = 0
|
||||
|
||||
do {
|
||||
const buffer = new Uint32Array(1)
|
||||
window.crypto.getRandomValues(buffer)
|
||||
randomValue = (buffer[0]! / (maxSafe + 1)) * range // 转换为[0, range)的浮点数
|
||||
} while (randomValue >= range) // 拒绝采样避免偏差
|
||||
|
||||
return Math.floor(randomValue) + min
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径拼接
|
||||
*/
|
||||
export function browserPathJoin(base: string, ...paths: string[]) {
|
||||
const [protocol, ...rest] = base.split('://')
|
||||
if (rest.length > 0) {
|
||||
const pathPart = rest.join('://').replace(/\/+/g, '/')
|
||||
return `${protocol}://${path.join(pathPart, ...paths)}`
|
||||
}
|
||||
return path.join(base, ...paths)
|
||||
}
|
||||
|
||||
/**
|
||||
* 下一个tick后执行
|
||||
*/
|
||||
export function nextTickSleep() {
|
||||
return new Promise((resolve) => {
|
||||
nextTick(() => {
|
||||
resolve(true)
|
||||
})
|
||||
})
|
||||
}
|
||||
26
apps/admin/src/utils/event-bus.ts
Normal file
@ -0,0 +1,26 @@
|
||||
/* eslint-disable ts/no-unsafe-function-type */
|
||||
export class EventBus {
|
||||
private listeners: Record<string, Function[]> = {}
|
||||
|
||||
on(event: string, callback: Function) {
|
||||
if (!this.listeners[event]) {
|
||||
this.listeners[event] = []
|
||||
}
|
||||
this.listeners[event].push(callback)
|
||||
}
|
||||
|
||||
off(event: string, callback: Function) {
|
||||
if (!this.listeners[event])
|
||||
return
|
||||
this.listeners[event] = this.listeners[event].filter(cb => cb !== callback)
|
||||
}
|
||||
|
||||
emit(event: string, data?: any) {
|
||||
if (!this.listeners[event])
|
||||
return
|
||||
this.listeners[event].forEach(cb => cb(data))
|
||||
}
|
||||
}
|
||||
|
||||
export const $mitt = new EventBus()
|
||||
export const open_book_topic_edit = 'open_book_topic_edit'
|
||||
339
apps/admin/src/utils/file.ts
Normal file
@ -0,0 +1,339 @@
|
||||
/* eslint-disable no-console */
|
||||
import { snapdom } from '@zumer/snapdom'
|
||||
import { getTrueRandomInt } from './date'
|
||||
import { getSnowflake } from './rest'
|
||||
|
||||
/** 通过 url 判断是否为 图片链接 */
|
||||
export function isImageUrl(url: string) {
|
||||
const str = url.split('?')[0]!.substring(url.lastIndexOf('.') + 1) || ''
|
||||
return ['jpg', 'jpeg', 'png', 'webp', 'svg', 'gif'].includes(str.toLocaleLowerCase())
|
||||
}
|
||||
|
||||
/** 通过 url 判断是否为 图片链接 */
|
||||
export function isVideoUrl(url: string) {
|
||||
const str = url.split('?')[0]!.substring(url.lastIndexOf('.') + 1) || ''
|
||||
return ['m4v', 'mov', '3gp', '3g2', 'mp4', 'flv', 'f4v', 'webm', 'wmv', 'avi', 'asf'].includes(str.toLocaleLowerCase())
|
||||
}
|
||||
|
||||
/** 得到 */
|
||||
export function getAssetsFile(url: string) {
|
||||
return new URL(url, import.meta.url).href
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开文件选择器并返回用户选择的文件
|
||||
* @param accept 文件类型过滤(如 "image/*"、"application/pdf")
|
||||
* @param multiple 是否支持多选(默认 false)
|
||||
* @returns Promise<File[]> 返回 File 对象数组
|
||||
*/
|
||||
export function selectFiles(accept = '*/*', multiple = false): Promise<File[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 1. 创建隐藏的文件输入元素
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = accept
|
||||
input.multiple = multiple
|
||||
input.style.display = 'none'
|
||||
|
||||
// 2. 监听文件选择事件
|
||||
input.addEventListener('change', () => {
|
||||
if (!input.files || input.files.length === 0) {
|
||||
reject(new Error('未选择文件'))
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 转换为 File 对象数组
|
||||
const files = Array.from(input.files)
|
||||
resolve(files)
|
||||
|
||||
// 4. 清理DOM
|
||||
document.body.removeChild(input)
|
||||
})
|
||||
|
||||
// 5. 触发文件选择弹窗
|
||||
document.body.appendChild(input)
|
||||
input.click()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64字符串转换为File对象
|
||||
* @param dataUrl Base64字符串
|
||||
* @param filename 文件名
|
||||
* @returns File对象
|
||||
*/
|
||||
export function base64ToFile(dataUrl: string, filename: string): File {
|
||||
// 拆分 Data URL
|
||||
const arr = dataUrl.split(',')
|
||||
const mimeMatch = arr[0]?.match(/:(.*?);/) || null
|
||||
if (!mimeMatch || !mimeMatch[1] || !arr[1]) {
|
||||
throw new Error('无效的Base64字符串')
|
||||
}
|
||||
const mime = mimeMatch[1] // 提取 MIME 类型(如 "image/png")
|
||||
const bstr = atob(arr[1]) // Base64 解码
|
||||
const n = bstr.length
|
||||
const u8arr = new Uint8Array(n)
|
||||
|
||||
// 将解码后的二进制数据存入 Uint8Array
|
||||
for (let i = 0; i < n; i++) {
|
||||
u8arr[i] = bstr.charCodeAt(i)
|
||||
}
|
||||
|
||||
// 生成 File 对象
|
||||
return new File([u8arr], filename, { type: mime })
|
||||
}
|
||||
|
||||
/**
|
||||
* 将图片file转换为Base64对象
|
||||
*/
|
||||
export function fileToBase64(file: File): Promise<{ id: string, img: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.readAsDataURL(file)
|
||||
reader.onload = (e) => {
|
||||
const date = new Date().getTime()
|
||||
const num = getTrueRandomInt(100000000, 99999999999)
|
||||
if (file.size > 1024 * 40) {
|
||||
// 只有大于40kb才压缩
|
||||
const img = new Image()
|
||||
img.src = String(e.target?.result || '')
|
||||
img.onload = async () => {
|
||||
const Base64Url = await compressImg(img, file.type)
|
||||
resolve({ id: `id_${date}_${num}`, img: Base64Url })
|
||||
}
|
||||
img.onerror = () => {
|
||||
reject(new Error('加载图片失败,002'))
|
||||
}
|
||||
}
|
||||
else {
|
||||
resolve({ id: `id_${date}_${num}`, img: String(e.target?.result || '') })
|
||||
}
|
||||
}
|
||||
reader.onerror = () => {
|
||||
reject(new Error('加载图片失败,001'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 压缩图片
|
||||
* @param img - 被压缩的img对象
|
||||
* @param imgType - 图片类型
|
||||
* @param mx - 触发压缩的图片最大宽度限制
|
||||
* @param mh - 触发压缩的图片最大高度限制
|
||||
* @param quality - 清晰度 0到1之间
|
||||
*/
|
||||
export function compressImg(img: HTMLImageElement, imgType = 'image/jpeg', mx = 720, mh = 1280, quality = 0.8): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
const context = canvas.getContext('2d')
|
||||
const { width: originWidth, height: originHeight } = img
|
||||
// 最大尺寸限制
|
||||
const maxWidth = mx
|
||||
const maxHeight = mh
|
||||
// 目标尺寸
|
||||
let targetWidth = originWidth
|
||||
let targetHeight = originHeight
|
||||
if (originWidth > maxWidth || originHeight > maxHeight) {
|
||||
if (originWidth / originHeight > 1) {
|
||||
// 宽图片
|
||||
targetWidth = maxWidth
|
||||
targetHeight = Math.round(maxWidth * (originHeight / originWidth))
|
||||
}
|
||||
else {
|
||||
// 高图片
|
||||
targetHeight = maxHeight
|
||||
targetWidth = Math.round(maxHeight * (originWidth / originHeight))
|
||||
}
|
||||
}
|
||||
canvas.width = targetWidth
|
||||
canvas.height = targetHeight
|
||||
if (context) {
|
||||
context.clearRect(0, 0, targetWidth, targetHeight)
|
||||
// 图片绘制
|
||||
context.drawImage(img, 0, 0, targetWidth, targetHeight)
|
||||
}
|
||||
const dataURL = canvas.toDataURL(imgType, quality) // 转换图片为dataURL
|
||||
// const fun = (blob) => {
|
||||
// resolve(blob);
|
||||
// };
|
||||
// 转换为bolb对象
|
||||
// canvas.toBlob(fun, imgType, 0.7);
|
||||
resolve(dataURL)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视频第一帧
|
||||
* @param videoSource - 视频源,可以是URL或Blob
|
||||
* @returns - 第一帧的File对象
|
||||
*/
|
||||
export function getFirstFrameOfVideo(
|
||||
videoSource: Blob | File | string,
|
||||
): Promise<{ firstFrame: File, duration: number, videoWidth: number, videoHeight: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let url = '' // 使用createObjectURL创建的URL
|
||||
|
||||
// 创建视频元素
|
||||
const video = document.createElement('video')
|
||||
video.crossOrigin = 'Anonymous'
|
||||
video.setAttribute('playsinline', '')
|
||||
video.muted = true
|
||||
|
||||
// 事件监听:当视频可播放时处理
|
||||
video.addEventListener('seeked', () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = video.videoWidth
|
||||
canvas.height = video.videoHeight
|
||||
|
||||
const context = canvas.getContext('2d')
|
||||
if (context) {
|
||||
context.drawImage(video, 0, 0, canvas.width, canvas.height)
|
||||
const blobCallback = (blob: Blob | null) => {
|
||||
if (blob) {
|
||||
const name = `${getSnowflake()}_${Math.random().toString(32).substring(2)}.png`
|
||||
// 将 Blob 转换为 File
|
||||
const firstFrame = new File([blob], name, { type: blob.type })
|
||||
resolve({ firstFrame, videoWidth: video.videoWidth, videoHeight: video.videoHeight, duration: video.duration })
|
||||
url && URL.revokeObjectURL(url) // 使用createObjectURL创建的URL应在不再需要时通过revokeObjectURL释放,以避免内存泄漏。
|
||||
}
|
||||
else {
|
||||
reject(new Error('获取地一帧失败,BD002'))
|
||||
}
|
||||
}
|
||||
canvas.toBlob(blobCallback, 'image/jpeg', 0.7)
|
||||
}
|
||||
else {
|
||||
reject(new Error('获取地一帧失败,BD001'))
|
||||
}
|
||||
})
|
||||
|
||||
// 错误处理
|
||||
video.addEventListener('error', () => {
|
||||
reject(new Error('获取地一帧失败,BD003'))
|
||||
})
|
||||
|
||||
// 设置视频源
|
||||
if (videoSource instanceof Blob || (videoSource as any) instanceof File) {
|
||||
// Blob 或 File
|
||||
url = URL.createObjectURL(videoSource as Blob | File)
|
||||
video.src = url
|
||||
video.load()
|
||||
}
|
||||
else if (typeof videoSource === 'string') {
|
||||
video.src = videoSource
|
||||
video.load()
|
||||
}
|
||||
else {
|
||||
reject(new Error('不支持此类型'))
|
||||
}
|
||||
video.currentTime = 0.01
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 得到图片的宽高
|
||||
* @param file - 图片,可以是 url 或者File
|
||||
*/
|
||||
export function getImageWidthHeight(file: Blob | File | string): Promise<{ width: number, height: number }> {
|
||||
return new Promise<{ width: number, height: number }>((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
const size = { width: img.width, height: img.height }
|
||||
resolve(size)
|
||||
}
|
||||
img.onerror = () => {
|
||||
reject(new Error('读取图片失败,BD0001'))
|
||||
}
|
||||
|
||||
if (typeof file === 'string') {
|
||||
img.src = file
|
||||
}
|
||||
else if (file instanceof File || file instanceof Blob) {
|
||||
img.src = URL.createObjectURL(file)
|
||||
}
|
||||
else {
|
||||
reject(new Error('读取图片失败,BD0002'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* html 转换为 jpg图片
|
||||
*/
|
||||
export async function htmlToJpgImgFile(html: string, op: { width: number }): Promise<File> {
|
||||
const type: 'jpeg' | 'jpg' | 'png' | 'svg' | 'webp' = 'jpg'
|
||||
// 创建临时容器
|
||||
const container = document.createElement('div')
|
||||
container.style.width = `${op.width}px`
|
||||
container.style.position = 'absolute'
|
||||
container.style.left = '101vw'
|
||||
container.style.top = '101vh'
|
||||
container.style.zIndex = '-1'
|
||||
container.style.backgroundColor = '#ffffff'
|
||||
container.innerHTML = html
|
||||
document.body.appendChild(container)
|
||||
try {
|
||||
// 等待图片加载完成
|
||||
const images = container.querySelectorAll('img')
|
||||
const imageLoadPromises = Array.from(images).map((img) => {
|
||||
if (img.complete) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
img.onload = () => resolve(null)
|
||||
img.onerror = () => resolve(null) // 即使图片加载失败也继续
|
||||
// 设置超时,防止某些图片一直加载不成功
|
||||
setTimeout(() => resolve(null), 5000)
|
||||
})
|
||||
})
|
||||
|
||||
await Promise.all(imageLoadPromises)
|
||||
console.log('所有图片加载完成,图片数量:', images.length)
|
||||
|
||||
// 额外等待确保DOM完全渲染
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
requestAnimationFrame(resolve)
|
||||
}, 100) // 增加等待时间
|
||||
})
|
||||
|
||||
const name = `${getSnowflake()}_${Math.random().toString(32).substring(2)}.jpg`
|
||||
console.log('开始截图,容器宽度:', container.offsetWidth, '高度:', container.offsetHeight)
|
||||
|
||||
// 使用 snapdom 进行 DOM 快照
|
||||
const res = await snapdom(container, {
|
||||
width: op.width,
|
||||
scale: 1,
|
||||
type,
|
||||
backgroundColor: '#ffffff',
|
||||
})
|
||||
|
||||
const blob = await res.toBlob({ type, backgroundColor: '#ffffff' })
|
||||
|
||||
console.log('截图完成,blob大小:', (blob.size / 1024).toFixed(2), 'KB')
|
||||
|
||||
// 将blob转换为base64以便在浏览器查看
|
||||
const reader = new FileReader()
|
||||
const base64Promise = new Promise<string>((resolve) => {
|
||||
reader.onloadend = () => {
|
||||
resolve(reader.result as string)
|
||||
}
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
const base64 = await base64Promise
|
||||
// console.log("blob=====", blob);
|
||||
// console.log("图片base64=====", base64);
|
||||
// console.log("👆 复制上面的base64到浏览器地址栏查看图片");
|
||||
|
||||
return new File([blob], name, { type: blob.type })
|
||||
}
|
||||
catch (error) {
|
||||
console.error('htmlToJpgImgFile 错误:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
finally {
|
||||
// document.body.removeChild(container);
|
||||
}
|
||||
}
|
||||
42
apps/admin/src/utils/oss.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import OSS, { type Checkpoint } from 'ali-oss'
|
||||
import { type AliOssSTS, getAliOssTokenAxios } from '@/service/api/upload'
|
||||
|
||||
// 初始化OSS客户端
|
||||
export function initOSSClient(token: AliOssSTS) {
|
||||
return new OSS({
|
||||
region: token.Region || 'oss-cn-shenzhen',
|
||||
accessKeyId: token.AccessKeyId || '',
|
||||
accessKeySecret: token.AccessKeySecret || '',
|
||||
stsToken: token.SecurityToken || '',
|
||||
bucket: token.BucketName || '',
|
||||
refreshSTSTokenInterval: 600000, // 10分钟刷新一次token
|
||||
refreshSTSToken: async () => {
|
||||
// 这里可以添加获取新token的逻辑
|
||||
const res = await getAliOssTokenAxios()
|
||||
return {
|
||||
accessKeyId: res.data?.data?.AccessKeyId || '',
|
||||
accessKeySecret: res.data?.data?.AccessKeySecret || '',
|
||||
stsToken: res.data?.data?.SecurityToken || '',
|
||||
bucket: res.data?.data?.BucketName || '',
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件到OSS (注意进度从 0 开始到 1 结束)
|
||||
*/
|
||||
export async function uploadFileToOSS(client: OSS, file: File, path: string, progress?: ((progress: number, checkpoint?: Checkpoint, http?: any) => any) | undefined) {
|
||||
try {
|
||||
const result = await client.multipartUpload(path, file, {
|
||||
parallel: 5, // 并发分片数
|
||||
partSize: 1024 * 1024 * 5, // 分片大小5MB
|
||||
progress,
|
||||
})
|
||||
return result
|
||||
}
|
||||
catch (error) {
|
||||
console.error('上传文件失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
104
apps/admin/src/utils/rest.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { Snowflake } from '@sapphire/snowflake'
|
||||
|
||||
/**
|
||||
* 得到雪花ID
|
||||
*/
|
||||
export function getSnowflake(): bigint {
|
||||
const epoch = new Date('2025-07-01T00:00:00.000Z')
|
||||
const snowflake = new Snowflake(epoch)
|
||||
snowflake.workerId = 1
|
||||
return snowflake.generate()
|
||||
}
|
||||
|
||||
/**
|
||||
* 并发控制池,限制同时执行的异步任务数量
|
||||
* @param tasks 异步任务数组(每个任务为返回 Promise 的函数)
|
||||
* @param concurrency 最大并发数
|
||||
* @returns Promise,解析为所有任务结果的数组
|
||||
*/
|
||||
export async function concurrentPool<T>(tasks: (() => Promise<T>)[], concurrency: number): Promise<T[]> {
|
||||
// 存储所有任务的结果
|
||||
const results: T[] = []
|
||||
// 存储当前正在执行的任务
|
||||
const executing: Promise<void>[] = []
|
||||
let index = 0 // 任务索引,用于按顺序添加任务
|
||||
|
||||
// 创建执行器函数
|
||||
const execute = async (taskIndex: number): Promise<void> => {
|
||||
try {
|
||||
// 执行任务并获取结果
|
||||
const result = await tasks[taskIndex]!()
|
||||
results[taskIndex] = result // 按原始顺序存储结果
|
||||
}
|
||||
catch (error) {
|
||||
results[taskIndex] = error as any // 捕获错误(可根据需求调整)
|
||||
}
|
||||
finally {
|
||||
// 无论成功失败,任务完成后从执行队列移除
|
||||
const executingIndex = executing.findIndex(p => p === executing[taskIndex])
|
||||
if (executingIndex !== -1) {
|
||||
executing.splice(executingIndex, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 启动初始并发任务
|
||||
while (index < Math.min(concurrency, tasks.length)) {
|
||||
const taskPromise = execute(index)
|
||||
executing.push(taskPromise)
|
||||
index++
|
||||
}
|
||||
|
||||
// 动态管理任务池
|
||||
while (index < tasks.length) {
|
||||
if (executing.length < concurrency) {
|
||||
const taskPromise = execute(index)
|
||||
executing.push(taskPromise)
|
||||
index++
|
||||
}
|
||||
else {
|
||||
// 等待任意一个任务完成
|
||||
await Promise.race(executing)
|
||||
}
|
||||
}
|
||||
|
||||
// 等待所有剩余任务完成
|
||||
await Promise.all(executing)
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断连个版本号大于小于或者等于
|
||||
* @param v1 - 版本号1
|
||||
* @param v2 - 版本号2
|
||||
* @returns -
|
||||
* 当 v1 大于 v2 时返回 1
|
||||
* 当 v1 等于 v2 时返回 0
|
||||
* 当 v1 小于 v2 时返回 -1
|
||||
*/
|
||||
export function compareVersion(v1: string, v2: string, operator: '_' | '-' | '.' = '.'): -1 | 0 | 1 {
|
||||
if (v1 === v2) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const vs1 = v1.split(operator).map(a => Number.parseInt(a))
|
||||
const vs2 = v2.split(operator).map(a => Number.parseInt(a))
|
||||
|
||||
const length = Math.min(vs1.length, vs2.length)
|
||||
for (let i = 0; i < length; i++) {
|
||||
const s1 = vs1[i] || 0
|
||||
const s2 = vs2[i] || 0
|
||||
if (s1 > s2) {
|
||||
return 1
|
||||
}
|
||||
else if (s1 < s2) {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
if (length === vs1.length) {
|
||||
return -1
|
||||
}
|
||||
else {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,9 @@
|
||||
import json5 from 'json5'
|
||||
|
||||
/**
|
||||
* Create service config by current env
|
||||
* 根据当前环境创建服务配置
|
||||
*
|
||||
* @param env The current env
|
||||
* @param env 当前环境
|
||||
*/
|
||||
export function createServiceConfig(env: Env.ImportMeta) {
|
||||
const { VITE_SERVICE_BASE_URL, VITE_OTHER_SERVICE_BASE_URL } = env
|
||||
@ -13,11 +13,11 @@ export function createServiceConfig(env: Env.ImportMeta) {
|
||||
other = json5.parse(VITE_OTHER_SERVICE_BASE_URL)
|
||||
}
|
||||
catch {
|
||||
console.error('VITE_OTHER_SERVICE_BASE_URL is not a valid json5 string')
|
||||
console.error('VITE_OTHER_SERVICE_BASE_URL 不是有效的 json5 字符串')
|
||||
}
|
||||
|
||||
const httpConfig: App.Service.SimpleServiceConfig = {
|
||||
baseURL: VITE_SERVICE_BASE_URL,
|
||||
baseURL: VITE_SERVICE_BASE_URL, // 服务基础URL
|
||||
other,
|
||||
}
|
||||
|
||||
@ -27,24 +27,23 @@ export function createServiceConfig(env: Env.ImportMeta) {
|
||||
return {
|
||||
key,
|
||||
baseURL: httpConfig.other[key],
|
||||
proxyPattern: createProxyPattern(key),
|
||||
proxyPattern: createProxyPattern(key), // 其他服务基础URL的代理模式
|
||||
}
|
||||
})
|
||||
|
||||
const config: App.Service.ServiceConfig = {
|
||||
baseURL: httpConfig.baseURL,
|
||||
proxyPattern: createProxyPattern(),
|
||||
baseURL: httpConfig.baseURL, // 服务基础URL
|
||||
proxyPattern: createProxyPattern(), // 服务基础URL的代理模式
|
||||
other: otherConfig,
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* get backend service base url
|
||||
* 获取后端服务基础地址
|
||||
*
|
||||
* @param env - the current env
|
||||
* @param isProxy - if use proxy
|
||||
* @param env - 当前环境
|
||||
* @param isProxy - 是否使用代理
|
||||
*/
|
||||
export function getServiceBaseURL(env: Env.ImportMeta, isProxy: boolean) {
|
||||
const { baseURL, other } = createServiceConfig(env)
|
||||
@ -62,9 +61,9 @@ export function getServiceBaseURL(env: Env.ImportMeta, isProxy: boolean) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get proxy pattern of backend service base url
|
||||
* 获取后端服务基础地址的代理模式
|
||||
*
|
||||
* @param key If not set, will use the default key
|
||||
* @param key 如果未设置,将使用默认键
|
||||
*/
|
||||
function createProxyPattern(key?: App.Service.OtherBaseURLKey) {
|
||||
if (!key) {
|
||||
|
||||
105
apps/admin/src/utils/upload-util.ts
Normal file
@ -0,0 +1,105 @@
|
||||
// 定义文件上传任务类型
|
||||
interface UploadTask<R, K> {
|
||||
myFlie: R
|
||||
resolve: (res: K) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
type UploadFile<R = any, K = any> = (myFlie: R) => Promise<K>
|
||||
|
||||
/**
|
||||
* 文件上传服务类(单例模式),
|
||||
*/
|
||||
export class FileUploadService<R, K> {
|
||||
/** 当前上传的数量 */
|
||||
private currentUploads = 0
|
||||
/** 一次最多上传多少个,剩下的加入队列 */
|
||||
private concurrency = 5
|
||||
/** 上传队列 */
|
||||
private uploadTaskList: UploadTask<R, K>[] = []
|
||||
/** 上传的方法 */
|
||||
private uploadFile: UploadFile<R, K>
|
||||
|
||||
/**
|
||||
* 私有构造函数
|
||||
*/
|
||||
public constructor(_uploadFile: UploadFile, _concurrency?: number) {
|
||||
this.currentUploads = 0
|
||||
this.concurrency = _concurrency || 5 // 默认并发数为2,可以根据需要调整
|
||||
this.uploadTaskList = []
|
||||
this.uploadFile = _uploadFile
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行文件上传
|
||||
* @param filesToUpload - 要上传的文件对象数组
|
||||
* @param concurrency - 并发上传数,默认为构造函数中设置的并发数
|
||||
*/
|
||||
public upload(filesToUpload: R[], concurrency: number = this.concurrency): Promise<PromiseSettledResult<Awaited<K>>[]> {
|
||||
return new Promise<PromiseSettledResult<Awaited<K>>[]>((resolve, reject) => {
|
||||
const uploadPromises = filesToUpload.map(file => this.addUploadTask(file))
|
||||
this.concurrency = concurrency // 更新并发数
|
||||
|
||||
// 返回所有上传任务的 Promise
|
||||
Promise.allSettled(uploadPromises)
|
||||
.then((res) => {
|
||||
resolve(res) // 所有任务完成后调用总体解决函数
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('文件上传过程中出现错误:')
|
||||
reject(err) // 如果有任务失败,直接调用总体拒绝函数
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加上传任务到队列
|
||||
* @param file - 要上传的文件对象
|
||||
*/
|
||||
private addUploadTask(myFlie: R): Promise<K> {
|
||||
return new Promise<K>((resolve, reject) => {
|
||||
if (this.currentUploads < this.concurrency) {
|
||||
// 如果当前上传数小于并发数,则直接上传
|
||||
this.currentUploads++
|
||||
this.uploadFile(myFlie)
|
||||
.then((res: K) => {
|
||||
this.currentUploads--
|
||||
resolve(res)
|
||||
this.processQueue()
|
||||
})
|
||||
.catch((error) => {
|
||||
this.currentUploads--
|
||||
console.error(`上传失败,文件:`, myFlie, '错误====', error)
|
||||
reject(error)
|
||||
this.processQueue()
|
||||
})
|
||||
}
|
||||
else {
|
||||
// 如果当前上传数达到并发数,则加入队列等待
|
||||
this.uploadTaskList.push({ myFlie, resolve, reject })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理上传队列,依次执行上传任务
|
||||
*/
|
||||
private processQueue(): void {
|
||||
if (this.uploadTaskList.length > 0) {
|
||||
const { myFlie, resolve, reject } = this.uploadTaskList.shift()!
|
||||
this.addUploadTask(myFlie)
|
||||
.then((res: K) => {
|
||||
resolve(res) // 在完成上传后,调用任务的 resolve
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止上传
|
||||
*/
|
||||
public abort(): void {
|
||||
this.uploadTaskList = []
|
||||
}
|
||||
}
|
||||
340
apps/admin/src/utils/verify-number.ts
Normal file
@ -0,0 +1,340 @@
|
||||
export type $InputVerify =
|
||||
| 'add_float'
|
||||
| 'add_int_or_zero'
|
||||
| 'add_int'
|
||||
| 'all_float'
|
||||
| 'all_int_or_zero'
|
||||
| 'all_int'
|
||||
| 'minus_float'
|
||||
| 'minus_int_or_zero'
|
||||
| 'minus_int'
|
||||
/** ********************整数**********************整数 */
|
||||
/** ********************整数**********************整数 */
|
||||
/** ********************整数**********************整数 */
|
||||
// 正整数(不含0)
|
||||
export const add_int_numb_RegEx = /^[1-9]\d*$/
|
||||
|
||||
// 负整数(不含0)
|
||||
export const minus_int_numb_RegEx = /^-[1-9]\d*$/
|
||||
|
||||
// 整数 ( 不含0(正负都可以)
|
||||
export const all_int_numb_RegEx = /^-?[1-9]\d*$/
|
||||
|
||||
// 正整数(含0)
|
||||
export const add_int_or_zero_numb_RegEx = /^[1-9]\d*$|^0$/
|
||||
|
||||
// 负整数 (含0)
|
||||
export const minus_int_or_zero_numb_RegEx = /^-[1-9]\d*$|^0$/
|
||||
|
||||
// 正负整数 (含0)
|
||||
export const all_int_or_zero_numb_RegEx = /^-?[1-9]\d*$|^0$/
|
||||
|
||||
/** ********************浮点数**********************浮点数 */
|
||||
/** ********************浮点数**********************浮点数 */
|
||||
/** ********************浮点数**********************浮点数 */
|
||||
|
||||
// 正浮点数 (不含0)
|
||||
export const add_float_numb_RegEx = /(^[1-9]\d*|^0)\.\d+$/
|
||||
|
||||
// 负浮点数(不含0)
|
||||
export const minus_float_numb_RegEx = /(^-[1-9]\d*|^-0)\.\d+$/
|
||||
|
||||
// 正浮点数 (含0)
|
||||
export const add_float_ro_zero_numb_RegEx = /(^[1-9]\d*|^0)\.\d+$|^0$/
|
||||
|
||||
// 负浮点数(含0)
|
||||
export const minus_float_ro_zero_numb_RegEx = /(^-[1-9]\d*|^-0)\.\d+$|^0$/
|
||||
|
||||
// 浮点数(包含正负浮点数不含0)
|
||||
export const all_float_numb_RegEx = /(^-?[1-9]\d*|^-?0)\.\d+$/
|
||||
|
||||
// 浮点数(包含正负浮点数和0)
|
||||
export const all_float_or_zero_numb_RegEx = /(^-?[1-9]\d*|^-?0)\.\d+$|^0$/
|
||||
|
||||
// 所有合法数字 (整数,小数,正数,负数,0) 【不推荐用正则验证直接用isNaN(numb)并判断大于小于就可以了】
|
||||
export const all_numb = /(^-?[1-9]\d*|^-?0)(\.\d+)?$/
|
||||
|
||||
/** ********************数字输入时正则**********************数字输入时正则 */
|
||||
/** ********************数字输入时正则**********************数字输入时正则 */
|
||||
/** ********************数字输入时正则**********************数字输入时正则 */
|
||||
|
||||
// 正整数 (不含0) 主要用于输入时验证
|
||||
export const add_int_numb_input_RegEx = /^[1-9]\d*/
|
||||
|
||||
// 负整数(不含0) 主要用于输入时验证
|
||||
export const minus_int_numb_input_RegEx = /^-?[1-9]\d*/
|
||||
|
||||
// 正负整数(不含0) 主要用于输入时验证
|
||||
export const all_int_numb_input_RegEx = /^-?[1-9]\d*/
|
||||
|
||||
// 正整数 (含0) 主要用于输入时验证
|
||||
export const add_int_or_zero_numb_input_RegEx = /^[1-9]\d*$|^0$/
|
||||
|
||||
// 负整数 (含0) 主要用于输入时验证
|
||||
export const minus_int_or_zero_numb_input_RegEx = /^-?[1-9]\d*$|^0/
|
||||
|
||||
// 正负整数(含0) 主要用于输入时验证
|
||||
export const all_int_or_zero_numb_input_RegEx = /^-?\d*/
|
||||
|
||||
// 正浮点数 主要用于输入时验证
|
||||
export const add_float_numb_input_RegEx = /^\d+\.?\d*/
|
||||
|
||||
// 负浮点数 主要用于输入时验证
|
||||
export const minus_float_numb_input_RegEx = /^-?\d*\.?\d*/
|
||||
|
||||
// 正负浮点数 主要用于输入时验证 【这里就可以验证合法的数字输入了包含 0. - -0. 】
|
||||
export const all_float_numb_input_RegEx = /^-?\d*\.?\d*/
|
||||
|
||||
/** *******************数字验证****************数字验证 */
|
||||
/** *******************数字验证****************数字验证 */
|
||||
/** *******************数字验证****************数字验证 */
|
||||
/**
|
||||
* 正整数 (不含0)
|
||||
*/
|
||||
export function verifyAddIntNumb(str: string): boolean {
|
||||
return Boolean(add_int_numb_RegEx.test(str))
|
||||
}
|
||||
|
||||
/**
|
||||
* 负整数(不含0)
|
||||
*/
|
||||
export function verifyMinusIntNumb(str: string): boolean {
|
||||
return Boolean(minus_int_numb_RegEx.test(str))
|
||||
}
|
||||
|
||||
/**
|
||||
* 正负整数(不含0)
|
||||
*/
|
||||
export function verifyAllIntNumb(str: string): boolean {
|
||||
return Boolean(all_int_numb_RegEx.test(str))
|
||||
}
|
||||
|
||||
/**
|
||||
* 正整数 (含0)
|
||||
*/
|
||||
export function verifyAddIntRoZeroNumb(str: string): boolean {
|
||||
return Boolean(add_int_or_zero_numb_RegEx.test(str))
|
||||
}
|
||||
|
||||
/**
|
||||
* 负整数 (含0)
|
||||
*/
|
||||
export function verifyMinusIntRoZeroNumb(str: string): boolean {
|
||||
return Boolean(minus_int_or_zero_numb_RegEx.test(str))
|
||||
}
|
||||
|
||||
/**
|
||||
* 正 负 整数(含0)
|
||||
*/
|
||||
export function verifyAllIntRoZeroNumb(str: string): boolean {
|
||||
return Boolean(all_int_or_zero_numb_RegEx.test(str))
|
||||
}
|
||||
|
||||
/**
|
||||
* 正浮点数
|
||||
*/
|
||||
export function verifyAddFloatNumb(str: string): boolean {
|
||||
return Boolean(add_float_numb_RegEx.test(str))
|
||||
}
|
||||
|
||||
/**
|
||||
* 负浮点数
|
||||
*/
|
||||
export function verifyMinusFloatNumb(str: string): boolean {
|
||||
return Boolean(minus_float_numb_RegEx.test(str))
|
||||
}
|
||||
|
||||
/**
|
||||
* 正负浮点数
|
||||
*/
|
||||
export function verifyAllsFloatNumb(str: string): boolean {
|
||||
return Boolean(all_float_numb_RegEx.test(str))
|
||||
}
|
||||
|
||||
/** *******************输入时验证****************输入时验证 */
|
||||
/** *******************输入时验证****************输入时验证 */
|
||||
/** *******************输入时验证****************输入时验证 */
|
||||
|
||||
/**
|
||||
* 正整数 (不含0) 主要用于输入时验证
|
||||
*/
|
||||
export function verifyAddIntNumbInput(str: number | string): string {
|
||||
let numb = String(str)
|
||||
numb = String(Number.parseInt(numb))
|
||||
const numbArr = numb.match(add_int_numb_input_RegEx)
|
||||
numb = Array.isArray(numbArr) ? numbArr[0] : ''
|
||||
numb = numb.replace(/^0+/g, '0')
|
||||
return numb
|
||||
}
|
||||
|
||||
/**
|
||||
* 负整数(不含0) 主要用于输入时验证
|
||||
*/
|
||||
export function verifyMinusIntNumbInput(str: number | string): string {
|
||||
let numb = String(str)
|
||||
numb = String(Number.parseInt(numb))
|
||||
const numbArr = numb.match(minus_int_numb_input_RegEx)
|
||||
numb = Array.isArray(numbArr) ? numbArr[0] : ''
|
||||
numb = numb.replace(/^0+/g, '0')
|
||||
numb = numb !== '' && numb !== '0' && numb.startsWith('-') ? numb : `-${numb}`
|
||||
return numb
|
||||
}
|
||||
|
||||
/**
|
||||
* 正负整数(不含0) 主要用于输入时验证
|
||||
*/
|
||||
export function verifyAllIntNumbInput(str: number | string): string {
|
||||
let numb = String(str)
|
||||
numb = String(Number.parseInt(numb))
|
||||
const numbArr = numb.match(all_int_numb_input_RegEx)
|
||||
numb = Array.isArray(numbArr) ? numbArr[0] : ''
|
||||
numb = numb.replace(/^0+/g, '0')
|
||||
return numb
|
||||
}
|
||||
|
||||
/**
|
||||
* 正整数 (含0) 主要用于输入时验证
|
||||
*/
|
||||
export function verifyAddIntRoZeroNumbInput(str: number | string): string {
|
||||
let numb = String(str)
|
||||
numb = String(Number.parseInt(numb))
|
||||
const numbArr = numb.match(minus_int_or_zero_numb_input_RegEx)
|
||||
numb = Array.isArray(numbArr) ? numbArr[0] : ''
|
||||
numb = numb.replace(/^0+/g, '0')
|
||||
return numb
|
||||
}
|
||||
|
||||
/**
|
||||
* 负整数 (含0) 主要用于输入时验证
|
||||
*/
|
||||
export function verifyMinusIntRoZeroNumbInput(str: number | string): string {
|
||||
const numbArr = String(str).match(minus_int_or_zero_numb_input_RegEx)
|
||||
let numb = Array.isArray(numbArr) ? numbArr[0] : ''
|
||||
numb = numb.replace(/^0+/g, '0')
|
||||
numb = numb.replace(/^-0+/g, '0')
|
||||
numb = numb !== '' && numb !== '0' && numb.startsWith('-') ? numb : `-${numb}`
|
||||
return numb
|
||||
}
|
||||
|
||||
/**
|
||||
* 正 负 整数(含0) 主要用于输入时验证
|
||||
*/
|
||||
export function verifyAllIntRoZeroNumbInput(str: number | string): string {
|
||||
const numbArr = String(str).match(all_int_or_zero_numb_input_RegEx)
|
||||
let numb = Array.isArray(numbArr) ? numbArr[0] : ''
|
||||
numb = numb.replace(/^0+/g, '0')
|
||||
numb = numb.replace(/^-0+/g, '-')
|
||||
numb = !Number.isNaN(Number(numb)) ? String(Number(numb)) : numb
|
||||
return numb
|
||||
}
|
||||
|
||||
/**
|
||||
* 正浮点数 主要用于输入时验证
|
||||
*/
|
||||
export function verifyAddFloatNumbInput(str: number | string): string {
|
||||
let numb = str
|
||||
if (str === '.') {
|
||||
numb = '0.'
|
||||
}
|
||||
else {
|
||||
const numbArr = String(numb).match(add_float_numb_input_RegEx)
|
||||
numb = Array.isArray(numbArr) ? numbArr[0] : ''
|
||||
numb = numb.replace(/^0+/g, '0')
|
||||
numb = numb.replace(/^0+\./g, '0.')
|
||||
numb = numb.match(/^0+[1-9]+/) ? numb.replace(/^0+/g, '') : numb
|
||||
}
|
||||
return numb
|
||||
}
|
||||
|
||||
/**
|
||||
* 负浮点数 主要用于输入时验证
|
||||
*/
|
||||
export function verifyMinusFloatNumbInput(str: number | string): string {
|
||||
let numb = str
|
||||
if (str === '.') {
|
||||
numb = '-0.'
|
||||
}
|
||||
else {
|
||||
const numbArr = String(str).match(minus_float_numb_input_RegEx)
|
||||
numb = Array.isArray(numbArr) ? numbArr[0] : ''
|
||||
numb = numb.replace(/^0+/g, '0')
|
||||
numb = numb.replace(/^-0+/g, '-0')
|
||||
numb = numb.replace(/^0+\./g, '0.')
|
||||
numb = numb.replace(/^-\./g, '-0.')
|
||||
numb = numb.match(/^0+[1-9]+/) ? numb.replace(/^0+/g, '') : numb
|
||||
numb = numb !== '' && numb !== '0' && numb.startsWith('-') ? numb : `-${numb}`
|
||||
}
|
||||
return numb
|
||||
}
|
||||
|
||||
/**
|
||||
* 正负浮点数 主要用于输入时验证
|
||||
*/
|
||||
export function verifyAllFloatNumbInput(str: number | string): string {
|
||||
let numb = str
|
||||
if (str === '.') {
|
||||
numb = '0.'
|
||||
}
|
||||
else {
|
||||
const numbArr = String(str).match(all_float_numb_input_RegEx)
|
||||
numb = Array.isArray(numbArr) ? numbArr[0] : ''
|
||||
numb = numb.replace(/^0+/g, '0')
|
||||
numb = numb.replace(/^-0+/g, '-0')
|
||||
numb = numb.replace(/^0+\./g, '0.')
|
||||
numb = numb.replace(/^-\./g, '-0.')
|
||||
numb = numb.match(/^0+[1-9]+/) ? numb.replace(/^0+/g, '') : numb
|
||||
}
|
||||
return numb
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证数字
|
||||
* @param verify - 验证类型
|
||||
* @param numb - 需要验证的数字
|
||||
* @param oldNumb - 修改之前的
|
||||
*/
|
||||
export function verifyNumbInput(verify: $InputVerify | undefined, numb: number | string, oldNumb: number | string) {
|
||||
let newNumb = Number.isNaN(Number(numb)) && numb !== '.' && numb !== '-' ? oldNumb : numb
|
||||
if (verify === 'add_int') {
|
||||
// 正整数 (不含0) 主要用于输入时验证
|
||||
newNumb = verifyAddIntNumbInput(newNumb)
|
||||
}
|
||||
else if (verify === 'minus_int') {
|
||||
// 负整数(不含0) 主要用于输入时验证
|
||||
newNumb = verifyMinusIntNumbInput(newNumb)
|
||||
}
|
||||
else if (verify === 'all_int') {
|
||||
// 正负整数(不含0) 主要用于输入时验证
|
||||
newNumb = verifyAllIntNumbInput(newNumb)
|
||||
}
|
||||
else if (verify === 'add_int_or_zero') {
|
||||
// 正整数 (含0) 主要用于输入时验证
|
||||
newNumb = verifyAddIntRoZeroNumbInput(newNumb)
|
||||
}
|
||||
else if (verify === 'minus_int_or_zero') {
|
||||
// 负整数 (含0) 主要用于输入时验证
|
||||
newNumb = verifyMinusIntRoZeroNumbInput(newNumb)
|
||||
}
|
||||
else if (verify === 'all_int_or_zero') {
|
||||
// 正负整数(含0) 主要用于输入时验证
|
||||
newNumb = verifyAllIntRoZeroNumbInput(newNumb)
|
||||
}
|
||||
else if (verify === 'add_float') {
|
||||
// 正浮点数 主要用于输入时验证
|
||||
newNumb = verifyAddFloatNumbInput(newNumb)
|
||||
}
|
||||
else if (verify === 'minus_float') {
|
||||
// 负浮点数 主要用于输入时验证
|
||||
newNumb = verifyMinusFloatNumbInput(newNumb)
|
||||
}
|
||||
else if (verify === 'all_float') {
|
||||
// 正负浮点数 主要用于输入时验证
|
||||
newNumb = verifyAllFloatNumbInput(newNumb)
|
||||
}
|
||||
if (newNumb !== '') {
|
||||
return newNumb
|
||||
}
|
||||
else {
|
||||
return !Number.isNaN(Number(oldNumb)) ? oldNumb : ''
|
||||
}
|
||||
}
|
||||
162
apps/admin/src/utils/verify.ts
Normal file
@ -0,0 +1,162 @@
|
||||
/** 双字节字符(包括汉字在内,即占两个英文字符位置的字符) */
|
||||
export const double_byte_RegEx = /[^\x00-\xFF]/ // eslint-disable-line no-control-regex
|
||||
|
||||
/** 中文字符 */
|
||||
export const chinese_RegEx = /[\u4E00-\u9FA5]/
|
||||
|
||||
/** 密码至少包含字母、数字、特殊字符中的两种,且不能出现4个大小连续或相同的数字 */
|
||||
export const password_RegEx
|
||||
// eslint-disable-next-line regexp/optimal-quantifier-concatenation
|
||||
= /^(?!.*(.)\1{3})(?!.*(0123|1234|2345|3456|4567|5678|6789|9876|8765|7654|6543|5432|4321))(?=.*[a-z])(?=.*\d|.*[!@#¥%&*.,])[a-z\d!@#¥%&*.,]+$/i
|
||||
|
||||
/** 密码至少包含字母、数字、特殊字符中的两种,且不能出现4个大小连续或相同的数字,且为 8到 24 位 */
|
||||
export const password_RegEx2
|
||||
// eslint-disable-next-line regexp/optimal-quantifier-concatenation
|
||||
= /^(?!.*(.)\1{3})(?!.*(0123|1234|2345|3456|4567|5678|6789|9876|8765|7654|6543|5432|4321))(?=.*[a-z])(?=.*\d|.*[!@#¥%&*.,])[a-z\d!@#¥%&*.,]{8,24}$/i
|
||||
/** 验证电话号 */
|
||||
export const tel_RegEx = /^1\d{10}$/
|
||||
|
||||
/** 邮箱 */
|
||||
export const email_RegEx = /^([\w-]+)@([\w-]+\.?)*\w\.[a-z]{2,10}$/i
|
||||
|
||||
/** 网址(不带参数) */
|
||||
export const url_RegEx = /^(https?:\/\/)([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(\/\S*)?$/
|
||||
/** 网址(带参数) */
|
||||
export const url_RegEx2 = /^(https?:\/\/)([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(\/[^\s?#]*)?(\?[^?\s#]*)?(#\S*)?$/
|
||||
|
||||
/** 身份证 */
|
||||
export const IDcard_RegEx = /^(\d{6})(\d{4})(\d{2})(\d{2})(\d{3})([0-9X])$/i
|
||||
|
||||
/** 视频 */
|
||||
// eslint-disable-next-line regexp/no-dupe-disjunctions
|
||||
export const video_RegEx = /\.(mp4|mpg|mpeg|dat|asf|avi|rm|rmvb|mov|wmv|flv|mkv|m3u8)/i
|
||||
|
||||
/**
|
||||
* 验证密码合法性
|
||||
*/
|
||||
export function verifyPassword(str: string): boolean {
|
||||
return Boolean(password_RegEx.test(str))
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证电话号
|
||||
*/
|
||||
export function verifyTel(str: string): boolean {
|
||||
return Boolean(tel_RegEx.test(str))
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证邮箱
|
||||
*/
|
||||
export function verifyEmail(str: string): boolean {
|
||||
return Boolean(email_RegEx.test(str))
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证身份证
|
||||
*/
|
||||
export function verifyIDcard(str: string): boolean {
|
||||
if (!IDcard_RegEx.test(str)) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
// 出生年月日校验
|
||||
const year = str.slice(6, 10) // 身份证年
|
||||
const month = str.slice(10, 12) // 身份证月
|
||||
const date = str.slice(12, 14) // 身份证日
|
||||
const d = new Date(`${year}/${month}/${date}`)
|
||||
const dY = Number(d.getFullYear())
|
||||
const dM = Number(d.getMonth()) + 1
|
||||
const dD = Number(d.getDate())
|
||||
if (Number(year) === dY && Number(month) === dM && Number(date) === dD) {
|
||||
// 限制起始年份为1850且不能超过今天
|
||||
if (d.getTime() <= new Date().getTime() && dY >= 1850) {
|
||||
return true
|
||||
}
|
||||
else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
else {
|
||||
return false
|
||||
}
|
||||
// oxlint-disable-next-line no-unused-vars
|
||||
}
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
catch (_) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证网址
|
||||
* @param str - 网址
|
||||
* @param isParam - 可以携带参数 默认为 true
|
||||
*/
|
||||
export function verifyUrl(str: string, isParam = true): boolean {
|
||||
return Boolean(isParam ? url_RegEx2.test(str) : url_RegEx.test(str))
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为纯对象 不包含 Array,undefined function 等等
|
||||
*/
|
||||
export function isObject(obj: unknown): obj is Record<number | string | symbol, unknown> {
|
||||
return Object.prototype.toString.call(obj) === '[object Object]'
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为dom
|
||||
*/
|
||||
export function isDOM(obj: unknown): obj is HTMLElement {
|
||||
if (typeof HTMLElement === 'object') {
|
||||
return obj instanceof HTMLElement
|
||||
}
|
||||
else if (obj && typeof obj === 'object') {
|
||||
return (obj as Record<string, unknown>).nodeType === 1 && typeof (obj as Record<string, unknown>).nodeName === 'string'
|
||||
}
|
||||
else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为日期对象
|
||||
*/
|
||||
export function isDate(obj: unknown): obj is Date {
|
||||
return Object.prototype.toString.call(obj) === '[object Date]'
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为Math对象
|
||||
*/
|
||||
export function iIsMath(obj: unknown): obj is Math {
|
||||
return Object.prototype.toString.call(obj) === '[object Math]'
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为RegExp对象
|
||||
*/
|
||||
export function isRegExp(obj: unknown): obj is RegExp {
|
||||
return Object.prototype.toString.call(obj) === '[object RegExp]'
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为json对象
|
||||
*/
|
||||
export function isJson(obj: unknown): obj is Record<string, unknown> | unknown[] {
|
||||
return Boolean(isObject(obj) || Array.isArray(obj))
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为空对象(传入对象为假也代表是空对象,如false,null,0等)
|
||||
*/
|
||||
export function isNullObj(obj: unknown): boolean {
|
||||
return Boolean(!obj || JSON.stringify(obj) === '{}')
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为小屏幕
|
||||
*/
|
||||
export function isSmallScreen(): boolean {
|
||||
return document.documentElement.clientWidth < 900
|
||||
}
|
||||