feat(admin): 新增模板管理功能并完善用户认证

- 新增模板管理模块,包含模板列表和详情页面
- 重构用户认证逻辑,支持用户ID存储和API调用
- 新增排行榜API接口和类型定义
- 更新环境配置,添加开发环境配置
- 优化表格操作钩子,增强类型安全性
- 新增文件上传工具类和验证工具函数
- 更新路由配置,支持模板详情页面导航
- 修复登录表单默认值问题
This commit is contained in:
2026-01-27 09:48:42 +08:00
parent 264f600250
commit 5a48a65ef8
56 changed files with 2907 additions and 434 deletions

View File

@ -1,9 +1,17 @@
# 应用的基础URL默认为 "/"
# 如果使用子目录,必须以 "/" 结尾,例如 "/admin/" 而不是 "/admin"
VITE_BASE_URL=/
# VITE_BASE_URL='/admin'
# VITE_BASE_URL="https://api.qyzhjy.com"
VITE_APP_TITLE="阅读比赛配置后台"
# 阿里云OSS地址前缀(用于拼接完整地址)
VITE_BASE_OSS_URL = https://oss.qyzhjy.com/
# 上传图片的URL
VITE_BASE_UPLOAD_URL="http://192.168.20.150:8080"
VITE_APP_DESC="阅读比赛配置"
# 图标名称的前缀
@ -23,16 +31,16 @@ VITE_ROUTE_HOME=home
VITE_MENU_ICON=mdi:menu
# 在开发模式下是否启用http代理
VITE_HTTP_PROXY=Y
VITE_HTTP_PROXY=Y # Y 开启后,所有请求都将通过代理服务器,N 关闭后,所有请求都将直接发送到后端服务
# vue-router 模式: hash | history | memory
VITE_ROUTER_HISTORY_MODE=history
# 后端服务成功代码,收到此代码表示请求成功
VITE_SERVICE_SUCCESS_CODE=0000
VITE_SERVICE_SUCCESS_CODE=200
# 后端服务登出代码,收到此代码将登出并重定向到登录页
VITE_SERVICE_LOGOUT_CODES=8888,8889
VITE_SERVICE_LOGOUT_CODES=401,403
# 后端服务模态框登出代码,收到此代码将通过显示模态框登出
VITE_SERVICE_MODAL_LOGOUT_CODES=7777,7778

View File

@ -1,5 +1,10 @@
# 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
VITE_OTHER_SERVICE_BASE_URL= `{

View File

@ -18,7 +18,6 @@ export function createViteProxy(env: Env.ImportMeta, enable: boolean) {
const isEnableProxyLog = env.VITE_PROXY_LOG === 'Y'
const { baseURL, proxyPattern, other } = createServiceConfig(env)
console.log('baseURL:', baseURL, 'proxyPattern:', proxyPattern, 'other:', other)
const proxy: Record<string, ProxyOptions> = createProxyItem({ baseURL, proxyPattern }, isEnableProxyLog)

View File

@ -9,11 +9,11 @@
},
"scripts": {
"build": "vite build --mode prod",
"build:test": "vite build --mode test",
"build:dev": "vite build --mode dev",
"cleanup": "sa cleanup",
"commit": "sa git-commit",
"commit:zh": "sa git-commit -l=zh-cn",
"dev": "vite --mode test",
"dev": "vite --mode dev",
"dev:prod": "vite --mode prod",
"gen-route": "sa gen-route",
"lint": "eslint . --fix",
@ -25,6 +25,7 @@
},
"dependencies": {
"@better-scroll/core": "2.5.1",
"@formkit/auto-animate": "^0.9.0",
"@iconify/vue": "5.0.0",
"@opentiny/fluent-editor": "^4.0.1",
"@sa/axios": "workspace:*",
@ -32,7 +33,10 @@
"@sa/hooks": "workspace:*",
"@sa/materials": "workspace:*",
"@sa/utils": "workspace:*",
"@sapphire/snowflake": "^3.5.5",
"@vueuse/core": "14.1.0",
"@zumer/snapdom": "^2.0.1",
"ali-oss": "^6.23.0",
"clipboard": "2.0.11",
"dayjs": "1.11.19",
"defu": "6.1.4",
@ -42,6 +46,7 @@
"mathlive": "^0.108.2",
"naive-ui": "2.43.2",
"nprogress": "0.2.0",
"path-browserify": "^1.0.1",
"pinia": "3.0.4",
"quill-toolbar-tip": "^0.1.0",
"tailwind-merge": "3.4.0",
@ -56,8 +61,10 @@
"@iconify/json": "2.2.417",
"@sa/scripts": "workspace:*",
"@sa/uno-preset": "workspace:*",
"@types/ali-oss": "^6.16.13",
"@types/node": "25.0.3",
"@types/nprogress": "0.2.3",
"@types/path-browserify": "^1.0.3",
"@unocss/eslint-config": "66.5.10",
"@unocss/preset-icons": "66.5.10",
"@unocss/preset-uno": "66.5.10",

View File

@ -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 { 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 QuillToolbarTip, { type QuillToolbarTipOptions } from 'quill-toolbar-tip'
import { ref } from 'vue'
@ -20,7 +20,7 @@ export const upFileList = ref<UpFile[]>([])
*/
export function initEditor(container: HTMLElement, params: IEditorConfig): FluentEditor {
// 需要依赖 katex
window.katex = katex
window.katex = katex as any
// 处理图片给每个图片添加类名和最大宽度
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'] }
new Parchment.StyleAttributor('align', 'text-align', config)
const Align = FluentEditor.import('attributors/style/align')
const Align = new Parchment.StyleAttributor('align', 'text-align', config)
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}`)))
return paths
}
catch (error) {
catch (error: any) {
window.$message?.error(`上传失败: ${error.message}`)
// eslint-disable-next-line no-console
console.log('error====', error)
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 }
try {
upFileList.value.push(item)
const tokenRes = await getAliOssTokenAxios()
const { data: tokenData } = await getAliOssTokenAxios()
if (!tokenData) {
return false
}
// 初始化OSS客户端
const client = initOSSClient(tokenRes)
const client = initOSSClient(tokenData)
const path = browserPathJoin(`temp/${Date.now()}`, file!.name)
// 上传文件
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)
// oxlint-disable-next-line no-unused-vars
}
catch (error) {
catch (error: any) {
upFileList.value.splice(upFileList.value.indexOf(item), 1)
window.$message?.error('上传失败')
window.$message?.error(`上传失败: ${error.message}`)
return Promise.resolve(false)
}
}

