1623 lines
53 KiB
Vue
1623 lines
53 KiB
Vue
<script setup lang="ts">
|
||
import { computed, defineAsyncComponent, markRaw, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||
import type { ComponentPublicInstance } from 'vue'
|
||
import draggable from 'vuedraggable'
|
||
import { useTabStore } from '@/pinia/tab'
|
||
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,
|
||
} from 'reka-ui'
|
||
import { decodeZwArchive, encodeZwArchive, ZW_FILE_EXTENSION } from '@/lib/zwArchive'
|
||
import { exportFile, serviceList } from '@/sql'
|
||
|
||
interface DataEntry {
|
||
key: string
|
||
value: any
|
||
}
|
||
|
||
interface DataPackage {
|
||
version: number
|
||
exportedAt: string
|
||
localStorage: DataEntry[]
|
||
sessionStorage: DataEntry[]
|
||
localforageDefault: DataEntry[]
|
||
}
|
||
|
||
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?: unknown
|
||
landArea?: unknown
|
||
}
|
||
|
||
interface XmInfoStorageLike extends XmInfoLike {
|
||
detailRows?: ScaleRowLike[]
|
||
}
|
||
interface XmScaleStorageLike {
|
||
detailRows?: ScaleRowLike[]
|
||
}
|
||
|
||
interface ContractCardItem {
|
||
id: string
|
||
name?: string
|
||
order?: number
|
||
}
|
||
|
||
interface ZxFwRowLike {
|
||
id: string
|
||
subtotal?: unknown
|
||
investScale?: unknown
|
||
landScale?: unknown
|
||
workload?: unknown
|
||
hourly?: unknown
|
||
}
|
||
|
||
interface ZxFwStorageLike {
|
||
detailRows?: ZxFwRowLike[]
|
||
}
|
||
|
||
interface ScaleMethodRowLike extends ScaleRowLike {
|
||
basicFormula?: unknown
|
||
optionalFormula?: unknown
|
||
budgetFee?: unknown
|
||
budgetFeeBasic?: unknown
|
||
budgetFeeOptional?: unknown
|
||
consultCategoryFactor?: unknown
|
||
majorFactor?: unknown
|
||
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[]
|
||
}
|
||
|
||
interface FactorRowLike {
|
||
id: string
|
||
standardFactor?: unknown
|
||
budgetValue?: unknown
|
||
remark?: unknown
|
||
}
|
||
|
||
interface ExportScaleRow {
|
||
major: number
|
||
cost: number
|
||
area: number
|
||
}
|
||
|
||
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: []
|
||
reserve: []
|
||
}
|
||
|
||
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 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'))),
|
||
}
|
||
|
||
const tabStore = useTabStore()
|
||
|
||
|
||
|
||
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 = ref<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 tabTitleOverflowMap = ref<Record<string, boolean>>({})
|
||
let tabStripViewportEl: HTMLElement | null = null
|
||
let tabTitleOverflowRafId: number | 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 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 localforage.keys()
|
||
return keys.length === 0
|
||
} catch (error) {
|
||
console.error('read localforage 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 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)
|
||
}
|
||
}
|
||
|
||
const readForage = async (store: typeof localforage): 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 })
|
||
}
|
||
return entries
|
||
}
|
||
|
||
const writeForage = async (store: typeof localforage, entries: DataEntry[]) => {
|
||
await store.clear()
|
||
for (const entry of entries || []) {
|
||
await store.setItem(entry.key, 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 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 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) return null
|
||
const coe = toFiniteNumber(row.budgetValue) ?? toFiniteNumber(row.standardFactor) ?? 0
|
||
return {
|
||
serviceid,
|
||
coe,
|
||
remark: typeof row.remark === 'string' ? 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) return null
|
||
const coe = toFiniteNumber(row.budgetValue) ?? toFiniteNumber(row.standardFactor) ?? 0
|
||
return {
|
||
majorid,
|
||
coe,
|
||
remark: typeof row.remark === 'string' ? row.remark : ''
|
||
}
|
||
})
|
||
.filter((item): item is ExportMajorCoe => Boolean(item))
|
||
}
|
||
|
||
const buildScaleRows = (rows: ScaleRowLike[] | undefined): ExportScaleRow[] => {
|
||
if (!Array.isArray(rows)) return []
|
||
return rows
|
||
.map(row => {
|
||
const major = toSafeInteger(row.id)
|
||
const cost = toFiniteNumber(row.amount)
|
||
const area = toFiniteNumber(row.landArea)
|
||
if (major == null || (cost == null && area == null)) return null
|
||
return {
|
||
major,
|
||
cost: cost ?? 0,
|
||
area: area ?? 0
|
||
}
|
||
})
|
||
.filter((item): item is ExportScaleRow => Boolean(item))
|
||
}
|
||
|
||
const buildMethod1 = (rows: ScaleMethodRowLike[] | undefined): ExportMethod1 | null => {
|
||
if (!Array.isArray(rows)) return null
|
||
const det = rows
|
||
.map(row => {
|
||
const major = toSafeInteger(row.id)
|
||
if (major == null) return null
|
||
const cost = toFiniteNumber(row.amount)
|
||
const basicFee = toFiniteNumber(row.budgetFee)
|
||
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: 1,
|
||
proportion: 1,
|
||
fee: basicFee ?? 0,
|
||
remark
|
||
}
|
||
})
|
||
.filter((item): item is ExportMethod1Detail => Boolean(item))
|
||
|
||
if (det.length === 0) 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
|
||
const det = rows
|
||
.map(row => {
|
||
const major = toSafeInteger(row.id)
|
||
if (major == null) return null
|
||
const area = toFiniteNumber(row.landArea)
|
||
const basicFee = toFiniteNumber(row.budgetFee)
|
||
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: 1,
|
||
proportion: 1,
|
||
fee: basicFee ?? 0,
|
||
remark
|
||
}
|
||
})
|
||
.filter((item): item is ExportMethod2Detail => Boolean(item))
|
||
|
||
if (det.length === 0) 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
|
||
const det = rows
|
||
.map(row => {
|
||
const task = getTaskIdFromRowId(row.id)
|
||
if (task == null) return null
|
||
const amount = toFiniteNumber(row.workload)
|
||
const basicFee = toFiniteNumber(row.basicFee)
|
||
const fee = toFiniteNumber(row.serviceFee)
|
||
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) 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
|
||
const det = rows
|
||
.map(row => {
|
||
const expert = getExpertIdFromRowId(row.id)
|
||
if (expert == null) return null
|
||
const personNum = toFiniteNumber(row.personnelCount)
|
||
const workDay = toFiniteNumber(row.workdayCount)
|
||
const fee = toFiniteNumber(row.serviceBudget)
|
||
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) 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,
|
||
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 buildExportReportPayload = async (): Promise<ExportReportPayload> => {
|
||
const [projectInfoRaw, projectScaleRaw, consultCategoryFactorRaw, majorFactorRaw, contractCardsRaw] = await Promise.all([
|
||
localforage.getItem<XmInfoLike>(PROJECT_INFO_DB_KEY),
|
||
localforage.getItem<XmInfoStorageLike>(LEGACY_PROJECT_DB_KEY),
|
||
localforage.getItem<DetailRowsStorageLike<FactorRowLike>>(CONSULT_CATEGORY_FACTOR_DB_KEY),
|
||
localforage.getItem<DetailRowsStorageLike<FactorRowLike>>(MAJOR_FACTOR_DB_KEY),
|
||
localforage.getItem<ContractCardItem[]>('ht-card-v1')
|
||
])
|
||
|
||
const projectInfo = projectInfoRaw || {}
|
||
const projectScaleSource = projectScaleRaw || {}
|
||
const projectScale = buildScaleRows(projectScaleSource.detailRows)
|
||
const projectScaleCost = sumNumbers(projectScale.map(item => item.cost))
|
||
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
|
||
const [htInfoRaw, zxFwRaw] = await Promise.all([
|
||
localforage.getItem<DetailRowsStorageLike<ScaleRowLike>>(`ht-info-v3-${contractId}`),
|
||
localforage.getItem<ZxFwStorageLike>(`zxFW-${contractId}`)
|
||
])
|
||
|
||
const zxRows = Array.isArray(zxFwRaw?.detailRows) ? zxFwRaw.detailRows : []
|
||
const fixedRow = zxRows.find(row => row.id === 'fixed-budget-c')
|
||
const serviceRows = zxRows.filter(row => row.id !== 'fixed-budget-c' && hasServiceId(String(row.id)))
|
||
|
||
const services = (
|
||
await Promise.all(
|
||
serviceRows.map(async row => {
|
||
const serviceIdText = String(row.id)
|
||
const serviceId = toSafeInteger(serviceIdText)
|
||
if (serviceId == null) return null
|
||
|
||
const [method1Raw, method2Raw, method3Raw, method4Raw] = await Promise.all([
|
||
localforage.getItem<DetailRowsStorageLike<ScaleMethodRowLike>>(`tzGMF-${contractId}-${serviceIdText}`),
|
||
localforage.getItem<DetailRowsStorageLike<ScaleMethodRowLike>>(`ydGMF-${contractId}-${serviceIdText}`),
|
||
localforage.getItem<DetailRowsStorageLike<WorkloadMethodRowLike>>(`gzlF-${contractId}-${serviceIdText}`),
|
||
localforage.getItem<DetailRowsStorageLike<HourlyMethodRowLike>>(`hourlyPricing-${contractId}-${serviceIdText}`)
|
||
])
|
||
|
||
const method1 = buildMethod1(method1Raw?.detailRows)
|
||
const method2 = buildMethod2(method2Raw?.detailRows)
|
||
const method3 = buildMethod3(method3Raw?.detailRows)
|
||
const method4 = buildMethod4(method4Raw?.detailRows)
|
||
const fee = buildServiceFee(row, method1, method2, method3, method4)
|
||
|
||
const service: ExportService = {
|
||
id: serviceId,
|
||
process: 0,
|
||
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 addtionalFee = 0
|
||
const reserveFee = 0
|
||
const contractFee = serviceFee + addtionalFee + reserveFee
|
||
|
||
contracts.push({
|
||
name: isNonEmptyString(contract.name) ? contract.name : `合同段-${index + 1}`,
|
||
serviceFee,
|
||
addtionalFee,
|
||
reserveFee,
|
||
fee: contractFee,
|
||
scale: buildScaleRows(htInfoRaw?.detailRows),
|
||
serviceCoes: projectServiceCoes.map(item => ({ ...item })),
|
||
majorCoes: projectMajorCoes.map(item => ({ ...item })),
|
||
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 payload: DataPackage = {
|
||
version: 1,
|
||
exportedAt: now.toISOString(),
|
||
localStorage: readWebStorage(localStorage),
|
||
sessionStorage: readWebStorage(sessionStorage),
|
||
localforageDefault: await readForage(localforage),
|
||
}
|
||
|
||
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)
|
||
} catch (error) {
|
||
console.error('export report failed:', error)
|
||
window.alert('导出报表失败,请重试。')
|
||
} 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))
|
||
|
||
tabStore.resetTabs()
|
||
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()
|
||
localStorage.setItem(USER_GUIDE_COMPLETED_KEY, '1')
|
||
} catch (error) {
|
||
console.error('reset failed:', error)
|
||
} finally {
|
||
tabStore.resetTabs()
|
||
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(() => {
|
||
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>
|
||
<TooltipProvider>
|
||
<div class="flex flex-col w-full h-screen bg-background overflow-hidden">
|
||
<div class="flex items-start gap-2 border-b bg-muted/30 px-2 pt-2 flex-none">
|
||
<div
|
||
class="mb-2 flex min-w-0 flex-1 items-center gap-1"
|
||
@mouseenter="isTabStripHover = true"
|
||
@mouseleave="isTabStripHover = false"
|
||
>
|
||
<button
|
||
type="button"
|
||
:class="[
|
||
'h-9 w-8 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="tab-strip-scroll-area min-w-0 flex-1 whitespace-nowrap">
|
||
<draggable
|
||
v-model="tabsModel"
|
||
item-key="id"
|
||
tag="div"
|
||
class="flex w-max gap-1"
|
||
:animation="180"
|
||
:move="canMoveTab"
|
||
>
|
||
<template #item="{ element: tab }">
|
||
<div
|
||
:ref="el => setTabItemRef(tab.id, el)"
|
||
@click="tabStore.activeTabId = tab.id"
|
||
@contextmenu.prevent="openTabContextMenu($event, tab.id)"
|
||
:class="[
|
||
'group relative flex items-center h-9 px-4 min-w-[120px] max-w-[220px] cursor-pointer rounded-t-md border-x border-t transition-all text-sm',
|
||
tabStore.activeTabId === tab.id
|
||
? 'bg-background border-border font-medium'
|
||
: 'border-transparent hover:bg-muted text-muted-foreground',
|
||
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="[
|
||
'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 ref="dataMenuRef" class="relative mb-2 shrink-0">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
class="h-9 min-h-9 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="mb-2 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="mb-2 h-9 min-h-9 shrink-0 px-3 py-0 text-sm leading-none">
|
||
<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 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>
|
||
</div>
|
||
</TooltipProvider>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.tab-strip-scroll-area :deep([data-slot="scroll-area-viewport"]) {
|
||
scrollbar-width: none;
|
||
}
|
||
|
||
.tab-strip-scroll-area :deep([data-slot="scroll-area-viewport"]::-webkit-scrollbar) {
|
||
display: none;
|
||
}
|
||
</style>
|