2074 lines
70 KiB
Vue
2074 lines
70 KiB
Vue
<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 { PROJECT_TAB_ID, QUICK_TAB_ID, readWorkspaceMode } from '@/lib/workspace'
|
||
import { addNumbers, roundTo } from '@/lib/decimal'
|
||
import { exportFile, serviceList } 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
|
||
}
|
||
|
||
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
|
||
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> = {
|
||
ProjectCalcView: markRaw(defineAsyncComponent(() => import('@/components/xm/xmCard.vue'))),
|
||
QuickCalcView: markRaw(defineAsyncComponent(() => import('@/components/ht/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>('ProjectCalcView')
|
||
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 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('')
|
||
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) => {
|
||
clearReportExportToastTimer()
|
||
reportExportStatus.value = success ? 'success' : 'error'
|
||
reportExportProgress.value = 100
|
||
reportExportText.value = text
|
||
reportExportToastOpen.value = true
|
||
reportExportToastTimer = setTimeout(() => {
|
||
reportExportToastOpen.value = false
|
||
}, success ? 1200 : 1800)
|
||
}
|
||
|
||
const tabsModel = computed({
|
||
get: () => tabStore.tabs,
|
||
set: (value) => {
|
||
tabStore.tabs = value
|
||
}
|
||
})
|
||
|
||
const contextTabIndex = computed(() => tabStore.tabs.findIndex((t:any) => t.id === contextTabId.value))
|
||
|
||
const hasClosableTabs = computed(() => {
|
||
const fixedId = readWorkspaceMode() === 'quick' ? QUICK_TAB_ID : PROJECT_TAB_ID
|
||
|
||
return tabStore.tabs.some((t: any) => t.componentName !== fixedId)
|
||
})
|
||
const activeGuideStep = computed(
|
||
() => userGuideSteps[userGuideStepIndex.value] || userGuideSteps[0]
|
||
)
|
||
const isFirstGuideStep = computed(() => userGuideStepIndex.value === 0)
|
||
const isLastGuideStep = computed(() => userGuideStepIndex.value >= userGuideSteps.length - 1)
|
||
const guideProgressText = computed(() => `${userGuideStepIndex.value + 1} / ${userGuideSteps.length}`)
|
||
const canCloseLeft = computed(() => {
|
||
if (contextTabIndex.value <= 0) return false
|
||
return tabStore.tabs.slice(1, contextTabIndex.value).length > 0
|
||
})
|
||
const canCloseRight = computed(() => {
|
||
if (contextTabIndex.value < 0) return false
|
||
return tabStore.tabs.slice(contextTabIndex.value + 1).length > 0
|
||
})
|
||
const canCloseOther = computed(() =>
|
||
tabStore.tabs.slice(1, contextTabIndex.value).length > 1 && contextTabIndex.value !== 0
|
||
)
|
||
|
||
const closeMenus = () => {
|
||
tabContextOpen.value = false
|
||
dataMenuOpen.value = false
|
||
}
|
||
|
||
const markGuideCompleted = () => {
|
||
try {
|
||
localStorage.setItem(USER_GUIDE_COMPLETED_KEY, '1')
|
||
} catch (error) {
|
||
console.error('mark guide completed failed:', error)
|
||
}
|
||
}
|
||
|
||
const hasGuideCompleted = () => {
|
||
try {
|
||
return localStorage.getItem(USER_GUIDE_COMPLETED_KEY) === '1'
|
||
} catch (error) {
|
||
console.error('read guide completion failed:', error)
|
||
return false
|
||
}
|
||
}
|
||
|
||
const hasNonDefaultTabState = () => {
|
||
try {
|
||
const raw = localStorage.getItem('tabs')
|
||
if (!raw) return false
|
||
const parsed = JSON.parse(raw) as { tabs?: Array<{ id?: string }>; activeTabId?: string }
|
||
const tabs = Array.isArray(parsed?.tabs) ? parsed.tabs : []
|
||
const hasCustomTabs = tabs.some(item => item?.id && item.id !== 'ProjectCalcView')
|
||
const activeTabId = typeof parsed?.activeTabId === 'string' ? parsed.activeTabId : ''
|
||
return hasCustomTabs || (activeTabId !== '' && activeTabId !== 'ProjectCalcView')
|
||
} catch (error) {
|
||
console.error('parse tabs cache failed:', error)
|
||
return false
|
||
}
|
||
}
|
||
|
||
const shouldAutoOpenGuide = async () => {
|
||
if (hasGuideCompleted()) return false
|
||
if (hasNonDefaultTabState()) return false
|
||
try {
|
||
const keys = await kvStore.keys()
|
||
return keys.length === 0
|
||
} catch (error) {
|
||
console.error('read kv keys failed:', error)
|
||
return false
|
||
}
|
||
}
|
||
|
||
const openUserGuide = (startAt = 0) => {
|
||
closeMenus()
|
||
userGuideStepIndex.value = Math.min(Math.max(startAt, 0), userGuideSteps.length - 1)
|
||
userGuideOpen.value = true
|
||
}
|
||
|
||
const closeUserGuide = (completed = false) => {
|
||
userGuideOpen.value = false
|
||
if (completed) markGuideCompleted()
|
||
}
|
||
|
||
const prevUserGuideStep = () => {
|
||
if (isFirstGuideStep.value) return
|
||
userGuideStepIndex.value -= 1
|
||
}
|
||
|
||
const nextUserGuideStep = () => {
|
||
if (isLastGuideStep.value) {
|
||
closeUserGuide(true)
|
||
return
|
||
}
|
||
userGuideStepIndex.value += 1
|
||
}
|
||
|
||
const jumpToGuideStep = (stepIndex: number) => {
|
||
userGuideStepIndex.value = Math.min(Math.max(stepIndex, 0), userGuideSteps.length - 1)
|
||
}
|
||
|
||
const openTabContextMenu = (event: MouseEvent, tabId: string) => {
|
||
contextTabId.value = tabId
|
||
tabContextX.value = event.clientX
|
||
tabContextY.value = event.clientY
|
||
tabContextOpen.value = true
|
||
void nextTick(() => {
|
||
if (!tabContextRef.value) return
|
||
const gap = 8
|
||
const rect = tabContextRef.value.getBoundingClientRect()
|
||
if (tabContextX.value + rect.width + gap > window.innerWidth) {
|
||
tabContextX.value = Math.max(gap, window.innerWidth - rect.width - gap)
|
||
}
|
||
if (tabContextY.value + rect.height + gap > window.innerHeight) {
|
||
tabContextY.value = Math.max(gap, window.innerHeight - rect.height - gap)
|
||
}
|
||
if (tabContextX.value < gap) tabContextX.value = gap
|
||
if (tabContextY.value < gap) tabContextY.value = gap
|
||
})
|
||
}
|
||
|
||
const handleGlobalMouseDown = (event: MouseEvent) => {
|
||
const target = event.target as Node
|
||
if (tabContextOpen.value && tabContextRef.value && !tabContextRef.value.contains(target)) {
|
||
tabContextOpen.value = false
|
||
}
|
||
if (dataMenuOpen.value && dataMenuRef.value && !dataMenuRef.value.contains(target)) {
|
||
dataMenuOpen.value = false
|
||
}
|
||
}
|
||
|
||
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 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 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 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 || row.budgetFee == null) return null
|
||
const cost = toFiniteNumber(row.amount)
|
||
const basicFee = toFiniteNumber(row.budgetFee)
|
||
if (basicFee != null) hasTotalValue = true
|
||
const basicFeeBasic = toFiniteNumber(row.budgetFeeBasic)
|
||
const basicFeeOptional = toFiniteNumber(row.budgetFeeOptional)
|
||
const remark = typeof row.remark === 'string' ? row.remark : ''
|
||
const hasValue =
|
||
cost != null ||
|
||
basicFee != null ||
|
||
basicFeeBasic != null ||
|
||
basicFeeOptional != null ||
|
||
isNonEmptyString(remark)
|
||
if (!hasValue) return null
|
||
return {
|
||
major,
|
||
cost: cost ?? 0,
|
||
basicFee: basicFee ?? 0,
|
||
basicFormula: typeof row.basicFormula === 'string' ? row.basicFormula : '',
|
||
basicFee_basic: basicFeeBasic ?? 0,
|
||
optionalFormula: typeof row.optionalFormula === 'string' ? row.optionalFormula : '',
|
||
basicFee_optional: basicFeeOptional ?? 0,
|
||
serviceCoe: toFiniteNumberOrZero(row.consultCategoryFactor),
|
||
majorCoe: toFiniteNumberOrZero(row.majorFactor),
|
||
processCoe: toFiniteNumber(row.workStageFactor) ?? 1,
|
||
proportion: toFiniteNumber(row.workRatio) ?? 1,
|
||
fee: basicFee ?? 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 || row.budgetFee == null) return null
|
||
const area = toFiniteNumber(row.landArea)
|
||
const basicFee = toFiniteNumber(row.budgetFee)
|
||
if (basicFee != null) hasTotalValue = true
|
||
const basicFeeBasic = toFiniteNumber(row.budgetFeeBasic)
|
||
const basicFeeOptional = toFiniteNumber(row.budgetFeeOptional)
|
||
const remark = typeof row.remark === 'string' ? row.remark : ''
|
||
const hasValue =
|
||
area != null ||
|
||
basicFee != null ||
|
||
basicFeeBasic != null ||
|
||
basicFeeOptional != null ||
|
||
isNonEmptyString(remark)
|
||
if (!hasValue) return null
|
||
return {
|
||
major,
|
||
area: area ?? 0,
|
||
basicFee: basicFee ?? 0,
|
||
basicFormula: typeof row.basicFormula === 'string' ? row.basicFormula : '',
|
||
basicFee_basic: basicFeeBasic ?? 0,
|
||
optionalFormula: typeof row.optionalFormula === 'string' ? row.optionalFormula : '',
|
||
basicFee_optional: basicFeeOptional ?? 0,
|
||
serviceCoe: toFiniteNumberOrZero(row.consultCategoryFactor),
|
||
majorCoe: toFiniteNumberOrZero(row.majorFactor),
|
||
processCoe: toFiniteNumber(row.workStageFactor) ?? 1,
|
||
proportion: toFiniteNumber(row.workRatio) ?? 1,
|
||
fee: basicFee ?? 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 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: { 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: 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: { richText: [{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'C' }, { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'C' }] },
|
||
|
||
name: '附加工作',
|
||
fee: 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 = {
|
||
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) ?? sumNumbers(projectScale.map(item => item.cost))
|
||
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 {
|
||
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 {
|
||
const now = new Date()
|
||
const payload = await buildExportReportPayload()
|
||
const fileName = `${sanitizeFileNamePart(payload.name)}-报表-${formatExportTimestamp(now)}`
|
||
await exportFile(fileName, payload, () => {
|
||
showReportExportProgress(30, '正在生成报表文件...')
|
||
})
|
||
finishReportExportProgress(true, '报表导出完成')
|
||
} catch (error) {
|
||
console.error('export report failed:', error)
|
||
if (reportExportToastOpen.value) {
|
||
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)
|
||
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?.()
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
window.addEventListener('mousedown', handleGlobalMouseDown)
|
||
window.addEventListener('keydown', handleGlobalKeyDown)
|
||
window.addEventListener('resize', scheduleUpdateTabTitleOverflow)
|
||
void nextTick(() => {
|
||
bindTabStripScroll()
|
||
ensureActiveTabVisible()
|
||
updateTabScrollButtons()
|
||
scheduleUpdateTabTitleOverflow()
|
||
scheduleRestoreTabInnerScrollTop(tabStore.activeTabId)
|
||
})
|
||
void (async () => {
|
||
if (await shouldAutoOpenGuide()) {
|
||
openUserGuide(0)
|
||
}
|
||
})()
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
window.removeEventListener('mousedown', handleGlobalMouseDown)
|
||
window.removeEventListener('keydown', handleGlobalKeyDown)
|
||
window.removeEventListener('resize', scheduleUpdateTabTitleOverflow)
|
||
if (tabStripViewportEl) {
|
||
tabStripViewportEl.removeEventListener('scroll', handleTabStripScroll)
|
||
tabStripViewportEl = null
|
||
}
|
||
if (tabTitleOverflowRafId != null) {
|
||
cancelAnimationFrame(tabTitleOverflowRafId)
|
||
tabTitleOverflowRafId = null
|
||
}
|
||
})
|
||
|
||
watch(
|
||
() => tabStore.activeTabId,
|
||
(nextId, prevId) => {
|
||
saveTabInnerScrollTop(prevId)
|
||
void nextTick(() => {
|
||
bindTabStripScroll()
|
||
ensureActiveTabVisible()
|
||
updateTabScrollButtons()
|
||
scheduleUpdateTabTitleOverflow()
|
||
scheduleRestoreTabInnerScrollTop(nextId)
|
||
})
|
||
}
|
||
)
|
||
|
||
watch(
|
||
() => tabStore.tabs.map((t:any) => t.id),
|
||
(ids) => {
|
||
const idSet = new Set(ids)
|
||
try {
|
||
for (let i = sessionStorage.length - 1; i >= 0; i--) {
|
||
const key = sessionStorage.key(i)
|
||
if (!key || !key.startsWith('tab-scroll-top:')) continue
|
||
const tabId = key.slice('tab-scroll-top:'.length)
|
||
if (!idSet.has(tabId)) sessionStorage.removeItem(key)
|
||
}
|
||
} catch (error) {
|
||
console.error('cleanup tab scroll cache failed:', error)
|
||
}
|
||
void nextTick(() => {
|
||
bindTabStripScroll()
|
||
updateTabScrollButtons()
|
||
scheduleUpdateTabTitleOverflow()
|
||
})
|
||
}
|
||
)
|
||
</script>
|
||
|
||
<template>
|
||
<ToastProvider>
|
||
<TooltipProvider>
|
||
<div class="flex flex-col w-full h-screen bg-background overflow-hidden">
|
||
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-2 border-b bg-muted/30 px-2 pt-1 h-15 flex-none">
|
||
<div class="flex min-w-0 items-start gap-1 h-full" @mouseenter="isTabStripHover = true"
|
||
@mouseleave="isTabStripHover = false">
|
||
<button type="button" :class="[
|
||
'h-9 w-8 self-center shrink-0 cursor-pointer rounded border bg-background text-sm text-muted-foreground transition hover:bg-muted',
|
||
isTabStripHover && showTabScrollLeft ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||
]" @click="scrollTabStripBy(-260)">
|
||
<
|
||
</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">
|
||
<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"
|
||
v-if="readWorkspaceMode() !== 'quick'"
|
||
@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 animate-in fade-in duration-300">
|
||
<component :is="componentMap[tab.componentName]" v-bind="tab.props || {}" />
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="tabContextOpen" ref="tabContextRef"
|
||
class="fixed z-[70] w-max rounded-lg border border-border/80 bg-background/95 p-1.5 shadow-xl ring-1 ring-black/5 backdrop-blur-sm"
|
||
:style="{ left: `${tabContextX}px`, top: `${tabContextY}px` }">
|
||
<button
|
||
class="flex cursor-pointer items-center whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
|
||
:disabled="!hasClosableTabs" @click="runTabMenuAction('all')">
|
||
删除所有
|
||
</button>
|
||
<button
|
||
class="mt-0.5 flex cursor-pointer items-center whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
|
||
:disabled="!canCloseLeft" @click="runTabMenuAction('left')">
|
||
删除左侧
|
||
</button>
|
||
<button
|
||
class="mt-0.5 flex cursor-pointer items-center whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
|
||
:disabled="!canCloseRight" @click="runTabMenuAction('right')">
|
||
删除右侧
|
||
</button>
|
||
<button
|
||
class="mt-0.5 flex cursor-pointer items-center whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
|
||
:disabled="!canCloseOther" @click="runTabMenuAction('other')">
|
||
删除其他
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="userGuideOpen" class="fixed inset-0 z-[120] flex items-center justify-center bg-black/45 p-4"
|
||
@click.self="closeUserGuide(false)">
|
||
<div class="w-full max-w-3xl rounded-xl border bg-background shadow-2xl">
|
||
<div class="flex items-start justify-between border-b px-6 py-5">
|
||
<div>
|
||
<p class="text-xs text-muted-foreground">新用户引导 · {{ guideProgressText }}</p>
|
||
<h3 class="mt-1 text-lg font-semibold">{{ activeGuideStep.title }}</h3>
|
||
</div>
|
||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="closeUserGuide(false)">
|
||
<X class="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
|
||
<div class="space-y-4 px-6 py-5">
|
||
<p class="text-sm leading-6 text-foreground">{{ activeGuideStep.description }}</p>
|
||
<ul class="list-disc space-y-2 pl-5 text-sm text-muted-foreground">
|
||
<li v-for="(point, index) in activeGuideStep.points" :key="`${activeGuideStep.title}-${index}`">
|
||
{{ point }}
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="flex flex-col gap-3 border-t px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||
<div class="flex items-center gap-1.5">
|
||
<button v-for="(_step, index) in userGuideSteps" :key="`guide-dot-${index}`"
|
||
class="h-2.5 w-2.5 cursor-pointer rounded-full transition-colors"
|
||
:class="index === userGuideStepIndex ? 'bg-primary' : 'bg-muted'" :aria-label="`跳转到第 ${index + 1} 步`"
|
||
@click="jumpToGuideStep(index)" />
|
||
</div>
|
||
<div class="flex items-center justify-end gap-2">
|
||
<Button variant="ghost" @click="closeUserGuide(false)">稍后再看</Button>
|
||
<Button variant="outline" :disabled="isFirstGuideStep" @click="prevUserGuideStep">上一步</Button>
|
||
<Button @click="nextUserGuideStep">{{ isLastGuideStep ? '完成并不再自动弹出' : '下一步' }}</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<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"
|
||
>
|
||
<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 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>
|
||
.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>
|