Files
calculator2026/src/layout/tab.vue
T
2026-03-06 17:40:08 +08:00

1623 lines
53 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 } from '@/components/ui/scroll-area'
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
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'
import { exportFile, serviceList } from '@/sql'
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
preparedBy?: unknown
reviewedBy?: unknown
preparedDate?: unknown
projectIndustry?: unknown
}
interface ScaleRowLike {
id: string
amount?: unknown
landArea?: unknown
}
interface XmInfoStorageLike extends XmInfoLike {
detailRows?: ScaleRowLike[]
}
interface XmScaleStorageLike {
detailRows?: ScaleRowLike[]
}
interface ContractCardItem {
id: string
name?: string
order?: number
}
interface ZxFwRowLike {
id: string
subtotal?: unknown
investScale?: unknown
landScale?: unknown
workload?: unknown
hourly?: unknown
}
interface ZxFwStorageLike {
detailRows?: ZxFwRowLike[]
}
interface ScaleMethodRowLike extends ScaleRowLike {
basicFormula?: unknown
optionalFormula?: unknown
budgetFee?: unknown
budgetFeeBasic?: unknown
budgetFeeOptional?: unknown
consultCategoryFactor?: unknown
majorFactor?: unknown
remark?: unknown
}
interface WorkloadMethodRowLike {
id: string
budgetAdoptedUnitPrice?: unknown
workload?: unknown
basicFee?: unknown
consultCategoryFactor?: unknown
serviceFee?: unknown
remark?: unknown
}
interface HourlyMethodRowLike {
id: string
adoptedBudgetUnitPrice?: unknown
personnelCount?: unknown
workdayCount?: unknown
serviceBudget?: unknown
remark?: unknown
}
interface DetailRowsStorageLike<T> {
detailRows?: T[]
}
interface FactorRowLike {
id: string
standardFactor?: unknown
budgetValue?: unknown
remark?: unknown
}
interface ExportScaleRow {
major: number
cost: number
area: number
}
interface ExportMethod1Detail {
major: number
cost: number
basicFee: number
basicFormula: string
basicFee_basic: number
optionalFormula: string
basicFee_optional: number
serviceCoe: number
majorCoe: number
processCoe: number
proportion: number
fee: number
remark: string
}
interface ExportMethod1 {
cost: number
basicFee: number
basicFee_basic: number
basicFee_optional: number
fee: number
det: ExportMethod1Detail[]
}
interface ExportMethod2Detail {
major: number
area: number
basicFee: number
basicFormula: string
basicFee_basic: number
optionalFormula: string
basicFee_optional: number
serviceCoe: number
majorCoe: number
processCoe: number
proportion: number
fee: number
remark: string
}
interface ExportMethod2 {
area: number
basicFee: number
basicFee_basic: number
basicFee_optional: number
fee: number
det: ExportMethod2Detail[]
}
interface ExportMethod3Detail {
task: number
price: number
amount: number
basicFee: number
serviceCoe: number
fee: number
remark: string
}
interface ExportMethod3 {
basicFee: number
fee: number
det: ExportMethod3Detail[]
}
interface ExportMethod4Detail {
expert: number
price: number
person_num: number
work_day: number
fee: number
remark: string
}
interface ExportMethod4 {
person_num: number
work_day: number
fee: number
det: ExportMethod4Detail[]
}
interface ExportService {
id: number
fee: number
process: number
method1?: ExportMethod1
method2?: ExportMethod2
method3?: ExportMethod3
method4?: ExportMethod4
}
interface ExportServiceCoe {
serviceid: number
coe: number
remark: string
}
interface ExportMajorCoe {
majorid: number
coe: number
remark: string
}
interface ExportContract {
name: string
serviceFee: number
addtionalFee: number
reserveFee: number
fee: number
scale: ExportScaleRow[]
serviceCoes: ExportServiceCoe[]
majorCoes: ExportMajorCoe[]
services: ExportService[]
addtional: []
reserve: []
}
interface ExportReportPayload {
name: string
writer: string
reviewer: string
date: string
industry: number
fee: number
scaleCost: number
scale: ExportScaleRow[]
serviceCoes: ExportServiceCoe[]
majorCoes: ExportMajorCoe[]
contracts: ExportContract[]
}
const USER_GUIDE_COMPLETED_KEY = 'jgjs-user-guide-completed-v1'
const PROJECT_INFO_DB_KEY = 'xm-base-info-v1'
const LEGACY_PROJECT_DB_KEY = 'xm-info-v3'
const CONSULT_CATEGORY_FACTOR_DB_KEY = 'xm-consult-category-factor-v1'
const MAJOR_FACTOR_DB_KEY = 'xm-major-factor-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/xmCard.vue'))),
ContractDetailView: markRaw(defineAsyncComponent(() => import('@/components/views/htCard.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 importConfirmOpen = ref(false)
const pendingImportPayload = ref<DataPackage | null>(null)
const pendingImportFileName = ref('')
const userGuideOpen = ref(false)
const userGuideStepIndex = ref(0)
const tabItemElMap = new Map<string, HTMLElement>()
const tabTitleElMap = new Map<string, HTMLElement>()
const tabPanelElMap = new Map<string, HTMLElement>()
const tabScrollAreaRef = ref<HTMLElement | null>(null)
const showTabScrollLeft = ref(false)
const showTabScrollRight = ref(false)
const isTabStripHover = ref(false)
const tabTitleOverflowMap = ref<Record<string, boolean>>({})
let tabStripViewportEl: HTMLElement | null = null
let tabTitleOverflowRafId: number | null = null
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)
tabTitleElMap.delete(id)
delete tabTitleOverflowMap.value[id]
scheduleUpdateTabTitleOverflow()
}
const setTabTitleRef = (id: string, el: Element | ComponentPublicInstance | null) => {
if (el instanceof HTMLElement) {
tabTitleElMap.set(id, el)
scheduleUpdateTabTitleOverflow()
return
}
tabTitleElMap.delete(id)
delete tabTitleOverflowMap.value[id]
scheduleUpdateTabTitleOverflow()
}
const updateTabTitleOverflow = () => {
const nextMap: Record<string, boolean> = {}
for (const [id, el] of tabTitleElMap.entries()) {
nextMap[id] = el.scrollWidth > el.clientWidth + 1
}
tabTitleOverflowMap.value = nextMap
}
const scheduleUpdateTabTitleOverflow = () => {
if (tabTitleOverflowRafId != null) return
tabTitleOverflowRafId = requestAnimationFrame(() => {
tabTitleOverflowRafId = null
updateTabTitleOverflow()
})
}
const setTabPanelRef = (id: string, el: Element | ComponentPublicInstance | null) => {
if (el instanceof HTMLElement) {
tabPanelElMap.set(id, el)
return
}
tabPanelElMap.delete(id)
}
const setTabScrollAreaRef = (el: Element | ComponentPublicInstance | null) => {
if (el instanceof HTMLElement) {
tabScrollAreaRef.value = el
return
}
const rootEl =
el && typeof el === 'object' && '$el' in el ? (el as ComponentPublicInstance).$el : null
tabScrollAreaRef.value = rootEl instanceof HTMLElement ? rootEl : null
}
const getTabStripViewport = () =>
tabScrollAreaRef.value?.querySelector<HTMLElement>('[data-slot="scroll-area-viewport"]') || null
const updateTabScrollButtons = () => {
const viewport = getTabStripViewport()
if (!viewport) {
showTabScrollLeft.value = false
showTabScrollRight.value = false
return
}
const maxLeft = Math.max(0, viewport.scrollWidth - viewport.clientWidth)
showTabScrollLeft.value = viewport.scrollLeft > 1
showTabScrollRight.value = viewport.scrollLeft < maxLeft - 1
}
const handleTabStripScroll = () => {
updateTabScrollButtons()
}
const bindTabStripScroll = () => {
const viewport = getTabStripViewport()
if (tabStripViewportEl === viewport) {
updateTabScrollButtons()
return
}
if (tabStripViewportEl) {
tabStripViewportEl.removeEventListener('scroll', handleTabStripScroll)
}
tabStripViewportEl = viewport
if (tabStripViewportEl) {
tabStripViewportEl.addEventListener('scroll', handleTabStripScroll, { passive: true })
}
updateTabScrollButtons()
}
const scrollTabStripBy = (delta: number) => {
const viewport = getTabStripViewport()
if (!viewport) return
viewport.scrollBy({ left: delta, behavior: 'smooth' })
requestAnimationFrame(updateTabScrollButtons)
}
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 === PROJECT_INFO_DB_KEY) ||
entries.find(item => item.key === LEGACY_PROJECT_DB_KEY)
const data = (target?.value || {}) as XmInfoLike
return typeof data.projectName === 'string' ? sanitizeFileNamePart(data.projectName) : '造价项目'
}
const toFiniteNumber = (value: unknown): number | null => {
const num = Number(value)
return Number.isFinite(num) ? num : null
}
const toFiniteNumberOrZero = (value: unknown): number => toFiniteNumber(value) ?? 0
const toSafeInteger = (value: unknown): number | null => {
const num = Number(value)
if (!Number.isInteger(num)) return null
if (!Number.isSafeInteger(num)) return null
return num
}
const sumNumbers = (values: Array<number | null | undefined>): number =>
values.reduce<number>(
(sum, value) => sum + (typeof value === 'number' && Number.isFinite(value) ? value : 0),
0
)
const isNonEmptyString = (value: unknown): value is string =>
typeof value === 'string' && value.trim().length > 0
const getTaskIdFromRowId = (value: string): number | null => {
const match = /^task-(\d+)-\d+$/.exec(value)
return match ? toSafeInteger(match[1]) : null
}
const getExpertIdFromRowId = (value: string): number | null => {
const match = /^expert-(\d+)$/.exec(value)
return match ? toSafeInteger(match[1]) : null
}
const hasServiceId = (serviceId: string) =>
Object.prototype.hasOwnProperty.call(serviceList as Record<string, unknown>, serviceId)
const mapIndustryCodeToExportIndustry = (value: unknown): number => {
const raw = typeof value === 'string' ? value.trim().toUpperCase() : ''
if (!raw) return 0
if (raw === '0' || raw === 'E2') return 0
if (raw === '1' || raw === 'E3') return 1
if (raw === '2' || raw === 'E4') return 2
return 0
}
const buildProjectServiceCoes = (rows: FactorRowLike[] | undefined): ExportServiceCoe[] => {
if (!Array.isArray(rows)) return []
return rows
.map(row => {
const serviceid = toSafeInteger(row.id)
if (serviceid == null) return null
const coe = toFiniteNumber(row.budgetValue) ?? toFiniteNumber(row.standardFactor) ?? 0
return {
serviceid,
coe,
remark: typeof row.remark === 'string' ? row.remark : ''
}
})
.filter((item): item is ExportServiceCoe => Boolean(item))
}
const buildProjectMajorCoes = (rows: FactorRowLike[] | undefined): ExportMajorCoe[] => {
if (!Array.isArray(rows)) return []
return rows
.map(row => {
const majorid = toSafeInteger(row.id)
if (majorid == null) return null
const coe = toFiniteNumber(row.budgetValue) ?? toFiniteNumber(row.standardFactor) ?? 0
return {
majorid,
coe,
remark: typeof row.remark === 'string' ? row.remark : ''
}
})
.filter((item): item is ExportMajorCoe => Boolean(item))
}
const buildScaleRows = (rows: ScaleRowLike[] | undefined): ExportScaleRow[] => {
if (!Array.isArray(rows)) return []
return rows
.map(row => {
const major = toSafeInteger(row.id)
const cost = toFiniteNumber(row.amount)
const area = toFiniteNumber(row.landArea)
if (major == null || (cost == null && area == null)) return null
return {
major,
cost: cost ?? 0,
area: area ?? 0
}
})
.filter((item): item is ExportScaleRow => Boolean(item))
}
const buildMethod1 = (rows: ScaleMethodRowLike[] | undefined): ExportMethod1 | null => {
if (!Array.isArray(rows)) return null
const det = rows
.map(row => {
const major = toSafeInteger(row.id)
if (major == null) return null
const cost = toFiniteNumber(row.amount)
const basicFee = toFiniteNumber(row.budgetFee)
const basicFeeBasic = toFiniteNumber(row.budgetFeeBasic)
const basicFeeOptional = toFiniteNumber(row.budgetFeeOptional)
const remark = typeof row.remark === 'string' ? row.remark : ''
const hasValue =
cost != null ||
basicFee != null ||
basicFeeBasic != null ||
basicFeeOptional != null ||
isNonEmptyString(remark)
if (!hasValue) return null
return {
major,
cost: cost ?? 0,
basicFee: basicFee ?? 0,
basicFormula: typeof row.basicFormula === 'string' ? row.basicFormula : '',
basicFee_basic: basicFeeBasic ?? 0,
optionalFormula: typeof row.optionalFormula === 'string' ? row.optionalFormula : '',
basicFee_optional: basicFeeOptional ?? 0,
serviceCoe: toFiniteNumberOrZero(row.consultCategoryFactor),
majorCoe: toFiniteNumberOrZero(row.majorFactor),
processCoe: 1,
proportion: 1,
fee: basicFee ?? 0,
remark
}
})
.filter((item): item is ExportMethod1Detail => Boolean(item))
if (det.length === 0) return null
return {
cost: sumNumbers(det.map(item => item.cost)),
basicFee: sumNumbers(det.map(item => item.basicFee)),
basicFee_basic: sumNumbers(det.map(item => item.basicFee_basic)),
basicFee_optional: sumNumbers(det.map(item => item.basicFee_optional)),
fee: sumNumbers(det.map(item => item.fee)),
det
}
}
const buildMethod2 = (rows: ScaleMethodRowLike[] | undefined): ExportMethod2 | null => {
if (!Array.isArray(rows)) return null
const det = rows
.map(row => {
const major = toSafeInteger(row.id)
if (major == null) return null
const area = toFiniteNumber(row.landArea)
const basicFee = toFiniteNumber(row.budgetFee)
const basicFeeBasic = toFiniteNumber(row.budgetFeeBasic)
const basicFeeOptional = toFiniteNumber(row.budgetFeeOptional)
const remark = typeof row.remark === 'string' ? row.remark : ''
const hasValue =
area != null ||
basicFee != null ||
basicFeeBasic != null ||
basicFeeOptional != null ||
isNonEmptyString(remark)
if (!hasValue) return null
return {
major,
area: area ?? 0,
basicFee: basicFee ?? 0,
basicFormula: typeof row.basicFormula === 'string' ? row.basicFormula : '',
basicFee_basic: basicFeeBasic ?? 0,
optionalFormula: typeof row.optionalFormula === 'string' ? row.optionalFormula : '',
basicFee_optional: basicFeeOptional ?? 0,
serviceCoe: toFiniteNumberOrZero(row.consultCategoryFactor),
majorCoe: toFiniteNumberOrZero(row.majorFactor),
processCoe: 1,
proportion: 1,
fee: basicFee ?? 0,
remark
}
})
.filter((item): item is ExportMethod2Detail => Boolean(item))
if (det.length === 0) return null
return {
area: sumNumbers(det.map(item => item.area)),
basicFee: sumNumbers(det.map(item => item.basicFee)),
basicFee_basic: sumNumbers(det.map(item => item.basicFee_basic)),
basicFee_optional: sumNumbers(det.map(item => item.basicFee_optional)),
fee: sumNumbers(det.map(item => item.fee)),
det
}
}
const buildMethod3 = (rows: WorkloadMethodRowLike[] | undefined): ExportMethod3 | null => {
if (!Array.isArray(rows)) return null
const det = rows
.map(row => {
const task = getTaskIdFromRowId(row.id)
if (task == null) return null
const amount = toFiniteNumber(row.workload)
const basicFee = toFiniteNumber(row.basicFee)
const fee = toFiniteNumber(row.serviceFee)
const remark = typeof row.remark === 'string' ? row.remark : ''
const hasValue = amount != null || basicFee != null || fee != null || isNonEmptyString(remark)
if (!hasValue) return null
return {
task,
price: toFiniteNumberOrZero(row.budgetAdoptedUnitPrice),
amount: amount ?? 0,
basicFee: basicFee ?? 0,
serviceCoe: toFiniteNumberOrZero(row.consultCategoryFactor),
fee: fee ?? 0,
remark
}
})
.filter((item): item is ExportMethod3Detail => Boolean(item))
if (det.length === 0) return null
return {
basicFee: sumNumbers(det.map(item => item.basicFee)),
fee: sumNumbers(det.map(item => item.fee)),
det
}
}
const buildMethod4 = (rows: HourlyMethodRowLike[] | undefined): ExportMethod4 | null => {
if (!Array.isArray(rows)) return null
const det = rows
.map(row => {
const expert = getExpertIdFromRowId(row.id)
if (expert == null) return null
const personNum = toFiniteNumber(row.personnelCount)
const workDay = toFiniteNumber(row.workdayCount)
const fee = toFiniteNumber(row.serviceBudget)
const remark = typeof row.remark === 'string' ? row.remark : ''
const hasValue = personNum != null || workDay != null || fee != null || isNonEmptyString(remark)
if (!hasValue) return null
return {
expert,
price: toFiniteNumberOrZero(row.adoptedBudgetUnitPrice),
person_num: personNum ?? 0,
work_day: workDay ?? 0,
fee: fee ?? 0,
remark
}
})
.filter((item): item is ExportMethod4Detail => Boolean(item))
if (det.length === 0) return null
return {
person_num: sumNumbers(det.map(item => item.person_num)),
work_day: sumNumbers(det.map(item => item.work_day)),
fee: sumNumbers(det.map(item => item.fee)),
det
}
}
const buildServiceFee = (
row: ZxFwRowLike,
method1: ExportMethod1 | null,
method2: ExportMethod2 | null,
method3: ExportMethod3 | null,
method4: ExportMethod4 | null
) => {
const subtotal = toFiniteNumber(row.subtotal)
if (subtotal != null) return subtotal
const methodSum = sumNumbers([method1?.fee, method2?.fee, method3?.fee, method4?.fee])
if (methodSum !== 0) return methodSum
return sumNumbers([
toFiniteNumber(row.investScale),
toFiniteNumber(row.landScale),
toFiniteNumber(row.workload),
toFiniteNumber(row.hourly)
])
}
const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
const [projectInfoRaw, projectScaleRaw, consultCategoryFactorRaw, majorFactorRaw, contractCardsRaw] = await Promise.all([
localforage.getItem<XmInfoLike>(PROJECT_INFO_DB_KEY),
localforage.getItem<XmInfoStorageLike>(LEGACY_PROJECT_DB_KEY),
localforage.getItem<DetailRowsStorageLike<FactorRowLike>>(CONSULT_CATEGORY_FACTOR_DB_KEY),
localforage.getItem<DetailRowsStorageLike<FactorRowLike>>(MAJOR_FACTOR_DB_KEY),
localforage.getItem<ContractCardItem[]>('ht-card-v1')
])
const projectInfo = projectInfoRaw || {}
const projectScaleSource = projectScaleRaw || {}
const projectScale = buildScaleRows(projectScaleSource.detailRows)
const projectScaleCost = sumNumbers(projectScale.map(item => item.cost))
const projectServiceCoes = buildProjectServiceCoes(consultCategoryFactorRaw?.detailRows)
const projectMajorCoes = buildProjectMajorCoes(majorFactorRaw?.detailRows)
const projectName = isNonEmptyString(projectInfo.projectName) ? projectInfo.projectName.trim() : '造价项目'
const writer = isNonEmptyString(projectInfo.preparedBy) ? projectInfo.preparedBy.trim() : ''
const reviewer = isNonEmptyString(projectInfo.reviewedBy) ? projectInfo.reviewedBy.trim() : ''
const date = isNonEmptyString(projectInfo.preparedDate) ? projectInfo.preparedDate.trim() : ''
const industry = mapIndustryCodeToExportIndustry(projectInfo.projectIndustry)
const contractCards = (Array.isArray(contractCardsRaw) ? contractCardsRaw : [])
.filter(item => item && typeof item.id === 'string')
.sort((a, b) => (typeof a.order === 'number' ? a.order : Number.MAX_SAFE_INTEGER) - (typeof b.order === 'number' ? b.order : Number.MAX_SAFE_INTEGER))
const contracts: ExportContract[] = []
for (let index = 0; index < contractCards.length; index++) {
const contract = contractCards[index]
const contractId = contract.id
const [htInfoRaw, zxFwRaw] = await Promise.all([
localforage.getItem<DetailRowsStorageLike<ScaleRowLike>>(`ht-info-v3-${contractId}`),
localforage.getItem<ZxFwStorageLike>(`zxFW-${contractId}`)
])
const zxRows = Array.isArray(zxFwRaw?.detailRows) ? zxFwRaw.detailRows : []
const fixedRow = zxRows.find(row => row.id === 'fixed-budget-c')
const serviceRows = zxRows.filter(row => row.id !== 'fixed-budget-c' && hasServiceId(String(row.id)))
const services = (
await Promise.all(
serviceRows.map(async row => {
const serviceIdText = String(row.id)
const serviceId = toSafeInteger(serviceIdText)
if (serviceId == null) return null
const [method1Raw, method2Raw, method3Raw, method4Raw] = await Promise.all([
localforage.getItem<DetailRowsStorageLike<ScaleMethodRowLike>>(`tzGMF-${contractId}-${serviceIdText}`),
localforage.getItem<DetailRowsStorageLike<ScaleMethodRowLike>>(`ydGMF-${contractId}-${serviceIdText}`),
localforage.getItem<DetailRowsStorageLike<WorkloadMethodRowLike>>(`gzlF-${contractId}-${serviceIdText}`),
localforage.getItem<DetailRowsStorageLike<HourlyMethodRowLike>>(`hourlyPricing-${contractId}-${serviceIdText}`)
])
const method1 = buildMethod1(method1Raw?.detailRows)
const method2 = buildMethod2(method2Raw?.detailRows)
const method3 = buildMethod3(method3Raw?.detailRows)
const method4 = buildMethod4(method4Raw?.detailRows)
const fee = buildServiceFee(row, method1, method2, method3, method4)
const service: ExportService = {
id: serviceId,
process: 0,
fee
}
if (method1) service.method1 = method1
if (method2) service.method2 = method2
if (method3) service.method3 = method3
if (method4) service.method4 = method4
return service
})
)
).filter((item): item is ExportService => Boolean(item))
const fixedSubtotal = toFiniteNumber(fixedRow?.subtotal)
const serviceFeeSum = sumNumbers(services.map(item => item.fee))
const fixedMethodSum = sumNumbers([
toFiniteNumber(fixedRow?.investScale),
toFiniteNumber(fixedRow?.landScale),
toFiniteNumber(fixedRow?.workload),
toFiniteNumber(fixedRow?.hourly)
])
const serviceFee = fixedSubtotal ?? (serviceFeeSum !== 0 ? serviceFeeSum : fixedMethodSum)
const addtionalFee = 0
const reserveFee = 0
const contractFee = serviceFee + addtionalFee + reserveFee
contracts.push({
name: isNonEmptyString(contract.name) ? contract.name : `合同段-${index + 1}`,
serviceFee,
addtionalFee,
reserveFee,
fee: contractFee,
scale: buildScaleRows(htInfoRaw?.detailRows),
serviceCoes: projectServiceCoes.map(item => ({ ...item })),
majorCoes: projectMajorCoes.map(item => ({ ...item })),
services,
addtional: [],
reserve: []
})
}
return {
name: projectName,
writer,
reviewer,
date,
industry,
fee: sumNumbers(contracts.map(item => item.fee)),
scaleCost: projectScaleCost,
scale: projectScale,
serviceCoes: projectServiceCoes,
majorCoes: projectMajorCoes,
contracts
}
}
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 () => {
try {
const now = new Date()
const payload = await buildExportReportPayload()
const fileName = `${sanitizeFileNamePart(payload.name)}-报表-${formatExportTimestamp(now)}`
await exportFile(fileName, payload)
} catch (error) {
console.error('export report failed:', error)
window.alert('导出报表失败,请重试。')
} finally {
dataMenuOpen.value = false
}
}
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)
pendingImportPayload.value = payload
pendingImportFileName.value = file.name
importConfirmOpen.value = true
} catch (error) {
console.error('import failed:', error)
window.alert('导入失败:文件无效、已损坏或被修改。')
} finally {
input.value = ''
}
}
const cancelImportConfirm = () => {
importConfirmOpen.value = false
pendingImportPayload.value = null
pendingImportFileName.value = ''
}
const confirmImportOverride = async () => {
const payload = pendingImportPayload.value
if (!payload) return
try {
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 apply failed:', error)
window.alert('导入失败:写入本地数据时发生错误。')
} finally {
cancelImportConfirm()
}
}
const handleReset = async () => {
try {
localStorage.clear()
sessionStorage.clear()
await localforage.clear()
localStorage.setItem(USER_GUIDE_COMPLETED_KEY, '1')
} catch (error) {
console.error('reset failed:', error)
} finally {
tabStore.resetTabs()
window.location.reload()
}
}
onMounted(() => {
window.addEventListener('mousedown', handleGlobalMouseDown)
window.addEventListener('keydown', handleGlobalKeyDown)
window.addEventListener('resize', scheduleUpdateTabTitleOverflow)
void nextTick(() => {
bindTabStripScroll()
ensureActiveTabVisible()
updateTabScrollButtons()
scheduleUpdateTabTitleOverflow()
scheduleRestoreTabInnerScrollTop(tabStore.activeTabId)
})
void (async () => {
if (await shouldAutoOpenGuide()) {
openUserGuide(0)
}
})()
})
onBeforeUnmount(() => {
window.removeEventListener('mousedown', handleGlobalMouseDown)
window.removeEventListener('keydown', handleGlobalKeyDown)
window.removeEventListener('resize', scheduleUpdateTabTitleOverflow)
if (tabStripViewportEl) {
tabStripViewportEl.removeEventListener('scroll', handleTabStripScroll)
tabStripViewportEl = null
}
if (tabTitleOverflowRafId != null) {
cancelAnimationFrame(tabTitleOverflowRafId)
tabTitleOverflowRafId = null
}
})
watch(
() => tabStore.activeTabId,
(nextId, prevId) => {
saveTabInnerScrollTop(prevId)
void nextTick(() => {
bindTabStripScroll()
ensureActiveTabVisible()
updateTabScrollButtons()
scheduleUpdateTabTitleOverflow()
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)
}
void nextTick(() => {
bindTabStripScroll()
updateTabScrollButtons()
scheduleUpdateTabTitleOverflow()
})
}
)
</script>
<template>
<TooltipProvider>
<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">
<div
class="mb-2 flex min-w-0 flex-1 items-center gap-1"
@mouseenter="isTabStripHover = true"
@mouseleave="isTabStripHover = false"
>
<button
type="button"
:class="[
'h-9 w-8 shrink-0 cursor-pointer rounded border bg-background text-sm text-muted-foreground transition hover:bg-muted',
isTabStripHover && showTabScrollLeft ? 'opacity-100' : 'pointer-events-none opacity-0'
]"
@click="scrollTabStripBy(-260)"
>
&lt;
</button>
<ScrollArea :ref="setTabScrollAreaRef" type="auto" class="tab-strip-scroll-area min-w-0 flex-1 whitespace-nowrap">
<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' : ''
]"
>
<TooltipRoot>
<TooltipTrigger as-child>
<span
:ref="el => setTabTitleRef(tab.id, el)"
class="truncate mr-2"
>
{{ tab.title }}
</span>
</TooltipTrigger>
<TooltipContent v-if="tabTitleOverflowMap[tab.id]" side="bottom">{{ tab.title }}</TooltipContent>
</TooltipRoot>
<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>
</ScrollArea>
<button
type="button"
:class="[
'h-9 w-8 shrink-0 cursor-pointer rounded border bg-background text-sm text-muted-foreground transition hover:bg-muted',
isTabStripHover && showTabScrollRight ? 'opacity-100' : 'pointer-events-none opacity-0'
]"
@click="scrollTabStripBy(260)"
>
&gt;
</button>
</div>
<div ref="dataMenuRef" class="relative mb-2 shrink-0">
<Button
variant="outline"
size="sm"
class="h-9 min-h-9 px-3 py-0 text-sm leading-none 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 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 h-9 min-h-9 shrink-0 px-3 py-0 text-sm leading-none 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 h-9 min-h-9 shrink-0 px-3 py-0 text-sm leading-none">
<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>
<AlertDialogRoot :open="importConfirmOpen" @update:open="importConfirmOpen = $event">
<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">
将使用“{{ pendingImportFileName || '所选文件' }}”覆盖当前本地全部数据,是否继续?
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child>
<Button variant="outline" @click="cancelImportConfirm">取消</Button>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button variant="destructive" @click="confirmImportOverride">确认覆盖</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-4 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>
</TooltipProvider>
</template>
<style scoped>
.tab-strip-scroll-area :deep([data-slot="scroll-area-viewport"]) {
scrollbar-width: none;
}
.tab-strip-scroll-area :deep([data-slot="scroll-area-viewport"]::-webkit-scrollbar) {
display: none;
}
</style>