甘特图组件,有点改炸了
This commit is contained in:
84
components/Gantt/uniapp/DimensionPanel.vue
Normal file
84
components/Gantt/uniapp/DimensionPanel.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<view class="dimension-panel">
|
||||
<!-- 维度切换 -->
|
||||
<picker :range="dimensionOptions" range-key="label" @change="onSwitch">
|
||||
<view class="dimension-switch">切换维度</view>
|
||||
</picker>
|
||||
</view>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
name: 'DimensionPanel',
|
||||
props: {
|
||||
dimensionOptions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
defaultColors: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
'#409EFF', '#67C23A', '#E6A23C', '#F56C6C', '#909399',
|
||||
'#1abc9c', '#9b59b6', '#e67e22', '#e74c3c', '#34495e'
|
||||
]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onSwitch(e) {
|
||||
const dim = this.dimensionOptions[e.detail.value]
|
||||
this.$emit('switch', dim)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.dimension-panel {
|
||||
width: 100vw;
|
||||
background: #fafbfc;
|
||||
border-right: 1px solid #eee;
|
||||
padding: 12px 0 8px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.discrete-bar {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
height: 10px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bar-segment {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
}
|
||||
.dimension-legend {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 16px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.legend-color {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
margin-right: 6px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
.legend-label {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
}
|
||||
.dimension-switch {
|
||||
margin-top: 12px;
|
||||
color: #409EFF;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
146
components/Gantt/uniapp/Gantt.vue
Normal file
146
components/Gantt/uniapp/Gantt.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<view class="gantt-container">
|
||||
<!-- 顶部分组栏(维度选择) -->
|
||||
<dimension-panel :dimensionOptions="dimensionOptions" @switch="onSwitchDimension" class="gantt-dimension-panel" />
|
||||
<!-- 图例组件 -->
|
||||
<legend :groups="taskGroups" :dimensionName="getCurrentDimensionName" />
|
||||
<view class="gantt-body">
|
||||
<!-- 右侧横向滚动区 -->
|
||||
<scroll-view scroll-x class="gantt-scroll-x" @scroll="onScroll">
|
||||
<view class="gantt-right-area">
|
||||
<!-- 上方时间轴 -->
|
||||
<timeline-header :config="timelineConfig" class="gantt-timeline-header" />
|
||||
<!-- 下方任务条 -->
|
||||
<gantt-canvas :tasks="visibleTasks" :layout="taskLayout" @drag="onTaskDrag" @task-click="onTaskClick" class="gantt-canvas-area" >
|
||||
<template #task-bar="slotProps">
|
||||
<slot name="task-bar" v-bind="slotProps" />
|
||||
</template>
|
||||
</gantt-canvas>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<script>
|
||||
import DataManager from '../core/DataManager'
|
||||
import TimeCalculator from '../core/TimeCalculator'
|
||||
import Interaction from '../core/Interaction'
|
||||
import Layout from '../core/Layout'
|
||||
import TimelineHeader from './TimelineHeader.vue'
|
||||
import DimensionPanel from './DimensionPanel.vue'
|
||||
import GanttCanvas from './GanttCanvas.vue'
|
||||
import LegendCom from './Legend.vue'
|
||||
|
||||
export default {
|
||||
name: 'Gantt',
|
||||
components: { TimelineHeader, DimensionPanel, GanttCanvas, LegendCom },
|
||||
props: {
|
||||
tasks: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
config: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
dataManager: null,
|
||||
timeCalculator: null,
|
||||
interaction: null,
|
||||
layout: null,
|
||||
taskGroups: [],
|
||||
visibleTasks: [],
|
||||
taskLayout: [],
|
||||
timelineConfig: {},
|
||||
dimensionOptions: this.config.visibleDimensions || [],
|
||||
groupBy: this.config.groupBy // 新增响应式分组依据
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
getCurrentDimensionName() {
|
||||
const dim = this.dimensionOptions.find(d => d.prop === this.groupBy)
|
||||
return dim ? dim.label || dim.name || dim.prop : ''
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.dataManager = new DataManager(this.tasks, this.config.visibleDimensions)
|
||||
this.timeCalculator = new TimeCalculator(this.config)
|
||||
this.interaction = new Interaction(this.dataManager, this.timeCalculator)
|
||||
this.layout = new Layout(this.tasks, this.config)
|
||||
this.refresh()
|
||||
this.dataManager.onChange(this.refresh)
|
||||
},
|
||||
methods: {
|
||||
refresh() {
|
||||
this.taskGroups = this.dataManager.groupByDimension(this.groupBy)
|
||||
this.visibleTasks = this.taskGroups.flatMap(g => g.tasks)
|
||||
this.layout = new Layout(this.visibleTasks, this.config)
|
||||
this.taskLayout = this.layout.computeTaskLayout()
|
||||
const ticks = this.timeCalculator.getTimelineTicks().map(tick => {
|
||||
return {
|
||||
...tick,
|
||||
left: this.timeCalculator.dateToPixel(tick.date)
|
||||
}
|
||||
})
|
||||
this.timelineConfig = { ticks }
|
||||
},
|
||||
onSwitchDimension(dim) {
|
||||
this.groupBy = dim.prop
|
||||
this.refresh()
|
||||
},
|
||||
onTaskDrag({ taskId, deltaX }) {
|
||||
const task = this.tasks.find(t => t.id === taskId)
|
||||
if (!task) return
|
||||
const startPixel = this.timeCalculator.dateToPixel(task.start)
|
||||
const newPixel = startPixel + deltaX
|
||||
const newDate = this.timeCalculator.pixelToDate(newPixel)
|
||||
this.interaction.handleDragUpdate(taskId, newDate.toISOString().slice(0, 10))
|
||||
},
|
||||
onTaskClick(task) {
|
||||
this.$emit('task-click', task)
|
||||
},
|
||||
onScroll(e) {
|
||||
// 不做 scrollLeft 回写,避免抖动
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.gantt-container {
|
||||
width: 100%;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
.gantt-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 500px;
|
||||
}
|
||||
.gantt-dimension-panel {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
background: #fafbfc;
|
||||
z-index: 2;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.gantt-scroll-x {
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
background: #fff;
|
||||
}
|
||||
.gantt-right-area {
|
||||
min-width: 800px;
|
||||
}
|
||||
.gantt-timeline-header {
|
||||
height: 32px;
|
||||
background: #f7f7f7;
|
||||
border-bottom: 1px solid #eee;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
.gantt-canvas-area {
|
||||
min-height: 400px;
|
||||
}
|
||||
</style>
|
||||
79
components/Gantt/uniapp/GanttCanvas.vue
Normal file
79
components/Gantt/uniapp/GanttCanvas.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<view class="gantt-canvas">
|
||||
<view v-for="task in tasks" :key="task.id"
|
||||
class="gantt-task-bar"
|
||||
:style="{
|
||||
top: layoutMap[task.id] && layoutMap[task.id].top + 'px',
|
||||
left: layoutMap[task.id] && layoutMap[task.id].left + 'px',
|
||||
width: layoutMap[task.id] && layoutMap[task.id].width + 'px',
|
||||
height: layoutMap[task.id] && layoutMap[task.id].height + 'px',
|
||||
background: layoutMap[task.id] && layoutMap[task.id].color
|
||||
}"
|
||||
@tap.stop="onTaskClick(task)"
|
||||
>
|
||||
<slot name="task-bar" :task="task" :layout="layoutMap[task.id]">
|
||||
<!-- 默认渲染 -->
|
||||
<text class="task-name">{{ task.name }}</text>
|
||||
</slot>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
name: 'GanttCanvas',
|
||||
props: {
|
||||
tasks: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
layout: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
layoutMap: {}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
layout: {
|
||||
immediate: true,
|
||||
handler(val) {
|
||||
const map = {}
|
||||
val.forEach(item => { map[item.id] = item })
|
||||
this.layoutMap = map
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onTaskClick(task) {
|
||||
this.$emit('task-click', task)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.gantt-canvas {
|
||||
position: relative;
|
||||
min-height: 400px;
|
||||
background: #fff;
|
||||
}
|
||||
.gantt-task-bar {
|
||||
position: absolute;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 8px;
|
||||
font-size: 13px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.task-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
49
components/Gantt/uniapp/Legend.vue
Normal file
49
components/Gantt/uniapp/Legend.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<view class="legend-container">
|
||||
<view class="legend-title">{{ dimensionName ? `当前维度:${dimensionName}` : '分组' }}</view>
|
||||
<view class="legend-list">
|
||||
<view v-for="group in groups" :key="group.value" class="legend-item">
|
||||
<text>{{ group.label || group.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Legend',
|
||||
props: {
|
||||
groups: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
dimensionName: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.legend-container {
|
||||
padding: 8px 16px;
|
||||
background: #f7f7f7;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.legend-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.legend-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.legend-item {
|
||||
background: #e3e6ef;
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
36
components/Gantt/uniapp/TimelineHeader.vue
Normal file
36
components/Gantt/uniapp/TimelineHeader.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<view class="timeline-header">
|
||||
<view v-for="tick in config.ticks" :key="tick.label" class="timeline-tick" :style="{ left: tick.left + 'px' }">
|
||||
{{ tick.label }}
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
name: 'TimelineHeader',
|
||||
props: {
|
||||
config: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.timeline-header {
|
||||
position: relative;
|
||||
height: 32px;
|
||||
background: #f7f7f7;
|
||||
border-bottom: 1px solid #eee;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.timeline-tick {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 80px;
|
||||
text-align: center;
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user