feat(admin): 新增题库、排行榜、实时结果和模板管理功能

- 添加题库管理页面及相关组件
- 实现排行榜管理功能,包括列表和详情页
- 新增实时结果展示页面
- 添加模板制作和管理功能
- 完善路由配置和国际化支持
- 新增阿里云OSS文件上传服务
- 添加多种工具函数和类型定义
- 优化表格和表单组件
- 调整布局和样式细节
This commit is contained in:
2026-01-22 17:55:45 +08:00
parent 697d606611
commit f1dacf7c66
25 changed files with 1001 additions and 945 deletions

View File

@ -1,33 +1,33 @@
<template>
<div>
<NModal v-model:show="visible" preset="card" title="第三方在线编辑器" style="width: 90vw" @after-leave="hide">
<div style="height: 80vh; overflow: hidden">
<iframe src="https://www.processon.com/latex" style="width: 100%; height: calc(100% + 50px); margin-top: -50px; border: none"></iframe>
</div>
</NModal>
</div>
</template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { NModal } from 'naive-ui'
import { NModal } from 'naive-ui'; import { ref } from 'vue'
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'hide'): void; (e: 'hide'): void
}>(); }>()
const visible = ref(false); const visible = ref(false)
function show() { function show() {
visible.value = true; visible.value = true
} }
function hide() { function hide() {
visible.value = false; visible.value = false
emit('hide'); emit('hide')
} }
defineExpose({ defineExpose({
show, show,
hide, hide,
}); })
</script> </script>
<template>
<div>
<NModal v-model:show="visible" preset="card" title="第三方在线编辑器" style="width: 90vw" @after-leave="hide">
<div style="height: 80vh; overflow: hidden">
<iframe src="https://www.processon.com/latex" style="width: 100%; height: calc(100% + 50px); margin-top: -50px; border: none" />
</div>
</NModal>
</div>
</template>

View File

