feat(admin): 新增题库、排行榜、实时结果和模板管理功能
- 添加题库管理页面及相关组件 - 实现排行榜管理功能,包括列表和详情页 - 新增实时结果展示页面 - 添加模板制作和管理功能 - 完善路由配置和国际化支持 - 新增阿里云OSS文件上传服务 - 添加多种工具函数和类型定义 - 优化表格和表单组件 - 调整布局和样式细节
This commit is contained in:
@ -1,33 +1,33 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<NModal v-model:show="visible" preset="card" title="第三方在线编辑器" style="width: 90vw" @after-leave="hide">
|
|
||||||
<div style="height: 80vh; overflow: hidden">
|
|
||||||
<iframe src="https://www.processon.com/latex" style="width: 100%; height: calc(100% + 50px); margin-top: -50px; border: none"></iframe>
|
|
||||||
</div>
|
|
||||||
</NModal>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { NModal } from 'naive-ui'
|
||||||
import { NModal } from 'naive-ui';
|
import { ref } from 'vue'
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'hide'): void;
|
(e: 'hide'): void
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
const visible = ref(false);
|
const visible = ref(false)
|
||||||
|
|
||||||
function show() {
|
function show() {
|
||||||
visible.value = true;
|
visible.value = true
|
||||||
}
|
}
|
||||||
function hide() {
|
function hide() {
|
||||||
visible.value = false;
|
visible.value = false
|
||||||
emit('hide');
|
emit('hide')
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
show,
|
show,
|
||||||
hide,
|
hide,
|
||||||
});
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<NModal v-model:show="visible" preset="card" title="第三方在线编辑器" style="width: 90vw" @after-leave="hide">
|
||||||
|
<div style="height: 80vh; overflow: hidden">
|
||||||
|
<iframe src="https://www.processon.com/latex" style="width: 100%; height: calc(100% + 50px); margin-top: -50px; border: none" />
|
||||||
|
</div>
|
||||||
|
</NModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|||||||
@ -1,10 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { UpFile } from '../types'
|
||||||
|
import { NModal, NProgress, NScrollbar } from 'naive-ui'
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** 上传文件列表 */
|
||||||
|
upFileList: UpFile[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
watch(
|
||||||
|
() => props.upFileList.length,
|
||||||
|
(len) => {
|
||||||
|
visible.value = len > 0
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<NModal v-model:show="visible" preset="card" title="上传进度" style="width: 600px">
|
<NModal v-model:show="visible" preset="card" title="上传进度" style="width: 600px">
|
||||||
<NScrollbar style="max-height: 700px">
|
<NScrollbar style="max-height: 700px">
|
||||||
<div class="list">
|
<div class="list">
|
||||||
<div v-for="item in props.upFileList" :key="item.id" class="item">
|
<div v-for="item in props.upFileList" :key="item.id" class="item">
|
||||||
<NProgress type="circle" :percentage="item.progress" :indicator-placement="'inside'" :radius="40" />
|
<NProgress type="circle" :percentage="item.progress" indicator-placement="inside" :radius="40" />
|
||||||
<span class="name">{{ item.name }}</span>
|
<span class="name">{{ item.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -13,25 +32,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, watch } from 'vue';
|
|
||||||
import { NModal, NScrollbar, NProgress } from 'naive-ui';
|
|
||||||
import type { UpFile } from '../types';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
/** 上传文件列表 */
|
|
||||||
upFileList: UpFile[];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const visible = ref(false);
|
|
||||||
watch(
|
|
||||||
() => props.upFileList.length,
|
|
||||||
(len) => {
|
|
||||||
visible.value = len > 0;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.list {
|
.list {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -1,62 +1,62 @@
|
|||||||
import { getAliOssTokenAxios } from '@/service/api/upload';
|
import type { IEditorConfig, ToolbarOptions } from '@opentiny/fluent-editor/types/config/types'
|
||||||
import { browserPathJoin } from '@/utils/date';
|
import type OSS from 'ali-oss'
|
||||||
import { initOSSClient, uploadFileToOSS } from '@/utils/oss';
|
import type { GetEditorConfigParams, MyToolbarOption, UpFile } from './types'
|
||||||
import type { IEditorConfig, ToolbarOptions } from '@opentiny/fluent-editor/types/config/types';
|
import { default as FluentEditor, generateToolbarTip, type MathliveModule, type Range } from '@opentiny/fluent-editor'
|
||||||
import type OSS from 'ali-oss';
|
import katex from 'katex'
|
||||||
import katex from 'katex';
|
import QuillToolbarTip, { type QuillToolbarTipOptions } from 'quill-toolbar-tip'
|
||||||
import { default as FluentEditor, type MathliveModule, type Range, generateToolbarTip } from '@opentiny/fluent-editor';
|
import { ref } from 'vue'
|
||||||
import QuillToolbarTip, { type QuillToolbarTipOptions } from 'quill-toolbar-tip';
|
import { getAliOssTokenAxios } from '@/service/api/upload'
|
||||||
import { base64ToFile } from '@/utils/file';
|
import { browserPathJoin } from '@/utils/date'
|
||||||
import { getSnowflake } from '@/utils/rest';
|
import { base64ToFile } from '@/utils/file'
|
||||||
import type { GetEditorConfigParams, MyToolbarOption, UpFile } from './types';
|
import { initOSSClient, uploadFileToOSS } from '@/utils/oss'
|
||||||
import { ref } from 'vue';
|
import { getSnowflake } from '@/utils/rest'
|
||||||
|
|
||||||
/** 自定义打开iframe的按钮的key */
|
/** 自定义打开iframe的按钮的key */
|
||||||
export const MY_OPEN_PROCESSON_IFRAME = 'my-open-processon-iframe' as const;
|
export const MY_OPEN_PROCESSON_IFRAME = 'my-open-processon-iframe' as const
|
||||||
export const upFileList = ref<UpFile[]>([]);
|
export const upFileList = ref<UpFile[]>([])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化编辑器
|
* 初始化编辑器
|
||||||
*/
|
*/
|
||||||
export function initEditor(container: HTMLElement, params: IEditorConfig): FluentEditor {
|
export function initEditor(container: HTMLElement, params: IEditorConfig): FluentEditor {
|
||||||
// 需要依赖 katex
|
// 需要依赖 katex
|
||||||
window.katex = katex;
|
window.katex = katex
|
||||||
|
|
||||||
// 处理图片给每个图片添加类名和最大宽度
|
// 处理图片给每个图片添加类名和最大宽度
|
||||||
const ImageBlot: any = FluentEditor.import('formats/image');
|
const ImageBlot: any = FluentEditor.import('formats/image')
|
||||||
class CustomImageBlot extends ImageBlot {
|
class CustomImageBlot extends ImageBlot {
|
||||||
static create(value: any) {
|
static create(value: any) {
|
||||||
const node: HTMLImageElement = super.create(value);
|
const node: HTMLImageElement = super.create(value)
|
||||||
editImageAttribute(node, false);
|
editImageAttribute(node, false)
|
||||||
return node;
|
return node
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
FluentEditor.register(CustomImageBlot, true);
|
FluentEditor.register(CustomImageBlot, true)
|
||||||
|
|
||||||
// 处理视频给每个视频添加类名和最大宽度
|
// 处理视频给每个视频添加类名和最大宽度
|
||||||
const VideoBlot: any = FluentEditor.import('formats/video');
|
const VideoBlot: any = FluentEditor.import('formats/video')
|
||||||
class CustomVideoBlot extends VideoBlot {
|
class CustomVideoBlot extends VideoBlot {
|
||||||
static create(value: any) {
|
static create(value: any) {
|
||||||
const node: HTMLVideoElement = super.create(value);
|
const node: HTMLVideoElement = super.create(value)
|
||||||
editVideoAttribute(node, false);
|
editVideoAttribute(node, false)
|
||||||
return node;
|
return node
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
FluentEditor.register(CustomVideoBlot, true);
|
FluentEditor.register(CustomVideoBlot, true)
|
||||||
|
|
||||||
// 注册工具栏提示模块
|
// 注册工具栏提示模块
|
||||||
FluentEditor.register({ 'modules/toolbar-tip': generateToolbarTip(QuillToolbarTip) }, true);
|
FluentEditor.register({ 'modules/toolbar-tip': generateToolbarTip(QuillToolbarTip) }, true)
|
||||||
const Parchment = FluentEditor.import('parchment');
|
const Parchment = FluentEditor.import('parchment')
|
||||||
addToolbarIcon();
|
addToolbarIcon()
|
||||||
|
|
||||||
// 居中问题
|
// 居中问题
|
||||||
const config = { scope: Parchment.Scope.BLOCK, whitelist: ['right', 'center', 'justify'] };
|
const config = { scope: Parchment.Scope.BLOCK, whitelist: ['right', 'center', 'justify'] }
|
||||||
new Parchment.StyleAttributor('align', 'text-align', config);
|
new Parchment.StyleAttributor('align', 'text-align', config)
|
||||||
const Align = FluentEditor.import('attributors/style/align');
|
const Align = FluentEditor.import('attributors/style/align')
|
||||||
FluentEditor.register(Align, true);
|
FluentEditor.register(Align, true)
|
||||||
|
|
||||||
// 工具栏配置
|
// 工具栏配置
|
||||||
return new FluentEditor(container, params);
|
return new FluentEditor(container, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -67,53 +67,54 @@ export async function getEditorConfig(params: GetEditorConfigParams): Promise<IE
|
|||||||
theme: 'snow',
|
theme: 'snow',
|
||||||
modules: {
|
modules: {
|
||||||
// 是否开启数学公式模块
|
// 是否开启数学公式模块
|
||||||
mathlive: true,
|
'mathlive': true,
|
||||||
// 开启字数统计
|
// 开启字数统计
|
||||||
// counter: {
|
// counter: {
|
||||||
// count: 2000,
|
// count: 2000,
|
||||||
// },
|
// },
|
||||||
toolbar: {
|
'toolbar': {
|
||||||
// 工具栏显示那些按钮
|
// 工具栏显示那些按钮
|
||||||
container: await getToolbarOption(params.toolbarOption || {}),
|
container: await getToolbarOption(params.toolbarOption || {}),
|
||||||
handlers: {
|
handlers: {
|
||||||
formula() {
|
formula() {
|
||||||
// 点击数学公式按钮时打开弹出框
|
// 点击数学公式按钮时打开弹出框
|
||||||
const mathlive = this.quill.getModule('mathlive') as MathliveModule;
|
const mathlive = this.quill.getModule('mathlive') as MathliveModule
|
||||||
mathlive.createDialog('');
|
mathlive.createDialog('')
|
||||||
},
|
},
|
||||||
// 自定义打开iframe的按钮的点击事件的回调
|
// 自定义打开iframe的按钮的点击事件的回调
|
||||||
[`${MY_OPEN_PROCESSON_IFRAME}`](_value: boolean) {
|
[`${MY_OPEN_PROCESSON_IFRAME}`](_value: boolean) {
|
||||||
params[MY_OPEN_PROCESSON_IFRAME](); // 执行回调
|
params[MY_OPEN_PROCESSON_IFRAME]() // 执行回调
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
uploader: {
|
'uploader': {
|
||||||
// only allow image
|
// only allow image
|
||||||
mimetypes: ['*', 'video/*', 'audio/*'],
|
mimetypes: ['*', 'video/*', 'audio/*'],
|
||||||
async handler(_range: Range, files: File[]): Promise<(string | false)[]> {
|
async handler(_range: Range, files: File[]): Promise<(string | false)[]> {
|
||||||
try {
|
try {
|
||||||
const paths = await Promise.all(files.map((file, index) => upFileAxios(file, `${Date.now()}_${index}`)));
|
const paths = await Promise.all(files.map((file, index) => upFileAxios(file, `${Date.now()}_${index}`)))
|
||||||
return paths;
|
return paths
|
||||||
} catch (error) {
|
}
|
||||||
console.log('error====', error);
|
catch (error) {
|
||||||
return files.map(() => false);
|
console.log('error====', error)
|
||||||
|
return files.map(() => false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
clipboard: {
|
'clipboard': {
|
||||||
matchers: [
|
matchers: [
|
||||||
[
|
[
|
||||||
'img',
|
'img',
|
||||||
(node: HTMLImageElement, delta: any) => {
|
(node: HTMLImageElement, delta: any) => {
|
||||||
editImageAttribute(node, false);
|
editImageAttribute(node, false)
|
||||||
return delta;
|
return delta
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'video',
|
'video',
|
||||||
(node: HTMLVideoElement, delta: any) => {
|
(node: HTMLVideoElement, delta: any) => {
|
||||||
editVideoAttribute(node, false);
|
editVideoAttribute(node, false)
|
||||||
return delta;
|
return delta
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@ -128,28 +129,28 @@ export async function getEditorConfig(params: GetEditorConfigParams): Promise<IE
|
|||||||
},
|
},
|
||||||
} satisfies Partial<QuillToolbarTipOptions>,
|
} satisfies Partial<QuillToolbarTipOptions>,
|
||||||
// 国际化配置
|
// 国际化配置
|
||||||
i18n: { lang: 'zh-CN' },
|
'i18n': { lang: 'zh-CN' },
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
return editorOptions;
|
return editorOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 得到工具栏配置(显示那些按钮)
|
* 得到工具栏配置(显示那些按钮)
|
||||||
*/
|
*/
|
||||||
function getToolbarOption(params: MyToolbarOption): Promise<ToolbarOptions['container']> {
|
function getToolbarOption(params: MyToolbarOption): Promise<ToolbarOptions['container']> {
|
||||||
const { show, showUpFile, showUpVideo, customToolbar } = params;
|
const { show, showUpFile, showUpVideo, customToolbar } = params
|
||||||
|
|
||||||
if (show === false) {
|
if (show === false) {
|
||||||
return Promise.resolve([] satisfies ToolbarOptions['container']);
|
return Promise.resolve([] satisfies ToolbarOptions['container'])
|
||||||
}
|
}
|
||||||
|
|
||||||
if (customToolbar !== undefined) {
|
if (customToolbar !== undefined) {
|
||||||
return Promise.resolve(customToolbar);
|
return Promise.resolve(customToolbar)
|
||||||
}
|
}
|
||||||
const va = ['image'];
|
const va = ['image']
|
||||||
showUpVideo !== false && va.push('video'); // 只要不等于false就添加 (undefined也添加)
|
showUpVideo !== false && va.push('video') // 只要不等于false就添加 (undefined也添加)
|
||||||
showUpFile !== false && va.push('file'); // 只要不等于false就添加 (undefined也添加)
|
showUpFile !== false && va.push('file') // 只要不等于false就添加 (undefined也添加)
|
||||||
// 工具栏配置
|
// 工具栏配置
|
||||||
const TOOLBAR_CONFIG: ToolbarOptions['container'] = [
|
const TOOLBAR_CONFIG: ToolbarOptions['container'] = [
|
||||||
['undo', 'redo', 'clean', 'format-painter'],
|
['undo', 'redo', 'clean', 'format-painter'],
|
||||||
@ -157,7 +158,7 @@ function getToolbarOption(params: MyToolbarOption): Promise<ToolbarOptions['cont
|
|||||||
{ header: [1, 2, 3, 4, 5, 6, false] },
|
{ header: [1, 2, 3, 4, 5, 6, false] },
|
||||||
{ size: [false, '12px', '14px', '16px', '18px', '20px', '24px', '32px', '36px', '48px', '72px'] },
|
{ size: [false, '12px', '14px', '16px', '18px', '20px', '24px', '32px', '36px', '48px', '72px'] },
|
||||||
{ 'line-height': [false, '1.2', '1.5', '1.75', '2', '3', '4', '5'] },
|
{ 'line-height': [false, '1.2', '1.5', '1.75', '2', '3', '4', '5'] },
|
||||||
{ 'font': [false, 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', '宋体', '黑体', '微软雅黑', '楷体', '仿宋', '等线'] },
|
{ font: [false, 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', '宋体', '黑体', '微软雅黑', '楷体', '仿宋', '等线'] },
|
||||||
],
|
],
|
||||||
['bold', 'italic', 'strike', 'underline', 'divider'],
|
['bold', 'italic', 'strike', 'underline', 'divider'],
|
||||||
[{ color: [] }, { background: [] }],
|
[{ color: [] }, { background: [] }],
|
||||||
@ -168,8 +169,8 @@ function getToolbarOption(params: MyToolbarOption): Promise<ToolbarOptions['cont
|
|||||||
['link', 'blockquote'],
|
['link', 'blockquote'],
|
||||||
[...va],
|
[...va],
|
||||||
['fullscreen', 'formula', MY_OPEN_PROCESSON_IFRAME],
|
['fullscreen', 'formula', MY_OPEN_PROCESSON_IFRAME],
|
||||||
];
|
]
|
||||||
return Promise.resolve(TOOLBAR_CONFIG);
|
return Promise.resolve(TOOLBAR_CONFIG)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -177,49 +178,50 @@ function getToolbarOption(params: MyToolbarOption): Promise<ToolbarOptions['cont
|
|||||||
*/
|
*/
|
||||||
function addToolbarIcon() {
|
function addToolbarIcon() {
|
||||||
// 这里时增加按钮
|
// 这里时增加按钮
|
||||||
const myOpenProcessonIframeIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path fill="currentColor" d="M6.5 1a1.5 1.5 0 0 0-1.415 1H4.5A1.5 1.5 0 0 0 3 3.5v10A1.5 1.5 0 0 0 4.5 15h1.651a1.5 1.5 0 0 1-.111-1H4.5a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h.585A1.5 1.5 0 0 0 6.5 4h3a1.5 1.5 0 0 0 1.415-1h.585a.5.5 0 0 1 .5.5v1.57q.346.079.681.247.178.09.319.217V3.5A1.5 1.5 0 0 0 11.5 2h-.585A1.5 1.5 0 0 0 9.5 1zM6 2.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5m3.451 5.277a1.916 1.916 0 0 1 2.778-1.568.5.5 0 0 1-.452.892.916.916 0 0 0-1.328.75l-.087 1.172H11.5a.5.5 0 0 1 0 1h-1.212l-.237 3.2a1.916 1.916 0 0 1-2.777 1.567.5.5 0 1 1 .452-.892.916.916 0 0 0 1.328-.75l.231-3.125H8.5a.5.5 0 1 1 0-1h.86zm5.403 4.077a.5.5 0 0 0-.708-.708l-1.057 1.057-.457-.734a.994.994 0 0 0-1.424-.281.5.5 0 0 0 .579.815l.576.926-1.217 1.217a.5.5 0 0 0 .708.708l1.052-1.053.457.735a.99.99 0 0 0 1.442.264.5.5 0 0 0-.599-.801l-.574-.924z"/></svg>`;
|
const myOpenProcessonIframeIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path fill="currentColor" d="M6.5 1a1.5 1.5 0 0 0-1.415 1H4.5A1.5 1.5 0 0 0 3 3.5v10A1.5 1.5 0 0 0 4.5 15h1.651a1.5 1.5 0 0 1-.111-1H4.5a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h.585A1.5 1.5 0 0 0 6.5 4h3a1.5 1.5 0 0 0 1.415-1h.585a.5.5 0 0 1 .5.5v1.57q.346.079.681.247.178.09.319.217V3.5A1.5 1.5 0 0 0 11.5 2h-.585A1.5 1.5 0 0 0 9.5 1zM6 2.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5m3.451 5.277a1.916 1.916 0 0 1 2.778-1.568.5.5 0 0 1-.452.892.916.916 0 0 0-1.328.75l-.087 1.172H11.5a.5.5 0 0 1 0 1h-1.212l-.237 3.2a1.916 1.916 0 0 1-2.777 1.567.5.5 0 1 1 .452-.892.916.916 0 0 0 1.328-.75l.231-3.125H8.5a.5.5 0 1 1 0-1h.86zm5.403 4.077a.5.5 0 0 0-.708-.708l-1.057 1.057-.457-.734a.994.994 0 0 0-1.424-.281.5.5 0 0 0 .579.815l.576.926-1.217 1.217a.5.5 0 0 0 .708.708l1.052-1.053.457.735a.99.99 0 0 0 1.442.264.5.5 0 0 0-.599-.801l-.574-.924z"/></svg>`
|
||||||
const icons = FluentEditor.import('ui/icons') as Record<string, string>;
|
const icons = FluentEditor.import('ui/icons') as Record<string, string>
|
||||||
icons[MY_OPEN_PROCESSON_IFRAME] = myOpenProcessonIframeIcon;
|
icons[MY_OPEN_PROCESSON_IFRAME] = myOpenProcessonIframeIcon
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 上传文件
|
* 上传文件
|
||||||
*/
|
*/
|
||||||
async function upFileAxios(file: File, id: string): Promise<string | false> {
|
async function upFileAxios(file: File, id: string): Promise<string | false> {
|
||||||
const item: UpFile = { id, name: file.name, progress: 0 };
|
const item: UpFile = { id, name: file.name, progress: 0 }
|
||||||
try {
|
try {
|
||||||
upFileList.value.push(item);
|
upFileList.value.push(item)
|
||||||
const tokenRes = await getAliOssTokenAxios();
|
const tokenRes = await getAliOssTokenAxios()
|
||||||
// 初始化OSS客户端
|
// 初始化OSS客户端
|
||||||
const client = initOSSClient(tokenRes);
|
const client = initOSSClient(tokenRes)
|
||||||
const path = browserPathJoin(`temp/${Date.now()}`, file!.name);
|
const path = browserPathJoin(`temp/${Date.now()}`, file!.name)
|
||||||
// 上传文件
|
// 上传文件
|
||||||
const res: OSS.MultipartUploadResult = await uploadFileToOSS(client, file, path, (progress) => {
|
const res: OSS.MultipartUploadResult = await uploadFileToOSS(client, file, path, (progress) => {
|
||||||
item.progress = Math.floor(progress * 10000) / 100;
|
item.progress = Math.floor(progress * 10000) / 100
|
||||||
});
|
})
|
||||||
const url = browserPathJoin(import.meta.env.VITE_BASE_OSS_URL, res.name);
|
const url = browserPathJoin(import.meta.env.VITE_BASE_OSS_URL, res.name)
|
||||||
upFileList.value.splice(upFileList.value.indexOf(item), 1);
|
upFileList.value.splice(upFileList.value.indexOf(item), 1)
|
||||||
return Promise.resolve(url);
|
return Promise.resolve(url)
|
||||||
// oxlint-disable-next-line no-unused-vars
|
// oxlint-disable-next-line no-unused-vars
|
||||||
} catch (error) {
|
}
|
||||||
upFileList.value.splice(upFileList.value.indexOf(item), 1);
|
catch (error) {
|
||||||
window.$message?.error('上传失败');
|
upFileList.value.splice(upFileList.value.indexOf(item), 1)
|
||||||
return Promise.resolve(false);
|
window.$message?.error('上传失败')
|
||||||
|
return Promise.resolve(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 编辑视频属性 */
|
/** 编辑视频属性 */
|
||||||
export function editVideoAttribute(node: HTMLVideoElement, isReplace = true) {
|
export function editVideoAttribute(node: HTMLVideoElement, isReplace = true) {
|
||||||
node.setAttribute('style', 'max-width: 100%;'); // 强制内联样式
|
node.setAttribute('style', 'max-width: 100%;') // 强制内联样式
|
||||||
node.classList.add('my-ql-video');
|
node.classList.add('my-ql-video')
|
||||||
isReplace && replaceFileUrl(node);
|
isReplace && replaceFileUrl(node)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 编辑图片属性 */
|
/** 编辑图片属性 */
|
||||||
export function editImageAttribute(node: HTMLImageElement, isReplace = true) {
|
export function editImageAttribute(node: HTMLImageElement, isReplace = true) {
|
||||||
node.setAttribute('style', 'max-width: 100%;'); // 强制内联样式
|
node.setAttribute('style', 'max-width: 100%;') // 强制内联样式
|
||||||
node.classList.add('my-ql-image');
|
node.classList.add('my-ql-image')
|
||||||
isReplace && replaceFileUrl(node);
|
isReplace && replaceFileUrl(node)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -227,10 +229,10 @@ export function editImageAttribute(node: HTMLImageElement, isReplace = true) {
|
|||||||
* @param node
|
* @param node
|
||||||
*/
|
*/
|
||||||
export function replaceFileUrl(node: HTMLImageElement | HTMLVideoElement) {
|
export function replaceFileUrl(node: HTMLImageElement | HTMLVideoElement) {
|
||||||
const ex = getExtensionFromDataUrl(node.src);
|
const ex = getExtensionFromDataUrl(node.src)
|
||||||
if (ex) {
|
if (ex) {
|
||||||
const file = base64ToFile(node.src, `${Date.now()}_${getSnowflake()}.${ex}`);
|
const file = base64ToFile(node.src, `${Date.now()}_${getSnowflake()}.${ex}`)
|
||||||
upFileAxios(file, file.name);
|
upFileAxios(file, file.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -245,7 +247,7 @@ const mimeToExtension: Record<string, string> = {
|
|||||||
'audio/mpeg': 'mp3',
|
'audio/mpeg': 'mp3',
|
||||||
'application/pdf': 'pdf',
|
'application/pdf': 'pdf',
|
||||||
'text/plain': 'txt',
|
'text/plain': 'txt',
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从 base64 获取文件扩展名
|
* 从 base64 获取文件扩展名
|
||||||
@ -253,11 +255,12 @@ const mimeToExtension: Record<string, string> = {
|
|||||||
* @param mimeMap
|
* @param mimeMap
|
||||||
*/
|
*/
|
||||||
function getExtensionFromDataUrl(dataUrl: string, mimeMap = mimeToExtension): string | null {
|
function getExtensionFromDataUrl(dataUrl: string, mimeMap = mimeToExtension): string | null {
|
||||||
const base64Pattern = /^data:(image|video)\/([a-zA-Z]+);base64,([A-Za-z0-9+/]+={0,2})$/;
|
const base64Pattern = /^data:(image|video)\/([a-zA-Z]+);base64,([A-Za-z0-9+/]+={0,2})$/
|
||||||
if (base64Pattern.test(dataUrl)) {
|
if (base64Pattern.test(dataUrl)) {
|
||||||
const mimeType = dataUrl.split(',')?.[0]?.split(':')?.[1]?.split(';')[0];
|
const mimeType = dataUrl.split(',')?.[0]?.split(':')?.[1]?.split(';')[0]
|
||||||
return mimeType && mimeType in mimeMap ? mimeMap[mimeType]! : null;
|
return mimeType && mimeType in mimeMap ? mimeMap[mimeType]! : null
|
||||||
} else {
|
}
|
||||||
return null;
|
else {
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,236 +1,244 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { IEditorConfig, ToolbarOptions } from '@opentiny/fluent-editor/types/config/types'
|
||||||
|
import type { PropType } from 'vue'
|
||||||
|
import type MathIframeDailog from './components/math-iframe-dialog.vue'
|
||||||
|
import type { MyToolbarOption } from './types'
|
||||||
|
import FluentEditor from '@opentiny/fluent-editor'
|
||||||
|
import { nextTick, onMounted, ref, watch } from 'vue'
|
||||||
|
import UpFileDialog from './components/up-file-dialog.vue'
|
||||||
|
import { editImageAttribute, editVideoAttribute, getEditorConfig, initEditor, MY_OPEN_PROCESSON_IFRAME, upFileList } from './editor-util'
|
||||||
|
import 'mathlive'
|
||||||
|
import 'mathlive/static.css'
|
||||||
|
import 'mathlive/fonts.css'
|
||||||
|
import '@opentiny/fluent-editor/style.css'
|
||||||
|
import 'katex/dist/katex.min.css'
|
||||||
|
import 'quill-toolbar-tip/dist/index.css'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
/** 输入内容 */
|
||||||
|
modelValue: { type: String, default: '' },
|
||||||
|
/** 工具栏配置 */
|
||||||
|
toolbarOption: { type: Object as PropType<MyToolbarOption>, default: undefined },
|
||||||
|
})
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const editorRef = useTemplateRef<HTMLDivElement>('editorRef')
|
||||||
|
const mathIframeDalogRef = useTemplateRef<InstanceType<typeof MathIframeDailog>>('mathIframeDalogRef')
|
||||||
|
const editorContainerRef = useTemplateRef<HTMLDivElement>('editorContainerRef')
|
||||||
|
const showToolbar = ref(true)
|
||||||
|
|
||||||
|
let editor: FluentEditor | undefined
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(newValue) => {
|
||||||
|
if (editor && newValue) {
|
||||||
|
if (newValue.startsWith('<p><br></p>') || newValue.startsWith('<p></p>')) {
|
||||||
|
emit('update:modelValue', '')
|
||||||
|
editor!.root.innerHTML = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nextTick(() => {
|
||||||
|
if (editor!.root.innerHTML !== newValue) {
|
||||||
|
editor!.root.innerHTML = newValue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// 得到编辑器配置
|
||||||
|
const editorOptions: IEditorConfig = await getEditorConfig({
|
||||||
|
toolbarOption: props.toolbarOption,
|
||||||
|
/** 点击自定义打开iframe的按钮的回调 */
|
||||||
|
[MY_OPEN_PROCESSON_IFRAME]: () => {
|
||||||
|
mathIframeDalogRef.value?.show()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const container = (editorOptions!.modules!.toolbar as ToolbarOptions).container || []
|
||||||
|
showToolbar.value = !(container && Array.isArray(container) && container.length > 0)
|
||||||
|
|
||||||
|
// 初始化编辑器
|
||||||
|
editor = initEditor(editorRef.value!, editorOptions)
|
||||||
|
// 设置内容
|
||||||
|
editor.root.innerHTML = props.modelValue
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const ImageBlot: any = FluentEditor.import('formats/image')
|
||||||
|
// 处理图片给每个图片添加类名和最大宽度
|
||||||
|
editor?.scroll.descendants(ImageBlot).forEach((imageBlot: any) => {
|
||||||
|
const node = imageBlot.domNode as HTMLImageElement
|
||||||
|
editImageAttribute(node, true)
|
||||||
|
})
|
||||||
|
// 处理视频给每个视频添加类名和最大宽度
|
||||||
|
const VideoBlot: any = FluentEditor.import('formats/video')
|
||||||
|
editor?.scroll.descendants(VideoBlot).forEach((videoBlot: any) => {
|
||||||
|
const node = videoBlot.domNode as HTMLVideoElement
|
||||||
|
editVideoAttribute(node, true)
|
||||||
|
})
|
||||||
|
}, 10)
|
||||||
|
|
||||||
|
editor?.on('text-change', () => {
|
||||||
|
nextTick(() => {
|
||||||
|
emit('update:modelValue', editor!.root.innerHTML)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 弹出框关闭时弹出mathlive输入框
|
||||||
|
*/
|
||||||
|
function hideIframeDialog() {
|
||||||
|
const butt = (editorContainerRef.value?.querySelector('.ql-toolbar.ql-snow .ql-formats button.ql-formula') || null) as HTMLButtonElement | null
|
||||||
|
butt?.click()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="editorContainerRef" class="rest-basic-editor" :class="{ 'hide-toolbar': showToolbar }">
|
<div ref="editorContainerRef" class="rest-basic-editor" :class="{ 'hide-toolbar': showToolbar }">
|
||||||
<div ref="editorRef"></div>
|
<div ref="editorRef" />
|
||||||
<math-iframe-dialog ref="mathIframeDalogRef" @hide="hideIframeDialog"></math-iframe-dialog>
|
<MathIframeDialog ref="mathIframeDalogRef" @hide="hideIframeDialog" />
|
||||||
<up-file-dialog :upFileList="upFileList"></up-file-dialog>
|
<UpFileDialog :up-file-list="upFileList" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import 'mathlive';
|
|
||||||
import FluentEditor from '@opentiny/fluent-editor';
|
|
||||||
import type { IEditorConfig, ToolbarOptions } from '@opentiny/fluent-editor/types/config/types';
|
|
||||||
import type MathIframeDailog from './components/math-iframe-dialog.vue';
|
|
||||||
import { MY_OPEN_PROCESSON_IFRAME, editImageAttribute, editVideoAttribute, getEditorConfig, initEditor, upFileList } from './editor-util';
|
|
||||||
import UpFileDialog from './components/up-file-dialog.vue';
|
|
||||||
import type { PropType } from 'vue';
|
|
||||||
import type { MyToolbarOption } from './types';
|
|
||||||
import 'mathlive/static.css';
|
|
||||||
import 'mathlive/fonts.css';
|
|
||||||
import '@opentiny/fluent-editor/style.css';
|
|
||||||
import 'katex/dist/katex.min.css';
|
|
||||||
import 'quill-toolbar-tip/dist/index.css';
|
|
||||||
import { ref, watch, onMounted, nextTick } from 'vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
/** 输入内容 */
|
|
||||||
modelValue: { type: String, default: '' },
|
|
||||||
/** 工具栏配置 */
|
|
||||||
toolbarOption: { type: Object as PropType<MyToolbarOption>, default: undefined },
|
|
||||||
});
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'update:modelValue', value: string): void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const editorRef = useTemplateRef<HTMLDivElement>('editorRef');
|
|
||||||
const mathIframeDalogRef = useTemplateRef<InstanceType<typeof MathIframeDailog>>('mathIframeDalogRef');
|
|
||||||
const editorContainerRef = useTemplateRef<HTMLDivElement>('editorContainerRef');
|
|
||||||
const showToolbar = ref(true);
|
|
||||||
|
|
||||||
let editor: FluentEditor | undefined = undefined;
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
(newValue) => {
|
|
||||||
if (editor && newValue) {
|
|
||||||
if (newValue.startsWith('<p><br></p>') || newValue.startsWith('<p></p>')) {
|
|
||||||
emit('update:modelValue', '');
|
|
||||||
editor!.root.innerHTML = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
nextTick(() => {
|
|
||||||
if (editor!.root.innerHTML !== newValue) {
|
|
||||||
editor!.root.innerHTML = newValue;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
// 得到编辑器配置
|
|
||||||
const editorOptions: IEditorConfig = await getEditorConfig({
|
|
||||||
toolbarOption: props.toolbarOption,
|
|
||||||
/** 点击自定义打开iframe的按钮的回调 */
|
|
||||||
[MY_OPEN_PROCESSON_IFRAME]: () => {
|
|
||||||
mathIframeDalogRef.value?.show();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const container = (editorOptions!.modules!.toolbar as ToolbarOptions).container || [];
|
|
||||||
showToolbar.value = !(container && Array.isArray(container) && container.length > 0);
|
|
||||||
|
|
||||||
// 初始化编辑器
|
|
||||||
editor = initEditor(editorRef.value!, editorOptions);
|
|
||||||
// 设置内容
|
|
||||||
editor.root.innerHTML = props.modelValue;
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const ImageBlot: any = FluentEditor.import('formats/image');
|
|
||||||
// 处理图片给每个图片添加类名和最大宽度
|
|
||||||
editor?.scroll.descendants(ImageBlot).forEach((imageBlot: any) => {
|
|
||||||
const node = imageBlot.domNode as HTMLImageElement;
|
|
||||||
editImageAttribute(node, true);
|
|
||||||
});
|
|
||||||
// 处理视频给每个视频添加类名和最大宽度
|
|
||||||
const VideoBlot: any = FluentEditor.import('formats/video');
|
|
||||||
editor?.scroll.descendants(VideoBlot).forEach((videoBlot: any) => {
|
|
||||||
const node = videoBlot.domNode as HTMLVideoElement;
|
|
||||||
editVideoAttribute(node, true);
|
|
||||||
});
|
|
||||||
}, 10);
|
|
||||||
|
|
||||||
editor?.on('text-change', () => {
|
|
||||||
nextTick(() => {
|
|
||||||
emit('update:modelValue', editor!.root.innerHTML);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 弹出框关闭时弹出mathlive输入框
|
|
||||||
*/
|
|
||||||
function hideIframeDialog() {
|
|
||||||
const butt = (editorContainerRef.value?.querySelector('.ql-toolbar.ql-snow .ql-formats button.ql-formula') || null) as HTMLButtonElement | null;
|
|
||||||
butt?.click();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
/* stylelint-disable selector-attribute-name-disallowed-list */
|
/* stylelint-disable selector-attribute-name-disallowed-list */
|
||||||
.ql-toolbar.ql-snow {
|
.ql-toolbar.ql-snow {
|
||||||
// 行高
|
// 行高
|
||||||
.ql-picker.ql-line-height .ql-picker-label:before,
|
.ql-picker.ql-line-height .ql-picker-label:before,
|
||||||
.ql-picker.ql-line-height .ql-picker-item:before {
|
.ql-picker.ql-line-height .ql-picker-item:before {
|
||||||
content: '行高(系统默认)';
|
content: '行高(系统默认)';
|
||||||
}
|
|
||||||
|
|
||||||
.ql-formats .ql-line-height.ql-picker .ql-picker-label[data-value]:before,
|
|
||||||
.ql-formats .ql-line-height.ql-picker .ql-picker-item[data-value]:before {
|
|
||||||
content: attr(data-value) '倍';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 字体
|
|
||||||
.ql-picker.ql-font .ql-picker-label:before,
|
|
||||||
.ql-picker.ql-font .ql-picker-item:before {
|
|
||||||
content: '字体(系统默认)';
|
|
||||||
}
|
|
||||||
|
|
||||||
.ql-formats .ql-font.ql-picker .ql-picker-label[data-value]:before,
|
|
||||||
.ql-formats .ql-font.ql-picker .ql-picker-item[data-value]:before {
|
|
||||||
content: attr(data-value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 字号
|
|
||||||
.ql-picker.ql-size .ql-picker-label:before,
|
|
||||||
.ql-picker.ql-size .ql-picker-item:before {
|
|
||||||
content: '字号(系统默认)';
|
|
||||||
}
|
|
||||||
|
|
||||||
.ql-formats .ql-size.ql-picker .ql-picker-label[data-value]:before,
|
|
||||||
.ql-formats .ql-size.ql-picker .ql-picker-item[data-value]:before {
|
|
||||||
content: attr(data-value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 标题
|
|
||||||
.ql-picker.ql-header .ql-picker-label:before,
|
|
||||||
.ql-picker.ql-header .ql-picker-item:before {
|
|
||||||
content: '标题(默认)';
|
|
||||||
}
|
|
||||||
|
|
||||||
.ql-formats .ql-header.ql-picker .ql-picker-label[data-value]:before,
|
|
||||||
.ql-formats .ql-header.ql-picker .ql-picker-item[data-value]:before {
|
|
||||||
content: '标题(H' attr(data-value) ')';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ql-formats .ql-line-height.ql-picker .ql-picker-label[data-value]:before,
|
||||||
|
.ql-formats .ql-line-height.ql-picker .ql-picker-item[data-value]:before {
|
||||||
|
content: attr(data-value) '倍';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字体
|
||||||
|
.ql-picker.ql-font .ql-picker-label:before,
|
||||||
|
.ql-picker.ql-font .ql-picker-item:before {
|
||||||
|
content: '字体(系统默认)';
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-formats .ql-font.ql-picker .ql-picker-label[data-value]:before,
|
||||||
|
.ql-formats .ql-font.ql-picker .ql-picker-item[data-value]:before {
|
||||||
|
content: attr(data-value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字号
|
||||||
|
.ql-picker.ql-size .ql-picker-label:before,
|
||||||
|
.ql-picker.ql-size .ql-picker-item:before {
|
||||||
|
content: '字号(系统默认)';
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-formats .ql-size.ql-picker .ql-picker-label[data-value]:before,
|
||||||
|
.ql-formats .ql-size.ql-picker .ql-picker-item[data-value]:before {
|
||||||
|
content: attr(data-value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标题
|
||||||
|
.ql-picker.ql-header .ql-picker-label:before,
|
||||||
|
.ql-picker.ql-header .ql-picker-item:before {
|
||||||
|
content: '标题(默认)';
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-formats .ql-header.ql-picker .ql-picker-label[data-value]:before,
|
||||||
|
.ql-formats .ql-header.ql-picker .ql-picker-item[data-value]:before {
|
||||||
|
content: '标题(H' attr(data-value) ')';
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.rest-basic-editor {
|
.rest-basic-editor {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
&.hide-toolbar :deep(.ql-toolbar) {
|
&.hide-toolbar :deep(.ql-toolbar) {
|
||||||
display: none;
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep() {
|
||||||
|
.ql-container.ql-snow .toolbar-tip__tooltip {
|
||||||
|
position: fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep() {
|
.ql-container.ql-snow {
|
||||||
.ql-container.ql-snow .toolbar-tip__tooltip {
|
display: flex;
|
||||||
position: fixed;
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-editor {
|
||||||
|
overflow-y: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-toolbar.ql-snow > .ql-formats {
|
||||||
|
position: relative;
|
||||||
|
padding: 6px 10px;
|
||||||
|
margin: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 1px;
|
||||||
|
height: 100%;
|
||||||
|
content: '';
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent 0%,
|
||||||
|
transparent 26%,
|
||||||
|
#dcdada 20%,
|
||||||
|
#dcdada 80%,
|
||||||
|
transparent 74%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ql-container.ql-snow {
|
.ql-picker {
|
||||||
display: flex;
|
width: auto;
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
.ql-picker-label {
|
||||||
width: 100%;
|
&:before {
|
||||||
min-height: 0;
|
width: auto;
|
||||||
overflow-y: auto;
|
padding-right: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
right: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ql-editor {
|
&:first-child {
|
||||||
overflow-y: visible;
|
padding-left: 0;
|
||||||
}
|
|
||||||
|
|
||||||
.ql-toolbar.ql-snow > .ql-formats {
|
|
||||||
position: relative;
|
|
||||||
padding: 6px 10px;
|
|
||||||
margin: 0;
|
|
||||||
margin-right: 0;
|
|
||||||
|
|
||||||
&:before {
|
&:before {
|
||||||
position: absolute;
|
display: none;
|
||||||
top: 0;
|
width: 0;
|
||||||
left: 0;
|
height: 0;
|
||||||
width: 1px;
|
|
||||||
height: 100%;
|
|
||||||
content: '';
|
|
||||||
background: linear-gradient(to bottom, transparent 0%, transparent 26%, #dcdada 20%, #dcdada 80%, transparent 74%, transparent 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ql-picker {
|
|
||||||
width: auto;
|
|
||||||
|
|
||||||
.ql-picker-label {
|
|
||||||
&:before {
|
|
||||||
width: auto;
|
|
||||||
padding-right: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
right: 6px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:first-child {
|
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
|
background: transparent;
|
||||||
&:before {
|
|
||||||
display: none;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
padding-left: 0;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
> .ql-picker.ql-expanded .ql-picker-options {
|
> .ql-picker.ql-expanded .ql-picker-options {
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,23 +1,23 @@
|
|||||||
import type { ToolbarOptions } from '@opentiny/fluent-editor';
|
import type { ToolbarOptions } from '@opentiny/fluent-editor'
|
||||||
import type { MY_OPEN_PROCESSON_IFRAME } from './editor-util';
|
import type { MY_OPEN_PROCESSON_IFRAME } from './editor-util'
|
||||||
|
|
||||||
export type MyToolbarOption = {
|
export interface MyToolbarOption {
|
||||||
/** 是否显示工具栏 */
|
/** 是否显示工具栏 */
|
||||||
show?: boolean;
|
show?: boolean
|
||||||
/** 是否显示上传文件按钮 */
|
/** 是否显示上传文件按钮 */
|
||||||
showUpFile?: boolean;
|
showUpFile?: boolean
|
||||||
/** 是否显示上传视频按钮 */
|
/** 是否显示上传视频按钮 */
|
||||||
showUpVideo?: boolean;
|
showUpVideo?: boolean
|
||||||
/** 自定义工具栏 */
|
/** 自定义工具栏 */
|
||||||
customToolbar?: ToolbarOptions['container'];
|
customToolbar?: ToolbarOptions['container']
|
||||||
};
|
}
|
||||||
|
|
||||||
export type GetEditorConfigParams = {
|
export interface GetEditorConfigParams {
|
||||||
toolbarOption?: MyToolbarOption;
|
toolbarOption?: MyToolbarOption
|
||||||
[MY_OPEN_PROCESSON_IFRAME]: () => void;
|
[MY_OPEN_PROCESSON_IFRAME]: () => void
|
||||||
};
|
}
|
||||||
export type UpFile = {
|
export interface UpFile {
|
||||||
id: string;
|
id: string
|
||||||
name: string;
|
name: string
|
||||||
progress: number;
|
progress: number
|
||||||
};
|
}
|
||||||
|
|||||||
@ -5,14 +5,17 @@
|
|||||||
## 1. 快速开始
|
## 1. 快速开始
|
||||||
|
|
||||||
### 1.1 使用 Iconify 图标 (推荐)
|
### 1.1 使用 Iconify 图标 (推荐)
|
||||||
|
|
||||||
本项目集成了 [Iconify](https://iconify.design/),可直接使用海量开源图标库(如 Material Design, Carbon, Phosphor 等)。
|
本项目集成了 [Iconify](https://iconify.design/),可直接使用海量开源图标库(如 Material Design, Carbon, Phosphor 等)。
|
||||||
|
|
||||||
**语法**:
|
**语法**:
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<SvgIcon icon="图集名:图标名" />
|
<SvgIcon icon="图集名:图标名" />
|
||||||
```
|
```
|
||||||
|
|
||||||
**示例**:
|
**示例**:
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<!-- Material Design Icons -->
|
<!-- Material Design Icons -->
|
||||||
<SvgIcon icon="mdi:home" class="text-xl text-blue-500" />
|
<SvgIcon icon="mdi:home" class="text-xl text-blue-500" />
|
||||||
@ -22,18 +25,21 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 1.2 使用本地 SVG 图标
|
### 1.2 使用本地 SVG 图标
|
||||||
|
|
||||||
当需要使用设计师提供的自定义图标或彩色图标时。
|
当需要使用设计师提供的自定义图标或彩色图标时。
|
||||||
|
|
||||||
1. **存放**:将 `.svg` 文件放入 `src/assets/svg-icon/` 目录。
|
1. **存放**:将 `.svg` 文件放入 `src/assets/svg-icon/` 目录。
|
||||||
2. **引用**:使用 `local-icon` 属性引用文件名(不含 `.svg` 后缀)。
|
2. **引用**:使用 `local-icon` 属性引用文件名(不含 `.svg` 后缀)。
|
||||||
|
|
||||||
**语法**:
|
**语法**:
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<SvgIcon local-icon="文件名" />
|
<SvgIcon local-icon="文件名" />
|
||||||
```
|
```
|
||||||
|
|
||||||
**示例**:
|
**示例**:
|
||||||
假设文件位于 `src/assets/svg-icon/custom-logo.svg`:
|
假设文件位于 `src/assets/svg-icon/custom-logo.svg`:
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<SvgIcon local-icon="custom-logo" class="text-32px" />
|
<SvgIcon local-icon="custom-logo" class="text-32px" />
|
||||||
```
|
```
|
||||||
@ -44,12 +50,13 @@
|
|||||||
|
|
||||||
该组件定义在 `src/components/custom/svg-icon.vue`。
|
该组件定义在 `src/components/custom/svg-icon.vue`。
|
||||||
|
|
||||||
| 属性名 | 类型 | 必填 | 默认值 | 说明 |
|
| 属性名 | 类型 | 必填 | 默认值 | 说明 |
|
||||||
| :--- | :--- | :--- | :--- | :--- |
|
| :---------- | :------- | :--- | :----- | :------------------------------------------- |
|
||||||
| `icon` | `string` | 否 | - | Iconify 图标名称,格式为 `collection:name`。 |
|
| `icon` | `string` | 否 | - | Iconify 图标名称,格式为 `collection:name`。 |
|
||||||
| `localIcon` | `string` | 否 | - | 本地 SVG 文件名。**优先级高于 `icon`**。 |
|
| `localIcon` | `string` | 否 | - | 本地 SVG 文件名。**优先级高于 `icon`**。 |
|
||||||
|
|
||||||
> **提示**:
|
> **提示**:
|
||||||
|
>
|
||||||
> - 组件设置了 `inheritAttrs: false`,但会手动绑定 `class` 和 `style` 到根元素。
|
> - 组件设置了 `inheritAttrs: false`,但会手动绑定 `class` 和 `style` 到根元素。
|
||||||
> - 你可以通过 Tailwind CSS 类名(如 `text-xl`, `text-red-500`)直接控制图标的大小和颜色。
|
> - 你可以通过 Tailwind CSS 类名(如 `text-xl`, `text-red-500`)直接控制图标的大小和颜色。
|
||||||
|
|
||||||
@ -60,29 +67,34 @@
|
|||||||
推荐使用 [Icones.js.org](https://icones.js.org/) 图标搜索引擎。
|
推荐使用 [Icones.js.org](https://icones.js.org/) 图标搜索引擎。
|
||||||
|
|
||||||
### 3.1 查找步骤
|
### 3.1 查找步骤
|
||||||
|
|
||||||
1. 打开 [Icones.js.org](https://icones.js.org/)。
|
1. 打开 [Icones.js.org](https://icones.js.org/)。
|
||||||
2. 输入关键词搜索(如 `user`, `setting`)。
|
2. 输入关键词搜索(如 `user`, `setting`)。
|
||||||
3. 点击选中的图标,复制底部的 **ID**(例如 `mdi:shield-airplane-outline`)。
|
3. 点击选中的图标,复制底部的 **ID**(例如 `mdi:shield-airplane-outline`)。
|
||||||
|
|
||||||
### 3.2 使用示例对照
|
### 3.2 使用示例对照
|
||||||
|
|
||||||
| Icones 图标 ID | 在本项目中的写法 (推荐) |
|
| Icones 图标 ID | 在本项目中的写法 (推荐) |
|
||||||
| :--- | :--- |
|
| :--------------------------------------------- | :---------------------------------------------------------------- |
|
||||||
| `mdi:shield-airplane-outline` | `<SvgIcon icon="mdi:shield-airplane-outline" />` |
|
| `mdi:shield-airplane-outline` | `<SvgIcon icon="mdi:shield-airplane-outline" />` |
|
||||||
| `material-symbols:android-wifi-4-bar-question` | `<SvgIcon icon="material-symbols:android-wifi-4-bar-question" />` |
|
| `material-symbols:android-wifi-4-bar-question` | `<SvgIcon icon="material-symbols:android-wifi-4-bar-question" />` |
|
||||||
| `solar:minimize-square-minimalistic-outline` | `<SvgIcon icon="solar:minimize-square-minimalistic-outline" />` |
|
| `solar:minimize-square-minimalistic-outline` | `<SvgIcon icon="solar:minimize-square-minimalistic-outline" />` |
|
||||||
| `tabler:align-box-top-right` | `<SvgIcon icon="tabler:align-box-top-right" />` |
|
| `tabler:align-box-top-right` | `<SvgIcon icon="tabler:align-box-top-right" />` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 常见问题与进阶
|
## 4. 常见问题与进阶
|
||||||
|
|
||||||
### 4.1 什么是 `icon-ic-baseline-refresh`?
|
### 4.1 什么是 `icon-ic-baseline-refresh`?
|
||||||
|
|
||||||
在项目中(如 `competition-add/index.vue`)你可能会看到这种写法:
|
在项目中(如 `competition-add/index.vue`)你可能会看到这种写法:
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<icon-ic-baseline-refresh class="text-icon" />
|
<icon-ic-baseline-refresh class="text-icon" />
|
||||||
```
|
```
|
||||||
|
|
||||||
这是 `unplugin-icons` 插件提供的**自动组件导入**功能。
|
这是 `unplugin-icons` 插件提供的**自动组件导入**功能。
|
||||||
|
|
||||||
- **命名规则**:`{Prefix}-{Collection}-{Name}`
|
- **命名规则**:`{Prefix}-{Collection}-{Name}`
|
||||||
- **配置来源**:`.env` 文件中的 `VITE_ICON_PREFIX=icon`。
|
- **配置来源**:`.env` 文件中的 `VITE_ICON_PREFIX=icon`。
|
||||||
- **解析**:`icon-ic-baseline-refresh` 对应图集 `ic` 下的 `baseline-refresh` 图标。
|
- **解析**:`icon-ic-baseline-refresh` 对应图集 `ic` 下的 `baseline-refresh` 图标。
|
||||||
@ -90,6 +102,7 @@
|
|||||||
**建议**:虽然支持这种写法,但为了统一性和灵活性(支持动态变量),**推荐统一使用 `<SvgIcon />` 组件**。
|
**建议**:虽然支持这种写法,但为了统一性和灵活性(支持动态变量),**推荐统一使用 `<SvgIcon />` 组件**。
|
||||||
|
|
||||||
### 4.2 在 Render 函数中使用 (TSX)
|
### 4.2 在 Render 函数中使用 (TSX)
|
||||||
|
|
||||||
在 Naive UI 的 `NTree`, `NDataTable` 或 `NMenu` 等需要渲染函数的场景中:
|
在 Naive UI 的 `NTree`, `NDataTable` 或 `NMenu` 等需要渲染函数的场景中:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
@ -98,16 +111,16 @@ import SvgIcon from '@/components/custom/svg-icon.vue'
|
|||||||
|
|
||||||
// 渲染 Iconify 图标
|
// 渲染 Iconify 图标
|
||||||
function renderIcon() {
|
function renderIcon() {
|
||||||
return h(SvgIcon, {
|
return h(SvgIcon, {
|
||||||
icon: 'carbon:folder',
|
icon: 'carbon:folder',
|
||||||
class: 'text-gray-500'
|
class: 'text-gray-500'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 渲染本地图标
|
// 渲染本地图标
|
||||||
function renderLocalIcon() {
|
function renderLocalIcon() {
|
||||||
return h(SvgIcon, {
|
return h(SvgIcon, {
|
||||||
localIcon: 'custom-logo'
|
localIcon: 'custom-logo'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@ -1,29 +1,29 @@
|
|||||||
import { transformRecordToOption } from '@/utils/common';
|
import { transformRecordToOption } from '@/utils/common'
|
||||||
|
|
||||||
export const enableStatusRecord: Record<Api.Common.EnableStatus, string> = {
|
export const enableStatusRecord: Record<Api.Common.EnableStatus, string> = {
|
||||||
'1': '启用',
|
1: '启用',
|
||||||
'2': '禁用'
|
2: '禁用',
|
||||||
};
|
}
|
||||||
|
|
||||||
export const enableStatusOptions = transformRecordToOption(enableStatusRecord);
|
export const enableStatusOptions = transformRecordToOption(enableStatusRecord)
|
||||||
|
|
||||||
export const userGenderRecord: Record<Api.SystemManage.UserGender, string> = {
|
export const userGenderRecord: Record<Api.SystemManage.UserGender, string> = {
|
||||||
'1': '男',
|
1: '男',
|
||||||
'2': '女'
|
2: '女',
|
||||||
};
|
}
|
||||||
|
|
||||||
export const userGenderOptions = transformRecordToOption(userGenderRecord);
|
export const userGenderOptions = transformRecordToOption(userGenderRecord)
|
||||||
|
|
||||||
export const menuTypeRecord: Record<Api.SystemManage.MenuType, string> = {
|
export const menuTypeRecord: Record<Api.SystemManage.MenuType, string> = {
|
||||||
'1': '目录',
|
1: '目录',
|
||||||
'2': '菜单'
|
2: '菜单',
|
||||||
};
|
}
|
||||||
|
|
||||||
export const menuTypeOptions = transformRecordToOption(menuTypeRecord);
|
export const menuTypeOptions = transformRecordToOption(menuTypeRecord)
|
||||||
|
|
||||||
export const menuIconTypeRecord: Record<Api.SystemManage.IconType, string> = {
|
export const menuIconTypeRecord: Record<Api.SystemManage.IconType, string> = {
|
||||||
'1': 'iconify',
|
1: 'iconify',
|
||||||
'2': '本地图标'
|
2: '本地图标',
|
||||||
};
|
}
|
||||||
|
|
||||||
export const menuIconTypeOptions = transformRecordToOption(menuIconTypeRecord);
|
export const menuIconTypeOptions = transformRecordToOption(menuIconTypeRecord)
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
export * from './auth'
|
export * from './auth'
|
||||||
export * from './route'
|
export * from './route'
|
||||||
export * from './system-manage';
|
export * from './system-manage'
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import { request } from '../request';
|
import { request } from '../request'
|
||||||
|
|
||||||
/** get role list */
|
/** get role list */
|
||||||
export function fetchGetRoleList(params?: Api.SystemManage.RoleSearchParams) {
|
export function fetchGetRoleList(params?: Api.SystemManage.RoleSearchParams) {
|
||||||
return request<Api.SystemManage.RoleList>({
|
return request<Api.SystemManage.RoleList>({
|
||||||
url: '/systemManage/getRoleList',
|
url: '/systemManage/getRoleList',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
params
|
params,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -17,8 +17,8 @@ export function fetchGetRoleList(params?: Api.SystemManage.RoleSearchParams) {
|
|||||||
export function fetchGetAllRoles() {
|
export function fetchGetAllRoles() {
|
||||||
return request<Api.SystemManage.AllRole[]>({
|
return request<Api.SystemManage.AllRole[]>({
|
||||||
url: '/systemManage/getAllRoles',
|
url: '/systemManage/getAllRoles',
|
||||||
method: 'get'
|
method: 'get',
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** get user list */
|
/** get user list */
|
||||||
@ -26,30 +26,30 @@ export function fetchGetUserList(params?: Api.SystemManage.UserSearchParams) {
|
|||||||
return request<Api.SystemManage.UserList>({
|
return request<Api.SystemManage.UserList>({
|
||||||
url: '/systemManage/getUserList',
|
url: '/systemManage/getUserList',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
params
|
params,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** get menu list */
|
/** get menu list */
|
||||||
export function fetchGetMenuList() {
|
export function fetchGetMenuList() {
|
||||||
return request<Api.SystemManage.MenuList>({
|
return request<Api.SystemManage.MenuList>({
|
||||||
url: '/systemManage/getMenuList/v2',
|
url: '/systemManage/getMenuList/v2',
|
||||||
method: 'get'
|
method: 'get',
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** get all pages */
|
/** get all pages */
|
||||||
export function fetchGetAllPages() {
|
export function fetchGetAllPages() {
|
||||||
return request<string[]>({
|
return request<string[]>({
|
||||||
url: '/systemManage/getAllPages',
|
url: '/systemManage/getAllPages',
|
||||||
method: 'get'
|
method: 'get',
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** get menu tree */
|
/** get menu tree */
|
||||||
export function fetchGetMenuTree() {
|
export function fetchGetMenuTree() {
|
||||||
return request<Api.SystemManage.MenuTree[]>({
|
return request<Api.SystemManage.MenuTree[]>({
|
||||||
url: '/systemManage/getMenuTree',
|
url: '/systemManage/getMenuTree',
|
||||||
method: 'get'
|
method: 'get',
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,26 +1,24 @@
|
|||||||
import { request } from '../request'
|
import { request } from '../request'
|
||||||
|
|
||||||
|
|
||||||
export interface ReqDeleteAliOssFile {
|
export interface ReqDeleteAliOssFile {
|
||||||
key: string;
|
key: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AliOssSTS = {
|
export interface AliOssSTS {
|
||||||
AccessKeyId: string;
|
AccessKeyId: string
|
||||||
AccessKeySecret: string;
|
AccessKeySecret: string
|
||||||
SecurityToken: string;
|
SecurityToken: string
|
||||||
Expiration: string;
|
Expiration: string
|
||||||
BucketName: string;
|
BucketName: string
|
||||||
Region: string;
|
Region: string
|
||||||
};
|
}
|
||||||
|
|
||||||
|
|
||||||
/** 得到上传的token */
|
/** 得到上传的token */
|
||||||
export function getAliOssTokenAxios(){
|
export function getAliOssTokenAxios() {
|
||||||
return request<AliOssSTS>({
|
return request<AliOssSTS>({
|
||||||
url: `${import.meta.env.VITE_BASE_UPLOAD_URL}/admin/v1/oss/sts`,
|
url: `${import.meta.env.VITE_BASE_UPLOAD_URL}/admin/v1/oss/sts`,
|
||||||
method: 'get',
|
method: 'get',
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除阿里云oss 文件 */
|
/** 删除阿里云oss 文件 */
|
||||||
@ -29,5 +27,5 @@ export function deleteAliOssFileAxios(data: ReqDeleteAliOssFile) {
|
|||||||
url: `${import.meta.env.VITE_BASE_UPLOAD_URL}/admin/v1/oss/delete`,
|
url: `${import.meta.env.VITE_BASE_UPLOAD_URL}/admin/v1/oss/delete`,
|
||||||
method: 'post',
|
method: 'post',
|
||||||
data,
|
data,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -165,7 +165,8 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
|||||||
// 如果是静态路由模式,直接添加静态常量路由
|
// 如果是静态路由模式,直接添加静态常量路由
|
||||||
if (authRouteMode.value === 'static') { // 静态路由模式
|
if (authRouteMode.value === 'static') { // 静态路由模式
|
||||||
addConstantRoutes(staticRoute.constantRoutes)
|
addConstantRoutes(staticRoute.constantRoutes)
|
||||||
}else {// 动态路由模式
|
}
|
||||||
|
else { // 动态路由模式
|
||||||
const { data, error } = await fetchGetConstantRoutes()
|
const { data, error } = await fetchGetConstantRoutes()
|
||||||
|
|
||||||
if (!error) {
|
if (!error) {
|
||||||
|
|||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
108
apps/admin/src/typings/api/system-manage.d.ts
vendored
108
apps/admin/src/typings/api/system-manage.d.ts
vendored
@ -5,28 +5,28 @@ declare namespace Api {
|
|||||||
* backend api module: "systemManage"
|
* backend api module: "systemManage"
|
||||||
*/
|
*/
|
||||||
namespace SystemManage {
|
namespace SystemManage {
|
||||||
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'current' | 'size'>;
|
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'current' | 'size'>
|
||||||
|
|
||||||
/** role */
|
/** role */
|
||||||
type Role = Common.CommonRecord<{
|
type Role = Common.CommonRecord<{
|
||||||
/** role name */
|
/** role name */
|
||||||
roleName: string;
|
roleName: string
|
||||||
/** role code */
|
/** role code */
|
||||||
roleCode: string;
|
roleCode: string
|
||||||
/** role description */
|
/** role description */
|
||||||
roleDesc: string;
|
roleDesc: string
|
||||||
}>;
|
}>
|
||||||
|
|
||||||
/** role search params */
|
/** role search params */
|
||||||
type RoleSearchParams = CommonType.RecordNullable<
|
type RoleSearchParams = CommonType.RecordNullable<
|
||||||
Pick<Api.SystemManage.Role, 'roleName' | 'roleCode' | 'status'> & CommonSearchParams
|
Pick<Api.SystemManage.Role, 'roleName' | 'roleCode' | 'status'> & CommonSearchParams
|
||||||
>;
|
>
|
||||||
|
|
||||||
/** role list */
|
/** role list */
|
||||||
type RoleList = Common.PaginatingQueryRecord<Role>;
|
type RoleList = Common.PaginatingQueryRecord<Role>
|
||||||
|
|
||||||
/** all role */
|
/** all role */
|
||||||
type AllRole = Pick<Role, 'id' | 'roleName' | 'roleCode'>;
|
type AllRole = Pick<Role, 'id' | 'roleName' | 'roleCode'>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* user gender
|
* user gender
|
||||||
@ -34,32 +34,32 @@ declare namespace Api {
|
|||||||
* - "1": "male"
|
* - "1": "male"
|
||||||
* - "2": "female"
|
* - "2": "female"
|
||||||
*/
|
*/
|
||||||
type UserGender = '1' | '2';
|
type UserGender = '1' | '2'
|
||||||
|
|
||||||
/** user */
|
/** user */
|
||||||
type User = Common.CommonRecord<{
|
type User = Common.CommonRecord<{
|
||||||
/** user name */
|
/** user name */
|
||||||
userName: string;
|
userName: string
|
||||||
/** user gender */
|
/** user gender */
|
||||||
userGender: UserGender | null;
|
userGender: UserGender | null
|
||||||
/** user nick name */
|
/** user nick name */
|
||||||
nickName: string;
|
nickName: string
|
||||||
/** user phone */
|
/** user phone */
|
||||||
userPhone: string;
|
userPhone: string
|
||||||
/** user email */
|
/** user email */
|
||||||
userEmail: string;
|
userEmail: string
|
||||||
/** user role code collection */
|
/** user role code collection */
|
||||||
userRoles: string[];
|
userRoles: string[]
|
||||||
}>;
|
}>
|
||||||
|
|
||||||
/** user search params */
|
/** user search params */
|
||||||
type UserSearchParams = CommonType.RecordNullable<
|
type UserSearchParams = CommonType.RecordNullable<
|
||||||
Pick<Api.SystemManage.User, 'userName' | 'userGender' | 'nickName' | 'userPhone' | 'userEmail' | 'status'> &
|
Pick<Api.SystemManage.User, 'userName' | 'userGender' | 'nickName' | 'userPhone' | 'userEmail' | 'status'> &
|
||||||
CommonSearchParams
|
CommonSearchParams
|
||||||
>;
|
>
|
||||||
|
|
||||||
/** user list */
|
/** user list */
|
||||||
type UserList = Common.PaginatingQueryRecord<User>;
|
type UserList = Common.PaginatingQueryRecord<User>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* menu type
|
* menu type
|
||||||
@ -67,18 +67,18 @@ declare namespace Api {
|
|||||||
* - "1": directory
|
* - "1": directory
|
||||||
* - "2": menu
|
* - "2": menu
|
||||||
*/
|
*/
|
||||||
type MenuType = '1' | '2';
|
type MenuType = '1' | '2'
|
||||||
|
|
||||||
type MenuButton = {
|
interface MenuButton {
|
||||||
/**
|
/**
|
||||||
* button code
|
* button code
|
||||||
*
|
*
|
||||||
* it can be used to control the button permission
|
* it can be used to control the button permission
|
||||||
*/
|
*/
|
||||||
code: string;
|
code: string
|
||||||
/** button description */
|
/** button description */
|
||||||
desc: string;
|
desc: string
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* icon type
|
* icon type
|
||||||
@ -86,54 +86,54 @@ declare namespace Api {
|
|||||||
* - "1": iconify icon
|
* - "1": iconify icon
|
||||||
* - "2": local icon
|
* - "2": local icon
|
||||||
*/
|
*/
|
||||||
type IconType = '1' | '2';
|
type IconType = '1' | '2'
|
||||||
|
|
||||||
type MenuPropsOfRoute = Pick<
|
type MenuPropsOfRoute = Pick<
|
||||||
import('vue-router').RouteMeta,
|
import('vue-router').RouteMeta,
|
||||||
| 'i18nKey'
|
| 'i18nKey'
|
||||||
| 'keepAlive'
|
| 'keepAlive'
|
||||||
| 'constant'
|
| 'constant'
|
||||||
| 'order'
|
| 'order'
|
||||||
| 'href'
|
| 'href'
|
||||||
| 'hideInMenu'
|
| 'hideInMenu'
|
||||||
| 'activeMenu'
|
| 'activeMenu'
|
||||||
| 'multiTab'
|
| 'multiTab'
|
||||||
| 'fixedIndexInTab'
|
| 'fixedIndexInTab'
|
||||||
| 'query'
|
| 'query'
|
||||||
>;
|
>
|
||||||
|
|
||||||
type Menu = Common.CommonRecord<{
|
type Menu = Common.CommonRecord<{
|
||||||
/** parent menu id */
|
/** parent menu id */
|
||||||
parentId: number;
|
parentId: number
|
||||||
/** menu type */
|
/** menu type */
|
||||||
menuType: MenuType;
|
menuType: MenuType
|
||||||
/** menu name */
|
/** menu name */
|
||||||
menuName: string;
|
menuName: string
|
||||||
/** route name */
|
/** route name */
|
||||||
routeName: string;
|
routeName: string
|
||||||
/** route path */
|
/** route path */
|
||||||
routePath: string;
|
routePath: string
|
||||||
/** component */
|
/** component */
|
||||||
component?: string;
|
component?: string
|
||||||
/** iconify icon name or local icon name */
|
/** iconify icon name or local icon name */
|
||||||
icon: string;
|
icon: string
|
||||||
/** icon type */
|
/** icon type */
|
||||||
iconType: IconType;
|
iconType: IconType
|
||||||
/** buttons */
|
/** buttons */
|
||||||
buttons?: MenuButton[] | null;
|
buttons?: MenuButton[] | null
|
||||||
/** children menu */
|
/** children menu */
|
||||||
children?: Menu[] | null;
|
children?: Menu[] | null
|
||||||
}> &
|
}> &
|
||||||
MenuPropsOfRoute;
|
MenuPropsOfRoute
|
||||||
|
|
||||||
/** menu list */
|
/** menu list */
|
||||||
type MenuList = Common.PaginatingQueryRecord<Menu>;
|
type MenuList = Common.PaginatingQueryRecord<Menu>
|
||||||
|
|
||||||
type MenuTree = {
|
interface MenuTree {
|
||||||
id: number;
|
id: number
|
||||||
label: string;
|
label: string
|
||||||
pId: number;
|
pId: number
|
||||||
children?: MenuTree[];
|
children?: MenuTree[]
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import path from 'path-browserify';
|
import path from 'path-browserify'
|
||||||
|
|
||||||
export function getCurrentYear(): Date {
|
export function getCurrentYear(): Date {
|
||||||
return new Date();
|
return new Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -11,29 +11,29 @@ export function getCurrentYear(): Date {
|
|||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export function getTrueRandomInt(min: number, max: number) {
|
export function getTrueRandomInt(min: number, max: number) {
|
||||||
const range = max - min + 1;
|
const range = max - min + 1
|
||||||
const maxSafe = 0xffffffff; // 32位最大无符号整数 (2^32 - 1)
|
const maxSafe = 0xFFFFFFFF // 32位最大无符号整数 (2^32 - 1)
|
||||||
let randomValue = 0;
|
let randomValue = 0
|
||||||
|
|
||||||
do {
|
do {
|
||||||
const buffer = new Uint32Array(1);
|
const buffer = new Uint32Array(1)
|
||||||
window.crypto.getRandomValues(buffer);
|
window.crypto.getRandomValues(buffer)
|
||||||
randomValue = (buffer[0]! / (maxSafe + 1)) * range; // 转换为[0, range)的浮点数
|
randomValue = (buffer[0]! / (maxSafe + 1)) * range // 转换为[0, range)的浮点数
|
||||||
} while (randomValue >= range); // 拒绝采样避免偏差
|
} while (randomValue >= range) // 拒绝采样避免偏差
|
||||||
|
|
||||||
return Math.floor(randomValue) + min;
|
return Math.floor(randomValue) + min
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 路径拼接
|
* 路径拼接
|
||||||
*/
|
*/
|
||||||
export function browserPathJoin(base: string, ...paths: string[]) {
|
export function browserPathJoin(base: string, ...paths: string[]) {
|
||||||
const [protocol, ...rest] = base.split('://');
|
const [protocol, ...rest] = base.split('://')
|
||||||
if (rest.length > 0) {
|
if (rest.length > 0) {
|
||||||
const pathPart = rest.join('://').replace(/\/+/g, '/');
|
const pathPart = rest.join('://').replace(/\/+/g, '/')
|
||||||
return `${protocol}://${path.join(pathPart, ...paths)}`;
|
return `${protocol}://${path.join(pathPart, ...paths)}`
|
||||||
}
|
}
|
||||||
return path.join(base, ...paths);
|
return path.join(base, ...paths)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -42,7 +42,7 @@ export function browserPathJoin(base: string, ...paths: string[]) {
|
|||||||
export function nextTickSleep() {
|
export function nextTickSleep() {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
resolve(true);
|
resolve(true)
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,22 +1,22 @@
|
|||||||
import { snapdom } from '@zumer/snapdom';
|
import { snapdom } from '@zumer/snapdom'
|
||||||
import { getTrueRandomInt } from './date';
|
import { getTrueRandomInt } from './date'
|
||||||
import { getSnowflake } from './rest';
|
import { getSnowflake } from './rest'
|
||||||
|
|
||||||
/** 通过 url 判断是否为 图片链接 */
|
/** 通过 url 判断是否为 图片链接 */
|
||||||
export function isImageUrl(url: string) {
|
export function isImageUrl(url: string) {
|
||||||
const str = url.split('?')[0]!.substring(url.lastIndexOf('.') + 1) || '';
|
const str = url.split('?')[0]!.substring(url.lastIndexOf('.') + 1) || ''
|
||||||
return ['jpg', 'jpeg', 'png', 'webp', 'svg', 'gif'].includes(str.toLocaleLowerCase());
|
return ['jpg', 'jpeg', 'png', 'webp', 'svg', 'gif'].includes(str.toLocaleLowerCase())
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 通过 url 判断是否为 图片链接 */
|
/** 通过 url 判断是否为 图片链接 */
|
||||||
export function isVideoUrl(url: string) {
|
export function isVideoUrl(url: string) {
|
||||||
const str = url.split('?')[0]!.substring(url.lastIndexOf('.') + 1) || '';
|
const str = url.split('?')[0]!.substring(url.lastIndexOf('.') + 1) || ''
|
||||||
return ['m4v', 'mov', '3gp', '3g2', 'mp4', 'flv', 'f4v', 'webm', 'wmv', 'avi', 'asf'].includes(str.toLocaleLowerCase());
|
return ['m4v', 'mov', '3gp', '3g2', 'mp4', 'flv', 'f4v', 'webm', 'wmv', 'avi', 'asf'].includes(str.toLocaleLowerCase())
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 得到 */
|
/** 得到 */
|
||||||
export function getAssetsFile(url: string) {
|
export function getAssetsFile(url: string) {
|
||||||
return new URL(url, import.meta.url).href;
|
return new URL(url, import.meta.url).href
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -28,31 +28,31 @@ export function getAssetsFile(url: string) {
|
|||||||
export function selectFiles(accept = '*/*', multiple = false): Promise<File[]> {
|
export function selectFiles(accept = '*/*', multiple = false): Promise<File[]> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// 1. 创建隐藏的文件输入元素
|
// 1. 创建隐藏的文件输入元素
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input')
|
||||||
input.type = 'file';
|
input.type = 'file'
|
||||||
input.accept = accept;
|
input.accept = accept
|
||||||
input.multiple = multiple;
|
input.multiple = multiple
|
||||||
input.style.display = 'none';
|
input.style.display = 'none'
|
||||||
|
|
||||||
// 2. 监听文件选择事件
|
// 2. 监听文件选择事件
|
||||||
input.addEventListener('change', () => {
|
input.addEventListener('change', () => {
|
||||||
if (!input.files || input.files.length === 0) {
|
if (!input.files || input.files.length === 0) {
|
||||||
reject(new Error('未选择文件'));
|
reject(new Error('未选择文件'))
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 转换为 File 对象数组
|
// 3. 转换为 File 对象数组
|
||||||
const files = Array.from(input.files);
|
const files = Array.from(input.files)
|
||||||
resolve(files);
|
resolve(files)
|
||||||
|
|
||||||
// 4. 清理DOM
|
// 4. 清理DOM
|
||||||
document.body.removeChild(input);
|
document.body.removeChild(input)
|
||||||
});
|
})
|
||||||
|
|
||||||
// 5. 触发文件选择弹窗
|
// 5. 触发文件选择弹窗
|
||||||
document.body.appendChild(input);
|
document.body.appendChild(input)
|
||||||
input.click();
|
input.click()
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -63,54 +63,55 @@ export function selectFiles(accept = '*/*', multiple = false): Promise<File[]> {
|
|||||||
*/
|
*/
|
||||||
export function base64ToFile(dataUrl: string, filename: string): File {
|
export function base64ToFile(dataUrl: string, filename: string): File {
|
||||||
// 拆分 Data URL
|
// 拆分 Data URL
|
||||||
const arr = dataUrl.split(',');
|
const arr = dataUrl.split(',')
|
||||||
const mimeMatch = arr[0]?.match(/:(.*?);/) || null;
|
const mimeMatch = arr[0]?.match(/:(.*?);/) || null
|
||||||
if (!mimeMatch || !mimeMatch[1] || !arr[1]) {
|
if (!mimeMatch || !mimeMatch[1] || !arr[1]) {
|
||||||
throw new Error('无效的Base64字符串');
|
throw new Error('无效的Base64字符串')
|
||||||
}
|
}
|
||||||
const mime = mimeMatch[1]; // 提取 MIME 类型(如 "image/png")
|
const mime = mimeMatch[1] // 提取 MIME 类型(如 "image/png")
|
||||||
const bstr = atob(arr[1]); // Base64 解码
|
const bstr = atob(arr[1]) // Base64 解码
|
||||||
const n = bstr.length;
|
const n = bstr.length
|
||||||
const u8arr = new Uint8Array(n);
|
const u8arr = new Uint8Array(n)
|
||||||
|
|
||||||
// 将解码后的二进制数据存入 Uint8Array
|
// 将解码后的二进制数据存入 Uint8Array
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
u8arr[i] = bstr.charCodeAt(i);
|
u8arr[i] = bstr.charCodeAt(i)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成 File 对象
|
// 生成 File 对象
|
||||||
return new File([u8arr], filename, { type: mime });
|
return new File([u8arr], filename, { type: mime })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将图片file转换为Base64对象
|
* 将图片file转换为Base64对象
|
||||||
*/
|
*/
|
||||||
export function fileToBase64(file: File): Promise<{ id: string; img: string }> {
|
export function fileToBase64(file: File): Promise<{ id: string, img: string }> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader()
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file)
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
const date = new Date().getTime();
|
const date = new Date().getTime()
|
||||||
const num = getTrueRandomInt(100000000, 99999999999);
|
const num = getTrueRandomInt(100000000, 99999999999)
|
||||||
if (file.size > 1024 * 40) {
|
if (file.size > 1024 * 40) {
|
||||||
// 只有大于40kb才压缩
|
// 只有大于40kb才压缩
|
||||||
const img = new Image();
|
const img = new Image()
|
||||||
img.src = String(e.target?.result || '');
|
img.src = String(e.target?.result || '')
|
||||||
img.onload = async () => {
|
img.onload = async () => {
|
||||||
const Base64Url = await compressImg(img, file.type);
|
const Base64Url = await compressImg(img, file.type)
|
||||||
resolve({ id: `id_${date}_${num}`, img: Base64Url });
|
resolve({ id: `id_${date}_${num}`, img: Base64Url })
|
||||||
};
|
}
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
reject('加载图片失败,002');
|
reject('加载图片失败,002')
|
||||||
};
|
}
|
||||||
} else {
|
|
||||||
resolve({ id: `id_${date}_${num}`, img: String(e.target?.result || '') });
|
|
||||||
}
|
}
|
||||||
};
|
else {
|
||||||
|
resolve({ id: `id_${date}_${num}`, img: String(e.target?.result || '') })
|
||||||
|
}
|
||||||
|
}
|
||||||
reader.onerror = () => {
|
reader.onerror = () => {
|
||||||
reject('加载图片失败,001');
|
reject('加载图片失败,001')
|
||||||
};
|
}
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -123,41 +124,42 @@ export function fileToBase64(file: File): Promise<{ id: string; img: string }> {
|
|||||||
*/
|
*/
|
||||||
export function compressImg(img: HTMLImageElement, imgType = 'image/jpeg', mx = 720, mh = 1280, quality = 0.8): Promise<string> {
|
export function compressImg(img: HTMLImageElement, imgType = 'image/jpeg', mx = 720, mh = 1280, quality = 0.8): Promise<string> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas')
|
||||||
const context = canvas.getContext('2d');
|
const context = canvas.getContext('2d')
|
||||||
const { width: originWidth, height: originHeight } = img;
|
const { width: originWidth, height: originHeight } = img
|
||||||
// 最大尺寸限制
|
// 最大尺寸限制
|
||||||
const maxWidth = mx;
|
const maxWidth = mx
|
||||||
const maxHeight = mh;
|
const maxHeight = mh
|
||||||
// 目标尺寸
|
// 目标尺寸
|
||||||
let targetWidth = originWidth;
|
let targetWidth = originWidth
|
||||||
let targetHeight = originHeight;
|
let targetHeight = originHeight
|
||||||
if (originWidth > maxWidth || originHeight > maxHeight) {
|
if (originWidth > maxWidth || originHeight > maxHeight) {
|
||||||
if (originWidth / originHeight > 1) {
|
if (originWidth / originHeight > 1) {
|
||||||
// 宽图片
|
// 宽图片
|
||||||
targetWidth = maxWidth;
|
targetWidth = maxWidth
|
||||||
targetHeight = Math.round(maxWidth * (originHeight / originWidth));
|
targetHeight = Math.round(maxWidth * (originHeight / originWidth))
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
// 高图片
|
// 高图片
|
||||||
targetHeight = maxHeight;
|
targetHeight = maxHeight
|
||||||
targetWidth = Math.round(maxHeight * (originWidth / originHeight));
|
targetWidth = Math.round(maxHeight * (originWidth / originHeight))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
canvas.width = targetWidth;
|
canvas.width = targetWidth
|
||||||
canvas.height = targetHeight;
|
canvas.height = targetHeight
|
||||||
if (context) {
|
if (context) {
|
||||||
context.clearRect(0, 0, targetWidth, targetHeight);
|
context.clearRect(0, 0, targetWidth, targetHeight)
|
||||||
// 图片绘制
|
// 图片绘制
|
||||||
context.drawImage(img, 0, 0, targetWidth, targetHeight);
|
context.drawImage(img, 0, 0, targetWidth, targetHeight)
|
||||||
}
|
}
|
||||||
const dataURL = canvas.toDataURL(imgType, quality); // 转换图片为dataURL
|
const dataURL = canvas.toDataURL(imgType, quality) // 转换图片为dataURL
|
||||||
// const fun = (blob) => {
|
// const fun = (blob) => {
|
||||||
// resolve(blob);
|
// resolve(blob);
|
||||||
// };
|
// };
|
||||||
// 转换为bolb对象
|
// 转换为bolb对象
|
||||||
// canvas.toBlob(fun, imgType, 0.7);
|
// canvas.toBlob(fun, imgType, 0.7);
|
||||||
resolve(dataURL);
|
resolve(dataURL)
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -166,131 +168,137 @@ export function compressImg(img: HTMLImageElement, imgType = 'image/jpeg', mx =
|
|||||||
* @returns - 第一帧的File对象
|
* @returns - 第一帧的File对象
|
||||||
*/
|
*/
|
||||||
export function getFirstFrameOfVideo(
|
export function getFirstFrameOfVideo(
|
||||||
videoSource: Blob | File | string
|
videoSource: Blob | File | string,
|
||||||
): Promise<{ firstFrame: File; duration: number; videoWidth: number; videoHeight: number }> {
|
): Promise<{ firstFrame: File, duration: number, videoWidth: number, videoHeight: number }> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let url = ''; // 使用createObjectURL创建的URL
|
let url = '' // 使用createObjectURL创建的URL
|
||||||
|
|
||||||
// 创建视频元素
|
// 创建视频元素
|
||||||
const video = document.createElement('video');
|
const video = document.createElement('video')
|
||||||
video.crossOrigin = 'Anonymous';
|
video.crossOrigin = 'Anonymous'
|
||||||
video.setAttribute('playsinline', '');
|
video.setAttribute('playsinline', '')
|
||||||
video.muted = true;
|
video.muted = true
|
||||||
|
|
||||||
// 事件监听:当视频可播放时处理
|
// 事件监听:当视频可播放时处理
|
||||||
video.addEventListener('seeked', () => {
|
video.addEventListener('seeked', () => {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas')
|
||||||
canvas.width = video.videoWidth;
|
canvas.width = video.videoWidth
|
||||||
canvas.height = video.videoHeight;
|
canvas.height = video.videoHeight
|
||||||
|
|
||||||
const context = canvas.getContext('2d');
|
const context = canvas.getContext('2d')
|
||||||
if (context) {
|
if (context) {
|
||||||
context.drawImage(video, 0, 0, canvas.width, canvas.height);
|
context.drawImage(video, 0, 0, canvas.width, canvas.height)
|
||||||
const blobCallback = (blob: Blob | null) => {
|
const blobCallback = (blob: Blob | null) => {
|
||||||
if (blob) {
|
if (blob) {
|
||||||
const name = `${getSnowflake()}_${Math.random().toString(32).substring(2)}.png`;
|
const name = `${getSnowflake()}_${Math.random().toString(32).substring(2)}.png`
|
||||||
// 将 Blob 转换为 File
|
// 将 Blob 转换为 File
|
||||||
const firstFrame = new File([blob], name, { type: blob.type });
|
const firstFrame = new File([blob], name, { type: blob.type })
|
||||||
resolve({ firstFrame, videoWidth: video.videoWidth, videoHeight: video.videoHeight, duration: video.duration });
|
resolve({ firstFrame, videoWidth: video.videoWidth, videoHeight: video.videoHeight, duration: video.duration })
|
||||||
url && URL.revokeObjectURL(url); // 使用createObjectURL创建的URL应在不再需要时通过revokeObjectURL释放,以避免内存泄漏。
|
url && URL.revokeObjectURL(url) // 使用createObjectURL创建的URL应在不再需要时通过revokeObjectURL释放,以避免内存泄漏。
|
||||||
} else {
|
|
||||||
reject(new Error('获取地一帧失败,BD002'));
|
|
||||||
}
|
}
|
||||||
};
|
else {
|
||||||
canvas.toBlob(blobCallback, 'image/jpeg', 0.7);
|
reject(new Error('获取地一帧失败,BD002'))
|
||||||
} else {
|
}
|
||||||
reject(new Error('获取地一帧失败,BD001'));
|
}
|
||||||
|
canvas.toBlob(blobCallback, 'image/jpeg', 0.7)
|
||||||
}
|
}
|
||||||
});
|
else {
|
||||||
|
reject(new Error('获取地一帧失败,BD001'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 错误处理
|
// 错误处理
|
||||||
video.addEventListener('error', () => {
|
video.addEventListener('error', () => {
|
||||||
reject(new Error('获取地一帧失败,BD003'));
|
reject(new Error('获取地一帧失败,BD003'))
|
||||||
});
|
})
|
||||||
|
|
||||||
// 设置视频源
|
// 设置视频源
|
||||||
if (videoSource instanceof Blob || (videoSource as any) instanceof File) {
|
if (videoSource instanceof Blob || (videoSource as any) instanceof File) {
|
||||||
// Blob 或 File
|
// Blob 或 File
|
||||||
url = URL.createObjectURL(videoSource as Blob | File);
|
url = URL.createObjectURL(videoSource as Blob | File)
|
||||||
video.src = url;
|
video.src = url
|
||||||
video.load();
|
video.load()
|
||||||
} else if (typeof videoSource === 'string') {
|
|
||||||
video.src = videoSource;
|
|
||||||
video.load();
|
|
||||||
} else {
|
|
||||||
reject(new Error('不支持此类型'));
|
|
||||||
}
|
}
|
||||||
video.currentTime = 0.01;
|
else if (typeof videoSource === 'string') {
|
||||||
});
|
video.src = videoSource
|
||||||
|
video.load()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
reject(new Error('不支持此类型'))
|
||||||
|
}
|
||||||
|
video.currentTime = 0.01
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 得到图片的宽高
|
* 得到图片的宽高
|
||||||
* @param file - 图片,可以是 url 或者File
|
* @param file - 图片,可以是 url 或者File
|
||||||
*/
|
*/
|
||||||
export function getImageWidthHeight(file: Blob | File | string): Promise<{ width: number; height: number }> {
|
export function getImageWidthHeight(file: Blob | File | string): Promise<{ width: number, height: number }> {
|
||||||
return new Promise<{ width: number; height: number }>((resolve, reject) => {
|
return new Promise<{ width: number, height: number }>((resolve, reject) => {
|
||||||
const img = new Image();
|
const img = new Image()
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
const size = { width: img.width, height: img.height };
|
const size = { width: img.width, height: img.height }
|
||||||
resolve(size);
|
resolve(size)
|
||||||
};
|
}
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
reject(new Error('读取图片失败,BD0001'));
|
reject(new Error('读取图片失败,BD0001'))
|
||||||
};
|
}
|
||||||
|
|
||||||
if (typeof file === 'string') {
|
if (typeof file === 'string') {
|
||||||
img.src = file;
|
img.src = file
|
||||||
} else if (file instanceof File || file instanceof Blob) {
|
|
||||||
img.src = URL.createObjectURL(file);
|
|
||||||
} else {
|
|
||||||
reject(new Error('读取图片失败,BD0002'));
|
|
||||||
}
|
}
|
||||||
});
|
else if (file instanceof File || file instanceof Blob) {
|
||||||
|
img.src = URL.createObjectURL(file)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
reject(new Error('读取图片失败,BD0002'))
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* html 转换为 jpg图片
|
* html 转换为 jpg图片
|
||||||
*/
|
*/
|
||||||
export async function htmlToJpgImgFile(html: string, op: { width: number }): Promise<File> {
|
export async function htmlToJpgImgFile(html: string, op: { width: number }): Promise<File> {
|
||||||
const type: 'jpeg' | 'jpg' | 'png' | 'svg' | 'webp' = 'jpg';
|
const type: 'jpeg' | 'jpg' | 'png' | 'svg' | 'webp' = 'jpg'
|
||||||
// 创建临时容器
|
// 创建临时容器
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div')
|
||||||
container.style.width = `${op.width}px`;
|
container.style.width = `${op.width}px`
|
||||||
container.style.position = 'absolute';
|
container.style.position = 'absolute'
|
||||||
container.style.left = '101vw';
|
container.style.left = '101vw'
|
||||||
container.style.top = '101vh';
|
container.style.top = '101vh'
|
||||||
container.style.zIndex = '-1';
|
container.style.zIndex = '-1'
|
||||||
container.style.backgroundColor = '#ffffff';
|
container.style.backgroundColor = '#ffffff'
|
||||||
container.innerHTML = html;
|
container.innerHTML = html
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container)
|
||||||
try {
|
try {
|
||||||
// 等待图片加载完成
|
// 等待图片加载完成
|
||||||
const images = container.querySelectorAll('img');
|
const images = container.querySelectorAll('img')
|
||||||
const imageLoadPromises = Array.from(images).map((img) => {
|
const imageLoadPromises = Array.from(images).map((img) => {
|
||||||
if (img.complete) {
|
if (img.complete) {
|
||||||
return Promise.resolve();
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
img.onload = () => resolve(null);
|
img.onload = () => resolve(null)
|
||||||
img.onerror = () => resolve(null); // 即使图片加载失败也继续
|
img.onerror = () => resolve(null) // 即使图片加载失败也继续
|
||||||
// 设置超时,防止某些图片一直加载不成功
|
// 设置超时,防止某些图片一直加载不成功
|
||||||
setTimeout(() => resolve(null), 5000);
|
setTimeout(() => resolve(null), 5000)
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
await Promise.all(imageLoadPromises);
|
await Promise.all(imageLoadPromises)
|
||||||
console.log('所有图片加载完成,图片数量:', images.length);
|
console.log('所有图片加载完成,图片数量:', images.length)
|
||||||
|
|
||||||
// 额外等待确保DOM完全渲染
|
// 额外等待确保DOM完全渲染
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
requestAnimationFrame(resolve);
|
requestAnimationFrame(resolve)
|
||||||
}, 100); // 增加等待时间
|
}, 100) // 增加等待时间
|
||||||
});
|
})
|
||||||
|
|
||||||
const name = `${getSnowflake()}_${Math.random().toString(32).substring(2)}.jpg`;
|
const name = `${getSnowflake()}_${Math.random().toString(32).substring(2)}.jpg`
|
||||||
console.log('开始截图,容器宽度:', container.offsetWidth, '高度:', container.offsetHeight);
|
console.log('开始截图,容器宽度:', container.offsetWidth, '高度:', container.offsetHeight)
|
||||||
|
|
||||||
// 使用 snapdom 进行 DOM 快照
|
// 使用 snapdom 进行 DOM 快照
|
||||||
const res = await snapdom(container, {
|
const res = await snapdom(container, {
|
||||||
@ -300,29 +308,31 @@ export async function htmlToJpgImgFile(html: string, op: { width: number }): Pro
|
|||||||
format: type,
|
format: type,
|
||||||
filename: name,
|
filename: name,
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
});
|
})
|
||||||
|
|
||||||
const blob = await res.toBlob({ type, backgroundColor: '#ffffff' });
|
const blob = await res.toBlob({ type, backgroundColor: '#ffffff' })
|
||||||
console.log('截图完成,blob大小:', (blob.size / 1024).toFixed(2), 'KB');
|
console.log('截图完成,blob大小:', (blob.size / 1024).toFixed(2), 'KB')
|
||||||
|
|
||||||
// 将blob转换为base64以便在浏览器查看
|
// 将blob转换为base64以便在浏览器查看
|
||||||
const reader = new FileReader();
|
const reader = new FileReader()
|
||||||
const base64Promise = new Promise<string>((resolve) => {
|
const base64Promise = new Promise<string>((resolve) => {
|
||||||
reader.onloadend = () => {
|
reader.onloadend = () => {
|
||||||
resolve(reader.result as string);
|
resolve(reader.result as string)
|
||||||
};
|
}
|
||||||
reader.readAsDataURL(blob);
|
reader.readAsDataURL(blob)
|
||||||
});
|
})
|
||||||
const base64 = await base64Promise;
|
const base64 = await base64Promise
|
||||||
// console.log("blob=====", blob);
|
// console.log("blob=====", blob);
|
||||||
// console.log("图片base64=====", base64);
|
// console.log("图片base64=====", base64);
|
||||||
// console.log("👆 复制上面的base64到浏览器地址栏查看图片");
|
// console.log("👆 复制上面的base64到浏览器地址栏查看图片");
|
||||||
|
|
||||||
return new File([blob], name, { type: blob.type });
|
return new File([blob], name, { type: blob.type })
|
||||||
} catch (error) {
|
}
|
||||||
console.error('htmlToJpgImgFile 错误:', error);
|
catch (error) {
|
||||||
return Promise.reject(error);
|
console.error('htmlToJpgImgFile 错误:', error)
|
||||||
} finally {
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
// document.body.removeChild(container);
|
// document.body.removeChild(container);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { type AliOssSTS, getAliOssTokenAxios } from '@/service/api/upload';
|
import OSS, { type Checkpoint } from 'ali-oss'
|
||||||
import OSS, { type Checkpoint } from 'ali-oss';
|
import { type AliOssSTS, getAliOssTokenAxios } from '@/service/api/upload'
|
||||||
|
|
||||||
// 初始化OSS客户端
|
// 初始化OSS客户端
|
||||||
export const initOSSClient = (token: AliOssSTS) => {
|
export function initOSSClient(token: AliOssSTS) {
|
||||||
return new OSS({
|
return new OSS({
|
||||||
region: token.Region || 'oss-cn-shenzhen',
|
region: token.Region || 'oss-cn-shenzhen',
|
||||||
accessKeyId: token.AccessKeyId,
|
accessKeyId: token.AccessKeyId,
|
||||||
@ -12,34 +12,30 @@ export const initOSSClient = (token: AliOssSTS) => {
|
|||||||
refreshSTSTokenInterval: 600000, // 10分钟刷新一次token
|
refreshSTSTokenInterval: 600000, // 10分钟刷新一次token
|
||||||
refreshSTSToken: async () => {
|
refreshSTSToken: async () => {
|
||||||
// 这里可以添加获取新token的逻辑
|
// 这里可以添加获取新token的逻辑
|
||||||
const res = await getAliOssTokenAxios();
|
const res = await getAliOssTokenAxios()
|
||||||
return {
|
return {
|
||||||
accessKeyId: res.AccessKeyId,
|
accessKeyId: res.AccessKeyId,
|
||||||
accessKeySecret: res.AccessKeySecret,
|
accessKeySecret: res.AccessKeySecret,
|
||||||
stsToken: res.SecurityToken,
|
stsToken: res.SecurityToken,
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 上传文件到OSS (注意进度从 0 开始到 1 结束)
|
* 上传文件到OSS (注意进度从 0 开始到 1 结束)
|
||||||
*/
|
*/
|
||||||
export const uploadFileToOSS = async (
|
export async function uploadFileToOSS(client: OSS, file: File, path: string, progress?: ((progress: number, checkpoint?: Checkpoint, http?: any) => any) | undefined) {
|
||||||
client: OSS,
|
|
||||||
file: File,
|
|
||||||
path: string,
|
|
||||||
progress?: ((progress: number, checkpoint?: Checkpoint, http?: any) => any) | undefined
|
|
||||||
) => {
|
|
||||||
try {
|
try {
|
||||||
const result = await client.multipartUpload(path, file, {
|
const result = await client.multipartUpload(path, file, {
|
||||||
parallel: 5, // 并发分片数
|
parallel: 5, // 并发分片数
|
||||||
partSize: 1024 * 1024 * 5, // 分片大小5MB
|
partSize: 1024 * 1024 * 5, // 分片大小5MB
|
||||||
progress,
|
progress,
|
||||||
});
|
})
|
||||||
return result;
|
return result
|
||||||
} catch (error) {
|
|
||||||
console.error('上传文件失败:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
};
|
catch (error) {
|
||||||
|
console.error('上传文件失败:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
import { Snowflake } from '@sapphire/snowflake';
|
import { Snowflake } from '@sapphire/snowflake'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 得到雪花ID
|
* 得到雪花ID
|
||||||
*/
|
*/
|
||||||
export function getSnowflake(): bigint {
|
export function getSnowflake(): bigint {
|
||||||
const epoch = new Date('2025-07-01T00:00:00.000Z');
|
const epoch = new Date('2025-07-01T00:00:00.000Z')
|
||||||
const snowflake = new Snowflake(epoch);
|
const snowflake = new Snowflake(epoch)
|
||||||
snowflake.workerId = 1;
|
snowflake.workerId = 1
|
||||||
return snowflake.generate();
|
return snowflake.generate()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -18,50 +18,53 @@ export function getSnowflake(): bigint {
|
|||||||
*/
|
*/
|
||||||
export async function concurrentPool<T>(tasks: (() => Promise<T>)[], concurrency: number): Promise<T[]> {
|
export async function concurrentPool<T>(tasks: (() => Promise<T>)[], concurrency: number): Promise<T[]> {
|
||||||
// 存储所有任务的结果
|
// 存储所有任务的结果
|
||||||
const results: T[] = [];
|
const results: T[] = []
|
||||||
// 存储当前正在执行的任务
|
// 存储当前正在执行的任务
|
||||||
const executing: Promise<void>[] = [];
|
const executing: Promise<void>[] = []
|
||||||
let index = 0; // 任务索引,用于按顺序添加任务
|
let index = 0 // 任务索引,用于按顺序添加任务
|
||||||
|
|
||||||
// 创建执行器函数
|
// 创建执行器函数
|
||||||
const execute = async (taskIndex: number): Promise<void> => {
|
const execute = async (taskIndex: number): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
// 执行任务并获取结果
|
// 执行任务并获取结果
|
||||||
const result = await tasks[taskIndex]!();
|
const result = await tasks[taskIndex]!()
|
||||||
results[taskIndex] = result; // 按原始顺序存储结果
|
results[taskIndex] = result // 按原始顺序存储结果
|
||||||
} catch (error) {
|
}
|
||||||
results[taskIndex] = error as any; // 捕获错误(可根据需求调整)
|
catch (error) {
|
||||||
} finally {
|
results[taskIndex] = error as any // 捕获错误(可根据需求调整)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
// 无论成功失败,任务完成后从执行队列移除
|
// 无论成功失败,任务完成后从执行队列移除
|
||||||
const executingIndex = executing.findIndex((p) => p === executing[taskIndex]);
|
const executingIndex = executing.findIndex(p => p === executing[taskIndex])
|
||||||
if (executingIndex !== -1) {
|
if (executingIndex !== -1) {
|
||||||
executing.splice(executingIndex, 1);
|
executing.splice(executingIndex, 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
// 启动初始并发任务
|
// 启动初始并发任务
|
||||||
while (index < Math.min(concurrency, tasks.length)) {
|
while (index < Math.min(concurrency, tasks.length)) {
|
||||||
const taskPromise = execute(index);
|
const taskPromise = execute(index)
|
||||||
executing.push(taskPromise);
|
executing.push(taskPromise)
|
||||||
index++;
|
index++
|
||||||
}
|
}
|
||||||
|
|
||||||
// 动态管理任务池
|
// 动态管理任务池
|
||||||
while (index < tasks.length) {
|
while (index < tasks.length) {
|
||||||
if (executing.length < concurrency) {
|
if (executing.length < concurrency) {
|
||||||
const taskPromise = execute(index);
|
const taskPromise = execute(index)
|
||||||
executing.push(taskPromise);
|
executing.push(taskPromise)
|
||||||
index++;
|
index++
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
// 等待任意一个任务完成
|
// 等待任意一个任务完成
|
||||||
await Promise.race(executing); // eslint-disable-line no-await-in-loop
|
await Promise.race(executing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 等待所有剩余任务完成
|
// 等待所有剩余任务完成
|
||||||
await Promise.all(executing);
|
await Promise.all(executing)
|
||||||
return results;
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -75,25 +78,27 @@ export async function concurrentPool<T>(tasks: (() => Promise<T>)[], concurrency
|
|||||||
*/
|
*/
|
||||||
export function compareVersion(v1: string, v2: string, operator: '_' | '-' | '.' = '.'): -1 | 0 | 1 {
|
export function compareVersion(v1: string, v2: string, operator: '_' | '-' | '.' = '.'): -1 | 0 | 1 {
|
||||||
if (v1 === v2) {
|
if (v1 === v2) {
|
||||||
return 0;
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const vs1 = v1.split(operator).map((a) => parseInt(a));
|
const vs1 = v1.split(operator).map(a => Number.parseInt(a))
|
||||||
const vs2 = v2.split(operator).map((a) => parseInt(a));
|
const vs2 = v2.split(operator).map(a => Number.parseInt(a))
|
||||||
|
|
||||||
const length = Math.min(vs1.length, vs2.length);
|
const length = Math.min(vs1.length, vs2.length)
|
||||||
for (let i = 0; i < length; i++) {
|
for (let i = 0; i < length; i++) {
|
||||||
const s1 = vs1[i] || 0;
|
const s1 = vs1[i] || 0
|
||||||
const s2 = vs2[i] || 0;
|
const s2 = vs2[i] || 0
|
||||||
if (s1 > s2) {
|
if (s1 > s2) {
|
||||||
return 1;
|
return 1
|
||||||
} else if (s1 < s2) {
|
}
|
||||||
return -1;
|
else if (s1 < s2) {
|
||||||
|
return -1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (length === vs1.length) {
|
if (length === vs1.length) {
|
||||||
return -1;
|
return -1
|
||||||
} else {
|
}
|
||||||
return 1;
|
else {
|
||||||
|
return 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,7 +27,8 @@ function onMouseDown(e: MouseEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onMouseMove(e: MouseEvent) {
|
function onMouseMove(e: MouseEvent) {
|
||||||
if (!isDragging.value) return
|
if (!isDragging.value)
|
||||||
|
return
|
||||||
const dx = e.clientX - startX.value
|
const dx = e.clientX - startX.value
|
||||||
const newWidth = startWidth.value + dx
|
const newWidth = startWidth.value + dx
|
||||||
|
|
||||||
@ -48,12 +49,13 @@ function onMouseUp() {
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-full flex overflow-hidden rounded-2xl bg-white shadow-sm">
|
<div class="h-full flex overflow-hidden rounded-2xl bg-white shadow-sm">
|
||||||
<!-- 分类树区域 -->
|
<!-- 分类树区域 -->
|
||||||
<div class="h-full flex-shrink-0 relative" :style="{ width: `${siderWidth}px` }">
|
<div class="relative h-full flex-shrink-0" :style="{ width: `${siderWidth}px` }">
|
||||||
<CategoryTree @update:category="val => currentCategory = val" />
|
<CategoryTree @update:category="val => currentCategory = val" />
|
||||||
<!-- 分类树宽度调整手柄 -->
|
<!-- 分类树宽度调整手柄 -->
|
||||||
<div
|
<div
|
||||||
class="absolute top-0 right-0 h-full w-1 cursor-col-resize hover:bg-primary/20 active:bg-primary/40 transition-colors z-10"
|
class="absolute right-0 top-0 z-10 h-full w-1 cursor-col-resize transition-colors active:bg-primary/40 hover:bg-primary/20"
|
||||||
@mousedown="onMouseDown"></div>
|
@mousedown="onMouseDown"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- 题目列表区域 -->
|
<!-- 题目列表区域 -->
|
||||||
<div class="h-full flex-1 overflow-hidden">
|
<div class="h-full flex-1 overflow-hidden">
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, h, watch } from 'vue'
|
|
||||||
import {
|
import {
|
||||||
NButton,
|
NButton,
|
||||||
NTree,
|
|
||||||
NModal,
|
|
||||||
NForm,
|
NForm,
|
||||||
NFormItem,
|
NFormItem,
|
||||||
NInput,
|
NInput,
|
||||||
useMessage,
|
NModal,
|
||||||
|
NTree,
|
||||||
|
type TreeOption,
|
||||||
useDialog,
|
useDialog,
|
||||||
type TreeOption
|
useMessage,
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
|
import { h, ref, watch } from 'vue'
|
||||||
import SvgIcon from '@/components/custom/svg-icon.vue'
|
import SvgIcon from '@/components/custom/svg-icon.vue'
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@ -135,9 +135,10 @@ watch(selectedKeys, (newKeys) => {
|
|||||||
level: node.isLeaf ? 3 : 1,
|
level: node.isLeaf ? 3 : 1,
|
||||||
key: node.key,
|
key: node.key,
|
||||||
isLeaf: node.isLeaf,
|
isLeaf: node.isLeaf,
|
||||||
children: node.children
|
children: node.children,
|
||||||
})
|
})
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
emit('update:category', null)
|
emit('update:category', null)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -318,10 +319,12 @@ function renderSuffix({ option }: { option: TreeOption }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto py-2">
|
<div class="flex-1 overflow-y-auto py-2">
|
||||||
<NTree block-line :data="treeData" :selected-keys="selectedKeys" :expanded-keys="expandedKeys"
|
<NTree
|
||||||
|
block-line :data="treeData" :selected-keys="selectedKeys" :expanded-keys="expandedKeys"
|
||||||
:render-prefix="renderPrefix" :render-suffix="renderSuffix" selectable expand-on-click class="px-2"
|
:render-prefix="renderPrefix" :render-suffix="renderSuffix" selectable expand-on-click class="px-2"
|
||||||
@update:selected-keys="(keys) => (selectedKeys = keys)"
|
@update:selected-keys="(keys) => (selectedKeys = keys)"
|
||||||
@update:expanded-keys="(keys) => (expandedKeys = keys)" />
|
@update:expanded-keys="(keys) => (expandedKeys = keys)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-t border-gray-100 bg-gray-50 p-3 text-xs text-gray-400">
|
<div class="border-t border-gray-100 bg-gray-50 p-3 text-xs text-gray-400">
|
||||||
@ -329,8 +332,10 @@ function renderSuffix({ option }: { option: TreeOption }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add/Edit Category Modal -->
|
<!-- Add/Edit Category Modal -->
|
||||||
<NModal v-model:show="showCategoryModal" preset="card"
|
<NModal
|
||||||
:title="categoryOperation === 'edit' ? '编辑分类' : `新增 ${categoryModalType} 级分类`" class="w-[500px]">
|
v-model:show="showCategoryModal" preset="card"
|
||||||
|
:title="categoryOperation === 'edit' ? '编辑分类' : `新增 ${categoryModalType} 级分类`" class="w-[500px]"
|
||||||
|
>
|
||||||
<NForm>
|
<NForm>
|
||||||
<NFormItem label="分类显示名称">
|
<NFormItem label="分类显示名称">
|
||||||
<NInput v-model:value="categoryForm.name" placeholder="请输入内容" />
|
<NInput v-model:value="categoryForm.name" placeholder="请输入内容" />
|
||||||
|
|||||||
@ -1,22 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import {
|
import {
|
||||||
NBreadcrumb,
|
NBreadcrumb,
|
||||||
NBreadcrumbItem,
|
NBreadcrumbItem,
|
||||||
NButton,
|
NButton,
|
||||||
NCard,
|
NCard,
|
||||||
|
NDrawer,
|
||||||
|
NDrawerContent,
|
||||||
NEmpty,
|
NEmpty,
|
||||||
NForm,
|
NForm,
|
||||||
NFormItem,
|
NFormItem,
|
||||||
NInput,
|
NInput,
|
||||||
NInputNumber,
|
NInputNumber,
|
||||||
NDrawer,
|
|
||||||
NDrawerContent,
|
|
||||||
NTag,
|
NTag,
|
||||||
|
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
import SvgIcon from '@/components/custom/svg-icon.vue'
|
import SvgIcon from '@/components/custom/svg-icon.vue'
|
||||||
import RestBasicEditor from '@/components/common/rest-basic-editor/rest-basic-editor.vue'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
currentCategory: any
|
currentCategory: any
|
||||||
@ -168,7 +167,7 @@ function submitQuestion() {
|
|||||||
questionList.value.push({
|
questionList.value.push({
|
||||||
id: `Q-${Math.floor(Math.random() * 10000)}`,
|
id: `Q-${Math.floor(Math.random() * 10000)}`,
|
||||||
...questionForm.value,
|
...questionForm.value,
|
||||||
categoryKey: props.currentCategory?.key // Add categoryKey
|
categoryKey: props.currentCategory?.key, // Add categoryKey
|
||||||
})
|
})
|
||||||
window.$message?.success('题目添加成功')
|
window.$message?.success('题目添加成功')
|
||||||
}
|
}
|
||||||
@ -294,9 +293,9 @@ function submitQuestion() {
|
|||||||
<NDrawerContent :title="questionOperation === 'edit' ? '编辑题目详情' : '新增题目详情'">
|
<NDrawerContent :title="questionOperation === 'edit' ? '编辑题目详情' : '新增题目详情'">
|
||||||
<NForm label-placement="top">
|
<NForm label-placement="top">
|
||||||
<NFormItem label="题目正文内容">
|
<NFormItem label="题目正文内容">
|
||||||
<div class="w-full border border-gray-200 rounded-lg overflow-hidden">
|
<div class="w-full overflow-hidden border border-gray-200 rounded-lg">
|
||||||
<!-- <RestBasicEditor v-model="questionForm.content" /> -->
|
<!-- <RestBasicEditor v-model="questionForm.content" /> -->
|
||||||
<NInput type="textarea" v-model:value="questionForm.content" class="w-full h-64" />
|
<NInput v-model:value="questionForm.content" type="textarea" class="h-64 w-full" />
|
||||||
</div>
|
</div>
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
|
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
<script setup lang="tsx">
|
<script setup lang="tsx">
|
||||||
import { reactive, ref } from 'vue';
|
import { NButton, NCard, NTag } from 'naive-ui'
|
||||||
import { useRouter } from 'vue-router';
|
import { reactive } from 'vue'
|
||||||
import { NButton, NTag, NCard, NSpace } from 'naive-ui';
|
import { useRouter } from 'vue-router'
|
||||||
import { fetchGetUserList } from '@/service/api';
|
import { defaultTransform, useNaivePaginatedTable, useTableOperate } from '@/hooks/common/table'
|
||||||
import { useAppStore } from '@/store/modules/app';
|
import { fetchGetUserList } from '@/service/api'
|
||||||
import { defaultTransform, useNaivePaginatedTable, useTableOperate } from '@/hooks/common/table';
|
import { useAppStore } from '@/store/modules/app'
|
||||||
import RankSearch from './modules/rank-search.vue';
|
import RankSearch from './modules/rank-search.vue'
|
||||||
|
|
||||||
const appStore = useAppStore();
|
const appStore = useAppStore()
|
||||||
const router = useRouter();
|
const router = useRouter()
|
||||||
|
|
||||||
const searchParams: Api.SystemManage.UserSearchParams = reactive({
|
const searchParams: Api.SystemManage.UserSearchParams = reactive({
|
||||||
current: 1,
|
current: 1,
|
||||||
@ -18,67 +18,67 @@ const searchParams: Api.SystemManage.UserSearchParams = reactive({
|
|||||||
userGender: null,
|
userGender: null,
|
||||||
nickName: null,
|
nickName: null,
|
||||||
userPhone: null,
|
userPhone: null,
|
||||||
userEmail: null
|
userEmail: null,
|
||||||
});
|
})
|
||||||
|
|
||||||
const rankTitle = '排行榜管理列表';
|
const rankTitle = '排行榜管理列表'
|
||||||
|
|
||||||
const statusMap: Record<string, string> = {
|
const statusMap: Record<string, string> = {
|
||||||
1: '已发布',
|
1: '已发布',
|
||||||
2: '待审核'
|
2: '待审核',
|
||||||
};
|
}
|
||||||
|
|
||||||
const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagination } = useNaivePaginatedTable({
|
const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagination } = useNaivePaginatedTable({
|
||||||
api: () => fetchGetUserList(searchParams),
|
api: () => fetchGetUserList(searchParams),
|
||||||
transform: response => {
|
transform: (response) => {
|
||||||
const transformed = defaultTransform(response);
|
const transformed = defaultTransform(response)
|
||||||
// Mocking data for Rank Management based on User API response
|
// Mocking data for Rank Management based on User API response
|
||||||
const mockCompetitions = ['阅读之星-辞海遨游环节 (2026)', '古诗词大会年度精英巅峰赛', '趣味百科常识挑战周'];
|
const mockCompetitions = ['阅读之星-辞海遨游环节 (2026)', '古诗词大会年度精英巅峰赛', '趣味百科常识挑战周']
|
||||||
const mockDates = ['2026.01.12', '2026.01.15', '2026.01.18'];
|
const mockDates = ['2026.01.12', '2026.01.15', '2026.01.18']
|
||||||
const mockScales = ['40 支队伍', '24 支队伍', '60 支队伍'];
|
const mockScales = ['40 支队伍', '24 支队伍', '60 支队伍']
|
||||||
const mockScores = ['145', '180', '120'];
|
const mockScores = ['145', '180', '120']
|
||||||
const mockTimes = ['2026-01-22 16:30:12', '2026-01-22 12:00:00', '2026-01-21 18:22:45'];
|
const mockTimes = ['2026-01-22 16:30:12', '2026-01-22 12:00:00', '2026-01-21 18:22:45']
|
||||||
|
|
||||||
transformed.data.forEach((item, index) => {
|
transformed.data.forEach((item, index) => {
|
||||||
const mockIndex = index % 3;
|
const mockIndex = index % 3
|
||||||
item.userName = mockCompetitions[mockIndex]; // Competition Name
|
item.userName = mockCompetitions[mockIndex] // Competition Name
|
||||||
item.userEmail = mockDates[mockIndex]; // Activity Date (using userEmail field)
|
item.userEmail = mockDates[mockIndex] // Activity Date (using userEmail field)
|
||||||
item.userPhone = mockScales[mockIndex]; // Team Scale (using userPhone field)
|
item.userPhone = mockScales[mockIndex] // Team Scale (using userPhone field)
|
||||||
item.nickName = mockScores[mockIndex]; // Max Score (using nickName field)
|
item.nickName = mockScores[mockIndex] // Max Score (using nickName field)
|
||||||
// item.createTime is typically available, we'll simulate update time
|
// item.createTime is typically available, we'll simulate update time
|
||||||
item.createTime = mockTimes[mockIndex];
|
item.createTime = mockTimes[mockIndex]
|
||||||
// Status: 1 (Published), 2 (Pending)
|
// Status: 1 (Published), 2 (Pending)
|
||||||
item.status = (index % 2 === 0) ? '2' : '1';
|
item.status = (index % 2 === 0) ? '2' : '1'
|
||||||
});
|
})
|
||||||
return transformed;
|
return transformed
|
||||||
},
|
},
|
||||||
onPaginationParamsChange: params => {
|
onPaginationParamsChange: (params) => {
|
||||||
searchParams.current = params.page;
|
searchParams.current = params.page
|
||||||
searchParams.size = params.pageSize;
|
searchParams.size = params.pageSize
|
||||||
},
|
},
|
||||||
columns: () => [
|
columns: () => [
|
||||||
{
|
{
|
||||||
type: 'selection',
|
type: 'selection',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 48
|
width: 48,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'userName',
|
key: 'userName',
|
||||||
title: '比赛活动名称',
|
title: '比赛活动名称',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
minWidth: 200
|
minWidth: 200,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'userEmail',
|
key: 'userEmail',
|
||||||
title: '活动日期',
|
title: '活动日期',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
minWidth: 120
|
minWidth: 120,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'userPhone',
|
key: 'userPhone',
|
||||||
title: '队伍规模',
|
title: '队伍规模',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
minWidth: 100
|
minWidth: 100,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'nickName',
|
key: 'nickName',
|
||||||
@ -86,26 +86,31 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
|
|||||||
align: 'center',
|
align: 'center',
|
||||||
minWidth: 100,
|
minWidth: 100,
|
||||||
render: row => (
|
render: row => (
|
||||||
<span class="text-primary font-bold">{row.nickName} Pts</span>
|
<span class="text-primary font-bold">
|
||||||
)
|
{row.nickName}
|
||||||
|
{' '}
|
||||||
|
Pts
|
||||||
|
</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'createTime',
|
key: 'createTime',
|
||||||
title: '最后更新时间',
|
title: '最后更新时间',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
minWidth: 160
|
minWidth: 160,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'status',
|
key: 'status',
|
||||||
title: '发布状态',
|
title: '发布状态',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: row => {
|
render: (row) => {
|
||||||
if (row.status === null) return null;
|
if (row.status === null)
|
||||||
const label = statusMap[row.status] || '未知';
|
return null
|
||||||
|
const label = statusMap[row.status] || '未知'
|
||||||
// 1: Published (Success/Green), 2: Pending (Warning/Orange)
|
// 1: Published (Success/Green), 2: Pending (Warning/Orange)
|
||||||
return <NTag type={row.status === '1' ? 'success' : 'warning'}>{label}</NTag>;
|
return <NTag type={row.status === '1' ? 'success' : 'warning'}>{label}</NTag>
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'operate',
|
key: 'operate',
|
||||||
@ -118,10 +123,10 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
|
|||||||
进入排行详情
|
进入排行详情
|
||||||
</NButton>
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
)
|
),
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
});
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
drawerVisible,
|
drawerVisible,
|
||||||
@ -130,16 +135,14 @@ const {
|
|||||||
handleEdit,
|
handleEdit,
|
||||||
checkedRowKeys,
|
checkedRowKeys,
|
||||||
onBatchDeleted,
|
onBatchDeleted,
|
||||||
} = useTableOperate(data, 'id', getData);
|
} = useTableOperate(data, 'id', getData)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function edit(id: number) {
|
function edit(id: number) {
|
||||||
router.push({ name: 'rank_rank-detail', query: { id } });
|
router.push({ name: 'rank_rank-detail', query: { id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBatchPublish() {
|
function handleBatchPublish() {
|
||||||
window.$message?.success('批量发布成功');
|
window.$message?.success('批量发布成功')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -150,8 +153,12 @@ function handleBatchPublish() {
|
|||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-16px font-bold">{{ rankTitle }}</div>
|
<div class="text-16px font-bold">
|
||||||
<div class="text-12px text-gray-500 mt-4px">您可以对所有比赛场次的最终积分进行核对、修正,并正式发布结果。</div>
|
{{ rankTitle }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-4px text-12px text-gray-500">
|
||||||
|
您可以对所有比赛场次的最终积分进行核对、修正,并正式发布结果。
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NButton type="primary" ghost class="ml-auto" @click="handleBatchPublish">
|
<NButton type="primary" ghost class="ml-auto" @click="handleBatchPublish">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
@ -165,9 +172,11 @@ function handleBatchPublish() {
|
|||||||
<TableHeaderOperation v-model:columns="columnChecks" :disabled-delete="checkedRowKeys.length === 0"
|
<TableHeaderOperation v-model:columns="columnChecks" :disabled-delete="checkedRowKeys.length === 0"
|
||||||
:loading="loading" @add="handleAdd" @delete="handleBatchDelete" @refresh="getData" />
|
:loading="loading" @add="handleAdd" @delete="handleBatchDelete" @refresh="getData" />
|
||||||
</template> -->
|
</template> -->
|
||||||
<NDataTable v-model:checked-row-keys="checkedRowKeys" :columns="columns" :data="data" size="small" striped
|
<NDataTable
|
||||||
|
v-model:checked-row-keys="checkedRowKeys" :columns="columns" :data="data" size="small" striped
|
||||||
:flex-height="!appStore.isMobile" :scroll-x="962" :loading="loading" remote :row-key="row => row.id"
|
:flex-height="!appStore.isMobile" :scroll-x="962" :loading="loading" remote :row-key="row => row.id"
|
||||||
:pagination="mobilePagination" class="sm:h-full" />
|
:pagination="mobilePagination" class="sm:h-full"
|
||||||
|
/>
|
||||||
</NCard>
|
</NCard>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,53 +1,53 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, toRaw } from 'vue';
|
import { jsonClone } from '@sa/utils'
|
||||||
import { jsonClone } from '@sa/utils';
|
import { computed, toRaw } from 'vue'
|
||||||
import { useFormRules, useNaiveForm } from '@/hooks/common/form';
|
import { useFormRules, useNaiveForm } from '@/hooks/common/form'
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'RankSearch'
|
name: 'RankSearch',
|
||||||
});
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'search'): void;
|
(e: 'search'): void
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<Emits>();
|
const { formRef, validate, restoreValidation } = useNaiveForm()
|
||||||
|
|
||||||
const { formRef, validate, restoreValidation } = useNaiveForm();
|
const model = defineModel<Api.SystemManage.UserSearchParams>('model', { required: true })
|
||||||
|
|
||||||
const model = defineModel<Api.SystemManage.UserSearchParams>('model', { required: true });
|
type RuleKey = Extract<keyof Api.SystemManage.UserSearchParams, 'userEmail' | 'userPhone'>
|
||||||
|
|
||||||
type RuleKey = Extract<keyof Api.SystemManage.UserSearchParams, 'userEmail' | 'userPhone'>;
|
|
||||||
|
|
||||||
const rules = computed<Record<RuleKey, App.Global.FormRule>>(() => {
|
const rules = computed<Record<RuleKey, App.Global.FormRule>>(() => {
|
||||||
const { patternRules } = useFormRules();
|
const { patternRules } = useFormRules()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userEmail: patternRules.email,
|
userEmail: patternRules.email,
|
||||||
userPhone: patternRules.phone
|
userPhone: patternRules.phone,
|
||||||
};
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
const defaultModel = jsonClone(toRaw(model.value));
|
const defaultModel = jsonClone(toRaw(model.value))
|
||||||
|
|
||||||
function resetModel() {
|
function resetModel() {
|
||||||
Object.assign(model.value, defaultModel);
|
Object.assign(model.value, defaultModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reset() {
|
async function reset() {
|
||||||
await restoreValidation();
|
await restoreValidation()
|
||||||
resetModel();
|
resetModel()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function search() {
|
async function search() {
|
||||||
await validate();
|
await validate()
|
||||||
emit('search');
|
emit('search')
|
||||||
}
|
}
|
||||||
|
|
||||||
const publishStatusOptions = [
|
const publishStatusOptions = [
|
||||||
{ label: '待审核', value: '2' }, // Mapping to '2' (Warning)
|
{ label: '待审核', value: '2' }, // Mapping to '2' (Warning)
|
||||||
{ label: '已发布', value: '1' } // Mapping to '1' (Success)
|
{ label: '已发布', value: '1' }, // Mapping to '1' (Success)
|
||||||
];
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
<script setup lang="tsx">
|
<script setup lang="tsx">
|
||||||
import { reactive } from 'vue';
|
import { NButton, NPopconfirm, NTag } from 'naive-ui'
|
||||||
import { NButton, NPopconfirm, NTag } from 'naive-ui';
|
import { reactive } from 'vue'
|
||||||
import { fetchGetUserList } from '@/service/api';
|
import { defaultTransform, useNaivePaginatedTable, useTableOperate } from '@/hooks/common/table'
|
||||||
import { useAppStore } from '@/store/modules/app';
|
import { fetchGetUserList } from '@/service/api'
|
||||||
import { defaultTransform, useNaivePaginatedTable, useTableOperate } from '@/hooks/common/table';
|
import { useAppStore } from '@/store/modules/app'
|
||||||
import TemplateOperateDrawer from './modules/template-operate-drawer.vue';
|
import TemplateOperateDrawer from './modules/template-operate-drawer.vue'
|
||||||
import TemplateSearch from './modules/template-search.vue';
|
import TemplateSearch from './modules/template-search.vue'
|
||||||
|
|
||||||
const appStore = useAppStore();
|
const appStore = useAppStore()
|
||||||
|
|
||||||
const searchParams: Api.SystemManage.UserSearchParams = reactive({
|
const searchParams: Api.SystemManage.UserSearchParams = reactive({
|
||||||
current: 1,
|
current: 1,
|
||||||
@ -17,48 +17,48 @@ const searchParams: Api.SystemManage.UserSearchParams = reactive({
|
|||||||
userGender: null,
|
userGender: null,
|
||||||
nickName: null,
|
nickName: null,
|
||||||
userPhone: null,
|
userPhone: null,
|
||||||
userEmail: null
|
userEmail: null,
|
||||||
});
|
})
|
||||||
|
|
||||||
const templateTitle = '模板管理';
|
const templateTitle = '模板管理'
|
||||||
|
|
||||||
const competitionMap: Record<string, string> = {
|
const competitionMap: Record<string, string> = {
|
||||||
1: '第九届阅读之星大赛',
|
1: '第九届阅读之星大赛',
|
||||||
2: '科普阅读大赛'
|
2: '科普阅读大赛',
|
||||||
};
|
}
|
||||||
|
|
||||||
const statusMap: Record<string, string> = {
|
const statusMap: Record<string, string> = {
|
||||||
1: '铺码成功',
|
1: '铺码成功',
|
||||||
2: '未铺码'
|
2: '未铺码',
|
||||||
};
|
}
|
||||||
|
|
||||||
const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagination } = useNaivePaginatedTable({
|
const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagination } = useNaivePaginatedTable({
|
||||||
api: () => fetchGetUserList(searchParams),
|
api: () => fetchGetUserList(searchParams),
|
||||||
transform: response => {
|
transform: (response) => {
|
||||||
const transformed = defaultTransform(response);
|
const transformed = defaultTransform(response)
|
||||||
transformed.data.forEach((item, index) => {
|
transformed.data.forEach((item, index) => {
|
||||||
item.nickName = `MD${String(index + 1).padStart(6, '0')}`;
|
item.nickName = `MD${String(index + 1).padStart(6, '0')}`
|
||||||
item.userPhone = '210mmx297mm';
|
item.userPhone = '210mmx297mm'
|
||||||
item.userGender = (item.userGender === '1' || item.userGender === '2') ? item.userGender : '1';
|
item.userGender = (item.userGender === '1' || item.userGender === '2') ? item.userGender : '1'
|
||||||
});
|
})
|
||||||
return transformed;
|
return transformed
|
||||||
},
|
},
|
||||||
onPaginationParamsChange: params => {
|
onPaginationParamsChange: (params) => {
|
||||||
searchParams.current = params.page;
|
searchParams.current = params.page
|
||||||
searchParams.size = params.pageSize;
|
searchParams.size = params.pageSize
|
||||||
},
|
},
|
||||||
columns: () => [
|
columns: () => [
|
||||||
{
|
{
|
||||||
type: 'selection',
|
type: 'selection',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 48
|
width: 48,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'index',
|
key: 'index',
|
||||||
title: '序号',
|
title: '序号',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 60,
|
width: 60,
|
||||||
render: (_, index) => index + 1
|
render: (_, index) => index + 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'userName',
|
key: 'userName',
|
||||||
@ -74,10 +74,10 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
|
|||||||
key: 'userGender',
|
key: 'userGender',
|
||||||
title: '关联比赛',
|
title: '关联比赛',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: row => {
|
render: (row) => {
|
||||||
const label = competitionMap[row.userGender as string] || '未知比赛';
|
const label = competitionMap[row.userGender as string] || '未知比赛'
|
||||||
return <span>{label}</span>;
|
return <span>{label}</span>
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'userPhone',
|
key: 'userPhone',
|
||||||
@ -89,13 +89,13 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
|
|||||||
title: '状态',
|
title: '状态',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: row => {
|
render: (row) => {
|
||||||
if (row.status === null) {
|
if (row.status === null) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
const label = statusMap[row.status] || '未知';
|
const label = statusMap[row.status] || '未知'
|
||||||
return <NTag type={row.status === '1' ? 'success' : 'warning'}>{label}</NTag>;
|
return <NTag type={row.status === '1' ? 'success' : 'warning'}>{label}</NTag>
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'operate',
|
key: 'operate',
|
||||||
@ -126,14 +126,14 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
|
|||||||
<NButton type="error" ghost size="small">
|
<NButton type="error" ghost size="small">
|
||||||
删除
|
删除
|
||||||
</NButton>
|
</NButton>
|
||||||
)
|
),
|
||||||
}}
|
}}
|
||||||
</NPopconfirm>
|
</NPopconfirm>
|
||||||
</div>
|
</div>
|
||||||
)
|
),
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
});
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
drawerVisible,
|
drawerVisible,
|
||||||
@ -143,26 +143,21 @@ const {
|
|||||||
handleEdit,
|
handleEdit,
|
||||||
checkedRowKeys,
|
checkedRowKeys,
|
||||||
onBatchDeleted,
|
onBatchDeleted,
|
||||||
onDeleted
|
onDeleted,
|
||||||
// closeDrawer
|
// closeDrawer
|
||||||
} = useTableOperate(data, 'id', getData);
|
} = useTableOperate(data, 'id', getData)
|
||||||
|
|
||||||
async function handleBatchDelete() {
|
async function handleBatchDelete() {
|
||||||
// request
|
// request
|
||||||
console.log(checkedRowKeys.value);
|
onBatchDeleted()
|
||||||
|
|
||||||
onBatchDeleted();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDelete(id: number) {
|
function handleDelete(id: number) {
|
||||||
// request
|
onDeleted(id)
|
||||||
console.log(id);
|
|
||||||
|
|
||||||
onDeleted();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function edit(id: number) {
|
function edit(id: number) {
|
||||||
handleEdit(id);
|
handleEdit(id)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -171,14 +166,20 @@ function edit(id: number) {
|
|||||||
<TemplateSearch v-model:model="searchParams" @search="getDataByPage" />
|
<TemplateSearch v-model:model="searchParams" @search="getDataByPage" />
|
||||||
<NCard :title="templateTitle" :bordered="false" size="small" class="card-wrapper sm:flex-1-hidden">
|
<NCard :title="templateTitle" :bordered="false" size="small" class="card-wrapper sm:flex-1-hidden">
|
||||||
<template #header-extra>
|
<template #header-extra>
|
||||||
<TableHeaderOperation v-model:columns="columnChecks" :disabled-delete="checkedRowKeys.length === 0"
|
<TableHeaderOperation
|
||||||
:loading="loading" @add="handleAdd" @delete="handleBatchDelete" @refresh="getData" />
|
v-model:columns="columnChecks" :disabled-delete="checkedRowKeys.length === 0"
|
||||||
|
:loading="loading" @add="handleAdd" @delete="handleBatchDelete" @refresh="getData"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<NDataTable v-model:checked-row-keys="checkedRowKeys" :columns="columns" :data="data" size="small" striped
|
<NDataTable
|
||||||
|
v-model:checked-row-keys="checkedRowKeys" :columns="columns" :data="data" size="small" striped
|
||||||
:flex-height="!appStore.isMobile" :scroll-x="962" :loading="loading" remote :row-key="row => row.id"
|
:flex-height="!appStore.isMobile" :scroll-x="962" :loading="loading" remote :row-key="row => row.id"
|
||||||
:pagination="mobilePagination" class="sm:h-full" />
|
:pagination="mobilePagination" class="sm:h-full"
|
||||||
<TemplateOperateDrawer v-model:visible="drawerVisible" :operate-type="operateType" :row-data="editingData"
|
/>
|
||||||
@submitted="getDataByPage" />
|
<TemplateOperateDrawer
|
||||||
|
v-model:visible="drawerVisible" :operate-type="operateType" :row-data="editingData"
|
||||||
|
@submitted="getDataByPage"
|
||||||
|
/>
|
||||||
</NCard>
|
</NCard>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,47 +1,47 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue';
|
import { jsonClone } from '@sa/utils'
|
||||||
import { jsonClone } from '@sa/utils';
|
import { computed, ref, watch } from 'vue'
|
||||||
import { enableStatusOptions } from '@/constants/business';
|
import { enableStatusOptions } from '@/constants/business'
|
||||||
import { useFormRules, useNaiveForm } from '@/hooks/common/form';
|
import { useFormRules, useNaiveForm } from '@/hooks/common/form'
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'TemplateOperateDrawer'
|
name: 'TemplateOperateDrawer',
|
||||||
});
|
})
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
operateType: NaiveUI.TableOperateType;
|
operateType: NaiveUI.TableOperateType
|
||||||
rowData?: Api.SystemManage.User | null;
|
rowData?: Api.SystemManage.User | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'submitted'): void;
|
(e: 'submitted'): void
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<Emits>();
|
|
||||||
|
|
||||||
const visible = defineModel<boolean>('visible', {
|
const visible = defineModel<boolean>('visible', {
|
||||||
default: false
|
default: false,
|
||||||
});
|
})
|
||||||
|
|
||||||
const { formRef, validate, restoreValidation } = useNaiveForm();
|
const { formRef, validate, restoreValidation } = useNaiveForm()
|
||||||
const { defaultRequiredRule } = useFormRules();
|
const { defaultRequiredRule } = useFormRules()
|
||||||
|
|
||||||
const title = computed(() => {
|
const title = computed(() => {
|
||||||
const titles: Record<NaiveUI.TableOperateType, string> = {
|
const titles: Record<NaiveUI.TableOperateType, string> = {
|
||||||
add: '新增模板',
|
add: '新增模板',
|
||||||
edit: '编辑模板'
|
edit: '编辑模板',
|
||||||
};
|
}
|
||||||
return titles[props.operateType];
|
return titles[props.operateType]
|
||||||
});
|
})
|
||||||
|
|
||||||
type Model = Pick<
|
type Model = Pick<
|
||||||
Api.SystemManage.User,
|
Api.SystemManage.User,
|
||||||
'userName' | 'userGender' | 'nickName' | 'userPhone' | 'userEmail' | 'userRoles' | 'status'
|
'userName' | 'userGender' | 'nickName' | 'userPhone' | 'userEmail' | 'userRoles' | 'status'
|
||||||
>;
|
>
|
||||||
|
|
||||||
const model = ref(createDefaultModel());
|
const model = ref(createDefaultModel())
|
||||||
|
|
||||||
function createDefaultModel(): Model {
|
function createDefaultModel(): Model {
|
||||||
return {
|
return {
|
||||||
@ -51,53 +51,53 @@ function createDefaultModel(): Model {
|
|||||||
userPhone: '',
|
userPhone: '',
|
||||||
userEmail: '',
|
userEmail: '',
|
||||||
userRoles: [],
|
userRoles: [],
|
||||||
status: null
|
status: null,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type RuleKey = Extract<keyof Model, 'userName' | 'status'>;
|
type RuleKey = Extract<keyof Model, 'userName' | 'status'>
|
||||||
|
|
||||||
const rules: Record<RuleKey, App.Global.FormRule> = {
|
const rules: Record<RuleKey, App.Global.FormRule> = {
|
||||||
userName: defaultRequiredRule,
|
userName: defaultRequiredRule,
|
||||||
status: defaultRequiredRule
|
status: defaultRequiredRule,
|
||||||
};
|
}
|
||||||
|
|
||||||
function handleInitModel() {
|
function handleInitModel() {
|
||||||
model.value = createDefaultModel();
|
model.value = createDefaultModel()
|
||||||
|
|
||||||
if (props.operateType === 'edit' && props.rowData) {
|
if (props.operateType === 'edit' && props.rowData) {
|
||||||
Object.assign(model.value, jsonClone(props.rowData));
|
Object.assign(model.value, jsonClone(props.rowData))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeDrawer() {
|
function closeDrawer() {
|
||||||
visible.value = false;
|
visible.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
await validate();
|
await validate()
|
||||||
// request
|
// request
|
||||||
window.$message?.success('更新成功');
|
window.$message?.success('更新成功')
|
||||||
closeDrawer();
|
closeDrawer()
|
||||||
emit('submitted');
|
emit('submitted')
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(visible, () => {
|
watch(visible, () => {
|
||||||
if (visible.value) {
|
if (visible.value) {
|
||||||
handleInitModel();
|
handleInitModel()
|
||||||
restoreValidation();
|
restoreValidation()
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
const competitionOptions = [
|
const competitionOptions = [
|
||||||
{ label: '第九届阅读之星大赛', value: '1' },
|
{ label: '第九届阅读之星大赛', value: '1' },
|
||||||
{ label: '科普阅读大赛', value: '2' }
|
{ label: '科普阅读大赛', value: '2' },
|
||||||
];
|
]
|
||||||
|
|
||||||
const sizeOptions = [
|
const sizeOptions = [
|
||||||
{ label: '210mmx297mm', value: '1' },
|
{ label: '210mmx297mm', value: '1' },
|
||||||
{ label: 'A3', value: '2' }
|
{ label: 'A3', value: '2' },
|
||||||
];
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -132,8 +132,12 @@ const sizeOptions = [
|
|||||||
</NForm>
|
</NForm>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<NSpace :size="16">
|
<NSpace :size="16">
|
||||||
<NButton @click="closeDrawer">取消</NButton>
|
<NButton @click="closeDrawer">
|
||||||
<NButton type="primary" @click="handleSubmit">确认</NButton>
|
取消
|
||||||
|
</NButton>
|
||||||
|
<NButton type="primary" @click="handleSubmit">
|
||||||
|
确认
|
||||||
|
</NButton>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
</template>
|
</template>
|
||||||
</NDrawerContent>
|
</NDrawerContent>
|
||||||
|
|||||||
@ -1,59 +1,59 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, toRaw } from 'vue';
|
import { jsonClone } from '@sa/utils'
|
||||||
import { jsonClone } from '@sa/utils';
|
import { computed, toRaw } from 'vue'
|
||||||
import { enableStatusOptions } from '@/constants/business';
|
import { enableStatusOptions } from '@/constants/business'
|
||||||
import { useFormRules, useNaiveForm } from '@/hooks/common/form';
|
import { useFormRules, useNaiveForm } from '@/hooks/common/form'
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'TemplateSearch'
|
name: 'TemplateSearch',
|
||||||
});
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'search'): void;
|
(e: 'search'): void
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<Emits>();
|
const { formRef, validate, restoreValidation } = useNaiveForm()
|
||||||
|
|
||||||
const { formRef, validate, restoreValidation } = useNaiveForm();
|
const model = defineModel<Api.SystemManage.UserSearchParams>('model', { required: true })
|
||||||
|
|
||||||
const model = defineModel<Api.SystemManage.UserSearchParams>('model', { required: true });
|
type RuleKey = Extract<keyof Api.SystemManage.UserSearchParams, 'userEmail' | 'userPhone'>
|
||||||
|
|
||||||
type RuleKey = Extract<keyof Api.SystemManage.UserSearchParams, 'userEmail' | 'userPhone'>;
|
|
||||||
|
|
||||||
const rules = computed<Record<RuleKey, App.Global.FormRule>>(() => {
|
const rules = computed<Record<RuleKey, App.Global.FormRule>>(() => {
|
||||||
const { patternRules } = useFormRules();
|
const { patternRules } = useFormRules()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userEmail: patternRules.email,
|
userEmail: patternRules.email,
|
||||||
userPhone: patternRules.phone
|
userPhone: patternRules.phone,
|
||||||
};
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
const defaultModel = jsonClone(toRaw(model.value));
|
const defaultModel = jsonClone(toRaw(model.value))
|
||||||
|
|
||||||
function resetModel() {
|
function resetModel() {
|
||||||
Object.assign(model.value, defaultModel);
|
Object.assign(model.value, defaultModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reset() {
|
async function reset() {
|
||||||
await restoreValidation();
|
await restoreValidation()
|
||||||
resetModel();
|
resetModel()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function search() {
|
async function search() {
|
||||||
await validate();
|
await validate()
|
||||||
emit('search');
|
emit('search')
|
||||||
}
|
}
|
||||||
|
|
||||||
const competitionOptions = [
|
const competitionOptions = [
|
||||||
{ label: '第九届阅读之星大赛', value: '1' },
|
{ label: '第九届阅读之星大赛', value: '1' },
|
||||||
{ label: '科普阅读大赛', value: '2' }
|
{ label: '科普阅读大赛', value: '2' },
|
||||||
];
|
]
|
||||||
|
|
||||||
const sizeOptions = [
|
const sizeOptions = [
|
||||||
{ label: '210mmx297mm', value: '1' },
|
{ label: '210mmx297mm', value: '1' },
|
||||||
{ label: 'A3', value: '2' }
|
{ label: 'A3', value: '2' },
|
||||||
];
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -66,8 +66,10 @@ const sizeOptions = [
|
|||||||
<NInput v-model:value="model.userName" placeholder="请输入名称" />
|
<NInput v-model:value="model.userName" placeholder="请输入名称" />
|
||||||
</NFormItemGi>
|
</NFormItemGi>
|
||||||
<NFormItemGi span="24 s:12 m:6" label="关联比赛" path="userGender" class="pr-24px">
|
<NFormItemGi span="24 s:12 m:6" label="关联比赛" path="userGender" class="pr-24px">
|
||||||
<NSelect v-model:value="model.userGender" placeholder="请选择" :options="competitionOptions as any"
|
<NSelect
|
||||||
clearable />
|
v-model:value="model.userGender" placeholder="请选择" :options="competitionOptions as any"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
</NFormItemGi>
|
</NFormItemGi>
|
||||||
<NFormItemGi span="24 s:12 m:6" label="状态" path="userStatus" class="pr-24px">
|
<NFormItemGi span="24 s:12 m:6" label="状态" path="userStatus" class="pr-24px">
|
||||||
<NSelect v-model:value="model.status" placeholder="请选择" :options="enableStatusOptions as any" clearable />
|
<NSelect v-model:value="model.status" placeholder="请选择" :options="enableStatusOptions as any" clearable />
|
||||||
|
|||||||
Reference in New Issue
Block a user