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:
sexygoat
2026-01-22 16:35:33 +08:00
commit 222e5bb11a
495 changed files with 145440 additions and 0 deletions

View 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>

View 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: '&#xe602;', color: BgColorEnum.PRIMARY },
{ type: 'edit', icon: '&#xe642;', color: BgColorEnum.SECONDARY },
{ type: 'delete', icon: '&#xe783;', color: BgColorEnum.ERROR },
{ type: 'more', icon: '&#xe6df;', 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>

View 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: '&#xea50;',
successIcon: '&#xe621;',
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>

View 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>

View 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>

View 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'>&#xe630;</i>"
const icon1 = document.querySelector(`button[data-menu-key="blockquote"]`)
if (icon1) icon1.innerHTML = "<i class='iconfont-sys'>&#xe61c;</i>"
const icon2 = document.querySelector(`button[data-menu-key="underline"]`)
if (icon2) icon2.innerHTML = "<i class='iconfont-sys'>&#xe65a;</i>"
const icon3 = document.querySelector(`button[data-menu-key="italic"]`)
if (icon3) icon3.innerHTML = "<i class='iconfont-sys'>&#xe638;</i>"
const icon4 = document.querySelector(`button[data-menu-key="group-more-style"]`)
if (icon4) icon4.innerHTML = "<i class='iconfont-sys'>&#xe648;</i>"
const icon5 = document.querySelector(`button[data-menu-key="color"]`)
if (icon5) icon5.innerHTML = "<i class='iconfont-sys'>&#xe68c;</i>"
const icon6 = document.querySelector(`button[data-menu-key="bgColor"]`)
if (icon6) icon6.innerHTML = "<i class='iconfont-sys'>&#xe691;</i>"
const icon7 = document.querySelector(`button[data-menu-key="bulletedList"]`)
if (icon7) icon7.innerHTML = "<i class='iconfont-sys'>&#xe64e;</i>"
const icon8 = document.querySelector(`button[data-menu-key="numberedList"]`)
if (icon8) icon8.innerHTML = "<i class='iconfont-sys'>&#xe66c;</i>"
const icon9 = document.querySelector(`button[data-menu-key="todo"]`)
if (icon9) icon9.innerHTML = "<i class='iconfont-sys'>&#xe641;</i>"
const icon10 = document.querySelector(`button[data-menu-key="group-justify"]`)
if (icon10) icon10.innerHTML = "<i class='iconfont-sys'>&#xe67e;</i>"
const icon11 = document.querySelector(`button[data-menu-key="group-indent"]`)
if (icon11) icon11.innerHTML = "<i class='iconfont-sys'>&#xe63e;</i>"
const icon12 = document.querySelector(`button[data-menu-key="emotion"]`)
if (icon12) icon12.innerHTML = "<i class='iconfont-sys'>&#xe690;</i>"
const icon13 = document.querySelector(`button[data-menu-key="insertLink"]`)
if (icon13) icon13.innerHTML = "<i class='iconfont-sys'>&#xe63a;</i>"
const icon14 = document.querySelector(`button[data-menu-key="group-image"]`)
if (icon14) icon14.innerHTML = "<i class='iconfont-sys'>&#xe634;</i>"
const icon15 = document.querySelector(`button[data-menu-key="insertTable"]`)
if (icon15) icon15.innerHTML = "<i class='iconfont-sys'>&#xe67b;</i>"
const icon16 = document.querySelector(`button[data-menu-key="codeBlock"]`)
if (icon16) icon16.innerHTML = "<i class='iconfont-sys'>&#xe68b;</i>"
const icon17 = document.querySelector(`button[data-menu-key="divider"]`)
if (icon17) icon17.innerHTML = "<i class='iconfont-sys'>&#xe66d;</i>"
const icon18 = document.querySelector(`button[data-menu-key="undo"]`)
if (icon18) icon18.innerHTML = "<i class='iconfont-sys'>&#xe65e;</i>"
const icon19 = document.querySelector(`button[data-menu-key="redo"]`)
if (icon19) icon19.innerHTML = "<i class='iconfont-sys'>&#xe659;</i>"
const icon20 = document.querySelector(`button[data-menu-key="fullScreen"]`)
if (icon20) icon20.innerHTML = "<i class='iconfont-sys'>&#xe633;</i>"
const icon21 = document.querySelector(`button[data-menu-key="tableFullWidth"]`)
if (icon21) icon21.innerHTML = "<i class='iconfont-sys'>&#xe67b;</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>

View 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>

View File

@@ -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表示该周的第一天)

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>