feat: BMS 项目初始化(前端+后端)
- 前端 bms-front:基于 RuoYi-Vue3,封装 BasicForm/BasicTable 公共组件,替换 18 个业务页面表格 - 后端 bms-back:基于 RuoYi-Vue,包含 system/quartz/generator 等模块 - 项目标题由若依管理系统改为 BMS
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<!-- 输入框 -->
|
||||
<el-input
|
||||
v-if="comp === 'input'"
|
||||
v-model="model[item.prop]"
|
||||
:placeholder="placeholder"
|
||||
:disabled="item.disabled"
|
||||
:style="controlStyle"
|
||||
v-bind="item.props"
|
||||
/>
|
||||
<!-- 文本域 -->
|
||||
<el-input
|
||||
v-else-if="comp === 'textarea'"
|
||||
v-model="model[item.prop]"
|
||||
type="textarea"
|
||||
:placeholder="placeholder"
|
||||
:disabled="item.disabled"
|
||||
:style="controlStyle"
|
||||
v-bind="item.props"
|
||||
/>
|
||||
<!-- 数字输入 -->
|
||||
<el-input-number
|
||||
v-else-if="comp === 'inputNumber'"
|
||||
v-model="model[item.prop]"
|
||||
:disabled="item.disabled"
|
||||
:style="controlStyle"
|
||||
v-bind="item.props"
|
||||
/>
|
||||
<!-- 下拉选择 -->
|
||||
<el-select
|
||||
v-else-if="comp === 'select'"
|
||||
v-model="model[item.prop]"
|
||||
:placeholder="placeholder || '请选择'"
|
||||
:disabled="item.disabled"
|
||||
:style="controlStyle"
|
||||
v-bind="item.props"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in options"
|
||||
:key="opt[valueKey]"
|
||||
:label="opt[labelKey]"
|
||||
:value="opt[valueKey]"
|
||||
/>
|
||||
</el-select>
|
||||
<!-- 单选组 -->
|
||||
<el-radio-group
|
||||
v-else-if="comp === 'radio'"
|
||||
v-model="model[item.prop]"
|
||||
:disabled="item.disabled"
|
||||
v-bind="item.props"
|
||||
>
|
||||
<el-radio v-for="opt in options" :key="opt[valueKey]" :value="opt[valueKey]">{{ opt[labelKey] }}</el-radio>
|
||||
</el-radio-group>
|
||||
<!-- 复选组 -->
|
||||
<el-checkbox-group
|
||||
v-else-if="comp === 'checkbox'"
|
||||
v-model="model[item.prop]"
|
||||
:disabled="item.disabled"
|
||||
v-bind="item.props"
|
||||
>
|
||||
<el-checkbox v-for="opt in options" :key="opt[valueKey]" :value="opt[valueKey]">{{ opt[labelKey] }}</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
<!-- 开关 -->
|
||||
<el-switch
|
||||
v-else-if="comp === 'switch'"
|
||||
v-model="model[item.prop]"
|
||||
:disabled="item.disabled"
|
||||
v-bind="item.props"
|
||||
/>
|
||||
<!-- 日期 -->
|
||||
<el-date-picker
|
||||
v-else-if="comp === 'date'"
|
||||
v-model="model[item.prop]"
|
||||
type="date"
|
||||
:placeholder="placeholder || '请选择日期'"
|
||||
:disabled="item.disabled"
|
||||
:style="controlStyle"
|
||||
value-format="YYYY-MM-DD"
|
||||
v-bind="item.props"
|
||||
/>
|
||||
<!-- 日期时间 -->
|
||||
<el-date-picker
|
||||
v-else-if="comp === 'datetime'"
|
||||
v-model="model[item.prop]"
|
||||
type="datetime"
|
||||
:placeholder="placeholder || '请选择日期时间'"
|
||||
:disabled="item.disabled"
|
||||
:style="controlStyle"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
v-bind="item.props"
|
||||
/>
|
||||
<!-- 日期范围 -->
|
||||
<el-date-picker
|
||||
v-else-if="comp === 'daterange'"
|
||||
v-model="model[item.prop]"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
:disabled="item.disabled"
|
||||
:style="controlStyle"
|
||||
value-format="YYYY-MM-DD"
|
||||
v-bind="item.props"
|
||||
/>
|
||||
<!-- 日期时间范围 -->
|
||||
<el-date-picker
|
||||
v-else-if="comp === 'datetimerange'"
|
||||
v-model="model[item.prop]"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
:disabled="item.disabled"
|
||||
:style="controlStyle"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
v-bind="item.props"
|
||||
/>
|
||||
<!-- 时间 -->
|
||||
<el-time-picker
|
||||
v-else-if="comp === 'time'"
|
||||
v-model="model[item.prop]"
|
||||
:disabled="item.disabled"
|
||||
:style="controlStyle"
|
||||
value-format="HH:mm:ss"
|
||||
v-bind="item.props"
|
||||
/>
|
||||
<!-- 级联 -->
|
||||
<el-cascader
|
||||
v-else-if="comp === 'cascader'"
|
||||
v-model="model[item.prop]"
|
||||
:options="options"
|
||||
:placeholder="placeholder || '请选择'"
|
||||
:disabled="item.disabled"
|
||||
:style="controlStyle"
|
||||
v-bind="item.props"
|
||||
/>
|
||||
<!-- 未知控件回退到 input -->
|
||||
<el-input
|
||||
v-else
|
||||
v-model="model[item.prop]"
|
||||
:placeholder="placeholder"
|
||||
:disabled="item.disabled"
|
||||
:style="controlStyle"
|
||||
v-bind="item.props"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 表单控件渲染器(BasicForm 内部使用)
|
||||
* 根据 item.component 渲染对应的 Element Plus 控件,支持字典自动加载选项。
|
||||
* 控件直接双向绑定 model[item.prop](引用修改,与 el-form 一致)。
|
||||
*/
|
||||
defineOptions({ name: 'FormControl' })
|
||||
|
||||
const props = defineProps({
|
||||
// 表单数据对象(引用,控件原地修改其字段)
|
||||
model: { type: Object, required: true },
|
||||
// schema 项配置
|
||||
item: { type: Object, required: true },
|
||||
// 字典映射表(来自父级 BasicForm)
|
||||
dictMap: { type: Object, default: () => ({}) }
|
||||
})
|
||||
|
||||
const comp = computed(() => props.item.component || 'input')
|
||||
const placeholder = computed(() => props.item.placeholder)
|
||||
const valueKey = computed(() => props.item.valueKey || 'value')
|
||||
const labelKey = computed(() => props.item.labelKey || 'label')
|
||||
|
||||
// 控件宽度样式
|
||||
const controlStyle = computed(() => (props.item.width ? { width: props.item.width } : null))
|
||||
|
||||
// 选项:静态 options 优先,其次字典
|
||||
const options = computed(() => {
|
||||
if (props.item.options) return props.item.options
|
||||
if (props.item.dict) {
|
||||
const d = props.dictMap[props.item.dict]
|
||||
return isRef(d) ? d.value : d || []
|
||||
}
|
||||
return []
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="modelValue"
|
||||
:rules="mergedRules"
|
||||
:inline="inline"
|
||||
:label-width="labelWidth"
|
||||
:label-position="labelPosition"
|
||||
:disabled="disabled"
|
||||
:size="size"
|
||||
:class="['basic-form', { 'basic-form--grid': !inline }]"
|
||||
>
|
||||
<!-- 行内布局:直接平铺 -->
|
||||
<template v-if="inline">
|
||||
<template v-for="item in visibleSchemas" :key="item.prop">
|
||||
<el-form-item
|
||||
:label="item.label"
|
||||
:prop="item.prop"
|
||||
:label-width="item.labelWidth"
|
||||
v-bind="item.formItemProps"
|
||||
>
|
||||
<slot :name="`form-${item.prop}`" :model="modelValue" :item="item">
|
||||
<form-control :model="modelValue" :item="item" :dict-map="dictMap" />
|
||||
</slot>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item v-if="$slots.default">
|
||||
<slot />
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 栅格布局:el-row + el-col -->
|
||||
<template v-else>
|
||||
<el-row :gutter="gutter">
|
||||
<el-col
|
||||
v-for="item in visibleSchemas"
|
||||
:key="item.prop"
|
||||
:span="item.span || 24"
|
||||
v-bind="item.colProps"
|
||||
>
|
||||
<el-form-item
|
||||
:label="item.label"
|
||||
:prop="item.prop"
|
||||
:label-width="item.labelWidth"
|
||||
v-bind="item.formItemProps"
|
||||
>
|
||||
<slot :name="`form-${item.prop}`" :model="modelValue" :item="item">
|
||||
<form-control :model="modelValue" :item="item" :dict-map="dictMap" />
|
||||
</slot>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="actionSpan" v-if="$slots.default">
|
||||
<el-form-item :label-width="actionLabelWidth">
|
||||
<slot />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 公共表单组件 BasicForm
|
||||
* 基于 schema 配置驱动,支持常见控件、字典自动加载、校验、插槽扩展。
|
||||
*
|
||||
* 数据流:与 Element Plus el-form 一致,传入的对象为引用传递,控件直接修改其字段属性。
|
||||
* 用法:v-model 或 :model-value 均可(两者等价,组件内部不替换对象,而是原地修改字段)。
|
||||
*
|
||||
* 用法示例:
|
||||
* <basic-form ref="formRef" v-model="form" :schemas="schemas" :rules="rules" label-width="100px" />
|
||||
*
|
||||
* schema 项结构:
|
||||
* {
|
||||
* label: '岗位名称', // 标签
|
||||
* prop: 'postName', // 字段名
|
||||
* component: 'input', // 控件类型:input/textarea/inputNumber/select/radio/checkbox/switch/date/datetime/daterange/datetimerange/time/cascader
|
||||
* placeholder: '请输入', // 占位文本
|
||||
* options: [], // 静态选项(select/radio/checkbox)
|
||||
* dict: 'sys_normal_disable', // 字典类型,自动加载选项
|
||||
* valueKey: 'value', // 选项值字段(默认 value)
|
||||
* labelKey: 'label', // 选项标签字段(默认 label)
|
||||
* span: 12, // 栅格占位(grid 布局,默认 24)
|
||||
* width: '200px', // 控件宽度
|
||||
* show: true, // 是否显示
|
||||
* disabled: false,
|
||||
* rules: [], // 该项校验规则
|
||||
* defaultValue: undefined, // resetToDefault 时该项的默认值
|
||||
* props: {}, // 透传给底层控件的属性
|
||||
* formItemProps: {}, // 透传给 el-form-item 的属性
|
||||
* colProps: {} // 透传给 el-col 的属性
|
||||
* }
|
||||
*/
|
||||
|
||||
defineOptions({ name: 'BasicForm' })
|
||||
|
||||
const props = defineProps({
|
||||
// 表单数据对象(引用传递,控件原地修改其字段)
|
||||
modelValue: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
// 表单项配置数组
|
||||
schemas: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
// 校验规则(可与 schema 内 rules 合并,schema 优先)
|
||||
rules: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
// 是否行内布局(搜索场景)
|
||||
inline: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 标签宽度
|
||||
labelWidth: {
|
||||
type: [String, Number],
|
||||
default: '80px'
|
||||
},
|
||||
// 标签位置
|
||||
labelPosition: {
|
||||
type: String,
|
||||
default: 'right'
|
||||
},
|
||||
// 整体禁用
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 表单尺寸(large/default/small)
|
||||
size: {
|
||||
type: String,
|
||||
default: undefined
|
||||
},
|
||||
// 栅格间距(grid 布局)
|
||||
gutter: {
|
||||
type: Number,
|
||||
default: 16
|
||||
},
|
||||
// 操作区栅格占位(grid 布局)
|
||||
actionSpan: {
|
||||
type: Number,
|
||||
default: 24
|
||||
},
|
||||
// 操作区 label-width(grid 布局)
|
||||
actionLabelWidth: {
|
||||
type: [String, Number],
|
||||
default: '0'
|
||||
}
|
||||
})
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
const formRef = ref()
|
||||
|
||||
// 收集所有 schema 中用到的字典类型,统一加载
|
||||
const dictMap = reactive({})
|
||||
const dictTypes = computed(() => {
|
||||
const set = new Set()
|
||||
props.schemas.forEach((s) => s.dict && set.add(s.dict))
|
||||
return [...set]
|
||||
})
|
||||
|
||||
watch(
|
||||
dictTypes,
|
||||
(types) => {
|
||||
if (!types.length) return
|
||||
const dictRefs = proxy.useDict(...types)
|
||||
types.forEach((t) => {
|
||||
// useDict 返回的是 toRefs,每项是 ref
|
||||
const d = dictRefs[t]
|
||||
dictMap[t] = isRef(d) ? d : ref(d)
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 合并 schema 内 rules 与外部 rules(schema 优先)
|
||||
const mergedRules = computed(() => {
|
||||
const result = { ...props.rules }
|
||||
props.schemas.forEach((s) => {
|
||||
if (s.rules) result[s.prop] = s.rules
|
||||
})
|
||||
return result
|
||||
})
|
||||
|
||||
// 过滤出可见的 schema
|
||||
const visibleSchemas = computed(() => props.schemas.filter((s) => s.show !== false))
|
||||
|
||||
// ====== 暴露 el-form 方法 ======
|
||||
function validate(callback) {
|
||||
return formRef.value?.validate(callback)
|
||||
}
|
||||
function validateField(p, callback) {
|
||||
return formRef.value?.validateField(p, callback)
|
||||
}
|
||||
function resetFields(p) {
|
||||
return formRef.value?.resetFields(p)
|
||||
}
|
||||
function clearValidate(p) {
|
||||
return formRef.value?.clearValidate(p)
|
||||
}
|
||||
function scrollToField(prop) {
|
||||
return formRef.value?.scrollToField(prop)
|
||||
}
|
||||
function getFormRef() {
|
||||
return formRef.value
|
||||
}
|
||||
// 重置为 schema defaultValue 定义的状态
|
||||
function resetToDefault() {
|
||||
props.schemas.forEach((s) => {
|
||||
props.modelValue[s.prop] = s.defaultValue !== undefined ? s.defaultValue : undefined
|
||||
})
|
||||
nextTick(() => clearValidate())
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
validate,
|
||||
validateField,
|
||||
resetFields,
|
||||
clearValidate,
|
||||
scrollToField,
|
||||
getFormRef,
|
||||
resetToDefault
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.basic-form--grid :deep(.el-form-item) {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,440 @@
|
||||
<template>
|
||||
<div class="basic-table">
|
||||
<!-- 搜索栏 -->
|
||||
<basic-form
|
||||
v-if="searchColumns.length"
|
||||
ref="searchFormRef"
|
||||
:model-value="queryParams"
|
||||
:schemas="searchColumns"
|
||||
:rules="searchRules"
|
||||
:label-width="searchLabelWidth"
|
||||
:size="size"
|
||||
inline
|
||||
v-show="showSearch"
|
||||
>
|
||||
<template v-for="name in searchSlotNames" :key="name" #[toFormSlot(name)]>
|
||||
<slot :name="name" />
|
||||
</template>
|
||||
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
|
||||
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
|
||||
<slot name="search-extra" />
|
||||
</basic-form>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<el-row v-if="showToolbar" :gutter="10" class="mb8">
|
||||
<slot name="toolbar" :selection="selectionList" :ids="selectionIds" />
|
||||
<el-col :span="1.5" v-if="!$slots.toolbar">
|
||||
<el-button type="primary" plain icon="Plus" @click="$emit('add')" v-if="showAdd">新增</el-button>
|
||||
</el-col>
|
||||
<right-toolbar
|
||||
v-model:showSearch="showSearchInternal"
|
||||
:columns="toolbarColumns"
|
||||
:search="searchColumns.length > 0"
|
||||
@query-table="getList"
|
||||
/>
|
||||
</el-row>
|
||||
|
||||
<!-- 表格 -->
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
v-loading="loading"
|
||||
:data="tableData"
|
||||
:row-key="rowKey"
|
||||
:border="border"
|
||||
:stripe="stripe"
|
||||
:size="size"
|
||||
:height="height"
|
||||
:max-height="maxHeight"
|
||||
:default-sort="defaultSort"
|
||||
@selection-change="handleSelectionChange"
|
||||
@row-click="(row, col, e) => $emit('row-click', row, col, e)"
|
||||
@sort-change="(c) => $emit('sort-change', c)"
|
||||
>
|
||||
<!-- 多选列 -->
|
||||
<el-table-column v-if="selection" type="selection" width="55" align="center" />
|
||||
|
||||
<!-- 序号列 -->
|
||||
<el-table-column v-if="showIndex" type="index" label="序号" width="55" align="center" />
|
||||
|
||||
<!-- 动态列 -->
|
||||
<el-table-column
|
||||
v-for="col in visibleColumns"
|
||||
:key="col.key"
|
||||
:prop="col.prop"
|
||||
:label="col.label"
|
||||
:width="col.width"
|
||||
:min-width="col.minWidth"
|
||||
:align="col.align || 'center'"
|
||||
:fixed="col.fixed"
|
||||
:sortable="col.sortable"
|
||||
:show-overflow-tooltip="col.showOverflowTooltip !== false"
|
||||
>
|
||||
<template #header="scope">
|
||||
<slot :name="`header-${col.prop}`" v-bind="scope">{{ col.label }}</slot>
|
||||
</template>
|
||||
<template #default="scope">
|
||||
<slot :name="`col-${col.prop}`" v-bind="scope" :row="scope.row">
|
||||
<!-- 字典列 -->
|
||||
<dict-tag
|
||||
v-if="col.dict"
|
||||
:options="getDictOptions(col.dict)"
|
||||
:value="scope.row[col.prop]"
|
||||
/>
|
||||
<!-- 时间格式化 -->
|
||||
<span v-else-if="col.parseTime">{{ parseTime(scope.row[col.prop], col.parseTime === true ? undefined : col.parseTime) }}</span>
|
||||
<!-- 自定义格式化 -->
|
||||
<span v-else-if="col.formatter">{{ col.formatter(scope.row, col, scope.row[col.prop], scope.$index) }}</span>
|
||||
<!-- 默认显示 -->
|
||||
<span v-else>{{ scope.row[col.prop] }}</span>
|
||||
</slot>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 空数据 -->
|
||||
<template #empty>
|
||||
<slot name="empty"><el-empty /></slot>
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<pagination
|
||||
v-show="total > 0"
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNum"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
:page-sizes="pageSizes"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 公共表格组件 BasicTable
|
||||
* 集成搜索栏 + 工具栏 + 表格 + 分页,基于 columns/searchColumns 配置驱动,内置复用 BasicForm、RightToolbar、Pagination、DictTag。
|
||||
*
|
||||
* 用法示例:
|
||||
* <basic-table
|
||||
* ref="tableRef"
|
||||
* :columns="columns"
|
||||
* :search-columns="searchColumns"
|
||||
* :query="listPost"
|
||||
* @add="handleAdd"
|
||||
* >
|
||||
* <template #toolbar="{ selection, ids }">
|
||||
* <el-button type="primary" icon="Plus" @click="handleAdd">新增</el-button>
|
||||
* </template>
|
||||
* <template #col-status="{ row }">
|
||||
* <dict-tag :options="sys_normal_disable" :value="row.status" />
|
||||
* </template>
|
||||
* </basic-table>
|
||||
*
|
||||
* column 配置:
|
||||
* {
|
||||
* label: '岗位名称', // 列标题
|
||||
* prop: 'postName', // 字段名
|
||||
* width: 120, // 列宽
|
||||
* minWidth: 120,
|
||||
* align: 'center', // 对齐(默认 center)
|
||||
* fixed: 'left'|'right',
|
||||
* sortable: true,
|
||||
* showOverflowTooltip: true, // 默认 true
|
||||
* dict: 'sys_normal_disable', // 用字典标签渲染
|
||||
* parseTime: true | 'YYYY-MM-DD', // 时间格式化(true 用默认格式)
|
||||
* formatter: (row,col,val,index)=> ..., // 自定义格式化
|
||||
* show: true, // 是否显示(参与显隐列)
|
||||
* showInToolbar: true, // 是否在显隐列工具栏中显示(默认 true)
|
||||
* visible: true // 初始是否可见(显隐列控制)
|
||||
* }
|
||||
*
|
||||
* searchColumns 配置:同 BasicForm 的 schemas。
|
||||
*/
|
||||
|
||||
defineOptions({ name: 'BasicTable' })
|
||||
|
||||
const props = defineProps({
|
||||
// 列配置数组
|
||||
columns: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
// 搜索表单配置(同 BasicForm schemas)
|
||||
searchColumns: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
// 搜索表单校验规则
|
||||
searchRules: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
// 搜索表单标签宽度
|
||||
searchLabelWidth: {
|
||||
type: [String, Number],
|
||||
default: 'auto'
|
||||
},
|
||||
// 列表加载函数:(params) => Promise<{rows, total}> | {rows, total}
|
||||
query: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
// 额外固定查询参数(每次请求合并传入,不参与搜索表单重置)
|
||||
extraParams: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
// 初始查询参数(搜索字段的默认值)
|
||||
defaultParams: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
// 是否显示多选列
|
||||
selection: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 是否显示序号列
|
||||
showIndex: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 是否显示工具栏
|
||||
showToolbar: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 是否显示默认新增按钮(无 toolbar 插槽时生效)
|
||||
showAdd: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 是否显示搜索栏(可双向控制)
|
||||
showSearch: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 行 key
|
||||
rowKey: {
|
||||
type: [String, Function],
|
||||
default: undefined
|
||||
},
|
||||
// 边框
|
||||
border: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 斑马纹
|
||||
stripe: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 表格/表单尺寸(large/default/small)
|
||||
size: {
|
||||
type: String,
|
||||
default: undefined
|
||||
},
|
||||
// 表格高度
|
||||
height: { type: [String, Number], default: undefined },
|
||||
maxHeight: { type: [String, Number], default: undefined },
|
||||
// 默认排序(透传 el-table default-sort,如 { prop: 'createTime', order: 'descending' })
|
||||
defaultSort: {
|
||||
type: Object,
|
||||
default: undefined
|
||||
},
|
||||
// 每页条数可选项
|
||||
pageSizes: {
|
||||
type: Array,
|
||||
default: () => [10, 20, 30, 50]
|
||||
},
|
||||
// 默认每页条数
|
||||
pageSize: {
|
||||
type: Number,
|
||||
default: 10
|
||||
},
|
||||
// 是否在挂载后自动加载
|
||||
autoLoad: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['add', 'update:showSearch', 'row-click', 'sort-change', 'selection-change'])
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
const tableRef = ref()
|
||||
const searchFormRef = ref()
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref([])
|
||||
const total = ref(0)
|
||||
const selectionList = ref([])
|
||||
const showSearchInternal = ref(props.showSearch)
|
||||
|
||||
// 查询参数:搜索字段 + 分页
|
||||
const queryParams = reactive({
|
||||
pageNum: 1,
|
||||
pageSize: props.pageSize,
|
||||
...props.defaultParams
|
||||
})
|
||||
|
||||
watch(() => props.showSearch, (v) => (showSearchInternal.value = v))
|
||||
watch(showSearchInternal, (v) => emit('update:showSearch', v))
|
||||
|
||||
// ====== 列显隐状态 ======
|
||||
// 内部维护列状态(带 key/visible),供 RightToolbar 控制显隐
|
||||
const tableColumns = ref([])
|
||||
function initColumns() {
|
||||
tableColumns.value = props.columns.map((col, index) => ({
|
||||
...col,
|
||||
key: col.key || col.prop || index,
|
||||
visible: col.visible !== false && col.show !== false
|
||||
}))
|
||||
}
|
||||
initColumns()
|
||||
watch(
|
||||
() => props.columns,
|
||||
() => initColumns(),
|
||||
{ deep: false }
|
||||
)
|
||||
|
||||
// 实际渲染的列(visible !== false)
|
||||
const visibleColumns = computed(() => tableColumns.value.filter((c) => c.visible !== false))
|
||||
|
||||
// 参与显隐工具栏的列(有 label 且未禁用工具栏显示)
|
||||
const toolbarColumns = computed(() =>
|
||||
tableColumns.value.filter((c) => c.label && c.showInToolbar !== false)
|
||||
)
|
||||
|
||||
// ====== 字典加载(用于表格列 dict 渲染) ======
|
||||
const dictMap = reactive({})
|
||||
const dictTypes = computed(() => {
|
||||
const set = new Set()
|
||||
props.columns.forEach((c) => c.dict && set.add(c.dict))
|
||||
props.searchColumns.forEach((c) => c.dict && set.add(c.dict))
|
||||
return [...set]
|
||||
})
|
||||
watch(
|
||||
dictTypes,
|
||||
(types) => {
|
||||
if (!types.length) return
|
||||
const dictRefs = proxy.useDict(...types)
|
||||
types.forEach((t) => {
|
||||
const d = dictRefs[t]
|
||||
dictMap[t] = isRef(d) ? d : ref(d)
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
function getDictOptions(type) {
|
||||
const d = dictMap[type]
|
||||
return isRef(d) ? d.value : d || []
|
||||
}
|
||||
|
||||
// ====== 搜索栏插槽透传:search-xxx -> form-xxx ======
|
||||
const slots = useSlots()
|
||||
const searchSlotNames = computed(() =>
|
||||
Object.keys(slots).filter((n) => n.startsWith('search-') && n !== 'search-extra')
|
||||
)
|
||||
function toFormSlot(name) {
|
||||
return 'form-' + name.slice('search-'.length)
|
||||
}
|
||||
|
||||
// ====== 数据加载 ======
|
||||
async function getList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { ...queryParams, ...props.extraParams }
|
||||
const res = await props.query(params)
|
||||
tableData.value = res?.rows || []
|
||||
total.value = res?.total || 0
|
||||
} catch (e) {
|
||||
console.error('BasicTable 加载失败', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
function handleQuery() {
|
||||
queryParams.pageNum = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
// 重置搜索
|
||||
function resetQuery() {
|
||||
searchFormRef.value?.resetFields()
|
||||
Object.keys(props.defaultParams || {}).forEach((k) => {
|
||||
queryParams[k] = props.defaultParams[k]
|
||||
})
|
||||
queryParams.pageNum = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
// 选中变化
|
||||
function handleSelectionChange(val) {
|
||||
selectionList.value = val
|
||||
emit('selection-change', val)
|
||||
}
|
||||
const selectionIds = computed(() =>
|
||||
selectionList.value.map((r) => (props.rowKey ? r[props.rowKey] : r.id)).filter((v) => v !== undefined)
|
||||
)
|
||||
|
||||
// ====== 暴露方法 ======
|
||||
function refresh() {
|
||||
getList()
|
||||
}
|
||||
function reload() {
|
||||
queryParams.pageNum = 1
|
||||
getList()
|
||||
}
|
||||
function getSelection() {
|
||||
return selectionList.value
|
||||
}
|
||||
function getSelectionIds() {
|
||||
return selectionIds.value
|
||||
}
|
||||
function clearSelection() {
|
||||
tableRef.value?.clearSelection()
|
||||
}
|
||||
function toggleRowSelection(row, selected) {
|
||||
tableRef.value?.toggleRowSelection(row, selected)
|
||||
}
|
||||
function getTableRef() {
|
||||
return tableRef.value
|
||||
}
|
||||
function getSearchFormRef() {
|
||||
return searchFormRef.value
|
||||
}
|
||||
function getQueryParams() {
|
||||
return { ...queryParams, ...props.extraParams }
|
||||
}
|
||||
function setQueryParams(p) {
|
||||
Object.assign(queryParams, p)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
refresh,
|
||||
reload,
|
||||
getList,
|
||||
handleQuery,
|
||||
resetQuery,
|
||||
getSelection,
|
||||
getSelectionIds,
|
||||
clearSelection,
|
||||
toggleRowSelection,
|
||||
getTableRef,
|
||||
getSearchFormRef,
|
||||
getQueryParams,
|
||||
setQueryParams
|
||||
})
|
||||
|
||||
if (props.autoLoad) {
|
||||
onMounted(getList)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.basic-table {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<el-breadcrumb class="app-breadcrumb" separator="/">
|
||||
<transition-group name="breadcrumb">
|
||||
<el-breadcrumb-item v-for="(item, index) in levelList" :key="item.path">
|
||||
<span v-if="item.redirect === 'noRedirect' || index == levelList.length - 1" class="no-redirect">{{ item.meta.title }}</span>
|
||||
<a v-else @click.prevent="handleLink(item)">{{ item.meta.title }}</a>
|
||||
</el-breadcrumb-item>
|
||||
</transition-group>
|
||||
</el-breadcrumb>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import usePermissionStore from '@/store/modules/permission'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const permissionStore = usePermissionStore()
|
||||
const levelList = ref([])
|
||||
|
||||
function getBreadcrumb() {
|
||||
// only show routes with meta.title
|
||||
let matched = []
|
||||
const pathNum = findPathNum(route.path)
|
||||
// multi-level menu
|
||||
if (pathNum > 2) {
|
||||
const reg = /\/\w+/gi
|
||||
const pathList = route.path.match(reg).map((item, index) => {
|
||||
if (index !== 0) item = item.slice(1)
|
||||
return item
|
||||
})
|
||||
getMatched(pathList, permissionStore.defaultRoutes, matched)
|
||||
} else {
|
||||
matched = route.matched.filter((item) => item.meta && item.meta.title)
|
||||
}
|
||||
// 判断是否为首页
|
||||
if (!isDashboard(matched[0])) {
|
||||
matched = [{ path: "/index", meta: { title: "首页" } }].concat(matched)
|
||||
}
|
||||
levelList.value = matched.filter(item => item.meta && item.meta.title && item.meta.breadcrumb !== false)
|
||||
}
|
||||
function findPathNum(str, char = "/") {
|
||||
let index = str.indexOf(char)
|
||||
let num = 0
|
||||
while (index !== -1) {
|
||||
num++
|
||||
index = str.indexOf(char, index + 1)
|
||||
}
|
||||
return num
|
||||
}
|
||||
function getMatched(pathList, routeList, matched) {
|
||||
let data = routeList.find(item => item.path == pathList[0] || (item.name += '').toLowerCase() == pathList[0])
|
||||
if (data) {
|
||||
matched.push(data)
|
||||
if (data.children && pathList.length) {
|
||||
pathList.shift()
|
||||
getMatched(pathList, data.children, matched)
|
||||
}
|
||||
}
|
||||
}
|
||||
function isDashboard(route) {
|
||||
const name = route && route.name
|
||||
if (!name) {
|
||||
return false
|
||||
}
|
||||
return name.trim() === 'Index'
|
||||
}
|
||||
function handleLink(item) {
|
||||
const { redirect, path } = item
|
||||
if (redirect) {
|
||||
router.push(redirect)
|
||||
return
|
||||
}
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
// if you go to the redirect page, do not update the breadcrumbs
|
||||
if (route.path.startsWith('/redirect/')) {
|
||||
return
|
||||
}
|
||||
getBreadcrumb()
|
||||
})
|
||||
getBreadcrumb()
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
.app-breadcrumb.el-breadcrumb {
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
line-height: 50px;
|
||||
|
||||
.no-redirect {
|
||||
color: #97a8be;
|
||||
cursor: text;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<el-form>
|
||||
<el-form-item>
|
||||
<el-radio v-model='radioValue' :value="1">
|
||||
日,允许的通配符[, - * ? / L W]
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-radio v-model='radioValue' :value="2">
|
||||
不指定
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-radio v-model='radioValue' :value="3">
|
||||
周期从
|
||||
<el-input-number v-model='cycle01' :min="1" :max="30" /> -
|
||||
<el-input-number v-model='cycle02' :min="cycle01 + 1" :max="31" /> 日
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-radio v-model='radioValue' :value="4">
|
||||
从
|
||||
<el-input-number v-model='average01' :min="1" :max="30" /> 号开始,每
|
||||
<el-input-number v-model='average02' :min="1" :max="31 - average01" /> 日执行一次
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-radio v-model='radioValue' :value="5">
|
||||
每月
|
||||
<el-input-number v-model='workday' :min="1" :max="31" /> 号最近的那个工作日
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-radio v-model='radioValue' :value="6">
|
||||
本月最后一天
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-radio v-model='radioValue' :value="7">
|
||||
指定
|
||||
<el-select clearable v-model="checkboxList" placeholder="可多选" multiple :multiple-limit="10">
|
||||
<el-option v-for="item in 31" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
<script setup>
|
||||
const emit = defineEmits(['update'])
|
||||
const props = defineProps({
|
||||
cron: {
|
||||
type: Object,
|
||||
default: {
|
||||
second: "*",
|
||||
min: "*",
|
||||
hour: "*",
|
||||
day: "*",
|
||||
month: "*",
|
||||
week: "?",
|
||||
year: "",
|
||||
}
|
||||
},
|
||||
check: {
|
||||
type: Function,
|
||||
default: () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
const radioValue = ref(1)
|
||||
const cycle01 = ref(1)
|
||||
const cycle02 = ref(2)
|
||||
const average01 = ref(1)
|
||||
const average02 = ref(1)
|
||||
const workday = ref(1)
|
||||
const checkboxList = ref([])
|
||||
const checkCopy = ref([1])
|
||||
const cycleTotal = computed(() => {
|
||||
cycle01.value = props.check(cycle01.value, 1, 30)
|
||||
cycle02.value = props.check(cycle02.value, cycle01.value + 1, 31)
|
||||
return cycle01.value + '-' + cycle02.value
|
||||
})
|
||||
const averageTotal = computed(() => {
|
||||
average01.value = props.check(average01.value, 1, 30)
|
||||
average02.value = props.check(average02.value, 1, 31 - average01.value)
|
||||
return average01.value + '/' + average02.value
|
||||
})
|
||||
const workdayTotal = computed(() => {
|
||||
workday.value = props.check(workday.value, 1, 31)
|
||||
return workday.value + 'W'
|
||||
})
|
||||
const checkboxString = computed(() => {
|
||||
return checkboxList.value.join(',')
|
||||
})
|
||||
watch(() => props.cron.day, value => changeRadioValue(value))
|
||||
watch([radioValue, cycleTotal, averageTotal, workdayTotal, checkboxString], () => onRadioChange())
|
||||
function changeRadioValue(value) {
|
||||
if (value === "*") {
|
||||
radioValue.value = 1
|
||||
} else if (value === "?") {
|
||||
radioValue.value = 2
|
||||
} else if (value.indexOf("-") > -1) {
|
||||
const indexArr = value.split('-')
|
||||
cycle01.value = Number(indexArr[0])
|
||||
cycle02.value = Number(indexArr[1])
|
||||
radioValue.value = 3
|
||||
} else if (value.indexOf("/") > -1) {
|
||||
const indexArr = value.split('/')
|
||||
average01.value = Number(indexArr[0])
|
||||
average02.value = Number(indexArr[1])
|
||||
radioValue.value = 4
|
||||
} else if (value.indexOf("W") > -1) {
|
||||
const indexArr = value.split("W")
|
||||
workday.value = Number(indexArr[0])
|
||||
radioValue.value = 5
|
||||
} else if (value === "L") {
|
||||
radioValue.value = 6
|
||||
} else {
|
||||
checkboxList.value = [...new Set(value.split(',').map(item => Number(item)))]
|
||||
radioValue.value = 7
|
||||
}
|
||||
}
|
||||
// 单选按钮值变化时
|
||||
function onRadioChange() {
|
||||
if (radioValue.value === 2 && props.cron.week === '?') {
|
||||
emit('update', 'week', '*', 'day')
|
||||
}
|
||||
if (radioValue.value !== 2 && props.cron.week !== '?') {
|
||||
emit('update', 'week', '?', 'day')
|
||||
}
|
||||
switch (radioValue.value) {
|
||||
case 1:
|
||||
emit('update', 'day', '*', 'day')
|
||||
break
|
||||
case 2:
|
||||
emit('update', 'day', '?', 'day')
|
||||
break
|
||||
case 3:
|
||||
emit('update', 'day', cycleTotal.value, 'day')
|
||||
break
|
||||
case 4:
|
||||
emit('update', 'day', averageTotal.value, 'day')
|
||||
break
|
||||
case 5:
|
||||
emit('update', 'day', workdayTotal.value, 'day')
|
||||
break
|
||||
case 6:
|
||||
emit('update', 'day', 'L', 'day')
|
||||
break
|
||||
case 7:
|
||||
if (checkboxList.value.length === 0) {
|
||||
checkboxList.value.push(checkCopy.value[0])
|
||||
} else {
|
||||
checkCopy.value = checkboxList.value
|
||||
}
|
||||
emit('update', 'day', checkboxString.value, 'day')
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.el-input-number--small, .el-select, .el-select--small {
|
||||
margin: 0 0.2rem;
|
||||
}
|
||||
.el-select, .el-select--small {
|
||||
width: 18.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<el-form>
|
||||
<el-form-item>
|
||||
<el-radio v-model='radioValue' :value="1">
|
||||
小时,允许的通配符[, - * /]
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-radio v-model='radioValue' :value="2">
|
||||
周期从
|
||||
<el-input-number v-model='cycle01' :min="0" :max="22" /> -
|
||||
<el-input-number v-model='cycle02' :min="cycle01 + 1" :max="23" /> 时
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-radio v-model='radioValue' :value="3">
|
||||
从
|
||||
<el-input-number v-model='average01' :min="0" :max="22" /> 时开始,每
|
||||
<el-input-number v-model='average02' :min="1" :max="23 - average01" /> 小时执行一次
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-radio v-model='radioValue' :value="4">
|
||||
指定
|
||||
<el-select clearable v-model="checkboxList" placeholder="可多选" multiple :multiple-limit="10">
|
||||
<el-option v-for="item in 24" :key="item" :label="item - 1" :value="item - 1" />
|
||||
</el-select>
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const emit = defineEmits(['update'])
|
||||
const props = defineProps({
|
||||
cron: {
|
||||
type: Object,
|
||||
default: {
|
||||
second: "*",
|
||||
min: "*",
|
||||
hour: "*",
|
||||
day: "*",
|
||||
month: "*",
|
||||
week: "?",
|
||||
year: "",
|
||||
}
|
||||
},
|
||||
check: {
|
||||
type: Function,
|
||||
default: () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
const radioValue = ref(1)
|
||||
const cycle01 = ref(0)
|
||||
const cycle02 = ref(1)
|
||||
const average01 = ref(0)
|
||||
const average02 = ref(1)
|
||||
const checkboxList = ref([])
|
||||
const checkCopy = ref([0])
|
||||
const cycleTotal = computed(() => {
|
||||
cycle01.value = props.check(cycle01.value, 0, 22)
|
||||
cycle02.value = props.check(cycle02.value, cycle01.value + 1, 23)
|
||||
return cycle01.value + '-' + cycle02.value
|
||||
})
|
||||
const averageTotal = computed(() => {
|
||||
average01.value = props.check(average01.value, 0, 22)
|
||||
average02.value = props.check(average02.value, 1, 23 - average01.value)
|
||||
return average01.value + '/' + average02.value
|
||||
})
|
||||
const checkboxString = computed(() => {
|
||||
return checkboxList.value.join(',')
|
||||
})
|
||||
watch(() => props.cron.hour, value => changeRadioValue(value))
|
||||
watch([radioValue, cycleTotal, averageTotal, checkboxString], () => onRadioChange())
|
||||
function changeRadioValue(value) {
|
||||
if (props.cron.min === '*') {
|
||||
emit('update', 'min', '0', 'hour')
|
||||
}
|
||||
if (props.cron.second === '*') {
|
||||
emit('update', 'second', '0', 'hour')
|
||||
}
|
||||
if (value === '*') {
|
||||
radioValue.value = 1
|
||||
} else if (value.indexOf('-') > -1) {
|
||||
const indexArr = value.split('-')
|
||||
cycle01.value = Number(indexArr[0])
|
||||
cycle02.value = Number(indexArr[1])
|
||||
radioValue.value = 2
|
||||
} else if (value.indexOf('/') > -1) {
|
||||
const indexArr = value.split('/')
|
||||
average01.value = Number(indexArr[0])
|
||||
average02.value = Number(indexArr[1])
|
||||
radioValue.value = 3
|
||||
} else {
|
||||
checkboxList.value = [...new Set(value.split(',').map(item => Number(item)))]
|
||||
radioValue.value = 4
|
||||
}
|
||||
}
|
||||
function onRadioChange() {
|
||||
switch (radioValue.value) {
|
||||
case 1:
|
||||
emit('update', 'hour', '*', 'hour')
|
||||
break
|
||||
case 2:
|
||||
emit('update', 'hour', cycleTotal.value, 'hour')
|
||||
break
|
||||
case 3:
|
||||
emit('update', 'hour', averageTotal.value, 'hour')
|
||||
break
|
||||
case 4:
|
||||
if (checkboxList.value.length === 0) {
|
||||
checkboxList.value.push(checkCopy.value[0])
|
||||
} else {
|
||||
checkCopy.value = checkboxList.value
|
||||
}
|
||||
emit('update', 'hour', checkboxString.value, 'hour')
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.el-input-number--small, .el-select, .el-select--small {
|
||||
margin: 0 0.2rem;
|
||||
}
|
||||
.el-select, .el-select--small {
|
||||
width: 18.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,313 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-tabs type="border-card">
|
||||
<el-tab-pane label="秒" v-if="shouldHide('second')">
|
||||
<CrontabSecond
|
||||
@update="updateCrontabValue"
|
||||
:check="checkNumber"
|
||||
:cron="crontabValueObj"
|
||||
ref="cronsecond"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="分钟" v-if="shouldHide('min')">
|
||||
<CrontabMin
|
||||
@update="updateCrontabValue"
|
||||
:check="checkNumber"
|
||||
:cron="crontabValueObj"
|
||||
ref="cronmin"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="小时" v-if="shouldHide('hour')">
|
||||
<CrontabHour
|
||||
@update="updateCrontabValue"
|
||||
:check="checkNumber"
|
||||
:cron="crontabValueObj"
|
||||
ref="cronhour"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="日" v-if="shouldHide('day')">
|
||||
<CrontabDay
|
||||
@update="updateCrontabValue"
|
||||
:check="checkNumber"
|
||||
:cron="crontabValueObj"
|
||||
ref="cronday"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="月" v-if="shouldHide('month')">
|
||||
<CrontabMonth
|
||||
@update="updateCrontabValue"
|
||||
:check="checkNumber"
|
||||
:cron="crontabValueObj"
|
||||
ref="cronmonth"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="周" v-if="shouldHide('week')">
|
||||
<CrontabWeek
|
||||
@update="updateCrontabValue"
|
||||
:check="checkNumber"
|
||||
:cron="crontabValueObj"
|
||||
ref="cronweek"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="年" v-if="shouldHide('year')">
|
||||
<CrontabYear
|
||||
@update="updateCrontabValue"
|
||||
:check="checkNumber"
|
||||
:cron="crontabValueObj"
|
||||
ref="cronyear"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<div class="popup-main">
|
||||
<div class="popup-result">
|
||||
<p class="title">时间表达式</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="item of tabTitles" :key="item">{{item}}</th>
|
||||
<th>Cron 表达式</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<span v-if="crontabValueObj.second.length < 10">{{crontabValueObj.second}}</span>
|
||||
<el-tooltip v-else :content="crontabValueObj.second" placement="top"><span>{{crontabValueObj.second}}</span></el-tooltip>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="crontabValueObj.min.length < 10">{{crontabValueObj.min}}</span>
|
||||
<el-tooltip v-else :content="crontabValueObj.min" placement="top"><span>{{crontabValueObj.min}}</span></el-tooltip>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="crontabValueObj.hour.length < 10">{{crontabValueObj.hour}}</span>
|
||||
<el-tooltip v-else :content="crontabValueObj.hour" placement="top"><span>{{crontabValueObj.hour}}</span></el-tooltip>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="crontabValueObj.day.length < 10">{{crontabValueObj.day}}</span>
|
||||
<el-tooltip v-else :content="crontabValueObj.day" placement="top"><span>{{crontabValueObj.day}}</span></el-tooltip>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="crontabValueObj.month.length < 10">{{crontabValueObj.month}}</span>
|
||||
<el-tooltip v-else :content="crontabValueObj.month" placement="top"><span>{{crontabValueObj.month}}</span></el-tooltip>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="crontabValueObj.week.length < 10">{{crontabValueObj.week}}</span>
|
||||
<el-tooltip v-else :content="crontabValueObj.week" placement="top"><span>{{crontabValueObj.week}}</span></el-tooltip>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="crontabValueObj.year.length < 10">{{crontabValueObj.year}}</span>
|
||||
<el-tooltip v-else :content="crontabValueObj.year" placement="top"><span>{{crontabValueObj.year}}</span></el-tooltip>
|
||||
</td>
|
||||
<td class="result">
|
||||
<span v-if="crontabValueString.length < 90">{{crontabValueString}}</span>
|
||||
<el-tooltip v-else :content="crontabValueString" placement="top"><span>{{crontabValueString}}</span></el-tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<CrontabResult :ex="crontabValueString"></CrontabResult>
|
||||
|
||||
<div class="pop_btn">
|
||||
<el-button type="primary" @click="submitFill">确定</el-button>
|
||||
<el-button type="warning" @click="clearCron">重置</el-button>
|
||||
<el-button @click="hidePopup">取消</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import CrontabSecond from "./second.vue"
|
||||
import CrontabMin from "./min.vue"
|
||||
import CrontabHour from "./hour.vue"
|
||||
import CrontabDay from "./day.vue"
|
||||
import CrontabMonth from "./month.vue"
|
||||
import CrontabWeek from "./week.vue"
|
||||
import CrontabYear from "./year.vue"
|
||||
import CrontabResult from "./result.vue"
|
||||
const { proxy } = getCurrentInstance()
|
||||
const emit = defineEmits(['hide', 'fill'])
|
||||
const props = defineProps({
|
||||
hideComponent: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
expression: {
|
||||
type: String,
|
||||
default: ""
|
||||
}
|
||||
})
|
||||
const tabTitles = ref(["秒", "分钟", "小时", "日", "月", "周", "年"])
|
||||
const tabActive = ref(0)
|
||||
const hideComponent = ref([])
|
||||
const expression = ref('')
|
||||
const crontabValueObj = ref({
|
||||
second: "*",
|
||||
min: "*",
|
||||
hour: "*",
|
||||
day: "*",
|
||||
month: "*",
|
||||
week: "?",
|
||||
year: "",
|
||||
})
|
||||
const crontabValueString = computed(() => {
|
||||
const obj = crontabValueObj.value
|
||||
return obj.second
|
||||
+ " "
|
||||
+ obj.min
|
||||
+ " "
|
||||
+ obj.hour
|
||||
+ " "
|
||||
+ obj.day
|
||||
+ " "
|
||||
+ obj.month
|
||||
+ " "
|
||||
+ obj.week
|
||||
+ (obj.year === "" ? "" : " " + obj.year)
|
||||
})
|
||||
watch(expression, () => resolveExp())
|
||||
function shouldHide(key) {
|
||||
return !(hideComponent.value && hideComponent.value.includes(key))
|
||||
}
|
||||
function resolveExp() {
|
||||
// 反解析 表达式
|
||||
if (expression.value) {
|
||||
const arr = expression.value.split(/\s+/)
|
||||
if (arr.length >= 6) {
|
||||
//6 位以上是合法表达式
|
||||
let obj = {
|
||||
second: arr[0],
|
||||
min: arr[1],
|
||||
hour: arr[2],
|
||||
day: arr[3],
|
||||
month: arr[4],
|
||||
week: arr[5],
|
||||
year: arr[6] ? arr[6] : ""
|
||||
}
|
||||
crontabValueObj.value = {
|
||||
...obj,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 没有传入的表达式 则还原
|
||||
clearCron()
|
||||
}
|
||||
}
|
||||
// tab切换值
|
||||
function tabCheck(index) {
|
||||
tabActive.value = index
|
||||
}
|
||||
// 由子组件触发,更改表达式组成的字段值
|
||||
function updateCrontabValue(name, value, from) {
|
||||
crontabValueObj.value[name] = value
|
||||
}
|
||||
// 表单选项的子组件校验数字格式(通过-props传递)
|
||||
function checkNumber(value, minLimit, maxLimit) {
|
||||
// 检查必须为整数
|
||||
value = Math.floor(value)
|
||||
if (value < minLimit) {
|
||||
value = minLimit
|
||||
} else if (value > maxLimit) {
|
||||
value = maxLimit
|
||||
}
|
||||
return value
|
||||
}
|
||||
// 隐藏弹窗
|
||||
function hidePopup() {
|
||||
emit("hide")
|
||||
}
|
||||
// 填充表达式
|
||||
function submitFill() {
|
||||
emit("fill", crontabValueString.value)
|
||||
hidePopup()
|
||||
}
|
||||
function clearCron() {
|
||||
// 还原选择项
|
||||
crontabValueObj.value = {
|
||||
second: "*",
|
||||
min: "*",
|
||||
hour: "*",
|
||||
day: "*",
|
||||
month: "*",
|
||||
week: "?",
|
||||
year: "",
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
expression.value = props.expression
|
||||
hideComponent.value = props.hideComponent
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.pop_btn {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.popup-main {
|
||||
position: relative;
|
||||
margin: 10px auto;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.popup-title {
|
||||
overflow: hidden;
|
||||
line-height: 34px;
|
||||
padding-top: 6px;
|
||||
background: #f2f2f2;
|
||||
}
|
||||
.popup-result {
|
||||
box-sizing: border-box;
|
||||
line-height: 24px;
|
||||
margin: 25px auto;
|
||||
padding: 15px 10px 10px;
|
||||
border: 1px solid #ccc;
|
||||
position: relative;
|
||||
}
|
||||
.popup-result .title {
|
||||
position: absolute;
|
||||
top: -28px;
|
||||
left: 50%;
|
||||
width: 140px;
|
||||
font-size: 14px;
|
||||
margin-left: -70px;
|
||||
text-align: center;
|
||||
line-height: 30px;
|
||||
background: #fff;
|
||||
}
|
||||
.popup-result table {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.popup-result table td:not(.result) {
|
||||
width: 3.5rem;
|
||||
min-width: 3.5rem;
|
||||
max-width: 3.5rem;
|
||||
}
|
||||
.popup-result table span {
|
||||
display: block;
|
||||
width: 100%;
|
||||
font-family: arial;
|
||||
line-height: 30px;
|
||||
height: 30px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e8e8e8;
|
||||
}
|
||||
.popup-result-scroll {
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
height: 10em;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<el-form>
|
||||
<el-form-item>
|
||||
<el-radio v-model='radioValue' :value="1">
|
||||
分钟,允许的通配符[, - * /]
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-radio v-model='radioValue' :value="2">
|
||||
周期从
|
||||
<el-input-number v-model='cycle01' :min="0" :max="58" /> -
|
||||
<el-input-number v-model='cycle02' :min="cycle01 + 1" :max="59" /> 分钟
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-radio v-model='radioValue' :value="3">
|
||||
从
|
||||
<el-input-number v-model='average01' :min="0" :max="58" /> 分钟开始, 每
|
||||
<el-input-number v-model='average02' :min="1" :max="59 - average01" /> 分钟执行一次
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-radio v-model='radioValue' :value="4">
|
||||
指定
|
||||
<el-select clearable v-model="checkboxList" placeholder="可多选" multiple :multiple-limit="10">
|
||||
<el-option v-for="item in 60" :key="item" :label="item - 1" :value="item - 1" />
|
||||
</el-select>
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
<script setup>
|
||||
const emit = defineEmits(['update'])
|
||||
const props = defineProps({
|
||||
cron: {
|
||||
type: Object,
|
||||
default: {
|
||||
second: "*",
|
||||
min: "*",
|
||||
hour: "*",
|
||||
day: "*",
|
||||
month: "*",
|
||||
week: "?",
|
||||
year: "",
|
||||
}
|
||||
},
|
||||
check: {
|
||||
type: Function,
|
||||
default: () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
const radioValue = ref(1)
|
||||
const cycle01 = ref(0)
|
||||
const cycle02 = ref(1)
|
||||
const average01 = ref(0)
|
||||
const average02 = ref(1)
|
||||
const checkboxList = ref([])
|
||||
const checkCopy = ref([0])
|
||||
const cycleTotal = computed(() => {
|
||||
cycle01.value = props.check(cycle01.value, 0, 58)
|
||||
cycle02.value = props.check(cycle02.value, cycle01.value + 1, 59)
|
||||
return cycle01.value + '-' + cycle02.value
|
||||
})
|
||||
const averageTotal = computed(() => {
|
||||
average01.value = props.check(average01.value, 0, 58)
|
||||
average02.value = props.check(average02.value, 1, 59 - average01.value)
|
||||
return average01.value + '/' + average02.value
|
||||
})
|
||||
const checkboxString = computed(() => {
|
||||
return checkboxList.value.join(',')
|
||||
})
|
||||
watch(() => props.cron.min, value => changeRadioValue(value))
|
||||
watch([radioValue, cycleTotal, averageTotal, checkboxString], () => onRadioChange())
|
||||
function changeRadioValue(value) {
|
||||
if (value === '*') {
|
||||
radioValue.value = 1
|
||||
} else if (value.indexOf('-') > -1) {
|
||||
const indexArr = value.split('-')
|
||||
cycle01.value = Number(indexArr[0])
|
||||
cycle02.value = Number(indexArr[1])
|
||||
radioValue.value = 2
|
||||
} else if (value.indexOf('/') > -1) {
|
||||
const indexArr = value.split('/')
|
||||
average01.value = Number(indexArr[0])
|
||||
average02.value = Number(indexArr[1])
|
||||
radioValue.value = 3
|
||||
} else {
|
||||
checkboxList.value = [...new Set(value.split(',').map(item => Number(item)))]
|
||||
radioValue.value = 4
|
||||
}
|
||||
}
|
||||
function onRadioChange() {
|
||||
switch (radioValue.value) {
|
||||
case 1:
|
||||
emit('update', 'min', '*', 'min')
|
||||
break
|
||||
case 2:
|
||||
emit('update', 'min', cycleTotal.value, 'min')
|
||||
break
|
||||
case 3:
|
||||
emit('update', 'min', averageTotal.value, 'min')
|
||||
break
|
||||
case 4:
|
||||
if (checkboxList.value.length === 0) {
|
||||
checkboxList.value.push(checkCopy.value[0])
|
||||
} else {
|
||||
checkCopy.value = checkboxList.value
|
||||
}
|
||||
emit('update', 'min', checkboxString.value, 'min')
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.el-input-number--small, .el-select, .el-select--small {
|
||||
margin: 0 0.2rem;
|
||||
}
|
||||
.el-select, .el-select--small {
|
||||
width: 19.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<el-form>
|
||||
<el-form-item>
|
||||
<el-radio v-model='radioValue' :value="1">
|
||||
月,允许的通配符[, - * /]
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-radio v-model='radioValue' :value="2">
|
||||
周期从
|
||||
<el-input-number v-model='cycle01' :min="1" :max="11" /> -
|
||||
<el-input-number v-model='cycle02' :min="cycle01 + 1" :max="12" /> 月
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-radio v-model='radioValue' :value="3">
|
||||
从
|
||||
<el-input-number v-model='average01' :min="1" :max="11" /> 月开始,每
|
||||
<el-input-number v-model='average02' :min="1" :max="12 - average01" /> 月月执行一次
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-radio v-model='radioValue' :value="4">
|
||||
指定
|
||||
<el-select clearable v-model="checkboxList" placeholder="可多选" multiple :multiple-limit="8">
|
||||
<el-option v-for="item in monthList" :key="item.key" :label="item.value" :value="item.key" />
|
||||
</el-select>
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const emit = defineEmits(['update'])
|
||||
const props = defineProps({
|
||||
cron: {
|
||||
type: Object,
|
||||
default: {
|
||||
second: "*",
|
||||
min: "*",
|
||||
hour: "*",
|
||||
day: "*",
|
||||
month: "*",
|
||||
week: "?",
|
||||
year: "",
|
||||
}
|
||||
},
|
||||
check: {
|
||||
type: Function,
|
||||
default: () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
const radioValue = ref(1)
|
||||
const cycle01 = ref(1)
|
||||
const cycle02 = ref(2)
|
||||
const average01 = ref(1)
|
||||
const average02 = ref(1)
|
||||
const checkboxList = ref([])
|
||||
const checkCopy = ref([1])
|
||||
const monthList = ref([
|
||||
{key: 1, value: '一月'},
|
||||
{key: 2, value: '二月'},
|
||||
{key: 3, value: '三月'},
|
||||
{key: 4, value: '四月'},
|
||||
{key: 5, value: '五月'},
|
||||
{key: 6, value: '六月'},
|
||||
{key: 7, value: '七月'},
|
||||
{key: 8, value: '八月'},
|
||||
{key: 9, value: '九月'},
|
||||
{key: 10, value: '十月'},
|
||||
{key: 11, value: '十一月'},
|
||||
{key: 12, value: '十二月'}
|
||||
])
|
||||
const cycleTotal = computed(() => {
|
||||
cycle01.value = props.check(cycle01.value, 1, 11)
|
||||
cycle02.value = props.check(cycle02.value, cycle01.value + 1, 12)
|
||||
return cycle01.value + '-' + cycle02.value
|
||||
})
|
||||
const averageTotal = computed(() => {
|
||||
average01.value = props.check(average01.value, 1, 11)
|
||||
average02.value = props.check(average02.value, 1, 12 - average01.value)
|
||||
return average01.value + '/' + average02.value
|
||||
})
|
||||
const checkboxString = computed(() => {
|
||||
return checkboxList.value.join(',')
|
||||
})
|
||||
watch(() => props.cron.month, value => changeRadioValue(value))
|
||||
watch([radioValue, cycleTotal, averageTotal, checkboxString], () => onRadioChange())
|
||||
function changeRadioValue(value) {
|
||||
if (value === '*') {
|
||||
radioValue.value = 1
|
||||
} else if (value.indexOf('-') > -1) {
|
||||
const indexArr = value.split('-')
|
||||
cycle01.value = Number(indexArr[0])
|
||||
cycle02.value = Number(indexArr[1])
|
||||
radioValue.value = 2
|
||||
} else if (value.indexOf('/') > -1) {
|
||||
const indexArr = value.split('/')
|
||||
average01.value = Number(indexArr[0])
|
||||
average02.value = Number(indexArr[1])
|
||||
radioValue.value = 3
|
||||
} else {
|
||||
checkboxList.value = [...new Set(value.split(',').map(item => Number(item)))]
|
||||
radioValue.value = 4
|
||||
}
|
||||
}
|
||||
function onRadioChange() {
|
||||
switch (radioValue.value) {
|
||||
case 1:
|
||||
emit('update', 'month', '*', 'month')
|
||||
break
|
||||
case 2:
|
||||
emit('update', 'month', cycleTotal.value, 'month')
|
||||
break
|
||||
case 3:
|
||||
emit('update', 'month', averageTotal.value, 'month')
|
||||
break
|
||||
case 4:
|
||||
if (checkboxList.value.length === 0) {
|
||||
checkboxList.value.push(checkCopy.value[0])
|
||||
} else {
|
||||
checkCopy.value = checkboxList.value
|
||||
}
|
||||
emit('update', 'month', checkboxString.value, 'month')
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.el-input-number--small, .el-select, .el-select--small {
|
||||
margin: 0 0.2rem;
|
||||
}
|
||||
.el-select, .el-select--small {
|
||||
width: 18.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,540 @@
|
||||
<template>
|
||||
<div class="popup-result">
|
||||
<p class="title">最近5次运行时间</p>
|
||||
<ul class="popup-result-scroll">
|
||||
<template v-if='isShow'>
|
||||
<li v-for='item in resultList' :key="item">{{item}}</li>
|
||||
</template>
|
||||
<li v-else>计算结果中...</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
ex: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
const dayRule = ref('')
|
||||
const dayRuleSup = ref('')
|
||||
const dateArr = ref([])
|
||||
const resultList = ref([])
|
||||
const isShow = ref(false)
|
||||
watch(() => props.ex, () => expressionChange())
|
||||
// 表达式值变化时,开始去计算结果
|
||||
function expressionChange() {
|
||||
// 计算开始-隐藏结果
|
||||
isShow.value = false
|
||||
// 获取规则数组[0秒、1分、2时、3日、4月、5星期、6年]
|
||||
let ruleArr = props.ex.split(' ')
|
||||
// 用于记录进入循环的次数
|
||||
let nums = 0
|
||||
// 用于暂时存符号时间规则结果的数组
|
||||
let resultArr = []
|
||||
// 获取当前时间精确至[年、月、日、时、分、秒]
|
||||
let nTime = new Date()
|
||||
let nYear = nTime.getFullYear()
|
||||
let nMonth = nTime.getMonth() + 1
|
||||
let nDay = nTime.getDate()
|
||||
let nHour = nTime.getHours()
|
||||
let nMin = nTime.getMinutes()
|
||||
let nSecond = nTime.getSeconds()
|
||||
// 根据规则获取到近100年可能年数组、月数组等等
|
||||
getSecondArr(ruleArr[0])
|
||||
getMinArr(ruleArr[1])
|
||||
getHourArr(ruleArr[2])
|
||||
getDayArr(ruleArr[3])
|
||||
getMonthArr(ruleArr[4])
|
||||
getWeekArr(ruleArr[5])
|
||||
getYearArr(ruleArr[6], nYear)
|
||||
// 将获取到的数组赋值-方便使用
|
||||
let sDate = dateArr.value[0]
|
||||
let mDate = dateArr.value[1]
|
||||
let hDate = dateArr.value[2]
|
||||
let DDate = dateArr.value[3]
|
||||
let MDate = dateArr.value[4]
|
||||
let YDate = dateArr.value[5]
|
||||
// 获取当前时间在数组中的索引
|
||||
let sIdx = getIndex(sDate, nSecond)
|
||||
let mIdx = getIndex(mDate, nMin)
|
||||
let hIdx = getIndex(hDate, nHour)
|
||||
let DIdx = getIndex(DDate, nDay)
|
||||
let MIdx = getIndex(MDate, nMonth)
|
||||
let YIdx = getIndex(YDate, nYear)
|
||||
// 重置月日时分秒的函数(后面用的比较多)
|
||||
const resetSecond = function () {
|
||||
sIdx = 0
|
||||
nSecond = sDate[sIdx]
|
||||
}
|
||||
const resetMin = function () {
|
||||
mIdx = 0
|
||||
nMin = mDate[mIdx]
|
||||
resetSecond()
|
||||
}
|
||||
const resetHour = function () {
|
||||
hIdx = 0
|
||||
nHour = hDate[hIdx]
|
||||
resetMin()
|
||||
}
|
||||
const resetDay = function () {
|
||||
DIdx = 0
|
||||
nDay = DDate[DIdx]
|
||||
resetHour()
|
||||
}
|
||||
const resetMonth = function () {
|
||||
MIdx = 0
|
||||
nMonth = MDate[MIdx]
|
||||
resetDay()
|
||||
}
|
||||
// 如果当前年份不为数组中当前值
|
||||
if (nYear !== YDate[YIdx]) {
|
||||
resetMonth()
|
||||
}
|
||||
// 如果当前月份不为数组中当前值
|
||||
if (nMonth !== MDate[MIdx]) {
|
||||
resetDay()
|
||||
}
|
||||
// 如果当前“日”不为数组中当前值
|
||||
if (nDay !== DDate[DIdx]) {
|
||||
resetHour()
|
||||
}
|
||||
// 如果当前“时”不为数组中当前值
|
||||
if (nHour !== hDate[hIdx]) {
|
||||
resetMin()
|
||||
}
|
||||
// 如果当前“分”不为数组中当前值
|
||||
if (nMin !== mDate[mIdx]) {
|
||||
resetSecond()
|
||||
}
|
||||
// 循环年份数组
|
||||
goYear: for (let Yi = YIdx; Yi < YDate.length; Yi++) {
|
||||
let YY = YDate[Yi]
|
||||
// 如果到达最大值时
|
||||
if (nMonth > MDate[MDate.length - 1]) {
|
||||
resetMonth()
|
||||
continue
|
||||
}
|
||||
// 循环月份数组
|
||||
goMonth: for (let Mi = MIdx; Mi < MDate.length; Mi++) {
|
||||
// 赋值、方便后面运算
|
||||
let MM = MDate[Mi];
|
||||
MM = MM < 10 ? '0' + MM : MM
|
||||
// 如果到达最大值时
|
||||
if (nDay > DDate[DDate.length - 1]) {
|
||||
resetDay()
|
||||
if (Mi === MDate.length - 1) {
|
||||
resetMonth()
|
||||
continue goYear
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 循环日期数组
|
||||
goDay: for (let Di = DIdx; Di < DDate.length; Di++) {
|
||||
// 赋值、方便后面运算
|
||||
let DD = DDate[Di]
|
||||
let thisDD = DD < 10 ? '0' + DD : DD
|
||||
// 如果到达最大值时
|
||||
if (nHour > hDate[hDate.length - 1]) {
|
||||
resetHour()
|
||||
if (Di === DDate.length - 1) {
|
||||
resetDay()
|
||||
if (Mi === MDate.length - 1) {
|
||||
resetMonth()
|
||||
continue goYear
|
||||
}
|
||||
continue goMonth
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 判断日期的合法性,不合法的话也是跳出当前循环
|
||||
if (checkDate(YY + '-' + MM + '-' + thisDD + ' 00:00:00') !== true && dayRule.value !== 'workDay' && dayRule.value !== 'lastWeek' && dayRule.value !== 'lastDay') {
|
||||
resetDay()
|
||||
continue goMonth
|
||||
}
|
||||
// 如果日期规则中有值时
|
||||
if (dayRule.value === 'lastDay') {
|
||||
// 如果不是合法日期则需要将前将日期调到合法日期即月末最后一天
|
||||
if (checkDate(YY + '-' + MM + '-' + thisDD + ' 00:00:00') !== true) {
|
||||
while (DD > 0 && checkDate(YY + '-' + MM + '-' + thisDD + ' 00:00:00') !== true) {
|
||||
DD--
|
||||
thisDD = DD < 10 ? '0' + DD : DD
|
||||
}
|
||||
}
|
||||
} else if (dayRule.value === 'workDay') {
|
||||
// 校验并调整如果是2月30号这种日期传进来时需调整至正常月底
|
||||
if (checkDate(YY + '-' + MM + '-' + thisDD + ' 00:00:00') !== true) {
|
||||
while (DD > 0 && checkDate(YY + '-' + MM + '-' + thisDD + ' 00:00:00') !== true) {
|
||||
DD--
|
||||
thisDD = DD < 10 ? '0' + DD : DD
|
||||
}
|
||||
}
|
||||
// 获取达到条件的日期是星期X
|
||||
let thisWeek = formatDate(new Date(YY + '-' + MM + '-' + thisDD + ' 00:00:00'), 'week')
|
||||
// 当星期日时
|
||||
if (thisWeek === 1) {
|
||||
// 先找下一个日,并判断是否为月底
|
||||
DD++
|
||||
thisDD = DD < 10 ? '0' + DD : DD
|
||||
// 判断下一日已经不是合法日期
|
||||
if (checkDate(YY + '-' + MM + '-' + thisDD + ' 00:00:00') !== true) {
|
||||
DD -= 3
|
||||
}
|
||||
} else if (thisWeek === 7) {
|
||||
// 当星期6时只需判断不是1号就可进行操作
|
||||
if (dayRuleSup.value !== 1) {
|
||||
DD--
|
||||
} else {
|
||||
DD += 2
|
||||
}
|
||||
}
|
||||
} else if (dayRule.value === 'weekDay') {
|
||||
// 如果指定了是星期几
|
||||
// 获取当前日期是属于星期几
|
||||
let thisWeek = formatDate(new Date(YY + '-' + MM + '-' + DD + ' 00:00:00'), 'week')
|
||||
// 校验当前星期是否在星期池(dayRuleSup)中
|
||||
if (dayRuleSup.value.indexOf(thisWeek) < 0) {
|
||||
// 如果到达最大值时
|
||||
if (Di === DDate.length - 1) {
|
||||
resetDay()
|
||||
if (Mi === MDate.length - 1) {
|
||||
resetMonth()
|
||||
continue goYear
|
||||
}
|
||||
continue goMonth
|
||||
}
|
||||
continue
|
||||
}
|
||||
} else if (dayRule.value === 'assWeek') {
|
||||
// 如果指定了是第几周的星期几
|
||||
// 获取每月1号是属于星期几
|
||||
let thisWeek = formatDate(new Date(YY + '-' + MM + '-' + DD + ' 00:00:00'), 'week')
|
||||
if (dayRuleSup.value[1] >= thisWeek) {
|
||||
DD = (dayRuleSup.value[0] - 1) * 7 + dayRuleSup.value[1] - thisWeek + 1
|
||||
} else {
|
||||
DD = dayRuleSup.value[0] * 7 + dayRuleSup.value[1] - thisWeek + 1
|
||||
}
|
||||
} else if (dayRule.value === 'lastWeek') {
|
||||
// 如果指定了每月最后一个星期几
|
||||
// 校验并调整如果是2月30号这种日期传进来时需调整至正常月底
|
||||
if (checkDate(YY + '-' + MM + '-' + thisDD + ' 00:00:00') !== true) {
|
||||
while (DD > 0 && checkDate(YY + '-' + MM + '-' + thisDD + ' 00:00:00') !== true) {
|
||||
DD--
|
||||
thisDD = DD < 10 ? '0' + DD : DD
|
||||
}
|
||||
}
|
||||
// 获取月末最后一天是星期几
|
||||
let thisWeek = formatDate(new Date(YY + '-' + MM + '-' + thisDD + ' 00:00:00'), 'week')
|
||||
// 找到要求中最近的那个星期几
|
||||
if (dayRuleSup.value < thisWeek) {
|
||||
DD -= thisWeek - dayRuleSup.value
|
||||
} else if (dayRuleSup.value > thisWeek) {
|
||||
DD -= 7 - (dayRuleSup.value - thisWeek)
|
||||
}
|
||||
}
|
||||
// 判断时间值是否小于10置换成“05”这种格式
|
||||
DD = DD < 10 ? '0' + DD : DD
|
||||
// 循环“时”数组
|
||||
goHour: for (let hi = hIdx; hi < hDate.length; hi++) {
|
||||
let hh = hDate[hi] < 10 ? '0' + hDate[hi] : hDate[hi]
|
||||
// 如果到达最大值时
|
||||
if (nMin > mDate[mDate.length - 1]) {
|
||||
resetMin()
|
||||
if (hi === hDate.length - 1) {
|
||||
resetHour()
|
||||
if (Di === DDate.length - 1) {
|
||||
resetDay()
|
||||
if (Mi === MDate.length - 1) {
|
||||
resetMonth()
|
||||
continue goYear
|
||||
}
|
||||
continue goMonth
|
||||
}
|
||||
continue goDay
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 循环"分"数组
|
||||
goMin: for (let mi = mIdx; mi < mDate.length; mi++) {
|
||||
let mm = mDate[mi] < 10 ? '0' + mDate[mi] : mDate[mi]
|
||||
// 如果到达最大值时
|
||||
if (nSecond > sDate[sDate.length - 1]) {
|
||||
resetSecond()
|
||||
if (mi === mDate.length - 1) {
|
||||
resetMin()
|
||||
if (hi === hDate.length - 1) {
|
||||
resetHour()
|
||||
if (Di === DDate.length - 1) {
|
||||
resetDay()
|
||||
if (Mi === MDate.length - 1) {
|
||||
resetMonth()
|
||||
continue goYear
|
||||
}
|
||||
continue goMonth
|
||||
}
|
||||
continue goDay
|
||||
}
|
||||
continue goHour
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 循环"秒"数组
|
||||
goSecond: for (let si = sIdx; si <= sDate.length - 1; si++) {
|
||||
let ss = sDate[si] < 10 ? '0' + sDate[si] : sDate[si]
|
||||
// 添加当前时间(时间合法性在日期循环时已经判断)
|
||||
if (MM !== '00' && DD !== '00') {
|
||||
resultArr.push(YY + '-' + MM + '-' + DD + ' ' + hh + ':' + mm + ':' + ss)
|
||||
nums++
|
||||
}
|
||||
// 如果条数满了就退出循环
|
||||
if (nums === 5) break goYear
|
||||
// 如果到达最大值时
|
||||
if (si === sDate.length - 1) {
|
||||
resetSecond()
|
||||
if (mi === mDate.length - 1) {
|
||||
resetMin()
|
||||
if (hi === hDate.length - 1) {
|
||||
resetHour()
|
||||
if (Di === DDate.length - 1) {
|
||||
resetDay()
|
||||
if (Mi === MDate.length - 1) {
|
||||
resetMonth()
|
||||
continue goYear
|
||||
}
|
||||
continue goMonth
|
||||
}
|
||||
continue goDay
|
||||
}
|
||||
continue goHour
|
||||
}
|
||||
continue goMin
|
||||
}
|
||||
} //goSecond
|
||||
} //goMin
|
||||
}//goHour
|
||||
}//goDay
|
||||
}//goMonth
|
||||
}
|
||||
// 判断100年内的结果条数
|
||||
if (resultArr.length === 0) {
|
||||
resultList.value = ['没有达到条件的结果!']
|
||||
} else {
|
||||
resultList.value = resultArr
|
||||
if (resultArr.length !== 5) {
|
||||
resultList.value.push('最近100年内只有上面' + resultArr.length + '条结果!')
|
||||
}
|
||||
}
|
||||
// 计算完成-显示结果
|
||||
isShow.value = true
|
||||
}
|
||||
// 用于计算某位数字在数组中的索引
|
||||
function getIndex(arr, value) {
|
||||
if (value <= arr[0] || value > arr[arr.length - 1]) {
|
||||
return 0
|
||||
} else {
|
||||
for (let i = 0; i < arr.length - 1; i++) {
|
||||
if (value > arr[i] && value <= arr[i + 1]) {
|
||||
return i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 获取"年"数组
|
||||
function getYearArr(rule, year) {
|
||||
dateArr.value[5] = getOrderArr(year, year + 100)
|
||||
if (rule !== undefined) {
|
||||
if (rule.indexOf('-') >= 0) {
|
||||
dateArr.value[5] = getCycleArr(rule, year + 100, false)
|
||||
} else if (rule.indexOf('/') >= 0) {
|
||||
dateArr.value[5] = getAverageArr(rule, year + 100)
|
||||
} else if (rule !== '*') {
|
||||
dateArr.value[5] = getAssignArr(rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 获取"月"数组
|
||||
function getMonthArr(rule) {
|
||||
dateArr.value[4] = getOrderArr(1, 12)
|
||||
if (rule.indexOf('-') >= 0) {
|
||||
dateArr.value[4] = getCycleArr(rule, 12, false)
|
||||
} else if (rule.indexOf('/') >= 0) {
|
||||
dateArr.value[4] = getAverageArr(rule, 12)
|
||||
} else if (rule !== '*') {
|
||||
dateArr.value[4] = getAssignArr(rule)
|
||||
}
|
||||
}
|
||||
// 获取"日"数组-主要为日期规则
|
||||
function getWeekArr(rule) {
|
||||
// 只有当日期规则的两个值均为“”时则表达日期是有选项的
|
||||
if (dayRule.value === '' && dayRuleSup.value === '') {
|
||||
if (rule.indexOf('-') >= 0) {
|
||||
dayRule.value = 'weekDay'
|
||||
dayRuleSup.value = getCycleArr(rule, 7, false)
|
||||
} else if (rule.indexOf('#') >= 0) {
|
||||
dayRule.value = 'assWeek'
|
||||
let matchRule = rule.match(/[0-9]{1}/g)
|
||||
dayRuleSup.value = [Number(matchRule[1]), Number(matchRule[0])]
|
||||
dateArr.value[3] = [1]
|
||||
if (dayRuleSup.value[1] === 7) {
|
||||
dayRuleSup.value[1] = 0
|
||||
}
|
||||
} else if (rule.indexOf('L') >= 0) {
|
||||
dayRule.value = 'lastWeek'
|
||||
dayRuleSup.value = Number(rule.match(/[0-9]{1,2}/g)[0])
|
||||
dateArr.value[3] = [31]
|
||||
if (dayRuleSup.value === 7) {
|
||||
dayRuleSup.value = 0
|
||||
}
|
||||
} else if (rule !== '*' && rule !== '?') {
|
||||
dayRule.value = 'weekDay'
|
||||
dayRuleSup.value = getAssignArr(rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 获取"日"数组-少量为日期规则
|
||||
function getDayArr(rule) {
|
||||
dateArr.value[3] = getOrderArr(1, 31)
|
||||
dayRule.value = ''
|
||||
dayRuleSup.value = ''
|
||||
if (rule.indexOf('-') >= 0) {
|
||||
dateArr.value[3] = getCycleArr(rule, 31, false)
|
||||
dayRuleSup.value = 'null'
|
||||
} else if (rule.indexOf('/') >= 0) {
|
||||
dateArr.value[3] = getAverageArr(rule, 31)
|
||||
dayRuleSup.value = 'null'
|
||||
} else if (rule.indexOf('W') >= 0) {
|
||||
dayRule.value = 'workDay'
|
||||
dayRuleSup.value = Number(rule.match(/[0-9]{1,2}/g)[0])
|
||||
dateArr.value[3] = [dayRuleSup.value]
|
||||
} else if (rule.indexOf('L') >= 0) {
|
||||
dayRule.value = 'lastDay'
|
||||
dayRuleSup.value = 'null'
|
||||
dateArr.value[3] = [31]
|
||||
} else if (rule !== '*' && rule !== '?') {
|
||||
dateArr.value[3] = getAssignArr(rule)
|
||||
dayRuleSup.value = 'null'
|
||||
} else if (rule === '*') {
|
||||
dayRuleSup.value = 'null'
|
||||
}
|
||||
}
|
||||
// 获取"时"数组
|
||||
function getHourArr(rule) {
|
||||
dateArr.value[2] = getOrderArr(0, 23)
|
||||
if (rule.indexOf('-') >= 0) {
|
||||
dateArr.value[2] = getCycleArr(rule, 24, true)
|
||||
} else if (rule.indexOf('/') >= 0) {
|
||||
dateArr.value[2] = getAverageArr(rule, 23)
|
||||
} else if (rule !== '*') {
|
||||
dateArr.value[2] = getAssignArr(rule)
|
||||
}
|
||||
}
|
||||
// 获取"分"数组
|
||||
function getMinArr(rule) {
|
||||
dateArr.value[1] = getOrderArr(0, 59)
|
||||
if (rule.indexOf('-') >= 0) {
|
||||
dateArr.value[1] = getCycleArr(rule, 60, true)
|
||||
} else if (rule.indexOf('/') >= 0) {
|
||||
dateArr.value[1] = getAverageArr(rule, 59)
|
||||
} else if (rule !== '*') {
|
||||
dateArr.value[1] = getAssignArr(rule)
|
||||
}
|
||||
}
|
||||
// 获取"秒"数组
|
||||
function getSecondArr(rule) {
|
||||
dateArr.value[0] = getOrderArr(0, 59)
|
||||
if (rule.indexOf('-') >= 0) {
|
||||
dateArr.value[0] = getCycleArr(rule, 60, true)
|
||||
} else if (rule.indexOf('/') >= 0) {
|
||||
dateArr.value[0] = getAverageArr(rule, 59)
|
||||
} else if (rule !== '*') {
|
||||
dateArr.value[0] = getAssignArr(rule)
|
||||
}
|
||||
}
|
||||
// 根据传进来的min-max返回一个顺序的数组
|
||||
function getOrderArr(min, max) {
|
||||
let arr = []
|
||||
for (let i = min; i <= max; i++) {
|
||||
arr.push(i)
|
||||
}
|
||||
return arr
|
||||
}
|
||||
// 根据规则中指定的零散值返回一个数组
|
||||
function getAssignArr(rule) {
|
||||
let arr = []
|
||||
let assiginArr = rule.split(',')
|
||||
for (let i = 0; i < assiginArr.length; i++) {
|
||||
arr[i] = Number(assiginArr[i])
|
||||
}
|
||||
arr.sort(compare)
|
||||
return arr
|
||||
}
|
||||
// 根据一定算术规则计算返回一个数组
|
||||
function getAverageArr(rule, limit) {
|
||||
let arr = []
|
||||
let agArr = rule.split('/')
|
||||
let min = Number(agArr[0])
|
||||
let step = Number(agArr[1])
|
||||
while (min <= limit) {
|
||||
arr.push(min)
|
||||
min += step
|
||||
}
|
||||
return arr
|
||||
}
|
||||
// 根据规则返回一个具有周期性的数组
|
||||
function getCycleArr(rule, limit, status) {
|
||||
// status--表示是否从0开始(则从1开始)
|
||||
let arr = []
|
||||
let cycleArr = rule.split('-')
|
||||
let min = Number(cycleArr[0])
|
||||
let max = Number(cycleArr[1])
|
||||
if (min > max) {
|
||||
max += limit
|
||||
}
|
||||
for (let i = min; i <= max; i++) {
|
||||
let add = 0
|
||||
if (status === false && i % limit === 0) {
|
||||
add = limit
|
||||
}
|
||||
arr.push(Math.round(i % limit + add))
|
||||
}
|
||||
arr.sort(compare)
|
||||
return arr
|
||||
}
|
||||
// 比较数字大小(用于Array.sort)
|
||||
function compare(value1, value2) {
|
||||
if (value2 - value1 > 0) {
|
||||
return -1
|
||||
} else {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
// 格式化日期格式如:2017-9-19 18:04:33
|
||||
function formatDate(value, type) {
|
||||
// 计算日期相关值
|
||||
let time = typeof value == 'number' ? new Date(value) : value
|
||||
let Y = time.getFullYear()
|
||||
let M = time.getMonth() + 1
|
||||
let D = time.getDate()
|
||||
let h = time.getHours()
|
||||
let m = time.getMinutes()
|
||||
let s = time.getSeconds()
|
||||
let week = time.getDay()
|
||||
// 如果传递了type的话
|
||||
if (type === undefined) {
|
||||
return Y + '-' + (M < 10 ? '0' + M : M) + '-' + (D < 10 ? '0' + D : D) + ' ' + (h < 10 ? '0' + h : h) + ':' + (m < 10 ? '0' + m : m) + ':' + (s < 10 ? '0' + s : s)
|
||||
} else if (type === 'week') {
|
||||
// 在quartz中 1为星期日
|
||||
return week + 1
|
||||
}
|
||||
}
|
||||
// 检查日期是否存在
|
||||
function checkDate(value) {
|
||||
let time = new Date(value)
|
||||
let format = formatDate(time)
|
||||
return value === format
|
||||
}
|
||||
onMounted(() => {
|
||||
expressionChange()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<el-form>
|
||||
<el-form-item>
|
||||
<el-radio v-model='radioValue' :value="1">
|
||||
秒,允许的通配符[, - * /]
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-radio v-model='radioValue' :value="2">
|
||||
周期从
|
||||
<el-input-number v-model='cycle01' :min="0" :max="58" /> -
|
||||
<el-input-number v-model='cycle02' :min="cycle01 + 1" :max="59" /> 秒
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-radio v-model='radioValue' :value="3">
|
||||
从
|
||||
<el-input-number v-model='average01' :min="0" :max="58" /> 秒开始,每
|
||||
<el-input-number v-model='average02' :min="1" :max="59 - average01" /> 秒执行一次
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-radio v-model='radioValue' :value="4">
|
||||
指定
|
||||
<el-select clearable v-model="checkboxList" placeholder="可多选" multiple :multiple-limit="10">
|
||||
<el-option v-for="item in 60" :key="item" :label="item - 1" :value="item - 1" />
|
||||
</el-select>
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const emit = defineEmits(['update'])
|
||||
const props = defineProps({
|
||||
cron: {
|
||||
type: Object,
|
||||
default: {
|
||||
second: "*",
|
||||
min: "*",
|
||||
hour: "*",
|
||||
day: "*",
|
||||
month: "*",
|
||||
week: "?",
|
||||
year: "",
|
||||
}
|
||||
},
|
||||
check: {
|
||||
type: Function,
|
||||
default: () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
const radioValue = ref(1)
|
||||
const cycle01 = ref(0)
|
||||
const cycle02 = ref(1)
|
||||
const average01 = ref(0)
|
||||
const average02 = ref(1)
|
||||
const checkboxList = ref([])
|
||||
const checkCopy = ref([0])
|
||||
const cycleTotal = computed(() => {
|
||||
cycle01.value = props.check(cycle01.value, 0, 58)
|
||||
cycle02.value = props.check(cycle02.value, cycle01.value + 1, 59)
|
||||
return cycle01.value + '-' + cycle02.value
|
||||
})
|
||||
const averageTotal = computed(() => {
|
||||
average01.value = props.check(average01.value, 0, 58)
|
||||
average02.value = props.check(average02.value, 1, 59 - average01.value)
|
||||
return average01.value + '/' + average02.value
|
||||
})
|
||||
const checkboxString = computed(() => {
|
||||
return checkboxList.value.join(',')
|
||||
})
|
||||
watch(() => props.cron.second, value => changeRadioValue(value))
|
||||
watch([radioValue, cycleTotal, averageTotal, checkboxString], () => onRadioChange())
|
||||
function changeRadioValue(value) {
|
||||
if (value === '*') {
|
||||
radioValue.value = 1
|
||||
} else if (value.indexOf('-') > -1) {
|
||||
const indexArr = value.split('-')
|
||||
cycle01.value = Number(indexArr[0])
|
||||
cycle02.value = Number(indexArr[1])
|
||||
radioValue.value = 2
|
||||
} else if (value.indexOf('/') > -1) {
|
||||
const indexArr = value.split('/')
|
||||
average01.value = Number(indexArr[0])
|
||||
average02.value = Number(indexArr[1])
|
||||
radioValue.value = 3
|
||||
} else {
|
||||
checkboxList.value = [...new Set(value.split(',').map(item => Number(item)))]
|
||||
radioValue.value = 4
|
||||
}
|
||||
}
|
||||
// 单选按钮值变化时
|
||||
function onRadioChange() {
|
||||
switch (radioValue.value) {
|
||||
case 1:
|
||||
emit('update', 'second', '*', 'second')
|
||||
break
|
||||
case 2:
|
||||
emit('update', 'second', cycleTotal.value, 'second')
|
||||
break
|
||||
case 3:
|
||||
emit('update', 'second', averageTotal.value, 'second')
|
||||
break
|
||||
case 4:
|
||||
if (checkboxList.value.length === 0) {
|
||||
checkboxList.value.push(checkCopy.value[0])
|
||||
} else {
|
||||
checkCopy.value = checkboxList.value
|
||||
}
|
||||
emit('update', 'second', checkboxString.value, 'second')
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.el-input-number--small, .el-select, .el-select--small {
|
||||
margin: 0 0.2rem;
|
||||
}
|
||||
.el-select, .el-select--small {
|
||||
width: 18.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,197 @@
|
||||
<template>
|
||||
<el-form>
|
||||
<el-form-item>
|
||||
<el-radio v-model='radioValue' :value="1">
|
||||
周,允许的通配符[, - * ? / L #]
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-radio v-model='radioValue' :value="2">
|
||||
不指定
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-radio v-model='radioValue' :value="3">
|
||||
周期从
|
||||
<el-select clearable v-model="cycle01">
|
||||
<el-option
|
||||
v-for="(item,index) of weekList"
|
||||
:key="index"
|
||||
:label="item.value"
|
||||
:value="item.key"
|
||||
:disabled="item.key === 7"
|
||||
>{{item.value}}</el-option>
|
||||
</el-select>
|
||||
-
|
||||
<el-select clearable v-model="cycle02">
|
||||
<el-option
|
||||
v-for="(item,index) of weekList"
|
||||
:key="index"
|
||||
:label="item.value"
|
||||
:value="item.key"
|
||||
:disabled="item.key <= cycle01"
|
||||
>{{item.value}}</el-option>
|
||||
</el-select>
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-radio v-model='radioValue' :value="4">
|
||||
第
|
||||
<el-input-number v-model='average01' :min="1" :max="4" /> 周的
|
||||
<el-select clearable v-model="average02">
|
||||
<el-option v-for="item in weekList" :key="item.key" :label="item.value" :value="item.key" />
|
||||
</el-select>
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-radio v-model='radioValue' :value="5">
|
||||
本月最后一个
|
||||
<el-select clearable v-model="weekday">
|
||||
<el-option v-for="item in weekList" :key="item.key" :label="item.value" :value="item.key" />
|
||||
</el-select>
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-radio v-model='radioValue' :value="6">
|
||||
指定
|
||||
<el-select class="multiselect" clearable v-model="checkboxList" placeholder="可多选" multiple :multiple-limit="6">
|
||||
<el-option v-for="item in weekList" :key="item.key" :label="item.value" :value="item.key" />
|
||||
</el-select>
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const emit = defineEmits(['update'])
|
||||
const props = defineProps({
|
||||
cron: {
|
||||
type: Object,
|
||||
default: {
|
||||
second: "*",
|
||||
min: "*",
|
||||
hour: "*",
|
||||
day: "*",
|
||||
month: "*",
|
||||
week: "?",
|
||||
year: ""
|
||||
}
|
||||
},
|
||||
check: {
|
||||
type: Function,
|
||||
default: () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
const radioValue = ref(2)
|
||||
const cycle01 = ref(2)
|
||||
const cycle02 = ref(3)
|
||||
const average01 = ref(1)
|
||||
const average02 = ref(2)
|
||||
const weekday = ref(2)
|
||||
const checkboxList = ref([])
|
||||
const checkCopy = ref([2])
|
||||
const weekList = ref([
|
||||
{key: 1, value: '星期日'},
|
||||
{key: 2, value: '星期一'},
|
||||
{key: 3, value: '星期二'},
|
||||
{key: 4, value: '星期三'},
|
||||
{key: 5, value: '星期四'},
|
||||
{key: 6, value: '星期五'},
|
||||
{key: 7, value: '星期六'}
|
||||
])
|
||||
const cycleTotal = computed(() => {
|
||||
cycle01.value = props.check(cycle01.value, 1, 6)
|
||||
cycle02.value = props.check(cycle02.value, cycle01.value + 1, 7)
|
||||
return cycle01.value + '-' + cycle02.value
|
||||
})
|
||||
const averageTotal = computed(() => {
|
||||
average01.value = props.check(average01.value, 1, 4)
|
||||
average02.value = props.check(average02.value, 1, 7)
|
||||
return average02.value + '#' + average01.value
|
||||
})
|
||||
const weekdayTotal = computed(() => {
|
||||
weekday.value = props.check(weekday.value, 1, 7)
|
||||
return weekday.value + 'L'
|
||||
})
|
||||
const checkboxString = computed(() => {
|
||||
return checkboxList.value.join(',')
|
||||
})
|
||||
watch(() => props.cron.week, value => changeRadioValue(value))
|
||||
watch([radioValue, cycleTotal, averageTotal, weekdayTotal, checkboxString], () => onRadioChange())
|
||||
function changeRadioValue(value) {
|
||||
if (value === "*") {
|
||||
radioValue.value = 1
|
||||
} else if (value === "?") {
|
||||
radioValue.value = 2
|
||||
} else if (value.indexOf("-") > -1) {
|
||||
const indexArr = value.split('-')
|
||||
cycle01.value = Number(indexArr[0])
|
||||
cycle02.value = Number(indexArr[1])
|
||||
radioValue.value = 3
|
||||
} else if (value.indexOf("#") > -1) {
|
||||
const indexArr = value.split('#')
|
||||
average01.value = Number(indexArr[1])
|
||||
average02.value = Number(indexArr[0])
|
||||
radioValue.value = 4
|
||||
} else if (value.indexOf("L") > -1) {
|
||||
const indexArr = value.split("L")
|
||||
weekday.value = Number(indexArr[0])
|
||||
radioValue.value = 5
|
||||
} else {
|
||||
checkboxList.value = [...new Set(value.split(',').map(item => Number(item)))]
|
||||
radioValue.value = 6
|
||||
}
|
||||
}
|
||||
function onRadioChange() {
|
||||
if (radioValue.value === 2 && props.cron.day === '?') {
|
||||
emit('update', 'day', '*', 'week')
|
||||
}
|
||||
if (radioValue.value !== 2 && props.cron.day !== '?') {
|
||||
emit('update', 'day', '?', 'week')
|
||||
}
|
||||
switch (radioValue.value) {
|
||||
case 1:
|
||||
emit('update', 'week', '*', 'week')
|
||||
break
|
||||
case 2:
|
||||
emit('update', 'week', '?', 'week')
|
||||
break
|
||||
case 3:
|
||||
emit('update', 'week', cycleTotal.value, 'week')
|
||||
break
|
||||
case 4:
|
||||
emit('update', 'week', averageTotal.value, 'week')
|
||||
break
|
||||
case 5:
|
||||
emit('update', 'week', weekdayTotal.value, 'week')
|
||||
break
|
||||
case 6:
|
||||
if (checkboxList.value.length === 0) {
|
||||
checkboxList.value.push(checkCopy.value[0])
|
||||
} else {
|
||||
checkCopy.value = checkboxList.value
|
||||
}
|
||||
emit('update', 'week', checkboxString.value, 'week')
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.el-input-number--small, .el-select, .el-select--small {
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
.el-select, .el-select--small {
|
||||
width: 8rem;
|
||||
}
|
||||
.el-select.multiselect, .el-select--small.multiselect {
|
||||
width: 17.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<el-form>
|
||||
<el-form-item>
|
||||
<el-radio :value="1" v-model='radioValue'>
|
||||
不填,允许的通配符[, - * /]
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-radio :value="2" v-model='radioValue'>
|
||||
每年
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-radio :value="3" v-model='radioValue'>
|
||||
周期从
|
||||
<el-input-number v-model='cycle01' :min='fullYear' :max="2098"/> -
|
||||
<el-input-number v-model='cycle02' :min="cycle01 ? cycle01 + 1 : fullYear + 1" :max="2099"/>
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-radio :value="4" v-model='radioValue'>
|
||||
从
|
||||
<el-input-number v-model='average01' :min='fullYear' :max="2098"/> 年开始,每
|
||||
<el-input-number v-model='average02' :min="1" :max="2099 - average01 || fullYear"/> 年执行一次
|
||||
</el-radio>
|
||||
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-radio :value="5" v-model='radioValue'>
|
||||
指定
|
||||
<el-select clearable v-model="checkboxList" placeholder="可多选" multiple :multiple-limit="8">
|
||||
<el-option v-for="item in 9" :key="item" :value="item - 1 + fullYear" :label="item -1 + fullYear" />
|
||||
</el-select>
|
||||
</el-radio>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const emit = defineEmits(['update'])
|
||||
const props = defineProps({
|
||||
cron: {
|
||||
type: Object,
|
||||
default: {
|
||||
second: "*",
|
||||
min: "*",
|
||||
hour: "*",
|
||||
day: "*",
|
||||
month: "*",
|
||||
week: "?",
|
||||
year: ""
|
||||
}
|
||||
},
|
||||
check: {
|
||||
type: Function,
|
||||
default: () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const fullYear = Number(new Date().getFullYear())
|
||||
const maxFullYear = fullYear + 10
|
||||
const radioValue = ref(1)
|
||||
const cycle01 = ref(fullYear)
|
||||
const cycle02 = ref(fullYear + 1)
|
||||
const average01 = ref(fullYear)
|
||||
const average02 = ref(1)
|
||||
const checkboxList = ref([])
|
||||
const checkCopy = ref([fullYear])
|
||||
|
||||
const cycleTotal = computed(() => {
|
||||
cycle01.value = props.check(cycle01.value, fullYear, maxFullYear - 1)
|
||||
cycle02.value = props.check(cycle02.value, cycle01.value + 1, maxFullYear)
|
||||
return cycle01.value + '-' + cycle02.value
|
||||
})
|
||||
const averageTotal = computed(() => {
|
||||
average01.value = props.check(average01.value, fullYear, maxFullYear - 1)
|
||||
average02.value = props.check(average02.value, 1, 10)
|
||||
return average01.value + '/' + average02.value
|
||||
})
|
||||
const checkboxString = computed(() => {
|
||||
return checkboxList.value.join(',')
|
||||
})
|
||||
watch(() => props.cron.year, value => changeRadioValue(value))
|
||||
watch([radioValue, cycleTotal, averageTotal, checkboxString], () => onRadioChange())
|
||||
function changeRadioValue(value) {
|
||||
if (value === '') {
|
||||
radioValue.value = 1
|
||||
} else if (value === "*") {
|
||||
radioValue.value = 2
|
||||
} else if (value.indexOf("-") > -1) {
|
||||
const indexArr = value.split('-')
|
||||
cycle01.value = Number(indexArr[0])
|
||||
cycle02.value = Number(indexArr[1])
|
||||
radioValue.value = 3
|
||||
} else if (value.indexOf("/") > -1) {
|
||||
const indexArr = value.split('/')
|
||||
average01.value = Number(indexArr[0])
|
||||
average02.value = Number(indexArr[1])
|
||||
radioValue.value = 4
|
||||
} else {
|
||||
checkboxList.value = [...new Set(value.split(',').map(item => Number(item)))]
|
||||
radioValue.value = 5
|
||||
}
|
||||
}
|
||||
function onRadioChange() {
|
||||
switch (radioValue.value) {
|
||||
case 1:
|
||||
emit('update', 'year', '', 'year')
|
||||
break
|
||||
case 2:
|
||||
emit('update', 'year', '*', 'year')
|
||||
break
|
||||
case 3:
|
||||
emit('update', 'year', cycleTotal.value, 'year')
|
||||
break
|
||||
case 4:
|
||||
emit('update', 'year', averageTotal.value, 'year')
|
||||
break
|
||||
case 5:
|
||||
if (checkboxList.value.length === 0) {
|
||||
checkboxList.value.push(checkCopy.value[0])
|
||||
} else {
|
||||
checkCopy.value = checkboxList.value
|
||||
}
|
||||
emit('update', 'year', checkboxString.value, 'year')
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.el-input-number--small, .el-select, .el-select--small {
|
||||
margin: 0 0.2rem;
|
||||
}
|
||||
.el-select, .el-select--small {
|
||||
width: 18.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div>
|
||||
<template v-for="(item, index) in options">
|
||||
<template v-if="isValueMatch(item.value)">
|
||||
<span
|
||||
v-if="(item.elTagType == 'default' || item.elTagType == '') && (item.elTagClass == '' || item.elTagClass == null)"
|
||||
:key="item.value"
|
||||
:index="index"
|
||||
:class="item.elTagClass"
|
||||
>{{ item.label + " " }}</span>
|
||||
<el-tag
|
||||
v-else
|
||||
:disable-transitions="true"
|
||||
:key="item.value + ''"
|
||||
:index="index"
|
||||
:type="item.elTagType"
|
||||
:class="item.elTagClass"
|
||||
>{{ item.label + " " }}</el-tag>
|
||||
</template>
|
||||
</template>
|
||||
<template v-if="unmatch && showValue">
|
||||
{{ unmatchArray | handleArray }}
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// 记录未匹配的项
|
||||
const unmatchArray = ref([])
|
||||
|
||||
const props = defineProps({
|
||||
// 数据
|
||||
options: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
// 当前的值
|
||||
value: [Number, String, Array],
|
||||
// 当未找到匹配的数据时,显示value
|
||||
showValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
separator: {
|
||||
type: String,
|
||||
default: ",",
|
||||
}
|
||||
})
|
||||
|
||||
const values = computed(() => {
|
||||
if (props.value === null || typeof props.value === 'undefined' || props.value === '') return []
|
||||
if (typeof props.value === 'number' || typeof props.value === 'boolean') return [props.value]
|
||||
return Array.isArray(props.value) ? props.value.map(item => '' + item) : String(props.value).split(props.separator)
|
||||
})
|
||||
|
||||
const unmatch = computed(() => {
|
||||
unmatchArray.value = []
|
||||
// 没有value不显示
|
||||
if (props.value === null || typeof props.value === 'undefined' || props.value === '' || !Array.isArray(props.options) || props.options.length === 0) return false
|
||||
// 传入值为数组
|
||||
let unmatch = false // 添加一个标志来判断是否有未匹配项
|
||||
values.value.forEach(item => {
|
||||
if (!props.options.some(v => v.value == item)) {
|
||||
unmatchArray.value.push(item)
|
||||
unmatch = true // 如果有未匹配项,将标志设置为true
|
||||
}
|
||||
})
|
||||
return unmatch // 返回标志的值
|
||||
})
|
||||
|
||||
function handleArray(array) {
|
||||
if (array.length === 0) return ""
|
||||
return array.reduce((pre, cur) => {
|
||||
return pre + " " + cur
|
||||
})
|
||||
}
|
||||
|
||||
function isValueMatch(itemValue) {
|
||||
return values.value.some(val => val == itemValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.el-tag + .el-tag {
|
||||
margin-left: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,276 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-upload
|
||||
:action="uploadUrl"
|
||||
:before-upload="handleBeforeUpload"
|
||||
:on-success="handleUploadSuccess"
|
||||
:on-error="handleUploadError"
|
||||
name="file"
|
||||
:show-file-list="false"
|
||||
:headers="headers"
|
||||
class="editor-img-uploader"
|
||||
v-if="type == 'url'"
|
||||
>
|
||||
<i ref="uploadRef" class="editor-img-uploader"></i>
|
||||
</el-upload>
|
||||
</div>
|
||||
<div class="editor">
|
||||
<quill-editor
|
||||
ref="quillEditorRef"
|
||||
v-model:content="content"
|
||||
contentType="html"
|
||||
@textChange="(e) => $emit('update:modelValue', content)"
|
||||
:options="options"
|
||||
:style="styles"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import axios from 'axios'
|
||||
import { QuillEditor } from "@vueup/vue-quill"
|
||||
import "@vueup/vue-quill/dist/vue-quill.snow.css"
|
||||
import { getToken } from "@/utils/auth"
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
|
||||
const quillEditorRef = ref()
|
||||
const uploadUrl = ref(import.meta.env.VITE_APP_BASE_API + "/common/upload") // 上传的图片服务器地址
|
||||
const headers = ref({
|
||||
Authorization: "Bearer " + getToken()
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
/* 编辑器的内容 */
|
||||
modelValue: {
|
||||
type: String,
|
||||
},
|
||||
/* 高度 */
|
||||
height: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
/* 最小高度 */
|
||||
minHeight: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
/* 只读 */
|
||||
readOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
/* 上传文件大小限制(MB) */
|
||||
fileSize: {
|
||||
type: Number,
|
||||
default: 5,
|
||||
},
|
||||
/* 类型(base64格式、url格式) */
|
||||
type: {
|
||||
type: String,
|
||||
default: "url",
|
||||
}
|
||||
})
|
||||
|
||||
const options = ref({
|
||||
theme: "snow",
|
||||
bounds: document.body,
|
||||
debug: "warn",
|
||||
modules: {
|
||||
// 工具栏配置
|
||||
toolbar: [
|
||||
["bold", "italic", "underline", "strike"], // 加粗 斜体 下划线 删除线
|
||||
["blockquote", "code-block"], // 引用 代码块
|
||||
[{ list: "ordered" }, { list: "bullet" }], // 有序、无序列表
|
||||
[{ indent: "-1" }, { indent: "+1" }], // 缩进
|
||||
[{ size: ["small", false, "large", "huge"] }], // 字体大小
|
||||
[{ header: [1, 2, 3, 4, 5, 6, false] }], // 标题
|
||||
[{ color: [] }, { background: [] }], // 字体颜色、字体背景颜色
|
||||
[{ align: [] }], // 对齐方式
|
||||
["clean"], // 清除文本格式
|
||||
["link", "image", "video"] // 链接、图片、视频
|
||||
],
|
||||
},
|
||||
placeholder: "请输入内容",
|
||||
readOnly: props.readOnly
|
||||
})
|
||||
|
||||
const styles = computed(() => {
|
||||
let style = {}
|
||||
if (props.minHeight) {
|
||||
style.minHeight = `${props.minHeight}px`
|
||||
}
|
||||
if (props.height) {
|
||||
style.height = `${props.height}px`
|
||||
}
|
||||
return style
|
||||
})
|
||||
|
||||
const content = ref("")
|
||||
watch(() => props.modelValue, (v) => {
|
||||
if (v !== content.value) {
|
||||
content.value = v == undefined ? "<p></p>" : v
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 如果设置了上传地址则自定义图片上传事件
|
||||
onMounted(() => {
|
||||
if (props.type == 'url') {
|
||||
let quill = quillEditorRef.value.getQuill()
|
||||
let toolbar = quill.getModule("toolbar")
|
||||
toolbar.addHandler("image", (value) => {
|
||||
if (value) {
|
||||
proxy.$refs.uploadRef.click()
|
||||
} else {
|
||||
quill.format("image", false)
|
||||
}
|
||||
})
|
||||
quill.root.addEventListener('paste', handlePasteCapture, true)
|
||||
}
|
||||
})
|
||||
|
||||
// 上传前校检格式和大小
|
||||
function handleBeforeUpload(file) {
|
||||
const type = ["image/jpeg", "image/jpg", "image/png", "image/svg"]
|
||||
const isJPG = type.includes(file.type)
|
||||
//检验文件格式
|
||||
if (!isJPG) {
|
||||
proxy.$modal.msgError(`图片格式错误!`)
|
||||
return false
|
||||
}
|
||||
// 校检文件大小
|
||||
if (props.fileSize) {
|
||||
const isLt = file.size / 1024 / 1024 < props.fileSize
|
||||
if (!isLt) {
|
||||
proxy.$modal.msgError(`上传文件大小不能超过 ${props.fileSize} MB!`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 上传成功处理
|
||||
function handleUploadSuccess(res, file) {
|
||||
// 如果上传成功
|
||||
if (res.code == 200) {
|
||||
// 获取富文本实例
|
||||
let quill = toRaw(quillEditorRef.value).getQuill()
|
||||
// 获取光标位置
|
||||
let length = quill.selection.savedRange.index
|
||||
// 插入图片,res.url为服务器返回的图片链接地址
|
||||
quill.insertEmbed(length, "image", import.meta.env.VITE_APP_BASE_API + res.fileName)
|
||||
// 调整光标到最后
|
||||
quill.setSelection(length + 1)
|
||||
} else {
|
||||
proxy.$modal.msgError("图片插入失败")
|
||||
}
|
||||
}
|
||||
|
||||
// 上传失败处理
|
||||
function handleUploadError() {
|
||||
proxy.$modal.msgError("图片插入失败")
|
||||
}
|
||||
|
||||
// 复制粘贴图片处理
|
||||
function handlePasteCapture(e) {
|
||||
const clipboard = e.clipboardData || window.clipboardData
|
||||
if (clipboard && clipboard.items) {
|
||||
for (let i = 0; i < clipboard.items.length; i++) {
|
||||
const item = clipboard.items[i]
|
||||
if (item.type.indexOf('image') !== -1) {
|
||||
e.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
insertImage(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function insertImage(file) {
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
axios.post(uploadUrl.value, formData, { headers: { "Content-Type": "multipart/form-data", Authorization: headers.value.Authorization } }).then(res => {
|
||||
handleUploadSuccess(res.data)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.editor-img-uploader {
|
||||
display: none;
|
||||
}
|
||||
.editor, .ql-toolbar {
|
||||
white-space: pre-wrap !important;
|
||||
line-height: normal !important;
|
||||
}
|
||||
.quill-img {
|
||||
display: none;
|
||||
}
|
||||
.ql-snow .ql-tooltip[data-mode="link"]::before {
|
||||
content: "请输入链接地址:";
|
||||
}
|
||||
.ql-snow .ql-tooltip.ql-editing a.ql-action::after {
|
||||
border-right: 0px;
|
||||
content: "保存";
|
||||
padding-right: 0px;
|
||||
}
|
||||
.ql-snow .ql-tooltip[data-mode="video"]::before {
|
||||
content: "请输入视频地址:";
|
||||
}
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-label::before,
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-item::before {
|
||||
content: "14px";
|
||||
}
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="small"]::before,
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="small"]::before {
|
||||
content: "10px";
|
||||
}
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="large"]::before,
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="large"]::before {
|
||||
content: "18px";
|
||||
}
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="huge"]::before,
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="huge"]::before {
|
||||
content: "32px";
|
||||
}
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-label::before,
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-item::before {
|
||||
content: "文本";
|
||||
}
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="1"]::before,
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="1"]::before {
|
||||
content: "标题1";
|
||||
}
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="2"]::before,
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="2"]::before {
|
||||
content: "标题2";
|
||||
}
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="3"]::before,
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="3"]::before {
|
||||
content: "标题3";
|
||||
}
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="4"]::before,
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="4"]::before {
|
||||
content: "标题4";
|
||||
}
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="5"]::before,
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="5"]::before {
|
||||
content: "标题5";
|
||||
}
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="6"]::before,
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="6"]::before {
|
||||
content: "标题6";
|
||||
}
|
||||
.ql-snow .ql-picker.ql-font .ql-picker-label::before,
|
||||
.ql-snow .ql-picker.ql-font .ql-picker-item::before {
|
||||
content: "标准字体";
|
||||
}
|
||||
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="serif"]::before,
|
||||
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="serif"]::before {
|
||||
content: "衬线字体";
|
||||
}
|
||||
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="monospace"]::before,
|
||||
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="monospace"]::before {
|
||||
content: "等宽字体";
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<el-dialog :title="title" v-model="visible" :width="width" append-to-body @close="handleClose">
|
||||
<el-upload ref="uploadRef" :limit="1" accept=".xlsx, .xls" :headers="headers" :action="uploadUrl" :disabled="isUploading" :on-progress="handleProgress" :on-change="handleFileChange" :on-remove="handleFileRemove" :on-success="handleSuccess" :auto-upload="false" drag>
|
||||
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
|
||||
<div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip text-center">
|
||||
<div class="el-upload__tip">
|
||||
<el-checkbox v-model="updateSupport"> {{ updateSupportLabel }} </el-checkbox>
|
||||
</div>
|
||||
<span>仅允许导入xls、xlsx格式文件。</span>
|
||||
<el-link v-if="templateUrl" type="primary" underline="never" style="font-size: 12px; vertical-align: baseline" @click="handleDownloadTemplate">下载模板</el-link>
|
||||
</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button type="primary" @click="handleSubmit">确 定</el-button>
|
||||
<el-button @click="visible = false">取 消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { getToken } from '@/utils/auth'
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
|
||||
const props = defineProps({
|
||||
// 对话框标题
|
||||
title: {
|
||||
type: String,
|
||||
default: '数据导入'
|
||||
},
|
||||
// 对话框宽度
|
||||
width: {
|
||||
type: String,
|
||||
default: '400px'
|
||||
},
|
||||
// 上传接口地址(必传)
|
||||
action: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
// 模板下载接口地址,不传则不显示下载模板链接
|
||||
templateAction: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 模板文件名前缀
|
||||
templateFileName: {
|
||||
type: String,
|
||||
default: 'template'
|
||||
},
|
||||
// 覆盖更新勾选框的说明文字
|
||||
updateSupportLabel: {
|
||||
type: String,
|
||||
default: '是否更新已经存在的数据'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
const uploadRef = ref(null)
|
||||
const visible = ref(false)
|
||||
const selectedFile = ref(null)
|
||||
const isUploading = ref(false)
|
||||
const updateSupport = ref(false)
|
||||
const headers = { Authorization: 'Bearer ' + getToken() }
|
||||
|
||||
const uploadUrl = computed(() => {
|
||||
return import.meta.env.VITE_APP_BASE_API + props.action + '?updateSupport=' + (updateSupport.value ? 1 : 0)
|
||||
})
|
||||
|
||||
const templateUrl = computed(() => !!props.templateAction)
|
||||
|
||||
// 打开对话框(供父组件通过 ref 调用)
|
||||
function open() {
|
||||
updateSupport.value = false
|
||||
isUploading.value = false
|
||||
visible.value = true
|
||||
nextTick(() => {
|
||||
selectedFile.value = null
|
||||
uploadRef.value?.clearFiles()
|
||||
})
|
||||
}
|
||||
|
||||
// 关闭时清理
|
||||
function handleClose() {
|
||||
isUploading.value = false
|
||||
selectedFile.value = null
|
||||
uploadRef.value?.clearFiles()
|
||||
}
|
||||
|
||||
// 下载模板
|
||||
function handleDownloadTemplate() {
|
||||
proxy.download(props.templateAction, {}, `${props.templateFileName}_${new Date().getTime()}.xlsx`)
|
||||
}
|
||||
|
||||
// 上传进度
|
||||
function handleProgress() {
|
||||
isUploading.value = true
|
||||
}
|
||||
|
||||
/** 文件选择处理 */
|
||||
const handleFileChange = (file, fileList) => {
|
||||
selectedFile.value = file
|
||||
}
|
||||
|
||||
/** 文件删除处理 */
|
||||
const handleFileRemove = (file, fileList) => {
|
||||
selectedFile.value = null
|
||||
}
|
||||
|
||||
// 上传成功
|
||||
function handleSuccess(response) {
|
||||
visible.value = false
|
||||
isUploading.value = false
|
||||
selectedFile.value = null
|
||||
uploadRef.value?.clearFiles()
|
||||
proxy.$alert("<div style='overflow:auto;overflow-x:hidden;max-height:70vh;padding:10px 20px 0;'>" + response.msg + '</div>', '导入结果', { dangerouslyUseHTMLString: true })
|
||||
emit('success')
|
||||
}
|
||||
|
||||
// 提交上传
|
||||
function handleSubmit() {
|
||||
const file = selectedFile.value
|
||||
if (!file || file.length === 0 || !file.name.toLowerCase().endsWith('.xls') && !file.name.toLowerCase().endsWith('.xlsx')) {
|
||||
proxy.$modal.msgError("请选择后缀为 “xls”或“xlsx”的文件。")
|
||||
return
|
||||
}
|
||||
uploadRef.value.submit()
|
||||
}
|
||||
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
@@ -0,0 +1,256 @@
|
||||
<template>
|
||||
<div class="upload-file">
|
||||
<el-upload
|
||||
multiple
|
||||
:action="uploadFileUrl"
|
||||
:before-upload="handleBeforeUpload"
|
||||
:file-list="fileList"
|
||||
:data="data"
|
||||
:limit="limit"
|
||||
:on-error="handleUploadError"
|
||||
:on-exceed="handleExceed"
|
||||
:on-success="handleUploadSuccess"
|
||||
:show-file-list="false"
|
||||
:headers="headers"
|
||||
class="upload-file-uploader"
|
||||
ref="fileUpload"
|
||||
v-if="!disabled"
|
||||
>
|
||||
<!-- 上传按钮 -->
|
||||
<el-button type="primary">选取文件</el-button>
|
||||
</el-upload>
|
||||
<!-- 上传提示 -->
|
||||
<div class="el-upload__tip" v-if="showTip && !disabled">
|
||||
请上传
|
||||
<template v-if="fileSize"> 大小不超过 <b style="color: #f56c6c">{{ fileSize }}MB</b> </template>
|
||||
<template v-if="fileType"> 格式为 <b style="color: #f56c6c">{{ fileType.join("/") }}</b> </template>
|
||||
的文件
|
||||
</div>
|
||||
<!-- 文件列表 -->
|
||||
<transition-group ref="uploadFileList" class="upload-file-list el-upload-list el-upload-list--text" name="el-fade-in-linear" tag="ul">
|
||||
<li :key="file.uid" class="el-upload-list__item ele-upload-list__item-content" v-for="(file, index) in fileList">
|
||||
<el-link :href="`${baseUrl}${file.url}`" underline="never" target="_blank">
|
||||
<span class="el-icon-document"> {{ getFileName(file.name) }} </span>
|
||||
</el-link>
|
||||
<div class="ele-upload-list__item-content-action">
|
||||
<el-link underline="never" @click="handleDelete(index)" type="danger" v-if="!disabled"> 删除</el-link>
|
||||
</div>
|
||||
</li>
|
||||
</transition-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { getToken } from "@/utils/auth"
|
||||
import Sortable from 'sortablejs'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: [String, Object, Array],
|
||||
// 上传接口地址
|
||||
action: {
|
||||
type: String,
|
||||
default: "/common/upload"
|
||||
},
|
||||
// 上传携带的参数
|
||||
data: {
|
||||
type: Object
|
||||
},
|
||||
// 数量限制
|
||||
limit: {
|
||||
type: Number,
|
||||
default: 5
|
||||
},
|
||||
// 大小限制(MB)
|
||||
fileSize: {
|
||||
type: Number,
|
||||
default: 5
|
||||
},
|
||||
// 文件类型, 例如['png', 'jpg', 'jpeg']
|
||||
fileType: {
|
||||
type: Array,
|
||||
default: () => ["doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "pdf"]
|
||||
},
|
||||
// 是否显示提示
|
||||
isShowTip: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 禁用组件(仅查看文件)
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 拖动排序
|
||||
drag: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
const emit = defineEmits()
|
||||
const number = ref(0)
|
||||
const uploadList = ref([])
|
||||
const baseUrl = import.meta.env.VITE_APP_BASE_API
|
||||
const uploadFileUrl = ref(import.meta.env.VITE_APP_BASE_API + props.action) // 上传文件服务器地址
|
||||
const headers = ref({ Authorization: "Bearer " + getToken() })
|
||||
const fileList = ref([])
|
||||
const showTip = computed(
|
||||
() => props.isShowTip && (props.fileType || props.fileSize)
|
||||
)
|
||||
|
||||
watch(() => props.modelValue, val => {
|
||||
if (val) {
|
||||
let temp = 1
|
||||
// 首先将值转为数组
|
||||
const list = Array.isArray(val) ? val : props.modelValue.split(',')
|
||||
// 然后将数组转为对象数组
|
||||
fileList.value = list.map(item => {
|
||||
if (typeof item === "string") {
|
||||
item = { name: item, url: item }
|
||||
}
|
||||
item.uid = item.uid || new Date().getTime() + temp++
|
||||
return item
|
||||
})
|
||||
} else {
|
||||
fileList.value = []
|
||||
return []
|
||||
}
|
||||
},{ deep: true, immediate: true })
|
||||
|
||||
// 上传前校检格式和大小
|
||||
function handleBeforeUpload(file) {
|
||||
// 校检文件类型
|
||||
if (props.fileType.length) {
|
||||
const fileName = file.name.split('.')
|
||||
const fileExt = fileName[fileName.length - 1]
|
||||
const isTypeOk = props.fileType.indexOf(fileExt) >= 0
|
||||
if (!isTypeOk) {
|
||||
proxy.$modal.msgError(`文件格式不正确,请上传${props.fileType.join("/")}格式文件!`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
// 校检文件名是否包含特殊字符
|
||||
if (file.name.includes(',')) {
|
||||
proxy.$modal.msgError('文件名不正确,不能包含英文逗号!')
|
||||
return false
|
||||
}
|
||||
// 校检文件大小
|
||||
if (props.fileSize) {
|
||||
const isLt = file.size / 1024 / 1024 < props.fileSize
|
||||
if (!isLt) {
|
||||
proxy.$modal.msgError(`上传文件大小不能超过 ${props.fileSize} MB!`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
proxy.$modal.loading("正在上传文件,请稍候...")
|
||||
number.value++
|
||||
return true
|
||||
}
|
||||
|
||||
// 文件个数超出
|
||||
function handleExceed() {
|
||||
proxy.$modal.msgError(`上传文件数量不能超过 ${props.limit} 个!`)
|
||||
}
|
||||
|
||||
// 上传失败
|
||||
function handleUploadError(err) {
|
||||
proxy.$modal.msgError("上传文件失败")
|
||||
proxy.$modal.closeLoading()
|
||||
}
|
||||
|
||||
// 上传成功回调
|
||||
function handleUploadSuccess(res, file) {
|
||||
if (res.code === 200) {
|
||||
uploadList.value.push({ name: res.fileName, url: res.fileName })
|
||||
uploadedSuccessfully()
|
||||
} else {
|
||||
number.value--
|
||||
proxy.$modal.closeLoading()
|
||||
proxy.$modal.msgError(res.msg)
|
||||
proxy.$refs.fileUpload.handleRemove(file)
|
||||
uploadedSuccessfully()
|
||||
}
|
||||
}
|
||||
|
||||
// 删除文件
|
||||
function handleDelete(index) {
|
||||
fileList.value.splice(index, 1)
|
||||
emit("update:modelValue", listToString(fileList.value))
|
||||
}
|
||||
|
||||
// 上传结束处理
|
||||
function uploadedSuccessfully() {
|
||||
if (number.value > 0 && uploadList.value.length === number.value) {
|
||||
fileList.value = fileList.value.filter(f => f.url !== undefined).concat(uploadList.value)
|
||||
uploadList.value = []
|
||||
number.value = 0
|
||||
emit("update:modelValue", listToString(fileList.value))
|
||||
proxy.$modal.closeLoading()
|
||||
}
|
||||
}
|
||||
|
||||
// 获取文件名称
|
||||
function getFileName(name) {
|
||||
// 如果是url那么取最后的名字 如果不是直接返回
|
||||
if (name.lastIndexOf("/") > -1) {
|
||||
return name.slice(name.lastIndexOf("/") + 1)
|
||||
} else {
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
// 对象转成指定字符串分隔
|
||||
function listToString(list, separator) {
|
||||
let strs = ""
|
||||
separator = separator || ","
|
||||
for (let i in list) {
|
||||
if (list[i].url) {
|
||||
strs += list[i].url + separator
|
||||
}
|
||||
}
|
||||
return strs != '' ? strs.substr(0, strs.length - 1) : ''
|
||||
}
|
||||
|
||||
// 初始化拖拽排序
|
||||
onMounted(() => {
|
||||
if (props.drag && !props.disabled) {
|
||||
nextTick(() => {
|
||||
const element = proxy.$refs.uploadFileList?.$el || proxy.$refs.uploadFileList
|
||||
Sortable.create(element, {
|
||||
ghostClass: 'file-upload-darg',
|
||||
onEnd: (evt) => {
|
||||
const movedItem = fileList.value.splice(evt.oldIndex, 1)[0]
|
||||
fileList.value.splice(evt.newIndex, 0, movedItem)
|
||||
emit('update:modelValue', listToString(fileList.value))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.file-upload-darg {
|
||||
opacity: 0.5;
|
||||
background: #c8ebfb;
|
||||
}
|
||||
.upload-file-uploader {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.upload-file-list .el-upload-list__item {
|
||||
border: 1px solid #e4e7ed;
|
||||
line-height: 2;
|
||||
margin-bottom: 10px;
|
||||
position: relative;
|
||||
transition: none !important;
|
||||
}
|
||||
.upload-file-list .ele-upload-list__item-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: inherit;
|
||||
}
|
||||
.ele-upload-list__item-content-action .el-link {
|
||||
margin-right: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div style="padding: 0 15px;" @click="toggleClick">
|
||||
<svg
|
||||
:class="{'is-active':isActive}"
|
||||
class="hamburger"
|
||||
viewBox="0 0 1024 1024"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="64"
|
||||
height="64"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M408 442h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8zm-8 204c0 4.4 3.6 8 8 8h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56zm504-486H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zm0 632H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zM142.4 642.1L298.7 519a8.84 8.84 0 0 0 0-13.9L142.4 381.9c-5.8-4.6-14.4-.5-14.4 6.9v246.3a8.9 8.9 0 0 0 14.4 7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits()
|
||||
const toggleClick = () => {
|
||||
emit('toggleClick')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hamburger {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.hamburger.is-active {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,397 @@
|
||||
<template>
|
||||
<div class="header-search">
|
||||
<svg-icon class-name="search-icon" icon-class="search" @click.stop="click" />
|
||||
<el-dialog
|
||||
v-model="show"
|
||||
width="600"
|
||||
@close="close"
|
||||
@opened="onDialogOpened"
|
||||
:show-close="false"
|
||||
append-to-body
|
||||
>
|
||||
<el-input
|
||||
v-model="search"
|
||||
ref="headerSearchSelectRef"
|
||||
size="large"
|
||||
@input="querySearch"
|
||||
prefix-icon="Search"
|
||||
placeholder="菜单搜索,支持标题、URL模糊查询"
|
||||
clearable
|
||||
@keyup.enter="selectActiveResult"
|
||||
@keydown.up.prevent="navigateResult('up')"
|
||||
@keydown.down.prevent="navigateResult('down')"
|
||||
>
|
||||
</el-input>
|
||||
|
||||
<div class="result-count" v-if="search && options.length > 0">
|
||||
找到 <strong>{{ options.length }}</strong> 个结果
|
||||
</div>
|
||||
|
||||
<div class="result-wrap">
|
||||
<el-scrollbar>
|
||||
|
||||
<template v-if="options.length > 0">
|
||||
<div
|
||||
class="search-item"
|
||||
tabindex="1"
|
||||
v-for="(item, index) in options"
|
||||
:key="item.path"
|
||||
:class="{ 'is-active': index === activeIndex }"
|
||||
:style="activeStyle(index)"
|
||||
@mouseenter="activeIndex = index"
|
||||
@mouseleave="activeIndex = -1"
|
||||
>
|
||||
<div class="left">
|
||||
<svg-icon class="menu-icon" :icon-class="item.icon" />
|
||||
</div>
|
||||
<div class="search-info" @click="change(item)">
|
||||
<div class="menu-title" v-html="highlightText(item.title.join(' / '))"></div>
|
||||
<div class="menu-path" v-html="highlightText(item.path)"></div>
|
||||
</div>
|
||||
<svg-icon icon-class="enter" v-show="index === activeIndex" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="empty-state" v-else-if="search && options.length === 0">
|
||||
<el-icon class="empty-icon"><Search /></el-icon>
|
||||
<p class="empty-text">未找到 "<strong>{{ search }}</strong>" 相关菜单</p>
|
||||
<p class="empty-tip">试试其他关键词或路径</p>
|
||||
</div>
|
||||
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
|
||||
<div class="search-footer">
|
||||
<span class="shortcut-item">
|
||||
<kbd>↑</kbd><kbd>↓</kbd> 切换
|
||||
</span>
|
||||
<span class="shortcut-item">
|
||||
<kbd>↵</kbd> 选择
|
||||
</span>
|
||||
<span class="shortcut-item">
|
||||
<kbd>Esc</kbd> 关闭
|
||||
</span>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Fuse from 'fuse.js'
|
||||
import { getNormalPath } from '@/utils/ruoyi'
|
||||
import { isHttp } from '@/utils/validate'
|
||||
import useSettingsStore from '@/store/modules/settings'
|
||||
import usePermissionStore from '@/store/modules/permission'
|
||||
|
||||
const search = ref('')
|
||||
const options = ref([])
|
||||
const searchPool = ref([])
|
||||
const activeIndex = ref(-1)
|
||||
const show = ref(false)
|
||||
const fuse = ref(undefined)
|
||||
const headerSearchSelectRef = ref(null)
|
||||
const router = useRouter()
|
||||
const theme = computed(() => useSettingsStore().theme)
|
||||
const routes = computed(() => usePermissionStore().defaultRoutes)
|
||||
|
||||
function click() {
|
||||
show.value = !show.value
|
||||
if (show.value) {
|
||||
options.value = searchPool.value
|
||||
}
|
||||
}
|
||||
|
||||
function onDialogOpened() {
|
||||
nextTick(() => {
|
||||
headerSearchSelectRef.value && headerSearchSelectRef.value.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function close() {
|
||||
headerSearchSelectRef.value && headerSearchSelectRef.value.blur()
|
||||
search.value = ''
|
||||
options.value = searchPool.value
|
||||
show.value = false
|
||||
activeIndex.value = -1
|
||||
}
|
||||
|
||||
function change(val) {
|
||||
const p = val.path
|
||||
const query = val.query
|
||||
if (isHttp(p)) {
|
||||
// http(s):// 路径新窗口打开
|
||||
const pindex = p.indexOf("http")
|
||||
window.open(p.substr(pindex, p.length), "_blank")
|
||||
} else {
|
||||
if (query) {
|
||||
router.push({ path: p, query: JSON.parse(query) })
|
||||
} else {
|
||||
router.push(p)
|
||||
}
|
||||
}
|
||||
search.value = ''
|
||||
options.value = searchPool.value
|
||||
nextTick(() => {
|
||||
show.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function initFuse(list) {
|
||||
fuse.value = new Fuse(list, {
|
||||
shouldSort: true,
|
||||
threshold: 0.2,
|
||||
distance: 100,
|
||||
minMatchCharLength: 1,
|
||||
keys: [{
|
||||
name: 'title',
|
||||
weight: 0.7
|
||||
}, {
|
||||
name: 'path',
|
||||
weight: 0.3
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
function generateRoutes(routes, basePath = '', prefixTitle = []) {
|
||||
let res = []
|
||||
for (const r of routes) {
|
||||
if (r.hidden) { continue }
|
||||
const p = r.path.length > 0 && r.path[0] === '/' ? r.path : '/' + r.path
|
||||
const data = {
|
||||
path: !isHttp(r.path) ? getNormalPath(basePath + p) : r.path,
|
||||
title: [...prefixTitle],
|
||||
icon: ''
|
||||
}
|
||||
if (r.meta && r.meta.title) {
|
||||
data.title = [...data.title, r.meta.title]
|
||||
data.icon = r.meta.icon
|
||||
if (r.redirect !== "noRedirect") {
|
||||
res.push(data)
|
||||
}
|
||||
}
|
||||
if (r.query) {
|
||||
data.query = r.query
|
||||
}
|
||||
if (r.children) {
|
||||
const tempRoutes = generateRoutes(r.children, data.path, data.title)
|
||||
if (tempRoutes.length >= 1) {
|
||||
res = [...res, ...tempRoutes]
|
||||
}
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
function querySearch(query) {
|
||||
activeIndex.value = -1
|
||||
if (query !== '') {
|
||||
const q = query.toLowerCase()
|
||||
const pathMatches = searchPool.value.filter(item =>
|
||||
item.path.toLowerCase().includes(q)
|
||||
)
|
||||
const fuseMatches = fuse.value.search(query).map(item => item.item)
|
||||
const merged = [...pathMatches]
|
||||
fuseMatches.forEach(item => {
|
||||
if (!merged.find(m => m.path === item.path)) {
|
||||
merged.push(item)
|
||||
}
|
||||
})
|
||||
options.value = merged
|
||||
} else {
|
||||
options.value = searchPool.value
|
||||
}
|
||||
}
|
||||
|
||||
function activeStyle(index) {
|
||||
if (index !== activeIndex.value) return {}
|
||||
return {
|
||||
"background-color": theme.value,
|
||||
"color": "#fff"
|
||||
}
|
||||
}
|
||||
|
||||
function navigateResult(direction) {
|
||||
if (direction === "up") {
|
||||
activeIndex.value = activeIndex.value <= 0 ? options.value.length - 1 : activeIndex.value - 1
|
||||
} else if (direction === "down") {
|
||||
activeIndex.value = activeIndex.value >= options.value.length - 1 ? 0 : activeIndex.value + 1
|
||||
}
|
||||
}
|
||||
|
||||
function selectActiveResult() {
|
||||
if (options.value.length > 0 && activeIndex.value >= 0) {
|
||||
change(options.value[activeIndex.value])
|
||||
}
|
||||
}
|
||||
|
||||
function highlightText(text) {
|
||||
if (!text) return ''
|
||||
if (!search.value) return text
|
||||
const keyword = escapeRegExp(search.value)
|
||||
const reg = new RegExp(`(${keyword})`, 'gi')
|
||||
return text.replace(reg, '<span class="highlight">$1</span>')
|
||||
}
|
||||
|
||||
function escapeRegExp(str) {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
searchPool.value = generateRoutes(routes.value)
|
||||
})
|
||||
|
||||
watch(searchPool, (list) => {
|
||||
initFuse(list)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
:deep(.el-dialog__header) {
|
||||
padding: 6px !important;
|
||||
}
|
||||
|
||||
:deep(.highlight) {
|
||||
color: red;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.is-active .highlight) {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-search {
|
||||
.search-icon {
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.result-count {
|
||||
padding: 6px 16px 0;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
|
||||
strong {
|
||||
color: red;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.result-wrap {
|
||||
height: 280px;
|
||||
margin: 4px 0;
|
||||
|
||||
.search-item {
|
||||
display: flex;
|
||||
height: 48px;
|
||||
align-items: center;
|
||||
padding-right: 10px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s;
|
||||
|
||||
.left {
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
.menu-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.search-info {
|
||||
padding-left: 5px;
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
.menu-title,
|
||||
.menu-path {
|
||||
height: 20px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.menu-path {
|
||||
color: #ccc;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-item:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 42px;
|
||||
color: #e0e0e0;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
margin: 0 0 6px;
|
||||
|
||||
strong {
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
font-size: 12px;
|
||||
color: #bbb;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
padding: 10px 20px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
|
||||
.shortcut-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 5px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: #f7f7f7;
|
||||
color: #555;
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
line-height: 1;
|
||||
box-shadow: 0 1px 0 #ccc;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="icon-body">
|
||||
<el-input
|
||||
v-model="iconName"
|
||||
class="icon-search"
|
||||
clearable
|
||||
placeholder="请输入图标名称"
|
||||
@clear="filterIcons"
|
||||
@input="filterIcons"
|
||||
>
|
||||
<template #suffix><i class="el-icon-search el-input__icon" /></template>
|
||||
</el-input>
|
||||
<div class="icon-list">
|
||||
<div class="list-container">
|
||||
<div v-for="(item, index) in iconList" class="icon-item-wrapper" :key="index" @click="selectedIcon(item)">
|
||||
<div :class="['icon-item', { active: activeIcon === item }]">
|
||||
<svg-icon :icon-class="item" class-name="icon" style="height: 25px;width: 16px;"/>
|
||||
<span>{{ item }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import icons from './requireIcons'
|
||||
|
||||
const props = defineProps({
|
||||
activeIcon: {
|
||||
type: String
|
||||
}
|
||||
})
|
||||
|
||||
const iconName = ref('')
|
||||
const iconList = ref(icons)
|
||||
const emit = defineEmits(['selected'])
|
||||
|
||||
function filterIcons() {
|
||||
iconList.value = icons
|
||||
if (iconName.value) {
|
||||
iconList.value = icons.filter(item => item.indexOf(iconName.value) !== -1)
|
||||
}
|
||||
}
|
||||
|
||||
function selectedIcon(name) {
|
||||
emit('selected', name)
|
||||
document.body.click()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
iconName.value = ''
|
||||
iconList.value = icons
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
reset
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
.icon-body {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
.icon-search {
|
||||
position: relative;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.icon-list {
|
||||
height: 200px;
|
||||
overflow: auto;
|
||||
.list-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
.icon-item-wrapper {
|
||||
width: calc(100% / 3);
|
||||
height: 25px;
|
||||
line-height: 25px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
.icon-item {
|
||||
display: flex;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 5px;
|
||||
&:hover {
|
||||
background: #ececec;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
span {
|
||||
display: inline-block;
|
||||
vertical-align: -0.15em;
|
||||
fill: currentColor;
|
||||
padding-left: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
.icon-item.active {
|
||||
background: #ececec;
|
||||
border-radius: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
let icons = []
|
||||
const modules = import.meta.glob('./../../assets/icons/svg/*.svg')
|
||||
for (const path in modules) {
|
||||
const p = path.split('assets/icons/svg/')[1].split('.svg')[0]
|
||||
icons.push(p)
|
||||
}
|
||||
|
||||
export default icons
|
||||
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<el-image
|
||||
:src="`${realSrc}`"
|
||||
fit="cover"
|
||||
:style="`width:${realWidth};height:${realHeight};`"
|
||||
:preview-src-list="realSrcList"
|
||||
preview-teleported
|
||||
>
|
||||
<template #error>
|
||||
<div class="image-slot">
|
||||
<el-icon><picture-filled /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { isExternal } from "@/utils/validate"
|
||||
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
width: {
|
||||
type: [Number, String],
|
||||
default: ""
|
||||
},
|
||||
height: {
|
||||
type: [Number, String],
|
||||
default: ""
|
||||
}
|
||||
})
|
||||
|
||||
const realSrc = computed(() => {
|
||||
if (!props.src) {
|
||||
return
|
||||
}
|
||||
let real_src = props.src.split(",")[0]
|
||||
if (isExternal(real_src)) {
|
||||
return real_src
|
||||
}
|
||||
return import.meta.env.VITE_APP_BASE_API + real_src
|
||||
})
|
||||
|
||||
const realSrcList = computed(() => {
|
||||
if (!props.src) {
|
||||
return
|
||||
}
|
||||
let real_src_list = props.src.split(",")
|
||||
let srcList = []
|
||||
real_src_list.forEach(item => {
|
||||
if (isExternal(item)) {
|
||||
return srcList.push(item)
|
||||
}
|
||||
return srcList.push(import.meta.env.VITE_APP_BASE_API + item)
|
||||
})
|
||||
return srcList
|
||||
})
|
||||
|
||||
const realWidth = computed(() =>
|
||||
typeof props.width == "string" ? props.width : `${props.width}px`
|
||||
)
|
||||
|
||||
const realHeight = computed(() =>
|
||||
typeof props.height == "string" ? props.height : `${props.height}px`
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.el-image {
|
||||
border-radius: 5px;
|
||||
background-color: #ebeef5;
|
||||
box-shadow: 0 0 5px 1px #ccc;
|
||||
:deep(.el-image__inner) {
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
:deep(.image-slot) {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: #909399;
|
||||
font-size: 30px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<div class="component-upload-image">
|
||||
<el-upload
|
||||
multiple
|
||||
:disabled="disabled"
|
||||
:action="uploadImgUrl"
|
||||
list-type="picture-card"
|
||||
:on-success="handleUploadSuccess"
|
||||
:before-upload="handleBeforeUpload"
|
||||
:data="data"
|
||||
:limit="limit"
|
||||
:on-error="handleUploadError"
|
||||
:on-exceed="handleExceed"
|
||||
ref="imageUpload"
|
||||
:before-remove="handleDelete"
|
||||
:show-file-list="true"
|
||||
:headers="headers"
|
||||
:file-list="fileList"
|
||||
:on-preview="handlePictureCardPreview"
|
||||
:class="{ hide: fileList.length >= limit }"
|
||||
>
|
||||
<el-icon class="avatar-uploader-icon"><plus /></el-icon>
|
||||
</el-upload>
|
||||
<!-- 上传提示 -->
|
||||
<div class="el-upload__tip" v-if="showTip && !disabled">
|
||||
请上传
|
||||
<template v-if="fileSize">
|
||||
大小不超过 <b style="color: #f56c6c">{{ fileSize }}MB</b>
|
||||
</template>
|
||||
<template v-if="fileType">
|
||||
格式为 <b style="color: #f56c6c">{{ fileType.join("/") }}</b>
|
||||
</template>
|
||||
的文件
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
title="预览"
|
||||
width="800px"
|
||||
append-to-body
|
||||
>
|
||||
<img
|
||||
:src="dialogImageUrl"
|
||||
style="display: block; max-width: 100%; margin: 0 auto"
|
||||
/>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { getToken } from "@/utils/auth"
|
||||
import { isExternal } from "@/utils/validate"
|
||||
import Sortable from 'sortablejs'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: [String, Object, Array],
|
||||
// 上传接口地址
|
||||
action: {
|
||||
type: String,
|
||||
default: "/common/upload"
|
||||
},
|
||||
// 上传携带的参数
|
||||
data: {
|
||||
type: Object
|
||||
},
|
||||
// 图片数量限制
|
||||
limit: {
|
||||
type: Number,
|
||||
default: 5
|
||||
},
|
||||
// 大小限制(MB)
|
||||
fileSize: {
|
||||
type: Number,
|
||||
default: 5
|
||||
},
|
||||
// 文件类型, 例如['png', 'jpg', 'jpeg']
|
||||
fileType: {
|
||||
type: Array,
|
||||
default: () => ["png", "jpg", "jpeg"]
|
||||
},
|
||||
// 是否显示提示
|
||||
isShowTip: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 禁用组件(仅查看图片)
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 拖动排序
|
||||
drag: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
const emit = defineEmits()
|
||||
const number = ref(0)
|
||||
const uploadList = ref([])
|
||||
const dialogImageUrl = ref("")
|
||||
const dialogVisible = ref(false)
|
||||
const baseUrl = import.meta.env.VITE_APP_BASE_API
|
||||
const uploadImgUrl = ref(import.meta.env.VITE_APP_BASE_API + props.action) // 上传的图片服务器地址
|
||||
const headers = ref({ Authorization: "Bearer " + getToken() })
|
||||
const fileList = ref([])
|
||||
const showTip = computed(
|
||||
() => props.isShowTip && (props.fileType || props.fileSize)
|
||||
)
|
||||
|
||||
watch(() => props.modelValue, val => {
|
||||
if (val) {
|
||||
// 首先将值转为数组
|
||||
const list = Array.isArray(val) ? val : props.modelValue.split(",")
|
||||
// 然后将数组转为对象数组
|
||||
fileList.value = list.map(item => {
|
||||
if (typeof item === "string") {
|
||||
if (item.indexOf(baseUrl) === -1 && !isExternal(item)) {
|
||||
item = { name: baseUrl + item, url: baseUrl + item }
|
||||
} else {
|
||||
item = { name: item, url: item }
|
||||
}
|
||||
}
|
||||
return item
|
||||
})
|
||||
} else {
|
||||
fileList.value = []
|
||||
return []
|
||||
}
|
||||
},{ deep: true, immediate: true })
|
||||
|
||||
// 上传前loading加载
|
||||
function handleBeforeUpload(file) {
|
||||
let isImg = false
|
||||
if (props.fileType.length) {
|
||||
let fileExtension = ""
|
||||
if (file.name.lastIndexOf(".") > -1) {
|
||||
fileExtension = file.name.slice(file.name.lastIndexOf(".") + 1)
|
||||
}
|
||||
isImg = props.fileType.some(type => {
|
||||
if (file.type.indexOf(type) > -1) return true
|
||||
if (fileExtension && fileExtension.indexOf(type) > -1) return true
|
||||
return false
|
||||
})
|
||||
} else {
|
||||
isImg = file.type.indexOf("image") > -1
|
||||
}
|
||||
if (!isImg) {
|
||||
proxy.$modal.msgError(`文件格式不正确,请上传${props.fileType.join("/")}图片格式文件!`)
|
||||
return false
|
||||
}
|
||||
if (file.name.includes(',')) {
|
||||
proxy.$modal.msgError('文件名不正确,不能包含英文逗号!')
|
||||
return false
|
||||
}
|
||||
if (props.fileSize) {
|
||||
const isLt = file.size / 1024 / 1024 < props.fileSize
|
||||
if (!isLt) {
|
||||
proxy.$modal.msgError(`上传头像图片大小不能超过 ${props.fileSize} MB!`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
proxy.$modal.loading("正在上传图片,请稍候...")
|
||||
number.value++
|
||||
}
|
||||
|
||||
// 文件个数超出
|
||||
function handleExceed() {
|
||||
proxy.$modal.msgError(`上传文件数量不能超过 ${props.limit} 个!`)
|
||||
}
|
||||
|
||||
// 上传成功回调
|
||||
function handleUploadSuccess(res, file) {
|
||||
if (res.code === 200) {
|
||||
uploadList.value.push({ name: res.fileName, url: res.fileName })
|
||||
uploadedSuccessfully()
|
||||
} else {
|
||||
number.value--
|
||||
proxy.$modal.closeLoading()
|
||||
proxy.$modal.msgError(res.msg)
|
||||
proxy.$refs.imageUpload.handleRemove(file)
|
||||
uploadedSuccessfully()
|
||||
}
|
||||
}
|
||||
|
||||
// 删除图片
|
||||
function handleDelete(file) {
|
||||
const findex = fileList.value.map(f => f.name).indexOf(file.name)
|
||||
if (findex > -1 && uploadList.value.length === number.value) {
|
||||
fileList.value.splice(findex, 1)
|
||||
emit("update:modelValue", listToString(fileList.value))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 上传结束处理
|
||||
function uploadedSuccessfully() {
|
||||
if (number.value > 0 && uploadList.value.length === number.value) {
|
||||
fileList.value = fileList.value.filter(f => f.url !== undefined).concat(uploadList.value)
|
||||
uploadList.value = []
|
||||
number.value = 0
|
||||
emit("update:modelValue", listToString(fileList.value))
|
||||
proxy.$modal.closeLoading()
|
||||
}
|
||||
}
|
||||
|
||||
// 上传失败
|
||||
function handleUploadError() {
|
||||
proxy.$modal.msgError("上传图片失败")
|
||||
proxy.$modal.closeLoading()
|
||||
}
|
||||
|
||||
// 预览
|
||||
function handlePictureCardPreview(file) {
|
||||
dialogImageUrl.value = file.url
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 对象转成指定字符串分隔
|
||||
function listToString(list, separator) {
|
||||
let strs = ""
|
||||
separator = separator || ","
|
||||
for (let i in list) {
|
||||
if (undefined !== list[i].url && list[i].url.indexOf("blob:") !== 0) {
|
||||
strs += list[i].url.replace(baseUrl, "") + separator
|
||||
}
|
||||
}
|
||||
return strs != "" ? strs.substr(0, strs.length - 1) : ""
|
||||
}
|
||||
|
||||
// 初始化拖拽排序
|
||||
onMounted(() => {
|
||||
if (props.drag && !props.disabled) {
|
||||
nextTick(() => {
|
||||
const element = proxy.$refs.imageUpload?.$el?.querySelector('.el-upload-list')
|
||||
Sortable.create(element, {
|
||||
onEnd: (evt) => {
|
||||
const movedItem = fileList.value.splice(evt.oldIndex, 1)[0]
|
||||
fileList.value.splice(evt.newIndex, 0, movedItem)
|
||||
emit('update:modelValue', listToString(fileList.value))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// .el-upload--picture-card 控制加号部分
|
||||
:deep(.hide .el-upload--picture-card) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.el-upload.el-upload--picture-card.is-disabled) {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div :class="{ 'hidden': hidden }" class="pagination-container">
|
||||
<el-pagination
|
||||
:background="background"
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:layout="layout"
|
||||
:page-sizes="pageSizes"
|
||||
:pager-count="pagerCount"
|
||||
:total="total"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { scrollTo } from '@/utils/scroll-to'
|
||||
|
||||
const props = defineProps({
|
||||
total: {
|
||||
required: true,
|
||||
type: Number
|
||||
},
|
||||
page: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
limit: {
|
||||
type: Number,
|
||||
default: 20
|
||||
},
|
||||
pageSizes: {
|
||||
type: Array,
|
||||
default() {
|
||||
return [10, 20, 30, 50]
|
||||
}
|
||||
},
|
||||
// 移动端页码按钮的数量端默认值5
|
||||
pagerCount: {
|
||||
type: Number,
|
||||
default: document.body.clientWidth < 992 ? 5 : 7
|
||||
},
|
||||
layout: {
|
||||
type: String,
|
||||
default: 'total, sizes, prev, pager, next, jumper'
|
||||
},
|
||||
background: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
autoScroll: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
hidden: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits()
|
||||
const currentPage = computed({
|
||||
get() {
|
||||
return props.page
|
||||
},
|
||||
set(val) {
|
||||
emit('update:page', val)
|
||||
}
|
||||
})
|
||||
const pageSize = computed({
|
||||
get() {
|
||||
return props.limit
|
||||
},
|
||||
set(val){
|
||||
emit('update:limit', val)
|
||||
}
|
||||
})
|
||||
|
||||
function handleSizeChange(val) {
|
||||
if (currentPage.value * val > props.total) {
|
||||
currentPage.value = 1
|
||||
}
|
||||
emit('pagination', { page: currentPage.value, limit: val })
|
||||
if (props.autoScroll) {
|
||||
scrollTo(0, 800)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCurrentChange(val) {
|
||||
emit('pagination', { page: val, limit: pageSize.value })
|
||||
if (props.autoScroll) {
|
||||
scrollTo(0, 800)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pagination-container {
|
||||
background: #fff;
|
||||
}
|
||||
.pagination-container.hidden {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template >
|
||||
<router-view />
|
||||
</template>
|
||||
@@ -0,0 +1,249 @@
|
||||
<template>
|
||||
<div class="top-right-btn" :style="style">
|
||||
<el-row>
|
||||
<el-tooltip class="item" effect="dark" :content="showSearch ? '隐藏搜索' : '显示搜索'" placement="top" v-if="search">
|
||||
<el-button circle icon="Search" @click="toggleSearch()" />
|
||||
</el-tooltip>
|
||||
<el-tooltip class="item" effect="dark" content="刷新" placement="top">
|
||||
<el-button circle icon="Refresh" @click="refresh()" />
|
||||
</el-tooltip>
|
||||
<el-tooltip class="item" effect="dark" content="显隐列" placement="top" v-if="Object.keys(columns).length > 0">
|
||||
<el-button circle icon="Menu" @click="showColumn()" v-if="showColumnsType == 'transfer'"/>
|
||||
<el-dropdown trigger="click" :hide-on-click="false" style="padding-left: 12px" v-if="showColumnsType == 'checkbox'">
|
||||
<el-button circle icon="Menu" />
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<!-- 全选/反选 按钮 -->
|
||||
<el-dropdown-item>
|
||||
<el-checkbox :indeterminate="isIndeterminate" v-model="isChecked" @change="toggleCheckAll"> 列展示 </el-checkbox>
|
||||
</el-dropdown-item>
|
||||
<div class="check-line"></div>
|
||||
<template v-for="(item, key) in columns" :key="item.key">
|
||||
<el-dropdown-item>
|
||||
<el-checkbox v-model="item.visible" @change="checkboxChange($event, key)" :label="item.label" />
|
||||
</el-dropdown-item>
|
||||
</template>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</el-tooltip>
|
||||
</el-row>
|
||||
<el-dialog :title="title" v-model="open" append-to-body>
|
||||
<el-transfer
|
||||
:titles="['显示', '隐藏']"
|
||||
v-model="value"
|
||||
:data="transferData"
|
||||
@change="dataChange"
|
||||
></el-transfer>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import cache from '@/plugins/cache'
|
||||
|
||||
const props = defineProps({
|
||||
/* 是否显示检索条件 */
|
||||
showSearch: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
/* 显隐列信息(数组格式、对象格式) */
|
||||
columns: {
|
||||
type: [Array, Object],
|
||||
default: () => ({})
|
||||
},
|
||||
/* 是否显示检索图标 */
|
||||
search: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
/* 显隐列类型(transfer穿梭框、checkbox复选框) */
|
||||
showColumnsType: {
|
||||
type: String,
|
||||
default: "checkbox"
|
||||
},
|
||||
/* 右外边距 */
|
||||
gutter: {
|
||||
type: Number,
|
||||
default: 10
|
||||
},
|
||||
/* 列显隐状态记忆的 localStorage key(传入则启用记忆,不传则不记忆) */
|
||||
storageKey: {
|
||||
type: String,
|
||||
default: ""
|
||||
}
|
||||
})
|
||||
|
||||
const emits = defineEmits(['update:showSearch', 'queryTable'])
|
||||
|
||||
// 显隐数据
|
||||
const value = ref([])
|
||||
// 弹出层标题
|
||||
const title = ref("显示/隐藏")
|
||||
// 是否显示弹出层
|
||||
const open = ref(false)
|
||||
|
||||
const style = computed(() => {
|
||||
const ret = {}
|
||||
if (props.gutter) {
|
||||
ret.marginRight = `${props.gutter / 2}px`
|
||||
}
|
||||
return ret
|
||||
})
|
||||
|
||||
// 是否全选/半选 状态
|
||||
const isChecked = computed({
|
||||
get: () => Array.isArray(props.columns) ? props.columns.every(col => col.visible) : Object.values(props.columns).every((col) => col.visible),
|
||||
set: () => {}
|
||||
})
|
||||
const isIndeterminate = computed(() => Array.isArray(props.columns) ? props.columns.some((col) => col.visible) && !isChecked.value : Object.values(props.columns).some((col) => col.visible) && !isChecked.value)
|
||||
const transferData = computed(() => Array.isArray(props.columns) ? props.columns.map((item, index) => ({ key: index, label: item.label })) : Object.keys(props.columns).map((key, index) => ({ key: index, label: props.columns[key].label })))
|
||||
|
||||
// 搜索
|
||||
const { proxy } = getCurrentInstance()
|
||||
function toggleSearch() {
|
||||
let el = proxy.$el
|
||||
let formEl = null
|
||||
while ((el = el.parentElement) && el !== document.body) {
|
||||
if ((formEl = el.querySelector('.el-form'))) break
|
||||
}
|
||||
if (!formEl) return emits('update:showSearch', !props.showSearch)
|
||||
animateSearch(formEl, props.showSearch)
|
||||
}
|
||||
function animateSearch(el, isHide) {
|
||||
const DURATION = 260
|
||||
const TRANSITION = 'max-height 0.25s ease, opacity 0.2s ease'
|
||||
const clear = () => Object.assign(el.style, { transition: '', maxHeight: '', opacity: '', overflow: '' })
|
||||
Object.assign(el.style, { overflow: 'hidden', transition: '' })
|
||||
if (isHide) {
|
||||
Object.assign(el.style, { maxHeight: el.scrollHeight + 'px', opacity: '1', transition: TRANSITION })
|
||||
requestAnimationFrame(() => Object.assign(el.style, { maxHeight: '0', opacity: '0' }))
|
||||
setTimeout(() => { emits('update:showSearch', false); clear() }, DURATION)
|
||||
} else {
|
||||
emits('update:showSearch', true)
|
||||
nextTick(() => {
|
||||
Object.assign(el.style, { maxHeight: '0', opacity: '0' })
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
Object.assign(el.style, { transition: TRANSITION, maxHeight: el.scrollHeight + 'px', opacity: '1' })
|
||||
}))
|
||||
setTimeout(clear, DURATION)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新
|
||||
function refresh() {
|
||||
emits("queryTable")
|
||||
}
|
||||
|
||||
// 右侧列表元素变化
|
||||
function dataChange(data) {
|
||||
if (Array.isArray(props.columns)) {
|
||||
for (let item in props.columns) {
|
||||
const key = props.columns[item].key
|
||||
props.columns[item].visible = !data.includes(key)
|
||||
}
|
||||
} else {
|
||||
Object.keys(props.columns).forEach((key, index) => {
|
||||
props.columns[key].visible = !data.includes(index)
|
||||
})
|
||||
}
|
||||
saveStorage()
|
||||
}
|
||||
|
||||
// 打开显隐列dialog
|
||||
function showColumn() {
|
||||
open.value = true
|
||||
}
|
||||
|
||||
// 如果传入了 storageKey,从 localStorage 恢复列显隐状态
|
||||
if (props.storageKey) {
|
||||
try {
|
||||
const saved = cache.local.getJSON(props.storageKey)
|
||||
if (saved && typeof saved === 'object') {
|
||||
if (Array.isArray(props.columns)) {
|
||||
props.columns.forEach((col, index) => {
|
||||
if (saved[index] !== undefined) col.visible = saved[index]
|
||||
})
|
||||
} else {
|
||||
Object.keys(props.columns).forEach(key => {
|
||||
if (saved[key] !== undefined) props.columns[key].visible = saved[key]
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
if (props.showColumnsType == "transfer") {
|
||||
// transfer穿梭显隐列初始默认隐藏列
|
||||
if (Array.isArray(props.columns)) {
|
||||
for (let item in props.columns) {
|
||||
if (props.columns[item].visible === false) {
|
||||
value.value.push(parseInt(item))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Object.keys(props.columns).forEach((key, index) => {
|
||||
if (props.columns[key].visible === false) {
|
||||
value.value.push(index)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 单勾选
|
||||
function checkboxChange(event, key) {
|
||||
if (Array.isArray(props.columns)) {
|
||||
props.columns.filter(item => item.key == key)[0].visible = event
|
||||
} else {
|
||||
props.columns[key].visible = event
|
||||
}
|
||||
saveStorage()
|
||||
}
|
||||
|
||||
// 切换全选/反选
|
||||
function toggleCheckAll() {
|
||||
const newValue = !isChecked.value
|
||||
if (Array.isArray(props.columns)) {
|
||||
props.columns.forEach((col) => (col.visible = newValue))
|
||||
} else {
|
||||
Object.values(props.columns).forEach((col) => (col.visible = newValue))
|
||||
}
|
||||
saveStorage()
|
||||
}
|
||||
|
||||
// 将当前列显隐状态持久化到 localStorage
|
||||
function saveStorage() {
|
||||
if (!props.storageKey) return
|
||||
try {
|
||||
let state = {}
|
||||
if (Array.isArray(props.columns)) {
|
||||
props.columns.forEach((col, index) => { state[index] = col.visible })
|
||||
} else {
|
||||
Object.keys(props.columns).forEach(key => { state[key] = props.columns[key].visible })
|
||||
}
|
||||
cache.local.setJSON(props.storageKey, state)
|
||||
} catch (e) {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
:deep(.el-transfer__button) {
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
margin-left: 0px;
|
||||
}
|
||||
:deep(.el-transfer__button:first-child) {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
:deep(.el-dropdown-menu__item) {
|
||||
line-height: 30px;
|
||||
padding: 0 17px;
|
||||
}
|
||||
.check-line {
|
||||
width: 90%;
|
||||
height: 1px;
|
||||
background-color: #ccc;
|
||||
margin: 3px auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div>
|
||||
<svg-icon icon-class="question" @click="goto" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const url = ref('http://doc.ruoyi.vip/ruoyi-vue')
|
||||
|
||||
function goto() {
|
||||
window.open(url.value)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div>
|
||||
<svg-icon icon-class="github" @click="goto" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const url = ref('https://gitee.com/y_project/RuoYi-Vue')
|
||||
|
||||
function goto() {
|
||||
window.open(url.value)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div>
|
||||
<svg-icon :icon-class="isFullscreen ? 'exit-fullscreen' : 'fullscreen'" @click="toggle" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useFullscreen } from '@vueuse/core'
|
||||
|
||||
const { isFullscreen, toggle } = useFullscreen()
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
.screenfull-svg {
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
fill: #5a5e66;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
vertical-align: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-dropdown trigger="click" @command="handleSetSize">
|
||||
<div class="size-icon--style">
|
||||
<svg-icon class-name="size-icon" icon-class="size" />
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item v-for="item of sizeOptions" :key="item.value" :disabled="size === item.value" :command="item.value">
|
||||
{{ item.label }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import useAppStore from "@/store/modules/app"
|
||||
|
||||
const appStore = useAppStore()
|
||||
const size = computed(() => appStore.size)
|
||||
const { proxy } = getCurrentInstance()
|
||||
const sizeOptions = ref([
|
||||
{ label: "较大", value: "large" },
|
||||
{ label: "默认", value: "default" },
|
||||
{ label: "稍小", value: "small" },
|
||||
])
|
||||
|
||||
function handleSetSize(size) {
|
||||
proxy.$modal.loading("正在设置布局大小,请稍候...")
|
||||
appStore.setSize(size)
|
||||
setTimeout("window.location.reload()", 1000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
.size-icon--style {
|
||||
font-size: 18px;
|
||||
line-height: 50px;
|
||||
padding-right: 7px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<svg :class="svgClass" aria-hidden="true">
|
||||
<use :xlink:href="iconName" :fill="color" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default defineComponent({
|
||||
props: {
|
||||
iconClass: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
className: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
return {
|
||||
iconName: computed(() => `#icon-${props.iconClass}`),
|
||||
svgClass: computed(() => {
|
||||
if (props.className) {
|
||||
return `svg-icon ${props.className}`
|
||||
}
|
||||
return 'svg-icon'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scope lang="scss">
|
||||
.sub-el-icon,
|
||||
.nav-icon {
|
||||
display: inline-block;
|
||||
font-size: 15px;
|
||||
margin-right: 12px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.svg-icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
position: relative;
|
||||
fill: currentColor;
|
||||
vertical-align: -2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
import * as components from '@element-plus/icons-vue'
|
||||
|
||||
export default {
|
||||
install: (app) => {
|
||||
for (const key in components) {
|
||||
const componentConfig = components[key]
|
||||
app.component(componentConfig.name, componentConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,756 @@
|
||||
<template>
|
||||
<div class="tree-sidebar" :class="{ collapsed: collapsed, resizing: isResizing, 'no-initial-transition': isLoadingFromStorage}" :style="{ width: sidebarWidth + 'px' }">
|
||||
<!-- 右侧拖动条 -->
|
||||
<div v-if="!collapsed" class="resize-handle" @mousedown="startResize" @touchstart="startResize" :class="{ active: isResizing }" />
|
||||
<div class="tree-header">
|
||||
<span class="tree-title" v-show="!collapsed">
|
||||
<el-icon><component :is="titleIcon" /></el-icon> {{ title }}
|
||||
</span>
|
||||
<div class="tree-actions" v-show="!collapsed">
|
||||
<el-tooltip :content="isExpandedAll ? '收起全部' : '展开全部'" placement="right">
|
||||
<el-icon class="tree-action-icon" @click="toggleExpandAll">
|
||||
<ArrowDown v-if="isExpandedAll" />
|
||||
<ArrowUp v-else />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="刷新" placement="right">
|
||||
<el-icon class="tree-action-icon" @click="handleRefresh"><Refresh /></el-icon>
|
||||
</el-tooltip>
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 侧边栏展开/收起按钮 -->
|
||||
<div class="collapse-button-container">
|
||||
<el-tooltip :content="collapsed ? '展开' : '收起'" placement="right">
|
||||
<el-icon class="collapse-button" @click="toggleCollapsed">
|
||||
<DArrowRight v-if="collapsed" />
|
||||
<DArrowLeft v-else />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="tree-search" v-show="!collapsed" v-if="showSearch">
|
||||
<el-input v-model="searchKeyword" :placeholder="searchPlaceholder" clearable>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<div class="tree-wrap" v-show="!collapsed">
|
||||
<el-tree
|
||||
ref="treeRef"
|
||||
:data="treeData"
|
||||
:props="treeProps"
|
||||
:expand-on-click-node="expandOnClickNode"
|
||||
:filter-node-method="filterNodeMethod"
|
||||
:default-expand-all="defaultExpandAll"
|
||||
:default-expanded-keys="defaultExpandedKeys"
|
||||
:node-key="nodeKey"
|
||||
:check-strictly="checkStrictly"
|
||||
:show-checkbox="showCheckbox"
|
||||
@node-click="onNodeClick"
|
||||
@check="onCheck"
|
||||
@node-expand="onNodeExpand"
|
||||
@node-collapse="onNodeCollapse"
|
||||
>
|
||||
<template #default="{ node, data }">
|
||||
<slot name="node" :node="node" :data="data">
|
||||
<span class="tree-node">
|
||||
<el-icon class="node-icon">
|
||||
<Folder v-if="data.children && data.children.length" />
|
||||
<Document v-else />
|
||||
</el-icon>
|
||||
<span class="node-label" :title="node.label">{{ node.label }}</span>
|
||||
</span>
|
||||
</slot>
|
||||
</template>
|
||||
</el-tree>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
// 树形数据
|
||||
treeData: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
// 标题
|
||||
title: {
|
||||
type: String,
|
||||
default: '树形结构'
|
||||
},
|
||||
// 标题图标
|
||||
titleIcon: {
|
||||
type: [String, Object],
|
||||
default: 'OfficeBuilding'
|
||||
},
|
||||
// 是否显示搜索框
|
||||
showSearch: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 搜索框占位符
|
||||
searchPlaceholder: {
|
||||
type: String,
|
||||
default: '请输入名称'
|
||||
},
|
||||
// 是否默认收起侧边栏
|
||||
defaultCollapsed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 树配置项
|
||||
treeProps: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
children: "children",
|
||||
label: "label"
|
||||
})
|
||||
},
|
||||
// 节点唯一标识字段
|
||||
nodeKey: {
|
||||
type: String,
|
||||
default: 'id'
|
||||
},
|
||||
// 是否在点击节点时展开或收起
|
||||
expandOnClickNode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 是否显示复选框
|
||||
showCheckbox: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 是否严格的遵循父子不互相关联
|
||||
checkStrictly: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 是否默认展开所有节点
|
||||
defaultExpandAll: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 默认展开的节点的key数组
|
||||
defaultExpandedKeys: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
// 默认宽度
|
||||
defaultWidth: {
|
||||
type: Number,
|
||||
default: 220
|
||||
},
|
||||
// 收起时的宽度
|
||||
collapsedWidth: {
|
||||
type: Number,
|
||||
default: 20
|
||||
},
|
||||
// 最小宽度
|
||||
minWidth: {
|
||||
type: Number,
|
||||
default: 180
|
||||
},
|
||||
// 最大宽度
|
||||
maxWidth: {
|
||||
type: Number,
|
||||
default: 400
|
||||
},
|
||||
// 本地存储的宽度key
|
||||
storageKey: {
|
||||
type: String,
|
||||
default: 'tree-sidebar-width'
|
||||
},
|
||||
// 是否启用本地存储宽度
|
||||
enableStorage: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 自定义过滤方法
|
||||
filterMethod: {
|
||||
type: Function,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'collapsed-change',
|
||||
'expanded-all-change',
|
||||
'refresh',
|
||||
'node-click',
|
||||
'check',
|
||||
'node-expand',
|
||||
'node-collapse',
|
||||
'search'
|
||||
])
|
||||
|
||||
const treeRef = ref(null)
|
||||
|
||||
// 响应式数据
|
||||
const searchKeyword = ref('')
|
||||
const collapsed = ref(props.defaultCollapsed)
|
||||
const sidebarWidth = ref(props.defaultCollapsed ? props.collapsedWidth : props.defaultWidth)
|
||||
const isResizing = ref(false)
|
||||
const startX = ref(0)
|
||||
const startWidth = ref(0)
|
||||
const saveWidthTimer = ref(null)
|
||||
const rafId = ref(null)
|
||||
const isLoadingFromStorage = ref(false)
|
||||
const expandedAll = ref(props.defaultExpandAll)
|
||||
|
||||
// 计算属性
|
||||
const isExpandedAll = computed({
|
||||
get: () => expandedAll.value,
|
||||
set: (val) => {
|
||||
expandedAll.value = val
|
||||
}
|
||||
})
|
||||
|
||||
// 节点过滤方法
|
||||
const filterNodeMethod = (value, data) => {
|
||||
if (props.filterMethod) {
|
||||
return props.filterMethod(value, data)
|
||||
}
|
||||
if (!value) return true
|
||||
return data.label && data.label.indexOf(value) !== -1
|
||||
}
|
||||
|
||||
// 监听折叠状态
|
||||
watch(collapsed, (newVal, oldVal) => {
|
||||
if (newVal !== oldVal) {
|
||||
handleCollapseChange(newVal)
|
||||
emit('collapsed-change', newVal)
|
||||
}
|
||||
})
|
||||
|
||||
// 监听内部展开状态变化,触发实际树的展开/收起
|
||||
watch(expandedAll, (newVal) => {
|
||||
nextTick(() => {
|
||||
if (newVal) {
|
||||
expandAllNodes()
|
||||
} else {
|
||||
collapseAllNodes()
|
||||
}
|
||||
})
|
||||
emit('expanded-all-change', newVal)
|
||||
})
|
||||
|
||||
// 监听搜索关键词
|
||||
watch(searchKeyword, (val) => {
|
||||
if (treeRef.value) {
|
||||
treeRef.value.filter(val)
|
||||
emit('search', val)
|
||||
}
|
||||
})
|
||||
|
||||
// 清理定时器和动画帧
|
||||
const cleanup = () => {
|
||||
if (rafId.value) {
|
||||
cancelAnimationFrame(rafId.value)
|
||||
rafId.value = null
|
||||
}
|
||||
if (saveWidthTimer.value) {
|
||||
clearTimeout(saveWidthTimer.value)
|
||||
saveWidthTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 处理收起/展开状态变化
|
||||
const handleCollapseChange = (isCollapsed) => {
|
||||
if (isCollapsed) {
|
||||
saveWidthToStorage()
|
||||
sidebarWidth.value = props.collapsedWidth
|
||||
} else {
|
||||
const savedWidth = getSavedWidth()
|
||||
sidebarWidth.value = savedWidth !== null ? savedWidth : props.defaultWidth
|
||||
}
|
||||
}
|
||||
|
||||
// 获取保存的宽度
|
||||
const getSavedWidth = () => {
|
||||
if (!props.enableStorage) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const savedWidth = localStorage.getItem(props.storageKey)
|
||||
if (savedWidth) {
|
||||
const width = parseInt(savedWidth, 10)
|
||||
if (!isNaN(width) && width >= props.minWidth && width <= props.maxWidth) {
|
||||
return width
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load sidebar width from storage with key ${props.storageKey}:`, error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 保存宽度到本地存储
|
||||
const saveWidthToStorage = () => {
|
||||
if (collapsed.value || !props.enableStorage) return
|
||||
try {
|
||||
localStorage.setItem(props.storageKey, sidebarWidth.value.toString())
|
||||
} catch (error) {
|
||||
console.warn(`Failed to save sidebar width to storage with key ${props.storageKey}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换侧边栏收起/展开状态
|
||||
const toggleCollapsed = () => {
|
||||
collapsed.value = !collapsed.value
|
||||
}
|
||||
|
||||
// 切换展开/折叠所有节点
|
||||
const toggleExpandAll = () => {
|
||||
expandedAll.value = !expandedAll.value
|
||||
}
|
||||
|
||||
// 展开所有节点
|
||||
const expandAllNodes = () => {
|
||||
if (!treeRef.value) return
|
||||
const allNodes = getAllNodes(treeRef.value.root)
|
||||
allNodes.forEach(node => {
|
||||
if (node.expanded !== undefined && !node.expanded) {
|
||||
node.expanded = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取所有节点
|
||||
const getAllNodes = (rootNode) => {
|
||||
const nodes = []
|
||||
const traverse = (node) => {
|
||||
if (!node) return
|
||||
nodes.push(node)
|
||||
if (node.childNodes && node.childNodes.length) {
|
||||
node.childNodes.forEach(child => traverse(child))
|
||||
}
|
||||
}
|
||||
traverse(rootNode)
|
||||
return nodes
|
||||
}
|
||||
|
||||
// 收起所有节点
|
||||
const collapseAllNodes = () => {
|
||||
if (!treeRef.value) return
|
||||
const allNodes = getAllNodes(treeRef.value.root)
|
||||
allNodes.forEach(node => {
|
||||
if (node.expanded !== undefined && node.expanded) {
|
||||
node.expanded = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 处理刷新操作
|
||||
const handleRefresh = () => {
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
// 节点点击事件
|
||||
const onNodeClick = (data, node, e) => {
|
||||
emit('node-click', data, node, e)
|
||||
}
|
||||
|
||||
// 复选框选中事件
|
||||
const onCheck = (data, checkedInfo) => {
|
||||
emit('check', data, checkedInfo)
|
||||
}
|
||||
|
||||
// 节点展开事件
|
||||
const onNodeExpand = (data, node, e) => {
|
||||
emit('node-expand', data, node, e)
|
||||
}
|
||||
|
||||
// 节点折叠事件
|
||||
const onNodeCollapse = (data, node, e) => {
|
||||
emit('node-collapse', data, node, e)
|
||||
}
|
||||
|
||||
const setCurrentKey = (key) => {
|
||||
if (treeRef.value) {
|
||||
treeRef.value.setCurrentKey(key)
|
||||
}
|
||||
}
|
||||
|
||||
const getCurrentNode = () => {
|
||||
if (treeRef.value) {
|
||||
return treeRef.value.getCurrentNode()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const getCurrentKey = () => {
|
||||
if (treeRef.value) {
|
||||
return treeRef.value.getCurrentKey()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const setCheckedKeys = (keys) => {
|
||||
if (treeRef.value && props.showCheckbox) {
|
||||
treeRef.value.setCheckedKeys(keys)
|
||||
}
|
||||
}
|
||||
|
||||
const getCheckedKeys = () => {
|
||||
if (treeRef.value && props.showCheckbox) {
|
||||
return treeRef.value.getCheckedKeys()
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const getCheckedNodes = () => {
|
||||
if (treeRef.value && props.showCheckbox) {
|
||||
return treeRef.value.getCheckedNodes()
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const clearSearch = () => {
|
||||
searchKeyword.value = ""
|
||||
if (treeRef.value) {
|
||||
treeRef.value.filter("")
|
||||
}
|
||||
}
|
||||
|
||||
const filter = (value) => {
|
||||
searchKeyword.value = value
|
||||
}
|
||||
|
||||
const startResize = (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
isResizing.value = true
|
||||
startX.value = e.type === 'mousedown' ? e.clientX : e.touches[0].clientX
|
||||
startWidth.value = sidebarWidth.value
|
||||
|
||||
if (e.type === 'mousedown') {
|
||||
document.addEventListener('mousemove', handleResizeMove)
|
||||
document.addEventListener('mouseup', stopResize)
|
||||
} else {
|
||||
document.addEventListener('touchmove', handleResizeMove, { passive: false })
|
||||
document.addEventListener('touchend', stopResize)
|
||||
}
|
||||
disableUserSelect()
|
||||
}
|
||||
|
||||
const handleResizeMove = (e) => {
|
||||
if (!isResizing.value) return
|
||||
if (rafId.value) {
|
||||
cancelAnimationFrame(rafId.value)
|
||||
}
|
||||
rafId.value = requestAnimationFrame(() => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const clientX = e.type === 'mousemove' ? e.clientX : e.touches[0].clientX
|
||||
const deltaX = clientX - startX.value
|
||||
const newWidth = startWidth.value + deltaX
|
||||
const clampedWidth = Math.max(props.minWidth, Math.min(props.maxWidth, newWidth))
|
||||
if (Math.abs(clampedWidth - sidebarWidth.value) >= 1) {
|
||||
sidebarWidth.value = clampedWidth
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const stopResize = () => {
|
||||
if (!isResizing.value) return
|
||||
isResizing.value = false
|
||||
if (rafId.value) {
|
||||
cancelAnimationFrame(rafId.value)
|
||||
rafId.value = null
|
||||
}
|
||||
startX.value = 0
|
||||
startWidth.value = 0
|
||||
document.removeEventListener('mousemove', handleResizeMove)
|
||||
document.removeEventListener('mouseup', stopResize)
|
||||
document.removeEventListener('touchmove', handleResizeMove)
|
||||
document.removeEventListener('touchend', stopResize)
|
||||
enableUserSelect()
|
||||
saveWidthToStorage()
|
||||
}
|
||||
|
||||
const disableUserSelect = () => {
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.webkitUserSelect = 'none'
|
||||
document.body.style.mozUserSelect = 'none'
|
||||
document.body.style.msUserSelect = 'none'
|
||||
}
|
||||
|
||||
const enableUserSelect = () => {
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.webkitUserSelect = ''
|
||||
document.body.style.mozUserSelect = ''
|
||||
document.body.style.msUserSelect = ''
|
||||
}
|
||||
|
||||
const resetWidth = () => {
|
||||
sidebarWidth.value = props.defaultWidth
|
||||
saveWidthToStorage()
|
||||
}
|
||||
|
||||
const getCurrentWidth = () => {
|
||||
return sidebarWidth.value
|
||||
}
|
||||
|
||||
const setWidth = (width) => {
|
||||
if (typeof width === 'number' && width >= props.minWidth && width <= props.maxWidth) {
|
||||
sidebarWidth.value = width
|
||||
if (!collapsed.value) {
|
||||
saveWidthToStorage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
setCurrentKey,
|
||||
getCurrentNode,
|
||||
getCurrentKey,
|
||||
setCheckedKeys,
|
||||
getCheckedKeys,
|
||||
getCheckedNodes,
|
||||
clearSearch,
|
||||
filter,
|
||||
resetWidth,
|
||||
getCurrentWidth,
|
||||
setWidth,
|
||||
expandAllNodes,
|
||||
collapseAllNodes,
|
||||
toggleCollapsed,
|
||||
treeRef
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
isLoadingFromStorage.value = true
|
||||
if (!collapsed.value && props.enableStorage) {
|
||||
const savedWidth = getSavedWidth()
|
||||
if (savedWidth !== null) {
|
||||
sidebarWidth.value = savedWidth
|
||||
}
|
||||
}
|
||||
nextTick(() => {
|
||||
isLoadingFromStorage.value = false
|
||||
})
|
||||
if (expandedAll.value) {
|
||||
nextTick(() => {
|
||||
expandAllNodes()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanup()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tree-sidebar {
|
||||
flex-shrink: 0;
|
||||
width: 220px;
|
||||
background: #fff;
|
||||
border-right: 1px solid #e8eaed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: width 0.25s ease;
|
||||
|
||||
&.collapsed {
|
||||
width: 42px;
|
||||
}
|
||||
|
||||
&.resizing {
|
||||
transition: none;
|
||||
will-change: width;
|
||||
|
||||
* {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.no-initial-transition {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: 20;
|
||||
background: transparent;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(64, 158, 255, 0.3);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba(64, 158, 255, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.collapse-button-container {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
transform: translateY(-50%);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 15px;
|
||||
height: 20px;
|
||||
background: #fff;
|
||||
border-radius: 0 4px 4px 0;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
.tree-sidebar.collapsed & {
|
||||
right: 0;
|
||||
background: #f7f8fa;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.tree-sidebar.resizing & {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.collapse-button {
|
||||
font-size: 20px;
|
||||
color: #909399;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #409eff;
|
||||
background: #ecf5ff;
|
||||
}
|
||||
}
|
||||
|
||||
.tree-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 10px;
|
||||
height: 40px;
|
||||
border-bottom: 1px solid #e8eaed;
|
||||
background: #f7f8fa;
|
||||
flex-shrink: 0;
|
||||
|
||||
.tree-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
|
||||
.el-icon {
|
||||
color: #409eff;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.tree-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tree-action-icon {
|
||||
font-size: 20px;
|
||||
color: #909399;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #409eff;
|
||||
background: #ecf5ff;
|
||||
}
|
||||
}
|
||||
|
||||
.tree-search {
|
||||
padding: 10px 10px 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-wrap {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px 6px 12px;
|
||||
|
||||
.tree-sidebar.resizing & {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #dcdfe6;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: #c0c4cc;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-tree-node__content) {
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1px;
|
||||
|
||||
&:hover {
|
||||
background: #f0f7ff;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-tree-node.is-current > .el-tree-node__content) {
|
||||
background: #e6f0fd;
|
||||
color: #409eff;
|
||||
font-weight: 600;
|
||||
|
||||
.node-icon {
|
||||
color: #409eff !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tree-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
|
||||
.node-icon {
|
||||
font-size: 14px;
|
||||
color: #f5a623;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div v-loading="loading" :style="'height:' + height">
|
||||
<iframe
|
||||
:src="url"
|
||||
frameborder="no"
|
||||
style="width: 100%; height: 100%"
|
||||
scrolling="auto" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const height = ref(document.documentElement.clientHeight - 94.5 + "px;")
|
||||
const loading = ref(true)
|
||||
const url = computed(() => props.src)
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
}, 300)
|
||||
window.onresize = function temp() {
|
||||
height.value = document.documentElement.clientHeight - 94.5 + "px;"
|
||||
}
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user