@ -1,10 +1,29 @@
<script setup lang="ts">
import type { UpFile } from '../types'
import { NModal, NProgress, NScrollbar } from 'naive-ui'
import { ref, watch } from 'vue'
const props = defineProps<{
/** 上传文件列表 */
upFileList: UpFile[]
}>()
const visible = ref(false)
watch(
() => props.upFileList.length,
(len) => {
visible.value = len > 0
},
)
</script>
<template> <template>
<div> <div>
<NModal v-model:show="visible" preset="card" title="上传进度" style="width: 600px"> <NModal v-model:show="visible" preset="card" title="上传进度" style="width: 600px">
<NScrollbar style="max-height: 700px"> <NScrollbar style="max-height: 700px">
<div class="list"> <div class="list">
<div v-for="item in props.upFileList" :key="item.id" class="item"> <div v-for="item in props.upFileList" :key="item.id" class="item">
<NProgress type="circle" :percentage="item.progress" :indicator-placement="'inside'" :radius="40" /> <NProgress type="circle" :percentage="item.progress" indicator-placement="inside" :radius="40" />
<span class="name">{{ item.name }}</span> <span class="name">{{ item.name }}</span>
</div> </div>
</div> </div>
@ -13,25 +32,6 @@
</div> </div>
</template> </template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { NModal, NScrollbar, NProgress } from 'naive-ui';
import type { UpFile } from '../types';
const props = defineProps<{
/** 上传文件列表 */
upFileList: UpFile[];
}>();
const visible = ref(false);
watch(
() => props.upFileList.length,
(len) => {
visible.value = len > 0;
}
);
</script>
<style lang="scss" scoped> <style lang="scss" scoped>
.list { .list {
display: flex; display: flex;

View File

@ -1,62 +1,62 @@
import { getAliOssTokenAxios } from '@/service/api/upload'; import type { IEditorConfig, ToolbarOptions } from '@opentiny/fluent-editor/types/config/types'
import { browserPathJoin } from '@/utils/date'; import type OSS from 'ali-oss'
import { initOSSClient, uploadFileToOSS } from '@/utils/oss'; import type { GetEditorConfigParams, MyToolbarOption, UpFile } from './types'
import type { IEditorConfig, ToolbarOptions } from '@opentiny/fluent-editor/types/config/types'; import { default as FluentEditor, generateToolbarTip, type MathliveModule, type Range } from '@opentiny/fluent-editor'
import type OSS from 'ali-oss'; import katex from 'katex'
import katex from 'katex'; import QuillToolbarTip, { type QuillToolbarTipOptions } from 'quill-toolbar-tip'
import { default as FluentEditor, type MathliveModule, type Range, generateToolbarTip } from '@opentiny/fluent-editor'; import { ref } from 'vue'
import QuillToolbarTip, { type QuillToolbarTipOptions } from 'quill-toolbar-tip'; import { getAliOssTokenAxios } from '@/service/api/upload'
import { base64ToFile } from '@/utils/file'; import { browserPathJoin } from '@/utils/date'
import { getSnowflake } from '@/utils/rest'; import { base64ToFile } from '@/utils/file'
import type { GetEditorConfigParams, MyToolbarOption, UpFile } from './types'; import { initOSSClient, uploadFileToOSS } from '@/utils/oss'
import { ref } from 'vue'; import { getSnowflake } from '@/utils/rest'
/** 自定义打开iframe的按钮的key */ /** 自定义打开iframe的按钮的key */
export const MY_OPEN_PROCESSON_IFRAME = 'my-open-processon-iframe' as const; export const MY_OPEN_PROCESSON_IFRAME = 'my-open-processon-iframe' as const
export const upFileList = ref<UpFile[]>([]); export const upFileList = ref<UpFile[]>([])
/** /**
* 初始化编辑器 * 初始化编辑器
*/ */
export function initEditor(container: HTMLElement, params: IEditorConfig): FluentEditor { export function initEditor(container: HTMLElement, params: IEditorConfig): FluentEditor {
// 需要依赖 katex // 需要依赖 katex
window.katex = katex; window.katex = katex
// 处理图片给每个图片添加类名和最大宽度 // 处理图片给每个图片添加类名和最大宽度
const ImageBlot: any = FluentEditor.import('formats/image'); const ImageBlot: any = FluentEditor.import('formats/image')
class CustomImageBlot extends ImageBlot { class CustomImageBlot extends ImageBlot {
static create(value: any) { static create(value: any) {
const node: HTMLImageElement = super.create(value); const node: HTMLImageElement = super.create(value)
editImageAttribute(node, false); editImageAttribute(node, false)
return node; return node
} }
} }
FluentEditor.register(CustomImageBlot, true); FluentEditor.register(CustomImageBlot, true)
// 处理视频给每个视频添加类名和最大宽度 // 处理视频给每个视频添加类名和最大宽度
const VideoBlot: any = FluentEditor.import('formats/video'); const VideoBlot: any = FluentEditor.import('formats/video')
class CustomVideoBlot extends VideoBlot { class CustomVideoBlot extends VideoBlot {
static create(value: any) { static create(value: any) {
const node: HTMLVideoElement = super.create(value); const node: HTMLVideoElement = super.create(value)
editVideoAttribute(node, false); editVideoAttribute(node, false)
return node; return node
} }
} }
FluentEditor.register(CustomVideoBlot, true); FluentEditor.register(CustomVideoBlot, true)
// 注册工具栏提示模块 // 注册工具栏提示模块
FluentEditor.register({ 'modules/toolbar-tip': generateToolbarTip(QuillToolbarTip) }, true); FluentEditor.register({ 'modules/toolbar-tip': generateToolbarTip(QuillToolbarTip) }, true)
const Parchment = FluentEditor.import('parchment'); const Parchment = FluentEditor.import('parchment')
addToolbarIcon(); addToolbarIcon()
// 居中问题 // 居中问题
const config = { scope: Parchment.Scope.BLOCK, whitelist: ['right', 'center', 'justify'] }; const config = { scope: Parchment.Scope.BLOCK, whitelist: ['right', 'center', 'justify'] }
new Parchment.StyleAttributor('align', 'text-align', config); new Parchment.StyleAttributor('align', 'text-align', config)
const Align = FluentEditor.import('attributors/style/align'); const Align = FluentEditor.import('attributors/style/align')
FluentEditor.register(Align, true); FluentEditor.register(Align, true)
// 工具栏配置 // 工具栏配置
return new FluentEditor(container, params); return new FluentEditor(container, params)
} }
/** /**
@ -67,53 +67,54 @@ export async function getEditorConfig(params: GetEditorConfigParams): Promise<IE
theme: 'snow', theme: 'snow',
modules: { modules: {
// 是否开启数学公式模块 // 是否开启数学公式模块
mathlive: true, 'mathlive': true,
// 开启字数统计 // 开启字数统计
// counter: { // counter: {
// count: 2000, // count: 2000,
// }, // },
toolbar: { 'toolbar': {
// 工具栏显示那些按钮 // 工具栏显示那些按钮
container: await getToolbarOption(params.toolbarOption || {}), container: await getToolbarOption(params.toolbarOption || {}),
handlers: { handlers: {
formula() { formula() {
// 点击数学公式按钮时打开弹出框 // 点击数学公式按钮时打开弹出框
const mathlive = this.quill.getModule('mathlive') as MathliveModule; const mathlive = this.quill.getModule('mathlive') as MathliveModule
mathlive.createDialog(''); mathlive.createDialog('')
}, },
// 自定义打开iframe的按钮的点击事件的回调 // 自定义打开iframe的按钮的点击事件的回调
[`${MY_OPEN_PROCESSON_IFRAME}`](_value: boolean) { [`${MY_OPEN_PROCESSON_IFRAME}`](_value: boolean) {
params[MY_OPEN_PROCESSON_IFRAME](); // 执行回调 params[MY_OPEN_PROCESSON_IFRAME]() // 执行回调
}, },
}, },
}, },
uploader: { 'uploader': {
// only allow image // only allow image
mimetypes: ['*', 'video/*', 'audio/*'], mimetypes: ['*', 'video/*', 'audio/*'],
async handler(_range: Range, files: File[]): Promise<(string | false)[]> { async handler(_range: Range, files: File[]): Promise<(string | false)[]> {
try { try {
const paths = await Promise.all(files.map((file, index) => upFileAxios(file, `${Date.now()}_${index}`))); const paths = await Promise.all(files.map((file, index) => upFileAxios(file, `${Date.now()}_${index}`)))
return paths; return paths
} catch (error) { }
console.log('error====', error); catch (error) {
return files.map(() => false); console.log('error====', error)
return files.map(() => false)
} }
}, },
}, },
clipboard: { 'clipboard': {
matchers: [ matchers: [
[ [
'img', 'img',
(node: HTMLImageElement, delta: any) => { (node: HTMLImageElement, delta: any) => {
editImageAttribute(node, false); editImageAttribute(node, false)
return delta; return delta
}, },
], ],
[ [
'video', 'video',
(node: HTMLVideoElement, delta: any) => { (node: HTMLVideoElement, delta: any) => {
editVideoAttribute(node, false); editVideoAttribute(node, false)
return delta; return delta
}, },
], ],
], ],
@ -128,28 +129,28 @@ export async function getEditorConfig(params: GetEditorConfigParams): Promise<IE
}, },
} satisfies Partial<QuillToolbarTipOptions>, } satisfies Partial<QuillToolbarTipOptions>,
// 国际化配置 // 国际化配置
i18n: { lang: 'zh-CN' }, 'i18n': { lang: 'zh-CN' },
}, },
}; }
return editorOptions; return editorOptions
} }
/** /**
* 得到工具栏配置(显示那些按钮) * 得到工具栏配置(显示那些按钮)
*/ */
function getToolbarOption(params: MyToolbarOption): Promise<ToolbarOptions['container']> { function getToolbarOption(params: MyToolbarOption): Promise<ToolbarOptions['container']> {
const { show, showUpFile, showUpVideo, customToolbar } = params; const { show, showUpFile, showUpVideo, customToolbar } = params
if (show === false) { if (show === false) {
return Promise.resolve([] satisfies ToolbarOptions['container']); return Promise.resolve([] satisfies ToolbarOptions['container'])
} }
if (customToolbar !== undefined) { if (customToolbar !== undefined) {
return Promise.resolve(customToolbar); return Promise.resolve(customToolbar)
} }
const va = ['image']; const va = ['image']
showUpVideo !== false && va.push('video'); // 只要不等于false就添加 (undefined也添加) showUpVideo !== false && va.push('video') // 只要不等于false就添加 (undefined也添加)
showUpFile !== false && va.push('file'); // 只要不等于false就添加 (undefined也添加) showUpFile !== false && va.push('file') // 只要不等于false就添加 (undefined也添加)
// 工具栏配置 // 工具栏配置
const TOOLBAR_CONFIG: ToolbarOptions['container'] = [ const TOOLBAR_CONFIG: ToolbarOptions['container'] = [
['undo', 'redo', 'clean', 'format-painter'], ['undo', 'redo', 'clean', 'format-painter'],
@ -157,7 +158,7 @@ function getToolbarOption(params: MyToolbarOption): Promise<ToolbarOptions['cont
{ header: [1, 2, 3, 4, 5, 6, false] }, { header: [1, 2, 3, 4, 5, 6, false] },
{ size: [false, '12px', '14px', '16px', '18px', '20px', '24px', '32px', '36px', '48px', '72px'] }, { size: [false, '12px', '14px', '16px', '18px', '20px', '24px', '32px', '36px', '48px', '72px'] },
{ 'line-height': [false, '1.2', '1.5', '1.75', '2', '3', '4', '5'] }, { 'line-height': [false, '1.2', '1.5', '1.75', '2', '3', '4', '5'] },
{ 'font': [false, 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', '宋体', '黑体', '微软雅黑', '楷体', '仿宋', '等线'] }, { font: [false, 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', '宋体', '黑体', '微软雅黑', '楷体', '仿宋', '等线'] },
], ],
['bold', 'italic', 'strike', 'underline', 'divider'], ['bold', 'italic', 'strike', 'underline', 'divider'],
[{ color: [] }, { background: [] }], [{ color: [] }, { background: [] }],
@ -168,8 +169,8 @@ function getToolbarOption(params: MyToolbarOption): Promise<ToolbarOptions['cont
['link', 'blockquote'], ['link', 'blockquote'],
[...va], [...va],
['fullscreen', 'formula', MY_OPEN_PROCESSON_IFRAME], ['fullscreen', 'formula', MY_OPEN_PROCESSON_IFRAME],
]; ]
return Promise.resolve(TOOLBAR_CONFIG); return Promise.resolve(TOOLBAR_CONFIG)
} }
/** /**
@ -177,49 +178,50 @@ function getToolbarOption(params: MyToolbarOption): Promise<ToolbarOptions['cont
*/ */
function addToolbarIcon() { function addToolbarIcon() {
// 这里时增加按钮 // 这里时增加按钮
const myOpenProcessonIframeIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path fill="currentColor" d="M6.5 1a1.5 1.5 0 0 0-1.415 1H4.5A1.5 1.5 0 0 0 3 3.5v10A1.5 1.5 0 0 0 4.5 15h1.651a1.5 1.5 0 0 1-.111-1H4.5a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h.585A1.5 1.5 0 0 0 6.5 4h3a1.5 1.5 0 0 0 1.415-1h.585a.5.5 0 0 1 .5.5v1.57q.346.079.681.247.178.09.319.217V3.5A1.5 1.5 0 0 0 11.5 2h-.585A1.5 1.5 0 0 0 9.5 1zM6 2.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5m3.451 5.277a1.916 1.916 0 0 1 2.778-1.568.5.5 0 0 1-.452.892.916.916 0 0 0-1.328.75l-.087 1.172H11.5a.5.5 0 0 1 0 1h-1.212l-.237 3.2a1.916 1.916 0 0 1-2.777 1.567.5.5 0 1 1 .452-.892.916.916 0 0 0 1.328-.75l.231-3.125H8.5a.5.5 0 1 1 0-1h.86zm5.403 4.077a.5.5 0 0 0-.708-.708l-1.057 1.057-.457-.734a.994.994 0 0 0-1.424-.281.5.5 0 0 0 .579.815l.576.926-1.217 1.217a.5.5 0 0 0 .708.708l1.052-1.053.457.735a.99.99 0 0 0 1.442.264.5.5 0 0 0-.599-.801l-.574-.924z"/></svg>`; const myOpenProcessonIframeIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path fill="currentColor" d="M6.5 1a1.5 1.5 0 0 0-1.415 1H4.5A1.5 1.5 0 0 0 3 3.5v10A1.5 1.5 0 0 0 4.5 15h1.651a1.5 1.5 0 0 1-.111-1H4.5a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h.585A1.5 1.5 0 0 0 6.5 4h3a1.5 1.5 0 0 0 1.415-1h.585a.5.5 0 0 1 .5.5v1.57q.346.079.681.247.178.09.319.217V3.5A1.5 1.5 0 0 0 11.5 2h-.585A1.5 1.5 0 0 0 9.5 1zM6 2.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5m3.451 5.277a1.916 1.916 0 0 1 2.778-1.568.5.5 0 0 1-.452.892.916.916 0 0 0-1.328.75l-.087 1.172H11.5a.5.5 0 0 1 0 1h-1.212l-.237 3.2a1.916 1.916 0 0 1-2.777 1.567.5.5 0 1 1 .452-.892.916.916 0 0 0 1.328-.75l.231-3.125H8.5a.5.5 0 1 1 0-1h.86zm5.403 4.077a.5.5 0 0 0-.708-.708l-1.057 1.057-.457-.734a.994.994 0 0 0-1.424-.281.5.5 0 0 0 .579.815l.576.926-1.217 1.217a.5.5 0 0 0 .708.708l1.052-1.053.457.735a.99.99 0 0 0 1.442.264.5.5 0 0 0-.599-.801l-.574-.924z"/></svg>`
const icons = FluentEditor.import('ui/icons') as Record<string, string>; const icons = FluentEditor.import('ui/icons') as Record<string, string>
icons[MY_OPEN_PROCESSON_IFRAME] = myOpenProcessonIframeIcon; icons[MY_OPEN_PROCESSON_IFRAME] = myOpenProcessonIframeIcon
} }
/** /**
* 上传文件 * 上传文件
*/ */
async function upFileAxios(file: File, id: string): Promise<string | false> { async function upFileAxios(file: File, id: string): Promise<string | false> {
const item: UpFile = { id, name: file.name, progress: 0 }; const item: UpFile = { id, name: file.name, progress: 0 }
try { try {
upFileList.value.push(item); upFileList.value.push(item)
const tokenRes = await getAliOssTokenAxios(); const tokenRes = await getAliOssTokenAxios()
// 初始化OSS客户端 // 初始化OSS客户端
const client = initOSSClient(tokenRes); const client = initOSSClient(tokenRes)
const path = browserPathJoin(`temp/${Date.now()}`, file!.name); const path = browserPathJoin(`temp/${Date.now()}`, file!.name)
// 上传文件 // 上传文件
const res: OSS.MultipartUploadResult = await uploadFileToOSS(client, file, path, (progress) => { const res: OSS.MultipartUploadResult = await uploadFileToOSS(client, file, path, (progress) => {
item.progress = Math.floor(progress * 10000) / 100; item.progress = Math.floor(progress * 10000) / 100
}); })
const url = browserPathJoin(import.meta.env.VITE_BASE_OSS_URL, res.name); const url = browserPathJoin(import.meta.env.VITE_BASE_OSS_URL, res.name)
upFileList.value.splice(upFileList.value.indexOf(item), 1); upFileList.value.splice(upFileList.value.indexOf(item), 1)
return Promise.resolve(url); return Promise.resolve(url)
// oxlint-disable-next-line no-unused-vars // oxlint-disable-next-line no-unused-vars
} catch (error) { }
upFileList.value.splice(upFileList.value.indexOf(item), 1); catch (error) {
window.$message?.error('上传失败'); upFileList.value.splice(upFileList.value.indexOf(item), 1)
return Promise.resolve(false); window.$message?.error('上传失败')
return Promise.resolve(false)
} }
} }
/** 编辑视频属性 */ /** 编辑视频属性 */
export function editVideoAttribute(node: HTMLVideoElement, isReplace = true) { export function editVideoAttribute(node: HTMLVideoElement, isReplace = true) {
node.setAttribute('style', 'max-width: 100%;'); // 强制内联样式 node.setAttribute('style', 'max-width: 100%;') // 强制内联样式
node.classList.add('my-ql-video'); node.classList.add('my-ql-video')
isReplace && replaceFileUrl(node); isReplace && replaceFileUrl(node)
} }
/** 编辑图片属性 */ /** 编辑图片属性 */
export function editImageAttribute(node: HTMLImageElement, isReplace = true) { export function editImageAttribute(node: HTMLImageElement, isReplace = true) {
node.setAttribute('style', 'max-width: 100%;'); // 强制内联样式 node.setAttribute('style', 'max-width: 100%;') // 强制内联样式
node.classList.add('my-ql-image'); node.classList.add('my-ql-image')
isReplace && replaceFileUrl(node); isReplace && replaceFileUrl(node)
} }
/** /**
@ -227,10 +229,10 @@ export function editImageAttribute(node: HTMLImageElement, isReplace = true) {
* @param node * @param node
*/ */
export function replaceFileUrl(node: HTMLImageElement | HTMLVideoElement) { export function replaceFileUrl(node: HTMLImageElement | HTMLVideoElement) {
const ex = getExtensionFromDataUrl(node.src); const ex = getExtensionFromDataUrl(node.src)
if (ex) { if (ex) {
const file = base64ToFile(node.src, `${Date.now()}_${getSnowflake()}.${ex}`); const file = base64ToFile(node.src, `${Date.now()}_${getSnowflake()}.${ex}`)
upFileAxios(file, file.name); upFileAxios(file, file.name)
} }
} }
@ -245,7 +247,7 @@ const mimeToExtension: Record<string, string> = {
'audio/mpeg': 'mp3', 'audio/mpeg': 'mp3',
'application/pdf': 'pdf', 'application/pdf': 'pdf',
'text/plain': 'txt', 'text/plain': 'txt',
}; }
/** /**
* 从 base64 获取文件扩展名 * 从 base64 获取文件扩展名
@ -253,11 +255,12 @@ const mimeToExtension: Record<string, string> = {
* @param mimeMap * @param mimeMap
*/ */
function getExtensionFromDataUrl(dataUrl: string, mimeMap = mimeToExtension): string | null { function getExtensionFromDataUrl(dataUrl: string, mimeMap = mimeToExtension): string | null {
const base64Pattern = /^data:(image|video)\/([a-zA-Z]+);base64,([A-Za-z0-9+/]+={0,2})$/; const base64Pattern = /^data:(image|video)\/([a-zA-Z]+);base64,([A-Za-z0-9+/]+={0,2})$/
if (base64Pattern.test(dataUrl)) { if (base64Pattern.test(dataUrl)) {
const mimeType = dataUrl.split(',')?.[0]?.split(':')?.[1]?.split(';')[0]; const mimeType = dataUrl.split(',')?.[0]?.split(':')?.[1]?.split(';')[0]
return mimeType && mimeType in mimeMap ? mimeMap[mimeType]! : null; return mimeType && mimeType in mimeMap ? mimeMap[mimeType]! : null
} else { }
return null; else {
return null
} }
} }

View File

@ -1,236 +1,244 @@
<script setup lang="ts">
import type { IEditorConfig, ToolbarOptions } from '@opentiny/fluent-editor/types/config/types'
import type { PropType } from 'vue'
import type MathIframeDailog from './components/math-iframe-dialog.vue'
import type { MyToolbarOption } from './types'
import FluentEditor from '@opentiny/fluent-editor'
import { nextTick, onMounted, ref, watch } from 'vue'
import UpFileDialog from './components/up-file-dialog.vue'
import { editImageAttribute, editVideoAttribute, getEditorConfig, initEditor, MY_OPEN_PROCESSON_IFRAME, upFileList } from './editor-util'
import 'mathlive'
import 'mathlive/static.css'
import 'mathlive/fonts.css'
import '@opentiny/fluent-editor/style.css'
import 'katex/dist/katex.min.css'
import 'quill-toolbar-tip/dist/index.css'
const props = defineProps({
/** 输入内容 */
modelValue: { type: String, default: '' },
/** 工具栏配置 */
toolbarOption: { type: Object as PropType<MyToolbarOption>, default: undefined },
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const editorRef = useTemplateRef<HTMLDivElement>('editorRef')
const mathIframeDalogRef = useTemplateRef<InstanceType<typeof MathIframeDailog>>('mathIframeDalogRef')
const editorContainerRef = useTemplateRef<HTMLDivElement>('editorContainerRef')
const showToolbar = ref(true)
let editor: FluentEditor | undefined
watch(
() => props.modelValue,
(newValue) => {
if (editor && newValue) {
if (newValue.startsWith('<p><br></p>') || newValue.startsWith('<p></p>')) {
emit('update:modelValue', '')
editor!.root.innerHTML = ''
return
}
nextTick(() => {
if (editor!.root.innerHTML !== newValue) {
editor!.root.innerHTML = newValue
}
})
}
},
)
onMounted(async () => {
// 得到编辑器配置
const editorOptions: IEditorConfig = await getEditorConfig({
toolbarOption: props.toolbarOption,
/** 点击自定义打开iframe的按钮的回调 */
[MY_OPEN_PROCESSON_IFRAME]: () => {
mathIframeDalogRef.value?.show()
},
})
const container = (editorOptions!.modules!.toolbar as ToolbarOptions).container || []
showToolbar.value = !(container && Array.isArray(container) && container.length > 0)
// 初始化编辑器
editor = initEditor(editorRef.value!, editorOptions)
// 设置内容
editor.root.innerHTML = props.modelValue
setTimeout(() => {
const ImageBlot: any = FluentEditor.import('formats/image')
// 处理图片给每个图片添加类名和最大宽度
editor?.scroll.descendants(ImageBlot).forEach((imageBlot: any) => {
const node = imageBlot.domNode as HTMLImageElement
editImageAttribute(node, true)
})
// 处理视频给每个视频添加类名和最大宽度
const VideoBlot: any = FluentEditor.import('formats/video')
editor?.scroll.descendants(VideoBlot).forEach((videoBlot: any) => {
const node = videoBlot.domNode as HTMLVideoElement
editVideoAttribute(node, true)
})
}, 10)
editor?.on('text-change', () => {
nextTick(() => {
emit('update:modelValue', editor!.root.innerHTML)
})
})
})
/**
* 弹出框关闭时弹出mathlive输入框
*/
function hideIframeDialog() {
const butt = (editorContainerRef.value?.querySelector('.ql-toolbar.ql-snow .ql-formats button.ql-formula') || null) as HTMLButtonElement | null
butt?.click()
}
</script>
<template> <template>
<div ref="editorContainerRef" class="rest-basic-editor" :class="{ 'hide-toolbar': showToolbar }"> <div ref="editorContainerRef" class="rest-basic-editor" :class="{ 'hide-toolbar': showToolbar }">
<div ref="editorRef"></div> <div ref="editorRef" />
<math-iframe-dialog ref="mathIframeDalogRef" @hide="hideIframeDialog"></math-iframe-dialog> <MathIframeDialog ref="mathIframeDalogRef" @hide="hideIframeDialog" />
<up-file-dialog :upFileList="upFileList"></up-file-dialog> <UpFileDialog :up-file-list="upFileList" />
</div> </div>
</template> </template>
<script setup lang="ts">
import 'mathlive';
import FluentEditor from '@opentiny/fluent-editor';
import type { IEditorConfig, ToolbarOptions } from '@opentiny/fluent-editor/types/config/types';
import type MathIframeDailog from './components/math-iframe-dialog.vue';
import { MY_OPEN_PROCESSON_IFRAME, editImageAttribute, editVideoAttribute, getEditorConfig, initEditor, upFileList } from './editor-util';
import UpFileDialog from './components/up-file-dialog.vue';
import type { PropType } from 'vue';
import type { MyToolbarOption } from './types';
import 'mathlive/static.css';
import 'mathlive/fonts.css';
import '@opentiny/fluent-editor/style.css';
import 'katex/dist/katex.min.css';
import 'quill-toolbar-tip/dist/index.css';
import { ref, watch, onMounted, nextTick } from 'vue';
const props = defineProps({
/** 输入内容 */
modelValue: { type: String, default: '' },
/** 工具栏配置 */
toolbarOption: { type: Object as PropType<MyToolbarOption>, default: undefined },
});
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
}>();
const editorRef = useTemplateRef<HTMLDivElement>('editorRef');
const mathIframeDalogRef = useTemplateRef<InstanceType<typeof MathIframeDailog>>('mathIframeDalogRef');
const editorContainerRef = useTemplateRef<HTMLDivElement>('editorContainerRef');
const showToolbar = ref(true);
let editor: FluentEditor | undefined = undefined;
watch(
() => props.modelValue,
(newValue) => {
if (editor && newValue) {
if (newValue.startsWith('<p><br></p>') || newValue.startsWith('<p></p>')) {
emit('update:modelValue', '');
editor!.root.innerHTML = '';
return;
}
nextTick(() => {
if (editor!.root.innerHTML !== newValue) {
editor!.root.innerHTML = newValue;
}
});
}
}
);
onMounted(async () => {
// 得到编辑器配置
const editorOptions: IEditorConfig = await getEditorConfig({
toolbarOption: props.toolbarOption,
/** 点击自定义打开iframe的按钮的回调 */
[MY_OPEN_PROCESSON_IFRAME]: () => {
mathIframeDalogRef.value?.show();
},
});
const container = (editorOptions!.modules!.toolbar as ToolbarOptions).container || [];
showToolbar.value = !(container && Array.isArray(container) && container.length > 0);
// 初始化编辑器
editor = initEditor(editorRef.value!, editorOptions);
// 设置内容
editor.root.innerHTML = props.modelValue;
setTimeout(() => {
const ImageBlot: any = FluentEditor.import('formats/image');
// 处理图片给每个图片添加类名和最大宽度
editor?.scroll.descendants(ImageBlot).forEach((imageBlot: any) => {
const node = imageBlot.domNode as HTMLImageElement;
editImageAttribute(node, true);
});
// 处理视频给每个视频添加类名和最大宽度
const VideoBlot: any = FluentEditor.import('formats/video');
editor?.scroll.descendants(VideoBlot).forEach((videoBlot: any) => {
const node = videoBlot.domNode as HTMLVideoElement;
editVideoAttribute(node, true);
});
}, 10);
editor?.on('text-change', () => {
nextTick(() => {
emit('update:modelValue', editor!.root.innerHTML);
});
});
});
/**
* 弹出框关闭时弹出mathlive输入框
*/
function hideIframeDialog() {
const butt = (editorContainerRef.value?.querySelector('.ql-toolbar.ql-snow .ql-formats button.ql-formula') || null) as HTMLButtonElement | null;
butt?.click();
}
</script>
<style lang="scss"> <style lang="scss">
/* stylelint-disable selector-attribute-name-disallowed-list */ /* stylelint-disable selector-attribute-name-disallowed-list */
.ql-toolbar.ql-snow { .ql-toolbar.ql-snow {
// 行高 // 行高
.ql-picker.ql-line-height .ql-picker-label:before, .ql-picker.ql-line-height .ql-picker-label:before,
.ql-picker.ql-line-height .ql-picker-item:before { .ql-picker.ql-line-height .ql-picker-item:before {
content: '行高(系统默认)'; content: '行高(系统默认)';
}
.ql-formats .ql-line-height.ql-picker .ql-picker-label[data-value]:before,
.ql-formats .ql-line-height.ql-picker .ql-picker-item[data-value]:before {
content: attr(data-value) '倍';
}
// 字体
.ql-picker.ql-font .ql-picker-label:before,
.ql-picker.ql-font .ql-picker-item:before {
content: '字体(系统默认)';
}
.ql-formats .ql-font.ql-picker .ql-picker-label[data-value]:before,
.ql-formats .ql-font.ql-picker .ql-picker-item[data-value]:before {
content: attr(data-value);
}
// 字号
.ql-picker.ql-size .ql-picker-label:before,
.ql-picker.ql-size .ql-picker-item:before {
content: '字号(系统默认)';
}
.ql-formats .ql-size.ql-picker .ql-picker-label[data-value]:before,
.ql-formats .ql-size.ql-picker .ql-picker-item[data-value]:before {
content: attr(data-value);
}
// 标题
.ql-picker.ql-header .ql-picker-label:before,
.ql-picker.ql-header .ql-picker-item:before {
content: '标题(默认)';
}
.ql-formats .ql-header.ql-picker .ql-picker-label[data-value]:before,
.ql-formats .ql-header.ql-picker .ql-picker-item[data-value]:before {
content: '标题(H' attr(data-value) ')';
}
} }
.ql-formats .ql-line-height.ql-picker .ql-picker-label[data-value]:before,
.ql-formats .ql-line-height.ql-picker .ql-picker-item[data-value]:before {
content: attr(data-value) '倍';
}
// 字体
.ql-picker.ql-font .ql-picker-label:before,
.ql-picker.ql-font .ql-picker-item:before {
content: '字体(系统默认)';
}
.ql-formats .ql-font.ql-picker .ql-picker-label[data-value]:before,
.ql-formats .ql-font.ql-picker .ql-picker-item[data-value]:before {
content: attr(data-value);
}
// 字号
.ql-picker.ql-size .ql-picker-label:before,
.ql-picker.ql-size .ql-picker-item:before {
content: '字号(系统默认)';
}
.ql-formats .ql-size.ql-picker .ql-picker-label[data-value]:before,
.ql-formats .ql-size.ql-picker .ql-picker-item[data-value]:before {
content: attr(data-value);
}
// 标题
.ql-picker.ql-header .ql-picker-label:before,
.ql-picker.ql-header .ql-picker-item:before {
content: '标题(默认)';
}
.ql-formats .ql-header.ql-picker .ql-picker-label[data-value]:before,
.ql-formats .ql-header.ql-picker .ql-picker-item[data-value]:before {
content: '标题(H' attr(data-value) ')';
}
}
</style> </style>
<style lang="scss" scoped> <style lang="scss" scoped>
.rest-basic-editor { .rest-basic-editor {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
height: 100%; height: 100%;
&.hide-toolbar :deep(.ql-toolbar) { &.hide-toolbar :deep(.ql-toolbar) {
display: none; display: none;
}
:deep() {
.ql-container.ql-snow .toolbar-tip__tooltip {
position: fixed;
} }
:deep() { .ql-container.ql-snow {
.ql-container.ql-snow .toolbar-tip__tooltip { display: flex;
position: fixed; flex: 1;
flex-direction: column;
width: 100%;
min-height: 0;
overflow-y: auto;
}
.ql-editor {
overflow-y: visible;
}
.ql-toolbar.ql-snow > .ql-formats {
position: relative;
padding: 6px 10px;
margin: 0;
margin-right: 0;
&:before {
position: absolute;
top: 0;
left: 0;
width: 1px;
height: 100%;
content: '';
background: linear-gradient(
to bottom,
transparent 0%,
transparent 26%,
#dcdada 20%,
#dcdada 80%,
transparent 74%,
transparent 100%
);
} }
.ql-container.ql-snow { .ql-picker {
display: flex; width: auto;
flex: 1;
flex-direction: column; .ql-picker-label {
width: 100%; &:before {
min-height: 0; width: auto;
overflow-y: auto; padding-right: 14px;
}
.icon {
right: 6px;
}
}
} }
.ql-editor { &:first-child {
overflow-y: visible; padding-left: 0;
}
.ql-toolbar.ql-snow > .ql-formats {
position: relative;
padding: 6px 10px;
margin: 0;
margin-right: 0;
&:before { &:before {
position: absolute; display: none;
top: 0; width: 0;
left: 0; height: 0;
width: 1px;
height: 100%;
content: '';
background: linear-gradient(to bottom, transparent 0%, transparent 26%, #dcdada 20%, #dcdada 80%, transparent 74%, transparent 100%);
}
.ql-picker {
width: auto;
.ql-picker-label {
&:before {
width: auto;
padding-right: 14px;
}
.icon {
right: 6px;
}
}
}
&:first-child {
padding-left: 0; padding-left: 0;
background: transparent;
&:before {
display: none;
width: 0;
height: 0;
padding-left: 0;
background: transparent;
}
} }
}
> .ql-picker.ql-expanded .ql-picker-options { > .ql-picker.ql-expanded .ql-picker-options {
z-index: 3; z-index: 3;
width: auto; width: auto;
}
} }
} }
} }
}
</style> </style>

View File

@ -1,23 +1,23 @@
import type { ToolbarOptions } from '@opentiny/fluent-editor'; import type { ToolbarOptions } from '@opentiny/fluent-editor'
import type { MY_OPEN_PROCESSON_IFRAME } from './editor-util'; import type { MY_OPEN_PROCESSON_IFRAME } from './editor-util'
export type MyToolbarOption = { export interface MyToolbarOption {
/** 是否显示工具栏 */ /** 是否显示工具栏 */
show?: boolean; show?: boolean
/** 是否显示上传文件按钮 */ /** 是否显示上传文件按钮 */
showUpFile?: boolean; showUpFile?: boolean
/** 是否显示上传视频按钮 */ /** 是否显示上传视频按钮 */
showUpVideo?: boolean; showUpVideo?: boolean
/** 自定义工具栏 */ /** 自定义工具栏 */
customToolbar?: ToolbarOptions['container']; customToolbar?: ToolbarOptions['container']
}; }
export type GetEditorConfigParams = { export interface GetEditorConfigParams {
toolbarOption?: MyToolbarOption; toolbarOption?: MyToolbarOption
[MY_OPEN_PROCESSON_IFRAME]: () => void; [MY_OPEN_PROCESSON_IFRAME]: () => void
}; }
export type UpFile = { export interface UpFile {
id: string; id: string
name: string; name: string
progress: number; progress: number
}; }

View File

@ -5,14 +5,17 @@
## 1. 快速开始 ## 1. 快速开始
### 1.1 使用 Iconify 图标 (推荐) ### 1.1 使用 Iconify 图标 (推荐)
本项目集成了 [Iconify](https://iconify.design/),可直接使用海量开源图标库(如 Material Design, Carbon, Phosphor 等)。 本项目集成了 [Iconify](https://iconify.design/),可直接使用海量开源图标库(如 Material Design, Carbon, Phosphor 等)。
**语法** **语法**
```vue ```vue
<SvgIcon icon="图集名:图标名" /> <SvgIcon icon="图集名:图标名" />
``` ```
**示例** **示例**
```vue ```vue
<!-- Material Design Icons --> <!-- Material Design Icons -->
<SvgIcon icon="mdi:home" class="text-xl text-blue-500" /> <SvgIcon icon="mdi:home" class="text-xl text-blue-500" />
@ -22,18 +25,21 @@
``` ```
### 1.2 使用本地 SVG 图标 ### 1.2 使用本地 SVG 图标
当需要使用设计师提供的自定义图标或彩色图标时。 当需要使用设计师提供的自定义图标或彩色图标时。
1. **存放**:将 `.svg` 文件放入 `src/assets/svg-icon/` 目录。 1. **存放**:将 `.svg` 文件放入 `src/assets/svg-icon/` 目录。
2. **引用**:使用 `local-icon` 属性引用文件名(不含 `.svg` 后缀)。 2. **引用**:使用 `local-icon` 属性引用文件名(不含 `.svg` 后缀)。
**语法** **语法**
```vue ```vue
<SvgIcon local-icon="文件名" /> <SvgIcon local-icon="文件名" />
``` ```
**示例** **示例**
假设文件位于 `src/assets/svg-icon/custom-logo.svg` 假设文件位于 `src/assets/svg-icon/custom-logo.svg`
```vue ```vue
<SvgIcon local-icon="custom-logo" class="text-32px" /> <SvgIcon local-icon="custom-logo" class="text-32px" />
``` ```
@ -44,12 +50,13 @@
该组件定义在 `src/components/custom/svg-icon.vue` 该组件定义在 `src/components/custom/svg-icon.vue`
| 属性名 | 类型 | 必填 | 默认值 | 说明 | | 属性名 | 类型 | 必填 | 默认值 | 说明 |
| :--- | :--- | :--- | :--- | :--- | | :---------- | :------- | :--- | :----- | :------------------------------------------- |
| `icon` | `string` | 否 | - | Iconify 图标名称,格式为 `collection:name`。 | | `icon` | `string` | 否 | - | Iconify 图标名称,格式为 `collection:name`。 |
| `localIcon` | `string` | 否 | - | 本地 SVG 文件名。**优先级高于 `icon`**。 | | `localIcon` | `string` | 否 | - | 本地 SVG 文件名。**优先级高于 `icon`**。 |
> **提示** > **提示**
>
> - 组件设置了 `inheritAttrs: false`,但会手动绑定 `class` 和 `style` 到根元素。 > - 组件设置了 `inheritAttrs: false`,但会手动绑定 `class` 和 `style` 到根元素。
> - 你可以通过 Tailwind CSS 类名(如 `text-xl`, `text-red-500`)直接控制图标的大小和颜色。 > - 你可以通过 Tailwind CSS 类名(如 `text-xl`, `text-red-500`)直接控制图标的大小和颜色。
@ -60,29 +67,34 @@
推荐使用 [Icones.js.org](https://icones.js.org/) 图标搜索引擎。 推荐使用 [Icones.js.org](https://icones.js.org/) 图标搜索引擎。
### 3.1 查找步骤 ### 3.1 查找步骤
1. 打开 [Icones.js.org](https://icones.js.org/)。 1. 打开 [Icones.js.org](https://icones.js.org/)。
2. 输入关键词搜索(如 `user`, `setting`)。 2. 输入关键词搜索(如 `user`, `setting`)。
3. 点击选中的图标,复制底部的 **ID**(例如 `mdi:shield-airplane-outline`)。 3. 点击选中的图标,复制底部的 **ID**(例如 `mdi:shield-airplane-outline`)。
### 3.2 使用示例对照 ### 3.2 使用示例对照
| Icones 图标 ID | 在本项目中的写法 (推荐) | | Icones 图标 ID | 在本项目中的写法 (推荐) |
| :--- | :--- | | :--------------------------------------------- | :---------------------------------------------------------------- |
| `mdi:shield-airplane-outline` | `<SvgIcon icon="mdi:shield-airplane-outline" />` | | `mdi:shield-airplane-outline` | `<SvgIcon icon="mdi:shield-airplane-outline" />` |
| `material-symbols:android-wifi-4-bar-question` | `<SvgIcon icon="material-symbols:android-wifi-4-bar-question" />` | | `material-symbols:android-wifi-4-bar-question` | `<SvgIcon icon="material-symbols:android-wifi-4-bar-question" />` |
| `solar:minimize-square-minimalistic-outline` | `<SvgIcon icon="solar:minimize-square-minimalistic-outline" />` | | `solar:minimize-square-minimalistic-outline` | `<SvgIcon icon="solar:minimize-square-minimalistic-outline" />` |
| `tabler:align-box-top-right` | `<SvgIcon icon="tabler:align-box-top-right" />` | | `tabler:align-box-top-right` | `<SvgIcon icon="tabler:align-box-top-right" />` |
--- ---
## 4. 常见问题与进阶 ## 4. 常见问题与进阶
### 4.1 什么是 `icon-ic-baseline-refresh` ### 4.1 什么是 `icon-ic-baseline-refresh`
在项目中(如 `competition-add/index.vue`)你可能会看到这种写法: 在项目中(如 `competition-add/index.vue`)你可能会看到这种写法:
```vue ```vue
<icon-ic-baseline-refresh class="text-icon" /> <icon-ic-baseline-refresh class="text-icon" />
``` ```
这是 `unplugin-icons` 插件提供的**自动组件导入**功能。 这是 `unplugin-icons` 插件提供的**自动组件导入**功能。
- **命名规则**`{Prefix}-{Collection}-{Name}` - **命名规则**`{Prefix}-{Collection}-{Name}`
- **配置来源**`.env` 文件中的 `VITE_ICON_PREFIX=icon` - **配置来源**`.env` 文件中的 `VITE_ICON_PREFIX=icon`
- **解析**`icon-ic-baseline-refresh` 对应图集 `ic` 下的 `baseline-refresh` 图标。 - **解析**`icon-ic-baseline-refresh` 对应图集 `ic` 下的 `baseline-refresh` 图标。
@ -90,6 +102,7 @@
**建议**:虽然支持这种写法,但为了统一性和灵活性(支持动态变量),**推荐统一使用 `<SvgIcon />` 组件**。 **建议**:虽然支持这种写法,但为了统一性和灵活性(支持动态变量),**推荐统一使用 `<SvgIcon />` 组件**。
### 4.2 在 Render 函数中使用 (TSX) ### 4.2 在 Render 函数中使用 (TSX)
在 Naive UI 的 `NTree`, `NDataTable``NMenu` 等需要渲染函数的场景中: 在 Naive UI 的 `NTree`, `NDataTable``NMenu` 等需要渲染函数的场景中:
```ts ```ts
@ -98,16 +111,16 @@ import SvgIcon from '@/components/custom/svg-icon.vue'
// 渲染 Iconify 图标 // 渲染 Iconify 图标
function renderIcon() { function renderIcon() {
return h(SvgIcon, { return h(SvgIcon, {
icon: 'carbon:folder', icon: 'carbon:folder',
class: 'text-gray-500' class: 'text-gray-500'
}) })
} }
// 渲染本地图标 // 渲染本地图标
function renderLocalIcon() { function renderLocalIcon() {
return h(SvgIcon, { return h(SvgIcon, {
localIcon: 'custom-logo' localIcon: 'custom-logo'
}) })
} }
``` ```

View File

@ -1,29 +1,29 @@
import { transformRecordToOption } from '@/utils/common'; import { transformRecordToOption } from '@/utils/common'
export const enableStatusRecord: Record<Api.Common.EnableStatus, string> = { export const enableStatusRecord: Record<Api.Common.EnableStatus, string> = {
'1': '启用', 1: '启用',
'2': '禁用' 2: '禁用',
}; }
export const enableStatusOptions = transformRecordToOption(enableStatusRecord); export const enableStatusOptions = transformRecordToOption(enableStatusRecord)
export const userGenderRecord: Record<Api.SystemManage.UserGender, string> = { export const userGenderRecord: Record<Api.SystemManage.UserGender, string> = {
'1': '男', 1: '男',
'2': '女' 2: '女',
}; }
export const userGenderOptions = transformRecordToOption(userGenderRecord); export const userGenderOptions = transformRecordToOption(userGenderRecord)
export const menuTypeRecord: Record<Api.SystemManage.MenuType, string> = { export const menuTypeRecord: Record<Api.SystemManage.MenuType, string> = {
'1': '目录', 1: '目录',
'2': '菜单' 2: '菜单',
}; }
export const menuTypeOptions = transformRecordToOption(menuTypeRecord); export const menuTypeOptions = transformRecordToOption(menuTypeRecord)
export const menuIconTypeRecord: Record<Api.SystemManage.IconType, string> = { export const menuIconTypeRecord: Record<Api.SystemManage.IconType, string> = {
'1': 'iconify', 1: 'iconify',
'2': '本地图标' 2: '本地图标',
}; }
export const menuIconTypeOptions = transformRecordToOption(menuIconTypeRecord); export const menuIconTypeOptions = transformRecordToOption(menuIconTypeRecord)

View File

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

View File

@ -1,12 +1,12 @@
import { request } from '../request'; import { request } from '../request'
/** get role list */ /** get role list */
export function fetchGetRoleList(params?: Api.SystemManage.RoleSearchParams) { export function fetchGetRoleList(params?: Api.SystemManage.RoleSearchParams) {
return request<Api.SystemManage.RoleList>({ return request<Api.SystemManage.RoleList>({
url: '/systemManage/getRoleList', url: '/systemManage/getRoleList',
method: 'get', method: 'get',
params params,
}); })
} }
/** /**
@ -17,8 +17,8 @@ export function fetchGetRoleList(params?: Api.SystemManage.RoleSearchParams) {
export function fetchGetAllRoles() { export function fetchGetAllRoles() {
return request<Api.SystemManage.AllRole[]>({ return request<Api.SystemManage.AllRole[]>({
url: '/systemManage/getAllRoles', url: '/systemManage/getAllRoles',
method: 'get' method: 'get',
}); })
} }
/** get user list */ /** get user list */
@ -26,30 +26,30 @@ export function fetchGetUserList(params?: Api.SystemManage.UserSearchParams) {
return request<Api.SystemManage.UserList>({ return request<Api.SystemManage.UserList>({
url: '/systemManage/getUserList', url: '/systemManage/getUserList',
method: 'get', method: 'get',
params params,
}); })
} }
/** get menu list */ /** get menu list */
export function fetchGetMenuList() { export function fetchGetMenuList() {
return request<Api.SystemManage.MenuList>({ return request<Api.SystemManage.MenuList>({
url: '/systemManage/getMenuList/v2', url: '/systemManage/getMenuList/v2',
method: 'get' method: 'get',
}); })
} }
/** get all pages */ /** get all pages */
export function fetchGetAllPages() { export function fetchGetAllPages() {
return request<string[]>({ return request<string[]>({
url: '/systemManage/getAllPages', url: '/systemManage/getAllPages',
method: 'get' method: 'get',
}); })
} }
/** get menu tree */ /** get menu tree */
export function fetchGetMenuTree() { export function fetchGetMenuTree() {
return request<Api.SystemManage.MenuTree[]>({ return request<Api.SystemManage.MenuTree[]>({
url: '/systemManage/getMenuTree', url: '/systemManage/getMenuTree',
method: 'get' method: 'get',
}); })
} }

View File

@ -1,26 +1,24 @@
import { request } from '../request' import { request } from '../request'
export interface ReqDeleteAliOssFile { export interface ReqDeleteAliOssFile {
key: string; key: string
} }
export type AliOssSTS = { export interface AliOssSTS {
AccessKeyId: string; AccessKeyId: string
AccessKeySecret: string; AccessKeySecret: string
SecurityToken: string; SecurityToken: string
Expiration: string; Expiration: string
BucketName: string; BucketName: string
Region: string; Region: string
}; }
/** 得到上传的token */ /** 得到上传的token */
export function getAliOssTokenAxios(){ export function getAliOssTokenAxios() {
return request<AliOssSTS>({ return request<AliOssSTS>({
url: `${import.meta.env.VITE_BASE_UPLOAD_URL}/admin/v1/oss/sts`, url: `${import.meta.env.VITE_BASE_UPLOAD_URL}/admin/v1/oss/sts`,
method: 'get', method: 'get',
}); })
} }
/** 删除阿里云oss 文件 */ /** 删除阿里云oss 文件 */
@ -29,5 +27,5 @@ export function deleteAliOssFileAxios(data: ReqDeleteAliOssFile) {
url: `${import.meta.env.VITE_BASE_UPLOAD_URL}/admin/v1/oss/delete`, url: `${import.meta.env.VITE_BASE_UPLOAD_URL}/admin/v1/oss/delete`,
method: 'post', method: 'post',
data, data,
}); })
} }

