重构
This commit is contained in:
parent
33913c29d2
commit
42fd6e48c4
@ -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 });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@ -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)
|
||||
window.alert('导入失败:文件无效、已损坏或不是合同段导出文件。')
|
||||
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>
|
||||
|
||||
66
src/components/views/ServiceCheckboxSelector.vue
Normal file
66
src/components/views/ServiceCheckboxSelector.vue
Normal 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>
|
||||
@ -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 }
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}`"
|
||||
|
||||
@ -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)
|
||||
|
||||
302
src/components/views/info.vue
Normal file
302
src/components/views/info.vue
Normal 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>
|
||||
@ -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 '点击输入'
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
22
src/components/views/pricingView/MethodUnavailableNotice.vue
Normal file
22
src/components/views/pricingView/MethodUnavailableNotice.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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 { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
||||
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
|
||||
@ -64,8 +67,7 @@ const updateGridCardHeight = () => {
|
||||
const paddingTop = style ? Number.parseFloat(style.paddingTop || '0') || 0 : 0
|
||||
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
|
||||
|
||||
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,18 +311,16 @@ 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,
|
||||
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
||||
const payload: XmScaleState = {
|
||||
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
||||
}
|
||||
await localforage.setItem(DB_KEY, payload)
|
||||
} catch (error) {
|
||||
@ -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,105 +361,90 @@ 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) => {
|
||||
|
||||
const processCellForClipboard = (params: any) => {
|
||||
if (Array.isArray(params.value)) {
|
||||
return JSON.stringify(params.value); // 数组转字符串复制
|
||||
return JSON.stringify(params.value)
|
||||
}
|
||||
return params.value;
|
||||
};
|
||||
|
||||
const processCellFromClipboard = (params:any) => {
|
||||
try {
|
||||
const parsed = JSON.parse(params.value);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
} catch (e) {
|
||||
// 解析失败时返回原始值,无需额外处理
|
||||
}
|
||||
return params.value;
|
||||
};
|
||||
|
||||
|
||||
const scrollToGridSection = () => {
|
||||
const target = gridSectionRef.value || agGridRef.value
|
||||
target?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
return params.value
|
||||
}
|
||||
|
||||
const processCellFromClipboard = (params: any) => {
|
||||
try {
|
||||
const parsed = JSON.parse(params.value)
|
||||
if (Array.isArray(parsed)) return parsed
|
||||
} catch (e) {
|
||||
// no-op
|
||||
}
|
||||
return params.value
|
||||
}
|
||||
|
||||
|
||||
</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>
|
||||
|
||||
<div ref="rootRef" class="h-full">
|
||||
<MethodUnavailableNotice
|
||||
v-if="!hasProjectBaseInfo"
|
||||
title="请先在“基础信息”里新建项目"
|
||||
message="完成新建后将自动加载规模信息。"
|
||||
/>
|
||||
<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">
|
||||
<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">
|
||||
<AgGridVue
|
||||
:style="{ height: '100%' }"
|
||||
:rowData="detailRows"
|
||||
:pinnedTopRowData="pinnedTopRowData"
|
||||
:columnDefs="columnDefs"
|
||||
:autoGroupColumnDef="autoGroupColumnDef"
|
||||
:gridOptions="gridOptions"
|
||||
:theme="myTheme"
|
||||
@cell-value-changed="handleCellValueChanged"
|
||||
:suppressColumnVirtualisation="true"
|
||||
:suppressRowVirtualisation="true"
|
||||
:cellSelection="{ handle: { mode: 'range' } }"
|
||||
:enableClipboard="true"
|
||||
:localeText="AG_GRID_LOCALE_CN"
|
||||
:tooltipShowDelay="500"
|
||||
:headerHeight="50"
|
||||
:suppressHorizontalScroll="true"
|
||||
:processCellForClipboard="processCellForClipboard"
|
||||
:processCellFromClipboard="processCellFromClipboard"
|
||||
:undoRedoCellEditing="true"
|
||||
:undoRedoCellEditingLimit="20"
|
||||
/>
|
||||
<div ref="agGridRef" class="ag-theme-quartz w-full flex-1 min-h-0 h-full">
|
||||
<AgGridVue
|
||||
:style="{ height: '100%' }"
|
||||
:rowData="detailRows"
|
||||
:pinnedTopRowData="pinnedTopRowData"
|
||||
:columnDefs="columnDefs"
|
||||
:autoGroupColumnDef="autoGroupColumnDef"
|
||||
:gridOptions="gridOptions"
|
||||
:theme="myTheme"
|
||||
@cell-value-changed="handleCellValueChanged"
|
||||
:suppressColumnVirtualisation="true"
|
||||
:suppressRowVirtualisation="true"
|
||||
:cellSelection="{ handle: { mode: 'range' } }"
|
||||
:enableClipboard="true"
|
||||
:localeText="AG_GRID_LOCALE_CN"
|
||||
:tooltipShowDelay="500"
|
||||
:headerHeight="50"
|
||||
:suppressHorizontalScroll="true"
|
||||
:processCellForClipboard="processCellForClipboard"
|
||||
:processCellFromClipboard="processCellFromClipboard"
|
||||
:undoRedoCellEditing="true"
|
||||
:undoRedoCellEditingLimit="20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,54 +1212,100 @@ 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">
|
||||
<draggable
|
||||
v-model="tabsModel"
|
||||
item-key="id"
|
||||
tag="div"
|
||||
class="flex w-max gap-1"
|
||||
:animation="180"
|
||||
:move="canMoveTab"
|
||||
<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)"
|
||||
>
|
||||
<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' : ''
|
||||
]"
|
||||
>
|
||||
<span class="truncate mr-2">{{ tab.title }}</span>
|
||||
|
||||
<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)"
|
||||
<
|
||||
</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' : ''
|
||||
]"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
<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="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>
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch, type Component ,onMounted} from 'vue'
|
||||
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,
|
||||
@ -9,7 +10,7 @@ import {
|
||||
DialogPortal,
|
||||
DialogRoot,
|
||||
DialogTitle,
|
||||
DialogTrigger,DialogDescription
|
||||
DialogTrigger, DialogDescription
|
||||
} from 'reka-ui'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { animate, AnimatePresence, Motion, useMotionValue, useMotionValueEvent, useTransform } from 'motion-v'
|
||||
@ -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">
|
||||
{{ 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"
|
||||
>
|
||||
<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>
|
||||
</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="[
|
||||
'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-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="[
|
||||
'text-sm transition-colors',
|
||||
activeCategory === item.key
|
||||
? 'font-bold text-primary'
|
||||
: 'text-muted-foreground group-hover:text-foreground'
|
||||
]"
|
||||
>
|
||||
<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>
|
||||
公司立足大湾区,布局全球,设有澳门公司、斯里兰卡分公司,具备跨境与海外项目服务能力,以十五年专业沉淀、万亿级项目经验,为客户提供精准、可靠的工程咨询服务。
|
||||
@ -349,7 +331,7 @@ useMotionValueEvent(
|
||||
</DialogContent>
|
||||
</AnimatePresence>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</DialogRoot>
|
||||
|
||||
</div>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user