WholeProcessPlatform/frontend/src/components/SidePanelItem/index.vue
2026-05-26 19:30:22 +08:00

811 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- SidePanelItem.vue -->
<template>
<div class="qgc-side-pannel-item">
<div class="qgc_title">
<div class="title_left">
<span class="texttitle">{{ title }}</span>
<span v-if="prompt.show" class="title_icon">
<a-tooltip placement="top" :title="prompt.value" :get-popup-container="getPopupContainer">
<QuestionCircleOutlined />
</a-tooltip>
</span>
<span v-if="clickprompt.show" class="title_icon">
<a-tooltip placement="top" trigger="click" :title="clickprompt.value"
:get-popup-container="getPopupContainer">
<InfoCircleOutlined />
</a-tooltip>
</span>
<span v-if="iconmap.show" class="title_icon">
<a-tooltip placement="top" :title="iconmap.value" :get-popup-container="getPopupContainer">
<span :class="iconmap.icon"></span>
</a-tooltip>
</span>
</div>
<div class="title_right">
<div v-if="select.show">
<a-select v-model:value="selectValue" show-search placeholder="请选择" :size="'small'"
style="width: 120px" :options="select.options" :filter-option="filterOption"
@focus="handleFocus" @blur="handleBlur" @change="handleChange"></a-select>
</div>
<div v-if="shrink" class="title_shrink" @click="isExpand = !isExpand">
<img v-if="isExpand" src="@/assets/components/arrow-up.png" alt="">
<img v-else src="@/assets/components/arrow-down.png" alt="">
</div>
<div v-if="moreSelect.show">
<a-tree-select v-model:value="moreSelectValue" v-model:tree-expanded-keys="treeExpandedKeys"
show-search :size="'small'" style="width: 110px"
:dropdown-style="{ maxHeight: '400px', overflow: 'auto', minWidth: '180px' }" placeholder=" "
:tree-data="processedMoreSelectOptions"
:field-names="{ label: 'title', value: 'value', children: 'children' }"
tree-node-filter-prop="label" popup-class-name="no-wrap-tree-select" @select="handleTreeSelect"
@expand="handleTreeExpand">
</a-tree-select>
</div>
<div v-if="datetimePicker.show">
<!-- 添加 locale 属性来设置语言 -->
<a-date-picker v-model:value="datetimeValue" show-time
:style="{ width: datetimePicker.picker === 'year' ? '80px' : datetimePicker.picker === 'month' ? '90px' : '130px' }"
:format="datetimePicker.format !== null ? datetimePicker.format : undefined"
:picker="datetimePicker.picker" :allowClear="false" placeholder=" "
@change="handleDateTimeChange" :size="'small'"
:disabledDate="createDisabledDateFn(datetimePicker.picker)"
:disabledTime="disabledTimeForSinglePicker" />
<!-- 修改为 locale 变量 -->
</div>
<div v-if="scopeDate.show" class="title_scopeDate">
<a-range-picker v-model:value="scopeDateValue" :picker="scopeDate.picker" :allowClear="false"
:style="{ width: scopeDate.picker === 'year' ? '80px' : (scopeDate.picker === 'month' ? '180px' : '') }"
:format="scopeDate.format" :range-separator="' 至 '" :size="'small'"
:presets="computedScopeDatePresets" :disabledDate="createDisabledDateFn(scopeDate.picker)" />
</div>
<div v-if="tabs.show" class="typeOne">
<div @click="handleTabClick('one')" :class="tabsValue == 'one' ? 'typezhong' : ''">图片</div>
<div @click="handleTabClick('two')" :class="tabsValue == 'two' ? 'typezhong' : ''">视频</div>
</div>
</div>
</div>
<div class="body">
<slot v-if="isExpand" />
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, watch, computed, nextTick } from 'vue';
import {
QuestionCircleOutlined,
InfoCircleOutlined
} from '@ant-design/icons-vue';
import type { SelectProps } from 'ant-design-vue';
// 导入 dayjs
import dayjs, { Dayjs } from 'dayjs';
// 定义类型接口
interface PromptConfig {
show: boolean;
value: string;
icon?: string;
}
interface SelectConfig {
picker: any;
format: any;
show: boolean;
value: string | undefined;
options: SelectProps['options'];
}
// 定义组件名(便于调试和递归)
defineOptions({
name: 'SidePanelItem'
});
// 定义props
const props = defineProps({
title: { // 标题
type: String,
default: ''
},
shrink: { // 是否显示收缩
type: Boolean,
default: false
},
prompt: { // 浮动提示
type: Object as () => PromptConfig,
default: () => ({
show: false,
value: '',
})
},
clickprompt: { // 点击提示
type: Object as () => PromptConfig,
default: () => ({
show: false,
value: '',
})
},
iconmap: {//自定义图标浮动
type: Object as () => PromptConfig,
default: () => ({
show: false,
value: '',
icon: 'iconfont icon-time',
})
},
select: { // 选择框
type: Object as () => SelectConfig,
default: () => ({
show: false,
value: undefined,
options: []
})
},
moreSelect: {//树选择框
type: Object as () => SelectConfig,
default: () => ({
show: false,
value: undefined,
options: []
})
},
datetimePicker: { // 时间选择框
type: Object as () => SelectConfig,
default: () => ({
show: false,
value: undefined,
format: null, //YYYY-MM-DD HH
picker: 'date' //date | week | month | quarter | year
})
},
scopeDate: { // 时间选择框
type: Object as () => SelectConfig,
default: () => ({
show: false,
value: undefined,
format: null, //YYYY-MM-DD HH
picker: 'month' //date | week | month | quarter | year
})
},
tabs: {
type: Object,
default: () => ({
show: false,
value: 'one',
})
}
});
const emit = defineEmits(['tab-change', 'update-values']);
const isExpand = ref(true);
const selectValue = ref(props.select.value)
const moreSelectValue = ref(props.moreSelect.value)
const datetimeValue = ref<Dayjs | null>(props.datetimePicker.value ? dayjs(props.datetimePicker.value) : null);
const scopeDateValue = ref<[Dayjs, Dayjs] | undefined>(
props.scopeDate.value && Array.isArray(props.scopeDate.value)
? [dayjs(props.scopeDate.value[0]), dayjs(props.scopeDate.value[1])]
: undefined
);
const tabsValue = ref(props.tabs.value)
// 树形选择器展开状态管理
const treeExpandedKeys = ref<string[]>([])
const nodeMap = new Map<string, { node: any; parentKey: string | null }>()
/**
* 创建针对不同 picker 类型的日期禁用函数
* @param pickerType - 选择器类型: year | month | quarter | week | date
* @returns disabledDate 回调函数
*/
const createDisabledDateFn = (pickerType: string) => {
return (current: Dayjs) => {
if (!current) return false
const now = dayjs()
switch (pickerType) {
case 'year':
// 年份选择器:禁用 > 当前年份
return current.year() > now.year()
case 'month':
// 月份选择器:禁用 > 当前年月
return current.isAfter(now, 'month')
case 'quarter':
// 季度选择器:禁用 > 当前季度
return current.isAfter(now, 'quarter')
case 'week':
// 周选择器:禁用 > 当前周
return current.isAfter(now, 'week')
case 'date':
default:
// 日期选择器:禁用 > 当前日期
return current.isAfter(now, 'day')
}
}
}
/**
* 单日期选择器的时间禁用函数(针对 show-time
* 精确限制到当前时刻的时分秒
*/
const disabledTimeForSinglePicker = () => {
// 只在 datetimePicker 显示且为 date 类型时生效
if (!props.datetimePicker.show || props.datetimePicker.picker !== 'date') {
return undefined
}
const now = dayjs()
const selectedDate = datetimeValue.value
// 如果未选择日期,或选择的不是今天,则不限制时间
if (!selectedDate || !selectedDate.isSame(now, 'day')) {
return {}
}
// 辅助函数:生成从 start 到 end-1 的数组
const range = (start: number, end: number) => {
const result: number[] = []
for (let i = start; i < end; i++) {
result.push(i)
}
return result
}
return {
// 禁用当前小时之后的所有小时
disabledHours: () => {
return range(now.hour() + 1, 24)
},
// 禁用当前分钟之后的所有分钟(仅在当前小时)
disabledMinutes: (selectedHour: number) => {
if (selectedHour === now.hour()) {
return range(now.minute() + 1, 60)
}
return []
},
// 禁用当前秒数之后的所有秒数(仅在当前小时和分钟)
disabledSeconds: (selectedHour: number, selectedMinute: number) => {
if (selectedHour === now.hour() && selectedMinute === now.minute()) {
return range(now.second() + 1, 60)
}
return []
}
}
}
// // 定义 locale 变量
// const locale = zhCN;
// console.log(locale, "zhCN");
/**
* 递归处理树数据,为父节点添加 selectable: false
* @param data 原始树数据
* @returns 处理后的树数据
*/
const processTreeData = (data: any[]): any[] => {
if (!data || !Array.isArray(data)) return [];
return data.map(item => {
const newItem = { ...item };
// 如果有子项,标记为不可选中
if (newItem.children && newItem.children.length > 0) {
newItem.selectable = false;
// 递归处理子项
newItem.children = processTreeData(newItem.children);
} else {
// 叶子节点默认可选中(显式声明更清晰)
newItem.selectable = true;
}
return newItem;
});
};
// 计算属性:处理后的树数据
const processedMoreSelectOptions = computed(() => {
return processTreeData(props.moreSelect.options || []);
});
/**
* 构建树节点映射(建立父子关系)
* @param treeData 树形数据
* @param parentKey 父节点key
*/
const buildNodeMap = (treeData: any[], parentKey: string | null = null) => {
if (!treeData || !Array.isArray(treeData)) return
treeData.forEach(node => {
// 存储当前节点及其父节点信息
nodeMap.set(node.value, {
node,
parentKey
})
// 递归处理子节点
if (node.children && node.children.length > 0) {
buildNodeMap(node.children, node.value)
}
})
}
/**
* 获取目标节点的所有父节点keys
* @param targetValue 目标节点value
* @returns 父节点keys数组从根到直接父节点
*/
const getParentKeys = (targetValue: string): string[] => {
const parentKeys: string[] = []
let currentKey: string | null = targetValue
// 向上追溯所有父节点
while (currentKey !== null && currentKey !== undefined) {
const nodeInfo = nodeMap.get(currentKey)
// 如果节点不存在或已到达根节点,停止追溯
if (!nodeInfo || nodeInfo.parentKey === null || nodeInfo.parentKey === undefined) {
break
}
// 将父节点插入到数组开头(保持从根到叶的顺序)
parentKeys.unshift(nodeInfo.parentKey)
currentKey = nodeInfo.parentKey
}
return parentKeys
}
/**
* 计算属性:根据 picker 类型动态生成快捷日期选项
*/
const computedScopeDatePresets = computed(() => {
const now = dayjs();
const presets: Array<{ label: string; value: [Dayjs, Dayjs] }> = [];
// 根据 picker 类型生成对应的快捷选项
switch (props.scopeDate.picker) {
case 'year':
// 年份选择器:只显示今年
presets.push({
label: '今年',
value: [now.startOf('year'), now]
});
break;
case 'month':
// 月份选择器:显示今天、昨天、最近七天、最近一个月、最近三个月、今年
presets.push(
{
label: '今天',
value: [now.startOf('day'), now.endOf('day')]
},
{
label: '昨天',
value: [now.subtract(1, 'day').startOf('day'), now.subtract(1, 'day').endOf('day')]
},
{
label: '最近七天',
value: [now.subtract(6, 'day').startOf('day'), now.endOf('day')]
},
{
label: '最近一个月',
value: [now.subtract(1, 'month').startOf('day'), now.endOf('day')]
},
{
label: '最近三个月',
value: [now.subtract(3, 'month').startOf('day'), now.endOf('day')]
},
{
label: '今年',
value: [now.startOf('year'), now.endOf('day')]
}
);
break;
case 'quarter':
// 季度选择器:显示最近一季度、今年
presets.push(
{
label: '最近一季度',
value: [now.subtract(1, 'quarter').startOf('quarter'), now.endOf('quarter')]
},
{
label: '今年',
value: [now.startOf('year'), now.endOf('quarter')]
}
);
break;
case 'week':
// 周选择器:显示本周、上周、最近四周
presets.push(
{
label: '本周',
value: [now.startOf('week'), now.endOf('week')]
},
{
label: '上周',
value: [now.subtract(1, 'week').startOf('week'), now.subtract(1, 'week').endOf('week')]
},
{
label: '最近四周',
value: [now.subtract(3, 'week').startOf('week'), now.endOf('week')]
}
);
break;
case 'date':
default:
// 日期选择器:显示所有常用选项
presets.push(
{
label: '今天',
value: [now.startOf('day'), now.endOf('day')]
},
{
label: '昨天',
value: [now.subtract(1, 'day').startOf('day'), now.subtract(1, 'day').endOf('day')]
},
{
label: '最近七天',
value: [now.subtract(6, 'day').startOf('day'), now.endOf('day')]
},
{
label: '最近一个月',
value: [now.subtract(1, 'month').startOf('day'), now.endOf('day')]
},
{
label: '最近三个月',
value: [now.subtract(3, 'month').startOf('day'), now.endOf('day')]
},
{
label: '今年',
value: [now.startOf('year'), now.endOf('day')]
}
);
break;
}
return presets;
});
/**
* 处理树节点选择事件
* 由于父节点已设置 selectable: false这里只会接收到叶子节点
* @param selectedKeys 选中的键值(可能是字符串或数组)
* @param info 节点信息对象(本身就是节点,不是包含 node 的对象)
*/
const handleTreeSelect = (selectedKeys: string | string[], info: any) => {
// selectedKeys 可能是字符串(单选模式)或数组(多选模式)
let selectedValue: string;
if (typeof selectedKeys === 'string') {
// 单选模式selectedKeys 直接就是字符串
selectedValue = selectedKeys;
} else if (Array.isArray(selectedKeys)) {
// 多选模式:取第一个元素
selectedValue = selectedKeys[0];
} else {
// 兜底:尝试从 info 中获取
selectedValue = info?.value || '';
}
moreSelectValue.value = selectedValue;
};
/**
* 处理树节点展开/收起事件
* @param keys 展开的节点键值数组
*/
const handleTreeExpand = (keys: string[]) => {
// 保留事件处理函数以维持组件接口完整性
};
// 下拉选择框事件处理
const handleChange = (value: string) => {
// Handle change
};
const handleBlur = () => {
// Handle blur
};
const handleFocus = () => {
// Handle focus
};
const filterOption = (input: string, option?: { value: string }) => {
if (!option) return false;
return option.value.toLowerCase().includes(input.toLowerCase());
};
// 文字提示容器
const getPopupContainer = (trigger: HTMLElement) => {
return trigger.parentElement;
};
//时间选择框事件处理
const handleDateTimeChange = (date: any | null, dateString: string) => {
// Handle date time change
};
const handleTabClick = (value: string) => {
tabsValue.value = value;
// 向父组件传递参数
emit('tab-change', {
tabValue: value,
tabLabel: value === 'one' ? '图片' : '视频'
});
};
// 统一收集并发送所有控件的值
const emitAllValues = () => {
const payload: any = {
select: selectValue.value,
moreSelect: moreSelectValue.value,
tabs: tabsValue.value,
};
// 处理单个时间选择器
if (datetimeValue.value) {
// 使用传入的 format如果没有则根据 picker 类型设置默认格式
const format = props.datetimePicker.format || getDefaultFormat(props.datetimePicker.picker);
payload.datetime = datetimeValue.value.format(format);
} else {
payload.datetime = null;
}
// 处理时间范围选择器
if (scopeDateValue.value && Array.isArray(scopeDateValue.value)) {
// 使用传入的 format如果没有则根据 picker 类型设置默认格式
const format = props.scopeDate.format || getDefaultFormat(props.scopeDate.picker);
payload.scopeDate = scopeDateValue.value.map(d => d ? d.format(format) : null);
} else {
payload.scopeDate = [];
}
emit('update-values', payload);
};
// 根据 picker 类型获取默认格式
const getDefaultFormat = (picker: string): string => {
switch (picker) {
case 'year':
return 'YYYY';
case 'month':
return 'YYYY-MM';
case 'quarter':
return 'YYYY-[Q]Q';
case 'week':
return 'YYYY-wo';
case 'date':
default:
return 'YYYY-MM-DD';
}
};
// 监听各个变量的变化
watch([selectValue, moreSelectValue, datetimeValue, scopeDateValue, tabsValue], () => {
emitAllValues();
});
// 监听 props 变化,实现父子组件数据同步
watch(
() => props.select.value,
(newVal) => {
if (newVal !== selectValue.value) {
selectValue.value = newVal;
}
}
);
// 监听 moreSelectValue 的变化,移除了自动展开逻辑
watch(
() => props.moreSelect.value,
(newVal) => {
if (newVal !== moreSelectValue.value) {
moreSelectValue.value = newVal;
}
}
);
watch(
() => props.datetimePicker.value,
(newVal) => {
const newDayjs = newVal ? dayjs(newVal) : null;
if (!datetimeValue.value || !newDayjs || !datetimeValue.value.isSame(newDayjs)) {
datetimeValue.value = newDayjs;
}
}
);
watch(
() => props.scopeDate.value,
(newVal) => {
if (newVal && Array.isArray(newVal) && newVal.length === 2) {
const newRange: [Dayjs, Dayjs] = [dayjs(newVal[0]), dayjs(newVal[1])];
const currentRange = scopeDateValue.value;
// 检查是否需要更新(避免不必要的触发)
if (!currentRange ||
!currentRange[0].isSame(newRange[0]) ||
!currentRange[1].isSame(newRange[1])) {
scopeDateValue.value = newRange;
}
} else {
if (scopeDateValue.value) {
scopeDateValue.value = undefined;
}
}
}
);
watch(
() => props.tabs.value,
(newVal) => {
if (newVal !== tabsValue.value) {
tabsValue.value = newVal;
}
}
);
// 监听 moreSelectValue 变化(包括默认值和用户选择)
watch(() => moreSelectValue.value, (newValue) => {
console.log('moreSelectValue 变化:', newValue)
if (newValue && nodeMap.size > 0) {
// 只有当 nodeMap 已构建时才执行
const parentKeys = getParentKeys(newValue as string)
treeExpandedKeys.value = parentKeys
console.log('自动展开父节点:', parentKeys)
} else {
treeExpandedKeys.value = []
}
}, { immediate: true }) // immediate: true 确保默认值也能触发
// 监听树数据变化,重新构建映射
watch(() => processedMoreSelectOptions.value, (newData) => {
console.log('树数据变化,重新构建映射')
if (newData && newData.length > 0) {
// 清空旧映射
nodeMap.clear()
// 重新构建映射
buildNodeMap(newData)
// 数据加载完成后,如果已有默认值,重新触发一次
if (moreSelectValue.value) {
nextTick(() => {
const parentKeys = getParentKeys(moreSelectValue.value as string)
treeExpandedKeys.value = parentKeys
console.log('数据加载后自动展开父节点:', parentKeys)
})
}
}
}, { deep: true })
// 页面加载时执行的逻辑
onMounted(() => {
// 初始化时发送一次默认值
emitAllValues();
});
</script>
<style lang="scss">
.qgc-side-pannel-item {
width: 100%;
display: flex;
justify-content: space-between;
flex-direction: column;
.qgc_title {
width: 100%;
background-color: #e5edf3;
border-radius: 2px;
font-size: 16px;
color: #2f6b98;
line-height: 36px;
padding-left: 16px;
padding-right: 8px;
display: flex;
justify-content: space-between;
position: relative;
font-weight: 500;
.title_shrink {
cursor: pointer;
}
.title_left {
display: flex;
align-items: center;
.texttitle {
display: inline-block;
//width:180px;
}
.title_icon {
display: inline-block;
margin-left: 5px;
cursor: pointer;
}
}
.title_right {
display: flex;
align-items: center;
div {
margin-right: 2px;
}
}
.typeOne {
display: flex;
// width: 84px;
height: 24px;
box-sizing: border-box;
cursor: pointer;
div {
width: 38px;
height: 24px;
font-size: 14px;
line-height: 24px;
text-align: center;
background: #fff;
margin: 0px;
color: rgba(0, 0, 0, .85);
}
.typezhong {
background: #40a9ff;
color: #fff;
}
}
}
.qgc_title:before {
position: absolute;
content: "";
display: inline-block;
left: 0;
width: 2px;
background-color: #005293;
top: 2px;
height: 32px;
border-radius: 3px;
}
.body {
width: 100%;
font-size: 14px;
line-height: 22px;
padding: 16px 0 0;
margin-bottom: 16px;
text-overflow: ellipsis;
overflow: hidden;
height: calc(100% - 36px);
box-sizing: border-box;
p {
text-indent: 2em;
}
}
}
.title_scopeDate {
:deep(.ant-picker-range-separator) {
padding: 0px !important;
}
}
</style>