From 748cecaee85ee2ff7a71d0d595a50a3ebe6f55e4 Mon Sep 17 00:00:00 2001 From: CityFun <31820853+zhengkunwang223@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:26:26 +0800 Subject: [PATCH] feat: add fu-step (#12562) --- frontend/src/components/fu/FuStep.ts | 19 +- frontend/src/components/fu/FuSteps.ts | 214 +----------------- .../fu/steps/FuHorizontalNavigation.vue | 35 +++ .../components/fu/steps/FuHorizontalSteps.ts | 72 ++++++ frontend/src/components/fu/steps/FuStep.vue | 42 ++++ .../src/components/fu/steps/FuStepButton.vue | 16 ++ frontend/src/components/fu/steps/FuSteps.ts | 42 ++++ .../src/components/fu/steps/FuStepsFooter.ts | 76 +++++++ .../fu/steps/FuVerticalNavigation.vue | 49 ++++ .../components/fu/steps/FuVerticalSteps.ts | 61 +++++ frontend/src/components/fu/steps/Stepper.ts | 124 ++++++++++ frontend/src/components/fu/steps/types.ts | 27 +++ frontend/src/styles/fu.scss | 130 ++++++----- 13 files changed, 625 insertions(+), 282 deletions(-) create mode 100644 frontend/src/components/fu/steps/FuHorizontalNavigation.vue create mode 100644 frontend/src/components/fu/steps/FuHorizontalSteps.ts create mode 100644 frontend/src/components/fu/steps/FuStep.vue create mode 100644 frontend/src/components/fu/steps/FuStepButton.vue create mode 100644 frontend/src/components/fu/steps/FuSteps.ts create mode 100644 frontend/src/components/fu/steps/FuStepsFooter.ts create mode 100644 frontend/src/components/fu/steps/FuVerticalNavigation.vue create mode 100644 frontend/src/components/fu/steps/FuVerticalSteps.ts create mode 100644 frontend/src/components/fu/steps/Stepper.ts create mode 100644 frontend/src/components/fu/steps/types.ts diff --git a/frontend/src/components/fu/FuStep.ts b/frontend/src/components/fu/FuStep.ts index a62ce7633..1d0349e84 100644 --- a/frontend/src/components/fu/FuStep.ts +++ b/frontend/src/components/fu/FuStep.ts @@ -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'; diff --git a/frontend/src/components/fu/FuSteps.ts b/frontend/src/components/fu/FuSteps.ts index 34bf60e97..099b161ff 100644 --- a/frontend/src/components/fu/FuSteps.ts +++ b/frontend/src/components/fu/FuSteps.ts @@ -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 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 - >, - default: undefined, - }, - }, - emits: ['change'], - setup(props, { emit, slots, expose }) { - const activeIndex = ref(props.active); - const isRunningBeforeLeave = ref(false); - - const stepItems = computed(() => - flattenVNodes(slots.default?.() || []) - .filter((vnode) => getVNodeComponentName(vnode) === 'FuStep') - .map((vnode, index) => { - const vnodeProps = (vnode.props || {}) as Record; - 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'; diff --git a/frontend/src/components/fu/steps/FuHorizontalNavigation.vue b/frontend/src/components/fu/steps/FuHorizontalNavigation.vue new file mode 100644 index 000000000..090d032c1 --- /dev/null +++ b/frontend/src/components/fu/steps/FuHorizontalNavigation.vue @@ -0,0 +1,35 @@ + + + diff --git a/frontend/src/components/fu/steps/FuHorizontalSteps.ts b/frontend/src/components/fu/steps/FuHorizontalSteps.ts new file mode 100644 index 000000000..f6a62363d --- /dev/null +++ b/frontend/src/components/fu/steps/FuHorizontalSteps.ts @@ -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 }), + ), + ]); + }; + }, +}); diff --git a/frontend/src/components/fu/steps/FuStep.vue b/frontend/src/components/fu/steps/FuStep.vue new file mode 100644 index 000000000..c62808d62 --- /dev/null +++ b/frontend/src/components/fu/steps/FuStep.vue @@ -0,0 +1,42 @@ + + + diff --git a/frontend/src/components/fu/steps/FuStepButton.vue b/frontend/src/components/fu/steps/FuStepButton.vue new file mode 100644 index 000000000..43b12db85 --- /dev/null +++ b/frontend/src/components/fu/steps/FuStepButton.vue @@ -0,0 +1,16 @@ + + + diff --git a/frontend/src/components/fu/steps/FuSteps.ts b/frontend/src/components/fu/steps/FuSteps.ts new file mode 100644 index 000000000..8ab7856d4 --- /dev/null +++ b/frontend/src/components/fu/steps/FuSteps.ts @@ -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); + }, +}); diff --git a/frontend/src/components/fu/steps/FuStepsFooter.ts b/frontend/src/components/fu/steps/FuStepsFooter.ts new file mode 100644 index 000000000..b713aebf6 --- /dev/null +++ b/frontend/src/components/fu/steps/FuStepsFooter.ts @@ -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'); + 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'), + ]), + ]); + }, +}); diff --git a/frontend/src/components/fu/steps/FuVerticalNavigation.vue b/frontend/src/components/fu/steps/FuVerticalNavigation.vue new file mode 100644 index 000000000..33aecdbb8 --- /dev/null +++ b/frontend/src/components/fu/steps/FuVerticalNavigation.vue @@ -0,0 +1,49 @@ + + + diff --git a/frontend/src/components/fu/steps/FuVerticalSteps.ts b/frontend/src/components/fu/steps/FuVerticalSteps.ts new file mode 100644 index 000000000..09bfdc8d9 --- /dev/null +++ b/frontend/src/components/fu/steps/FuVerticalSteps.ts @@ -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 }), + ), + ]); + }; + }, +}); diff --git a/frontend/src/components/fu/steps/Stepper.ts b/frontend/src/components/fu/steps/Stepper.ts new file mode 100644 index 000000000..c267d8176 --- /dev/null +++ b/frontend/src/components/fu/steps/Stepper.ts @@ -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; + 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) { + this.steps = options?.steps?.map((step) => new Step(step)) || []; + this.index = options?.index ?? 0; + this.activeSet = new Set(); + 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); + } +} diff --git a/frontend/src/components/fu/steps/types.ts b/frontend/src/components/fu/steps/types.ts new file mode 100644 index 000000000..e74cc9dd9 --- /dev/null +++ b/frontend/src/components/fu/steps/types.ts @@ -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; + isLoading?: boolean; + onCancelButtonText: string; + onFinishButtonText: string; + prevButtonText: string; + nextButtonText: string; + buttonSize: string; + footerAlign: string; + showCancel: boolean; + beforeActive?: Function; + beforeLeave?: Function; + height?: string | number; +} diff --git a/frontend/src/styles/fu.scss b/frontend/src/styles/fu.scss index d62bb150f..d3bc59da5 100644 --- a/frontend/src/styles/fu.scss +++ b/frontend/src/styles/fu.scss @@ -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; }