View File

@ -165,7 +165,8 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
// 如果是静态路由模式,直接添加静态常量路由 // 如果是静态路由模式,直接添加静态常量路由
if (authRouteMode.value === 'static') { // 静态路由模式 if (authRouteMode.value === 'static') { // 静态路由模式
addConstantRoutes(staticRoute.constantRoutes) addConstantRoutes(staticRoute.constantRoutes)
}else {// 动态路由模式 }
else { // 动态路由模式
const { data, error } = await fetchGetConstantRoutes() const { data, error } = await fetchGetConstantRoutes()
if (!error) { if (!error) {

View File

@ -8,20 +8,20 @@ declare namespace Api {
/** common params of paginating */ /** common params of paginating */
interface PaginatingCommonParams { interface PaginatingCommonParams {
/** current page number */ /** current page number */
current: number; current: number
/** page size */ /** page size */
size: number; size: number
/** total count */ /** total count */
total: number; total: number
} }
/** common params of paginating query list data */ /** common params of paginating query list data */
interface PaginatingQueryRecord<T = any> extends PaginatingCommonParams { interface PaginatingQueryRecord<T = any> extends PaginatingCommonParams {
records: T[]; records: T[]
} }
/** common search params of table */ /** common search params of table */
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'current' | 'size'>; type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'current' | 'size'>
/** /**
* enable status * enable status
@ -29,22 +29,22 @@ declare namespace Api {
* - "1": enabled * - "1": enabled
* - "2": disabled * - "2": disabled
*/ */
type EnableStatus = '1' | '2'; type EnableStatus = '1' | '2'
/** common record */ /** common record */
type CommonRecord<T = any> = { type CommonRecord<T = any> = {
/** record id */ /** record id */
id: number; id: number
/** record creator */ /** record creator */
createBy: string; createBy: string
/** record create time */ /** record create time */
createTime: string; createTime: string
/** record updater */ /** record updater */
updateBy: string; updateBy: string
/** record update time */ /** record update time */
updateTime: string; updateTime: string
/** record status */ /** record status */
status: EnableStatus | null; status: EnableStatus | null
} & T; } & T
} }
} }

View File

@ -5,28 +5,28 @@ declare namespace Api {
* backend api module: "systemManage" * backend api module: "systemManage"
*/ */
namespace SystemManage { namespace SystemManage {
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'current' | 'size'>; type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'current' | 'size'>
/** role */ /** role */
type Role = Common.CommonRecord<{ type Role = Common.CommonRecord<{
/** role name */ /** role name */
roleName: string; roleName: string
/** role code */ /** role code */
roleCode: string; roleCode: string
/** role description */ /** role description */
roleDesc: string; roleDesc: string
}>; }>
/** role search params */ /** role search params */
type RoleSearchParams = CommonType.RecordNullable< type RoleSearchParams = CommonType.RecordNullable<
Pick<Api.SystemManage.Role, 'roleName' | 'roleCode' | 'status'> & CommonSearchParams Pick<Api.SystemManage.Role, 'roleName' | 'roleCode' | 'status'> & CommonSearchParams
>; >
/** role list */ /** role list */
type RoleList = Common.PaginatingQueryRecord<Role>; type RoleList = Common.PaginatingQueryRecord<Role>
/** all role */ /** all role */
type AllRole = Pick<Role, 'id' | 'roleName' | 'roleCode'>; type AllRole = Pick<Role, 'id' | 'roleName' | 'roleCode'>
/** /**
* user gender * user gender
@ -34,32 +34,32 @@ declare namespace Api {
* - "1": "male" * - "1": "male"
* - "2": "female" * - "2": "female"
*/ */
type UserGender = '1' | '2'; type UserGender = '1' | '2'
/** user */ /** user */
type User = Common.CommonRecord<{ type User = Common.CommonRecord<{
/** user name */ /** user name */
userName: string; userName: string
/** user gender */ /** user gender */
userGender: UserGender | null; userGender: UserGender | null
/** user nick name */ /** user nick name */
nickName: string; nickName: string
/** user phone */ /** user phone */
userPhone: string; userPhone: string
/** user email */ /** user email */
userEmail: string; userEmail: string
/** user role code collection */ /** user role code collection */
userRoles: string[]; userRoles: string[]
}>; }>
/** user search params */ /** user search params */
type UserSearchParams = CommonType.RecordNullable< type UserSearchParams = CommonType.RecordNullable<
Pick<Api.SystemManage.User, 'userName' | 'userGender' | 'nickName' | 'userPhone' | 'userEmail' | 'status'> & Pick<Api.SystemManage.User, 'userName' | 'userGender' | 'nickName' | 'userPhone' | 'userEmail' | 'status'> &
CommonSearchParams CommonSearchParams
>; >
/** user list */ /** user list */
type UserList = Common.PaginatingQueryRecord<User>; type UserList = Common.PaginatingQueryRecord<User>
/** /**
* menu type * menu type
@ -67,18 +67,18 @@ declare namespace Api {
* - "1": directory * - "1": directory
* - "2": menu * - "2": menu
*/ */
type MenuType = '1' | '2'; type MenuType = '1' | '2'
type MenuButton = { interface MenuButton {
/** /**
* button code * button code
* *
* it can be used to control the button permission * it can be used to control the button permission
*/ */
code: string; code: string
/** button description */ /** button description */
desc: string; desc: string
}; }
/** /**
* icon type * icon type
@ -86,54 +86,54 @@ declare namespace Api {
* - "1": iconify icon * - "1": iconify icon
* - "2": local icon * - "2": local icon
*/ */
type IconType = '1' | '2'; type IconType = '1' | '2'
type MenuPropsOfRoute = Pick< type MenuPropsOfRoute = Pick<
import('vue-router').RouteMeta, import('vue-router').RouteMeta,
| 'i18nKey' | 'i18nKey'
| 'keepAlive' | 'keepAlive'
| 'constant' | 'constant'
| 'order' | 'order'
| 'href' | 'href'
| 'hideInMenu' | 'hideInMenu'
| 'activeMenu' | 'activeMenu'
| 'multiTab' | 'multiTab'
| 'fixedIndexInTab' | 'fixedIndexInTab'
| 'query' | 'query'
>; >
type Menu = Common.CommonRecord<{ type Menu = Common.CommonRecord<{
/** parent menu id */ /** parent menu id */
parentId: number; parentId: number
/** menu type */ /** menu type */
menuType: MenuType; menuType: MenuType
/** menu name */ /** menu name */
menuName: string; menuName: string
/** route name */ /** route name */
routeName: string; routeName: string
/** route path */ /** route path */
routePath: string; routePath: string
/** component */ /** component */
component?: string; component?: string
/** iconify icon name or local icon name */ /** iconify icon name or local icon name */
icon: string; icon: string
/** icon type */ /** icon type */
iconType: IconType; iconType: IconType
/** buttons */ /** buttons */
buttons?: MenuButton[] | null; buttons?: MenuButton[] | null
/** children menu */ /** children menu */
children?: Menu[] | null; children?: Menu[] | null
}> & }> &
MenuPropsOfRoute; MenuPropsOfRoute
/** menu list */ /** menu list */
type MenuList = Common.PaginatingQueryRecord<Menu>; type MenuList = Common.PaginatingQueryRecord<Menu>
type MenuTree = { interface MenuTree {
id: number; id: number
label: string; label: string
pId: number; pId: number
children?: MenuTree[]; children?: MenuTree[]
}; }
} }
} }

