feat(admin): 新增题库、排行榜、实时结果和模板管理功能
- 添加题库管理页面及相关组件 - 实现排行榜管理功能,包括列表和详情页 - 新增实时结果展示页面 - 添加模板制作和管理功能 - 完善路由配置和国际化支持 - 新增阿里云OSS文件上传服务 - 添加多种工具函数和类型定义 - 优化表格和表单组件 - 调整布局和样式细节
This commit is contained in:
@ -4,10 +4,10 @@ import { bgRed, bgYellow, green, lightBlue } from 'kolorist'
|
|||||||
import { createServiceConfig } from '../../src/utils/service'
|
import { createServiceConfig } from '../../src/utils/service'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set http proxy
|
* 设置 HTTP 代理
|
||||||
*
|
*
|
||||||
* @param env - The current env
|
* @param env - 当前环境变量
|
||||||
* @param enable - If enable http proxy
|
* @param enable - 是否启用 HTTP 代理
|
||||||
*/
|
*/
|
||||||
export function createViteProxy(env: Env.ImportMeta, enable: boolean) {
|
export function createViteProxy(env: Env.ImportMeta, enable: boolean) {
|
||||||
const isEnableHttpProxy = enable && env.VITE_HTTP_PROXY === 'Y'
|
const isEnableHttpProxy = enable && env.VITE_HTTP_PROXY === 'Y'
|
||||||
@ -18,6 +18,7 @@ export function createViteProxy(env: Env.ImportMeta, enable: boolean) {
|
|||||||
const isEnableProxyLog = env.VITE_PROXY_LOG === 'Y'
|
const isEnableProxyLog = env.VITE_PROXY_LOG === 'Y'
|
||||||
|
|
||||||
const { baseURL, proxyPattern, other } = createServiceConfig(env)
|
const { baseURL, proxyPattern, other } = createServiceConfig(env)
|
||||||
|
console.log('baseURL:', baseURL, 'proxyPattern:', proxyPattern, 'other:', other)
|
||||||
|
|
||||||
const proxy: Record<string, ProxyOptions> = createProxyItem({ baseURL, proxyPattern }, isEnableProxyLog)
|
const proxy: Record<string, ProxyOptions> = createProxyItem({ baseURL, proxyPattern }, isEnableProxyLog)
|
||||||
|
|
||||||
@ -28,6 +29,12 @@ export function createViteProxy(env: Env.ImportMeta, enable: boolean) {
|
|||||||
return proxy
|
return proxy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 HTTP 代理项
|
||||||
|
*
|
||||||
|
* @param item - 服务配置项
|
||||||
|
* @param enableLog - 是否启用日志记录
|
||||||
|
*/
|
||||||
function createProxyItem(item: App.Service.ServiceConfigItem, enableLog: boolean) {
|
function createProxyItem(item: App.Service.ServiceConfigItem, enableLog: boolean) {
|
||||||
const proxy: Record<string, ProxyOptions> = {}
|
const proxy: Record<string, ProxyOptions> = {}
|
||||||
|
|
||||||
@ -39,16 +46,16 @@ function createProxyItem(item: App.Service.ServiceConfigItem, enableLog: boolean
|
|||||||
if (!enableLog)
|
if (!enableLog)
|
||||||
return
|
return
|
||||||
|
|
||||||
const requestUrl = `${lightBlue('[proxy url]')}: ${bgYellow(` ${req.method} `)} ${green(`${item.proxyPattern}${req.url}`)}`
|
const requestUrl = `${lightBlue('[代理地址]')}: ${bgYellow(` ${req.method} `)} ${green(`${item.proxyPattern}${req.url}`)}`
|
||||||
|
|
||||||
const proxyUrl = `${lightBlue('[real request url]')}: ${green(`${options.target}${req.url}`)}`
|
const proxyUrl = `${lightBlue('[真实请求地址]')}: ${green(`${options.target}${req.url}`)}`
|
||||||
|
|
||||||
consola.log(`${requestUrl}\n${proxyUrl}`)
|
consola.log(`${requestUrl}\n${proxyUrl}`)
|
||||||
})
|
})
|
||||||
_proxy.on('error', (_err, req, _res) => {
|
_proxy.on('error', (_err, req, _res) => {
|
||||||
if (!enableLog)
|
if (!enableLog)
|
||||||
return
|
return
|
||||||
consola.log(bgRed(`Error: ${req.method} `), green(`${options.target}${req.url}`))
|
consola.log(bgRed(`错误: ${req.method} `), green(`${options.target}${req.url}`))
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
rewrite: path => path.replace(new RegExp(`^${item.proxyPattern}`), ''),
|
rewrite: path => path.replace(new RegExp(`^${item.proxyPattern}`), ''),
|
||||||
|
|||||||
@ -26,6 +26,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@better-scroll/core": "2.5.1",
|
"@better-scroll/core": "2.5.1",
|
||||||
"@iconify/vue": "5.0.0",
|
"@iconify/vue": "5.0.0",
|
||||||
|
"@opentiny/fluent-editor": "^4.0.1",
|
||||||
"@sa/axios": "workspace:*",
|
"@sa/axios": "workspace:*",
|
||||||
"@sa/color": "workspace:*",
|
"@sa/color": "workspace:*",
|
||||||
"@sa/hooks": "workspace:*",
|
"@sa/hooks": "workspace:*",
|
||||||
@ -37,9 +38,12 @@
|
|||||||
"defu": "6.1.4",
|
"defu": "6.1.4",
|
||||||
"echarts": "6.0.0",
|
"echarts": "6.0.0",
|
||||||
"json5": "2.2.3",
|
"json5": "2.2.3",
|
||||||
|
"katex": "^0.16.27",
|
||||||
|
"mathlive": "^0.108.2",
|
||||||
"naive-ui": "2.43.2",
|
"naive-ui": "2.43.2",
|
||||||
"nprogress": "0.2.0",
|
"nprogress": "0.2.0",
|
||||||
"pinia": "3.0.4",
|
"pinia": "3.0.4",
|
||||||
|
"quill-toolbar-tip": "^0.1.0",
|
||||||
"tailwind-merge": "3.4.0",
|
"tailwind-merge": "3.4.0",
|
||||||
"vue": "3.5.26",
|
"vue": "3.5.26",
|
||||||
"vue-draggable-plus": "0.6.0",
|
"vue-draggable-plus": "0.6.0",
|
||||||
|
|||||||
@ -0,0 +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">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { NModal } from 'naive-ui';
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'hide'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
visible.value = true;
|
||||||
|
}
|
||||||
|
function hide() {
|
||||||
|
visible.value = false;
|
||||||
|
emit('hide');
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
show,
|
||||||
|
hide,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<NModal v-model:show="visible" preset="card" title="上传进度" style="width: 600px">
|
||||||
|
<NScrollbar style="max-height: 700px">
|
||||||
|
<div class="list">
|
||||||
|
<div v-for="item in props.upFileList" :key="item.id" class="item">
|
||||||
|
<NProgress type="circle" :percentage="item.progress" :indicator-placement="'inside'" :radius="40" />
|
||||||
|
<span class="name">{{ item.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NScrollbar>
|
||||||
|
</NModal>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
.list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100px;
|
||||||
|
margin: 15px;
|
||||||
|
|
||||||
|
.name {
|
||||||
|
// 超出部分省略号
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,263 @@
|
|||||||
|
import { getAliOssTokenAxios } from '@/service/api/upload';
|
||||||
|
import { browserPathJoin } from '@/utils/date';
|
||||||
|
import { initOSSClient, uploadFileToOSS } from '@/utils/oss';
|
||||||
|
import type { IEditorConfig, ToolbarOptions } from '@opentiny/fluent-editor/types/config/types';
|
||||||
|
import type OSS from 'ali-oss';
|
||||||
|
import katex from 'katex';
|
||||||
|
import { default as FluentEditor, type MathliveModule, type Range, generateToolbarTip } from '@opentiny/fluent-editor';
|
||||||
|
import QuillToolbarTip, { type QuillToolbarTipOptions } from 'quill-toolbar-tip';
|
||||||
|
import { base64ToFile } from '@/utils/file';
|
||||||
|
import { getSnowflake } from '@/utils/rest';
|
||||||
|
import type { GetEditorConfigParams, MyToolbarOption, UpFile } from './types';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
/** 自定义打开iframe的按钮的key */
|
||||||
|
export const MY_OPEN_PROCESSON_IFRAME = 'my-open-processon-iframe' as const;
|
||||||
|
export const upFileList = ref<UpFile[]>([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化编辑器
|
||||||
|
*/
|
||||||
|
export function initEditor(container: HTMLElement, params: IEditorConfig): FluentEditor {
|
||||||
|
// 需要依赖 katex
|
||||||
|
window.katex = katex;
|
||||||
|
|
||||||
|
// 处理图片给每个图片添加类名和最大宽度
|
||||||
|
const ImageBlot: any = FluentEditor.import('formats/image');
|
||||||
|
class CustomImageBlot extends ImageBlot {
|
||||||
|
static create(value: any) {
|
||||||
|
const node: HTMLImageElement = super.create(value);
|
||||||
|
editImageAttribute(node, false);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FluentEditor.register(CustomImageBlot, true);
|
||||||
|
|
||||||
|
// 处理视频给每个视频添加类名和最大宽度
|
||||||
|
const VideoBlot: any = FluentEditor.import('formats/video');
|
||||||
|
class CustomVideoBlot extends VideoBlot {
|
||||||
|
static create(value: any) {
|
||||||
|
const node: HTMLVideoElement = super.create(value);
|
||||||
|
editVideoAttribute(node, false);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FluentEditor.register(CustomVideoBlot, true);
|
||||||
|
|
||||||
|
// 注册工具栏提示模块
|
||||||
|
FluentEditor.register({ 'modules/toolbar-tip': generateToolbarTip(QuillToolbarTip) }, true);
|
||||||
|
const Parchment = FluentEditor.import('parchment');
|
||||||
|
addToolbarIcon();
|
||||||
|
|
||||||
|
// 居中问题
|
||||||
|
const config = { scope: Parchment.Scope.BLOCK, whitelist: ['right', 'center', 'justify'] };
|
||||||
|
new Parchment.StyleAttributor('align', 'text-align', config);
|
||||||
|
const Align = FluentEditor.import('attributors/style/align');
|
||||||
|
FluentEditor.register(Align, true);
|
||||||
|
|
||||||
|
// 工具栏配置
|
||||||
|
return new FluentEditor(container, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 得到编辑器的配置
|
||||||
|
*/
|
||||||
|
export async function getEditorConfig(params: GetEditorConfigParams): Promise<IEditorConfig> {
|
||||||
|
const editorOptions: IEditorConfig = {
|
||||||
|
theme: 'snow',
|
||||||
|
modules: {
|
||||||
|
// 是否开启数学公式模块
|
||||||
|
mathlive: true,
|
||||||
|
// 开启字数统计
|
||||||
|
// counter: {
|
||||||
|
// count: 2000,
|
||||||
|
// },
|
||||||
|
toolbar: {
|
||||||
|
// 工具栏显示那些按钮
|
||||||
|
container: await getToolbarOption(params.toolbarOption || {}),
|
||||||
|
handlers: {
|
||||||
|
formula() {
|
||||||
|
// 点击数学公式按钮时打开弹出框
|
||||||
|
const mathlive = this.quill.getModule('mathlive') as MathliveModule;
|
||||||
|
mathlive.createDialog('');
|
||||||
|
},
|
||||||
|
// 自定义打开iframe的按钮的点击事件的回调
|
||||||
|
[`${MY_OPEN_PROCESSON_IFRAME}`](_value: boolean) {
|
||||||
|
params[MY_OPEN_PROCESSON_IFRAME](); // 执行回调
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
uploader: {
|
||||||
|
// only allow image
|
||||||
|
mimetypes: ['*', 'video/*', 'audio/*'],
|
||||||
|
async handler(_range: Range, files: File[]): Promise<(string | false)[]> {
|
||||||
|
try {
|
||||||
|
const paths = await Promise.all(files.map((file, index) => upFileAxios(file, `${Date.now()}_${index}`)));
|
||||||
|
return paths;
|
||||||
|
} catch (error) {
|
||||||
|
console.log('error====', error);
|
||||||
|
return files.map(() => false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
clipboard: {
|
||||||
|
matchers: [
|
||||||
|
[
|
||||||
|
'img',
|
||||||
|
(node: HTMLImageElement, delta: any) => {
|
||||||
|
editImageAttribute(node, false);
|
||||||
|
return delta;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'video',
|
||||||
|
(node: HTMLVideoElement, delta: any) => {
|
||||||
|
editVideoAttribute(node, false);
|
||||||
|
return delta;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// 工具栏提示配置
|
||||||
|
'toolbar-tip': {
|
||||||
|
defaultTooltipOptions: {
|
||||||
|
tipHoverable: false,
|
||||||
|
},
|
||||||
|
tipTextMap: {
|
||||||
|
[MY_OPEN_PROCESSON_IFRAME]: '跳转第三方公式编辑器\n可在第三方编辑器编辑好公式后复制过来\n[注意]:需要复制到公式输入框内才能生效',
|
||||||
|
},
|
||||||
|
} satisfies Partial<QuillToolbarTipOptions>,
|
||||||
|
// 国际化配置
|
||||||
|
i18n: { lang: 'zh-CN' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return editorOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 得到工具栏配置(显示那些按钮)
|
||||||
|
*/
|
||||||
|
function getToolbarOption(params: MyToolbarOption): Promise<ToolbarOptions['container']> {
|
||||||
|
const { show, showUpFile, showUpVideo, customToolbar } = params;
|
||||||
|
|
||||||
|
if (show === false) {
|
||||||
|
return Promise.resolve([] satisfies ToolbarOptions['container']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customToolbar !== undefined) {
|
||||||
|
return Promise.resolve(customToolbar);
|
||||||
|
}
|
||||||
|
const va = ['image'];
|
||||||
|
showUpVideo !== false && va.push('video'); // 只要不等于false就添加 (undefined也添加)
|
||||||
|
showUpFile !== false && va.push('file'); // 只要不等于false就添加 (undefined也添加)
|
||||||
|
// 工具栏配置
|
||||||
|
const TOOLBAR_CONFIG: ToolbarOptions['container'] = [
|
||||||
|
['undo', 'redo', 'clean', 'format-painter'],
|
||||||
|
[
|
||||||
|
{ header: [1, 2, 3, 4, 5, 6, false] },
|
||||||
|
{ 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'] },
|
||||||
|
{ 'font': [false, 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', '宋体', '黑体', '微软雅黑', '楷体', '仿宋', '等线'] },
|
||||||
|
],
|
||||||
|
['bold', 'italic', 'strike', 'underline', 'divider'],
|
||||||
|
[{ color: [] }, { background: [] }],
|
||||||
|
[{ align: '' }, { align: 'center' }, { align: 'right' }, { align: 'justify' }], // 这里可以为字符串
|
||||||
|
[{ list: 'ordered' }, { list: 'bullet' }, { list: 'check' }],
|
||||||
|
[{ script: 'sub' }, { script: 'super' }],
|
||||||
|
[{ indent: '-1' }, { indent: '+1' }],
|
||||||
|
['link', 'blockquote'],
|
||||||
|
[...va],
|
||||||
|
['fullscreen', 'formula', MY_OPEN_PROCESSON_IFRAME],
|
||||||
|
];
|
||||||
|
return Promise.resolve(TOOLBAR_CONFIG);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加打开iframe的弹出框的自定义按钮
|
||||||
|
*/
|
||||||
|
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 icons = FluentEditor.import('ui/icons') as Record<string, string>;
|
||||||
|
icons[MY_OPEN_PROCESSON_IFRAME] = myOpenProcessonIframeIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文件
|
||||||
|
*/
|
||||||
|
async function upFileAxios(file: File, id: string): Promise<string | false> {
|
||||||
|
const item: UpFile = { id, name: file.name, progress: 0 };
|
||||||
|
try {
|
||||||
|
upFileList.value.push(item);
|
||||||
|
const tokenRes = await getAliOssTokenAxios();
|
||||||
|
// 初始化OSS客户端
|
||||||
|
const client = initOSSClient(tokenRes);
|
||||||
|
const path = browserPathJoin(`temp/${Date.now()}`, file!.name);
|
||||||
|
// 上传文件
|
||||||
|
const res: OSS.MultipartUploadResult = await uploadFileToOSS(client, file, path, (progress) => {
|
||||||
|
item.progress = Math.floor(progress * 10000) / 100;
|
||||||
|
});
|
||||||
|
const url = browserPathJoin(import.meta.env.VITE_BASE_OSS_URL, res.name);
|
||||||
|
upFileList.value.splice(upFileList.value.indexOf(item), 1);
|
||||||
|
return Promise.resolve(url);
|
||||||
|
// oxlint-disable-next-line no-unused-vars
|
||||||
|
} catch (error) {
|
||||||
|
upFileList.value.splice(upFileList.value.indexOf(item), 1);
|
||||||
|
window.$message?.error('上传失败');
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑视频属性 */
|
||||||
|
export function editVideoAttribute(node: HTMLVideoElement, isReplace = true) {
|
||||||
|
node.setAttribute('style', 'max-width: 100%;'); // 强制内联样式
|
||||||
|
node.classList.add('my-ql-video');
|
||||||
|
isReplace && replaceFileUrl(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑图片属性 */
|
||||||
|
export function editImageAttribute(node: HTMLImageElement, isReplace = true) {
|
||||||
|
node.setAttribute('style', 'max-width: 100%;'); // 强制内联样式
|
||||||
|
node.classList.add('my-ql-image');
|
||||||
|
isReplace && replaceFileUrl(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* base64替换为url地址
|
||||||
|
* @param node
|
||||||
|
*/
|
||||||
|
export function replaceFileUrl(node: HTMLImageElement | HTMLVideoElement) {
|
||||||
|
const ex = getExtensionFromDataUrl(node.src);
|
||||||
|
if (ex) {
|
||||||
|
const file = base64ToFile(node.src, `${Date.now()}_${getSnowflake()}.${ex}`);
|
||||||
|
upFileAxios(file, file.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeToExtension: Record<string, string> = {
|
||||||
|
'image/png': 'png',
|
||||||
|
'image/jpeg': 'jpg',
|
||||||
|
'image/jpg': 'jpg',
|
||||||
|
'image/gif': 'gif',
|
||||||
|
'image/webp': 'webp',
|
||||||
|
'image/svg': 'webp',
|
||||||
|
'video/mp4': 'mp4',
|
||||||
|
'audio/mpeg': 'mp3',
|
||||||
|
'application/pdf': 'pdf',
|
||||||
|
'text/plain': 'txt',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 base64 获取文件扩展名
|
||||||
|
* @param dataUrl
|
||||||
|
* @param mimeMap
|
||||||
|
*/
|
||||||
|
function getExtensionFromDataUrl(dataUrl: string, mimeMap = mimeToExtension): string | null {
|
||||||
|
const base64Pattern = /^data:(image|video)\/([a-zA-Z]+);base64,([A-Za-z0-9+/]+={0,2})$/;
|
||||||
|
if (base64Pattern.test(dataUrl)) {
|
||||||
|
const mimeType = dataUrl.split(',')?.[0]?.split(':')?.[1]?.split(';')[0];
|
||||||
|
return mimeType && mimeType in mimeMap ? mimeMap[mimeType]! : null;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,236 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="editorContainerRef" class="rest-basic-editor" :class="{ 'hide-toolbar': showToolbar }">
|
||||||
|
<div ref="editorRef"></div>
|
||||||
|
<math-iframe-dialog ref="mathIframeDalogRef" @hide="hideIframeDialog"></math-iframe-dialog>
|
||||||
|
<up-file-dialog :upFileList="upFileList"></up-file-dialog>
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
|
/* stylelint-disable selector-attribute-name-disallowed-list */
|
||||||
|
.ql-toolbar.ql-snow {
|
||||||
|
// 行高
|
||||||
|
.ql-picker.ql-line-height .ql-picker-label:before,
|
||||||
|
.ql-picker.ql-line-height .ql-picker-item:before {
|
||||||
|
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) ')';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.rest-basic-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
&.hide-toolbar :deep(.ql-toolbar) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep() {
|
||||||
|
.ql-container.ql-snow .toolbar-tip__tooltip {
|
||||||
|
position: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-container.ql-snow {
|
||||||
|
display: flex;
|
||||||
|
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-picker {
|
||||||
|
width: auto;
|
||||||
|
|
||||||
|
.ql-picker-label {
|
||||||
|
&:before {
|
||||||
|
width: auto;
|
||||||
|
padding-right: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
right: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
padding-left: 0;
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
display: none;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> .ql-picker.ql-expanded .ql-picker-options {
|
||||||
|
z-index: 3;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
23
apps/admin/src/components/common/rest-basic-editor/types.ts
Normal file
23
apps/admin/src/components/common/rest-basic-editor/types.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import type { ToolbarOptions } from '@opentiny/fluent-editor';
|
||||||
|
import type { MY_OPEN_PROCESSON_IFRAME } from './editor-util';
|
||||||
|
|
||||||
|
export type MyToolbarOption = {
|
||||||
|
/** 是否显示工具栏 */
|
||||||
|
show?: boolean;
|
||||||
|
/** 是否显示上传文件按钮 */
|
||||||
|
showUpFile?: boolean;
|
||||||
|
/** 是否显示上传视频按钮 */
|
||||||
|
showUpVideo?: boolean;
|
||||||
|
/** 自定义工具栏 */
|
||||||
|
customToolbar?: ToolbarOptions['container'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetEditorConfigParams = {
|
||||||
|
toolbarOption?: MyToolbarOption;
|
||||||
|
[MY_OPEN_PROCESSON_IFRAME]: () => void;
|
||||||
|
};
|
||||||
|
export type UpFile = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
progress: number;
|
||||||
|
};
|
||||||
113
apps/admin/src/components/custom/README.md
Normal file
113
apps/admin/src/components/custom/README.md
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
# SvgIcon 图标组件使用指南
|
||||||
|
|
||||||
|
`SvgIcon` 是本项目统一使用的图标组件,底层基于 `@iconify/vue`,支持渲染 **Iconify 开源图标** 和 **本地 SVG 图标**。
|
||||||
|
|
||||||
|
## 1. 快速开始
|
||||||
|
|
||||||
|
### 1.1 使用 Iconify 图标 (推荐)
|
||||||
|
本项目集成了 [Iconify](https://iconify.design/),可直接使用海量开源图标库(如 Material Design, Carbon, Phosphor 等)。
|
||||||
|
|
||||||
|
**语法**:
|
||||||
|
```vue
|
||||||
|
<SvgIcon icon="图集名:图标名" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```vue
|
||||||
|
<!-- Material Design Icons -->
|
||||||
|
<SvgIcon icon="mdi:home" class="text-xl text-blue-500" />
|
||||||
|
|
||||||
|
<!-- Carbon Icons -->
|
||||||
|
<SvgIcon icon="carbon:user" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 使用本地 SVG 图标
|
||||||
|
当需要使用设计师提供的自定义图标或彩色图标时。
|
||||||
|
|
||||||
|
1. **存放**:将 `.svg` 文件放入 `src/assets/svg-icon/` 目录。
|
||||||
|
2. **引用**:使用 `local-icon` 属性引用文件名(不含 `.svg` 后缀)。
|
||||||
|
|
||||||
|
**语法**:
|
||||||
|
```vue
|
||||||
|
<SvgIcon local-icon="文件名" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
假设文件位于 `src/assets/svg-icon/custom-logo.svg`:
|
||||||
|
```vue
|
||||||
|
<SvgIcon local-icon="custom-logo" class="text-32px" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 属性说明 (Props)
|
||||||
|
|
||||||
|
该组件定义在 `src/components/custom/svg-icon.vue`。
|
||||||
|
|
||||||
|
| 属性名 | 类型 | 必填 | 默认值 | 说明 |
|
||||||
|
| :--- | :--- | :--- | :--- | :--- |
|
||||||
|
| `icon` | `string` | 否 | - | Iconify 图标名称,格式为 `collection:name`。 |
|
||||||
|
| `localIcon` | `string` | 否 | - | 本地 SVG 文件名。**优先级高于 `icon`**。 |
|
||||||
|
|
||||||
|
> **提示**:
|
||||||
|
> - 组件设置了 `inheritAttrs: false`,但会手动绑定 `class` 和 `style` 到根元素。
|
||||||
|
> - 你可以通过 Tailwind CSS 类名(如 `text-xl`, `text-red-500`)直接控制图标的大小和颜色。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 如何查找与使用图标
|
||||||
|
|
||||||
|
推荐使用 [Icones.js.org](https://icones.js.org/) 图标搜索引擎。
|
||||||
|
|
||||||
|
### 3.1 查找步骤
|
||||||
|
1. 打开 [Icones.js.org](https://icones.js.org/)。
|
||||||
|
2. 输入关键词搜索(如 `user`, `setting`)。
|
||||||
|
3. 点击选中的图标,复制底部的 **ID**(例如 `mdi:shield-airplane-outline`)。
|
||||||
|
|
||||||
|
### 3.2 使用示例对照
|
||||||
|
|
||||||
|
| Icones 图标 ID | 在本项目中的写法 (推荐) |
|
||||||
|
| :--- | :--- |
|
||||||
|
| `mdi:shield-airplane-outline` | `<SvgIcon icon="mdi:shield-airplane-outline" />` |
|
||||||
|
| `material-symbols:android-wifi-4-bar-question` | `<SvgIcon icon="material-symbols:android-wifi-4-bar-question" />` |
|
||||||
|
| `solar:minimize-square-minimalistic-outline` | `<SvgIcon icon="solar:minimize-square-minimalistic-outline" />` |
|
||||||
|
| `tabler:align-box-top-right` | `<SvgIcon icon="tabler:align-box-top-right" />` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 常见问题与进阶
|
||||||
|
|
||||||
|
### 4.1 什么是 `icon-ic-baseline-refresh`?
|
||||||
|
在项目中(如 `competition-add/index.vue`)你可能会看到这种写法:
|
||||||
|
```vue
|
||||||
|
<icon-ic-baseline-refresh class="text-icon" />
|
||||||
|
```
|
||||||
|
这是 `unplugin-icons` 插件提供的**自动组件导入**功能。
|
||||||
|
- **命名规则**:`{Prefix}-{Collection}-{Name}`
|
||||||
|
- **配置来源**:`.env` 文件中的 `VITE_ICON_PREFIX=icon`。
|
||||||
|
- **解析**:`icon-ic-baseline-refresh` 对应图集 `ic` 下的 `baseline-refresh` 图标。
|
||||||
|
|
||||||
|
**建议**:虽然支持这种写法,但为了统一性和灵活性(支持动态变量),**推荐统一使用 `<SvgIcon />` 组件**。
|
||||||
|
|
||||||
|
### 4.2 在 Render 函数中使用 (TSX)
|
||||||
|
在 Naive UI 的 `NTree`, `NDataTable` 或 `NMenu` 等需要渲染函数的场景中:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { h } from 'vue'
|
||||||
|
import SvgIcon from '@/components/custom/svg-icon.vue'
|
||||||
|
|
||||||
|
// 渲染 Iconify 图标
|
||||||
|
function renderIcon() {
|
||||||
|
return h(SvgIcon, {
|
||||||
|
icon: 'carbon:folder',
|
||||||
|
class: 'text-gray-500'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染本地图标
|
||||||
|
function renderLocalIcon() {
|
||||||
|
return h(SvgIcon, {
|
||||||
|
localIcon: 'custom-logo'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
29
apps/admin/src/constants/business.ts
Normal file
29
apps/admin/src/constants/business.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { transformRecordToOption } from '@/utils/common';
|
||||||
|
|
||||||
|
export const enableStatusRecord: Record<Api.Common.EnableStatus, string> = {
|
||||||
|
'1': '启用',
|
||||||
|
'2': '禁用'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const enableStatusOptions = transformRecordToOption(enableStatusRecord);
|
||||||
|
|
||||||
|
export const userGenderRecord: Record<Api.SystemManage.UserGender, string> = {
|
||||||
|
'1': '男',
|
||||||
|
'2': '女'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const userGenderOptions = transformRecordToOption(userGenderRecord);
|
||||||
|
|
||||||
|
export const menuTypeRecord: Record<Api.SystemManage.MenuType, string> = {
|
||||||
|
'1': '目录',
|
||||||
|
'2': '菜单'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const menuTypeOptions = transformRecordToOption(menuTypeRecord);
|
||||||
|
|
||||||
|
export const menuIconTypeRecord: Record<Api.SystemManage.IconType, string> = {
|
||||||
|
'1': 'iconify',
|
||||||
|
'2': '本地图标'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const menuIconTypeOptions = transformRecordToOption(menuIconTypeRecord);
|
||||||
@ -13,13 +13,13 @@ export type UseNaiveTableOptions<ResponseData, ApiData, Pagination extends boole
|
|||||||
'pagination' | 'getColumnChecks' | 'getColumns'
|
'pagination' | 'getColumnChecks' | 'getColumns'
|
||||||
> & {
|
> & {
|
||||||
/**
|
/**
|
||||||
* get column visible
|
* 获取列的可见性
|
||||||
*
|
*
|
||||||
* @param column
|
* @param column
|
||||||
*
|
*
|
||||||
* @default true
|
* @default true
|
||||||
*
|
*
|
||||||
* @returns true if the column is visible, false otherwise
|
* @returns 如果列可见返回 true,否则返回 false
|
||||||
*/
|
*/
|
||||||
getColumnVisible?: (column: NaiveUI.TableColumn<ApiData>) => boolean
|
getColumnVisible?: (column: NaiveUI.TableColumn<ApiData>) => boolean
|
||||||
}
|
}
|
||||||
@ -38,7 +38,7 @@ export function useNaiveTable<ResponseData, ApiData>(options: UseNaiveTableOptio
|
|||||||
getColumns,
|
getColumns,
|
||||||
})
|
})
|
||||||
|
|
||||||
// calculate the total width of the table this is used for horizontal scrolling
|
// 计算表格的总宽度,用于水平滚动
|
||||||
const scrollX = computed(() => {
|
const scrollX = computed(() => {
|
||||||
return result.columns.value.reduce((acc, column) => {
|
return result.columns.value.reduce((acc, column) => {
|
||||||
return acc + Number(column.width ?? column.minWidth ?? 120)
|
return acc + Number(column.width ?? column.minWidth ?? 120)
|
||||||
@ -69,7 +69,7 @@ type PaginationParams = Pick<PaginationProps, 'page' | 'pageSize'>
|
|||||||
type UseNaivePaginatedTableOptions<ResponseData, ApiData> = UseNaiveTableOptions<ResponseData, ApiData, true> & {
|
type UseNaivePaginatedTableOptions<ResponseData, ApiData> = UseNaiveTableOptions<ResponseData, ApiData, true> & {
|
||||||
paginationProps?: Omit<PaginationProps, 'page' | 'pageSize' | 'itemCount'>
|
paginationProps?: Omit<PaginationProps, 'page' | 'pageSize' | 'itemCount'>
|
||||||
/**
|
/**
|
||||||
* whether to show the total count of the table
|
* 是否显示表格的总条数
|
||||||
*
|
*
|
||||||
* @default true
|
* @default true
|
||||||
*/
|
*/
|
||||||
@ -104,7 +104,7 @@ export function useNaivePaginatedTable<ResponseData, ApiData>(
|
|||||||
...options.paginationProps,
|
...options.paginationProps,
|
||||||
}) as PaginationProps
|
}) as PaginationProps
|
||||||
|
|
||||||
// this is for mobile, if the system does not support mobile, you can use `pagination` directly
|
// 针对移动端,如果系统不支持移动端,可以直接使用 `pagination`
|
||||||
const mobilePagination = computed(() => {
|
const mobilePagination = computed(() => {
|
||||||
const p: PaginationProps = {
|
const p: PaginationProps = {
|
||||||
...pagination,
|
...pagination,
|
||||||
@ -186,7 +186,7 @@ export function useTableOperate<TableData>(
|
|||||||
openDrawer()
|
openDrawer()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** the editing row data */
|
/** 编辑行数据 */
|
||||||
const editingData = shallowRef<TableData | null>(null)
|
const editingData = shallowRef<TableData | null>(null)
|
||||||
|
|
||||||
function handleEdit(id: TableData[keyof TableData]) {
|
function handleEdit(id: TableData[keyof TableData]) {
|
||||||
@ -197,10 +197,10 @@ export function useTableOperate<TableData>(
|
|||||||
openDrawer()
|
openDrawer()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** the checked row keys of table */
|
/** 表格的选中行 keys */
|
||||||
const checkedRowKeys = shallowRef<string[]>([])
|
const checkedRowKeys = shallowRef<string[]>([])
|
||||||
|
|
||||||
/** the hook after the batch delete operation is completed */
|
/** 批量删除操作完成后的钩子 */
|
||||||
async function onBatchDeleted() {
|
async function onBatchDeleted() {
|
||||||
window.$message?.success($t('common.deleteSuccess'))
|
window.$message?.success($t('common.deleteSuccess'))
|
||||||
|
|
||||||
@ -209,7 +209,7 @@ export function useTableOperate<TableData>(
|
|||||||
await getData()
|
await getData()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** the hook after the delete operation is completed */
|
/** 删除操作完成后的钩子 */
|
||||||
async function onDeleted() {
|
async function onDeleted() {
|
||||||
window.$message?.success($t('common.deleteSuccess'))
|
window.$message?.success($t('common.deleteSuccess'))
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,7 @@ interface Props {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RouterLink to="/" class="w-full flex-center nowrap-hidden">
|
<RouterLink to="/" class="w-full flex-center nowrap-hidden">
|
||||||
<SystemLogo class="size-32px" />
|
<SystemLogo class="size-42px" />
|
||||||
<h2 v-show="showTitle" class="pl-8px text-16px text-primary font-bold transition duration-300 ease-in-out">
|
<h2 v-show="showTitle" class="pl-8px text-16px text-primary font-bold transition duration-300 ease-in-out">
|
||||||
{{ $t('system.title') }}
|
{{ $t('system.title') }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@ -230,7 +230,12 @@ const local: App.I18n.Schema = {
|
|||||||
'iframe-page': 'Iframe',
|
'iframe-page': 'Iframe',
|
||||||
'home': 'Home',
|
'home': 'Home',
|
||||||
'competition': 'Competition',
|
'competition': 'Competition',
|
||||||
'question': 'Question',
|
'question-store': 'Question Store',
|
||||||
|
'template': 'Template',
|
||||||
|
'rank': 'Rank',
|
||||||
|
'rank_rank-detail': 'Rank Detail',
|
||||||
|
'rank_rank-list': 'Rank List',
|
||||||
|
'results': 'Results',
|
||||||
'competition_competition-add': 'Competition Add',
|
'competition_competition-add': 'Competition Add',
|
||||||
'competition_competition-detail': 'Competition Detail',
|
'competition_competition-detail': 'Competition Detail',
|
||||||
'competition_competition-list': 'Competition List',
|
'competition_competition-list': 'Competition List',
|
||||||
|
|||||||
@ -225,8 +225,13 @@ const local: App.I18n.Schema = {
|
|||||||
'500': '服务器错误',
|
'500': '服务器错误',
|
||||||
'iframe-page': '外链页面',
|
'iframe-page': '外链页面',
|
||||||
'home': '首页',
|
'home': '首页',
|
||||||
'competition': '比赛',
|
'competition': '比赛配置',
|
||||||
'question': '题库',
|
'question-store': '题库',
|
||||||
|
'template': '模板制作',
|
||||||
|
'rank': '名次排行',
|
||||||
|
'rank_rank-detail': '名次详情',
|
||||||
|
'rank_rank-list': '名次列表',
|
||||||
|
'results': '实时结果',
|
||||||
'competition_competition-add': '比赛添加',
|
'competition_competition-add': '比赛添加',
|
||||||
'competition_competition-detail': '比赛详情',
|
'competition_competition-detail': '比赛详情',
|
||||||
'competition_competition-list': '比赛列表',
|
'competition_competition-list': '比赛列表',
|
||||||
|
|||||||
@ -24,5 +24,9 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
|
|||||||
"competition_competition-detail": () => import("@/views/competition/competition-detail/index.vue"),
|
"competition_competition-detail": () => import("@/views/competition/competition-detail/index.vue"),
|
||||||
"competition_competition-list": () => import("@/views/competition/competition-list/index.vue"),
|
"competition_competition-list": () => import("@/views/competition/competition-list/index.vue"),
|
||||||
home: () => import("@/views/home/index.vue"),
|
home: () => import("@/views/home/index.vue"),
|
||||||
question: () => import("@/views/question/index.vue"),
|
"question-store": () => import("@/views/question-store/index.vue"),
|
||||||
|
"rank_rank-detail": () => import("@/views/rank/rank-detail/index.vue"),
|
||||||
|
"rank_rank-list": () => import("@/views/rank/rank-list/index.vue"),
|
||||||
|
results: () => import("@/views/results/index.vue"),
|
||||||
|
template: () => import("@/views/template/index.vue"),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -45,7 +45,9 @@ export const generatedRoutes: GeneratedRoute[] = [
|
|||||||
component: 'layout.base',
|
component: 'layout.base',
|
||||||
meta: {
|
meta: {
|
||||||
title: 'competition',
|
title: 'competition',
|
||||||
i18nKey: 'route.competition'
|
i18nKey: 'route.competition',
|
||||||
|
icon: 'material-symbols:settings-motion-mode-outline-rounded',
|
||||||
|
order: 1
|
||||||
},
|
},
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
@ -89,7 +91,8 @@ export const generatedRoutes: GeneratedRoute[] = [
|
|||||||
title: 'home',
|
title: 'home',
|
||||||
i18nKey: 'route.home',
|
i18nKey: 'route.home',
|
||||||
icon: 'mdi:monitor-dashboard',
|
icon: 'mdi:monitor-dashboard',
|
||||||
order: 1
|
order: 99,
|
||||||
|
hideInMenu: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -118,12 +121,72 @@ export const generatedRoutes: GeneratedRoute[] = [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'question',
|
name: 'question-store',
|
||||||
path: '/question',
|
path: '/question-store',
|
||||||
component: 'layout.base$view.question',
|
component: 'layout.base$view.question-store',
|
||||||
meta: {
|
meta: {
|
||||||
title: 'question',
|
title: 'question-store',
|
||||||
i18nKey: 'route.question'
|
i18nKey: 'route.question-store',
|
||||||
|
icon: 'solar:clipboard-text-outline',
|
||||||
|
order: 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rank',
|
||||||
|
path: '/rank',
|
||||||
|
component: 'layout.base',
|
||||||
|
meta: {
|
||||||
|
title: 'rank',
|
||||||
|
i18nKey: 'route.rank',
|
||||||
|
icon: 'mdi:chart-line',
|
||||||
|
order: 5
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: 'rank_rank-detail',
|
||||||
|
path: '/rank/rank-detail',
|
||||||
|
component: 'view.rank_rank-detail',
|
||||||
|
meta: {
|
||||||
|
title: 'rank_rank-detail',
|
||||||
|
i18nKey: 'route.rank_rank-detail',
|
||||||
|
icon: 'mdi:chart-line-variant',
|
||||||
|
multiTab: true,
|
||||||
|
hideInMenu: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rank_rank-list',
|
||||||
|
path: '/rank/rank-list',
|
||||||
|
component: 'view.rank_rank-list',
|
||||||
|
meta: {
|
||||||
|
title: 'rank_rank-list',
|
||||||
|
i18nKey: 'route.rank_rank-list',
|
||||||
|
icon: 'mdi:chart-line-variant',
|
||||||
|
order: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'results',
|
||||||
|
path: '/results',
|
||||||
|
component: 'layout.base$view.results',
|
||||||
|
meta: {
|
||||||
|
title: 'results',
|
||||||
|
i18nKey: 'route.results',
|
||||||
|
icon: 'mdi:clipboard-check-multiple',
|
||||||
|
order: 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'template',
|
||||||
|
path: '/template',
|
||||||
|
component: 'layout.base$view.template',
|
||||||
|
meta: {
|
||||||
|
title: 'template',
|
||||||
|
i18nKey: 'route.template',
|
||||||
|
icon: 'material-symbols:settings-motion-mode-outline-rounded',
|
||||||
|
order: 4
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@ -173,7 +173,12 @@ const routeMap: RouteMap = {
|
|||||||
"home": "/home",
|
"home": "/home",
|
||||||
"iframe-page": "/iframe-page/:url",
|
"iframe-page": "/iframe-page/:url",
|
||||||
"login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?",
|
"login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?",
|
||||||
"question": "/question"
|
"question-store": "/question-store",
|
||||||
|
"rank": "/rank",
|
||||||
|
"rank_rank-detail": "/rank/rank-detail",
|
||||||
|
"rank_rank-list": "/rank/rank-list",
|
||||||
|
"results": "/results",
|
||||||
|
"template": "/template"
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,2 +1,3 @@
|
|||||||
export * from './auth'
|
export * from './auth'
|
||||||
export * from './route'
|
export * from './route'
|
||||||
|
export * from './system-manage';
|
||||||
|
|||||||
55
apps/admin/src/service/api/system-manage.ts
Normal file
55
apps/admin/src/service/api/system-manage.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { request } from '../request';
|
||||||
|
|
||||||
|
/** get role list */
|
||||||
|
export function fetchGetRoleList(params?: Api.SystemManage.RoleSearchParams) {
|
||||||
|
return request<Api.SystemManage.RoleList>({
|
||||||
|
url: '/systemManage/getRoleList',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get all roles
|
||||||
|
*
|
||||||
|
* these roles are all enabled
|
||||||
|
*/
|
||||||
|
export function fetchGetAllRoles() {
|
||||||
|
return request<Api.SystemManage.AllRole[]>({
|
||||||
|
url: '/systemManage/getAllRoles',
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** get user list */
|
||||||
|
export function fetchGetUserList(params?: Api.SystemManage.UserSearchParams) {
|
||||||
|
return request<Api.SystemManage.UserList>({
|
||||||
|
url: '/systemManage/getUserList',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** get menu list */
|
||||||
|
export function fetchGetMenuList() {
|
||||||
|
return request<Api.SystemManage.MenuList>({
|
||||||
|
url: '/systemManage/getMenuList/v2',
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** get all pages */
|
||||||
|
export function fetchGetAllPages() {
|
||||||
|
return request<string[]>({
|
||||||
|
url: '/systemManage/getAllPages',
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** get menu tree */
|
||||||
|
export function fetchGetMenuTree() {
|
||||||
|
return request<Api.SystemManage.MenuTree[]>({
|
||||||
|
url: '/systemManage/getMenuTree',
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
}
|
||||||
33
apps/admin/src/service/api/upload.ts
Normal file
33
apps/admin/src/service/api/upload.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { request } from '../request'
|
||||||
|
|
||||||
|
|
||||||
|
export interface ReqDeleteAliOssFile {
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AliOssSTS = {
|
||||||
|
AccessKeyId: string;
|
||||||
|
AccessKeySecret: string;
|
||||||
|
SecurityToken: string;
|
||||||
|
Expiration: string;
|
||||||
|
BucketName: string;
|
||||||
|
Region: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/** 得到上传的token */
|
||||||
|
export function getAliOssTokenAxios(){
|
||||||
|
return request<AliOssSTS>({
|
||||||
|
url: `${import.meta.env.VITE_BASE_UPLOAD_URL}/admin/v1/oss/sts`,
|
||||||
|
method: 'get',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除阿里云oss 文件 */
|
||||||
|
export function deleteAliOssFileAxios(data: ReqDeleteAliOssFile) {
|
||||||
|
return request<boolean>({
|
||||||
|
url: `${import.meta.env.VITE_BASE_UPLOAD_URL}/admin/v1/oss/delete`,
|
||||||
|
method: 'post',
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -30,27 +30,26 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
|||||||
const { bool: isInitAuthRoute, setBool: setIsInitAuthRoute } = useBoolean()
|
const { bool: isInitAuthRoute, setBool: setIsInitAuthRoute } = useBoolean()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auth route mode
|
* 权限路由模式
|
||||||
*
|
*
|
||||||
* It recommends to use static mode in the development environment, and use dynamic mode in the production
|
* 建议在开发环境中使用静态模式,在生产环境中使用动态模式
|
||||||
* environment, if use static mode in development environment, the auth routes will be auto generated by plugin
|
* 如果在开发环境中使用静态模式,权限路由将由插件 "@elegant-router/vue" 自动生成
|
||||||
* "@elegant-router/vue"
|
|
||||||
*/
|
*/
|
||||||
const authRouteMode = ref(import.meta.env.VITE_AUTH_ROUTE_MODE)
|
const authRouteMode = ref(import.meta.env.VITE_AUTH_ROUTE_MODE)
|
||||||
|
|
||||||
/** Home route key */
|
/** 首页路由 key */
|
||||||
const routeHome = ref(import.meta.env.VITE_ROUTE_HOME)
|
const routeHome = ref(import.meta.env.VITE_ROUTE_HOME)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set route home
|
* 设置首页路由
|
||||||
*
|
*
|
||||||
* @param routeKey Route key
|
* @param routeKey 路由 key
|
||||||
*/
|
*/
|
||||||
function setRouteHome(routeKey: LastLevelRouteKey) {
|
function setRouteHome(routeKey: LastLevelRouteKey) {
|
||||||
routeHome.value = routeKey
|
routeHome.value = routeKey
|
||||||
}
|
}
|
||||||
|
|
||||||
/** constant routes */
|
/** 常量路由 */
|
||||||
const constantRoutes = shallowRef<ElegantConstRoute[]>([])
|
const constantRoutes = shallowRef<ElegantConstRoute[]>([])
|
||||||
|
|
||||||
function addConstantRoutes(routes: ElegantConstRoute[]) {
|
function addConstantRoutes(routes: ElegantConstRoute[]) {
|
||||||
@ -63,7 +62,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
|||||||
constantRoutes.value = Array.from(constantRoutesMap.values())
|
constantRoutes.value = Array.from(constantRoutesMap.values())
|
||||||
}
|
}
|
||||||
|
|
||||||
/** auth routes */
|
/** 权限路由 */
|
||||||
const authRoutes = shallowRef<ElegantConstRoute[]>([])
|
const authRoutes = shallowRef<ElegantConstRoute[]>([])
|
||||||
|
|
||||||
function addAuthRoutes(routes: ElegantConstRoute[]) {
|
function addAuthRoutes(routes: ElegantConstRoute[]) {
|
||||||
@ -78,44 +77,51 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
|||||||
|
|
||||||
const removeRouteFns: (() => void)[] = []
|
const removeRouteFns: (() => void)[] = []
|
||||||
|
|
||||||
/** Global menus */
|
/** 全局菜单 */
|
||||||
const menus = ref<App.Global.Menu[]>([])
|
const menus = ref<App.Global.Menu[]>([])
|
||||||
|
const menusForBreadcrumb = ref<App.Global.Menu[]>([])
|
||||||
const searchMenus = computed(() => transformMenuToSearchMenus(menus.value))
|
const searchMenus = computed(() => transformMenuToSearchMenus(menus.value))
|
||||||
|
|
||||||
/** Get global menus */
|
/**
|
||||||
|
* 获取全局菜单
|
||||||
|
*
|
||||||
|
* @param routes 路由
|
||||||
|
*/
|
||||||
function getGlobalMenus(routes: ElegantConstRoute[]) {
|
function getGlobalMenus(routes: ElegantConstRoute[]) {
|
||||||
menus.value = getGlobalMenusByAuthRoutes(routes)
|
menus.value = getGlobalMenusByAuthRoutes(routes, true, false)
|
||||||
|
menusForBreadcrumb.value = getGlobalMenusByAuthRoutes(routes, false, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update global menus by locale */
|
/** 根据语言更新全局菜单 */
|
||||||
function updateGlobalMenusByLocale() {
|
function updateGlobalMenusByLocale() {
|
||||||
menus.value = updateLocaleOfGlobalMenus(menus.value)
|
menus.value = updateLocaleOfGlobalMenus(menus.value)
|
||||||
|
menusForBreadcrumb.value = updateLocaleOfGlobalMenus(menusForBreadcrumb.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cache routes */
|
/** 缓存路由 */
|
||||||
const cacheRoutes = ref<RouteKey[]>([])
|
const cacheRoutes = ref<RouteKey[]>([])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exclude cache routes
|
* 排除缓存路由
|
||||||
*
|
*
|
||||||
* for reset route cache
|
* 用于重置路由缓存
|
||||||
*/
|
*/
|
||||||
const excludeCacheRoutes = ref<RouteKey[]>([])
|
const excludeCacheRoutes = ref<RouteKey[]>([])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get cache routes
|
* 获取缓存路由
|
||||||
*
|
*
|
||||||
* @param routes Vue routes
|
* @param routes Vue 路由
|
||||||
*/
|
*/
|
||||||
function getCacheRoutes(routes: RouteRecordRaw[]) {
|
function getCacheRoutes(routes: RouteRecordRaw[]) {
|
||||||
cacheRoutes.value = getCacheRouteNames(routes)
|
cacheRoutes.value = getCacheRouteNames(routes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset route cache
|
* 重置路由缓存
|
||||||
*
|
*
|
||||||
* @default
|
* @default
|
||||||
* @param routeKey
|
* @param routeKey 路由 key
|
||||||
*/
|
*/
|
||||||
async function resetRouteCache(routeKey?: RouteKey) {
|
async function resetRouteCache(routeKey?: RouteKey) {
|
||||||
const routeName = routeKey || (router.currentRoute.value.name as RouteKey)
|
const routeName = routeKey || (router.currentRoute.value.name as RouteKey)
|
||||||
@ -127,10 +133,10 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
|||||||
excludeCacheRoutes.value = []
|
excludeCacheRoutes.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Global breadcrumbs */
|
/** 全局面包屑 */
|
||||||
const breadcrumbs = computed(() => getBreadcrumbsByRoute(router.currentRoute.value, menus.value))
|
const breadcrumbs = computed(() => getBreadcrumbsByRoute(router.currentRoute.value, menusForBreadcrumb.value))
|
||||||
|
|
||||||
/** Reset store */
|
/** 重置 store */
|
||||||
async function resetStore() {
|
async function resetStore() {
|
||||||
const routeStore = useRouteStore()
|
const routeStore = useRouteStore()
|
||||||
|
|
||||||
@ -138,34 +144,35 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
|||||||
|
|
||||||
resetVueRoutes()
|
resetVueRoutes()
|
||||||
|
|
||||||
// after reset store, need to re-init constant route
|
// 重置 store 后,需要重新初始化常量路由
|
||||||
await initConstantRoute()
|
await initConstantRoute()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reset vue routes */
|
/** 重置 vue 路由 */
|
||||||
function resetVueRoutes() {
|
function resetVueRoutes() {
|
||||||
removeRouteFns.forEach(fn => fn())
|
removeRouteFns.forEach(fn => fn())
|
||||||
removeRouteFns.length = 0
|
removeRouteFns.length = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/** init constant route */
|
/** 初始化常量路由 */
|
||||||
async function initConstantRoute() {
|
async function initConstantRoute() {
|
||||||
if (isInitConstantRoute.value)
|
if (isInitConstantRoute.value)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
// 静态路由
|
||||||
const staticRoute = createStaticRoutes()
|
const staticRoute = createStaticRoutes()
|
||||||
|
|
||||||
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) {
|
||||||
addConstantRoutes(data)
|
addConstantRoutes(data)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// if fetch constant routes failed, use static constant routes
|
// 如果获取常量路由失败,使用静态常量路由
|
||||||
addConstantRoutes(staticRoute.constantRoutes)
|
addConstantRoutes(staticRoute.constantRoutes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -177,9 +184,9 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
|||||||
tabStore.initHomeTab()
|
tabStore.initHomeTab()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Init auth route */
|
/** 初始化权限路由 */
|
||||||
async function initAuthRoute() {
|
async function initAuthRoute() {
|
||||||
// check if user info is initialized
|
// 检查用户信息是否已初始化
|
||||||
if (!authStore.userInfo.userId) {
|
if (!authStore.userInfo.userId) {
|
||||||
await authStore.initUserInfo()
|
await authStore.initUserInfo()
|
||||||
}
|
}
|
||||||
@ -194,7 +201,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
|||||||
tabStore.initHomeTab()
|
tabStore.initHomeTab()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Init static auth route */
|
/** 初始化静态权限路由 */
|
||||||
function initStaticAuthRoute() {
|
function initStaticAuthRoute() {
|
||||||
const { authRoutes: staticAuthRoutes } = createStaticRoutes()
|
const { authRoutes: staticAuthRoutes } = createStaticRoutes()
|
||||||
|
|
||||||
@ -212,7 +219,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
|||||||
setIsInitAuthRoute(true)
|
setIsInitAuthRoute(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Init dynamic auth route */
|
/** 初始化动态权限路由 */
|
||||||
async function initDynamicAuthRoute() {
|
async function initDynamicAuthRoute() {
|
||||||
const { data, error } = await fetchGetUserRoutes()
|
const { data, error } = await fetchGetUserRoutes()
|
||||||
|
|
||||||
@ -230,12 +237,12 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
|||||||
setIsInitAuthRoute(true)
|
setIsInitAuthRoute(true)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// if fetch user routes failed, reset store
|
// 如果获取用户路由失败,重置 store
|
||||||
authStore.resetStore()
|
authStore.resetStore()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** handle constant and auth routes */
|
/** 处理常量路由和权限路由 */
|
||||||
function handleConstantAndAuthRoutes() {
|
function handleConstantAndAuthRoutes() {
|
||||||
const allRoutes = [...constantRoutes.value, ...authRoutes.value]
|
const allRoutes = [...constantRoutes.value, ...authRoutes.value]
|
||||||
|
|
||||||
@ -253,9 +260,9 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add routes to vue router
|
* 添加路由到 vue router
|
||||||
*
|
*
|
||||||
* @param routes Vue routes
|
* @param routes Vue 路由
|
||||||
*/
|
*/
|
||||||
function addRoutesToVueRouter(routes: RouteRecordRaw[]) {
|
function addRoutesToVueRouter(routes: RouteRecordRaw[]) {
|
||||||
routes.forEach((route) => {
|
routes.forEach((route) => {
|
||||||
@ -265,18 +272,18 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add remove route fn
|
* 添加删除路由函数
|
||||||
*
|
*
|
||||||
* @param fn
|
* @param fn 删除函数
|
||||||
*/
|
*/
|
||||||
function addRemoveRouteFn(fn: () => void) {
|
function addRemoveRouteFn(fn: () => void) {
|
||||||
removeRouteFns.push(fn)
|
removeRouteFns.push(fn)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update root route redirect when auth route mode is dynamic
|
* 当权限路由模式为动态时,更新根路由重定向
|
||||||
*
|
*
|
||||||
* @param redirectKey Redirect route key
|
* @param redirectKey 重定向路由 key
|
||||||
*/
|
*/
|
||||||
function handleUpdateRootRouteRedirect(redirectKey: LastLevelRouteKey) {
|
function handleUpdateRootRouteRedirect(redirectKey: LastLevelRouteKey) {
|
||||||
const redirect = getRoutePath(redirectKey)
|
const redirect = getRoutePath(redirectKey)
|
||||||
@ -293,9 +300,9 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get is auth route exist
|
* 获取权限路由是否存在
|
||||||
*
|
*
|
||||||
* @param routePath Route path
|
* @param routePath 路由路径
|
||||||
*/
|
*/
|
||||||
async function getIsAuthRouteExist(routePath: RouteMap[RouteKey]) {
|
async function getIsAuthRouteExist(routePath: RouteMap[RouteKey]) {
|
||||||
const routeName = getRouteName(routePath)
|
const routeName = getRouteName(routePath)
|
||||||
@ -315,20 +322,20 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get selected menu key path
|
* 获取选中的菜单 key 路径
|
||||||
*
|
*
|
||||||
* @param selectedKey Selected menu key
|
* @param selectedKey 选中的菜单 key
|
||||||
*/
|
*/
|
||||||
function getSelectedMenuKeyPath(selectedKey: string) {
|
function getSelectedMenuKeyPath(selectedKey: string) {
|
||||||
return getSelectedMenuKeyPathByKey(selectedKey, menus.value)
|
return getSelectedMenuKeyPathByKey(selectedKey, menus.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onRouteSwitchWhenLoggedIn() {
|
async function onRouteSwitchWhenLoggedIn() {
|
||||||
// some global init logic when logged in and switch route
|
// 登录并切换路由时的一些全局初始化逻辑
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onRouteSwitchWhenNotLoggedIn() {
|
async function onRouteSwitchWhenNotLoggedIn() {
|
||||||
// some global init logic if it does not need to be logged in
|
// 如果不需要登录时的一些全局初始化逻辑
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -4,28 +4,28 @@ import { useSvgIcon } from '@/hooks/common/icon'
|
|||||||
import { $t } from '@/locales'
|
import { $t } from '@/locales'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter auth routes by roles
|
* 根据角色过滤权限路由
|
||||||
*
|
*
|
||||||
* @param routes Auth routes
|
* @param routes 权限路由
|
||||||
* @param roles Roles
|
* @param roles 角色
|
||||||
*/
|
*/
|
||||||
export function filterAuthRoutesByRoles(routes: ElegantConstRoute[], roles: string[]) {
|
export function filterAuthRoutesByRoles(routes: ElegantConstRoute[], roles: string[]) {
|
||||||
return routes.flatMap(route => filterAuthRouteByRoles(route, roles))
|
return routes.flatMap(route => filterAuthRouteByRoles(route, roles))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter auth route by roles
|
* 根据角色过滤权限路由
|
||||||
*
|
*
|
||||||
* @param route Auth route
|
* @param route 权限路由
|
||||||
* @param roles Roles
|
* @param roles 角色
|
||||||
*/
|
*/
|
||||||
function filterAuthRouteByRoles(route: ElegantConstRoute, roles: string[]): ElegantConstRoute[] {
|
function filterAuthRouteByRoles(route: ElegantConstRoute, roles: string[]): ElegantConstRoute[] {
|
||||||
const routeRoles = (route.meta && route.meta.roles) || []
|
const routeRoles = (route.meta && route.meta.roles) || []
|
||||||
|
|
||||||
// if the route's "roles" is empty, then it is allowed to access
|
// 如果路由的 "roles" 为空,则允许访问
|
||||||
const isEmptyRoles = !routeRoles.length
|
const isEmptyRoles = !routeRoles.length
|
||||||
|
|
||||||
// if the user's role is included in the route's "roles", then it is allowed to access
|
// 如果用户的角色包含在路由的 "roles" 中,则允许访问
|
||||||
const hasPermission = routeRoles.some(role => roles.includes(role))
|
const hasPermission = routeRoles.some(role => roles.includes(role))
|
||||||
|
|
||||||
const filterRoute = { ...route }
|
const filterRoute = { ...route }
|
||||||
@ -34,7 +34,7 @@ function filterAuthRouteByRoles(route: ElegantConstRoute, roles: string[]): Eleg
|
|||||||
filterRoute.children = filterRoute.children.flatMap(item => filterAuthRouteByRoles(item, roles))
|
filterRoute.children = filterRoute.children.flatMap(item => filterAuthRouteByRoles(item, roles))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exclude the route if it has no children after filtering
|
// 如果过滤后没有子路由,则排除该路由
|
||||||
if (filterRoute.children?.length === 0) {
|
if (filterRoute.children?.length === 0) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@ -43,9 +43,9 @@ function filterAuthRouteByRoles(route: ElegantConstRoute, roles: string[]): Eleg
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* sort route by order
|
* 根据 order 对路由进行排序
|
||||||
*
|
*
|
||||||
* @param route route
|
* @param route 路由
|
||||||
*/
|
*/
|
||||||
function sortRouteByOrder(route: ElegantConstRoute) {
|
function sortRouteByOrder(route: ElegantConstRoute) {
|
||||||
if (route.children?.length) {
|
if (route.children?.length) {
|
||||||
@ -57,9 +57,9 @@ function sortRouteByOrder(route: ElegantConstRoute) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* sort routes by order
|
* 根据 order 对路由进行排序
|
||||||
*
|
*
|
||||||
* @param routes routes
|
* @param routes 路由
|
||||||
*/
|
*/
|
||||||
export function sortRoutesByOrder(routes: ElegantConstRoute[]) {
|
export function sortRoutesByOrder(routes: ElegantConstRoute[]) {
|
||||||
routes.sort((next, prev) => (Number(next.meta?.order) || 0) - (Number(prev.meta?.order) || 0))
|
routes.sort((next, prev) => (Number(next.meta?.order) || 0) - (Number(prev.meta?.order) || 0))
|
||||||
@ -69,19 +69,30 @@ export function sortRoutesByOrder(routes: ElegantConstRoute[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get global menus by auth routes
|
* 根据权限路由获取全局菜单
|
||||||
*
|
* 当嵌套路由里面,有且仅有一个子路由时,将其提升到一级菜单,点击一级菜单时,跳转到子路由
|
||||||
* @param routes Auth routes
|
* @param routes 权限路由
|
||||||
|
* @param shouldHoist 是否提升
|
||||||
|
* @param includeHidden 是否包含隐藏路由
|
||||||
*/
|
*/
|
||||||
export function getGlobalMenusByAuthRoutes(routes: ElegantConstRoute[]) {
|
export function getGlobalMenusByAuthRoutes(routes: ElegantConstRoute[], shouldHoist = false, includeHidden = false) {
|
||||||
const menus: App.Global.Menu[] = []
|
const menus: App.Global.Menu[] = []
|
||||||
|
|
||||||
routes.forEach((route) => {
|
routes.forEach((route) => {
|
||||||
if (!route.meta?.hideInMenu) {
|
if (includeHidden || !route.meta?.hideInMenu) {
|
||||||
const menu = getGlobalMenuByBaseRoute(route)
|
const menu = getGlobalMenuByBaseRoute(route)
|
||||||
|
|
||||||
if (route.children?.some(child => !child.meta?.hideInMenu)) {
|
if (route.children?.some(child => includeHidden || !child.meta?.hideInMenu)) {
|
||||||
menu.children = getGlobalMenusByAuthRoutes(route.children)
|
menu.children = getGlobalMenusByAuthRoutes(route.children, shouldHoist, includeHidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果只有一个子菜单,将其提升
|
||||||
|
if (shouldHoist && menu.children?.length === 1) {
|
||||||
|
const singleChild = menu.children[0]
|
||||||
|
menu.key = singleChild.key
|
||||||
|
menu.routeKey = singleChild.routeKey
|
||||||
|
menu.routePath = singleChild.routePath
|
||||||
|
menu.children = singleChild.children
|
||||||
}
|
}
|
||||||
|
|
||||||
menus.push(menu)
|
menus.push(menu)
|
||||||
@ -92,7 +103,7 @@ export function getGlobalMenusByAuthRoutes(routes: ElegantConstRoute[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update locale of global menus
|
* 更新全局菜单的国际化
|
||||||
*
|
*
|
||||||
* @param menus
|
* @param menus
|
||||||
*/
|
*/
|
||||||
@ -120,7 +131,7 @@ export function updateLocaleOfGlobalMenus(menus: App.Global.Menu[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get global menu by route
|
* 根据路由获取全局菜单
|
||||||
*
|
*
|
||||||
* @param route
|
* @param route
|
||||||
*/
|
*/
|
||||||
@ -145,15 +156,15 @@ function getGlobalMenuByBaseRoute(route: RouteLocationNormalizedLoaded | Elegant
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get cache route names
|
* 获取缓存路由名称
|
||||||
*
|
*
|
||||||
* @param routes Vue routes (two levels)
|
* @param routes Vue 路由 (两级)
|
||||||
*/
|
*/
|
||||||
export function getCacheRouteNames(routes: RouteRecordRaw[]) {
|
export function getCacheRouteNames(routes: RouteRecordRaw[]) {
|
||||||
const cacheNames: LastLevelRouteKey[] = []
|
const cacheNames: LastLevelRouteKey[] = []
|
||||||
|
|
||||||
routes.forEach((route) => {
|
routes.forEach((route) => {
|
||||||
// only get last two level route, which has component
|
// 只获取最后两级有组件的路由
|
||||||
route.children?.forEach((child) => {
|
route.children?.forEach((child) => {
|
||||||
if (child.component && child.meta?.keepAlive) {
|
if (child.component && child.meta?.keepAlive) {
|
||||||
cacheNames.push(child.name as LastLevelRouteKey)
|
cacheNames.push(child.name as LastLevelRouteKey)
|
||||||
@ -165,7 +176,7 @@ export function getCacheRouteNames(routes: RouteRecordRaw[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Is route exist by route name
|
* 根据路由名称判断路由是否存在
|
||||||
*
|
*
|
||||||
* @param routeName
|
* @param routeName
|
||||||
* @param routes
|
* @param routes
|
||||||
@ -175,7 +186,7 @@ export function isRouteExistByRouteName(routeName: RouteKey, routes: ElegantCons
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursive get is route exist by route name
|
* 递归判断路由名称是否存在
|
||||||
*
|
*
|
||||||
* @param route
|
* @param route
|
||||||
* @param routeName
|
* @param routeName
|
||||||
@ -195,7 +206,7 @@ function recursiveGetIsRouteExistByRouteName(route: ElegantConstRoute, routeName
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get selected menu key path
|
* 获取选中的菜单 key 路径
|
||||||
*
|
*
|
||||||
* @param selectedKey
|
* @param selectedKey
|
||||||
* @param menus
|
* @param menus
|
||||||
@ -219,10 +230,10 @@ export function getSelectedMenuKeyPathByKey(selectedKey: string, menus: App.Glob
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find menu path
|
* 查找菜单路径
|
||||||
*
|
*
|
||||||
* @param targetKey Target menu key
|
* @param targetKey 目标菜单 key
|
||||||
* @param menu Menu
|
* @param menu 菜单
|
||||||
*/
|
*/
|
||||||
function findMenuPath(targetKey: string, menu: App.Global.Menu): string[] | null {
|
function findMenuPath(targetKey: string, menu: App.Global.Menu): string[] | null {
|
||||||
const path: string[] = []
|
const path: string[] = []
|
||||||
@ -255,7 +266,7 @@ function findMenuPath(targetKey: string, menu: App.Global.Menu): string[] | null
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transform menu to breadcrumb
|
* 将菜单转换为面包屑
|
||||||
*
|
*
|
||||||
* @param menu
|
* @param menu
|
||||||
*/
|
*/
|
||||||
@ -274,7 +285,7 @@ function transformMenuToBreadcrumb(menu: App.Global.Menu) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get breadcrumbs by route
|
* 根据路由获取面包屑
|
||||||
*
|
*
|
||||||
* @param route
|
* @param route
|
||||||
* @param menus
|
* @param menus
|
||||||
@ -316,9 +327,9 @@ export function getBreadcrumbsByRoute(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transform menu to searchMenus
|
* 将菜单转换为搜索菜单
|
||||||
*
|
*
|
||||||
* @param menus - menus
|
* @param menus - 菜单
|
||||||
* @param treeMap
|
* @param treeMap
|
||||||
*/
|
*/
|
||||||
export function transformMenuToSearchMenus(menus: App.Global.Menu[], treeMap: App.Global.Menu[] = []) {
|
export function transformMenuToSearchMenus(menus: App.Global.Menu[], treeMap: App.Global.Menu[] = []) {
|
||||||
|
|||||||
@ -13,7 +13,7 @@ export const themeSettings: App.Theme.ThemeSetting = {
|
|||||||
error: '#f5222d',
|
error: '#f5222d',
|
||||||
},
|
},
|
||||||
isInfoFollowPrimary: true, // 是否开启信息类颜色跟随主题颜色
|
isInfoFollowPrimary: true, // 是否开启信息类颜色跟随主题颜色
|
||||||
layout: { mode: 'horizontal', scrollMode: 'content' }, // 布局模式 mode: horizontal | vertical
|
layout: { mode: 'vertical', scrollMode: 'content' }, // 布局模式 mode: horizontal | vertical
|
||||||
page: { animate: true, animateMode: 'fade-slide' }, // 页面动画模式
|
page: { animate: true, animateMode: 'fade-slide' }, // 页面动画模式
|
||||||
header: {
|
header: {
|
||||||
height: 56, // 头部高度
|
height: 56, // 头部高度
|
||||||
|
|||||||
26
apps/admin/src/typings/api/common.d.ts
vendored
26
apps/admin/src/typings/api/common.d.ts
vendored
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
139
apps/admin/src/typings/api/system-manage.d.ts
vendored
Normal file
139
apps/admin/src/typings/api/system-manage.d.ts
vendored
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
declare namespace Api {
|
||||||
|
/**
|
||||||
|
* namespace SystemManage
|
||||||
|
*
|
||||||
|
* backend api module: "systemManage"
|
||||||
|
*/
|
||||||
|
namespace SystemManage {
|
||||||
|
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'current' | 'size'>;
|
||||||
|
|
||||||
|
/** role */
|
||||||
|
type Role = Common.CommonRecord<{
|
||||||
|
/** role name */
|
||||||
|
roleName: string;
|
||||||
|
/** role code */
|
||||||
|
roleCode: string;
|
||||||
|
/** role description */
|
||||||
|
roleDesc: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/** role search params */
|
||||||
|
type RoleSearchParams = CommonType.RecordNullable<
|
||||||
|
Pick<Api.SystemManage.Role, 'roleName' | 'roleCode' | 'status'> & CommonSearchParams
|
||||||
|
>;
|
||||||
|
|
||||||
|
/** role list */
|
||||||
|
type RoleList = Common.PaginatingQueryRecord<Role>;
|
||||||
|
|
||||||
|
/** all role */
|
||||||
|
type AllRole = Pick<Role, 'id' | 'roleName' | 'roleCode'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* user gender
|
||||||
|
*
|
||||||
|
* - "1": "male"
|
||||||
|
* - "2": "female"
|
||||||
|
*/
|
||||||
|
type UserGender = '1' | '2';
|
||||||
|
|
||||||
|
/** user */
|
||||||
|
type User = Common.CommonRecord<{
|
||||||
|
/** user name */
|
||||||
|
userName: string;
|
||||||
|
/** user gender */
|
||||||
|
userGender: UserGender | null;
|
||||||
|
/** user nick name */
|
||||||
|
nickName: string;
|
||||||
|
/** user phone */
|
||||||
|
userPhone: string;
|
||||||
|
/** user email */
|
||||||
|
userEmail: string;
|
||||||
|
/** user role code collection */
|
||||||
|
userRoles: string[];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/** user search params */
|
||||||
|
type UserSearchParams = CommonType.RecordNullable<
|
||||||
|
Pick<Api.SystemManage.User, 'userName' | 'userGender' | 'nickName' | 'userPhone' | 'userEmail' | 'status'> &
|
||||||
|
CommonSearchParams
|
||||||
|
>;
|
||||||
|
|
||||||
|
/** user list */
|
||||||
|
type UserList = Common.PaginatingQueryRecord<User>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* menu type
|
||||||
|
*
|
||||||
|
* - "1": directory
|
||||||
|
* - "2": menu
|
||||||
|
*/
|
||||||
|
type MenuType = '1' | '2';
|
||||||
|
|
||||||
|
type MenuButton = {
|
||||||
|
/**
|
||||||
|
* button code
|
||||||
|
*
|
||||||
|
* it can be used to control the button permission
|
||||||
|
*/
|
||||||
|
code: string;
|
||||||
|
/** button description */
|
||||||
|
desc: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* icon type
|
||||||
|
*
|
||||||
|
* - "1": iconify icon
|
||||||
|
* - "2": local icon
|
||||||
|
*/
|
||||||
|
type IconType = '1' | '2';
|
||||||
|
|
||||||
|
type MenuPropsOfRoute = Pick<
|
||||||
|
import('vue-router').RouteMeta,
|
||||||
|
| 'i18nKey'
|
||||||
|
| 'keepAlive'
|
||||||
|
| 'constant'
|
||||||
|
| 'order'
|
||||||
|
| 'href'
|
||||||
|
| 'hideInMenu'
|
||||||
|
| 'activeMenu'
|
||||||
|
| 'multiTab'
|
||||||
|
| 'fixedIndexInTab'
|
||||||
|
| 'query'
|
||||||
|
>;
|
||||||
|
|
||||||
|
type Menu = Common.CommonRecord<{
|
||||||
|
/** parent menu id */
|
||||||
|
parentId: number;
|
||||||
|
/** menu type */
|
||||||
|
menuType: MenuType;
|
||||||
|
/** menu name */
|
||||||
|
menuName: string;
|
||||||
|
/** route name */
|
||||||
|
routeName: string;
|
||||||
|
/** route path */
|
||||||
|
routePath: string;
|
||||||
|
/** component */
|
||||||
|
component?: string;
|
||||||
|
/** iconify icon name or local icon name */
|
||||||
|
icon: string;
|
||||||
|
/** icon type */
|
||||||
|
iconType: IconType;
|
||||||
|
/** buttons */
|
||||||
|
buttons?: MenuButton[] | null;
|
||||||
|
/** children menu */
|
||||||
|
children?: Menu[] | null;
|
||||||
|
}> &
|
||||||
|
MenuPropsOfRoute;
|
||||||
|
|
||||||
|
/** menu list */
|
||||||
|
type MenuList = Common.PaginatingQueryRecord<Menu>;
|
||||||
|
|
||||||
|
type MenuTree = {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
pId: number;
|
||||||
|
children?: MenuTree[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
54
apps/admin/src/typings/components.d.ts
vendored
54
apps/admin/src/typings/components.d.ts
vendored
@ -21,6 +21,7 @@ declare module 'vue' {
|
|||||||
FullScreen: typeof import('./../components/common/full-screen.vue')['default']
|
FullScreen: typeof import('./../components/common/full-screen.vue')['default']
|
||||||
IconAntDesignEnterOutlined: typeof import('~icons/ant-design/enter-outlined')['default']
|
IconAntDesignEnterOutlined: typeof import('~icons/ant-design/enter-outlined')['default']
|
||||||
IconAntDesignReloadOutlined: typeof import('~icons/ant-design/reload-outlined')['default']
|
IconAntDesignReloadOutlined: typeof import('~icons/ant-design/reload-outlined')['default']
|
||||||
|
IconAntDesignSettingOutlined: typeof import('~icons/ant-design/setting-outlined')['default']
|
||||||
IconGridiconsFullscreen: typeof import('~icons/gridicons/fullscreen')['default']
|
IconGridiconsFullscreen: typeof import('~icons/gridicons/fullscreen')['default']
|
||||||
IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
|
IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
|
||||||
IconIcBaselineAdd: typeof import('~icons/ic/baseline-add')['default']
|
IconIcBaselineAdd: typeof import('~icons/ic/baseline-add')['default']
|
||||||
@ -50,18 +51,27 @@ declare module 'vue' {
|
|||||||
IconIcOutlineFolderOff: typeof import('~icons/ic/outline-folder-off')['default']
|
IconIcOutlineFolderOff: typeof import('~icons/ic/outline-folder-off')['default']
|
||||||
IconIcOutlineFormatListNumbered: typeof import('~icons/ic/outline-format-list-numbered')['default']
|
IconIcOutlineFormatListNumbered: typeof import('~icons/ic/outline-format-list-numbered')['default']
|
||||||
IconIcOutlineTimer: typeof import('~icons/ic/outline-timer')['default']
|
IconIcOutlineTimer: typeof import('~icons/ic/outline-timer')['default']
|
||||||
|
IconIcRoundAccessTime: typeof import('~icons/ic/round-access-time')['default']
|
||||||
|
IconIcRoundArrowBack: typeof import('~icons/ic/round-arrow-back')['default']
|
||||||
|
IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
|
||||||
IconIcRoundLibraryAdd: typeof import('~icons/ic/round-library-add')['default']
|
IconIcRoundLibraryAdd: typeof import('~icons/ic/round-library-add')['default']
|
||||||
IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
|
IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
|
||||||
|
IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default']
|
||||||
|
IconIcRoundSearch: typeof import('~icons/ic/round-search')['default']
|
||||||
IconIcRoundStarBorder: typeof import('~icons/ic/round-star-border')['default']
|
IconIcRoundStarBorder: typeof import('~icons/ic/round-star-border')['default']
|
||||||
|
IconIcRoundUpload: typeof import('~icons/ic/round-upload')['default']
|
||||||
IconLocalBanner: typeof import('~icons/local/banner')['default']
|
IconLocalBanner: typeof import('~icons/local/banner')['default']
|
||||||
IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
|
IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
|
||||||
IconMdiArrowUpThin: typeof import('~icons/mdi/arrow-up-thin')['default']
|
IconMdiArrowUpThin: typeof import('~icons/mdi/arrow-up-thin')['default']
|
||||||
|
IconMdiDrag: typeof import('~icons/mdi/drag')['default']
|
||||||
IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
|
IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
|
||||||
IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
|
IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
|
||||||
|
IconMdiRefresh: typeof import('~icons/mdi/refresh')['default']
|
||||||
IconTooltip: typeof import('./../components/common/icon-tooltip.vue')['default']
|
IconTooltip: typeof import('./../components/common/icon-tooltip.vue')['default']
|
||||||
IconUilSearch: typeof import('~icons/uil/search')['default']
|
IconUilSearch: typeof import('~icons/uil/search')['default']
|
||||||
LangSwitch: typeof import('./../components/common/lang-switch.vue')['default']
|
LangSwitch: typeof import('./../components/common/lang-switch.vue')['default']
|
||||||
LookForward: typeof import('./../components/custom/look-forward.vue')['default']
|
LookForward: typeof import('./../components/custom/look-forward.vue')['default']
|
||||||
|
MathIframeDialog: typeof import('./../components/common/rest-basic-editor/components/math-iframe-dialog.vue')['default']
|
||||||
MenuToggler: typeof import('./../components/common/menu-toggler.vue')['default']
|
MenuToggler: typeof import('./../components/common/menu-toggler.vue')['default']
|
||||||
NAlert: typeof import('naive-ui')['NAlert']
|
NAlert: typeof import('naive-ui')['NAlert']
|
||||||
NBadge: typeof import('naive-ui')['NBadge']
|
NBadge: typeof import('naive-ui')['NBadge']
|
||||||
@ -70,20 +80,28 @@ declare module 'vue' {
|
|||||||
NButton: typeof import('naive-ui')['NButton']
|
NButton: typeof import('naive-ui')['NButton']
|
||||||
NCard: typeof import('naive-ui')['NCard']
|
NCard: typeof import('naive-ui')['NCard']
|
||||||
NCheckbox: typeof import('naive-ui')['NCheckbox']
|
NCheckbox: typeof import('naive-ui')['NCheckbox']
|
||||||
|
NCollapse: typeof import('naive-ui')['NCollapse']
|
||||||
|
NCollapseItem: typeof import('naive-ui')['NCollapseItem']
|
||||||
NColorPicker: typeof import('naive-ui')['NColorPicker']
|
NColorPicker: typeof import('naive-ui')['NColorPicker']
|
||||||
|
NDataTable: typeof import('naive-ui')['NDataTable']
|
||||||
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
|
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
|
||||||
NDivider: typeof import('naive-ui')['NDivider']
|
NDivider: typeof import('naive-ui')['NDivider']
|
||||||
NDrawer: typeof import('naive-ui')['NDrawer']
|
NDrawer: typeof import('naive-ui')['NDrawer']
|
||||||
NDrawerContent: typeof import('naive-ui')['NDrawerContent']
|
NDrawerContent: typeof import('naive-ui')['NDrawerContent']
|
||||||
NDropdown: typeof import('naive-ui')['NDropdown']
|
NDropdown: typeof import('naive-ui')['NDropdown']
|
||||||
|
NEditor: typeof import('naive-ui')['NEditor']
|
||||||
NEmpty: typeof import('naive-ui')['NEmpty']
|
NEmpty: typeof import('naive-ui')['NEmpty']
|
||||||
NForm: typeof import('naive-ui')['NForm']
|
NForm: typeof import('naive-ui')['NForm']
|
||||||
NFormItem: typeof import('naive-ui')['NFormItem']
|
NFormItem: typeof import('naive-ui')['NFormItem']
|
||||||
|
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
|
||||||
NGi: typeof import('naive-ui')['NGi']
|
NGi: typeof import('naive-ui')['NGi']
|
||||||
NGrid: typeof import('naive-ui')['NGrid']
|
NGrid: typeof import('naive-ui')['NGrid']
|
||||||
NInput: typeof import('naive-ui')['NInput']
|
NInput: typeof import('naive-ui')['NInput']
|
||||||
NInputGroup: typeof import('naive-ui')['NInputGroup']
|
NInputGroup: typeof import('naive-ui')['NInputGroup']
|
||||||
NInputNumber: typeof import('naive-ui')['NInputNumber']
|
NInputNumber: typeof import('naive-ui')['NInputNumber']
|
||||||
|
NLayout: typeof import('naive-ui')['NLayout']
|
||||||
|
NLayoutContent: typeof import('naive-ui')['NLayoutContent']
|
||||||
|
NLayoutHeader: typeof import('naive-ui')['NLayoutHeader']
|
||||||
NList: typeof import('naive-ui')['NList']
|
NList: typeof import('naive-ui')['NList']
|
||||||
NListItem: typeof import('naive-ui')['NListItem']
|
NListItem: typeof import('naive-ui')['NListItem']
|
||||||
NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider']
|
NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider']
|
||||||
@ -91,19 +109,27 @@ declare module 'vue' {
|
|||||||
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
|
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
|
||||||
NModal: typeof import('naive-ui')['NModal']
|
NModal: typeof import('naive-ui')['NModal']
|
||||||
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
|
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
|
||||||
|
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
|
||||||
|
NPopover: typeof import('naive-ui')['NPopover']
|
||||||
|
NRadio: typeof import('naive-ui')['NRadio']
|
||||||
|
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
|
||||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||||
NSelect: typeof import('naive-ui')['NSelect']
|
NSelect: typeof import('naive-ui')['NSelect']
|
||||||
NSpace: typeof import('naive-ui')['NSpace']
|
NSpace: typeof import('naive-ui')['NSpace']
|
||||||
NStatistic: typeof import('naive-ui')['NStatistic']
|
NStatistic: typeof import('naive-ui')['NStatistic']
|
||||||
NSwitch: typeof import('naive-ui')['NSwitch']
|
NSwitch: typeof import('naive-ui')['NSwitch']
|
||||||
NTab: typeof import('naive-ui')['NTab']
|
NTab: typeof import('naive-ui')['NTab']
|
||||||
|
NTable: typeof import('naive-ui')['NTable']
|
||||||
NTabs: typeof import('naive-ui')['NTabs']
|
NTabs: typeof import('naive-ui')['NTabs']
|
||||||
|
NTag: typeof import('naive-ui')['NTag']
|
||||||
|
NTextarea: typeof import('naive-ui')['NTextarea']
|
||||||
NThing: typeof import('naive-ui')['NThing']
|
NThing: typeof import('naive-ui')['NThing']
|
||||||
NTooltip: typeof import('naive-ui')['NTooltip']
|
NTooltip: typeof import('naive-ui')['NTooltip']
|
||||||
NWatermark: typeof import('naive-ui')['NWatermark']
|
NWatermark: typeof import('naive-ui')['NWatermark']
|
||||||
PageHeader: typeof import('./../components/common/page-header.vue')['default']
|
PageHeader: typeof import('./../components/common/page-header.vue')['default']
|
||||||
PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
|
PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
|
||||||
ReloadButton: typeof import('./../components/common/reload-button.vue')['default']
|
ReloadButton: typeof import('./../components/common/reload-button.vue')['default']
|
||||||
|
RestBasicEditor: typeof import('./../components/common/rest-basic-editor/rest-basic-editor.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
SoybeanAvatar: typeof import('./../components/custom/soybean-avatar.vue')['default']
|
SoybeanAvatar: typeof import('./../components/custom/soybean-avatar.vue')['default']
|
||||||
@ -112,6 +138,7 @@ declare module 'vue' {
|
|||||||
TableColumnSetting: typeof import('./../components/advanced/table-column-setting.vue')['default']
|
TableColumnSetting: typeof import('./../components/advanced/table-column-setting.vue')['default']
|
||||||
TableHeaderOperation: typeof import('./../components/advanced/table-header-operation.vue')['default']
|
TableHeaderOperation: typeof import('./../components/advanced/table-header-operation.vue')['default']
|
||||||
ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default']
|
ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default']
|
||||||
|
UpFileDialog: typeof import('./../components/common/rest-basic-editor/components/up-file-dialog.vue')['default']
|
||||||
WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']
|
WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -127,6 +154,7 @@ declare global {
|
|||||||
const FullScreen: typeof import('./../components/common/full-screen.vue')['default']
|
const FullScreen: typeof import('./../components/common/full-screen.vue')['default']
|
||||||
const IconAntDesignEnterOutlined: typeof import('~icons/ant-design/enter-outlined')['default']
|
const IconAntDesignEnterOutlined: typeof import('~icons/ant-design/enter-outlined')['default']
|
||||||
const IconAntDesignReloadOutlined: typeof import('~icons/ant-design/reload-outlined')['default']
|
const IconAntDesignReloadOutlined: typeof import('~icons/ant-design/reload-outlined')['default']
|
||||||
|
const IconAntDesignSettingOutlined: typeof import('~icons/ant-design/setting-outlined')['default']
|
||||||
const IconGridiconsFullscreen: typeof import('~icons/gridicons/fullscreen')['default']
|
const IconGridiconsFullscreen: typeof import('~icons/gridicons/fullscreen')['default']
|
||||||
const IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
|
const IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
|
||||||
const IconIcBaselineAdd: typeof import('~icons/ic/baseline-add')['default']
|
const IconIcBaselineAdd: typeof import('~icons/ic/baseline-add')['default']
|
||||||
@ -156,18 +184,27 @@ declare global {
|
|||||||
const IconIcOutlineFolderOff: typeof import('~icons/ic/outline-folder-off')['default']
|
const IconIcOutlineFolderOff: typeof import('~icons/ic/outline-folder-off')['default']
|
||||||
const IconIcOutlineFormatListNumbered: typeof import('~icons/ic/outline-format-list-numbered')['default']
|
const IconIcOutlineFormatListNumbered: typeof import('~icons/ic/outline-format-list-numbered')['default']
|
||||||
const IconIcOutlineTimer: typeof import('~icons/ic/outline-timer')['default']
|
const IconIcOutlineTimer: typeof import('~icons/ic/outline-timer')['default']
|
||||||
|
const IconIcRoundAccessTime: typeof import('~icons/ic/round-access-time')['default']
|
||||||
|
const IconIcRoundArrowBack: typeof import('~icons/ic/round-arrow-back')['default']
|
||||||
|
const IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
|
||||||
const IconIcRoundLibraryAdd: typeof import('~icons/ic/round-library-add')['default']
|
const IconIcRoundLibraryAdd: typeof import('~icons/ic/round-library-add')['default']
|
||||||
const IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
|
const IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
|
||||||
|
const IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default']
|
||||||
|
const IconIcRoundSearch: typeof import('~icons/ic/round-search')['default']
|
||||||
const IconIcRoundStarBorder: typeof import('~icons/ic/round-star-border')['default']
|
const IconIcRoundStarBorder: typeof import('~icons/ic/round-star-border')['default']
|
||||||
|
const IconIcRoundUpload: typeof import('~icons/ic/round-upload')['default']
|
||||||
const IconLocalBanner: typeof import('~icons/local/banner')['default']
|
const IconLocalBanner: typeof import('~icons/local/banner')['default']
|
||||||
const IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
|
const IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
|
||||||
const IconMdiArrowUpThin: typeof import('~icons/mdi/arrow-up-thin')['default']
|
const IconMdiArrowUpThin: typeof import('~icons/mdi/arrow-up-thin')['default']
|
||||||
|
const IconMdiDrag: typeof import('~icons/mdi/drag')['default']
|
||||||
const IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
|
const IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
|
||||||
const IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
|
const IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
|
||||||
|
const IconMdiRefresh: typeof import('~icons/mdi/refresh')['default']
|
||||||
const IconTooltip: typeof import('./../components/common/icon-tooltip.vue')['default']
|
const IconTooltip: typeof import('./../components/common/icon-tooltip.vue')['default']
|
||||||
const IconUilSearch: typeof import('~icons/uil/search')['default']
|
const IconUilSearch: typeof import('~icons/uil/search')['default']
|
||||||
const LangSwitch: typeof import('./../components/common/lang-switch.vue')['default']
|
const LangSwitch: typeof import('./../components/common/lang-switch.vue')['default']
|
||||||
const LookForward: typeof import('./../components/custom/look-forward.vue')['default']
|
const LookForward: typeof import('./../components/custom/look-forward.vue')['default']
|
||||||
|
const MathIframeDialog: typeof import('./../components/common/rest-basic-editor/components/math-iframe-dialog.vue')['default']
|
||||||
const MenuToggler: typeof import('./../components/common/menu-toggler.vue')['default']
|
const MenuToggler: typeof import('./../components/common/menu-toggler.vue')['default']
|
||||||
const NAlert: typeof import('naive-ui')['NAlert']
|
const NAlert: typeof import('naive-ui')['NAlert']
|
||||||
const NBadge: typeof import('naive-ui')['NBadge']
|
const NBadge: typeof import('naive-ui')['NBadge']
|
||||||
@ -176,20 +213,28 @@ declare global {
|
|||||||
const NButton: typeof import('naive-ui')['NButton']
|
const NButton: typeof import('naive-ui')['NButton']
|
||||||
const NCard: typeof import('naive-ui')['NCard']
|
const NCard: typeof import('naive-ui')['NCard']
|
||||||
const NCheckbox: typeof import('naive-ui')['NCheckbox']
|
const NCheckbox: typeof import('naive-ui')['NCheckbox']
|
||||||
|
const NCollapse: typeof import('naive-ui')['NCollapse']
|
||||||
|
const NCollapseItem: typeof import('naive-ui')['NCollapseItem']
|
||||||
const NColorPicker: typeof import('naive-ui')['NColorPicker']
|
const NColorPicker: typeof import('naive-ui')['NColorPicker']
|
||||||
|
const NDataTable: typeof import('naive-ui')['NDataTable']
|
||||||
const NDialogProvider: typeof import('naive-ui')['NDialogProvider']
|
const NDialogProvider: typeof import('naive-ui')['NDialogProvider']
|
||||||
const NDivider: typeof import('naive-ui')['NDivider']
|
const NDivider: typeof import('naive-ui')['NDivider']
|
||||||
const NDrawer: typeof import('naive-ui')['NDrawer']
|
const NDrawer: typeof import('naive-ui')['NDrawer']
|
||||||
const NDrawerContent: typeof import('naive-ui')['NDrawerContent']
|
const NDrawerContent: typeof import('naive-ui')['NDrawerContent']
|
||||||
const NDropdown: typeof import('naive-ui')['NDropdown']
|
const NDropdown: typeof import('naive-ui')['NDropdown']
|
||||||
|
const NEditor: typeof import('naive-ui')['NEditor']
|
||||||
const NEmpty: typeof import('naive-ui')['NEmpty']
|
const NEmpty: typeof import('naive-ui')['NEmpty']
|
||||||
const NForm: typeof import('naive-ui')['NForm']
|
const NForm: typeof import('naive-ui')['NForm']
|
||||||
const NFormItem: typeof import('naive-ui')['NFormItem']
|
const NFormItem: typeof import('naive-ui')['NFormItem']
|
||||||
|
const NFormItemGi: typeof import('naive-ui')['NFormItemGi']
|
||||||
const NGi: typeof import('naive-ui')['NGi']
|
const NGi: typeof import('naive-ui')['NGi']
|
||||||
const NGrid: typeof import('naive-ui')['NGrid']
|
const NGrid: typeof import('naive-ui')['NGrid']
|
||||||
const NInput: typeof import('naive-ui')['NInput']
|
const NInput: typeof import('naive-ui')['NInput']
|
||||||
const NInputGroup: typeof import('naive-ui')['NInputGroup']
|
const NInputGroup: typeof import('naive-ui')['NInputGroup']
|
||||||
const NInputNumber: typeof import('naive-ui')['NInputNumber']
|
const NInputNumber: typeof import('naive-ui')['NInputNumber']
|
||||||
|
const NLayout: typeof import('naive-ui')['NLayout']
|
||||||
|
const NLayoutContent: typeof import('naive-ui')['NLayoutContent']
|
||||||
|
const NLayoutHeader: typeof import('naive-ui')['NLayoutHeader']
|
||||||
const NList: typeof import('naive-ui')['NList']
|
const NList: typeof import('naive-ui')['NList']
|
||||||
const NListItem: typeof import('naive-ui')['NListItem']
|
const NListItem: typeof import('naive-ui')['NListItem']
|
||||||
const NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider']
|
const NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider']
|
||||||
@ -197,19 +242,27 @@ declare global {
|
|||||||
const NMessageProvider: typeof import('naive-ui')['NMessageProvider']
|
const NMessageProvider: typeof import('naive-ui')['NMessageProvider']
|
||||||
const NModal: typeof import('naive-ui')['NModal']
|
const NModal: typeof import('naive-ui')['NModal']
|
||||||
const NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
|
const NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
|
||||||
|
const NPopconfirm: typeof import('naive-ui')['NPopconfirm']
|
||||||
|
const NPopover: typeof import('naive-ui')['NPopover']
|
||||||
|
const NRadio: typeof import('naive-ui')['NRadio']
|
||||||
|
const NRadioGroup: typeof import('naive-ui')['NRadioGroup']
|
||||||
const NScrollbar: typeof import('naive-ui')['NScrollbar']
|
const NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||||
const NSelect: typeof import('naive-ui')['NSelect']
|
const NSelect: typeof import('naive-ui')['NSelect']
|
||||||
const NSpace: typeof import('naive-ui')['NSpace']
|
const NSpace: typeof import('naive-ui')['NSpace']
|
||||||
const NStatistic: typeof import('naive-ui')['NStatistic']
|
const NStatistic: typeof import('naive-ui')['NStatistic']
|
||||||
const NSwitch: typeof import('naive-ui')['NSwitch']
|
const NSwitch: typeof import('naive-ui')['NSwitch']
|
||||||
const NTab: typeof import('naive-ui')['NTab']
|
const NTab: typeof import('naive-ui')['NTab']
|
||||||
|
const NTable: typeof import('naive-ui')['NTable']
|
||||||
const NTabs: typeof import('naive-ui')['NTabs']
|
const NTabs: typeof import('naive-ui')['NTabs']
|
||||||
|
const NTag: typeof import('naive-ui')['NTag']
|
||||||
|
const NTextarea: typeof import('naive-ui')['NTextarea']
|
||||||
const NThing: typeof import('naive-ui')['NThing']
|
const NThing: typeof import('naive-ui')['NThing']
|
||||||
const NTooltip: typeof import('naive-ui')['NTooltip']
|
const NTooltip: typeof import('naive-ui')['NTooltip']
|
||||||
const NWatermark: typeof import('naive-ui')['NWatermark']
|
const NWatermark: typeof import('naive-ui')['NWatermark']
|
||||||
const PageHeader: typeof import('./../components/common/page-header.vue')['default']
|
const PageHeader: typeof import('./../components/common/page-header.vue')['default']
|
||||||
const PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
|
const PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
|
||||||
const ReloadButton: typeof import('./../components/common/reload-button.vue')['default']
|
const ReloadButton: typeof import('./../components/common/reload-button.vue')['default']
|
||||||
|
const RestBasicEditor: typeof import('./../components/common/rest-basic-editor/rest-basic-editor.vue')['default']
|
||||||
const RouterLink: typeof import('vue-router')['RouterLink']
|
const RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
const RouterView: typeof import('vue-router')['RouterView']
|
const RouterView: typeof import('vue-router')['RouterView']
|
||||||
const SoybeanAvatar: typeof import('./../components/custom/soybean-avatar.vue')['default']
|
const SoybeanAvatar: typeof import('./../components/custom/soybean-avatar.vue')['default']
|
||||||
@ -218,5 +271,6 @@ declare global {
|
|||||||
const TableColumnSetting: typeof import('./../components/advanced/table-column-setting.vue')['default']
|
const TableColumnSetting: typeof import('./../components/advanced/table-column-setting.vue')['default']
|
||||||
const TableHeaderOperation: typeof import('./../components/advanced/table-header-operation.vue')['default']
|
const TableHeaderOperation: typeof import('./../components/advanced/table-header-operation.vue')['default']
|
||||||
const ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default']
|
const ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default']
|
||||||
|
const UpFileDialog: typeof import('./../components/common/rest-basic-editor/components/up-file-dialog.vue')['default']
|
||||||
const WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']
|
const WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']
|
||||||
}
|
}
|
||||||
18
apps/admin/src/typings/elegant-router.d.ts
vendored
18
apps/admin/src/typings/elegant-router.d.ts
vendored
@ -27,7 +27,12 @@ declare module "@elegant-router/types" {
|
|||||||
"home": "/home";
|
"home": "/home";
|
||||||
"iframe-page": "/iframe-page/:url";
|
"iframe-page": "/iframe-page/:url";
|
||||||
"login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?";
|
"login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?";
|
||||||
"question": "/question";
|
"question-store": "/question-store";
|
||||||
|
"rank": "/rank";
|
||||||
|
"rank_rank-detail": "/rank/rank-detail";
|
||||||
|
"rank_rank-list": "/rank/rank-list";
|
||||||
|
"results": "/results";
|
||||||
|
"template": "/template";
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -66,7 +71,10 @@ declare module "@elegant-router/types" {
|
|||||||
| "home"
|
| "home"
|
||||||
| "iframe-page"
|
| "iframe-page"
|
||||||
| "login"
|
| "login"
|
||||||
| "question"
|
| "question-store"
|
||||||
|
| "rank"
|
||||||
|
| "results"
|
||||||
|
| "template"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -92,7 +100,11 @@ declare module "@elegant-router/types" {
|
|||||||
| "competition_competition-detail"
|
| "competition_competition-detail"
|
||||||
| "competition_competition-list"
|
| "competition_competition-list"
|
||||||
| "home"
|
| "home"
|
||||||
| "question"
|
| "question-store"
|
||||||
|
| "rank_rank-detail"
|
||||||
|
| "rank_rank-list"
|
||||||
|
| "results"
|
||||||
|
| "template"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
48
apps/admin/src/utils/date.ts
Normal file
48
apps/admin/src/utils/date.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import path from 'path-browserify';
|
||||||
|
|
||||||
|
export function getCurrentYear(): Date {
|
||||||
|
return new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 得到随机数
|
||||||
|
* @param min 最小值
|
||||||
|
* @param max 最大值
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export function getTrueRandomInt(min: number, max: number) {
|
||||||
|
const range = max - min + 1;
|
||||||
|
const maxSafe = 0xffffffff; // 32位最大无符号整数 (2^32 - 1)
|
||||||
|
let randomValue = 0;
|
||||||
|
|
||||||
|
do {
|
||||||
|
const buffer = new Uint32Array(1);
|
||||||
|
window.crypto.getRandomValues(buffer);
|
||||||
|
randomValue = (buffer[0]! / (maxSafe + 1)) * range; // 转换为[0, range)的浮点数
|
||||||
|
} while (randomValue >= range); // 拒绝采样避免偏差
|
||||||
|
|
||||||
|
return Math.floor(randomValue) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路径拼接
|
||||||
|
*/
|
||||||
|
export function browserPathJoin(base: string, ...paths: string[]) {
|
||||||
|
const [protocol, ...rest] = base.split('://');
|
||||||
|
if (rest.length > 0) {
|
||||||
|
const pathPart = rest.join('://').replace(/\/+/g, '/');
|
||||||
|
return `${protocol}://${path.join(pathPart, ...paths)}`;
|
||||||
|
}
|
||||||
|
return path.join(base, ...paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下一个tick后执行
|
||||||
|
*/
|
||||||
|
export function nextTickSleep() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
nextTick(() => {
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
328
apps/admin/src/utils/file.ts
Normal file
328
apps/admin/src/utils/file.ts
Normal file
@ -0,0 +1,328 @@
|
|||||||
|
import { snapdom } from '@zumer/snapdom';
|
||||||
|
import { getTrueRandomInt } from './date';
|
||||||
|
import { getSnowflake } from './rest';
|
||||||
|
|
||||||
|
/** 通过 url 判断是否为 图片链接 */
|
||||||
|
export function isImageUrl(url: string) {
|
||||||
|
const str = url.split('?')[0]!.substring(url.lastIndexOf('.') + 1) || '';
|
||||||
|
return ['jpg', 'jpeg', 'png', 'webp', 'svg', 'gif'].includes(str.toLocaleLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通过 url 判断是否为 图片链接 */
|
||||||
|
export function isVideoUrl(url: string) {
|
||||||
|
const str = url.split('?')[0]!.substring(url.lastIndexOf('.') + 1) || '';
|
||||||
|
return ['m4v', 'mov', '3gp', '3g2', 'mp4', 'flv', 'f4v', 'webm', 'wmv', 'avi', 'asf'].includes(str.toLocaleLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 得到 */
|
||||||
|
export function getAssetsFile(url: string) {
|
||||||
|
return new URL(url, import.meta.url).href;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开文件选择器并返回用户选择的文件
|
||||||
|
* @param accept 文件类型过滤(如 "image/*"、"application/pdf")
|
||||||
|
* @param multiple 是否支持多选(默认 false)
|
||||||
|
* @returns Promise<File[]> 返回 File 对象数组
|
||||||
|
*/
|
||||||
|
export function selectFiles(accept = '*/*', multiple = false): Promise<File[]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// 1. 创建隐藏的文件输入元素
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = accept;
|
||||||
|
input.multiple = multiple;
|
||||||
|
input.style.display = 'none';
|
||||||
|
|
||||||
|
// 2. 监听文件选择事件
|
||||||
|
input.addEventListener('change', () => {
|
||||||
|
if (!input.files || input.files.length === 0) {
|
||||||
|
reject(new Error('未选择文件'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 转换为 File 对象数组
|
||||||
|
const files = Array.from(input.files);
|
||||||
|
resolve(files);
|
||||||
|
|
||||||
|
// 4. 清理DOM
|
||||||
|
document.body.removeChild(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. 触发文件选择弹窗
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base64字符串转换为File对象
|
||||||
|
* @param dataUrl Base64字符串
|
||||||
|
* @param filename 文件名
|
||||||
|
* @returns File对象
|
||||||
|
*/
|
||||||
|
export function base64ToFile(dataUrl: string, filename: string): File {
|
||||||
|
// 拆分 Data URL
|
||||||
|
const arr = dataUrl.split(',');
|
||||||
|
const mimeMatch = arr[0]?.match(/:(.*?);/) || null;
|
||||||
|
if (!mimeMatch || !mimeMatch[1] || !arr[1]) {
|
||||||
|
throw new Error('无效的Base64字符串');
|
||||||
|
}
|
||||||
|
const mime = mimeMatch[1]; // 提取 MIME 类型(如 "image/png")
|
||||||
|
const bstr = atob(arr[1]); // Base64 解码
|
||||||
|
const n = bstr.length;
|
||||||
|
const u8arr = new Uint8Array(n);
|
||||||
|
|
||||||
|
// 将解码后的二进制数据存入 Uint8Array
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
u8arr[i] = bstr.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 File 对象
|
||||||
|
return new File([u8arr], filename, { type: mime });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将图片file转换为Base64对象
|
||||||
|
*/
|
||||||
|
export function fileToBase64(file: File): Promise<{ id: string; img: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const date = new Date().getTime();
|
||||||
|
const num = getTrueRandomInt(100000000, 99999999999);
|
||||||
|
if (file.size > 1024 * 40) {
|
||||||
|
// 只有大于40kb才压缩
|
||||||
|
const img = new Image();
|
||||||
|
img.src = String(e.target?.result || '');
|
||||||
|
img.onload = async () => {
|
||||||
|
const Base64Url = await compressImg(img, file.type);
|
||||||
|
resolve({ id: `id_${date}_${num}`, img: Base64Url });
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
reject('加载图片失败,002');
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
resolve({ id: `id_${date}_${num}`, img: String(e.target?.result || '') });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
reject('加载图片失败,001');
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 压缩图片
|
||||||
|
* @param img - 被压缩的img对象
|
||||||
|
* @param imgType - 图片类型
|
||||||
|
* @param mx - 触发压缩的图片最大宽度限制
|
||||||
|
* @param mh - 触发压缩的图片最大高度限制
|
||||||
|
* @param quality - 清晰度 0到1之间
|
||||||
|
*/
|
||||||
|
export function compressImg(img: HTMLImageElement, imgType = 'image/jpeg', mx = 720, mh = 1280, quality = 0.8): Promise<string> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
const { width: originWidth, height: originHeight } = img;
|
||||||
|
// 最大尺寸限制
|
||||||
|
const maxWidth = mx;
|
||||||
|
const maxHeight = mh;
|
||||||
|
// 目标尺寸
|
||||||
|
let targetWidth = originWidth;
|
||||||
|
let targetHeight = originHeight;
|
||||||
|
if (originWidth > maxWidth || originHeight > maxHeight) {
|
||||||
|
if (originWidth / originHeight > 1) {
|
||||||
|
// 宽图片
|
||||||
|
targetWidth = maxWidth;
|
||||||
|
targetHeight = Math.round(maxWidth * (originHeight / originWidth));
|
||||||
|
} else {
|
||||||
|
// 高图片
|
||||||
|
targetHeight = maxHeight;
|
||||||
|
targetWidth = Math.round(maxHeight * (originWidth / originHeight));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
canvas.width = targetWidth;
|
||||||
|
canvas.height = targetHeight;
|
||||||
|
if (context) {
|
||||||
|
context.clearRect(0, 0, targetWidth, targetHeight);
|
||||||
|
// 图片绘制
|
||||||
|
context.drawImage(img, 0, 0, targetWidth, targetHeight);
|
||||||
|
}
|
||||||
|
const dataURL = canvas.toDataURL(imgType, quality); // 转换图片为dataURL
|
||||||
|
// const fun = (blob) => {
|
||||||
|
// resolve(blob);
|
||||||
|
// };
|
||||||
|
// 转换为bolb对象
|
||||||
|
// canvas.toBlob(fun, imgType, 0.7);
|
||||||
|
resolve(dataURL);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取视频第一帧
|
||||||
|
* @param videoSource - 视频源,可以是URL或Blob
|
||||||
|
* @returns - 第一帧的File对象
|
||||||
|
*/
|
||||||
|
export function getFirstFrameOfVideo(
|
||||||
|
videoSource: Blob | File | string
|
||||||
|
): Promise<{ firstFrame: File; duration: number; videoWidth: number; videoHeight: number }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let url = ''; // 使用createObjectURL创建的URL
|
||||||
|
|
||||||
|
// 创建视频元素
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.crossOrigin = 'Anonymous';
|
||||||
|
video.setAttribute('playsinline', '');
|
||||||
|
video.muted = true;
|
||||||
|
|
||||||
|
// 事件监听:当视频可播放时处理
|
||||||
|
video.addEventListener('seeked', () => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = video.videoWidth;
|
||||||
|
canvas.height = video.videoHeight;
|
||||||
|
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (context) {
|
||||||
|
context.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||||
|
const blobCallback = (blob: Blob | null) => {
|
||||||
|
if (blob) {
|
||||||
|
const name = `${getSnowflake()}_${Math.random().toString(32).substring(2)}.png`;
|
||||||
|
// 将 Blob 转换为 File
|
||||||
|
const firstFrame = new File([blob], name, { type: blob.type });
|
||||||
|
resolve({ firstFrame, videoWidth: video.videoWidth, videoHeight: video.videoHeight, duration: video.duration });
|
||||||
|
url && URL.revokeObjectURL(url); // 使用createObjectURL创建的URL应在不再需要时通过revokeObjectURL释放,以避免内存泄漏。
|
||||||
|
} else {
|
||||||
|
reject(new Error('获取地一帧失败,BD002'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
canvas.toBlob(blobCallback, 'image/jpeg', 0.7);
|
||||||
|
} else {
|
||||||
|
reject(new Error('获取地一帧失败,BD001'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 错误处理
|
||||||
|
video.addEventListener('error', () => {
|
||||||
|
reject(new Error('获取地一帧失败,BD003'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 设置视频源
|
||||||
|
if (videoSource instanceof Blob || (videoSource as any) instanceof File) {
|
||||||
|
// Blob 或 File
|
||||||
|
url = URL.createObjectURL(videoSource as Blob | File);
|
||||||
|
video.src = url;
|
||||||
|
video.load();
|
||||||
|
} else if (typeof videoSource === 'string') {
|
||||||
|
video.src = videoSource;
|
||||||
|
video.load();
|
||||||
|
} else {
|
||||||
|
reject(new Error('不支持此类型'));
|
||||||
|
}
|
||||||
|
video.currentTime = 0.01;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 得到图片的宽高
|
||||||
|
* @param file - 图片,可以是 url 或者File
|
||||||
|
*/
|
||||||
|
export function getImageWidthHeight(file: Blob | File | string): Promise<{ width: number; height: number }> {
|
||||||
|
return new Promise<{ width: number; height: number }>((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const size = { width: img.width, height: img.height };
|
||||||
|
resolve(size);
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
reject(new Error('读取图片失败,BD0001'));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof file === 'string') {
|
||||||
|
img.src = file;
|
||||||
|
} else if (file instanceof File || file instanceof Blob) {
|
||||||
|
img.src = URL.createObjectURL(file);
|
||||||
|
} else {
|
||||||
|
reject(new Error('读取图片失败,BD0002'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* html 转换为 jpg图片
|
||||||
|
*/
|
||||||
|
export async function htmlToJpgImgFile(html: string, op: { width: number }): Promise<File> {
|
||||||
|
const type: 'jpeg' | 'jpg' | 'png' | 'svg' | 'webp' = 'jpg';
|
||||||
|
// 创建临时容器
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.style.width = `${op.width}px`;
|
||||||
|
container.style.position = 'absolute';
|
||||||
|
container.style.left = '101vw';
|
||||||
|
container.style.top = '101vh';
|
||||||
|
container.style.zIndex = '-1';
|
||||||
|
container.style.backgroundColor = '#ffffff';
|
||||||
|
container.innerHTML = html;
|
||||||
|
document.body.appendChild(container);
|
||||||
|
try {
|
||||||
|
// 等待图片加载完成
|
||||||
|
const images = container.querySelectorAll('img');
|
||||||
|
const imageLoadPromises = Array.from(images).map((img) => {
|
||||||
|
if (img.complete) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
img.onload = () => resolve(null);
|
||||||
|
img.onerror = () => resolve(null); // 即使图片加载失败也继续
|
||||||
|
// 设置超时,防止某些图片一直加载不成功
|
||||||
|
setTimeout(() => resolve(null), 5000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(imageLoadPromises);
|
||||||
|
console.log('所有图片加载完成,图片数量:', images.length);
|
||||||
|
|
||||||
|
// 额外等待确保DOM完全渲染
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
requestAnimationFrame(resolve);
|
||||||
|
}, 100); // 增加等待时间
|
||||||
|
});
|
||||||
|
|
||||||
|
const name = `${getSnowflake()}_${Math.random().toString(32).substring(2)}.jpg`;
|
||||||
|
console.log('开始截图,容器宽度:', container.offsetWidth, '高度:', container.offsetHeight);
|
||||||
|
|
||||||
|
// 使用 snapdom 进行 DOM 快照
|
||||||
|
const res = await snapdom(container, {
|
||||||
|
width: op.width,
|
||||||
|
scale: 1,
|
||||||
|
type,
|
||||||
|
format: type,
|
||||||
|
filename: name,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = await res.toBlob({ type, backgroundColor: '#ffffff' });
|
||||||
|
console.log('截图完成,blob大小:', (blob.size / 1024).toFixed(2), 'KB');
|
||||||
|
|
||||||
|
// 将blob转换为base64以便在浏览器查看
|
||||||
|
const reader = new FileReader();
|
||||||
|
const base64Promise = new Promise<string>((resolve) => {
|
||||||
|
reader.onloadend = () => {
|
||||||
|
resolve(reader.result as string);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
const base64 = await base64Promise;
|
||||||
|
// console.log("blob=====", blob);
|
||||||
|
// console.log("图片base64=====", base64);
|
||||||
|
// console.log("👆 复制上面的base64到浏览器地址栏查看图片");
|
||||||
|
|
||||||
|
return new File([blob], name, { type: blob.type });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('htmlToJpgImgFile 错误:', error);
|
||||||
|
return Promise.reject(error);
|
||||||
|
} finally {
|
||||||
|
// document.body.removeChild(container);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
apps/admin/src/utils/oss.ts
Normal file
45
apps/admin/src/utils/oss.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { type AliOssSTS, getAliOssTokenAxios } from '@/service/api/upload';
|
||||||
|
import OSS, { type Checkpoint } from 'ali-oss';
|
||||||
|
|
||||||
|
// 初始化OSS客户端
|
||||||
|
export const initOSSClient = (token: AliOssSTS) => {
|
||||||
|
return new OSS({
|
||||||
|
region: token.Region || 'oss-cn-shenzhen',
|
||||||
|
accessKeyId: token.AccessKeyId,
|
||||||
|
accessKeySecret: token.AccessKeySecret,
|
||||||
|
stsToken: token.SecurityToken,
|
||||||
|
bucket: token.BucketName,
|
||||||
|
refreshSTSTokenInterval: 600000, // 10分钟刷新一次token
|
||||||
|
refreshSTSToken: async () => {
|
||||||
|
// 这里可以添加获取新token的逻辑
|
||||||
|
const res = await getAliOssTokenAxios();
|
||||||
|
return {
|
||||||
|
accessKeyId: res.AccessKeyId,
|
||||||
|
accessKeySecret: res.AccessKeySecret,
|
||||||
|
stsToken: res.SecurityToken,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文件到OSS (注意进度从 0 开始到 1 结束)
|
||||||
|
*/
|
||||||
|
export const uploadFileToOSS = async (
|
||||||
|
client: OSS,
|
||||||
|
file: File,
|
||||||
|
path: string,
|
||||||
|
progress?: ((progress: number, checkpoint?: Checkpoint, http?: any) => any) | undefined
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const result = await client.multipartUpload(path, file, {
|
||||||
|
parallel: 5, // 并发分片数
|
||||||
|
partSize: 1024 * 1024 * 5, // 分片大小5MB
|
||||||
|
progress,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('上传文件失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
99
apps/admin/src/utils/rest.ts
Normal file
99
apps/admin/src/utils/rest.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { Snowflake } from '@sapphire/snowflake';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 得到雪花ID
|
||||||
|
*/
|
||||||
|
export function getSnowflake(): bigint {
|
||||||
|
const epoch = new Date('2025-07-01T00:00:00.000Z');
|
||||||
|
const snowflake = new Snowflake(epoch);
|
||||||
|
snowflake.workerId = 1;
|
||||||
|
return snowflake.generate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 并发控制池,限制同时执行的异步任务数量
|
||||||
|
* @param tasks 异步任务数组(每个任务为返回 Promise 的函数)
|
||||||
|
* @param concurrency 最大并发数
|
||||||
|
* @returns Promise,解析为所有任务结果的数组
|
||||||
|
*/
|
||||||
|
export async function concurrentPool<T>(tasks: (() => Promise<T>)[], concurrency: number): Promise<T[]> {
|
||||||
|
// 存储所有任务的结果
|
||||||
|
const results: T[] = [];
|
||||||
|
// 存储当前正在执行的任务
|
||||||
|
const executing: Promise<void>[] = [];
|
||||||
|
let index = 0; // 任务索引,用于按顺序添加任务
|
||||||
|
|
||||||
|
// 创建执行器函数
|
||||||
|
const execute = async (taskIndex: number): Promise<void> => {
|
||||||
|
try {
|
||||||
|
// 执行任务并获取结果
|
||||||
|
const result = await tasks[taskIndex]!();
|
||||||
|
results[taskIndex] = result; // 按原始顺序存储结果
|
||||||
|
} catch (error) {
|
||||||
|
results[taskIndex] = error as any; // 捕获错误(可根据需求调整)
|
||||||
|
} finally {
|
||||||
|
// 无论成功失败,任务完成后从执行队列移除
|
||||||
|
const executingIndex = executing.findIndex((p) => p === executing[taskIndex]);
|
||||||
|
if (executingIndex !== -1) {
|
||||||
|
executing.splice(executingIndex, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 启动初始并发任务
|
||||||
|
while (index < Math.min(concurrency, tasks.length)) {
|
||||||
|
const taskPromise = execute(index);
|
||||||
|
executing.push(taskPromise);
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动态管理任务池
|
||||||
|
while (index < tasks.length) {
|
||||||
|
if (executing.length < concurrency) {
|
||||||
|
const taskPromise = execute(index);
|
||||||
|
executing.push(taskPromise);
|
||||||
|
index++;
|
||||||
|
} else {
|
||||||
|
// 等待任意一个任务完成
|
||||||
|
await Promise.race(executing); // eslint-disable-line no-await-in-loop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待所有剩余任务完成
|
||||||
|
await Promise.all(executing);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断连个版本号大于小于或者等于
|
||||||
|
* @param v1 - 版本号1
|
||||||
|
* @param v2 - 版本号2
|
||||||
|
* @returns -
|
||||||
|
* 当 v1 大于 v2 时返回 1
|
||||||
|
* 当 v1 等于 v2 时返回 0
|
||||||
|
* 当 v1 小于 v2 时返回 -1
|
||||||
|
*/
|
||||||
|
export function compareVersion(v1: string, v2: string, operator: '_' | '-' | '.' = '.'): -1 | 0 | 1 {
|
||||||
|
if (v1 === v2) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vs1 = v1.split(operator).map((a) => parseInt(a));
|
||||||
|
const vs2 = v2.split(operator).map((a) => parseInt(a));
|
||||||
|
|
||||||
|
const length = Math.min(vs1.length, vs2.length);
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
const s1 = vs1[i] || 0;
|
||||||
|
const s2 = vs2[i] || 0;
|
||||||
|
if (s1 > s2) {
|
||||||
|
return 1;
|
||||||
|
} else if (s1 < s2) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (length === vs1.length) {
|
||||||
|
return -1;
|
||||||
|
} else {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,9 +1,9 @@
|
|||||||
import json5 from 'json5'
|
import json5 from 'json5'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create service config by current env
|
* 根据当前环境创建服务配置
|
||||||
*
|
*
|
||||||
* @param env The current env
|
* @param env 当前环境
|
||||||
*/
|
*/
|
||||||
export function createServiceConfig(env: Env.ImportMeta) {
|
export function createServiceConfig(env: Env.ImportMeta) {
|
||||||
const { VITE_SERVICE_BASE_URL, VITE_OTHER_SERVICE_BASE_URL } = env
|
const { VITE_SERVICE_BASE_URL, VITE_OTHER_SERVICE_BASE_URL } = env
|
||||||
@ -13,7 +13,7 @@ export function createServiceConfig(env: Env.ImportMeta) {
|
|||||||
other = json5.parse(VITE_OTHER_SERVICE_BASE_URL)
|
other = json5.parse(VITE_OTHER_SERVICE_BASE_URL)
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
console.error('VITE_OTHER_SERVICE_BASE_URL is not a valid json5 string')
|
console.error('VITE_OTHER_SERVICE_BASE_URL 不是有效的 json5 字符串')
|
||||||
}
|
}
|
||||||
|
|
||||||
const httpConfig: App.Service.SimpleServiceConfig = {
|
const httpConfig: App.Service.SimpleServiceConfig = {
|
||||||
@ -41,10 +41,10 @@ export function createServiceConfig(env: Env.ImportMeta) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* get backend service base url
|
* 获取后端服务基础地址
|
||||||
*
|
*
|
||||||
* @param env - the current env
|
* @param env - 当前环境
|
||||||
* @param isProxy - if use proxy
|
* @param isProxy - 是否使用代理
|
||||||
*/
|
*/
|
||||||
export function getServiceBaseURL(env: Env.ImportMeta, isProxy: boolean) {
|
export function getServiceBaseURL(env: Env.ImportMeta, isProxy: boolean) {
|
||||||
const { baseURL, other } = createServiceConfig(env)
|
const { baseURL, other } = createServiceConfig(env)
|
||||||
@ -62,9 +62,9 @@ export function getServiceBaseURL(env: Env.ImportMeta, isProxy: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get proxy pattern of backend service base url
|
* 获取后端服务基础地址的代理模式
|
||||||
*
|
*
|
||||||
* @param key If not set, will use the default key
|
* @param key 如果未设置,将使用默认键
|
||||||
*/
|
*/
|
||||||
function createProxyPattern(key?: App.Service.OtherBaseURLKey) {
|
function createProxyPattern(key?: App.Service.OtherBaseURLKey) {
|
||||||
if (!key) {
|
if (!key) {
|
||||||
|
|||||||
63
apps/admin/src/views/question-store/index.vue
Normal file
63
apps/admin/src/views/question-store/index.vue
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import CategoryTree from './modules/CategoryTree.vue'
|
||||||
|
import QuestionList from './modules/QuestionList.vue'
|
||||||
|
|
||||||
|
defineOptions({ name: 'QuestionStore' })
|
||||||
|
|
||||||
|
const currentCategory = ref<any>(null)
|
||||||
|
|
||||||
|
// Resize logic
|
||||||
|
const siderWidth = ref(450)
|
||||||
|
const minWidth = 300
|
||||||
|
const maxWidth = 800
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const startX = ref(0)
|
||||||
|
const startWidth = ref(0)
|
||||||
|
|
||||||
|
function onMouseDown(e: MouseEvent) {
|
||||||
|
isDragging.value = true
|
||||||
|
startX.value = e.clientX
|
||||||
|
startWidth.value = siderWidth.value
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', onMouseMove)
|
||||||
|
document.addEventListener('mouseup', onMouseUp)
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
document.body.style.cursor = 'col-resize'
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseMove(e: MouseEvent) {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
const dx = e.clientX - startX.value
|
||||||
|
const newWidth = startWidth.value + dx
|
||||||
|
|
||||||
|
if (newWidth >= minWidth && newWidth <= maxWidth) {
|
||||||
|
siderWidth.value = newWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseUp() {
|
||||||
|
isDragging.value = false
|
||||||
|
document.removeEventListener('mousemove', onMouseMove)
|
||||||
|
document.removeEventListener('mouseup', onMouseUp)
|
||||||
|
document.body.style.userSelect = ''
|
||||||
|
document.body.style.cursor = ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<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` }">
|
||||||
|
<CategoryTree @update:category="val => currentCategory = val" />
|
||||||
|
<!-- 分类树宽度调整手柄 -->
|
||||||
|
<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"
|
||||||
|
@mousedown="onMouseDown"></div>
|
||||||
|
</div>
|
||||||
|
<!-- 题目列表区域 -->
|
||||||
|
<div class="h-full flex-1 overflow-hidden">
|
||||||
|
<QuestionList :current-category="currentCategory" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
380
apps/admin/src/views/question-store/modules/CategoryTree.vue
Normal file
380
apps/admin/src/views/question-store/modules/CategoryTree.vue
Normal file
@ -0,0 +1,380 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, h, watch } from 'vue'
|
||||||
|
import {
|
||||||
|
NButton,
|
||||||
|
NTree,
|
||||||
|
NModal,
|
||||||
|
NForm,
|
||||||
|
NFormItem,
|
||||||
|
NInput,
|
||||||
|
useMessage,
|
||||||
|
useDialog,
|
||||||
|
type TreeOption
|
||||||
|
} from 'naive-ui'
|
||||||
|
import SvgIcon from '@/components/custom/svg-icon.vue'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:category', node: any): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
// Mock Data - 模拟分类树数据
|
||||||
|
const treeData = ref<TreeOption[]>([
|
||||||
|
{
|
||||||
|
key: 'stage-1',
|
||||||
|
label: '汉字听写',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: 'q-type-1',
|
||||||
|
label: '请根据提示书写正确的汉字。',
|
||||||
|
isLeaf: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'q-type-2',
|
||||||
|
label: '请根据拼音书写同音字。',
|
||||||
|
isLeaf: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'stage-2',
|
||||||
|
label: '汉字加一加',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: 'q-type-3',
|
||||||
|
label: '请写出含有“”的汉字。',
|
||||||
|
isLeaf: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'poem',
|
||||||
|
label: '词语大比拼',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: 'q-type-4',
|
||||||
|
label: '词语听写',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: 'q-type-4-1',
|
||||||
|
label: '请根据拼音书写正确的词语。',
|
||||||
|
isLeaf: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'q-type-5',
|
||||||
|
label: '成语写一写',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: 'q-type-5-1',
|
||||||
|
label: '请写出含有反义字的四字成语。',
|
||||||
|
isLeaf: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'q-type-5-2',
|
||||||
|
label: '请根据图片书写正确的成语',
|
||||||
|
isLeaf: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'stage-extra-1',
|
||||||
|
label: '换字组成语',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: 'q-type-extra-1',
|
||||||
|
label: '请换一个字,组成新的成语。',
|
||||||
|
isLeaf: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const selectedKeys = ref<string[]>([])
|
||||||
|
const expandedKeys = ref<string[]>(['root', 'stage-1'])
|
||||||
|
|
||||||
|
// Modal State
|
||||||
|
const showCategoryModal = ref(false)
|
||||||
|
const categoryModalType = ref<1 | 2>(1) // 1级或2级分类
|
||||||
|
const categoryForm = ref({ name: '' })
|
||||||
|
|
||||||
|
// 记录当前操作类型:add-root, add-child, edit
|
||||||
|
const categoryOperation = ref<'add-root' | 'add-child' | 'edit'>('add-root')
|
||||||
|
// 记录当前操作的目标节点(编辑时为该节点,添加子节点时为父节点)
|
||||||
|
const currentOperationNode = ref<TreeOption | null>(null)
|
||||||
|
|
||||||
|
// 递归查找节点
|
||||||
|
function findNodeByKey(key: string, nodes: TreeOption[]): TreeOption | null {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.key === key)
|
||||||
|
return node
|
||||||
|
if (node.children) {
|
||||||
|
const found = findNodeByKey(key, node.children)
|
||||||
|
if (found)
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(selectedKeys, (newKeys) => {
|
||||||
|
if (newKeys.length === 0) {
|
||||||
|
emit('update:category', null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const key = newKeys[0]
|
||||||
|
const node = findNodeByKey(key, treeData.value)
|
||||||
|
if (node) {
|
||||||
|
emit('update:category', {
|
||||||
|
label: node.label,
|
||||||
|
level: node.isLeaf ? 3 : 1,
|
||||||
|
key: node.key,
|
||||||
|
isLeaf: node.isLeaf,
|
||||||
|
children: node.children
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
emit('update:category', null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 打开新增根节点弹窗
|
||||||
|
function handleAddRootCategory() {
|
||||||
|
categoryOperation.value = 'add-root'
|
||||||
|
categoryModalType.value = 1
|
||||||
|
categoryForm.value.name = ''
|
||||||
|
currentOperationNode.value = null
|
||||||
|
showCategoryModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开新增子节点弹窗
|
||||||
|
function handleAddChildCategory(parentNode: TreeOption) {
|
||||||
|
categoryOperation.value = 'add-child'
|
||||||
|
categoryModalType.value = 2 // 视为下一级
|
||||||
|
categoryForm.value.name = ''
|
||||||
|
currentOperationNode.value = parentNode
|
||||||
|
showCategoryModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开编辑节点弹窗
|
||||||
|
function handleEditCategory(node: TreeOption) {
|
||||||
|
categoryOperation.value = 'edit'
|
||||||
|
categoryForm.value.name = node.label as string
|
||||||
|
currentOperationNode.value = node
|
||||||
|
showCategoryModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 递归删除节点 如果节点有子节点,无法删除
|
||||||
|
function deleteNode(nodes: TreeOption[], key: string | number): boolean {
|
||||||
|
const index = nodes.findIndex(n => n.key === key)
|
||||||
|
if (index !== -1) {
|
||||||
|
// 检查是否有子节点
|
||||||
|
if (nodes[index].children && nodes[index].children.length > 0) {
|
||||||
|
throw new Error('该分类下有子分类,无法删除')
|
||||||
|
}
|
||||||
|
nodes.splice(index, 1)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.children) {
|
||||||
|
if (deleteNode(node.children, key))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteCategory(node: TreeOption) {
|
||||||
|
dialog.warning({
|
||||||
|
title: '警告',
|
||||||
|
content: `确定要删除分类 "${node.label}" 吗?此操作无法撤销。`,
|
||||||
|
positiveText: '确定',
|
||||||
|
negativeText: '取消',
|
||||||
|
onPositiveClick: () => {
|
||||||
|
try {
|
||||||
|
deleteNode(treeData.value, node.key!)
|
||||||
|
// 如果删除的是当前选中的节点,清空选中
|
||||||
|
if (selectedKeys.value.includes(node.key as string)) {
|
||||||
|
selectedKeys.value = []
|
||||||
|
}
|
||||||
|
message.success('删除成功')
|
||||||
|
}
|
||||||
|
catch (error: any) {
|
||||||
|
message.error(error.message || '删除失败')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitCategory() {
|
||||||
|
if (!categoryForm.value.name) {
|
||||||
|
message.error('请输入分类名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categoryOperation.value === 'add-root') {
|
||||||
|
// 新增根节点
|
||||||
|
const newKey = `root-${Date.now()}`
|
||||||
|
treeData.value.push({
|
||||||
|
key: newKey,
|
||||||
|
label: categoryForm.value.name,
|
||||||
|
children: [],
|
||||||
|
})
|
||||||
|
message.success('分类添加成功')
|
||||||
|
}
|
||||||
|
else if (categoryOperation.value === 'add-child' && currentOperationNode.value) {
|
||||||
|
// 新增子节点
|
||||||
|
if (!currentOperationNode.value.children) {
|
||||||
|
currentOperationNode.value.children = []
|
||||||
|
}
|
||||||
|
// 确保父节点不再是叶子节点,否则无法展开显示子节点
|
||||||
|
if (currentOperationNode.value.isLeaf) {
|
||||||
|
currentOperationNode.value.isLeaf = false
|
||||||
|
}
|
||||||
|
const newKey = `node-${Date.now()}`
|
||||||
|
// 如果是第三级(叶子),标记 isLeaf
|
||||||
|
currentOperationNode.value.children.push({
|
||||||
|
key: newKey,
|
||||||
|
label: categoryForm.value.name,
|
||||||
|
isLeaf: true,
|
||||||
|
})
|
||||||
|
// 展开父节点
|
||||||
|
if (!expandedKeys.value.includes(currentOperationNode.value.key as string)) {
|
||||||
|
expandedKeys.value.push(currentOperationNode.value.key as string)
|
||||||
|
}
|
||||||
|
message.success('子分类添加成功')
|
||||||
|
}
|
||||||
|
else if (categoryOperation.value === 'edit' && currentOperationNode.value) {
|
||||||
|
// 编辑节点
|
||||||
|
currentOperationNode.value.label = categoryForm.value.name
|
||||||
|
message.success('分类修改成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
showCategoryModal.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tree Rendering
|
||||||
|
function renderPrefix({ option }: { option: TreeOption }) {
|
||||||
|
// 根据层级或类型显示不同图标
|
||||||
|
if (option.children && option.children.length > 0) {
|
||||||
|
return h(SvgIcon, { icon: 'carbon:folder', class: 'text-gray-400 text-lg' })
|
||||||
|
}
|
||||||
|
// 如果明确标记为叶子节点,或者没有 children
|
||||||
|
return h(SvgIcon, { icon: 'carbon:document', class: 'text-gray-400 text-lg' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSuffix({ option }: { option: TreeOption }) {
|
||||||
|
// 悬浮时显示操作按钮
|
||||||
|
return h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
class: 'flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity',
|
||||||
|
onClick: (e: Event) => e.stopPropagation(),
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h('div', {
|
||||||
|
class: 'text-gray-400 hover:text-blue-500 cursor-pointer flex items-center',
|
||||||
|
title: '编辑',
|
||||||
|
onClick: (e: Event) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleEditCategory(option)
|
||||||
|
},
|
||||||
|
}, [h(SvgIcon, { icon: 'carbon:edit', class: 'text-lg' })]),
|
||||||
|
// 允许所有节点添加子节点,如果添加了子节点,它就变成文件夹
|
||||||
|
h('div', {
|
||||||
|
class: 'text-gray-400 hover:text-green-500 cursor-pointer flex items-center',
|
||||||
|
title: '添加子分类',
|
||||||
|
onClick: (e: Event) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleAddChildCategory(option)
|
||||||
|
},
|
||||||
|
}, [h(SvgIcon, { icon: 'carbon:add', class: 'text-lg' })]),
|
||||||
|
h('div', {
|
||||||
|
class: 'text-gray-400 hover:text-red-500 cursor-pointer flex items-center',
|
||||||
|
title: '删除',
|
||||||
|
onClick: (e: Event) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDeleteCategory(option)
|
||||||
|
},
|
||||||
|
}, [h(SvgIcon, { icon: 'carbon:trash-can', class: 'text-lg' })]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-full flex flex-col border-r border-gray-100 bg-gray-50/30">
|
||||||
|
<div class="flex items-center justify-between border-b border-gray-100 px-4 py-4">
|
||||||
|
<span class="text-lg text-gray-700 font-bold">分类导航树</span>
|
||||||
|
<NButton size="tiny" secondary type="primary" @click="handleAddRootCategory">
|
||||||
|
<template #icon>
|
||||||
|
<SvgIcon icon="ic:baseline-add" class="text-icon" />
|
||||||
|
</template>
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto py-2">
|
||||||
|
<NTree block-line :data="treeData" :selected-keys="selectedKeys" :expanded-keys="expandedKeys"
|
||||||
|
:render-prefix="renderPrefix" :render-suffix="renderSuffix" selectable expand-on-click class="px-2"
|
||||||
|
@update:selected-keys="(keys) => (selectedKeys = keys)"
|
||||||
|
@update:expanded-keys="(keys) => (expandedKeys = keys)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-100 bg-gray-50 p-3 text-xs text-gray-400">
|
||||||
|
提示:请勿随意删除维护原有分类,三级分类为最终题目目录。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add/Edit Category Modal -->
|
||||||
|
<NModal v-model:show="showCategoryModal" preset="card"
|
||||||
|
:title="categoryOperation === 'edit' ? '编辑分类' : `新增 ${categoryModalType} 级分类`" class="w-[500px]">
|
||||||
|
<NForm>
|
||||||
|
<NFormItem label="分类显示名称">
|
||||||
|
<NInput v-model:value="categoryForm.name" placeholder="请输入内容" />
|
||||||
|
</NFormItem>
|
||||||
|
</NForm>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<NButton @click="showCategoryModal = false">
|
||||||
|
取消操作
|
||||||
|
</NButton>
|
||||||
|
<NButton type="primary" @click="submitCategory">
|
||||||
|
确认提交
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.n-tree-node-content__text) {
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-tree-node--selected) {
|
||||||
|
background-color: #eff6ff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-tree-node--selected .n-tree-node-content__text) {
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure icons in tree are visible on hover */
|
||||||
|
:deep(.n-tree-node-content:hover .group-hover\:opacity-100) {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom group class for tree node content wrapper to handle hover state */
|
||||||
|
:deep(.n-tree-node-content) {
|
||||||
|
@apply group;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
329
apps/admin/src/views/question-store/modules/QuestionList.vue
Normal file
329
apps/admin/src/views/question-store/modules/QuestionList.vue
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import {
|
||||||
|
NBreadcrumb,
|
||||||
|
NBreadcrumbItem,
|
||||||
|
NButton,
|
||||||
|
NCard,
|
||||||
|
NEmpty,
|
||||||
|
NForm,
|
||||||
|
NFormItem,
|
||||||
|
NInput,
|
||||||
|
NInputNumber,
|
||||||
|
NDrawer,
|
||||||
|
NDrawerContent,
|
||||||
|
NTag,
|
||||||
|
|
||||||
|
} from 'naive-ui'
|
||||||
|
import SvgIcon from '@/components/custom/svg-icon.vue'
|
||||||
|
import RestBasicEditor from '@/components/common/rest-basic-editor/rest-basic-editor.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
currentCategory: any
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Mock Data - 模拟题目列表数据
|
||||||
|
const questionList = ref([
|
||||||
|
{
|
||||||
|
id: 'Q-0325',
|
||||||
|
categoryKey: 'q-type-1',
|
||||||
|
content: 'jiū 表示小鸟的叫声',
|
||||||
|
answer: '碧',
|
||||||
|
score: 10,
|
||||||
|
time: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Q-0326',
|
||||||
|
categoryKey: 'q-type-1',
|
||||||
|
content: '“春色满园关不住,一枝红杏出墙来”。请书写“杏”字。',
|
||||||
|
answer: '杏',
|
||||||
|
score: 10,
|
||||||
|
time: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Q-0327',
|
||||||
|
categoryKey: 'q-type-1',
|
||||||
|
content: '“春色满园关不住,一枝红杏出墙来”。请书写“㽱”字。',
|
||||||
|
answer: '㽱',
|
||||||
|
score: 10,
|
||||||
|
time: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Q-0328',
|
||||||
|
categoryKey: 'q-type-1',
|
||||||
|
content: '“春色满园关不住,一枝红杏出墙来”。请书写“不”字。',
|
||||||
|
answer: '不',
|
||||||
|
score: 10,
|
||||||
|
time: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Q-0329',
|
||||||
|
categoryKey: 'q-type-2',
|
||||||
|
content: 'táng',
|
||||||
|
answer: '糖',
|
||||||
|
score: 10,
|
||||||
|
time: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Q-0330',
|
||||||
|
categoryKey: 'q-type-2',
|
||||||
|
content: 'lái',
|
||||||
|
answer: '莱',
|
||||||
|
score: 10,
|
||||||
|
time: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Q-0331',
|
||||||
|
categoryKey: 'q-type-3',
|
||||||
|
content: '请写出含有“木”字的汉字。',
|
||||||
|
answer: '林',
|
||||||
|
score: 10,
|
||||||
|
time: 30,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const searchText = ref('')
|
||||||
|
const showQuestionModal = ref(false)
|
||||||
|
const questionOperation = ref<'add' | 'edit'>('add')
|
||||||
|
const currentQuestionId = ref<string | null>(null)
|
||||||
|
const questionForm = ref({
|
||||||
|
content: '',
|
||||||
|
answer: '',
|
||||||
|
score: 10,
|
||||||
|
time: 30,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isLeafSelected = computed(() => {
|
||||||
|
if (!props.currentCategory)
|
||||||
|
return false
|
||||||
|
return !!props.currentCategory.isLeaf || (props.currentCategory.children && props.currentCategory.children.length === 0 && props.currentCategory.level === 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredQuestions = computed(() => {
|
||||||
|
let list = questionList.value
|
||||||
|
|
||||||
|
// 1. Filter by Category Key
|
||||||
|
if (props.currentCategory && props.currentCategory.key) {
|
||||||
|
list = list.filter(q => q.categoryKey === props.currentCategory.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Filter by Search Text
|
||||||
|
if (searchText.value) {
|
||||||
|
list = list.filter(q => q.content.includes(searchText.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleAddQuestion() {
|
||||||
|
if (!props.currentCategory) {
|
||||||
|
window.$message?.warning('请先选择一个分类')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
questionOperation.value = 'add'
|
||||||
|
currentQuestionId.value = null
|
||||||
|
questionForm.value = { content: '', answer: '', score: 10, time: 30 }
|
||||||
|
showQuestionModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditQuestion(id: string) {
|
||||||
|
const question = questionList.value.find(q => q.id === id)
|
||||||
|
if (question) {
|
||||||
|
questionOperation.value = 'edit'
|
||||||
|
currentQuestionId.value = id
|
||||||
|
questionForm.value = {
|
||||||
|
content: question.content,
|
||||||
|
answer: question.answer,
|
||||||
|
score: question.score,
|
||||||
|
time: question.time,
|
||||||
|
}
|
||||||
|
showQuestionModal.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteQuestion(id: string) {
|
||||||
|
window.$dialog?.warning({
|
||||||
|
title: '警告',
|
||||||
|
content: '确定要删除这道题目吗?此操作无法撤销。',
|
||||||
|
positiveText: '确定',
|
||||||
|
negativeText: '取消',
|
||||||
|
onPositiveClick: () => {
|
||||||
|
const index = questionList.value.findIndex(q => q.id === id)
|
||||||
|
if (index !== -1) {
|
||||||
|
questionList.value.splice(index, 1)
|
||||||
|
window.$message?.success('删除成功')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitQuestion() {
|
||||||
|
if (!questionForm.value.content || !questionForm.value.answer) {
|
||||||
|
window.$message?.error('请填写完整信息')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (questionOperation.value === 'add') {
|
||||||
|
// Mock add
|
||||||
|
questionList.value.push({
|
||||||
|
id: `Q-${Math.floor(Math.random() * 10000)}`,
|
||||||
|
...questionForm.value,
|
||||||
|
categoryKey: props.currentCategory?.key // Add categoryKey
|
||||||
|
})
|
||||||
|
window.$message?.success('题目添加成功')
|
||||||
|
}
|
||||||
|
else if (questionOperation.value === 'edit' && currentQuestionId.value) {
|
||||||
|
const index = questionList.value.findIndex(q => q.id === currentQuestionId.value)
|
||||||
|
if (index !== -1) {
|
||||||
|
questionList.value[index] = {
|
||||||
|
...questionList.value[index],
|
||||||
|
...questionForm.value,
|
||||||
|
}
|
||||||
|
window.$message?.success('题目修改成功')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showQuestionModal.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-full flex flex-col flex-1 overflow-hidden bg-white">
|
||||||
|
<!-- 分类面包屑导航 -->
|
||||||
|
<div class="flex flex-col gap-4 border-b border-gray-100 px-6 py-4">
|
||||||
|
<NBreadcrumb>
|
||||||
|
<NBreadcrumbItem>题库全集</NBreadcrumbItem>
|
||||||
|
<NBreadcrumbItem v-if="currentCategory && currentCategory.level === 1">
|
||||||
|
{{ currentCategory.label }}
|
||||||
|
</NBreadcrumbItem>
|
||||||
|
</NBreadcrumb>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="m-0 text-xl text-gray-800 font-bold">
|
||||||
|
{{ currentCategory ? currentCategory.label : '' }}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索框 -->
|
||||||
|
<div v-if="currentCategory && currentCategory.level === 3" class="mt-2 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<NInput v-model:value="searchText" placeholder="输入题目关键字在当前分类下搜索..." class="!w-80">
|
||||||
|
<template #prefix>
|
||||||
|
<SvgIcon icon="carbon:search" class="text-gray-400" />
|
||||||
|
</template>
|
||||||
|
</NInput>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NButton type="primary" @click="handleAddQuestion">
|
||||||
|
<template #icon>
|
||||||
|
<SvgIcon icon="carbon:add" />
|
||||||
|
</template>
|
||||||
|
新增题目
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 题目列表区域 -->
|
||||||
|
<div class="flex-1 overflow-y-auto bg-gray-50/50 p-6">
|
||||||
|
<template v-if="!isLeafSelected">
|
||||||
|
<div class="h-full flex flex-col items-center justify-center text-gray-400">
|
||||||
|
<NEmpty description="暂无数据">
|
||||||
|
<template #extra>
|
||||||
|
请从左侧选择一个最后一级(三级)分类以管理题目数据
|
||||||
|
</template>
|
||||||
|
</NEmpty>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="filteredQuestions.length === 0">
|
||||||
|
<div class="mt-20 flex justify-center">
|
||||||
|
<NEmpty description="该分类下暂无题目" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 题目列表 -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<NCard v-for="q in filteredQuestions" :key="q.id" size="small" hoverable class="rounded-xl">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<NTag size="small" type="primary" :bordered="false">
|
||||||
|
ID: {{ q.id }}
|
||||||
|
</NTag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #header-extra>
|
||||||
|
<span class="text-xs text-gray-400 font-mono">{{ q.score }} PTS / {{ q.time }} S</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="py-2 text-base text-gray-700 font-medium">
|
||||||
|
{{ q.content }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 标准答案 -->
|
||||||
|
<div class="mt-3 border border-green-100 rounded-lg bg-green-50 p-3">
|
||||||
|
<div class="mb-1 text-xs text-green-600 font-bold tracking-wider uppercase">
|
||||||
|
STANDARD ANSWER
|
||||||
|
</div>
|
||||||
|
<div class="text-green-800 font-bold">
|
||||||
|
{{ q.answer }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<template #action>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<NButton size="tiny" quaternary type="primary" @click="handleEditQuestion(q.id)">
|
||||||
|
<template #icon>
|
||||||
|
<SvgIcon icon="carbon:edit" />
|
||||||
|
</template>
|
||||||
|
</NButton>
|
||||||
|
<NButton size="tiny" quaternary type="error" @click="handleDeleteQuestion(q.id)">
|
||||||
|
<template #icon>
|
||||||
|
<SvgIcon icon="carbon:trash-can" />
|
||||||
|
</template>
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Question Drawer -->
|
||||||
|
<NDrawer v-model:show="showQuestionModal" :width="800">
|
||||||
|
<NDrawerContent :title="questionOperation === 'edit' ? '编辑题目详情' : '新增题目详情'">
|
||||||
|
<NForm label-placement="top">
|
||||||
|
<NFormItem label="题目正文内容">
|
||||||
|
<div class="w-full border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<!-- <RestBasicEditor v-model="questionForm.content" /> -->
|
||||||
|
<NInput type="textarea" v-model:value="questionForm.content" class="w-full h-64" />
|
||||||
|
</div>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<NFormItem label="参考分值 (Pts)">
|
||||||
|
<NInputNumber v-model:value="questionForm.score" class="w-full" :min="1" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="限时 (S)">
|
||||||
|
<NInputNumber v-model:value="questionForm.time" class="w-full" :min="1" />
|
||||||
|
</NFormItem>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NFormItem label="参考标准答案">
|
||||||
|
<NInput v-model:value="questionForm.answer" placeholder="正确答案" />
|
||||||
|
</NFormItem>
|
||||||
|
</NForm>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<NButton @click="showQuestionModal = false">
|
||||||
|
取消并返回
|
||||||
|
</NButton>
|
||||||
|
<NButton type="primary" @click="submitQuestion">
|
||||||
|
保存并入库
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NDrawerContent>
|
||||||
|
</NDrawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -1,548 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { TreeOption } from 'naive-ui'
|
|
||||||
import {
|
|
||||||
NBreadcrumb,
|
|
||||||
NBreadcrumbItem,
|
|
||||||
NButton,
|
|
||||||
NCard,
|
|
||||||
NEmpty,
|
|
||||||
NForm,
|
|
||||||
NFormItem,
|
|
||||||
NInput,
|
|
||||||
NInputNumber,
|
|
||||||
NModal,
|
|
||||||
NTag,
|
|
||||||
NTree,
|
|
||||||
useDialog,
|
|
||||||
useMessage,
|
|
||||||
} from 'naive-ui'
|
|
||||||
import { computed, h, ref } from 'vue'
|
|
||||||
import SvgIcon from '@/components/custom/svg-icon.vue'
|
|
||||||
|
|
||||||
// Mock Data - 模拟分类树数据
|
|
||||||
const treeData = ref<TreeOption[]>([
|
|
||||||
{
|
|
||||||
key: 'root',
|
|
||||||
label: '汉字听写大赛',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
key: 'stage-1',
|
|
||||||
label: '第一阶段:基础训练',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
key: 'q-type-1',
|
|
||||||
label: '根据提示书写汉字',
|
|
||||||
isLeaf: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'q-type-2',
|
|
||||||
label: '根据拼音书写汉字',
|
|
||||||
isLeaf: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'stage-2',
|
|
||||||
label: '第二阶段:进阶比拼',
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'poem',
|
|
||||||
label: '古诗词大会',
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
// Mock Data - 模拟题目列表数据
|
|
||||||
const questionList = ref([
|
|
||||||
{
|
|
||||||
id: 'Q-0325',
|
|
||||||
content: '“接天莲叶无穷碧,映日荷花别样红”。请书写“碧”字。',
|
|
||||||
answer: '碧',
|
|
||||||
score: 10,
|
|
||||||
time: 30,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'Q-0326',
|
|
||||||
content: '“春色满园关不住,一枝红杏出墙来”。请书写“杏”字。',
|
|
||||||
answer: '杏',
|
|
||||||
score: 10,
|
|
||||||
time: 30,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
const selectedKeys = ref<string[]>([])
|
|
||||||
const expandedKeys = ref<string[]>(['root', 'stage-1'])
|
|
||||||
const searchText = ref('')
|
|
||||||
const message = useMessage()
|
|
||||||
const dialog = useDialog()
|
|
||||||
|
|
||||||
// Modal State
|
|
||||||
const showCategoryModal = ref(false)
|
|
||||||
const categoryModalType = ref<1 | 2>(1) // 1级或2级分类
|
|
||||||
const categoryForm = ref({ name: '' })
|
|
||||||
|
|
||||||
// 记录当前操作类型:add-root, add-child, edit
|
|
||||||
const categoryOperation = ref<'add-root' | 'add-child' | 'edit'>('add-root')
|
|
||||||
// 记录当前操作的目标节点(编辑时为该节点,添加子节点时为父节点)
|
|
||||||
const currentOperationNode = ref<TreeOption | null>(null)
|
|
||||||
|
|
||||||
const showQuestionModal = ref(false)
|
|
||||||
const questionForm = ref({
|
|
||||||
content: '',
|
|
||||||
answer: '',
|
|
||||||
score: 10,
|
|
||||||
time: 30,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 递归查找节点
|
|
||||||
function findNodeByKey(key: string, nodes: TreeOption[]): TreeOption | null {
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (node.key === key)
|
|
||||||
return node
|
|
||||||
if (node.children) {
|
|
||||||
const found = findNodeByKey(key, node.children)
|
|
||||||
if (found)
|
|
||||||
return found
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentCategory = computed(() => {
|
|
||||||
if (!selectedKeys.value.length)
|
|
||||||
return null
|
|
||||||
const key = selectedKeys.value[0]
|
|
||||||
const node = findNodeByKey(key, treeData.value)
|
|
||||||
if (node) {
|
|
||||||
// 假设没有 children 的就是叶子节点,level 简单判定为 3 (实际应该根据深度)
|
|
||||||
// 这里为了兼容之前的逻辑,如果有 isLeaf 属性则视为 3 级
|
|
||||||
return {
|
|
||||||
label: node.label,
|
|
||||||
level: node.isLeaf ? 3 : 1, // 简化逻辑,仅用于显示和判断是否可添加题目
|
|
||||||
key: node.key,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
|
|
||||||
const isLeafSelected = computed(() => {
|
|
||||||
if (!currentCategory.value)
|
|
||||||
return false
|
|
||||||
// 查找实际节点判断是否有 children
|
|
||||||
const node = findNodeByKey(currentCategory.value.key as string, treeData.value)
|
|
||||||
return !!node?.isLeaf || (node?.children && node.children.length === 0 && currentCategory.value.level === 3)
|
|
||||||
})
|
|
||||||
|
|
||||||
const filteredQuestions = computed(() => {
|
|
||||||
if (!searchText.value)
|
|
||||||
return questionList.value
|
|
||||||
return questionList.value.filter(q => q.content.includes(searchText.value))
|
|
||||||
})
|
|
||||||
|
|
||||||
// 打开新增根节点弹窗
|
|
||||||
function handleAddRootCategory() {
|
|
||||||
categoryOperation.value = 'add-root'
|
|
||||||
categoryModalType.value = 1
|
|
||||||
categoryForm.value.name = ''
|
|
||||||
currentOperationNode.value = null
|
|
||||||
showCategoryModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 打开新增子节点弹窗
|
|
||||||
function handleAddChildCategory(parentNode: TreeOption) {
|
|
||||||
categoryOperation.value = 'add-child'
|
|
||||||
categoryModalType.value = 2 // 视为下一级
|
|
||||||
categoryForm.value.name = ''
|
|
||||||
currentOperationNode.value = parentNode
|
|
||||||
showCategoryModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 打开编辑节点弹窗
|
|
||||||
function handleEditCategory(node: TreeOption) {
|
|
||||||
categoryOperation.value = 'edit'
|
|
||||||
categoryForm.value.name = node.label as string
|
|
||||||
currentOperationNode.value = node
|
|
||||||
showCategoryModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 递归删除节点 如果节点有子节点,无法删除
|
|
||||||
function deleteNode(nodes: TreeOption[], key: string | number): boolean {
|
|
||||||
const index = nodes.findIndex(n => n.key === key)
|
|
||||||
if (index !== -1) {
|
|
||||||
// 检查是否有子节点
|
|
||||||
if (nodes[index].children && nodes[index].children.length > 0) {
|
|
||||||
// message.warning('该分类下有子分类,无法删除')
|
|
||||||
throw new Error('该分类下有子分类,无法删除')
|
|
||||||
}
|
|
||||||
nodes.splice(index, 1)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (node.children) {
|
|
||||||
if (deleteNode(node.children, key))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDeleteCategory(node: TreeOption) {
|
|
||||||
dialog.warning({
|
|
||||||
title: '警告',
|
|
||||||
content: `确定要删除分类 "${node.label}" 吗?此操作无法撤销。`,
|
|
||||||
positiveText: '确定',
|
|
||||||
negativeText: '取消',
|
|
||||||
onPositiveClick: () => {
|
|
||||||
try {
|
|
||||||
deleteNode(treeData.value, node.key!)
|
|
||||||
// 如果删除的是当前选中的节点,清空选中
|
|
||||||
if (selectedKeys.value.includes(node.key as string)) {
|
|
||||||
selectedKeys.value = []
|
|
||||||
}
|
|
||||||
message.success('删除成功')
|
|
||||||
}
|
|
||||||
catch (error: any) {
|
|
||||||
message.error(error.message || '删除失败')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAddQuestion() {
|
|
||||||
if (!currentCategory.value) {
|
|
||||||
message.warning('请先选择一个分类')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
questionForm.value = { content: '', answer: '', score: 10, time: 30 }
|
|
||||||
showQuestionModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitCategory() {
|
|
||||||
if (!categoryForm.value.name) {
|
|
||||||
message.error('请输入分类名称')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (categoryOperation.value === 'add-root') {
|
|
||||||
// 新增根节点
|
|
||||||
const newKey = `root-${Date.now()}`
|
|
||||||
treeData.value.push({
|
|
||||||
key: newKey,
|
|
||||||
label: categoryForm.value.name,
|
|
||||||
children: [],
|
|
||||||
})
|
|
||||||
message.success('分类添加成功')
|
|
||||||
}
|
|
||||||
else if (categoryOperation.value === 'add-child' && currentOperationNode.value) {
|
|
||||||
// 新增子节点
|
|
||||||
if (!currentOperationNode.value.children) {
|
|
||||||
currentOperationNode.value.children = []
|
|
||||||
}
|
|
||||||
const newKey = `node-${Date.now()}`
|
|
||||||
// 如果是第三级(叶子),标记 isLeaf
|
|
||||||
// 这里简单逻辑:如果有 children 数组则不是 leaf,但在 UI 上我们允许无限层级,
|
|
||||||
// 为了匹配题目管理逻辑,我们假设用户手动添加的最后一级可以作为叶子
|
|
||||||
currentOperationNode.value.children.push({
|
|
||||||
key: newKey,
|
|
||||||
label: categoryForm.value.name,
|
|
||||||
// 可以在这里根据业务逻辑决定是否初始化 children,或者默认为叶子节点
|
|
||||||
// 这里暂定新添加的子节点如果有下一级需求再添加 children,否则视为叶子
|
|
||||||
isLeaf: true,
|
|
||||||
})
|
|
||||||
// 展开父节点
|
|
||||||
if (!expandedKeys.value.includes(currentOperationNode.value.key as string)) {
|
|
||||||
expandedKeys.value.push(currentOperationNode.value.key as string)
|
|
||||||
}
|
|
||||||
message.success('子分类添加成功')
|
|
||||||
}
|
|
||||||
else if (categoryOperation.value === 'edit' && currentOperationNode.value) {
|
|
||||||
// 编辑节点
|
|
||||||
currentOperationNode.value.label = categoryForm.value.name
|
|
||||||
message.success('分类修改成功')
|
|
||||||
}
|
|
||||||
|
|
||||||
showCategoryModal.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitQuestion() {
|
|
||||||
if (!questionForm.value.content || !questionForm.value.answer) {
|
|
||||||
message.error('请填写完整信息')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Mock add
|
|
||||||
questionList.value.push({
|
|
||||||
id: `Q-${Math.floor(Math.random() * 10000)}`,
|
|
||||||
...questionForm.value,
|
|
||||||
})
|
|
||||||
message.success('题目添加成功')
|
|
||||||
showQuestionModal.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tree Rendering
|
|
||||||
function renderPrefix({ option }: { option: TreeOption }) {
|
|
||||||
// 根据层级或类型显示不同图标
|
|
||||||
if (option.children && option.children.length > 0) {
|
|
||||||
return h(SvgIcon, { icon: 'carbon:folder', class: 'text-gray-400 text-lg' })
|
|
||||||
}
|
|
||||||
// 如果明确标记为叶子节点,或者没有 children
|
|
||||||
return h(SvgIcon, { icon: 'carbon:document', class: 'text-gray-400 text-lg' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSuffix({ option }: { option: TreeOption }) {
|
|
||||||
// 悬浮时显示操作按钮
|
|
||||||
return h(
|
|
||||||
'div',
|
|
||||||
{
|
|
||||||
class: 'flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity',
|
|
||||||
onClick: (e: Event) => e.stopPropagation(),
|
|
||||||
},
|
|
||||||
[
|
|
||||||
h('div', {
|
|
||||||
class: 'text-gray-400 hover:text-blue-500 cursor-pointer flex items-center',
|
|
||||||
title: '编辑',
|
|
||||||
onClick: (e: Event) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleEditCategory(option)
|
|
||||||
},
|
|
||||||
}, [h(SvgIcon, { icon: 'carbon:edit', class: 'text-lg' })]),
|
|
||||||
// 允许所有节点添加子节点,如果添加了子节点,它就变成文件夹
|
|
||||||
h('div', {
|
|
||||||
class: 'text-gray-400 hover:text-green-500 cursor-pointer flex items-center',
|
|
||||||
title: '添加子分类',
|
|
||||||
onClick: (e: Event) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleAddChildCategory(option)
|
|
||||||
},
|
|
||||||
}, [h(SvgIcon, { icon: 'carbon:add', class: 'text-lg' })]),
|
|
||||||
h('div', {
|
|
||||||
class: 'text-gray-400 hover:text-red-500 cursor-pointer flex items-center',
|
|
||||||
title: '删除',
|
|
||||||
onClick: (e: Event) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleDeleteCategory(option)
|
|
||||||
},
|
|
||||||
}, [h(SvgIcon, { icon: 'carbon:trash-can', class: 'text-lg' })]),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="h-full flex overflow-hidden border border-gray-100 rounded-2xl bg-white shadow-sm">
|
|
||||||
<!-- Sidebar -->
|
|
||||||
<div class="w-100 flex flex-col border-r border-gray-100 bg-gray-50/30">
|
|
||||||
<div class="flex items-center justify-between border-b border-gray-100 px-4 py-4">
|
|
||||||
<span class="text-lg text-gray-700 font-bold">分类导航树</span>
|
|
||||||
<NButton size="tiny" secondary type="primary" @click="handleAddRootCategory">
|
|
||||||
<template #icon>
|
|
||||||
<icon-ic-baseline-add class="text-icon" />
|
|
||||||
</template>
|
|
||||||
</NButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto py-2">
|
|
||||||
<NTree
|
|
||||||
block-line :data="treeData" :selected-keys="selectedKeys" :expanded-keys="expandedKeys"
|
|
||||||
:render-prefix="renderPrefix" :render-suffix="renderSuffix" selectable expand-on-click class="px-2"
|
|
||||||
@update:selected-keys="(keys) => (selectedKeys = keys)"
|
|
||||||
@update:expanded-keys="(keys) => (expandedKeys = keys)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border-t border-gray-100 bg-gray-50 p-3 text-xs text-gray-400">
|
|
||||||
提示:请勿随意删除维护原有分类,三级分类为最终题目目录。
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main Content -->
|
|
||||||
<div class="h-full flex flex-col flex-1 overflow-hidden bg-white">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="flex flex-col gap-4 border-b border-gray-100 px-6 py-4">
|
|
||||||
<NBreadcrumb>
|
|
||||||
<NBreadcrumbItem>题库全集</NBreadcrumbItem>
|
|
||||||
<NBreadcrumbItem v-if="currentCategory && currentCategory.level === 1">
|
|
||||||
{{ currentCategory.label }}
|
|
||||||
</NBreadcrumbItem>
|
|
||||||
</NBreadcrumb>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h1 class="m-0 text-xl text-gray-800 font-bold">
|
|
||||||
{{ currentCategory ? currentCategory.label : '' }}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Toolbar -->
|
|
||||||
<div v-if="currentCategory && currentCategory.level === 3" class="mt-2 flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<NInput v-model:value="searchText" placeholder="输入题目关键字在当前分类下搜索..." class="!w-80">
|
|
||||||
<template #prefix>
|
|
||||||
<SvgIcon icon="carbon:search" class="text-gray-400" />
|
|
||||||
</template>
|
|
||||||
</NInput>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NButton type="primary" @click="handleAddQuestion">
|
|
||||||
<template #icon>
|
|
||||||
<SvgIcon icon="carbon:add" />
|
|
||||||
</template>
|
|
||||||
新增题目
|
|
||||||
</NButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<div class="flex-1 overflow-y-auto bg-gray-50/50 p-6">
|
|
||||||
<template v-if="!isLeafSelected">
|
|
||||||
<div class="h-full flex flex-col items-center justify-center text-gray-400">
|
|
||||||
<NEmpty description="暂无数据">
|
|
||||||
<template #extra>
|
|
||||||
请从左侧选择一个最后一级(三级)分类以管理题目数据
|
|
||||||
</template>
|
|
||||||
</NEmpty>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="filteredQuestions.length === 0">
|
|
||||||
<div class="mt-20 flex justify-center">
|
|
||||||
<NEmpty description="该分类下暂无题目" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
|
||||||
<NCard v-for="q in filteredQuestions" :key="q.id" size="small" hoverable class="rounded-xl">
|
|
||||||
<template #header>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<NTag size="small" type="primary" :bordered="false">
|
|
||||||
ID: {{ q.id }}
|
|
||||||
</NTag>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #header-extra>
|
|
||||||
<span class="text-xs text-gray-400 font-mono">{{ q.score }} PTS / {{ q.time }} S</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="py-2 text-base text-gray-700 font-medium">
|
|
||||||
{{ q.content }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3 border border-green-100 rounded-lg bg-green-50 p-3">
|
|
||||||
<div class="mb-1 text-xs text-green-600 font-bold tracking-wider uppercase">
|
|
||||||
STANDARD ANSWER
|
|
||||||
</div>
|
|
||||||
<div class="text-green-800 font-bold">
|
|
||||||
{{ q.answer }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template #action>
|
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<NButton size="tiny" quaternary type="primary">
|
|
||||||
<template #icon>
|
|
||||||
<SvgIcon icon="carbon:edit" />
|
|
||||||
</template>
|
|
||||||
</NButton>
|
|
||||||
<NButton size="tiny" quaternary type="error">
|
|
||||||
<template #icon>
|
|
||||||
<SvgIcon icon="carbon:trash-can" />
|
|
||||||
</template>
|
|
||||||
</NButton>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</NCard>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Modals -->
|
|
||||||
<!-- Add/Edit Category Modal -->
|
|
||||||
<NModal
|
|
||||||
v-model:show="showCategoryModal" preset="card"
|
|
||||||
:title="categoryOperation === 'edit' ? '编辑分类' : `新增 ${categoryModalType} 级分类`" class="w-[500px]"
|
|
||||||
>
|
|
||||||
<NForm>
|
|
||||||
<NFormItem label="分类显示名称">
|
|
||||||
<NInput v-model:value="categoryForm.name" placeholder="请输入内容" />
|
|
||||||
</NFormItem>
|
|
||||||
</NForm>
|
|
||||||
<template #footer>
|
|
||||||
<div class="flex justify-end gap-3">
|
|
||||||
<NButton @click="showCategoryModal = false">
|
|
||||||
取消操作
|
|
||||||
</NButton>
|
|
||||||
<NButton type="primary" @click="submitCategory">
|
|
||||||
确认提交
|
|
||||||
</NButton>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</NModal>
|
|
||||||
|
|
||||||
<!-- Add Question Modal -->
|
|
||||||
<NModal v-model:show="showQuestionModal" preset="card" title="新增题目详情" class="w-[600px]">
|
|
||||||
<NForm label-placement="top">
|
|
||||||
<NFormItem label="题目正文内容">
|
|
||||||
<NInput
|
|
||||||
v-model:value="questionForm.content" type="textarea" placeholder="在此输入题目文本,例如:'大漠孤烟直,长河落日圆'。"
|
|
||||||
:rows="3"
|
|
||||||
/>
|
|
||||||
</NFormItem>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<NFormItem label="参考分值 (Pts)">
|
|
||||||
<NInputNumber v-model:value="questionForm.score" class="w-full" :min="1" />
|
|
||||||
</NFormItem>
|
|
||||||
<NFormItem label="限时 (S)">
|
|
||||||
<NInputNumber v-model:value="questionForm.time" class="w-full" :min="1" />
|
|
||||||
</NFormItem>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NFormItem label="参考标准答案">
|
|
||||||
<NInput v-model:value="questionForm.answer" placeholder="正确答案" />
|
|
||||||
</NFormItem>
|
|
||||||
</NForm>
|
|
||||||
<template #footer>
|
|
||||||
<div class="flex justify-end gap-3">
|
|
||||||
<NButton @click="showQuestionModal = false">
|
|
||||||
取消并返回
|
|
||||||
</NButton>
|
|
||||||
<NButton type="primary" @click="submitQuestion">
|
|
||||||
保存并入库
|
|
||||||
</NButton>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</NModal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
:deep(.n-tree-node-content__text) {
|
|
||||||
font-weight: 500;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.n-tree-node--selected) {
|
|
||||||
background-color: #eff6ff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.n-tree-node--selected .n-tree-node-content__text) {
|
|
||||||
color: #3b82f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure icons in tree are visible on hover */
|
|
||||||
:deep(.n-tree-node-content:hover .group-hover\:opacity-100) {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom group class for tree node content wrapper to handle hover state */
|
|
||||||
:deep(.n-tree-node-content) {
|
|
||||||
@apply group;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
154
apps/admin/src/views/rank/rank-detail/index.vue
Normal file
154
apps/admin/src/views/rank/rank-detail/index.vue
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
<script setup lang="tsx">
|
||||||
|
import { NCard, NDivider } from 'naive-ui'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import RankHeader from './modules/rank-header.vue'
|
||||||
|
import TeamItem from './modules/team-item.vue'
|
||||||
|
import TeamListHeader from './modules/team-list-header.vue'
|
||||||
|
|
||||||
|
const competitionInfo = {
|
||||||
|
name: '阅读之星-辞海遨游环节 (2026)',
|
||||||
|
date: '2026.01.12',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamData {
|
||||||
|
id: number
|
||||||
|
rank: number
|
||||||
|
name: string
|
||||||
|
group: string
|
||||||
|
correctCount: string
|
||||||
|
totalScore: number
|
||||||
|
updateTime: string
|
||||||
|
isExpanded: boolean
|
||||||
|
scores: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamList = ref<TeamData[]>([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
rank: 1,
|
||||||
|
name: '清华学霸团',
|
||||||
|
group: '第一组',
|
||||||
|
correctCount: '15 / 15',
|
||||||
|
totalScore: 150,
|
||||||
|
updateTime: '2026-01-22 16:28:55',
|
||||||
|
isExpanded: true,
|
||||||
|
scores: {
|
||||||
|
q1: 10,
|
||||||
|
q2: 10,
|
||||||
|
q3: 10,
|
||||||
|
q4: 10,
|
||||||
|
q5: 10,
|
||||||
|
q6: 10,
|
||||||
|
q7: 10,
|
||||||
|
q8: 5,
|
||||||
|
q9: 10,
|
||||||
|
q10: 10,
|
||||||
|
q11: 15,
|
||||||
|
q12: 10,
|
||||||
|
q13: 15,
|
||||||
|
q14: 10,
|
||||||
|
q15: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
rank: 2,
|
||||||
|
name: '无敌先锋队',
|
||||||
|
group: '第一组',
|
||||||
|
correctCount: '13 / 15',
|
||||||
|
totalScore: 145,
|
||||||
|
updateTime: '2026-01-22 16:30:12',
|
||||||
|
isExpanded: false,
|
||||||
|
scores: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
rank: 3,
|
||||||
|
name: '明日之星社',
|
||||||
|
group: '第二组',
|
||||||
|
correctCount: '13 / 15',
|
||||||
|
totalScore: 130,
|
||||||
|
updateTime: '2026-01-22 16:25:22',
|
||||||
|
isExpanded: false,
|
||||||
|
scores: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
rank: 4,
|
||||||
|
name: '火箭竞技团',
|
||||||
|
group: '第一组',
|
||||||
|
correctCount: '13 / 15',
|
||||||
|
totalScore: 125,
|
||||||
|
updateTime: '2026-01-22 16:22:10',
|
||||||
|
isExpanded: false,
|
||||||
|
scores: {},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
function toggleExpand(team: TeamData) {
|
||||||
|
// Collapse others if needed, or allow multiple. Screenshot shows one.
|
||||||
|
// Let's toggle.
|
||||||
|
team.isExpanded = !team.isExpanded
|
||||||
|
|
||||||
|
// If expanding, maybe populate scores if empty (mock logic)
|
||||||
|
if (team.isExpanded && Object.keys(team.scores).length === 0) {
|
||||||
|
for (let i = 1; i <= 15; i++) {
|
||||||
|
team.scores[`q${i}`] = 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveScores(team: TeamData) {
|
||||||
|
window.$message?.success(`已保存 ${team.name} 的分数变动`)
|
||||||
|
team.isExpanded = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePublish() {
|
||||||
|
window.$message?.success('本场榜单发布成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScore(team: TeamData, key: string, value: number | null) {
|
||||||
|
if (value !== null) {
|
||||||
|
team.scores[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
|
||||||
|
<NCard :bordered="false" size="small" class="card-wrapper sm:flex-1-hidden">
|
||||||
|
<!-- Header -->
|
||||||
|
<RankHeader
|
||||||
|
:title="competitionInfo.name"
|
||||||
|
:date="competitionInfo.date"
|
||||||
|
@publish="handlePublish"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NDivider />
|
||||||
|
|
||||||
|
<!-- List Header -->
|
||||||
|
<TeamListHeader />
|
||||||
|
|
||||||
|
<!-- Custom Table / List -->
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<!-- Data Rows -->
|
||||||
|
<TeamItem
|
||||||
|
v-for="team in teamList"
|
||||||
|
:key="team.id"
|
||||||
|
:team="team"
|
||||||
|
@toggle-expand="toggleExpand"
|
||||||
|
@save-scores="saveScores"
|
||||||
|
@update-score="updateScore"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.n-input-number .n-input__input-el) {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { NButton } from 'naive-ui'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string
|
||||||
|
date: string
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'publish'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<NButton text class="mt-1 text-24px" @click="goBack">
|
||||||
|
<template #icon>
|
||||||
|
<icon-ic-round-arrow-back class="text-icon" />
|
||||||
|
</template>
|
||||||
|
</NButton>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-20px font-bold">
|
||||||
|
{{ title }}
|
||||||
|
</h1>
|
||||||
|
<div class="mt-1 flex items-center gap-2 text-gray-500">
|
||||||
|
<icon-ic-round-access-time class="text-icon" />
|
||||||
|
<span>活动日期:{{ date }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NButton type="primary" class="px-6" @click="emit('publish')">
|
||||||
|
发布本场榜单
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
129
apps/admin/src/views/rank/rank-detail/modules/team-item.vue
Normal file
129
apps/admin/src/views/rank/rank-detail/modules/team-item.vue
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { NButton, NGrid, NGridItem, NInputNumber } from 'naive-ui'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
interface TeamData {
|
||||||
|
id: number
|
||||||
|
rank: number
|
||||||
|
name: string
|
||||||
|
group: string
|
||||||
|
correctCount: string
|
||||||
|
totalScore: number
|
||||||
|
updateTime: string
|
||||||
|
isExpanded: boolean
|
||||||
|
scores: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
team: TeamData
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'toggleExpand', team: TeamData): void
|
||||||
|
(e: 'saveScores', team: TeamData): void
|
||||||
|
(e: 'updateScore', team: TeamData, key: string, value: number | null): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const rankStyle = computed(() => {
|
||||||
|
const rank = props.team.rank
|
||||||
|
if (rank === 1)
|
||||||
|
return { backgroundColor: '#f59e0b', color: '#fff', border: 'none' }
|
||||||
|
if (rank === 2)
|
||||||
|
return { backgroundColor: '#9ca3af', color: '#fff', border: 'none' }
|
||||||
|
if (rank === 3)
|
||||||
|
return { backgroundColor: '#d97706', color: '#fff', border: 'none' }
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="border rounded-lg transition-all duration-200"
|
||||||
|
:class="team.isExpanded ? 'border-primary bg-blue-50/10' : 'border-gray-100 hover:border-gray-300'"
|
||||||
|
>
|
||||||
|
<!-- Main Row -->
|
||||||
|
<div class="grid grid-cols-[80px_200px_150px_150px_150px_200px_auto] items-center gap-4 px-4 py-4">
|
||||||
|
<!-- Rank -->
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="h-8 w-8 flex items-center justify-center rounded-full text-14px font-bold"
|
||||||
|
:style="rankStyle"
|
||||||
|
>
|
||||||
|
{{ team.rank }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Name -->
|
||||||
|
<div class="text-15px font-bold">
|
||||||
|
{{ team.name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Group -->
|
||||||
|
<div class="text-gray-500">
|
||||||
|
{{ team.group }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Correct Count -->
|
||||||
|
<div class="font-bold font-mono">
|
||||||
|
{{ team.correctCount }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Score -->
|
||||||
|
<div class="text-16px text-primary font-bold">
|
||||||
|
{{ team.totalScore }} Pts
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Time -->
|
||||||
|
<div class="text-13px text-gray-400">
|
||||||
|
{{ team.updateTime }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action -->
|
||||||
|
<div class="text-right">
|
||||||
|
<NButton v-if="team.isExpanded" type="primary" size="small" @click="emit('saveScores', team)">
|
||||||
|
完成核对
|
||||||
|
</NButton>
|
||||||
|
<NButton v-else size="small" @click="emit('toggleExpand', team)">
|
||||||
|
详情/修正分数
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expanded Content (Score Grid) -->
|
||||||
|
<div v-if="team.isExpanded" class="mx-1 mb-1 border-t border-gray-100 rounded-b-lg bg-white px-6 pb-6 pt-2">
|
||||||
|
<div class="mb-4 text-13px text-gray-500 font-bold uppercase">
|
||||||
|
Question Breakdown For {{ team.name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NGrid :x-gap="16" :y-gap="16" :cols="5">
|
||||||
|
<NGridItem v-for="i in 15" :key="i">
|
||||||
|
<div class="border rounded bg-white p-3">
|
||||||
|
<div class="mb-2 flex justify-between text-12px text-gray-400">
|
||||||
|
<span>Q{{ i }} SCORE</span>
|
||||||
|
<span>/ 10</span>
|
||||||
|
</div>
|
||||||
|
<NInputNumber
|
||||||
|
:value="team.scores[`q${i}`]" :min="0" :max="10" button-placement="both"
|
||||||
|
class="text-center text-primary font-bold"
|
||||||
|
@update:value="(val) => emit('updateScore', team, `q${i}`, val)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</NGridItem>
|
||||||
|
</NGrid>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end">
|
||||||
|
<NButton type="primary" class="w-120px" @click="emit('saveScores', team)">
|
||||||
|
保存变动并收起
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.n-input-number .n-input__input-el) {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { NTooltip } from 'naive-ui'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="text-14px text-gray-500 font-bold tracking-wide uppercase">
|
||||||
|
实时排行数据与修正 (LIVE CORRECTION)
|
||||||
|
</div>
|
||||||
|
<div class="text-12px text-red-500">
|
||||||
|
* 修改题目得分后,总分与名次将实时重算
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-[80px_200px_150px_150px_150px_200px_auto] gap-4 bg-gray-50 px-4 py-2 text-13px text-gray-600 font-bold"
|
||||||
|
>
|
||||||
|
<div>排名</div>
|
||||||
|
<div>队伍名称</div>
|
||||||
|
<div>所属小组</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
正确题数
|
||||||
|
<NTooltip placement="top" trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<span>
|
||||||
|
<SvgIcon icon="mdi:information-variant" class="text-xl text-yellow-500" />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
做题数量因加时赛可能有所不同
|
||||||
|
</NTooltip>
|
||||||
|
</div>
|
||||||
|
<div>总积分 (Total)</div>
|
||||||
|
<div>更新时间</div>
|
||||||
|
<div class="text-right">
|
||||||
|
操作
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
175
apps/admin/src/views/rank/rank-list/index.vue
Normal file
175
apps/admin/src/views/rank/rank-list/index.vue
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
<script setup lang="tsx">
|
||||||
|
import { reactive, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { NButton, NTag, NCard, NSpace } from 'naive-ui';
|
||||||
|
import { fetchGetUserList } from '@/service/api';
|
||||||
|
import { useAppStore } from '@/store/modules/app';
|
||||||
|
import { defaultTransform, useNaivePaginatedTable, useTableOperate } from '@/hooks/common/table';
|
||||||
|
import RankSearch from './modules/rank-search.vue';
|
||||||
|
|
||||||
|
const appStore = useAppStore();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const searchParams: Api.SystemManage.UserSearchParams = reactive({
|
||||||
|
current: 1,
|
||||||
|
size: 10,
|
||||||
|
status: null,
|
||||||
|
userName: null,
|
||||||
|
userGender: null,
|
||||||
|
nickName: null,
|
||||||
|
userPhone: null,
|
||||||
|
userEmail: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const rankTitle = '排行榜管理列表';
|
||||||
|
|
||||||
|
const statusMap: Record<string, string> = {
|
||||||
|
1: '已发布',
|
||||||
|
2: '待审核'
|
||||||
|
};
|
||||||
|
|
||||||
|
const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagination } = useNaivePaginatedTable({
|
||||||
|
api: () => fetchGetUserList(searchParams),
|
||||||
|
transform: response => {
|
||||||
|
const transformed = defaultTransform(response);
|
||||||
|
// Mocking data for Rank Management based on User API response
|
||||||
|
const mockCompetitions = ['阅读之星-辞海遨游环节 (2026)', '古诗词大会年度精英巅峰赛', '趣味百科常识挑战周'];
|
||||||
|
const mockDates = ['2026.01.12', '2026.01.15', '2026.01.18'];
|
||||||
|
const mockScales = ['40 支队伍', '24 支队伍', '60 支队伍'];
|
||||||
|
const mockScores = ['145', '180', '120'];
|
||||||
|
const mockTimes = ['2026-01-22 16:30:12', '2026-01-22 12:00:00', '2026-01-21 18:22:45'];
|
||||||
|
|
||||||
|
transformed.data.forEach((item, index) => {
|
||||||
|
const mockIndex = index % 3;
|
||||||
|
item.userName = mockCompetitions[mockIndex]; // Competition Name
|
||||||
|
item.userEmail = mockDates[mockIndex]; // Activity Date (using userEmail field)
|
||||||
|
item.userPhone = mockScales[mockIndex]; // Team Scale (using userPhone field)
|
||||||
|
item.nickName = mockScores[mockIndex]; // Max Score (using nickName field)
|
||||||
|
// item.createTime is typically available, we'll simulate update time
|
||||||
|
item.createTime = mockTimes[mockIndex];
|
||||||
|
// Status: 1 (Published), 2 (Pending)
|
||||||
|
item.status = (index % 2 === 0) ? '2' : '1';
|
||||||
|
});
|
||||||
|
return transformed;
|
||||||
|
},
|
||||||
|
onPaginationParamsChange: params => {
|
||||||
|
searchParams.current = params.page;
|
||||||
|
searchParams.size = params.pageSize;
|
||||||
|
},
|
||||||
|
columns: () => [
|
||||||
|
{
|
||||||
|
type: 'selection',
|
||||||
|
align: 'center',
|
||||||
|
width: 48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'userName',
|
||||||
|
title: '比赛活动名称',
|
||||||
|
align: 'left',
|
||||||
|
minWidth: 200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'userEmail',
|
||||||
|
title: '活动日期',
|
||||||
|
align: 'center',
|
||||||
|
minWidth: 120
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'userPhone',
|
||||||
|
title: '队伍规模',
|
||||||
|
align: 'center',
|
||||||
|
minWidth: 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'nickName',
|
||||||
|
title: '最高积分',
|
||||||
|
align: 'center',
|
||||||
|
minWidth: 100,
|
||||||
|
render: row => (
|
||||||
|
<span class="text-primary font-bold">{row.nickName} Pts</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'createTime',
|
||||||
|
title: '最后更新时间',
|
||||||
|
align: 'center',
|
||||||
|
minWidth: 160
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
title: '发布状态',
|
||||||
|
align: 'center',
|
||||||
|
width: 100,
|
||||||
|
render: row => {
|
||||||
|
if (row.status === null) return null;
|
||||||
|
const label = statusMap[row.status] || '未知';
|
||||||
|
// 1: Published (Success/Green), 2: Pending (Warning/Orange)
|
||||||
|
return <NTag type={row.status === '1' ? 'success' : 'warning'}>{label}</NTag>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'operate',
|
||||||
|
title: '操作',
|
||||||
|
align: 'center',
|
||||||
|
width: 120,
|
||||||
|
render: row => (
|
||||||
|
<div class="flex-center">
|
||||||
|
<NButton type="primary" text onClick={() => edit(row.id)}>
|
||||||
|
进入排行详情
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
drawerVisible,
|
||||||
|
operateType,
|
||||||
|
editingData,
|
||||||
|
handleEdit,
|
||||||
|
checkedRowKeys,
|
||||||
|
onBatchDeleted,
|
||||||
|
} = useTableOperate(data, 'id', getData);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function edit(id: number) {
|
||||||
|
router.push({ name: 'rank_rank-detail', query: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBatchPublish() {
|
||||||
|
window.$message?.success('批量发布成功');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
|
||||||
|
<RankSearch v-model:model="searchParams" @search="getDataByPage" />
|
||||||
|
<NCard :bordered="false" size="small" class="card-wrapper sm:flex-1-hidden">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="text-16px font-bold">{{ rankTitle }}</div>
|
||||||
|
<div class="text-12px text-gray-500 mt-4px">您可以对所有比赛场次的最终积分进行核对、修正,并正式发布结果。</div>
|
||||||
|
</div>
|
||||||
|
<NButton type="primary" ghost class="ml-auto" @click="handleBatchPublish">
|
||||||
|
<template #icon>
|
||||||
|
<icon-ic-round-upload class="text-icon" />
|
||||||
|
</template>
|
||||||
|
批量发布至终端
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<!-- <template #header-extra>
|
||||||
|
<TableHeaderOperation v-model:columns="columnChecks" :disabled-delete="checkedRowKeys.length === 0"
|
||||||
|
:loading="loading" @add="handleAdd" @delete="handleBatchDelete" @refresh="getData" />
|
||||||
|
</template> -->
|
||||||
|
<NDataTable v-model:checked-row-keys="checkedRowKeys" :columns="columns" :data="data" size="small" striped
|
||||||
|
:flex-height="!appStore.isMobile" :scroll-x="962" :loading="loading" remote :row-key="row => row.id"
|
||||||
|
:pagination="mobilePagination" class="sm:h-full" />
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
91
apps/admin/src/views/rank/rank-list/modules/rank-search.vue
Normal file
91
apps/admin/src/views/rank/rank-list/modules/rank-search.vue
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, toRaw } from 'vue';
|
||||||
|
import { jsonClone } from '@sa/utils';
|
||||||
|
import { useFormRules, useNaiveForm } from '@/hooks/common/form';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'RankSearch'
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'search'): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const { formRef, validate, restoreValidation } = useNaiveForm();
|
||||||
|
|
||||||
|
const model = defineModel<Api.SystemManage.UserSearchParams>('model', { required: true });
|
||||||
|
|
||||||
|
type RuleKey = Extract<keyof Api.SystemManage.UserSearchParams, 'userEmail' | 'userPhone'>;
|
||||||
|
|
||||||
|
const rules = computed<Record<RuleKey, App.Global.FormRule>>(() => {
|
||||||
|
const { patternRules } = useFormRules();
|
||||||
|
|
||||||
|
return {
|
||||||
|
userEmail: patternRules.email,
|
||||||
|
userPhone: patternRules.phone
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultModel = jsonClone(toRaw(model.value));
|
||||||
|
|
||||||
|
function resetModel() {
|
||||||
|
Object.assign(model.value, defaultModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reset() {
|
||||||
|
await restoreValidation();
|
||||||
|
resetModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search() {
|
||||||
|
await validate();
|
||||||
|
emit('search');
|
||||||
|
}
|
||||||
|
|
||||||
|
const publishStatusOptions = [
|
||||||
|
{ label: '待审核', value: '2' }, // Mapping to '2' (Warning)
|
||||||
|
{ label: '已发布', value: '1' } // Mapping to '1' (Success)
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NCard :bordered="false" size="small" class="card-wrapper">
|
||||||
|
<NCollapse>
|
||||||
|
<NCollapseItem title="筛选查询" name="rank-search">
|
||||||
|
<NForm ref="formRef" :model="model" :rules="rules" label-placement="left" :label-width="100">
|
||||||
|
<NGrid responsive="screen" item-responsive>
|
||||||
|
<NFormItemGi span="24 s:12 m:8" label="比赛活动名称" path="userName" class="pr-24px">
|
||||||
|
<NInput v-model:value="model.userName" placeholder="请输入比赛活动名称" />
|
||||||
|
</NFormItemGi>
|
||||||
|
<NFormItemGi span="24 s:12 m:8" label="发布状态" path="status" class="pr-24px">
|
||||||
|
<NSelect
|
||||||
|
v-model:value="model.status"
|
||||||
|
placeholder="请选择"
|
||||||
|
:options="publishStatusOptions"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</NFormItemGi>
|
||||||
|
<NFormItemGi span="24 s:12 m:8" class="pr-24px">
|
||||||
|
<NSpace class="w-full" justify="end">
|
||||||
|
<NButton @click="reset">
|
||||||
|
<template #icon>
|
||||||
|
<icon-ic-round-refresh class="text-icon" />
|
||||||
|
</template>
|
||||||
|
重置
|
||||||
|
</NButton>
|
||||||
|
<NButton type="primary" ghost @click="search">
|
||||||
|
<template #icon>
|
||||||
|
<icon-ic-round-search class="text-icon" />
|
||||||
|
</template>
|
||||||
|
搜索
|
||||||
|
</NButton>
|
||||||
|
</NSpace>
|
||||||
|
</NFormItemGi>
|
||||||
|
</NGrid>
|
||||||
|
</NForm>
|
||||||
|
</NCollapseItem>
|
||||||
|
</NCollapse>
|
||||||
|
</NCard>
|
||||||
|
</template>
|
||||||
9
apps/admin/src/views/results/index.vue
Normal file
9
apps/admin/src/views/results/index.vue
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
// 结果列表
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1 class="m-0 text-xl text-gray-800 font-bold">
|
||||||
|
实时结果
|
||||||
|
</h1>
|
||||||
|
</template>
|
||||||
186
apps/admin/src/views/template/index.vue
Normal file
186
apps/admin/src/views/template/index.vue
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
<script setup lang="tsx">
|
||||||
|
import { reactive } from 'vue';
|
||||||
|
import { NButton, NPopconfirm, NTag } from 'naive-ui';
|
||||||
|
import { fetchGetUserList } from '@/service/api';
|
||||||
|
import { useAppStore } from '@/store/modules/app';
|
||||||
|
import { defaultTransform, useNaivePaginatedTable, useTableOperate } from '@/hooks/common/table';
|
||||||
|
import TemplateOperateDrawer from './modules/template-operate-drawer.vue';
|
||||||
|
import TemplateSearch from './modules/template-search.vue';
|
||||||
|
|
||||||
|
const appStore = useAppStore();
|
||||||
|
|
||||||
|
const searchParams: Api.SystemManage.UserSearchParams = reactive({
|
||||||
|
current: 1,
|
||||||
|
size: 10,
|
||||||
|
status: null,
|
||||||
|
userName: null,
|
||||||
|
userGender: null,
|
||||||
|
nickName: null,
|
||||||
|
userPhone: null,
|
||||||
|
userEmail: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const templateTitle = '模板管理';
|
||||||
|
|
||||||
|
const competitionMap: Record<string, string> = {
|
||||||
|
1: '第九届阅读之星大赛',
|
||||||
|
2: '科普阅读大赛'
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusMap: Record<string, string> = {
|
||||||
|
1: '铺码成功',
|
||||||
|
2: '未铺码'
|
||||||
|
};
|
||||||
|
|
||||||
|
const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagination } = useNaivePaginatedTable({
|
||||||
|
api: () => fetchGetUserList(searchParams),
|
||||||
|
transform: response => {
|
||||||
|
const transformed = defaultTransform(response);
|
||||||
|
transformed.data.forEach((item, index) => {
|
||||||
|
item.nickName = `MD${String(index + 1).padStart(6, '0')}`;
|
||||||
|
item.userPhone = '210mmx297mm';
|
||||||
|
item.userGender = (item.userGender === '1' || item.userGender === '2') ? item.userGender : '1';
|
||||||
|
});
|
||||||
|
return transformed;
|
||||||
|
},
|
||||||
|
onPaginationParamsChange: params => {
|
||||||
|
searchParams.current = params.page;
|
||||||
|
searchParams.size = params.pageSize;
|
||||||
|
},
|
||||||
|
columns: () => [
|
||||||
|
{
|
||||||
|
type: 'selection',
|
||||||
|
align: 'center',
|
||||||
|
width: 48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'index',
|
||||||
|
title: '序号',
|
||||||
|
align: 'center',
|
||||||
|
width: 60,
|
||||||
|
render: (_, index) => index + 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'userName',
|
||||||
|
title: '名称',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'nickName',
|
||||||
|
title: '模板ID',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'userGender',
|
||||||
|
title: '关联比赛',
|
||||||
|
align: 'center',
|
||||||
|
render: row => {
|
||||||
|
const label = competitionMap[row.userGender as string] || '未知比赛';
|
||||||
|
return <span>{label}</span>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'userPhone',
|
||||||
|
title: '设计尺寸',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
title: '状态',
|
||||||
|
align: 'center',
|
||||||
|
width: 100,
|
||||||
|
render: row => {
|
||||||
|
if (row.status === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const label = statusMap[row.status] || '未知';
|
||||||
|
return <NTag type={row.status === '1' ? 'success' : 'warning'}>{label}</NTag>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'operate',
|
||||||
|
title: '操作',
|
||||||
|
align: 'center',
|
||||||
|
// width: 430,
|
||||||
|
render: row => (
|
||||||
|
<div class="flex-center gap-8px">
|
||||||
|
<NButton type="primary" ghost size="small" onClick={() => edit(row.id)}>
|
||||||
|
基础信息
|
||||||
|
</NButton>
|
||||||
|
<NButton size="small" onClick={() => { }}>
|
||||||
|
铺码
|
||||||
|
</NButton>
|
||||||
|
<NButton size="small" onClick={() => { }}>
|
||||||
|
预览
|
||||||
|
</NButton>
|
||||||
|
<NButton size="small" onClick={() => { }}>
|
||||||
|
页面信息
|
||||||
|
</NButton>
|
||||||
|
<NButton size="small" onClick={() => { }}>
|
||||||
|
打印
|
||||||
|
</NButton>
|
||||||
|
<NPopconfirm onPositiveClick={() => handleDelete(row.id)}>
|
||||||
|
{{
|
||||||
|
default: () => '确认删除?',
|
||||||
|
trigger: () => (
|
||||||
|
<NButton type="error" ghost size="small">
|
||||||
|
删除
|
||||||
|
</NButton>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</NPopconfirm>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
drawerVisible,
|
||||||
|
operateType,
|
||||||
|
editingData,
|
||||||
|
handleAdd,
|
||||||
|
handleEdit,
|
||||||
|
checkedRowKeys,
|
||||||
|
onBatchDeleted,
|
||||||
|
onDeleted
|
||||||
|
// closeDrawer
|
||||||
|
} = useTableOperate(data, 'id', getData);
|
||||||
|
|
||||||
|
async function handleBatchDelete() {
|
||||||
|
// request
|
||||||
|
console.log(checkedRowKeys.value);
|
||||||
|
|
||||||
|
onBatchDeleted();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(id: number) {
|
||||||
|
// request
|
||||||
|
console.log(id);
|
||||||
|
|
||||||
|
onDeleted();
|
||||||
|
}
|
||||||
|
|
||||||
|
function edit(id: number) {
|
||||||
|
handleEdit(id);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
|
||||||
|
<TemplateSearch v-model:model="searchParams" @search="getDataByPage" />
|
||||||
|
<NCard :title="templateTitle" :bordered="false" size="small" class="card-wrapper sm:flex-1-hidden">
|
||||||
|
<template #header-extra>
|
||||||
|
<TableHeaderOperation v-model:columns="columnChecks" :disabled-delete="checkedRowKeys.length === 0"
|
||||||
|
:loading="loading" @add="handleAdd" @delete="handleBatchDelete" @refresh="getData" />
|
||||||
|
</template>
|
||||||
|
<NDataTable v-model:checked-row-keys="checkedRowKeys" :columns="columns" :data="data" size="small" striped
|
||||||
|
:flex-height="!appStore.isMobile" :scroll-x="962" :loading="loading" remote :row-key="row => row.id"
|
||||||
|
:pagination="mobilePagination" class="sm:h-full" />
|
||||||
|
<TemplateOperateDrawer v-model:visible="drawerVisible" :operate-type="operateType" :row-data="editingData"
|
||||||
|
@submitted="getDataByPage" />
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@ -0,0 +1,143 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { jsonClone } from '@sa/utils';
|
||||||
|
import { enableStatusOptions } from '@/constants/business';
|
||||||
|
import { useFormRules, useNaiveForm } from '@/hooks/common/form';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'TemplateOperateDrawer'
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
operateType: NaiveUI.TableOperateType;
|
||||||
|
rowData?: Api.SystemManage.User | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'submitted'): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>('visible', {
|
||||||
|
default: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const { formRef, validate, restoreValidation } = useNaiveForm();
|
||||||
|
const { defaultRequiredRule } = useFormRules();
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
const titles: Record<NaiveUI.TableOperateType, string> = {
|
||||||
|
add: '新增模板',
|
||||||
|
edit: '编辑模板'
|
||||||
|
};
|
||||||
|
return titles[props.operateType];
|
||||||
|
});
|
||||||
|
|
||||||
|
type Model = Pick<
|
||||||
|
Api.SystemManage.User,
|
||||||
|
'userName' | 'userGender' | 'nickName' | 'userPhone' | 'userEmail' | 'userRoles' | 'status'
|
||||||
|
>;
|
||||||
|
|
||||||
|
const model = ref(createDefaultModel());
|
||||||
|
|
||||||
|
function createDefaultModel(): Model {
|
||||||
|
return {
|
||||||
|
userName: '',
|
||||||
|
userGender: null,
|
||||||
|
nickName: '',
|
||||||
|
userPhone: '',
|
||||||
|
userEmail: '',
|
||||||
|
userRoles: [],
|
||||||
|
status: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type RuleKey = Extract<keyof Model, 'userName' | 'status'>;
|
||||||
|
|
||||||
|
const rules: Record<RuleKey, App.Global.FormRule> = {
|
||||||
|
userName: defaultRequiredRule,
|
||||||
|
status: defaultRequiredRule
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleInitModel() {
|
||||||
|
model.value = createDefaultModel();
|
||||||
|
|
||||||
|
if (props.operateType === 'edit' && props.rowData) {
|
||||||
|
Object.assign(model.value, jsonClone(props.rowData));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDrawer() {
|
||||||
|
visible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
await validate();
|
||||||
|
// request
|
||||||
|
window.$message?.success('更新成功');
|
||||||
|
closeDrawer();
|
||||||
|
emit('submitted');
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(visible, () => {
|
||||||
|
if (visible.value) {
|
||||||
|
handleInitModel();
|
||||||
|
restoreValidation();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const competitionOptions = [
|
||||||
|
{ label: '第九届阅读之星大赛', value: '1' },
|
||||||
|
{ label: '科普阅读大赛', value: '2' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const sizeOptions = [
|
||||||
|
{ label: '210mmx297mm', value: '1' },
|
||||||
|
{ label: 'A3', value: '2' }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NDrawer v-model:show="visible" display-directive="show" :width="360">
|
||||||
|
<NDrawerContent :title="title" :native-scrollbar="false" closable>
|
||||||
|
<NForm ref="formRef" :model="model" :rules="rules">
|
||||||
|
<NFormItem label="名称" path="userName">
|
||||||
|
<NInput v-model:value="model.userName" placeholder="请输入名称" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="模板ID" path="nickName">
|
||||||
|
<NInput v-model:value="model.nickName" placeholder="请输入模板ID" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="关联比赛" path="userGender">
|
||||||
|
<NSelect
|
||||||
|
v-model:value="model.userGender"
|
||||||
|
:options="competitionOptions"
|
||||||
|
placeholder="请选择关联比赛"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="设计尺寸" path="userPhone">
|
||||||
|
<NSelect
|
||||||
|
v-model:value="model.userPhone"
|
||||||
|
:options="sizeOptions"
|
||||||
|
placeholder="请选择设计尺寸"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="状态" path="status">
|
||||||
|
<NRadioGroup v-model:value="model.status">
|
||||||
|
<NRadio v-for="item in enableStatusOptions" :key="item.value" :value="item.value" :label="item.label" />
|
||||||
|
</NRadioGroup>
|
||||||
|
</NFormItem>
|
||||||
|
</NForm>
|
||||||
|
<template #footer>
|
||||||
|
<NSpace :size="16">
|
||||||
|
<NButton @click="closeDrawer">取消</NButton>
|
||||||
|
<NButton type="primary" @click="handleSubmit">确认</NButton>
|
||||||
|
</NSpace>
|
||||||
|
</template>
|
||||||
|
</NDrawerContent>
|
||||||
|
</NDrawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
101
apps/admin/src/views/template/modules/template-search.vue
Normal file
101
apps/admin/src/views/template/modules/template-search.vue
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, toRaw } from 'vue';
|
||||||
|
import { jsonClone } from '@sa/utils';
|
||||||
|
import { enableStatusOptions } from '@/constants/business';
|
||||||
|
import { useFormRules, useNaiveForm } from '@/hooks/common/form';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'TemplateSearch'
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'search'): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const { formRef, validate, restoreValidation } = useNaiveForm();
|
||||||
|
|
||||||
|
const model = defineModel<Api.SystemManage.UserSearchParams>('model', { required: true });
|
||||||
|
|
||||||
|
type RuleKey = Extract<keyof Api.SystemManage.UserSearchParams, 'userEmail' | 'userPhone'>;
|
||||||
|
|
||||||
|
const rules = computed<Record<RuleKey, App.Global.FormRule>>(() => {
|
||||||
|
const { patternRules } = useFormRules();
|
||||||
|
|
||||||
|
return {
|
||||||
|
userEmail: patternRules.email,
|
||||||
|
userPhone: patternRules.phone
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultModel = jsonClone(toRaw(model.value));
|
||||||
|
|
||||||
|
function resetModel() {
|
||||||
|
Object.assign(model.value, defaultModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reset() {
|
||||||
|
await restoreValidation();
|
||||||
|
resetModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search() {
|
||||||
|
await validate();
|
||||||
|
emit('search');
|
||||||
|
}
|
||||||
|
|
||||||
|
const competitionOptions = [
|
||||||
|
{ label: '第九届阅读之星大赛', value: '1' },
|
||||||
|
{ label: '科普阅读大赛', value: '2' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const sizeOptions = [
|
||||||
|
{ label: '210mmx297mm', value: '1' },
|
||||||
|
{ label: 'A3', value: '2' }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NCard :bordered="false" size="small" class="card-wrapper">
|
||||||
|
<NCollapse>
|
||||||
|
<NCollapseItem title="基础信息" name="template-search">
|
||||||
|
<NForm ref="formRef" :model="model" :rules="rules" label-placement="left" :label-width="80">
|
||||||
|
<NGrid responsive="screen" item-responsive>
|
||||||
|
<NFormItemGi span="24 s:12 m:6" label="模板名称" path="userName" class="pr-24px">
|
||||||
|
<NInput v-model:value="model.userName" placeholder="请输入名称" />
|
||||||
|
</NFormItemGi>
|
||||||
|
<NFormItemGi span="24 s:12 m:6" label="关联比赛" path="userGender" class="pr-24px">
|
||||||
|
<NSelect v-model:value="model.userGender" placeholder="请选择" :options="competitionOptions as any"
|
||||||
|
clearable />
|
||||||
|
</NFormItemGi>
|
||||||
|
<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 />
|
||||||
|
</NFormItemGi>
|
||||||
|
<NFormItemGi span="24 s:12 m:6" label="尺寸" path="userPhone" class="pr-24px">
|
||||||
|
<NSelect v-model:value="model.userPhone" placeholder="请选择" :options="sizeOptions" clearable />
|
||||||
|
</NFormItemGi>
|
||||||
|
<NFormItemGi span="24" class="pr-24px">
|
||||||
|
<NSpace class="w-full" justify="end">
|
||||||
|
<NButton @click="reset">
|
||||||
|
<template #icon>
|
||||||
|
<icon-ic-round-refresh class="text-icon" />
|
||||||
|
</template>
|
||||||
|
重置
|
||||||
|
</NButton>
|
||||||
|
<NButton type="primary" ghost @click="search">
|
||||||
|
<template #icon>
|
||||||
|
<icon-ic-round-search class="text-icon" />
|
||||||
|
</template>
|
||||||
|
搜索
|
||||||
|
</NButton>
|
||||||
|
</NSpace>
|
||||||
|
</NFormItemGi>
|
||||||
|
</NGrid>
|
||||||
|
</NForm>
|
||||||
|
</NCollapseItem>
|
||||||
|
</NCollapse>
|
||||||
|
</NCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@ -18,7 +18,6 @@ export default readStarConfig(
|
|||||||
'vue/component-name-in-template-casing': ['warn', 'PascalCase', { registeredComponentsOnly: false, ignores: ['/^icon-/'] }],
|
'vue/component-name-in-template-casing': ['warn', 'PascalCase', { registeredComponentsOnly: false, ignores: ['/^icon-/'] }],
|
||||||
'unocss/order-attributify': 'off',
|
'unocss/order-attributify': 'off',
|
||||||
'regexp/no-unused-capturing-group': 'off',
|
'regexp/no-unused-capturing-group': 'off',
|
||||||
'unused-imports/no-unused-vars': 'error',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
147
pnpm-lock.yaml
generated
147
pnpm-lock.yaml
generated
@ -35,6 +35,9 @@ importers:
|
|||||||
'@iconify/vue':
|
'@iconify/vue':
|
||||||
specifier: 5.0.0
|
specifier: 5.0.0
|
||||||
version: 5.0.0(vue@3.5.26(typescript@5.9.3))
|
version: 5.0.0(vue@3.5.26(typescript@5.9.3))
|
||||||
|
'@opentiny/fluent-editor':
|
||||||
|
specifier: ^4.0.1
|
||||||
|
version: 4.0.1
|
||||||
'@sa/axios':
|
'@sa/axios':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/axios
|
version: link:../../packages/axios
|
||||||
@ -68,6 +71,12 @@ importers:
|
|||||||
json5:
|
json5:
|
||||||
specifier: 2.2.3
|
specifier: 2.2.3
|
||||||
version: 2.2.3
|
version: 2.2.3
|
||||||
|
katex:
|
||||||
|
specifier: ^0.16.27
|
||||||
|
version: 0.16.27
|
||||||
|
mathlive:
|
||||||
|
specifier: ^0.108.2
|
||||||
|
version: 0.108.2
|
||||||
naive-ui:
|
naive-ui:
|
||||||
specifier: 2.43.2
|
specifier: 2.43.2
|
||||||
version: 2.43.2(vue@3.5.26(typescript@5.9.3))
|
version: 2.43.2(vue@3.5.26(typescript@5.9.3))
|
||||||
@ -77,6 +86,9 @@ importers:
|
|||||||
pinia:
|
pinia:
|
||||||
specifier: 3.0.4
|
specifier: 3.0.4
|
||||||
version: 3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3))
|
version: 3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3))
|
||||||
|
quill-toolbar-tip:
|
||||||
|
specifier: ^0.1.0
|
||||||
|
version: 0.1.0(quill@2.0.3)
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: 3.4.0
|
specifier: 3.4.0
|
||||||
version: 3.4.0
|
version: 3.4.0
|
||||||
@ -697,6 +709,10 @@ packages:
|
|||||||
'@clack/prompts@0.9.1':
|
'@clack/prompts@0.9.1':
|
||||||
resolution: {integrity: sha512-JIpyaboYZeWYlyP0H+OoPPxd6nqueG/CmN6ixBiNFsIDHREevjIf0n0Ohh5gr5C8pEDknzgvz+pIJ8dMhzWIeg==}
|
resolution: {integrity: sha512-JIpyaboYZeWYlyP0H+OoPPxd6nqueG/CmN6ixBiNFsIDHREevjIf0n0Ohh5gr5C8pEDknzgvz+pIJ8dMhzWIeg==}
|
||||||
|
|
||||||
|
'@cortex-js/compute-engine@0.30.2':
|
||||||
|
resolution: {integrity: sha512-Zx+iisk9WWdbxjm8EYsneIBszvjfUs7BHNwf1jBtSINIgfWGpHrTTq9vW0J59iGCFt6bOFxbmWyxNMRSmksHMA==}
|
||||||
|
engines: {node: '>=21.7.3', npm: '>=10.5.0'}
|
||||||
|
|
||||||
'@css-render/plugin-bem@0.15.14':
|
'@css-render/plugin-bem@0.15.14':
|
||||||
resolution: {integrity: sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==}
|
resolution: {integrity: sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -969,6 +985,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
|
'@floating-ui/core@1.7.3':
|
||||||
|
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
|
||||||
|
|
||||||
|
'@floating-ui/dom@1.7.4':
|
||||||
|
resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==}
|
||||||
|
|
||||||
|
'@floating-ui/utils@0.2.10':
|
||||||
|
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
|
||||||
|
|
||||||
'@humanfs/core@0.19.1':
|
'@humanfs/core@0.19.1':
|
||||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||||
engines: {node: '>=18.18.0'}
|
engines: {node: '>=18.18.0'}
|
||||||
@ -1057,6 +1082,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
'@opentiny/fluent-editor@4.0.1':
|
||||||
|
resolution: {integrity: sha512-E80X5qqx56ORbvS2XSTbYY48Bhs8xwpc9EiPBTWNW3rSXzQCR0bdi+9wO0kn90RtQTplRhaB3nyoVdFgLtEfKA==}
|
||||||
|
|
||||||
'@parcel/watcher-android-arm64@2.5.1':
|
'@parcel/watcher-android-arm64@2.5.1':
|
||||||
resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==}
|
resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
@ -2136,10 +2164,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
|
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
|
|
||||||
|
commander@8.3.0:
|
||||||
|
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
|
||||||
|
engines: {node: '>= 12'}
|
||||||
|
|
||||||
comment-parser@1.4.1:
|
comment-parser@1.4.1:
|
||||||
resolution: {integrity: sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==}
|
resolution: {integrity: sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
|
|
||||||
|
complex-esm@2.1.1-esm1:
|
||||||
|
resolution: {integrity: sha512-IShBEWHILB9s7MnfyevqNGxV0A1cfcSnewL/4uPFiSxkcQL4Mm3FxJ0pXMtCXuWLjYz3lRRyk6OfkeDZcjD6nw==}
|
||||||
|
engines: {node: '>=16.14.2', npm: '>=8.5.0'}
|
||||||
|
|
||||||
component-emitter@1.3.1:
|
component-emitter@1.3.1:
|
||||||
resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
|
resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
|
||||||
|
|
||||||
@ -2266,6 +2302,9 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
decimal.js@10.6.0:
|
||||||
|
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||||
|
|
||||||
decode-named-character-reference@1.2.0:
|
decode-named-character-reference@1.2.0:
|
||||||
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
|
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
|
||||||
|
|
||||||
@ -3396,6 +3435,10 @@ packages:
|
|||||||
jsonfile@6.2.0:
|
jsonfile@6.2.0:
|
||||||
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
|
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
|
||||||
|
|
||||||
|
katex@0.16.27:
|
||||||
|
resolution: {integrity: sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||||
|
|
||||||
@ -3467,6 +3510,13 @@ packages:
|
|||||||
lodash-es@4.17.22:
|
lodash-es@4.17.22:
|
||||||
resolution: {integrity: sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==}
|
resolution: {integrity: sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==}
|
||||||
|
|
||||||
|
lodash.clonedeep@4.5.0:
|
||||||
|
resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
|
||||||
|
|
||||||
|
lodash.isequal@4.5.0:
|
||||||
|
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
|
||||||
|
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
|
||||||
|
|
||||||
lodash.merge@4.6.2:
|
lodash.merge@4.6.2:
|
||||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||||
|
|
||||||
@ -3514,6 +3564,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
mathlive@0.108.2:
|
||||||
|
resolution: {integrity: sha512-GIZkfprGTxrbHckOvwo92ZmOOxdD018BHDzlrEwYUU+pzR5KabhqI1s43lxe/vqXdF5RLiQKgDcuk5jxEjhkYg==}
|
||||||
|
|
||||||
mdast-util-find-and-replace@3.0.2:
|
mdast-util-find-and-replace@3.0.2:
|
||||||
resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
|
resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
|
||||||
|
|
||||||
@ -3872,6 +3925,9 @@ packages:
|
|||||||
package-manager-detector@1.6.0:
|
package-manager-detector@1.6.0:
|
||||||
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
|
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
|
||||||
|
|
||||||
|
parchment@3.0.0:
|
||||||
|
resolution: {integrity: sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@ -4109,6 +4165,29 @@ packages:
|
|||||||
queue-microtask@1.2.3:
|
queue-microtask@1.2.3:
|
||||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||||
|
|
||||||
|
quill-delta@5.1.0:
|
||||||
|
resolution: {integrity: sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
|
||||||
|
quill-easy-color@0.0.10:
|
||||||
|
resolution: {integrity: sha512-InFICsoo9w22G2x8h/LlJMXSipORsqiCVQgiCdC/PfQrvIyB3pfMBFbtpGR5GpwtJJIcDOS7lrxhRs119F269Q==}
|
||||||
|
peerDependencies:
|
||||||
|
quill: '>=1.3.7'
|
||||||
|
|
||||||
|
quill-shortcut-key@0.0.5:
|
||||||
|
resolution: {integrity: sha512-7B1KLgkaN4e1a9+ZtT3Fui3I0bqrdeMW3jQaMYJ3W8dHEi4jg2pIwSKW3cFerqH0iSPnNE3pAhX0y9AeDg4vnA==}
|
||||||
|
peerDependencies:
|
||||||
|
quill: ^2.0.0
|
||||||
|
|
||||||
|
quill-toolbar-tip@0.1.0:
|
||||||
|
resolution: {integrity: sha512-9BsPiFpUGMhvz8TuuIJt4i+y0dSlu02eOwFjWzVF6IlcEaYF/+2fxCCON7MCmwo0uO8Yz5Gh4pDkridbatalhA==}
|
||||||
|
peerDependencies:
|
||||||
|
quill: ^2.0.0
|
||||||
|
|
||||||
|
quill@2.0.3:
|
||||||
|
resolution: {integrity: sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==}
|
||||||
|
engines: {npm: '>=8.2.3'}
|
||||||
|
|
||||||
rate-limiter-flexible@5.0.5:
|
rate-limiter-flexible@5.0.5:
|
||||||
resolution: {integrity: sha512-+/dSQfo+3FYwYygUs/V2BBdwGa9nFtakDwKt4l0bnvNB53TNT++QSFewwHX9qXrZJuMe9j+TUaU21lm5ARgqdQ==}
|
resolution: {integrity: sha512-+/dSQfo+3FYwYygUs/V2BBdwGa9nFtakDwKt4l0bnvNB53TNT++QSFewwHX9qXrZJuMe9j+TUaU21lm5ARgqdQ==}
|
||||||
|
|
||||||
@ -5322,6 +5401,11 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
sisteransi: 1.0.5
|
sisteransi: 1.0.5
|
||||||
|
|
||||||
|
'@cortex-js/compute-engine@0.30.2':
|
||||||
|
dependencies:
|
||||||
|
complex-esm: 2.1.1-esm1
|
||||||
|
decimal.js: 10.6.0
|
||||||
|
|
||||||
'@css-render/plugin-bem@0.15.14(css-render@0.15.14)':
|
'@css-render/plugin-bem@0.15.14(css-render@0.15.14)':
|
||||||
dependencies:
|
dependencies:
|
||||||
css-render: 0.15.14
|
css-render: 0.15.14
|
||||||
@ -5548,6 +5632,17 @@ snapshots:
|
|||||||
'@eslint/core': 0.17.0
|
'@eslint/core': 0.17.0
|
||||||
levn: 0.4.1
|
levn: 0.4.1
|
||||||
|
|
||||||
|
'@floating-ui/core@1.7.3':
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/utils': 0.2.10
|
||||||
|
|
||||||
|
'@floating-ui/dom@1.7.4':
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/core': 1.7.3
|
||||||
|
'@floating-ui/utils': 0.2.10
|
||||||
|
|
||||||
|
'@floating-ui/utils@0.2.10': {}
|
||||||
|
|
||||||
'@humanfs/core@0.19.1': {}
|
'@humanfs/core@0.19.1': {}
|
||||||
|
|
||||||
'@humanfs/node@0.16.7':
|
'@humanfs/node@0.16.7':
|
||||||
@ -5644,6 +5739,12 @@ snapshots:
|
|||||||
'@nodelib/fs.scandir': 2.1.5
|
'@nodelib/fs.scandir': 2.1.5
|
||||||
fastq: 1.20.1
|
fastq: 1.20.1
|
||||||
|
|
||||||
|
'@opentiny/fluent-editor@4.0.1':
|
||||||
|
dependencies:
|
||||||
|
quill: 2.0.3
|
||||||
|
quill-easy-color: 0.0.10(quill@2.0.3)
|
||||||
|
quill-shortcut-key: 0.0.5(quill@2.0.3)
|
||||||
|
|
||||||
'@parcel/watcher-android-arm64@2.5.1':
|
'@parcel/watcher-android-arm64@2.5.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@ -6794,8 +6895,12 @@ snapshots:
|
|||||||
|
|
||||||
commander@7.2.0: {}
|
commander@7.2.0: {}
|
||||||
|
|
||||||
|
commander@8.3.0: {}
|
||||||
|
|
||||||
comment-parser@1.4.1: {}
|
comment-parser@1.4.1: {}
|
||||||
|
|
||||||
|
complex-esm@2.1.1-esm1: {}
|
||||||
|
|
||||||
component-emitter@1.3.1: {}
|
component-emitter@1.3.1: {}
|
||||||
|
|
||||||
concat-map@0.0.1: {}
|
concat-map@0.0.1: {}
|
||||||
@ -6906,6 +7011,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
|
decimal.js@10.6.0: {}
|
||||||
|
|
||||||
decode-named-character-reference@1.2.0:
|
decode-named-character-reference@1.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
character-entities: 2.0.2
|
character-entities: 2.0.2
|
||||||
@ -8165,6 +8272,10 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
|
|
||||||
|
katex@0.16.27:
|
||||||
|
dependencies:
|
||||||
|
commander: 8.3.0
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
json-buffer: 3.0.1
|
json-buffer: 3.0.1
|
||||||
@ -8243,6 +8354,10 @@ snapshots:
|
|||||||
|
|
||||||
lodash-es@4.17.22: {}
|
lodash-es@4.17.22: {}
|
||||||
|
|
||||||
|
lodash.clonedeep@4.5.0: {}
|
||||||
|
|
||||||
|
lodash.isequal@4.5.0: {}
|
||||||
|
|
||||||
lodash.merge@4.6.2: {}
|
lodash.merge@4.6.2: {}
|
||||||
|
|
||||||
lodash@4.17.21: {}
|
lodash@4.17.21: {}
|
||||||
@ -8289,6 +8404,10 @@ snapshots:
|
|||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
|
mathlive@0.108.2:
|
||||||
|
dependencies:
|
||||||
|
'@cortex-js/compute-engine': 0.30.2
|
||||||
|
|
||||||
mdast-util-find-and-replace@3.0.2:
|
mdast-util-find-and-replace@3.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/mdast': 4.0.4
|
'@types/mdast': 4.0.4
|
||||||
@ -8861,6 +8980,8 @@ snapshots:
|
|||||||
|
|
||||||
package-manager-detector@1.6.0: {}
|
package-manager-detector@1.6.0: {}
|
||||||
|
|
||||||
|
parchment@3.0.0: {}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
callsites: 3.1.0
|
callsites: 3.1.0
|
||||||
@ -9066,6 +9187,32 @@ snapshots:
|
|||||||
|
|
||||||
queue-microtask@1.2.3: {}
|
queue-microtask@1.2.3: {}
|
||||||
|
|
||||||
|
quill-delta@5.1.0:
|
||||||
|
dependencies:
|
||||||
|
fast-diff: 1.3.0
|
||||||
|
lodash.clonedeep: 4.5.0
|
||||||
|
lodash.isequal: 4.5.0
|
||||||
|
|
||||||
|
quill-easy-color@0.0.10(quill@2.0.3):
|
||||||
|
dependencies:
|
||||||
|
quill: 2.0.3
|
||||||
|
|
||||||
|
quill-shortcut-key@0.0.5(quill@2.0.3):
|
||||||
|
dependencies:
|
||||||
|
quill: 2.0.3
|
||||||
|
|
||||||
|
quill-toolbar-tip@0.1.0(quill@2.0.3):
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/dom': 1.7.4
|
||||||
|
quill: 2.0.3
|
||||||
|
|
||||||
|
quill@2.0.3:
|
||||||
|
dependencies:
|
||||||
|
eventemitter3: 5.0.1
|
||||||
|
lodash-es: 4.17.22
|
||||||
|
parchment: 3.0.0
|
||||||
|
quill-delta: 5.1.0
|
||||||
|
|
||||||
rate-limiter-flexible@5.0.5: {}
|
rate-limiter-flexible@5.0.5: {}
|
||||||
|
|
||||||
rc9@2.1.2:
|
rc9@2.1.2:
|
||||||
|
|||||||
Reference in New Issue
Block a user