View 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>

View File

@ -1,3 +1,4 @@
import type { SelectOption } from 'naive-ui'
import { transformRecordToOption } from '@/utils/common'
export const enableStatusRecord: Record<Api.Common.EnableStatus, string> = {
@ -5,21 +6,21 @@ export const enableStatusRecord: Record<Api.Common.EnableStatus, string> = {
2: '禁用',
}
export const enableStatusOptions = transformRecordToOption(enableStatusRecord)
export const enableStatusOptions: SelectOption[] = transformRecordToOption(enableStatusRecord) as unknown as SelectOption[]
export const userGenderRecord: Record<Api.SystemManage.UserGender, string> = {
1: '男',
2: '女',
}
export const userGenderOptions = transformRecordToOption(userGenderRecord)
export const userGenderOptions: SelectOption[] = transformRecordToOption(userGenderRecord) as unknown as SelectOption[]
export const menuTypeRecord: Record<Api.SystemManage.MenuType, string> = {
1: '目录',
2: '菜单',
}
export const menuTypeOptions = transformRecordToOption(menuTypeRecord)
export const menuTypeOptions: SelectOption[] = transformRecordToOption(menuTypeRecord) as unknown as SelectOption[]
export const menuIconTypeRecord: Record<Api.SystemManage.IconType, string> = {
1: 'iconify',
@ -42,8 +43,32 @@ export const roundTypeRecord: Record<Api.Competition.CompetitionRoundType, strin
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[]

View File

@ -176,7 +176,18 @@ export function useTableOperate<TableData>(
data: Ref<TableData[]>,
idKey: keyof TableData,
getData: () => Promise<void>,
) {
): {
drawerVisible: Ref<boolean>
openDrawer: () => void
closeDrawer: () => void
operateType: Ref<NaiveUI.TableOperateType>
handleAdd: () => void
editingData: Ref<TableData | null>
handleEdit: (id: TableData[keyof TableData]) => void
checkedRowKeys: Ref<string[]>
onBatchDeleted: () => Promise<void>
onDeleted: () => Promise<void>
} {
const { bool: drawerVisible, setTrue: openDrawer, setFalse: closeDrawer } = useBoolean()
const operateType = shallowRef<NaiveUI.TableOperateType>('add')
@ -230,17 +241,23 @@ export function useTableOperate<TableData>(
}
}
/**
* 默认的表格数据转换函数
*
* @param response 原始的 API 响应数据
* @returns 转换后的分页数据
*/
export function defaultTransform<ApiData>(
response: FlatResponseData<any, Api.Common.PaginatingQueryRecord<ApiData>>,
): PaginationData<ApiData> {
const { data, error } = response
if (!error) {
const { records, current, size, total } = data
const { list, currentPage: pageNum, pageSize: size, total } = data
return {
data: records,
pageNum: current,
data: list,
pageNum,
pageSize: size,
total,
}

View File

@ -235,6 +235,9 @@ const local: App.I18n.Schema = {
'rank': 'Rank',
'rank_rank-detail': 'Rank Detail',
'rank_rank-list': 'Rank List',
'template_template-detail': 'Template Detail',
'template_template-list': 'Template List',
'user': 'User',
'results': 'Results',
'competition_competition-add': 'Competition Add',
'competition_competition-detail': 'Competition Detail',

View File

@ -231,6 +231,9 @@ const local: App.I18n.Schema = {
'rank': '名次排行',
'rank_rank-detail': '名次详情',
'rank_rank-list': '名次列表',
'template_template-detail': '模板详情',
'template_template-list': '模板列表',
'user': '用户管理',
'results': '实时结果',
'competition_competition-add': '比赛添加',
'competition_competition-detail': '比赛详情',

View File

@ -89,6 +89,10 @@ export function setupAppVersionNotification() {
}
}
/**
* 获取HTML构建时间
* @returns 构建时间字符串或null
*/
async function getHtmlBuildTime(): Promise<string | null> {
const baseUrl = import.meta.env.VITE_BASE_URL || '/'

View File

@ -28,6 +28,7 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
"rank_rank-detail": () => import("@/views/rank/rank-detail/index.vue"),
"rank_rank-list": () => import("@/views/rank/rank-list/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"),
};

View File

@ -181,13 +181,38 @@ export const generatedRoutes: GeneratedRoute[] = [
{
name: 'template',
path: '/template',
component: 'layout.base$view.template',
component: 'layout.base',
meta: {
title: 'template',
i18nKey: 'route.template',
icon: 'material-symbols:settings-motion-mode-outline-rounded',
order: 4
}
},
children: [
{
name: 'template_template-detail',
path: '/template/template-detail',
component: 'view.template_template-detail',
meta: {
title: 'template_template-detail',
i18nKey: 'route.template_template-detail',
icon: 'material-symbols:settings-motion-mode-outline-rounded',
multiTab: true,
hideInMenu: true
}
},
{
name: 'template_template-list',
path: '/template/template-list',
component: 'view.template_template-list',
meta: {
title: 'template_template-list',
i18nKey: 'route.template_template-list',
icon: 'material-symbols:settings-motion-mode-outline-rounded',
order: 1
}
}
]
},
{
name: 'user',

View File

@ -179,6 +179,8 @@ const routeMap: RouteMap = {
"rank_rank-list": "/rank/rank-list",
"results": "/results",
"template": "/template",
"template_template-detail": "/template/template-detail",
"template_template-list": "/template/template-list",
"user": "/user"
};

View File

@ -18,7 +18,7 @@ const historyCreatorMap: Record<Env.RouterHistoryMode, (base?: string) => Router
}
export const router = createRouter({
history: historyCreatorMap[VITE_ROUTER_HISTORY_MODE](VITE_BASE_URL),
history: historyCreatorMap[VITE_ROUTER_HISTORY_MODE](VITE_BASE_URL), // 路由模式
routes: createBuiltinVueRoutes(),
})

View File

@ -6,20 +6,20 @@ import { request } from '../request'
* @param userName User name
* @param password Password
*/
export function fetchLogin(userName: string, password: string) {
export function fetchLogin(account: string, pwd: string) {
return request<Api.Auth.LoginToken>({
url: '/auth/login',
url: '/admin/v1/user/login',
method: 'post',
data: {
userName,
password,
account,
pwd,
},
})
}
/** Get user info */
export function fetchGetUserInfo() {
return request<Api.Auth.UserInfo>({ url: '/auth/getUserInfo' })
export function fetchGetUserInfo(userId: number) {
return request<Api.Auth.UserInfo>({ url: `admin/v1/manager/detail/${userId}` })
}
/**

View File

@ -1,3 +1,4 @@
export * from './auth'
export * from './rank'
export * from './route'
export * from './system-manage'
export * from './template'

View 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 || {},
})
}

View 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 || {},
})
}

View File

@ -24,8 +24,8 @@ export function fetchGetAllRoles() {
/** get user list */
export function fetchGetUserList(params?: Api.SystemManage.UserSearchParams) {
return request<Api.SystemManage.UserList>({
url: '/systemManage/getUserList',
method: 'get',
url: '/admin/v1/class/pagelist',
method: 'post',
params,
})
}

View 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 || {},
})
}

View File

@ -10,36 +10,45 @@ import { getAuthorization, handleExpiredRequest, showErrorMsg } from './shared'
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y'
const { baseURL, otherBaseURL } = getServiceBaseURL(import.meta.env, isHttpProxy)
/**
* 基础请求
*/
export const request = createFlatRequest(
{
baseURL,
headers: {
apifoxToken: 'XL299LiMEDZ0H5h3A29PxwQXdMJqWyY2',
'apifoxToken': 'XL299LiMEDZ0H5h3A29PxwQXdMJqWyY2', // 用于 apifox 调试
'Custom-Platform': 'pc',
},
},
{
defaultState: {
errMsgStack: [],
refreshTokenPromise: null,
errMsgStack: [], // 错误信息栈
refreshTokenPromise: null, // 刷新token Promise
} as RequestInstanceState,
// 响应数据转换
transform(response: AxiosResponse<App.Service.Response<any>>) {
return response.data.data
},
// 请求配置转换
async onRequest(config) {
const Authorization = getAuthorization()
Object.assign(config.headers, { Authorization })
return config
},
// 响应状态判断
isBackendSuccess(response) {
// when the backend response code is "0000"(default), it means the request is success
// to change this logic by yourself, you can modify the `VITE_SERVICE_SUCCESS_CODE` in `.env` file
// 当后端响应状态码为 "200"(默认值)时,表示请求成功
// 如果你想自行更改此逻辑,可以在 .env 文件中修改 VITE_SERVICE_SUCCESS_CODE
return String(response.data.code) === import.meta.env.VITE_SERVICE_SUCCESS_CODE
},
// 响应错误处理
async onBackendFail(response, instance) {
const authStore = useAuthStore()
const responseCode = String(response.data.code)
//
function handleLogout() {
authStore.resetStore()
}
@ -51,19 +60,19 @@ export const request = createFlatRequest(
request.state.errMsgStack = request.state.errMsgStack.filter(msg => msg !== response.data.msg)
}
// when the backend response code is in `logoutCodes`, it means the user will be logged out and redirected to login page
// 当后端响应状态码在 logoutCodes 中时,表示用户将被登出并重定向到登录页面
const logoutCodes = import.meta.env.VITE_SERVICE_LOGOUT_CODES?.split(',') || []
if (logoutCodes.includes(responseCode)) {
handleLogout()
return null
}
// when the backend response code is in `modalLogoutCodes`, it means the user will be logged out by displaying a modal
// 当后端响应状态码在 modalLogoutCodes 中时,表示将通过显示模态框来使用户登出
const modalLogoutCodes = import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES?.split(',') || []
if (modalLogoutCodes.includes(responseCode) && !request.state.errMsgStack?.includes(response.data.msg)) {
request.state.errMsgStack = [...(request.state.errMsgStack || []), response.data.msg]
// prevent the user from refreshing the page
// 防止用户刷新页面
window.addEventListener('beforeunload', handleLogout)
window.$dialog?.error({
@ -83,8 +92,8 @@ export const request = createFlatRequest(
return null
}
// when the backend response code is in `expiredTokenCodes`, it means the token is expired, and refresh token
// the api `refreshToken` can not return error code in `expiredTokenCodes`, otherwise it will be a dead loop, should return `logoutCodes` or `modalLogoutCodes`
// 当后端响应状态码在 expiredTokenCodes 中时,表示 token 已过期,需要刷新 token
// refreshToken 接口不能返回 expiredTokenCodes 中的错误码,否则会形成死循环,应该返回 logoutCodesmodalLogoutCodes
const expiredTokenCodes = import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES?.split(',') || []
if (expiredTokenCodes.includes(responseCode)) {
const success = await handleExpiredRequest(request.state)
@ -99,24 +108,24 @@ export const request = createFlatRequest(
return null
},
onError(error) {
// when the request is fail, you can show error message
// 当请求失败时,可以显示错误信息
let message = error.message
let backendErrorCode = ''
// get backend error message and code
// 获取后端错误信息和状态码
if (error.code === BACKEND_ERROR_CODE) {
message = error.response?.data?.msg || message
backendErrorCode = String(error.response?.data?.code || '')
}
// the error message is displayed in the modal
// 错误信息已在模态框中显示
const modalLogoutCodes = import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES?.split(',') || []
if (modalLogoutCodes.includes(backendErrorCode)) {
return
}
// when the token is expired, refresh token and retry request, so no need to show error message
// 当 token 过期时,刷新 token 并重试请求,因此不需要显示错误信息
const expiredTokenCodes = import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES?.split(',') || []
if (expiredTokenCodes.includes(backendErrorCode)) {
return
@ -127,39 +136,48 @@ export const request = createFlatRequest(
},
)
/**
* 演示请求
*/
export const demoRequest = createRequest(
{
baseURL: otherBaseURL.demo,
},
/**
* 演示请求响应数据转换
*/
{
// 响应数据转换
transform(response: AxiosResponse<App.Service.DemoResponse>) {
return response.data.result
},
// 请求配置转换
async onRequest(config) {
const { headers } = config
// set token
// 设置 token
const token = localStg.get('token')
const Authorization = token ? `Bearer ${token}` : null
Object.assign(headers, { Authorization })
return config
},
// 响应状态判断
isBackendSuccess(response) {
// when the backend response code is "200", it means the request is success
// you can change this logic by yourself
// 当后端响应状态码为 "200" 时,表示请求成功
// 你可以自行更改此逻辑
return response.data.status === '200'
},
async onBackendFail(_response) {
// when the backend response code is not "200", it means the request is fail
// for example: the token is expired, refresh token and retry request
// 当后端响应状态码不是 "200" 时,表示请求失败
// 例如token 过期,刷新 token 并重试请求
},
onError(error) {
// when the request is fail, you can show error message
// 当请求失败时,可以显示错误信息
let message = error.message
// show backend error message
// 显示后端错误信息
if (error.code === BACKEND_ERROR_CODE) {
message = error.response?.data?.message || message
}

View File

@ -10,7 +10,9 @@ export function getAuthorization() {
return Authorization
}
/** refresh token */
/**
* 刷新token
*/
async function handleRefreshToken() {
const { resetStore } = useAuthStore()
@ -27,6 +29,9 @@ async function handleRefreshToken() {
return false
}
/**
* 处理过期token的请求
*/
export async function handleExpiredRequest(state: RequestInstanceState) {
if (!state.refreshTokenPromise) {
state.refreshTokenPromise = handleRefreshToken()
@ -41,6 +46,9 @@ export async function handleExpiredRequest(state: RequestInstanceState) {
return success
}
/**
* 显示错误信息
*/
export function showErrorMsg(state: RequestInstanceState, message: string) {
if (!state.errMsgStack?.length) {
state.errMsgStack = []

View File

@ -1,7 +1,7 @@
export interface RequestInstanceState {
/** the promise of refreshing token */
/** 刷新token Promise */
refreshTokenPromise: Promise<boolean> | null
/** the request error message stack */
/** 请求错误信息栈 */
errMsgStack: string[]
[key: string]: unknown
}

View File

@ -9,7 +9,7 @@ import { fetchGetUserInfo, fetchLogin } from '@/service/api'
import { localStg } from '@/utils/storage'
import { useRouteStore } from '../route'
import { useTabStore } from '../tab'
import { clearAuthStorage, getToken } from './shared'
import { clearAuthStorage, getToken, getUserId } from './shared'
export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
const route = useRoute()
@ -98,7 +98,6 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
*/
async function login(userName: string, password: string, redirect = true) {
startLoading()
const { data: loginToken, error } = await fetchLogin(userName, password)
if (!error) {
@ -133,9 +132,10 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
// 1. stored in the localStorage, the later requests need it in headers
localStg.set('token', loginToken.token)
localStg.set('refreshToken', loginToken.refreshToken)
localStg.set('userId', loginToken.id)
// 2. get user info
const pass = await getUserInfo()
const pass = await getUserInfo(loginToken.id)
if (pass) {
token.value = loginToken.token
@ -146,8 +146,8 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
return false
}
async function getUserInfo() {
const { data: info, error } = await fetchGetUserInfo()
async function getUserInfo(userId: number) {
const { data: info, error } = await fetchGetUserInfo(userId)
if (!error) {
// update store
@ -161,9 +161,10 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
async function initUserInfo() {
const hasToken = getToken()
const userId = getUserId()
if (hasToken) {
const pass = await getUserInfo()
if (hasToken && userId) {
const pass = await getUserInfo(userId)
if (!pass) {
resetStore()

View File

@ -5,8 +5,14 @@ export function getToken() {
return localStg.get('token') || ''
}
export function getUserId() {
const userId = localStg.get('userId')
return userId || 0
}
/** Clear auth storage */
export function clearAuthStorage() {
localStg.remove('token')
localStg.remove('refreshToken')
localStg.remove('userId')
}

View File

@ -31,6 +31,21 @@ declare namespace Api {
*/
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 */
type QuestionScoreType = '1' | '2' /** 固定分数 | 答题个数 */

View File

@ -8,6 +8,7 @@ declare namespace Api {
interface LoginToken {
token: string
refreshToken: string
id: number
}
interface UserInfo {

View File

@ -7,21 +7,21 @@ declare namespace Api {
namespace Common {
/** common params of paginating */
interface PaginatingCommonParams {
/** current page number */
current: number
/** page size */
size: number
/** currentPage page number */
currentPage: number
/** page pageSize */
pageSize: number
/** total count */
total: number
}
/** common params of paginating query list data */
interface PaginatingQueryRecord<T = any> extends PaginatingCommonParams {
records: T[]
list: T[]
}
/** common search params of table */
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'current' | 'size'>
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'currentPage' | 'pageSize'>
/**
* enable status

View 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
View 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
}
}

View 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
}
}

View File

@ -99,6 +99,8 @@ declare module 'vue' {
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
NGi: typeof import('naive-ui')['NGi']
NGrid: typeof import('naive-ui')['NGrid']
NImage: typeof import('naive-ui')['NImage']
NImageGroup: typeof import('naive-ui')['NImageGroup']
NInput: typeof import('naive-ui')['NInput']
NInputGroup: typeof import('naive-ui')['NInputGroup']
NInputNumber: typeof import('naive-ui')['NInputNumber']
@ -114,6 +116,7 @@ declare module 'vue' {
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
NPopover: typeof import('naive-ui')['NPopover']
NProgress: typeof import('naive-ui')['NProgress']
NRadio: typeof import('naive-ui')['NRadio']
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
NScrollbar: typeof import('naive-ui')['NScrollbar']
@ -128,11 +131,17 @@ declare module 'vue' {
NTextarea: typeof import('naive-ui')['NTextarea']
NThing: typeof import('naive-ui')['NThing']
NTooltip: typeof import('naive-ui')['NTooltip']
NUpload: typeof import('naive-ui')['NUpload']
NWatermark: typeof import('naive-ui')['NWatermark']
PageHeader: typeof import('./../components/common/page-header.vue')['default']
PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
ReloadButton: typeof import('./../components/common/reload-button.vue')['default']
RestBasicEditor: typeof import('./../components/common/rest-basic-editor/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']
RouterView: typeof import('vue-router')['RouterView']
SoybeanAvatar: typeof import('./../components/custom/soybean-avatar.vue')['default']
@ -235,6 +244,8 @@ declare global {
const NFormItemGi: typeof import('naive-ui')['NFormItemGi']
const NGi: typeof import('naive-ui')['NGi']
const NGrid: typeof import('naive-ui')['NGrid']
const NImage: typeof import('naive-ui')['NImage']
const NImageGroup: typeof import('naive-ui')['NImageGroup']
const NInput: typeof import('naive-ui')['NInput']
const NInputGroup: typeof import('naive-ui')['NInputGroup']
const NInputNumber: typeof import('naive-ui')['NInputNumber']
@ -250,6 +261,7 @@ declare global {
const NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
const NPopconfirm: typeof import('naive-ui')['NPopconfirm']
const NPopover: typeof import('naive-ui')['NPopover']
const NProgress: typeof import('naive-ui')['NProgress']
const NRadio: typeof import('naive-ui')['NRadio']
const NRadioGroup: typeof import('naive-ui')['NRadioGroup']
const NScrollbar: typeof import('naive-ui')['NScrollbar']
@ -264,11 +276,17 @@ declare global {
const NTextarea: typeof import('naive-ui')['NTextarea']
const NThing: typeof import('naive-ui')['NThing']
const NTooltip: typeof import('naive-ui')['NTooltip']
const NUpload: typeof import('naive-ui')['NUpload']
const NWatermark: typeof import('naive-ui')['NWatermark']
const PageHeader: typeof import('./../components/common/page-header.vue')['default']
const PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
const ReloadButton: typeof import('./../components/common/reload-button.vue')['default']
const RestBasicEditor: typeof import('./../components/common/rest-basic-editor/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 RouterView: typeof import('vue-router')['RouterView']
const SoybeanAvatar: typeof import('./../components/custom/soybean-avatar.vue')['default']

View File

@ -33,6 +33,8 @@ declare module "@elegant-router/types" {
"rank_rank-list": "/rank/rank-list";
"results": "/results";
"template": "/template";
"template_template-detail": "/template/template-detail";
"template_template-list": "/template/template-list";
"user": "/user";
};
@ -106,7 +108,8 @@ declare module "@elegant-router/types" {
| "rank_rank-detail"
| "rank_rank-list"
| "results"
| "template"
| "template_template-detail"
| "template_template-list"
| "user"
>;

View File

@ -39,5 +39,8 @@ declare namespace StorageType {
}
/** The last login user id */
lastLoginUserId: string
/** The user id */
userId: number
}
}

View 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)
}

View File

@ -1,4 +1,5 @@
import path from 'path-browserify'
import { nextTick } from 'vue'
export function getCurrentYear(): Date {
return new Date()

View File

@ -1,3 +1,4 @@
/* eslint-disable no-console */
import { snapdom } from '@zumer/snapdom'
import { getTrueRandomInt } from './date'
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 })
}
img.onerror = () => {
reject('加载图片失败,002')
reject(new Error('加载图片失败,002'))
}
}
else {
@ -109,7 +110,7 @@ export function fileToBase64(file: File): Promise<{ id: string, img: string }> {
}
}
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,
scale: 1,
type,
format: type,
filename: name,
backgroundColor: '#ffffff',
})
const blob = await res.toBlob({ type, backgroundColor: '#ffffff' })
console.log('截图完成blob大小:', (blob.size / 1024).toFixed(2), 'KB')
// 将blob转换为base64以便在浏览器查看
@ -321,6 +321,7 @@ export async function htmlToJpgImgFile(html: string, op: { width: number }): Pro
}
reader.readAsDataURL(blob)
})
// eslint-disable-next-line unused-imports/no-unused-vars
const base64 = await base64Promise
// console.log("blob=====", blob);
// console.log("图片base64=====", base64);

View File

@ -17,7 +17,7 @@ export function createServiceConfig(env: Env.ImportMeta) {
}
const httpConfig: App.Service.SimpleServiceConfig = {
baseURL: VITE_SERVICE_BASE_URL,
baseURL: VITE_SERVICE_BASE_URL, // 服务基础URL
other,
}
@ -27,16 +27,15 @@ export function createServiceConfig(env: Env.ImportMeta) {
return {
key,
baseURL: httpConfig.other[key],
proxyPattern: createProxyPattern(key),
proxyPattern: createProxyPattern(key), // 其他服务基础URL的代理模式
}
})
const config: App.Service.ServiceConfig = {
baseURL: httpConfig.baseURL,
proxyPattern: createProxyPattern(),
baseURL: httpConfig.baseURL, // 服务基础URL
proxyPattern: createProxyPattern(), // 服务基础URL的代理模式
other: otherConfig,
}
return config
}

View 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 = []
}
}

View 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 : ''
}
}

View 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
}

View File

@ -20,7 +20,7 @@ interface FormModel {
}
const model: FormModel = reactive({
userName: 'Admin',
userName: '111111',
password: '123456',
})

View File

@ -35,7 +35,7 @@ const competitionData = ref({
rounds: [ // 赛题包配置
{
id: 'round_default',
name: '第一轮:主赛环节',
name: '星·辞海遨游',
roundType: roundTypeOptions[0].value,
questions: Array.from({ length: 10 }).map(() => ({
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"
@click="nextStep"
>
{{ currentStep === 4 ? '确认发布比赛' : '下一步' }}
{{ currentStep === 3 ? '确认发布比赛' : '下一步' }}
<template #icon>
<icon-ic-baseline-arrow-forward v-if="currentStep < 4" class="text-icon" />
<icon-ic-baseline-check v-else class="text-icon" />

View File

@ -2,7 +2,7 @@
import { NButton, NForm, NFormItem, NInput, NInputNumber, NModal, NSelect, type SelectOption } from 'naive-ui'
import { ref, watch } from 'vue'
import { VueDraggable } from 'vue-draggable-plus'
import { roundTypeOptions, scoreTypeOptions } from '@/constants/business'
import { roundTypeOptions, scoreTypeOptions, timeOptions, uiTypeOptions } from '@/constants/business'
import { useQuestionConfig } from './useQuestionConfig'
const props = defineProps<{
@ -11,7 +11,15 @@ const props = defineProps<{
id: string
name: 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,
totalStats,
questionOptions,
timeOptions,
addRound,
removeRound,
addQuestion,
@ -29,6 +36,25 @@ const {
reset,
} = 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 newRoundForm = ref({
@ -39,7 +65,7 @@ const newRoundForm = ref({
function handleOpenAddModal() {
newRoundForm.value = {
name: `${(props.modelValue?.rounds?.length || 0) + 1}汉字听写`,
name: `${(props.modelValue?.rounds?.length || 0) + 1}星·诗词大会`,
count: 10,
roundType: roundTypeOptions[0].value,
}
@ -51,7 +77,7 @@ function handleConfirmAdd() {
window.$message?.warning('请输入环节名称')
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
}
@ -145,7 +171,7 @@ defineExpose({ validate, reset })
<div class="mb-4 flex items-center justify-between">
<div class="flex items-center gap-4">
<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 || '未知类型' }}
</NTag>
<div class="flex items-center gap-3">
@ -196,70 +222,97 @@ defineExpose({ validate, reset })
<!-- 题目列表 -->
<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"
>
<div
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
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 class="flex items-center justify-between gap-3">
<div class="flex flex-1 items-center gap-3">
<div class="flex items-center gap-2">
<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>
<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>
<!-- 删除按钮 -->
<NButton
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 class="flex-1">
<NSelect
v-model:value="item.value" :options="questionOptions" placeholder="请选择题目类型..."
:bordered="false" class="font-medium"
/>
</div>
<!-- 第二行配置项 -->
<div class="grid grid-cols-12 gap-3">
<!-- 题型 -->
<div class="col-span-4">
<NSelect
v-model:value="item.value" :options="questionOptions" placeholder="请选择题目类型" size="small"
class="font-medium"
/>
</div>
<!-- 时间 -->
<div class="flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
<icon-ic-outline-timer class="text-gray-400" />
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">答题时间</span>
<NSelect
v-model:value="item.time" :options="timeOptions" class="w-26 !bg-transparent" size="small"
:bordered="false"
/>
<span class="text-xs text-gray-400 font-bold">S</span>
</div>
<!-- UItype -->
<div class="col-span-4">
<NSelect
v-model:value="item.uiType" :options="uiTypeOptions" placeholder="请选择UI类型"
size="small" class="font-medium"
/>
</div>
<!-- 分数规则 -->
<div class="flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
<icon-streamline-sharp:type-area-remix class="text-gray-400" />
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">分数规则</span>
<NSelect
v-model:value="item.scoreType" :options="scoreTypeOptions as unknown as SelectOption[]"
class="w-28 !bg-transparent" size="small" :bordered="false"
/>
<span class="text-xs text-gray-400 font-bold">S</span>
</div>
<!-- templateId -->
<div class="col-span-4">
<NSelect
v-model:value="item.templateId" :options="templateIdOptions" placeholder="请选择模板"
size="small" class="font-medium"
/>
</div>
<!-- 标题 -->
<div class="flex 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="w-28 !bg-transparent" size="small" :bordered="false" />
</div>
<!-- 时间 -->
<div class="col-span-6 flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
<icon-ic-outline-timer class="text-gray-400" />
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">答题时间</span>
<NSelect
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
text
class="ml-2 opacity-0 transition-opacity !text-gray-300 group-hover/item:opacity-100 hover:!text-red-500"
@click="removeQuestion(roundIndex, qIdx)"
>
<icon-ic-outline-delete class="!text-xl" />
</NButton>
<!-- 分数规则 -->
<div class="col-span-6 flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
<icon-streamline-sharp:type-area-remix class="text-gray-400" />
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">分数规则</span>
<NSelect
v-model:value="item.scoreType" :options="scoreTypeOptions as unknown as SelectOption[]"
class="flex-1 !bg-transparent" size="small" :bordered="false"
/>
<span class="text-xs text-gray-400 font-bold"></span>
</div>
</div>
</div>
</VueDraggable>
</div>
@ -285,7 +338,7 @@ defineExpose({ validate, reset })
<NForm size="large">
<NFormItem label="环节/题包名称">
<NInput v-model:value="newRoundForm.name" placeholder="例如:第一轮 汉字听写" />
<NInput v-model:value="newRoundForm.name" placeholder="例如:第一轮 星·诗词大会" />
</NFormItem>
<NFormItem label="环节/题包类型">
<NSelect

View File

@ -8,6 +8,8 @@ export interface QuestionItem {
time: number
title: string
scoreType: Api.Competition.QuestionScoreType
uiType?: string
templateId?: string
}
export interface RoundModel {
@ -49,12 +51,12 @@ export function useQuestionConfig(props: any) {
})
const questionOptions = [
{ label: '汉字听写 - 根据提示书写汉字', value: 'hanzi' },
{ label: '同音字辨析 - 根据拼音书写', value: 'tongyin' },
{ label: '偏旁部首 - 按要求写字', value: 'pianpang' },
{ label: '词语听写 - 综合练习', value: 'ciyu' },
{ label: '成语填空 - 四字成语', value: 'chengyu' },
{ label: '反义词挑战', value: 'fanyi' },
{ label: '请根据提示书写正确的汉字-jiū 表示小鸟的叫声。', value: 'hanzi' },
{ label: '请根据提示书写正确的汉字-“春色满园关不住,一枝红杏出墙来”。请书写“杏”字。', value: 'tongyin' },
{ label: '请写出含有“车”的汉字。', value: 'pianpang' },
{ label: '请根据拼音书写正确的词语。', value: 'ciyu' },
{ label: '请写出含有反义字的四字成语。 - 例如:“不日而月”', value: 'chengyu' },
{ label: '请根据图片书写正确的成语。', value: 'fanyi' },
{ label: '近义词挑战', value: 'jinyi' },
{ label: '看图猜字', value: 'kantu' },
{ label: '古诗词接龙', value: 'gushi' },
@ -63,14 +65,6 @@ export function useQuestionConfig(props: any) {
{ 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) {
if (!props.modelValue.rounds) {
@ -141,8 +135,35 @@ export function useQuestionConfig(props: any) {
return false
}
for (let j = 0; j < round.questions.length; j++) {
if (!round.questions[j].value) {
window.$message?.error(`${round.name}${j + 1}未选择题型`)
const question = round.questions[j]
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
}
}
@ -161,7 +182,6 @@ export function useQuestionConfig(props: any) {
getRoundTime,
totalStats,
questionOptions,
timeOptions,
addRound,
removeRound,
addQuestion,

View File

@ -90,6 +90,7 @@ const questionForm = ref({
answer: '',
score: 10,
time: 30,
imageUrl: '',
})
const isLeafSelected = computed(() => {
@ -119,7 +120,7 @@ function handleAddQuestion() {
}
questionOperation.value = 'add'
currentQuestionId.value = null
questionForm.value = { content: '', answer: '', score: 10, time: 30 }
questionForm.value = { content: '', answer: '', score: 10, time: 30, imageUrl: '' }
showQuestionModal.value = true
}
@ -133,6 +134,7 @@ function handleEditQuestion(id: string) {
answer: question.answer,
score: question.score,
time: question.time,
imageUrl: (question as any).imageUrl || '',
}
showQuestionModal.value = true
}
@ -159,6 +161,8 @@ function submitQuestion() {
window.$message?.error('请填写完整信息')
return
}
// eslint-disable-next-line no-console
console.log('questionForm.value', questionForm.value)
if (questionOperation.value === 'add') {
// Mock add
@ -306,6 +310,11 @@ function submitQuestion() {
</NFormItem>
</div>
<!-- 上传图片 -->
<NFormItem label="上传图片">
<RestUpload v-model="questionForm.imageUrl" width="100px" height="100px" hint="请上传题目图片" />
</NFormItem>
<NFormItem label="参考标准答案">
<NInput v-model:value="questionForm.answer" placeholder="正确答案" />
</NFormItem>

View File

@ -3,22 +3,18 @@ import { NButton, NCard, NTag } from 'naive-ui'
import { reactive } from 'vue'
import { useRouter } from 'vue-router'
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 RankSearch from './modules/rank-search.vue'
const appStore = useAppStore()
const router = useRouter()
const searchParams: Api.SystemManage.UserSearchParams = reactive({
current: 1,
size: 10,
status: null,
userName: null,
userGender: null,
nickName: null,
userPhone: null,
userEmail: null,
const searchParams: Api.Rank.UserSearchParams = reactive({
currentPage: 1,
pageSize: 10,
publishStatus: undefined,
competitionActivityName: '',
})
const rankTitle = '排行榜管理列表'
@ -28,8 +24,8 @@ const statusMap: Record<string, string> = {
2: '待审核',
}
const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagination } = useNaivePaginatedTable({
api: () => fetchGetUserList(searchParams),
const { columns, data, getData, getDataByPage, loading, mobilePagination } = useNaivePaginatedTable({
api: () => fetchRankList(searchParams as any),
transform: (response) => {
const transformed = defaultTransform(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 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
item.userName = mockCompetitions[mockIndex] // Competition Name
item.userEmail = mockDates[mockIndex] // Activity Date (using userEmail field)
item.userPhone = mockScales[mockIndex] // Team Scale (using userPhone field)
item.nickName = mockScores[mockIndex] // Max Score (using nickName field)
item.competitionActivityName = mockCompetitions[mockIndex] // Competition Name
item.competitionActivityDate = mockDates[mockIndex] // Activity Date (using userEmail field)
item.competitionTeamScale = mockScales[mockIndex] // Team Scale (using userPhone field)
item.competitionMaxScore = mockScores[mockIndex] // Max Score (using nickName field)
// item.createTime is typically available, we'll simulate update time
item.createTime = mockTimes[mockIndex]
// Status: 1 (Published), 2 (Pending)
@ -53,42 +49,45 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
return transformed
},
onPaginationParamsChange: (params) => {
searchParams.current = params.page
searchParams.size = params.pageSize
searchParams.currentPage = params.page || 1
searchParams.pageSize = params.pageSize || 10
},
columns: () => [
{
type: 'selection',
align: 'center',
width: 48,
width: 40,
},
{
key: 'userName',
title: '序号',
align: 'center',
key: 'index',
width: 80,
render: (row, index) => index + 1,
},
{
key: 'competitionActivityName',
title: '比赛活动名称',
align: 'left',
minWidth: 200,
},
{
key: 'userEmail',
key: 'competitionActivityDate',
title: '活动日期',
align: 'center',
minWidth: 120,
},
{
key: 'userPhone',
key: 'competitionTeamScale',
title: '队伍规模',
align: 'center',
minWidth: 100,
},
{
key: 'nickName',
key: 'competitionMaxScore',
title: '最高积分',
align: 'center',
minWidth: 100,
render: row => (
<span class="text-primary font-bold">
{row.nickName}
{' '}
{row.competitionMaxScore}
Pts
</span>
),
@ -97,13 +96,11 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
key: 'createTime',
title: '最后更新时间',
align: 'center',
minWidth: 160,
},
{
key: 'status',
title: '发布状态',
align: 'center',
width: 100,
render: (row) => {
if (row.status === null)
return null
@ -116,7 +113,6 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
key: 'operate',
title: '操作',
align: 'center',
width: 120,
render: row => (
<div class="flex-center">
<NButton type="primary" text onClick={() => edit(row.id)}>
@ -129,12 +125,7 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
})
const {
drawerVisible,
operateType,
editingData,
handleEdit,
checkedRowKeys,
onBatchDeleted,
} = useTableOperate(data, 'id', getData)
function edit(id: number) {
@ -149,7 +140,7 @@ function handleBatchPublish() {
<template>
<div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
<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>
<div class="flex items-center justify-between">
<div>
@ -173,7 +164,7 @@ function handleBatchPublish() {
:loading="loading" @add="handleAdd" @delete="handleBatchDelete" @refresh="getData" />
</template> -->
<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"
:pagination="mobilePagination" class="sm:h-full"
/>

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { jsonClone } from '@sa/utils'
import { computed, toRaw } from 'vue'
import { useFormRules, useNaiveForm } from '@/hooks/common/form'
import { toRaw } from 'vue'
import { useNaiveForm } from '@/hooks/common/form'
defineOptions({
name: 'RankSearch',
@ -15,18 +15,7 @@ interface Emits {
const { formRef, validate, restoreValidation } = useNaiveForm()
const model = defineModel<Api.SystemManage.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 model = defineModel<Api.Rank.UserSearchParams>('model', { required: true })
const defaultModel = jsonClone(toRaw(model.value))
@ -54,14 +43,14 @@ const publishStatusOptions = [
<NCard :bordered="false" size="small" class="card-wrapper">
<NCollapse>
<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>
<NFormItemGi span="24 s:12 m:8" label="比赛活动名称" path="userName" class="pr-24px">
<NInput v-model:value="model.userName" placeholder="请输入比赛活动名称" />
<NFormItemGi span="24 s:12 m:8" label="比赛活动名称" path="competitionActivityName" class="pr-24px">
<NInput v-model:value="model.competitionActivityName" placeholder="请输入比赛活动名称" />
</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
v-model:value="model.status"
v-model:value="model.publishStatus"
placeholder="请选择"
:options="publishStatusOptions"
clearable

View File

@ -1,8 +1,8 @@
<script setup lang="ts">
import { jsonClone } from '@sa/utils'
import { computed, toRaw } from 'vue'
import { toRaw } from 'vue'
import { enableStatusOptions } from '@/constants/business'
import { useFormRules, useNaiveForm } from '@/hooks/common/form'
import { useNaiveForm } from '@/hooks/common/form'
defineOptions({
name: 'TemplateSearch',
@ -16,18 +16,7 @@ interface Emits {
const { formRef, validate, restoreValidation } = useNaiveForm()
const model = defineModel<Api.SystemManage.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 model = defineModel<Api.Template.TemplateSearchParams>('model', { required: true })
const defaultModel = jsonClone(toRaw(model.value))
@ -60,22 +49,22 @@ const sizeOptions = [
<NCard :bordered="false" size="small" class="card-wrapper">
<NCollapse>
<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>
<NFormItemGi span="24 s:12 m:6" label="模板名称" path="userName" class="pr-24px">
<NInput v-model:value="model.userName" placeholder="请输入名称" />
<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="userGender" class="pr-24px">
<NFormItemGi span="24 s:12 m:6" label="关联比赛" path="competitionId" class="pr-24px">
<NSelect
v-model:value="model.userGender" placeholder="请选择" :options="competitionOptions as any"
v-model:value="model.competitionId" placeholder="请选择" :options="competitionOptions as any"
clearable
/>
</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 />
</NFormItemGi>
<NFormItemGi span="24 s:12 m:6" label="尺寸" path="userPhone" class="pr-24px">
<NSelect v-model:value="model.userPhone" placeholder="请选择" :options="sizeOptions" clearable />
<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">

View File

@ -0,0 +1,8 @@
<script lang="ts" setup>
</script>
<template>
<div class="">
<h1>模板详情</h1>
</div>
</template>

View File

@ -1,23 +1,24 @@
<script setup lang="tsx">
import { NButton, NPopconfirm, NTag } from 'naive-ui'
import { reactive } from 'vue'
import { useRouterPush } from '@/hooks/common/router'
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 TemplateOperateDrawer from './modules/template-operate-drawer.vue'
import TemplateSearch from './modules/template-search.vue'
const { routerPushByKey } = useRouterPush()
const appStore = useAppStore()
const searchParams: Api.SystemManage.UserSearchParams = reactive({
current: 1,
size: 10,
const searchParams: Api.Template.TemplateSearchParams = reactive({
currentPage: 1,
pageSize: 10,
status: null,
userName: null,
userGender: null,
nickName: null,
userPhone: null,
userEmail: null,
templateName: undefined,
competitionId: undefined,
size: undefined,
})
const templateTitle = '模板管理'
@ -33,19 +34,22 @@ const statusMap: Record<string, string> = {
}
const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagination } = useNaivePaginatedTable({
api: () => fetchGetUserList(searchParams),
api: () => fetchTemplateList(searchParams),
transform: (response) => {
const transformed = defaultTransform(response)
transformed.data.forEach((item, index) => {
item.nickName = `MD${String(index + 1).padStart(6, '0')}`
item.userPhone = '210mmx297mm'
item.userGender = (item.userGender === '1' || item.userGender === '2') ? item.userGender : '1'
transformed.data.forEach((item: any, index) => {
item.code = `MD${String(index + 1).padStart(6, '0')}`
item.size = '210mmx297mm'
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
},
onPaginationParamsChange: (params) => {
searchParams.current = params.page
searchParams.size = params.pageSize
searchParams.currentPage = params.page || 1
searchParams.pageSize = params.pageSize || 10
},
columns: () => [
{
@ -61,26 +65,26 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
render: (_, index) => index + 1,
},
{
key: 'userName',
key: 'templateName',
title: '名称',
align: 'center',
},
{
key: 'nickName',
key: 'code',
title: '模板ID',
align: 'center',
},
{
key: 'userGender',
key: 'competitionId',
title: '关联比赛',
align: 'center',
render: (row) => {
const label = competitionMap[row.userGender as string] || '未知比赛'
render: (row: any) => {
const label = competitionMap[row.competitionId as string] || '未知比赛'
return <span>{label}</span>
},
},
{
key: 'userPhone',
key: 'size',
title: '设计尺寸',
align: 'center',
},
@ -88,8 +92,7 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
key: 'status',
title: '状态',
align: 'center',
width: 100,
render: (row) => {
render: (row: Api.Template.CommonRecord) => {
if (row.status === null) {
return null
}
@ -101,7 +104,7 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
key: 'operate',
title: '操作',
align: 'center',
// width: 430,
width: 430,
render: row => (
<div class="flex-center gap-8px">
<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>
<NButton size="small" onClick={() => { }}>
<NButton size="small" onClick={() => handlePageInfo(row.id)}>
页面信息
</NButton>
<NButton size="small" onClick={() => { }}>
@ -159,12 +162,16 @@ function handleDelete() {
function edit(id: number) {
handleEdit(id)
}
function handlePageInfo(id: number) {
routerPushByKey('template_template-detail', { query: { id: id.toString() } })
}
</script>
<template>
<div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
<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>
<TableHeaderOperation
v-model:columns="columnChecks" :disabled-delete="checkedRowKeys.length === 0"
@ -172,8 +179,8 @@ function edit(id: number) {
/>
</template>
<NDataTable
v-model:checked-row-keys="checkedRowKeys" :columns="columns" :data="data" size="small" striped
:flex-height="!appStore.isMobile" :scroll-x="962" :loading="loading" remote :row-key="row => row.id"
v-model:checked-row-keys="checkedRowKeys" :columns="columns as any" :data="data" page-size="small"
striped :flex-height="!appStore.isMobile" :scroll-x="962" :loading="loading" remote :row-key="row => row.id"
:pagination="mobilePagination" class="sm:h-full"
/>
<TemplateOperateDrawer

View File

@ -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>

View File

@ -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

File diff suppressed because it is too large Load Diff