View File

@ -1,7 +1,7 @@
import path from 'path-browserify'; import path from 'path-browserify'
export function getCurrentYear(): Date { export function getCurrentYear(): Date {
return new Date(); return new Date()
} }
/** /**
@ -11,29 +11,29 @@ export function getCurrentYear(): Date {
* @returns * @returns
*/ */
export function getTrueRandomInt(min: number, max: number) { export function getTrueRandomInt(min: number, max: number) {
const range = max - min + 1; const range = max - min + 1
const maxSafe = 0xffffffff; // 32位最大无符号整数 (2^32 - 1) const maxSafe = 0xFFFFFFFF // 32位最大无符号整数 (2^32 - 1)
let randomValue = 0; let randomValue = 0
do { do {
const buffer = new Uint32Array(1); const buffer = new Uint32Array(1)
window.crypto.getRandomValues(buffer); window.crypto.getRandomValues(buffer)
randomValue = (buffer[0]! / (maxSafe + 1)) * range; // [0, range) randomValue = (buffer[0]! / (maxSafe + 1)) * range // [0, range)
} while (randomValue >= range); // 拒绝采样避免偏差 } while (randomValue >= range) // 拒绝采样避免偏差
return Math.floor(randomValue) + min; return Math.floor(randomValue) + min
} }
/** /**
* 路径拼接 * 路径拼接
*/ */
export function browserPathJoin(base: string, ...paths: string[]) { export function browserPathJoin(base: string, ...paths: string[]) {
const [protocol, ...rest] = base.split('://'); const [protocol, ...rest] = base.split('://')
if (rest.length > 0) { if (rest.length > 0) {
const pathPart = rest.join('://').replace(/\/+/g, '/'); const pathPart = rest.join('://').replace(/\/+/g, '/')
return `${protocol}://${path.join(pathPart, ...paths)}`; return `${protocol}://${path.join(pathPart, ...paths)}`
} }
return path.join(base, ...paths); return path.join(base, ...paths)
} }
/** /**
@ -42,7 +42,7 @@ export function browserPathJoin(base: string, ...paths: string[]) {
export function nextTickSleep() { export function nextTickSleep() {
return new Promise((resolve) => { return new Promise((resolve) => {
nextTick(() => { nextTick(() => {
resolve(true); resolve(true)
}); })
}); })
} }

View File

@ -1,22 +1,22 @@
import { snapdom } from '@zumer/snapdom'; import { snapdom } from '@zumer/snapdom'
import { getTrueRandomInt } from './date'; import { getTrueRandomInt } from './date'
import { getSnowflake } from './rest'; import { getSnowflake } from './rest'
/** 通过 url 判断是否为 图片链接 */ /** 通过 url 判断是否为 图片链接 */
export function isImageUrl(url: string) { export function isImageUrl(url: string) {
const str = url.split('?')[0]!.substring(url.lastIndexOf('.') + 1) || ''; const str = url.split('?')[0]!.substring(url.lastIndexOf('.') + 1) || ''
return ['jpg', 'jpeg', 'png', 'webp', 'svg', 'gif'].includes(str.toLocaleLowerCase()); return ['jpg', 'jpeg', 'png', 'webp', 'svg', 'gif'].includes(str.toLocaleLowerCase())
} }
/** 通过 url 判断是否为 图片链接 */ /** 通过 url 判断是否为 图片链接 */
export function isVideoUrl(url: string) { export function isVideoUrl(url: string) {
const str = url.split('?')[0]!.substring(url.lastIndexOf('.') + 1) || ''; const str = url.split('?')[0]!.substring(url.lastIndexOf('.') + 1) || ''
return ['m4v', 'mov', '3gp', '3g2', 'mp4', 'flv', 'f4v', 'webm', 'wmv', 'avi', 'asf'].includes(str.toLocaleLowerCase()); return ['m4v', 'mov', '3gp', '3g2', 'mp4', 'flv', 'f4v', 'webm', 'wmv', 'avi', 'asf'].includes(str.toLocaleLowerCase())
} }
/** 得到 */ /** 得到 */
export function getAssetsFile(url: string) { export function getAssetsFile(url: string) {
return new URL(url, import.meta.url).href; return new URL(url, import.meta.url).href
} }
/** /**
@ -28,31 +28,31 @@ export function getAssetsFile(url: string) {
export function selectFiles(accept = '*/*', multiple = false): Promise<File[]> { export function selectFiles(accept = '*/*', multiple = false): Promise<File[]> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// 1. 创建隐藏的文件输入元素 // 1. 创建隐藏的文件输入元素
const input = document.createElement('input'); const input = document.createElement('input')
input.type = 'file'; input.type = 'file'
input.accept = accept; input.accept = accept
input.multiple = multiple; input.multiple = multiple
input.style.display = 'none'; input.style.display = 'none'
// 2. 监听文件选择事件 // 2. 监听文件选择事件
input.addEventListener('change', () => { input.addEventListener('change', () => {
if (!input.files || input.files.length === 0) { if (!input.files || input.files.length === 0) {
reject(new Error('未选择文件')); reject(new Error('未选择文件'))
return; return
} }
// 3. 转换为 File 对象数组 // 3. 转换为 File 对象数组
const files = Array.from(input.files); const files = Array.from(input.files)
resolve(files); resolve(files)
// 4. 清理DOM // 4. 清理DOM
document.body.removeChild(input); document.body.removeChild(input)
}); })
// 5. 触发文件选择弹窗 // 5. 触发文件选择弹窗
document.body.appendChild(input); document.body.appendChild(input)
input.click(); input.click()
}); })
} }
/** /**
@ -63,54 +63,55 @@ export function selectFiles(accept = '*/*', multiple = false): Promise<File[]> {
*/ */
export function base64ToFile(dataUrl: string, filename: string): File { export function base64ToFile(dataUrl: string, filename: string): File {
// 拆分 Data URL // 拆分 Data URL
const arr = dataUrl.split(','); const arr = dataUrl.split(',')
const mimeMatch = arr[0]?.match(/:(.*?);/) || null; const mimeMatch = arr[0]?.match(/:(.*?);/) || null
if (!mimeMatch || !mimeMatch[1] || !arr[1]) { if (!mimeMatch || !mimeMatch[1] || !arr[1]) {
throw new Error('无效的Base64字符串'); throw new Error('无效的Base64字符串')
} }
const mime = mimeMatch[1]; // 提取 MIME 类型(如 "image/png" const mime = mimeMatch[1] // 提取 MIME 类型(如 "image/png"
const bstr = atob(arr[1]); // Base64 解码 const bstr = atob(arr[1]) // Base64 解码
const n = bstr.length; const n = bstr.length
const u8arr = new Uint8Array(n); const u8arr = new Uint8Array(n)
// 将解码后的二进制数据存入 Uint8Array // 将解码后的二进制数据存入 Uint8Array
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
u8arr[i] = bstr.charCodeAt(i); u8arr[i] = bstr.charCodeAt(i)
} }
// 生成 File 对象 // 生成 File 对象
return new File([u8arr], filename, { type: mime }); return new File([u8arr], filename, { type: mime })
} }
/** /**
* 将图片file转换为Base64对象 * 将图片file转换为Base64对象
*/ */
export function fileToBase64(file: File): Promise<{ id: string; img: string }> { export function fileToBase64(file: File): Promise<{ id: string, img: string }> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader()
reader.readAsDataURL(file); reader.readAsDataURL(file)
reader.onload = (e) => { reader.onload = (e) => {
const date = new Date().getTime(); const date = new Date().getTime()
const num = getTrueRandomInt(100000000, 99999999999); const num = getTrueRandomInt(100000000, 99999999999)
if (file.size > 1024 * 40) { if (file.size > 1024 * 40) {
// 只有大于40kb才压缩 // 只有大于40kb才压缩
const img = new Image(); const img = new Image()
img.src = String(e.target?.result || ''); img.src = String(e.target?.result || '')
img.onload = async () => { img.onload = async () => {
const Base64Url = await compressImg(img, file.type); const Base64Url = await compressImg(img, file.type)
resolve({ id: `id_${date}_${num}`, img: Base64Url }); resolve({ id: `id_${date}_${num}`, img: Base64Url })
}; }
img.onerror = () => { img.onerror = () => {
reject('加载图片失败,002'); reject('加载图片失败,002')
}; }
} else {
resolve({ id: `id_${date}_${num}`, img: String(e.target?.result || '') });
} }
}; else {
resolve({ id: `id_${date}_${num}`, img: String(e.target?.result || '') })
}
}
reader.onerror = () => { reader.onerror = () => {
reject('加载图片失败,001'); reject('加载图片失败,001')
}; }
}); })
} }
/** /**
@ -123,41 +124,42 @@ export function fileToBase64(file: File): Promise<{ id: string; img: string }> {
*/ */
export function compressImg(img: HTMLImageElement, imgType = 'image/jpeg', mx = 720, mh = 1280, quality = 0.8): Promise<string> { export function compressImg(img: HTMLImageElement, imgType = 'image/jpeg', mx = 720, mh = 1280, quality = 0.8): Promise<string> {
return new Promise((resolve) => { return new Promise((resolve) => {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas')
const context = canvas.getContext('2d'); const context = canvas.getContext('2d')
const { width: originWidth, height: originHeight } = img; const { width: originWidth, height: originHeight } = img
// 最大尺寸限制 // 最大尺寸限制
const maxWidth = mx; const maxWidth = mx
const maxHeight = mh; const maxHeight = mh
// 目标尺寸 // 目标尺寸
let targetWidth = originWidth; let targetWidth = originWidth
let targetHeight = originHeight; let targetHeight = originHeight
if (originWidth > maxWidth || originHeight > maxHeight) { if (originWidth > maxWidth || originHeight > maxHeight) {
if (originWidth / originHeight > 1) { if (originWidth / originHeight > 1) {
// 宽图片 // 宽图片
targetWidth = maxWidth; targetWidth = maxWidth
targetHeight = Math.round(maxWidth * (originHeight / originWidth)); targetHeight = Math.round(maxWidth * (originHeight / originWidth))
} else { }
else {
// 高图片 // 高图片
targetHeight = maxHeight; targetHeight = maxHeight
targetWidth = Math.round(maxHeight * (originWidth / originHeight)); targetWidth = Math.round(maxHeight * (originWidth / originHeight))
} }
} }
canvas.width = targetWidth; canvas.width = targetWidth
canvas.height = targetHeight; canvas.height = targetHeight
if (context) { if (context) {
context.clearRect(0, 0, targetWidth, targetHeight); context.clearRect(0, 0, targetWidth, targetHeight)
// 图片绘制 // 图片绘制
context.drawImage(img, 0, 0, targetWidth, targetHeight); context.drawImage(img, 0, 0, targetWidth, targetHeight)
} }
const dataURL = canvas.toDataURL(imgType, quality); // 转换图片为dataURL const dataURL = canvas.toDataURL(imgType, quality) // 转换图片为dataURL
// const fun = (blob) => { // const fun = (blob) => {
// resolve(blob); // resolve(blob);
// }; // };
// 转换为bolb对象 // 转换为bolb对象
// canvas.toBlob(fun, imgType, 0.7); // canvas.toBlob(fun, imgType, 0.7);
resolve(dataURL); resolve(dataURL)
}); })
} }
/** /**
@ -166,131 +168,137 @@ export function compressImg(img: HTMLImageElement, imgType = 'image/jpeg', mx =
* @returns - 第一帧的File对象 * @returns - 第一帧的File对象
*/ */
export function getFirstFrameOfVideo( export function getFirstFrameOfVideo(
videoSource: Blob | File | string videoSource: Blob | File | string,
): Promise<{ firstFrame: File; duration: number; videoWidth: number; videoHeight: number }> { ): Promise<{ firstFrame: File, duration: number, videoWidth: number, videoHeight: number }> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let url = ''; // 使用createObjectURL创建的URL let url = '' // 使用createObjectURL创建的URL
// 创建视频元素 // 创建视频元素
const video = document.createElement('video'); const video = document.createElement('video')
video.crossOrigin = 'Anonymous'; video.crossOrigin = 'Anonymous'
video.setAttribute('playsinline', ''); video.setAttribute('playsinline', '')
video.muted = true; video.muted = true
// 事件监听:当视频可播放时处理 // 事件监听:当视频可播放时处理
video.addEventListener('seeked', () => { video.addEventListener('seeked', () => {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas')
canvas.width = video.videoWidth; canvas.width = video.videoWidth
canvas.height = video.videoHeight; canvas.height = video.videoHeight
const context = canvas.getContext('2d'); const context = canvas.getContext('2d')
if (context) { if (context) {
context.drawImage(video, 0, 0, canvas.width, canvas.height); context.drawImage(video, 0, 0, canvas.width, canvas.height)
const blobCallback = (blob: Blob | null) => { const blobCallback = (blob: Blob | null) => {
if (blob) { if (blob) {
const name = `${getSnowflake()}_${Math.random().toString(32).substring(2)}.png`; const name = `${getSnowflake()}_${Math.random().toString(32).substring(2)}.png`
// 将 Blob 转换为 File // 将 Blob 转换为 File
const firstFrame = new File([blob], name, { type: blob.type }); const firstFrame = new File([blob], name, { type: blob.type })
resolve({ firstFrame, videoWidth: video.videoWidth, videoHeight: video.videoHeight, duration: video.duration }); resolve({ firstFrame, videoWidth: video.videoWidth, videoHeight: video.videoHeight, duration: video.duration })
url && URL.revokeObjectURL(url); // 使用createObjectURL创建的URL应在不再需要时通过revokeObjectURL释放以避免内存泄漏。 url && URL.revokeObjectURL(url) // 使用createObjectURL创建的URL应在不再需要时通过revokeObjectURL释放以避免内存泄漏。
} else {
reject(new Error('获取地一帧失败,BD002'));
} }
}; else {
canvas.toBlob(blobCallback, 'image/jpeg', 0.7); reject(new Error('获取地一帧失败,BD002'))
} else { }
reject(new Error('获取地一帧失败,BD001')); }
canvas.toBlob(blobCallback, 'image/jpeg', 0.7)
} }
}); else {
reject(new Error('获取地一帧失败,BD001'))
}
})
// 错误处理 // 错误处理
video.addEventListener('error', () => { video.addEventListener('error', () => {
reject(new Error('获取地一帧失败,BD003')); reject(new Error('获取地一帧失败,BD003'))
}); })
// 设置视频源 // 设置视频源
if (videoSource instanceof Blob || (videoSource as any) instanceof File) { if (videoSource instanceof Blob || (videoSource as any) instanceof File) {
// Blob 或 File // Blob 或 File
url = URL.createObjectURL(videoSource as Blob | File); url = URL.createObjectURL(videoSource as Blob | File)
video.src = url; video.src = url
video.load(); video.load()
} else if (typeof videoSource === 'string') {
video.src = videoSource;
video.load();
} else {
reject(new Error('不支持此类型'));
} }
video.currentTime = 0.01; else if (typeof videoSource === 'string') {
}); video.src = videoSource
video.load()
}
else {
reject(new Error('不支持此类型'))
}
video.currentTime = 0.01
})
} }
/** /**
* 得到图片的宽高 * 得到图片的宽高
* @param file - 图片,可以是 url 或者File * @param file - 图片,可以是 url 或者File
*/ */
export function getImageWidthHeight(file: Blob | File | string): Promise<{ width: number; height: number }> { export function getImageWidthHeight(file: Blob | File | string): Promise<{ width: number, height: number }> {
return new Promise<{ width: number; height: number }>((resolve, reject) => { return new Promise<{ width: number, height: number }>((resolve, reject) => {
const img = new Image(); const img = new Image()
img.onload = () => { img.onload = () => {
const size = { width: img.width, height: img.height }; const size = { width: img.width, height: img.height }
resolve(size); resolve(size)
}; }
img.onerror = () => { img.onerror = () => {
reject(new Error('读取图片失败,BD0001')); reject(new Error('读取图片失败,BD0001'))
}; }
if (typeof file === 'string') { if (typeof file === 'string') {
img.src = file; img.src = file
} else if (file instanceof File || file instanceof Blob) {
img.src = URL.createObjectURL(file);
} else {
reject(new Error('读取图片失败,BD0002'));
} }
}); else if (file instanceof File || file instanceof Blob) {
img.src = URL.createObjectURL(file)
}
else {
reject(new Error('读取图片失败,BD0002'))
}
})
} }
/** /**
* html 转换为 jpg图片 * html 转换为 jpg图片
*/ */
export async function htmlToJpgImgFile(html: string, op: { width: number }): Promise<File> { export async function htmlToJpgImgFile(html: string, op: { width: number }): Promise<File> {
const type: 'jpeg' | 'jpg' | 'png' | 'svg' | 'webp' = 'jpg'; const type: 'jpeg' | 'jpg' | 'png' | 'svg' | 'webp' = 'jpg'
// 创建临时容器 // 创建临时容器
const container = document.createElement('div'); const container = document.createElement('div')
container.style.width = `${op.width}px`; container.style.width = `${op.width}px`
container.style.position = 'absolute'; container.style.position = 'absolute'
container.style.left = '101vw'; container.style.left = '101vw'
container.style.top = '101vh'; container.style.top = '101vh'
container.style.zIndex = '-1'; container.style.zIndex = '-1'
container.style.backgroundColor = '#ffffff'; container.style.backgroundColor = '#ffffff'
container.innerHTML = html; container.innerHTML = html
document.body.appendChild(container); document.body.appendChild(container)
try { try {
// 等待图片加载完成 // 等待图片加载完成
const images = container.querySelectorAll('img'); const images = container.querySelectorAll('img')
const imageLoadPromises = Array.from(images).map((img) => { const imageLoadPromises = Array.from(images).map((img) => {
if (img.complete) { if (img.complete) {
return Promise.resolve(); return Promise.resolve()
} }
return new Promise((resolve) => { return new Promise((resolve) => {
img.onload = () => resolve(null); img.onload = () => resolve(null)
img.onerror = () => resolve(null); // 即使图片加载失败也继续 img.onerror = () => resolve(null) // 即使图片加载失败也继续
// 设置超时,防止某些图片一直加载不成功 // 设置超时,防止某些图片一直加载不成功
setTimeout(() => resolve(null), 5000); setTimeout(() => resolve(null), 5000)
}); })
}); })
await Promise.all(imageLoadPromises); await Promise.all(imageLoadPromises)
console.log('所有图片加载完成,图片数量:', images.length); console.log('所有图片加载完成,图片数量:', images.length)
// 额外等待确保DOM完全渲染 // 额外等待确保DOM完全渲染
await new Promise((resolve) => { await new Promise((resolve) => {
setTimeout(() => { setTimeout(() => {
requestAnimationFrame(resolve); requestAnimationFrame(resolve)
}, 100); // 增加等待时间 }, 100) // 增加等待时间
}); })
const name = `${getSnowflake()}_${Math.random().toString(32).substring(2)}.jpg`; const name = `${getSnowflake()}_${Math.random().toString(32).substring(2)}.jpg`
console.log('开始截图,容器宽度:', container.offsetWidth, '高度:', container.offsetHeight); console.log('开始截图,容器宽度:', container.offsetWidth, '高度:', container.offsetHeight)
// 使用 snapdom 进行 DOM 快照 // 使用 snapdom 进行 DOM 快照
const res = await snapdom(container, { const res = await snapdom(container, {
@ -300,29 +308,31 @@ export async function htmlToJpgImgFile(html: string, op: { width: number }): Pro
format: type, format: type,
filename: name, filename: name,
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
}); })
const blob = await res.toBlob({ type, backgroundColor: '#ffffff' }); const blob = await res.toBlob({ type, backgroundColor: '#ffffff' })
console.log('截图完成blob大小:', (blob.size / 1024).toFixed(2), 'KB'); console.log('截图完成blob大小:', (blob.size / 1024).toFixed(2), 'KB')
// 将blob转换为base64以便在浏览器查看 // 将blob转换为base64以便在浏览器查看
const reader = new FileReader(); const reader = new FileReader()
const base64Promise = new Promise<string>((resolve) => { const base64Promise = new Promise<string>((resolve) => {
reader.onloadend = () => { reader.onloadend = () => {
resolve(reader.result as string); resolve(reader.result as string)
}; }
reader.readAsDataURL(blob); reader.readAsDataURL(blob)
}); })
const base64 = await base64Promise; const base64 = await base64Promise
// console.log("blob=====", blob); // console.log("blob=====", blob);
// console.log("图片base64=====", base64); // console.log("图片base64=====", base64);
// console.log("👆 复制上面的base64到浏览器地址栏查看图片"); // console.log("👆 复制上面的base64到浏览器地址栏查看图片");
return new File([blob], name, { type: blob.type }); return new File([blob], name, { type: blob.type })
} catch (error) { }
console.error('htmlToJpgImgFile 错误:', error); catch (error) {
return Promise.reject(error); console.error('htmlToJpgImgFile 错误:', error)
} finally { return Promise.reject(error)
}
finally {
// document.body.removeChild(container); // document.body.removeChild(container);
} }
} }

View File

@ -1,8 +1,8 @@
import { type AliOssSTS, getAliOssTokenAxios } from '@/service/api/upload'; import OSS, { type Checkpoint } from 'ali-oss'
import OSS, { type Checkpoint } from 'ali-oss'; import { type AliOssSTS, getAliOssTokenAxios } from '@/service/api/upload'
// 初始化OSS客户端 // 初始化OSS客户端
export const initOSSClient = (token: AliOssSTS) => { export function initOSSClient(token: AliOssSTS) {
return new OSS({ return new OSS({
region: token.Region || 'oss-cn-shenzhen', region: token.Region || 'oss-cn-shenzhen',
accessKeyId: token.AccessKeyId, accessKeyId: token.AccessKeyId,
@ -12,34 +12,30 @@ export const initOSSClient = (token: AliOssSTS) => {
refreshSTSTokenInterval: 600000, // 10分钟刷新一次token refreshSTSTokenInterval: 600000, // 10分钟刷新一次token
refreshSTSToken: async () => { refreshSTSToken: async () => {
// 这里可以添加获取新token的逻辑 // 这里可以添加获取新token的逻辑
const res = await getAliOssTokenAxios(); const res = await getAliOssTokenAxios()
return { return {
accessKeyId: res.AccessKeyId, accessKeyId: res.AccessKeyId,
accessKeySecret: res.AccessKeySecret, accessKeySecret: res.AccessKeySecret,
stsToken: res.SecurityToken, stsToken: res.SecurityToken,
}; }
}, },
}); })
}; }
/** /**
* 上传文件到OSS (注意进度从 0 开始到 1 结束) * 上传文件到OSS (注意进度从 0 开始到 1 结束)
*/ */
export const uploadFileToOSS = async ( export async function uploadFileToOSS(client: OSS, file: File, path: string, progress?: ((progress: number, checkpoint?: Checkpoint, http?: any) => any) | undefined) {
client: OSS,
file: File,
path: string,
progress?: ((progress: number, checkpoint?: Checkpoint, http?: any) => any) | undefined
) => {
try { try {
const result = await client.multipartUpload(path, file, { const result = await client.multipartUpload(path, file, {
parallel: 5, // 并发分片数 parallel: 5, // 并发分片数
partSize: 1024 * 1024 * 5, // 分片大小5MB partSize: 1024 * 1024 * 5, // 分片大小5MB
progress, progress,
}); })
return result; return result
} catch (error) {
console.error('上传文件失败:', error);
throw error;
} }
}; catch (error) {
console.error('上传文件失败:', error)
throw error
}
}

View File

@ -1,13 +1,13 @@
import { Snowflake } from '@sapphire/snowflake'; import { Snowflake } from '@sapphire/snowflake'
/** /**
* 得到雪花ID * 得到雪花ID
*/ */
export function getSnowflake(): bigint { export function getSnowflake(): bigint {
const epoch = new Date('2025-07-01T00:00:00.000Z'); const epoch = new Date('2025-07-01T00:00:00.000Z')
const snowflake = new Snowflake(epoch); const snowflake = new Snowflake(epoch)
snowflake.workerId = 1; snowflake.workerId = 1
return snowflake.generate(); return snowflake.generate()
} }
/** /**
@ -18,50 +18,53 @@ export function getSnowflake(): bigint {
*/ */
export async function concurrentPool<T>(tasks: (() => Promise<T>)[], concurrency: number): Promise<T[]> { export async function concurrentPool<T>(tasks: (() => Promise<T>)[], concurrency: number): Promise<T[]> {
// 存储所有任务的结果 // 存储所有任务的结果
const results: T[] = []; const results: T[] = []
// 存储当前正在执行的任务 // 存储当前正在执行的任务
const executing: Promise<void>[] = []; const executing: Promise<void>[] = []
let index = 0; // 任务索引,用于按顺序添加任务 let index = 0 // 任务索引,用于按顺序添加任务
// 创建执行器函数 // 创建执行器函数
const execute = async (taskIndex: number): Promise<void> => { const execute = async (taskIndex: number): Promise<void> => {
try { try {
// 执行任务并获取结果 // 执行任务并获取结果
const result = await tasks[taskIndex]!(); const result = await tasks[taskIndex]!()
results[taskIndex] = result; // 按原始顺序存储结果 results[taskIndex] = result // 按原始顺序存储结果
} catch (error) { }
results[taskIndex] = error as any; // 捕获错误(可根据需求调整) catch (error) {
} finally { results[taskIndex] = error as any // 捕获错误(可根据需求调整)
}
finally {
// 无论成功失败,任务完成后从执行队列移除 // 无论成功失败,任务完成后从执行队列移除
const executingIndex = executing.findIndex((p) => p === executing[taskIndex]); const executingIndex = executing.findIndex(p => p === executing[taskIndex])
if (executingIndex !== -1) { if (executingIndex !== -1) {
executing.splice(executingIndex, 1); executing.splice(executingIndex, 1)
} }
} }
}; }
// 启动初始并发任务 // 启动初始并发任务
while (index < Math.min(concurrency, tasks.length)) { while (index < Math.min(concurrency, tasks.length)) {
const taskPromise = execute(index); const taskPromise = execute(index)
executing.push(taskPromise); executing.push(taskPromise)
index++; index++
} }
// 动态管理任务池 // 动态管理任务池
while (index < tasks.length) { while (index < tasks.length) {
if (executing.length < concurrency) { if (executing.length < concurrency) {
const taskPromise = execute(index); const taskPromise = execute(index)
executing.push(taskPromise); executing.push(taskPromise)
index++; index++
} else { }
else {
// 等待任意一个任务完成 // 等待任意一个任务完成
await Promise.race(executing); // eslint-disable-line no-await-in-loop await Promise.race(executing)
} }
} }
// 等待所有剩余任务完成 // 等待所有剩余任务完成
await Promise.all(executing); await Promise.all(executing)
return results; return results
} }
/** /**
@ -75,25 +78,27 @@ export async function concurrentPool<T>(tasks: (() => Promise<T>)[], concurrency
*/ */
export function compareVersion(v1: string, v2: string, operator: '_' | '-' | '.' = '.'): -1 | 0 | 1 { export function compareVersion(v1: string, v2: string, operator: '_' | '-' | '.' = '.'): -1 | 0 | 1 {
if (v1 === v2) { if (v1 === v2) {
return 0; return 0
} }
const vs1 = v1.split(operator).map((a) => parseInt(a)); const vs1 = v1.split(operator).map(a => Number.parseInt(a))
const vs2 = v2.split(operator).map((a) => parseInt(a)); const vs2 = v2.split(operator).map(a => Number.parseInt(a))
const length = Math.min(vs1.length, vs2.length); const length = Math.min(vs1.length, vs2.length)
for (let i = 0; i < length; i++) { for (let i = 0; i < length; i++) {
const s1 = vs1[i] || 0; const s1 = vs1[i] || 0
const s2 = vs2[i] || 0; const s2 = vs2[i] || 0
if (s1 > s2) { if (s1 > s2) {
return 1; return 1
} else if (s1 < s2) { }
return -1; else if (s1 < s2) {
return -1
} }
} }
if (length === vs1.length) { if (length === vs1.length) {
return -1; return -1
} else { }
return 1; else {
return 1
} }
} }

View File

@ -27,7 +27,8 @@ function onMouseDown(e: MouseEvent) {
} }
function onMouseMove(e: MouseEvent) { function onMouseMove(e: MouseEvent) {
if (!isDragging.value) return if (!isDragging.value)
return
const dx = e.clientX - startX.value const dx = e.clientX - startX.value
const newWidth = startWidth.value + dx const newWidth = startWidth.value + dx
@ -48,12 +49,13 @@ function onMouseUp() {
<template> <template>
<div class="h-full flex overflow-hidden rounded-2xl bg-white shadow-sm"> <div class="h-full flex overflow-hidden rounded-2xl bg-white shadow-sm">
<!-- 分类树区域 --> <!-- 分类树区域 -->
<div class="h-full flex-shrink-0 relative" :style="{ width: `${siderWidth}px` }"> <div class="relative h-full flex-shrink-0" :style="{ width: `${siderWidth}px` }">
<CategoryTree @update:category="val => currentCategory = val" /> <CategoryTree @update:category="val => currentCategory = val" />
<!-- 分类树宽度调整手柄 --> <!-- 分类树宽度调整手柄 -->
<div <div
class="absolute top-0 right-0 h-full w-1 cursor-col-resize hover:bg-primary/20 active:bg-primary/40 transition-colors z-10" class="absolute right-0 top-0 z-10 h-full w-1 cursor-col-resize transition-colors active:bg-primary/40 hover:bg-primary/20"
@mousedown="onMouseDown"></div> @mousedown="onMouseDown"
/>
</div> </div>
<!-- 题目列表区域 --> <!-- 题目列表区域 -->
<div class="h-full flex-1 overflow-hidden"> <div class="h-full flex-1 overflow-hidden">

View File

@ -1,16 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, h, watch } from 'vue'
import { import {
NButton, NButton,
NTree,
NModal,
NForm, NForm,
NFormItem, NFormItem,
NInput, NInput,
useMessage, NModal,
NTree,
type TreeOption,
useDialog, useDialog,
type TreeOption useMessage,
} from 'naive-ui' } from 'naive-ui'
import { h, ref, watch } from 'vue'
import SvgIcon from '@/components/custom/svg-icon.vue' import SvgIcon from '@/components/custom/svg-icon.vue'
const emit = defineEmits<{ const emit = defineEmits<{
@ -135,9 +135,10 @@ watch(selectedKeys, (newKeys) => {
level: node.isLeaf ? 3 : 1, level: node.isLeaf ? 3 : 1,
key: node.key, key: node.key,
isLeaf: node.isLeaf, isLeaf: node.isLeaf,
children: node.children children: node.children,
}) })
} else { }
else {
emit('update:category', null) emit('update:category', null)
} }
}) })
@ -318,10 +319,12 @@ function renderSuffix({ option }: { option: TreeOption }) {
</div> </div>
<div class="flex-1 overflow-y-auto py-2"> <div class="flex-1 overflow-y-auto py-2">
<NTree block-line :data="treeData" :selected-keys="selectedKeys" :expanded-keys="expandedKeys" <NTree
block-line :data="treeData" :selected-keys="selectedKeys" :expanded-keys="expandedKeys"
:render-prefix="renderPrefix" :render-suffix="renderSuffix" selectable expand-on-click class="px-2" :render-prefix="renderPrefix" :render-suffix="renderSuffix" selectable expand-on-click class="px-2"
@update:selected-keys="(keys) => (selectedKeys = keys)" @update:selected-keys="(keys) => (selectedKeys = keys)"
@update:expanded-keys="(keys) => (expandedKeys = keys)" /> @update:expanded-keys="(keys) => (expandedKeys = keys)"
/>
</div> </div>
<div class="border-t border-gray-100 bg-gray-50 p-3 text-xs text-gray-400"> <div class="border-t border-gray-100 bg-gray-50 p-3 text-xs text-gray-400">
@ -329,8 +332,10 @@ function renderSuffix({ option }: { option: TreeOption }) {
</div> </div>
<!-- Add/Edit Category Modal --> <!-- Add/Edit Category Modal -->
<NModal v-model:show="showCategoryModal" preset="card" <NModal
:title="categoryOperation === 'edit' ? '编辑分类' : `新增 ${categoryModalType} 级分类`" class="w-[500px]"> v-model:show="showCategoryModal" preset="card"
:title="categoryOperation === 'edit' ? '编辑分类' : `新增 ${categoryModalType} 级分类`" class="w-[500px]"
>
<NForm> <NForm>
<NFormItem label="分类显示名称"> <NFormItem label="分类显示名称">
<NInput v-model:value="categoryForm.name" placeholder="请输入内容" /> <NInput v-model:value="categoryForm.name" placeholder="请输入内容" />

View File

@ -1,22 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'
import { import {
NBreadcrumb, NBreadcrumb,
NBreadcrumbItem, NBreadcrumbItem,
NButton, NButton,
NCard, NCard,
NDrawer,
NDrawerContent,
NEmpty, NEmpty,
NForm, NForm,
NFormItem, NFormItem,
NInput, NInput,
NInputNumber, NInputNumber,
NDrawer,
NDrawerContent,
NTag, NTag,
} from 'naive-ui' } from 'naive-ui'
import { computed, ref } from 'vue'
import SvgIcon from '@/components/custom/svg-icon.vue' import SvgIcon from '@/components/custom/svg-icon.vue'
import RestBasicEditor from '@/components/common/rest-basic-editor/rest-basic-editor.vue'
const props = defineProps<{ const props = defineProps<{
currentCategory: any currentCategory: any
@ -168,7 +167,7 @@ function submitQuestion() {
questionList.value.push({ questionList.value.push({
id: `Q-${Math.floor(Math.random() * 10000)}`, id: `Q-${Math.floor(Math.random() * 10000)}`,
...questionForm.value, ...questionForm.value,
categoryKey: props.currentCategory?.key // Add categoryKey categoryKey: props.currentCategory?.key, // Add categoryKey
}) })
window.$message?.success('题目添加成功') window.$message?.success('题目添加成功')
} }
@ -294,9 +293,9 @@ function submitQuestion() {
<NDrawerContent :title="questionOperation === 'edit' ? '编辑题目详情' : '新增题目详情'"> <NDrawerContent :title="questionOperation === 'edit' ? '编辑题目详情' : '新增题目详情'">
<NForm label-placement="top"> <NForm label-placement="top">
<NFormItem label="题目正文内容"> <NFormItem label="题目正文内容">
<div class="w-full border border-gray-200 rounded-lg overflow-hidden"> <div class="w-full overflow-hidden border border-gray-200 rounded-lg">
<!-- <RestBasicEditor v-model="questionForm.content" /> --> <!-- <RestBasicEditor v-model="questionForm.content" /> -->
<NInput type="textarea" v-model:value="questionForm.content" class="w-full h-64" /> <NInput v-model:value="questionForm.content" type="textarea" class="h-64 w-full" />
</div> </div>
</NFormItem> </NFormItem>

View File

@ -1,14 +1,14 @@
<script setup lang="tsx"> <script setup lang="tsx">
import { reactive, ref } from 'vue'; import { NButton, NCard, NTag } from 'naive-ui'
import { useRouter } from 'vue-router'; import { reactive } from 'vue'
import { NButton, NTag, NCard, NSpace } from 'naive-ui'; import { useRouter } from 'vue-router'
import { fetchGetUserList } from '@/service/api'; import { defaultTransform, useNaivePaginatedTable, useTableOperate } from '@/hooks/common/table'
import { useAppStore } from '@/store/modules/app'; import { fetchGetUserList } from '@/service/api'
import { defaultTransform, useNaivePaginatedTable, useTableOperate } from '@/hooks/common/table'; import { useAppStore } from '@/store/modules/app'
import RankSearch from './modules/rank-search.vue'; import RankSearch from './modules/rank-search.vue'
const appStore = useAppStore(); const appStore = useAppStore()
const router = useRouter(); const router = useRouter()
const searchParams: Api.SystemManage.UserSearchParams = reactive({ const searchParams: Api.SystemManage.UserSearchParams = reactive({
current: 1, current: 1,
@ -18,67 +18,67 @@ const searchParams: Api.SystemManage.UserSearchParams = reactive({
userGender: null, userGender: null,
nickName: null, nickName: null,
userPhone: null, userPhone: null,
userEmail: null userEmail: null,
}); })
const rankTitle = '排行榜管理列表'; const rankTitle = '排行榜管理列表'
const statusMap: Record<string, string> = { const statusMap: Record<string, string> = {
1: '已发布', 1: '已发布',
2: '待审核' 2: '待审核',
}; }
const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagination } = useNaivePaginatedTable({ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagination } = useNaivePaginatedTable({
api: () => fetchGetUserList(searchParams), api: () => fetchGetUserList(searchParams),
transform: response => { transform: (response) => {
const transformed = defaultTransform(response); const transformed = defaultTransform(response)
// Mocking data for Rank Management based on User API response // Mocking data for Rank Management based on User API response
const mockCompetitions = ['阅读之星-辞海遨游环节 (2026)', '古诗词大会年度精英巅峰赛', '趣味百科常识挑战周']; const mockCompetitions = ['阅读之星-辞海遨游环节 (2026)', '古诗词大会年度精英巅峰赛', '趣味百科常识挑战周']
const mockDates = ['2026.01.12', '2026.01.15', '2026.01.18']; const mockDates = ['2026.01.12', '2026.01.15', '2026.01.18']
const mockScales = ['40 支队伍', '24 支队伍', '60 支队伍']; const mockScales = ['40 支队伍', '24 支队伍', '60 支队伍']
const mockScores = ['145', '180', '120']; const mockScores = ['145', '180', '120']
const mockTimes = ['2026-01-22 16:30:12', '2026-01-22 12:00:00', '2026-01-21 18:22:45']; const mockTimes = ['2026-01-22 16:30:12', '2026-01-22 12:00:00', '2026-01-21 18:22:45']
transformed.data.forEach((item, index) => { transformed.data.forEach((item, index) => {
const mockIndex = index % 3; const mockIndex = index % 3
item.userName = mockCompetitions[mockIndex]; // Competition Name item.userName = mockCompetitions[mockIndex] // Competition Name
item.userEmail = mockDates[mockIndex]; // Activity Date (using userEmail field) item.userEmail = mockDates[mockIndex] // Activity Date (using userEmail field)
item.userPhone = mockScales[mockIndex]; // Team Scale (using userPhone field) item.userPhone = mockScales[mockIndex] // Team Scale (using userPhone field)
item.nickName = mockScores[mockIndex]; // Max Score (using nickName field) item.nickName = mockScores[mockIndex] // Max Score (using nickName field)
// item.createTime is typically available, we'll simulate update time // item.createTime is typically available, we'll simulate update time
item.createTime = mockTimes[mockIndex]; item.createTime = mockTimes[mockIndex]
// Status: 1 (Published), 2 (Pending) // Status: 1 (Published), 2 (Pending)
item.status = (index % 2 === 0) ? '2' : '1'; item.status = (index % 2 === 0) ? '2' : '1'
}); })
return transformed; return transformed
}, },
onPaginationParamsChange: params => { onPaginationParamsChange: (params) => {
searchParams.current = params.page; searchParams.current = params.page
searchParams.size = params.pageSize; searchParams.size = params.pageSize
}, },
columns: () => [ columns: () => [
{ {
type: 'selection', type: 'selection',
align: 'center', align: 'center',
width: 48 width: 48,
}, },
{ {
key: 'userName', key: 'userName',
title: '比赛活动名称', title: '比赛活动名称',
align: 'left', align: 'left',
minWidth: 200 minWidth: 200,
}, },
{ {
key: 'userEmail', key: 'userEmail',
title: '活动日期', title: '活动日期',
align: 'center', align: 'center',
minWidth: 120 minWidth: 120,
}, },
{ {
key: 'userPhone', key: 'userPhone',
title: '队伍规模', title: '队伍规模',
align: 'center', align: 'center',
minWidth: 100 minWidth: 100,
}, },
{ {
key: 'nickName', key: 'nickName',
@ -86,26 +86,31 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
align: 'center', align: 'center',
minWidth: 100, minWidth: 100,
render: row => ( render: row => (
<span class="text-primary font-bold">{row.nickName} Pts</span> <span class="text-primary font-bold">
) {row.nickName}
{' '}
Pts
</span>
),
}, },
{ {
key: 'createTime', key: 'createTime',
title: '最后更新时间', title: '最后更新时间',
align: 'center', align: 'center',
minWidth: 160 minWidth: 160,
}, },
{ {
key: 'status', key: 'status',
title: '发布状态', title: '发布状态',
align: 'center', align: 'center',
width: 100, width: 100,
render: row => { render: (row) => {
if (row.status === null) return null; if (row.status === null)
const label = statusMap[row.status] || '未知'; return null
const label = statusMap[row.status] || '未知'
// 1: Published (Success/Green), 2: Pending (Warning/Orange) // 1: Published (Success/Green), 2: Pending (Warning/Orange)
return <NTag type={row.status === '1' ? 'success' : 'warning'}>{label}</NTag>; return <NTag type={row.status === '1' ? 'success' : 'warning'}>{label}</NTag>
} },
}, },
{ {
key: 'operate', key: 'operate',
@ -118,10 +123,10 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
进入排行详情 进入排行详情
</NButton> </NButton>
</div> </div>
) ),
} },
] ],
}); })
const { const {
drawerVisible, drawerVisible,
@ -130,16 +135,14 @@ const {
handleEdit, handleEdit,
checkedRowKeys, checkedRowKeys,
onBatchDeleted, onBatchDeleted,
} = useTableOperate(data, 'id', getData); } = useTableOperate(data, 'id', getData)
function edit(id: number) { function edit(id: number) {
router.push({ name: 'rank_rank-detail', query: { id } }); router.push({ name: 'rank_rank-detail', query: { id } })
} }
function handleBatchPublish() { function handleBatchPublish() {
window.$message?.success('批量发布成功'); window.$message?.success('批量发布成功')
} }
</script> </script>
@ -150,8 +153,12 @@ function handleBatchPublish() {
<template #header> <template #header>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<div class="text-16px font-bold">{{ rankTitle }}</div> <div class="text-16px font-bold">
<div class="text-12px text-gray-500 mt-4px">您可以对所有比赛场次的最终积分进行核对修正并正式发布结果</div> {{ rankTitle }}
</div>
<div class="mt-4px text-12px text-gray-500">
您可以对所有比赛场次的最终积分进行核对修正并正式发布结果
</div>
</div> </div>
<NButton type="primary" ghost class="ml-auto" @click="handleBatchPublish"> <NButton type="primary" ghost class="ml-auto" @click="handleBatchPublish">
<template #icon> <template #icon>
@ -165,9 +172,11 @@ function handleBatchPublish() {
<TableHeaderOperation v-model:columns="columnChecks" :disabled-delete="checkedRowKeys.length === 0" <TableHeaderOperation v-model:columns="columnChecks" :disabled-delete="checkedRowKeys.length === 0"
:loading="loading" @add="handleAdd" @delete="handleBatchDelete" @refresh="getData" /> :loading="loading" @add="handleAdd" @delete="handleBatchDelete" @refresh="getData" />
</template> --> </template> -->
<NDataTable v-model:checked-row-keys="checkedRowKeys" :columns="columns" :data="data" size="small" striped <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" :flex-height="!appStore.isMobile" :scroll-x="962" :loading="loading" remote :row-key="row => row.id"
:pagination="mobilePagination" class="sm:h-full" /> :pagination="mobilePagination" class="sm:h-full"
/>
</NCard> </NCard>
</div> </div>
</template> </template>

View File

@ -1,53 +1,53 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, toRaw } from 'vue'; import { jsonClone } from '@sa/utils'
import { jsonClone } from '@sa/utils'; import { computed, toRaw } from 'vue'
import { useFormRules, useNaiveForm } from '@/hooks/common/form'; import { useFormRules, useNaiveForm } from '@/hooks/common/form'
defineOptions({ defineOptions({
name: 'RankSearch' name: 'RankSearch',
}); })
const emit = defineEmits<Emits>()
interface Emits { interface Emits {
(e: 'search'): void; (e: 'search'): void
} }
const emit = defineEmits<Emits>(); const { formRef, validate, restoreValidation } = useNaiveForm()
const { formRef, validate, restoreValidation } = useNaiveForm(); const model = defineModel<Api.SystemManage.UserSearchParams>('model', { required: true })
const model = defineModel<Api.SystemManage.UserSearchParams>('model', { required: true }); type RuleKey = Extract<keyof Api.SystemManage.UserSearchParams, 'userEmail' | 'userPhone'>
type RuleKey = Extract<keyof Api.SystemManage.UserSearchParams, 'userEmail' | 'userPhone'>;
const rules = computed<Record<RuleKey, App.Global.FormRule>>(() => { const rules = computed<Record<RuleKey, App.Global.FormRule>>(() => {
const { patternRules } = useFormRules(); const { patternRules } = useFormRules()
return { return {
userEmail: patternRules.email, userEmail: patternRules.email,
userPhone: patternRules.phone userPhone: patternRules.phone,
}; }
}); })
const defaultModel = jsonClone(toRaw(model.value)); const defaultModel = jsonClone(toRaw(model.value))
function resetModel() { function resetModel() {
Object.assign(model.value, defaultModel); Object.assign(model.value, defaultModel)
} }
async function reset() { async function reset() {
await restoreValidation(); await restoreValidation()
resetModel(); resetModel()
} }
async function search() { async function search() {
await validate(); await validate()
emit('search'); emit('search')
} }
const publishStatusOptions = [ const publishStatusOptions = [
{ label: '待审核', value: '2' }, // Mapping to '2' (Warning) { label: '待审核', value: '2' }, // Mapping to '2' (Warning)
{ label: '已发布', value: '1' } // Mapping to '1' (Success) { label: '已发布', value: '1' }, // Mapping to '1' (Success)
]; ]
</script> </script>
<template> <template>

View File

@ -1,13 +1,13 @@
<script setup lang="tsx"> <script setup lang="tsx">
import { reactive } from 'vue'; import { NButton, NPopconfirm, NTag } from 'naive-ui'
import { NButton, NPopconfirm, NTag } from 'naive-ui'; import { reactive } from 'vue'
import { fetchGetUserList } from '@/service/api'; import { defaultTransform, useNaivePaginatedTable, useTableOperate } from '@/hooks/common/table'
import { useAppStore } from '@/store/modules/app'; import { fetchGetUserList } from '@/service/api'
import { defaultTransform, useNaivePaginatedTable, useTableOperate } from '@/hooks/common/table'; import { useAppStore } from '@/store/modules/app'
import TemplateOperateDrawer from './modules/template-operate-drawer.vue'; import TemplateOperateDrawer from './modules/template-operate-drawer.vue'
import TemplateSearch from './modules/template-search.vue'; import TemplateSearch from './modules/template-search.vue'
const appStore = useAppStore(); const appStore = useAppStore()
const searchParams: Api.SystemManage.UserSearchParams = reactive({ const searchParams: Api.SystemManage.UserSearchParams = reactive({
current: 1, current: 1,
@ -17,48 +17,48 @@ const searchParams: Api.SystemManage.UserSearchParams = reactive({
userGender: null, userGender: null,
nickName: null, nickName: null,
userPhone: null, userPhone: null,
userEmail: null userEmail: null,
}); })
const templateTitle = '模板管理'; const templateTitle = '模板管理'
const competitionMap: Record<string, string> = { const competitionMap: Record<string, string> = {
1: '第九届阅读之星大赛', 1: '第九届阅读之星大赛',
2: '科普阅读大赛' 2: '科普阅读大赛',
}; }
const statusMap: Record<string, string> = { const statusMap: Record<string, string> = {
1: '铺码成功', 1: '铺码成功',
2: '未铺码' 2: '未铺码',
}; }
const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagination } = useNaivePaginatedTable({ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagination } = useNaivePaginatedTable({
api: () => fetchGetUserList(searchParams), api: () => fetchGetUserList(searchParams),
transform: response => { transform: (response) => {
const transformed = defaultTransform(response); const transformed = defaultTransform(response)
transformed.data.forEach((item, index) => { transformed.data.forEach((item, index) => {
item.nickName = `MD${String(index + 1).padStart(6, '0')}`; item.nickName = `MD${String(index + 1).padStart(6, '0')}`
item.userPhone = '210mmx297mm'; item.userPhone = '210mmx297mm'
item.userGender = (item.userGender === '1' || item.userGender === '2') ? item.userGender : '1'; item.userGender = (item.userGender === '1' || item.userGender === '2') ? item.userGender : '1'
}); })
return transformed; return transformed
}, },
onPaginationParamsChange: params => { onPaginationParamsChange: (params) => {
searchParams.current = params.page; searchParams.current = params.page
searchParams.size = params.pageSize; searchParams.size = params.pageSize
}, },
columns: () => [ columns: () => [
{ {
type: 'selection', type: 'selection',
align: 'center', align: 'center',
width: 48 width: 48,
}, },
{ {
key: 'index', key: 'index',
title: '序号', title: '序号',
align: 'center', align: 'center',
width: 60, width: 60,
render: (_, index) => index + 1 render: (_, index) => index + 1,
}, },
{ {
key: 'userName', key: 'userName',
@ -74,10 +74,10 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
key: 'userGender', key: 'userGender',
title: '关联比赛', title: '关联比赛',
align: 'center', align: 'center',
render: row => { render: (row) => {
const label = competitionMap[row.userGender as string] || '未知比赛'; const label = competitionMap[row.userGender as string] || '未知比赛'
return <span>{label}</span>; return <span>{label}</span>
} },
}, },
{ {
key: 'userPhone', key: 'userPhone',
@ -89,13 +89,13 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
title: '状态', title: '状态',
align: 'center', align: 'center',
width: 100, width: 100,
render: row => { render: (row) => {
if (row.status === null) { if (row.status === null) {
return null; return null
} }
const label = statusMap[row.status] || '未知'; const label = statusMap[row.status] || '未知'
return <NTag type={row.status === '1' ? 'success' : 'warning'}>{label}</NTag>; return <NTag type={row.status === '1' ? 'success' : 'warning'}>{label}</NTag>
} },
}, },
{ {
key: 'operate', key: 'operate',
@ -126,14 +126,14 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
<NButton type="error" ghost size="small"> <NButton type="error" ghost size="small">
删除 删除
</NButton> </NButton>
) ),
}} }}
</NPopconfirm> </NPopconfirm>
</div> </div>
) ),
} },
] ],
}); })
const { const {
drawerVisible, drawerVisible,
@ -143,26 +143,21 @@ const {
handleEdit, handleEdit,
checkedRowKeys, checkedRowKeys,
onBatchDeleted, onBatchDeleted,
onDeleted onDeleted,
// closeDrawer // closeDrawer
} = useTableOperate(data, 'id', getData); } = useTableOperate(data, 'id', getData)
async function handleBatchDelete() { async function handleBatchDelete() {
// request // request
console.log(checkedRowKeys.value); onBatchDeleted()
onBatchDeleted();
} }
function handleDelete(id: number) { function handleDelete(id: number) {
// request onDeleted(id)
console.log(id);
onDeleted();
} }
function edit(id: number) { function edit(id: number) {
handleEdit(id); handleEdit(id)
} }
</script> </script>
@ -171,14 +166,20 @@ function edit(id: number) {
<TemplateSearch v-model:model="searchParams" @search="getDataByPage" /> <TemplateSearch v-model:model="searchParams" @search="getDataByPage" />
<NCard :title="templateTitle" :bordered="false" size="small" class="card-wrapper sm:flex-1-hidden"> <NCard :title="templateTitle" :bordered="false" size="small" class="card-wrapper sm:flex-1-hidden">
<template #header-extra> <template #header-extra>
<TableHeaderOperation v-model:columns="columnChecks" :disabled-delete="checkedRowKeys.length === 0" <TableHeaderOperation
:loading="loading" @add="handleAdd" @delete="handleBatchDelete" @refresh="getData" /> v-model:columns="columnChecks" :disabled-delete="checkedRowKeys.length === 0"
:loading="loading" @add="handleAdd" @delete="handleBatchDelete" @refresh="getData"
/>
</template> </template>
<NDataTable v-model:checked-row-keys="checkedRowKeys" :columns="columns" :data="data" size="small" striped <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" :flex-height="!appStore.isMobile" :scroll-x="962" :loading="loading" remote :row-key="row => row.id"
:pagination="mobilePagination" class="sm:h-full" /> :pagination="mobilePagination" class="sm:h-full"
<TemplateOperateDrawer v-model:visible="drawerVisible" :operate-type="operateType" :row-data="editingData" />
@submitted="getDataByPage" /> <TemplateOperateDrawer
v-model:visible="drawerVisible" :operate-type="operateType" :row-data="editingData"
@submitted="getDataByPage"
/>
</NCard> </NCard>
</div> </div>
</template> </template>

View File

@ -1,47 +1,47 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue'; import { jsonClone } from '@sa/utils'
import { jsonClone } from '@sa/utils'; import { computed, ref, watch } from 'vue'
import { enableStatusOptions } from '@/constants/business'; import { enableStatusOptions } from '@/constants/business'
import { useFormRules, useNaiveForm } from '@/hooks/common/form'; import { useFormRules, useNaiveForm } from '@/hooks/common/form'
defineOptions({ defineOptions({
name: 'TemplateOperateDrawer' name: 'TemplateOperateDrawer',
}); })
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
interface Props { interface Props {
operateType: NaiveUI.TableOperateType; operateType: NaiveUI.TableOperateType
rowData?: Api.SystemManage.User | null; rowData?: Api.SystemManage.User | null
} }
const props = defineProps<Props>();
interface Emits { interface Emits {
(e: 'submitted'): void; (e: 'submitted'): void
} }
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', { const visible = defineModel<boolean>('visible', {
default: false default: false,
}); })
const { formRef, validate, restoreValidation } = useNaiveForm(); const { formRef, validate, restoreValidation } = useNaiveForm()
const { defaultRequiredRule } = useFormRules(); const { defaultRequiredRule } = useFormRules()
const title = computed(() => { const title = computed(() => {
const titles: Record<NaiveUI.TableOperateType, string> = { const titles: Record<NaiveUI.TableOperateType, string> = {
add: '新增模板', add: '新增模板',
edit: '编辑模板' edit: '编辑模板',
}; }
return titles[props.operateType]; return titles[props.operateType]
}); })
type Model = Pick< type Model = Pick<
Api.SystemManage.User, Api.SystemManage.User,
'userName' | 'userGender' | 'nickName' | 'userPhone' | 'userEmail' | 'userRoles' | 'status' 'userName' | 'userGender' | 'nickName' | 'userPhone' | 'userEmail' | 'userRoles' | 'status'
>; >
const model = ref(createDefaultModel()); const model = ref(createDefaultModel())
function createDefaultModel(): Model { function createDefaultModel(): Model {
return { return {
@ -51,53 +51,53 @@ function createDefaultModel(): Model {
userPhone: '', userPhone: '',
userEmail: '', userEmail: '',
userRoles: [], userRoles: [],
status: null status: null,
}; }
} }
type RuleKey = Extract<keyof Model, 'userName' | 'status'>; type RuleKey = Extract<keyof Model, 'userName' | 'status'>
const rules: Record<RuleKey, App.Global.FormRule> = { const rules: Record<RuleKey, App.Global.FormRule> = {
userName: defaultRequiredRule, userName: defaultRequiredRule,
status: defaultRequiredRule status: defaultRequiredRule,
}; }
function handleInitModel() { function handleInitModel() {
model.value = createDefaultModel(); model.value = createDefaultModel()
if (props.operateType === 'edit' && props.rowData) { if (props.operateType === 'edit' && props.rowData) {
Object.assign(model.value, jsonClone(props.rowData)); Object.assign(model.value, jsonClone(props.rowData))
} }
} }
function closeDrawer() { function closeDrawer() {
visible.value = false; visible.value = false
} }
async function handleSubmit() { async function handleSubmit() {
await validate(); await validate()
// request // request
window.$message?.success('更新成功'); window.$message?.success('更新成功')
closeDrawer(); closeDrawer()
emit('submitted'); emit('submitted')
} }
watch(visible, () => { watch(visible, () => {
if (visible.value) { if (visible.value) {
handleInitModel(); handleInitModel()
restoreValidation(); restoreValidation()
} }
}); })
const competitionOptions = [ const competitionOptions = [
{ label: '第九届阅读之星大赛', value: '1' }, { label: '第九届阅读之星大赛', value: '1' },
{ label: '科普阅读大赛', value: '2' } { label: '科普阅读大赛', value: '2' },
]; ]
const sizeOptions = [ const sizeOptions = [
{ label: '210mmx297mm', value: '1' }, { label: '210mmx297mm', value: '1' },
{ label: 'A3', value: '2' } { label: 'A3', value: '2' },
]; ]
</script> </script>
<template> <template>
@ -132,8 +132,12 @@ const sizeOptions = [
</NForm> </NForm>
<template #footer> <template #footer>
<NSpace :size="16"> <NSpace :size="16">
<NButton @click="closeDrawer">取消</NButton> <NButton @click="closeDrawer">
<NButton type="primary" @click="handleSubmit">确认</NButton> 取消
</NButton>
<NButton type="primary" @click="handleSubmit">
确认
</NButton>
</NSpace> </NSpace>
</template> </template>
</NDrawerContent> </NDrawerContent>

View File

@ -1,59 +1,59 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, toRaw } from 'vue'; import { jsonClone } from '@sa/utils'
import { jsonClone } from '@sa/utils'; import { computed, toRaw } from 'vue'
import { enableStatusOptions } from '@/constants/business'; import { enableStatusOptions } from '@/constants/business'
import { useFormRules, useNaiveForm } from '@/hooks/common/form'; import { useFormRules, useNaiveForm } from '@/hooks/common/form'
defineOptions({ defineOptions({
name: 'TemplateSearch' name: 'TemplateSearch',
}); })
const emit = defineEmits<Emits>()
interface Emits { interface Emits {
(e: 'search'): void; (e: 'search'): void
} }
const emit = defineEmits<Emits>(); const { formRef, validate, restoreValidation } = useNaiveForm()
const { formRef, validate, restoreValidation } = useNaiveForm(); const model = defineModel<Api.SystemManage.UserSearchParams>('model', { required: true })
const model = defineModel<Api.SystemManage.UserSearchParams>('model', { required: true }); type RuleKey = Extract<keyof Api.SystemManage.UserSearchParams, 'userEmail' | 'userPhone'>
type RuleKey = Extract<keyof Api.SystemManage.UserSearchParams, 'userEmail' | 'userPhone'>;
const rules = computed<Record<RuleKey, App.Global.FormRule>>(() => { const rules = computed<Record<RuleKey, App.Global.FormRule>>(() => {
const { patternRules } = useFormRules(); const { patternRules } = useFormRules()
return { return {
userEmail: patternRules.email, userEmail: patternRules.email,
userPhone: patternRules.phone userPhone: patternRules.phone,
}; }
}); })
const defaultModel = jsonClone(toRaw(model.value)); const defaultModel = jsonClone(toRaw(model.value))
function resetModel() { function resetModel() {
Object.assign(model.value, defaultModel); Object.assign(model.value, defaultModel)
} }
async function reset() { async function reset() {
await restoreValidation(); await restoreValidation()
resetModel(); resetModel()
} }
async function search() { async function search() {
await validate(); await validate()
emit('search'); emit('search')
} }
const competitionOptions = [ const competitionOptions = [
{ label: '第九届阅读之星大赛', value: '1' }, { label: '第九届阅读之星大赛', value: '1' },
{ label: '科普阅读大赛', value: '2' } { label: '科普阅读大赛', value: '2' },
]; ]
const sizeOptions = [ const sizeOptions = [
{ label: '210mmx297mm', value: '1' }, { label: '210mmx297mm', value: '1' },
{ label: 'A3', value: '2' } { label: 'A3', value: '2' },
]; ]
</script> </script>
<template> <template>
@ -66,8 +66,10 @@ const sizeOptions = [
<NInput v-model:value="model.userName" placeholder="请输入名称" /> <NInput v-model:value="model.userName" placeholder="请输入名称" />
</NFormItemGi> </NFormItemGi>
<NFormItemGi span="24 s:12 m:6" label="关联比赛" path="userGender" class="pr-24px"> <NFormItemGi span="24 s:12 m:6" label="关联比赛" path="userGender" class="pr-24px">
<NSelect v-model:value="model.userGender" placeholder="请选择" :options="competitionOptions as any" <NSelect
clearable /> v-model:value="model.userGender" placeholder="请选择" :options="competitionOptions as any"
clearable
/>
</NFormItemGi> </NFormItemGi>
<NFormItemGi span="24 s:12 m:6" label="状态" path="userStatus" class="pr-24px"> <NFormItemGi span="24 s:12 m:6" label="状态" path="userStatus" class="pr-24px">
<NSelect v-model:value="model.status" placeholder="请选择" :options="enableStatusOptions as any" clearable /> <NSelect v-model:value="model.status" placeholder="请选择" :options="enableStatusOptions as any" clearable />