JGJS2026/src/layout/tab.vue
2026-03-02 16:55:27 +08:00

788 lines
26 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.

<script setup lang="ts">
import { computed, defineAsyncComponent, markRaw, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import draggable from 'vuedraggable'
import { useTabStore } from '@/pinia/tab'
import { Button } from '@/components/ui/button'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { ChevronDown, CircleHelp, RotateCcw, X } from 'lucide-vue-next'
import localforage from 'localforage'
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle,
AlertDialogTrigger,
} from 'reka-ui'
import { decodeZwArchive, encodeZwArchive, ZW_FILE_EXTENSION } from '@/lib/zwArchive'
interface DataEntry {
key: string
value: any
}
interface DataPackage {
version: number
exportedAt: string
localStorage: DataEntry[]
sessionStorage: DataEntry[]
localforageDefault: DataEntry[]
}
interface UserGuideStep {
title: string
description: string
points: string[]
}
type XmInfoLike = {
projectName?: unknown
}
const USER_GUIDE_COMPLETED_KEY = 'jgjs-user-guide-completed-v1'
const userGuideSteps: UserGuideStep[] = [
{
title: '欢迎使用',
description: '这个引导会覆盖系统主要功能和完整使用路径,建议按顺序走一遍。',
points: [
'顶部是标签页区,可在项目、合同段、服务计算页之间快速切换。',
'页面里的表格与表单会自动保存到本地,无需手动点击保存。',
'你可以随时点击右上角“使用引导”重新打开本教程。'
]
},
{
title: '项目卡片与四个模块',
description: '默认标签“项目卡片”是入口,左侧流程线包含四个项目级功能。',
points: [
'基础信息:填写项目名称与项目规模明细。',
'合同段管理:新建、排序、搜索、导入/导出合同段。',
'咨询分类系数 / 工程专业系数:维护系数预算取值和备注。'
]
},
{
title: '基础信息填写',
description: '先在“基础信息”中补齐项目级数据,再进入后续合同段计算。',
points: [
'项目名称会用于导出文件名和页面展示。',
'项目明细表支持直接编辑、复制粘贴、撤销重做。',
'分组行自动汇总,顶部固定行显示总合计。'
]
},
{
title: '合同段管理',
description: '在“合同段管理”中完成合同段生命周期操作。',
points: [
'“添加合同段”用于新增,卡片右上角可编辑或删除。',
'支持搜索、网格/列表切换,非搜索状态可拖拽排序。',
'更多菜单可导入/导出合同段;点击卡片进入该合同段详情。'
]
},
{
title: '合同段详情',
description: '进入合同段后,左侧同样是流程线,包含规模信息和咨询服务两部分。',
points: [
'规模信息:按工程专业填写当前合同段的规模数据。',
'咨询服务:选择服务词典并生成服务费用明细。',
'合同段页面会独立缓存,不同合同段互不干扰。'
]
},
{
title: '咨询服务与计算页',
description: '咨询服务页面用于管理服务明细,并进入具体计费方法页面。',
points: [
'先点击“浏览”选择服务,再确认生成明细行。',
'明细表“编辑”可打开服务计算页,“清空”会重置该服务计算结果。',
'服务计算页包含投资规模法、用地规模法、工作量法、工时法四种方法。'
]
},
{
title: '系数维护',
description: '项目级系数用于调节预算取值,可在两个系数页分别维护。',
points: [
'咨询分类系数页:按咨询分类维护预算取值与说明。',
'工程专业系数页:按专业树维护预算取值与说明。',
'支持批量粘贴、撤销重做,便于一次性维护多行数据。'
]
},
{
title: '数据管理与恢复',
description: '顶部工具栏负责全量数据导入导出与初始化重置。',
points: [
'“导入/导出”是整项目级别的数据包操作。',
'“重置”会清空本地全部数据并恢复默认页面。',
'建议在重要调整前先导出备份。'
]
}
]
const componentMap: Record<string, any> = {
XmView: markRaw(defineAsyncComponent(() => import('@/components/views/Xm.vue'))),
ContractDetailView: markRaw(defineAsyncComponent(() => import('@/components/views/ContractDetailView.vue'))),
ZxFwView: markRaw(defineAsyncComponent(() => import('@/components/views/ZxFwView.vue'))),
}
const tabStore = useTabStore()
const tabContextOpen = ref(false)
const tabContextX = ref(0)
const tabContextY = ref(0)
const contextTabId = ref<string>('XmView')
const tabContextRef = ref<HTMLElement | null>(null)
const dataMenuOpen = ref(false)
const dataMenuRef = ref<HTMLElement | null>(null)
const importFileRef = ref<HTMLInputElement | null>(null)
const userGuideOpen = ref(false)
const userGuideStepIndex = ref(0)
const tabItemElMap = new Map<string, HTMLElement>()
const tabPanelElMap = new Map<string, HTMLElement>()
const tabsModel = computed({
get: () => tabStore.tabs,
set: (value) => {
tabStore.tabs = value
}
})
const contextTabIndex = computed(() => tabStore.tabs.findIndex(t => t.id === contextTabId.value))
const hasClosableTabs = computed(() => tabStore.tabs.some(t => t.id !== 'XmView'))
const activeGuideStep = computed(
() => userGuideSteps[userGuideStepIndex.value] || userGuideSteps[0]
)
const isFirstGuideStep = computed(() => userGuideStepIndex.value === 0)
const isLastGuideStep = computed(() => userGuideStepIndex.value >= userGuideSteps.length - 1)
const guideProgressText = computed(() => `${userGuideStepIndex.value + 1} / ${userGuideSteps.length}`)
const canCloseLeft = computed(() => {
if (contextTabIndex.value <= 0) return false
return tabStore.tabs.slice(0, contextTabIndex.value).some(t => t.id !== 'XmView')
})
const canCloseRight = computed(() => {
if (contextTabIndex.value < 0) return false
return tabStore.tabs.slice(contextTabIndex.value + 1).some(t => t.id !== 'XmView')
})
const canCloseOther = computed(() =>
tabStore.tabs.some(t => t.id !== 'XmView' && t.id !== contextTabId.value)
)
const closeMenus = () => {
tabContextOpen.value = false
dataMenuOpen.value = false
}
const markGuideCompleted = () => {
try {
localStorage.setItem(USER_GUIDE_COMPLETED_KEY, '1')
} catch (error) {
console.error('mark guide completed failed:', error)
}
}
const hasGuideCompleted = () => {
try {
return localStorage.getItem(USER_GUIDE_COMPLETED_KEY) === '1'
} catch (error) {
console.error('read guide completion failed:', error)
return false
}
}
const hasNonDefaultTabState = () => {
try {
const raw = localStorage.getItem('tabs')
if (!raw) return false
const parsed = JSON.parse(raw) as { tabs?: Array<{ id?: string }>; activeTabId?: string }
const tabs = Array.isArray(parsed?.tabs) ? parsed.tabs : []
const hasCustomTabs = tabs.some(item => item?.id && item.id !== 'XmView')
const activeTabId = typeof parsed?.activeTabId === 'string' ? parsed.activeTabId : ''
return hasCustomTabs || (activeTabId !== '' && activeTabId !== 'XmView')
} catch (error) {
console.error('parse tabs cache failed:', error)
return false
}
}
const shouldAutoOpenGuide = async () => {
if (hasGuideCompleted()) return false
if (hasNonDefaultTabState()) return false
try {
const keys = await localforage.keys()
return keys.length === 0
} catch (error) {
console.error('read localforage keys failed:', error)
return false
}
}
const openUserGuide = (startAt = 0) => {
closeMenus()
userGuideStepIndex.value = Math.min(Math.max(startAt, 0), userGuideSteps.length - 1)
userGuideOpen.value = true
}
const closeUserGuide = (completed = false) => {
userGuideOpen.value = false
if (completed) markGuideCompleted()
}
const prevUserGuideStep = () => {
if (isFirstGuideStep.value) return
userGuideStepIndex.value -= 1
}
const nextUserGuideStep = () => {
if (isLastGuideStep.value) {
closeUserGuide(true)
return
}
userGuideStepIndex.value += 1
}
const jumpToGuideStep = (stepIndex: number) => {
userGuideStepIndex.value = Math.min(Math.max(stepIndex, 0), userGuideSteps.length - 1)
}
const openTabContextMenu = (event: MouseEvent, tabId: string) => {
contextTabId.value = tabId
tabContextX.value = event.clientX
tabContextY.value = event.clientY
tabContextOpen.value = true
void nextTick(() => {
if (!tabContextRef.value) return
const gap = 8
const rect = tabContextRef.value.getBoundingClientRect()
if (tabContextX.value + rect.width + gap > window.innerWidth) {
tabContextX.value = Math.max(gap, window.innerWidth - rect.width - gap)
}
if (tabContextY.value + rect.height + gap > window.innerHeight) {
tabContextY.value = Math.max(gap, window.innerHeight - rect.height - gap)
}
if (tabContextX.value < gap) tabContextX.value = gap
if (tabContextY.value < gap) tabContextY.value = gap
})
}
const handleGlobalMouseDown = (event: MouseEvent) => {
const target = event.target as Node
if (tabContextOpen.value && tabContextRef.value && !tabContextRef.value.contains(target)) {
tabContextOpen.value = false
}
if (dataMenuOpen.value && dataMenuRef.value && !dataMenuRef.value.contains(target)) {
dataMenuOpen.value = false
}
}
const handleGlobalKeyDown = (event: KeyboardEvent) => {
if (!userGuideOpen.value) return
if (event.key === 'Escape') {
event.preventDefault()
closeUserGuide(false)
return
}
if (event.key === 'ArrowLeft') {
event.preventDefault()
prevUserGuideStep()
return
}
if (event.key === 'ArrowRight') {
event.preventDefault()
nextUserGuideStep()
}
}
const runTabMenuAction = (action: 'all' | 'left' | 'right' | 'other') => {
if (action === 'all') tabStore.closeAllTabs()
if (action === 'left') tabStore.closeLeftTabs(contextTabId.value)
if (action === 'right') tabStore.closeRightTabs(contextTabId.value)
if (action === 'other') tabStore.closeOtherTabs(contextTabId.value)
tabContextOpen.value = false
}
const canMoveTab = (event: any) => {
const draggedId = event?.draggedContext?.element?.id
const targetIndex = event?.relatedContext?.index
if (draggedId === 'XmView') return false
if (typeof targetIndex === 'number' && targetIndex === 0) return false
return true
}
const setTabItemRef = (id: string, el: Element | ComponentPublicInstance | null) => {
if (el instanceof HTMLElement) {
tabItemElMap.set(id, el)
return
}
tabItemElMap.delete(id)
}
const setTabPanelRef = (id: string, el: Element | ComponentPublicInstance | null) => {
if (el instanceof HTMLElement) {
tabPanelElMap.set(id, el)
return
}
tabPanelElMap.delete(id)
}
const ensureActiveTabVisible = () => {
const activeId = tabStore.activeTabId
const el = tabItemElMap.get(activeId)
if (!el) return
el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
}
const getActivePanelScrollViewport = (tabId?: string | null) => {
if (!tabId) return null
const panelEl = tabPanelElMap.get(tabId)
if (!panelEl) return null
return panelEl.querySelector<HTMLElement>('[data-slot="scroll-area-viewport"]')
}
const getTabScrollSessionKey = (tabId: string) => `tab-scroll-top:${tabId}`
const saveTabInnerScrollTop = (tabId?: string | null) => {
if (!tabId) return
const viewport = getActivePanelScrollViewport(tabId)
if (!viewport) return
const top = viewport.scrollTop || 0
try {
sessionStorage.setItem(getTabScrollSessionKey(tabId), String(top))
} catch (error) {
console.error('save tab scroll failed:', error)
}
}
const restoreTabInnerScrollTop = (tabId?: string | null) => {
if (!tabId) return
const viewport = getActivePanelScrollViewport(tabId)
if (!viewport) return
let top = 0
try {
top = Number(sessionStorage.getItem(getTabScrollSessionKey(tabId)) || '0') || 0
} catch (error) {
console.error('restore tab scroll failed:', error)
}
viewport.scrollTop = top
}
const scheduleRestoreTabInnerScrollTop = (tabId?: string | null) => {
if (!tabId) return
requestAnimationFrame(() => {
restoreTabInnerScrollTop(tabId)
requestAnimationFrame(() => {
restoreTabInnerScrollTop(tabId)
})
})
}
const readWebStorage = (storageObj: Storage): DataEntry[] => {
const entries: DataEntry[] = []
for (let i = 0; i < storageObj.length; i++) {
const key = storageObj.key(i)
if (!key) continue
const raw = storageObj.getItem(key)
let value: any = raw
if (raw != null) {
try {
value = JSON.parse(raw)
} catch {
value = raw
}
}
entries.push({ key, value })
}
return entries
}
const writeWebStorage = (storageObj: Storage, entries: DataEntry[]) => {
storageObj.clear()
for (const entry of entries || []) {
const value = typeof entry.value === 'string' ? entry.value : JSON.stringify(entry.value)
storageObj.setItem(entry.key, value)
}
}
const readForage = async (store: typeof localforage): Promise<DataEntry[]> => {
const keys = await store.keys()
const entries: DataEntry[] = []
for (const key of keys) {
const value = await store.getItem(key)
entries.push({ key, value })
}
return entries
}
const writeForage = async (store: typeof localforage, entries: DataEntry[]) => {
await store.clear()
for (const entry of entries || []) {
await store.setItem(entry.key, entry.value)
}
}
const normalizeEntries = (value: unknown): DataEntry[] => {
if (!Array.isArray(value)) return []
return value
.filter(item => item && typeof item === 'object' && typeof (item as any).key === 'string')
.map(item => ({ key: String((item as any).key), value: (item as any).value }))
}
const sanitizeFileNamePart = (value: string): string => {
const cleaned = value
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/\s+/g, ' ')
.trim()
return cleaned || '造价项目'
}
const formatExportTimestamp = (date: Date): string => {
const yyyy = date.getFullYear()
const mm = String(date.getMonth() + 1).padStart(2, '0')
const dd = String(date.getDate()).padStart(2, '0')
const hh = String(date.getHours()).padStart(2, '0')
const mi = String(date.getMinutes()).padStart(2, '0')
return `${yyyy}${mm}${dd}-${hh}${mi}`
}
const getExportProjectName = (entries: DataEntry[]): string => {
const target = entries.find(item => item.key === 'xm-info-v3')
const data = (target?.value || {}) as XmInfoLike
return typeof data.projectName === 'string' ? sanitizeFileNamePart(data.projectName) : '造价项目'
}
const exportData = async () => {
try {
const now = new Date()
const payload: DataPackage = {
version: 1,
exportedAt: now.toISOString(),
localStorage: readWebStorage(localStorage),
sessionStorage: readWebStorage(sessionStorage),
localforageDefault: await readForage(localforage),
}
const content = await encodeZwArchive(payload)
const binary = new Uint8Array(content.length)
binary.set(content)
const blob = new Blob([binary], { type: 'application/octet-stream' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
const projectName = getExportProjectName(payload.localforageDefault)
const timestamp = formatExportTimestamp(now)
link.download = `${projectName}-${timestamp}${ZW_FILE_EXTENSION}`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
} catch (error) {
console.error('export failed:', error)
window.alert('导出失败,请重试。')
} finally {
dataMenuOpen.value = false
}
}
const exportReport = async ()=>{
}
const triggerImport = () => {
importFileRef.value?.click()
}
const importData = async (event: Event) => {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
try {
if (!file.name.toLowerCase().endsWith(ZW_FILE_EXTENSION)) {
throw new Error('INVALID_FILE_EXT')
}
const buffer = await file.arrayBuffer()
const payload = await decodeZwArchive<DataPackage>(buffer)
writeWebStorage(localStorage, normalizeEntries(payload.localStorage))
writeWebStorage(sessionStorage, normalizeEntries(payload.sessionStorage))
await writeForage(localforage, normalizeEntries(payload.localforageDefault))
tabStore.resetTabs()
dataMenuOpen.value = false
window.location.reload()
} catch (error) {
console.error('import failed:', error)
window.alert('导入失败:文件无效、已损坏或被修改。')
} finally {
input.value = ''
}
}
const handleReset = async () => {
try {
localStorage.clear()
sessionStorage.clear()
await localforage.clear()
} catch (error) {
console.error('reset failed:', error)
} finally {
tabStore.resetTabs()
window.location.reload()
}
}
onMounted(() => {
window.addEventListener('mousedown', handleGlobalMouseDown)
window.addEventListener('keydown', handleGlobalKeyDown)
void nextTick(() => {
ensureActiveTabVisible()
scheduleRestoreTabInnerScrollTop(tabStore.activeTabId)
})
void (async () => {
if (await shouldAutoOpenGuide()) {
openUserGuide(0)
}
})()
})
onBeforeUnmount(() => {
window.removeEventListener('mousedown', handleGlobalMouseDown)
window.removeEventListener('keydown', handleGlobalKeyDown)
})
watch(
() => tabStore.activeTabId,
(nextId, prevId) => {
saveTabInnerScrollTop(prevId)
void nextTick(() => {
ensureActiveTabVisible()
scheduleRestoreTabInnerScrollTop(nextId)
})
}
)
watch(
() => tabStore.tabs.map(t => t.id),
(ids) => {
const idSet = new Set(ids)
try {
for (let i = sessionStorage.length - 1; i >= 0; i--) {
const key = sessionStorage.key(i)
if (!key || !key.startsWith('tab-scroll-top:')) continue
const tabId = key.slice('tab-scroll-top:'.length)
if (!idSet.has(tabId)) sessionStorage.removeItem(key)
}
} catch (error) {
console.error('cleanup tab scroll cache failed:', error)
}
}
)
</script>
<template>
<div class="flex flex-col w-full h-screen bg-background overflow-hidden">
<div class="flex items-start gap-2 border-b bg-muted/30 px-2 pt-2 flex-none">
<ScrollArea type="auto" class="min-w-0 flex-1 whitespace-nowrap pb-2">
<draggable
v-model="tabsModel"
item-key="id"
tag="div"
class="flex w-max gap-1"
:animation="180"
:move="canMoveTab"
>
<template #item="{ element: tab }">
<div
:ref="el => setTabItemRef(tab.id, el)"
@click="tabStore.activeTabId = tab.id"
@contextmenu.prevent="openTabContextMenu($event, tab.id)"
:class="[
'group relative flex items-center h-9 px-4 min-w-[120px] max-w-[220px] cursor-pointer rounded-t-md border-x border-t transition-all text-sm',
tabStore.activeTabId === tab.id
? 'bg-background border-border font-medium'
: 'border-transparent hover:bg-muted text-muted-foreground',
tab.id !== 'XmView' ? 'cursor-move' : ''
]"
>
<span class="truncate mr-2">{{ tab.title }}</span>
<Button
v-if="tab.id !== 'XmView'"
variant="ghost"
size="icon"
class="h-4 w-4 ml-auto opacity-0 group-hover:opacity-100 hover:bg-destructive hover:text-destructive-foreground transition-opacity"
@click.stop="tabStore.removeTab(tab.id)"
>
<X class="h-3 w-3" />
</Button>
</div>
</template>
</draggable>
<ScrollBar orientation="horizontal" />
</ScrollArea>
<div ref="dataMenuRef" class="relative mb-2 shrink-0">
<Button variant="outline" size="sm" class="cursor-pointer" @click="dataMenuOpen = !dataMenuOpen">
<ChevronDown class="h-4 w-4 mr-1" />
导入/导出
</Button>
<div
v-if="dataMenuOpen"
class="absolute right-0 top-full mt-1 z-50 min-w-[108px] rounded-md border bg-background p-1 shadow-md"
>
<button
class="w-full cursor-pointer rounded px-3 py-1.5 text-left text-sm hover:bg-muted"
@click="triggerImport"
>
导入
</button>
<button
class="w-full cursor-pointer rounded px-3 py-1.5 text-left text-sm hover:bg-muted"
@click="exportData"
>
导出
</button>
<button
class="w-full cursor-pointer rounded px-3 py-1.5 text-left text-sm hover:bg-muted"
@click="exportReport"
>
导出报表
</button>
</div>
<input
ref="importFileRef"
type="file"
accept=".zw"
class="hidden"
@change="importData"
/>
</div>
<Button variant="outline" size="sm" class="mb-2 shrink-0 cursor-pointer" @click="openUserGuide(0)">
<CircleHelp class="h-4 w-4 mr-1" />
使用引导
</Button>
<AlertDialogRoot>
<AlertDialogTrigger as-child>
<Button variant="destructive" size="sm" class="mb-2 shrink-0">
<RotateCcw class="h-4 w-4 mr-1" />
重置
</Button>
</AlertDialogTrigger>
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">确认重置</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
将清空所有项目数据并恢复默认页面确认继续吗
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child>
<Button variant="outline">取消</Button>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button variant="destructive" @click="handleReset">确认重置</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</div>
<div class="flex-1 overflow-auto relative">
<div
v-for="tab in tabStore.tabs"
:key="tab.id"
:ref="el => setTabPanelRef(tab.id, el)"
v-show="tabStore.activeTabId === tab.id"
class="h-full w-full p-6 animate-in fade-in duration-300"
>
<component :is="componentMap[tab.componentName]" v-bind="tab.props || {}" />
</div>
</div>
<div
v-if="tabContextOpen"
ref="tabContextRef"
class="fixed z-[70] w-max rounded-lg border border-border/80 bg-background/95 p-1.5 shadow-xl ring-1 ring-black/5 backdrop-blur-sm"
:style="{ left: `${tabContextX}px`, top: `${tabContextY}px` }"
>
<button
class="flex cursor-pointer items-center whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!hasClosableTabs"
@click="runTabMenuAction('all')"
>
删除所有
</button>
<button
class="mt-0.5 flex cursor-pointer items-center whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!canCloseLeft"
@click="runTabMenuAction('left')"
>
删除左侧
</button>
<button
class="mt-0.5 flex cursor-pointer items-center whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!canCloseRight"
@click="runTabMenuAction('right')"
>
删除右侧
</button>
<button
class="mt-0.5 flex cursor-pointer items-center whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!canCloseOther"
@click="runTabMenuAction('other')"
>
删除其他
</button>
</div>
<div
v-if="userGuideOpen"
class="fixed inset-0 z-[120] flex items-center justify-center bg-black/45 p-4"
@click.self="closeUserGuide(false)"
>
<div class="w-full max-w-3xl rounded-xl border bg-background shadow-2xl">
<div class="flex items-start justify-between border-b px-6 py-5">
<div>
<p class="text-xs text-muted-foreground">新用户引导 · {{ guideProgressText }}</p>
<h3 class="mt-1 text-lg font-semibold">{{ activeGuideStep.title }}</h3>
</div>
<Button variant="ghost" size="icon" class="h-8 w-8" @click="closeUserGuide(false)">
<X class="h-4 w-4" />
</Button>
</div>
<div class="space-y-4 px-6 py-5">
<p class="text-sm leading-6 text-foreground">{{ activeGuideStep.description }}</p>
<ul class="list-disc space-y-2 pl-5 text-sm text-muted-foreground">
<li v-for="(point, index) in activeGuideStep.points" :key="`${activeGuideStep.title}-${index}`">
{{ point }}
</li>
</ul>
</div>
<div class="flex flex-col gap-3 border-t px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-1.5">
<button
v-for="(_step, index) in userGuideSteps"
:key="`guide-dot-${index}`"
class="h-2.5 w-2.5 cursor-pointer rounded-full transition-colors"
:class="index === userGuideStepIndex ? 'bg-primary' : 'bg-muted'"
:aria-label="`跳转到第 ${index + 1} `"
@click="jumpToGuideStep(index)"
/>
</div>
<div class="flex items-center justify-end gap-2">
<Button variant="ghost" @click="closeUserGuide(false)">稍后再看</Button>
<Button variant="outline" :disabled="isFirstGuideStep" @click="prevUserGuideStep">上一步</Button>
<Button @click="nextUserGuideStep">{{ isLastGuideStep ? '完成并不再自动弹出' : '下一步' }}</Button>
</div>
</div>
</div>
</div>
</div>
</template>