feat(admin): 新增模板管理功能并完善用户认证
- 新增模板管理模块,包含模板列表和详情页面 - 重构用户认证逻辑,支持用户ID存储和API调用 - 新增排行榜API接口和类型定义 - 更新环境配置,添加开发环境配置 - 优化表格操作钩子,增强类型安全性 - 新增文件上传工具类和验证工具函数 - 更新路由配置,支持模板详情页面导航 - 修复登录表单默认值问题
This commit is contained in:
@ -1,9 +1,17 @@
|
|||||||
# 应用的基础URL,默认为 "/"
|
# 应用的基础URL,默认为 "/"
|
||||||
# 如果使用子目录,必须以 "/" 结尾,例如 "/admin/" 而不是 "/admin"
|
# 如果使用子目录,必须以 "/" 结尾,例如 "/admin/" 而不是 "/admin"
|
||||||
VITE_BASE_URL=/
|
# VITE_BASE_URL='/admin'
|
||||||
|
|
||||||
|
# VITE_BASE_URL="https://api.qyzhjy.com"
|
||||||
|
|
||||||
VITE_APP_TITLE="阅读比赛配置后台"
|
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="阅读比赛配置"
|
VITE_APP_DESC="阅读比赛配置"
|
||||||
|
|
||||||
# 图标名称的前缀
|
# 图标名称的前缀
|
||||||
@ -23,16 +31,16 @@ VITE_ROUTE_HOME=home
|
|||||||
VITE_MENU_ICON=mdi:menu
|
VITE_MENU_ICON=mdi:menu
|
||||||
|
|
||||||
# 在开发模式下是否启用http代理
|
# 在开发模式下是否启用http代理
|
||||||
VITE_HTTP_PROXY=Y
|
VITE_HTTP_PROXY=Y # Y 开启后,所有请求都将通过代理服务器,N 关闭后,所有请求都将直接发送到后端服务
|
||||||
|
|
||||||
# vue-router 模式: hash | history | memory
|
# vue-router 模式: hash | history | memory
|
||||||
VITE_ROUTER_HISTORY_MODE=history
|
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
|
VITE_SERVICE_MODAL_LOGOUT_CODES=7777,7778
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
# backend service base url, test environment
|
# backend service base url, test environment
|
||||||
VITE_SERVICE_BASE_URL=https://mock.apifox.cn/m1/3109515-0-default
|
# 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
|
||||||
|
|
||||||
|
|
||||||
# other backend service base url, test environment
|
# other backend service base url, test environment
|
||||||
VITE_OTHER_SERVICE_BASE_URL= `{
|
VITE_OTHER_SERVICE_BASE_URL= `{
|
||||||
@ -18,7 +18,6 @@ export function createViteProxy(env: Env.ImportMeta, enable: boolean) {
|
|||||||
const isEnableProxyLog = env.VITE_PROXY_LOG === 'Y'
|
const isEnableProxyLog = env.VITE_PROXY_LOG === 'Y'
|
||||||
|
|
||||||
const { baseURL, proxyPattern, other } = createServiceConfig(env)
|
const { baseURL, proxyPattern, other } = createServiceConfig(env)
|
||||||
console.log('baseURL:', baseURL, 'proxyPattern:', proxyPattern, 'other:', other)
|
|
||||||
|
|
||||||
const proxy: Record<string, ProxyOptions> = createProxyItem({ baseURL, proxyPattern }, isEnableProxyLog)
|
const proxy: Record<string, ProxyOptions> = createProxyItem({ baseURL, proxyPattern }, isEnableProxyLog)
|
||||||
|
|
||||||
|
|||||||
@ -9,11 +9,11 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build --mode prod",
|
"build": "vite build --mode prod",
|
||||||
"build:test": "vite build --mode test",
|
"build:dev": "vite build --mode dev",
|
||||||
"cleanup": "sa cleanup",
|
"cleanup": "sa cleanup",
|
||||||
"commit": "sa git-commit",
|
"commit": "sa git-commit",
|
||||||
"commit:zh": "sa git-commit -l=zh-cn",
|
"commit:zh": "sa git-commit -l=zh-cn",
|
||||||
"dev": "vite --mode test",
|
"dev": "vite --mode dev",
|
||||||
"dev:prod": "vite --mode prod",
|
"dev:prod": "vite --mode prod",
|
||||||
"gen-route": "sa gen-route",
|
"gen-route": "sa gen-route",
|
||||||
"lint": "eslint . --fix",
|
"lint": "eslint . --fix",
|
||||||
@ -25,6 +25,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@better-scroll/core": "2.5.1",
|
"@better-scroll/core": "2.5.1",
|
||||||
|
"@formkit/auto-animate": "^0.9.0",
|
||||||
"@iconify/vue": "5.0.0",
|
"@iconify/vue": "5.0.0",
|
||||||
"@opentiny/fluent-editor": "^4.0.1",
|
"@opentiny/fluent-editor": "^4.0.1",
|
||||||
"@sa/axios": "workspace:*",
|
"@sa/axios": "workspace:*",
|
||||||
@ -32,7 +33,10 @@
|
|||||||
"@sa/hooks": "workspace:*",
|
"@sa/hooks": "workspace:*",
|
||||||
"@sa/materials": "workspace:*",
|
"@sa/materials": "workspace:*",
|
||||||
"@sa/utils": "workspace:*",
|
"@sa/utils": "workspace:*",
|
||||||
|
"@sapphire/snowflake": "^3.5.5",
|
||||||
"@vueuse/core": "14.1.0",
|
"@vueuse/core": "14.1.0",
|
||||||
|
"@zumer/snapdom": "^2.0.1",
|
||||||
|
"ali-oss": "^6.23.0",
|
||||||
"clipboard": "2.0.11",
|
"clipboard": "2.0.11",
|
||||||
"dayjs": "1.11.19",
|
"dayjs": "1.11.19",
|
||||||
"defu": "6.1.4",
|
"defu": "6.1.4",
|
||||||
@ -42,6 +46,7 @@
|
|||||||
"mathlive": "^0.108.2",
|
"mathlive": "^0.108.2",
|
||||||
"naive-ui": "2.43.2",
|
"naive-ui": "2.43.2",
|
||||||
"nprogress": "0.2.0",
|
"nprogress": "0.2.0",
|
||||||
|
"path-browserify": "^1.0.1",
|
||||||
"pinia": "3.0.4",
|
"pinia": "3.0.4",
|
||||||
"quill-toolbar-tip": "^0.1.0",
|
"quill-toolbar-tip": "^0.1.0",
|
||||||
"tailwind-merge": "3.4.0",
|
"tailwind-merge": "3.4.0",
|
||||||
@ -56,8 +61,10 @@
|
|||||||
"@iconify/json": "2.2.417",
|
"@iconify/json": "2.2.417",
|
||||||
"@sa/scripts": "workspace:*",
|
"@sa/scripts": "workspace:*",
|
||||||
"@sa/uno-preset": "workspace:*",
|
"@sa/uno-preset": "workspace:*",
|
||||||
|
"@types/ali-oss": "^6.16.13",
|
||||||
"@types/node": "25.0.3",
|
"@types/node": "25.0.3",
|
||||||
"@types/nprogress": "0.2.3",
|
"@types/nprogress": "0.2.3",
|
||||||
|
"@types/path-browserify": "^1.0.3",
|
||||||
"@unocss/eslint-config": "66.5.10",
|
"@unocss/eslint-config": "66.5.10",
|
||||||
"@unocss/preset-icons": "66.5.10",
|
"@unocss/preset-icons": "66.5.10",
|
||||||
"@unocss/preset-uno": "66.5.10",
|
"@unocss/preset-uno": "66.5.10",
|
||||||
|
|||||||
BIN
apps/admin/src/assets/imgs/副本第九届“阅读之星”题目示例 (1).xlsx
Normal file
BIN
apps/admin/src/assets/imgs/副本第九届“阅读之星”题目示例 (1).xlsx
Normal file
Binary file not shown.
@ -1,7 +1,7 @@
|
|||||||
import type { IEditorConfig, ToolbarOptions } from '@opentiny/fluent-editor/types/config/types'
|
import type { IEditorConfig, MathliveModule, Range, ToolbarOptions } from '@opentiny/fluent-editor'
|
||||||
import type OSS from 'ali-oss'
|
import type OSS from 'ali-oss'
|
||||||
import type { GetEditorConfigParams, MyToolbarOption, UpFile } from './types'
|
import type { GetEditorConfigParams, MyToolbarOption, UpFile } from './types'
|
||||||
import { default as FluentEditor, generateToolbarTip, type MathliveModule, type Range } from '@opentiny/fluent-editor'
|
import FluentEditor, { generateToolbarTip } from '@opentiny/fluent-editor'
|
||||||
import katex from 'katex'
|
import katex from 'katex'
|
||||||
import QuillToolbarTip, { type QuillToolbarTipOptions } from 'quill-toolbar-tip'
|
import QuillToolbarTip, { type QuillToolbarTipOptions } from 'quill-toolbar-tip'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
@ -20,7 +20,7 @@ export const upFileList = ref<UpFile[]>([])
|
|||||||
*/
|
*/
|
||||||
export function initEditor(container: HTMLElement, params: IEditorConfig): FluentEditor {
|
export function initEditor(container: HTMLElement, params: IEditorConfig): FluentEditor {
|
||||||
// 需要依赖 katex
|
// 需要依赖 katex
|
||||||
window.katex = katex
|
window.katex = katex as any
|
||||||
|
|
||||||
// 处理图片给每个图片添加类名和最大宽度
|
// 处理图片给每个图片添加类名和最大宽度
|
||||||
const ImageBlot: any = FluentEditor.import('formats/image')
|
const ImageBlot: any = FluentEditor.import('formats/image')
|
||||||
@ -51,8 +51,7 @@ export function initEditor(container: HTMLElement, params: IEditorConfig): Fluen
|
|||||||
|
|
||||||
// 居中问题
|
// 居中问题
|
||||||
const config = { scope: Parchment.Scope.BLOCK, whitelist: ['right', 'center', 'justify'] }
|
const config = { scope: Parchment.Scope.BLOCK, whitelist: ['right', 'center', 'justify'] }
|
||||||
new Parchment.StyleAttributor('align', 'text-align', config)
|
const Align = new Parchment.StyleAttributor('align', 'text-align', config)
|
||||||
const Align = FluentEditor.import('attributors/style/align')
|
|
||||||
FluentEditor.register(Align, true)
|
FluentEditor.register(Align, true)
|
||||||
|
|
||||||
// 工具栏配置
|
// 工具栏配置
|
||||||
@ -95,7 +94,9 @@ export async function getEditorConfig(params: GetEditorConfigParams): Promise<IE
|
|||||||
const paths = await Promise.all(files.map((file, index) => upFileAxios(file, `${Date.now()}_${index}`)))
|
const paths = await Promise.all(files.map((file, index) => upFileAxios(file, `${Date.now()}_${index}`)))
|
||||||
return paths
|
return paths
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error: any) {
|
||||||
|
window.$message?.error(`上传失败: ${error.message}`)
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
console.log('error====', error)
|
console.log('error====', error)
|
||||||
return files.map(() => false)
|
return files.map(() => false)
|
||||||
}
|
}
|
||||||
@ -190,9 +191,12 @@ async function upFileAxios(file: File, id: string): Promise<string | false> {
|
|||||||
const item: UpFile = { id, name: file.name, progress: 0 }
|
const item: UpFile = { id, name: file.name, progress: 0 }
|
||||||
try {
|
try {
|
||||||
upFileList.value.push(item)
|
upFileList.value.push(item)
|
||||||
const tokenRes = await getAliOssTokenAxios()
|
const { data: tokenData } = await getAliOssTokenAxios()
|
||||||
|
if (!tokenData) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
// 初始化OSS客户端
|
// 初始化OSS客户端
|
||||||
const client = initOSSClient(tokenRes)
|
const client = initOSSClient(tokenData)
|
||||||
const path = browserPathJoin(`temp/${Date.now()}`, file!.name)
|
const path = browserPathJoin(`temp/${Date.now()}`, file!.name)
|
||||||
// 上传文件
|
// 上传文件
|
||||||
const res: OSS.MultipartUploadResult = await uploadFileToOSS(client, file, path, (progress) => {
|
const res: OSS.MultipartUploadResult = await uploadFileToOSS(client, file, path, (progress) => {
|
||||||
@ -203,9 +207,9 @@ async function upFileAxios(file: File, id: string): Promise<string | false> {
|
|||||||
return Promise.resolve(url)
|
return Promise.resolve(url)
|
||||||
// oxlint-disable-next-line no-unused-vars
|
// oxlint-disable-next-line no-unused-vars
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error: any) {
|
||||||
upFileList.value.splice(upFileList.value.indexOf(item), 1)
|
upFileList.value.splice(upFileList.value.indexOf(item), 1)
|
||||||
window.$message?.error('上传失败')
|
window.$message?.error(`上传失败: ${error.message}`)
|
||||||
return Promise.resolve(false)
|
return Promise.resolve(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
489
apps/admin/src/components/common/rest-upload/rest-upload.vue
Normal file
489
apps/admin/src/components/common/rest-upload/rest-upload.vue
Normal file
@ -0,0 +1,489 @@
|
|||||||
|
<!-- 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)
|
||||||
|
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>
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
import type { SelectOption } from 'naive-ui'
|
||||||
import { transformRecordToOption } from '@/utils/common'
|
import { transformRecordToOption } from '@/utils/common'
|
||||||
|
|
||||||
export const enableStatusRecord: Record<Api.Common.EnableStatus, string> = {
|
export const enableStatusRecord: Record<Api.Common.EnableStatus, string> = {
|
||||||
@ -5,21 +6,21 @@ export const enableStatusRecord: Record<Api.Common.EnableStatus, string> = {
|
|||||||
2: '禁用',
|
2: '禁用',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const enableStatusOptions = transformRecordToOption(enableStatusRecord)
|
export const enableStatusOptions: SelectOption[] = transformRecordToOption(enableStatusRecord) as unknown as SelectOption[]
|
||||||
|
|
||||||
export const userGenderRecord: Record<Api.SystemManage.UserGender, string> = {
|
export const userGenderRecord: Record<Api.SystemManage.UserGender, string> = {
|
||||||
1: '男',
|
1: '男',
|
||||||
2: '女',
|
2: '女',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const userGenderOptions = transformRecordToOption(userGenderRecord)
|
export const userGenderOptions: SelectOption[] = transformRecordToOption(userGenderRecord) as unknown as SelectOption[]
|
||||||
|
|
||||||
export const menuTypeRecord: Record<Api.SystemManage.MenuType, string> = {
|
export const menuTypeRecord: Record<Api.SystemManage.MenuType, string> = {
|
||||||
1: '目录',
|
1: '目录',
|
||||||
2: '菜单',
|
2: '菜单',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const menuTypeOptions = transformRecordToOption(menuTypeRecord)
|
export const menuTypeOptions: SelectOption[] = transformRecordToOption(menuTypeRecord) as unknown as SelectOption[]
|
||||||
|
|
||||||
export const menuIconTypeRecord: Record<Api.SystemManage.IconType, string> = {
|
export const menuIconTypeRecord: Record<Api.SystemManage.IconType, string> = {
|
||||||
1: 'iconify',
|
1: 'iconify',
|
||||||
@ -42,8 +43,32 @@ export const roundTypeRecord: Record<Api.Competition.CompetitionRoundType, strin
|
|||||||
2: '加时环节',
|
2: '加时环节',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const roundTypeOptions = transformRecordToOption(roundTypeRecord)
|
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 scoreTypeOptions = transformRecordToOption(scoreTypeRecord)
|
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 menuIconTypeOptions = transformRecordToOption(menuIconTypeRecord)
|
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[]
|
||||||
|
|||||||
@ -176,7 +176,18 @@ export function useTableOperate<TableData>(
|
|||||||
data: Ref<TableData[]>,
|
data: Ref<TableData[]>,
|
||||||
idKey: keyof TableData,
|
idKey: keyof TableData,
|
||||||
getData: () => Promise<void>,
|
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[]>
|
||||||
|
onBatchDeleted: () => Promise<void>
|
||||||
|
onDeleted: () => Promise<void>
|
||||||
|
} {
|
||||||
const { bool: drawerVisible, setTrue: openDrawer, setFalse: closeDrawer } = useBoolean()
|
const { bool: drawerVisible, setTrue: openDrawer, setFalse: closeDrawer } = useBoolean()
|
||||||
|
|
||||||
const operateType = shallowRef<NaiveUI.TableOperateType>('add')
|
const operateType = shallowRef<NaiveUI.TableOperateType>('add')
|
||||||
@ -230,17 +241,23 @@ export function useTableOperate<TableData>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认的表格数据转换函数
|
||||||
|
*
|
||||||
|
* @param response 原始的 API 响应数据
|
||||||
|
* @returns 转换后的分页数据
|
||||||
|
*/
|
||||||
export function defaultTransform<ApiData>(
|
export function defaultTransform<ApiData>(
|
||||||
response: FlatResponseData<any, Api.Common.PaginatingQueryRecord<ApiData>>,
|
response: FlatResponseData<any, Api.Common.PaginatingQueryRecord<ApiData>>,
|
||||||
): PaginationData<ApiData> {
|
): PaginationData<ApiData> {
|
||||||
const { data, error } = response
|
const { data, error } = response
|
||||||
|
|
||||||
if (!error) {
|
if (!error) {
|
||||||
const { records, current, size, total } = data
|
const { list, currentPage: pageNum, pageSize: size, total } = data
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: records,
|
data: list,
|
||||||
pageNum: current,
|
pageNum,
|
||||||
pageSize: size,
|
pageSize: size,
|
||||||
total,
|
total,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -235,6 +235,9 @@ const local: App.I18n.Schema = {
|
|||||||
'rank': 'Rank',
|
'rank': 'Rank',
|
||||||
'rank_rank-detail': 'Rank Detail',
|
'rank_rank-detail': 'Rank Detail',
|
||||||
'rank_rank-list': 'Rank List',
|
'rank_rank-list': 'Rank List',
|
||||||
|
'template_template-detail': 'Template Detail',
|
||||||
|
'template_template-list': 'Template List',
|
||||||
|
'user': 'User',
|
||||||
'results': 'Results',
|
'results': 'Results',
|
||||||
'competition_competition-add': 'Competition Add',
|
'competition_competition-add': 'Competition Add',
|
||||||
'competition_competition-detail': 'Competition Detail',
|
'competition_competition-detail': 'Competition Detail',
|
||||||
|
|||||||
@ -231,6 +231,9 @@ const local: App.I18n.Schema = {
|
|||||||
'rank': '名次排行',
|
'rank': '名次排行',
|
||||||
'rank_rank-detail': '名次详情',
|
'rank_rank-detail': '名次详情',
|
||||||
'rank_rank-list': '名次列表',
|
'rank_rank-list': '名次列表',
|
||||||
|
'template_template-detail': '模板详情',
|
||||||
|
'template_template-list': '模板列表',
|
||||||
|
'user': '用户管理',
|
||||||
'results': '实时结果',
|
'results': '实时结果',
|
||||||
'competition_competition-add': '比赛添加',
|
'competition_competition-add': '比赛添加',
|
||||||
'competition_competition-detail': '比赛详情',
|
'competition_competition-detail': '比赛详情',
|
||||||
|
|||||||
@ -89,6 +89,10 @@ export function setupAppVersionNotification() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取HTML构建时间
|
||||||
|
* @returns 构建时间字符串或null
|
||||||
|
*/
|
||||||
async function getHtmlBuildTime(): Promise<string | null> {
|
async function getHtmlBuildTime(): Promise<string | null> {
|
||||||
const baseUrl = import.meta.env.VITE_BASE_URL || '/'
|
const baseUrl = import.meta.env.VITE_BASE_URL || '/'
|
||||||
|
|
||||||
|
|||||||
@ -28,6 +28,7 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
|
|||||||
"rank_rank-detail": () => import("@/views/rank/rank-detail/index.vue"),
|
"rank_rank-detail": () => import("@/views/rank/rank-detail/index.vue"),
|
||||||
"rank_rank-list": () => import("@/views/rank/rank-list/index.vue"),
|
"rank_rank-list": () => import("@/views/rank/rank-list/index.vue"),
|
||||||
results: () => import("@/views/results/index.vue"),
|
results: () => import("@/views/results/index.vue"),
|
||||||
template: () => import("@/views/template/index.vue"),
|
"template_template-detail": () => import("@/views/template/template-detail/index.vue"),
|
||||||
|
"template_template-list": () => import("@/views/template/template-list/index.vue"),
|
||||||
user: () => import("@/views/user/index.vue"),
|
user: () => import("@/views/user/index.vue"),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -181,13 +181,38 @@ export const generatedRoutes: GeneratedRoute[] = [
|
|||||||
{
|
{
|
||||||
name: 'template',
|
name: 'template',
|
||||||
path: '/template',
|
path: '/template',
|
||||||
component: 'layout.base$view.template',
|
component: 'layout.base',
|
||||||
meta: {
|
meta: {
|
||||||
title: 'template',
|
title: 'template',
|
||||||
i18nKey: 'route.template',
|
i18nKey: 'route.template',
|
||||||
icon: 'material-symbols:settings-motion-mode-outline-rounded',
|
icon: 'material-symbols:settings-motion-mode-outline-rounded',
|
||||||
order: 4
|
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: 'user',
|
name: 'user',
|
||||||
|
|||||||
@ -179,6 +179,8 @@ const routeMap: RouteMap = {
|
|||||||
"rank_rank-list": "/rank/rank-list",
|
"rank_rank-list": "/rank/rank-list",
|
||||||
"results": "/results",
|
"results": "/results",
|
||||||
"template": "/template",
|
"template": "/template",
|
||||||
|
"template_template-detail": "/template/template-detail",
|
||||||
|
"template_template-list": "/template/template-list",
|
||||||
"user": "/user"
|
"user": "/user"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -18,7 +18,7 @@ const historyCreatorMap: Record<Env.RouterHistoryMode, (base?: string) => Router
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
history: historyCreatorMap[VITE_ROUTER_HISTORY_MODE](VITE_BASE_URL),
|
history: historyCreatorMap[VITE_ROUTER_HISTORY_MODE](VITE_BASE_URL), // 路由模式
|
||||||
routes: createBuiltinVueRoutes(),
|
routes: createBuiltinVueRoutes(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -6,20 +6,20 @@ import { request } from '../request'
|
|||||||
* @param userName User name
|
* @param userName User name
|
||||||
* @param password Password
|
* @param password Password
|
||||||
*/
|
*/
|
||||||
export function fetchLogin(userName: string, password: string) {
|
export function fetchLogin(account: string, pwd: string) {
|
||||||
return request<Api.Auth.LoginToken>({
|
return request<Api.Auth.LoginToken>({
|
||||||
url: '/auth/login',
|
url: '/admin/v1/user/login',
|
||||||
method: 'post',
|
method: 'post',
|
||||||
data: {
|
data: {
|
||||||
userName,
|
account,
|
||||||
password,
|
pwd,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get user info */
|
/** Get user info */
|
||||||
export function fetchGetUserInfo() {
|
export function fetchGetUserInfo(userId: number) {
|
||||||
return request<Api.Auth.UserInfo>({ url: '/auth/getUserInfo' })
|
return request<Api.Auth.UserInfo>({ url: `admin/v1/manager/detail/${userId}` })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
export * from './auth'
|
export * from './auth'
|
||||||
|
export * from './rank'
|
||||||
export * from './route'
|
export * from './route'
|
||||||
export * from './system-manage'
|
export * from './template'
|
||||||
|
|||||||
10
apps/admin/src/service/api/question.ts
Normal file
10
apps/admin/src/service/api/question.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { request } from '../request'
|
||||||
|
|
||||||
|
/** 新增题目 */
|
||||||
|
export function fetchAddQuestion(data?: Api.Question.AddParams) {
|
||||||
|
return request<Api.Question.CommonRecord>({
|
||||||
|
url: '/Base/ActivityMain/AddBase_QuestionList',
|
||||||
|
method: 'post',
|
||||||
|
data: data || {},
|
||||||
|
})
|
||||||
|
}
|
||||||
10
apps/admin/src/service/api/rank.ts
Normal file
10
apps/admin/src/service/api/rank.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { request } from '../request'
|
||||||
|
|
||||||
|
/** get user list */
|
||||||
|
export function fetchRankList(data?: Api.Rank.UserSearchParams) {
|
||||||
|
return request<Api.Rank.CommonRecord>({
|
||||||
|
url: '/admin/v1/class/pagelist',
|
||||||
|
method: 'post',
|
||||||
|
data: data || {},
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -24,8 +24,8 @@ export function fetchGetAllRoles() {
|
|||||||
/** get user list */
|
/** get user list */
|
||||||
export function fetchGetUserList(params?: Api.SystemManage.UserSearchParams) {
|
export function fetchGetUserList(params?: Api.SystemManage.UserSearchParams) {
|
||||||
return request<Api.SystemManage.UserList>({
|
return request<Api.SystemManage.UserList>({
|
||||||
url: '/systemManage/getUserList',
|
url: '/admin/v1/class/pagelist',
|
||||||
method: 'get',
|
method: 'post',
|
||||||
params,
|
params,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
10
apps/admin/src/service/api/template.ts
Normal file
10
apps/admin/src/service/api/template.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { request } from '../request'
|
||||||
|
|
||||||
|
/** get template list */
|
||||||
|
export function fetchTemplateList(data?: Api.Template.TemplateSearchParams) {
|
||||||
|
return request<Api.Common.PaginatingQueryRecord<Api.Template.CommonRecord>>({
|
||||||
|
url: '/admin/v1/book/list',
|
||||||
|
method: 'post',
|
||||||
|
data: data || {},
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -10,36 +10,45 @@ import { getAuthorization, handleExpiredRequest, showErrorMsg } from './shared'
|
|||||||
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y'
|
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y'
|
||||||
const { baseURL, otherBaseURL } = getServiceBaseURL(import.meta.env, isHttpProxy)
|
const { baseURL, otherBaseURL } = getServiceBaseURL(import.meta.env, isHttpProxy)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基础请求
|
||||||
|
*/
|
||||||
export const request = createFlatRequest(
|
export const request = createFlatRequest(
|
||||||
{
|
{
|
||||||
baseURL,
|
baseURL,
|
||||||
headers: {
|
headers: {
|
||||||
apifoxToken: 'XL299LiMEDZ0H5h3A29PxwQXdMJqWyY2',
|
'apifoxToken': 'XL299LiMEDZ0H5h3A29PxwQXdMJqWyY2', // 用于 apifox 调试
|
||||||
|
'Custom-Platform': 'pc',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defaultState: {
|
defaultState: {
|
||||||
errMsgStack: [],
|
errMsgStack: [], // 错误信息栈
|
||||||
refreshTokenPromise: null,
|
refreshTokenPromise: null, // 刷新token Promise
|
||||||
} as RequestInstanceState,
|
} as RequestInstanceState,
|
||||||
|
// 响应数据转换
|
||||||
transform(response: AxiosResponse<App.Service.Response<any>>) {
|
transform(response: AxiosResponse<App.Service.Response<any>>) {
|
||||||
return response.data.data
|
return response.data.data
|
||||||
},
|
},
|
||||||
|
// 请求配置转换
|
||||||
async onRequest(config) {
|
async onRequest(config) {
|
||||||
const Authorization = getAuthorization()
|
const Authorization = getAuthorization()
|
||||||
Object.assign(config.headers, { Authorization })
|
Object.assign(config.headers, { Authorization })
|
||||||
|
|
||||||
return config
|
return config
|
||||||
},
|
},
|
||||||
|
// 响应状态判断
|
||||||
isBackendSuccess(response) {
|
isBackendSuccess(response) {
|
||||||
// when the backend response code is "0000"(default), it means the request is success
|
// 当后端响应状态码为 "200"(默认值)时,表示请求成功
|
||||||
// to change this logic by yourself, you can modify the `VITE_SERVICE_SUCCESS_CODE` in `.env` file
|
// 如果你想自行更改此逻辑,可以在 .env 文件中修改 VITE_SERVICE_SUCCESS_CODE
|
||||||
return String(response.data.code) === import.meta.env.VITE_SERVICE_SUCCESS_CODE
|
return String(response.data.code) === import.meta.env.VITE_SERVICE_SUCCESS_CODE
|
||||||
},
|
},
|
||||||
|
// 响应错误处理
|
||||||
async onBackendFail(response, instance) {
|
async onBackendFail(response, instance) {
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const responseCode = String(response.data.code)
|
const responseCode = String(response.data.code)
|
||||||
|
|
||||||
|
//
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
authStore.resetStore()
|
authStore.resetStore()
|
||||||
}
|
}
|
||||||
@ -51,19 +60,19 @@ export const request = createFlatRequest(
|
|||||||
request.state.errMsgStack = request.state.errMsgStack.filter(msg => msg !== response.data.msg)
|
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(',') || []
|
const logoutCodes = import.meta.env.VITE_SERVICE_LOGOUT_CODES?.split(',') || []
|
||||||
if (logoutCodes.includes(responseCode)) {
|
if (logoutCodes.includes(responseCode)) {
|
||||||
handleLogout()
|
handleLogout()
|
||||||
return null
|
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(',') || []
|
const modalLogoutCodes = import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES?.split(',') || []
|
||||||
if (modalLogoutCodes.includes(responseCode) && !request.state.errMsgStack?.includes(response.data.msg)) {
|
if (modalLogoutCodes.includes(responseCode) && !request.state.errMsgStack?.includes(response.data.msg)) {
|
||||||
request.state.errMsgStack = [...(request.state.errMsgStack || []), response.data.msg]
|
request.state.errMsgStack = [...(request.state.errMsgStack || []), response.data.msg]
|
||||||
|
|
||||||
// prevent the user from refreshing the page
|
// 防止用户刷新页面
|
||||||
window.addEventListener('beforeunload', handleLogout)
|
window.addEventListener('beforeunload', handleLogout)
|
||||||
|
|
||||||
window.$dialog?.error({
|
window.$dialog?.error({
|
||||||
@ -83,8 +92,8 @@ export const request = createFlatRequest(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// when the backend response code is in `expiredTokenCodes`, it means the token is expired, and refresh token
|
// 当后端响应状态码在 expiredTokenCodes 中时,表示 token 已过期,需要刷新 token
|
||||||
// the api `refreshToken` can not return error code in `expiredTokenCodes`, otherwise it will be a dead loop, should return `logoutCodes` or `modalLogoutCodes`
|
// refreshToken 接口不能返回 expiredTokenCodes 中的错误码,否则会形成死循环,应该返回 logoutCodes 或 modalLogoutCodes
|
||||||
const expiredTokenCodes = import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES?.split(',') || []
|
const expiredTokenCodes = import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES?.split(',') || []
|
||||||
if (expiredTokenCodes.includes(responseCode)) {
|
if (expiredTokenCodes.includes(responseCode)) {
|
||||||
const success = await handleExpiredRequest(request.state)
|
const success = await handleExpiredRequest(request.state)
|
||||||
@ -99,24 +108,24 @@ export const request = createFlatRequest(
|
|||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
onError(error) {
|
onError(error) {
|
||||||
// when the request is fail, you can show error message
|
// 当请求失败时,可以显示错误信息
|
||||||
|
|
||||||
let message = error.message
|
let message = error.message
|
||||||
let backendErrorCode = ''
|
let backendErrorCode = ''
|
||||||
|
|
||||||
// get backend error message and code
|
// 获取后端错误信息和状态码
|
||||||
if (error.code === BACKEND_ERROR_CODE) {
|
if (error.code === BACKEND_ERROR_CODE) {
|
||||||
message = error.response?.data?.msg || message
|
message = error.response?.data?.msg || message
|
||||||
backendErrorCode = String(error.response?.data?.code || '')
|
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(',') || []
|
const modalLogoutCodes = import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES?.split(',') || []
|
||||||
if (modalLogoutCodes.includes(backendErrorCode)) {
|
if (modalLogoutCodes.includes(backendErrorCode)) {
|
||||||
return
|
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(',') || []
|
const expiredTokenCodes = import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES?.split(',') || []
|
||||||
if (expiredTokenCodes.includes(backendErrorCode)) {
|
if (expiredTokenCodes.includes(backendErrorCode)) {
|
||||||
return
|
return
|
||||||
@ -127,39 +136,48 @@ export const request = createFlatRequest(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 演示请求
|
||||||
|
*/
|
||||||
export const demoRequest = createRequest(
|
export const demoRequest = createRequest(
|
||||||
{
|
{
|
||||||
baseURL: otherBaseURL.demo,
|
baseURL: otherBaseURL.demo,
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* 演示请求响应数据转换
|
||||||
|
*/
|
||||||
{
|
{
|
||||||
|
// 响应数据转换
|
||||||
transform(response: AxiosResponse<App.Service.DemoResponse>) {
|
transform(response: AxiosResponse<App.Service.DemoResponse>) {
|
||||||
return response.data.result
|
return response.data.result
|
||||||
},
|
},
|
||||||
|
// 请求配置转换
|
||||||
async onRequest(config) {
|
async onRequest(config) {
|
||||||
const { headers } = config
|
const { headers } = config
|
||||||
|
|
||||||
// set token
|
// 设置 token
|
||||||
const token = localStg.get('token')
|
const token = localStg.get('token')
|
||||||
const Authorization = token ? `Bearer ${token}` : null
|
const Authorization = token ? `Bearer ${token}` : null
|
||||||
Object.assign(headers, { Authorization })
|
Object.assign(headers, { Authorization })
|
||||||
|
|
||||||
return config
|
return config
|
||||||
},
|
},
|
||||||
|
// 响应状态判断
|
||||||
isBackendSuccess(response) {
|
isBackendSuccess(response) {
|
||||||
// when the backend response code is "200", it means the request is success
|
// 当后端响应状态码为 "200" 时,表示请求成功
|
||||||
// you can change this logic by yourself
|
// 你可以自行更改此逻辑
|
||||||
return response.data.status === '200'
|
return response.data.status === '200'
|
||||||
},
|
},
|
||||||
async onBackendFail(_response) {
|
async onBackendFail(_response) {
|
||||||
// when the backend response code is not "200", it means the request is fail
|
// 当后端响应状态码不是 "200" 时,表示请求失败
|
||||||
// for example: the token is expired, refresh token and retry request
|
// 例如:token 过期,刷新 token 并重试请求
|
||||||
},
|
},
|
||||||
onError(error) {
|
onError(error) {
|
||||||
// when the request is fail, you can show error message
|
// 当请求失败时,可以显示错误信息
|
||||||
|
|
||||||
let message = error.message
|
let message = error.message
|
||||||
|
|
||||||
// show backend error message
|
// 显示后端错误信息
|
||||||
if (error.code === BACKEND_ERROR_CODE) {
|
if (error.code === BACKEND_ERROR_CODE) {
|
||||||
message = error.response?.data?.message || message
|
message = error.response?.data?.message || message
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,9 @@ export function getAuthorization() {
|
|||||||
return Authorization
|
return Authorization
|
||||||
}
|
}
|
||||||
|
|
||||||
/** refresh token */
|
/**
|
||||||
|
* 刷新token
|
||||||
|
*/
|
||||||
async function handleRefreshToken() {
|
async function handleRefreshToken() {
|
||||||
const { resetStore } = useAuthStore()
|
const { resetStore } = useAuthStore()
|
||||||
|
|
||||||
@ -27,6 +29,9 @@ async function handleRefreshToken() {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理过期token的请求
|
||||||
|
*/
|
||||||
export async function handleExpiredRequest(state: RequestInstanceState) {
|
export async function handleExpiredRequest(state: RequestInstanceState) {
|
||||||
if (!state.refreshTokenPromise) {
|
if (!state.refreshTokenPromise) {
|
||||||
state.refreshTokenPromise = handleRefreshToken()
|
state.refreshTokenPromise = handleRefreshToken()
|
||||||
@ -41,6 +46,9 @@ export async function handleExpiredRequest(state: RequestInstanceState) {
|
|||||||
return success
|
return success
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示错误信息
|
||||||
|
*/
|
||||||
export function showErrorMsg(state: RequestInstanceState, message: string) {
|
export function showErrorMsg(state: RequestInstanceState, message: string) {
|
||||||
if (!state.errMsgStack?.length) {
|
if (!state.errMsgStack?.length) {
|
||||||
state.errMsgStack = []
|
state.errMsgStack = []
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
export interface RequestInstanceState {
|
export interface RequestInstanceState {
|
||||||
/** the promise of refreshing token */
|
/** 刷新token Promise */
|
||||||
refreshTokenPromise: Promise<boolean> | null
|
refreshTokenPromise: Promise<boolean> | null
|
||||||
/** the request error message stack */
|
/** 请求错误信息栈 */
|
||||||
errMsgStack: string[]
|
errMsgStack: string[]
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { fetchGetUserInfo, fetchLogin } from '@/service/api'
|
|||||||
import { localStg } from '@/utils/storage'
|
import { localStg } from '@/utils/storage'
|
||||||
import { useRouteStore } from '../route'
|
import { useRouteStore } from '../route'
|
||||||
import { useTabStore } from '../tab'
|
import { useTabStore } from '../tab'
|
||||||
import { clearAuthStorage, getToken } from './shared'
|
import { clearAuthStorage, getToken, getUserId } from './shared'
|
||||||
|
|
||||||
export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@ -98,7 +98,6 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
|||||||
*/
|
*/
|
||||||
async function login(userName: string, password: string, redirect = true) {
|
async function login(userName: string, password: string, redirect = true) {
|
||||||
startLoading()
|
startLoading()
|
||||||
|
|
||||||
const { data: loginToken, error } = await fetchLogin(userName, password)
|
const { data: loginToken, error } = await fetchLogin(userName, password)
|
||||||
|
|
||||||
if (!error) {
|
if (!error) {
|
||||||
@ -133,9 +132,10 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
|||||||
// 1. stored in the localStorage, the later requests need it in headers
|
// 1. stored in the localStorage, the later requests need it in headers
|
||||||
localStg.set('token', loginToken.token)
|
localStg.set('token', loginToken.token)
|
||||||
localStg.set('refreshToken', loginToken.refreshToken)
|
localStg.set('refreshToken', loginToken.refreshToken)
|
||||||
|
localStg.set('userId', loginToken.id)
|
||||||
|
|
||||||
// 2. get user info
|
// 2. get user info
|
||||||
const pass = await getUserInfo()
|
const pass = await getUserInfo(loginToken.id)
|
||||||
|
|
||||||
if (pass) {
|
if (pass) {
|
||||||
token.value = loginToken.token
|
token.value = loginToken.token
|
||||||
@ -146,8 +146,8 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getUserInfo() {
|
async function getUserInfo(userId: number) {
|
||||||
const { data: info, error } = await fetchGetUserInfo()
|
const { data: info, error } = await fetchGetUserInfo(userId)
|
||||||
|
|
||||||
if (!error) {
|
if (!error) {
|
||||||
// update store
|
// update store
|
||||||
@ -161,9 +161,10 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
|||||||
|
|
||||||
async function initUserInfo() {
|
async function initUserInfo() {
|
||||||
const hasToken = getToken()
|
const hasToken = getToken()
|
||||||
|
const userId = getUserId()
|
||||||
|
|
||||||
if (hasToken) {
|
if (hasToken && userId) {
|
||||||
const pass = await getUserInfo()
|
const pass = await getUserInfo(userId)
|
||||||
|
|
||||||
if (!pass) {
|
if (!pass) {
|
||||||
resetStore()
|
resetStore()
|
||||||
|
|||||||
@ -5,8 +5,14 @@ export function getToken() {
|
|||||||
return localStg.get('token') || ''
|
return localStg.get('token') || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getUserId() {
|
||||||
|
const userId = localStg.get('userId')
|
||||||
|
return userId || 0
|
||||||
|
}
|
||||||
|
|
||||||
/** Clear auth storage */
|
/** Clear auth storage */
|
||||||
export function clearAuthStorage() {
|
export function clearAuthStorage() {
|
||||||
localStg.remove('token')
|
localStg.remove('token')
|
||||||
localStg.remove('refreshToken')
|
localStg.remove('refreshToken')
|
||||||
|
localStg.remove('userId')
|
||||||
}
|
}
|
||||||
|
|||||||
15
apps/admin/src/typings/api/Competition.d.ts
vendored
15
apps/admin/src/typings/api/Competition.d.ts
vendored
@ -31,6 +31,21 @@ declare namespace Api {
|
|||||||
*/
|
*/
|
||||||
type EnableStatus = '1' | '2'
|
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 QuestionTemplateId =
|
||||||
|
| 'TEMPLATE_DICTATION_HINT'
|
||||||
|
| 'TEMPLATE_DICTATION_HOMOPHONE'
|
||||||
|
| 'TEMPLATE_COMPONENT_ADD'
|
||||||
|
| 'TEMPLATE_WORD_DICTATION'
|
||||||
|
| 'TEMPLATE_IDIOM_TEXT_REQ'
|
||||||
|
| 'TEMPLATE_IDIOM_IMAGE' /** 汉字听写-提示 | 汉字听写-同音 | 汉字加一加 | 词语听写 | 成语-文字要求 | 成语-看图 */
|
||||||
|
|
||||||
/** question score type */
|
/** question score type */
|
||||||
type QuestionScoreType = '1' | '2' /** 固定分数 | 答题个数 */
|
type QuestionScoreType = '1' | '2' /** 固定分数 | 答题个数 */
|
||||||
|
|
||||||
|
|||||||
1
apps/admin/src/typings/api/auth.d.ts
vendored
1
apps/admin/src/typings/api/auth.d.ts
vendored
@ -8,6 +8,7 @@ declare namespace Api {
|
|||||||
interface LoginToken {
|
interface LoginToken {
|
||||||
token: string
|
token: string
|
||||||
refreshToken: string
|
refreshToken: string
|
||||||
|
id: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserInfo {
|
interface UserInfo {
|
||||||
|
|||||||
12
apps/admin/src/typings/api/common.d.ts
vendored
12
apps/admin/src/typings/api/common.d.ts
vendored
@ -7,21 +7,21 @@ declare namespace Api {
|
|||||||
namespace Common {
|
namespace Common {
|
||||||
/** common params of paginating */
|
/** common params of paginating */
|
||||||
interface PaginatingCommonParams {
|
interface PaginatingCommonParams {
|
||||||
/** current page number */
|
/** currentPage page number */
|
||||||
current: number
|
currentPage: number
|
||||||
/** page size */
|
/** page pageSize */
|
||||||
size: number
|
pageSize: number
|
||||||
/** total count */
|
/** total count */
|
||||||
total: number
|
total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** common params of paginating query list data */
|
/** common params of paginating query list data */
|
||||||
interface PaginatingQueryRecord<T = any> extends PaginatingCommonParams {
|
interface PaginatingQueryRecord<T = any> extends PaginatingCommonParams {
|
||||||
records: T[]
|
list: T[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** common search params of table */
|
/** common search params of table */
|
||||||
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'current' | 'size'>
|
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'currentPage' | 'pageSize'>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* enable status
|
* enable status
|
||||||
|
|||||||
66
apps/admin/src/typings/api/question.d.ts
vendored
Normal file
66
apps/admin/src/typings/api/question.d.ts
vendored
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Namespace Api
|
||||||
|
*
|
||||||
|
* All backend api type
|
||||||
|
*/
|
||||||
|
declare namespace Api {
|
||||||
|
namespace Question {
|
||||||
|
/** 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[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** add params */
|
||||||
|
interface AddParams {
|
||||||
|
/** question id */
|
||||||
|
id: string
|
||||||
|
/** question name */
|
||||||
|
name: string
|
||||||
|
/** question content */
|
||||||
|
questionContent: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
}
|
||||||
|
}
|
||||||
64
apps/admin/src/typings/api/rank.d.ts
vendored
Normal file
64
apps/admin/src/typings/api/rank.d.ts
vendored
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* 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'>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
}
|
||||||
72
apps/admin/src/typings/api/template.d.ts
vendored
Normal file
72
apps/admin/src/typings/api/template.d.ts
vendored
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
}
|
||||||
|
}
|
||||||
18
apps/admin/src/typings/components.d.ts
vendored
18
apps/admin/src/typings/components.d.ts
vendored
@ -99,6 +99,8 @@ declare module 'vue' {
|
|||||||
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
|
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
|
||||||
NGi: typeof import('naive-ui')['NGi']
|
NGi: typeof import('naive-ui')['NGi']
|
||||||
NGrid: typeof import('naive-ui')['NGrid']
|
NGrid: typeof import('naive-ui')['NGrid']
|
||||||
|
NImage: typeof import('naive-ui')['NImage']
|
||||||
|
NImageGroup: typeof import('naive-ui')['NImageGroup']
|
||||||
NInput: typeof import('naive-ui')['NInput']
|
NInput: typeof import('naive-ui')['NInput']
|
||||||
NInputGroup: typeof import('naive-ui')['NInputGroup']
|
NInputGroup: typeof import('naive-ui')['NInputGroup']
|
||||||
NInputNumber: typeof import('naive-ui')['NInputNumber']
|
NInputNumber: typeof import('naive-ui')['NInputNumber']
|
||||||
@ -114,6 +116,7 @@ declare module 'vue' {
|
|||||||
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
|
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
|
||||||
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
|
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
|
||||||
NPopover: typeof import('naive-ui')['NPopover']
|
NPopover: typeof import('naive-ui')['NPopover']
|
||||||
|
NProgress: typeof import('naive-ui')['NProgress']
|
||||||
NRadio: typeof import('naive-ui')['NRadio']
|
NRadio: typeof import('naive-ui')['NRadio']
|
||||||
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
|
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
|
||||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||||
@ -128,11 +131,17 @@ declare module 'vue' {
|
|||||||
NTextarea: typeof import('naive-ui')['NTextarea']
|
NTextarea: typeof import('naive-ui')['NTextarea']
|
||||||
NThing: typeof import('naive-ui')['NThing']
|
NThing: typeof import('naive-ui')['NThing']
|
||||||
NTooltip: typeof import('naive-ui')['NTooltip']
|
NTooltip: typeof import('naive-ui')['NTooltip']
|
||||||
|
NUpload: typeof import('naive-ui')['NUpload']
|
||||||
NWatermark: typeof import('naive-ui')['NWatermark']
|
NWatermark: typeof import('naive-ui')['NWatermark']
|
||||||
PageHeader: typeof import('./../components/common/page-header.vue')['default']
|
PageHeader: typeof import('./../components/common/page-header.vue')['default']
|
||||||
PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
|
PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
|
||||||
ReloadButton: typeof import('./../components/common/reload-button.vue')['default']
|
ReloadButton: typeof import('./../components/common/reload-button.vue')['default']
|
||||||
RestBasicEditor: typeof import('./../components/common/rest-basic-editor/rest-basic-editor.vue')['default']
|
RestBasicEditor: typeof import('./../components/common/rest-basic-editor/rest-basic-editor.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']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
SoybeanAvatar: typeof import('./../components/custom/soybean-avatar.vue')['default']
|
SoybeanAvatar: typeof import('./../components/custom/soybean-avatar.vue')['default']
|
||||||
@ -235,6 +244,8 @@ declare global {
|
|||||||
const NFormItemGi: typeof import('naive-ui')['NFormItemGi']
|
const NFormItemGi: typeof import('naive-ui')['NFormItemGi']
|
||||||
const NGi: typeof import('naive-ui')['NGi']
|
const NGi: typeof import('naive-ui')['NGi']
|
||||||
const NGrid: typeof import('naive-ui')['NGrid']
|
const NGrid: typeof import('naive-ui')['NGrid']
|
||||||
|
const NImage: typeof import('naive-ui')['NImage']
|
||||||
|
const NImageGroup: typeof import('naive-ui')['NImageGroup']
|
||||||
const NInput: typeof import('naive-ui')['NInput']
|
const NInput: typeof import('naive-ui')['NInput']
|
||||||
const NInputGroup: typeof import('naive-ui')['NInputGroup']
|
const NInputGroup: typeof import('naive-ui')['NInputGroup']
|
||||||
const NInputNumber: typeof import('naive-ui')['NInputNumber']
|
const NInputNumber: typeof import('naive-ui')['NInputNumber']
|
||||||
@ -250,6 +261,7 @@ declare global {
|
|||||||
const NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
|
const NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
|
||||||
const NPopconfirm: typeof import('naive-ui')['NPopconfirm']
|
const NPopconfirm: typeof import('naive-ui')['NPopconfirm']
|
||||||
const NPopover: typeof import('naive-ui')['NPopover']
|
const NPopover: typeof import('naive-ui')['NPopover']
|
||||||
|
const NProgress: typeof import('naive-ui')['NProgress']
|
||||||
const NRadio: typeof import('naive-ui')['NRadio']
|
const NRadio: typeof import('naive-ui')['NRadio']
|
||||||
const NRadioGroup: typeof import('naive-ui')['NRadioGroup']
|
const NRadioGroup: typeof import('naive-ui')['NRadioGroup']
|
||||||
const NScrollbar: typeof import('naive-ui')['NScrollbar']
|
const NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||||
@ -264,11 +276,17 @@ declare global {
|
|||||||
const NTextarea: typeof import('naive-ui')['NTextarea']
|
const NTextarea: typeof import('naive-ui')['NTextarea']
|
||||||
const NThing: typeof import('naive-ui')['NThing']
|
const NThing: typeof import('naive-ui')['NThing']
|
||||||
const NTooltip: typeof import('naive-ui')['NTooltip']
|
const NTooltip: typeof import('naive-ui')['NTooltip']
|
||||||
|
const NUpload: typeof import('naive-ui')['NUpload']
|
||||||
const NWatermark: typeof import('naive-ui')['NWatermark']
|
const NWatermark: typeof import('naive-ui')['NWatermark']
|
||||||
const PageHeader: typeof import('./../components/common/page-header.vue')['default']
|
const PageHeader: typeof import('./../components/common/page-header.vue')['default']
|
||||||
const PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
|
const PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
|
||||||
const ReloadButton: typeof import('./../components/common/reload-button.vue')['default']
|
const ReloadButton: typeof import('./../components/common/reload-button.vue')['default']
|
||||||
const RestBasicEditor: typeof import('./../components/common/rest-basic-editor/rest-basic-editor.vue')['default']
|
const RestBasicEditor: typeof import('./../components/common/rest-basic-editor/rest-basic-editor.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 RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
const RouterView: typeof import('vue-router')['RouterView']
|
const RouterView: typeof import('vue-router')['RouterView']
|
||||||
const SoybeanAvatar: typeof import('./../components/custom/soybean-avatar.vue')['default']
|
const SoybeanAvatar: typeof import('./../components/custom/soybean-avatar.vue')['default']
|
||||||
|
|||||||
5
apps/admin/src/typings/elegant-router.d.ts
vendored
5
apps/admin/src/typings/elegant-router.d.ts
vendored
@ -33,6 +33,8 @@ declare module "@elegant-router/types" {
|
|||||||
"rank_rank-list": "/rank/rank-list";
|
"rank_rank-list": "/rank/rank-list";
|
||||||
"results": "/results";
|
"results": "/results";
|
||||||
"template": "/template";
|
"template": "/template";
|
||||||
|
"template_template-detail": "/template/template-detail";
|
||||||
|
"template_template-list": "/template/template-list";
|
||||||
"user": "/user";
|
"user": "/user";
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -106,7 +108,8 @@ declare module "@elegant-router/types" {
|
|||||||
| "rank_rank-detail"
|
| "rank_rank-detail"
|
||||||
| "rank_rank-list"
|
| "rank_rank-list"
|
||||||
| "results"
|
| "results"
|
||||||
| "template"
|
| "template_template-detail"
|
||||||
|
| "template_template-list"
|
||||||
| "user"
|
| "user"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
|||||||
3
apps/admin/src/typings/storage.d.ts
vendored
3
apps/admin/src/typings/storage.d.ts
vendored
@ -39,5 +39,8 @@ declare namespace StorageType {
|
|||||||
}
|
}
|
||||||
/** The last login user id */
|
/** The last login user id */
|
||||||
lastLoginUserId: string
|
lastLoginUserId: string
|
||||||
|
/** The user id */
|
||||||
|
userId: number
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
335
apps/admin/src/utils/data.ts
Normal file
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)
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import path from 'path-browserify'
|
import path from 'path-browserify'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
|
||||||
export function getCurrentYear(): Date {
|
export function getCurrentYear(): Date {
|
||||||
return new Date()
|
return new Date()
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable no-console */
|
||||||
import { snapdom } from '@zumer/snapdom'
|
import { snapdom } from '@zumer/snapdom'
|
||||||
import { getTrueRandomInt } from './date'
|
import { getTrueRandomInt } from './date'
|
||||||
import { getSnowflake } from './rest'
|
import { getSnowflake } from './rest'
|
||||||
@ -101,7 +102,7 @@ export function fileToBase64(file: File): Promise<{ id: string, img: string }> {
|
|||||||
resolve({ id: `id_${date}_${num}`, img: Base64Url })
|
resolve({ id: `id_${date}_${num}`, img: Base64Url })
|
||||||
}
|
}
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
reject('加载图片失败,002')
|
reject(new Error('加载图片失败,002'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@ -109,7 +110,7 @@ export function fileToBase64(file: File): Promise<{ id: string, img: string }> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
reader.onerror = () => {
|
reader.onerror = () => {
|
||||||
reject('加载图片失败,001')
|
reject(new Error('加载图片失败,001'))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -305,12 +306,11 @@ export async function htmlToJpgImgFile(html: string, op: { width: number }): Pro
|
|||||||
width: op.width,
|
width: op.width,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
type,
|
type,
|
||||||
format: type,
|
|
||||||
filename: name,
|
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
})
|
})
|
||||||
|
|
||||||
const blob = await res.toBlob({ type, backgroundColor: '#ffffff' })
|
const blob = await res.toBlob({ type, backgroundColor: '#ffffff' })
|
||||||
|
|
||||||
console.log('截图完成,blob大小:', (blob.size / 1024).toFixed(2), 'KB')
|
console.log('截图完成,blob大小:', (blob.size / 1024).toFixed(2), 'KB')
|
||||||
|
|
||||||
// 将blob转换为base64以便在浏览器查看
|
// 将blob转换为base64以便在浏览器查看
|
||||||
@ -321,6 +321,7 @@ export async function htmlToJpgImgFile(html: string, op: { width: number }): Pro
|
|||||||
}
|
}
|
||||||
reader.readAsDataURL(blob)
|
reader.readAsDataURL(blob)
|
||||||
})
|
})
|
||||||
|
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||||
const base64 = await base64Promise
|
const base64 = await base64Promise
|
||||||
// console.log("blob=====", blob);
|
// console.log("blob=====", blob);
|
||||||
// console.log("图片base64=====", base64);
|
// console.log("图片base64=====", base64);
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export function createServiceConfig(env: Env.ImportMeta) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const httpConfig: App.Service.SimpleServiceConfig = {
|
const httpConfig: App.Service.SimpleServiceConfig = {
|
||||||
baseURL: VITE_SERVICE_BASE_URL,
|
baseURL: VITE_SERVICE_BASE_URL, // 服务基础URL
|
||||||
other,
|
other,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -27,16 +27,15 @@ export function createServiceConfig(env: Env.ImportMeta) {
|
|||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
baseURL: httpConfig.other[key],
|
baseURL: httpConfig.other[key],
|
||||||
proxyPattern: createProxyPattern(key),
|
proxyPattern: createProxyPattern(key), // 其他服务基础URL的代理模式
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const config: App.Service.ServiceConfig = {
|
const config: App.Service.ServiceConfig = {
|
||||||
baseURL: httpConfig.baseURL,
|
baseURL: httpConfig.baseURL, // 服务基础URL
|
||||||
proxyPattern: createProxyPattern(),
|
proxyPattern: createProxyPattern(), // 服务基础URL的代理模式
|
||||||
other: otherConfig,
|
other: otherConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
105
apps/admin/src/utils/upload-util.ts
Normal file
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
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
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
|
||||||
|
}
|
||||||
@ -20,7 +20,7 @@ interface FormModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const model: FormModel = reactive({
|
const model: FormModel = reactive({
|
||||||
userName: 'Admin',
|
userName: '111111',
|
||||||
password: '123456',
|
password: '123456',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -35,7 +35,7 @@ const competitionData = ref({
|
|||||||
rounds: [ // 赛题包配置
|
rounds: [ // 赛题包配置
|
||||||
{
|
{
|
||||||
id: 'round_default',
|
id: 'round_default',
|
||||||
name: '第一轮:主赛环节',
|
name: '星·辞海遨游',
|
||||||
roundType: roundTypeOptions[0].value,
|
roundType: roundTypeOptions[0].value,
|
||||||
questions: Array.from({ length: 10 }).map(() => ({
|
questions: Array.from({ length: 10 }).map(() => ({
|
||||||
value: null,
|
value: null,
|
||||||
@ -203,7 +203,7 @@ function handleReset() {
|
|||||||
type="primary" size="large" class="px-10 text-lg font-bold shadow-blue-500/30 shadow-lg"
|
type="primary" size="large" class="px-10 text-lg font-bold shadow-blue-500/30 shadow-lg"
|
||||||
@click="nextStep"
|
@click="nextStep"
|
||||||
>
|
>
|
||||||
{{ currentStep === 4 ? '确认发布比赛' : '下一步' }}
|
{{ currentStep === 3 ? '确认发布比赛' : '下一步' }}
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<icon-ic-baseline-arrow-forward v-if="currentStep < 4" class="text-icon" />
|
<icon-ic-baseline-arrow-forward v-if="currentStep < 4" class="text-icon" />
|
||||||
<icon-ic-baseline-check v-else class="text-icon" />
|
<icon-ic-baseline-check v-else class="text-icon" />
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
import { NButton, NForm, NFormItem, NInput, NInputNumber, NModal, NSelect, type SelectOption } from 'naive-ui'
|
import { NButton, NForm, NFormItem, NInput, NInputNumber, NModal, NSelect, type SelectOption } from 'naive-ui'
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { VueDraggable } from 'vue-draggable-plus'
|
import { VueDraggable } from 'vue-draggable-plus'
|
||||||
import { roundTypeOptions, scoreTypeOptions } from '@/constants/business'
|
import { roundTypeOptions, scoreTypeOptions, timeOptions, uiTypeOptions } from '@/constants/business'
|
||||||
import { useQuestionConfig } from './useQuestionConfig'
|
import { useQuestionConfig } from './useQuestionConfig'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@ -11,7 +11,15 @@ const props = defineProps<{
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
roundType: string
|
roundType: string
|
||||||
questions: Array<{ id: string, value: string | null, time: number, title: string, scoreType: Api.Competition.QuestionScoreType }>
|
questions: Array<{
|
||||||
|
id: string
|
||||||
|
value: string | null
|
||||||
|
time: number
|
||||||
|
title: string
|
||||||
|
scoreType: Api.Competition.QuestionScoreType
|
||||||
|
uiType?: string
|
||||||
|
templateId?: string
|
||||||
|
}>
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
}>()
|
}>()
|
||||||
@ -20,7 +28,6 @@ const {
|
|||||||
getRoundTime,
|
getRoundTime,
|
||||||
totalStats,
|
totalStats,
|
||||||
questionOptions,
|
questionOptions,
|
||||||
timeOptions,
|
|
||||||
addRound,
|
addRound,
|
||||||
removeRound,
|
removeRound,
|
||||||
addQuestion,
|
addQuestion,
|
||||||
@ -29,6 +36,25 @@ const {
|
|||||||
reset,
|
reset,
|
||||||
} = useQuestionConfig(props)
|
} = useQuestionConfig(props)
|
||||||
|
|
||||||
|
const templateIdOptions: SelectOption[] = [
|
||||||
|
{
|
||||||
|
label: '模板1',
|
||||||
|
value: 't1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '模版2',
|
||||||
|
value: 't2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '模版3',
|
||||||
|
value: 't3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '模版4',
|
||||||
|
value: 't4',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
// 模态框控制
|
// 模态框控制
|
||||||
const showAddModal = ref(false)
|
const showAddModal = ref(false)
|
||||||
const newRoundForm = ref({
|
const newRoundForm = ref({
|
||||||
@ -39,7 +65,7 @@ const newRoundForm = ref({
|
|||||||
|
|
||||||
function handleOpenAddModal() {
|
function handleOpenAddModal() {
|
||||||
newRoundForm.value = {
|
newRoundForm.value = {
|
||||||
name: `第${(props.modelValue?.rounds?.length || 0) + 1}轮 汉字听写`,
|
name: `第${(props.modelValue?.rounds?.length || 0) + 1}轮 星·诗词大会`,
|
||||||
count: 10,
|
count: 10,
|
||||||
roundType: roundTypeOptions[0].value,
|
roundType: roundTypeOptions[0].value,
|
||||||
}
|
}
|
||||||
@ -51,7 +77,7 @@ function handleConfirmAdd() {
|
|||||||
window.$message?.warning('请输入环节名称')
|
window.$message?.warning('请输入环节名称')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
addRound(newRoundForm.value.name, newRoundForm.value.count, newRoundForm.value.roundType)
|
addRound(newRoundForm.value.name, newRoundForm.value.count, newRoundForm.value.roundType as Api.Competition.CompetitionRoundType)
|
||||||
showAddModal.value = false
|
showAddModal.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -145,7 +171,7 @@ defineExpose({ validate, reset })
|
|||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="mb-4 flex items-center justify-between">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div class="h-8 w-1.5 rounded-full bg-blue-500" />
|
<div class="h-8 w-1.5 rounded-full bg-blue-500" />
|
||||||
<NTag type="primary" class="!text-white">
|
<NTag type="primary" class="!text-blue-500">
|
||||||
{{ roundTypeOptions.find((item) => item.value === round.roundType)?.label || '未知类型' }}
|
{{ roundTypeOptions.find((item) => item.value === round.roundType)?.label || '未知类型' }}
|
||||||
</NTag>
|
</NTag>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@ -196,70 +222,97 @@ defineExpose({ validate, reset })
|
|||||||
|
|
||||||
<!-- 题目列表 -->
|
<!-- 题目列表 -->
|
||||||
<VueDraggable
|
<VueDraggable
|
||||||
v-model="round.questions" :animation="300" handle=".drag-handle" class="flex flex-col gap-2"
|
v-model="round.questions" :animation="300" handle=".drag-handle" class="flex flex-col gap-3"
|
||||||
ghost-class="ghost"
|
ghost-class="ghost"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(item, qIdx) in round.questions" :key="item.id"
|
v-for="(item, qIdx) in round.questions" :key="item.id"
|
||||||
class="group/item flex items-center gap-2 border border-gray-100 rounded-xl bg-white p-2 px-2 transition-all hover:border-blue-200 hover:shadow-sm"
|
class="group/item flex flex-col gap-3 border border-gray-100 rounded-xl bg-white p-3 transition-all hover:border-blue-200 hover:shadow-sm"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-1">
|
<!-- 第一行:序号、标题、删除按钮 -->
|
||||||
<div
|
<div class="flex items-center justify-between gap-3">
|
||||||
class="drag-handle flex cursor-grab items-center justify-center rounded p-1 text-gray-400 transition-colors active:cursor-grabbing hover:bg-gray-100 hover:text-blue-500"
|
<div class="flex flex-1 items-center gap-3">
|
||||||
>
|
<div class="flex items-center gap-2">
|
||||||
<icon-mdi-drag class="text-xl" />
|
<div
|
||||||
|
class="drag-handle flex cursor-grab items-center justify-center rounded p-1 text-gray-400 transition-colors active:cursor-grabbing hover:bg-gray-100 hover:text-blue-500"
|
||||||
|
>
|
||||||
|
<icon-mdi-drag class="text-xl" />
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="w-6 flex-shrink-0 text-center text-sm text-gray-300 font-bold transition-colors group-hover/item:text-blue-500"
|
||||||
|
>
|
||||||
|
{{ String(qIdx + 1).padStart(2, '0') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 标题 -->
|
||||||
|
<div class="flex flex-1 items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
|
||||||
|
<icon-ic-outline-title class="text-gray-400" />
|
||||||
|
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">标题:</span>
|
||||||
|
<NInput
|
||||||
|
v-model:value="item.title" class="flex-1 !bg-transparent" size="small" :bordered="false"
|
||||||
|
placeholder="请输入题目标题"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span
|
|
||||||
class="w-6 flex-shrink-0 text-center text-sm text-gray-300 font-bold transition-colors group-hover/item:text-blue-500"
|
<!-- 删除按钮 -->
|
||||||
>{{
|
<NButton
|
||||||
String(qIdx + 1).padStart(2, '0') }}</span>
|
text class="transition-opacity !text-gray-300 hover:!text-red-500"
|
||||||
|
@click="removeQuestion(roundIndex, qIdx)"
|
||||||
|
>
|
||||||
|
<icon-ic-outline-delete class="!text-xl" />
|
||||||
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 题型 -->
|
<!-- 第二行:配置项 -->
|
||||||
<div class="flex-1">
|
<div class="grid grid-cols-12 gap-3">
|
||||||
<NSelect
|
<!-- 题型 -->
|
||||||
v-model:value="item.value" :options="questionOptions" placeholder="请选择题目类型..."
|
<div class="col-span-4">
|
||||||
:bordered="false" class="font-medium"
|
<NSelect
|
||||||
/>
|
v-model:value="item.value" :options="questionOptions" placeholder="请选择题目类型" size="small"
|
||||||
</div>
|
class="font-medium"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 时间 -->
|
<!-- UItype -->
|
||||||
<div class="flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
|
<div class="col-span-4">
|
||||||
<icon-ic-outline-timer class="text-gray-400" />
|
<NSelect
|
||||||
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">答题时间:</span>
|
v-model:value="item.uiType" :options="uiTypeOptions" placeholder="请选择UI类型"
|
||||||
<NSelect
|
size="small" class="font-medium"
|
||||||
v-model:value="item.time" :options="timeOptions" class="w-26 !bg-transparent" size="small"
|
/>
|
||||||
:bordered="false"
|
</div>
|
||||||
/>
|
|
||||||
<span class="text-xs text-gray-400 font-bold">S</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 分数规则 -->
|
<!-- templateId -->
|
||||||
<div class="flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
|
<div class="col-span-4">
|
||||||
<icon-streamline-sharp:type-area-remix class="text-gray-400" />
|
<NSelect
|
||||||
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">分数规则:</span>
|
v-model:value="item.templateId" :options="templateIdOptions" placeholder="请选择模板"
|
||||||
<NSelect
|
size="small" class="font-medium"
|
||||||
v-model:value="item.scoreType" :options="scoreTypeOptions as unknown as SelectOption[]"
|
/>
|
||||||
class="w-28 !bg-transparent" size="small" :bordered="false"
|
</div>
|
||||||
/>
|
|
||||||
<span class="text-xs text-gray-400 font-bold">S</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 标题 -->
|
<!-- 时间 -->
|
||||||
<div class="flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
|
<div class="col-span-6 flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
|
||||||
<icon-ic-outline-title class="text-gray-400" />
|
<icon-ic-outline-timer class="text-gray-400" />
|
||||||
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">标题:</span>
|
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">答题时间:</span>
|
||||||
<NInput v-model:value="item.title" class="w-28 !bg-transparent" size="small" :bordered="false" />
|
<NSelect
|
||||||
</div>
|
v-model:value="item.time" :options="timeOptions" class="flex-1 !bg-transparent" size="small"
|
||||||
|
:bordered="false"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-gray-400 font-bold">S</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 删除按钮 -->
|
<!-- 分数规则 -->
|
||||||
<NButton
|
<div class="col-span-6 flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
|
||||||
text
|
<icon-streamline-sharp:type-area-remix class="text-gray-400" />
|
||||||
class="ml-2 opacity-0 transition-opacity !text-gray-300 group-hover/item:opacity-100 hover:!text-red-500"
|
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">分数规则:</span>
|
||||||
@click="removeQuestion(roundIndex, qIdx)"
|
<NSelect
|
||||||
>
|
v-model:value="item.scoreType" :options="scoreTypeOptions as unknown as SelectOption[]"
|
||||||
<icon-ic-outline-delete class="!text-xl" />
|
class="flex-1 !bg-transparent" size="small" :bordered="false"
|
||||||
</NButton>
|
/>
|
||||||
|
<span class="text-xs text-gray-400 font-bold">分</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</VueDraggable>
|
</VueDraggable>
|
||||||
</div>
|
</div>
|
||||||
@ -285,7 +338,7 @@ defineExpose({ validate, reset })
|
|||||||
|
|
||||||
<NForm size="large">
|
<NForm size="large">
|
||||||
<NFormItem label="环节/题包名称">
|
<NFormItem label="环节/题包名称">
|
||||||
<NInput v-model:value="newRoundForm.name" placeholder="例如:第一轮 汉字听写" />
|
<NInput v-model:value="newRoundForm.name" placeholder="例如:第一轮 星·诗词大会" />
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NFormItem label="环节/题包类型">
|
<NFormItem label="环节/题包类型">
|
||||||
<NSelect
|
<NSelect
|
||||||
|
|||||||
@ -8,6 +8,8 @@ export interface QuestionItem {
|
|||||||
time: number
|
time: number
|
||||||
title: string
|
title: string
|
||||||
scoreType: Api.Competition.QuestionScoreType
|
scoreType: Api.Competition.QuestionScoreType
|
||||||
|
uiType?: string
|
||||||
|
templateId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoundModel {
|
export interface RoundModel {
|
||||||
@ -49,12 +51,12 @@ export function useQuestionConfig(props: any) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const questionOptions = [
|
const questionOptions = [
|
||||||
{ label: '汉字听写 - 根据提示书写汉字', value: 'hanzi' },
|
{ label: '请根据提示书写正确的汉字-jiū 表示小鸟的叫声。', value: 'hanzi' },
|
||||||
{ label: '同音字辨析 - 根据拼音书写', value: 'tongyin' },
|
{ label: '请根据提示书写正确的汉字-“春色满园关不住,一枝红杏出墙来”。请书写“杏”字。', value: 'tongyin' },
|
||||||
{ label: '偏旁部首 - 按要求写字', value: 'pianpang' },
|
{ label: '请写出含有“车”的汉字。', value: 'pianpang' },
|
||||||
{ label: '词语听写 - 综合练习', value: 'ciyu' },
|
{ label: '请根据拼音书写正确的词语。', value: 'ciyu' },
|
||||||
{ label: '成语填空 - 四字成语', value: 'chengyu' },
|
{ label: '请写出含有反义字的四字成语。 - 例如:“不日而月”', value: 'chengyu' },
|
||||||
{ label: '反义词挑战', value: 'fanyi' },
|
{ label: '请根据图片书写正确的成语。', value: 'fanyi' },
|
||||||
{ label: '近义词挑战', value: 'jinyi' },
|
{ label: '近义词挑战', value: 'jinyi' },
|
||||||
{ label: '看图猜字', value: 'kantu' },
|
{ label: '看图猜字', value: 'kantu' },
|
||||||
{ label: '古诗词接龙', value: 'gushi' },
|
{ label: '古诗词接龙', value: 'gushi' },
|
||||||
@ -63,14 +65,6 @@ export function useQuestionConfig(props: any) {
|
|||||||
{ label: '极速成语接龙 - 每题20秒', value: 'speed20' },
|
{ label: '极速成语接龙 - 每题20秒', value: 'speed20' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const timeOptions = [
|
|
||||||
{ label: '15s', value: 15 },
|
|
||||||
{ label: '30s', value: 30 },
|
|
||||||
{ label: '45s', value: 45 },
|
|
||||||
{ label: '60s', value: 60 },
|
|
||||||
{ label: '90s', value: 90 },
|
|
||||||
]
|
|
||||||
|
|
||||||
// 新增轮次
|
// 新增轮次
|
||||||
function addRound(customName?: string, customCount?: number, roundType?: Api.Competition.CompetitionRoundType) {
|
function addRound(customName?: string, customCount?: number, roundType?: Api.Competition.CompetitionRoundType) {
|
||||||
if (!props.modelValue.rounds) {
|
if (!props.modelValue.rounds) {
|
||||||
@ -141,8 +135,35 @@ export function useQuestionConfig(props: any) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for (let j = 0; j < round.questions.length; j++) {
|
for (let j = 0; j < round.questions.length; j++) {
|
||||||
if (!round.questions[j].value) {
|
const question = round.questions[j]
|
||||||
window.$message?.error(`${round.name} 第 ${j + 1} 题未选择题型`)
|
const questionPrefix = `${round.name} 第 ${j + 1} 题`
|
||||||
|
|
||||||
|
if (!question.value) {
|
||||||
|
window.$message?.error(`${questionPrefix}未选择题型`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!question.uiType) {
|
||||||
|
window.$message?.error(`${questionPrefix}未选择UI类型`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!question.templateId) {
|
||||||
|
window.$message?.error(`${questionPrefix}未选择模板`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!question.time) {
|
||||||
|
window.$message?.error(`${questionPrefix}未设置答题时间`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!question.scoreType) {
|
||||||
|
window.$message?.error(`${questionPrefix}未设置分数规则`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!question.title) {
|
||||||
|
window.$message?.error(`${questionPrefix}未填写标题`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (question.score === null || question.score === undefined) {
|
||||||
|
window.$message?.error(`${questionPrefix}未设置分数`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -161,7 +182,6 @@ export function useQuestionConfig(props: any) {
|
|||||||
getRoundTime,
|
getRoundTime,
|
||||||
totalStats,
|
totalStats,
|
||||||
questionOptions,
|
questionOptions,
|
||||||
timeOptions,
|
|
||||||
addRound,
|
addRound,
|
||||||
removeRound,
|
removeRound,
|
||||||
addQuestion,
|
addQuestion,
|
||||||
|
|||||||
@ -90,6 +90,7 @@ const questionForm = ref({
|
|||||||
answer: '',
|
answer: '',
|
||||||
score: 10,
|
score: 10,
|
||||||
time: 30,
|
time: 30,
|
||||||
|
imageUrl: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const isLeafSelected = computed(() => {
|
const isLeafSelected = computed(() => {
|
||||||
@ -119,7 +120,7 @@ function handleAddQuestion() {
|
|||||||
}
|
}
|
||||||
questionOperation.value = 'add'
|
questionOperation.value = 'add'
|
||||||
currentQuestionId.value = null
|
currentQuestionId.value = null
|
||||||
questionForm.value = { content: '', answer: '', score: 10, time: 30 }
|
questionForm.value = { content: '', answer: '', score: 10, time: 30, imageUrl: '' }
|
||||||
showQuestionModal.value = true
|
showQuestionModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -133,6 +134,7 @@ function handleEditQuestion(id: string) {
|
|||||||
answer: question.answer,
|
answer: question.answer,
|
||||||
score: question.score,
|
score: question.score,
|
||||||
time: question.time,
|
time: question.time,
|
||||||
|
imageUrl: (question as any).imageUrl || '',
|
||||||
}
|
}
|
||||||
showQuestionModal.value = true
|
showQuestionModal.value = true
|
||||||
}
|
}
|
||||||
@ -159,6 +161,8 @@ function submitQuestion() {
|
|||||||
window.$message?.error('请填写完整信息')
|
window.$message?.error('请填写完整信息')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('questionForm.value', questionForm.value)
|
||||||
|
|
||||||
if (questionOperation.value === 'add') {
|
if (questionOperation.value === 'add') {
|
||||||
// Mock add
|
// Mock add
|
||||||
@ -306,6 +310,11 @@ function submitQuestion() {
|
|||||||
</NFormItem>
|
</NFormItem>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 上传图片 -->
|
||||||
|
<NFormItem label="上传图片">
|
||||||
|
<RestUpload v-model="questionForm.imageUrl" width="100px" height="100px" hint="请上传题目图片" />
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
<NFormItem label="参考标准答案">
|
<NFormItem label="参考标准答案">
|
||||||
<NInput v-model:value="questionForm.answer" placeholder="正确答案" />
|
<NInput v-model:value="questionForm.answer" placeholder="正确答案" />
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
|
|||||||
@ -3,22 +3,18 @@ import { NButton, NCard, NTag } from 'naive-ui'
|
|||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { defaultTransform, useNaivePaginatedTable, useTableOperate } from '@/hooks/common/table'
|
import { defaultTransform, useNaivePaginatedTable, useTableOperate } from '@/hooks/common/table'
|
||||||
import { fetchGetUserList } from '@/service/api'
|
import { fetchRankList } from '@/service/api'
|
||||||
import { useAppStore } from '@/store/modules/app'
|
import { useAppStore } from '@/store/modules/app'
|
||||||
import RankSearch from './modules/rank-search.vue'
|
import RankSearch from './modules/rank-search.vue'
|
||||||
|
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const searchParams: Api.SystemManage.UserSearchParams = reactive({
|
const searchParams: Api.Rank.UserSearchParams = reactive({
|
||||||
current: 1,
|
currentPage: 1,
|
||||||
size: 10,
|
pageSize: 10,
|
||||||
status: null,
|
publishStatus: undefined,
|
||||||
userName: null,
|
competitionActivityName: '',
|
||||||
userGender: null,
|
|
||||||
nickName: null,
|
|
||||||
userPhone: null,
|
|
||||||
userEmail: null,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const rankTitle = '排行榜管理列表'
|
const rankTitle = '排行榜管理列表'
|
||||||
@ -28,8 +24,8 @@ const statusMap: Record<string, string> = {
|
|||||||
2: '待审核',
|
2: '待审核',
|
||||||
}
|
}
|
||||||
|
|
||||||
const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagination } = useNaivePaginatedTable({
|
const { columns, data, getData, getDataByPage, loading, mobilePagination } = useNaivePaginatedTable({
|
||||||
api: () => fetchGetUserList(searchParams),
|
api: () => fetchRankList(searchParams as any),
|
||||||
transform: (response) => {
|
transform: (response) => {
|
||||||
const transformed = defaultTransform(response)
|
const transformed = defaultTransform(response)
|
||||||
// Mocking data for Rank Management based on User API response
|
// Mocking data for Rank Management based on User API response
|
||||||
@ -39,12 +35,12 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
|
|||||||
const mockScores = ['145', '180', '120']
|
const mockScores = ['145', '180', '120']
|
||||||
const mockTimes = ['2026-01-22 16:30:12', '2026-01-22 12:00:00', '2026-01-21 18:22:45']
|
const mockTimes = ['2026-01-22 16:30:12', '2026-01-22 12:00:00', '2026-01-21 18:22:45']
|
||||||
|
|
||||||
transformed.data.forEach((item, index) => {
|
transformed.data.forEach((item: any, index) => {
|
||||||
const mockIndex = index % 3
|
const mockIndex = index % 3
|
||||||
item.userName = mockCompetitions[mockIndex] // Competition Name
|
item.competitionActivityName = mockCompetitions[mockIndex] // Competition Name
|
||||||
item.userEmail = mockDates[mockIndex] // Activity Date (using userEmail field)
|
item.competitionActivityDate = mockDates[mockIndex] // Activity Date (using userEmail field)
|
||||||
item.userPhone = mockScales[mockIndex] // Team Scale (using userPhone field)
|
item.competitionTeamScale = mockScales[mockIndex] // Team Scale (using userPhone field)
|
||||||
item.nickName = mockScores[mockIndex] // Max Score (using nickName field)
|
item.competitionMaxScore = mockScores[mockIndex] // Max Score (using nickName field)
|
||||||
// item.createTime is typically available, we'll simulate update time
|
// item.createTime is typically available, we'll simulate update time
|
||||||
item.createTime = mockTimes[mockIndex]
|
item.createTime = mockTimes[mockIndex]
|
||||||
// Status: 1 (Published), 2 (Pending)
|
// Status: 1 (Published), 2 (Pending)
|
||||||
@ -53,42 +49,45 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
|
|||||||
return transformed
|
return transformed
|
||||||
},
|
},
|
||||||
onPaginationParamsChange: (params) => {
|
onPaginationParamsChange: (params) => {
|
||||||
searchParams.current = params.page
|
searchParams.currentPage = params.page || 1
|
||||||
searchParams.size = params.pageSize
|
searchParams.pageSize = params.pageSize || 10
|
||||||
},
|
},
|
||||||
columns: () => [
|
columns: () => [
|
||||||
{
|
{
|
||||||
type: 'selection',
|
type: 'selection',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 48,
|
width: 40,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'userName',
|
title: '序号',
|
||||||
|
align: 'center',
|
||||||
|
key: 'index',
|
||||||
|
width: 80,
|
||||||
|
render: (row, index) => index + 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'competitionActivityName',
|
||||||
title: '比赛活动名称',
|
title: '比赛活动名称',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
minWidth: 200,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'userEmail',
|
key: 'competitionActivityDate',
|
||||||
title: '活动日期',
|
title: '活动日期',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
minWidth: 120,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'userPhone',
|
key: 'competitionTeamScale',
|
||||||
title: '队伍规模',
|
title: '队伍规模',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
minWidth: 100,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'nickName',
|
key: 'competitionMaxScore',
|
||||||
title: '最高积分',
|
title: '最高积分',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
minWidth: 100,
|
minWidth: 100,
|
||||||
render: row => (
|
render: row => (
|
||||||
<span class="text-primary font-bold">
|
<span class="text-primary font-bold">
|
||||||
{row.nickName}
|
{row.competitionMaxScore}
|
||||||
{' '}
|
|
||||||
Pts
|
Pts
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
@ -97,13 +96,11 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
|
|||||||
key: 'createTime',
|
key: 'createTime',
|
||||||
title: '最后更新时间',
|
title: '最后更新时间',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
minWidth: 160,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'status',
|
key: 'status',
|
||||||
title: '发布状态',
|
title: '发布状态',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 100,
|
|
||||||
render: (row) => {
|
render: (row) => {
|
||||||
if (row.status === null)
|
if (row.status === null)
|
||||||
return null
|
return null
|
||||||
@ -116,7 +113,6 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
|
|||||||
key: 'operate',
|
key: 'operate',
|
||||||
title: '操作',
|
title: '操作',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 120,
|
|
||||||
render: row => (
|
render: row => (
|
||||||
<div class="flex-center">
|
<div class="flex-center">
|
||||||
<NButton type="primary" text onClick={() => edit(row.id)}>
|
<NButton type="primary" text onClick={() => edit(row.id)}>
|
||||||
@ -129,12 +125,7 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
|
|||||||
})
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
drawerVisible,
|
|
||||||
operateType,
|
|
||||||
editingData,
|
|
||||||
handleEdit,
|
|
||||||
checkedRowKeys,
|
checkedRowKeys,
|
||||||
onBatchDeleted,
|
|
||||||
} = useTableOperate(data, 'id', getData)
|
} = useTableOperate(data, 'id', getData)
|
||||||
|
|
||||||
function edit(id: number) {
|
function edit(id: number) {
|
||||||
@ -149,7 +140,7 @@ function handleBatchPublish() {
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
|
<div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
|
||||||
<RankSearch v-model:model="searchParams" @search="getDataByPage" />
|
<RankSearch v-model:model="searchParams" @search="getDataByPage" />
|
||||||
<NCard :bordered="false" size="small" class="card-wrapper sm:flex-1-hidden">
|
<NCard :bordered="false" page-size="small" class="card-wrapper sm:flex-1-hidden">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@ -173,7 +164,7 @@ function handleBatchPublish() {
|
|||||||
:loading="loading" @add="handleAdd" @delete="handleBatchDelete" @refresh="getData" />
|
:loading="loading" @add="handleAdd" @delete="handleBatchDelete" @refresh="getData" />
|
||||||
</template> -->
|
</template> -->
|
||||||
<NDataTable
|
<NDataTable
|
||||||
v-model:checked-row-keys="checkedRowKeys" :columns="columns" :data="data" size="small" striped
|
v-model:checked-row-keys="checkedRowKeys" :columns="columns" :data="data" page-size="small" striped
|
||||||
:flex-height="!appStore.isMobile" :scroll-x="962" :loading="loading" remote :row-key="row => row.id"
|
:flex-height="!appStore.isMobile" :scroll-x="962" :loading="loading" remote :row-key="row => row.id"
|
||||||
:pagination="mobilePagination" class="sm:h-full"
|
:pagination="mobilePagination" class="sm:h-full"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { jsonClone } from '@sa/utils'
|
import { jsonClone } from '@sa/utils'
|
||||||
import { computed, toRaw } from 'vue'
|
import { toRaw } from 'vue'
|
||||||
import { useFormRules, useNaiveForm } from '@/hooks/common/form'
|
import { useNaiveForm } from '@/hooks/common/form'
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'RankSearch',
|
name: 'RankSearch',
|
||||||
@ -15,18 +15,7 @@ interface Emits {
|
|||||||
|
|
||||||
const { formRef, validate, restoreValidation } = useNaiveForm()
|
const { formRef, validate, restoreValidation } = useNaiveForm()
|
||||||
|
|
||||||
const model = defineModel<Api.SystemManage.UserSearchParams>('model', { required: true })
|
const model = defineModel<Api.Rank.UserSearchParams>('model', { required: true })
|
||||||
|
|
||||||
type RuleKey = Extract<keyof Api.SystemManage.UserSearchParams, 'userEmail' | 'userPhone'>
|
|
||||||
|
|
||||||
const rules = computed<Record<RuleKey, App.Global.FormRule>>(() => {
|
|
||||||
const { patternRules } = useFormRules()
|
|
||||||
|
|
||||||
return {
|
|
||||||
userEmail: patternRules.email,
|
|
||||||
userPhone: patternRules.phone,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const defaultModel = jsonClone(toRaw(model.value))
|
const defaultModel = jsonClone(toRaw(model.value))
|
||||||
|
|
||||||
@ -54,14 +43,14 @@ const publishStatusOptions = [
|
|||||||
<NCard :bordered="false" size="small" class="card-wrapper">
|
<NCard :bordered="false" size="small" class="card-wrapper">
|
||||||
<NCollapse>
|
<NCollapse>
|
||||||
<NCollapseItem title="筛选查询" name="rank-search">
|
<NCollapseItem title="筛选查询" name="rank-search">
|
||||||
<NForm ref="formRef" :model="model" :rules="rules" label-placement="left" :label-width="100">
|
<NForm ref="formRef" :model="model" label-placement="left" :label-width="100">
|
||||||
<NGrid responsive="screen" item-responsive>
|
<NGrid responsive="screen" item-responsive>
|
||||||
<NFormItemGi span="24 s:12 m:8" label="比赛活动名称" path="userName" class="pr-24px">
|
<NFormItemGi span="24 s:12 m:8" label="比赛活动名称" path="competitionActivityName" class="pr-24px">
|
||||||
<NInput v-model:value="model.userName" placeholder="请输入比赛活动名称" />
|
<NInput v-model:value="model.competitionActivityName" placeholder="请输入比赛活动名称" />
|
||||||
</NFormItemGi>
|
</NFormItemGi>
|
||||||
<NFormItemGi span="24 s:12 m:8" label="发布状态" path="status" class="pr-24px">
|
<NFormItemGi span="24 s:12 m:8" label="发布状态" path="publishStatus" class="pr-24px">
|
||||||
<NSelect
|
<NSelect
|
||||||
v-model:value="model.status"
|
v-model:value="model.publishStatus"
|
||||||
placeholder="请选择"
|
placeholder="请选择"
|
||||||
:options="publishStatusOptions"
|
:options="publishStatusOptions"
|
||||||
clearable
|
clearable
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { jsonClone } from '@sa/utils'
|
import { jsonClone } from '@sa/utils'
|
||||||
import { computed, toRaw } from 'vue'
|
import { toRaw } from 'vue'
|
||||||
import { enableStatusOptions } from '@/constants/business'
|
import { enableStatusOptions } from '@/constants/business'
|
||||||
import { useFormRules, useNaiveForm } from '@/hooks/common/form'
|
import { useNaiveForm } from '@/hooks/common/form'
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'TemplateSearch',
|
name: 'TemplateSearch',
|
||||||
@ -16,18 +16,7 @@ interface Emits {
|
|||||||
|
|
||||||
const { formRef, validate, restoreValidation } = useNaiveForm()
|
const { formRef, validate, restoreValidation } = useNaiveForm()
|
||||||
|
|
||||||
const model = defineModel<Api.SystemManage.UserSearchParams>('model', { required: true })
|
const model = defineModel<Api.Template.TemplateSearchParams>('model', { required: true })
|
||||||
|
|
||||||
type RuleKey = Extract<keyof Api.SystemManage.UserSearchParams, 'userEmail' | 'userPhone'>
|
|
||||||
|
|
||||||
const rules = computed<Record<RuleKey, App.Global.FormRule>>(() => {
|
|
||||||
const { patternRules } = useFormRules()
|
|
||||||
|
|
||||||
return {
|
|
||||||
userEmail: patternRules.email,
|
|
||||||
userPhone: patternRules.phone,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const defaultModel = jsonClone(toRaw(model.value))
|
const defaultModel = jsonClone(toRaw(model.value))
|
||||||
|
|
||||||
@ -60,22 +49,22 @@ const sizeOptions = [
|
|||||||
<NCard :bordered="false" size="small" class="card-wrapper">
|
<NCard :bordered="false" size="small" class="card-wrapper">
|
||||||
<NCollapse>
|
<NCollapse>
|
||||||
<NCollapseItem title="基础信息" name="template-search">
|
<NCollapseItem title="基础信息" name="template-search">
|
||||||
<NForm ref="formRef" :model="model" :rules="rules" label-placement="left" :label-width="80">
|
<NForm ref="formRef" :model="model" label-placement="left" :label-width="80">
|
||||||
<NGrid responsive="screen" item-responsive>
|
<NGrid responsive="screen" item-responsive>
|
||||||
<NFormItemGi span="24 s:12 m:6" label="模板名称" path="userName" class="pr-24px">
|
<NFormItemGi span="24 s:12 m:6" label="模板名称" path="templateName" class="pr-24px">
|
||||||
<NInput v-model:value="model.userName" placeholder="请输入名称" />
|
<NInput v-model:value="model.templateName" placeholder="请输入名称" />
|
||||||
</NFormItemGi>
|
</NFormItemGi>
|
||||||
<NFormItemGi span="24 s:12 m:6" label="关联比赛" path="userGender" class="pr-24px">
|
<NFormItemGi span="24 s:12 m:6" label="关联比赛" path="competitionId" class="pr-24px">
|
||||||
<NSelect
|
<NSelect
|
||||||
v-model:value="model.userGender" placeholder="请选择" :options="competitionOptions as any"
|
v-model:value="model.competitionId" placeholder="请选择" :options="competitionOptions as any"
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
</NFormItemGi>
|
</NFormItemGi>
|
||||||
<NFormItemGi span="24 s:12 m:6" label="状态" path="userStatus" class="pr-24px">
|
<NFormItemGi span="24 s:12 m:6" label="状态" path="status" class="pr-24px">
|
||||||
<NSelect v-model:value="model.status" placeholder="请选择" :options="enableStatusOptions as any" clearable />
|
<NSelect v-model:value="model.status" placeholder="请选择" :options="enableStatusOptions as any" clearable />
|
||||||
</NFormItemGi>
|
</NFormItemGi>
|
||||||
<NFormItemGi span="24 s:12 m:6" label="尺寸" path="userPhone" class="pr-24px">
|
<NFormItemGi span="24 s:12 m:6" label="尺寸" path="size" class="pr-24px">
|
||||||
<NSelect v-model:value="model.userPhone" placeholder="请选择" :options="sizeOptions" clearable />
|
<NSelect v-model:value="model.size" placeholder="请选择" :options="sizeOptions" clearable />
|
||||||
</NFormItemGi>
|
</NFormItemGi>
|
||||||
<NFormItemGi span="24" class="pr-24px">
|
<NFormItemGi span="24" class="pr-24px">
|
||||||
<NSpace class="w-full" justify="end">
|
<NSpace class="w-full" justify="end">
|
||||||
|
|||||||
8
apps/admin/src/views/template/template-detail/index.vue
Normal file
8
apps/admin/src/views/template/template-detail/index.vue
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="">
|
||||||
|
<h1>模板详情</h1>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -1,23 +1,24 @@
|
|||||||
<script setup lang="tsx">
|
<script setup lang="tsx">
|
||||||
import { NButton, NPopconfirm, NTag } from 'naive-ui'
|
import { NButton, NPopconfirm, NTag } from 'naive-ui'
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
|
import { useRouterPush } from '@/hooks/common/router'
|
||||||
import { defaultTransform, useNaivePaginatedTable, useTableOperate } from '@/hooks/common/table'
|
import { defaultTransform, useNaivePaginatedTable, useTableOperate } from '@/hooks/common/table'
|
||||||
import { fetchGetUserList } from '@/service/api'
|
import { fetchTemplateList } from '@/service/api'
|
||||||
import { useAppStore } from '@/store/modules/app'
|
import { useAppStore } from '@/store/modules/app'
|
||||||
import TemplateOperateDrawer from './modules/template-operate-drawer.vue'
|
import TemplateOperateDrawer from './modules/template-operate-drawer.vue'
|
||||||
import TemplateSearch from './modules/template-search.vue'
|
import TemplateSearch from './modules/template-search.vue'
|
||||||
|
|
||||||
|
const { routerPushByKey } = useRouterPush()
|
||||||
|
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
|
||||||
const searchParams: Api.SystemManage.UserSearchParams = reactive({
|
const searchParams: Api.Template.TemplateSearchParams = reactive({
|
||||||
current: 1,
|
currentPage: 1,
|
||||||
size: 10,
|
pageSize: 10,
|
||||||
status: null,
|
status: null,
|
||||||
userName: null,
|
templateName: undefined,
|
||||||
userGender: null,
|
competitionId: undefined,
|
||||||
nickName: null,
|
size: undefined,
|
||||||
userPhone: null,
|
|
||||||
userEmail: null,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const templateTitle = '模板管理'
|
const templateTitle = '模板管理'
|
||||||
@ -33,19 +34,22 @@ const statusMap: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagination } = useNaivePaginatedTable({
|
const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagination } = useNaivePaginatedTable({
|
||||||
api: () => fetchGetUserList(searchParams),
|
api: () => fetchTemplateList(searchParams),
|
||||||
transform: (response) => {
|
transform: (response) => {
|
||||||
const transformed = defaultTransform(response)
|
const transformed = defaultTransform(response)
|
||||||
transformed.data.forEach((item, index) => {
|
transformed.data.forEach((item: any, index) => {
|
||||||
item.nickName = `MD${String(index + 1).padStart(6, '0')}`
|
item.code = `MD${String(index + 1).padStart(6, '0')}`
|
||||||
item.userPhone = '210mmx297mm'
|
item.size = '210mmx297mm'
|
||||||
item.userGender = (item.userGender === '1' || item.userGender === '2') ? item.userGender : '1'
|
item.competitionId = (item.competitionId === '1' || item.competitionId === '2') ? item.competitionId : '1'
|
||||||
|
// Mock templateName if not present
|
||||||
|
if (!item.templateName)
|
||||||
|
item.templateName = item.userName || '测试模板'
|
||||||
})
|
})
|
||||||
return transformed
|
return transformed
|
||||||
},
|
},
|
||||||
onPaginationParamsChange: (params) => {
|
onPaginationParamsChange: (params) => {
|
||||||
searchParams.current = params.page
|
searchParams.currentPage = params.page || 1
|
||||||
searchParams.size = params.pageSize
|
searchParams.pageSize = params.pageSize || 10
|
||||||
},
|
},
|
||||||
columns: () => [
|
columns: () => [
|
||||||
{
|
{
|
||||||
@ -61,26 +65,26 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
|
|||||||
render: (_, index) => index + 1,
|
render: (_, index) => index + 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'userName',
|
key: 'templateName',
|
||||||
title: '名称',
|
title: '名称',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'nickName',
|
key: 'code',
|
||||||
title: '模板ID',
|
title: '模板ID',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'userGender',
|
key: 'competitionId',
|
||||||
title: '关联比赛',
|
title: '关联比赛',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: (row) => {
|
render: (row: any) => {
|
||||||
const label = competitionMap[row.userGender as string] || '未知比赛'
|
const label = competitionMap[row.competitionId as string] || '未知比赛'
|
||||||
return <span>{label}</span>
|
return <span>{label}</span>
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'userPhone',
|
key: 'size',
|
||||||
title: '设计尺寸',
|
title: '设计尺寸',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
},
|
},
|
||||||
@ -88,8 +92,7 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
|
|||||||
key: 'status',
|
key: 'status',
|
||||||
title: '状态',
|
title: '状态',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 100,
|
render: (row: Api.Template.CommonRecord) => {
|
||||||
render: (row) => {
|
|
||||||
if (row.status === null) {
|
if (row.status === null) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@ -101,7 +104,7 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
|
|||||||
key: 'operate',
|
key: 'operate',
|
||||||
title: '操作',
|
title: '操作',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
// width: 430,
|
width: 430,
|
||||||
render: row => (
|
render: row => (
|
||||||
<div class="flex-center gap-8px">
|
<div class="flex-center gap-8px">
|
||||||
<NButton type="primary" ghost size="small" onClick={() => edit(row.id)}>
|
<NButton type="primary" ghost size="small" onClick={() => edit(row.id)}>
|
||||||
@ -113,7 +116,7 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
|
|||||||
<NButton size="small" onClick={() => { }}>
|
<NButton size="small" onClick={() => { }}>
|
||||||
预览
|
预览
|
||||||
</NButton>
|
</NButton>
|
||||||
<NButton size="small" onClick={() => { }}>
|
<NButton size="small" onClick={() => handlePageInfo(row.id)}>
|
||||||
页面信息
|
页面信息
|
||||||
</NButton>
|
</NButton>
|
||||||
<NButton size="small" onClick={() => { }}>
|
<NButton size="small" onClick={() => { }}>
|
||||||
@ -159,12 +162,16 @@ function handleDelete() {
|
|||||||
function edit(id: number) {
|
function edit(id: number) {
|
||||||
handleEdit(id)
|
handleEdit(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handlePageInfo(id: number) {
|
||||||
|
routerPushByKey('template_template-detail', { query: { id: id.toString() } })
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
|
<div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
|
||||||
<TemplateSearch v-model:model="searchParams" @search="getDataByPage" />
|
<TemplateSearch v-model:model="searchParams" @search="getDataByPage" />
|
||||||
<NCard :title="templateTitle" :bordered="false" size="small" class="card-wrapper sm:flex-1-hidden">
|
<NCard :title="templateTitle" :bordered="false" page-size="small" class="card-wrapper sm:flex-1-hidden">
|
||||||
<template #header-extra>
|
<template #header-extra>
|
||||||
<TableHeaderOperation
|
<TableHeaderOperation
|
||||||
v-model:columns="columnChecks" :disabled-delete="checkedRowKeys.length === 0"
|
v-model:columns="columnChecks" :disabled-delete="checkedRowKeys.length === 0"
|
||||||
@ -172,8 +179,8 @@ function edit(id: number) {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<NDataTable
|
<NDataTable
|
||||||
v-model:checked-row-keys="checkedRowKeys" :columns="columns" :data="data" size="small" striped
|
v-model:checked-row-keys="checkedRowKeys" :columns="columns as any" :data="data" page-size="small"
|
||||||
:flex-height="!appStore.isMobile" :scroll-x="962" :loading="loading" remote :row-key="row => row.id"
|
striped :flex-height="!appStore.isMobile" :scroll-x="962" :loading="loading" remote :row-key="row => row.id"
|
||||||
:pagination="mobilePagination" class="sm:h-full"
|
:pagination="mobilePagination" class="sm:h-full"
|
||||||
/>
|
/>
|
||||||
<TemplateOperateDrawer
|
<TemplateOperateDrawer
|
||||||
@ -0,0 +1,147 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { jsonClone } from '@sa/utils'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { enableStatusOptions } from '@/constants/business'
|
||||||
|
import { useFormRules, useNaiveForm } from '@/hooks/common/form'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'TemplateOperateDrawer',
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
operateType: NaiveUI.TableOperateType
|
||||||
|
rowData?: Api.SystemManage.User | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'submitted'): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>('visible', {
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { formRef, validate, restoreValidation } = useNaiveForm()
|
||||||
|
const { defaultRequiredRule } = useFormRules()
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
const titles: Record<NaiveUI.TableOperateType, string> = {
|
||||||
|
add: '新增模板',
|
||||||
|
edit: '编辑模板',
|
||||||
|
}
|
||||||
|
return titles[props.operateType]
|
||||||
|
})
|
||||||
|
|
||||||
|
type Model = Pick<
|
||||||
|
Api.SystemManage.User,
|
||||||
|
'userName' | 'userGender' | 'nickName' | 'userPhone' | 'userEmail' | 'userRoles' | 'status'
|
||||||
|
>
|
||||||
|
|
||||||
|
const model = ref(createDefaultModel())
|
||||||
|
|
||||||
|
function createDefaultModel(): Model {
|
||||||
|
return {
|
||||||
|
userName: '',
|
||||||
|
userGender: null,
|
||||||
|
nickName: '',
|
||||||
|
userPhone: '',
|
||||||
|
userEmail: '',
|
||||||
|
userRoles: [],
|
||||||
|
status: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type RuleKey = Extract<keyof Model, 'userName' | 'status'>
|
||||||
|
|
||||||
|
const rules: Record<RuleKey, App.Global.FormRule> = {
|
||||||
|
userName: defaultRequiredRule,
|
||||||
|
status: defaultRequiredRule,
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInitModel() {
|
||||||
|
model.value = createDefaultModel()
|
||||||
|
|
||||||
|
if (props.operateType === 'edit' && props.rowData) {
|
||||||
|
Object.assign(model.value, jsonClone(props.rowData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDrawer() {
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
await validate()
|
||||||
|
// request
|
||||||
|
window.$message?.success('更新成功')
|
||||||
|
closeDrawer()
|
||||||
|
emit('submitted')
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(visible, () => {
|
||||||
|
if (visible.value) {
|
||||||
|
handleInitModel()
|
||||||
|
restoreValidation()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const competitionOptions = [
|
||||||
|
{ label: '第九届阅读之星大赛', value: '1' },
|
||||||
|
{ label: '科普阅读大赛', value: '2' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const sizeOptions = [
|
||||||
|
{ label: '210mmx297mm', value: '1' },
|
||||||
|
{ label: 'A3', value: '2' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NDrawer v-model:show="visible" display-directive="show" :width="360">
|
||||||
|
<NDrawerContent :title="title" :native-scrollbar="false" closable>
|
||||||
|
<NForm ref="formRef" :model="model" :rules="rules">
|
||||||
|
<NFormItem label="名称" path="userName">
|
||||||
|
<NInput v-model:value="model.userName" placeholder="请输入名称" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="模板ID" path="nickName">
|
||||||
|
<NInput v-model:value="model.nickName" placeholder="请输入模板ID" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="关联比赛" path="userGender">
|
||||||
|
<NSelect
|
||||||
|
v-model:value="model.userGender"
|
||||||
|
:options="competitionOptions"
|
||||||
|
placeholder="请选择关联比赛"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="设计尺寸" path="userPhone">
|
||||||
|
<NSelect
|
||||||
|
v-model:value="model.userPhone"
|
||||||
|
:options="sizeOptions"
|
||||||
|
placeholder="请选择设计尺寸"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="状态" path="status">
|
||||||
|
<NRadioGroup v-model:value="model.status">
|
||||||
|
<NRadio v-for="item in enableStatusOptions" :key="item.value" :value="item.value" :label="item.label" />
|
||||||
|
</NRadioGroup>
|
||||||
|
</NFormItem>
|
||||||
|
</NForm>
|
||||||
|
<template #footer>
|
||||||
|
<NSpace :size="16">
|
||||||
|
<NButton @click="closeDrawer">
|
||||||
|
取消
|
||||||
|
</NButton>
|
||||||
|
<NButton type="primary" @click="handleSubmit">
|
||||||
|
确认
|
||||||
|
</NButton>
|
||||||
|
</NSpace>
|
||||||
|
</template>
|
||||||
|
</NDrawerContent>
|
||||||
|
</NDrawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { jsonClone } from '@sa/utils'
|
||||||
|
import { toRaw } from 'vue'
|
||||||
|
import { enableStatusOptions } from '@/constants/business'
|
||||||
|
import { useNaiveForm } from '@/hooks/common/form'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'TemplateSearch',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'search'): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const { formRef, validate, restoreValidation } = useNaiveForm()
|
||||||
|
|
||||||
|
const model = defineModel<Api.Template.TemplateSearchParams>('model', { required: true })
|
||||||
|
|
||||||
|
const defaultModel = jsonClone(toRaw(model.value))
|
||||||
|
|
||||||
|
function resetModel() {
|
||||||
|
Object.assign(model.value, defaultModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reset() {
|
||||||
|
await restoreValidation()
|
||||||
|
resetModel()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search() {
|
||||||
|
await validate()
|
||||||
|
emit('search')
|
||||||
|
}
|
||||||
|
|
||||||
|
const competitionOptions = [
|
||||||
|
{ label: '第九届阅读之星大赛', value: '1' },
|
||||||
|
{ label: '科普阅读大赛', value: '2' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const sizeOptions = [
|
||||||
|
{ label: '210mmx297mm', value: '1' },
|
||||||
|
{ label: 'A3', value: '2' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NCard :bordered="false" size="small" class="card-wrapper">
|
||||||
|
<NCollapse>
|
||||||
|
<NCollapseItem title="基础信息" name="template-search">
|
||||||
|
<NForm ref="formRef" :model="model" label-placement="left" :label-width="80">
|
||||||
|
<NGrid responsive="screen" item-responsive>
|
||||||
|
<NFormItemGi span="24 s:12 m:6" label="模板名称" path="templateName" class="pr-24px">
|
||||||
|
<NInput v-model:value="model.templateName" placeholder="请输入名称" />
|
||||||
|
</NFormItemGi>
|
||||||
|
<NFormItemGi span="24 s:12 m:6" label="关联比赛" path="competitionId" class="pr-24px">
|
||||||
|
<NSelect
|
||||||
|
v-model:value="model.competitionId" placeholder="请选择" :options="competitionOptions as any"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</NFormItemGi>
|
||||||
|
<NFormItemGi span="24 s:12 m:6" label="状态" path="status" class="pr-24px">
|
||||||
|
<NSelect v-model:value="model.status" placeholder="请选择" :options="enableStatusOptions as any" clearable />
|
||||||
|
</NFormItemGi>
|
||||||
|
<NFormItemGi span="24 s:12 m:6" label="尺寸" path="size" class="pr-24px">
|
||||||
|
<NSelect v-model:value="model.size" placeholder="请选择" :options="sizeOptions" clearable />
|
||||||
|
</NFormItemGi>
|
||||||
|
<NFormItemGi span="24" class="pr-24px">
|
||||||
|
<NSpace class="w-full" justify="end">
|
||||||
|
<NButton @click="reset">
|
||||||
|
<template #icon>
|
||||||
|
<icon-ic-round-refresh class="text-icon" />
|
||||||
|
</template>
|
||||||
|
重置
|
||||||
|
</NButton>
|
||||||
|
<NButton type="primary" ghost @click="search">
|
||||||
|
<template #icon>
|
||||||
|
<icon-ic-round-search class="text-icon" />
|
||||||
|
</template>
|
||||||
|
搜索
|
||||||
|
</NButton>
|
||||||
|
</NSpace>
|
||||||
|
</NFormItemGi>
|
||||||
|
</NGrid>
|
||||||
|
</NForm>
|
||||||
|
</NCollapseItem>
|
||||||
|
</NCollapse>
|
||||||
|
</NCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
641
pnpm-lock.yaml
generated
641
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user