JGJS2026/src/layout/tab.vue
2026-03-12 16:52:39 +08:00

2102 lines
71 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 draggable from 'vuedraggable'
import { useTabStore } from '@/pinia/tab'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { useKvStore } from '@/pinia/kv'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
import { ChevronDown, CircleHelp, RotateCcw, X } from 'lucide-vue-next'
import localforage from 'localforage'
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle,
AlertDialogTrigger,
ToastDescription,
ToastProvider,
ToastRoot,
ToastTitle,
ToastViewport,
} from 'reka-ui'
import { decodeZwArchive, encodeZwArchive, ZW_FILE_EXTENSION } from '@/lib/zwArchive'
import { addNumbers, roundTo } from '@/lib/decimal'
import { exportFile, serviceList, additionalWorkList, reserveList } from '@/sql'
interface DataEntry {
key: string
value: any
}
interface ForageStoreSnapshot {
storeName: string
entries: DataEntry[]
}
interface DataPackage {
version: number
exportedAt: string
localStorage: DataEntry[]
sessionStorage: DataEntry[]
localforageDefault: DataEntry[]
localforageStores?: ForageStoreSnapshot[]
}
interface UserGuideStep {
title: string
description: string
points: string[]
}
type XmInfoLike = {
projectName?: unknown
preparedBy?: unknown
reviewedBy?: unknown
preparedDate?: unknown
projectIndustry?: unknown
}
interface ScaleRowLike {
id: string
amount: number | null
landArea: number | null
isGroupRow?: boolean
}
interface XmInfoStorageLike extends XmInfoLike {
detailRows?: ScaleRowLike[],
totalAmount?: number,
roughCalcEnabled?: boolean
}
interface XmScaleStorageLike {
detailRows?: ScaleRowLike[]
}
interface ContractCardItem {
id: string
name?: string
order?: number
}
interface ZxFwRowLike {
id: string
process?: unknown
subtotal?: unknown
investScale?: unknown
landScale?: unknown
workload?: unknown
hourly?: unknown
}
interface ZxFwStorageLike {
selectedIds?: string[]
selectedCodes?: string[]
detailRows?: ZxFwRowLike[]
}
interface ScaleMethodRowLike extends ScaleRowLike {
basicFormula?: unknown
optionalFormula?: unknown
benchmarkBudget?: unknown
benchmarkBudgetBasic?: unknown
benchmarkBudgetOptional?: unknown
budgetFee?: unknown
budgetFeeBasic?: unknown
budgetFeeOptional?: unknown
consultCategoryFactor?: unknown
majorFactor?: unknown
workStageFactor?: unknown
workRatio?: unknown
remark?: unknown
}
interface HtFeeMainRowLike {
id?: unknown
name?: unknown
}
interface RateMethodRowLike {
rate?: unknown
budgetFee?: unknown
}
interface QuantityMethodRowLike {
id?: unknown
feeItem?: unknown
unit?: unknown
quantity?: unknown
unitPrice?: unknown
budgetFee?: number|null
remark?: unknown
}
interface WorkloadMethodRowLike {
id: string
budgetAdoptedUnitPrice?: unknown
workload?: unknown
basicFee?: unknown
consultCategoryFactor?: unknown
serviceFee?: unknown
remark?: unknown
}
interface HourlyMethodRowLike {
id: string
adoptedBudgetUnitPrice?: unknown
personnelCount?: unknown
workdayCount?: unknown
serviceBudget?: unknown
remark?: unknown
}
interface DetailRowsStorageLike<T> {
detailRows?: T[],
roughCalcEnabled?: boolean,
totalAmount?: number,
}
interface FactorRowLike {
id: string
standardFactor?: unknown
budgetValue?: unknown
remark?: unknown
}
interface ExportScaleRow {
major: number
cost: number | null
area: number | null
}
interface ExportMethod1Detail {
major: number
cost: number
basicFee: number
basicFormula: string
basicFee_basic: number
optionalFormula: string
basicFee_optional: number
serviceCoe: number
majorCoe: number
processCoe: number
proportion: number
fee: number
remark: string
}
interface ExportMethod1 {
cost: number
basicFee: number
basicFee_basic: number
basicFee_optional: number
fee: number
det: ExportMethod1Detail[]
}
interface ExportMethod2Detail {
major: number
area: number
basicFee: number
basicFormula: string
basicFee_basic: number
optionalFormula: string
basicFee_optional: number
serviceCoe: number
majorCoe: number
processCoe: number
proportion: number
fee: number
remark: string
}
interface ExportMethod2 {
area: number
basicFee: number
basicFee_basic: number
basicFee_optional: number
fee: number
det: ExportMethod2Detail[]
}
interface ExportMethod3Detail {
task: number
price: number
amount: number
basicFee: number
serviceCoe: number
fee: number
remark: string
}
interface ExportMethod3 {
basicFee: number
fee: number
det: ExportMethod3Detail[]
}
interface ExportMethod4Detail {
expert: number
price: number
person_num: number
work_day: number
fee: number
remark: string
}
interface ExportMethod4 {
person_num: number
work_day: number
fee: number
det: ExportMethod4Detail[]
}
interface ExportService {
id: number
fee: number
process: number
method1?: ExportMethod1
method2?: ExportMethod2
method3?: ExportMethod3
method4?: ExportMethod4
}
interface ExportServiceCoe {
serviceid: number
coe: number
remark: string
}
interface ExportMajorCoe {
majorid: number
coe: number
remark: string
}
interface ExportContract {
name: string
serviceFee: number
addtionalFee: number
reserveFee: number
fee: number
scale: ExportScaleRow[]
serviceCoes: ExportServiceCoe[]
majorCoes: ExportMajorCoe[]
services: ExportService[]
addtional: ExportAdditional | null
reserve: ExportReserve | null
}
interface ExportMethod0 {
coe: number
fee: number
}
interface ExportMethod5Detail {
name: string
unit: string
amount: number
price: number
fee: number
remark: string
}
interface ExportMethod5 {
fee: number
det: ExportMethod5Detail[]
}
interface ExportAdditionalDetail {
id: number
code?: unknown
name: string
fee: number
m0?: ExportMethod0
m4?: ExportMethod4
m5?: ExportMethod5
}
interface ExportAdditional {
code?: unknown
name: string
fee: number
det: ExportAdditionalDetail[]
}
interface ExportReserve {
code?: unknown
name: string
fee: number
m0?: ExportMethod0
m4?: ExportMethod4
m5?: ExportMethod5
}
interface ExportReportPayload {
name: string
writer: string
reviewer: string
date: string
industry: number
fee: number
scaleCost: number
scale: ExportScaleRow[]
serviceCoes: ExportServiceCoe[]
majorCoes: ExportMajorCoe[]
contracts: ExportContract[]
}
const USER_GUIDE_COMPLETED_KEY = 'jgjs-user-guide-completed-v1'
const PROJECT_INFO_DB_KEY = 'xm-base-info-v1'
const LEGACY_PROJECT_DB_KEY = 'xm-info-v3'
const CONSULT_CATEGORY_FACTOR_DB_KEY = 'xm-consult-category-factor-v1'
const MAJOR_FACTOR_DB_KEY = 'xm-major-factor-v1'
const PINIA_PERSIST_DB_NAME = 'DB'
const PINIA_PERSIST_BASE_STORE_NAME = 'pinia'
const PINIA_PERSIST_STORE_IDS = ['tabs', 'zxFwPricing', 'kv'] as const
const userGuideSteps: UserGuideStep[] = [
{
title: '欢迎使用',
description: '这个引导会覆盖系统主要功能和完整使用路径,建议按顺序走一遍。',
points: [
'顶部是标签页区,可在项目、合同段、服务计算页之间快速切换。',
'页面里的表格与表单会自动保存到本地,无需手动点击保存。',
'你可以随时点击右上角“使用引导”重新打开本教程。'
]
},
{
title: '项目卡片与四个模块',
description: '默认标签“项目卡片”是入口,左侧流程线包含四个项目级功能。',
points: [
'基础信息:填写项目名称与项目规模明细。',
'合同段管理:新建、排序、搜索、导入/导出合同段。',
'咨询分类系数 / 工程专业系数:维护系数预算取值和备注。'
]
},
{
title: '基础信息填写',
description: '先在“基础信息”中补齐项目级数据,再进入后续合同段计算。',
points: [
'项目名称会用于导出文件名和页面展示。',
'项目明细表支持直接编辑、复制粘贴、撤销重做。',
'分组行自动汇总,顶部固定行显示总合计。'
]
},
{
title: '合同段管理',
description: '在“合同段管理”中完成合同段生命周期操作。',
points: [
'“添加合同段”用于新增,卡片右上角可编辑或删除。',
'支持搜索、网格/列表切换,非搜索状态可拖拽排序。',
'更多菜单可导入/导出合同段;点击卡片进入该合同段详情。'
]
},
{
title: '合同段详情',
description: '进入合同段后,左侧同样是流程线,包含规模信息和咨询服务两部分。',
points: [
'规模信息:按工程专业填写当前合同段的规模数据。',
'咨询服务:选择服务词典并生成服务费用明细。',
'合同段页面会独立缓存,不同合同段互不干扰。'
]
},
{
title: '咨询服务与计算页',
description: '咨询服务页面用于管理服务明细,并进入具体计费方法页面。',
points: [
'先点击“浏览”选择服务,再确认生成明细行。',
'明细表“编辑”可打开服务计算页,“清空”会重置该服务计算结果。',
'服务计算页包含投资规模法、用地规模法、工作量法、工时法四种方法。'
]
},
{
title: '系数维护',
description: '项目级系数用于调节预算取值,可在两个系数页分别维护。',
points: [
'咨询分类系数页:按咨询分类维护预算取值与说明。',
'工程专业系数页:按专业树维护预算取值与说明。',
'支持批量粘贴、撤销重做,便于一次性维护多行数据。'
]
},
{
title: '数据管理与恢复',
description: '顶部工具栏负责全量数据导入导出与初始化重置。',
points: [
'“导入/导出”是整项目级别的数据包操作。',
'“重置”会清空本地全部数据并恢复默认页面。',
'建议在重要调整前先导出备份。'
]
}
]
const componentMap: Record<string, any> = {
XmView: markRaw(defineAsyncComponent(() => import('@/components/views/xmCard.vue'))),
ContractDetailView: markRaw(defineAsyncComponent(() => import('@/components/views/htCard.vue'))),
ZxFwView: markRaw(defineAsyncComponent(() => import('@/components/views/ZxFwView.vue'))),
HtFeeMethodTypeLineView: markRaw(defineAsyncComponent(() => import('@/components/views/HtFeeMethodTypeLineView.vue'))),
}
const tabStore = useTabStore()
const zxFwPricingStore = useZxFwPricingStore()
const kvStore = useKvStore()
const tabContextOpen = ref(false)
const tabContextX = ref(0)
const tabContextY = ref(0)
const contextTabId = ref<string>('XmView')
const tabContextRef = ref<HTMLElement | null>(null)
const dataMenuOpen = ref(false)
const dataMenuRef = ref<HTMLElement | null>(null)
const importFileRef = ref<HTMLInputElement | null>(null)
const importConfirmOpen = ref(false)
const pendingImportPayload = shallowRef<DataPackage | null>(null)
const pendingImportFileName = ref('')
const userGuideOpen = ref(false)
const userGuideStepIndex = ref(0)
const reportExportToastOpen = ref(false)
const reportExportProgress = ref(0)
const reportExportStatus = ref<'running' | 'success' | 'error'>('running')
const reportExportText = ref('')
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
let reportExportToastTimer: ReturnType<typeof setTimeout> | null = null
const tabsModel = computed({
get: () => tabStore.tabs,
set: (value) => {
tabStore.tabs = value
}
})
const contextTabIndex = computed(() => tabStore.tabs.findIndex(t => t.id === contextTabId.value))
const hasClosableTabs = computed(() => tabStore.tabs.some(t => t.id !== 'XmView'))
const activeGuideStep = computed(
() => userGuideSteps[userGuideStepIndex.value] || userGuideSteps[0]
)
const isFirstGuideStep = computed(() => userGuideStepIndex.value === 0)
const isLastGuideStep = computed(() => userGuideStepIndex.value >= userGuideSteps.length - 1)
const guideProgressText = computed(() => `${userGuideStepIndex.value + 1} / ${userGuideSteps.length}`)
const canCloseLeft = computed(() => {
if (contextTabIndex.value <= 0) return false
return tabStore.tabs.slice(0, contextTabIndex.value).some(t => t.id !== 'XmView')
})
const canCloseRight = computed(() => {
if (contextTabIndex.value < 0) return false
return tabStore.tabs.slice(contextTabIndex.value + 1).some(t => t.id !== 'XmView')
})
const canCloseOther = computed(() =>
tabStore.tabs.some(t => t.id !== 'XmView' && t.id !== contextTabId.value)
)
const closeMenus = () => {
tabContextOpen.value = false
dataMenuOpen.value = false
}
const 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) => {
clearReportExportToastTimer()
reportExportStatus.value = success ? 'success' : 'error'
reportExportProgress.value = 100
reportExportText.value = text
reportExportToastOpen.value = true
reportExportToastTimer = setTimeout(() => {
reportExportToastOpen.value = false
}, success ? 1200 : 1800)
}
const markGuideCompleted = () => {
try {
localStorage.setItem(USER_GUIDE_COMPLETED_KEY, '1')
} catch (error) {
console.error('mark guide completed failed:', error)
}
}
const hasGuideCompleted = () => {
try {
return localStorage.getItem(USER_GUIDE_COMPLETED_KEY) === '1'
} catch (error) {
console.error('read guide completion failed:', error)
return false
}
}
const hasNonDefaultTabState = () => {
try {
const raw = localStorage.getItem('tabs')
if (!raw) return false
const parsed = JSON.parse(raw) as { tabs?: Array<{ id?: string }>; activeTabId?: string }
const tabs = Array.isArray(parsed?.tabs) ? parsed.tabs : []
const hasCustomTabs = tabs.some(item => item?.id && item.id !== 'XmView')
const activeTabId = typeof parsed?.activeTabId === 'string' ? parsed.activeTabId : ''
return hasCustomTabs || (activeTabId !== '' && activeTabId !== 'XmView')
} catch (error) {
console.error('parse tabs cache failed:', error)
return false
}
}
const shouldAutoOpenGuide = async () => {
if (hasGuideCompleted()) return false
if (hasNonDefaultTabState()) return false
try {
const keys = await kvStore.keys()
return keys.length === 0
} catch (error) {
console.error('read kv keys failed:', error)
return false
}
}
const openUserGuide = (startAt = 0) => {
closeMenus()
userGuideStepIndex.value = Math.min(Math.max(startAt, 0), userGuideSteps.length - 1)
userGuideOpen.value = true
}
const closeUserGuide = (completed = false) => {
userGuideOpen.value = false
if (completed) markGuideCompleted()
}
const prevUserGuideStep = () => {
if (isFirstGuideStep.value) return
userGuideStepIndex.value -= 1
}
const nextUserGuideStep = () => {
if (isLastGuideStep.value) {
closeUserGuide(true)
return
}
userGuideStepIndex.value += 1
}
const jumpToGuideStep = (stepIndex: number) => {
userGuideStepIndex.value = Math.min(Math.max(stepIndex, 0), userGuideSteps.length - 1)
}
const openTabContextMenu = (event: MouseEvent, tabId: string) => {
contextTabId.value = tabId
tabContextX.value = event.clientX
tabContextY.value = event.clientY
tabContextOpen.value = true
void nextTick(() => {
if (!tabContextRef.value) return
const gap = 8
const rect = tabContextRef.value.getBoundingClientRect()
if (tabContextX.value + rect.width + gap > window.innerWidth) {
tabContextX.value = Math.max(gap, window.innerWidth - rect.width - gap)
}
if (tabContextY.value + rect.height + gap > window.innerHeight) {
tabContextY.value = Math.max(gap, window.innerHeight - rect.height - gap)
}
if (tabContextX.value < gap) tabContextX.value = gap
if (tabContextY.value < gap) tabContextY.value = gap
})
}
const handleGlobalMouseDown = (event: MouseEvent) => {
const target = event.target as Node
if (tabContextOpen.value && tabContextRef.value && !tabContextRef.value.contains(target)) {
tabContextOpen.value = false
}
if (dataMenuOpen.value && dataMenuRef.value && !dataMenuRef.value.contains(target)) {
dataMenuOpen.value = false
}
}
const handleGlobalKeyDown = (event: KeyboardEvent) => {
if (!userGuideOpen.value) return
if (event.key === 'Escape') {
event.preventDefault()
closeUserGuide(false)
return
}
if (event.key === 'ArrowLeft') {
event.preventDefault()
prevUserGuideStep()
return
}
if (event.key === 'ArrowRight') {
event.preventDefault()
nextUserGuideStep()
}
}
const runTabMenuAction = (action: 'all' | 'left' | 'right' | 'other') => {
if (action === 'all') tabStore.closeAllTabs()
if (action === 'left') tabStore.closeLeftTabs(contextTabId.value)
if (action === 'right') tabStore.closeRightTabs(contextTabId.value)
if (action === 'other') tabStore.closeOtherTabs(contextTabId.value)
tabContextOpen.value = false
}
const canMoveTab = (event: any) => {
const draggedId = event?.draggedContext?.element?.id
const targetIndex = event?.relatedContext?.index
if (draggedId === 'XmView') return false
if (typeof targetIndex === 'number' && targetIndex === 0) return false
return true
}
const 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 readWebStorage = (storageObj: Storage): DataEntry[] => {
const entries: DataEntry[] = []
for (let i = 0; i < storageObj.length; i++) {
const key = storageObj.key(i)
if (!key) continue
const raw = storageObj.getItem(key)
let value: any = raw
if (raw != null) {
try {
value = JSON.parse(raw)
} catch {
value = raw
}
}
entries.push({ key, value })
}
return entries
}
const writeWebStorage = (storageObj: Storage, entries: DataEntry[]) => {
storageObj.clear()
for (const entry of entries || []) {
const value = typeof entry.value === 'string' ? entry.value : JSON.stringify(entry.value)
storageObj.setItem(entry.key, value)
}
}
type ForageInstance = ReturnType<typeof localforage.createInstance>
type ForageStore = Pick<ForageInstance, 'keys' | 'getItem' | 'setItem' | 'clear'>
const createForageStore = (storeName: string): ForageInstance =>
localforage.createInstance({
name: PINIA_PERSIST_DB_NAME,
storeName
})
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 flushPiniaPersistNow = async () => {
await Promise.all([
tabStore.$persistNow?.(),
zxFwPricingStore.$persistNow?.(),
kvStore.$persistNow?.()
])
}
const readForage = async (store: ForageStore): Promise<DataEntry[]> => {
const keys = await store.keys()
const entries: DataEntry[] = []
for (const key of keys) {
const value = await store.getItem(key)
entries.push({ key, value: toPersistableValue(value) })
}
return entries
}
const toPersistableValue = (value: unknown) => {
try {
return JSON.parse(JSON.stringify(value))
} catch (error) {
console.error('normalize persist value failed, fallback to null:', error)
return null
}
}
const writeForage = async (store: ForageStore, entries: DataEntry[]) => {
await store.clear()
for (const entry of entries || []) {
await store.setItem(entry.key, toPersistableValue(entry.value))
}
}
const normalizeEntries = (value: unknown): DataEntry[] => {
if (!Array.isArray(value)) return []
return value
.filter(item => item && typeof item === 'object' && typeof (item as any).key === 'string')
.map(item => ({ key: String((item as any).key), value: (item as any).value }))
}
const normalizeForageStoreSnapshots = (value: unknown): ForageStoreSnapshot[] => {
if (!Array.isArray(value)) return []
return value
.filter(item =>
item
&& typeof item === 'object'
&& typeof (item as any).storeName === 'string'
&& Array.isArray((item as any).entries)
)
.map(item => ({
storeName: String((item as any).storeName),
entries: normalizeEntries((item as any).entries)
}))
}
const sanitizeFileNamePart = (value: string): string => {
const cleaned = value
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/\s+/g, ' ')
.trim()
return cleaned || '造价项目'
}
const formatExportTimestamp = (date: Date): string => {
const yyyy = date.getFullYear()
const mm = String(date.getMonth() + 1).padStart(2, '0')
const dd = String(date.getDate()).padStart(2, '0')
const hh = String(date.getHours()).padStart(2, '0')
const mi = String(date.getMinutes()).padStart(2, '0')
return `${yyyy}${mm}${dd}-${hh}${mi}`
}
const getExportProjectName = (entries: DataEntry[]): string => {
const target =
entries.find(item => item.key === PROJECT_INFO_DB_KEY) ||
entries.find(item => item.key === LEGACY_PROJECT_DB_KEY)
const data = (target?.value || {}) as XmInfoLike
return typeof data.projectName === 'string' ? sanitizeFileNamePart(data.projectName) : '造价项目'
}
const toFiniteNumber = (value: unknown): number | null => {
const num = Number(value)
return Number.isFinite(num) ? num : null
}
const toFiniteNumberOrZero = (value: unknown): number => toFiniteNumber(value) ?? 0
const toSafeInteger = (value: unknown): number | null => {
const num = Number(value)
if (!Number.isInteger(num)) return null
if (!Number.isSafeInteger(num)) return null
return num
}
const sumNumbers = (values: Array<number | null | undefined>): number =>
values.reduce<number>(
(sum, value) => sum + (typeof value === 'number' && Number.isFinite(value) ? value : 0),
0
)
const isNonEmptyString = (value: unknown): value is string =>
typeof value === 'string' && value.trim().length > 0
const getTaskIdFromRowId = (value: string): number | null => {
const match = /^task-(\d+)-\d+$/.exec(value)
return match ? toSafeInteger(match[1]) : null
}
const getExpertIdFromRowId = (value: string): number | null => {
const match = /^expert-(\d+)$/.exec(value)
return match ? toSafeInteger(match[1]) : null
}
const hasServiceId = (serviceId: string) =>
Object.prototype.hasOwnProperty.call(serviceList as Record<string, unknown>, serviceId)
const sortServiceIdsByDict = (ids: string[]) =>
[...ids].sort((left, right) => {
const leftOrder = Number((serviceList as Record<string, any>)[left]?.order)
const rightOrder = Number((serviceList as Record<string, any>)[right]?.order)
const safeLeft = Number.isFinite(leftOrder) ? leftOrder : Number.MAX_SAFE_INTEGER
const safeRight = Number.isFinite(rightOrder) ? rightOrder : Number.MAX_SAFE_INTEGER
return safeLeft - safeRight
})
const mapIndustryCodeToExportIndustry = (value: unknown): number => {
const raw = typeof value === 'string' ? value.trim().toUpperCase() : ''
if (!raw) return 0
if (raw === '0' || raw === 'E2') return 0
if (raw === '1' || raw === 'E3') return 1
if (raw === '2' || raw === 'E4') return 2
return 0
}
const buildProjectServiceCoes = (rows: FactorRowLike[] | undefined): ExportServiceCoe[] => {
if (!Array.isArray(rows)) return []
return rows
.map(row => {
const serviceid = toSafeInteger(row.id)
if (serviceid == null || row.budgetValue == null) return null
const coe = toFiniteNumber(row.budgetValue)
return {
serviceid,
coe,
remark: row.remark
}
})
.filter((item): item is ExportServiceCoe => Boolean(item))
}
const buildProjectMajorCoes = (rows: FactorRowLike[] | undefined): ExportMajorCoe[] => {
if (!Array.isArray(rows)) return []
return rows
.map(row => {
const majorid = toSafeInteger(row.id)
if (majorid == null || row.budgetValue == null) return null
const coe = toFiniteNumber(row.budgetValue)
return {
majorid,
coe,
remark: row.remark
}
})
.filter((item): item is ExportMajorCoe => Boolean(item))
}
const toExportScaleRows = (rows: ScaleRowLike[] | undefined): ExportScaleRow[] => {
if (!Array.isArray(rows)) return []
return rows
.map(row => {
if (row.id == null || (row.amount == null && row.landArea == null)) return null
return {
major: toSafeInteger(row.id),
cost: row.amount,
area: row.landArea
}
})
.filter((item): item is ExportScaleRow => Boolean(item))
}
const sumLeafScaleCost = (rows: ScaleRowLike[] | undefined) => {
if (!Array.isArray(rows)) return 0
return sumNumbers(
rows.map(row => {
if (row?.isGroupRow === true) return null
return toFiniteNumber(row?.amount)
})
)
}
const buildMethod1 = (rows: ScaleMethodRowLike[] | undefined): ExportMethod1 | null => {
if (!Array.isArray(rows)) return null
let hasTotalValue = false
const det = rows
.map(row => {
const major = toSafeInteger(row.id)
if (major == null) return null
const cost = toFiniteNumber(row.amount)
const basicFee = toFiniteNumber(row.benchmarkBudget)
const basicFeeBasic = toFiniteNumber(row.benchmarkBudgetBasic)
const basicFeeOptional = toFiniteNumber(row.benchmarkBudgetOptional)
const fee = toFiniteNumber(row.budgetFee)
if (basicFee != null || fee != null) hasTotalValue = true
const remark = typeof row.remark === 'string' ? row.remark : ''
const hasValue =
cost != null ||
basicFee != null ||
basicFeeBasic != null ||
basicFeeOptional != null ||
fee != null ||
isNonEmptyString(remark)
if (!hasValue) return null
return {
major,
cost: cost ?? 0,
basicFee: basicFee ?? 0,
basicFormula: typeof row.basicFormula === 'string' ? row.basicFormula : '',
basicFee_basic: basicFeeBasic ?? 0,
optionalFormula: typeof row.optionalFormula === 'string' ? row.optionalFormula : '',
basicFee_optional: basicFeeOptional ?? 0,
serviceCoe: toFiniteNumberOrZero(row.consultCategoryFactor),
majorCoe: toFiniteNumberOrZero(row.majorFactor),
processCoe: toFiniteNumber(row.workStageFactor) ?? 1,
proportion: toFiniteNumber(row.workRatio) ?? 1,
fee: fee ?? 0,
remark
}
})
.filter((item): item is ExportMethod1Detail => Boolean(item))
if (det.length === 0 || !hasTotalValue) return null
return {
cost: sumNumbers(det.map(item => item.cost)),
basicFee: sumNumbers(det.map(item => item.basicFee)),
basicFee_basic: sumNumbers(det.map(item => item.basicFee_basic)),
basicFee_optional: sumNumbers(det.map(item => item.basicFee_optional)),
fee: sumNumbers(det.map(item => item.fee)),
det
}
}
const buildMethod2 = (rows: ScaleMethodRowLike[] | undefined): ExportMethod2 | null => {
if (!Array.isArray(rows)) return null
let hasTotalValue = false
const det = rows
.map(row => {
const major = toSafeInteger(row.id)
if (major == null) return null
const area = toFiniteNumber(row.landArea)
const basicFee = toFiniteNumber(row.benchmarkBudget)
const basicFeeBasic = toFiniteNumber(row.benchmarkBudgetBasic)
const basicFeeOptional = toFiniteNumber(row.benchmarkBudgetOptional)
const fee = toFiniteNumber(row.budgetFee)
if (basicFee != null || fee != null) hasTotalValue = true
const remark = typeof row.remark === 'string' ? row.remark : ''
const hasValue =
area != null ||
basicFee != null ||
basicFeeBasic != null ||
basicFeeOptional != null ||
fee != null ||
isNonEmptyString(remark)
if (!hasValue) return null
return {
major,
area: area ?? 0,
basicFee: basicFee ?? 0,
basicFormula: typeof row.basicFormula === 'string' ? row.basicFormula : '',
basicFee_basic: basicFeeBasic ?? 0,
optionalFormula: typeof row.optionalFormula === 'string' ? row.optionalFormula : '',
basicFee_optional: basicFeeOptional ?? 0,
serviceCoe: toFiniteNumberOrZero(row.consultCategoryFactor),
majorCoe: toFiniteNumberOrZero(row.majorFactor),
processCoe: toFiniteNumber(row.workStageFactor) ?? 1,
proportion: toFiniteNumber(row.workRatio) ?? 1,
fee: fee ?? 0,
remark
}
})
.filter((item): item is ExportMethod2Detail => Boolean(item))
if (det.length === 0 || !hasTotalValue) return null
return {
area: sumNumbers(det.map(item => item.area)),
basicFee: sumNumbers(det.map(item => item.basicFee)),
basicFee_basic: sumNumbers(det.map(item => item.basicFee_basic)),
basicFee_optional: sumNumbers(det.map(item => item.basicFee_optional)),
fee: sumNumbers(det.map(item => item.fee)),
det
}
}
const buildMethod3 = (rows: WorkloadMethodRowLike[] | undefined): ExportMethod3 | null => {
if (!Array.isArray(rows)) return null
let hasTotalValue = false
const det = rows
.map(row => {
const task = getTaskIdFromRowId(row.id)
if (task == null || row.basicFee == null) return null
const amount = toFiniteNumber(row.workload)
const basicFee = toFiniteNumber(row.basicFee)
const fee = toFiniteNumber(row.serviceFee)
if (fee != null) hasTotalValue = true
const remark = typeof row.remark === 'string' ? row.remark : ''
const hasValue = amount != null || basicFee != null || fee != null || isNonEmptyString(remark)
if (!hasValue) return null
return {
task,
price: toFiniteNumberOrZero(row.budgetAdoptedUnitPrice),
amount: amount ?? 0,
basicFee: basicFee ?? 0,
serviceCoe: toFiniteNumberOrZero(row.consultCategoryFactor),
fee: fee ?? 0,
remark
}
})
.filter((item): item is ExportMethod3Detail => Boolean(item))
if (det.length === 0 || !hasTotalValue) return null
return {
basicFee: sumNumbers(det.map(item => item.basicFee)),
fee: sumNumbers(det.map(item => item.fee)),
det
}
}
const buildMethod4 = (rows: HourlyMethodRowLike[] | undefined): ExportMethod4 | null => {
if (!Array.isArray(rows)) return null
let hasTotalValue = false
const det = rows
.map(row => {
const expert = getExpertIdFromRowId(row.id)
if (expert == null || row.serviceBudget == null) return null
const personNum = toFiniteNumber(row.personnelCount)
const workDay = toFiniteNumber(row.workdayCount)
const fee = toFiniteNumber(row.serviceBudget)
if (fee != null) hasTotalValue = true
const remark = typeof row.remark === 'string' ? row.remark : ''
const hasValue = personNum != null || workDay != null || fee != null || isNonEmptyString(remark)
if (!hasValue) return null
return {
expert,
price: toFiniteNumberOrZero(row.adoptedBudgetUnitPrice),
person_num: personNum ?? 0,
work_day: workDay ?? 0,
fee: fee ?? 0,
remark
}
})
.filter((item): item is ExportMethod4Detail => Boolean(item))
if (det.length === 0 || !hasTotalValue) return null
return {
person_num: sumNumbers(det.map(item => item.person_num)),
work_day: sumNumbers(det.map(item => item.work_day)),
fee: sumNumbers(det.map(item => item.fee)),
det
}
}
const buildServiceFee = (
row: ZxFwRowLike | null | undefined,
method1: ExportMethod1 | null,
method2: ExportMethod2 | null,
method3: ExportMethod3 | null,
method4: ExportMethod4 | null
) => {
const subtotal = toFiniteNumber(row?.subtotal)
if (subtotal != null) return subtotal
const methodSum = sumNumbers([method1?.fee, method2?.fee, method3?.fee, method4?.fee])
if (methodSum !== 0) return methodSum
return sumNumbers([
toFiniteNumber(row?.investScale),
toFiniteNumber(row?.landScale),
toFiniteNumber(row?.workload),
toFiniteNumber(row?.hourly)
])
}
const createRichTextCode = (...parts: string[]): unknown => ({
richText: parts
.map(item => String(item || '').trim())
.filter(Boolean)
.map(text => ({ text }))
})
const buildMethod0 = (payload: RateMethodRowLike | null | undefined): ExportMethod0 | null => {
const coe = toFiniteNumber(payload?.rate)
const fee = toFiniteNumber(payload?.budgetFee)
if (fee == null) return null
return {
coe: coe ?? 0,
fee
}
}
const buildMethod5 = (rows: QuantityMethodRowLike[] | undefined): ExportMethod5 | null => {
if (!Array.isArray(rows) || rows.length === 0) return null
const subtotalRow = rows.find(row => String(row?.id || '') === 'fee-subtotal-fixed')
const subtotalFee = toFiniteNumber(subtotalRow?.budgetFee)
if (subtotalFee == null ) return null
const det = rows
.filter(row => String(row?.id || '') !== 'fee-subtotal-fixed')
.map(row => {
const quantity = toFiniteNumber(row.quantity)
const unitPrice = toFiniteNumber(row.unitPrice)
const fee = toFiniteNumber(row.budgetFee)
const name = typeof row.feeItem === 'string' ? row.feeItem : ''
const unit = typeof row.unit === 'string' ? row.unit : ''
const remark = typeof row.remark === 'string' ? row.remark : ''
if (row.budgetFee==null) return null
return {
name,
unit,
amount: quantity ,
price: unitPrice ,
fee: fee ,
remark
}
})
.filter((item): item is ExportMethod5Detail => Boolean(item))
if (det.length === 0) return null
return {
fee: subtotalFee,
det
}
}
const normalizeHtFeeMainRows = (rows: HtFeeMainRowLike[] | undefined) => {
if (!Array.isArray(rows)) return [] as Array<{ id: string; name: string }>
return rows
.map(row => {
const id = String(row?.id || '').trim()
if (!id) return null
return {
id,
name: typeof row?.name === 'string' ? row.name : ''
}
})
.filter((item): item is { id: string; name: string } => Boolean(item))
}
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 buildAdditionalDetailCode = (index: number, name: string): unknown => {
const dictItem = additionalWorkList.find(item => String(item?.name || '').trim() === String(name || '').trim())
if (dictItem?.code) return dictItem.code
const useIndex = index
const suffix = useIndex === 0 ? 'F' : useIndex === 1 ? 'X' : String(useIndex + 1)
return createRichTextCode('C', suffix)
}
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, index) => {
const methodPayload = await loadHtFeeMethodsByRow(storageKey, row.id)
if (!methodPayload) return null
const item: ExportAdditionalDetail = {
id: index,
code: buildAdditionalDetailCode(index, row.name),
name: row.name,
fee: methodPayload.fee
}
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: createRichTextCode('C', 'C'),
name: '附加工作',
fee: sumNumbers(det.map(item => item.fee)),
det
}
}
const buildReserveExport = async (contractId: string): Promise<ExportReserve | null> => {
const storageKey = `htExtraFee-${contractId}-reserve`
const mainState = await zxFwPricingStore.loadHtFeeMainState<HtFeeMainRowLike>(storageKey)
const rows = normalizeHtFeeMainRows(mainState?.detailRows)
if (rows.length === 0) return null
for (const row of rows) {
const methodPayload = await loadHtFeeMethodsByRow(storageKey, row.id)
if (!methodPayload) continue
const reserve: ExportReserve = {
code: reserveList[0]?.code || createRichTextCode('Y', 'B'),
name: row.name || '预备费',
fee: methodPayload.fee
}
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, consultCategoryFactorRaw, majorFactorRaw, contractCardsRaw] = await Promise.all([
kvStore.getItem<XmInfoLike>(PROJECT_INFO_DB_KEY),
kvStore.getItem<XmInfoStorageLike>(LEGACY_PROJECT_DB_KEY),
kvStore.getItem<DetailRowsStorageLike<FactorRowLike>>(CONSULT_CATEGORY_FACTOR_DB_KEY),
kvStore.getItem<DetailRowsStorageLike<FactorRowLike>>(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) ?? sumLeafScaleCost(projectScaleSource.detailRows)
projectScale.push({
major: -1, cost: projectScaleCost,
area: null
})
const projectServiceCoes = buildProjectServiceCoes(consultCategoryFactorRaw?.detailRows)
const projectMajorCoes = buildProjectMajorCoes(majorFactorRaw?.detailRows)
const projectName = isNonEmptyString(projectInfo.projectName) ? projectInfo.projectName.trim() : '造价项目'
const writer = isNonEmptyString(projectInfo.preparedBy) ? projectInfo.preparedBy.trim() : ''
const reviewer = isNonEmptyString(projectInfo.reviewedBy) ? projectInfo.reviewedBy.trim() : ''
const date = isNonEmptyString(projectInfo.preparedDate) ? projectInfo.preparedDate.trim() : ''
const industry = mapIndustryCodeToExportIndustry(projectInfo.projectIndustry)
const contractCards = (Array.isArray(contractCardsRaw) ? contractCardsRaw : [])
.filter(item => item && typeof item.id === 'string')
.sort((a, b) => (typeof a.order === 'number' ? a.order : Number.MAX_SAFE_INTEGER) - (typeof b.order === 'number' ? b.order : Number.MAX_SAFE_INTEGER))
const contracts: ExportContract[] = []
for (let index = 0; index < contractCards.length; index++) {
const contract = contractCards[index]
const contractId = contract.id
await zxFwPricingStore.loadContract(contractId)
const [htInfoRaw, zxFwRaw, htConsultCategoryFactorRaw, htMajorFactorRaw] = await Promise.all([
kvStore.getItem<DetailRowsStorageLike<ScaleRowLike>>(`ht-info-v3-${contractId}`),
kvStore.getItem<ZxFwStorageLike>(`zxFW-${contractId}`),
kvStore.getItem<DetailRowsStorageLike<FactorRowLike>>(`ht-consult-category-factor-v1-${contractId}`),
kvStore.getItem<DetailRowsStorageLike<FactorRowLike>>(`ht-major-factor-v1-${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,
investScale: row.investScale,
landScale: row.landScale,
workload: row.workload,
hourly: row.hourly
}))
: []
const zxRowsFromKv = Array.isArray(zxFwRaw?.detailRows) ? zxFwRaw.detailRows : []
const zxRows = zxRowsFromStore.length > 0 ? zxRowsFromStore : zxRowsFromKv
const selectedIdsFromStore = Array.isArray(contractState?.selectedIds)
? contractState.selectedIds.map(id => String(id || '').trim()).filter(Boolean)
: []
const selectedIdsFromKv = Array.isArray(zxFwRaw?.selectedIds)
? zxFwRaw.selectedIds.map(id => String(id || '').trim()).filter(Boolean)
: []
const selectedIds = Array.from(new Set([...selectedIdsFromStore, ...selectedIdsFromKv])).filter(hasServiceId)
let fixedRow: ZxFwRowLike | undefined
const serviceRowMap = new Map<string, ZxFwRowLike>()
for (const row of zxRows) {
const rowId = String(row?.id || '').trim()
if (!rowId) continue
if (rowId === 'fixed-budget-c') {
fixedRow = row
continue
}
if (!hasServiceId(rowId)) continue
serviceRowMap.set(rowId, row)
}
const fallbackServiceIds = Array.from(serviceRowMap.keys())
const serviceIdTexts = sortServiceIdsByDict(
(selectedIds.length > 0 ? selectedIds : fallbackServiceIds).filter(hasServiceId)
)
const services = (
await Promise.all(
serviceIdTexts.map(async serviceIdText => {
const serviceId = toSafeInteger(serviceIdText)
if (serviceId == null) return null
const sourceRow = serviceRowMap.get(serviceIdText)
const [method1State, method2State, method3State, method4State] = await Promise.all([
zxFwPricingStore.loadServicePricingMethodState<ScaleMethodRowLike>(contractId, serviceIdText, 'investScale'),
zxFwPricingStore.loadServicePricingMethodState<ScaleMethodRowLike>(contractId, serviceIdText, 'landScale'),
zxFwPricingStore.loadServicePricingMethodState<WorkloadMethodRowLike>(contractId, serviceIdText, 'workload'),
zxFwPricingStore.loadServicePricingMethodState<HourlyMethodRowLike>(contractId, serviceIdText, 'hourly')
])
const method1Raw = method1State ? { detailRows: method1State.detailRows } : null
const method2Raw = method2State ? { detailRows: method2State.detailRows } : null
const method3Raw = method3State ? { detailRows: method3State.detailRows } : null
const method4Raw = method4State ? { detailRows: method4State.detailRows } : null
const method1 = buildMethod1(method1Raw?.detailRows)
const method2 = buildMethod2(method2Raw?.detailRows)
const method3 = buildMethod3(method3Raw?.detailRows)
const method4 = buildMethod4(method4Raw?.detailRows)
const fee = buildServiceFee(sourceRow, method1, method2, method3, method4)
const process = Number(sourceRow?.process) === 1 ? 1 : 0
const service: ExportService = {
id: serviceId,
process,
fee
}
if (method1) service.method1 = method1
if (method2) service.method2 = method2
if (method3) service.method3 = method3
if (method4) service.method4 = method4
return service
})
)
).filter((item): item is ExportService => Boolean(item))
const fixedSubtotal = toFiniteNumber(fixedRow?.subtotal)
const serviceFeeSum = sumNumbers(services.map(item => item.fee))
const fixedMethodSum = sumNumbers([
toFiniteNumber(fixedRow?.investScale),
toFiniteNumber(fixedRow?.landScale),
toFiniteNumber(fixedRow?.workload),
toFiniteNumber(fixedRow?.hourly)
])
const serviceFee = fixedSubtotal ?? (serviceFeeSum !== 0 ? serviceFeeSum : fixedMethodSum)
const [addtional, reserve] = await Promise.all([
buildAdditionalExport(contractId),
buildReserveExport(contractId)
])
const addtionalFee = addtional ? addtional.fee : 0
const reserveFee = reserve ? reserve.fee : 0
const contractFee = roundTo(addNumbers(serviceFee, addtionalFee, reserveFee), 3)
const contractScale = htInfoRaw?.roughCalcEnabled ? [] : toExportScaleRows(htInfoRaw?.detailRows)
contractScale.push({
major: -1, cost: contractFee,
area: null
})
const contractServiceCoesRaw = buildProjectServiceCoes(htConsultCategoryFactorRaw?.detailRows)
const contractMajorCoesRaw = buildProjectMajorCoes(htMajorFactorRaw?.detailRows)
contracts.push({
name: isNonEmptyString(contract.name) ? contract.name : `合同段-${index + 1}`,
serviceFee,
addtionalFee,
reserveFee,
fee: contractFee,
scale: contractScale,
serviceCoes: contractServiceCoesRaw,
majorCoes: contractMajorCoesRaw,
services,
addtional,
reserve
})
}
return {
name: projectName,
writer,
reviewer,
date,
industry,
fee: sumNumbers(contracts.map(item => item.fee)),
scaleCost: projectScaleCost,
scale: projectScale,
serviceCoes: projectServiceCoes,
majorCoes: projectMajorCoes,
contracts
}
}
const exportData = async () => {
try {
// 先把 Pinia 内存态强制落盘,避免导出快照缺失附加工作费/预备费最新值。
await flushPiniaPersistNow()
const now = new Date()
const piniaForageStores = await Promise.all(
getPiniaPersistStores().map(async ({ storeName, store }) => ({
storeName,
entries: await readForage(store)
}))
)
const payload: DataPackage = {
version: 2,
exportedAt: now.toISOString(),
localStorage: readWebStorage(localStorage),
sessionStorage: readWebStorage(sessionStorage),
localforageDefault: await readForage(localforage),
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)
const timestamp = formatExportTimestamp(now)
link.download = `${projectName}-${timestamp}${ZW_FILE_EXTENSION}`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
} catch (error) {
console.error('export failed:', error)
window.alert('导出失败,请重试。')
} finally {
dataMenuOpen.value = false
}
}
const exportReport = async () => {
try {
showReportExportProgress(10, '正在准备报表导出...')
const now = new Date()
showReportExportProgress(40, '正在汇总报表数据...')
const payload = await buildExportReportPayload()
showReportExportProgress(80, '正在生成并写出报表文件...')
const fileName = `${sanitizeFileNamePart(payload.name)}-报表-${formatExportTimestamp(now)}`
console.log(payload)
await exportFile(fileName, payload)
finishReportExportProgress(true, '报表导出完成')
} catch (error) {
console.error('export report failed:', error)
finishReportExportProgress(false, '报表导出失败,请重试')
} finally {
dataMenuOpen.value = false
}
}
const triggerImport = () => {
importFileRef.value?.click()
}
const importData = async (event: Event) => {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
try {
if (!file.name.toLowerCase().endsWith(ZW_FILE_EXTENSION)) {
throw new Error('INVALID_FILE_EXT')
}
const buffer = await file.arrayBuffer()
const payload = await decodeZwArchive<DataPackage>(buffer)
pendingImportPayload.value = payload
pendingImportFileName.value = file.name
importConfirmOpen.value = true
} catch (error) {
console.error('import failed:', error)
window.alert('导入失败:文件无效、已损坏或被修改。')
} finally {
input.value = ''
}
}
const cancelImportConfirm = () => {
importConfirmOpen.value = false
pendingImportPayload.value = null
pendingImportFileName.value = ''
}
const confirmImportOverride = async () => {
const payload = pendingImportPayload.value
if (!payload) return
try {
writeWebStorage(localStorage, normalizeEntries(payload.localStorage))
writeWebStorage(sessionStorage, normalizeEntries(payload.sessionStorage))
await writeForage(localforage, normalizeEntries(payload.localforageDefault))
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) ||
entries.find(entry => entry.key === storeId) ||
entries.find(entry => entry.value && typeof entry.value === 'object')
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 kvState = readPersistedState('kv')
if (kvState) {
kvStore.$patch(kvState as any)
}
await Promise.all([
tabStore.$persistNow?.(),
zxFwPricingStore.$persistNow?.(),
kvStore.$persistNow?.()
])
dataMenuOpen.value = false
window.location.reload()
} catch (error) {
console.error('import apply failed:', error)
window.alert('导入失败:写入本地数据时发生错误。')
} finally {
cancelImportConfirm()
}
}
const handleReset = async () => {
try {
localStorage.clear()
sessionStorage.clear()
await localforage.clear()
await Promise.all(
getPiniaPersistStores().map(async ({ store }) => {
await store.clear()
})
)
localStorage.setItem(USER_GUIDE_COMPLETED_KEY, '1')
} catch (error) {
console.error('reset failed:', error)
} finally {
tabStore.resetTabs()
await tabStore.$persistNow?.()
window.location.reload()
}
}
onMounted(() => {
window.addEventListener('mousedown', handleGlobalMouseDown)
window.addEventListener('keydown', handleGlobalKeyDown)
window.addEventListener('resize', scheduleUpdateTabTitleOverflow)
void nextTick(() => {
bindTabStripScroll()
ensureActiveTabVisible()
updateTabScrollButtons()
scheduleUpdateTabTitleOverflow()
scheduleRestoreTabInnerScrollTop(tabStore.activeTabId)
})
void (async () => {
if (await shouldAutoOpenGuide()) {
openUserGuide(0)
}
})()
})
onBeforeUnmount(() => {
clearReportExportToastTimer()
window.removeEventListener('mousedown', handleGlobalMouseDown)
window.removeEventListener('keydown', handleGlobalKeyDown)
window.removeEventListener('resize', scheduleUpdateTabTitleOverflow)
if (tabStripViewportEl) {
tabStripViewportEl.removeEventListener('scroll', handleTabStripScroll)
tabStripViewportEl = null
}
if (tabTitleOverflowRafId != null) {
cancelAnimationFrame(tabTitleOverflowRafId)
tabTitleOverflowRafId = null
}
})
watch(
() => tabStore.activeTabId,
(nextId, prevId) => {
saveTabInnerScrollTop(prevId)
void nextTick(() => {
bindTabStripScroll()
ensureActiveTabVisible()
updateTabScrollButtons()
scheduleUpdateTabTitleOverflow()
scheduleRestoreTabInnerScrollTop(nextId)
})
}
)
watch(
() => tabStore.tabs.map(t => t.id),
(ids) => {
const idSet = new Set(ids)
try {
for (let i = sessionStorage.length - 1; i >= 0; i--) {
const key = sessionStorage.key(i)
if (!key || !key.startsWith('tab-scroll-top:')) continue
const tabId = key.slice('tab-scroll-top:'.length)
if (!idSet.has(tabId)) sessionStorage.removeItem(key)
}
} catch (error) {
console.error('cleanup tab scroll cache failed:', error)
}
void nextTick(() => {
bindTabStripScroll()
updateTabScrollButtons()
scheduleUpdateTabTitleOverflow()
})
}
)
</script>
<template>
<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 }">
<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',
tab.id !== 'XmView' ? 'cursor-move' : ''
]">
<TooltipRoot>
<TooltipTrigger as-child>
<span :ref="el => setTabTitleRef(tab.id, el)" class="truncate mr-2">
{{ tab.title }}
</span>
</TooltipTrigger>
<TooltipContent v-if="tabTitleOverflowMap[tab.id]" side="bottom">{{ tab.title }}</TooltipContent>
</TooltipRoot>
<Button v-if="tab.id !== 'XmView'" variant="ghost" size="icon"
class="h-4 w-4 ml-auto opacity-0 group-hover:opacity-100 hover:bg-destructive hover:text-destructive-foreground transition-opacity"
@click.stop="tabStore.removeTab(tab.id)">
<X class="h-3 w-3" />
</Button>
</div>
</template>
</draggable>
</ScrollArea>
<button type="button" :class="[
' 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">
<div ref="dataMenuRef" class="relative shrink-0">
<Button variant="outline" size="sm"
class="h-9 min-h-9 shrink-0 px-3 py-0 text-sm leading-none cursor-pointer"
@click="dataMenuOpen = !dataMenuOpen">
<ChevronDown class="h-4 w-4 mr-1" />
导入/导出
</Button>
<div v-if="dataMenuOpen"
class="absolute right-0 top-full mt-1 z-50 rounded-md border bg-background p-1 shadow-md">
<button class="w-full cursor-pointer rounded px-3 py-1.5 text-left text-sm hover:bg-muted"
@click="triggerImport">
导入
</button>
<button class="w-full cursor-pointer rounded px-3 py-1.5 text-left text-sm hover:bg-muted"
@click="exportData">
导出
</button>
<button class="w-full cursor-pointer rounded px-3 py-1.5 text-left text-sm hover:bg-muted"
@click="exportReport">
导出报表
</button>
</div>
<input ref="importFileRef" type="file" accept=".zw" class="hidden" @change="importData" />
</div>
<Button variant="outline" size="sm" class="h-9 min-h-9 shrink-0 px-3 py-0 text-sm leading-none cursor-pointer"
@click="openUserGuide(0)">
<CircleHelp class="h-4 w-4 mr-1" />
使用引导
</Button>
<AlertDialogRoot>
<AlertDialogTrigger as-child>
<Button variant="destructive" size="sm"
class="h-9 min-h-9 shrink-0 px-3 py-0 text-sm leading-none cursor-pointer">
<RotateCcw class="h-4 w-4 mr-1" />
重置
</Button>
</AlertDialogTrigger>
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent
class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">确认重置</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
将清空所有项目数据并恢复默认页面确认继续吗
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child>
<Button variant="outline">取消</Button>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button variant="destructive" @click="handleReset">确认重置</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
<AlertDialogRoot :open="importConfirmOpen" @update:open="importConfirmOpen = $event">
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent
class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">确认导入覆盖</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
将使用{{ pendingImportFileName || '所选文件' }}覆盖当前本地全部数据是否继续
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child>
<Button variant="outline" @click="cancelImportConfirm">取消</Button>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button variant="destructive" @click="confirmImportOverride">确认覆盖</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</div>
</div>
<div class="flex-1 overflow-auto relative">
<div v-for="tab in tabStore.tabs" :key="tab.id" :ref="el => setTabPanelRef(tab.id, el)"
v-show="tabStore.activeTabId === tab.id" class="h-full w-full p-4 animate-in fade-in duration-300">
<component :is="componentMap[tab.componentName]" v-bind="tab.props || {}" />
</div>
</div>
<div v-if="tabContextOpen" ref="tabContextRef"
class="fixed z-[70] w-max rounded-lg border border-border/80 bg-background/95 p-1.5 shadow-xl ring-1 ring-black/5 backdrop-blur-sm"
:style="{ left: `${tabContextX}px`, top: `${tabContextY}px` }">
<button
class="flex cursor-pointer items-center whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!hasClosableTabs" @click="runTabMenuAction('all')">
删除所有
</button>
<button
class="mt-0.5 flex cursor-pointer items-center whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!canCloseLeft" @click="runTabMenuAction('left')">
删除左侧
</button>
<button
class="mt-0.5 flex cursor-pointer items-center whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!canCloseRight" @click="runTabMenuAction('right')">
删除右侧
</button>
<button
class="mt-0.5 flex cursor-pointer items-center whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!canCloseOther" @click="runTabMenuAction('other')">
删除其他
</button>
</div>
<div v-if="userGuideOpen" class="fixed inset-0 z-[120] flex items-center justify-center bg-black/45 p-4"
@click.self="closeUserGuide(false)">
<div class="w-full max-w-3xl rounded-xl border bg-background shadow-2xl">
<div class="flex items-start justify-between border-b px-6 py-5">
<div>
<p class="text-xs text-muted-foreground">新用户引导 · {{ guideProgressText }}</p>
<h3 class="mt-1 text-lg font-semibold">{{ activeGuideStep.title }}</h3>
</div>
<Button variant="ghost" size="icon" class="h-8 w-8" @click="closeUserGuide(false)">
<X class="h-4 w-4" />
</Button>
</div>
<div class="space-y-4 px-6 py-5">
<p class="text-sm leading-6 text-foreground">{{ activeGuideStep.description }}</p>
<ul class="list-disc space-y-2 pl-5 text-sm text-muted-foreground">
<li v-for="(point, index) in activeGuideStep.points" :key="`${activeGuideStep.title}-${index}`">
{{ point }}
</li>
</ul>
</div>
<div class="flex flex-col gap-3 border-t px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-1.5">
<button v-for="(_step, index) in userGuideSteps" :key="`guide-dot-${index}`"
class="h-2.5 w-2.5 cursor-pointer rounded-full transition-colors"
:class="index === userGuideStepIndex ? 'bg-primary' : 'bg-muted'" :aria-label="`跳转到第 ${index + 1} `"
@click="jumpToGuideStep(index)" />
</div>
<div class="flex items-center justify-end gap-2">
<Button variant="ghost" @click="closeUserGuide(false)">稍后再看</Button>
<Button variant="outline" :disabled="isFirstGuideStep" @click="prevUserGuideStep">上一步</Button>
<Button @click="nextUserGuideStep">{{ isLastGuideStep ? '完成并不再自动弹出' : '下一步' }}</Button>
</div>
</div>
</div>
</div>
<ToastRoot
:open="reportExportToastOpen"
:duration="0"
class="pointer-events-auto rounded-xl border border-border bg-card px-4 py-3 text-foreground shadow-lg"
>
<ToastTitle class="text-sm font-semibold text-foreground">
{{ reportExportStatus === 'running' ? '导出报表' : (reportExportStatus === 'success' ? '导出成功' : '导出失败') }}
</ToastTitle>
<ToastDescription class="mt-1 text-xs text-muted-foreground">{{ reportExportText }}</ToastDescription>
<div class="mt-2 h-1.5 w-full 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>
</ToastRoot>
<ToastViewport class="fixed bottom-5 right-5 z-[85] flex w-[380px] max-w-[92vw] flex-col gap-2 outline-none" />
</div>
</TooltipProvider>
</ToastProvider>
</template>
<style scoped>
.tab-strip-sortable>.tab-item {
transition: transform 0.26s cubic-bezier(0.22, 1, 0.36, 1);
}
.tab-strip-sortable.is-dragging>.tab-item {
will-change: transform;
}
.tab-drag-ghost {
opacity: 0.32;
}
.tab-drag-chosen {
transform: scale(1.015);
box-shadow: 0 10px 24px rgb(0 0 0 / 18%);
}
.tab-drag-active {
cursor: grabbing;
}
.tab-strip-scroll-area :deep([data-slot="scroll-area-viewport"]) {
scrollbar-width: none;
overflow-y: hidden !important;
}
.tab-strip-scroll-area :deep([data-slot="scroll-area-viewport"]::-webkit-scrollbar) {
display: none;
}
.tab-strip-scroll-area :deep([data-slot="scroll-area-scrollbar"][data-orientation="vertical"]),
.tab-strip-scroll-area :deep([data-slot="scroll-area-corner"]) {
display: none !important;
}
</style>