feat(admin): 新增模板管理功能并完善用户认证
- 新增模板管理模块,包含模板列表和详情页面 - 重构用户认证逻辑,支持用户ID存储和API调用 - 新增排行榜API接口和类型定义 - 更新环境配置,添加开发环境配置 - 优化表格操作钩子,增强类型安全性 - 新增文件上传工具类和验证工具函数 - 更新路由配置,支持模板详情页面导航 - 修复登录表单默认值问题
This commit is contained in:
@ -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
|
||||
|
||||
@ -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= `{
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
BIN
apps/admin/src/assets/imgs/副本第九届“阅读之星”题目示例 (1).xlsx
Normal file
BIN
apps/admin/src/assets/imgs/副本第九届“阅读之星”题目示例 (1).xlsx
Normal file
Binary file not shown.
@ -1,7 +1,7 @@
|
||||
import type { IEditorConfig, ToolbarOptions } from '@opentiny/fluent-editor/types/config/types'
|
||||
import type { IEditorConfig, MathliveModule, Range, ToolbarOptions } from '@opentiny/fluent-editor'
|
||||
import type OSS from 'ali-oss'
|
||||
import type { 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)
|
||||
}
|
||||
}
|
||||
|
||||
489
apps/admin/src/components/common/rest-upload/rest-upload.vue
Normal file
489
apps/admin/src/components/common/rest-upload/rest-upload.vue
Normal file
@ -0,0 +1,489 @@
|
||||
<!-- eslint-disable no-console -->
|
||||
<script setup lang="ts"
|
||||
generic="K extends string | string[] | Record<string, unknown> | Record<string, unknown>[] | null | undefined"
|
||||
>
|
||||
import type OSS from 'ali-oss'
|
||||
import type { UploadFileInfo } from 'naive-ui'
|
||||
import type { PropType, Ref } from 'vue'
|
||||
import { useAutoAnimate } from '@formkit/auto-animate/vue'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import { getAliOssTokenAxios } from '@/service/api/upload'
|
||||
import { getOnlyId } from '@/utils/data'
|
||||
import { browserPathJoin } from '@/utils/date'
|
||||
import { fileToBase64, getFirstFrameOfVideo } from '@/utils/file'
|
||||
import { initOSSClient, uploadFileToOSS } from '@/utils/oss'
|
||||
import { FileUploadService } from '@/utils/upload-util'
|
||||
import { isObject } from '@/utils/verify'
|
||||
import { verifyAddIntRoZeroNumb } from '@/utils/verify-number'
|
||||
|
||||
export interface FileItem {
|
||||
/** 唯一 ID */
|
||||
__id: number | string
|
||||
/** 浏览器中的文件对象 */
|
||||
file?: File
|
||||
/** 进度 */
|
||||
percentage: number
|
||||
/** 当前 index */
|
||||
index: number
|
||||
/** 上传状态; 0:正在上传; 1:上传成功; -1:上传失败; */
|
||||
uploadStatus: -1 | 0 | 1
|
||||
/** 错误原因 */
|
||||
errMsg?: string
|
||||
/** url */
|
||||
url: string
|
||||
/** 海报 */
|
||||
poster: string
|
||||
}
|
||||
|
||||
export interface CustomKeys {
|
||||
/** 绑定上传文件链接的 key(即modelValue为对象或对象数组时绑定的上传文件链接的key) */
|
||||
bindFileUrlKey?: string
|
||||
/** 绑定返回海报链接的 key(即modelValue为对象或对象数组时绑定的海报链接的key) */
|
||||
bindPosterUrlKey?: string
|
||||
/** 上传成功后服务器返回链接中文件链接的 key */
|
||||
upFileUrlKey?: string
|
||||
/** 上传成功后服务器返回海报字段的的 key */
|
||||
upPosterUrlKey?: string
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
/** 上传图片的URL */
|
||||
path: { type: String, default: undefined },
|
||||
/** 自定义 key */
|
||||
customKeys: { type: Object as PropType<CustomKeys>, default: (): CustomKeys => ({}) },
|
||||
/** 提示文字 */
|
||||
hint: { type: String, default: '点击上传' },
|
||||
/** 图片对象数组 */
|
||||
modelValue: { type: [Array, String, Object, null, undefined] as PropType<K>, default: () => [] },
|
||||
/** 宽度 */
|
||||
width: { type: [String, Number], default: 100 },
|
||||
/** 高度 */
|
||||
height: { type: [String, Number], default: 100 },
|
||||
/** 最多上传几张图片 */
|
||||
limit: { type: Number, default: 1 },
|
||||
/** 限制可以选择的文件类型(根据后缀限制) 如:['.png', 'jpg'] */
|
||||
accept: { type: Array as PropType<`.${string}`[]>, default: () => ['.png', '.jpg'] },
|
||||
/** 加载状态 */
|
||||
loading: { type: Boolean, default: false },
|
||||
/** 选择图片完成后的回调 */
|
||||
afterSelectFile: { type: Function as PropType<(data: File[]) => File[] | Promise<File[]>>, default: undefined },
|
||||
/** 上传成功后的回调 */
|
||||
afterUpload: { type: Function as PropType<(data: OSS.MultipartUploadResult) => Record<string, unknown> | string>, default: undefined },
|
||||
/** 限制视频最大允许上传多少 kb, */
|
||||
maxVideoSize: { type: Number, default: undefined },
|
||||
/** 限制图片最小允许上传多少 kb,默认 1 */
|
||||
minImageSize: { type: Number, default: 1 },
|
||||
/** 限制图片最大允许上传多少 kb,默认 800 */
|
||||
maxImageSize: { type: Number, default: undefined },
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
(e: 'change', list: FileItem[]): void
|
||||
(e: 'update:loading', state: boolean): void
|
||||
(e: 'update:modelValue', list: any): void
|
||||
(e: 'error', myFile: FileItem): void
|
||||
}>()
|
||||
|
||||
const [parent] = useAutoAnimate()
|
||||
const upLoading = ref(props.loading)
|
||||
|
||||
// 视频预览状态
|
||||
const showVideoPreview = ref(false)
|
||||
const videoPreviewSrc = ref('')
|
||||
const videoPreviewPoster = ref('')
|
||||
|
||||
const bindFileUrlKey = computed(() => props.customKeys.bindFileUrlKey || 'url')
|
||||
const bindPosterUrlKey = computed(() => props.customKeys.bindPosterUrlKey || 'frameUrl')
|
||||
const upFileUrlKey = computed(() => props.customKeys.upFileUrlKey || 'url')
|
||||
const upPosterUrlKey = computed(() => props.customKeys.upPosterUrlKey || 'frameUrl')
|
||||
|
||||
const newWidth = computed(() => (verifyAddIntRoZeroNumb(`${props.width}`) || typeof props.width === 'number' ? `${props.width}px` : props.width))
|
||||
const newHeight = computed(() => (verifyAddIntRoZeroNumb(`${props.height}`) || typeof props.height === 'number' ? `${props.height}px` : props.height))
|
||||
|
||||
/** 全部的文件(包含上传和未上传的) */
|
||||
const fileList: Ref<FileItem[]> = ref(getDefFiles(props.modelValue))
|
||||
/** 得到上传成功的文件(包含本次上传成功和之前上传成功的) */
|
||||
const uploadedList = shallowRef<Record<string, string>[] | string[]>(getUploadedList())
|
||||
/** 最终需要得到文件(根据是多选还是单选整理成需要的文件) */
|
||||
const uploadedFiles = computed(() => (props.limit > 1 ? (uploadedList.value as K) : (uploadedList.value[0] as K)))
|
||||
/** 现在还可以添加多少个 */
|
||||
const efficientNumb = computed(() => props.limit - uploadedList.value.length - fileList.value.length)
|
||||
|
||||
// NUpload 文件列表,用于选择后清除
|
||||
const nUploadFileList = ref<UploadFileInfo[]>([])
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
fileList.value = getDefFiles(val)
|
||||
},
|
||||
)
|
||||
watch(
|
||||
() => upLoading.value,
|
||||
(val) => {
|
||||
emit('update:loading', val)
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => uploadedFiles.value,
|
||||
(val) => {
|
||||
emit('update:modelValue', val)
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* 视频预览
|
||||
*/
|
||||
function previewVideoClick(src: string, poster: string) {
|
||||
if (src) {
|
||||
videoPreviewSrc.value = src
|
||||
videoPreviewPoster.value = poster
|
||||
showVideoPreview.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 (已经上传的)
|
||||
*/
|
||||
function deleteClick(index: number) {
|
||||
fileList.value.splice(index, 1)
|
||||
emit('change', fileList.value)
|
||||
uploadedList.value = getUploadedList()
|
||||
}
|
||||
/**
|
||||
* 重新上传
|
||||
*/
|
||||
async function reUploadClick(index: number) {
|
||||
try {
|
||||
upLoading.value = true
|
||||
await uploadFile(fileList.value[index]!)
|
||||
upLoading.value = false
|
||||
emit('change', fileList.value)
|
||||
uploadedList.value = getUploadedList()
|
||||
}
|
||||
catch (error) {
|
||||
upLoading.value = false
|
||||
uploadedList.value = getUploadedList()
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('重新上传失败====', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置
|
||||
*/
|
||||
function reset() {
|
||||
fileList.value = []
|
||||
uploadedList.value = []
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
*/
|
||||
function handleUploadChange(options: { fileList: UploadFileInfo[] }) {
|
||||
// 获取新添加的文件
|
||||
const newFiles = options.fileList.map(item => item.file).filter(Boolean) as File[]
|
||||
|
||||
// 清除 NUpload 列表以避免重复并允许重新选择
|
||||
nUploadFileList.value = []
|
||||
|
||||
if (newFiles.length > 0) {
|
||||
uploadFileList(newFiles)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理上传所有图片
|
||||
*/
|
||||
async function uploadFileList(files: File[] | null) {
|
||||
try {
|
||||
if (files && files.length > 0) {
|
||||
let processFiles = files
|
||||
if (props.afterSelectFile) {
|
||||
processFiles = await props.afterSelectFile(files)
|
||||
}
|
||||
|
||||
const promiseArr: FileItem[] = []
|
||||
// 使用临时列表以避免循环期间 fileList 发生变化的问题(尽管 push 是安全的)
|
||||
// 但我们需要将其推入 fileList 以立即显示它们
|
||||
for (let i = 0, len = processFiles.length; i < len; i++) {
|
||||
if (efficientNumb.value > 0 && processFiles[i]) {
|
||||
const newItem: FileItem = {
|
||||
file: processFiles[i]!,
|
||||
percentage: 0,
|
||||
uploadStatus: 0,
|
||||
index: fileList.value.length,
|
||||
__id: getOnlyId(),
|
||||
url: '',
|
||||
poster: '',
|
||||
}
|
||||
fileList.value.push(newItem)
|
||||
// 必须获取响应式对象,否则进度条不会更新
|
||||
promiseArr.push(fileList.value[fileList.value.length - 1])
|
||||
}
|
||||
}
|
||||
|
||||
if (promiseArr.length > 0) {
|
||||
upLoading.value = true
|
||||
const uploadService = new FileUploadService(uploadFile, 5)
|
||||
await uploadService.upload(promiseArr)
|
||||
upLoading.value = false
|
||||
emit('change', fileList.value)
|
||||
uploadedList.value = getUploadedList()
|
||||
}
|
||||
else {
|
||||
window.$message?.warning('暂无可上传文件或已达上限')
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
uploadedList.value = getUploadedList()
|
||||
window.$message?.error('上传失败')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
*/
|
||||
async function uploadFile(myFile: FileItem): Promise<FileItem> {
|
||||
try {
|
||||
const fileSize = myFile.file!.size / 1024
|
||||
if (myFile.file!.type.includes('image')) {
|
||||
if (typeof props.maxImageSize === 'number' && fileSize > props.maxImageSize) {
|
||||
const msg = getRestrictSizeMsg(props.maxImageSize, 'max')
|
||||
myFile.uploadStatus = -1
|
||||
myFile.errMsg = msg
|
||||
return Promise.reject(new Error(msg))
|
||||
}
|
||||
else if (fileSize < props.minImageSize) {
|
||||
const msg = getRestrictSizeMsg(props.minImageSize, 'min')
|
||||
myFile.uploadStatus = -1
|
||||
myFile.errMsg = msg
|
||||
return Promise.reject(new Error(msg))
|
||||
}
|
||||
}
|
||||
if (typeof props.maxVideoSize === 'number' && myFile.file!.type.includes('video') && fileSize > props.maxVideoSize) {
|
||||
const msg = getRestrictSizeMsg(props.maxVideoSize, 'max')
|
||||
myFile.uploadStatus = -1
|
||||
myFile.errMsg = msg
|
||||
return Promise.reject(new Error(msg))
|
||||
}
|
||||
const { data: tokenData, error: tokenError } = await getAliOssTokenAxios()
|
||||
if (tokenError || !tokenData) {
|
||||
throw new Error('获取上传凭证失败')
|
||||
}
|
||||
// 初始化OSS客户端
|
||||
const client = initOSSClient(tokenData)
|
||||
let path = typeof props.path === 'string' ? '' : `temp/${Date.now()}`
|
||||
path = browserPathJoin(path, myFile.file!.name)
|
||||
// 上传文件
|
||||
const res: OSS.MultipartUploadResult = await uploadFileToOSS(client, myFile.file!, path, (progress) => {
|
||||
myFile.percentage = Math.floor((progress || 0) * 10000) / 100
|
||||
})
|
||||
const url = browserPathJoin(import.meta.env.VITE_BASE_OSS_URL, path)
|
||||
const data = typeof props.afterUpload === 'function' ? props.afterUpload(res) : res
|
||||
if (isObject(data)) {
|
||||
const newData = data as Record<string, string>
|
||||
myFile.url = upFileUrlKey.value in newData ? String(newData[upFileUrlKey.value] || url) : url
|
||||
myFile.poster = upPosterUrlKey.value in newData ? String(newData[upPosterUrlKey.value] || '') : ''
|
||||
if (myFile.file!.type.includes('video') && !myFile.poster) {
|
||||
try {
|
||||
const info = await getFirstFrameOfVideo(myFile.file!)
|
||||
myFile.poster = (await fileToBase64(info.firstFrame)).img || ''
|
||||
}
|
||||
catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('error====', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
myFile.url = url
|
||||
}
|
||||
myFile.uploadStatus = 1
|
||||
return Promise.resolve(myFile)
|
||||
}
|
||||
catch (error: any) {
|
||||
console.log('上传错误====', error)
|
||||
myFile.uploadStatus = -1
|
||||
myFile.errMsg = typeof error.msg === 'string' ? error.msg : '上传失败'
|
||||
emit('error', error)
|
||||
return Promise.reject(new Error(myFile.errMsg))
|
||||
}
|
||||
}
|
||||
|
||||
/** 得到初始已上传的文件 */
|
||||
function getDefFiles(data: unknown): FileItem[] {
|
||||
const val: unknown[] = Array.isArray(data) ? data : data ? [data] : []
|
||||
const newVal: FileItem[] = val.map((item, index: number) => {
|
||||
const _item: FileItem = isObject(item)
|
||||
? {
|
||||
url: String(item[bindFileUrlKey.value] || ''),
|
||||
poster: String(item[bindPosterUrlKey.value] || ''),
|
||||
uploadStatus: 1,
|
||||
index,
|
||||
__id: getOnlyId(),
|
||||
percentage: 0,
|
||||
}
|
||||
: { url: String(item), poster: '', uploadStatus: 1, index, __id: getOnlyId(), percentage: 0 }
|
||||
return _item
|
||||
})
|
||||
return newVal
|
||||
}
|
||||
/** 得到已上传成功的文件 */
|
||||
function getUploadedList() {
|
||||
const list = fileList.value.filter(item => item.uploadStatus === 1 && item.url)
|
||||
// 不能用 bindFileUrlKey 代替
|
||||
if (props.customKeys.bindFileUrlKey) {
|
||||
return list.map((item) => {
|
||||
const obj: Record<string, string> = {}
|
||||
obj[bindFileUrlKey.value] = item.url
|
||||
obj[bindPosterUrlKey.value] = item.poster
|
||||
return obj
|
||||
})
|
||||
}
|
||||
else {
|
||||
return list.map(item => item.url)
|
||||
}
|
||||
}
|
||||
/** 得到不符合大小限制的提示语 */
|
||||
function getRestrictSizeMsg(maxSize: number, type: 'max' | 'min') {
|
||||
const maxSizeText = maxSize >= 1024 ? `${Number((maxSize / 1024).toFixed(2))}M` : `${maxSize}KB`
|
||||
return type === 'max' ? `文件大小不能超过${maxSizeText}` : `文件大小不能小于${maxSizeText}`
|
||||
}
|
||||
|
||||
defineExpose({ reset, getLoadState: () => upLoading.value })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rest-upload-container">
|
||||
<NImageGroup>
|
||||
<div ref="parent" class="flex flex-wrap gap-4">
|
||||
<!-- 文件列表 -->
|
||||
<div
|
||||
v-for="(item, index) in fileList" :key="item.__id"
|
||||
class="group relative overflow-hidden border border-gray-200 rounded-lg transition-all duration-300 hover:border-primary"
|
||||
:style="{ width: newWidth, height: newHeight }"
|
||||
>
|
||||
<!-- 图片预览 -->
|
||||
<div
|
||||
v-if="!item.url.includes('.mp4') && !item.url.includes('.webm') && (item.file?.type?.includes('image') || !item.file)"
|
||||
class="h-full w-full flex items-center justify-center bg-gray-50"
|
||||
>
|
||||
<NImage
|
||||
:src="item.url" class="h-full w-full flex items-center justify-center"
|
||||
:img-props="{ style: { width: '100%', height: '100%', objectFit: 'contain' } }" object-fit="cover"
|
||||
>
|
||||
<template #error>
|
||||
<div class="h-full w-full flex items-center justify-center bg-gray-50 text-gray-400">
|
||||
<SvgIcon icon="mdi:image-broken-variant" class="text-2xl" />
|
||||
</div>
|
||||
</template>
|
||||
</NImage>
|
||||
</div>
|
||||
|
||||
<!-- 视频预览 -->
|
||||
<div
|
||||
v-else class="h-full w-full flex items-center justify-center bg-black bg-cover bg-center bg-no-repeat"
|
||||
:style="{ backgroundImage: item.poster ? `url(${item.poster})` : undefined }"
|
||||
>
|
||||
<SvgIcon icon="mdi:play-circle-outline" class="text-4xl text-white opacity-80" />
|
||||
</div>
|
||||
|
||||
<!-- 进度覆盖层 -->
|
||||
<div
|
||||
v-if="item.uploadStatus === 0"
|
||||
class="absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/50 p-1"
|
||||
>
|
||||
<NProgress
|
||||
type="circle" :percentage="item.percentage"
|
||||
:color="{ stops: ['#E3F2FD', '#2080f0'] }"
|
||||
style="width: 60px; height: 60px"
|
||||
>
|
||||
<template #default>
|
||||
<span class="text-xs text-white">{{ item.percentage }}%</span>
|
||||
</template>
|
||||
</NProgress>
|
||||
</div>
|
||||
|
||||
<!-- 错误覆盖层 -->
|
||||
<div
|
||||
v-if="item.uploadStatus === -1"
|
||||
class="absolute inset-0 z-20 flex flex-col items-center justify-center bg-red-50/90 p-2 text-center"
|
||||
>
|
||||
<span class="mb-1 text-xs text-red-500">{{ item.errMsg || '上传失败' }}</span>
|
||||
<NButton size="tiny" type="error" ghost @click="reUploadClick(index)">
|
||||
重试
|
||||
</NButton>
|
||||
<div class="absolute right-1 top-1 cursor-pointer" @click="deleteClick(index)">
|
||||
<SvgIcon icon="mdi:close" class="text-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作覆盖层(悬停) -->
|
||||
<div
|
||||
v-if="item.uploadStatus === 1"
|
||||
class="pointer-events-none absolute inset-0 z-10 flex items-center justify-center gap-2 bg-black/0 opacity-0 transition-all duration-300 group-hover:bg-black/50 group-hover:opacity-100"
|
||||
>
|
||||
<NButton
|
||||
v-if="item.url.includes('.mp4') || item.url.includes('.webm') || item.file?.type?.includes('video')"
|
||||
circle secondary type="info" class="pointer-events-auto"
|
||||
@click="previewVideoClick(item.url, item.poster)"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon icon="mdi:eye" />
|
||||
</template>
|
||||
</NButton>
|
||||
<NButton v-else circle secondary type="info" class="pointer-events-none">
|
||||
<template #icon>
|
||||
<SvgIcon icon="mdi:eye" />
|
||||
</template>
|
||||
</NButton>
|
||||
|
||||
<NButton circle secondary type="error" class="pointer-events-auto" @click="deleteClick(index)">
|
||||
<template #icon>
|
||||
<SvgIcon icon="mdi:delete" />
|
||||
</template>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上传触发器 -->
|
||||
<div v-if="efficientNumb > 0" :style="{ width: newWidth, height: newHeight }">
|
||||
<NUpload
|
||||
:accept="accept.join(',')" :multiple="limit > 1" :max="limit" :show-file-list="false"
|
||||
:file-list="nUploadFileList" :default-upload="false" class="h-full w-full" @change="handleUploadChange"
|
||||
>
|
||||
<template #default>
|
||||
<div
|
||||
class="h-full w-full flex flex-col cursor-pointer items-center justify-center border border-gray-300 rounded-lg border-dashed bg-gray-50 transition-colors hover:border-primary hover:bg-primary-50"
|
||||
:style="{ width: newWidth, height: newHeight }"
|
||||
>
|
||||
<SvgIcon icon="mdi:plus" class="text-3xl text-gray-400" />
|
||||
<span class="mt-1 text-xs text-gray-400">{{ hint }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</NUpload>
|
||||
</div>
|
||||
</div>
|
||||
</NImageGroup>
|
||||
|
||||
<!-- 视频预览弹窗 -->
|
||||
<NModal v-model:show="showVideoPreview" preset="card" title="视频预览" class="max-w-full w-[800px]">
|
||||
<div class="flex items-center justify-center rounded-lg bg-black/5 p-4">
|
||||
<video
|
||||
v-if="videoPreviewSrc" :src="videoPreviewSrc" :poster="videoPreviewPoster" controls
|
||||
class="max-h-[600px] max-w-full w-auto"
|
||||
/>
|
||||
</div>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rest-upload-container {
|
||||
/* 确保容器不会塌陷 */
|
||||
min-height: v-bind(newHeight);
|
||||
}
|
||||
</style>
|
||||
@ -1,3 +1,4 @@
|
||||
import type { SelectOption } from 'naive-ui'
|
||||
import { transformRecordToOption } from '@/utils/common'
|
||||
|
||||
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[]
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '比赛详情',
|
||||
|
||||
@ -89,6 +89,10 @@ export function setupAppVersionNotification() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取HTML构建时间
|
||||
* @returns 构建时间字符串或null
|
||||
*/
|
||||
async function getHtmlBuildTime(): Promise<string | null> {
|
||||
const baseUrl = import.meta.env.VITE_BASE_URL || '/'
|
||||
|
||||
|
||||
@ -28,6 +28,7 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
|
||||
"rank_rank-detail": () => import("@/views/rank/rank-detail/index.vue"),
|
||||
"rank_rank-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"),
|
||||
};
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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"
|
||||
};
|
||||
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
|
||||
|
||||
@ -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}` })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export * from './auth'
|
||||
export * from './rank'
|
||||
export * from './route'
|
||||
export * from './system-manage'
|
||||
export * from './template'
|
||||
|
||||
10
apps/admin/src/service/api/question.ts
Normal file
10
apps/admin/src/service/api/question.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { request } from '../request'
|
||||
|
||||
/** 新增题目 */
|
||||
export function fetchAddQuestion(data?: Api.Question.AddParams) {
|
||||
return request<Api.Question.CommonRecord>({
|
||||
url: '/Base/ActivityMain/AddBase_QuestionList',
|
||||
method: 'post',
|
||||
data: data || {},
|
||||
})
|
||||
}
|
||||
10
apps/admin/src/service/api/rank.ts
Normal file
10
apps/admin/src/service/api/rank.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { request } from '../request'
|
||||
|
||||
/** get user list */
|
||||
export function fetchRankList(data?: Api.Rank.UserSearchParams) {
|
||||
return request<Api.Rank.CommonRecord>({
|
||||
url: '/admin/v1/class/pagelist',
|
||||
method: 'post',
|
||||
data: data || {},
|
||||
})
|
||||
}
|
||||
@ -24,8 +24,8 @@ export function fetchGetAllRoles() {
|
||||
/** get user list */
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
10
apps/admin/src/service/api/template.ts
Normal file
10
apps/admin/src/service/api/template.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { request } from '../request'
|
||||
|
||||
/** get template list */
|
||||
export function fetchTemplateList(data?: Api.Template.TemplateSearchParams) {
|
||||
return request<Api.Common.PaginatingQueryRecord<Api.Template.CommonRecord>>({
|
||||
url: '/admin/v1/book/list',
|
||||
method: 'post',
|
||||
data: data || {},
|
||||
})
|
||||
}
|
||||
@ -10,36 +10,45 @@ import { getAuthorization, handleExpiredRequest, showErrorMsg } from './shared'
|
||||
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y'
|
||||
const { 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 中的错误码,否则会形成死循环,应该返回 logoutCodes 或 modalLogoutCodes
|
||||
const expiredTokenCodes = import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES?.split(',') || []
|
||||
if (expiredTokenCodes.includes(responseCode)) {
|
||||
const success = await handleExpiredRequest(request.state)
|
||||
@ -99,24 +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
|
||||
}
|
||||
|
||||
@ -10,7 +10,9 @@ export function getAuthorization() {
|
||||
return Authorization
|
||||
}
|
||||
|
||||
/** refresh token */
|
||||
/**
|
||||
* 刷新token
|
||||
*/
|
||||
async function handleRefreshToken() {
|
||||
const { resetStore } = useAuthStore()
|
||||
|
||||
@ -27,6 +29,9 @@ async function handleRefreshToken() {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理过期token的请求
|
||||
*/
|
||||
export async function handleExpiredRequest(state: RequestInstanceState) {
|
||||
if (!state.refreshTokenPromise) {
|
||||
state.refreshTokenPromise = handleRefreshToken()
|
||||
@ -41,6 +46,9 @@ export async function handleExpiredRequest(state: RequestInstanceState) {
|
||||
return success
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示错误信息
|
||||
*/
|
||||
export function showErrorMsg(state: RequestInstanceState, message: string) {
|
||||
if (!state.errMsgStack?.length) {
|
||||
state.errMsgStack = []
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
export interface RequestInstanceState {
|
||||
/** the promise of refreshing token */
|
||||
/** 刷新token Promise */
|
||||
refreshTokenPromise: Promise<boolean> | null
|
||||
/** the request error message stack */
|
||||
/** 请求错误信息栈 */
|
||||
errMsgStack: string[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@ import { fetchGetUserInfo, fetchLogin } from '@/service/api'
|
||||
import { localStg } from '@/utils/storage'
|
||||
import { useRouteStore } from '../route'
|
||||
import { useTabStore } from '../tab'
|
||||
import { clearAuthStorage, getToken } from './shared'
|
||||
import { clearAuthStorage, getToken, getUserId } from './shared'
|
||||
|
||||
export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
const route = useRoute()
|
||||
@ -98,7 +98,6 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
*/
|
||||
async function login(userName: string, password: string, redirect = true) {
|
||||
startLoading()
|
||||
|
||||
const { data: loginToken, error } = await fetchLogin(userName, password)
|
||||
|
||||
if (!error) {
|
||||
@ -133,9 +132,10 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
// 1. stored in the localStorage, the later requests need it in headers
|
||||
localStg.set('token', loginToken.token)
|
||||
localStg.set('refreshToken', loginToken.refreshToken)
|
||||
localStg.set('userId', loginToken.id)
|
||||
|
||||
// 2. get user info
|
||||
const pass = await getUserInfo()
|
||||
const pass = await getUserInfo(loginToken.id)
|
||||
|
||||
if (pass) {
|
||||
token.value = loginToken.token
|
||||
@ -146,8 +146,8 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
return false
|
||||
}
|
||||
|
||||
async function getUserInfo() {
|
||||
const { data: info, error } = await fetchGetUserInfo()
|
||||
async function getUserInfo(userId: number) {
|
||||
const { data: info, error } = await fetchGetUserInfo(userId)
|
||||
|
||||
if (!error) {
|
||||
// update store
|
||||
@ -161,9 +161,10 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
|
||||
async function initUserInfo() {
|
||||
const hasToken = getToken()
|
||||
const userId = getUserId()
|
||||
|
||||
if (hasToken) {
|
||||
const pass = await getUserInfo()
|
||||
if (hasToken && userId) {
|
||||
const pass = await getUserInfo(userId)
|
||||
|
||||
if (!pass) {
|
||||
resetStore()
|
||||
|
||||
@ -5,8 +5,14 @@ export function getToken() {
|
||||
return localStg.get('token') || ''
|
||||
}
|
||||
|
||||
export function getUserId() {
|
||||
const userId = localStg.get('userId')
|
||||
return userId || 0
|
||||
}
|
||||
|
||||
/** Clear auth storage */
|
||||
export function clearAuthStorage() {
|
||||
localStg.remove('token')
|
||||
localStg.remove('refreshToken')
|
||||
localStg.remove('userId')
|
||||
}
|
||||
|
||||
15
apps/admin/src/typings/api/Competition.d.ts
vendored
15
apps/admin/src/typings/api/Competition.d.ts
vendored
@ -31,6 +31,21 @@ declare namespace Api {
|
||||
*/
|
||||
type EnableStatus = '1' | '2'
|
||||
|
||||
/** 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' /** 固定分数 | 答题个数 */
|
||||
|
||||
|
||||
1
apps/admin/src/typings/api/auth.d.ts
vendored
1
apps/admin/src/typings/api/auth.d.ts
vendored
@ -8,6 +8,7 @@ declare namespace Api {
|
||||
interface LoginToken {
|
||||
token: string
|
||||
refreshToken: string
|
||||
id: number
|
||||
}
|
||||
|
||||
interface UserInfo {
|
||||
|
||||
12
apps/admin/src/typings/api/common.d.ts
vendored
12
apps/admin/src/typings/api/common.d.ts
vendored
@ -7,21 +7,21 @@ declare namespace Api {
|
||||
namespace Common {
|
||||
/** 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
|
||||
|
||||
66
apps/admin/src/typings/api/question.d.ts
vendored
Normal file
66
apps/admin/src/typings/api/question.d.ts
vendored
Normal file
@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Namespace Api
|
||||
*
|
||||
* All backend api type
|
||||
*/
|
||||
declare namespace Api {
|
||||
namespace Question {
|
||||
/** common params of paginating */
|
||||
interface PaginatingCommonParams {
|
||||
/** current page number */
|
||||
current: number
|
||||
/** page size */
|
||||
size: number
|
||||
/** total count */
|
||||
total: number
|
||||
}
|
||||
|
||||
/** common params of paginating query list data */
|
||||
interface PaginatingQueryRecord<T = any> extends PaginatingCommonParams {
|
||||
records: T[]
|
||||
}
|
||||
|
||||
/** add params */
|
||||
interface AddParams {
|
||||
/** question id */
|
||||
id: string
|
||||
/** question name */
|
||||
name: string
|
||||
/** question content */
|
||||
questionContent: string
|
||||
}
|
||||
|
||||
/** common search params of table */
|
||||
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'current' | 'size'>
|
||||
|
||||
/**
|
||||
* enable status
|
||||
*
|
||||
* - "1": enabled
|
||||
* - "2": disabled
|
||||
*/
|
||||
type EnableStatus = '1' | '2'
|
||||
|
||||
/** question score type */
|
||||
type QuestionScoreType = '1' | '2' /** 固定分数 | 答题个数 */
|
||||
|
||||
/** competition round type */
|
||||
type CompetitionRoundType = '1' | '2' /** 题包环节 | 加时环节 */
|
||||
|
||||
/** common record */
|
||||
type CommonRecord<T = any> = {
|
||||
/** record id */
|
||||
id: number
|
||||
/** record creator */
|
||||
createBy: string
|
||||
/** record create time */
|
||||
createTime: string
|
||||
/** record updater */
|
||||
updateBy: string
|
||||
/** record update time */
|
||||
updateTime: string
|
||||
/** record status */
|
||||
status: EnableStatus | null
|
||||
} & T
|
||||
}
|
||||
}
|
||||
64
apps/admin/src/typings/api/rank.d.ts
vendored
Normal file
64
apps/admin/src/typings/api/rank.d.ts
vendored
Normal file
@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Namespace Api
|
||||
*
|
||||
* All backend api type
|
||||
*/
|
||||
declare namespace Api {
|
||||
namespace Rank {
|
||||
/** common params of paginating */
|
||||
interface PaginatingCommonParams {
|
||||
/** currentPage page number */
|
||||
currentPage: number
|
||||
/** page pageSize */
|
||||
pageSize: number
|
||||
/** total count */
|
||||
total?: number
|
||||
}
|
||||
|
||||
/** user search params */
|
||||
interface UserSearchParams extends PaginatingCommonParams {
|
||||
/** competition activity name */
|
||||
competitionActivityName?: string
|
||||
/** publish status */
|
||||
publishStatus?: EnableStatus
|
||||
}
|
||||
|
||||
/** common params of paginating query list data */
|
||||
interface PaginatingQueryRecord<T = any> extends PaginatingCommonParams {
|
||||
records: T[]
|
||||
}
|
||||
|
||||
/** common search params of table */
|
||||
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'currentPage' | 'pageSize'>
|
||||
|
||||
/**
|
||||
* enable status
|
||||
*
|
||||
* - "1": enabled
|
||||
* - "2": disabled
|
||||
*/
|
||||
type EnableStatus = '1' | '2'
|
||||
|
||||
/** question score type */
|
||||
type QuestionScoreType = '1' | '2' /** 固定分数 | 答题个数 */
|
||||
|
||||
/** competition round type */
|
||||
type CompetitionRoundType = '1' | '2' /** 题包环节 | 加时环节 */
|
||||
|
||||
/** common record */
|
||||
type CommonRecord<T = any> = {
|
||||
/** record id */
|
||||
id: number
|
||||
/** record creator */
|
||||
createBy: string
|
||||
/** record create time */
|
||||
createTime: string
|
||||
/** record updater */
|
||||
updateBy: string
|
||||
/** record update time */
|
||||
updateTime: string
|
||||
/** record status */
|
||||
status: EnableStatus | null
|
||||
} & T
|
||||
}
|
||||
}
|
||||
72
apps/admin/src/typings/api/template.d.ts
vendored
Normal file
72
apps/admin/src/typings/api/template.d.ts
vendored
Normal file
@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Namespace Api
|
||||
*
|
||||
* All backend api type
|
||||
*/
|
||||
declare namespace Api {
|
||||
namespace Template {
|
||||
/** common params of paginating */
|
||||
interface PaginatingCommonParams {
|
||||
/** currentPage page number */
|
||||
currentPage: number
|
||||
/** page pageSize */
|
||||
pageSize: number
|
||||
/** total count */
|
||||
total?: number
|
||||
}
|
||||
|
||||
/** template search params */
|
||||
interface TemplateSearchParams extends PaginatingCommonParams {
|
||||
/** competition activity name */
|
||||
competitionActivityName?: string
|
||||
/** publish status */
|
||||
publishStatus?: EnableStatus
|
||||
/** template name */
|
||||
templateName?: string
|
||||
/** competition id */
|
||||
competitionId?: string
|
||||
/** size */
|
||||
size?: string
|
||||
/** status */
|
||||
status?: EnableStatus | null
|
||||
}
|
||||
|
||||
/** common params of paginating query list data */
|
||||
interface PaginatingQueryRecord<T = any> extends PaginatingCommonParams {
|
||||
records: T[]
|
||||
}
|
||||
|
||||
/** common search params of table */
|
||||
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'currentPage' | 'pageSize'>
|
||||
|
||||
/**
|
||||
* enable status
|
||||
*
|
||||
* - "1": enabled
|
||||
* - "2": disabled
|
||||
*/
|
||||
type EnableStatus = '1' | '2'
|
||||
|
||||
/** question score type */
|
||||
type QuestionScoreType = '1' | '2' /** 固定分数 | 答题个数 */
|
||||
|
||||
/** competition round type */
|
||||
type CompetitionRoundType = '1' | '2' /** 题包环节 | 加时环节 */
|
||||
|
||||
/** common record */
|
||||
type CommonRecord<T = any> = {
|
||||
/** record id */
|
||||
id: number
|
||||
/** record creator */
|
||||
createBy: string
|
||||
/** record create time */
|
||||
createTime: string
|
||||
/** record updater */
|
||||
updateBy: string
|
||||
/** record update time */
|
||||
updateTime: string
|
||||
/** record status */
|
||||
status: EnableStatus | null
|
||||
} & T
|
||||
}
|
||||
}
|
||||
18
apps/admin/src/typings/components.d.ts
vendored
18
apps/admin/src/typings/components.d.ts
vendored
@ -99,6 +99,8 @@ declare module 'vue' {
|
||||
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
|
||||
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']
|
||||
|
||||
5
apps/admin/src/typings/elegant-router.d.ts
vendored
5
apps/admin/src/typings/elegant-router.d.ts
vendored
@ -33,6 +33,8 @@ declare module "@elegant-router/types" {
|
||||
"rank_rank-list": "/rank/rank-list";
|
||||
"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"
|
||||
>;
|
||||
|
||||
|
||||
3
apps/admin/src/typings/storage.d.ts
vendored
3
apps/admin/src/typings/storage.d.ts
vendored
@ -39,5 +39,8 @@ declare namespace StorageType {
|
||||
}
|
||||
/** The last login user id */
|
||||
lastLoginUserId: string
|
||||
/** The user id */
|
||||
userId: number
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
335
apps/admin/src/utils/data.ts
Normal file
335
apps/admin/src/utils/data.ts
Normal file
@ -0,0 +1,335 @@
|
||||
import { isObject } from './verify'
|
||||
|
||||
export interface GroupKey<T extends Record<string, any>> {
|
||||
/** 子节点的key */
|
||||
children: string
|
||||
/** 根据那个字段分组 */
|
||||
groupKey: keyof T
|
||||
/** 组的id,默认于groupKey相同 */
|
||||
value?: keyof T
|
||||
/** 组名的key,默认于groupKey相同 */
|
||||
label: keyof T
|
||||
}
|
||||
export interface GroupProps { valueKey?: string, labelKey?: string, childrenKey?: string }
|
||||
|
||||
/**
|
||||
* 数组对象去重 (根据某个字段去重)
|
||||
* @param arr - 需要去重的数组
|
||||
* @param key - 需要根据那个字段去重
|
||||
*/
|
||||
export function filterRepeat<T extends Record<number | string | symbol, unknown>, K extends keyof T>(arr: T[], key: K): T[] {
|
||||
const res = new Map<unknown, 1>()
|
||||
return arr.filter(item => !res.has(item[key]) && res.set(item[key], 1))
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据key合并数组去掉重复
|
||||
* 有重复的就保留arr2的
|
||||
*/
|
||||
export function mergeArrFun<T extends Record<string, unknown>, K extends keyof T>(arr1: T[], arr2: T[], key: K): T[] {
|
||||
const res = new Map<unknown, T>()
|
||||
arr1.forEach((item) => {
|
||||
res.set(item[key], item)
|
||||
})
|
||||
arr2.forEach((item) => {
|
||||
res.set(item[key], item)
|
||||
})
|
||||
const arr: T[] = []
|
||||
|
||||
res.forEach((val) => {
|
||||
arr.push(val)
|
||||
})
|
||||
return arr
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据路径获取对象的值
|
||||
* @param obj - 对象
|
||||
* @param path - 如 'a.c'只支持点路径,不支持 'a[0]'
|
||||
*/
|
||||
export function getValueByPath(obj: Record<string, unknown>, path: string): unknown {
|
||||
const paths = path.split('.')
|
||||
let res: Record<string, unknown> = obj
|
||||
let result: unknown = ''
|
||||
paths.forEach((item: string) => {
|
||||
if (item in res && typeof res[item] === 'object') {
|
||||
res = res[item] as Record<string, unknown>
|
||||
}
|
||||
else {
|
||||
result = res[item]
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据路径 或一个路径数组得到一个由该路径值组成的一个字符串 (多由于唯一字段)
|
||||
* @param obj - 对象
|
||||
* @param paths - 如 ['a.c','a' 'a.c.d']只支持点路径,不支持 ['a[0]']
|
||||
*/
|
||||
export function getOnlyValue(obj: Record<string, unknown>, paths: string[] | string): string {
|
||||
if (Array.isArray(paths)) {
|
||||
let onlyValue = ''
|
||||
paths.forEach((key) => {
|
||||
onlyValue += `_${String(getValueByPath(obj, key)) || ''}`
|
||||
})
|
||||
return onlyValue
|
||||
}
|
||||
else {
|
||||
return obj[paths] as string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除指定的元素并返回删除后的数组 [用于需要返回新数组而不是直接修改原数组的情况]
|
||||
*/
|
||||
export function mySplice<T>(array: T[], index: number): T[] {
|
||||
return array.slice(0, index).concat(array.slice(index + 1))
|
||||
}
|
||||
|
||||
/**
|
||||
* 在数组后面添加一个元素并返回添加后的数组 [用于需要返回新数组而不是直接修改原数组的情况]
|
||||
*/
|
||||
export function myPush<T>(array: T[], item: T): T[] {
|
||||
return array.concat([item])
|
||||
}
|
||||
|
||||
/**
|
||||
* 特定的值删除数组中的元素并返回删除后的数组 [用于需要返回新数组而不是直接修改原数组的情况]
|
||||
*/
|
||||
export function myDelItem<T>(array: T[], item: T): T[] {
|
||||
const index = array.indexOf(item)
|
||||
return index >= 0 ? mySplice(array, index) : array
|
||||
}
|
||||
|
||||
/**
|
||||
* 得到两个数组的交集
|
||||
*/
|
||||
export function myGetIntersection<T>(arr1: T[], arr2: T[]): T[] {
|
||||
if (Array.isArray(arr1) && Array.isArray(arr2)) {
|
||||
return arr1.filter(item => arr2.includes(item))
|
||||
}
|
||||
else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断两个数组是否有交集
|
||||
*/
|
||||
export function myHaveIntersection<T = unknown>(arr1: T[], arr2: T[]): boolean {
|
||||
if (Array.isArray(arr1) && Array.isArray(arr2)) {
|
||||
const index = arr1.findIndex(item => arr2.includes(item))
|
||||
return index >= 0
|
||||
}
|
||||
else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断两个对象内容是否相等
|
||||
* @param obj1 - 需要判断的对象1
|
||||
* @param obj2 - 需要判断的对象2
|
||||
* @param notComparisonParam - notComparisonParam 不判断对象中的那些字段 [字符串数组]
|
||||
*/
|
||||
export function myEqual(obj1: unknown, obj2: unknown, notComparisonParam: string[] = []): boolean {
|
||||
// 判断两个对象是否指向同一内存或者全等,指向同一内存或全等返回true
|
||||
if (obj1 === obj2) {
|
||||
return true
|
||||
}
|
||||
// 类型相同才比较,不同则直接返回false
|
||||
const obj1Type = typeof obj1
|
||||
const obj2Type = typeof obj2
|
||||
// 注意 null 的typeof 也为 'object'
|
||||
if (obj1Type === obj2Type && obj1 !== null && obj2 !== null) {
|
||||
if (isObject(obj1) && isObject(obj2)) {
|
||||
const a = JSON.parse(JSON.stringify(obj1))
|
||||
const b = JSON.parse(JSON.stringify(obj2))
|
||||
notComparisonParam.forEach((item) => {
|
||||
delete a[item]
|
||||
delete b[item]
|
||||
})
|
||||
// 获取两个对象键值数组
|
||||
const aProps = Object.getOwnPropertyNames(a)
|
||||
const bProps = Object.getOwnPropertyNames(b)
|
||||
// 判断两个对象键值数组长度是否一致,不一致返回false
|
||||
if (aProps.length !== bProps.length) {
|
||||
return false
|
||||
}
|
||||
// 遍历对象的键值
|
||||
for (const prop in a) {
|
||||
// 判断a的键值,在b中是否存在,不存在,返回false
|
||||
if (Object.hasOwn(b, prop)) {
|
||||
// 判断a的键值是否为对象,是则递归,不是对象直接判断键值是否相等,不相等返回false
|
||||
if (isObject(a[prop])) {
|
||||
if (!myEqual(a[prop], b[prop])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
else if (a[prop] !== b[prop]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
else {
|
||||
// 不是对象且上面已经判断了不全等则返回false
|
||||
return false
|
||||
}
|
||||
}
|
||||
else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把字符串转为Json对象
|
||||
*/
|
||||
export function myStrToJson<T = Record<string, unknown>>(obj: T | string): T | undefined {
|
||||
if (typeof obj === 'string') {
|
||||
try {
|
||||
return JSON.parse(obj) as T
|
||||
// oxlint-disable-next-line no-unused-vars
|
||||
}
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
catch (err) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 反转对象的键值对(不会改变原对象)
|
||||
*/
|
||||
export function invertObjKeyValues<T extends Record<string, string>>(obj: T) {
|
||||
const newObj: Record<string, string> = {}
|
||||
for (const key in obj) {
|
||||
const val = obj[key]
|
||||
if (val) {
|
||||
newObj[val] = key
|
||||
}
|
||||
}
|
||||
return newObj
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断两个对象内容是否相等
|
||||
* @param obj1 - 需要判断的对象1
|
||||
* @param obj2 - 需要判断的对象2
|
||||
* @param notComparisonParam - notComparisonParam 不判断对象中的那些字段 [字符串数组]
|
||||
*/
|
||||
export function isEqual(obj1: unknown, obj2: unknown, notComparisonParam: string[] = []): boolean {
|
||||
// 判断两个对象是否指向同一内存或者全等,指向同一内存或全等返回true
|
||||
if (obj1 === obj2) {
|
||||
return true
|
||||
}
|
||||
// 类型相同才比较,不同则直接返回false
|
||||
const obj1Type = typeof obj1
|
||||
const obj2Type = typeof obj2
|
||||
// 注意 null 的typeof 也为 'object'
|
||||
if (obj1Type === obj2Type && obj1 !== null && obj2 !== null) {
|
||||
if (isObject(obj1) && isObject(obj2)) {
|
||||
const a = JSON.parse(JSON.stringify(obj1))
|
||||
const b = JSON.parse(JSON.stringify(obj2))
|
||||
notComparisonParam.forEach((item) => {
|
||||
delete a[item]
|
||||
delete b[item]
|
||||
})
|
||||
// 获取两个对象键值数组
|
||||
const aProps = Object.getOwnPropertyNames(a)
|
||||
const bProps = Object.getOwnPropertyNames(b)
|
||||
// 判断两个对象键值数组长度是否一致,不一致返回false
|
||||
if (aProps.length !== bProps.length) {
|
||||
return false
|
||||
}
|
||||
// 遍历对象的键值
|
||||
for (const prop in a) {
|
||||
// 判断a的键值,在b中是否存在,不存在,返回false
|
||||
if (Object.hasOwn(b, prop)) {
|
||||
// 判断a的键值是否为对象,是则递归,不是对象直接判断键值是否相等,不相等返回false
|
||||
if (isObject(a[prop])) {
|
||||
if (!isEqual(a[prop], b[prop])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
else if (a[prop] !== b[prop]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
else {
|
||||
// 不是对象且上面已经判断了不全等则返回false
|
||||
return false
|
||||
}
|
||||
}
|
||||
else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
let my__OnlyId = 10000
|
||||
|
||||
/** 得到唯一id */
|
||||
export function getOnlyId() {
|
||||
my__OnlyId++
|
||||
return my__OnlyId
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归分组函数
|
||||
* @param data 原始数据数组
|
||||
* @param keys 分组键数组
|
||||
* @returns 分组后的树形结构
|
||||
*/
|
||||
export function groupByKeys<T extends Record<number | string | symbol, any>>(data: T[], groupKeys: GroupKey<T>[]): any[] {
|
||||
// 递归分组函数
|
||||
const groupRecursively = (items: T[], keys: GroupKey<T>[]): any[] => {
|
||||
if (keys.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// 获取当前分组键和剩余键
|
||||
const [currentKey, ...remainingKeys] = keys
|
||||
const { groupKey, value, label, children = 'children' } = currentKey!
|
||||
const valueKey = value || groupKey
|
||||
const labelKey = label || groupKey
|
||||
|
||||
// 使用 reduce 按当前键分组
|
||||
const groupedMap = items.reduce((acc: Map<any, T[]>, item) => {
|
||||
const keyValue = item[valueKey]
|
||||
if (!acc.has(keyValue)) {
|
||||
acc.set(keyValue, [])
|
||||
}
|
||||
acc.get(keyValue)!.push(item)
|
||||
return acc
|
||||
}, new Map())
|
||||
// 构建分组节点
|
||||
return Array.from(groupedMap.entries()).map(([keyValue, groupItems]) => {
|
||||
// 获取当前分组的标签值(取第一条数据的对应字段)
|
||||
const labelValue = groupItems[0]![labelKey]
|
||||
|
||||
// 递归处理下一级分组
|
||||
const list: T[] = groupRecursively(groupItems, remainingKeys)
|
||||
// 构建当前节点
|
||||
return {
|
||||
[valueKey]: keyValue,
|
||||
[labelKey]: labelValue,
|
||||
[children]: list,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return groupRecursively(data, groupKeys)
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import path from 'path-browserify'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
export function getCurrentYear(): Date {
|
||||
return new Date()
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
105
apps/admin/src/utils/upload-util.ts
Normal file
105
apps/admin/src/utils/upload-util.ts
Normal file
@ -0,0 +1,105 @@
|
||||
// 定义文件上传任务类型
|
||||
interface UploadTask<R, K> {
|
||||
myFlie: R
|
||||
resolve: (res: K) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
type UploadFile<R = any, K = any> = (myFlie: R) => Promise<K>
|
||||
|
||||
/**
|
||||
* 文件上传服务类(单例模式),
|
||||
*/
|
||||
export class FileUploadService<R, K> {
|
||||
/** 当前上传的数量 */
|
||||
private currentUploads = 0
|
||||
/** 一次最多上传多少个,剩下的加入队列 */
|
||||
private concurrency = 5
|
||||
/** 上传队列 */
|
||||
private uploadTaskList: UploadTask<R, K>[] = []
|
||||
/** 上传的方法 */
|
||||
private uploadFile: UploadFile<R, K>
|
||||
|
||||
/**
|
||||
* 私有构造函数
|
||||
*/
|
||||
public constructor(_uploadFile: UploadFile, _concurrency?: number) {
|
||||
this.currentUploads = 0
|
||||
this.concurrency = _concurrency || 5 // 默认并发数为2,可以根据需要调整
|
||||
this.uploadTaskList = []
|
||||
this.uploadFile = _uploadFile
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行文件上传
|
||||
* @param filesToUpload - 要上传的文件对象数组
|
||||
* @param concurrency - 并发上传数,默认为构造函数中设置的并发数
|
||||
*/
|
||||
public upload(filesToUpload: R[], concurrency: number = this.concurrency): Promise<PromiseSettledResult<Awaited<K>>[]> {
|
||||
return new Promise<PromiseSettledResult<Awaited<K>>[]>((resolve, reject) => {
|
||||
const uploadPromises = filesToUpload.map(file => this.addUploadTask(file))
|
||||
this.concurrency = concurrency // 更新并发数
|
||||
|
||||
// 返回所有上传任务的 Promise
|
||||
Promise.allSettled(uploadPromises)
|
||||
.then((res) => {
|
||||
resolve(res) // 所有任务完成后调用总体解决函数
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('文件上传过程中出现错误:')
|
||||
reject(err) // 如果有任务失败,直接调用总体拒绝函数
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加上传任务到队列
|
||||
* @param file - 要上传的文件对象
|
||||
*/
|
||||
private addUploadTask(myFlie: R): Promise<K> {
|
||||
return new Promise<K>((resolve, reject) => {
|
||||
if (this.currentUploads < this.concurrency) {
|
||||
// 如果当前上传数小于并发数,则直接上传
|
||||
this.currentUploads++
|
||||
this.uploadFile(myFlie)
|
||||
.then((res: K) => {
|
||||
this.currentUploads--
|
||||
resolve(res)
|
||||
this.processQueue()
|
||||
})
|
||||
.catch((error) => {
|
||||
this.currentUploads--
|
||||
console.error(`上传失败,文件:`, myFlie, '错误====', error)
|
||||
reject(error)
|
||||
this.processQueue()
|
||||
})
|
||||
}
|
||||
else {
|
||||
// 如果当前上传数达到并发数,则加入队列等待
|
||||
this.uploadTaskList.push({ myFlie, resolve, reject })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理上传队列,依次执行上传任务
|
||||
*/
|
||||
private processQueue(): void {
|
||||
if (this.uploadTaskList.length > 0) {
|
||||
const { myFlie, resolve, reject } = this.uploadTaskList.shift()!
|
||||
this.addUploadTask(myFlie)
|
||||
.then((res: K) => {
|
||||
resolve(res) // 在完成上传后,调用任务的 resolve
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止上传
|
||||
*/
|
||||
public abort(): void {
|
||||
this.uploadTaskList = []
|
||||
}
|
||||
}
|
||||
340
apps/admin/src/utils/verify-number.ts
Normal file
340
apps/admin/src/utils/verify-number.ts
Normal file
@ -0,0 +1,340 @@
|
||||
export type $InputVerify =
|
||||
| 'add_float'
|
||||
| 'add_int_or_zero'
|
||||
| 'add_int'
|
||||
| 'all_float'
|
||||
| 'all_int_or_zero'
|
||||
| 'all_int'
|
||||
| 'minus_float'
|
||||
| 'minus_int_or_zero'
|
||||
| 'minus_int'
|
||||
/** ********************整数**********************整数 */
|
||||
/** ********************整数**********************整数 */
|
||||
/** ********************整数**********************整数 */
|
||||
// 正整数(不含0)
|
||||
export const add_int_numb_RegEx = /^[1-9]\d*$/
|
||||
|
||||
// 负整数(不含0)
|
||||
export const minus_int_numb_RegEx = /^-[1-9]\d*$/
|
||||
|
||||
// 整数 ( 不含0(正负都可以)
|
||||
export const all_int_numb_RegEx = /^-?[1-9]\d*$/
|
||||
|
||||
// 正整数(含0)
|
||||
export const add_int_or_zero_numb_RegEx = /^[1-9]\d*$|^0$/
|
||||
|
||||
// 负整数 (含0)
|
||||
export const minus_int_or_zero_numb_RegEx = /^-[1-9]\d*$|^0$/
|
||||
|
||||
// 正负整数 (含0)
|
||||
export const all_int_or_zero_numb_RegEx = /^-?[1-9]\d*$|^0$/
|
||||
|
||||
/** ********************浮点数**********************浮点数 */
|
||||
/** ********************浮点数**********************浮点数 */
|
||||
/** ********************浮点数**********************浮点数 */
|
||||
|
||||
// 正浮点数 (不含0)
|
||||
export const add_float_numb_RegEx = /(^[1-9]\d*|^0)\.\d+$/
|
||||
|
||||
// 负浮点数(不含0)
|
||||
export const minus_float_numb_RegEx = /(^-[1-9]\d*|^-0)\.\d+$/
|
||||
|
||||
// 正浮点数 (含0)
|
||||
export const add_float_ro_zero_numb_RegEx = /(^[1-9]\d*|^0)\.\d+$|^0$/
|
||||
|
||||
// 负浮点数(含0)
|
||||
export const minus_float_ro_zero_numb_RegEx = /(^-[1-9]\d*|^-0)\.\d+$|^0$/
|
||||
|
||||
// 浮点数(包含正负浮点数不含0)
|
||||
export const all_float_numb_RegEx = /(^-?[1-9]\d*|^-?0)\.\d+$/
|
||||
|
||||
// 浮点数(包含正负浮点数和0)
|
||||
export const all_float_or_zero_numb_RegEx = /(^-?[1-9]\d*|^-?0)\.\d+$|^0$/
|
||||
|
||||
// 所有合法数字 (整数,小数,正数,负数,0) 【不推荐用正则验证直接用isNaN(numb)并判断大于小于就可以了】
|
||||
export const all_numb = /(^-?[1-9]\d*|^-?0)(\.\d+)?$/
|
||||
|
||||
/** ********************数字输入时正则**********************数字输入时正则 */
|
||||
/** ********************数字输入时正则**********************数字输入时正则 */
|
||||
/** ********************数字输入时正则**********************数字输入时正则 */
|
||||
|
||||
// 正整数 (不含0) 主要用于输入时验证
|
||||
export const add_int_numb_input_RegEx = /^[1-9]\d*/
|
||||
|
||||
// 负整数(不含0) 主要用于输入时验证
|
||||
export const minus_int_numb_input_RegEx = /^-?[1-9]\d*/
|
||||
|
||||
// 正负整数(不含0) 主要用于输入时验证
|
||||
export const all_int_numb_input_RegEx = /^-?[1-9]\d*/
|
||||
|
||||
// 正整数 (含0) 主要用于输入时验证
|
||||
export const add_int_or_zero_numb_input_RegEx = /^[1-9]\d*$|^0$/
|
||||
|
||||
// 负整数 (含0) 主要用于输入时验证
|
||||
export const minus_int_or_zero_numb_input_RegEx = /^-?[1-9]\d*$|^0/
|
||||
|
||||
// 正负整数(含0) 主要用于输入时验证
|
||||
export const all_int_or_zero_numb_input_RegEx = /^-?\d*/
|
||||
|
||||
// 正浮点数 主要用于输入时验证
|
||||
export const add_float_numb_input_RegEx = /^\d+\.?\d*/
|
||||
|
||||
// 负浮点数 主要用于输入时验证
|
||||
export const minus_float_numb_input_RegEx = /^-?\d*\.?\d*/
|
||||
|
||||
// 正负浮点数 主要用于输入时验证 【这里就可以验证合法的数字输入了包含 0. - -0. 】
|
||||
export const all_float_numb_input_RegEx = /^-?\d*\.?\d*/
|
||||
|
||||
/** *******************数字验证****************数字验证 */
|
||||
/** *******************数字验证****************数字验证 */
|
||||
/** *******************数字验证****************数字验证 */
|
||||
/**
|
||||
* 正整数 (不含0)
|
||||
*/
|
||||
export function verifyAddIntNumb(str: string): boolean {
|
||||
return Boolean(add_int_numb_RegEx.test(str))
|
||||
}
|
||||
|
||||
/**
|
||||
* 负整数(不含0)
|
||||
*/
|
||||
export function verifyMinusIntNumb(str: string): boolean {
|
||||
return Boolean(minus_int_numb_RegEx.test(str))
|
||||
}
|
||||
|
||||
/**
|
||||
* 正负整数(不含0)
|
||||
*/
|
||||
export function verifyAllIntNumb(str: string): boolean {
|
||||
return Boolean(all_int_numb_RegEx.test(str))
|
||||
}
|
||||
|
||||
/**
|
||||
* 正整数 (含0)
|
||||
*/
|
||||
export function verifyAddIntRoZeroNumb(str: string): boolean {
|
||||
return Boolean(add_int_or_zero_numb_RegEx.test(str))
|
||||
}
|
||||
|
||||
/**
|
||||
* 负整数 (含0)
|
||||
*/
|
||||
export function verifyMinusIntRoZeroNumb(str: string): boolean {
|
||||
return Boolean(minus_int_or_zero_numb_RegEx.test(str))
|
||||
}
|
||||
|
||||
/**
|
||||
* 正 负 整数(含0)
|
||||
*/
|
||||
export function verifyAllIntRoZeroNumb(str: string): boolean {
|
||||
return Boolean(all_int_or_zero_numb_RegEx.test(str))
|
||||
}
|
||||
|
||||
/**
|
||||
* 正浮点数
|
||||
*/
|
||||
export function verifyAddFloatNumb(str: string): boolean {
|
||||
return Boolean(add_float_numb_RegEx.test(str))
|
||||
}
|
||||
|
||||
/**
|
||||
* 负浮点数
|
||||
*/
|
||||
export function verifyMinusFloatNumb(str: string): boolean {
|
||||
return Boolean(minus_float_numb_RegEx.test(str))
|
||||
}
|
||||
|
||||
/**
|
||||
* 正负浮点数
|
||||
*/
|
||||
export function verifyAllsFloatNumb(str: string): boolean {
|
||||
return Boolean(all_float_numb_RegEx.test(str))
|
||||
}
|
||||
|
||||
/** *******************输入时验证****************输入时验证 */
|
||||
/** *******************输入时验证****************输入时验证 */
|
||||
/** *******************输入时验证****************输入时验证 */
|
||||
|
||||
/**
|
||||
* 正整数 (不含0) 主要用于输入时验证
|
||||
*/
|
||||
export function verifyAddIntNumbInput(str: number | string): string {
|
||||
let numb = String(str)
|
||||
numb = String(Number.parseInt(numb))
|
||||
const numbArr = numb.match(add_int_numb_input_RegEx)
|
||||
numb = Array.isArray(numbArr) ? numbArr[0] : ''
|
||||
numb = numb.replace(/^0+/g, '0')
|
||||
return numb
|
||||
}
|
||||
|
||||
/**
|
||||
* 负整数(不含0) 主要用于输入时验证
|
||||
*/
|
||||
export function verifyMinusIntNumbInput(str: number | string): string {
|
||||
let numb = String(str)
|
||||
numb = String(Number.parseInt(numb))
|
||||
const numbArr = numb.match(minus_int_numb_input_RegEx)
|
||||
numb = Array.isArray(numbArr) ? numbArr[0] : ''
|
||||
numb = numb.replace(/^0+/g, '0')
|
||||
numb = numb !== '' && numb !== '0' && numb.startsWith('-') ? numb : `-${numb}`
|
||||
return numb
|
||||
}
|
||||
|
||||
/**
|
||||
* 正负整数(不含0) 主要用于输入时验证
|
||||
*/
|
||||
export function verifyAllIntNumbInput(str: number | string): string {
|
||||
let numb = String(str)
|
||||
numb = String(Number.parseInt(numb))
|
||||
const numbArr = numb.match(all_int_numb_input_RegEx)
|
||||
numb = Array.isArray(numbArr) ? numbArr[0] : ''
|
||||
numb = numb.replace(/^0+/g, '0')
|
||||
return numb
|
||||
}
|
||||
|
||||
/**
|
||||
* 正整数 (含0) 主要用于输入时验证
|
||||
*/
|
||||
export function verifyAddIntRoZeroNumbInput(str: number | string): string {
|
||||
let numb = String(str)
|
||||
numb = String(Number.parseInt(numb))
|
||||
const numbArr = numb.match(minus_int_or_zero_numb_input_RegEx)
|
||||
numb = Array.isArray(numbArr) ? numbArr[0] : ''
|
||||
numb = numb.replace(/^0+/g, '0')
|
||||
return numb
|
||||
}
|
||||
|
||||
/**
|
||||
* 负整数 (含0) 主要用于输入时验证
|
||||
*/
|
||||
export function verifyMinusIntRoZeroNumbInput(str: number | string): string {
|
||||
const numbArr = String(str).match(minus_int_or_zero_numb_input_RegEx)
|
||||
let numb = Array.isArray(numbArr) ? numbArr[0] : ''
|
||||
numb = numb.replace(/^0+/g, '0')
|
||||
numb = numb.replace(/^-0+/g, '0')
|
||||
numb = numb !== '' && numb !== '0' && numb.startsWith('-') ? numb : `-${numb}`
|
||||
return numb
|
||||
}
|
||||
|
||||
/**
|
||||
* 正 负 整数(含0) 主要用于输入时验证
|
||||
*/
|
||||
export function verifyAllIntRoZeroNumbInput(str: number | string): string {
|
||||
const numbArr = String(str).match(all_int_or_zero_numb_input_RegEx)
|
||||
let numb = Array.isArray(numbArr) ? numbArr[0] : ''
|
||||
numb = numb.replace(/^0+/g, '0')
|
||||
numb = numb.replace(/^-0+/g, '-')
|
||||
numb = !Number.isNaN(Number(numb)) ? String(Number(numb)) : numb
|
||||
return numb
|
||||
}
|
||||
|
||||
/**
|
||||
* 正浮点数 主要用于输入时验证
|
||||
*/
|
||||
export function verifyAddFloatNumbInput(str: number | string): string {
|
||||
let numb = str
|
||||
if (str === '.') {
|
||||
numb = '0.'
|
||||
}
|
||||
else {
|
||||
const numbArr = String(numb).match(add_float_numb_input_RegEx)
|
||||
numb = Array.isArray(numbArr) ? numbArr[0] : ''
|
||||
numb = numb.replace(/^0+/g, '0')
|
||||
numb = numb.replace(/^0+\./g, '0.')
|
||||
numb = numb.match(/^0+[1-9]+/) ? numb.replace(/^0+/g, '') : numb
|
||||
}
|
||||
return numb
|
||||
}
|
||||
|
||||
/**
|
||||
* 负浮点数 主要用于输入时验证
|
||||
*/
|
||||
export function verifyMinusFloatNumbInput(str: number | string): string {
|
||||
let numb = str
|
||||
if (str === '.') {
|
||||
numb = '-0.'
|
||||
}
|
||||
else {
|
||||
const numbArr = String(str).match(minus_float_numb_input_RegEx)
|
||||
numb = Array.isArray(numbArr) ? numbArr[0] : ''
|
||||
numb = numb.replace(/^0+/g, '0')
|
||||
numb = numb.replace(/^-0+/g, '-0')
|
||||
numb = numb.replace(/^0+\./g, '0.')
|
||||
numb = numb.replace(/^-\./g, '-0.')
|
||||
numb = numb.match(/^0+[1-9]+/) ? numb.replace(/^0+/g, '') : numb
|
||||
numb = numb !== '' && numb !== '0' && numb.startsWith('-') ? numb : `-${numb}`
|
||||
}
|
||||
return numb
|
||||
}
|
||||
|
||||
/**
|
||||
* 正负浮点数 主要用于输入时验证
|
||||
*/
|
||||
export function verifyAllFloatNumbInput(str: number | string): string {
|
||||
let numb = str
|
||||
if (str === '.') {
|
||||
numb = '0.'
|
||||
}
|
||||
else {
|
||||
const numbArr = String(str).match(all_float_numb_input_RegEx)
|
||||
numb = Array.isArray(numbArr) ? numbArr[0] : ''
|
||||
numb = numb.replace(/^0+/g, '0')
|
||||
numb = numb.replace(/^-0+/g, '-0')
|
||||
numb = numb.replace(/^0+\./g, '0.')
|
||||
numb = numb.replace(/^-\./g, '-0.')
|
||||
numb = numb.match(/^0+[1-9]+/) ? numb.replace(/^0+/g, '') : numb
|
||||
}
|
||||
return numb
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证数字
|
||||
* @param verify - 验证类型
|
||||
* @param numb - 需要验证的数字
|
||||
* @param oldNumb - 修改之前的
|
||||
*/
|
||||
export function verifyNumbInput(verify: $InputVerify | undefined, numb: number | string, oldNumb: number | string) {
|
||||
let newNumb = Number.isNaN(Number(numb)) && numb !== '.' && numb !== '-' ? oldNumb : numb
|
||||
if (verify === 'add_int') {
|
||||
// 正整数 (不含0) 主要用于输入时验证
|
||||
newNumb = verifyAddIntNumbInput(newNumb)
|
||||
}
|
||||
else if (verify === 'minus_int') {
|
||||
// 负整数(不含0) 主要用于输入时验证
|
||||
newNumb = verifyMinusIntNumbInput(newNumb)
|
||||
}
|
||||
else if (verify === 'all_int') {
|
||||
// 正负整数(不含0) 主要用于输入时验证
|
||||
newNumb = verifyAllIntNumbInput(newNumb)
|
||||
}
|
||||
else if (verify === 'add_int_or_zero') {
|
||||
// 正整数 (含0) 主要用于输入时验证
|
||||
newNumb = verifyAddIntRoZeroNumbInput(newNumb)
|
||||
}
|
||||
else if (verify === 'minus_int_or_zero') {
|
||||
// 负整数 (含0) 主要用于输入时验证
|
||||
newNumb = verifyMinusIntRoZeroNumbInput(newNumb)
|
||||
}
|
||||
else if (verify === 'all_int_or_zero') {
|
||||
// 正负整数(含0) 主要用于输入时验证
|
||||
newNumb = verifyAllIntRoZeroNumbInput(newNumb)
|
||||
}
|
||||
else if (verify === 'add_float') {
|
||||
// 正浮点数 主要用于输入时验证
|
||||
newNumb = verifyAddFloatNumbInput(newNumb)
|
||||
}
|
||||
else if (verify === 'minus_float') {
|
||||
// 负浮点数 主要用于输入时验证
|
||||
newNumb = verifyMinusFloatNumbInput(newNumb)
|
||||
}
|
||||
else if (verify === 'all_float') {
|
||||
// 正负浮点数 主要用于输入时验证
|
||||
newNumb = verifyAllFloatNumbInput(newNumb)
|
||||
}
|
||||
if (newNumb !== '') {
|
||||
return newNumb
|
||||
}
|
||||
else {
|
||||
return !Number.isNaN(Number(oldNumb)) ? oldNumb : ''
|
||||
}
|
||||
}
|
||||
162
apps/admin/src/utils/verify.ts
Normal file
162
apps/admin/src/utils/verify.ts
Normal file
@ -0,0 +1,162 @@
|
||||
/** 双字节字符(包括汉字在内,即占两个英文字符位置的字符) */
|
||||
export const double_byte_RegEx = /[^\x00-\xFF]/ // eslint-disable-line no-control-regex
|
||||
|
||||
/** 中文字符 */
|
||||
export const chinese_RegEx = /[\u4E00-\u9FA5]/
|
||||
|
||||
/** 密码至少包含字母、数字、特殊字符中的两种,且不能出现4个大小连续或相同的数字 */
|
||||
export const password_RegEx
|
||||
// eslint-disable-next-line regexp/optimal-quantifier-concatenation
|
||||
= /^(?!.*(.)\1{3})(?!.*(0123|1234|2345|3456|4567|5678|6789|9876|8765|7654|6543|5432|4321))(?=.*[a-z])(?=.*\d|.*[!@#¥%&*.,])[a-z\d!@#¥%&*.,]+$/i
|
||||
|
||||
/** 密码至少包含字母、数字、特殊字符中的两种,且不能出现4个大小连续或相同的数字,且为 8到 24 位 */
|
||||
export const password_RegEx2
|
||||
// eslint-disable-next-line regexp/optimal-quantifier-concatenation
|
||||
= /^(?!.*(.)\1{3})(?!.*(0123|1234|2345|3456|4567|5678|6789|9876|8765|7654|6543|5432|4321))(?=.*[a-z])(?=.*\d|.*[!@#¥%&*.,])[a-z\d!@#¥%&*.,]{8,24}$/i
|
||||
/** 验证电话号 */
|
||||
export const tel_RegEx = /^1\d{10}$/
|
||||
|
||||
/** 邮箱 */
|
||||
export const email_RegEx = /^([\w-]+)@([\w-]+\.?)*\w\.[a-z]{2,10}$/i
|
||||
|
||||
/** 网址(不带参数) */
|
||||
export const url_RegEx = /^(https?:\/\/)([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(\/\S*)?$/
|
||||
/** 网址(带参数) */
|
||||
export const url_RegEx2 = /^(https?:\/\/)([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(\/[^\s?#]*)?(\?[^?\s#]*)?(#\S*)?$/
|
||||
|
||||
/** 身份证 */
|
||||
export const IDcard_RegEx = /^(\d{6})(\d{4})(\d{2})(\d{2})(\d{3})([0-9X])$/i
|
||||
|
||||
/** 视频 */
|
||||
// eslint-disable-next-line regexp/no-dupe-disjunctions
|
||||
export const video_RegEx = /\.(mp4|mpg|mpeg|dat|asf|avi|rm|rmvb|mov|wmv|flv|mkv|m3u8)/i
|
||||
|
||||
/**
|
||||
* 验证密码合法性
|
||||
*/
|
||||
export function verifyPassword(str: string): boolean {
|
||||
return Boolean(password_RegEx.test(str))
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证电话号
|
||||
*/
|
||||
export function verifyTel(str: string): boolean {
|
||||
return Boolean(tel_RegEx.test(str))
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证邮箱
|
||||
*/
|
||||
export function verifyEmail(str: string): boolean {
|
||||
return Boolean(email_RegEx.test(str))
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证身份证
|
||||
*/
|
||||
export function verifyIDcard(str: string): boolean {
|
||||
if (!IDcard_RegEx.test(str)) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
// 出生年月日校验
|
||||
const year = str.slice(6, 10) // 身份证年
|
||||
const month = str.slice(10, 12) // 身份证月
|
||||
const date = str.slice(12, 14) // 身份证日
|
||||
const d = new Date(`${year}/${month}/${date}`)
|
||||
const dY = Number(d.getFullYear())
|
||||
const dM = Number(d.getMonth()) + 1
|
||||
const dD = Number(d.getDate())
|
||||
if (Number(year) === dY && Number(month) === dM && Number(date) === dD) {
|
||||
// 限制起始年份为1850且不能超过今天
|
||||
if (d.getTime() <= new Date().getTime() && dY >= 1850) {
|
||||
return true
|
||||
}
|
||||
else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
else {
|
||||
return false
|
||||
}
|
||||
// oxlint-disable-next-line no-unused-vars
|
||||
}
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
catch (_) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证网址
|
||||
* @param str - 网址
|
||||
* @param isParam - 可以携带参数 默认为 true
|
||||
*/
|
||||
export function verifyUrl(str: string, isParam = true): boolean {
|
||||
return Boolean(isParam ? url_RegEx2.test(str) : url_RegEx.test(str))
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为纯对象 不包含 Array,undefined function 等等
|
||||
*/
|
||||
export function isObject(obj: unknown): obj is Record<number | string | symbol, unknown> {
|
||||
return Object.prototype.toString.call(obj) === '[object Object]'
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为dom
|
||||
*/
|
||||
export function isDOM(obj: unknown): obj is HTMLElement {
|
||||
if (typeof HTMLElement === 'object') {
|
||||
return obj instanceof HTMLElement
|
||||
}
|
||||
else if (obj && typeof obj === 'object') {
|
||||
return (obj as Record<string, unknown>).nodeType === 1 && typeof (obj as Record<string, unknown>).nodeName === 'string'
|
||||
}
|
||||
else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为日期对象
|
||||
*/
|
||||
export function isDate(obj: unknown): obj is Date {
|
||||
return Object.prototype.toString.call(obj) === '[object Date]'
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为Math对象
|
||||
*/
|
||||
export function iIsMath(obj: unknown): obj is Math {
|
||||
return Object.prototype.toString.call(obj) === '[object Math]'
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为RegExp对象
|
||||
*/
|
||||
export function isRegExp(obj: unknown): obj is RegExp {
|
||||
return Object.prototype.toString.call(obj) === '[object RegExp]'
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为json对象
|
||||
*/
|
||||
export function isJson(obj: unknown): obj is Record<string, unknown> | unknown[] {
|
||||
return Boolean(isObject(obj) || Array.isArray(obj))
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为空对象(传入对象为假也代表是空对象,如false,null,0等)
|
||||
*/
|
||||
export function isNullObj(obj: unknown): boolean {
|
||||
return Boolean(!obj || JSON.stringify(obj) === '{}')
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为小屏幕
|
||||
*/
|
||||
export function isSmallScreen(): boolean {
|
||||
return document.documentElement.clientWidth < 900
|
||||
}
|
||||
@ -20,7 +20,7 @@ interface FormModel {
|
||||
}
|
||||
|
||||
const model: FormModel = reactive({
|
||||
userName: 'Admin',
|
||||
userName: '111111',
|
||||
password: '123456',
|
||||
})
|
||||
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
8
apps/admin/src/views/template/template-detail/index.vue
Normal file
8
apps/admin/src/views/template/template-detail/index.vue
Normal file
@ -0,0 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="">
|
||||
<h1>模板详情</h1>
|
||||
</div>
|
||||
</template>
|
||||
@ -1,23 +1,24 @@
|
||||
<script setup lang="tsx">
|
||||
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
|
||||
@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
import { jsonClone } from '@sa/utils'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { enableStatusOptions } from '@/constants/business'
|
||||
import { useFormRules, useNaiveForm } from '@/hooks/common/form'
|
||||
|
||||
defineOptions({
|
||||
name: 'TemplateOperateDrawer',
|
||||
})
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
interface Props {
|
||||
operateType: NaiveUI.TableOperateType
|
||||
rowData?: Api.SystemManage.User | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted'): void
|
||||
}
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false,
|
||||
})
|
||||
|
||||
const { formRef, validate, restoreValidation } = useNaiveForm()
|
||||
const { defaultRequiredRule } = useFormRules()
|
||||
|
||||
const title = computed(() => {
|
||||
const titles: Record<NaiveUI.TableOperateType, string> = {
|
||||
add: '新增模板',
|
||||
edit: '编辑模板',
|
||||
}
|
||||
return titles[props.operateType]
|
||||
})
|
||||
|
||||
type Model = Pick<
|
||||
Api.SystemManage.User,
|
||||
'userName' | 'userGender' | 'nickName' | 'userPhone' | 'userEmail' | 'userRoles' | 'status'
|
||||
>
|
||||
|
||||
const model = ref(createDefaultModel())
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
userName: '',
|
||||
userGender: null,
|
||||
nickName: '',
|
||||
userPhone: '',
|
||||
userEmail: '',
|
||||
userRoles: [],
|
||||
status: null,
|
||||
}
|
||||
}
|
||||
|
||||
type RuleKey = Extract<keyof Model, 'userName' | 'status'>
|
||||
|
||||
const rules: Record<RuleKey, App.Global.FormRule> = {
|
||||
userName: defaultRequiredRule,
|
||||
status: defaultRequiredRule,
|
||||
}
|
||||
|
||||
function handleInitModel() {
|
||||
model.value = createDefaultModel()
|
||||
|
||||
if (props.operateType === 'edit' && props.rowData) {
|
||||
Object.assign(model.value, jsonClone(props.rowData))
|
||||
}
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await validate()
|
||||
// request
|
||||
window.$message?.success('更新成功')
|
||||
closeDrawer()
|
||||
emit('submitted')
|
||||
}
|
||||
|
||||
watch(visible, () => {
|
||||
if (visible.value) {
|
||||
handleInitModel()
|
||||
restoreValidation()
|
||||
}
|
||||
})
|
||||
|
||||
const competitionOptions = [
|
||||
{ label: '第九届阅读之星大赛', value: '1' },
|
||||
{ label: '科普阅读大赛', value: '2' },
|
||||
]
|
||||
|
||||
const sizeOptions = [
|
||||
{ label: '210mmx297mm', value: '1' },
|
||||
{ label: 'A3', value: '2' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDrawer v-model:show="visible" display-directive="show" :width="360">
|
||||
<NDrawerContent :title="title" :native-scrollbar="false" closable>
|
||||
<NForm ref="formRef" :model="model" :rules="rules">
|
||||
<NFormItem label="名称" path="userName">
|
||||
<NInput v-model:value="model.userName" placeholder="请输入名称" />
|
||||
</NFormItem>
|
||||
<NFormItem label="模板ID" path="nickName">
|
||||
<NInput v-model:value="model.nickName" placeholder="请输入模板ID" />
|
||||
</NFormItem>
|
||||
<NFormItem label="关联比赛" path="userGender">
|
||||
<NSelect
|
||||
v-model:value="model.userGender"
|
||||
:options="competitionOptions"
|
||||
placeholder="请选择关联比赛"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem label="设计尺寸" path="userPhone">
|
||||
<NSelect
|
||||
v-model:value="model.userPhone"
|
||||
:options="sizeOptions"
|
||||
placeholder="请选择设计尺寸"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem label="状态" path="status">
|
||||
<NRadioGroup v-model:value="model.status">
|
||||
<NRadio v-for="item in enableStatusOptions" :key="item.value" :value="item.value" :label="item.label" />
|
||||
</NRadioGroup>
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
<template #footer>
|
||||
<NSpace :size="16">
|
||||
<NButton @click="closeDrawer">
|
||||
取消
|
||||
</NButton>
|
||||
<NButton type="primary" @click="handleSubmit">
|
||||
确认
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { jsonClone } from '@sa/utils'
|
||||
import { toRaw } from 'vue'
|
||||
import { enableStatusOptions } from '@/constants/business'
|
||||
import { useNaiveForm } from '@/hooks/common/form'
|
||||
|
||||
defineOptions({
|
||||
name: 'TemplateSearch',
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
interface Emits {
|
||||
(e: 'search'): void
|
||||
}
|
||||
|
||||
const { formRef, validate, restoreValidation } = useNaiveForm()
|
||||
|
||||
const model = defineModel<Api.Template.TemplateSearchParams>('model', { required: true })
|
||||
|
||||
const defaultModel = jsonClone(toRaw(model.value))
|
||||
|
||||
function resetModel() {
|
||||
Object.assign(model.value, defaultModel)
|
||||
}
|
||||
|
||||
async function reset() {
|
||||
await restoreValidation()
|
||||
resetModel()
|
||||
}
|
||||
|
||||
async function search() {
|
||||
await validate()
|
||||
emit('search')
|
||||
}
|
||||
|
||||
const competitionOptions = [
|
||||
{ label: '第九届阅读之星大赛', value: '1' },
|
||||
{ label: '科普阅读大赛', value: '2' },
|
||||
]
|
||||
|
||||
const sizeOptions = [
|
||||
{ label: '210mmx297mm', value: '1' },
|
||||
{ label: 'A3', value: '2' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCard :bordered="false" size="small" class="card-wrapper">
|
||||
<NCollapse>
|
||||
<NCollapseItem title="基础信息" name="template-search">
|
||||
<NForm ref="formRef" :model="model" label-placement="left" :label-width="80">
|
||||
<NGrid responsive="screen" item-responsive>
|
||||
<NFormItemGi span="24 s:12 m:6" label="模板名称" path="templateName" class="pr-24px">
|
||||
<NInput v-model:value="model.templateName" placeholder="请输入名称" />
|
||||
</NFormItemGi>
|
||||
<NFormItemGi span="24 s:12 m:6" label="关联比赛" path="competitionId" class="pr-24px">
|
||||
<NSelect
|
||||
v-model:value="model.competitionId" placeholder="请选择" :options="competitionOptions as any"
|
||||
clearable
|
||||
/>
|
||||
</NFormItemGi>
|
||||
<NFormItemGi span="24 s:12 m:6" label="状态" path="status" class="pr-24px">
|
||||
<NSelect v-model:value="model.status" placeholder="请选择" :options="enableStatusOptions as any" clearable />
|
||||
</NFormItemGi>
|
||||
<NFormItemGi span="24 s:12 m:6" label="尺寸" path="size" class="pr-24px">
|
||||
<NSelect v-model:value="model.size" placeholder="请选择" :options="sizeOptions" clearable />
|
||||
</NFormItemGi>
|
||||
<NFormItemGi span="24" class="pr-24px">
|
||||
<NSpace class="w-full" justify="end">
|
||||
<NButton @click="reset">
|
||||
<template #icon>
|
||||
<icon-ic-round-refresh class="text-icon" />
|
||||
</template>
|
||||
重置
|
||||
</NButton>
|
||||
<NButton type="primary" ghost @click="search">
|
||||
<template #icon>
|
||||
<icon-ic-round-search class="text-icon" />
|
||||
</template>
|
||||
搜索
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NFormItemGi>
|
||||
</NGrid>
|
||||
</NForm>
|
||||
</NCollapseItem>
|
||||
</NCollapse>
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
641
pnpm-lock.yaml
generated
641
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user