Initial commit: One Pipe System
完整的管理系统,包含账户管理、卡片管理、套餐管理、财务管理等功能模块。 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
34
src/components/core/forms/ArtButtonMore.vue
Normal file
34
src/components/core/forms/ArtButtonMore.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="btn-more">
|
||||
<el-dropdown>
|
||||
<ArtButtonTable type="more" />
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item v-for="item in list" :key="item.key" @click="handleClick(item)">
|
||||
{{ item.label }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
export interface ButtonMoreItem {
|
||||
key: string | number
|
||||
label: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
list: ButtonMoreItem[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click', item: ButtonMoreItem): void
|
||||
}>()
|
||||
|
||||
const handleClick = (item: ButtonMoreItem) => {
|
||||
emit('click', item)
|
||||
}
|
||||
</script>
|
||||
79
src/components/core/forms/ArtButtonTable.vue
Normal file
79
src/components/core/forms/ArtButtonTable.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<!-- 表格按钮,支持文字和图标 -->
|
||||
<template>
|
||||
<div :class="['btn-text', buttonColor]" @click="handleClick">
|
||||
<i v-if="iconContent" class="iconfont-sys" v-html="iconContent" :style="iconStyle"></i>
|
||||
<span v-if="props.text">{{ props.text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BgColorEnum } from '@/enums/appEnum'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
text?: string
|
||||
type?: 'add' | 'edit' | 'delete' | 'more'
|
||||
icon?: string // 自定义图标
|
||||
iconClass?: BgColorEnum // 自定义按钮背景色、文字颜色
|
||||
iconColor?: string // 外部传入的图标文字颜色
|
||||
iconBgColor?: string // 外部传入的图标背景色
|
||||
}>(),
|
||||
{}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
}>()
|
||||
|
||||
// 默认按钮配置:type 对应的图标和默认颜色
|
||||
const defaultButtons = [
|
||||
{ type: 'add', icon: '', color: BgColorEnum.PRIMARY },
|
||||
{ type: 'edit', icon: '', color: BgColorEnum.SECONDARY },
|
||||
{ type: 'delete', icon: '', color: BgColorEnum.ERROR },
|
||||
{ type: 'more', icon: '', color: '' }
|
||||
] as const
|
||||
|
||||
// 计算最终使用的图标:优先使用外部传入的 icon,否则根据 type 获取默认图标
|
||||
const iconContent = computed(() => {
|
||||
return props.icon || defaultButtons.find((btn) => btn.type === props.type)?.icon || ''
|
||||
})
|
||||
|
||||
// 计算按钮的背景色:优先使用外部传入的 iconClass,否则根据 type 选默认颜色
|
||||
const buttonColor = computed(() => {
|
||||
return props.iconClass || defaultButtons.find((btn) => btn.type === props.type)?.color || ''
|
||||
})
|
||||
|
||||
// 计算图标的颜色与背景色,支持外部传入
|
||||
const iconStyle = computed(() => {
|
||||
return {
|
||||
...(props.iconColor ? { color: props.iconColor } : {}),
|
||||
...(props.iconBgColor ? { backgroundColor: props.iconBgColor } : {})
|
||||
}
|
||||
})
|
||||
|
||||
const handleClick = () => {
|
||||
emit('click')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.btn-text {
|
||||
display: inline-block;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
margin-right: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 34px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
background-color: rgba(var(--art-gray-200-rgb), 0.7);
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover {
|
||||
color: var(--main-color);
|
||||
background-color: rgba(var(--art-gray-300-rgb), 0.5);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
302
src/components/core/forms/ArtDragVerify.vue
Normal file
302
src/components/core/forms/ArtDragVerify.vue
Normal file
@@ -0,0 +1,302 @@
|
||||
<template>
|
||||
<div
|
||||
ref="dragVerify"
|
||||
class="drag_verify"
|
||||
:style="dragVerifyStyle"
|
||||
@mousemove="dragMoving"
|
||||
@mouseup="dragFinish"
|
||||
@mouseleave="dragFinish"
|
||||
@touchmove="dragMoving"
|
||||
@touchend="dragFinish"
|
||||
>
|
||||
<div
|
||||
class="dv_progress_bar"
|
||||
:class="{ goFirst2: isOk }"
|
||||
ref="progressBar"
|
||||
:style="progressBarStyle"
|
||||
>
|
||||
</div>
|
||||
<div class="dv_text" :style="textStyle" ref="messageRef">
|
||||
<slot name="textBefore" v-if="$slots.textBefore"></slot>
|
||||
{{ message }}
|
||||
<slot name="textAfter" v-if="$slots.textAfter"></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="dv_handler dv_handler_bg"
|
||||
:class="{ goFirst: isOk }"
|
||||
@mousedown="dragStart"
|
||||
@touchstart="dragStart"
|
||||
ref="handler"
|
||||
:style="handlerStyle"
|
||||
>
|
||||
<i class="iconfont-sys" v-html="value ? successIcon : handlerIcon"></i>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits(['handlerMove', 'update:value', 'passCallback'])
|
||||
interface PropsType {
|
||||
value: boolean
|
||||
width?: number
|
||||
height?: number
|
||||
text?: string
|
||||
successText?: string
|
||||
background?: string
|
||||
progressBarBg?: string
|
||||
completedBg?: string
|
||||
circle?: boolean
|
||||
radius?: string
|
||||
handlerIcon?: string
|
||||
successIcon?: string
|
||||
handlerBg?: string
|
||||
textSize?: string
|
||||
textColor?: string
|
||||
}
|
||||
const props = withDefaults(defineProps<PropsType>(), {
|
||||
value: false,
|
||||
width: 260,
|
||||
height: 38,
|
||||
text: '按住滑块拖动',
|
||||
successText: 'success',
|
||||
background: '#eee',
|
||||
progressBarBg: '#1385FF',
|
||||
completedBg: '#57D187',
|
||||
circle: false,
|
||||
radius: 'calc(var(--custom-radius) / 3 + 2px)',
|
||||
handlerIcon: '',
|
||||
successIcon: '',
|
||||
handlerBg: '#fff',
|
||||
textSize: '13px',
|
||||
textColor: '#333'
|
||||
})
|
||||
interface stateType {
|
||||
isMoving: boolean
|
||||
x: number
|
||||
isOk: boolean
|
||||
}
|
||||
const state = reactive(<stateType>{
|
||||
isMoving: false,
|
||||
x: 0,
|
||||
isOk: false
|
||||
})
|
||||
const { isOk } = toRefs(state)
|
||||
const dragVerify = ref()
|
||||
const messageRef = ref()
|
||||
const handler = ref()
|
||||
const progressBar = ref()
|
||||
|
||||
// 禁止横向滑动
|
||||
let startX: number, startY: number, moveX: number, moveY: number
|
||||
|
||||
const onTouchStart = (e: any) => {
|
||||
startX = e.targetTouches[0].pageX
|
||||
startY = e.targetTouches[0].pageY
|
||||
}
|
||||
|
||||
const onTouchMove = (e: any) => {
|
||||
moveX = e.targetTouches[0].pageX
|
||||
moveY = e.targetTouches[0].pageY
|
||||
|
||||
if (Math.abs(moveX - startX) > Math.abs(moveY - startY)) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('touchstart', onTouchStart)
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||
|
||||
onMounted(() => {
|
||||
dragVerify.value?.style.setProperty('--textColor', props.textColor)
|
||||
dragVerify.value?.style.setProperty('--width', Math.floor(props.width / 2) + 'px')
|
||||
dragVerify.value?.style.setProperty('--pwidth', -Math.floor(props.width / 2) + 'px')
|
||||
|
||||
document.addEventListener('touchstart', onTouchStart)
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('touchstart', onTouchStart)
|
||||
document.removeEventListener('touchmove', onTouchMove)
|
||||
})
|
||||
const handlerStyle = {
|
||||
left: '0',
|
||||
width: props.height + 'px',
|
||||
height: props.height + 'px',
|
||||
background: props.handlerBg
|
||||
}
|
||||
const dragVerifyStyle = {
|
||||
width: props.width + 'px',
|
||||
height: props.height + 'px',
|
||||
lineHeight: props.height + 'px',
|
||||
background: props.background,
|
||||
borderRadius: props.circle ? props.height / 2 + 'px' : props.radius
|
||||
}
|
||||
const progressBarStyle = {
|
||||
background: props.progressBarBg,
|
||||
height: props.height + 'px',
|
||||
borderRadius: props.circle
|
||||
? props.height / 2 + 'px 0 0 ' + props.height / 2 + 'px'
|
||||
: props.radius
|
||||
}
|
||||
const textStyle = {
|
||||
height: props.height + 'px',
|
||||
width: props.width + 'px',
|
||||
fontSize: props.textSize
|
||||
}
|
||||
const message = computed(() => {
|
||||
return props.value ? props.successText : props.text
|
||||
})
|
||||
|
||||
const dragStart = (e: any) => {
|
||||
if (!props.value) {
|
||||
state.isMoving = true
|
||||
handler.value.style.transition = 'none'
|
||||
state.x =
|
||||
(e.pageX || e.touches[0].pageX) - parseInt(handler.value.style.left.replace('px', ''), 10)
|
||||
}
|
||||
emit('handlerMove')
|
||||
}
|
||||
const dragMoving = (e: any) => {
|
||||
if (state.isMoving && !props.value) {
|
||||
let _x = (e.pageX || e.touches[0].pageX) - state.x
|
||||
if (_x > 0 && _x <= props.width - props.height) {
|
||||
handler.value.style.left = _x + 'px'
|
||||
progressBar.value.style.width = _x + props.height / 2 + 'px'
|
||||
} else if (_x > props.width - props.height) {
|
||||
handler.value.style.left = props.width - props.height + 'px'
|
||||
progressBar.value.style.width = props.width - props.height / 2 + 'px'
|
||||
passVerify()
|
||||
}
|
||||
}
|
||||
}
|
||||
const dragFinish = (e: any) => {
|
||||
if (state.isMoving && !props.value) {
|
||||
let _x = (e.pageX || e.changedTouches[0].pageX) - state.x
|
||||
if (_x < props.width - props.height) {
|
||||
state.isOk = true
|
||||
handler.value.style.left = '0'
|
||||
handler.value.style.transition = 'all 0.2s'
|
||||
progressBar.value.style.width = '0'
|
||||
state.isOk = false
|
||||
} else {
|
||||
handler.value.style.transition = 'none'
|
||||
handler.value.style.left = props.width - props.height + 'px'
|
||||
progressBar.value.style.width = props.width - props.height / 2 + 'px'
|
||||
passVerify()
|
||||
}
|
||||
state.isMoving = false
|
||||
}
|
||||
}
|
||||
const passVerify = () => {
|
||||
emit('update:value', true)
|
||||
state.isMoving = false
|
||||
progressBar.value.style.background = props.completedBg
|
||||
messageRef.value.style['-webkit-text-fill-color'] = 'unset'
|
||||
messageRef.value.style.animation = 'slidetounlock2 3s infinite'
|
||||
messageRef.value.style.color = '#fff'
|
||||
emit('passCallback')
|
||||
}
|
||||
const reset = () => {
|
||||
handler.value.style.left = '0'
|
||||
progressBar.value.style.width = '0'
|
||||
handler.value.children[0].innerHTML = props.handlerIcon
|
||||
messageRef.value.style['-webkit-text-fill-color'] = 'transparent'
|
||||
messageRef.value.style.animation = 'slidetounlock 3s infinite'
|
||||
messageRef.value.style.color = props.background
|
||||
emit('update:value', false)
|
||||
state.isOk = false
|
||||
state.isMoving = false
|
||||
state.x = 0
|
||||
}
|
||||
defineExpose({
|
||||
reset
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.drag_verify {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
border: 1px solid var(--art-border-dashed-color);
|
||||
|
||||
.dv_handler {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
cursor: move;
|
||||
|
||||
i {
|
||||
padding-left: 0;
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.el-icon-circle-check {
|
||||
margin-top: 9px;
|
||||
color: #6c6;
|
||||
}
|
||||
}
|
||||
|
||||
.dv_progress_bar {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.dv_text {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
color: transparent;
|
||||
user-select: none;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--textColor) 0%,
|
||||
var(--textColor) 40%,
|
||||
#fff 50%,
|
||||
var(--textColor) 60%,
|
||||
var(--textColor) 100%
|
||||
);
|
||||
background-clip: text;
|
||||
animation: slidetounlock 3s infinite;
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-size-adjust: none;
|
||||
|
||||
* {
|
||||
-webkit-text-fill-color: var(--textColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.goFirst {
|
||||
left: 0 !important;
|
||||
transition: left 0.5s;
|
||||
}
|
||||
|
||||
.goFirst2 {
|
||||
width: 0 !important;
|
||||
transition: width 0.5s;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
@keyframes slidetounlock {
|
||||
0% {
|
||||
background-position: var(--pwidth) 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: var(--width) 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slidetounlock2 {
|
||||
0% {
|
||||
background-position: var(--pwidth) 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: var(--pwidth) 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
126
src/components/core/forms/ArtExcelExport.vue
Normal file
126
src/components/core/forms/ArtExcelExport.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<el-button :type="type" :loading="isExporting" v-ripple @click="handleExport">
|
||||
<slot>导出 Excel</slot>
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as XLSX from 'xlsx'
|
||||
import FileSaver from 'file-saver'
|
||||
import { ref } from 'vue'
|
||||
import type { ButtonType } from 'element-plus'
|
||||
import { useThrottleFn } from '@vueuse/core'
|
||||
|
||||
interface ExportData {
|
||||
[key: string]: string | number | boolean | null
|
||||
}
|
||||
|
||||
interface ExportOptions {
|
||||
data: ExportData[]
|
||||
filename?: string
|
||||
sheetName?: string
|
||||
type?: ButtonType
|
||||
autoIndex?: boolean
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ExportOptions>(), {
|
||||
filename: 'export',
|
||||
sheetName: 'Sheet1',
|
||||
type: 'primary',
|
||||
autoIndex: false,
|
||||
headers: () => ({})
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'export-success': []
|
||||
'export-error': [error: Error]
|
||||
}>()
|
||||
|
||||
const isExporting = ref(false)
|
||||
|
||||
const processData = (data: ExportData[]): ExportData[] => {
|
||||
const processedData = [...data]
|
||||
|
||||
if (props.autoIndex) {
|
||||
return processedData.map((item, index) => ({
|
||||
序号: index + 1,
|
||||
...Object.entries(item).reduce(
|
||||
(acc: Record<string, any>, [key, value]) => {
|
||||
acc[props.headers[key] || key] = value
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, any>
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
if (Object.keys(props.headers).length > 0) {
|
||||
return processedData.map((item) =>
|
||||
Object.entries(item).reduce(
|
||||
(acc, [key, value]) => ({
|
||||
...acc,
|
||||
[props.headers[key] || key]: value
|
||||
}),
|
||||
{}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return processedData
|
||||
}
|
||||
|
||||
const exportToExcel = async (
|
||||
data: ExportData[],
|
||||
filename: string,
|
||||
sheetName: string
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const processedData = processData(data)
|
||||
const workbook = XLSX.utils.book_new()
|
||||
const worksheet = XLSX.utils.json_to_sheet(processedData)
|
||||
|
||||
const maxWidth = 50
|
||||
const minWidth = 10
|
||||
const columnWidths = Object.keys(processedData[0] || {}).map((key) => {
|
||||
const keyLength = key.length
|
||||
return { wch: Math.min(Math.max(keyLength, minWidth), maxWidth) }
|
||||
})
|
||||
worksheet['!cols'] = columnWidths
|
||||
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName)
|
||||
|
||||
const excelBuffer = XLSX.write(workbook, {
|
||||
bookType: 'xlsx',
|
||||
type: 'array'
|
||||
})
|
||||
|
||||
const blob = new Blob([excelBuffer], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
})
|
||||
|
||||
FileSaver.saveAs(blob, `${filename}.xlsx`)
|
||||
} catch (error) {
|
||||
throw new Error(`Excel 导出失败: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = useThrottleFn(async () => {
|
||||
if (isExporting.value) return
|
||||
|
||||
isExporting.value = true
|
||||
try {
|
||||
if (!props.data?.length) {
|
||||
throw new Error('没有可导出的数据')
|
||||
}
|
||||
|
||||
await exportToExcel(props.data, props.filename, props.sheetName)
|
||||
emit('export-success')
|
||||
} catch (error) {
|
||||
emit('export-error', error as Error)
|
||||
console.error('Excel 导出错误:', error)
|
||||
} finally {
|
||||
isExporting.value = false
|
||||
}
|
||||
}, 1000)
|
||||
</script>
|
||||
65
src/components/core/forms/ArtExcelImport.vue
Normal file
65
src/components/core/forms/ArtExcelImport.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="excel-uploader">
|
||||
<el-upload
|
||||
:auto-upload="false"
|
||||
accept=".xlsx, .xls"
|
||||
:show-file-list="false"
|
||||
@change="handleFileChange"
|
||||
>
|
||||
<el-button type="primary" v-ripple>
|
||||
<slot name="import-text">导入 Excel</slot>
|
||||
</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as XLSX from 'xlsx'
|
||||
import type { UploadFile } from 'element-plus'
|
||||
|
||||
// Excel 导入工具函数
|
||||
async function importExcel(file: File): Promise<any[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = e.target?.result
|
||||
const workbook = XLSX.read(data, { type: 'array' })
|
||||
const firstSheetName = workbook.SheetNames[0]
|
||||
const worksheet = workbook.Sheets[firstSheetName]
|
||||
const results = XLSX.utils.sheet_to_json(worksheet)
|
||||
resolve(results)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
reader.onerror = (error) => reject(error)
|
||||
reader.readAsArrayBuffer(file)
|
||||
})
|
||||
}
|
||||
|
||||
// 定义 emits
|
||||
const emit = defineEmits<{
|
||||
'import-success': [data: any[]]
|
||||
'import-error': [error: Error]
|
||||
}>()
|
||||
|
||||
// 处理文件导入
|
||||
const handleFileChange = async (uploadFile: UploadFile) => {
|
||||
try {
|
||||
if (!uploadFile.raw) return
|
||||
const results = await importExcel(uploadFile.raw)
|
||||
emit('import-success', results)
|
||||
} catch (error) {
|
||||
emit('import-error', error as Error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.excel-uploader {
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
386
src/components/core/forms/ArtWangEditor.vue
Normal file
386
src/components/core/forms/ArtWangEditor.vue
Normal file
@@ -0,0 +1,386 @@
|
||||
<!-- 富文本编辑器 插件地址:https://www.wangeditor.com/ -->
|
||||
<template>
|
||||
<div class="editor-wrapper">
|
||||
<Toolbar
|
||||
class="editor-toolbar"
|
||||
:editor="editorRef"
|
||||
:mode="mode"
|
||||
:defaultConfig="toolbarConfig"
|
||||
/>
|
||||
<Editor
|
||||
style="height: 700px; overflow-y: hidden"
|
||||
v-model="modelValue"
|
||||
:mode="mode"
|
||||
:defaultConfig="editorConfig"
|
||||
@onCreated="onCreateEditor"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import '@wangeditor/editor/dist/css/style.css'
|
||||
import { onBeforeUnmount, onMounted, ref, shallowRef } from 'vue'
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import EmojiText from '@/utils/ui/emojo'
|
||||
import { IDomEditor } from '@wangeditor/editor'
|
||||
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
|
||||
// 编辑器实例
|
||||
const editorRef = shallowRef()
|
||||
let mode = ref('default')
|
||||
const userStore = useUserStore()
|
||||
// token
|
||||
let { accessToken } = userStore
|
||||
// 图片上传地址
|
||||
let server = `${import.meta.env.VITE_API_URL}/api/common/upload/wangeditor`
|
||||
|
||||
const toolbarConfig = {
|
||||
// 重新配置工具栏,显示哪些菜单,以及菜单的排序、分组。
|
||||
// toolbarKeys: [],
|
||||
// 可以在当前 toolbarKeys 的基础上继续插入新菜单,如自定义扩展的菜单。
|
||||
// insertKeys: {
|
||||
// index: 5, // 插入的位置,基于当前的 toolbarKeys
|
||||
// keys: ['menu-key1', 'menu-key2']
|
||||
// }
|
||||
// 排除某些菜单
|
||||
excludeKeys: ['fontFamily'] //'group-video', 'fontSize', 'lineHeight'
|
||||
}
|
||||
|
||||
const editorConfig = {
|
||||
placeholder: '请输入内容...',
|
||||
MENU_CONF: {
|
||||
// 上传图片
|
||||
uploadImage: {
|
||||
fieldName: 'file',
|
||||
maxFileSize: 3 * 1024 * 1024, // 大小限制
|
||||
maxNumberOfFiles: 10, // 最多可上传几个文件,默认为 100
|
||||
allowedFileTypes: ['image/*'], // 选择文件时的类型限制,默认为 ['image/*'] 。如不想限制,则设置为 []
|
||||
// 注意 ${import.meta.env.VITE_BASE_URL} 写你自己的后端服务地址
|
||||
server,
|
||||
// 传递token
|
||||
headers: { Authorization: accessToken },
|
||||
// 单个文件上传成功之后
|
||||
onSuccess() {
|
||||
ElMessage.success(`图片上传成功 ${EmojiText[200]}`)
|
||||
},
|
||||
// 上传错误,或者触发 timeout 超时
|
||||
onError(file: File, err: any, res: any) {
|
||||
console.log(`上传出错`, err, res)
|
||||
ElMessage.error(`图片上传失败 ${EmojiText[500]}`)
|
||||
}
|
||||
// 注意:返回格式需要按照指定格式返回,才能显示图片
|
||||
// 上传成功的返回格式:
|
||||
// "errno": 0, // 注意:值是数字,不能是字符串
|
||||
// "data": {
|
||||
// "url": "xxx", // 图片 src ,必须
|
||||
// "alt": "yyy", // 图片描述文字,非必须
|
||||
// "href": "zzz" // 图片的链接,非必须
|
||||
// }
|
||||
// }
|
||||
// 上传失败的返回格式:
|
||||
// {
|
||||
// "errno": 1, // 只要不等于 0 就行
|
||||
// "message": "失败信息"
|
||||
// }
|
||||
}
|
||||
// 代码语言
|
||||
// codeLangs: [
|
||||
// { text: 'CSS', value: 'css' },
|
||||
// { text: 'HTML', value: 'html' },
|
||||
// { text: 'XML', value: 'xml' },
|
||||
// ]
|
||||
}
|
||||
}
|
||||
|
||||
const onCreateEditor = (editor: IDomEditor) => {
|
||||
editorRef.value = editor // 记录 editor 实例
|
||||
// editorRef.value.setHtml('内容')
|
||||
// getToolbar(editor)
|
||||
|
||||
editor.on('fullScreen', () => {
|
||||
console.log('fullScreen')
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
overrideIcon()
|
||||
})
|
||||
|
||||
// 替换默认图标
|
||||
const overrideIcon = () => {
|
||||
setTimeout(() => {
|
||||
const icon0 = document.querySelector(`button[data-menu-key="bold"]`)
|
||||
if (icon0) icon0.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon1 = document.querySelector(`button[data-menu-key="blockquote"]`)
|
||||
if (icon1) icon1.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon2 = document.querySelector(`button[data-menu-key="underline"]`)
|
||||
if (icon2) icon2.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon3 = document.querySelector(`button[data-menu-key="italic"]`)
|
||||
if (icon3) icon3.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon4 = document.querySelector(`button[data-menu-key="group-more-style"]`)
|
||||
if (icon4) icon4.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon5 = document.querySelector(`button[data-menu-key="color"]`)
|
||||
if (icon5) icon5.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon6 = document.querySelector(`button[data-menu-key="bgColor"]`)
|
||||
if (icon6) icon6.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon7 = document.querySelector(`button[data-menu-key="bulletedList"]`)
|
||||
if (icon7) icon7.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon8 = document.querySelector(`button[data-menu-key="numberedList"]`)
|
||||
if (icon8) icon8.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon9 = document.querySelector(`button[data-menu-key="todo"]`)
|
||||
if (icon9) icon9.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon10 = document.querySelector(`button[data-menu-key="group-justify"]`)
|
||||
if (icon10) icon10.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon11 = document.querySelector(`button[data-menu-key="group-indent"]`)
|
||||
if (icon11) icon11.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon12 = document.querySelector(`button[data-menu-key="emotion"]`)
|
||||
if (icon12) icon12.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon13 = document.querySelector(`button[data-menu-key="insertLink"]`)
|
||||
if (icon13) icon13.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon14 = document.querySelector(`button[data-menu-key="group-image"]`)
|
||||
if (icon14) icon14.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon15 = document.querySelector(`button[data-menu-key="insertTable"]`)
|
||||
if (icon15) icon15.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon16 = document.querySelector(`button[data-menu-key="codeBlock"]`)
|
||||
if (icon16) icon16.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon17 = document.querySelector(`button[data-menu-key="divider"]`)
|
||||
if (icon17) icon17.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon18 = document.querySelector(`button[data-menu-key="undo"]`)
|
||||
if (icon18) icon18.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon19 = document.querySelector(`button[data-menu-key="redo"]`)
|
||||
if (icon19) icon19.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon20 = document.querySelector(`button[data-menu-key="fullScreen"]`)
|
||||
if (icon20) icon20.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon21 = document.querySelector(`button[data-menu-key="tableFullWidth"]`)
|
||||
if (icon21) icon21.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
}, 10)
|
||||
}
|
||||
|
||||
// 获取工具栏
|
||||
// const getToolbar = (editor: IDomEditor) => {
|
||||
// setTimeout(() => {
|
||||
// const toolbar = DomEditor.getToolbar(editor)
|
||||
// console.log(toolbar?.getConfig().toolbarKeys) // 当前菜单排序和分组
|
||||
// }, 300)
|
||||
// }
|
||||
|
||||
// 组件销毁时,也及时销毁编辑器
|
||||
onBeforeUnmount(() => {
|
||||
const editor = editorRef.value
|
||||
if (editor == null) return
|
||||
editor.destroy()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
$box-radius: calc(var(--custom-radius) / 2 + 2px);
|
||||
|
||||
/* 编辑器容器 */
|
||||
.editor-wrapper {
|
||||
z-index: 5000;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid rgba(var(--art-gray-300-rgb), 0.8);
|
||||
border-radius: $box-radius !important;
|
||||
|
||||
.iconfont-sys {
|
||||
font-size: 20px !important;
|
||||
}
|
||||
|
||||
.w-e-bar {
|
||||
border-radius: $box-radius $box-radius 0 0 !important;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
i {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 工具栏 */
|
||||
.editor-toolbar {
|
||||
border-bottom: 1px solid var(--art-border-color);
|
||||
}
|
||||
|
||||
/* 下拉选择框配置 */
|
||||
.w-e-select-list {
|
||||
min-width: 140px;
|
||||
padding: 5px 10px 10px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* 下拉选择框元素配置 */
|
||||
.w-e-select-list ul li {
|
||||
margin-top: 5px;
|
||||
font-size: 15px !important;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* 下拉选择框 正文文字大小调整 */
|
||||
.w-e-select-list ul li:last-of-type {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
/* 下拉选择框 hover 样式调整 */
|
||||
.w-e-select-list ul li:hover {
|
||||
background-color: var(--art-gray-200);
|
||||
}
|
||||
|
||||
:root {
|
||||
/* 激活颜色 */
|
||||
--w-e-toolbar-active-bg-color: var(--art-gray-200);
|
||||
|
||||
/* toolbar 图标和文字颜色 */
|
||||
--w-e-toolbar-color: #000;
|
||||
|
||||
/* 表格选中时候的边框颜色 */
|
||||
--w-e-textarea-selected-border-color: #ddd;
|
||||
|
||||
/* 表格头背景颜色 */
|
||||
--w-e-textarea-slight-bg-color: var(--art-gray-200);
|
||||
}
|
||||
|
||||
/* 工具栏按钮样式 */
|
||||
.w-e-bar-item button {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* 工具栏 hover 按钮背景颜色 */
|
||||
.w-e-bar-item button:hover {
|
||||
background-color: var(--art-gray-200);
|
||||
}
|
||||
|
||||
/* 工具栏分割线 */
|
||||
.w-e-bar-divider {
|
||||
height: 20px;
|
||||
margin-top: 10px;
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
/* 工具栏菜单 */
|
||||
.w-e-bar-item-group .w-e-bar-item-menus-container {
|
||||
min-width: 120px;
|
||||
padding: 10px 0;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* 代码块 */
|
||||
.w-e-text-container [data-slate-editor] pre > code {
|
||||
padding: 0.6rem 1rem;
|
||||
background-color: var(--art-gray-100);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* 弹出框 */
|
||||
.w-e-drop-panel {
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #318ef4;
|
||||
}
|
||||
|
||||
/* 表格样式优化 */
|
||||
.w-e-text-container [data-slate-editor] .table-container th {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.w-e-text-container [data-slate-editor] .table-container th:last-of-type {
|
||||
border-right: 1px solid #ccc !important;
|
||||
}
|
||||
|
||||
/* 引用 */
|
||||
.w-e-text-container [data-slate-editor] blockquote {
|
||||
background-color: rgba(var(--art-gray-300-rgb), 0.25);
|
||||
border-left: 4px solid var(--art-gray-300);
|
||||
}
|
||||
|
||||
/* 输入区域弹出 bar */
|
||||
.w-e-hover-bar {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* 超链接弹窗 */
|
||||
.w-e-modal {
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* 图片样式调整 */
|
||||
.w-e-text-container [data-slate-editor] .w-e-selected-image-container {
|
||||
overflow: inherit;
|
||||
|
||||
&:hover {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
img {
|
||||
border: 1px solid transparent;
|
||||
transition: border 0.3s;
|
||||
|
||||
&:hover {
|
||||
border: 1px solid #318ef4 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.w-e-image-dragger {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: #318ef4;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.left-top {
|
||||
top: -6px;
|
||||
left: -6px;
|
||||
}
|
||||
|
||||
.right-top {
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
}
|
||||
|
||||
.left-bottom {
|
||||
bottom: -6px;
|
||||
left: -6px;
|
||||
}
|
||||
|
||||
.right-bottom {
|
||||
right: -6px;
|
||||
bottom: -6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
201
src/components/core/forms/art-search-bar/index.vue
Normal file
201
src/components/core/forms/art-search-bar/index.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<section class="search-bar art-custom-card">
|
||||
<el-form :model="filter" :label-position="props.labelPosition">
|
||||
<el-row class="search-form-row" :gutter="props.gutter">
|
||||
<el-col
|
||||
v-for="item in useFormItemArr"
|
||||
:key="item.prop"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:md="item.elColSpan || props.elColSpan"
|
||||
:lg="item.elColSpan || props.elColSpan"
|
||||
:xl="item.elColSpan || props.elColSpan"
|
||||
>
|
||||
<el-form-item :label="`${item.label}`" :prop="item.prop" :label-width="props.labelWidth">
|
||||
<component
|
||||
:is="getComponent(item.type)"
|
||||
v-model:value="filter[item.prop]"
|
||||
:item="item"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col
|
||||
:xs="24"
|
||||
:sm="24"
|
||||
:md="props.elColSpan"
|
||||
:lg="props.elColSpan"
|
||||
:xl="props.elColSpan"
|
||||
class="action-column"
|
||||
>
|
||||
<div
|
||||
class="action-buttons-wrapper"
|
||||
:style="{
|
||||
'justify-content': isMobile
|
||||
? 'flex-end'
|
||||
: props.items.length <= props.buttonLeftLimit
|
||||
? 'flex-start'
|
||||
: 'flex-end'
|
||||
}"
|
||||
>
|
||||
<div class="form-buttons">
|
||||
<el-button class="reset-button" @click="$emit('reset')" v-ripple>{{
|
||||
$t('table.searchBar.reset')
|
||||
}}</el-button>
|
||||
<el-button type="primary" class="search-button" @click="$emit('search')" v-ripple>{{
|
||||
$t('table.searchBar.search')
|
||||
}}</el-button>
|
||||
</div>
|
||||
<div
|
||||
v-if="!props.isExpand && props.showExpand"
|
||||
class="filter-toggle"
|
||||
@click="isShow = !isShow"
|
||||
>
|
||||
<span>{{
|
||||
isShow ? $t('table.searchBar.collapse') : $t('table.searchBar.expand')
|
||||
}}</span>
|
||||
<div class="icon-wrapper">
|
||||
<el-icon>
|
||||
<ArrowUpBold v-if="isShow" />
|
||||
<ArrowDownBold v-else />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { ArrowUpBold, ArrowDownBold } from '@element-plus/icons-vue'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import ArtSearchInput from './widget/art-search-input/index.vue'
|
||||
import ArtSearchSelect from './widget/art-search-select/index.vue'
|
||||
import ArtSearchRadio from './widget/art-search-radio/index.vue'
|
||||
import ArtSearchDate from './widget/art-search-date/index.vue'
|
||||
import { SearchComponentType, SearchFormItem } from '@/types'
|
||||
|
||||
const { width } = useWindowSize()
|
||||
const isMobile = computed(() => width.value < 500)
|
||||
|
||||
type FilterVo = string | number | undefined | null | unknown[]
|
||||
|
||||
interface PropsVO {
|
||||
filter: Record<string, FilterVo> // 查询参数
|
||||
items: SearchFormItem[] // 表单数据
|
||||
elColSpan?: number // 每列的宽度(基于 24 格布局)
|
||||
gutter?: number // 表单控件间隙
|
||||
isExpand?: boolean // 展开/收起
|
||||
labelPosition?: 'left' | 'right' // 表单域标签的位置
|
||||
labelWidth?: string // 文字宽度
|
||||
showExpand?: boolean // 是否需要展示,收起
|
||||
buttonLeftLimit?: number // 按钮靠左对齐限制(表单项小于等于该值时)
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<PropsVO>(), {
|
||||
elColSpan: 6,
|
||||
gutter: 12,
|
||||
isExpand: false,
|
||||
labelPosition: 'right',
|
||||
labelWidth: '70px',
|
||||
showExpand: true,
|
||||
buttonLeftLimit: 2
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:filter', filter: Record<string, FilterVo>): void
|
||||
(e: 'reset'): void
|
||||
(e: 'search'): void
|
||||
}>()
|
||||
|
||||
const isShow = ref(false)
|
||||
|
||||
const useFormItemArr = computed(() => {
|
||||
const isshowLess = !props.isExpand && !isShow.value
|
||||
if (isshowLess) {
|
||||
const num = Math.floor(24 / props.elColSpan) - 1
|
||||
return props.items.slice(0, num)
|
||||
} else {
|
||||
return props.items
|
||||
}
|
||||
})
|
||||
|
||||
const filter = computed({
|
||||
get: () => props.filter,
|
||||
set: (val) => emit('update:filter', val)
|
||||
})
|
||||
|
||||
const getComponent = (type: SearchComponentType): any => {
|
||||
const componentsMap: Record<string, any> = {
|
||||
input: ArtSearchInput,
|
||||
select: ArtSearchSelect,
|
||||
radio: ArtSearchRadio,
|
||||
datetime: ArtSearchDate,
|
||||
date: ArtSearchDate,
|
||||
daterange: ArtSearchDate,
|
||||
datetimerange: ArtSearchDate,
|
||||
month: ArtSearchDate,
|
||||
monthrange: ArtSearchDate,
|
||||
year: ArtSearchDate,
|
||||
yearrange: ArtSearchDate,
|
||||
week: ArtSearchDate
|
||||
}
|
||||
return componentsMap[type]
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.search-bar {
|
||||
padding: 20px 20px 0;
|
||||
touch-action: none !important;
|
||||
background-color: var(--art-main-bg-color);
|
||||
border-radius: calc(var(--custom-radius) / 2 + 2px);
|
||||
|
||||
:deep(.el-form-item__label) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.search-form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-column {
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
|
||||
.action-buttons-wrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.filter-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 10px;
|
||||
line-height: 32px;
|
||||
color: var(--main-color);
|
||||
cursor: pointer;
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,286 @@
|
||||
# ArtSearchDate 日期选择器组件
|
||||
|
||||
一个功能丰富的日期选择器组件,支持多种日期选择类型。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ **单日期选择** - 选择单个日期
|
||||
- ✅ **日期时间选择** - 选择日期和时间
|
||||
- ✅ **日期范围选择** - 选择日期范围
|
||||
- ✅ **日期时间范围选择** - 选择日期时间范围
|
||||
- ✅ **月份选择** - 选择月份
|
||||
- ✅ **月份范围选择** - 选择月份范围
|
||||
- ✅ **年份选择** - 选择年份
|
||||
- ✅ **年份范围选择** - 选择年份范围
|
||||
- ✅ **周选择** - 选择周
|
||||
- ✅ **快捷选项** - 内置常用快捷选项
|
||||
- ✅ **自定义配置** - 支持全面的自定义配置
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 1. 单日期选择
|
||||
|
||||
```typescript
|
||||
const searchItems: SearchFormItem[] = [
|
||||
{
|
||||
prop: 'date',
|
||||
label: '日期',
|
||||
type: 'date',
|
||||
config: {
|
||||
type: 'date',
|
||||
placeholder: '请选择日期'
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 2. 日期时间选择
|
||||
|
||||
```typescript
|
||||
const searchItems: SearchFormItem[] = [
|
||||
{
|
||||
prop: 'datetime',
|
||||
label: '日期时间',
|
||||
type: 'datetime',
|
||||
config: {
|
||||
type: 'datetime',
|
||||
placeholder: '请选择日期时间'
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 3. 日期范围选择
|
||||
|
||||
```typescript
|
||||
const searchItems: SearchFormItem[] = [
|
||||
{
|
||||
prop: 'daterange',
|
||||
label: '日期范围',
|
||||
type: 'daterange',
|
||||
config: {
|
||||
type: 'daterange',
|
||||
startPlaceholder: '开始日期',
|
||||
endPlaceholder: '结束日期',
|
||||
rangeSeparator: '至'
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 4. 日期时间范围选择
|
||||
|
||||
```typescript
|
||||
const searchItems: SearchFormItem[] = [
|
||||
{
|
||||
prop: 'datetimerange',
|
||||
label: '日期时间范围',
|
||||
type: 'datetimerange',
|
||||
config: {
|
||||
type: 'datetimerange',
|
||||
startPlaceholder: '开始时间',
|
||||
endPlaceholder: '结束时间'
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 5. 月份选择
|
||||
|
||||
```typescript
|
||||
const searchItems: SearchFormItem[] = [
|
||||
{
|
||||
prop: 'month',
|
||||
label: '月份',
|
||||
type: 'month',
|
||||
config: {
|
||||
type: 'month',
|
||||
placeholder: '请选择月份'
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 6. 月份范围选择
|
||||
|
||||
```typescript
|
||||
const searchItems: SearchFormItem[] = [
|
||||
{
|
||||
prop: 'monthrange',
|
||||
label: '月份范围',
|
||||
type: 'monthrange',
|
||||
config: {
|
||||
type: 'monthrange',
|
||||
startPlaceholder: '开始月份',
|
||||
endPlaceholder: '结束月份'
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 7. 年份选择
|
||||
|
||||
```typescript
|
||||
const searchItems: SearchFormItem[] = [
|
||||
{
|
||||
prop: 'year',
|
||||
label: '年份',
|
||||
type: 'year',
|
||||
config: {
|
||||
type: 'year',
|
||||
placeholder: '请选择年份'
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 8. 年份范围选择
|
||||
|
||||
```typescript
|
||||
const searchItems: SearchFormItem[] = [
|
||||
{
|
||||
prop: 'yearrange',
|
||||
label: '年份范围',
|
||||
type: 'yearrange',
|
||||
config: {
|
||||
type: 'yearrange',
|
||||
startPlaceholder: '开始年份',
|
||||
endPlaceholder: '结束年份'
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 9. 周选择
|
||||
|
||||
```typescript
|
||||
const searchItems: SearchFormItem[] = [
|
||||
{
|
||||
prop: 'week',
|
||||
label: '周',
|
||||
type: 'week',
|
||||
config: {
|
||||
type: 'week',
|
||||
placeholder: '请选择周'
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 高级配置
|
||||
|
||||
### 自定义快捷选项
|
||||
|
||||
```typescript
|
||||
const searchItems: SearchFormItem[] = [
|
||||
{
|
||||
prop: 'daterange',
|
||||
label: '日期范围',
|
||||
type: 'daterange',
|
||||
config: {
|
||||
type: 'daterange',
|
||||
shortcuts: [
|
||||
{
|
||||
text: '今天',
|
||||
value: () => {
|
||||
const today = new Date()
|
||||
return [today, today]
|
||||
}
|
||||
},
|
||||
{
|
||||
text: '最近一周',
|
||||
value: () => {
|
||||
const end = new Date()
|
||||
const start = new Date()
|
||||
start.setDate(start.getDate() - 6)
|
||||
return [start, end]
|
||||
}
|
||||
},
|
||||
{
|
||||
text: '最近一个月',
|
||||
value: () => {
|
||||
const end = new Date()
|
||||
const start = new Date()
|
||||
start.setMonth(start.getMonth() - 1)
|
||||
return [start, end]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 禁用特定日期
|
||||
|
||||
```typescript
|
||||
const searchItems: SearchFormItem[] = [
|
||||
{
|
||||
prop: 'date',
|
||||
label: '日期',
|
||||
type: 'date',
|
||||
config: {
|
||||
type: 'date',
|
||||
disabledDate: (time: Date) => {
|
||||
// 禁用今天之前的日期
|
||||
return time.getTime() < Date.now() - 8.64e7
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 自定义格式
|
||||
|
||||
```typescript
|
||||
const searchItems: SearchFormItem[] = [
|
||||
{
|
||||
prop: 'date',
|
||||
label: '日期',
|
||||
type: 'date',
|
||||
config: {
|
||||
type: 'date',
|
||||
format: 'DD/MM/YYYY',
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
placeholder: '请选择日期 (DD/MM/YYYY)'
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 配置选项
|
||||
|
||||
| 参数 | 说明 | 类型 | 默认值 |
|
||||
| ---------------- | -------------- | --------------------------------- | ---------------- |
|
||||
| type | 日期选择器类型 | `DatePickerType` | `'date'` |
|
||||
| format | 显示格式 | `string` | 根据类型自动设置 |
|
||||
| valueFormat | 值格式 | `string` | 根据类型自动设置 |
|
||||
| placeholder | 占位符 | `string` | 自动生成 |
|
||||
| startPlaceholder | 开始日期占位符 | `string` | 根据类型自动设置 |
|
||||
| endPlaceholder | 结束日期占位符 | `string` | 根据类型自动设置 |
|
||||
| rangeSeparator | 范围分隔符 | `string` | `'至'` |
|
||||
| clearable | 是否可清空 | `boolean` | `true` |
|
||||
| disabled | 是否禁用 | `boolean` | `false` |
|
||||
| readonly | 是否只读 | `boolean` | `false` |
|
||||
| size | 尺寸 | `'large' \| 'default' \| 'small'` | `'default'` |
|
||||
| shortcuts | 快捷选项 | `Array` | 范围类型自动添加 |
|
||||
| disabledDate | 禁用日期函数 | `(time: Date) => boolean` | - |
|
||||
|
||||
## 内置快捷选项
|
||||
|
||||
对于日期范围和日期时间范围选择,组件会自动添加以下快捷选项:
|
||||
|
||||
- 今天
|
||||
- 昨天
|
||||
- 最近7天
|
||||
- 最近30天
|
||||
- 本月
|
||||
- 上月
|
||||
|
||||
## 返回值类型
|
||||
|
||||
根据不同的选择器类型,组件会返回不同格式的值:
|
||||
|
||||
- **单日期/时间类型**: `string | Date | null`
|
||||
- **范围类型**: `[string, string] | [Date, Date] | null`
|
||||
- **周类型**: `string` (格式: YYYY-MM-DD,表示该周的第一天)
|
||||
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<el-date-picker
|
||||
v-model="modelValue"
|
||||
v-bind="config"
|
||||
@change="handleChange"
|
||||
:style="{ width: '100%' }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SearchFormItem } from '@/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 组件名称
|
||||
defineOptions({
|
||||
name: 'ArtSearchDate'
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// 日期选择器类型
|
||||
type DatePickerType =
|
||||
| 'date' // 单日期
|
||||
| 'datetime' // 日期时间
|
||||
| 'daterange' // 日期范围
|
||||
| 'datetimerange' // 日期时间范围
|
||||
| 'month' // 月份
|
||||
| 'monthrange' // 月份范围
|
||||
| 'year' // 年份
|
||||
| 'yearrange' // 年份范围
|
||||
| 'week' // 周
|
||||
|
||||
// 定义组件值类型 - 根据不同类型返回不同的值
|
||||
export type ValueVO = string | Date | null | undefined | [Date, Date] | [string, string] | number
|
||||
|
||||
// 定义组件props
|
||||
interface Props {
|
||||
value: ValueVO
|
||||
item: SearchFormItem & {
|
||||
config?: {
|
||||
type?: DatePickerType
|
||||
format?: string
|
||||
valueFormat?: string
|
||||
placeholder?: string
|
||||
startPlaceholder?: string
|
||||
endPlaceholder?: string
|
||||
rangeSeparator?: string
|
||||
clearable?: boolean
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
size?: 'large' | 'default' | 'small'
|
||||
shortcuts?: Array<{
|
||||
text: string
|
||||
value: Date | (() => Date | [Date, Date])
|
||||
}>
|
||||
disabledDate?: (time: Date) => boolean
|
||||
[key: string]: any
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// 定义emit事件
|
||||
const emit = defineEmits<{
|
||||
'update:value': [value: ValueVO]
|
||||
}>()
|
||||
|
||||
// 计算属性: 处理v-model双向绑定
|
||||
const modelValue = computed({
|
||||
get: () => props.value as any,
|
||||
set: (newValue: ValueVO) => emit('update:value', newValue)
|
||||
})
|
||||
|
||||
// 获取默认配置
|
||||
const getDefaultConfig = (type: DatePickerType) => {
|
||||
const baseConfig = {
|
||||
clearable: true,
|
||||
size: 'default' as const
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'date':
|
||||
return {
|
||||
...baseConfig,
|
||||
type: 'date' as const,
|
||||
format: 'YYYY-MM-DD',
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
placeholder: `${t('table.searchBar.searchSelectPlaceholder')}${props.item.label}`
|
||||
}
|
||||
|
||||
case 'datetime':
|
||||
return {
|
||||
...baseConfig,
|
||||
type: 'datetime' as const,
|
||||
format: 'YYYY-MM-DD HH:mm:ss',
|
||||
valueFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
placeholder: `${t('table.searchBar.searchSelectPlaceholder')}${props.item.label}`
|
||||
}
|
||||
|
||||
case 'daterange':
|
||||
return {
|
||||
...baseConfig,
|
||||
type: 'daterange' as const,
|
||||
format: 'YYYY-MM-DD',
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始日期',
|
||||
endPlaceholder: '结束日期'
|
||||
}
|
||||
|
||||
case 'datetimerange':
|
||||
return {
|
||||
...baseConfig,
|
||||
type: 'datetimerange' as const,
|
||||
format: 'YYYY-MM-DD HH:mm:ss',
|
||||
valueFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始时间',
|
||||
endPlaceholder: '结束时间'
|
||||
}
|
||||
|
||||
case 'month':
|
||||
return {
|
||||
...baseConfig,
|
||||
type: 'month' as const,
|
||||
format: 'YYYY-MM',
|
||||
valueFormat: 'YYYY-MM',
|
||||
placeholder: `${t('table.searchBar.searchSelectPlaceholder')}${props.item.label}`
|
||||
}
|
||||
|
||||
case 'monthrange':
|
||||
return {
|
||||
...baseConfig,
|
||||
type: 'monthrange' as const,
|
||||
format: 'YYYY-MM',
|
||||
valueFormat: 'YYYY-MM',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始月份',
|
||||
endPlaceholder: '结束月份'
|
||||
}
|
||||
|
||||
case 'year':
|
||||
return {
|
||||
...baseConfig,
|
||||
type: 'year' as const,
|
||||
format: 'YYYY',
|
||||
valueFormat: 'YYYY',
|
||||
placeholder: `${t('table.searchBar.searchSelectPlaceholder')}${props.item.label}`
|
||||
}
|
||||
|
||||
case 'yearrange':
|
||||
return {
|
||||
...baseConfig,
|
||||
type: 'yearrange' as const,
|
||||
format: 'YYYY',
|
||||
valueFormat: 'YYYY',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始年份',
|
||||
endPlaceholder: '结束年份'
|
||||
}
|
||||
|
||||
case 'week':
|
||||
return {
|
||||
...baseConfig,
|
||||
type: 'week' as const,
|
||||
format: 'YYYY 第 ww 周',
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
placeholder: `${t('table.searchBar.searchSelectPlaceholder')}${props.item.label}`
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
...baseConfig,
|
||||
type: 'date' as const,
|
||||
format: 'YYYY-MM-DD',
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
placeholder: `${t('table.searchBar.searchSelectPlaceholder')}${props.item.label}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 常用快捷选项
|
||||
const getCommonShortcuts = (type: DatePickerType) => {
|
||||
if (!['daterange', 'datetimerange'].includes(type)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
text: '今天',
|
||||
value: () => {
|
||||
const today = new Date()
|
||||
return [today, today] as [Date, Date]
|
||||
}
|
||||
},
|
||||
{
|
||||
text: '昨天',
|
||||
value: () => {
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
return [yesterday, yesterday] as [Date, Date]
|
||||
}
|
||||
},
|
||||
{
|
||||
text: '最近7天',
|
||||
value: () => {
|
||||
const end = new Date()
|
||||
const start = new Date()
|
||||
start.setDate(start.getDate() - 6)
|
||||
return [start, end] as [Date, Date]
|
||||
}
|
||||
},
|
||||
{
|
||||
text: '最近30天',
|
||||
value: () => {
|
||||
const end = new Date()
|
||||
const start = new Date()
|
||||
start.setDate(start.getDate() - 29)
|
||||
return [start, end] as [Date, Date]
|
||||
}
|
||||
},
|
||||
{
|
||||
text: '本月',
|
||||
value: () => {
|
||||
const now = new Date()
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0)
|
||||
return [start, end] as [Date, Date]
|
||||
}
|
||||
},
|
||||
{
|
||||
text: '上月',
|
||||
value: () => {
|
||||
const now = new Date()
|
||||
const start = new Date(now.getFullYear(), now.getMonth() - 1, 1)
|
||||
const end = new Date(now.getFullYear(), now.getMonth(), 0)
|
||||
return [start, end] as [Date, Date]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 合并默认配置和自定义配置
|
||||
const config = computed(() => {
|
||||
const dateType = (props.item.config?.type || 'date') as DatePickerType
|
||||
const defaultConfig = getDefaultConfig(dateType)
|
||||
const userConfig = props.item.config || {}
|
||||
|
||||
// 如果用户没有提供快捷选项且类型是范围选择,则添加默认快捷选项
|
||||
const shouldAddShortcuts =
|
||||
!userConfig.shortcuts && ['daterange', 'datetimerange'].includes(dateType)
|
||||
const shortcuts = shouldAddShortcuts ? getCommonShortcuts(dateType) : userConfig.shortcuts
|
||||
|
||||
return {
|
||||
...defaultConfig,
|
||||
...userConfig,
|
||||
...(shortcuts && { shortcuts })
|
||||
}
|
||||
})
|
||||
|
||||
// 日期值变化处理函数
|
||||
const handleChange = (val: ValueVO): void => {
|
||||
if (props.item.onChange) {
|
||||
props.item.onChange({
|
||||
prop: props.item.prop,
|
||||
val
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<el-input v-model="value" v-bind="config" @change="(val) => changeValue(val)" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SearchFormItem } from '@/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
export type ValueVO = unknown
|
||||
|
||||
const prop = defineProps<{
|
||||
value: ValueVO // 输入框的值
|
||||
item: SearchFormItem // 表单项配置
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:value', value: ValueVO): void // 更新输入框值的事件
|
||||
}>()
|
||||
|
||||
// 计算属性:处理v-model双向绑定
|
||||
const value = computed({
|
||||
get: () => prop.value as string,
|
||||
set: (value: ValueVO) => emit('update:value', value)
|
||||
})
|
||||
|
||||
// 合并默认配置和自定义配置
|
||||
const config = reactive({
|
||||
placeholder: `${t('table.searchBar.searchInputPlaceholder')}${prop.item.label}`,
|
||||
...(prop.item.config || {}) // 合并自定义配置
|
||||
})
|
||||
|
||||
// 输入框值变化处理函数
|
||||
const changeValue = (val: unknown): void => {
|
||||
// 如果存在onChange回调则执行
|
||||
if (prop.item.onChange) {
|
||||
prop.item.onChange({
|
||||
prop: prop.item.prop,
|
||||
val
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<el-radio-group v-model="value" v-bind="config" @change="(val) => changeValue(val)">
|
||||
<el-radio v-for="v in options" :key="v.value" :value="v.value">{{ v.label }}</el-radio>
|
||||
</el-radio-group>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SearchFormItem } from '@/types'
|
||||
|
||||
export type ValueVO = unknown
|
||||
|
||||
const prop = defineProps<{
|
||||
value: ValueVO // 单选框的值
|
||||
item: SearchFormItem // 表单项配置
|
||||
}>()
|
||||
|
||||
// 定义emit事件
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:value', value: ValueVO): void // 更新单选框值的事件
|
||||
}>()
|
||||
|
||||
// 计算属性:处理v-model双向绑定
|
||||
const value = computed({
|
||||
get: () => prop.value as string,
|
||||
set: (value: ValueVO) => emit('update:value', value)
|
||||
})
|
||||
|
||||
// 合并默认配置和自定义配置
|
||||
const config = reactive({
|
||||
...(prop.item.config || {}) // 合并自定义配置
|
||||
})
|
||||
|
||||
// 计算属性:处理选项数据
|
||||
const options = computed(() => {
|
||||
if (prop.item.options) {
|
||||
// 判断options是数组还是函数
|
||||
if (Array.isArray(prop.item.options)) {
|
||||
return prop.item.options
|
||||
} else {
|
||||
return prop.item.options()
|
||||
}
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
// 单选框值变化处理函数
|
||||
const changeValue = (val: unknown): void => {
|
||||
// 如果存在onChange回调则执行
|
||||
if (prop.item.onChange) {
|
||||
prop.item.onChange({
|
||||
prop: prop.item.prop,
|
||||
val
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<el-select v-model="value" v-bind="config" @change="(val) => changeValue(val)">
|
||||
<el-option
|
||||
v-for="v in options"
|
||||
:key="v.value"
|
||||
:label="v.label"
|
||||
:value="v.value"
|
||||
:disabled="v.disabled || false"
|
||||
>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SearchFormItem } from '@/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// 定义组件值类型
|
||||
export type ValueVO = unknown
|
||||
|
||||
// 定义组件props
|
||||
const prop = defineProps<{
|
||||
value: ValueVO // 选择框的值
|
||||
item: SearchFormItem // 表单项配置
|
||||
}>()
|
||||
|
||||
// 定义emit事件
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:value', value: ValueVO): void // 更新选择框值的事件
|
||||
}>()
|
||||
|
||||
// 计算属性:处理v-model双向绑定
|
||||
const value = computed({
|
||||
get: () => prop.value as string,
|
||||
set: (value: ValueVO) => emit('update:value', value)
|
||||
})
|
||||
|
||||
// 合并默认配置和自定义配置
|
||||
const config = reactive({
|
||||
placeholder: `${t('table.searchBar.searchSelectPlaceholder')}${prop.item.label}`, // 修改默认placeholder文案
|
||||
...(prop.item.config || {})
|
||||
})
|
||||
|
||||
// 选择框值变化处理函数
|
||||
const changeValue = (val: unknown): void => {
|
||||
if (prop.item.onChange) {
|
||||
prop.item.onChange({
|
||||
prop: prop.item.prop,
|
||||
val
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 计算属性:处理选项数据
|
||||
const options = computed(() => {
|
||||
if (prop.item.options) {
|
||||
// 判断options是数组还是函数
|
||||
if (Array.isArray(prop.item.options)) {
|
||||
return prop.item.options
|
||||
} else {
|
||||
return prop.item.options()
|
||||
}
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user