feat: 完善模块引导与学习进度体验

统一引导提示的持久化控制,补充学习进度入口与模块资源,避免用户返回页面时重复触发提示。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
zhongyidianandClaude Opus 4.7 committed 2026-05-08 09:44:59 +08:00
1 parent fc9633f371
commit 6f8252472f
114 files changed
+2758 -1798

No files matched your search

@@ -0,0 +1,159 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { getStaticImage } from '@/utils'
import type { AnchoredProgressBarProps, ProgressAnchor } from './types'
defineOptions({
name: 'AnchoredProgressBar'
})
const props = withDefaults(defineProps<AnchoredProgressBarProps>(), {
min: 0,
max: 100,
showPercent: true,
nodeImage: '',
trackClass: 'bg-black/20',
fillClass: 'bg-[#31effa] shadow-[0_0_26px_rgba(49,239,250,0.95)]',
markerClass: 'bg-white shadow-[0_0_12px_rgba(255,255,255,0.9)]',
labelClass: 'text-white font-[1000] tracking-wider',
childLabelClass: 'text-[#43f3f2] font-[1000] tracking-wider',
percentClass: 'text-white font-[1000] italic',
labelTop: '0%',
childLabelTop: '26%',
barTop: '56%',
barHeight: '26%',
markerTop: '48%',
markerHeight: '42%',
markerWidth: '0.55%',
childMarkerTop: '56%',
childMarkerHeight: '26%',
childMarkerWidth: '0.22%',
nodeTop: '39%',
nodeHeight: '58%',
percentTop: '76%',
labelStyle: () => ({}),
childLabelStyle: () => ({}),
percentStyle: () => ({})
})
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
const toPercent = (value: number) => {
const range = props.max - props.min
if (range <= 0) return 0
return clamp(((value - props.min) / range) * 100, 0, 100)
}
const progressPercent = computed(() => toPercent(props.progress))
const nodeSrc = computed(() => (props.nodeImage ? getStaticImage(props.nodeImage) : ''))
const markerAnchors = computed(() => {
const anchorsByValue = new Map<number, ProgressAnchor & { markerType: 'major' | 'minor' }>()
props.anchors.forEach(anchor => {
anchorsByValue.set(toPercent(anchor.value), { ...anchor, markerType: 'major' })
anchor.children?.forEach(child => {
const left = toPercent(child.value)
if (!anchorsByValue.has(left)) {
anchorsByValue.set(left, { ...child, markerType: 'minor' })
}
})
})
anchorsByValue.set(0, { id: 'start', label: '', value: props.min, markerType: 'major' })
anchorsByValue.set(100, { id: 'end', label: '', value: props.max, markerType: 'major' })
return [...anchorsByValue.entries()]
.sort(([leftA], [leftB]) => leftA - leftB)
.map(([left, anchor]) => ({ ...anchor, left }))
})
const anchorWithPercent = computed(() =>
props.anchors.map(anchor => ({
...anchor,
left: toPercent(anchor.value),
children: anchor.children?.map(child => ({ ...child, left: toPercent(child.value) }))
}))
)
</script>
<template>
<div class="relative h-full w-full">
<template v-for="anchor in anchorWithPercent" :key="anchor.id">
<div
v-if="anchor.label"
class="absolute whitespace-nowrap font-[1000]"
:class="[labelClass, anchor.left >= 98 ? '-translate-x-full' : '-translate-x-0']"
:style="{
...labelStyle,
top: anchor.labelPosition === 'bottom' ? percentTop : labelTop,
left: `${anchor.left}%`
}"
>
{{ anchor.label }}
</div>
<div
v-for="child in anchor.children"
:key="child.id"
class="absolute -translate-x-1/2 whitespace-nowrap font-bold leading-none"
:class="childLabelClass"
:style="{ ...childLabelStyle, top: childLabelTop, left: `${child.left}%` }"
>
{{ child.label }}
</div>
</template>
<div
class="absolute left-0 w-full"
:class="trackClass"
:style="{ top: barTop, height: barHeight }"
></div>
<div
class="absolute left-0 rounded-full transition-all duration-700 ease-out"
:class="fillClass"
:style="{ top: barTop, height: barHeight, width: `${progressPercent}%` }"
></div>
<div
v-for="anchor in markerAnchors"
:key="anchor.id"
class="absolute rounded-full"
:class="markerClass"
:style="{
top: anchor.markerType === 'major' ? markerTop : childMarkerTop,
height: anchor.markerType === 'major' ? markerHeight : childMarkerHeight,
width: anchor.markerType === 'major' ? markerWidth : childMarkerWidth,
left: `${anchor.left}%`
}"
></div>
<img
v-if="nodeSrc"
class="absolute w-auto -translate-x-1/2 transition-all duration-700 ease-out"
:src="nodeSrc"
:style="{ top: nodeTop, height: nodeHeight, left: `${progressPercent}%` }"
alt=""
/>
<div
v-else
class="absolute aspect-square -translate-x-1/2 rounded-full border-[6px] border-[#31effa] bg-white/20 transition-all duration-700 ease-out"
:style="{ top: nodeTop, height: nodeHeight, left: `${progressPercent}%` }"
></div>
<div
v-if="showPercent"
class="absolute -translate-x-1/2 whitespace-nowrap"
:class="percentClass"
:style="{ ...percentStyle, top: percentTop, left: `${progressPercent}%` }"
>
{{ Math.round(progressPercent) }}%
</div>
<div
v-if="showPercent"
class="absolute right-0"
:class="percentClass"
:style="{ ...percentStyle, top: percentTop }"
>
100%
</div>
</div>
</template>
@@ -0,0 +1,2 @@
export { default as AnchoredProgressBar } from './AnchoredProgressBar.vue'
export type * from './types'
@@ -0,0 +1,38 @@
export type ProgressAnchor = {
id: string
label: string
value: number
labelPosition?: 'top' | 'bottom'
children?: ProgressAnchor[]
}
export type AnchoredProgressBarProps = {
progress: number
anchors: ProgressAnchor[]
min?: number
max?: number
showPercent?: boolean
nodeImage?: string
trackClass?: string
fillClass?: string
markerClass?: string
labelClass?: string
childLabelClass?: string
percentClass?: string
labelTop?: string
childLabelTop?: string
barTop?: string
barHeight?: string
markerTop?: string
markerHeight?: string
markerWidth?: string
childMarkerTop?: string
childMarkerHeight?: string
childMarkerWidth?: string
nodeTop?: string
nodeHeight?: string
percentTop?: string
labelStyle?: Record<string, string>
childLabelStyle?: Record<string, string>
percentStyle?: Record<string, string>
}
+1 -1
View File
@@ -27,7 +27,7 @@
<img
v-else-if="moduleName === 'interview'"
:src="getStaticImage('midterm/中期拍摄')"
:src="getStaticImage('interview/制作变焦镜-讲述我的育人故事')"
alt="制作变焦镜-我的志愿者手记"
class="title-image"
/>
+1
View File
@@ -10,6 +10,7 @@ export * from './Guide'
// 进度条组件
export * from './ProgressBar'
export * from './AnchoredProgressBar'
// 切换组件
export * from './Switcher'