2309 lines
91 KiB
Vue
2309 lines
91 KiB
Vue
<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)">
|
||
<
|
||
</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)">
|
||
>
|
||
</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>
|