This commit is contained in:
wintsa 2026-03-04 17:43:06 +08:00
parent 33913c29d2
commit 42fd6e48c4
19 changed files with 1163 additions and 420 deletions

View File

@ -54,7 +54,7 @@ const zxfwView = markRaw(
console.error('加载 zxFw 组件失败:', err);
}
});
return () => h(AsyncZxFw, { contractId: props.contractId });
return () => h(AsyncZxFw, { contractId: props.contractId, contractName: props.contractName });
}
})
);

View File

@ -9,6 +9,7 @@ import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/
import { useTabStore } from '@/pinia/tab'
import { ArrowUp, Edit3, GripVertical, MoreHorizontal, Plus, Trash2, X } from 'lucide-vue-next'
import { decodeZwArchive, encodeZwArchive } from '@/lib/zwArchive'
import { majorList } from '@/sql'
import {
AlertDialogAction,
AlertDialogCancel,
@ -41,9 +42,13 @@ interface DataEntry {
interface ContractSegmentPackage {
version: number
exportedAt: string
projectIndustry: string
contracts: ContractItem[]
localforageEntries: DataEntry[]
}
interface XmBaseInfoState {
projectIndustry?: string
}
const STORAGE_KEY = 'ht-card-v1'
const CONTRACT_SEGMENT_FILE_EXTENSION = '.htzw'
@ -51,6 +56,7 @@ const CONTRACT_SEGMENT_VERSION = 1
const CONTRACT_KEY_PREFIX = 'ht-info-v3-'
const SERVICE_KEY_PREFIX = 'zxFW-'
const PRICING_KEY_PREFIXES = ['tzGMF-', 'ydGMF-', 'gzlF-', 'hourlyPricing-']
const PROJECT_INFO_KEY = 'xm-base-info-v1'
const tabStore = useTabStore()
@ -84,6 +90,7 @@ const contractListViewportRef = ref<HTMLElement | null>(null)
const showScrollTopFab = ref(false)
const isDraggingContracts = ref(false)
const cardMotionState = ref<'enter' | 'ready'>('ready')
const canManageContracts = ref(false)
let contractAutoScrollRaf = 0
let dragPointerClientY: number | null = null
let cardEnterTimer: ReturnType<typeof setTimeout> | null = null
@ -101,6 +108,7 @@ const buildDefaultContracts = (): ContractItem[] => [
const normalizedSearchKeyword = computed(() => contractSearchKeyword.value.trim().toLowerCase())
const selectedExportCount = computed(() => selectedExportContractIds.value.length)
const hasContracts = computed(() => contracts.value.length > 0)
const filteredContracts = computed(() => {
if (!normalizedSearchKeyword.value) return contracts.value
return contracts.value.filter(item => {
@ -143,7 +151,6 @@ const pendingDeleteContractName = computed(() => {
const handleDeleteConfirmOpenChange = (open: boolean) => {
deleteConfirmOpen.value = open
if (!open) pendingDeleteContractId.value = null
}
const requestDeleteContract = (id: string) => {
@ -163,6 +170,21 @@ const closeContractDataMenu = () => {
contractDataMenuOpen.value = false
}
const loadProjectBaseState = async () => {
try {
const data = await localforage.getItem<XmBaseInfoState>(PROJECT_INFO_KEY)
canManageContracts.value = Boolean(typeof data?.projectIndustry === 'string' && data.projectIndustry.trim())
} catch (error) {
console.error('load project base state failed:', error)
canManageContracts.value = false
}
}
const getCurrentProjectIndustry = async (): Promise<string> => {
const data = await localforage.getItem<XmBaseInfoState>(PROJECT_INFO_KEY)
return typeof data?.projectIndustry === 'string' ? data.projectIndustry.trim() : ''
}
const formatExportTimestamp = (date: Date): string => {
const yyyy = date.getFullYear()
const mm = String(date.getMonth() + 1).padStart(2, '0')
@ -172,6 +194,22 @@ const formatExportTimestamp = (date: Date): string => {
return `${yyyy}${mm}${dd}-${hh}${mi}`
}
const industryNameByCode = (() => {
const map = new Map<string, string>()
for (const item of Object.values(majorList as Record<string, { code?: string; name?: string }>)) {
if (!item?.code || !item?.name) continue
if (item.code.includes('-')) continue
map.set(item.code, item.name)
}
return map
})()
const formatIndustryLabel = (code: string) => {
const trimmed = code.trim()
const name = industryNameByCode.get(trimmed)
return name ? `${trimmed} ${name}` : trimmed
}
const isContractSelectedForExport = (contractId: string) =>
selectedExportContractIds.value.includes(contractId)
@ -190,12 +228,14 @@ const exitContractExportMode = () => {
}
const enterContractExportMode = () => {
if (!hasContracts.value) return
closeContractDataMenu()
isExportSelecting.value = true
selectedExportContractIds.value = []
}
const triggerContractImport = () => {
if (!canManageContracts.value) return
closeContractDataMenu()
contractImportFileRef.value?.click()
}
@ -397,10 +437,17 @@ const exportSelectedContracts = async () => {
selectedContracts.map(item => item.id)
)
const projectIndustry = await getCurrentProjectIndustry()
if (!projectIndustry) {
window.alert('导出失败:未读取到当前项目工程行业,请先在“基础信息”里新建项目。')
return
}
const now = new Date()
const payload: ContractSegmentPackage = {
version: CONTRACT_SEGMENT_VERSION,
exportedAt: now.toISOString(),
projectIndustry,
contracts: selectedContracts,
localforageEntries
}
@ -443,6 +490,17 @@ const importContractSegments = async (event: Event) => {
throw new Error('INVALID_CONTRACT_SEGMENT_PAYLOAD')
}
const currentProjectIndustry = await getCurrentProjectIndustry()
if (!currentProjectIndustry) {
throw new Error('CURRENT_PROJECT_INDUSTRY_MISSING')
}
if (typeof payload.projectIndustry !== 'string' || !payload.projectIndustry.trim()) {
throw new Error('IMPORT_PACKAGE_INDUSTRY_MISSING')
}
if (payload.projectIndustry.trim() !== currentProjectIndustry) {
throw new Error(`PROJECT_INDUSTRY_MISMATCH:${payload.projectIndustry.trim()}:${currentProjectIndustry}`)
}
const importedContracts = normalizeContractsFromPayload(payload.contracts)
if (importedContracts.length === 0) {
throw new Error('EMPTY_CONTRACTS')
@ -483,7 +541,19 @@ const importContractSegments = async (event: Event) => {
scrollContractsToBottom()
} catch (error) {
console.error('import contract segments failed:', error)
const message = error instanceof Error ? error.message : ''
if (message.startsWith('PROJECT_INDUSTRY_MISMATCH:')) {
const [, importIndustry = '', currentIndustry = ''] = message.split(':')
window.alert(
`导入失败:工程行业不一致(导入包:${formatIndustryLabel(importIndustry)},当前项目:${formatIndustryLabel(currentIndustry)})。`
)
} else if (message === 'CURRENT_PROJECT_INDUSTRY_MISSING') {
window.alert('导入失败:当前项目未设置工程行业,请先在“基础信息”里新建项目。')
} else if (message === 'IMPORT_PACKAGE_INDUSTRY_MISSING') {
window.alert('导入失败:导入包缺少工程行业信息,请使用最新版本重新导出后再导入。')
} else {
window.alert('导入失败:文件无效、已损坏或不是合同段导出文件。')
}
} finally {
input.value = ''
}
@ -540,6 +610,7 @@ const loadContracts = async () => {
}
const openCreateModal = () => {
if (!canManageContracts.value) return
closeContractDataMenu()
editingContractId.value = null
contractNameInput.value = ''
@ -736,6 +807,7 @@ const handleGlobalMouseDown = (event: MouseEvent) => {
}
onMounted(async () => {
await loadProjectBaseState()
await loadContracts()
triggerCardEnterAnimation()
await nextTick()
@ -744,6 +816,7 @@ onMounted(async () => {
})
onActivated(() => {
void loadProjectBaseState()
triggerCardEnterAnimation()
void nextTick(() => {
bindContractListScroll()
@ -782,7 +855,7 @@ onBeforeUnmount(() => {
</Button>
</template>
<template v-else>
<Button @click="openCreateModal">
<Button :disabled="!canManageContracts" @click="openCreateModal">
<Plus class="mr-2 h-4 w-4" />
添加合同段
</Button>
@ -800,13 +873,17 @@ onBeforeUnmount(() => {
class="absolute right-0 top-full z-50 mt-1 min-w-[132px] 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"
class="w-full rounded px-3 py-1.5 text-left text-sm"
:class="hasContracts ? 'cursor-pointer hover:bg-muted' : 'cursor-not-allowed text-muted-foreground'"
:disabled="!hasContracts"
@click="enterContractExportMode"
>
导出合同段
</button>
<button
class="w-full cursor-pointer rounded px-3 py-1.5 text-left text-sm hover:bg-muted"
class="w-full rounded px-3 py-1.5 text-left text-sm"
:class="canManageContracts ? 'cursor-pointer hover:bg-muted' : 'cursor-not-allowed text-muted-foreground'"
:disabled="!canManageContracts"
@click="triggerContractImport"
>
导入合同段
@ -849,6 +926,9 @@ onBeforeUnmount(() => {
<div v-if="isExportSelecting" class="mt-1 text-xs text-muted-foreground">
导出选择模式勾选合同段后点击导出已选
</div>
<div v-if="!canManageContracts" class="mt-1 text-xs text-muted-foreground">
请先在基础信息里新建项目并选择工程行业后再新增或导入合同段
</div>
</div>
<div class="flex flex-wrap items-center gap-2 md:ml-auto">
<label class="inline-flex cursor-pointer items-center gap-2 text-xs text-muted-foreground select-none">
@ -1194,7 +1274,7 @@ onBeforeUnmount(() => {
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child>
<Button variant="outline">取消</Button>
<Button variant="outline" @click="pendingDeleteContractId = null">取消</Button>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button variant="destructive" @click="confirmDeleteContract">确认删除</Button>

View File

@ -0,0 +1,66 @@
<script setup lang="ts">
import { computed } from 'vue'
interface ServiceItem {
id: string
code: string
name: string
}
const props = defineProps<{
services: ServiceItem[]
modelValue: string[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string[]): void
}>()
const selectedSet = computed(() => new Set(props.modelValue))
const toggleService = (id: string, checked: boolean) => {
const next = new Set(props.modelValue)
if (checked) next.add(id)
else next.delete(id)
emit('update:modelValue', props.services.map(item => item.id).filter(itemId => next.has(itemId)))
}
const clearAll = () => {
emit('update:modelValue', [])
}
</script>
<template>
<div class="rounded-lg border bg-card p-4 shadow-sm shrink-0">
<div class="mb-2 flex items-center justify-between gap-3">
<label class="block text-sm font-medium text-foreground">选择服务</label>
<button
type="button"
class="h-8 rounded-md border px-3 text-xs text-muted-foreground transition hover:bg-accent"
@click="clearAll"
>
清空
</button>
</div>
<div class="max-h-52 overflow-auto rounded-md border p-2">
<div class="flex flex-wrap gap-2">
<label
v-for="item in props.services"
:key="item.id"
class="inline-flex cursor-pointer items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm hover:bg-muted/60"
>
<input
type="checkbox"
:checked="selectedSet.has(item.id)"
@change="toggleService(item.id, ($event.target as HTMLInputElement).checked)"
/>
<span class="text-muted-foreground">{{ item.code }}</span>
<span class="text-foreground">{{ item.name }}</span>
</label>
</div>
<div v-if="props.services.length === 0" class="px-2 py-4 text-center text-xs text-muted-foreground">
暂无服务
</div>
</div>
</div>
</template>

View File

@ -12,7 +12,8 @@
import { markRaw, defineAsyncComponent } from 'vue'
import TypeLine from '@/layout/typeLine.vue'
const xmView = markRaw(defineAsyncComponent(() => import('@/components/views/xmInfo.vue')))
const infoView = markRaw(defineAsyncComponent(() => import('@/components/views/info.vue')))
const scaleInfoView = markRaw(defineAsyncComponent(() => import('@/components/views/xmInfo.vue')))
const htView = markRaw(defineAsyncComponent(() => import('@/components/views/Ht.vue')))
const consultCategoryFactorView = markRaw(
defineAsyncComponent(() => import('@/components/views/XmConsultCategoryFactor.vue'))
@ -22,8 +23,10 @@ const majorFactorView = markRaw(
)
const xmCategories = [
{ key: 'info', label: '基础信息', component: xmView },
{ key: 'info', label: '基础信息', component: infoView },
{ key: 'contract', label: '合同段管理', component: htView },
{ key: 'scale-info', label: '规模信息', component: scaleInfoView },
{ key: 'consult-category-factor', label: '咨询分类系数', component: consultCategoryFactorView },
{ key: 'major-factor', label: '工程专业系数', component: majorFactorView }
]

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, FirstDataRenderedEvent, GridApi, GridReadyEvent, GridSizeChangedEvent } from 'ag-grid-community'
import localforage from 'localforage'
@ -165,7 +165,7 @@ const columnDefs: ColDef<FactorRow>[] = [
if (!props.disableBudgetEditWhenStandardNull) return true
return params.data?.standardFactor != null
},
valueParser: params => parseNumberOrNull(params.newValue),
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: params => {
const disabled = props.disableBudgetEditWhenStandardNull && params.data?.standardFactor == null
if (disabled && (params.value == null || params.value === '')) return ''
@ -260,6 +260,14 @@ onMounted(async () => {
await loadFromIndexedDB()
})
watch(
() => props.dict,
() => {
void loadFromIndexedDB()
},
{ deep: true }
)
onBeforeUnmount(() => {
if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridApi.value = null

View File

@ -1,12 +1,67 @@
<script setup lang="ts">
import { computed, onActivated, onMounted, ref } from 'vue'
import localforage from 'localforage'
import { majorList } from '@/sql'
import XmFactorGrid from '@/components/views/XmFactorGrid.vue'
import MethodUnavailableNotice from '@/components/views/pricingView/MethodUnavailableNotice.vue'
interface XmBaseInfoState {
projectIndustry?: string
}
type MajorItem = {
code: string
name: string
defCoe: number | null
desc?: string | null
notshowByzxflxs?: boolean
}
const PROJECT_INFO_KEY = 'xm-base-info-v1'
const projectIndustry = ref('')
const hasProjectBaseInfo = ref(false)
const loadProjectIndustry = async () => {
try {
const data = await localforage.getItem<XmBaseInfoState>(PROJECT_INFO_KEY)
hasProjectBaseInfo.value = Boolean(data)
projectIndustry.value =
typeof data?.projectIndustry === 'string' ? data.projectIndustry.trim() : ''
} catch (error) {
console.error('loadProjectIndustry failed:', error)
hasProjectBaseInfo.value = false
projectIndustry.value = ''
}
}
const filteredMajorDict = computed<Record<string, MajorItem>>(() => {
const industry = projectIndustry.value
if (!industry) return {}
const entries = Object.entries(majorList as Record<string, MajorItem>).filter(([, item]) =>
item.code === industry || item.code.startsWith(`${industry}-`)
)
return Object.fromEntries(entries)
})
onMounted(() => {
void loadProjectIndustry()
})
onActivated(() => {
void loadProjectIndustry()
})
</script>
<template>
<MethodUnavailableNotice
v-if="!hasProjectBaseInfo"
title="请先在“基础信息”里新建项目"
message="完成新建后将自动加载工程专业系数。"
/>
<XmFactorGrid
v-else
title="工程专业系数明细"
storage-key="xm-major-factor-v1"
:dict="majorList"
:dict="filteredMajorDict"
/>
</template>

View File

@ -1,7 +1,7 @@
<template>
<TypeLine
scene="zxfw-pricing-tab"
:title="`${fwName}计算`"
:title="`${contractName ? `合同段:${contractName} · ` : ''}${fwName}计算`"
:subtitle="`合同ID${contractId}`"
:copy-text="contractId"
:storage-key="`zxfw-pricing-active-cat-${contractId}-${serviceId}`"

View File

@ -1,11 +1,11 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onActivated, onBeforeUnmount, onMounted, ref } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, FirstDataRenderedEvent, GridApi, GridReadyEvent, GridSizeChangedEvent } from 'ag-grid-community'
import localforage from 'localforage'
import { majorList } from '@/sql'
import { myTheme ,gridOptions} from '@/lib/diyAgGridOptions'
import { decimalAggSum, sumByNumber } from '@/lib/decimal'
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
@ -39,15 +39,19 @@ interface XmInfoState {
projectName: string
detailRows: DetailRow[]
}
interface XmBaseInfoState {
projectIndustry?: string
}
const props = defineProps<{
contractId: string
}>()
const DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
const XM_DB_KEY = 'xm-info-v3'
const BASE_INFO_KEY = 'xm-base-info-v1'
const activeIndustryCode = ref('')
const detailRows = ref<DetailRow[]>([])
const gridApi = ref<GridApi<DetailRow> | null>(null)
type majorLite = { code: string; name: string }
const serviceEntries = Object.entries(majorList as Record<string, majorLite>)
@ -107,8 +111,10 @@ for (const group of detailDict) {
}
const buildDefaultRows = (): DetailRow[] => {
if (!activeIndustryCode.value) return []
const rows: DetailRow[] = []
for (const group of detailDict) {
if (activeIndustryCode.value && group.code !== activeIndustryCode.value) continue
for (const child of group.children) {
rows.push({
id: child.id,
@ -162,7 +168,7 @@ const columnDefs: ColDef<DetailRow>[] = [
valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? v : null
return Number.isFinite(v) ? roundTo(v, 2) : null
},
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
@ -175,27 +181,30 @@ const columnDefs: ColDef<DetailRow>[] = [
{
headerName: '用地面积(亩)',
field: 'landArea',
headerClass: 'ag-right-aligned-header',
minWidth: 100,
flex: 1, //
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClass: params =>
!params.node?.group && !params.node?.rowPinned
? 'ag-right-aligned-cell editable-cell-line'
: 'ag-right-aligned-cell',
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: decimalAggSum,
valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? v : null
return Number.isFinite(v) ? roundTo(v, 3) : null
},
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return String(Number(params.value))
return formatThousands(params.value, 3)
}
}
]
@ -227,7 +236,6 @@ wrapText: true,
const totalAmount = computed(() => sumByNumber(detailRows.value, row => row.amount))
const totalLandArea = computed(() => sumByNumber(detailRows.value, row => row.landArea))
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',
@ -236,7 +244,7 @@ const pinnedTopRowData = computed(() => [
majorCode: '',
majorName: '',
amount: totalAmount.value,
landArea: totalLandArea.value,
landArea: null,
path: ['TOTAL']
}
])
@ -257,6 +265,10 @@ const saveToIndexedDB = async () => {
const loadFromIndexedDB = async () => {
try {
const baseInfo = await localforage.getItem<XmBaseInfoState>(BASE_INFO_KEY)
activeIndustryCode.value =
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
if (data) {
detailRows.value = mergeWithDictRows(data.detailRows)
@ -264,7 +276,8 @@ const loadFromIndexedDB = async () => {
}
// id
const xmData = await localforage.getItem<XmInfoState>(XM_DB_KEY)
const xmData =
(await localforage.getItem<XmInfoState>(XM_DB_KEY))
if (xmData?.detailRows) {
detailRows.value = mergeWithDictRows(xmData.detailRows)
return
@ -295,6 +308,10 @@ onMounted(async () => {
await loadFromIndexedDB()
})
onActivated(() => {
void loadFromIndexedDB()
})
onBeforeUnmount(() => {
if (persistTimer) clearTimeout(persistTimer)
if (gridPersistTimer) clearTimeout(gridPersistTimer)

View File

@ -0,0 +1,302 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import localforage from 'localforage'
import { majorList } from '@/sql'
import { CircleHelp } from 'lucide-vue-next'
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
interface XmInfoState {
projectIndustry?: string
projectName?: string
preparedBy?: string
reviewedBy?: string
preparedCompany?: string
preparedDate?: string
}
type MajorLite = { code: string; name: string }
type MajorParentNode = { id: string; code: string; name: string }
const DB_KEY = 'xm-base-info-v1'
const DEFAULT_PROJECT_NAME = 'xxx造价咨询服务'
const INDUSTRY_HINT_TEXT = '变更需要重置后重新选择'
const isProjectInitialized = ref(false)
const showCreateDialog = ref(false)
const pendingIndustry = ref('')
const projectName = ref('')
const projectIndustry = ref('')
const preparedBy = ref('')
const reviewedBy = ref('')
const preparedCompany = ref('')
const preparedDate = ref('')
const majorEntries = Object.entries(majorList as Record<string, MajorLite>)
.sort((a, b) => Number(a[0]) - Number(b[0]))
.filter((entry): entry is [string, MajorLite] => {
const item = entry[1]
return Boolean(item?.code && item?.name)
})
const getMajorParentNodes = (entries: Array<[string, MajorLite]>): MajorParentNode[] =>
entries
.filter(([, item]) => !item.code.includes('-'))
.map(([id, item]) => ({
id,
code: item.code,
name: item.name
}))
const majorParentNodes = getMajorParentNodes(majorEntries)
const majorParentCodeSet = new Set(majorParentNodes.map(item => item.code))
const DEFAULT_PROJECT_INDUSTRY = majorParentNodes[0]?.code || ''
const saveToIndexedDB = async () => {
try {
const payload: XmInfoState = {
projectIndustry: projectIndustry.value,
projectName: projectName.value,
preparedBy: preparedBy.value,
reviewedBy: reviewedBy.value,
preparedCompany: preparedCompany.value,
preparedDate: preparedDate.value
}
await localforage.setItem(DB_KEY, payload)
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
}
const loadFromIndexedDB = async () => {
try {
const data = await localforage.getItem<XmInfoState>(DB_KEY)
if (data) {
isProjectInitialized.value = true
projectIndustry.value =
typeof data.projectIndustry === 'string' && majorParentCodeSet.has(data.projectIndustry)
? data.projectIndustry
: DEFAULT_PROJECT_INDUSTRY
projectName.value =
typeof data.projectName === 'string' && data.projectName.trim() ? data.projectName : DEFAULT_PROJECT_NAME
preparedBy.value = typeof data.preparedBy === 'string' ? data.preparedBy : ''
reviewedBy.value = typeof data.reviewedBy === 'string' ? data.reviewedBy : ''
preparedCompany.value = typeof data.preparedCompany === 'string' ? data.preparedCompany : ''
preparedDate.value = typeof data.preparedDate === 'string' ? data.preparedDate : ''
return
}
isProjectInitialized.value = false
projectIndustry.value = DEFAULT_PROJECT_INDUSTRY
projectName.value = DEFAULT_PROJECT_NAME
preparedBy.value = ''
reviewedBy.value = ''
preparedCompany.value = ''
preparedDate.value = ''
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
projectIndustry.value = DEFAULT_PROJECT_INDUSTRY
projectName.value = DEFAULT_PROJECT_NAME
preparedBy.value = ''
reviewedBy.value = ''
preparedCompany.value = ''
preparedDate.value = ''
}
}
let persistTimer: ReturnType<typeof setTimeout> | null = null
const schedulePersist = () => {
if (!isProjectInitialized.value) return
if (persistTimer) clearTimeout(persistTimer)
persistTimer = setTimeout(() => {
void saveToIndexedDB()
}, 250)
}
const handleProjectNameBlur = () => {
if (!projectName.value.trim()) {
projectName.value = DEFAULT_PROJECT_NAME
}
}
const openCreateDialog = () => {
pendingIndustry.value = DEFAULT_PROJECT_INDUSTRY
showCreateDialog.value = true
}
const closeCreateDialog = () => {
showCreateDialog.value = false
}
const createProject = async () => {
const selectedIndustry = majorParentCodeSet.has(pendingIndustry.value)
? pendingIndustry.value
: DEFAULT_PROJECT_INDUSTRY
projectIndustry.value = selectedIndustry
projectName.value = DEFAULT_PROJECT_NAME
preparedBy.value = ''
reviewedBy.value = ''
preparedCompany.value = ''
preparedDate.value = ''
isProjectInitialized.value = true
showCreateDialog.value = false
await saveToIndexedDB()
}
watch(
[projectIndustry, projectName, preparedBy, reviewedBy, preparedCompany, preparedDate],
schedulePersist
)
onMounted(async () => {
await loadFromIndexedDB()
})
</script>
<template>
<TooltipProvider>
<div class="space-y-6 h-full">
<div
v-if="!isProjectInitialized"
class=" bg-card p-10 h-full flex items-center justify-center"
>
<button
type="button"
class="cursor-pointer h-10 rounded-lg bg-primary px-6 text-sm font-medium text-primary-foreground transition hover:opacity-90"
@click="openCreateDialog"
>
新建项目
</button>
</div>
<div v-else class="rounded-xl border bg-card p-4 shadow-sm shrink-0 md:p-5">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<div class="md:col-span-2 xl:col-span-4">
<label class="block text-sm font-medium text-foreground">项目名称</label>
<input
v-model="projectName"
type="text"
required
placeholder="xxx造价咨询服务"
class="mt-2 h-10 w-full rounded-lg border bg-background px-4 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring"
@blur="handleProjectNameBlur"
/>
</div>
<div class="md:col-span-2 xl:col-span-4">
<div class="flex items-center gap-1.5">
<label class="block text-sm font-medium text-foreground">工程行业</label>
<TooltipRoot>
<TooltipTrigger as-child>
<button
type="button"
aria-label="工程行业提示"
class="inline-flex h-5 w-5 items-center justify-center text-muted-foreground/90 transition hover:text-foreground"
>
<CircleHelp class="h-5 w-5" />
</button>
</TooltipTrigger>
<TooltipContent side="top">{{ INDUSTRY_HINT_TEXT }}</TooltipContent>
</TooltipRoot>
</div>
<div class="mt-2 flex flex-wrap gap-3 rounded-lg border bg-background px-3 py-2">
<label
v-for="item in majorParentNodes"
:key="item.code"
class="inline-flex items-center gap-2 text-sm text-foreground/80"
>
<input
v-model="projectIndustry"
type="radio"
:value="item.code"
disabled
class="h-4 w-4 cursor-not-allowed accent-primary"
/>
<span>{{ item.code }} {{ item.name }}</span>
</label>
</div>
</div>
<div>
<label class="block text-sm font-medium text-foreground">编制人</label>
<input
v-model="preparedBy"
type="text"
placeholder="请输入编制人"
class="mt-2 h-10 w-full rounded-lg border bg-background px-4 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div>
<label class="block text-sm font-medium text-foreground">复核人</label>
<input
v-model="reviewedBy"
type="text"
placeholder="请输入复核人"
class="mt-2 h-10 w-full rounded-lg border bg-background px-4 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div>
<label class="block text-sm font-medium text-foreground">编制单位</label>
<input
v-model="preparedCompany"
type="text"
placeholder="请输入编制单位"
class="mt-2 h-10 w-full rounded-lg border bg-background px-4 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div>
<label class="block text-sm font-medium text-foreground">编制日期</label>
<input
v-model="preparedDate"
type="text"
placeholder="请输入编制日期"
class="mt-2 h-10 w-full rounded-lg border bg-background px-4 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
</div>
</div>
<div
v-if="showCreateDialog"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4"
@click.self="closeCreateDialog"
>
<div class="w-full max-w-lg rounded-xl border bg-card p-5 shadow-lg">
<h3 class="text-base font-semibold text-foreground">新建项目</h3>
<p class="mt-1 text-sm text-muted-foreground">请选择工程行业</p>
<div class="mt-4 max-h-72 space-y-2 overflow-auto rounded-lg border p-3">
<label
v-for="item in majorParentNodes"
:key="item.code"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/60"
>
<input v-model="pendingIndustry" type="radio" :value="item.code" class="h-4 w-4 accent-primary" />
<span class="text-sm text-foreground">{{ item.code }} {{ item.name }}</span>
</label>
</div>
<div class="mt-5 flex justify-end gap-2">
<button
type="button"
class="cursor-pointer h-9 rounded-lg border px-4 text-sm text-foreground transition hover:bg-muted"
@click="closeCreateDialog"
>
取消
</button>
<button
type="button"
class="cursor-pointer h-9 rounded-lg bg-primary px-4 text-sm text-primary-foreground transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!pendingIndustry"
@click="createProject"
>
确定
</button>
</div>
</div>
</div>
</div>
</TooltipProvider>
</template>

View File

@ -233,7 +233,7 @@ const editableNumberCol = <K extends keyof DetailRow>(
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue),
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: formatEditableNumber,
...extra
})
@ -254,7 +254,7 @@ const editableMoneyCol = <K extends keyof DetailRow>(
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue),
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, ColGroupDef } from 'ag-grid-community'
import localforage from 'localforage'
@ -66,6 +66,9 @@ interface XmInfoState {
projectName: string
detailRows: DetailRow[]
}
interface XmBaseInfoState {
projectIndustry?: string
}
const props = defineProps<{
contractId: string,
@ -73,6 +76,8 @@ const props = defineProps<{
}>()
const DB_KEY = computed(() => `tzGMF-${props.contractId}-${props.serviceId}`)
const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
const BASE_INFO_KEY = 'xm-base-info-v1'
const activeIndustryCode = ref('')
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
const pricingPaneReloadStore = usePricingPaneReloadStore()
@ -192,8 +197,10 @@ for (const group of detailDict) {
}
const buildDefaultRows = (): DetailRow[] => {
if (!activeIndustryCode.value) return []
const rows: DetailRow[] = []
for (const group of detailDict) {
if (activeIndustryCode.value && group.code !== activeIndustryCode.value) continue
for (const child of group.children) {
rows.push({
id: child.id,
@ -358,7 +365,7 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: decimalAggSum,
valueParser: params => parseNumberOrNull(params.newValue),
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: formatEditableMoney
},
@ -374,7 +381,7 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue),
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: formatConsultCategoryFactor
},
{
@ -389,7 +396,7 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue),
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: formatMajorFactor
},
{
@ -607,6 +614,10 @@ const saveToIndexedDB = async () => {
const loadFromIndexedDB = async () => {
try {
const baseInfo = await localforage.getItem<XmBaseInfoState>(BASE_INFO_KEY)
activeIndustryCode.value =
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
await ensureFactorDefaultsLoaded()
if (shouldForceDefaultLoad()) {
@ -638,6 +649,10 @@ const loadFromIndexedDB = async () => {
const importContractData = async () => {
try {
const baseInfo = await localforage.getItem<XmBaseInfoState>(BASE_INFO_KEY)
activeIndustryCode.value =
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
// 使退
await loadFactorDefaults()
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
@ -680,6 +695,10 @@ onMounted(async () => {
await loadFromIndexedDB()
})
onActivated(() => {
void loadFromIndexedDB()
})
onBeforeUnmount(() => {
if (persistTimer) clearTimeout(persistTimer)
if (gridPersistTimer) clearTimeout(gridPersistTimer)

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, ColGroupDef } from 'ag-grid-community'
import localforage from 'localforage'
@ -67,6 +67,9 @@ interface XmInfoState {
projectName: string
detailRows: DetailRow[]
}
interface XmBaseInfoState {
projectIndustry?: string
}
const props = defineProps<{
contractId: string,
@ -74,6 +77,8 @@ const props = defineProps<{
}>()
const DB_KEY = computed(() => `ydGMF-${props.contractId}-${props.serviceId}`)
const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
const BASE_INFO_KEY = 'xm-base-info-v1'
const activeIndustryCode = ref('')
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
const pricingPaneReloadStore = usePricingPaneReloadStore()
@ -193,8 +198,10 @@ for (const group of detailDict) {
}
const buildDefaultRows = (): DetailRow[] => {
if (!activeIndustryCode.value) return []
const rows: DetailRow[] = []
for (const group of detailDict) {
if (activeIndustryCode.value && group.code !== activeIndustryCode.value) continue
for (const child of group.children) {
rows.push({
id: child.id,
@ -344,24 +351,30 @@ const formatEditableFlexibleNumber = (params: any) => {
return '点击输入'
}
if (params.value == null) return ''
return String(Number(params.value))
return formatThousands(params.value, 3)
}
const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
{
headerName: '用地面积(亩)',
field: 'landArea',
headerClass: 'ag-right-aligned-header',
minWidth: 170,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClass: params =>
!params.node?.group && !params.node?.rowPinned
? 'ag-right-aligned-cell editable-cell-line'
: 'ag-right-aligned-cell',
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: decimalAggSum,
valueParser: params => parseNumberOrNull(params.newValue),
valueParser: params => {
const value = parseNumberOrNull(params.newValue)
return value == null ? null : roundTo(value, 3)
},
valueFormatter: formatEditableFlexibleNumber
},
@ -377,7 +390,7 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue),
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: formatConsultCategoryFactor
},
{
@ -392,7 +405,7 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue),
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: formatMajorFactor
},
{
@ -529,8 +542,6 @@ const autoGroupColumnDef: ColDef = {
const totalAmount = computed(() => sumByNumber(detailRows.value, row => row.amount))
const totalLandArea = computed(() => sumByNumber(detailRows.value, row => row.landArea))
const totalBenchmarkBudgetBasic = computed(() => sumByNumber(detailRows.value, row => getBenchmarkBudgetSplitByLandArea(row)?.basic))
const totalBenchmarkBudgetOptional = computed(() => sumByNumber(detailRows.value, row => getBenchmarkBudgetSplitByLandArea(row)?.optional))
const totalBenchmarkBudget = computed(() => sumByNumber(detailRows.value, row => getBenchmarkBudgetSplitByLandArea(row)?.total))
@ -546,7 +557,7 @@ const pinnedTopRowData = computed(() => [
majorCode: '',
majorName: '',
amount: totalAmount.value,
landArea: totalLandArea.value,
landArea: null,
benchmarkBudget: totalBenchmarkBudget.value,
benchmarkBudgetBasic: totalBenchmarkBudgetBasic.value,
benchmarkBudgetOptional: totalBenchmarkBudgetOptional.value,
@ -611,6 +622,10 @@ const saveToIndexedDB = async () => {
const loadFromIndexedDB = async () => {
try {
const baseInfo = await localforage.getItem<XmBaseInfoState>(BASE_INFO_KEY)
activeIndustryCode.value =
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
await ensureFactorDefaultsLoaded()
if (shouldForceDefaultLoad()) {
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
@ -641,6 +656,10 @@ const loadFromIndexedDB = async () => {
const importContractData = async () => {
try {
const baseInfo = await localforage.getItem<XmBaseInfoState>(BASE_INFO_KEY)
activeIndustryCode.value =
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
// 使退
await loadFactorDefaults()
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
@ -683,6 +702,10 @@ onMounted(async () => {
await loadFromIndexedDB()
})
onActivated(() => {
void loadFromIndexedDB()
})
onBeforeUnmount(() => {
if (persistTimer) clearTimeout(persistTimer)
if (gridPersistTimer) clearTimeout(gridPersistTimer)

View File

@ -0,0 +1,22 @@
<script setup lang="ts">
const props = withDefaults(
defineProps<{
title?: string
message: string
}>(),
{
title: '该服务不适用当前计价方法'
}
)
</script>
<template>
<div
class="flex h-full min-h-0 w-full flex-1 items-center justify-center bg-[radial-gradient(circle_at_top,_rgba(220,38,38,0.18),rgba(0,0,0,0.03)_46%,transparent_72%)] p-6"
>
<div class="w-full max-w-xl rounded-2xl border border-red-300/85 bg-white/90 px-8 py-10 text-center shadow-[0_18px_38px_-22px_rgba(153,27,27,0.6)] backdrop-blur">
<p class="text-lg font-semibold tracking-wide text-neutral-900">{{ props.title }}</p>
<p class="mt-2 text-sm leading-6 text-red-700">{{ props.message }}</p>
</div>
</div>
</template>

View File

@ -11,6 +11,7 @@ import { parseNumberOrNull } from '@/lib/number'
import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import { loadConsultCategoryFactorMap } from '@/lib/xmFactorDefaults'
import MethodUnavailableNotice from '@/components/views/pricingView/MethodUnavailableNotice.vue'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
@ -185,7 +186,7 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
}
const parseSanitizedNumberOrNull = (value: unknown) =>
parseNumberOrNull(value, { sanitize: true })
parseNumberOrNull(value, { sanitize: true, precision: 2 })
const calcBasicFee = (row: DetailRow | undefined) => {
if (!row || isNoTaskRow(row)) return null
@ -547,15 +548,11 @@ const mydiyTheme = myTheme.withParams({
:processCellForClipboard="processCellForClipboard" :processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true" :undoRedoCellEditingLimit="20" />
</div>
<div
<MethodUnavailableNotice
v-else
class="flex h-full min-h-0 w-full flex-1 items-center justify-center bg-[radial-gradient(circle_at_top,_rgba(220,38,38,0.18),rgba(0,0,0,0.03)_46%,transparent_72%)] p-6"
>
<div class="w-full max-w-xl rounded-2xl border border-red-300/85 bg-white/90 px-8 py-10 text-center shadow-[0_18px_38px_-22px_rgba(153,27,27,0.6)] backdrop-blur">
<p class="text-lg font-semibold tracking-wide text-neutral-900">该服务不适用工作量法</p>
<p class="mt-2 text-sm leading-6 text-red-700">当前服务没有关联工作量法任务无需填写此部分内容</p>
</div>
</div>
title="该服务不适用工作量法"
message="当前服务没有关联工作量法任务,无需填写此部分内容。"
/>
</div>
</div>
</template>

View File

@ -1,16 +1,14 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, onActivated, onBeforeUnmount, onMounted, ref } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, FirstDataRenderedEvent, GridApi, GridReadyEvent, GridSizeChangedEvent } from 'ag-grid-community'
import type { ColDef, GridApi } from 'ag-grid-community'
import localforage from 'localforage'
import { majorList } from '@/sql'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { decimalAggSum, sumByNumber } from '@/lib/decimal'
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 线+线
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import MethodUnavailableNotice from '@/components/views/pricingView/MethodUnavailableNotice.vue'
interface DictLeaf {
id: string
@ -36,26 +34,31 @@ interface DetailRow {
path: string[]
}
interface XmInfoState {
projectName: string
detailRows: DetailRow[]
interface XmScaleState {
detailRows?: DetailRow[]
}
interface XmBaseInfoState {
projectIndustry?: string
}
const DB_KEY = 'xm-info-v3'
const DEFAULT_PROJECT_NAME = 'xxx造价咨询服务'
const BASE_INFO_KEY = 'xm-base-info-v1'
type MajorLite = { code: string; name: string }
const projectName = ref('')
const detailRows = ref<DetailRow[]>([])
const gridApi = ref<GridApi<DetailRow> | null>(null)
const rootRef = ref<HTMLElement | null>(null)
const gridSectionRef = ref<HTMLElement | null>(null)
const agGridRef = ref<HTMLElement | null>(null)
const agGridHeight = ref(580)
let snapScrollHost: HTMLElement | null = null
let snapTimer: ReturnType<typeof setTimeout> | null = null
let snapLockTimer: ReturnType<typeof setTimeout> | null = null
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
let isSnapping = false
let hostResizeObserver: ResizeObserver | null = null
const activeIndustryCode = ref('')
const hasProjectBaseInfo = ref(false)
const detailDict = ref<DictGroup[]>([])
const idLabelMap = ref(new Map<string, string>())
const updateGridCardHeight = () => {
if (!snapScrollHost || !rootRef.value) return
@ -65,7 +68,6 @@ const updateGridCardHeight = () => {
const paddingBottom = style ? Number.parseFloat(style.paddingBottom || '0') || 0 : 0
const nextHeight = Math.max(360, Math.floor(snapScrollHost.clientHeight - paddingTop - paddingBottom))
agGridHeight.value = nextHeight + 10
}
const bindSnapScrollHost = () => {
@ -115,7 +117,7 @@ function handleSnapHostScroll() {
trySnapToGrid()
}, 90)
}
type MajorLite = { code: string; name: string }
const majorEntries = Object.entries(majorList as Record<string, MajorLite>)
.sort((a, b) => Number(a[0]) - Number(b[0]))
.filter((entry): entry is [string, MajorLite] => {
@ -123,12 +125,12 @@ const majorEntries = Object.entries(majorList as Record<string, MajorLite>)
return Boolean(item?.code && item?.name)
})
const detailDict: DictGroup[] = (() => {
const buildDetailDict = (entries: Array<[string, MajorLite]>) => {
const groupMap = new Map<string, DictGroup>()
const groupOrder: string[] = []
const codeLookup = new Map(majorEntries.map(([key, item]) => [item.code, { id: key, ...item }]))
const codeLookup = new Map(entries.map(([key, item]) => [item.code, { id: key, ...item }]))
for (const [key, item] of majorEntries) {
for (const [key, item] of entries) {
const isGroup = !item.code.includes('-')
if (isGroup) {
if (!groupMap.has(item.code)) groupOrder.push(item.code)
@ -161,19 +163,32 @@ const detailDict: DictGroup[] = (() => {
}
return groupOrder.map(code => groupMap.get(code)).filter((group): group is DictGroup => Boolean(group))
})()
const idLabelMap = new Map<string, string>()
for (const group of detailDict) {
idLabelMap.set(group.id, `${group.code} ${group.name}`)
for (const child of group.children) {
idLabelMap.set(child.id, `${child.code} ${child.name}`)
}
const rebuildDictByIndustry = (industryCode: string) => {
if (!industryCode) {
detailDict.value = []
idLabelMap.value = new Map()
return
}
const filteredEntries = majorEntries.filter(([, item]) =>
item.code === industryCode || item.code.startsWith(`${industryCode}-`)
)
const nextDict = buildDetailDict(filteredEntries)
const nextLabelMap = new Map<string, string>()
for (const group of nextDict) {
nextLabelMap.set(group.id, `${group.code} ${group.name}`)
for (const child of group.children) {
nextLabelMap.set(child.id, `${child.code} ${child.name}`)
}
}
detailDict.value = nextDict
idLabelMap.value = nextLabelMap
}
const buildDefaultRows = (): DetailRow[] => {
const rows: DetailRow[] = []
for (const group of detailDict) {
for (const group of detailDict.value) {
for (const child of group.children) {
rows.push({
id: child.id,
@ -209,26 +224,23 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
}
const columnDefs: ColDef<DetailRow>[] = [
{
headerName: '造价金额(万元)',
field: 'amount',
headerClass: 'ag-right-aligned-header',
minWidth: 100,
flex: 1, //
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'ag-right-aligned-cell editable-cell-line' : 'ag-right-aligned-cell'),
cellClassRules: {
'editable-cell-empty': params =>{
// console.log(!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === ''))
return !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')}
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: decimalAggSum,
valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? v : null
return Number.isFinite(v) ? roundTo(v, 2) : null
},
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
@ -241,27 +253,29 @@ const columnDefs: ColDef<DetailRow>[] = [
{
headerName: '用地面积(亩)',
field: 'landArea',
headerClass: 'ag-right-aligned-header',
minWidth: 100,
flex: 1, //
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClass: params =>
!params.node?.group && !params.node?.rowPinned
? 'ag-right-aligned-cell editable-cell-line'
: 'ag-right-aligned-cell',
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: decimalAggSum,
valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? v : null
return Number.isFinite(v) ? roundTo(v, 3) : null
},
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return String(Number(params.value))
return formatThousands(params.value, 3)
}
}
]
@ -269,8 +283,7 @@ const columnDefs: ColDef<DetailRow>[] = [
const autoGroupColumnDef: ColDef = {
headerName: '专业编码以及工程专业名称',
minWidth: 200,
flex:2, //
flex: 2,
cellRendererParams: {
suppressCount: true
},
@ -279,20 +292,17 @@ const autoGroupColumnDef: ColDef = {
return '总合计'
}
const nodeId = String(params.value || '')
return idLabelMap.get(nodeId) || nodeId
return idLabelMap.value.get(nodeId) || nodeId
},
tooltipValueGetter: params => {
if (params.node?.rowPinned) return '总合计'
const nodeId = String(params.value || '')
return idLabelMap.get(nodeId) || nodeId
return idLabelMap.value.get(nodeId) || nodeId
}
}
const totalAmount = computed(() => sumByNumber(detailRows.value, row => row.amount))
const totalLandArea = computed(() => sumByNumber(detailRows.value, row => row.landArea))
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',
@ -301,17 +311,15 @@ const pinnedTopRowData = computed(() => [
majorCode: '',
majorName: '',
amount: totalAmount.value,
landArea: totalLandArea.value,
landArea: null,
path: ['TOTAL']
}
])
const saveToIndexedDB = async () => {
if (!activeIndustryCode.value) return
try {
const payload: XmInfoState = {
projectName: projectName.value,
const payload: XmScaleState = {
detailRows: JSON.parse(JSON.stringify(detailRows.value))
}
await localforage.setItem(DB_KEY, payload)
@ -322,37 +330,30 @@ const saveToIndexedDB = async () => {
const loadFromIndexedDB = async () => {
try {
const data = await localforage.getItem<XmInfoState>(DB_KEY)
if (data) {
console.log('loaded data:', data)
projectName.value = data.projectName || DEFAULT_PROJECT_NAME
const baseInfo = await localforage.getItem<XmBaseInfoState>(BASE_INFO_KEY)
hasProjectBaseInfo.value = Boolean(baseInfo)
activeIndustryCode.value =
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
rebuildDictByIndustry(activeIndustryCode.value)
if (!activeIndustryCode.value) {
detailRows.value = []
return
}
const data = await localforage.getItem<XmScaleState>(DB_KEY)
if (data?.detailRows) {
detailRows.value = mergeWithDictRows(data.detailRows)
return
}
projectName.value = DEFAULT_PROJECT_NAME
detailRows.value = buildDefaultRows()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
projectName.value = DEFAULT_PROJECT_NAME
detailRows.value = buildDefaultRows()
hasProjectBaseInfo.value = false
detailRows.value = []
}
}
let persistTimer: ReturnType<typeof setTimeout> | null = null
const schedulePersist = () => {
if (persistTimer) clearTimeout(persistTimer)
persistTimer = setTimeout(() => {
void saveToIndexedDB()
}, 250)
}
// const handleBeforeUnload = () => {
// void saveToIndexedDB()
// }
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
const handleCellValueChanged = () => {
if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridPersistTimer = setTimeout(() => {
@ -360,83 +361,68 @@ const handleCellValueChanged = () => {
}, 1000)
}
watch(projectName, schedulePersist)
onMounted(async () => {
await loadFromIndexedDB()
bindSnapScrollHost()
requestAnimationFrame(() => {
updateGridCardHeight()
})
// window.addEventListener('beforeunload', handleBeforeUnload)
})
onActivated(() => {
void loadFromIndexedDB()
})
onBeforeUnmount(() => {
// window.removeEventListener('beforeunload', handleBeforeUnload)
unbindSnapScrollHost()
if (persistTimer) clearTimeout(persistTimer)
if (gridPersistTimer) clearTimeout(gridPersistTimer)
if (snapTimer) clearTimeout(snapTimer)
if (snapLockTimer) clearTimeout(snapLockTimer)
void saveToIndexedDB()
})
const processCellForClipboard = (params: any) => {
if (Array.isArray(params.value)) {
return JSON.stringify(params.value); //
return JSON.stringify(params.value)
}
return params.value
}
return params.value;
};
const processCellFromClipboard = (params: any) => {
try {
const parsed = JSON.parse(params.value);
if (Array.isArray(parsed)) return parsed;
const parsed = JSON.parse(params.value)
if (Array.isArray(parsed)) return parsed
} catch (e) {
//
// no-op
}
return params.value
}
return params.value;
};
const scrollToGridSection = () => {
const target = gridSectionRef.value || agGridRef.value
target?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
</script>
<template>
<div ref="rootRef" class="space-y-6">
<div class="rounded-xl border bg-card p-4 shadow-sm shrink-0 md:p-5">
<div class="mb-3 flex items-center justify-between gap-3">
<div>
<label class="block text-sm font-medium text-foreground">项目名称</label>
<p class="mt-1 text-xs text-muted-foreground">该名称会用于项目级计算与展示</p>
</div>
</div>
<input
v-model="projectName"
type="text"
placeholder="请输入项目名称"
class="h-10 w-full max-w-6xl rounded-lg border bg-background px-4 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring"
<div ref="rootRef" class="h-full">
<MethodUnavailableNotice
v-if="!hasProjectBaseInfo"
title="请先在“基础信息”里新建项目"
message="完成新建后将自动加载规模信息。"
/>
</div>
<div
ref="gridSectionRef"
class="rounded-lg border bg-card xmMx scroll-mt-3 flex flex-col overflow-hidden"
:style="{ height: `${agGridHeight}px` }"
v-else
class="rounded-lg border bg-card xmMx scroll-mt-3 flex flex-col overflow-hidden h-full"
>
<div class="flex items-center justify-between border-b px-4 py-3 ">
<h3
class="text-sm font-semibold text-foreground cursor-pointer select-none transition-colors hover:text-primary"
@click="scrollToGridSection"
>
项目明细
</h3>
<div class="text-xs text-muted-foreground"></div>
</div>
<div ref="agGridRef" class="ag-theme-quartz w-full flex-1 min-h-0">
<div ref="agGridRef" class="ag-theme-quartz w-full flex-1 min-h-0 h-full">
<AgGridVue
:style="{ height: '100%' }"
:rowData="detailRows"

View File

@ -7,6 +7,7 @@ import localforage from 'localforage'
import { myTheme ,gridOptions} from '@/lib/diyAgGridOptions'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { addNumbers } from '@/lib/decimal'
import { parseNumberOrNull } from '@/lib/number'
import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat'
import { getPricingMethodTotalsForServices } from '@/lib/pricingMethodTotals'
import { ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
@ -34,6 +35,7 @@ import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/
import { serviceList } from '@/sql'
import { useTabStore } from '@/pinia/tab'
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import ServiceCheckboxSelector from '@/components/views/ServiceCheckboxSelector.vue'
interface ServiceItem {
id: string
@ -61,6 +63,7 @@ interface ZxFwState {
const props = defineProps<{
contractId: string
contractName?: string
}>()
const tabStore = useTabStore()
const pricingPaneReloadStore = usePricingPaneReloadStore()
@ -291,9 +294,7 @@ const dragRectStyle = computed(() => {
})
const numericParser = (newValue: any): number | null => {
if (newValue === '' || newValue == null) return null
const num = Number(newValue)
return Number.isFinite(num) ? num : null
return parseNumberOrNull(newValue, { precision: 2 })
}
const valueOrZero = (v: number | null | undefined) => (typeof v === 'number' ? v : 0)
@ -383,7 +384,12 @@ const openEditTab = (row: DetailRow) => {
id: `zxfw-edit-${props.contractId}-${row.id}`,
title: `服务编辑-${row.code}${row.name}`,
componentName: 'ZxFwView',
props: { contractId: props.contractId, contractName: row.name ,serviceId: row.id,fwName:row.code+row.name}
props: {
contractId: props.contractId,
contractName: props.contractName || '',
serviceId: row.id,
fwName: row.code + row.name
}
})
}
@ -622,6 +628,11 @@ const applySelection = (codes: string[]) => {
detailRows.value = [...baseRows, fixedRow]
}
const handleServiceSelectionChange = async (ids: string[]) => {
applySelection(ids)
await saveToIndexedDB()
}
const preparePickerOpen = () => {
pickerTempIds.value = [...selectedIds.value]
pickerSearch.value = ''
@ -848,81 +859,13 @@ onBeforeUnmount(() => {
<template>
<TooltipProvider>
<div ref="rootRef" class="space-y-6">
<DialogRoot v-model:open="pickerOpen" @update:open="handlePickerOpenChange">
<div class="rounded-lg border bg-card p-4 shadow-sm shrink-0">
<div class="mb-2 flex items-center justify-between gap-3">
<label class="block text-sm font-medium text-foreground">选择服务</label>
</div>
<div class="flex items-center gap-2">
<input :value="selectedServiceText" readonly placeholder="请点击右侧“浏览”选择服务"
class="h-10 w-full rounded-md border bg-background px-3 text-sm text-foreground outline-none" />
<TooltipRoot>
<TooltipTrigger as-child>
<DialogTrigger as-child>
<button type="button"
class="inline-flex h-10 w-10 items-center justify-center rounded-md border text-sm hover:bg-accent cursor-pointer">
<Search class="h-4 w-4" />
</button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent side="top">浏览服务词典</TooltipContent>
</TooltipRoot>
</div>
</div>
<DialogPortal>
<DialogOverlay class="fixed inset-0 z-50 bg-black/40" />
<DialogContent
class="fixed left-1/2 top-1/2 z-[60] w-[96vw] max-w-2xl -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background shadow-xl p-0">
<DialogTitle class="sr-only">选择服务词典</DialogTitle>
<DialogDescription class="sr-only">浏览并选择服务词典</DialogDescription>
<div class="flex items-center justify-between border-b px-5 py-4">
<h4 class="text-base font-semibold">选择服务词典</h4>
<DialogClose as-child>
<button type="button"
class="inline-flex cursor-pointer h-8 items-center rounded-md border px-3 text-sm hover:bg-accent">
关闭
</button>
</DialogClose>
</div>
<div ref="pickerListScrollRef" class="max-h-[420px] overflow-auto px-5 py-4">
<div class="mb-3">
<input v-model="pickerSearch" type="text" placeholder="输入编码或名称过滤"
class="h-9 w-full rounded-md border bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" />
</div>
<div class="grid grid-cols-1 gap-2 md:grid-cols-2">
<label v-for="item in filteredServiceDict" :key="item.id" :ref="el => setPickerItemRef(item.id, el)"
:class="[
'picker-item-clickable flex select-none items-center gap-2 rounded-md border px-3 py-2 text-sm',
dragMoved ? 'cursor-default picker-item-dragging' : 'cursor-pointer',
pickerTempIds.includes(item.id) ? 'picker-item-selected' : '',
dragSelecting && pickerTempIds.includes(item.id) ? 'picker-item-selected-drag' : ''
]"
@mousedown.prevent="startDragSelect($event, item.id)" @mouseenter="handleDragHover(item.id)"
@click.prevent>
<input type="checkbox" :checked="pickerTempIds.includes(item.id)" class="pointer-events-none" />
<span class="text-muted-foreground">{{ item.code }}</span>
<span>{{ item.name }}</span>
</label>
</div>
</div>
<div class="flex items-center justify-end gap-2 border-t px-5 py-3">
<Button type="button" variant="outline" @click="clearPickerSelection">
清空
</Button>
<DialogClose as-child>
<Button type="button" @click="confirmPicker">
确认选择
</Button>
</DialogClose>
</div>
</DialogContent>
<div v-if="dragSelecting"
class="pointer-events-none fixed z-[70] rounded-sm border border-sky-500/90 bg-sky-400/10"
:style="dragRectStyle" />
</DialogPortal>
</DialogRoot>
<!-- 浏览框选择服务实现已抽离并停用改为直接复选框勾选 -->
<!-- <DialogRoot v-model:open="pickerOpen" @update:open="handlePickerOpenChange" /> -->
<ServiceCheckboxSelector
:services="serviceDict"
:model-value="selectedIds"
@update:model-value="handleServiceSelectionChange"
/>
<div
ref="gridSectionRef"

View File

@ -4,7 +4,8 @@ import type { ComponentPublicInstance } from 'vue'
import draggable from 'vuedraggable'
import { useTabStore } from '@/pinia/tab'
import { Button } from '@/components/ui/button'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
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 {
@ -53,6 +54,9 @@ interface ScaleRowLike {
interface XmInfoStorageLike extends XmInfoLike {
detailRows?: ScaleRowLike[]
}
interface XmScaleStorageLike {
detailRows?: ScaleRowLike[]
}
interface ContractCardItem {
id: string
@ -215,6 +219,8 @@ interface ExportReportPayload {
}
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 userGuideSteps: UserGuideStep[] = [
{
title: '欢迎使用',
@ -309,10 +315,21 @@ 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,
@ -489,6 +506,36 @@ const setTabItemRef = (id: string, el: Element | ComponentPublicInstance | null)
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) => {
@ -499,6 +546,58 @@ const setTabPanelRef = (id: string, el: Element | ComponentPublicInstance | null
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)
@ -619,7 +718,9 @@ const formatExportTimestamp = (date: Date): string => {
}
const getExportProjectName = (entries: DataEntry[]): string => {
const target = entries.find(item => item.key === 'xm-info-v3')
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) : '造价项目'
}
@ -853,14 +954,16 @@ const buildServiceFee = (
}
const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
const [xmInfoRaw, contractCardsRaw] = await Promise.all([
localforage.getItem<XmInfoStorageLike>('xm-info-v3'),
const [projectInfoRaw, projectScaleRaw, contractCardsRaw] = await Promise.all([
localforage.getItem<XmInfoLike>(PROJECT_INFO_DB_KEY),
localforage.getItem<XmInfoStorageLike>(LEGACY_PROJECT_DB_KEY),
localforage.getItem<ContractCardItem[]>('ht-card-v1')
])
const xmInfo = xmInfoRaw || {}
const projectScale = buildScaleRows(xmInfo.detailRows)
const projectName = isNonEmptyString(xmInfo.projectName) ? xmInfo.projectName.trim() : '造价项目'
const projectInfo = projectInfoRaw || {}
const projectScaleSource = projectScaleRaw || {}
const projectScale = buildScaleRows(projectScaleSource.detailRows)
const projectName = isNonEmptyString(projectInfo.projectName) ? projectInfo.projectName.trim() : '造价项目'
const contractCards = (Array.isArray(contractCardsRaw) ? contractCardsRaw : [])
.filter(item => item && typeof item.id === 'string')
@ -1000,7 +1103,27 @@ const importData = async (event: Event) => {
}
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))
@ -1009,10 +1132,10 @@ const importData = async (event: Event) => {
dataMenuOpen.value = false
window.location.reload()
} catch (error) {
console.error('import failed:', error)
window.alert('导入失败:文件无效、已损坏或被修改。')
console.error('import apply failed:', error)
window.alert('导入失败:写入本地数据时发生错误。')
} finally {
input.value = ''
cancelImportConfirm()
}
}
@ -1032,8 +1155,12 @@ const handleReset = async () => {
onMounted(() => {
window.addEventListener('mousedown', handleGlobalMouseDown)
window.addEventListener('keydown', handleGlobalKeyDown)
window.addEventListener('resize', scheduleUpdateTabTitleOverflow)
void nextTick(() => {
bindTabStripScroll()
ensureActiveTabVisible()
updateTabScrollButtons()
scheduleUpdateTabTitleOverflow()
scheduleRestoreTabInnerScrollTop(tabStore.activeTabId)
})
void (async () => {
@ -1046,6 +1173,15 @@ onMounted(() => {
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(
@ -1053,7 +1189,10 @@ watch(
(nextId, prevId) => {
saveTabInnerScrollTop(prevId)
void nextTick(() => {
bindTabStripScroll()
ensureActiveTabVisible()
updateTabScrollButtons()
scheduleUpdateTabTitleOverflow()
scheduleRestoreTabInnerScrollTop(nextId)
})
}
@ -1073,14 +1212,35 @@ watch(
} 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">
<ScrollArea type="auto" class="min-w-0 flex-1 whitespace-nowrap pb-2">
<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)"
>
&lt;
</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"
@ -1102,7 +1262,17 @@ watch(
tab.id !== 'XmView' ? 'cursor-move' : ''
]"
>
<span class="truncate mr-2">{{ tab.title }}</span>
<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'"
@ -1116,11 +1286,26 @@ watch(
</div>
</template>
</draggable>
<ScrollBar orientation="horizontal" />
</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)"
>
&gt;
</button>
</div>
<div ref="dataMenuRef" class="relative mb-2 shrink-0">
<Button variant="outline" size="sm" class="cursor-pointer" @click="dataMenuOpen = !dataMenuOpen">
<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>
@ -1156,14 +1341,19 @@ watch(
/>
</div>
<Button variant="outline" size="sm" class="mb-2 shrink-0 cursor-pointer" @click="openUserGuide(0)">
<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 shrink-0">
<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>
@ -1186,6 +1376,26 @@ watch(
</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">
@ -1194,7 +1404,7 @@ watch(
:key="tab.id"
:ref="el => setTabPanelRef(tab.id, el)"
v-show="tabStore.activeTabId === tab.id"
class="h-full w-full p-6 animate-in fade-in duration-300"
class="h-full w-full p-4 animate-in fade-in duration-300"
>
<component :is="componentMap[tab.componentName]" v-bind="tab.props || {}" />
</div>
@ -1281,4 +1491,15 @@ watch(
</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>

View File

@ -2,6 +2,7 @@
import { computed, onBeforeUnmount, ref, watch, type Component, onMounted } from 'vue'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Button } from '@/components/ui/button'
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
import {
DialogClose,
DialogContent,
@ -58,6 +59,8 @@ watch(
{ deep: true }
)
const switchCategory = (cat: string) => {
activeCategory.value = cat
sessionStorage.setItem(cacheKey.value, cat)
@ -70,7 +73,18 @@ const activeComponent = computed(() => {
const copyBtnText = ref('复制')
const sheetOpen = ref(false)
const titleRef = ref<HTMLElement | null>(null)
const isTitleOverflow = ref(false)
const subtitleRef = ref<HTMLElement | null>(null)
const isSubtitleOverflow = ref(false)
let copyBtnTimer: ReturnType<typeof setTimeout> | null = null
let titleOverflowRafId: number | null = null
const handleCopySubtitle = async () => {
const text = (props.copyText || '').trim()
@ -92,6 +106,10 @@ const handleCopySubtitle = async () => {
onBeforeUnmount(() => {
if (copyBtnTimer) clearTimeout(copyBtnTimer)
if (titleOverflowRafId != null) {
cancelAnimationFrame(titleOverflowRafId)
titleOverflowRafId = null
}
if (!root) return
root.style.scale = ''
root.style.translate = ''
@ -180,25 +198,29 @@ useMotionValueEvent(
</script>
<template>
<TooltipProvider>
<div class="flex h-full w-full bg-background">
<div class="w-12/100 border-r p-6 flex flex-col gap-8 relative">
<div class="w-12/100 border-r p-2 flex flex-col gap-8 relative">
<div v-if="props.title || props.subtitle" class="space-y-1">
<div v-if="props.title" class="font-bold text-base leading-6 text-primary break-words">
<TooltipRoot>
<TooltipTrigger as-child>
<div v-if="props.title" ref="titleRef" class="title-ellipsis-2 max-w-full font-bold text-base leading-6 text-primary">
{{ props.title }}
</div>
<div
v-if="props.subtitle"
class="flex flex-wrap items-center gap-2 text-xs leading-5 text-muted-foreground"
>
<span class="break-all">{{ props.subtitle }}</span>
<Button
v-if="props.copyText"
type="button"
variant="outline"
size="sm"
class="h-6 rounded-md px-2 text-[11px]"
@click.stop="handleCopySubtitle"
>
</TooltipTrigger>
<TooltipContent side="right" :avoid-collisions="false">{{ props.title }}</TooltipContent>
</TooltipRoot>
<div v-if="props.subtitle" class="flex min-w-0 items-center gap-2 text-xs leading-5 text-muted-foreground">
<div class="min-w-0 flex-1">
<TooltipRoot>
<TooltipTrigger as-child>
<span ref="subtitleRef" class="block max-w-full truncate">{{ props.subtitle }}</span>
</TooltipTrigger>
<TooltipContent side="right" :avoid-collisions="false">{{ props.subtitle }}</TooltipContent>
</TooltipRoot>
</div>
<Button v-if="props.copyText" type="button" variant="outline" size="sm"
class="h-6 rounded-md px-2 text-[11px]" @click.stop="handleCopySubtitle">
{{ copyBtnText }}
</Button>
</div>
@ -207,30 +229,22 @@ useMotionValueEvent(
<div class="flex flex-col gap-10 relative">
<div class="absolute left-[11px] top-2 bottom-2 w-[2px] bg-muted"></div>
<div
v-for="item in props.categories"
:key="item.key"
class="relative flex items-center gap-4 cursor-pointer group"
@click="switchCategory(item.key)"
>
<div
:class="[
<div v-for="item in props.categories" :key="item.key"
class="relative flex items-center gap-4 cursor-pointer group" @click="switchCategory(item.key)">
<div :class="[
'z-10 w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all',
activeCategory === item.key
? 'bg-primary border-primary scale-110'
: 'bg-background border-muted-foreground'
]"
>
]">
<div v-if="activeCategory === item.key" class="w-2 h-2 bg-background rounded-full"></div>
</div>
<span
:class="[
<span :class="[
'text-sm transition-colors',
activeCategory === item.key
? 'font-bold text-primary'
: 'text-muted-foreground group-hover:text-foreground'
]"
>
]">
{{ item.label }}
</span>
</div>
@ -238,27 +252,17 @@ useMotionValueEvent(
<DialogRoot v-model:open="sheetOpen">
<DialogTrigger as-child>
<button
type="button"
class="absolute left-4 right-4 bottom-4 flex cursor-pointer flex-col items-center gap-1.5 rounded-lg border bg-muted/35 px-3 py-2 text-center text-[12px] leading-5 text-foreground/85 shadow-sm transition-colors hover:bg-muted/55 hover:text-foreground"
>
<button type="button"
class="cursor-pointer absolute left-4 right-4 bottom-4 flex flex-col items-center gap-1.5 rounded-lg border bg-muted/35 px-3 py-2 text-center text-[12px] leading-5 text-foreground/85 shadow-sm transition-colors hover:bg-muted/55 hover:text-foreground">
<img src="/favicon.ico" alt="众为咨询" class="h-5 w-5 shrink-0 rounded-sm" />
<span>本网站由众为工程咨询有限公司提供免费技术支持</span>
</button>
</DialogTrigger>
<DialogPortal>
<AnimatePresence
multiple
as="div"
>
<AnimatePresence multiple as="div">
<DialogOverlay as-child>
<Motion
class="fixed inset-0 z-10 bg-black/45 backdrop-blur-[2px]"
:initial="{ opacity: 0 }"
:animate="{ opacity: 1 }"
:exit="{ opacity: 0 }"
:transition="staticTransition"
/>
<Motion class="fixed inset-0 z-10 bg-black/45 backdrop-blur-[2px]" :initial="{ opacity: 0 }"
:animate="{ opacity: 1 }" :exit="{ opacity: 0 }" :transition="staticTransition" />
</DialogOverlay>
<DialogContent as-child>
@ -267,32 +271,24 @@ useMotionValueEvent(
:style="{
y,
top: `${sheetTop}px`,
}"
drag="y"
:drag-constraints="{ top: 0 }"
@drag-end="(e, { offset, velocity }) => {
}" drag="y" :drag-constraints="{ top: 0 }" @drag-end="(e, { offset, velocity }) => {
if (offset.y > h * 0.35 || velocity.y > 10) {
sheetOpen = false;
}
else {
animate(y, 0, { ...inertiaTransition, min: 0, max: 0 });
}
}"
>
<div class="mx-auto mt-2 h-1.5 w-12 cursor-grab rounded-full bg-muted-foreground/35 active:cursor-grabbing" />
}">
<div
class="mx-auto mt-2 h-1.5 w-12 cursor-grab rounded-full bg-muted-foreground/35 active:cursor-grabbing" />
<div class="mx-auto flex h-full w-full max-w-2xl flex-col px-4 pb-5 pt-3">
<div class="mb-3">
<div class="flex justify-end">
<DialogClose class="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-md border border-muted-foreground/30 text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground">
<DialogClose
class="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-md border border-muted-foreground/30 text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground">
<svg viewBox="0 0 24 24" class="h-4 w-4" aria-hidden="true">
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M18 6L6 18M6 6l12 12"
/>
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M18 6L6 18M6 6l12 12" />
</svg>
</DialogClose>
</div>
@ -306,31 +302,16 @@ useMotionValueEvent(
<DialogDescription class="mb-4 text-base text-muted-foreground">
<div class="flex items-center gap-2">
<a
:href="OFFICIAL_SITE_URL"
target="_blank"
rel="noopener noreferrer"
class="inline-flex cursor-pointer items-center font-medium text-foreground transition-colors hover:text-primary hover:underline"
>
<a :href="OFFICIAL_SITE_URL" target="_blank" rel="noopener noreferrer"
class="inline-flex cursor-pointer items-center font-medium text-foreground transition-colors hover:text-primary hover:underline">
众为工程咨询有限公司
</a>
<a
:href="OFFICIAL_SITE_URL"
target="_blank"
rel="noopener noreferrer"
<a :href="OFFICIAL_SITE_URL" target="_blank" rel="noopener noreferrer"
class="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border border-muted-foreground/30 text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground"
aria-label="跳转到官网首页"
title="官网首页"
>
aria-label="跳转到官网首页" title="官网首页">
<svg viewBox="0 0 24 24" class="h-4 w-4" aria-hidden="true">
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h10v10M7 17L17 7"
/>
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M7 7h10v10M7 17L17 7" />
</svg>
</a>
</div>
@ -338,7 +319,8 @@ useMotionValueEvent(
<div class="space-y-4 overflow-y-auto pr-1 text-[15px] leading-7">
<p>
众为工程咨询有限公司 2009 年成立专注工程造价与工程成本管控全过程咨询是广东省政府审计入库优选单位公司服务覆盖多领域全类型客户累计服务投资额超万亿元深度参与港珠澳大桥澳门大学横琴校区等国家级重点工程参编三十余项国家及省市行业标准
众为工程咨询有限公司 2009
年成立专注工程造价与工程成本管控全过程咨询是广东省政府审计入库优选单位公司服务覆盖多领域全类型客户累计服务投资额超万亿元深度参与港珠澳大桥澳门大学横琴校区等国家级重点工程参编三十余项国家及省市行业标准
</p>
<p>
公司立足大湾区布局全球设有澳门公司斯里兰卡分公司具备跨境与海外项目服务能力以十五年专业沉淀万亿级项目经验为客户提供精准可靠的工程咨询服务
@ -363,6 +345,7 @@ useMotionValueEvent(
</ScrollArea>
</div>
</div>
</TooltipProvider>
</template>
<style scoped>
/* 核心修改:添加 :deep() 穿透 scoped 作用域 */
@ -371,4 +354,12 @@ useMotionValueEvent(
min-height: 0;
box-sizing: border-box;
}
.title-ellipsis-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
}
</style>

View File

@ -6,7 +6,7 @@ export const toFiniteNumberOrNull = (value: unknown): number | null =>
export const parseNumberOrNull = (
value: unknown,
options?: { sanitize?: boolean }
options?: { sanitize?: boolean; precision?: number }
): number | null => {
if (value === '' || value == null) return null
@ -16,5 +16,15 @@ export const parseNumberOrNull = (
: value
const numericValue = Number(normalized)
return Number.isFinite(numericValue) ? numericValue : null
if (!Number.isFinite(numericValue)) return null
const precision = options?.precision
if (typeof precision !== 'number' || !Number.isInteger(precision) || precision < 0) {
return numericValue
}
const factor = 10 ** precision
if (!Number.isFinite(factor) || factor <= 0) return numericValue
if (numericValue >= 0) return Math.round((numericValue + Number.EPSILON) * factor) / factor
return -Math.round((-numericValue + Number.EPSILON) * factor) / factor
}