This commit is contained in:
wintsa 2026-03-02 17:52:32 +08:00
parent 3950057707
commit a10359f7e0
19 changed files with 349 additions and 299 deletions

View File

@ -3,9 +3,5 @@ import Tab from '@/layout/tab.vue'
</script>
<template>
<tab></tab>
<Tab />
</template>
<style scoped>
</style>

View File

@ -4,6 +4,7 @@ import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, FirstDataRenderedEvent, GridApi, GridReadyEvent, GridSizeChangedEvent } from 'ag-grid-community'
import localforage from 'localforage'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { parseNumberOrNull } from '@/lib/number'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
interface DictItem {
@ -41,12 +42,6 @@ const props = defineProps<{
const detailRows = ref<FactorRow[]>([])
const gridApi = ref<GridApi<FactorRow> | null>(null)
const parseNumberOrNull = (value: unknown) => {
if (value === '' || value == null) return null
const v = Number(value)
return Number.isFinite(v) ? v : null
}
const formatReadonlyFactor = (value: unknown) => {
if (value == null || value === '') return ''
return Number(value).toFixed(2)
@ -277,7 +272,7 @@ onBeforeUnmount(() => {
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">{{ title }}</h3>
<div class="text-xs text-muted-foreground">导入导出</div>
<div class="text-xs text-muted-foreground"></div>
</div>
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">

View File

@ -327,7 +327,7 @@ const processCellFromClipboard = (params:any) => {
<div class="rounded-lg border bg-card xmMx flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">合同规模明细</h3>
<div class="text-xs text-muted-foreground">导入导出</div>
<div class="text-xs text-muted-foreground"></div>
</div>
<div class="ag-theme-quartz h-full min-h-0 min-w-0 w-full flex-1 overflow-hidden">

View File

@ -7,6 +7,7 @@ import { expertList } from '@/sql'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat'
import { parseNumberOrNull } from '@/lib/number'
import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
@ -173,12 +174,6 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
})
}
const parseNumberOrNull = (value: unknown) => {
if (value === '' || value == null) return null
const v = Number(value)
return Number.isFinite(v) ? v : null
}
const parseNonNegativeIntegerOrNull = (value: unknown) => {
if (value === '' || value == null) return null
if (typeof value === 'number') {
@ -482,7 +477,7 @@ const handleGridReady = (params: any) => {
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">工时法明细</h3>
<div class="text-xs text-muted-foreground">导入导出</div>
<div class="text-xs text-muted-foreground"></div>
</div>
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">

View File

@ -3,13 +3,15 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef } from 'ag-grid-community'
import localforage from 'localforage'
import { getBasicFeeFromScale, majorList } from '@/sql'
import { majorList } from '@/sql'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { addNumbers, decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat'
import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults'
import { parseNumberOrNull } from '@/lib/number'
import { getBenchmarkBudgetByScale, getScaleBudgetFee } from '@/lib/pricingScaleFee'
import { Button } from '@/components/ui/button'
import {
AlertDialogAction,
@ -250,12 +252,6 @@ const mergeWithDictRows = (
})
}
const parseNumberOrNull = (value: unknown) => {
if (value === '' || value == null) return null
const v = Number(value)
return Number.isFinite(v) ? v : null
}
const formatEditableNumber = (params: any) => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
@ -285,15 +281,15 @@ const formatReadonlyMoney = (params: any) => {
return formatThousands(roundTo(params.value, 2))
}
const getBenchmarkBudgetByAmount = (row?: Pick<DetailRow, 'amount'>) => {
const result = getBasicFeeFromScale(row?.amount, 'cost')
return result ? roundTo(addNumbers(result.basic, result.optional), 2) : null
}
const getBenchmarkBudgetByAmount = (row?: Pick<DetailRow, 'amount'>) =>
getBenchmarkBudgetByScale(row?.amount, 'cost')
const getBudgetFee = (row?: Pick<DetailRow, 'amount' | 'majorFactor' | 'consultCategoryFactor'>) => {
const benchmarkBudget = getBenchmarkBudgetByAmount(row)
if (benchmarkBudget == null || row?.majorFactor == null || row?.consultCategoryFactor == null) return null
return roundTo(toDecimal(benchmarkBudget).mul(row.majorFactor).mul(row.consultCategoryFactor), 2)
return getScaleBudgetFee({
benchmarkBudget: getBenchmarkBudgetByAmount(row),
majorFactor: row?.majorFactor,
consultCategoryFactor: row?.consultCategoryFactor
})
}
const columnDefs: ColDef<DetailRow>[] = [

View File

@ -3,13 +3,15 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef } from 'ag-grid-community'
import localforage from 'localforage'
import { getBasicFeeFromScale, majorList } from '@/sql'
import { majorList } from '@/sql'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { addNumbers, decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat'
import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults'
import { parseNumberOrNull } from '@/lib/number'
import { getBenchmarkBudgetByScale, getScaleBudgetFee } from '@/lib/pricingScaleFee'
import { Button } from '@/components/ui/button'
import {
AlertDialogAction,
@ -253,12 +255,6 @@ const mergeWithDictRows = (
})
}
const parseNumberOrNull = (value: unknown) => {
if (value === '' || value == null) return null
const v = Number(value)
return Number.isFinite(v) ? v : null
}
const formatEditableNumber = (params: any) => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
@ -280,15 +276,15 @@ const formatReadonlyMoney = (params: any) => {
return formatThousands(roundTo(params.value, 2))
}
const getBenchmarkBudgetByLandArea = (row?: Pick<DetailRow, 'landArea'>) => {
const result = getBasicFeeFromScale(row?.landArea, 'area')
return result ? roundTo(addNumbers(result.basic, result.optional), 2) : null
}
const getBenchmarkBudgetByLandArea = (row?: Pick<DetailRow, 'landArea'>) =>
getBenchmarkBudgetByScale(row?.landArea, 'area')
const getBudgetFee = (row?: Pick<DetailRow, 'landArea' | 'majorFactor' | 'consultCategoryFactor'>) => {
const benchmarkBudget = getBenchmarkBudgetByLandArea(row)
if (benchmarkBudget == null || row?.majorFactor == null || row?.consultCategoryFactor == null) return null
return roundTo(toDecimal(benchmarkBudget).mul(row.majorFactor).mul(row.consultCategoryFactor), 2)
return getScaleBudgetFee({
benchmarkBudget: getBenchmarkBudgetByLandArea(row),
majorFactor: row?.majorFactor,
consultCategoryFactor: row?.consultCategoryFactor
})
}
const formatEditableFlexibleNumber = (params: any) => {

View File

@ -7,6 +7,7 @@ import { taskList } from '@/sql'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat'
import { parseNumberOrNull } from '@/lib/number'
import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import { loadConsultCategoryFactorMap } from '@/lib/xmFactorDefaults'
@ -180,12 +181,8 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
})
}
const parseNumberOrNull = (value: unknown) => {
if (value === '' || value == null) return null
const normalized = typeof value === 'string' ? value.replace(/[^0-9.\-]/g, '') : value
const v = Number(normalized)
return Number.isFinite(v) ? v : null
}
const parseSanitizedNumberOrNull = (value: unknown) =>
parseNumberOrNull(value, { sanitize: true })
const calcServiceFee = (row: DetailRow | undefined) => {
if (!row || isNoTaskRow(row)) return null
@ -281,7 +278,7 @@ const columnDefs: ColDef<DetailRow>[] = [
!isNoTaskRow(params.data) &&
(params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue),
valueParser: params => parseSanitizedNumberOrNull(params.newValue),
valueFormatter: params => {
if (isNoTaskRow(params.data)) return '无'
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
@ -307,7 +304,7 @@ const columnDefs: ColDef<DetailRow>[] = [
(params.value == null || params.value === '')
},
aggFunc: decimalAggSum,
valueParser: params => parseNumberOrNull(params.newValue),
valueParser: params => parseSanitizedNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
},
{
@ -325,7 +322,7 @@ const columnDefs: ColDef<DetailRow>[] = [
!isNoTaskRow(params.data) &&
(params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue),
valueParser: params => parseSanitizedNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
},
{
@ -511,7 +508,7 @@ const mydiyTheme = myTheme.withParams({
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">工作量明细</h3>
<div class="text-xs text-muted-foreground">导入导出</div>
<div class="text-xs text-muted-foreground"></div>
</div>
<div v-if="isWorkloadMethodApplicable" class="ag-theme-quartz h-full min-h-0 w-full flex-1">

View File

@ -433,7 +433,7 @@ const scrollToGridSection = () => {
>
项目明细
</h3>
<div class="text-xs text-muted-foreground">导入导出</div>
<div class="text-xs text-muted-foreground"></div>
</div>
<div ref="agGridRef" class="ag-theme-quartz w-full flex-1 min-h-0">

View File

@ -1,4 +1,5 @@
import Decimal from 'decimal.js'
import { isFiniteNumber } from '@/lib/number'
type MaybeNumber = number | null | undefined
type DecimalInput = Decimal.Value
@ -8,30 +9,26 @@ export const toDecimal = (value: DecimalInput) => new Decimal(value)
export const roundTo = (value: DecimalInput, decimalPlaces = 2) =>
new Decimal(value).toDecimalPlaces(decimalPlaces, Decimal.ROUND_HALF_UP).toNumber()
export const addNumbers = (...values: MaybeNumber[]) => {
const sumFiniteValues = (values: Iterable<unknown>) => {
let total = new Decimal(0)
for (const value of values) {
if (typeof value !== 'number' || !Number.isFinite(value)) continue
if (!isFiniteNumber(value)) continue
total = total.plus(value)
}
return total.toNumber()
}
export const addNumbers = (...values: MaybeNumber[]) => sumFiniteValues(values)
export const sumByNumber = <T>(list: T[], pick: (item: T) => MaybeNumber) => {
let total = new Decimal(0)
for (const item of list) {
const value = pick(item)
if (typeof value !== 'number' || !Number.isFinite(value)) continue
if (!isFiniteNumber(value)) continue
total = total.plus(value)
}
return total.toNumber()
}
export const decimalAggSum = (params: { values?: unknown[] }) => {
let total = new Decimal(0)
for (const value of params.values || []) {
if (typeof value !== 'number' || !Number.isFinite(value)) continue
total = total.plus(value)
}
return total.toNumber()
}
export const decimalAggSum = (params: { values?: unknown[] }) =>
sumFiniteValues(params.values || [])

View File

@ -1,30 +1,25 @@
import { GridOptions, themeQuartz } from "ag-grid-community"
import type { GridOptions } from 'ag-grid-community'
import { themeQuartz } from 'ag-grid-community'
const borderConfig = {
style: "solid", // 虚线改实线更简洁,也可保留 dotted 但建议用 solid
width: 0.3, // 更细的边框,减少视觉干扰
color: "#d3d3d3" // 浅灰色边框,清新不刺眼
};
style: 'solid',
width: 0.3,
color: '#d3d3d3'
}
// 简洁清新风格的主题配置
export const myTheme = themeQuartz.withParams({
// 核心:移除外边框,减少视觉包裹感
wrapperBorder: false,
// 表头样式(柔和浅蓝,无加粗,更轻盈)
headerBackgroundColor: "#f0f2f3", // 极浅的背景色,替代深一点的 #e7f3fc
headerTextColor: "#374151", // 深灰色文字,比纯黑更柔和
headerFontSize: 15, // 字体稍大一点,更易读
headerFontWeight: "normal", // 取消加粗,降低视觉重量
// 行/列/表头边框(统一浅灰细边框)
headerBackgroundColor: '#f0f2f3',
headerTextColor: '#374151',
headerFontSize: 15,
headerFontWeight: 'normal',
rowBorder: borderConfig,
columnBorder: borderConfig,
headerRowBorder: borderConfig,
dataBackgroundColor: '#fefefe'
})
// 可选:偶数行背景色(轻微区分,更清新)
dataBackgroundColor: "#fefefe"
});
export const gridOptions: GridOptions<any> = {
export const gridOptions: GridOptions = {
treeData: true,
animateRows: true,
tooltipShowMode: 'whenTruncated',
@ -32,13 +27,9 @@ export const gridOptions: GridOptions<any> = {
singleClickEdit: true,
suppressClickEdit: false,
suppressContextMenu: false,
// autoSizeStrategy: {
// type: 'fitGridWidth',
// defaultMinWidth: 100,
// },
groupDefaultExpanded: -1,
suppressFieldDotNotation: true,
// Keep group expand/collapse state when rowData updates after edits/saves.
// rowData 更新后通过稳定 ID 维持展开状态和编辑上下文。
getRowId: params => String(params.data?.id ?? params.data?.path?.join('/') ?? ''),
getDataPath: data => data.path,
getContextMenuItems: () => ['copy', 'paste', 'separator', 'export'],

20
src/lib/number.ts Normal file
View File

@ -0,0 +1,20 @@
export const isFiniteNumber = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value)
export const toFiniteNumberOrNull = (value: unknown): number | null =>
isFiniteNumber(value) ? value : null
export const parseNumberOrNull = (
value: unknown,
options?: { sanitize?: boolean }
): number | null => {
if (value === '' || value == null) return null
const normalized =
options?.sanitize && typeof value === 'string'
? value.replace(/[^0-9.\-]/g, '')
: value
const numericValue = Number(normalized)
return Number.isFinite(numericValue) ? numericValue : null
}

View File

@ -1,19 +1,42 @@
import { parseNumberOrNull } from '@/lib/number'
const fixedFormatterCache = new Map<number, Intl.NumberFormat>()
const flexibleFormatterCache = new Map<number, Intl.NumberFormat>()
const getFixedFormatter = (fractionDigits: number) => {
if (!fixedFormatterCache.has(fractionDigits)) {
fixedFormatterCache.set(
fractionDigits,
new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits
})
)
}
return fixedFormatterCache.get(fractionDigits)!
}
const getFlexibleFormatter = (maxFractionDigits: number) => {
if (!flexibleFormatterCache.has(maxFractionDigits)) {
flexibleFormatterCache.set(
maxFractionDigits,
new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: 0,
maximumFractionDigits: maxFractionDigits
})
)
}
return flexibleFormatterCache.get(maxFractionDigits)!
}
export const formatThousands = (value: unknown, fractionDigits = 2) => {
if (value === '' || value == null) return ''
const numericValue = Number(value)
if (!Number.isFinite(numericValue)) return ''
return numericValue.toLocaleString('zh-CN', {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits
})
const numericValue = parseNumberOrNull(value)
if (numericValue == null) return ''
return getFixedFormatter(fractionDigits).format(numericValue)
}
export const formatThousandsFlexible = (value: unknown, maxFractionDigits = 20) => {
if (value === '' || value == null) return ''
const numericValue = Number(value)
if (!Number.isFinite(numericValue)) return ''
return numericValue.toLocaleString('zh-CN', {
minimumFractionDigits: 0,
maximumFractionDigits: maxFractionDigits
})
const numericValue = parseNumberOrNull(value)
if (numericValue == null) return ''
return getFlexibleFormatter(maxFractionDigits).format(numericValue)
}

View File

@ -1,6 +1,8 @@
import localforage from 'localforage'
import { expertList, getBasicFeeFromScale, majorList, serviceList, taskList } from '@/sql'
import { addNumbers, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
import { expertList, majorList, serviceList, taskList } from '@/sql'
import { roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
import { toFiniteNumberOrNull } from '@/lib/number'
import { getBenchmarkBudgetByScale, getScaleBudgetFee } from '@/lib/pricingScaleFee'
interface StoredDetailRowsState<T = any> {
detailRows?: T[]
@ -58,12 +60,17 @@ export interface PricingMethodTotals {
hourly: number | null
}
const toFiniteNumberOrNull = (value: unknown): number | null =>
typeof value === 'number' && Number.isFinite(value) ? value : null
const hasOwn = (obj: unknown, key: string) =>
Object.prototype.hasOwnProperty.call(obj || {}, key)
const toRowMap = <TRow extends { id: string }>(rows?: TRow[]) => {
const map = new Map<string, TRow>()
for (const row of rows || []) {
map.set(String(row.id), row)
}
return map
}
const getDefaultConsultCategoryFactor = (serviceId: string | number) => {
const service = (serviceList as Record<string, ServiceLite | undefined>)[String(serviceId)]
return toFiniteNumberOrNull(service?.defCoe)
@ -95,10 +102,7 @@ const mergeScaleRows = (
serviceId: string | number,
rowsFromDb: Array<Partial<ScaleRow> & Pick<ScaleRow, 'id'>> | undefined
): ScaleRow[] => {
const dbValueMap = new Map<string, Partial<ScaleRow> & Pick<ScaleRow, 'id'>>()
for (const row of rowsFromDb || []) {
dbValueMap.set(String(row.id), row)
}
const dbValueMap = toRowMap(rowsFromDb)
const defaultConsultCategoryFactor = getDefaultConsultCategoryFactor(serviceId)
return buildDefaultScaleRows(serviceId).map(row => {
@ -122,26 +126,26 @@ const mergeScaleRows = (
})
}
const getBenchmarkBudgetByAmount = (amount: MaybeNumber) => {
const result = getBasicFeeFromScale(amount, 'cost')
return result ? roundTo(addNumbers(result.basic, result.optional), 2) : null
}
const getBenchmarkBudgetByAmount = (amount: MaybeNumber) =>
getBenchmarkBudgetByScale(amount, 'cost')
const getBenchmarkBudgetByLandArea = (landArea: MaybeNumber) => {
const result = getBasicFeeFromScale(landArea, 'area')
return result ? roundTo(addNumbers(result.basic, result.optional), 2) : null
}
const getBenchmarkBudgetByLandArea = (landArea: MaybeNumber) =>
getBenchmarkBudgetByScale(landArea, 'area')
const getInvestmentBudgetFee = (row: ScaleRow) => {
const benchmarkBudget = getBenchmarkBudgetByAmount(row.amount)
if (benchmarkBudget == null || row.majorFactor == null || row.consultCategoryFactor == null) return null
return roundTo(toDecimal(benchmarkBudget).mul(row.majorFactor).mul(row.consultCategoryFactor), 2)
return getScaleBudgetFee({
benchmarkBudget: getBenchmarkBudgetByAmount(row.amount),
majorFactor: row.majorFactor,
consultCategoryFactor: row.consultCategoryFactor
})
}
const getLandBudgetFee = (row: ScaleRow) => {
const benchmarkBudget = getBenchmarkBudgetByLandArea(row.landArea)
if (benchmarkBudget == null || row.majorFactor == null || row.consultCategoryFactor == null) return null
return roundTo(toDecimal(benchmarkBudget).mul(row.majorFactor).mul(row.consultCategoryFactor), 2)
return getScaleBudgetFee({
benchmarkBudget: getBenchmarkBudgetByLandArea(row.landArea),
majorFactor: row.majorFactor,
consultCategoryFactor: row.consultCategoryFactor
})
}
const getTaskEntriesByServiceId = (serviceId: string | number) =>
@ -164,10 +168,7 @@ const mergeWorkloadRows = (
serviceId: string | number,
rowsFromDb: Array<Partial<WorkloadRow> & Pick<WorkloadRow, 'id'>> | undefined
): WorkloadRow[] => {
const dbValueMap = new Map<string, Partial<WorkloadRow> & Pick<WorkloadRow, 'id'>>()
for (const row of rowsFromDb || []) {
dbValueMap.set(String(row.id), row)
}
const dbValueMap = toRowMap(rowsFromDb)
return buildDefaultWorkloadRows(serviceId).map(row => {
const fromDb = dbValueMap.get(row.id)
@ -216,10 +217,7 @@ const buildDefaultHourlyRows = (): HourlyRow[] =>
const mergeHourlyRows = (
rowsFromDb: Array<Partial<HourlyRow> & Pick<HourlyRow, 'id'>> | undefined
): HourlyRow[] => {
const dbValueMap = new Map<string, Partial<HourlyRow> & Pick<HourlyRow, 'id'>>()
for (const row of rowsFromDb || []) {
dbValueMap.set(String(row.id), row)
}
const dbValueMap = toRowMap(rowsFromDb)
return buildDefaultHourlyRows().map(row => {
const fromDb = dbValueMap.get(row.id)
@ -239,6 +237,20 @@ const calcHourlyServiceBudget = (row: HourlyRow) => {
return roundTo(toDecimal(row.adoptedBudgetUnitPrice).mul(row.personnelCount).mul(row.workdayCount), 2)
}
const resolveScaleRows = (
serviceId: string,
pricingData: StoredDetailRowsState | null,
htData: StoredDetailRowsState | null
) => {
if (pricingData?.detailRows != null) {
return mergeScaleRows(serviceId, pricingData.detailRows as any)
}
if (htData?.detailRows != null) {
return mergeScaleRows(serviceId, htData.detailRows as any)
}
return buildDefaultScaleRows(serviceId)
}
export const getPricingMethodTotalsForService = async (params: {
contractId: string
serviceId: string | number
@ -258,20 +270,11 @@ export const getPricingMethodTotalsForService = async (params: {
localforage.getItem<StoredDetailRowsState>(htDbKey)
])
const investRows =
investData?.detailRows != null
? mergeScaleRows(serviceId, investData.detailRows as any)
: htData?.detailRows != null
? mergeScaleRows(serviceId, htData.detailRows as any)
: buildDefaultScaleRows(serviceId)
// 优先使用对应计费页的数据;不存在时回退合同段规模信息,再回退默认字典行。
const investRows = resolveScaleRows(serviceId, investData, htData)
const investScale = sumByNumber(investRows, row => getInvestmentBudgetFee(row))
const landRows =
landData?.detailRows != null
? mergeScaleRows(serviceId, landData.detailRows as any)
: htData?.detailRows != null
? mergeScaleRows(serviceId, htData.detailRows as any)
: buildDefaultScaleRows(serviceId)
const landRows = resolveScaleRows(serviceId, landData, htData)
const landScale = sumByNumber(landRows, row => getLandBudgetFee(row))
const defaultWorkloadRows = buildDefaultWorkloadRows(serviceId)

View File

@ -0,0 +1,27 @@
import { getBasicFeeFromScale } from '@/sql'
import { addNumbers, roundTo, toDecimal } from '@/lib/decimal'
import { toFiniteNumberOrNull } from '@/lib/number'
type ScaleMode = 'cost' | 'area'
export const getBenchmarkBudgetByScale = (value: unknown, mode: ScaleMode) => {
const scaleValue = toFiniteNumberOrNull(value)
const result = getBasicFeeFromScale(scaleValue, mode)
return result ? roundTo(addNumbers(result.basic, result.optional), 2) : null
}
export const getScaleBudgetFee = (params: {
benchmarkBudget: unknown
majorFactor: unknown
consultCategoryFactor: unknown
}) => {
const benchmarkBudget = toFiniteNumberOrNull(params.benchmarkBudget)
const majorFactor = toFiniteNumberOrNull(params.majorFactor)
const consultCategoryFactor = toFiniteNumberOrNull(params.consultCategoryFactor)
if (benchmarkBudget == null || majorFactor == null || consultCategoryFactor == null) {
return null
}
return roundTo(toDecimal(benchmarkBudget).mul(majorFactor).mul(consultCategoryFactor), 2)
}

View File

@ -1,5 +1,6 @@
import localforage from 'localforage'
import { majorList, serviceList } from '@/sql'
import { toFiniteNumberOrNull } from '@/lib/number'
const CONSULT_CATEGORY_FACTOR_KEY = 'xm-consult-category-factor-v1'
const MAJOR_FACTOR_KEY = 'xm-major-factor-v1'
@ -20,9 +21,6 @@ type FactorDictItem = {
type FactorDict = Record<string, FactorDictItem>
const toFiniteNumberOrNull = (value: unknown): number | null =>
typeof value === 'number' && Number.isFinite(value) ? value : null
const buildStandardFactorMap = (dict: FactorDict): Map<string, number | null> => {
const map = new Map<string, number | null>()
for (const [id, item] of Object.entries(dict)) {

View File

@ -1,4 +1,5 @@
import localforage from 'localforage'
import { toFiniteNumberOrNull } from '@/lib/number'
export type ZxFwPricingField = 'investScale' | 'landScale' | 'workload' | 'hourly'
@ -18,9 +19,6 @@ interface ZxFwState {
export const ZXFW_RELOAD_SERVICE_KEY = 'zxfw-main'
const toFiniteNumberOrNull = (value: number | null | undefined) =>
typeof value === 'number' && Number.isFinite(value) ? value : null
export const syncPricingTotalToZxFw = async (params: {
contractId: string
serviceId: string | number

View File

@ -1,18 +1,18 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { createPinia } from 'pinia'
import {
ModuleRegistry,
CellStyleModule,
ClientSideRowModelModule,
ColumnAutoSizeModule,
CsvExportModule,
LargeTextEditorModule,
LocaleModule,
ModuleRegistry,
NumberEditorModule,
PinnedRowModule,
RowAutoHeightModule,
TextEditorModule,
TooltipModule,
UndoRedoEditModule,ValidationModule,LocaleModule ,CellStyleModule ,RowAutoHeightModule
UndoRedoEditModule,
ValidationModule
} from 'ag-grid-community'
import {
AggregationModule,
@ -24,18 +24,26 @@ import {
RowGroupingModule,
TreeDataModule
} from 'ag-grid-enterprise'
LicenseManager.setLicenseKey("[v3][RELEASE][0102]_NDg2Njc4MzY3MDgzNw==16d78ca762fb5d2ff740aed081e2af7b")
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' // 引入
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
ModuleRegistry.registerModules([
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
LicenseManager.setLicenseKey(
'[v3][RELEASE][0102]_NDg2Njc4MzY3MDgzNw==16d78ca762fb5d2ff740aed081e2af7b'
)
const AG_GRID_MODULES = [
ClientSideRowModelModule,
ColumnAutoSizeModule,
CsvExportModule,
TextEditorModule,
NumberEditorModule,RowAutoHeightModule,
NumberEditorModule,
RowAutoHeightModule,
LargeTextEditorModule,
UndoRedoEditModule,CellStyleModule ,
UndoRedoEditModule,
CellStyleModule,
PinnedRowModule,
TooltipModule,
TreeDataModule,
@ -44,8 +52,15 @@ ModuleRegistry.registerModules([
MenuModule,
CellSelectionModule,
ContextMenuModule,
ClipboardModule,LocaleModule ,
ClipboardModule,
LocaleModule,
ValidationModule
])
]
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// 在应用启动时一次性注册 AG Grid 运行所需模块。
ModuleRegistry.registerModules(AG_GRID_MODULES)
createApp(App).use(pinia).mount('#app')

View File

@ -1,112 +1,115 @@
// src/stores/tab.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useTabStore = defineStore('tabs', () => {
interface TabItem<T = Record<string, any>> {
id: string; // 标签唯一标识
title: string; // 标签标题
componentName: string; // 组件名称
props?: T; // 传递给组件的 props可选泛型适配不同组件
export interface TabItem<TProps = Record<string, unknown>> {
id: string
title: string
componentName: string
props?: TProps
}
const defaultTabs :TabItem[]= [
{ id: 'XmView', title: '项目卡片', componentName: 'XmView' }
]
const tabs = ref([
...defaultTabs
])
const activeTabId = ref('XmView')
const HOME_TAB_ID = 'XmView'
const DEFAULT_TAB: TabItem = {
id: HOME_TAB_ID,
title: '项目卡片',
componentName: HOME_TAB_ID
}
const ensureActiveValid = () => {
const activeExists = tabs.value.some(t => t.id === activeTabId.value)
if (!activeExists) {
activeTabId.value = tabs.value[0]?.id || 'XmView'
}
}
const createDefaultTabs = (): TabItem[] => [{ ...DEFAULT_TAB }]
const openTab = (config: { id: string; title: string; componentName: string; props?: any }) => {
const exists = tabs.value.some(t => t.id === config.id)
if (!exists) {
tabs.value = [...tabs.value, config]
}
activeTabId.value = config.id
}
export const useTabStore = defineStore(
'tabs',
() => {
const tabs = ref<TabItem[]>(createDefaultTabs())
const activeTabId = ref(HOME_TAB_ID)
const removeTab = (id: string) => {
if (id === 'XmView') return // 首页不可删除
const index = tabs.value.findIndex(t => t.id === id)
if (index < 0) return
const wasActive = activeTabId.value === id
tabs.value = tabs.value.filter(t => t.id !== id)
if (tabs.value.length === 0) {
tabs.value = [...defaultTabs]
const ensureHomeTab = () => {
if (tabs.value.some(tab => tab.id === HOME_TAB_ID)) return
tabs.value = [...createDefaultTabs(), ...tabs.value]
}
if (wasActive) {
const fallbackIndex = Math.max(0, Math.min(index - 1, tabs.value.length - 1))
activeTabId.value = tabs.value[fallbackIndex]?.id || 'XmView'
return
const ensureActiveValid = () => {
ensureHomeTab()
if (tabs.value.length === 0) tabs.value = createDefaultTabs()
if (!tabs.value.some(tab => tab.id === activeTabId.value)) {
activeTabId.value = tabs.value[0]?.id ?? HOME_TAB_ID
}
}
const activeStillExists = tabs.value.some(t => t.id === activeTabId.value)
if (!activeStillExists) {
activeTabId.value = tabs.value[0]?.id || 'XmView'
const openTab = (config: TabItem) => {
if (!tabs.value.some(tab => tab.id === config.id)) {
tabs.value = [...tabs.value, config]
}
activeTabId.value = config.id
}
const removeTab = (id: string) => {
// 首页标签固定保留,不允许关闭。
if (id === HOME_TAB_ID) return
const index = tabs.value.findIndex(tab => tab.id === id)
if (index < 0) return
const wasActive = activeTabId.value === id
tabs.value = tabs.value.filter(tab => tab.id !== id)
ensureHomeTab()
if (wasActive) {
const fallbackIndex = Math.max(0, Math.min(index - 1, tabs.value.length - 1))
activeTabId.value = tabs.value[fallbackIndex]?.id ?? HOME_TAB_ID
return
}
ensureActiveValid()
}
const closeAllTabs = () => {
tabs.value = createDefaultTabs()
activeTabId.value = HOME_TAB_ID
}
const closeLeftTabs = (targetId: string) => {
const targetIndex = tabs.value.findIndex(tab => tab.id === targetId)
if (targetIndex < 0) return
tabs.value = tabs.value.filter((tab, index) => tab.id === HOME_TAB_ID || index >= targetIndex)
ensureActiveValid()
}
const closeRightTabs = (targetId: string) => {
const targetIndex = tabs.value.findIndex(tab => tab.id === targetId)
if (targetIndex < 0) return
tabs.value = tabs.value.filter((tab, index) => tab.id === HOME_TAB_ID || index <= targetIndex)
ensureActiveValid()
}
const closeOtherTabs = (targetId: string) => {
tabs.value = tabs.value.filter(tab => tab.id === HOME_TAB_ID || tab.id === targetId)
ensureHomeTab()
activeTabId.value = tabs.value.some(tab => tab.id === targetId) ? targetId : HOME_TAB_ID
}
const resetTabs = () => {
tabs.value = createDefaultTabs()
activeTabId.value = HOME_TAB_ID
}
return {
tabs,
activeTabId,
openTab,
removeTab,
closeAllTabs,
closeLeftTabs,
closeRightTabs,
closeOtherTabs,
resetTabs
}
},
{
persist: {
key: 'tabs',
storage: localStorage,
pick: ['tabs', 'activeTabId']
}
}
const closeAllTabs = () => {
tabs.value = tabs.value.filter(t => t.id === 'XmView')
if (tabs.value.length === 0) tabs.value = [...defaultTabs]
activeTabId.value = 'XmView'
}
const closeLeftTabs = (targetId: string) => {
const targetIndex = tabs.value.findIndex(t => t.id === targetId)
if (targetIndex < 0) return
tabs.value = tabs.value.filter((tab, index) => tab.id === 'XmView' || index >= targetIndex)
ensureActiveValid()
}
const closeRightTabs = (targetId: string) => {
const targetIndex = tabs.value.findIndex(t => t.id === targetId)
if (targetIndex < 0) return
tabs.value = tabs.value.filter((tab, index) => tab.id === 'XmView' || index <= targetIndex)
ensureActiveValid()
}
const closeOtherTabs = (targetId: string) => {
tabs.value = tabs.value.filter(tab => tab.id === 'XmView' || tab.id === targetId)
if (tabs.value.length === 0) tabs.value = [...defaultTabs]
if (targetId === 'XmView') {
activeTabId.value = 'XmView'
return
}
activeTabId.value = tabs.value.some(t => t.id === targetId) ? targetId : 'XmView'
}
const resetTabs = () => {
tabs.value = [...defaultTabs]
activeTabId.value = 'XmView'
}
return {
tabs,
activeTabId,
openTab,
removeTab,
closeAllTabs,
closeLeftTabs,
closeRightTabs,
closeOtherTabs,
resetTabs
}
}, {
// --- 关键配置:开启持久化 ---
persist: {
key: 'tabs', // 存储在 localStorage 里的 key
storage: localStorage, // 也可以改用 sessionStorage
pick: ['tabs', 'activeTabId'], // 指定哪些变量需要持久化
}
})
)

View File

@ -53,8 +53,8 @@ export const serviceList = {
14: { code: 'D4', name: '专项造价咨询', maxCoe: null, minCoe: null, defCoe: null, desc: '' },
15: { code: 'D4-1', name: '工程造价顾问', maxCoe: null, minCoe: null, defCoe: 1, desc: '本表系数适用于采用工作量计价法基准预算的调整系数。' },
16: { code: 'D4-2', name: '造价政策制(修)订', maxCoe: null, minCoe: null, defCoe: 1, desc: '' },
17: { code: 'D4-3', name: '造价科学与技术研究', maxCoe: null, minCoe: null, defCoe: 1, desc: ''},
18: { code: 'D4-4', name: '定额测定', maxCoe: null, minCoe: null, defCoe: 1, desc: ''},
17: { code: 'D4-3', name: '造价科学与技术研究', maxCoe: null, minCoe: null, defCoe: 1, desc: '' },
18: { code: 'D4-4', name: '定额测定', maxCoe: null, minCoe: null, defCoe: 1, desc: '' },
19: { code: 'D4-5', name: '造价信息咨询', maxCoe: null, minCoe: null, defCoe: 1, desc: '' },
20: { code: 'D4-6', name: '造价鉴定', maxCoe: null, minCoe: null, defCoe: 0.5, desc: '本表系数适用于采用规模计价法基准预算的调整系数。' },
21: { code: 'D4-7', name: '工程成本测算', maxCoe: null, minCoe: null, defCoe: 0.1, desc: '' },
@ -63,12 +63,12 @@ export const serviceList = {
24: { code: 'D4-10', name: '工程变更费用咨询', maxCoe: null, minCoe: null, defCoe: 0.5, desc: '' },
25: { code: 'D4-11', name: '调整估算', maxCoe: 0.2, minCoe: 0.1, defCoe: 0.15, desc: '' },
26: { code: 'D4-12', name: '调整概算', maxCoe: 0.3, minCoe: 0.15, defCoe: 0.225, desc: '本表系数适用于采用规模计价法基准预算的系数;依据其调整时期所在建设阶段和基础资料的不同,其系数取值不同。' },
27: { code: 'D4-13', name: '造价检查', maxCoe: null, minCoe: null, defCoe: null, desc: '可按照服务工日数量×服务工日人工单价×综合预算系数;也可按照服务工日数量×服务工日综合预算单价。' ,notshowByzxflxs:true},
28: { code: 'D4-14', name: '其他专项咨询', maxCoe: null, minCoe: null, defCoe: null, desc: '可参照相同或相似服务的系数。' ,notshowByzxflxs:true},
27: { code: 'D4-13', name: '造价检查', maxCoe: null, minCoe: null, defCoe: null, desc: '可按照服务工日数量×服务工日人工单价×综合预算系数;也可按照服务工日数量×服务工日综合预算单价。', notshowByzxflxs: true },
28: { code: 'D4-14', name: '其他专项咨询', maxCoe: null, minCoe: null, defCoe: null, desc: '可参照相同或相似服务的系数。', notshowByzxflxs: true },
};
//basicParam预算基数
export const taskList = {
export const taskList = {
0: { serviceID: 15, ref: 'C4-1', name: '工程造价日常顾问', basicParam: '服务月份数', required: true, unit: '万元/月', conversion: 10000, maxPrice: 0.5, minPrice: 0.3, defPrice: 0.4, desc: '' },
1: { serviceID: 15, ref: 'C4-2', name: '工程造价专项顾问', basicParam: '服务项目的造价金额', required: true, unit: '%', conversion: 0.01, maxPrice: null, minPrice: null, defPrice: 0.01, desc: '适用于涉及造价费用类的顾问' },
2: { serviceID: 16, ref: 'C5-1', name: '组织与调研工作', basicParam: '调研次数', required: true, unit: '万元/次', conversion: 10000, maxPrice: 2, minPrice: 1, defPrice: 1.5, desc: '' },
@ -124,7 +124,7 @@ export const expertList = {
7: { code: 'C9-3-2', name: '一级造价工程师且具备高级工程师资格', maxPrice: 2000, minPrice: 1800, defPrice: 1900, manageCoe: 2.05 },
};
const costScaleCal = [
const costScaleCal = [
{ code: 'C1-1', staLine: 0, endLine: 100, basic: { staPrice: 0, rate: 0.01 }, optional: { staPrice: 0, rate: 0.002 } },
{ code: 'C1-2', staLine: 100, endLine: 300, basic: { staPrice: 10000, rate: 0.008 }, optional: { staPrice: 2000, rate: 0.0016 } },
{ code: 'C1-3', staLine: 300, endLine: 500, basic: { staPrice: 26000, rate: 0.005 }, optional: { staPrice: 5200, rate: 0.001 } },
@ -144,7 +144,7 @@ export const expertList = {
{ code: 'C1-17', staLine: 1000000, endLine: null, basic: { staPrice: 5906000, rate: 0.00025 }, optional: { staPrice: 1181200, rate: 0.00005 } },
];
const areaScaleCal = [
const areaScaleCal = [
{ code: 'C2-1', staLine: 0, endLine: 50, basic: { staPrice: 0, rate: 200 }, optional: { staPrice: 0, rate: 40 } },
{ code: 'C2-2', staLine: 50, endLine: 100, basic: { staPrice: 10000, rate: 160 }, optional: { staPrice: 2000, rate: 32 } },
{ code: 'C2-3', staLine: 100, endLine: 500, basic: { staPrice: 18000, rate: 120 }, optional: { staPrice: 3600, rate: 24 } },
@ -238,7 +238,7 @@ export function getBasicFeeFromScale(scaleValue: unknown, scaleType: 'cost' | 'a
}
async function exportFile(fileName, data) {
export async function exportFile(fileName, data) {
if (window.showSaveFilePicker) {
const handle = await window.showSaveFilePicker({
suggestedName: fileName,
@ -817,15 +817,15 @@ function cloneCellValue(value) {
}
//demo
let data1 = {
name: 'test001',
fee: 10000,
scale: [
name: 'test001',//项目名称
fee: 10000, //所有合同段总费用
scale: [//项目明细aggrid数据
{
major: 0,
cost: 100000,
area: 200,
major: 0, //专业id对应专业majorList中的key
cost: 100000,//造价金额
area: 200,//用地面积
},
{
major: 1,
@ -833,15 +833,15 @@ let data1 = {
area: 200,
},
],
contracts: [
contracts: [//合同段数据
{
name: 'A合同段',
fee: 10000,
scale: [
name: 'A合同段',//合同段名称
fee: 10000,//合同段费用该合同段咨询服务zxfw.vue里面aggrid的合同预算行的小计
scale: [//合同段明细aggrid数据htinfo.vue
{
major: 0,
cost: 100000,
area: 200,
major: 0, //专业id对应专业majorList中的key
cost: 100000,//造价金额
area: 200,//用地面积
},
{
major: 1,
@ -849,12 +849,12 @@ let data1 = {
area: 200,
},
],
services: [
services: [//咨询服务数据zxfw.vue里面aggrid的数据不用输出合同预算
{
id: 0,
fee: 100000,
method1: { // 投资规模法
cost: 100000,
id: 0, //服务id对应serviceList中的key
fee: 100000 ,//服务费用(该服务咨询服务小计)
method1: { // 投资规模法InvestmentScalePricingPane.vue的数据
cost: 100000, //zxfw.vue里面aggrid该数据的投资规模法金额
basicFee: 200,
basicFee_basic: 200,
basicFee_optional: 0,