788 lines
26 KiB
Vue
788 lines
26 KiB
Vue
<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>
|