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

2309 lines
91 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 { UI_PREFS_STORAGE_KEY, useUiPrefsStore } from '@/pinia/uiPrefs'
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 { emitProjectDeleted, emitResetAll } from '@/lib/projectEvents'
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 {
getAdditionalWorkListEntries,
getIndustryDisplayName,
getReserveListEntries,
getServiceDictItemById,
industryTypeList
} from '@/sql'
import { initializeProjectFactorStates } from '@/lib/projectWorkspace'
import { createProjectKvAdapter } from '@/lib/projectKvStore'
import { I18N_LOCALE_KEY, type AppLocale } from '@/i18n'
const { t, locale } = useI18n()
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 = t('xmInfo.defaultProjectName')
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 = computed<UserGuideStep[]>(() => {
const stepKeys = ['step1', 'step2', 'step3', 'step4', 'step5', 'step6', 'step7', 'step8']
return stepKeys.map(step => ({
title: t(`tab.guide.steps.${step}.title`),
description: t(`tab.guide.steps.${step}.description`),
points: [
t(`tab.guide.steps.${step}.point1`),
t(`tab.guide.steps.${step}.point2`),
t(`tab.guide.steps.${step}.point3`)
]
}))
})
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 uiPrefsStore = useUiPrefsStore()
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.value[userGuideStepIndex.value] || userGuideSteps.value[0]
)
const isFirstGuideStep = computed(() => userGuideStepIndex.value === 0)
const isLastGuideStep = computed(() => userGuideStepIndex.value >= userGuideSteps.value.length - 1)
const guideProgressText = computed(() => `${userGuideStepIndex.value + 1} / ${userGuideSteps.value.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'
uiPrefsStore.setLocale(nextLocale)
}
const resolveHtFeeMethodRowNameByDict = (
storageKeyRaw: string,
rowIdRaw: string,
fallbackRaw: string
) => {
const storageKey = String(storageKeyRaw || '').trim()
const rowId = String(rowIdRaw || '').trim()
const fallback = String(fallbackRaw || '').trim()
if (!storageKey || !rowId) return fallback
if (storageKey.includes('-additional-work')) {
const item = getAdditionalWorkListEntries(locale.value).find(entry => String(entry?.id || '').trim() === rowId)
return String(item?.name || '').trim() || fallback
}
if (storageKey.includes('-reserve')) {
const item = getReserveListEntries(locale.value).find(entry => String(entry?.id || '').trim() === rowId)
return String(item?.name || '').trim() || fallback
}
return fallback
}
const syncTabLabelsByLocale = () => {
const nextProjectTitle = t('home.projectCalcTab')
const nextQuickTitle = t('home.quickCalcTab')
let changed = false
tabStore.tabs = tabStore.tabs.map((tab: any) => {
const currentProps = tab?.props && typeof tab.props === 'object' ? { ...tab.props } : undefined
if (tab.id === PROJECT_TAB_ID) {
if (tab.title === nextProjectTitle) return tab
changed = true
return { ...tab, title: nextProjectTitle }
}
if (tab.id === QUICK_TAB_ID) {
if (tab.title === nextQuickTitle) return tab
changed = true
return { ...tab, title: nextQuickTitle }
}
if (tab.componentName === 'QuickCalcView') {
const contractName = String(currentProps?.contractName || '').trim()
const nextTitle = t('ht.contractTabTitle', { name: contractName || '-' })
if (tab.title === nextTitle) return tab
changed = true
return { ...tab, title: nextTitle }
}
if (tab.componentName === 'ZxFwView') {
const serviceId = String(currentProps?.serviceId || '').trim()
const dict = serviceId ? (getServiceDictItemById(serviceId) as { code?: string; name?: string } | undefined) : undefined
const serviceName = dict
? `${String(dict.code || '').trim()}${String(dict.name || '').trim()}`
: String(currentProps?.fwName || '').trim()
const nextTitle = t('htZxFw.editTabTitle', { name: serviceName || '-' })
const nextProps = {
...(currentProps || {}),
fwName: serviceName || String(currentProps?.fwName || '')
}
if (tab.title === nextTitle && String(currentProps?.fwName || '') === String(nextProps.fwName || '')) return tab
changed = true
return { ...tab, title: nextTitle, props: nextProps }
}
if (tab.componentName === 'HtFeeMethodTypeLineView') {
const storageKey = String(currentProps?.storageKey || '').trim()
const rowId = String(currentProps?.rowId || '').trim()
const nextSourceTitle = storageKey.includes('-reserve')
? t('htSummary.reservePrefix')
: storageKey.includes('-additional-work')
? t('htSummary.additionalPrefix')
: String(currentProps?.sourceTitle || '')
const rows = storageKey
? (zxFwPricingStore.getHtFeeMainState<any>(storageKey)?.detailRows || [])
: []
const fromStoreName = Array.isArray(rows)
? String(rows.find((row: any) => String(row?.id || '') === rowId)?.name || '').trim()
: ''
const rowName = resolveHtFeeMethodRowNameByDict(
storageKey,
rowId,
fromStoreName || String(currentProps?.rowName || '').trim()
)
const nextTitle = t('htFeeGrid.editTabTitle', { name: rowName || t('htFeeGrid.unnamed') })
const nextProps = {
...(currentProps || {}),
sourceTitle: nextSourceTitle,
rowName
}
if (
tab.title === nextTitle
&& String(currentProps?.rowName || '') === String(nextProps.rowName || '')
&& String(currentProps?.sourceTitle || '') === String(nextProps.sourceTitle || '')
) return tab
changed = true
return { ...tab, title: nextTitle, props: nextProps }
}
return tab
})
if (!changed) return
scheduleUpdateTabTitleOverflow()
}
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 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(DEFAULT_PROJECT_NAME)
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 returnToHome = () => {
projectMenuOpen.value = false
const href = buildProjectUrl(currentProjectId.value || readCurrentProjectId(), {
forceHome: true
})
window.location.href = href
}
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
emitProjectDeleted(project.id)
writeWorkspaceMode('project')
tabStore.resetTabs()
tabStore.hasCompletedSetup = false
window.location.href = buildProjectUrl('default', { forceHome: true })
return
}
const removed = deleteProject(project.id)
if (!removed) return
emitProjectDeleted(project.id)
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.value.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.value.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 resolveMethodEnabled = (value: unknown, fallback: boolean) => {
if (typeof value === 'boolean') return value
if (typeof value === 'number') return value === 1
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase()
if (normalized === 'true' || normalized === '1') return true
if (normalized === 'false' || normalized === '0') return false
}
return fallback
}
const getServiceMethodAvailability = (serviceIdText: string) => {
const dict = getServiceDictItemById(serviceIdText) as {
scale?: boolean | null
onlyCostScale?: boolean | null
amount?: boolean | null
workDay?: boolean | null
} | null | undefined
const scale = resolveMethodEnabled(dict?.scale, false)
const onlyCostScale = resolveMethodEnabled(dict?.onlyCostScale, false)
const amount = resolveMethodEnabled(dict?.amount, false)
const workDay = resolveMethodEnabled(dict?.workDay, false)
return {
investmentScale: scale,
landScale: scale && !onlyCostScale,
workload: amount,
hourly: workDay
}
}
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: t('htSummary.additionalPrefix'),
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
const reserveCodeTemplate = getReserveListEntries(locale.value)?.[0]?.code
?? {
richText: [
{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'Y' },
{ font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'B' }
]
}
for (const row of rows) {
const methodPayload = await loadHtFeeMethodsByRow(storageKey, row.id)
const rowSubtotal = getHtMainRowSubtotal(row)
if (!methodPayload && rowSubtotal == null) continue
const reserve: ExportReserve = {
code: reserveCodeTemplate,
name: row.name || t('htSummary.reservePrefix'),
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))
const projectServiceCoes = buildProjectServiceCoes(consultCategoryFactorState.resolved?.detailRows)
const projectMajorCoes = buildProjectMajorCoes(majorFactorState.resolved?.detailRows)
const projectName = isNonEmptyString(projectInfo.projectName)
? projectInfo.projectName.trim()
: t('tab.messages.defaultProjectName')
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 methodAvailability = getServiceMethodAvailability(serviceIdText)
const [method1State, method2State, method3State, method4State] = await Promise.all([
methodAvailability.investmentScale
? zxFwPricingStore.loadServicePricingMethodState<ScaleMethodRowLike>(contractId, serviceIdText, 'investScale')
: Promise.resolve(null),
methodAvailability.landScale
? zxFwPricingStore.loadServicePricingMethodState<ScaleMethodRowLike>(contractId, serviceIdText, 'landScale')
: Promise.resolve(null),
methodAvailability.workload
? zxFwPricingStore.loadServicePricingMethodState<WorkloadMethodRowLike>(contractId, serviceIdText, 'workload')
: Promise.resolve(null),
methodAvailability.hourly
? zxFwPricingStore.loadServicePricingMethodState<HourlyMethodRowLike>(contractId, serviceIdText, 'hourly')
: Promise.resolve(null)
])
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 method2RawRows = Array.isArray(method2Raw?.detailRows) ? method2Raw.detailRows : []
const method1 = methodAvailability.investmentScale ? buildMethod1(method1Raw?.detailRows) : null
const method2 = methodAvailability.landScale ? buildMethod2(method2Raw?.detailRows) : null
const method3 = methodAvailability.workload ? buildMethod3(method3Raw?.detailRows) : null
const method4 = methodAvailability.hourly ? buildMethod4(method4Raw?.detailRows) : null
if (methodAvailability.landScale && method2RawRows.length > 0 && method2?.det?.length != null) {
const rawLen = method2RawRows.length
const detLen = method2.det.length
if (rawLen > detLen) {
console.warn('[export][landScale-duplicate-rows-deduped]', {
contractId,
serviceId: serviceIdText,
rawRows: rawLen,
exportedRows: detLen
})
}
}
const sanitizedSourceRow = sourceRow
? {
...sourceRow,
investScale: methodAvailability.investmentScale ? sourceRow.investScale : null,
landScale: methodAvailability.landScale ? sourceRow.landScale : null,
workload: methodAvailability.workload ? sourceRow.workload : null,
hourly: methodAvailability.hourly ? sourceRow.hourly : null,
subtotal: sumNumbers([
methodAvailability.investmentScale ? toFiniteNumber(sourceRow.investScale) : 0,
methodAvailability.landScale ? toFiniteNumber(sourceRow.landScale) : 0,
methodAvailability.workload ? toFiniteNumber(sourceRow.workload) : 0,
methodAvailability.hourly ? toFiniteNumber(sourceRow.hourly) : 0
])
}
: sourceRow
const fee = buildServiceFee(sanitizedSourceRow, method1, method2, method3, method4)
const finalFee = buildServiceFinalFee(sanitizedSourceRow, method1, method2, method3, method4)
const tasks = await buildServiceTasks(contractId, serviceIdText)
const process = Number(sourceRow?.process) === 1 ? 1 : 0
const disabledMethodLeak = {
investScale: !methodAvailability.investmentScale && (toFiniteNumber(sourceRow?.investScale) != null || Boolean(method1Raw)),
landScale: !methodAvailability.landScale && (toFiniteNumber(sourceRow?.landScale) != null || Boolean(method2Raw)),
workload: !methodAvailability.workload && (toFiniteNumber(sourceRow?.workload) != null || Boolean(method3Raw)),
hourly: !methodAvailability.hourly && (toFiniteNumber(sourceRow?.hourly) != null || Boolean(method4Raw))
}
if (disabledMethodLeak.investScale || disabledMethodLeak.landScale || disabledMethodLeak.workload || disabledMethodLeak.hourly) {
console.warn('[export][method-disabled-leak-detected]', {
contractId,
serviceId: serviceIdText,
methodAvailability,
sourceRow,
hasMethodPayload: {
method1: Boolean(method1Raw),
method2: Boolean(method2Raw),
method3: Boolean(method3Raw),
method4: Boolean(method4Raw)
},
disabledMethodLeak
})
}
console.log('[export][service-methods]', {
contractId,
serviceId: serviceIdText,
methodAvailability,
exported: {
method1: Boolean(method1),
method2: Boolean(method2),
method3: Boolean(method3),
method4: Boolean(method4)
},
fee,
finalFee
})
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)
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
: t('tab.messages.contractFallbackName', { index: 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(t('tab.toast.failed'), t('ht.retry'))
} 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)
: t('tab.messages.defaultProjectName')
const fileName = `${formatExportTimestamp(now)}-${projectName}${t('tab.messages.reportFileSuffix')}`
const blobUrl = await exportFile(fileName, () => buildExportReportPayload(), () => {
showReportExportProgress(30, t('tab.messages.reportGenerating'))
})
finishReportExportProgress(true, t('tab.messages.reportExportDone'), blobUrl)
} catch (error) {
console.error('export report failed:', error)
finishReportExportProgress(false, t('tab.messages.reportExportFailedRetry'))
} 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')
}
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)
showMessageDialog(t('tab.messages.importFailedTitle'), t('tab.messages.importInvalidFile'))
} 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)
}
// 导入快照可能携带旧语言标题,先按当前 locale 归一化一次再持久化。
syncTabLabelsByLocale()
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(t('tab.messages.importFailedTitle'), t('tab.messages.importWriteError'))
} 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
emitResetAll()
window.dispatchEvent(new CustomEvent('jgjs-release-project-lock'))
const themePreference =
typeof localStorage !== 'undefined' ? localStorage.getItem(THEME_PREFERENCE_KEY) : null
const localePreference =
typeof localStorage !== 'undefined' ? localStorage.getItem(I18N_LOCALE_KEY) : null
const uiPrefsSnapshot =
typeof localStorage !== 'undefined' ? localStorage.getItem(UI_PREFS_STORAGE_KEY) : null
// 1) 仅清持久化层,不先改当前页面内存态,避免“先切首页再刷新”的二次闪烁。
localStorage.clear()
if (themePreference != null) {
localStorage.setItem(THEME_PREFERENCE_KEY, themePreference)
}
if (localePreference != null) {
localStorage.setItem(I18N_LOCALE_KEY, localePreference)
}
if (uiPrefsSnapshot != null) {
localStorage.setItem(UI_PREFS_STORAGE_KEY, uiPrefsSnapshot)
}
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 skipWorkspaceImportConfirm = consumePendingHomeImportSkipConfirm()
void nextTick(() => {
bindTabStripScroll()
ensureActiveTabVisible()
updateTabScrollButtons()
scheduleUpdateTabTitleOverflow()
scheduleRestoreTabInnerScrollTop(tabStore.activeTabId)
})
void (async () => {
const pendingHomeImportFile = await consumePendingHomeImportFile()
if (!pendingHomeImportFile) return
await prepareImportPayloadFromFile(pendingHomeImportFile, { skipConfirm: skipWorkspaceImportConfirm }).catch(error => {
console.error('home import failed:', error)
showMessageDialog(t('tab.messages.importFailedTitle'), t('tab.messages.importInvalidFile'))
})
})()
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()
})
}
)
watch(
() => locale.value,
() => {
syncTabLabelsByLocale()
},
{ immediate: true }
)
</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="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>
<Button
v-if="workspaceModeForUi === 'quick'"
variant="outline"
size="sm"
class="app-toolbar-btn shrink-0 cursor-pointer"
@click="returnToHome"
>
{{ t('tab.toolbar.backHome') }}
</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="outline" size="sm" class="h-8 px-3 text-xs" @click="returnToHome">
{{ t('tab.toolbar.backHome') }}
</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}-${locale}`"
: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="t('tab.guide.jumpToStep', { index: 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">
{{ t('tab.messages.openFile') }}
</Button>
<Button variant="ghost" size="sm" class="h-7 rounded-md px-2 text-xs text-muted-foreground" @click="dismissReportToast">
{{ t('common.close') }}
</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>