feat: add fu-step (#12562)

This commit is contained in:
CityFun
2026-04-22 15:26:26 +08:00
committed by GitHub
parent b783034d0a
commit 748cecaee8
13 changed files with 625 additions and 282 deletions
+1 -18
View File
@@ -1,18 +1 @@
import { defineComponent, h } from 'vue';
export default defineComponent({
name: 'FuStep',
props: {
id: {
type: String,
default: '',
},
title: {
type: String,
default: '',
},
},
setup(_props, { slots }) {
return () => h('div', slots.default?.());
},
});
export { default } from './steps/FuStep.vue';
+1 -213
View File
@@ -1,213 +1 @@
import { computed, defineComponent, h, ref, watch, type PropType, type VNode } from 'vue';
import { flattenVNodes, getVNodeComponentName } from './shared';
interface FuStepItem {
id: string;
index: number;
title: string;
vnode: VNode;
}
const buildStepChildren = (vnode: VNode) => {
const children = vnode.children as Record<string, (() => VNode[]) | undefined> | null;
return children?.default?.() || [];
};
export default defineComponent({
name: 'FuSteps',
props: {
active: {
type: Number,
default: 0,
},
direction: {
type: String,
default: 'horizontal',
},
space: {
type: Number,
default: 0,
},
isLoading: {
type: Boolean,
default: false,
},
finishButtonText: {
type: String,
default: '',
},
beforeLeave: {
type: Function as PropType<
(step: { id: string; index: number; title: string }) => boolean | Promise<boolean>
>,
default: undefined,
},
},
emits: ['change'],
setup(props, { emit, slots, expose }) {
const activeIndex = ref(props.active);
const isRunningBeforeLeave = ref(false);
const stepItems = computed<FuStepItem[]>(() =>
flattenVNodes(slots.default?.() || [])
.filter((vnode) => getVNodeComponentName(vnode) === 'FuStep')
.map((vnode, index) => {
const vnodeProps = (vnode.props || {}) as Record<string, any>;
return {
id: String(vnodeProps.id || index),
index,
title: String(vnodeProps.title || ''),
vnode,
};
}),
);
const emitChange = () => {
const currentStep = stepItems.value[activeIndex.value];
if (!currentStep) {
return;
}
emit('change', {
id: currentStep.id,
index: currentStep.index,
title: currentStep.title,
});
};
watch(
() => props.active,
(value) => {
activeIndex.value = value;
},
);
watch(
stepItems,
(items) => {
if (items.length === 0) {
activeIndex.value = 0;
return;
}
if (activeIndex.value > items.length - 1) {
activeIndex.value = items.length - 1;
}
emitChange();
},
{ immediate: true, deep: true },
);
const runBeforeLeave = async () => {
if (!props.beforeLeave) {
return true;
}
const currentStep = stepItems.value[activeIndex.value];
if (!currentStep) {
return true;
}
if (isRunningBeforeLeave.value) {
return true;
}
isRunningBeforeLeave.value = true;
try {
return (
(await props.beforeLeave({
id: currentStep.id,
index: currentStep.index,
title: currentStep.title,
})) !== false
);
} finally {
isRunningBeforeLeave.value = false;
}
};
const changeTo = async (nextIndex: number, runGuard = true) => {
if (nextIndex < 0 || nextIndex >= stepItems.value.length || nextIndex === activeIndex.value) {
return false;
}
const currentIndex = activeIndex.value;
if (runGuard) {
const canLeave = await runBeforeLeave();
if (activeIndex.value !== currentIndex) {
return true;
}
if (!canLeave) {
return false;
}
}
activeIndex.value = nextIndex;
emitChange();
return true;
};
const next = async () => {
if (isRunningBeforeLeave.value) {
const nextIndex = Math.min(activeIndex.value + 1, stepItems.value.length - 1);
if (nextIndex !== activeIndex.value) {
activeIndex.value = nextIndex;
emitChange();
}
return true;
}
return changeTo(activeIndex.value + 1, true);
};
const prev = async () => {
return changeTo(activeIndex.value - 1, false);
};
expose({
next,
prev,
active: activeIndex,
});
return () => {
const currentStep = stepItems.value[activeIndex.value];
return h('div', { class: ['fu-steps', `fu-steps--${props.direction}`] }, [
h(
'div',
{
class: 'fu-steps__nav',
style: props.direction === 'vertical' && props.space ? { gap: `${props.space}px` } : undefined,
},
stepItems.value.map((step, index) =>
h(
'button',
{
key: step.id,
class: [
'fu-steps__item',
{
'is-active': index === activeIndex.value,
'is-finished': index < activeIndex.value,
},
],
type: 'button',
disabled: props.isLoading,
onClick: () => changeTo(step.index, step.index > activeIndex.value),
},
[
h('span', { class: 'fu-steps__index' }, index + 1),
h('span', { class: 'fu-steps__title' }, step.title),
],
),
),
),
h('div', { class: 'fu-steps__content' }, currentStep ? buildStepChildren(currentStep.vnode) : []),
slots.footer
? h(
'div',
{ class: 'fu-steps__footer' },
slots.footer({
active: currentStep,
next,
prev,
}),
)
: null,
]);
};
},
});
export { default } from './steps/FuSteps';
@@ -0,0 +1,35 @@
<template>
<el-steps :active="active" v-bind="stepper">
<el-step
v-for="(step, index) in steps"
:key="index"
v-bind="step"
:class="disable?.(index) && 'fu-step--disable'"
@click="handleClick(index)"
/>
</el-steps>
</template>
<script setup lang="ts">
import { computed, type PropType } from 'vue';
import type { Step, Stepper } from './Stepper';
defineOptions({ name: 'FuHorizontalNavigation' });
const props = defineProps({
stepper: Object as PropType<Stepper>,
steps: Array as PropType<Step[]>,
disable: Function as PropType<(index: number) => boolean>,
});
const emit = defineEmits(['active']);
const active = computed(() => props.stepper?.index ?? 0);
const handleClick = (index: number) => {
if (!props.disable?.(index)) {
emit('active', index);
}
};
</script>
@@ -0,0 +1,72 @@
import { computed, defineComponent, h, provide, ref, Transition, watch } from 'vue';
import { flattenVNodes, getVNodeComponentName } from '../shared';
import FuHorizontalNavigation from './FuHorizontalNavigation.vue';
import FuStepsFooter from './FuStepsFooter';
import { Step, Stepper } from './Stepper';
export default defineComponent({
name: 'FuHorizontalSteps',
emits: ['change', 'next', 'prev', 'onCancel', 'onFinish'],
setup(_props, { attrs, slots, emit, expose }) {
const stepper = ref(new Stepper());
stepper.value.activeSet.add(0);
watch(
() => stepper.value.index,
(value) => {
emit('change', stepper.value.steps[value]);
},
);
const heightStyle = computed(() => {
const height = Number.parseInt(String(stepper.value.height ?? ''), 10);
return Number.isFinite(height) ? { height: `${height}px` } : { height: 'auto' };
});
const active = (index: number) => stepper.value.active(index);
const disable = (index: number) => !stepper.value.isActive(index);
const next = () => stepper.value.next();
const prev = () => stepper.value.prev();
const emitStepperFn = (name: 'next' | 'prev' | 'onCancel' | 'onFinish') => emit(name);
provide('stepper', stepper.value);
expose({ next, prev, active });
return () => {
const stepNodes = flattenVNodes(slots.default?.() || []).filter(
(node) => getVNodeComponentName(node) === 'FuStep',
);
const steps = stepNodes.map((node, index) => new Step({ index, ...((node.props || {}) as object) }));
Object.assign(stepper.value, attrs);
stepper.value.steps = steps;
return h('div', { class: ['fu-steps', 'fu-steps--horizontal'] }, [
h(FuHorizontalNavigation, {
stepper: stepper.value,
steps,
disable,
onActive: active,
}),
h('div', { class: 'fu-steps__wrapper' }, [
h(
'div',
{ class: 'fu-steps__container', style: heightStyle.value },
h(Transition, { name: 'carousel', mode: 'out-in' }, () =>
stepNodes.map((node, index) =>
stepper.value.index === index ? h(node, { key: index }) : null,
),
),
),
]),
h(
'div',
{ class: 'fu-steps__footer' },
slots.footer?.() || h(FuStepsFooter, { onStepperFn: emitStepperFn }),
),
]);
};
},
});
@@ -0,0 +1,42 @@
<template>
<div class="fu-step" v-loading="loading">
<slot />
</div>
</template>
<script setup lang="ts">
import { computed, inject } from 'vue';
import type { Stepper } from './Stepper';
defineOptions({ name: 'FuStep' });
defineProps({
id: {
type: String,
default: '',
},
title: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
status: {
type: String,
default: '',
},
icon: {
type: String,
default: '',
},
});
const stepper = inject<Stepper>('stepper');
const loading = computed(() => {
return stepper?.isLoading || false;
});
</script>
@@ -0,0 +1,16 @@
<template>
<el-button :disabled="disabled" v-bind="$attrs">
<slot />
</el-button>
</template>
<script setup lang="ts">
defineOptions({ name: 'FuStepButton' });
defineProps({
disabled: {
type: Boolean,
default: false,
},
});
</script>
@@ -0,0 +1,42 @@
import { defineComponent, h, ref } from 'vue';
import FuHorizontalSteps from './FuHorizontalSteps';
import FuVerticalSteps from './FuVerticalSteps';
export default defineComponent({
name: 'FuSteps',
props: {
direction: {
type: String,
default: 'horizontal',
},
},
emits: ['change'],
setup(props, { attrs, slots, emit, expose }) {
const stepsRef = ref<{
next?: () => unknown;
prev?: () => unknown;
active?: (index: number) => unknown;
} | null>(null);
const next = () => stepsRef.value?.next();
const prev = () => stepsRef.value?.prev();
const active = (index: number) => stepsRef.value?.active(index);
const handleChange = (payload: any) => {
emit('change', payload);
};
expose({
next,
prev,
active,
});
if (props.direction === 'vertical') {
return () => h(FuVerticalSteps, { ref: stepsRef, onChange: handleChange, ...attrs }, slots);
}
return () => h(FuHorizontalSteps, { ref: stepsRef, onChange: handleChange, ...attrs }, slots);
},
});
@@ -0,0 +1,76 @@
import { computed, defineComponent, h, inject, ref } from 'vue';
import FuStepButton from './FuStepButton.vue';
import type { Stepper } from './Stepper';
export default defineComponent({
name: 'FuStepsFooter',
emits: ['stepperFn'],
setup(_props, { emit }) {
const stepper = inject<Stepper>('stepper');
const disabledButton = ref(false);
const isFirst = computed(() => {
return stepper?.isFirst(stepper.index) ?? true;
});
const isLast = computed(() => {
return stepper?.isLast(stepper.index) ?? true;
});
const showCancel = computed(() => {
return stepper?.showCancel !== false;
});
const disabled = computed(() => {
return Boolean(stepper?.isLoading || disabledButton.value);
});
const clickHandle = (fnName: string) => {
if (!stepper) {
return;
}
const fn = (stepper as any)[fnName];
if (typeof fn === 'function') {
fn.call(stepper);
} else {
emit('stepperFn', fnName);
}
disabledButton.value = true;
setTimeout(() => {
disabledButton.value = false;
}, 500);
};
const renderButton = (value: string) => {
if (!stepper) {
return null;
}
return h(
FuStepButton,
{
disabled: disabled.value,
size: stepper.buttonSize,
onClick: () => clickHandle(value),
},
() => (stepper as any)[`${value}ButtonText`],
);
};
return () =>
h('div', { class: `fu-steps__footer--${stepper?.footerAlign ?? 'flex'}` }, [
h(
'div',
{
class: 'fu-steps__footer--block',
style: 'margin-right: 10px',
},
[showCancel.value ? renderButton('onCancel') : null],
),
h('div', { class: 'fu-steps__footer--block' }, [
!isFirst.value ? renderButton('prev') : null,
isLast.value ? renderButton('onFinish') : renderButton('next'),
]),
]);
},
});
@@ -0,0 +1,49 @@
<template>
<el-steps :active="active" v-bind="stepper" direction="vertical">
<el-step
v-for="(step, index) in steps"
:key="index"
v-bind="step"
:class="disable?.(index) && 'fu-step--disable'"
@click="handleClick(index)"
>
<template #description>
<span>{{ step.description }}</span>
<el-collapse-transition>
<div v-if="index === active" class="fu-steps__container" :style="heightStyle">
<slot :step="step" />
</div>
</el-collapse-transition>
</template>
</el-step>
</el-steps>
</template>
<script setup lang="ts">
import { computed, type PropType } from 'vue';
import type { Step, Stepper } from './Stepper';
defineOptions({ name: 'FuVerticalNavigation' });
const props = defineProps({
stepper: Object as PropType<Stepper>,
steps: Array as PropType<Step[]>,
disable: Function as PropType<(index: number) => boolean>,
});
const emit = defineEmits(['active']);
const active = computed(() => props.stepper?.index ?? 0);
const heightStyle = computed(() => {
const height = Number.parseInt(String(props.stepper?.height ?? ''), 10);
return Number.isFinite(height) ? { height: `${height}px` } : undefined;
});
const handleClick = (index: number) => {
if (!props.disable?.(index)) {
emit('active', index);
}
};
</script>
@@ -0,0 +1,61 @@
import { defineComponent, h, provide, ref, watch } from 'vue';
import { flattenVNodes, getVNodeComponentName } from '../shared';
import FuStepsFooter from './FuStepsFooter';
import FuVerticalNavigation from './FuVerticalNavigation.vue';
import { Step, Stepper } from './Stepper';
export default defineComponent({
name: 'FuVerticalSteps',
emits: ['change', 'next', 'prev', 'onCancel', 'onFinish'],
setup(_props, { attrs, slots, emit, expose }) {
const stepper = ref(new Stepper());
stepper.value.activeSet.add(0);
watch(
() => stepper.value.index,
(value) => {
emit('change', stepper.value.steps[value]);
},
);
const active = (index: number) => stepper.value.active(index);
const disable = (index: number) => !stepper.value.isActive(index);
const next = () => stepper.value.next();
const prev = () => stepper.value.prev();
const emitStepperFn = (name: 'next' | 'prev' | 'onCancel' | 'onFinish') => emit(name);
provide('stepper', stepper.value);
expose({ next, prev, active });
return () => {
const stepNodes = flattenVNodes(slots.default?.() || []).filter(
(node) => getVNodeComponentName(node) === 'FuStep',
);
const steps = stepNodes.map((node, index) => new Step({ index, ...((node.props || {}) as object) }));
const currentNode = stepNodes.find((_node, index) => stepper.value.isCurrent(index));
Object.assign(stepper.value, attrs);
stepper.value.steps = steps;
return h('div', { class: ['fu-steps', 'fu-steps--vertical'] }, [
h(
FuVerticalNavigation,
{
stepper: stepper.value,
steps,
disable,
onActive: active,
},
() => currentNode,
),
h(
'div',
{ class: 'fu-steps__footer' },
slots.footer?.() || h(FuStepsFooter, { onStepperFn: emitStepperFn }),
),
]);
};
},
});
+124
View File
@@ -0,0 +1,124 @@
import i18n from '@/lang';
import type { StepOptions, StepperOptions } from './types';
export class Step implements StepOptions {
id?: string;
index: number;
beforeActive?: Function;
beforeLeave?: Function;
title?: string;
description?: string;
icon?: string;
status?: string;
constructor(options?: StepOptions) {
const stepOptions = options ?? ({ index: 0 } as StepOptions);
this.id = stepOptions.id;
this.index = stepOptions.index;
this.beforeActive = stepOptions.beforeActive;
this.beforeLeave = stepOptions.beforeLeave;
this.title = stepOptions.title;
this.description = stepOptions.description;
this.icon = stepOptions.icon;
this.status = stepOptions.status;
}
}
export class Stepper implements StepperOptions {
steps: Step[];
index: number;
activeSet: Set<number>;
isLoading: boolean;
onCancelButtonText: string;
onFinishButtonText: string;
prevButtonText: string;
nextButtonText: string;
buttonSize: string;
footerAlign: string;
showCancel: boolean;
beforeActive?: Function;
beforeLeave?: Function;
height?: string | number;
constructor(options?: Partial<StepperOptions>) {
this.steps = options?.steps?.map((step) => new Step(step)) || [];
this.index = options?.index ?? 0;
this.activeSet = new Set<number>();
this.isLoading = options?.isLoading ?? false;
this.onCancelButtonText = options?.onCancelButtonText ?? i18n.global.t('fu.steps.cancel');
this.onFinishButtonText = options?.onFinishButtonText ?? i18n.global.t('fu.steps.finish');
this.prevButtonText = options?.prevButtonText ?? i18n.global.t('fu.steps.prev');
this.nextButtonText = options?.nextButtonText ?? i18n.global.t('fu.steps.next');
this.buttonSize = options?.buttonSize ?? 'default';
this.footerAlign = options?.footerAlign ?? 'flex';
this.showCancel = options?.showCancel ?? false;
this.beforeActive = options?.beforeActive;
this.beforeLeave = options?.beforeLeave;
this.height = options?.height;
}
isFirst(index: number) {
return index === 0;
}
isLast(index: number) {
return index === this.steps.length - 1;
}
isActive(index: number) {
return this.activeSet.has(index);
}
isCurrent(index: number) {
return this.index === index;
}
async active(index: number) {
const isValid = index >= 0 && index < this.steps.length && this.index !== index;
const forward = index > this.index;
if (!isValid) {
return;
}
if ((await this.executeBeforeLeave(this.index, forward)) === false) {
return;
}
if ((await this.executeBeforeActive(index, forward)) === false) {
return;
}
this.index = index;
this.activeSet.add(index);
}
next() {
if (!this.isLast(this.index)) {
return this.active(this.index + 1);
}
}
prev() {
if (!this.isFirst(this.index)) {
return this.active(this.index - 1);
}
}
getStep(index: number) {
return this.steps[index];
}
executeBeforeLeave(index: number, forward: boolean) {
const step = this.getStep(index);
if (step?.beforeLeave) {
return step.beforeLeave(step, forward);
}
return this.beforeLeave?.(step, forward);
}
executeBeforeActive(index: number, forward: boolean) {
const step = this.getStep(index);
if (step?.beforeActive) {
return step.beforeActive(step, forward);
}
return this.beforeActive?.(step, forward);
}
}
+27
View File
@@ -0,0 +1,27 @@
export interface StepOptions {
id?: string;
index: number;
beforeActive?: Function;
beforeLeave?: Function;
title?: string;
description?: string;
icon?: string;
status?: string;
}
export interface StepperOptions {
steps: StepOptions[];
index: number;
activeSet: Set<number>;
isLoading?: boolean;
onCancelButtonText: string;
onFinishButtonText: string;
prevButtonText: string;
nextButtonText: string;
buttonSize: string;
footerAlign: string;
showCancel: boolean;
beforeActive?: Function;
beforeLeave?: Function;
height?: string | number;
}
+79 -51
View File
@@ -107,71 +107,99 @@
}
.fu-steps {
display: flex;
gap: 24px;
.fu-steps__footer {
margin-top: 15px;
}
.fu-steps__footer--block {
display: inline-block;
}
.fu-steps__footer--flex {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.fu-steps__footer--left {
text-align: left;
}
.fu-steps__footer--right {
text-align: right;
}
.fu-steps__footer--center {
text-align: center;
}
.fu-steps__container {
overflow-x: hidden;
overflow-y: auto;
}
}
.fu-steps--horizontal {
.fu-steps__wrapper {
overflow: hidden;
transition: none;
}
.fu-steps__container {
display: flex;
align-items: flex-start;
flex-wrap: nowrap;
}
.fu-step {
width: 100%;
flex: 1 0 100%;
margin-top: 15px;
}
}
.fu-steps--vertical {
align-items: flex-start;
.el-step__description {
padding-right: 0;
}
.fu-step {
font-size: 14px;
}
}
.fu-steps__nav {
display: flex;
.fu-step--disable {
cursor: not-allowed !important;
.is-wait {
color: #c0c4cc !important;
border-color: #c0c4cc !important;
}
}
.fu-steps--vertical .fu-steps__nav {
flex-direction: column;
min-width: 180px;
}
.fu-steps__item {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
border: 0;
background: transparent;
padding: 0;
color: var(--el-text-color-regular);
.el-step:hover {
cursor: pointer;
text-align: left;
.is-wait {
color: var(--el-color-primary);
border-color: var(--el-color-primary);
}
}
.fu-steps__item.is-active {
color: var(--el-color-primary);
font-weight: 600;
.carousel-enter-active {
transition: all 0.3s ease;
}
.fu-steps__item.is-finished .fu-steps__index {
background: var(--el-color-success);
color: var(--el-color-white);
.carousel-leave-active {
transition: all 0.5s cubic-bezier(1, 0.5, 0.8, 1);
}
.fu-steps__item:disabled {
cursor: not-allowed;
.carousel-enter-from {
transform: translateX(100%);
opacity: 0;
}
.fu-steps__index {
width: 28px;
height: 28px;
border-radius: 999px;
border: 1px solid var(--el-border-color);
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.fu-steps__title {
line-height: 1.4;
}
.fu-steps__content {
flex: 1;
min-width: 0;
}
.fu-steps__footer {
width: 100%;
margin-top: 16px;
.carousel-leave-to {
transform: translateX(-100%);
opacity: 0;
}