This commit is contained in:
wintsa 2026-03-10 15:50:07 +08:00
parent 1910f15564
commit 5bb6609ef8
18 changed files with 1447 additions and 979 deletions

View File

@ -186,367 +186,7 @@ function getBasicFeeFromScale(scaleValue, scaleType) {
return res; return res;
} }
let data1 = {
name: 'test001',
writer: '张三',// 编制人
reviewer: '李四',// 复核人
company: '测试公司',// 公司名称
date: '2021-09-24',// 编制日期
industry: 0,// 0为公路工程1为铁路工程2为水运工程
fee: 10000,
scaleCost: 100000,// scale的cost的合计数
scale: [// 规模信息
{
major: 0,
cost: 100000,
area: 200,
},
{
major: 1,
cost: 100000,
area: 200,
},
],
serviceCoes: [// 项目咨询分类系数
{
serviceid: 0,
coe: 1.1,
remark: '',// 用户输入的说明
},
{
serviceid: 1,
coe: 1.2,
remark: '',// 用户输入的说明
},
],
majorCoes: [// 项目工程专业系数
{
majorid: 0,
coe: 1.1,
remark: '',// 用户输入的说明
},
{
majorid: 1,
coe: 1.2,
remark: '',// 用户输入的说明
},
],
contracts: [// 合同段信息
{
name: 'A合同段',
serviceFee: 100000,
addtionalFee: 0,
reserveFee: 0,
fee: 10000,
scale: [
{
major: 0,
cost: 100000,
area: 200,
},
{
major: 1,
cost: 100000,
area: 200,
},
],
serviceCoes: [// 合同段咨询分类系数
{
serviceid: 0,
coe: 1.1,
remark: '',// 用户输入的说明
},
{
serviceid: 1,
coe: 1.2,
remark: '',// 用户输入的说明
},
],
majorCoes: [// 合同段工程专业系数
{
majorid: 0,
coe: 1.1,
remark: '',// 用户输入的说明
},
{
majorid: 1,
coe: 1.2,
remark: '',// 用户输入的说明
},
],
services: [
{
id: 0,
fee: 100000,
process: 0,// 工作环节0为编制1为审核
method1: { // 投资规模法
cost: 100000,
basicFee: 200,
basicFee_basic: 200,
basicFee_optional: 0,
fee: 250000,
proAmount: 3,
det: [
{
proNum: 1,
major: 0,
cost: 100000,
basicFee: 200,
basicFormula: '856,000+(1,000,000,000-500,000,000)×1‰',
basicFee_basic: 200,
optionalFormula: '171,200+(1,000,000,000-500,000,000)×0.2‰',
basicFee_optional: 0,
serviceCoe: 1.1,
majorCoe: 1.2,
processCoe: 1,// 工作环节系数(编审系数)
proportion: 0.5,// 工作占比
fee: 100000,
remark: '',// 用户输入的说明
},
],
},
method2: { // 用地规模法
area: 1200,
basicFee: 200,
basicFee_basic: 200,
basicFee_optional: 0,
fee: 250000,
proAmount: 3,
det: [
{
proNum: 1,
major: 0,
area: 1200,
basicFee: 200,
basicFormula: '106,000+(1,200-1,000)×60',
basicFee_basic: 200,
optionalFormula: '21,200+(1,200-1,000)×12',
basicFee_optional: 0,
serviceCoe: 1.1,
majorCoe: 1.2,
processCoe: 1,// 工作环节系数(编审系数)
proportion: 0.5,// 工作占比
fee: 100000,
remark: '',// 用户输入的说明
},
],
},
method3: { // 工作量法
basicFee: 200,
fee: 250000,
det: [
{
task: 0,
price: 100000,
amount: 10,
basicFee: 200,
serviceCoe: 1.1,
fee: 100000,
remark: '',// 用户输入的说明
},
{
task: 1,
price: 100000,
amount: 10,
basicFee: 200,
serviceCoe: 1.1,
fee: 100000,
remark: '',// 用户输入的说明
},
],
},
method4: { // 工时法
person_num: 10,
work_day: 10,
fee: 250000,
det: [
{
expert: 0,
price: 100000,
person_num: 10,
work_day: 3,
fee: 100000,
remark: '',// 用户输入的说明
},
{
expert: 1,
price: 100000,
person_num: 10,
work_day: 3,
fee: 100000,
remark: '',// 用户输入的说明
},
],
},
},
],
addtional: {// 附加工作费
ref: { richText: [{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'C' }, { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'C' }] },
name: '附加工作',
fee: 10000,
det: [
{
id: 0,
ref: { richText: [{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'C' }, { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'F' }] },
name: '人员驻场服务及其他附加工作',
fee: 10000,
m4: { //工时
person_num: 10,
work_day: 3,
fee: 10000,
det: [
{
expert: 0,
price: 100000,
person_num: 10,
work_day: 3,
fee: 100000,
remark: '',// 用户输入的说明
},
{
expert: 1,
price: 100000,
person_num: 10,
work_day: 3,
fee: 100000,
remark: '',// 用户输入的说明
},
],
},
m5: { //数量单价
fee: 10000,
det: [
{
name: '×××项',
unit: '项',
amount: 10,
price: 100000,
fee: 100000,
remark: '',// 用户输入的说明
},
{
name: '×××项',
unit: '项',
amount: 10,
price: 100000,
fee: 100000,
remark: '',// 用户输入的说明
},
],
},
},
{
id: 1,
ref: { richText: [{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'C' }, { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'X' }] },
name: '咨询服务协调工作',
fee: 10000,
m0: {
coe: 0.03,
fee: 10000,
},
m4: {
person_num: 10,
work_day: 3,
fee: 10000,
det: [
{
expert: 0,
price: 100000,
person_num: 10,
work_day: 3,
fee: 100000,
remark: '',// 用户输入的说明
},
{
expert: 1,
price: 100000,
person_num: 10,
work_day: 3,
fee: 100000,
remark: '',// 用户输入的说明
},
],
},
m5: {
fee: 10000,
det: [
{
name: '×××项',
unit: '项',
amount: 10,
price: 100000,
fee: 100000,
remark: '',// 用户输入的说明
},
{
name: '×××项',
unit: '项',
amount: 10,
price: 100000,
fee: 100000,
remark: '',// 用户输入的说明
},
],
},
},
]
},
reserve: {// 预备费
ref: { richText: [{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'Y' }, { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'B' }] },
name: '预备费',
fee: 10000,
m0: {
coe: 0.03,
fee: 10000,
},
m4: {
person_num: 10,
work_day: 3,
fee: 10000,
det: [
{
expert: 0,
price: 100000,
person_num: 10,
work_day: 3,
fee: 100000,
remark: '',// 用户输入的说明
},
{
expert: 1,
price: 100000,
person_num: 10,
work_day: 3,
fee: 100000,
remark: '',// 用户输入的说明
},
],
},
m5: {
fee: 10000,
det: [
{
name: '×××项',
unit: '项',
amount: 10,
price: 100000,
fee: 100000,
remark: '',// 用户输入的说明
},
{
name: '×××项',
unit: '项',
amount: 10,
price: 100000,
fee: 100000,
remark: '',// 用户输入的说明
},
],
}
},
},
],
};
let data2 = { let data2 = {
name: 'test001', name: 'test001',

View File

@ -0,0 +1,550 @@
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, ColGroupDef, GridApi, GridReadyEvent } from 'ag-grid-community'
import localforage from 'localforage'
import { expertList } from '@/sql'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
import { formatThousandsFlexible } from '@/lib/numberFormat'
import { parseNumberOrNull } from '@/lib/number'
import { syncPricingTotalToZxFw, type ZxFwPricingField, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
import { matchPricingPaneReload, usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import { useHtFeeMethodReloadStore } from '@/pinia/htFeeMethodReload'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
interface DetailRow {
id: string
expertCode: string
expertName: string
laborBudgetUnitPrice: string
compositeBudgetUnitPrice: string
adoptedBudgetUnitPrice: number | null
personnelCount: number | null
workdayCount: number | null
serviceBudget: number | null
remark: string
path: string[]
}
interface GridState {
detailRows: DetailRow[]
}
const props = withDefaults(
defineProps<{
storageKey: string
title?: string
contractId?: string
serviceId?: string | number
enableZxFwSync?: boolean
syncField?: ZxFwPricingField
syncMainStorageKey?: string
syncRowId?: string
}>(),
{
title: '工时法明细',
enableZxFwSync: false,
syncField: 'hourly'
}
)
const pricingPaneReloadStore = usePricingPaneReloadStore()
const htFeeMethodReloadStore = useHtFeeMethodReloadStore()
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
const paneInstanceCreatedAt = Date.now()
const reloadSignal = ref(0)
const shouldSkipPersist = () => {
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${props.storageKey}`
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const now = Date.now()
if (raw.includes(':')) {
const [issuedRaw, untilRaw] = raw.split(':')
const issuedAt = Number(issuedRaw)
const skipUntil = Number(untilRaw)
if (Number.isFinite(issuedAt) && Number.isFinite(skipUntil) && now <= skipUntil) {
return paneInstanceCreatedAt <= issuedAt
}
sessionStorage.removeItem(storageKey)
return false
}
const skipUntil = Number(raw)
if (Number.isFinite(skipUntil) && now <= skipUntil) return true
sessionStorage.removeItem(storageKey)
return false
}
const shouldForceDefaultLoad = () => {
const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${props.storageKey}`
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const forceUntil = Number(raw)
sessionStorage.removeItem(storageKey)
return Number.isFinite(forceUntil) && Date.now() <= forceUntil
}
const detailRows = ref<DetailRow[]>([])
const gridApi = ref<GridApi<DetailRow> | null>(null)
type ExpertLite = {
code: string
name: string
maxPrice: number | null
minPrice: number | null
defPrice: number | null
manageCoe: number | null
}
const expertEntries = Object.entries(expertList as Record<string, ExpertLite>)
.sort((a, b) => Number(a[0]) - Number(b[0]))
.filter((entry): entry is [string, ExpertLite] => {
const item = entry[1]
return Boolean(item?.code && item?.name)
})
const formatPriceRange = (min: number | null, max: number | null) => {
const hasMin = typeof min === 'number' && Number.isFinite(min)
const hasMax = typeof max === 'number' && Number.isFinite(max)
if (hasMin && hasMax) return `${min}-${max}`
if (hasMin) return String(min)
if (hasMax) return String(max)
return ''
}
const getCompositeBudgetUnitPriceRange = (expert: ExpertLite) => {
if (typeof expert.manageCoe !== 'number' || !Number.isFinite(expert.manageCoe)) return ''
const min =
typeof expert.minPrice === 'number' && Number.isFinite(expert.minPrice)
? roundTo(toDecimal(expert.minPrice).mul(expert.manageCoe), 2)
: null
const max =
typeof expert.maxPrice === 'number' && Number.isFinite(expert.maxPrice)
? roundTo(toDecimal(expert.maxPrice).mul(expert.manageCoe), 2)
: null
return formatPriceRange(min, max)
}
const getDefaultAdoptedBudgetUnitPrice = (expert: ExpertLite) => {
if (
typeof expert.defPrice !== 'number' ||
!Number.isFinite(expert.defPrice) ||
typeof expert.manageCoe !== 'number' ||
!Number.isFinite(expert.manageCoe)
) {
return null
}
return roundTo(toDecimal(expert.defPrice).mul(expert.manageCoe), 2)
}
const buildDefaultRows = (): DetailRow[] => {
const rows: DetailRow[] = []
for (const [expertId, expert] of expertEntries) {
const rowId = `expert-${expertId}`
rows.push({
id: rowId,
expertCode: expert.code,
expertName: expert.name,
laborBudgetUnitPrice: formatPriceRange(expert.minPrice, expert.maxPrice),
compositeBudgetUnitPrice: getCompositeBudgetUnitPriceRange(expert),
adoptedBudgetUnitPrice: getDefaultAdoptedBudgetUnitPrice(expert),
personnelCount: null,
workdayCount: null,
serviceBudget: null,
remark: '',
path: [rowId]
})
}
return rows
}
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
const dbValueMap = new Map<string, DetailRow>()
for (const row of rowsFromDb || []) {
dbValueMap.set(row.id, row)
}
return buildDefaultRows().map(row => {
const fromDb = dbValueMap.get(row.id)
if (!fromDb) return row
return {
...row,
adoptedBudgetUnitPrice:
typeof fromDb.adoptedBudgetUnitPrice === 'number' ? fromDb.adoptedBudgetUnitPrice : null,
personnelCount: typeof fromDb.personnelCount === 'number' ? fromDb.personnelCount : null,
workdayCount: typeof fromDb.workdayCount === 'number' ? fromDb.workdayCount : null,
serviceBudget: typeof fromDb.serviceBudget === 'number' ? fromDb.serviceBudget : null,
remark: typeof fromDb.remark === 'string' ? fromDb.remark : ''
}
})
}
const parseNonNegativeIntegerOrNull = (value: unknown) => {
if (value === '' || value == null) return null
if (typeof value === 'number') return Number.isInteger(value) && value >= 0 ? value : null
const normalized = String(value).trim()
if (!/^\d+$/.test(normalized)) return null
const v = Number(normalized)
return Number.isSafeInteger(v) ? v : null
}
const formatEditableNumber = (params: any) => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return formatThousandsFlexible(params.value, 3)
}
const formatEditableInteger = (params: any) => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return String(Number(params.value))
}
const calcServiceBudget = (row: DetailRow | undefined) => {
const adopted = row?.adoptedBudgetUnitPrice
const personnel = row?.personnelCount
const workday = row?.workdayCount
if (
typeof adopted !== 'number' ||
!Number.isFinite(adopted) ||
typeof personnel !== 'number' ||
!Number.isFinite(personnel) ||
typeof workday !== 'number' ||
!Number.isFinite(workday)
) {
return null
}
return roundTo(toDecimal(adopted).mul(personnel).mul(workday), 2)
}
const syncServiceBudgetToRows = () => {
for (const row of detailRows.value) {
row.serviceBudget = calcServiceBudget(row)
}
}
const editableNumberCol = <K extends keyof DetailRow>(
field: K,
headerName: string,
extra: Partial<ColDef<DetailRow>> = {}
): ColDef<DetailRow> => ({
headerName,
field,
minWidth: 150,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: formatEditableNumber,
...extra
})
const editableMoneyCol = <K extends keyof DetailRow>(
field: K,
headerName: string,
extra: Partial<ColDef<DetailRow>> = {}
): ColDef<DetailRow> => ({
headerName,
field,
headerClass: 'ag-right-aligned-header',
minWidth: 150,
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 =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return formatThousandsFlexible(params.value, 3)
},
...extra
})
const readonlyTextCol = <K extends keyof DetailRow>(
field: K,
headerName: string,
extra: Partial<ColDef<DetailRow>> = {}
): ColDef<DetailRow> => ({
headerName,
field,
minWidth: 170,
flex: 1,
editable: false,
valueFormatter: params => params.value || '',
...extra
})
const columnDefs: (ColDef<DetailRow> | ColGroupDef<DetailRow>)[] = [
{
headerName: '编码',
field: 'expertCode',
minWidth: 120,
width: 140,
pinned: 'left',
colSpan: params => (params.node?.rowPinned ? 2 : 1),
valueFormatter: params => (params.node?.rowPinned ? '总合计' : params.value || '')
},
{
headerName: '人员名称',
field: 'expertName',
minWidth: 200,
width: 220,
pinned: 'left',
tooltipField: 'expertName',
valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '')
},
{
headerName: '预算参考单价',
marryChildren: true,
children: [
readonlyTextCol('laborBudgetUnitPrice', '人工预算单价(元/工日)'),
readonlyTextCol('compositeBudgetUnitPrice', '综合预算单价(元/工日)')
]
},
editableMoneyCol('adoptedBudgetUnitPrice', '预算采用单价(元/工日)'),
editableNumberCol('personnelCount', '人员数量(人)', {
aggFunc: decimalAggSum,
valueParser: params => parseNonNegativeIntegerOrNull(params.newValue),
valueFormatter: formatEditableInteger
}),
editableNumberCol('workdayCount', '工日数量(工日)', { aggFunc: decimalAggSum }),
{
headerName: '服务预算(元)',
field: 'serviceBudget',
headerClass: 'ag-right-aligned-header',
minWidth: 150,
flex: 1,
cellClass: 'ag-right-aligned-cell',
editable: false,
aggFunc: decimalAggSum,
valueGetter: params => (params.node?.rowPinned ? params.data?.serviceBudget ?? null : calcServiceBudget(params.data)),
valueFormatter: params => {
if (params.value == null || params.value === '') return ''
return formatThousandsFlexible(params.value, 3)
}
},
{
headerName: '说明',
field: 'remark',
minWidth: 180,
flex: 1.2,
cellEditor: 'agLargeTextCellEditor',
wrapText: true,
autoHeight: true,
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
editable: params => !params.node?.group && !params.node?.rowPinned,
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && !params.value) return '点击输入'
return params.value || ''
},
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line remark-wrap-cell' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
}
}
]
const totalPersonnelCount = computed(() => sumByNumber(detailRows.value, row => row.personnelCount))
const totalWorkdayCount = computed(() => sumByNumber(detailRows.value, row => row.workdayCount))
const totalServiceBudget = computed(() => sumByNumber(detailRows.value, row => calcServiceBudget(row)))
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',
expertCode: '总合计',
expertName: '',
laborBudgetUnitPrice: '',
compositeBudgetUnitPrice: '',
adoptedBudgetUnitPrice: null,
personnelCount: totalPersonnelCount.value,
workdayCount: totalWorkdayCount.value,
serviceBudget: totalServiceBudget.value,
remark: '',
path: ['TOTAL']
}
])
const saveToIndexedDB = async () => {
if (shouldSkipPersist()) return
try {
syncServiceBudgetToRows()
const payload: GridState = {
detailRows: JSON.parse(JSON.stringify(detailRows.value))
}
await localforage.setItem(props.storageKey, payload)
if (props.enableZxFwSync && props.contractId && props.serviceId != null) {
const synced = await syncPricingTotalToZxFw({
contractId: props.contractId,
serviceId: props.serviceId,
field: props.syncField,
value: totalServiceBudget.value
})
if (synced) pricingPaneReloadStore.emit(props.contractId, ZXFW_RELOAD_SERVICE_KEY)
}
if (props.syncMainStorageKey && props.syncRowId) {
htFeeMethodReloadStore.emit(props.syncMainStorageKey, props.syncRowId)
}
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
}
const loadFromIndexedDB = async () => {
try {
if (shouldForceDefaultLoad()) {
detailRows.value = buildDefaultRows()
syncServiceBudgetToRows()
return
}
const data = await localforage.getItem<GridState>(props.storageKey)
if (data) {
detailRows.value = mergeWithDictRows(data.detailRows)
syncServiceBudgetToRows()
return
}
detailRows.value = buildDefaultRows()
syncServiceBudgetToRows()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
detailRows.value = buildDefaultRows()
syncServiceBudgetToRows()
}
}
watch(
() => pricingPaneReloadStore.seq,
(nextVersion, prevVersion) => {
if (nextVersion === prevVersion || nextVersion === 0) return
if (!props.contractId || props.serviceId == null) return
if (!matchPricingPaneReload(pricingPaneReloadStore.lastEvent, props.contractId, props.serviceId)) return
reloadSignal.value += 1
}
)
watch(
() => reloadSignal.value,
(nextVersion, prevVersion) => {
if (nextVersion === prevVersion || nextVersion === 0) return
void loadFromIndexedDB()
}
)
let persistTimer: ReturnType<typeof setTimeout> | null = null
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
const handleCellValueChanged = () => {
syncServiceBudgetToRows()
gridApi.value?.refreshCells({ force: true })
if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridPersistTimer = setTimeout(() => {
void saveToIndexedDB()
}, 300)
}
const handleGridReady = (event: GridReadyEvent<DetailRow>) => {
gridApi.value = event.api
}
const processCellForClipboard = (params: any) => {
if (Array.isArray(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 (_error) {
return params.value
}
return params.value
}
onMounted(async () => {
await loadFromIndexedDB()
})
onActivated(async () => {
await loadFromIndexedDB()
})
watch(
() => props.storageKey,
() => {
void loadFromIndexedDB()
}
)
onDeactivated(() => {
gridApi.value?.stopEditing()
void saveToIndexedDB()
})
onBeforeUnmount(() => {
if (persistTimer) clearTimeout(persistTimer)
if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridApi.value?.stopEditing()
gridApi.value = null
void saveToIndexedDB()
})
</script>
<template>
<div class="h-full min-h-0 flex flex-col">
<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">{{ props.title }}</h3>
<div class="text-xs text-muted-foreground"></div>
</div>
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
<AgGridVue
:style="{ height: '100%' }"
:rowData="detailRows"
:pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs"
:gridOptions="gridOptions"
:theme="myTheme"
:treeData="false"
@cell-value-changed="handleCellValueChanged"
:suppressColumnVirtualisation="true"
:suppressRowVirtualisation="true"
:cellSelection="{ handle: { mode: 'range' } }"
:enableClipboard="true"
:localeText="AG_GRID_LOCALE_CN"
:tooltipShowDelay="500"
:headerHeight="50"
:processCellForClipboard="processCellForClipboard"
:processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true"
:undoRedoCellEditingLimit="20"
@grid-ready="handleGridReady"
/>
</div>
</div>
</div>
</template>

View File

@ -1,13 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { defineComponent, h, onActivated, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue'
import { AgGridVue } from 'ag-grid-vue3' import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridApi, GridOptions, GridReadyEvent } from 'ag-grid-community' import type { ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale' import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import localforage from 'localforage' import localforage from 'localforage'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions' import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { parseNumberOrNull } from '@/lib/number' import { parseNumberOrNull } from '@/lib/number'
import { formatThousandsFlexible } from '@/lib/numberFormat' import { formatThousandsFlexible } from '@/lib/numberFormat'
import { roundTo, toDecimal } from '@/lib/decimal' import { roundTo, toDecimal } from '@/lib/decimal'
import { Button } from '@/components/ui/button'
import { useHtFeeMethodReloadStore } from '@/pinia/htFeeMethodReload'
import { Trash2 } from 'lucide-vue-next'
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle
} from 'reka-ui'
interface FeeRow { interface FeeRow {
id: string id: string
@ -17,6 +30,7 @@ interface FeeRow {
unitPrice: number | null unitPrice: number | null
budgetFee: number | null budgetFee: number | null
remark: string remark: string
actions?: unknown
} }
interface FeeGridState { interface FeeGridState {
@ -26,9 +40,13 @@ interface FeeGridState {
const props = defineProps<{ const props = defineProps<{
title: string title: string
storageKey: string storageKey: string
syncMainStorageKey?: string
syncRowId?: string
}>() }>()
const htFeeMethodReloadStore = useHtFeeMethodReloadStore()
const createRowId = () => `fee-${Date.now()}-${Math.random().toString(16).slice(2, 8)}` const createRowId = () => `fee-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
const SUBTOTAL_ROW_ID = 'fee-subtotal-fixed'
const createDefaultRow = (): FeeRow => ({ const createDefaultRow = (): FeeRow => ({
id: createRowId(), id: createRowId(),
@ -40,20 +58,77 @@ const createDefaultRow = (): FeeRow => ({
remark: '' remark: ''
}) })
const detailRows = ref<FeeRow[]>([createDefaultRow()]) const createSubtotalRow = (): FeeRow => ({
id: SUBTOTAL_ROW_ID,
feeItem: '小计',
unit: '',
quantity: null,
unitPrice: null,
budgetFee: 0,
remark: ''
})
const isSubtotalRow = (row?: FeeRow | null) => row?.id === SUBTOTAL_ROW_ID
const ensureSubtotalRow = (rows: FeeRow[]) => {
const normalRows = rows.filter(row => !isSubtotalRow(row))
if (normalRows.length === 0) return []
return [...normalRows, createSubtotalRow()]
}
const detailRows = ref<FeeRow[]>([])
const gridApi = ref<GridApi<FeeRow> | null>(null) const gridApi = ref<GridApi<FeeRow> | null>(null)
const deleteConfirmOpen = ref(false)
const pendingDeleteRowId = ref<string | null>(null)
const pendingDeleteRowName = ref('')
const addRow = () => {
const normalRows = detailRows.value.filter(row => !isSubtotalRow(row))
detailRows.value = ensureSubtotalRow([...normalRows, createDefaultRow()])
syncComputedValuesToRows()
void saveToIndexedDB()
}
const deleteRow = (id: string) => {
const normalRows = detailRows.value.filter(row => !isSubtotalRow(row) && row.id !== id)
detailRows.value = ensureSubtotalRow(normalRows)
syncComputedValuesToRows()
void saveToIndexedDB()
}
const requestDeleteRow = (id: string, name?: string) => {
pendingDeleteRowId.value = id
pendingDeleteRowName.value = String(name || '').trim() || '当前行'
deleteConfirmOpen.value = true
}
const handleDeleteConfirmOpenChange = (open: boolean) => {
deleteConfirmOpen.value = open
}
const confirmDeleteRow = () => {
const id = pendingDeleteRowId.value
if (!id) return
deleteRow(id)
deleteConfirmOpen.value = false
pendingDeleteRowId.value = null
pendingDeleteRowName.value = ''
}
const formatEditableText = (params: any) => { const formatEditableText = (params: any) => {
if (isSubtotalRow(params.data)) return ''
if (params.value == null || params.value === '') return '点击输入' if (params.value == null || params.value === '') return '点击输入'
return String(params.value) return String(params.value)
} }
const formatEditableQuantity = (params: any) => { const formatEditableQuantity = (params: any) => {
if (isSubtotalRow(params.data)) return ''
if (params.value == null || params.value === '') return '点击输入' if (params.value == null || params.value === '') return '点击输入'
return formatThousandsFlexible(params.value, 3) return formatThousandsFlexible(params.value, 3)
} }
const formatEditableUnitPrice = (params: any) => { const formatEditableUnitPrice = (params: any) => {
if (isSubtotalRow(params.data)) return ''
if (params.value == null || params.value === '') return '点击输入' if (params.value == null || params.value === '') return '点击输入'
return formatThousandsFlexible(params.value, 3) return formatThousandsFlexible(params.value, 3)
} }
@ -64,18 +139,32 @@ const formatReadonlyBudgetFee = (params: any) => {
} }
const syncComputedValuesToRows = () => { const syncComputedValuesToRows = () => {
let totalBudgetFee = 0
for (const row of detailRows.value) { for (const row of detailRows.value) {
if (isSubtotalRow(row)) continue
if (row.quantity == null || row.unitPrice == null) { if (row.quantity == null || row.unitPrice == null) {
row.budgetFee = null row.budgetFee = null
continue continue
} }
row.budgetFee = roundTo(toDecimal(row.quantity).mul(row.unitPrice), 2) row.budgetFee = roundTo(toDecimal(row.quantity).mul(row.unitPrice), 2)
if (typeof row.budgetFee === 'number' && Number.isFinite(row.budgetFee)) {
totalBudgetFee = roundTo(toDecimal(totalBudgetFee).add(row.budgetFee), 2)
}
}
const subtotalRow = detailRows.value.find(row => isSubtotalRow(row))
if (subtotalRow) {
subtotalRow.feeItem = '小计'
subtotalRow.unit = ''
subtotalRow.quantity = null
subtotalRow.unitPrice = null
subtotalRow.budgetFee = totalBudgetFee
subtotalRow.remark = ''
} }
} }
const mergeWithStoredRows = (rowsFromDb: unknown): FeeRow[] => { const mergeWithStoredRows = (rowsFromDb: unknown): FeeRow[] => {
if (!Array.isArray(rowsFromDb) || rowsFromDb.length === 0) { if (!Array.isArray(rowsFromDb) || rowsFromDb.length === 0) {
return [createDefaultRow()] return []
} }
const rows: FeeRow[] = rowsFromDb.map(item => { const rows: FeeRow[] = rowsFromDb.map(item => {
@ -91,7 +180,7 @@ const mergeWithStoredRows = (rowsFromDb: unknown): FeeRow[] => {
} }
}) })
return rows.length > 0 ? rows : [createDefaultRow()] return ensureSubtotalRow(rows)
} }
const buildPersistDetailRows = () => { const buildPersistDetailRows = () => {
@ -104,7 +193,11 @@ const saveToIndexedDB = async () => {
const payload: FeeGridState = { const payload: FeeGridState = {
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows())) detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows()))
} }
await localforage.setItem(props.storageKey, payload) await localforage.setItem(props.storageKey, payload)
if (props.syncMainStorageKey && props.syncRowId) {
htFeeMethodReloadStore.emit(props.syncMainStorageKey, props.syncRowId)
}
} catch (error) { } catch (error) {
console.error('saveToIndexedDB failed:', error) console.error('saveToIndexedDB failed:', error)
} }
@ -117,7 +210,7 @@ const loadFromIndexedDB = async () => {
syncComputedValuesToRows() syncComputedValuesToRows()
} catch (error) { } catch (error) {
console.error('loadFromIndexedDB failed:', error) console.error('loadFromIndexedDB failed:', error)
detailRows.value = [createDefaultRow()] detailRows.value = []
syncComputedValuesToRows() syncComputedValuesToRows()
} }
} }
@ -136,18 +229,22 @@ const columnDefs: ColDef<FeeRow>[] = [
valueGetter: params => valueGetter: params =>
params.node?.rowPinned params.node?.rowPinned
? '' ? ''
: isSubtotalRow(params.data)
? '小计'
: typeof params.node?.rowIndex === 'number' : typeof params.node?.rowIndex === 'number'
? params.node.rowIndex + 1 ? params.node.rowIndex + 1
: '' : '',
colSpan: params => (isSubtotalRow(params.data) ? 2 : 1)
}, },
{ {
headerName: '费用项', headerName: '费用项',
field: 'feeItem', field: 'feeItem',
minWidth: 140, minWidth: 140,
flex: 1.4, flex: 1.4,
editable: true, editable: params => !isSubtotalRow(params.data),
valueGetter: params => (isSubtotalRow(params.data) ? '' : (params.data?.feeItem ?? '')),
valueFormatter: formatEditableText, valueFormatter: formatEditableText,
cellClass: 'editable-cell-line', cellClass: params => (isSubtotalRow(params.data) ? '' : 'editable-cell-line'),
cellClassRules: { cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === '' 'editable-cell-empty': params => params.value == null || params.value === ''
} }
@ -157,7 +254,7 @@ const columnDefs: ColDef<FeeRow>[] = [
field: 'unit', field: 'unit',
minWidth: 90, minWidth: 90,
flex: 0.9, flex: 0.9,
editable: true, editable: params => !isSubtotalRow(params.data),
valueFormatter: formatEditableText, valueFormatter: formatEditableText,
cellClass: 'editable-cell-line', cellClass: 'editable-cell-line',
cellClassRules: { cellClassRules: {
@ -171,7 +268,7 @@ const columnDefs: ColDef<FeeRow>[] = [
flex: 1, flex: 1,
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell editable-cell-line', cellClass: 'ag-right-aligned-cell editable-cell-line',
editable: true, editable: params => !isSubtotalRow(params.data),
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }), valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: formatEditableQuantity, valueFormatter: formatEditableQuantity,
cellClassRules: { cellClassRules: {
@ -185,7 +282,7 @@ const columnDefs: ColDef<FeeRow>[] = [
flex: 1.1, flex: 1.1,
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell editable-cell-line', cellClass: 'ag-right-aligned-cell editable-cell-line',
editable: true, editable: params => !isSubtotalRow(params.data),
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }), valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: formatEditableUnitPrice, valueFormatter: formatEditableUnitPrice,
cellClassRules: { cellClassRules: {
@ -207,7 +304,7 @@ const columnDefs: ColDef<FeeRow>[] = [
field: 'remark', field: 'remark',
minWidth: 170, minWidth: 170,
flex: 2, flex: 2,
editable: true, editable: params => !isSubtotalRow(params.data),
cellEditor: 'agLargeTextCellEditor', cellEditor: 'agLargeTextCellEditor',
wrapText: true, wrapText: true,
autoHeight: true, autoHeight: true,
@ -217,6 +314,47 @@ const columnDefs: ColDef<FeeRow>[] = [
cellClassRules: { cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === '' 'editable-cell-empty': params => params.value == null || params.value === ''
} }
},
{
headerName: '操作',
field: 'actions',
minWidth: 92,
maxWidth: 110,
flex: 0.8,
editable: false,
sortable: false,
filter: false,
suppressMovable: true,
cellRenderer: defineComponent({
name: 'HtFeeGridActionCellRenderer',
props: {
params: {
type: Object as PropType<ICellRendererParams<FeeRow>>,
required: true
}
},
setup(rendererProps) {
return () => {
const row = rendererProps.params.data
if (!row || isSubtotalRow(row)) return null
const onDelete = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
requestDeleteRow(row.id, row.feeItem)
}
return h(
'button',
{
type: 'button',
class:
'inline-flex cursor-pointer items-center gap-1 rounded border border-red-200 px-2 py-1 text-xs text-red-600 hover:bg-red-50',
onClick: onDelete
},
[h(Trash2, { size: 12, 'aria-hidden': 'true' }), h('span', '删除')]
)
}
}
})
} }
] ]
@ -233,7 +371,7 @@ const handleGridReady = (event: GridReadyEvent<FeeRow>) => {
const handleCellValueChanged = () => { const handleCellValueChanged = () => {
syncComputedValuesToRows() syncComputedValuesToRows()
gridApi.value?.refreshCells({ columns: ['budgetFee'], force: true }) gridApi.value?.refreshCells({ force: true })
if (gridPersistTimer) clearTimeout(gridPersistTimer) if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridPersistTimer = setTimeout(() => { gridPersistTimer = setTimeout(() => {
void saveToIndexedDB() void saveToIndexedDB()
@ -267,6 +405,7 @@ onBeforeUnmount(() => {
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col"> <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"> <div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">{{ title }}</h3> <h3 class="text-sm font-semibold text-foreground">{{ title }}</h3>
<Button type="button" variant="outline" size="sm" @click="addRow">添加行</Button>
</div> </div>
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1"> <div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
@ -292,4 +431,24 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
</div> </div>
<AlertDialogRoot :open="deleteConfirmOpen" @update:open="handleDeleteConfirmOpenChange">
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent class="fixed left-1/2 top-1/2 z-[70] 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">
将删除{{ pendingDeleteRowName }}这条明细是否继续
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child>
<Button variant="outline">取消</Button>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button variant="destructive" @click="confirmDeleteRow">确认删除</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</template> </template>

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, defineComponent, h, onActivated, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue' import { computed, defineComponent, h, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue'
import { AgGridVue } from 'ag-grid-vue3' import { AgGridVue } from 'ag-grid-vue3'
import type { CellValueChangedEvent, ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community' import type { CellValueChangedEvent, ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community'
import localforage from 'localforage' import localforage from 'localforage'
@ -10,6 +10,17 @@ import { formatThousandsFlexible } from '@/lib/numberFormat'
import { Pencil, Eraser } from 'lucide-vue-next' import { Pencil, Eraser } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useTabStore } from '@/pinia/tab' import { useTabStore } from '@/pinia/tab'
import { useHtFeeMethodReloadStore } from '@/pinia/htFeeMethodReload'
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle
} from 'reka-ui'
interface FeeMethodRow { interface FeeMethodRow {
id: string id: string
@ -25,6 +36,42 @@ interface FeeMethodState {
detailRows: FeeMethodRow[] detailRows: FeeMethodRow[]
} }
interface MethodRateState {
rate?: unknown
budgetFee?: unknown
}
interface MethodHourlyRowLike {
adoptedBudgetUnitPrice?: unknown
personnelCount?: unknown
workdayCount?: unknown
serviceBudget?: unknown
}
interface MethodHourlyState {
detailRows?: MethodHourlyRowLike[]
}
interface MethodQuantityRowLike {
id?: unknown
budgetFee?: unknown
quantity?: unknown
unitPrice?: unknown
}
interface MethodQuantityState {
detailRows?: MethodQuantityRowLike[]
}
interface ZxFwRowLike {
id?: unknown
subtotal?: unknown
}
interface ZxFwStateLike {
detailRows?: ZxFwRowLike[]
}
interface LegacyFeeRow { interface LegacyFeeRow {
id?: string id?: string
feeItem?: string feeItem?: string
@ -36,10 +83,12 @@ interface LegacyFeeRow {
const props = defineProps<{ const props = defineProps<{
title: string title: string
storageKey: string storageKey: string
readonly?: boolean contractId?: string
contractName?: string
fixedNames?: string[] fixedNames?: string[]
}>() }>()
const tabStore = useTabStore() const tabStore = useTabStore()
const htFeeMethodReloadStore = useHtFeeMethodReloadStore()
const createRowId = () => `fee-method-${Date.now()}-${Math.random().toString(16).slice(2, 8)}` const createRowId = () => `fee-method-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
const createDefaultRow = (name = ''): FeeMethodRow => ({ const createDefaultRow = (name = ''): FeeMethodRow => ({
@ -56,8 +105,115 @@ const toFinite = (value: number | null | undefined) =>
const round3 = (value: number) => Number(value.toFixed(3)) const round3 = (value: number) => Number(value.toFixed(3))
const getRowSubtotal = (row: FeeMethodRow | null | undefined) => const getRowSubtotal = (row: FeeMethodRow | null | undefined) =>
row ? round3(toFinite(row.rateFee) + toFinite(row.hourlyFee) + toFinite(row.quantityUnitPriceFee)) : null row ? round3(toFinite(row.rateFee) + toFinite(row.hourlyFee) + toFinite(row.quantityUnitPriceFee)) : null
const toFiniteUnknown = (value: unknown): number | null => {
const numeric = Number(value)
return Number.isFinite(numeric) ? numeric : null
}
const buildMethodStorageKey = (
rowId: string,
method: 'rate-fee' | 'hourly-fee' | 'quantity-unit-price-fee'
) => `${props.storageKey}-${rowId}-${method}`
const loadContractServiceFeeBase = async (): Promise<number | null> => {
const contractId = String(props.contractId || '').trim()
if (!contractId) return null
try {
const data = await localforage.getItem<ZxFwStateLike>(`zxFW-${contractId}`)
const rows = Array.isArray(data?.detailRows) ? data.detailRows : []
const fixedRow = rows.find(row => String(row?.id || '') === 'fixed-budget-c')
const fixedSubtotal = toFiniteUnknown(fixedRow?.subtotal)
if (fixedSubtotal != null) return round3(fixedSubtotal)
const sum = rows.reduce((acc, row) => {
if (String(row?.id || '') === 'fixed-budget-c') return acc
const subtotal = toFiniteUnknown(row?.subtotal)
return subtotal == null ? acc : acc + subtotal
}, 0)
return round3(sum)
} catch (error) {
console.error('loadContractServiceFeeBase failed:', error)
return null
}
}
const sumHourlyMethodFee = (state: MethodHourlyState | null): number | null => {
const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
if (rows.length === 0) return null
let total = 0
for (const row of rows) {
const rowBudget = toFiniteUnknown(row?.serviceBudget)
if (rowBudget != null) {
total += rowBudget
continue
}
const adopted = toFiniteUnknown(row?.adoptedBudgetUnitPrice)
const personnel = toFiniteUnknown(row?.personnelCount)
const workday = toFiniteUnknown(row?.workdayCount)
if (adopted == null || personnel == null || workday == null) continue
total += adopted * personnel * workday
}
return round3(total)
}
const sumQuantityMethodFee = (state: MethodQuantityState | null): number | null => {
const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
if (rows.length === 0) return null
const subtotalRow = rows.find(row => String(row?.id || '') === 'fee-subtotal-fixed')
const subtotalBudget = toFiniteUnknown(subtotalRow?.budgetFee)
if (subtotalBudget != null) return round3(subtotalBudget)
let total = 0
for (const row of rows) {
if (String(row?.id || '') === 'fee-subtotal-fixed') continue
const budget = toFiniteUnknown(row?.budgetFee)
if (budget != null) {
total += budget
continue
}
const quantity = toFiniteUnknown(row?.quantity)
const unitPrice = toFiniteUnknown(row?.unitPrice)
if (quantity == null || unitPrice == null) continue
total += quantity * unitPrice
}
return round3(total)
}
const hydrateRowsFromMethodStores = async (rows: FeeMethodRow[]): Promise<FeeMethodRow[]> => {
if (!Array.isArray(rows) || rows.length === 0) return rows
const contractBase = await loadContractServiceFeeBase()
const hydratedRows = await Promise.all(
rows.map(async row => {
if (!row?.id) return row
const [rateData, hourlyData, quantityData] = await Promise.all([
localforage.getItem<MethodRateState>(buildMethodStorageKey(row.id, 'rate-fee')),
localforage.getItem<MethodHourlyState>(buildMethodStorageKey(row.id, 'hourly-fee')),
localforage.getItem<MethodQuantityState>(buildMethodStorageKey(row.id, 'quantity-unit-price-fee'))
])
const storedRateFee = toFiniteUnknown(rateData?.budgetFee)
const rateValue = toFiniteUnknown(rateData?.rate)
const rateFee =
storedRateFee != null
? round3(storedRateFee)
: contractBase != null && rateValue != null
? round3(contractBase * rateValue)
: null
const hourlyFee = sumHourlyMethodFee(hourlyData)
const quantityUnitPriceFee = sumQuantityMethodFee(quantityData)
return {
...row,
rateFee,
hourlyFee,
quantityUnitPriceFee
}
})
)
return hydratedRows
}
const isReadonly = computed(() => props.readonly === true)
const fixedNames = computed(() => const fixedNames = computed(() =>
Array.isArray(props.fixedNames) Array.isArray(props.fixedNames)
? props.fixedNames.map(item => String(item || '').trim()).filter(Boolean) ? props.fixedNames.map(item => String(item || '').trim()).filter(Boolean)
@ -91,19 +247,41 @@ const summaryRow = computed<FeeMethodRow>(() => {
}) })
const displayRows = computed<FeeMethodRow[]>(() => [...detailRows.value, summaryRow.value]) const displayRows = computed<FeeMethodRow[]>(() => [...detailRows.value, summaryRow.value])
const gridApi = ref<GridApi<FeeMethodRow> | null>(null) const gridApi = ref<GridApi<FeeMethodRow> | null>(null)
const clearConfirmOpen = ref(false)
const pendingClearRowId = ref<string | null>(null)
const pendingClearRowName = ref('')
const requestClearRow = (id: string, name?: string) => {
pendingClearRowId.value = id
pendingClearRowName.value = String(name || '').trim() || '当前行'
clearConfirmOpen.value = true
}
const handleClearConfirmOpenChange = (open: boolean) => {
clearConfirmOpen.value = open
}
const confirmClearRow = async () => {
const id = pendingClearRowId.value
if (!id) return
await clearRow(id)
clearConfirmOpen.value = false
pendingClearRowId.value = null
pendingClearRowName.value = ''
}
const formatEditableText = (params: any) => { const formatEditableText = (params: any) => {
if (params.value == null || params.value === '') { if (params.value == null || params.value === '') {
if (params.context?.readonly === true || isSummaryRow(params.data)) return '' if (isSummaryRow(params.data)) return ''
return '点击输入' return ''
} }
return String(params.value) return String(params.value)
} }
const formatEditableNumber = (params: any) => { const formatEditableNumber = (params: any) => {
if (params.value == null || params.value === '') { if (params.value == null || params.value === '') {
if (params.context?.readonly === true || isSummaryRow(params.data)) return '' if (isSummaryRow(params.data)) return ''
return '点击输入' return ''
} }
return formatThousandsFlexible(params.value, 3) return formatThousandsFlexible(params.value, 3)
} }
@ -175,22 +353,33 @@ const saveToIndexedDB = async () => {
const loadFromIndexedDB = async () => { const loadFromIndexedDB = async () => {
try { try {
const data = await localforage.getItem<FeeMethodState>(props.storageKey) const data = await localforage.getItem<FeeMethodState>(props.storageKey)
detailRows.value = mergeWithStoredRows(data?.detailRows)
const mergedRows = mergeWithStoredRows(data?.detailRows)
detailRows.value = await hydrateRowsFromMethodStores(mergedRows)
await saveToIndexedDB()
} catch (error) { } catch (error) {
console.error('loadFromIndexedDB failed:', error) console.error('loadFromIndexedDB failed:', error)
detailRows.value = mergeWithStoredRows([]) const mergedRows = mergeWithStoredRows([])
detailRows.value = await hydrateRowsFromMethodStores(mergedRows)
await saveToIndexedDB()
} }
} }
const addRow = () => { const addRow = () => {
if (isReadonly.value) return
detailRows.value = [...detailRows.value, createDefaultRow()] detailRows.value = [...detailRows.value, createDefaultRow()]
void saveToIndexedDB() void saveToIndexedDB()
} }
const clearRow = (id: string) => { const clearRow = async (id: string) => {
if (isReadonly.value) return tabStore.removeTab(`ht-fee-edit-${props.storageKey}-${id}`)
await nextTick()
await Promise.all([
localforage.removeItem(buildMethodStorageKey(id, 'rate-fee')),
localforage.removeItem(buildMethodStorageKey(id, 'hourly-fee')),
localforage.removeItem(buildMethodStorageKey(id, 'quantity-unit-price-fee'))
])
detailRows.value = detailRows.value.map(row => detailRows.value = detailRows.value.map(row =>
row.id !== id row.id !== id
? row ? row
@ -201,11 +390,10 @@ const clearRow = (id: string) => {
quantityUnitPriceFee: null quantityUnitPriceFee: null
} }
) )
void saveToIndexedDB() await saveToIndexedDB()
} }
const editRow = (id: string) => { const editRow = (id: string) => {
if (isReadonly.value) return
const row = detailRows.value.find(item => item.id === id) const row = detailRows.value.find(item => item.id === id)
if (!row) return if (!row) return
tabStore.openTab({ tabStore.openTab({
@ -216,7 +404,9 @@ const editRow = (id: string) => {
sourceTitle: props.title, sourceTitle: props.title,
storageKey: props.storageKey, storageKey: props.storageKey,
rowId: id, rowId: id,
rowName: row.name || '' rowName: row.name || '',
contractId: props.contractId,
contractName: props.contractName
} }
}) })
} }
@ -232,26 +422,36 @@ const ActionCellRenderer = defineComponent({
setup(props) { setup(props) {
return () => { return () => {
if (isSummaryRow(props.params.data as FeeMethodRow | undefined)) return null if (isSummaryRow(props.params.data as FeeMethodRow | undefined)) return null
const disabled = props.params.context?.readonly === true const onActionClick = (action: 'edit' | 'clear') => (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
const rowId = props.params.data?.id
if (!rowId) return
if (action === 'edit') {
props.params.context?.onActionEdit?.(rowId)
return
}
void props.params.context?.onActionRequestClear?.(rowId, String(props.params.data?.name || ''))
}
return h('div', { class: 'zxfw-action-wrap' }, [ return h('div', { class: 'zxfw-action-wrap' }, [
h('div', { class: 'zxfw-action-group' }, [ h('div', { class: 'zxfw-action-group' }, [
h('button', { h('button', {
class: ['zxfw-action-btn', disabled ? 'zxfw-action-btn--disabled' : ''], class: 'zxfw-action-btn',
'data-action': 'edit', 'data-action': 'edit',
type: 'button', type: 'button',
disabled onClick: onActionClick('edit')
}, [ }, [
h(Pencil, { size: 13, 'aria-hidden': 'true' }), h(Pencil, { size: 13, 'aria-hidden': 'true' }),
h('span', '编辑') h('span', '编辑')
]), ]),
h('button', { h('button', {
class: ['zxfw-action-btn', disabled ? 'zxfw-action-btn--disabled' : ''], class: 'zxfw-action-btn zxfw-action-btn--danger',
'data-action': 'clear', 'data-action': 'clear',
type: 'button', type: 'button',
disabled onClick: onActionClick('clear')
}, [ }, [
h(Eraser, { size: 13, 'aria-hidden': 'true' }), h(Eraser, { size: 13, 'aria-hidden': 'true' }),
h('span', '恢复默认') h('span', '清空')
]) ])
]) ])
]) ])
@ -265,11 +465,10 @@ const columnDefs: ColDef<FeeMethodRow>[] = [
field: 'name', field: 'name',
minWidth: 180, minWidth: 180,
flex: 1.8, flex: 1.8,
editable: params => editable: false,
!(params.context?.readonly === true || params.context?.fixedNames === true || isSummaryRow(params.data)),
valueFormatter: formatEditableText, valueFormatter: formatEditableText,
cellClass: params => cellClass: params =>
params.context?.readonly === true || params.context?.fixedNames === true || isSummaryRow(params.data) params.context?.fixedNames === true || isSummaryRow(params.data)
? '' ? ''
: 'editable-cell-line', : 'editable-cell-line',
cellClassRules: { cellClassRules: {
@ -281,12 +480,9 @@ const columnDefs: ColDef<FeeMethodRow>[] = [
field: 'rateFee', field: 'rateFee',
minWidth: 130, minWidth: 130,
flex: 1.2, flex: 1.2,
editable: params => params.context?.readonly !== true && !isSummaryRow(params.data), editable: false,
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
cellClass: params => cellClass: 'ag-right-aligned-cell',
params.context?.readonly === true
? 'ag-right-aligned-cell'
: 'ag-right-aligned-cell editable-cell-line',
valueParser: params => numericParser(params.newValue), valueParser: params => numericParser(params.newValue),
valueFormatter: formatEditableNumber, valueFormatter: formatEditableNumber,
cellClassRules: { cellClassRules: {
@ -298,12 +494,9 @@ const columnDefs: ColDef<FeeMethodRow>[] = [
field: 'hourlyFee', field: 'hourlyFee',
minWidth: 130, minWidth: 130,
flex: 1.2, flex: 1.2,
editable: params => params.context?.readonly !== true && !isSummaryRow(params.data), editable: false,
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
cellClass: params => cellClass: 'ag-right-aligned-cell',
params.context?.readonly === true
? 'ag-right-aligned-cell'
: 'ag-right-aligned-cell editable-cell-line',
valueParser: params => numericParser(params.newValue), valueParser: params => numericParser(params.newValue),
valueFormatter: formatEditableNumber, valueFormatter: formatEditableNumber,
cellClassRules: { cellClassRules: {
@ -315,12 +508,9 @@ const columnDefs: ColDef<FeeMethodRow>[] = [
field: 'quantityUnitPriceFee', field: 'quantityUnitPriceFee',
minWidth: 130, minWidth: 130,
flex: 1.2, flex: 1.2,
editable: params => params.context?.readonly !== true && !isSummaryRow(params.data), editable: false,
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
cellClass: params => cellClass: 'ag-right-aligned-cell',
params.context?.readonly === true
? 'ag-right-aligned-cell'
: 'ag-right-aligned-cell editable-cell-line',
valueParser: params => numericParser(params.newValue), valueParser: params => numericParser(params.newValue),
valueFormatter: formatEditableNumber, valueFormatter: formatEditableNumber,
cellClassRules: { cellClassRules: {
@ -357,12 +547,13 @@ const detailGridOptions: GridOptions<FeeMethodRow> = {
treeData: false, treeData: false,
getDataPath: undefined, getDataPath: undefined,
context: { context: {
readonly: isReadonly.value, fixedNames: hasFixedNames.value,
fixedNames: hasFixedNames.value onActionEdit: editRow,
onActionClear: clearRow,
onActionRequestClear: requestClearRow
}, },
onCellClicked: params => { onCellClicked: params => {
if (params.colDef.field !== 'actions' || !params.data || isSummaryRow(params.data)) return if (params.colDef.field !== 'actions' || !params.data || isSummaryRow(params.data)) return
if (params.context?.readonly === true) return
const target = params.event?.target as HTMLElement | null const target = params.event?.target as HTMLElement | null
const btn = target?.closest('button[data-action]') as HTMLButtonElement | null const btn = target?.closest('button[data-action]') as HTMLButtonElement | null
const action = btn?.dataset.action const action = btn?.dataset.action
@ -371,7 +562,7 @@ const detailGridOptions: GridOptions<FeeMethodRow> = {
return return
} }
if (action === 'clear') { if (action === 'clear') {
clearRow(params.data.id) requestClearRow(params.data.id, params.data.name)
return return
} }
} }
@ -384,7 +575,6 @@ const handleGridReady = (event: GridReadyEvent<FeeMethodRow>) => {
} }
const handleCellValueChanged = (event: CellValueChangedEvent<FeeMethodRow>) => { const handleCellValueChanged = (event: CellValueChangedEvent<FeeMethodRow>) => {
if (isReadonly.value) return
if (isSummaryRow(event.data)) return if (isSummaryRow(event.data)) return
if (gridPersistTimer) clearTimeout(gridPersistTimer) if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridPersistTimer = setTimeout(() => { gridPersistTimer = setTimeout(() => {
@ -405,10 +595,23 @@ watch(storageKeyRef, () => {
void loadFromIndexedDB() void loadFromIndexedDB()
}) })
watch([isReadonly, hasFixedNames], () => { watch(
() => htFeeMethodReloadStore.seq,
(nextVersion, prevVersion) => {
if (nextVersion === prevVersion || nextVersion === 0) return
const detail = htFeeMethodReloadStore.lastEvent
if (!detail) return
if (String(detail.mainStorageKey || '').trim() !== String(props.storageKey || '').trim()) return
void loadFromIndexedDB()
}
)
watch([hasFixedNames], () => {
if (!detailGridOptions.context) return if (!detailGridOptions.context) return
detailGridOptions.context.readonly = isReadonly.value
detailGridOptions.context.fixedNames = hasFixedNames.value detailGridOptions.context.fixedNames = hasFixedNames.value
detailGridOptions.context.onActionEdit = editRow
detailGridOptions.context.onActionClear = clearRow
detailGridOptions.context.onActionRequestClear = requestClearRow
gridApi.value?.refreshCells({ force: true }) gridApi.value?.refreshCells({ force: true })
}) })
@ -424,7 +627,7 @@ onBeforeUnmount(() => {
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col"> <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"> <div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">{{ title }}</h3> <h3 class="text-sm font-semibold text-foreground">{{ title }}</h3>
<Button v-if="!isReadonly" type="button" variant="outline" size="sm" @click="addRow">新增</Button> <Button v-if="!hasFixedNames" type="button" variant="outline" size="sm" @click="addRow">新增</Button>
</div> </div>
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1"> <div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
@ -450,11 +653,24 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
</div> </div>
</template>
<style scoped> <AlertDialogRoot :open="clearConfirmOpen" @update:open="handleClearConfirmOpenChange">
.zxfw-action-btn--disabled { <AlertDialogPortal>
opacity: 0.45; <AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
cursor: not-allowed; <AlertDialogContent class="fixed left-1/2 top-1/2 z-[70] 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>
</style> <AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
将清空{{ pendingClearRowName }}及其编辑页面的可填和自动计算数据是否继续
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child>
<Button variant="outline">取消</Button>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button variant="destructive" @click="confirmClearRow">确认清空</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</template>

View File

@ -5,6 +5,7 @@ import { additionalWorkList } from '@/sql'
const props = defineProps<{ const props = defineProps<{
contractId: string contractId: string
contractName?: string
}>() }>()
const STORAGE_KEY = computed(() => `htExtraFee-${props.contractId}-additional-work`) const STORAGE_KEY = computed(() => `htExtraFee-${props.contractId}-additional-work`)
@ -15,7 +16,8 @@ const additionalWorkNames = computed(() => additionalWorkList.map(item => String
<HtFeeMethodGrid <HtFeeMethodGrid
title="附加工作费" title="附加工作费"
:storageKey="STORAGE_KEY" :storageKey="STORAGE_KEY"
:readonly="true" :contract-id="props.contractId"
:contract-name="props.contractName"
:fixed-names="additionalWorkNames" :fixed-names="additionalWorkNames"
/> />
</template> </template>

View File

@ -1,9 +1,9 @@
<template> <template>
<TypeLine <TypeLine
scene="ht-fee-method-type-line" scene="ht-fee-method-type-line"
:title="`${sourceTitleText}${rowNameText}`" :title="titleText"
:subtitle="`明细ID${props.rowId}`" :subtitle="`合同ID${contractIdText}`"
:copy-text="props.rowId" :copy-text="contractIdText"
:storage-key="activeTypeStorageKey" :storage-key="activeTypeStorageKey"
default-category="rate-fee" default-category="rate-fee"
:categories="categories" :categories="categories"
@ -13,6 +13,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, defineComponent, h, markRaw, type Component } from 'vue' import { computed, defineComponent, h, markRaw, type Component } from 'vue'
import TypeLine from '@/layout/typeLine.vue' import TypeLine from '@/layout/typeLine.vue'
import HtFeeGrid from '@/components/common/HtFeeGrid.vue'
import HtFeeRateMethodForm from '@/components/views/HtFeeRateMethodForm.vue'
import HourlyFeeGrid from '@/components/common/HourlyFeeGrid.vue'
interface TypeLineCategoryItem { interface TypeLineCategoryItem {
key: string key: string
@ -25,31 +28,70 @@ const props = defineProps<{
storageKey: string storageKey: string
rowId: string rowId: string
rowName?: string rowName?: string
contractId?: string
contractName?: string
}>() }>()
const sourceTitleText = computed(() => props.sourceTitle || '费用明细') const sourceTitleText = computed(() => props.sourceTitle || '费用明细')
const rowNameText = computed(() => props.rowName || '未命名') const rowNameText = computed(() => props.rowName || '未命名')
const contractIdText = computed(() => String(props.contractId || '').trim())
const contractNameText = computed(() => String(props.contractName || '').trim() || contractIdText.value || '-')
const titleText = computed(() => `合同段:${contractNameText.value} · ${rowNameText.value || sourceTitleText.value}`)
const activeTypeStorageKey = computed(() => `ht-fee-type-active-cat-${props.storageKey}-${props.rowId}`) const activeTypeStorageKey = computed(() => `ht-fee-type-active-cat-${props.storageKey}-${props.rowId}`)
const buildMethodStorageKey = (method: 'rate-fee' | 'hourly-fee' | 'quantity-unit-price-fee') =>
`${props.storageKey}-${props.rowId}-${method}`
const createMethodPane = (methodLabel: string, key: string) => const quantityUnitPricePane = markRaw(
markRaw(
defineComponent({ defineComponent({
name: `HtFeeMethodTypePane-${key}`, name: 'HtFeeGrid',
setup() { setup() {
const quantityStorageKey = computed(() => buildMethodStorageKey('quantity-unit-price-fee'))
return () => return () =>
h('div', { class: 'h-full min-h-0 flex flex-col' }, [ h(HtFeeGrid, {
h('div', { class: 'rounded-lg border bg-card p-4' }, [ title: '数量单价',
h('h3', { class: 'text-sm font-semibold text-foreground' }, methodLabel), storageKey: quantityStorageKey.value,
h('p', { class: 'mt-2 text-xs text-muted-foreground' }, `当前编辑类型:${methodLabel}`) syncMainStorageKey: props.storageKey,
]) syncRowId: props.rowId
]) })
}
})
)
const rateFeePane = markRaw(
defineComponent({
name: 'HtFeeMethodTypePane-rate-fee',
setup() {
const rateStorageKey = computed(() => buildMethodStorageKey('rate-fee'))
return () =>
h(HtFeeRateMethodForm, {
storageKey: rateStorageKey.value,
contractId: props.contractId,
syncMainStorageKey: props.storageKey,
syncRowId: props.rowId
})
}
})
)
const hourlyFeePane = markRaw(
defineComponent({
name: 'HtFeeMethodTypePane-hourly-fee',
setup() {
const hourlyStorageKey = computed(() => buildMethodStorageKey('hourly-fee'))
return () =>
h(HourlyFeeGrid, {
title: '工时法明细',
storageKey: hourlyStorageKey.value,
syncMainStorageKey: props.storageKey,
syncRowId: props.rowId
})
} }
}) })
) )
const categories: TypeLineCategoryItem[] = [ const categories: TypeLineCategoryItem[] = [
{ key: 'rate-fee', label: '费率计取', component: createMethodPane('费率计取', 'rate-fee') }, { key: 'rate-fee', label: '费率计取', component: rateFeePane },
{ key: 'hourly-fee', label: '工时法', component: createMethodPane('工时法', 'hourly-fee') }, { key: 'hourly-fee', label: '工时法', component: hourlyFeePane },
{ key: 'quantity-unit-price-fee', label: '数量单价', component: createMethodPane('数量单价', 'quantity-unit-price-fee') } { key: 'quantity-unit-price-fee', label: '数量单价', component: quantityUnitPricePane }
] ]
</script> </script>

View File

@ -0,0 +1,223 @@
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import localforage from 'localforage'
import { parseNumberOrNull } from '@/lib/number'
import { formatThousandsFlexible } from '@/lib/numberFormat'
import { ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
import { matchPricingPaneReload, usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import { useHtFeeMethodReloadStore } from '@/pinia/htFeeMethodReload'
interface ZxFwRowLike {
id?: unknown
subtotal?: unknown
}
interface ZxFwStateLike {
detailRows?: ZxFwRowLike[]
}
interface RateMethodState {
rate: number | null
budgetFee?: number | null
remark: string
}
const props = defineProps<{
storageKey: string
contractId?: string
syncMainStorageKey?: string
syncRowId?: string
}>()
const pricingPaneReloadStore = usePricingPaneReloadStore()
const htFeeMethodReloadStore = useHtFeeMethodReloadStore()
const base = ref<number | null>(null)
const rate = ref<number | null>(null)
const remark = ref('')
const rateInput = ref('')
const toFinite = (value: unknown): number | null => {
const numeric = Number(value)
return Number.isFinite(numeric) ? numeric : null
}
const round3 = (value: number) => Number(value.toFixed(3))
const budgetFee = computed<number | null>(() => {
if (base.value == null || rate.value == null) return null
return round3(base.value * rate.value)
})
const formatAmount = (value: number | null) =>
value == null ? '' : formatThousandsFlexible(value, 3)
const loadBase = async () => {
const contractId = String(props.contractId || '').trim()
if (!contractId) {
base.value = null
return
}
try {
const data = await localforage.getItem<ZxFwStateLike>(`zxFW-${contractId}`)
const rows = Array.isArray(data?.detailRows) ? data.detailRows : []
const fixedRow = rows.find(row => String(row?.id || '') === 'fixed-budget-c')
const fixedSubtotal = toFinite(fixedRow?.subtotal)
if (fixedSubtotal != null) {
base.value = round3(fixedSubtotal)
return
}
const sum = rows.reduce((acc, row) => {
if (String(row?.id || '') === 'fixed-budget-c') return acc
const subtotal = toFinite(row?.subtotal)
return subtotal == null ? acc : acc + subtotal
}, 0)
base.value = round3(sum)
} catch (error) {
console.error('load rate base failed:', error)
base.value = null
}
}
const loadForm = async () => {
try {
const data = await localforage.getItem<RateMethodState>(props.storageKey)
rate.value = typeof data?.rate === 'number' ? data.rate : null
remark.value = typeof data?.remark === 'string' ? data.remark : ''
rateInput.value = rate.value == null ? '' : String(rate.value)
} catch (error) {
console.error('load rate form failed:', error)
rate.value = null
remark.value = ''
rateInput.value = ''
}
}
const saveForm = async () => {
try {
await localforage.setItem<RateMethodState>(props.storageKey, {
rate: rate.value,
budgetFee: budgetFee.value,
remark: remark.value
})
if (props.syncMainStorageKey && props.syncRowId) {
htFeeMethodReloadStore.emit(props.syncMainStorageKey, props.syncRowId)
}
} catch (error) {
console.error('save rate form failed:', error)
}
}
const applyRateInput = () => {
const next = parseNumberOrNull(rateInput.value, { sanitize: true, precision: 3 })
rate.value = next
rateInput.value = next == null ? '' : String(next)
}
let saveTimer: ReturnType<typeof setTimeout> | null = null
let basePollTimer: ReturnType<typeof setInterval> | null = null
watch([rate, remark, budgetFee], () => {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
void saveForm()
}, 250)
})
watch(
() => props.storageKey,
() => {
void loadForm()
}
)
watch(
() => pricingPaneReloadStore.seq,
(nextVersion, prevVersion) => {
if (nextVersion === prevVersion || nextVersion === 0) return
const contractId = String(props.contractId || '').trim()
if (!contractId) return
if (!matchPricingPaneReload(pricingPaneReloadStore.lastEvent, contractId, ZXFW_RELOAD_SERVICE_KEY)) return
void loadBase()
}
)
onMounted(async () => {
await Promise.all([loadBase(), loadForm()])
if (!basePollTimer) {
basePollTimer = setInterval(() => {
void loadBase()
}, 1000)
}
})
onActivated(async () => {
await Promise.all([loadBase(), loadForm()])
if (!basePollTimer) {
basePollTimer = setInterval(() => {
void loadBase()
}, 1000)
}
})
onBeforeUnmount(() => {
if (saveTimer) clearTimeout(saveTimer)
if (basePollTimer) {
clearInterval(basePollTimer)
basePollTimer = null
}
void saveForm()
})
</script>
<template>
<div class="h-full min-h-0 flex flex-col">
<div class="rounded-lg border bg-card p-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<label class="space-y-1.5">
<div class="text-xs text-muted-foreground">基数所有服务费预算合计</div>
<input
type="text"
:value="formatAmount(base)"
readonly
disabled
tabindex="-1"
class="h-9 w-full cursor-not-allowed rounded-md border bg-muted/40 px-3 text-sm text-foreground select-none"
/>
</label>
<label class="space-y-1.5">
<div class="text-xs text-muted-foreground">费率可编辑三位小数</div>
<input
v-model="rateInput"
type="text"
inputmode="decimal"
placeholder="请输入费率建议0.01 ~ 0.05"
class="h-9 w-full rounded-md border bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/30"
@blur="applyRateInput"
/>
</label>
<label class="space-y-1.5">
<div class="text-xs text-muted-foreground">预算费用自动计算</div>
<input
type="text"
:value="formatAmount(budgetFee)"
readonly
disabled
tabindex="-1"
class="h-9 w-full cursor-not-allowed rounded-md border bg-muted/40 px-3 text-sm text-foreground select-none"
/>
</label>
<label class="space-y-1.5 md:col-span-2">
<div class="text-xs text-muted-foreground">说明</div>
<textarea
v-model="remark"
rows="4"
placeholder="请输入说明"
class="w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/30"
/>
</label>
</div>
</div>
</div>
</template>

View File

@ -10,5 +10,5 @@ const STORAGE_KEY = computed(() => `htExtraFee-${props.contractId}-reserve`)
</script> </script>
<template> <template>
<HtFeeMethodGrid title="预备费" :storageKey="STORAGE_KEY" /> <HtFeeMethodGrid title="预备费" :storageKey="STORAGE_KEY" :contract-id="props.contractId" />
</template> </template>

View File

@ -99,7 +99,7 @@ const additionalWorkFeeView = markRaw(
console.error('加载 HtAdditionalWorkFee 组件失败:', err); console.error('加载 HtAdditionalWorkFee 组件失败:', err);
} }
}); });
return () => h(AsyncHtAdditionalWorkFee, { contractId: props.contractId }); return () => h(AsyncHtAdditionalWorkFee, { contractId: props.contractId, contractName: props.contractName });
} }
}) })
); );

View File

@ -1,495 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed } from 'vue'
import { AgGridVue } from 'ag-grid-vue3' import HourlyFeeGrid from '@/components/common/HourlyFeeGrid.vue'
import type { ColDef, ColGroupDef } from 'ag-grid-community'
import localforage from 'localforage'
import { expertList } from '@/sql'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
import { formatThousandsFlexible } from '@/lib/numberFormat'
import { parseNumberOrNull } from '@/lib/number'
import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
interface DetailRow {
id: string
expertCode: string
expertName: string
laborBudgetUnitPrice: string
compositeBudgetUnitPrice: string
adoptedBudgetUnitPrice: number | null
personnelCount: number | null
workdayCount: number | null
serviceBudget: number | null
remark: string
path: string[]
}
interface XmInfoState {
projectName: string
detailRows: DetailRow[]
}
const props = defineProps<{ const props = defineProps<{
contractId: string, contractId: string
serviceId: string | number serviceId: string | number
}>() }>()
const DB_KEY = computed(() => `hourlyPricing-${props.contractId}-${props.serviceId}`) const DB_KEY = computed(() => `hourlyPricing-${props.contractId}-${props.serviceId}`)
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
const pricingPaneReloadStore = usePricingPaneReloadStore()
const paneInstanceCreatedAt = Date.now()
const shouldSkipPersist = () => {
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${DB_KEY.value}`
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const now = Date.now()
if (raw.includes(':')) {
const [issuedRaw, untilRaw] = raw.split(':')
const issuedAt = Number(issuedRaw)
const skipUntil = Number(untilRaw)
if (Number.isFinite(issuedAt) && Number.isFinite(skipUntil) && now <= skipUntil) {
return paneInstanceCreatedAt <= issuedAt
}
sessionStorage.removeItem(storageKey)
return false
}
const skipUntil = Number(raw)
if (Number.isFinite(skipUntil) && now <= skipUntil) return true
sessionStorage.removeItem(storageKey)
return false
}
const shouldForceDefaultLoad = () => {
const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${DB_KEY.value}`
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const forceUntil = Number(raw)
sessionStorage.removeItem(storageKey)
return Number.isFinite(forceUntil) && Date.now() <= forceUntil
}
const detailRows = ref<DetailRow[]>([])
type ExpertLite = {
code: string
name: string
maxPrice: number | null
minPrice: number | null
defPrice: number | null
manageCoe: number | null
}
const expertEntries = Object.entries(expertList as Record<string, ExpertLite>)
.sort((a, b) => Number(a[0]) - Number(b[0]))
.filter((entry): entry is [string, ExpertLite] => {
const item = entry[1]
return Boolean(item?.code && item?.name)
})
const formatPriceRange = (min: number | null, max: number | null) => {
const hasMin = typeof min === 'number' && Number.isFinite(min)
const hasMax = typeof max === 'number' && Number.isFinite(max)
if (hasMin && hasMax) return `${min}-${max}`
if (hasMin) return String(min)
if (hasMax) return String(max)
return ''
}
const getCompositeBudgetUnitPriceRange = (expert: ExpertLite) => {
if (
typeof expert.manageCoe !== 'number' ||
!Number.isFinite(expert.manageCoe)
) {
return ''
}
const min = typeof expert.minPrice === 'number' && Number.isFinite(expert.minPrice)
? roundTo(toDecimal(expert.minPrice).mul(expert.manageCoe), 2)
: null
const max = typeof expert.maxPrice === 'number' && Number.isFinite(expert.maxPrice)
? roundTo(toDecimal(expert.maxPrice).mul(expert.manageCoe), 2)
: null
return formatPriceRange(min, max)
}
const getDefaultAdoptedBudgetUnitPrice = (expert: ExpertLite) => {
if (
typeof expert.defPrice !== 'number' ||
!Number.isFinite(expert.defPrice) ||
typeof expert.manageCoe !== 'number' ||
!Number.isFinite(expert.manageCoe)
) {
return null
}
return roundTo(toDecimal(expert.defPrice).mul(expert.manageCoe), 2)
}
const buildDefaultRows = (): DetailRow[] => {
const rows: DetailRow[] = []
for (const [expertId, expert] of expertEntries) {
const rowId = `expert-${expertId}`
rows.push({
id: rowId,
expertCode: expert.code,
expertName: expert.name,
laborBudgetUnitPrice: formatPriceRange(expert.minPrice, expert.maxPrice),
compositeBudgetUnitPrice: getCompositeBudgetUnitPriceRange(expert),
adoptedBudgetUnitPrice: getDefaultAdoptedBudgetUnitPrice(expert),
personnelCount: null,
workdayCount: null,
serviceBudget: null,
remark: '',
path: [rowId]
})
}
return rows
}
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
const dbValueMap = new Map<string, DetailRow>()
for (const row of rowsFromDb || []) {
dbValueMap.set(row.id, row)
}
return buildDefaultRows().map(row => {
const fromDb = dbValueMap.get(row.id)
if (!fromDb) return row
return {
...row,
adoptedBudgetUnitPrice:
typeof fromDb.adoptedBudgetUnitPrice === 'number' ? fromDb.adoptedBudgetUnitPrice : null,
personnelCount: typeof fromDb.personnelCount === 'number' ? fromDb.personnelCount : null,
workdayCount: typeof fromDb.workdayCount === 'number' ? fromDb.workdayCount : null,
serviceBudget: typeof fromDb.serviceBudget === 'number' ? fromDb.serviceBudget : null,
remark: typeof fromDb.remark === 'string' ? fromDb.remark : ''
}
})
}
const parseNonNegativeIntegerOrNull = (value: unknown) => {
if (value === '' || value == null) return null
if (typeof value === 'number') {
return Number.isInteger(value) && value >= 0 ? value : null
}
const normalized = String(value).trim()
if (!/^\d+$/.test(normalized)) return null
const v = Number(normalized)
return Number.isSafeInteger(v) ? v : null
}
const formatEditableNumber = (params: any) => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return formatThousandsFlexible(params.value, 3)
}
const formatEditableInteger = (params: any) => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return String(Number(params.value))
}
const calcServiceBudget = (row: DetailRow | undefined) => {
const adopted = row?.adoptedBudgetUnitPrice
const personnel = row?.personnelCount
const workday = row?.workdayCount
if (
typeof adopted !== 'number' ||
!Number.isFinite(adopted) ||
typeof personnel !== 'number' ||
!Number.isFinite(personnel) ||
typeof workday !== 'number' ||
!Number.isFinite(workday)
) {
return null
}
return roundTo(toDecimal(adopted).mul(personnel).mul(workday), 2)
}
const editableNumberCol = <K extends keyof DetailRow>(
field: K,
headerName: string,
extra: Partial<ColDef<DetailRow>> = {}
): ColDef<DetailRow> => ({
headerName,
field,
minWidth: 150,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: formatEditableNumber,
...extra
})
const editableMoneyCol = <K extends keyof DetailRow>(
field: K,
headerName: string,
extra: Partial<ColDef<DetailRow>> = {}
): ColDef<DetailRow> => ({
headerName,
field,
headerClass: 'ag-right-aligned-header',
minWidth: 150,
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 =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return formatThousandsFlexible(params.value, 3)
},
...extra
})
const readonlyTextCol = <K extends keyof DetailRow>(
field: K,
headerName: string,
extra: Partial<ColDef<DetailRow>> = {}
): ColDef<DetailRow> => ({
headerName,
field,
minWidth: 170,
flex: 1,
editable: false,
valueFormatter: params => params.value || '',
...extra
})
const columnDefs: (ColDef<DetailRow> | ColGroupDef<DetailRow>)[] = [
{
headerName: '编码',
field: 'expertCode',
minWidth: 120,
width: 140,
pinned: 'left',
colSpan: params => (params.node?.rowPinned ? 2 : 1),
valueFormatter: params => (params.node?.rowPinned ? '总合计' : params.value || '')
},
{
headerName: '人员名称',
field: 'expertName',
minWidth: 200,
width: 220,
pinned: 'left',
tooltipField: 'expertName',
valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '')
},
{
headerName: '预算参考单价',
marryChildren: true,
children: [
readonlyTextCol('laborBudgetUnitPrice', '人工预算单价(元/工日)'),
readonlyTextCol('compositeBudgetUnitPrice', '综合预算单价(元/工日)')
]
},
editableMoneyCol('adoptedBudgetUnitPrice', '预算采用单价(元/工日)'),
editableNumberCol('personnelCount', '人员数量(人)', {
aggFunc: decimalAggSum,
valueParser: params => parseNonNegativeIntegerOrNull(params.newValue),
valueFormatter: formatEditableInteger
}),
editableNumberCol('workdayCount', '工日数量(工日)', { aggFunc: decimalAggSum }),
{
headerName: '服务预算(元)',
field: 'serviceBudget',
headerClass: 'ag-right-aligned-header',
minWidth: 150,
flex: 1,
cellClass: 'ag-right-aligned-cell',
editable: false,
aggFunc: decimalAggSum,
valueGetter: params => (params.node?.rowPinned ? params.data?.serviceBudget ?? null : calcServiceBudget(params.data)),
valueFormatter: params => {
if (params.value == null || params.value === '') return ''
return formatThousandsFlexible(params.value, 3)
}
},
{
headerName: '说明',
field: 'remark',
minWidth: 180,
flex: 1.2,
cellEditor: 'agLargeTextCellEditor',
wrapText: true,
autoHeight: true,
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
editable: params => !params.node?.group && !params.node?.rowPinned,
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && !params.value) return '点击输入'
return params.value || ''
},
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line remark-wrap-cell' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
}
}
]
const totalPersonnelCount = computed(() => sumByNumber(detailRows.value, row => row.personnelCount))
const totalWorkdayCount = computed(() => sumByNumber(detailRows.value, row => row.workdayCount))
const totalServiceBudget = computed(() => sumByNumber(detailRows.value, row => calcServiceBudget(row)))
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',
expertCode: '总合计',
expertName: '',
laborBudgetUnitPrice: '',
compositeBudgetUnitPrice: '',
adoptedBudgetUnitPrice: null,
personnelCount: totalPersonnelCount.value,
workdayCount: totalWorkdayCount.value,
serviceBudget: totalServiceBudget.value,
remark: '',
path: ['TOTAL']
}
])
const saveToIndexedDB = async () => {
if (shouldSkipPersist()) return
try {
const payload = {
detailRows: JSON.parse(JSON.stringify(detailRows.value))
}
console.log('Saving to IndexedDB:', payload)
await localforage.setItem(DB_KEY.value, payload)
const synced = await syncPricingTotalToZxFw({
contractId: props.contractId,
serviceId: props.serviceId,
field: 'hourly',
value: totalServiceBudget.value
})
if (synced) {
pricingPaneReloadStore.markReload(props.contractId, ZXFW_RELOAD_SERVICE_KEY)
}
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
}
const loadFromIndexedDB = async () => {
try {
if (shouldForceDefaultLoad()) {
detailRows.value = buildDefaultRows()
return
}
const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
if (data) {
detailRows.value = mergeWithDictRows(data.detailRows)
return
}
detailRows.value = buildDefaultRows()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
detailRows.value = buildDefaultRows()
}
}
watch(
() => pricingPaneReloadStore.getReloadVersion(props.contractId, props.serviceId),
(nextVersion, prevVersion) => {
if (nextVersion === prevVersion || nextVersion === 0) return
void loadFromIndexedDB()
}
)
let persistTimer: ReturnType<typeof setTimeout> | null = null
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
const handleCellValueChanged = () => {
if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridPersistTimer = setTimeout(() => {
void saveToIndexedDB()
}, 300)
}
onMounted(async () => {
await loadFromIndexedDB()
})
onBeforeUnmount(() => {
if (persistTimer) clearTimeout(persistTimer)
if (gridPersistTimer) clearTimeout(gridPersistTimer)
void saveToIndexedDB()
})
const processCellForClipboard = (params: any) => {
if (Array.isArray(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 handleGridReady = (params: any) => {
const w = window as any
if (!w.__agGridApis) w.__agGridApis = {}
w.__agGridApis = params.api
}
</script> </script>
<template> <template>
<div class="h-full min-h-0 flex flex-col"> <HourlyFeeGrid
title="工时法明细"
:storage-key="DB_KEY"
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col"> :contract-id="props.contractId"
<div class="flex items-center justify-between border-b px-4 py-3"> :service-id="props.serviceId"
<h3 class="text-sm font-semibold text-foreground">工时法明细</h3> :enable-zx-fw-sync="true"
<div class="text-xs text-muted-foreground"></div> sync-field="hourly"
</div> />
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
<AgGridVue :style="{ height: '100%' }" :rowData="detailRows" :pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs" :gridOptions="gridOptions" :theme="myTheme" :treeData="false"
@grid-ready="handleGridReady"
@cell-value-changed="handleCellValueChanged" :suppressColumnVirtualisation="true"
:suppressRowVirtualisation="true" :cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true"
:localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50"
:processCellForClipboard="processCellForClipboard" :processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true" :undoRedoCellEditingLimit="20" />
</div>
</div>
</div>
</template> </template>

View File

@ -8,7 +8,7 @@ import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { addNumbers, decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal' import { addNumbers, decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
import { formatThousandsFlexible } from '@/lib/numberFormat' import { formatThousandsFlexible } from '@/lib/numberFormat'
import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync' import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload' import { matchPricingPaneReload, usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults' import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults'
import { parseNumberOrNull } from '@/lib/number' import { parseNumberOrNull } from '@/lib/number'
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee' import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee'
@ -93,6 +93,7 @@ const props = defineProps<{
contractId: string, contractId: string,
serviceId: string | number serviceId: string | number
}>() }>()
const pricingPaneReloadStore = usePricingPaneReloadStore()
const DB_KEY = computed(() => `tzGMF-${props.contractId}-${props.serviceId}`) const DB_KEY = computed(() => `tzGMF-${props.contractId}-${props.serviceId}`)
const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`) const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${props.contractId}`) const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${props.contractId}`)
@ -101,11 +102,11 @@ const BASE_INFO_KEY = 'xm-base-info-v1'
const activeIndustryCode = ref('') const activeIndustryCode = ref('')
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:' const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:' const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
const pricingPaneReloadStore = usePricingPaneReloadStore()
const consultCategoryFactorMap = ref<Map<string, number | null>>(new Map()) const consultCategoryFactorMap = ref<Map<string, number | null>>(new Map())
const majorFactorMap = ref<Map<string, number | null>>(new Map()) const majorFactorMap = ref<Map<string, number | null>>(new Map())
let factorDefaultsLoaded = false let factorDefaultsLoaded = false
const paneInstanceCreatedAt = Date.now() const paneInstanceCreatedAt = Date.now()
const reloadSignal = ref(0)
const ONLY_COST_SCALE_ROW_ID = '__only-cost-scale-total__' const ONLY_COST_SCALE_ROW_ID = '__only-cost-scale-total__'
const industryNameMap = new Map( const industryNameMap = new Map(
industryTypeList.flatMap(item => [ industryTypeList.flatMap(item => [
@ -1026,7 +1027,7 @@ const saveToIndexedDB = async () => {
value: totalBudgetFee.value value: totalBudgetFee.value
}) })
if (synced) { if (synced) {
pricingPaneReloadStore.markReload(props.contractId, ZXFW_RELOAD_SERVICE_KEY) pricingPaneReloadStore.emit(props.contractId, ZXFW_RELOAD_SERVICE_KEY)
} }
} catch (error) { } catch (error) {
console.error('saveToIndexedDB failed:', error) console.error('saveToIndexedDB failed:', error)
@ -1224,7 +1225,16 @@ const clearAllData = async () => {
} }
watch( watch(
() => pricingPaneReloadStore.getReloadVersion(props.contractId, props.serviceId), () => pricingPaneReloadStore.seq,
(nextVersion, prevVersion) => {
if (nextVersion === prevVersion || nextVersion === 0) return
if (!matchPricingPaneReload(pricingPaneReloadStore.lastEvent, props.contractId, props.serviceId)) return
reloadSignal.value += 1
}
)
watch(
() => reloadSignal.value,
(nextVersion, prevVersion) => { (nextVersion, prevVersion) => {
if (nextVersion === prevVersion || nextVersion === 0) return if (nextVersion === prevVersion || nextVersion === 0) return
void loadFromIndexedDB() void loadFromIndexedDB()

View File

@ -8,7 +8,7 @@ import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { addNumbers, decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal' import { addNumbers, decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
import { formatThousandsFlexible } from '@/lib/numberFormat' import { formatThousandsFlexible } from '@/lib/numberFormat'
import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync' import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload' import { matchPricingPaneReload, usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults' import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults'
import { parseNumberOrNull } from '@/lib/number' import { parseNumberOrNull } from '@/lib/number'
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee' import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee'
@ -93,6 +93,7 @@ const props = defineProps<{
contractId: string, contractId: string,
serviceId: string | number serviceId: string | number
}>() }>()
const pricingPaneReloadStore = usePricingPaneReloadStore()
const DB_KEY = computed(() => `ydGMF-${props.contractId}-${props.serviceId}`) const DB_KEY = computed(() => `ydGMF-${props.contractId}-${props.serviceId}`)
const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`) const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${props.contractId}`) const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${props.contractId}`)
@ -101,11 +102,11 @@ const BASE_INFO_KEY = 'xm-base-info-v1'
const activeIndustryCode = ref('') const activeIndustryCode = ref('')
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:' const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:' const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
const pricingPaneReloadStore = usePricingPaneReloadStore()
const consultCategoryFactorMap = ref<Map<string, number | null>>(new Map()) const consultCategoryFactorMap = ref<Map<string, number | null>>(new Map())
const majorFactorMap = ref<Map<string, number | null>>(new Map()) const majorFactorMap = ref<Map<string, number | null>>(new Map())
let factorDefaultsLoaded = false let factorDefaultsLoaded = false
const paneInstanceCreatedAt = Date.now() const paneInstanceCreatedAt = Date.now()
const reloadSignal = ref(0)
const industryNameMap = new Map( const industryNameMap = new Map(
industryTypeList.flatMap(item => [ industryTypeList.flatMap(item => [
[String(item.id).trim(), item.name], [String(item.id).trim(), item.name],
@ -116,6 +117,7 @@ const totalLabel = computed(() => {
const industryName = industryNameMap.get(activeIndustryCode.value.trim()) || '' const industryName = industryNameMap.get(activeIndustryCode.value.trim()) || ''
return industryName ? `${industryName}总投资` : '总投资' return industryName ? `${industryName}总投资` : '总投资'
}) })
const isMutipleService = computed(() => { const isMutipleService = computed(() => {
const service = getServiceDictItemById(props.serviceId) as ServiceLite | undefined const service = getServiceDictItemById(props.serviceId) as ServiceLite | undefined
return service?.mutiple === true return service?.mutiple === true
@ -878,7 +880,7 @@ const saveToIndexedDB = async () => {
value: totalBudgetFee.value value: totalBudgetFee.value
}) })
if (synced) { if (synced) {
pricingPaneReloadStore.markReload(props.contractId, ZXFW_RELOAD_SERVICE_KEY) pricingPaneReloadStore.emit(props.contractId, ZXFW_RELOAD_SERVICE_KEY)
} }
} catch (error) { } catch (error) {
console.error('saveToIndexedDB failed:', error) console.error('saveToIndexedDB failed:', error)
@ -1048,7 +1050,16 @@ const clearAllData = async () => {
} }
watch( watch(
() => pricingPaneReloadStore.getReloadVersion(props.contractId, props.serviceId), () => pricingPaneReloadStore.seq,
(nextVersion, prevVersion) => {
if (nextVersion === prevVersion || nextVersion === 0) return
if (!matchPricingPaneReload(pricingPaneReloadStore.lastEvent, props.contractId, props.serviceId)) return
reloadSignal.value += 1
}
)
watch(
() => reloadSignal.value,
(nextVersion, prevVersion) => { (nextVersion, prevVersion) => {
if (nextVersion === prevVersion || nextVersion === 0) return if (nextVersion === prevVersion || nextVersion === 0) return
void loadFromIndexedDB() void loadFromIndexedDB()

View File

@ -9,7 +9,7 @@ import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat' import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat'
import { parseNumberOrNull } from '@/lib/number' import { parseNumberOrNull } from '@/lib/number'
import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync' import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload' import { matchPricingPaneReload, usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import { loadConsultCategoryFactorMap } from '@/lib/xmFactorDefaults' import { loadConsultCategoryFactorMap } from '@/lib/xmFactorDefaults'
import MethodUnavailableNotice from '@/components/common/MethodUnavailableNotice.vue' import MethodUnavailableNotice from '@/components/common/MethodUnavailableNotice.vue'
@ -42,14 +42,15 @@ const props = defineProps<{
serviceId: string | number serviceId: string | number
}>() }>()
const pricingPaneReloadStore = usePricingPaneReloadStore()
const DB_KEY = computed(() => `gzlF-${props.contractId}-${props.serviceId}`) const DB_KEY = computed(() => `gzlF-${props.contractId}-${props.serviceId}`)
const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${props.contractId}`) const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${props.contractId}`)
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:' const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:' const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
const pricingPaneReloadStore = usePricingPaneReloadStore()
const consultCategoryFactorMap = ref<Map<string, number | null>>(new Map()) const consultCategoryFactorMap = ref<Map<string, number | null>>(new Map())
let factorDefaultsLoaded = false let factorDefaultsLoaded = false
const paneInstanceCreatedAt = Date.now() const paneInstanceCreatedAt = Date.now()
const reloadSignal = ref(0)
const getDefaultConsultCategoryFactor = () => const getDefaultConsultCategoryFactor = () =>
consultCategoryFactorMap.value.get(String(props.serviceId)) ?? null consultCategoryFactorMap.value.get(String(props.serviceId)) ?? null
@ -236,7 +237,6 @@ const formatEditableNumber = (params: any) => {
const spanRowsByTaskName = (params: any) => { const spanRowsByTaskName = (params: any) => {
const rowA = params?.nodeA?.data as DetailRow | undefined const rowA = params?.nodeA?.data as DetailRow | undefined
const rowB = params?.nodeB?.data as DetailRow | undefined const rowB = params?.nodeB?.data as DetailRow | undefined
// debugger
if (!rowA || !rowB) return false if (!rowA || !rowB) return false
if (isNoTaskRow(rowA) || isNoTaskRow(rowB)) return false if (isNoTaskRow(rowA) || isNoTaskRow(rowB)) return false
@ -433,7 +433,7 @@ const saveToIndexedDB = async () => {
value: totalServiceFee.value value: totalServiceFee.value
}) })
if (synced) { if (synced) {
pricingPaneReloadStore.markReload(props.contractId, ZXFW_RELOAD_SERVICE_KEY) pricingPaneReloadStore.emit(props.contractId, ZXFW_RELOAD_SERVICE_KEY)
} }
} catch (error) { } catch (error) {
console.error('saveToIndexedDB failed:', error) console.error('saveToIndexedDB failed:', error)
@ -468,7 +468,16 @@ const loadFromIndexedDB = async () => {
} }
watch( watch(
() => pricingPaneReloadStore.getReloadVersion(props.contractId, props.serviceId), () => pricingPaneReloadStore.seq,
(nextVersion, prevVersion) => {
if (nextVersion === prevVersion || nextVersion === 0) return
if (!matchPricingPaneReload(pricingPaneReloadStore.lastEvent, props.contractId, props.serviceId)) return
reloadSignal.value += 1
}
)
watch(
() => reloadSignal.value,
(nextVersion, prevVersion) => { (nextVersion, prevVersion) => {
if (nextVersion === prevVersion || nextVersion === 0) return if (nextVersion === prevVersion || nextVersion === 0) return
void loadFromIndexedDB() void loadFromIndexedDB()

View File

@ -17,6 +17,7 @@ import {
type PricingMethodTotals type PricingMethodTotals
} from '@/lib/pricingMethodTotals' } from '@/lib/pricingMethodTotals'
import { ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync' import { ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
import { matchPricingPaneReload, usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import { Pencil, Eraser, Trash2 } from 'lucide-vue-next' import { Pencil, Eraser, Trash2 } from 'lucide-vue-next'
import { import {
AlertDialogAction, AlertDialogAction,
@ -33,7 +34,6 @@ import { Button } from '@/components/ui/button'
import { TooltipProvider } from '@/components/ui/tooltip' import { TooltipProvider } from '@/components/ui/tooltip'
import { getServiceDictEntries, isIndustryEnabledByType,getIndustryTypeValue } from '@/sql' import { getServiceDictEntries, isIndustryEnabledByType,getIndustryTypeValue } from '@/sql'
import { useTabStore } from '@/pinia/tab' import { useTabStore } from '@/pinia/tab'
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import ServiceCheckboxSelector from '@/components/views/ServiceCheckboxSelector.vue' import ServiceCheckboxSelector from '@/components/views/ServiceCheckboxSelector.vue'
interface ServiceItem { interface ServiceItem {
@ -85,6 +85,7 @@ const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
const PRICING_CLEAR_SKIP_TTL_MS = 5000 const PRICING_CLEAR_SKIP_TTL_MS = 5000
const PRICING_TOTALS_OPTIONS = { excludeInvestmentCostAndAreaRows: true } as const const PRICING_TOTALS_OPTIONS = { excludeInvestmentCostAndAreaRows: true } as const
const projectIndustry = ref('') const projectIndustry = ref('')
const reloadSignal = ref(0)
type ServiceListItem = { type ServiceListItem = {
code?: string code?: string
@ -388,7 +389,7 @@ const clearPricingPaneValues = async (serviceId: string) => {
sessionStorage.setItem(`${PRICING_FORCE_DEFAULT_PREFIX}${key}`, String(skipUntil)) sessionStorage.setItem(`${PRICING_FORCE_DEFAULT_PREFIX}${key}`, String(skipUntil))
} }
await Promise.all(keys.map(key => localforage.removeItem(key))) await Promise.all(keys.map(key => localforage.removeItem(key)))
pricingPaneReloadStore.markReload(props.contractId, serviceId) pricingPaneReloadStore.emit(props.contractId, serviceId)
} }
const clearRowValues = async (row: DetailRow) => { const clearRowValues = async (row: DetailRow) => {
@ -492,8 +493,30 @@ const ActionCellRenderer = defineComponent({
}) })
const columnDefs: ColDef<DetailRow>[] = [ const columnDefs: ColDef<DetailRow>[] = [
{ headerName: '编码', field: 'code', minWidth: 50, maxWidth: 100 }, {
{ headerName: '名称', field: 'name', minWidth: 250, flex: 3, tooltipField: 'name' }, headerName: '编码',
field: 'code',
minWidth: 50,
maxWidth: 100,
valueGetter: params => {
if (!params.data) return ''
if (isFixedRow(params.data)) return '小计'
return params.data.code
},
colSpan: params => (params.data && isFixedRow(params.data) ? 2 : 1)
},
{
headerName: '名称',
field: 'name',
minWidth: 250,
flex: 3,
tooltipField: 'name',
valueGetter: params => {
if (!params.data) return ''
if (isFixedRow(params.data)) return ''
return params.data.name
}
},
{ {
headerName: '投资规模法', headerName: '投资规模法',
field: 'investScale', field: 'investScale',
@ -965,7 +988,16 @@ const loadProjectIndustry = async () => {
} }
watch( watch(
() => pricingPaneReloadStore.getReloadVersion(props.contractId, ZXFW_RELOAD_SERVICE_KEY), () => pricingPaneReloadStore.seq,
(nextVersion, prevVersion) => {
if (nextVersion === prevVersion || nextVersion === 0) return
if (!matchPricingPaneReload(pricingPaneReloadStore.lastEvent, props.contractId, ZXFW_RELOAD_SERVICE_KEY)) return
reloadSignal.value += 1
}
)
watch(
() => reloadSignal.value,
(nextVersion, prevVersion) => { (nextVersion, prevVersion) => {
if (nextVersion === prevVersion || nextVersion === 0) return if (nextVersion === prevVersion || nextVersion === 0) return
void loadFromIndexedDB() void loadFromIndexedDB()

View File

@ -1363,7 +1363,7 @@ watch(
<ScrollArea :ref="setTabScrollAreaRef" type="auto" <ScrollArea :ref="setTabScrollAreaRef" type="auto"
class="h-full tab-strip-scroll-area min-w-0 flex-1 whitespace-nowrap"> class="h-full tab-strip-scroll-area min-w-0 flex-1 whitespace-nowrap">
<draggable v-model="tabsModel" item-key="id" tag="div" <draggable v-model="tabsModel" item-key="id" tag="div"
:class="['tab-strip-sortable h-[calc(3.49rem)] flex w-max gap-0', isTabDragging ? 'is-dragging' : '']" :class="['tab-strip-sortable h-[calc(3.50rem)] flex w-max gap-0', isTabDragging ? 'is-dragging' : '']"
:animation="260" easing="cubic-bezier(0.22, 1, 0.36, 1)" ghost-class="tab-drag-ghost" :animation="260" easing="cubic-bezier(0.22, 1, 0.36, 1)" ghost-class="tab-drag-ghost"
chosen-class="tab-drag-chosen" drag-class="tab-drag-active" :move="canMoveTab" @start="handleTabDragStart" chosen-class="tab-drag-chosen" drag-class="tab-drag-active" :move="canMoveTab" @start="handleTabDragStart"
@end="handleTabDragEnd"> @end="handleTabDragEnd">

View File

@ -0,0 +1,34 @@
import { defineStore } from 'pinia'
export interface HtFeeMethodReloadDetail {
mainStorageKey: string
rowId: string
at: number
}
interface HtFeeMethodReloadState {
seq: number
lastEvent: HtFeeMethodReloadDetail | null
}
export const useHtFeeMethodReloadStore = defineStore('htFeeMethodReload', {
state: (): HtFeeMethodReloadState => ({
seq: 0,
lastEvent: null
}),
actions: {
emit(mainStorageKey: string, rowId: string) {
const normalizedMainStorageKey = String(mainStorageKey || '').trim()
const normalizedRowId = String(rowId || '').trim()
if (!normalizedMainStorageKey || !normalizedRowId) return
this.lastEvent = {
mainStorageKey: normalizedMainStorageKey,
rowId: normalizedRowId,
at: Date.now()
}
this.seq += 1
}
}
})

View File

@ -1,28 +1,41 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue'
const buildReloadKey = (contractId: string, serviceId: string | number) => export interface PricingPaneReloadDetail {
`${contractId}::${String(serviceId)}` contractId: string
serviceId: string
export const usePricingPaneReloadStore = defineStore('pricing-pane-reload', () => { at: number
const reloadVersionMap = ref<Record<string, number>>({})
const markReload = (contractId: string, serviceId: string | number) => {
const key = buildReloadKey(contractId, serviceId)
const current = reloadVersionMap.value[key] || 0
reloadVersionMap.value = {
...reloadVersionMap.value,
[key]: current + 1
}
} }
const getReloadVersion = (contractId: string, serviceId: string | number) => { interface PricingPaneReloadState {
const key = buildReloadKey(contractId, serviceId) seq: number
return reloadVersionMap.value[key] || 0 lastEvent: PricingPaneReloadDetail | null
} }
return { const toKey = (value: string | number) => String(value)
markReload,
getReloadVersion export const usePricingPaneReloadStore = defineStore('pricingPaneReload', {
state: (): PricingPaneReloadState => ({
seq: 0,
lastEvent: null
}),
actions: {
emit(contractId: string, serviceId: string | number) {
this.lastEvent = {
contractId: toKey(contractId),
serviceId: toKey(serviceId),
at: Date.now()
}
this.seq += 1
}
} }
}) })
export const matchPricingPaneReload = (
detail: PricingPaneReloadDetail | null | undefined,
contractId: string,
serviceId: string | number
) => {
if (!detail) return false
return detail.contractId === toKey(contractId) && detail.serviceId === toKey(serviceId)
}

View File

@ -1 +1 @@
{"root":["./src/main.ts","./src/sql.ts","./src/components/ui/button/index.ts","./src/components/ui/card/index.ts","./src/components/ui/scroll-area/index.ts","./src/components/ui/tooltip/index.ts","./src/lib/decimal.ts","./src/lib/diyaggridoptions.ts","./src/lib/number.ts","./src/lib/numberformat.ts","./src/lib/pricingmethodtotals.ts","./src/lib/pricingscalefee.ts","./src/lib/utils.ts","./src/lib/xmfactordefaults.ts","./src/lib/zwarchive.ts","./src/lib/zxfwpricingsync.ts","./src/pinia/pricingpanereload.ts","./src/pinia/tab.ts","./src/app.vue","./src/components/common/htfeegrid.vue","./src/components/common/methodunavailablenotice.vue","./src/components/common/xmfactorgrid.vue","./src/components/common/xmcommonaggrid.vue","./src/components/ui/button/button.vue","./src/components/ui/card/card.vue","./src/components/ui/card/cardaction.vue","./src/components/ui/card/cardcontent.vue","./src/components/ui/card/carddescription.vue","./src/components/ui/card/cardfooter.vue","./src/components/ui/card/cardheader.vue","./src/components/ui/card/cardtitle.vue","./src/components/ui/scroll-area/scrollarea.vue","./src/components/ui/scroll-area/scrollbar.vue","./src/components/ui/tooltip/tooltipcontent.vue","./src/components/views/ht.vue","./src/components/views/htadditionalworkfee.vue","./src/components/views/htconsultcategoryfactor.vue","./src/components/views/htmajorfactor.vue","./src/components/views/htreservefee.vue","./src/components/views/servicecheckboxselector.vue","./src/components/views/xmconsultcategoryfactor.vue","./src/components/views/xmmajorfactor.vue","./src/components/views/zxfwview.vue","./src/components/views/htcard.vue","./src/components/views/htinfo.vue","./src/components/views/info.vue","./src/components/views/xmcard.vue","./src/components/views/xminfo.vue","./src/components/views/zxfw.vue","./src/components/views/pricingview/hourlypricingpane.vue","./src/components/views/pricingview/investmentscalepricingpane.vue","./src/components/views/pricingview/landscalepricingpane.vue","./src/components/views/pricingview/workloadpricingpane.vue","./src/layout/tab.vue","./src/layout/typeline.vue"],"version":"5.9.3"} {"root":["./src/main.ts","./src/sql.ts","./src/components/ui/button/index.ts","./src/components/ui/card/index.ts","./src/components/ui/scroll-area/index.ts","./src/components/ui/tooltip/index.ts","./src/lib/decimal.ts","./src/lib/diyaggridoptions.ts","./src/lib/number.ts","./src/lib/numberformat.ts","./src/lib/pricingmethodtotals.ts","./src/lib/pricingscalefee.ts","./src/lib/utils.ts","./src/lib/xmfactordefaults.ts","./src/lib/zwarchive.ts","./src/lib/zxfwpricingsync.ts","./src/pinia/pricingpanereload.ts","./src/pinia/tab.ts","./src/app.vue","./src/components/common/htfeegrid.vue","./src/components/common/htfeemethodgrid.vue","./src/components/common/methodunavailablenotice.vue","./src/components/common/xmfactorgrid.vue","./src/components/common/xmcommonaggrid.vue","./src/components/ui/button/button.vue","./src/components/ui/card/card.vue","./src/components/ui/card/cardaction.vue","./src/components/ui/card/cardcontent.vue","./src/components/ui/card/carddescription.vue","./src/components/ui/card/cardfooter.vue","./src/components/ui/card/cardheader.vue","./src/components/ui/card/cardtitle.vue","./src/components/ui/scroll-area/scrollarea.vue","./src/components/ui/scroll-area/scrollbar.vue","./src/components/ui/tooltip/tooltipcontent.vue","./src/components/views/ht.vue","./src/components/views/htadditionalworkfee.vue","./src/components/views/htconsultcategoryfactor.vue","./src/components/views/htfeemethodtypelineview.vue","./src/components/views/htmajorfactor.vue","./src/components/views/htreservefee.vue","./src/components/views/servicecheckboxselector.vue","./src/components/views/xmconsultcategoryfactor.vue","./src/components/views/xmmajorfactor.vue","./src/components/views/zxfwview.vue","./src/components/views/htcard.vue","./src/components/views/htinfo.vue","./src/components/views/info.vue","./src/components/views/xmcard.vue","./src/components/views/xminfo.vue","./src/components/views/zxfw.vue","./src/components/views/pricingview/hourlypricingpane.vue","./src/components/views/pricingview/investmentscalepricingpane.vue","./src/components/views/pricingview/landscalepricingpane.vue","./src/components/views/pricingview/workloadpricingpane.vue","./src/layout/tab.vue","./src/layout/typeline.vue"],"version":"5.9.3"}