2026-03-25 14:31:01 +08:00

2173 lines
85 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, shallowRef, watch } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import draggable from 'vuedraggable'
import { useDark, useToggle } from '@vueuse/core'
import { useTabStore } from '@/pinia/tab'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { useZxFwPricingKeysStore } from '@/pinia/zxFwPricingKeys'
import { useZxFwPricingHtFeeStore } from '@/pinia/zxFwPricingHtFee'
import { useKvStore } from '@/pinia/kv'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
import { Check, ChevronDown, CircleHelp, Loader2, Moon, Sun, X } from 'lucide-vue-next'
import localforage from 'localforage'
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle,
ToastDescription,
ToastProvider,
ToastRoot,
ToastTitle,
ToastViewport,
SelectContent,
SelectIcon,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectPortal,
SelectRoot,
SelectTrigger,
SelectViewport
} from 'reka-ui'
import { decodeZwArchive, encodeZwArchive, ZW_FILE_EXTENSION } from '@/lib/zwArchive'
import { formatExportTimestamp } from '@/lib/contractSegment'
import {
getExportProjectName,
isDataPackageLike,
normalizeEntries,
normalizeForageStoreSnapshots,
readForage,
sanitizeFileNamePart,
writeForage,
type DataPackage,
type ForageInstance,
type ForageStore
} from '@/features/tab/importExport'
import type {
ContractCardItem,
DetailRowsStorageLike,
ExportAdditional,
ExportAdditionalDetail,
ExportContract,
ExportMajorCoe,
ExportMethod0,
ExportMethod1,
ExportMethod1Detail,
ExportMethod2,
ExportMethod2Detail,
ExportMethod3,
ExportMethod3Detail,
ExportMethod4,
ExportMethod4Detail,
ExportMethod5,
ExportMethod5Detail,
ExportReportPayload,
ExportReserve,
ExportScaleRow,
ExportService,
ExportServiceCoe,
ExportTaskGroup,
FactorRowLike,
HourlyMethodRowLike,
HtBaseInfoLike,
HtFeeMainRowLike,
QuantityMethodRowLike,
RateMethodRowLike,
ScaleMethodRowLike,
ScaleRowLike,
UserGuideStep,
WorkContentStateLike,
WorkloadMethodRowLike,
XmInfoLike,
XmInfoStorageLike,
XmScaleStorageLike,
ZxFwRowLike,
ZxFwStorageLike
} from '@/features/tab/types'
import {
buildProjectUrl,
getProjectDbName,
readCurrentProjectId,
PROJECT_TAB_ID,
QUICK_TAB_ID,
consumePendingHomeImportFile,
consumePendingHomeImportSkipConfirm,
readWorkspaceMode,
writeProjectIdToUrl,
writeWorkspaceMode
} from '@/lib/workspace'
import {
createProject,
deleteProject,
listProjects,
upsertProject,
type ProjectMeta
} from '@/lib/projectRegistry'
import { collectActiveProjectSessionLocks } from '@/lib/projectSessionLock'
import { addNumbers } from '@/lib/decimal'
import {
buildMethod0,
buildMethod1,
buildMethod2,
buildMethod3,
buildMethod4,
buildMethod5,
buildProjectMajorCoes,
buildProjectServiceCoes,
buildServiceFee,
buildServiceFinalFee,
getExpertIdFromRowId,
getTaskIdFromRowId,
groupWorkContentTasks,
hasServiceId,
isNonEmptyString,
mapIndustryCodeToExportIndustry,
sortServiceIdsByDict,
sumNumbers,
toExportScaleRows,
toFiniteNumber,
toFiniteNumberOrZero,
toSafeInteger,
toMoney
} from '@/lib/reportExportBuilders'
import { exportFile } from '@/sql'
import { getIndustryDisplayName, industryTypeList } from '@/sql'
import { initializeProjectFactorStates } from '@/lib/projectWorkspace'
import { setAppLocale, type AppLocale } from '@/i18n'
const USER_GUIDE_COMPLETED_KEY = 'jgjs-user-guide-completed-v1'
const THEME_PREFERENCE_KEY = 'jgjs-theme-dark-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 DEFAULT_PROJECT_NAME = 'xxx 造价咨询服务'
const MAX_PROJECT_COUNT = 10
const PINIA_PERSIST_DB_NAME = getProjectDbName(readCurrentProjectId())
const PINIA_PERSIST_BASE_STORE_NAME = 'pinia'
const PINIA_PERSIST_STORE_IDS = ['tabs', 'zxFwPricing', 'zxFwPricingKeys', 'zxFwPricingHtFee', 'kv'] as const
const RESET_MIN_LOADING_MS = 1000
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> = {
ProjectCalcView: markRaw(defineAsyncComponent(() => import('@/features/xm/components/xmCard.vue'))),
QuickCalcView: markRaw(defineAsyncComponent(() => import('@/features/ht/components/htCard.vue'))),
QuickCalcWorkbenchView: markRaw(defineAsyncComponent(() => import('@/features/workbench/components/QuickCalcWorkbenchView.vue'))),
ZxFwView: markRaw(defineAsyncComponent(() => import('@/features/workbench/components/ZxFwView.vue'))),
HtFeeMethodTypeLineView: markRaw(defineAsyncComponent(() => import('@/features/workbench/components/HtFeeMethodTypeLineView.vue'))),
}
const tabStore = useTabStore()
const zxFwPricingStore = useZxFwPricingStore()
const zxFwPricingKeysStore = useZxFwPricingKeysStore()
const zxFwPricingHtFeeStore = useZxFwPricingHtFeeStore()
const kvStore = useKvStore()
const { t, locale } = useI18n()
const isDark = useDark({
selector: 'html',
attribute: 'class',
valueDark: 'dark',
valueLight: '',
storageKey: 'jgjs-theme-dark-v1'
})
const toggleDark = useToggle(isDark)
const tabContextOpen = ref(false)
const tabContextX = ref(0)
const tabContextY = ref(0)
const contextTabId = ref<string>('ProjectCalcView')
const tabContextRef = ref<HTMLElement | null>(null)
const dataMenuOpen = ref(false)
const dataMenuRef = ref<HTMLElement | null>(null)
const projectMenuOpen = ref(false)
const projectMenuRef = ref<HTMLElement | null>(null)
const newProjectDialogOpen = ref(false)
const newProjectIndustry = ref(String(industryTypeList[0]?.id || ''))
const newProjectSubmitting = ref(false)
const projectLimitDialogOpen = ref(false)
const projectDeleteConfirmOpen = ref(false)
const pendingDeleteProject = shallowRef<ProjectMeta | null>(null)
const messageDialogOpen = ref(false)
const messageDialogTitle = ref('')
const messageDialogDesc = ref('')
const projectList = ref<ProjectMeta[]>([])
const openedProjectIds = ref<string[]>([])
const currentProjectId = ref(readCurrentProjectId())
const resetConfirmOpen = ref(false)
const isResetting = ref(false)
const importFileRef = ref<HTMLInputElement | null>(null)
const importConfirmOpen = ref(false)
const pendingImportPayload = shallowRef<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 isTabDragging = ref(false)
const tabTitleOverflowMap = ref<Record<string, boolean>>({})
let tabStripViewportEl: HTMLElement | null = null
let tabTitleOverflowRafId: number | null = null
const reportExportToastOpen = ref(false)
const reportExportProgress = ref(0)
const reportExportStatus = ref<'running' | 'success' | 'error'>('running')
const reportExportText = ref('')
const reportExportBlobUrl = ref<string | null>(null)
let reportExportToastTimer: ReturnType<typeof setTimeout> | null = null
const clearReportExportToastTimer = () => {
if (!reportExportToastTimer) return
clearTimeout(reportExportToastTimer)
reportExportToastTimer = null
}
const showReportExportProgress = (progress: number, text: string) => {
clearReportExportToastTimer()
reportExportStatus.value = 'running'
reportExportProgress.value = Math.max(0, Math.min(100, progress))
reportExportText.value = text
reportExportToastOpen.value = true
}
const finishReportExportProgress = (success: boolean, text: string, blobUrl?: string | null) => {
clearReportExportToastTimer()
reportExportStatus.value = success ? 'success' : 'error'
reportExportProgress.value = 100
reportExportText.value = text
reportExportBlobUrl.value = success && blobUrl ? blobUrl : null
reportExportToastOpen.value = true
reportExportToastTimer = setTimeout(() => {
reportExportToastOpen.value = false
}, success ? 2000 : 1800)
}
const openExportedReport = () => {
if (!reportExportBlobUrl.value) return
window.open(reportExportBlobUrl.value, '_blank')
reportExportToastOpen.value = false
}
const dismissReportToast = () => {
if (reportExportBlobUrl.value) {
URL.revokeObjectURL(reportExportBlobUrl.value)
reportExportBlobUrl.value = null
}
reportExportToastOpen.value = false
}
const tabsModel = computed({
get: () => tabStore.tabs,
set: (value) => {
tabStore.tabs = value
}
})
const activeTab = computed(() => tabStore.tabs.find((t: any) => t.id === tabStore.activeTabId) || null)
const activeTabId = computed(() => (activeTab.value ? String(activeTab.value.id || '') : ''))
const resetUiWorkspaceMode = ref<string>('')
const quickModeThemePreferenceSnapshot = ref<string | null | undefined>(undefined)
const workspaceModeForUi = computed(() =>
isResetting.value && resetUiWorkspaceMode.value
? resetUiWorkspaceMode.value
: readWorkspaceMode()
)
const contextTabIndex = computed(() => tabStore.tabs.findIndex((t:any) => t.id === contextTabId.value))
const hasClosableTabs = computed(() => {
const fixedId = workspaceModeForUi.value === 'quick' ? QUICK_TAB_ID : PROJECT_TAB_ID
return tabStore.tabs.some((t: any) => t.componentName !== fixedId)
})
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(1, contextTabIndex.value).length > 0
})
const canCloseRight = computed(() => {
if (contextTabIndex.value < 0) return false
return tabStore.tabs.slice(contextTabIndex.value + 1).length > 0
})
const canCloseOther = computed(() =>
tabStore.tabs.slice(1, contextTabIndex.value).length > 1 && contextTabIndex.value !== 0
)
const localeLabel = computed(() => (locale.value === 'en-US' ? 'EN' : '中'))
const projectCountText = computed(() => t('tab.toolbar.projectCount', { count: `${projectList.value.length}/${MAX_PROJECT_COUNT}` }))
const newProjectIndustryLabel = computed(() => {
const target = String(newProjectIndustry.value || '').trim()
if (!target) return ''
return getIndustryDisplayName(target, locale.value) || ''
})
const toggleLocale = () => {
const nextLocale: AppLocale = locale.value === 'en-US' ? 'zh-CN' : 'en-US'
setAppLocale(nextLocale)
window.location.reload()
}
const closeMenus = () => {
tabContextOpen.value = false
dataMenuOpen.value = false
projectMenuOpen.value = false
}
const refreshProjectList = async () => {
const baseProjects = listProjects()
openedProjectIds.value = Array.from(collectActiveProjectSessionLocks(baseProjects.map(item => item.id)))
const enriched = await Promise.all(
baseProjects.map(async (project) => {
try {
const kvStoreInstance = localforage.createInstance({
name: getProjectDbName(project.id),
storeName: 'pinia-kv'
})
const kvState = await kvStoreInstance.getItem<any>('pinia-kv')
const entries = kvState?.entries && typeof kvState.entries === 'object' ? kvState.entries : null
const projectInfo = entries?.[PROJECT_INFO_DB_KEY]
const projectName =
projectInfo && typeof projectInfo === 'object' && typeof projectInfo.projectName === 'string'
? projectInfo.projectName.trim()
: ''
return {
...project,
name: projectName || project.name
}
} catch {
return project
}
})
)
projectList.value = enriched
}
const isProjectOpen = (projectId: string) => openedProjectIds.value.includes(projectId)
const openProjectInNewTab = (projectId: string) => {
if (isProjectOpen(projectId)) return
const href = buildProjectUrl(projectId)
window.open(href, '_blank', 'noopener')
projectMenuOpen.value = false
}
const getTodayDateString = () => {
const now = new Date()
const year = String(now.getFullYear())
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const createProjectKvAdapter = (projectId: string) => {
const projectKvForage = localforage.createInstance({
name: getProjectDbName(projectId),
storeName: 'pinia-kv'
})
return {
setItem: async <T = unknown>(keyRaw: string | number, value: T) => {
const key = String(keyRaw || '').trim()
if (!key) return
const currentState = await projectKvForage.getItem<Record<string, unknown>>('pinia-kv')
const nextEntries = {
...(
currentState?.entries && typeof currentState.entries === 'object'
? (currentState.entries as Record<string, unknown>)
: {}
),
[key]: JSON.parse(JSON.stringify(value))
}
await projectKvForage.setItem('pinia-kv', {
...(currentState && typeof currentState === 'object' ? currentState : {}),
entries: nextEntries,
ready: true
})
}
}
}
const openCreateProjectDialog = () => {
if (listProjects().length >= MAX_PROJECT_COUNT) {
projectMenuOpen.value = false
projectLimitDialogOpen.value = true
return
}
projectMenuOpen.value = false
newProjectIndustry.value = String(industryTypeList[0]?.id || '')
newProjectDialogOpen.value = true
}
const closeCreateProjectDialog = () => {
if (newProjectSubmitting.value) return
newProjectDialogOpen.value = false
}
const createProjectAndOpen = async () => {
if (newProjectSubmitting.value) return
if (listProjects().length >= MAX_PROJECT_COUNT) {
projectMenuOpen.value = false
newProjectDialogOpen.value = false
projectLimitDialogOpen.value = true
return
}
const industry = String(newProjectIndustry.value || '').trim()
if (!industry) return
newProjectSubmitting.value = true
try {
const project = createProject()
const kvAdapter = createProjectKvAdapter(project.id)
await kvAdapter.setItem(PROJECT_INFO_DB_KEY, {
projectIndustry: industry,
projectName: DEFAULT_PROJECT_NAME,
preparedBy: '',
reviewedBy: '',
preparedCompany: '',
preparedDate: getTodayDateString()
})
await initializeProjectFactorStates(
kvAdapter,
industry,
CONSULT_CATEGORY_FACTOR_DB_KEY,
MAJOR_FACTOR_DB_KEY
)
void refreshProjectList()
const href = buildProjectUrl(project.id)
window.open(href, '_blank', 'noopener')
newProjectDialogOpen.value = false
} finally {
newProjectSubmitting.value = false
}
}
const handleCreateProjectDialogOpenChange = (open: boolean) => {
if (newProjectSubmitting.value) return
newProjectDialogOpen.value = open
}
const handleCreateProjectConfirm = () => {
void createProjectAndOpen()
projectMenuOpen.value = false
}
const formatProjectEditedTime = (value: string) => {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
const pad = (num: number) => String(num).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
const removeProjectItem = async (project: ProjectMeta) => {
const isCurrentProject = project.id === currentProjectId.value
const deleteIndexedDBByName = (dbName: string) =>
new Promise<void>((resolve) => {
try {
const request = window.indexedDB?.deleteDatabase(dbName)
if (!request) {
resolve()
return
}
request.onsuccess = () => resolve()
request.onerror = () => resolve()
request.onblocked = () => resolve()
} catch (_error) {
resolve()
}
})
const clearProjectPersistence = async () => {
await projectDefaultForage.clear()
await Promise.all(
getPiniaPersistStores().map(async ({ store }) => {
await store.clear()
})
)
const clearTasks: Promise<void>[] = []
if (tabStore.$clearPersisted) clearTasks.push(tabStore.$clearPersisted())
if (zxFwPricingStore.$clearPersisted) clearTasks.push(zxFwPricingStore.$clearPersisted())
if (zxFwPricingKeysStore.$clearPersisted) clearTasks.push(zxFwPricingKeysStore.$clearPersisted())
if (zxFwPricingHtFeeStore.$clearPersisted) clearTasks.push(zxFwPricingHtFeeStore.$clearPersisted())
if (kvStore.$clearPersisted) clearTasks.push(kvStore.$clearPersisted())
await Promise.all(clearTasks)
}
if (isCurrentProject) {
window.dispatchEvent(new CustomEvent('jgjs-release-project-lock'))
await clearProjectPersistence()
await deleteIndexedDBByName(getProjectDbName(project.id))
const removed = deleteProject(project.id)
if (!removed) return
const nextProject = createProject(DEFAULT_PROJECT_NAME)
const defaultIndustry = String(industryTypeList[0]?.id || '').trim()
if (defaultIndustry) {
const kvAdapter = createProjectKvAdapter(nextProject.id)
await kvAdapter.setItem(PROJECT_INFO_DB_KEY, {
projectIndustry: defaultIndustry,
projectName: DEFAULT_PROJECT_NAME,
preparedBy: '',
reviewedBy: '',
preparedCompany: '',
preparedDate: getTodayDateString()
})
await initializeProjectFactorStates(
kvAdapter,
defaultIndustry,
CONSULT_CATEGORY_FACTOR_DB_KEY,
MAJOR_FACTOR_DB_KEY
)
}
writeWorkspaceMode('project')
tabStore.resetTabs()
tabStore.hasCompletedSetup = false
window.location.href = buildProjectUrl(nextProject.id, {
newProject: true,
openProjectDialog: false,
forceHome: true
})
return
}
const removed = deleteProject(project.id)
if (!removed) return
try {
await deleteIndexedDBByName(getProjectDbName(project.id))
} catch (error) {
console.error('delete project database failed:', error)
}
await refreshProjectList()
}
const requestRemoveProjectItem = (project: ProjectMeta) => {
pendingDeleteProject.value = project
projectDeleteConfirmOpen.value = true
}
const handleProjectDeleteDialogOpenChange = (open: boolean) => {
projectDeleteConfirmOpen.value = open
}
const showMessageDialog = (title: string, description: string) => {
messageDialogTitle.value = title
messageDialogDesc.value = description
messageDialogOpen.value = true
}
const handleToggleTheme = () => {
toggleDark()
}
const cancelRemoveProjectItem = () => {
projectDeleteConfirmOpen.value = false
pendingDeleteProject.value = null
}
const confirmRemoveProjectItem = async () => {
const project = pendingDeleteProject.value
if (!project) return
projectDeleteConfirmOpen.value = false
pendingDeleteProject.value = null
await removeProjectItem(project)
}
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 !== 'ProjectCalcView')
const activeTabId = typeof parsed?.activeTabId === 'string' ? parsed.activeTabId : ''
return hasCustomTabs || (activeTabId !== '' && activeTabId !== 'ProjectCalcView')
} 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 kvStore.keys()
return keys.length === 0
} catch (error) {
console.error('read kv 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
}
if (projectMenuOpen.value && projectMenuRef.value && !projectMenuRef.value.contains(target)) {
projectMenuOpen.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 === tabStore.tabs[0]?.id) return false
if (typeof targetIndex === 'number' && targetIndex === 0) return false
return true
}
const handleTabDragStart = () => {
isTabDragging.value = true
}
const handleTabDragEnd = () => {
isTabDragging.value = false
}
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 createForageStore = (storeName: string): ForageInstance =>
localforage.createInstance({
name: PINIA_PERSIST_DB_NAME,
storeName
})
const projectDefaultForage = localforage.createInstance({
name: PINIA_PERSIST_DB_NAME
})
const getPiniaPersistStoreName = (storeId: string) => `${PINIA_PERSIST_BASE_STORE_NAME}-${storeId}`
const getPiniaPersistStores = () =>
PINIA_PERSIST_STORE_IDS.map(storeId => {
const storeName = getPiniaPersistStoreName(storeId)
return {
storeName,
store: createForageStore(storeName)
}
})
const loadFactorRowsState = async (storageKey: string) => {
const [piniaData, kvData] = await Promise.all([
zxFwPricingStore.loadKeyState<DetailRowsStorageLike<FactorRowLike>>(storageKey),
kvStore.getItem<DetailRowsStorageLike<FactorRowLike>>(storageKey)
])
return {
piniaData,
kvData,
resolved: piniaData || kvData || null
}
}
const createRichTextCode = (...parts: string[]): unknown => ({
richText: parts
.map(item => String(item || '').trim())
.filter(Boolean)
.map(text => ({ text }))
})
const normalizeHtFeeMainRows = (rows: HtFeeMainRowLike[] | undefined) => {
if (!Array.isArray(rows)) return [] as Array<{ id: string; name: string; subtotal?: unknown }>
const normalized = rows.map(row => {
const id = String(row?.id || '').trim()
if (!id) return null
const rowAny = row as { subtotal?: unknown }
return {
id,
name: typeof row?.name === 'string' ? row.name : '',
subtotal: rowAny?.subtotal
}
})
return normalized.filter(Boolean) as Array<{ id: string; name: string; subtotal?: unknown }>
}
const getHtMainRowSubtotal = (row: { subtotal?: unknown } | null | undefined): number | null => {
const subtotal = toFiniteNumber(row?.subtotal)
if (subtotal == null) return null
return toMoney(subtotal)
}
const loadHtFeeMethodsByRow = async (mainStorageKey: string, rowId: string) => {
const [rateState, hourlyState, quantityState] = await Promise.all([
zxFwPricingStore.loadHtFeeMethodState<RateMethodRowLike>(mainStorageKey, rowId, 'rate-fee'),
zxFwPricingStore.loadHtFeeMethodState<DetailRowsStorageLike<HourlyMethodRowLike>>(mainStorageKey, rowId, 'hourly-fee'),
zxFwPricingStore.loadHtFeeMethodState<DetailRowsStorageLike<QuantityMethodRowLike>>(mainStorageKey, rowId, 'quantity-unit-price-fee')
])
const m0 = buildMethod0(rateState)
const m4 = buildMethod4(Array.isArray(hourlyState?.detailRows) ? hourlyState?.detailRows : undefined)
const m5 = buildMethod5(Array.isArray(quantityState?.detailRows) ? quantityState?.detailRows : undefined)
if (!m0 && !m4 && !m5) return null
return {
fee: sumNumbers([m0?.fee, m4?.fee, m5?.fee]),
m0,
m4,
m5
}
}
const buildServiceTasks = async (
contractId: string,
serviceId: string
): Promise<ExportTaskGroup[]> => {
const taskState = await zxFwPricingStore.loadKeyState<WorkContentStateLike>(`work-content-${contractId}-${serviceId}`)
return groupWorkContentTasks(taskState?.detailRows)
}
const buildAdditionalRowTasks = async (contractId: string, rowId: string): Promise<ExportTaskGroup[]> => {
const taskStorageKey = `work-content-htExtraFee-${contractId}-additional-work-${rowId}`
const taskState = await zxFwPricingStore.loadKeyState<WorkContentStateLike>(taskStorageKey)
return groupWorkContentTasks(taskState?.detailRows, { forceUngroup: true })
}
const buildAdditionalExport = async (contractId: string): Promise<ExportAdditional | null> => {
const storageKey = `htExtraFee-${contractId}-additional-work`
const mainState = await zxFwPricingStore.loadHtFeeMainState<HtFeeMainRowLike>(storageKey)
const rows = normalizeHtFeeMainRows(mainState?.detailRows)
if (rows.length === 0) return null
const det = (
await Promise.all(
rows.map(async row => {
const methodPayload = await loadHtFeeMethodsByRow(storageKey, row.id)
const rowSubtotal = getHtMainRowSubtotal(row)
if (!methodPayload && rowSubtotal == null) return null
const tasks = await buildAdditionalRowTasks(contractId, row.id)
const item: ExportAdditionalDetail = {
id: row.id,
code: { richText: [{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'C' }, { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'F' }] },
name: row.name,
fee: toMoney(rowSubtotal ?? methodPayload?.fee ?? 0),
tasks
}
if (methodPayload?.m0) item.m0 = methodPayload.m0
if (methodPayload?.m4) item.m4 = methodPayload.m4
if (methodPayload?.m5) item.m5 = methodPayload.m5
return item
})
)
).filter((item): item is ExportAdditionalDetail => Boolean(item))
if (det.length === 0) return null
return {
code: { richText: [{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'C' }, { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'C' }] },
name: '附加工作',
fee: toMoney(sumNumbers(det.map(item => item.fee))),
det
}
}
const buildReserveExport = async (contractId: string): Promise<ExportReserve | null> => {
const storageKey = `htExtraFee-${contractId}-reserve`
const mainState = await zxFwPricingStore.loadHtFeeMainState<HtFeeMainRowLike>(storageKey)
const rows = normalizeHtFeeMainRows(mainState?.detailRows)
if (rows.length === 0) return null
for (const row of rows) {
const methodPayload = await loadHtFeeMethodsByRow(storageKey, row.id)
const rowSubtotal = getHtMainRowSubtotal(row)
if (!methodPayload && rowSubtotal == null) continue
const reserve: ExportReserve = {
name: row.name || '预备费',
fee: toMoney(rowSubtotal ?? methodPayload?.fee ?? 0),
tasks: []
}
if (methodPayload?.m0) reserve.m0 = methodPayload.m0
if (methodPayload?.m4) reserve.m4 = methodPayload.m4
if (methodPayload?.m5) reserve.m5 = methodPayload.m5
return reserve
}
return null
}
const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
const [projectInfoRaw, projectScaleRaw, consultCategoryFactorState, majorFactorState, contractCardsRaw] = await Promise.all([
kvStore.getItem<XmInfoLike>(PROJECT_INFO_DB_KEY),
kvStore.getItem<XmInfoStorageLike>(LEGACY_PROJECT_DB_KEY),
loadFactorRowsState(CONSULT_CATEGORY_FACTOR_DB_KEY),
loadFactorRowsState(MAJOR_FACTOR_DB_KEY),
kvStore.getItem<ContractCardItem[]>('ht-card-v1')
])
const projectInfo = projectInfoRaw || {}
const projectScaleSource = projectScaleRaw || {}
const projectScale = projectScaleSource.roughCalcEnabled ? [] : toExportScaleRows(projectScaleSource.detailRows)
const projectScaleCost = toFiniteNumber(projectScaleSource.totalAmount) ?? sumNumbers(projectScale.map(item => item.cost))
projectScale.push({
majorid: -1,
major: -1, cost: projectScaleCost,
area: null
})
const projectServiceCoes = buildProjectServiceCoes(consultCategoryFactorState.resolved?.detailRows)
const projectMajorCoes = buildProjectMajorCoes(majorFactorState.resolved?.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 company = isNonEmptyString(projectInfo.preparedCompany) ? projectInfo.preparedCompany.trim() : ''
const date = isNonEmptyString(projectInfo.preparedDate) ? projectInfo.preparedDate.trim() : ''
const industry = mapIndustryCodeToExportIndustry(projectInfo.projectIndustry)
const overview = isNonEmptyString(projectInfo.overview) ? projectInfo.overview.trim() : ''
const desc = isNonEmptyString(projectInfo.desc) ? projectInfo.desc.trim() : ''
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
await zxFwPricingStore.loadContract(contractId)
const [htInfoRaw, zxFwRaw, htConsultCategoryFactorState, htMajorFactorState, htBaseInfoRaw] = await Promise.all([
kvStore.getItem<DetailRowsStorageLike<ScaleRowLike>>(`ht-info-v3-${contractId}`),
kvStore.getItem<ZxFwStorageLike>(`zxFW-${contractId}`),
loadFactorRowsState(`ht-consult-category-factor-v1-${contractId}`),
loadFactorRowsState(`ht-major-factor-v1-${contractId}`),
zxFwPricingStore.loadKeyState<HtBaseInfoLike>(`ht-base-info-${contractId}`)
])
const contractState = zxFwPricingStore.getContractState(contractId)
const zxRowsFromStore: ZxFwRowLike[] = Array.isArray(contractState?.detailRows)
? contractState.detailRows.map(row => ({
id: String(row.id || ''),
process: row.process,
subtotal: row.subtotal,
finalFee: row.finalFee,
investScale: row.investScale,
landScale: row.landScale,
workload: row.workload,
hourly: row.hourly
}))
: []
const zxRowsFromKv = Array.isArray(zxFwRaw?.detailRows) ? zxFwRaw.detailRows : []
const zxRows = zxRowsFromStore.length > 0 ? zxRowsFromStore : zxRowsFromKv
const selectedIdsFromStore = Array.isArray(contractState?.selectedIds)
? contractState.selectedIds.map(id => String(id || '').trim()).filter(Boolean)
: []
const selectedIdsFromKv = Array.isArray(zxFwRaw?.selectedIds)
? zxFwRaw.selectedIds.map(id => String(id || '').trim()).filter(Boolean)
: []
const selectedIds = Array.from(new Set([...selectedIdsFromStore, ...selectedIdsFromKv])).filter(hasServiceId)
let fixedRow: ZxFwRowLike | undefined
const serviceRowMap = new Map<string, ZxFwRowLike>()
for (const row of zxRows) {
const rowId = String(row?.id || '').trim()
if (!rowId) continue
if (rowId === 'fixed-budget-c') {
fixedRow = row
continue
}
if (!hasServiceId(rowId)) continue
serviceRowMap.set(rowId, row)
}
const fallbackServiceIds = Array.from(serviceRowMap.keys())
const serviceIdTexts = sortServiceIdsByDict(
(selectedIds.length > 0 ? selectedIds : fallbackServiceIds).filter(hasServiceId)
)
const services = (
await Promise.all(
serviceIdTexts.map(async serviceIdText => {
const serviceId = toSafeInteger(serviceIdText)
if (serviceId == null) return null
const sourceRow = serviceRowMap.get(serviceIdText)
const [method1State, method2State, method3State, method4State] = await Promise.all([
zxFwPricingStore.loadServicePricingMethodState<ScaleMethodRowLike>(contractId, serviceIdText, 'investScale'),
zxFwPricingStore.loadServicePricingMethodState<ScaleMethodRowLike>(contractId, serviceIdText, 'landScale'),
zxFwPricingStore.loadServicePricingMethodState<WorkloadMethodRowLike>(contractId, serviceIdText, 'workload'),
zxFwPricingStore.loadServicePricingMethodState<HourlyMethodRowLike>(contractId, serviceIdText, 'hourly')
])
const method1Raw = method1State ? { detailRows: method1State.detailRows } : null
const method2Raw = method2State ? { detailRows: method2State.detailRows } : null
const method3Raw = method3State ? { detailRows: method3State.detailRows } : null
const method4Raw = method4State ? { detailRows: method4State.detailRows } : null
const method1 = buildMethod1(method1Raw?.detailRows)
const method2 = buildMethod2(method2Raw?.detailRows)
const method3 = buildMethod3(method3Raw?.detailRows)
const method4 = buildMethod4(method4Raw?.detailRows)
const fee = buildServiceFee(sourceRow, method1, method2, method3, method4)
const finalFee = buildServiceFinalFee(sourceRow, method1, method2, method3, method4)
const tasks = await buildServiceTasks(contractId, serviceIdText)
const process = Number(sourceRow?.process) === 1 ? 1 : 0
const service: ExportService = {
id: serviceId,
process,
fee,
finalFee,
tasks
}
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 fixedFinalFee = toFiniteNumber(fixedRow?.finalFee)
const fixedSubtotal = toFiniteNumber(fixedRow?.subtotal)
const serviceFeeRaw = fixedFinalFee ?? fixedSubtotal ?? 0
const serviceFee = toMoney(serviceFeeRaw)
const [addtional, reserve] = await Promise.all([
buildAdditionalExport(contractId),
buildReserveExport(contractId)
])
const addtionalFee = toMoney(addtional ? addtional.fee : 0)
const reserveFee = toMoney(reserve ? reserve.fee : 0)
const contractFee = toMoney(addNumbers(serviceFee, addtionalFee, reserveFee))
const contractScale = htInfoRaw?.roughCalcEnabled ? [] : toExportScaleRows(htInfoRaw?.detailRows)
contractScale.push({
majorid: -1,
major: -1, cost: contractFee,
area: null
})
const contractServiceCoesRaw = buildProjectServiceCoes(htConsultCategoryFactorState.resolved?.detailRows)
const contractMajorCoesRaw = buildProjectMajorCoes(htMajorFactorState.resolved?.detailRows)
console.log('[export][contract factor rows]', {
contractId,
consultFactorPinia: htConsultCategoryFactorState.piniaData,
consultFactorKv: htConsultCategoryFactorState.kvData,
consultFactorResolved: htConsultCategoryFactorState.resolved,
majorFactorPinia: htMajorFactorState.piniaData,
majorFactorKv: htMajorFactorState.kvData,
majorFactorResolved: htMajorFactorState.resolved,
contractServiceCoesRaw,
contractMajorCoesRaw
})
contracts.push({
name: isNonEmptyString(contract.name) ? contract.name : `合同段-${index + 1}`,
serviceFee,
addtionalFee,
reserveFee,
fee: contractFee,
quality: isNonEmptyString(htBaseInfoRaw?.quality) ? htBaseInfoRaw.quality.trim() : '',
duration: isNonEmptyString(htBaseInfoRaw?.duration) ? htBaseInfoRaw.duration.trim() : '',
scale: contractScale,
serviceCoes: contractServiceCoesRaw,
majorCoes: contractMajorCoesRaw,
services,
addtional,
reserve
})
}
return {
name: projectName,
writer,
reviewer,
company,
date,
industry,
fee: toMoney(sumNumbers(contracts.map(item => item.fee))),
scaleCost: projectScaleCost,
overview,
desc,
scale: projectScale,
serviceCoes: projectServiceCoes,
majorCoes: projectMajorCoes,
contracts
}
}
const exportData = async () => {
try {
const now = new Date()
const currentProjectId = readCurrentProjectId()
const piniaForageStores = await Promise.all(
getPiniaPersistStores().map(async ({ storeName, store }) => ({
storeName,
entries: await readForage(store)
}))
)
const payload: DataPackage = {
version: 3,
packageType: 'project-snapshot',
exportedAt: now.toISOString(),
projectId: currentProjectId,
localStorage: [],
sessionStorage: [],
localforageDefault: await readForage(projectDefaultForage),
localforageStores: piniaForageStores
}
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, PROJECT_INFO_DB_KEY, LEGACY_PROJECT_DB_KEY)
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)
showMessageDialog('导出失败', '请重试。')
} finally {
dataMenuOpen.value = false
}
}
const exportReport = async () => {
try {
const now = new Date()
const projectInfoRaw = await kvStore.getItem<XmInfoLike>(PROJECT_INFO_DB_KEY)
const projectName = isNonEmptyString(projectInfoRaw?.projectName) ? sanitizeFileNamePart(projectInfoRaw.projectName) : '造价项目'
const fileName = `${formatExportTimestamp(now)}-${projectName}预算文件`
const blobUrl = await exportFile(fileName, () => buildExportReportPayload(), () => {
showReportExportProgress(30, '正在生成报表文件...')
})
finishReportExportProgress(true, '报表导出完成', blobUrl)
} catch (error) {
console.error('export report failed:', error)
finishReportExportProgress(false, '报表导出失败,请重试')
} finally {
dataMenuOpen.value = false
}
}
const triggerImport = () => {
importFileRef.value?.click()
}
const prepareImportPayloadFromFile = async (
file: File,
options?: { skipConfirm?: boolean }
) => {
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)
if (!isDataPackageLike(payload)) {
throw new Error('INVALID_DATA_PACKAGE')
}
const currentProjectId = readCurrentProjectId()
const payloadProjectId = String(payload.projectId || '').trim()
if (!payloadProjectId) {
throw new Error('PROJECT_ID_MISSING')
}
if (payloadProjectId && payloadProjectId !== currentProjectId) {
throw new Error(`PROJECT_ID_MISMATCH:${payloadProjectId}:${currentProjectId}`)
}
pendingImportPayload.value = payload
pendingImportFileName.value = file.name
if (options?.skipConfirm) {
await confirmImportOverride()
return
}
importConfirmOpen.value = true
}
const importData = async (event: Event) => {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
try {
await prepareImportPayloadFromFile(file)
} catch (error) {
console.error('import failed:', error)
if (error instanceof Error && error.message === 'PROJECT_ID_MISSING') {
showMessageDialog('导入失败', '该数据包不包含项目标识(旧版本导出包),为避免串项目已禁止导入。')
return
}
if (error instanceof Error && error.message.startsWith('PROJECT_ID_MISMATCH:')) {
showMessageDialog('导入失败', '该数据包属于其他项目,不能覆盖当前项目。')
return
}
showMessageDialog('导入失败', '文件无效、已损坏或被修改。')
} finally {
input.value = ''
}
}
const cancelImportConfirm = () => {
importConfirmOpen.value = false
pendingImportPayload.value = null
pendingImportFileName.value = ''
}
const confirmImportOverride = async () => {
const payload = pendingImportPayload.value
if (!payload) return
try {
await writeForage(projectDefaultForage, normalizeEntries(payload.localforageDefault))
const piniaSnapshots = normalizeForageStoreSnapshots(payload.localforageStores)
const snapshotMap = new Map(piniaSnapshots.map(item => [item.storeName, item.entries]))
await Promise.all(
getPiniaPersistStores().map(async ({ storeName, store }) => {
const entries = snapshotMap.get(storeName) || []
await writeForage(store, entries)
})
)
const readPersistedState = (storeId: string) => {
const storeName = getPiniaPersistStoreName(storeId)
const entries = snapshotMap.get(storeName) || []
const hit = entries.find(entry => entry.key === storeName)
return hit && hit.value && typeof hit.value === 'object' ? hit.value : null
}
const tabsState = readPersistedState('tabs')
if (tabsState) {
tabStore.$patch(tabsState as any)
} else {
tabStore.resetTabs()
}
const zxFwPricingState = readPersistedState('zxFwPricing')
if (zxFwPricingState) {
zxFwPricingStore.$patch(zxFwPricingState as any)
}
const zxFwPricingKeysState = readPersistedState('zxFwPricingKeys')
if (zxFwPricingKeysState) {
zxFwPricingKeysStore.$patch(zxFwPricingKeysState as any)
}
const zxFwPricingHtFeeState = readPersistedState('zxFwPricingHtFee')
if (zxFwPricingHtFeeState) {
zxFwPricingHtFeeStore.$patch(zxFwPricingHtFeeState as any)
}
const kvState = readPersistedState('kv')
if (kvState) {
kvStore.$patch(kvState as any)
}
await Promise.all([
tabStore.$persistNow?.(),
zxFwPricingStore.$persistNow?.(),
zxFwPricingKeysStore.$persistNow?.(),
zxFwPricingHtFeeStore.$persistNow?.(),
kvStore.$persistNow?.()
])
dataMenuOpen.value = false
window.location.reload()
} catch (error) {
console.error('import apply failed:', error)
showMessageDialog('导入失败', '写入本地数据时发生错误。')
} finally {
cancelImportConfirm()
}
}
const handleReset = async () => {
if (isResetting.value) return
resetUiWorkspaceMode.value = readWorkspaceMode()
const wait = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
const resetStartedAt = Date.now()
const allProjectIds = Array.from(new Set(['default', 'quick', ...listProjects().map(item => item.id)]))
const deleteIndexedDBByName = (dbName: string) =>
new Promise<void>((resolve) => {
try {
const request = window.indexedDB?.deleteDatabase(dbName)
if (!request) {
resolve()
return
}
request.onsuccess = () => resolve()
request.onerror = () => resolve()
request.onblocked = () => {
// 被阻塞时也继续流程,刷新后浏览器会重试清理。
resolve()
}
} catch (_error) {
resolve()
}
})
const purgeKnownIndexedDB = async () => {
await Promise.all(allProjectIds.map(id => deleteIndexedDBByName(getProjectDbName(id))))
}
try {
isResetting.value = true
dataMenuOpen.value = false
projectMenuOpen.value = false
window.dispatchEvent(new CustomEvent('jgjs-release-project-lock'))
const themePreference =
typeof localStorage !== 'undefined' ? localStorage.getItem(THEME_PREFERENCE_KEY) : null
// 1) 仅清持久化层,不先改当前页面内存态,避免“先切首页再刷新”的二次闪烁。
localStorage.clear()
if (themePreference != null) {
localStorage.setItem(THEME_PREFERENCE_KEY, themePreference)
}
sessionStorage.clear()
await projectDefaultForage.clear()
// 2) 清 pinia 分库持久化
await Promise.all(
getPiniaPersistStores().map(async ({ store }) => {
await store.clear()
})
)
// 3) 清插件按 store 维护的持久化 key双保险
const clearTasks: Promise<void>[] = []
if (tabStore.$clearPersisted) clearTasks.push(tabStore.$clearPersisted())
if (zxFwPricingStore.$clearPersisted) clearTasks.push(zxFwPricingStore.$clearPersisted())
if (kvStore.$clearPersisted) clearTasks.push(kvStore.$clearPersisted())
await Promise.all(clearTasks)
// 4) 强制删除已知 IndexedDB避免页面卸载阶段旧组件回写脏数据
await purgeKnownIndexedDB()
// 5) 需要保留的最小标记恢复
writeProjectIdToUrl('default')
writeWorkspaceMode('project')
localStorage.setItem(USER_GUIDE_COMPLETED_KEY, '1')
// 6) 保证重置按钮进度动画至少可见 2s避免“刚点就消失”。
const elapsed = Date.now() - resetStartedAt
if (elapsed < RESET_MIN_LOADING_MS) {
await wait(RESET_MIN_LOADING_MS - elapsed)
}
// 7) 直接刷新,交由默认初始化重建空状态
window.location.reload()
} catch (error) {
console.error('reset failed:', error)
isResetting.value = false
resetUiWorkspaceMode.value = ''
}
}
const handleResetConfirmOpenChange = (open: boolean) => {
if (isResetting.value) return
resetConfirmOpen.value = open
}
onMounted(() => {
currentProjectId.value = readCurrentProjectId()
upsertProject(currentProjectId.value)
void refreshProjectList()
window.addEventListener('mousedown', handleGlobalMouseDown)
window.addEventListener('keydown', handleGlobalKeyDown)
window.addEventListener('resize', scheduleUpdateTabTitleOverflow)
const pendingHomeImportFile = consumePendingHomeImportFile()
const skipWorkspaceImportConfirm = consumePendingHomeImportSkipConfirm()
void nextTick(() => {
bindTabStripScroll()
ensureActiveTabVisible()
updateTabScrollButtons()
scheduleUpdateTabTitleOverflow()
scheduleRestoreTabInnerScrollTop(tabStore.activeTabId)
})
if (pendingHomeImportFile) {
void prepareImportPayloadFromFile(pendingHomeImportFile, { skipConfirm: skipWorkspaceImportConfirm }).catch(error => {
console.error('home import failed:', error)
showMessageDialog('导入失败', '文件无效、已损坏或被修改。')
})
}
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(
() => workspaceModeForUi.value,
(mode, prevMode) => {
if (typeof window === 'undefined') return
if (mode === 'quick') {
if (quickModeThemePreferenceSnapshot.value === undefined) {
quickModeThemePreferenceSnapshot.value = localStorage.getItem(THEME_PREFERENCE_KEY)
}
isDark.value = false
document.documentElement.classList.remove('dark')
return
}
if (prevMode !== 'quick' || quickModeThemePreferenceSnapshot.value === undefined) return
const snapshot = quickModeThemePreferenceSnapshot.value
quickModeThemePreferenceSnapshot.value = undefined
if (snapshot == null) {
localStorage.removeItem(THEME_PREFERENCE_KEY)
isDark.value = window.matchMedia('(prefers-color-scheme: dark)').matches
return
}
localStorage.setItem(THEME_PREFERENCE_KEY, snapshot)
isDark.value = snapshot === 'true'
},
{ immediate: true }
)
watch(
() => tabStore.activeTabId,
(nextId, prevId) => {
saveTabInnerScrollTop(prevId)
void nextTick(() => {
bindTabStripScroll()
ensureActiveTabVisible()
updateTabScrollButtons()
scheduleUpdateTabTitleOverflow()
scheduleRestoreTabInnerScrollTop(nextId)
})
}
)
watch(
() => tabStore.tabs.map((t:any) => 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>
<ToastProvider>
<TooltipProvider>
<div class="flex flex-col w-full h-screen bg-background overflow-hidden">
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-2 border-b bg-muted/30 px-2 pt-1 h-15 flex-none">
<div class="flex min-w-0 items-start gap-1 h-full" @mouseenter="isTabStripHover = true"
@mouseleave="isTabStripHover = false">
<button type="button" :class="[
'h-9 w-8 self-center 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="h-full tab-strip-scroll-area min-w-0 flex-1 whitespace-nowrap">
<draggable v-model="tabsModel" item-key="id" tag="div"
:class="['tab-strip-sortable h-[calc(3.50rem)] flex w-max gap-0', isTabDragging ? 'is-dragging' : '']"
:animation="260" easing="cubic-bezier(0.22, 1, 0.36, 1)" ghost-class="tab-drag-ghost"
chosen-class="tab-drag-chosen" drag-class="tab-drag-active" :move="canMoveTab" @start="handleTabDragStart"
@end="handleTabDragEnd">
<template #item="{ element: tab, index }">
<div :ref="el => setTabItemRef(tab.id, el)" @mousedown.left="tabStore.activeTabId = tab.id"
@contextmenu.prevent="openTabContextMenu($event, tab.id)" :class="[
'tab-item group relative -mb-px -ml-px first:ml-0 flex items-center h-full px-4 min-w-[120px] max-w-[220px] cursor-pointer rounded-t-md border border-transparent transition-[background-color,border-color,color,box-shadow,transform] duration-200 text-sm',
tabStore.activeTabId === tab.id && !isTabDragging
? 'z-10 bg-background text-foreground !border-border !border-b-0 font-medium'
: 'bg-muted/25 text-muted-foreground hover:bg-muted/40 hover:text-foreground hover:border-border/70',
index !== 0 ? '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="index !== 0" 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="[
' self-center 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 class="flex shrink-0 self-center items-center gap-1">
<Button
v-if="workspaceModeForUi !== 'quick'"
variant="ghost"
size="icon"
class="h-9 w-9 shrink-0 cursor-pointer text-muted-foreground transition-all duration-200 hover:text-foreground"
:disabled="isResetting"
:title="isDark ? t('tab.toolbar.dark') : t('tab.toolbar.light')"
:aria-label="isDark ? t('tab.toolbar.dark') : t('tab.toolbar.light')"
@click="handleToggleTheme"
>
<component
:is="isDark ? Moon : Sun"
class="h-4 w-4 transition-transform duration-200"
:class="isDark ? 'rotate-0' : 'rotate-180'"
/>
</Button>
<Button
v-if="workspaceModeForUi !== 'quick'"
variant="ghost"
size="sm"
class="h-9 min-w-9 px-2 shrink-0 cursor-pointer text-muted-foreground transition-all duration-200 hover:text-foreground"
:disabled="isResetting"
:title="t('tab.toolbar.language')"
:aria-label="t('tab.toolbar.language')"
@click="toggleLocale"
>
{{ localeLabel }}
</Button>
<div v-if="workspaceModeForUi !== 'quick'" ref="dataMenuRef" class="relative shrink-0">
<Button variant="outline" size="sm"
class="app-toolbar-btn shrink-0 cursor-pointer transition-all duration-200"
:class="dataMenuOpen ? 'border-primary/40 bg-primary/5 text-foreground' : ''"
:disabled="isResetting"
@click="dataMenuOpen = !dataMenuOpen">
<ChevronDown
class="mr-1 h-4 w-4 transition-transform duration-200"
:class="dataMenuOpen ? 'rotate-180' : 'rotate-0'"
/>
{{ t('tab.toolbar.importExport') }}
</Button>
<Transition name="toolbar-dropdown">
<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 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isResetting"
@click="triggerImport">
{{ t('tab.toolbar.importData') }}
</button>
<button class="w-full cursor-pointer rounded px-3 py-1.5 text-left text-sm hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isResetting"
@click="exportData">
{{ t('tab.toolbar.exportData') }}
</button>
<button class="w-full cursor-pointer rounded px-3 py-1.5 text-left text-sm hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isResetting"
v-if="workspaceModeForUi !== 'quick'"
@click="exportReport">
{{ t('tab.toolbar.exportReport') }}
</button>
</div>
</Transition>
<input ref="importFileRef" type="file" accept=".zw" class="hidden" @change="importData" />
</div>
<Button v-if="workspaceModeForUi !== 'quick'" variant="outline" size="sm" class="app-toolbar-btn shrink-0 cursor-pointer"
:disabled="isResetting"
@click="openUserGuide(0)">
<CircleHelp class="h-4 w-4 mr-1" />
{{ t('tab.toolbar.userGuide') }}
</Button>
<Button
v-if="workspaceModeForUi === 'quick'"
variant="destructive"
size="sm"
class="app-toolbar-btn shrink-0 cursor-pointer"
:disabled="isResetting"
@click="resetConfirmOpen = true"
>
<Loader2 v-if="isResetting" class="mr-1 h-4 w-4 animate-spin" />
{{ isResetting ? t('tab.toolbar.resetting') : t('tab.toolbar.reset') }}
</Button>
<div v-if="workspaceModeForUi !== 'quick'" ref="projectMenuRef" class="relative shrink-0">
<Button
variant="outline"
size="sm"
class="app-toolbar-btn shrink-0 cursor-pointer transition-all duration-200"
:class="projectMenuOpen ? 'border-primary/40 bg-primary/5 text-foreground' : ''"
:disabled="isResetting"
@click="projectMenuOpen = !projectMenuOpen; if (projectMenuOpen) void refreshProjectList()"
>
<ChevronDown
class="mr-1 h-4 w-4 transition-transform duration-200"
:class="projectMenuOpen ? 'rotate-180' : 'rotate-0'"
/>
{{ t('tab.toolbar.projectList') }}
</Button>
<Transition name="toolbar-dropdown">
<div
v-if="projectMenuOpen"
class="absolute right-0 top-full z-50 mt-1 w-[420px] rounded-md border bg-background p-2 shadow-md"
>
<div class="max-h-56 space-y-1 overflow-auto">
<div
v-for="project in projectList"
:key="project.id"
class="flex items-center gap-2 rounded px-2 py-1.5"
:class="isProjectOpen(project.id) ? 'opacity-60' : 'hover:bg-muted'"
>
<button
class="flex-1 text-left text-sm"
:class="isProjectOpen(project.id) ? 'cursor-not-allowed' : 'cursor-pointer'"
:disabled="isProjectOpen(project.id)"
@click="openProjectInNewTab(project.id)"
>
<div class="font-medium leading-5">
{{ project.name }}
<span v-if="isProjectOpen(project.id)" class="ml-1 text-xs text-muted-foreground">{{ t('tab.toolbar.opened') }}</span>
</div>
<div class="text-xs text-muted-foreground">{{ t('tab.toolbar.lastEdited', { time: formatProjectEditedTime(project.updatedAt) }) }}</div>
</button>
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
@click="requestRemoveProjectItem(project)"
>
{{ t('common.delete') }}
</Button>
</div>
</div>
<div class="mt-2 flex items-center justify-end gap-2 border-t pt-2">
<span class="mr-auto text-xs text-muted-foreground">{{ projectCountText }}</span>
<Button size="sm" class="h-8 px-3 text-xs" @click="openCreateProjectDialog">
{{ t('tab.toolbar.createProject') }}
</Button>
<Button variant="destructive" size="sm" class="h-8 px-3 text-xs" @click="resetConfirmOpen = true">
{{ t('tab.toolbar.resetAll') }}
</Button>
</div>
</div>
</Transition>
</div>
<AlertDialogRoot :open="resetConfirmOpen" @update:open="handleResetConfirmOpenChange">
<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">{{ t('tab.dialog.resetTitle') }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
{{ t('tab.dialog.resetDesc') }}
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<Button variant="outline" :disabled="isResetting" @click="resetConfirmOpen = false">{{ t('common.cancel') }}</Button>
<Button variant="destructive" :disabled="isResetting" @click="handleReset">
<Loader2 v-if="isResetting" class="mr-1 h-4 w-4 animate-spin" />
{{ isResetting ? t('tab.toolbar.resetting') : t('tab.dialog.confirmReset') }}
</Button>
</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">{{ t('tab.dialog.importOverrideTitle') }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
{{ t('tab.dialog.importOverrideDesc', { file: pendingImportFileName || t('home.cards.pickFile') }) }}
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child>
<Button variant="outline" @click="cancelImportConfirm">{{ t('common.cancel') }}</Button>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button variant="destructive" @click="confirmImportOverride">{{ t('tab.dialog.confirmOverride') }}</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
<AlertDialogRoot :open="newProjectDialogOpen" @update:open="handleCreateProjectDialogOpenChange">
<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">{{ t('tab.dialog.newProjectTitle') }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
{{ t('tab.dialog.newProjectDesc') }}
</AlertDialogDescription>
<div class="mt-4 space-y-2">
<label class="text-sm font-medium text-foreground">{{ t('home.dialog.industry') }}</label>
<SelectRoot v-model="newProjectIndustry">
<SelectTrigger
class="inline-flex h-11 w-full items-center justify-between rounded-xl border border-border/70 bg-[linear-gradient(180deg,rgba(248,250,252,0.98),rgba(241,245,249,0.92))] px-4 text-sm text-foreground shadow-sm outline-none transition hover:border-border focus-visible:ring-2 focus-visible:ring-slate-300"
>
<span :class="newProjectIndustryLabel ? 'text-foreground' : 'text-muted-foreground'">
{{ newProjectIndustryLabel || t('home.dialog.selectIndustry') }}
</span>
<SelectIcon as-child>
<ChevronDown class="h-4 w-4 text-muted-foreground" />
</SelectIcon>
</SelectTrigger>
<SelectPortal>
<SelectContent
:side-offset="6"
position="popper"
class="z-[120] w-[var(--reka-select-trigger-width)] overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-xl"
>
<SelectViewport class="p-1">
<SelectItem
v-for="item in industryTypeList"
:key="`new-project-${item.id}`"
:value="String(item.id)"
class="relative flex h-9 w-full cursor-default select-none items-center rounded-md pl-3 pr-8 text-sm outline-none data-[highlighted]:bg-muted data-[highlighted]:text-foreground data-[state=checked]:bg-slate-100"
>
<SelectItemText>{{ getIndustryDisplayName(item.id, locale) }}</SelectItemText>
<SelectItemIndicator class="absolute right-2 inline-flex items-center text-slate-700">
<Check class="h-4 w-4" />
</SelectItemIndicator>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
</div>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child>
<Button variant="outline" :disabled="newProjectSubmitting" @click="closeCreateProjectDialog">{{ t('common.cancel') }}</Button>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button :disabled="newProjectSubmitting || !newProjectIndustry" @click="handleCreateProjectConfirm">
<Loader2 v-if="newProjectSubmitting" class="mr-1 h-4 w-4 animate-spin" />
{{ newProjectSubmitting ? t('tab.dialog.creating') : t('tab.dialog.createAndOpen') }}
</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
<AlertDialogRoot :open="projectLimitDialogOpen" @update:open="projectLimitDialogOpen = $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">{{ t('tab.dialog.projectLimitTitle') }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
{{ t('tab.dialog.projectLimitDesc', { max: MAX_PROJECT_COUNT }) }}
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogAction as-child>
<Button @click="projectLimitDialogOpen = false">{{ t('tab.dialog.iKnow') }}</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
<AlertDialogRoot :open="projectDeleteConfirmOpen" @update:open="handleProjectDeleteDialogOpenChange">
<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">{{ t('tab.dialog.deleteProjectTitle') }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
{{ pendingDeleteProject?.id === currentProjectId
? t('tab.dialog.deleteCurrentProjectDesc', { name: pendingDeleteProject?.name || '' })
: t('tab.dialog.deleteProjectDesc', { name: pendingDeleteProject?.name || '' }) }}
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<Button variant="outline" @click="cancelRemoveProjectItem">{{ t('common.cancel') }}</Button>
<Button variant="destructive" @click="confirmRemoveProjectItem">{{ t('common.confirm') }}</Button>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
<AlertDialogRoot :open="messageDialogOpen" @update:open="messageDialogOpen = $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">{{ messageDialogTitle }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
{{ messageDialogDesc }}
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogAction as-child>
<Button @click="messageDialogOpen = false">{{ t('tab.dialog.iKnow') }}</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</div>
</div>
<div v-if="isResetting" class="fixed inset-0 z-40 cursor-wait bg-transparent" />
<div class="flex-1 overflow-auto relative">
<div
v-if="activeTab"
:key="activeTab.id"
:ref="el => setTabPanelRef(activeTabId, el)"
class="h-full w-full animate-in fade-in duration-300"
>
<component :is="componentMap[activeTab.componentName]" v-bind="activeTab.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')">
{{ t('tab.menu.closeAll') }}
</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')">
{{ t('tab.menu.closeLeft') }}
</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')">
{{ t('tab.menu.closeRight') }}
</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')">
{{ t('tab.menu.closeOther') }}
</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">{{ t('tab.guide.title') }} · {{ 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)">{{ t('tab.guide.later') }}</Button>
<Button variant="outline" :disabled="isFirstGuideStep" @click="prevUserGuideStep">{{ t('tab.guide.prev') }}</Button>
<Button @click="nextUserGuideStep">{{ isLastGuideStep ? t('tab.guide.finish') : t('tab.guide.next') }}</Button>
</div>
</div>
</div>
</div>
</div>
<ToastRoot
v-model:open="reportExportToastOpen"
:duration="0"
class="pointer-events-auto rounded-xl border border-border bg-card px-4 py-3 text-foreground shadow-lg"
@update:open="(val) => { if (!val) dismissReportToast() }"
>
<div class="flex items-start justify-between gap-2">
<ToastTitle class="text-sm font-semibold text-foreground">
{{ reportExportStatus === 'running' ? t('tab.toast.export') : (reportExportStatus === 'success' ? t('tab.toast.success') : t('tab.toast.failed')) }}
</ToastTitle>
<Button
variant="ghost"
size="sm"
class="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
@click="dismissReportToast"
>
<X class="h-3.5 w-3.5" />
</Button>
</div>
<ToastDescription class="mt-1 text-xs text-muted-foreground">{{ reportExportText }}</ToastDescription>
<!-- <div v-if="reportExportStatus === 'success' && reportExportBlobUrl" class="mt-2 flex items-center gap-2">
<Button size="sm" class="h-7 rounded-md px-3 text-xs" @click="openExportedReport">
打开文件
</Button>
<Button variant="ghost" size="sm" class="h-7 rounded-md px-2 text-xs text-muted-foreground" @click="dismissReportToast">
关闭
</Button>
</div> -->
<div class="mt-2 flex items-center gap-2">
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
<div
class="h-full transition-all duration-300"
:class="reportExportStatus === 'error'
? 'bg-red-500'
: (reportExportStatus === 'success' ? 'bg-foreground/70' : 'bg-foreground')"
:style="{ width: `${reportExportProgress}%` }"
/>
</div>
<span class="shrink-0 text-[11px] tabular-nums text-muted-foreground">{{ reportExportProgress }}%</span>
</div>
</ToastRoot>
<ToastViewport class="fixed bottom-5 right-5 z-[85] flex w-[380px] max-w-[92vw] flex-col gap-2 outline-none" />
</TooltipProvider>
</ToastProvider>
</template>
<style scoped src="@/features/tab/tab.css"></style>