Compare commits

..

2 Commits

Author SHA1 Message Date
f79e8e0da6 merge 2026-03-09 15:45:51 +08:00
ab310b49e9 1 2026-03-09 15:44:56 +08:00
6 changed files with 780 additions and 342 deletions

View File

@ -186,7 +186,7 @@ const columnDefs: ColDef<FeeRow>[] = [
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: true,
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }), valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: formatEditableUnitPrice, valueFormatter: formatEditableUnitPrice,
cellClassRules: { cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === '' 'editable-cell-empty': params => params.value == null || params.value === ''

View File

@ -49,6 +49,8 @@ interface DictGroup {
interface DetailRow { interface DetailRow {
id: string id: string
projectIndex?: number
majorDictId?: string
groupCode: string groupCode: string
groupName: string groupName: string
majorCode: string majorCode: string
@ -125,6 +127,64 @@ const isMutipleService = computed(() => {
return service?.mutiple === true return service?.mutiple === true
}) })
const projectCount = ref<number>(1) const projectCount = ref<number>(1)
const PROJECT_PATH_PREFIX = 'project-'
const PROJECT_ROW_ID_SEPARATOR = '::'
const normalizeProjectCount = (value: unknown) => {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return 1
return Math.max(1, Math.floor(parsed))
}
const getTargetProjectCount = () => (isMutipleService.value ? normalizeProjectCount(projectCount.value) : 1)
const buildProjectGroupPathKey = (projectIndex: number) => `${PROJECT_PATH_PREFIX}${projectIndex}`
const parseProjectIndexFromPathKey = (value: string) => {
const match = /^project-(\d+)$/.exec(value)
if (!match) return null
return normalizeProjectCount(Number(match[1]))
}
const buildScopedRowId = (projectIndex: number, majorId: string) =>
isMutipleService.value ? `${projectIndex}${PROJECT_ROW_ID_SEPARATOR}${majorId}` : majorId
const parseScopedRowId = (id: unknown) => {
const rawId = String(id || '')
const match = /^(\d+)::(.+)$/.exec(rawId)
if (!match) {
return {
projectIndex: 1,
majorDictId: rawId
}
}
return {
projectIndex: normalizeProjectCount(Number(match[1])),
majorDictId: String(match[2] || '').trim()
}
}
const resolveRowProjectIndex = (row: Partial<DetailRow> | undefined) => {
if (!row) return 1
if (typeof row.projectIndex === 'number' && Number.isFinite(row.projectIndex)) {
return normalizeProjectCount(row.projectIndex)
}
if (Array.isArray(row.path) && row.path.length > 0) {
const projectIndexFromPath = parseProjectIndexFromPathKey(String(row.path[0] || ''))
if (projectIndexFromPath != null) return projectIndexFromPath
}
return parseScopedRowId(row.id).projectIndex
}
const resolveRowMajorDictId = (row: Partial<DetailRow> | undefined) => {
if (!row) return ''
const direct = String(row.majorDictId || '').trim()
if (direct) return majorIdAliasMap.get(direct) || direct
const parsed = parseScopedRowId(row.id).majorDictId
return majorIdAliasMap.get(parsed) || parsed
}
const makeProjectMajorKey = (projectIndex: number, majorDictId: string) =>
`${normalizeProjectCount(projectIndex)}:${String(majorDictId || '').trim()}`
const inferProjectCountFromRows = (rows?: Array<Partial<DetailRow>>) => {
if (!isMutipleService.value) return 1
let maxProjectIndex = 1
for (const row of rows || []) {
maxProjectIndex = Math.max(maxProjectIndex, resolveRowProjectIndex(row))
}
return maxProjectIndex
}
const totalLabel = computed(() => { const totalLabel = computed(() => {
const industryName = industryNameMap.get(activeIndustryCode.value.trim()) || '' const industryName = industryNameMap.get(activeIndustryCode.value.trim()) || ''
return industryName ? `${industryName}总投资` : '总投资' return industryName ? `${industryName}总投资` : '总投资'
@ -246,38 +306,45 @@ for (const group of detailDict) {
} }
} }
const buildDefaultRows = (): DetailRow[] => { const buildDefaultRows = (projectCountValue = getTargetProjectCount()): DetailRow[] => {
if (!activeIndustryCode.value) return [] if (!activeIndustryCode.value) return []
const rows: DetailRow[] = [] const rows: DetailRow[] = []
for (const group of detailDict) { for (let projectIndex = 1; projectIndex <= projectCountValue; projectIndex++) {
if (activeIndustryCode.value && !isMajorIdInIndustryScope(group.id, activeIndustryCode.value)) continue for (const group of detailDict) {
for (const child of group.children) { if (activeIndustryCode.value && !isMajorIdInIndustryScope(group.id, activeIndustryCode.value)) continue
rows.push({ for (const child of group.children) {
id: child.id, const rowId = buildScopedRowId(projectIndex, child.id)
groupCode: group.code, rows.push({
groupName: group.name, id: rowId,
majorCode: child.code, projectIndex,
majorName: child.name, majorDictId: child.id,
hasCost: child.hasCost, groupCode: group.code,
hasArea: child.hasArea, groupName: group.name,
amount: null, majorCode: child.code,
benchmarkBudget: null, majorName: child.name,
benchmarkBudgetBasic: null, hasCost: child.hasCost,
benchmarkBudgetOptional: null, hasArea: child.hasArea,
benchmarkBudgetBasicChecked: true, amount: null,
benchmarkBudgetOptionalChecked: true, benchmarkBudget: null,
basicFormula: '', benchmarkBudgetBasic: null,
optionalFormula: '', benchmarkBudgetOptional: null,
consultCategoryFactor: null, benchmarkBudgetBasicChecked: true,
majorFactor: null, benchmarkBudgetOptionalChecked: true,
workStageFactor: 1, basicFormula: '',
workRatio: 100, optionalFormula: '',
budgetFee: null, consultCategoryFactor: null,
budgetFeeBasic: null, majorFactor: null,
budgetFeeOptional: null, workStageFactor: 1,
remark: '', workRatio: 100,
path: [group.id, child.id] budgetFee: null,
}) budgetFeeBasic: null,
budgetFeeOptional: null,
remark: '',
path: isMutipleService.value
? [buildProjectGroupPathKey(projectIndex), group.id, rowId]
: [group.id, rowId]
})
}
} }
} }
return rows return rows
@ -286,15 +353,23 @@ const buildDefaultRows = (): DetailRow[] => {
const calcOnlyCostScaleAmountFromRows = (rows?: Array<{ amount?: unknown }>) => const calcOnlyCostScaleAmountFromRows = (rows?: Array<{ amount?: unknown }>) =>
sumByNumber(rows || [], row => (typeof row?.amount === 'number' ? row.amount : null)) sumByNumber(rows || [], row => (typeof row?.amount === 'number' ? row.amount : null))
const getOnlyCostScaleMajorFactorDefault = () => { const getOnlyCostScaleMajorEntry = () => {
const industryId = String(activeIndustryCode.value || '').trim() const industryId = String(activeIndustryCode.value || '').trim()
if (!industryId) return 1 if (!industryId) return null
const industryMajor = serviceEntries.find(([, item]) => { const industryMajor = serviceEntries.find(([, item]) => {
const majorIndustryId = String(item?.industryId ?? '').trim() const majorIndustryId = String(item?.industryId ?? '').trim()
return majorIndustryId === industryId && !String(item?.code || '').includes('-') return majorIndustryId === industryId && !String(item?.code || '').includes('-')
}) })
if (!industryMajor) return null
const [id, item] = industryMajor
return { id, item }
}
const getOnlyCostScaleMajorFactorDefault = () => {
const industryMajor = getOnlyCostScaleMajorEntry()
if (!industryMajor) return 1 if (!industryMajor) return 1
const [majorId, majorItem] = industryMajor const majorId = industryMajor.id
const majorItem = industryMajor.item
const fromMap = majorFactorMap.value.get(String(majorId)) const fromMap = majorFactorMap.value.get(String(majorId))
if (typeof fromMap === 'number' && Number.isFinite(fromMap)) return fromMap if (typeof fromMap === 'number' && Number.isFinite(fromMap)) return fromMap
if (typeof majorItem?.defCoe === 'number' && Number.isFinite(majorItem.defCoe)) return majorItem.defCoe if (typeof majorItem?.defCoe === 'number' && Number.isFinite(majorItem.defCoe)) return majorItem.defCoe
@ -303,6 +378,7 @@ const getOnlyCostScaleMajorFactorDefault = () => {
const buildOnlyCostScaleRow = ( const buildOnlyCostScaleRow = (
amount: number | null, amount: number | null,
projectIndex: number,
fromDb?: Partial< fromDb?: Partial<
Pick< Pick<
DetailRow, DetailRow,
@ -316,11 +392,13 @@ const buildOnlyCostScaleRow = (
> >
> >
): DetailRow => ({ ): DetailRow => ({
id: ONLY_COST_SCALE_ROW_ID, id: buildScopedRowId(projectIndex, getOnlyCostScaleMajorEntry()?.id || ONLY_COST_SCALE_ROW_ID),
groupCode: 'TOTAL', projectIndex,
groupName: '总投资', majorDictId: getOnlyCostScaleMajorEntry()?.id || ONLY_COST_SCALE_ROW_ID,
majorCode: 'TOTAL', groupCode: getOnlyCostScaleMajorEntry()?.item?.code || 'TOTAL',
majorName: '总投资', groupName: getOnlyCostScaleMajorEntry()?.item?.name || totalLabel.value,
majorCode: getOnlyCostScaleMajorEntry()?.item?.code || 'TOTAL',
majorName: getOnlyCostScaleMajorEntry()?.item?.name || totalLabel.value,
hasCost: true, hasCost: true,
hasArea: false, hasArea: false,
amount, amount,
@ -342,21 +420,56 @@ const buildOnlyCostScaleRow = (
budgetFeeBasic: null, budgetFeeBasic: null,
budgetFeeOptional: null, budgetFeeOptional: null,
remark: typeof fromDb?.remark === 'string' ? fromDb.remark : '', remark: typeof fromDb?.remark === 'string' ? fromDb.remark : '',
path: [ONLY_COST_SCALE_ROW_ID] path: isMutipleService.value
? [buildProjectGroupPathKey(projectIndex), buildScopedRowId(projectIndex, getOnlyCostScaleMajorEntry()?.id || ONLY_COST_SCALE_ROW_ID)]
: [buildScopedRowId(projectIndex, getOnlyCostScaleMajorEntry()?.id || ONLY_COST_SCALE_ROW_ID)]
}) })
const buildOnlyCostScaleRows = ( const buildOnlyCostScaleRows = (
rowsFromDb?: Array<Partial<DetailRow> & Pick<DetailRow, 'id'>> rowsFromDb?: Array<Partial<DetailRow> & Pick<DetailRow, 'id'>>,
options?: { projectCount?: number; cloneFromProjectOne?: boolean }
): DetailRow[] => { ): DetailRow[] => {
const totalAmount = calcOnlyCostScaleAmountFromRows(rowsFromDb) const targetProjectCount = normalizeProjectCount(options?.projectCount ?? getTargetProjectCount())
const onlyRow = rowsFromDb?.find(row => String(row.id) === ONLY_COST_SCALE_ROW_ID) const onlyCostMajorId = getOnlyCostScaleMajorEntry()?.id || ONLY_COST_SCALE_ROW_ID
return [buildOnlyCostScaleRow(totalAmount, onlyRow)] const dbValueMap = new Map<string, Partial<DetailRow> & Pick<DetailRow, 'id'>>()
for (const row of rowsFromDb || []) {
const projectIndex = resolveRowProjectIndex(row)
const majorDictId = resolveRowMajorDictId(row) || onlyCostMajorId
dbValueMap.set(makeProjectMajorKey(projectIndex, majorDictId), row)
if (String(row.id || '') === ONLY_COST_SCALE_ROW_ID && !dbValueMap.has(makeProjectMajorKey(projectIndex, onlyCostMajorId))) {
dbValueMap.set(makeProjectMajorKey(projectIndex, onlyCostMajorId), row)
}
}
const result: DetailRow[] = []
for (let projectIndex = 1; projectIndex <= targetProjectCount; projectIndex++) {
const key = makeProjectMajorKey(projectIndex, onlyCostMajorId)
const firstProjectKey = makeProjectMajorKey(1, onlyCostMajorId)
const fromDb =
dbValueMap.get(key) ||
(options?.cloneFromProjectOne && projectIndex > 1 ? dbValueMap.get(firstProjectKey) : undefined)
const fallbackAmount =
options?.cloneFromProjectOne && projectIndex > 1 && fromDb == null
? calcOnlyCostScaleAmountFromRows(rowsFromDb)
: null
result.push(
buildOnlyCostScaleRow(
typeof fromDb?.amount === 'number' ? fromDb.amount : fallbackAmount,
projectIndex,
fromDb
)
)
}
return result
} }
type SourceRow = Pick<DetailRow, 'id'> & type SourceRow = Pick<DetailRow, 'id'> &
Partial< Partial<
Pick< Pick<
DetailRow, DetailRow,
| 'projectIndex'
| 'majorDictId'
| 'amount' | 'amount'
| 'benchmarkBudget' | 'benchmarkBudget'
| 'benchmarkBudgetBasic' | 'benchmarkBudgetBasic'
@ -377,22 +490,32 @@ type SourceRow = Pick<DetailRow, 'id'> &
> >
const mergeWithDictRows = ( const mergeWithDictRows = (
rowsFromDb: SourceRow[] | undefined, rowsFromDb: SourceRow[] | undefined,
options?: { includeAmount?: boolean; includeFactorValues?: boolean } options?: {
includeAmount?: boolean
includeFactorValues?: boolean
projectCount?: number
cloneFromProjectOne?: boolean
}
): DetailRow[] => { ): DetailRow[] => {
const includeAmount = options?.includeAmount ?? true const includeAmount = options?.includeAmount ?? true
const includeFactorValues = options?.includeFactorValues ?? true const includeFactorValues = options?.includeFactorValues ?? true
const targetProjectCount = normalizeProjectCount(options?.projectCount ?? getTargetProjectCount())
const dbValueMap = new Map<string, SourceRow>() const dbValueMap = new Map<string, SourceRow>()
for (const row of rowsFromDb || []) { for (const row of rowsFromDb || []) {
const rowId = String(row.id) const projectIndex = resolveRowProjectIndex(row)
dbValueMap.set(rowId, row) const majorDictId = resolveRowMajorDictId(row)
const aliasId = majorIdAliasMap.get(rowId) if (!majorDictId) continue
if (aliasId && !dbValueMap.has(aliasId)) { dbValueMap.set(makeProjectMajorKey(projectIndex, majorDictId), row)
dbValueMap.set(aliasId, row)
}
} }
return buildDefaultRows().map(row => { return buildDefaultRows(targetProjectCount).map(row => {
const fromDb = dbValueMap.get(row.id) const rowProjectIndex = resolveRowProjectIndex(row)
const rowMajorDictId = resolveRowMajorDictId(row)
const fromDb =
dbValueMap.get(makeProjectMajorKey(rowProjectIndex, rowMajorDictId)) ||
(options?.cloneFromProjectOne && rowProjectIndex > 1
? dbValueMap.get(makeProjectMajorKey(1, rowMajorDictId))
: undefined)
if (!fromDb) return row if (!fromDb) return row
const hasConsultCategoryFactor = Object.prototype.hasOwnProperty.call(fromDb, 'consultCategoryFactor') const hasConsultCategoryFactor = Object.prototype.hasOwnProperty.call(fromDb, 'consultCategoryFactor')
const hasMajorFactor = Object.prototype.hasOwnProperty.call(fromDb, 'majorFactor') const hasMajorFactor = Object.prototype.hasOwnProperty.call(fromDb, 'majorFactor')
@ -424,7 +547,7 @@ const mergeWithDictRows = (
? fromDb.majorFactor ? fromDb.majorFactor
: hasMajorFactor : hasMajorFactor
? null ? null
: getDefaultMajorFactorById(row.id), : getDefaultMajorFactorById(rowMajorDictId),
workStageFactor: typeof fromDb.workStageFactor === 'number' ? fromDb.workStageFactor : row.workStageFactor, workStageFactor: typeof fromDb.workStageFactor === 'number' ? fromDb.workStageFactor : row.workStageFactor,
workRatio: typeof fromDb.workRatio === 'number' ? fromDb.workRatio : row.workRatio, workRatio: typeof fromDb.workRatio === 'number' ? fromDb.workRatio : row.workRatio,
budgetFee: typeof fromDb.budgetFee === 'number' ? fromDb.budgetFee : null, budgetFee: typeof fromDb.budgetFee === 'number' ? fromDb.budgetFee : null,
@ -452,11 +575,6 @@ const formatMajorFactor = (params: any) => {
} }
const formatEditableMoney = (params: any) => { const formatEditableMoney = (params: any) => {
if (isOnlyCostScaleService.value) {
if (!params.node?.rowPinned) return ''
if (params.value == null || params.value === '') return '点击输入'
return formatThousandsFlexible(params.value, 3)
}
if (!params.node?.group && !params.node?.rowPinned && !params.data?.hasCost) { if (!params.node?.group && !params.node?.rowPinned && !params.data?.hasCost) {
return '' return ''
} }
@ -477,7 +595,7 @@ type BudgetCheckField = 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptional
const createBudgetCellRendererWithCheck = (checkField: BudgetCheckField) => (params: any) => { const createBudgetCellRendererWithCheck = (checkField: BudgetCheckField) => (params: any) => {
const valueText = formatReadonlyMoney(params) const valueText = formatReadonlyMoney(params)
const hasValue = params.value != null && params.value !== '' const hasValue = params.value != null && params.value !== ''
if (params.node?.group || (params.node?.rowPinned && !isOnlyCostScaleService.value) || !params.data || !hasValue) { if (params.node?.group || params.node?.rowPinned || !params.data || !hasValue) {
return valueText return valueText
} }
@ -495,11 +613,7 @@ const createBudgetCellRendererWithCheck = (checkField: BudgetCheckField) => (par
checkbox.checked = params.data[checkField] !== false checkbox.checked = params.data[checkField] !== false
checkbox.addEventListener('click', event => event.stopPropagation()) checkbox.addEventListener('click', event => event.stopPropagation())
checkbox.addEventListener('change', () => { checkbox.addEventListener('change', () => {
const isOnlyCostScalePinned = isOnlyCostScaleService.value && Boolean(params.node?.rowPinned) const targetRow = params.data as DetailRow | undefined
const targetRow =
isOnlyCostScalePinned
? detailRows.value[0]
: (params.data as DetailRow | undefined)
if (!targetRow) return if (!targetRow) return
targetRow[checkField] = checkbox.checked targetRow[checkField] = checkbox.checked
@ -595,7 +709,6 @@ const getBudgetFeeSplit = (
const getMergeColSpanBeforeTotal = (params: any) => { const getMergeColSpanBeforeTotal = (params: any) => {
if (!params.node?.group && !params.node?.rowPinned) return 1 if (!params.node?.group && !params.node?.rowPinned) return 1
if (isOnlyCostScaleService.value && params.node?.rowPinned) return 1
const displayedColumns = params.api?.getAllDisplayedColumns?.() const displayedColumns = params.api?.getAllDisplayedColumns?.()
if (!Array.isArray(displayedColumns) || !params.column) return 1 if (!Array.isArray(displayedColumns) || !params.column) return 1
const currentIndex = displayedColumns.findIndex((column: any) => column.getColId() === params.column.getColId()) const currentIndex = displayedColumns.findIndex((column: any) => column.getColId() === params.column.getColId())
@ -613,20 +726,15 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
flex: 2, flex: 2,
editable: params => { editable: params => {
if (isOnlyCostScaleService.value) return Boolean(params.node?.rowPinned)
return !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost) return !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost)
}, },
cellClass: params => cellClass: params =>
isOnlyCostScaleService.value && params.node?.rowPinned !params.node?.group && !params.node?.rowPinned && params.data?.hasCost
? 'ag-right-aligned-cell editable-cell-line'
: !params.node?.group && !params.node?.rowPinned && params.data?.hasCost
? 'ag-right-aligned-cell editable-cell-line' ? 'ag-right-aligned-cell editable-cell-line'
: 'ag-right-aligned-cell', : 'ag-right-aligned-cell',
cellClassRules: { cellClassRules: {
'editable-cell-empty': params => 'editable-cell-empty': params =>
isOnlyCostScaleService.value && params.node?.rowPinned !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost) && (params.value == null || params.value === '')
? params.value == null || params.value === ''
: !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost) && (params.value == null || params.value === '')
}, },
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }), valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: formatEditableMoney valueFormatter: formatEditableMoney
@ -645,7 +753,7 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
cellClass: 'ag-right-aligned-cell', cellClass: 'ag-right-aligned-cell',
valueGetter: params => valueGetter: params =>
params.node?.rowPinned params.node?.rowPinned
? (isOnlyCostScaleService.value ? getCheckedBenchmarkBudgetSplitByAmount(params.data)?.basic ?? null : null) ? null
: getCheckedBenchmarkBudgetSplitByAmount(params.data)?.basic ?? null, : getCheckedBenchmarkBudgetSplitByAmount(params.data)?.basic ?? null,
cellRenderer: createBudgetCellRendererWithCheck('benchmarkBudgetBasicChecked'), cellRenderer: createBudgetCellRendererWithCheck('benchmarkBudgetBasicChecked'),
valueFormatter: formatReadonlyMoney valueFormatter: formatReadonlyMoney
@ -660,7 +768,7 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
cellClass: 'ag-right-aligned-cell', cellClass: 'ag-right-aligned-cell',
valueGetter: params => valueGetter: params =>
params.node?.rowPinned params.node?.rowPinned
? (isOnlyCostScaleService.value ? getCheckedBenchmarkBudgetSplitByAmount(params.data)?.optional ?? null : null) ? null
: getCheckedBenchmarkBudgetSplitByAmount(params.data)?.optional ?? null, : getCheckedBenchmarkBudgetSplitByAmount(params.data)?.optional ?? null,
cellRenderer: createBudgetCellRendererWithCheck('benchmarkBudgetOptionalChecked'), cellRenderer: createBudgetCellRendererWithCheck('benchmarkBudgetOptionalChecked'),
valueFormatter: formatReadonlyMoney valueFormatter: formatReadonlyMoney
@ -675,7 +783,7 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
cellClass: 'ag-right-aligned-cell', cellClass: 'ag-right-aligned-cell',
valueGetter: params => valueGetter: params =>
params.node?.rowPinned params.node?.rowPinned
? (isOnlyCostScaleService.value ? getCheckedBenchmarkBudgetSplitByAmount(params.data)?.total ?? null : null) ? null
: getCheckedBenchmarkBudgetSplitByAmount(params.data)?.total ?? null, : getCheckedBenchmarkBudgetSplitByAmount(params.data)?.total ?? null,
valueFormatter: formatReadonlyMoney valueFormatter: formatReadonlyMoney
} }
@ -691,21 +799,14 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
colId: 'consultCategoryFactor', colId: 'consultCategoryFactor',
minWidth: 80, minWidth: 80,
flex: 1, flex: 1,
editable: params => editable: params => !params.node?.group && !params.node?.rowPinned,
isOnlyCostScaleService.value
? Boolean(params.node?.rowPinned)
: !params.node?.group && !params.node?.rowPinned,
cellClass: params => cellClass: params =>
isOnlyCostScaleService.value && params.node?.rowPinned !params.node?.group && !params.node?.rowPinned
? 'editable-cell-line' ? 'editable-cell-line'
: !params.node?.group && !params.node?.rowPinned : '',
? 'editable-cell-line'
: '',
cellClassRules: { cellClassRules: {
'editable-cell-empty': params => 'editable-cell-empty': params =>
isOnlyCostScaleService.value && params.node?.rowPinned !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
? params.value == null || params.value === ''
: !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
}, },
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }), valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: formatConsultCategoryFactor valueFormatter: formatConsultCategoryFactor
@ -716,21 +817,14 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
colId: 'majorFactor', colId: 'majorFactor',
minWidth: 80, minWidth: 80,
flex: 1, flex: 1,
editable: params => editable: params => !params.node?.group && !params.node?.rowPinned,
isOnlyCostScaleService.value
? Boolean(params.node?.rowPinned)
: !params.node?.group && !params.node?.rowPinned,
cellClass: params => cellClass: params =>
isOnlyCostScaleService.value && params.node?.rowPinned !params.node?.group && !params.node?.rowPinned
? 'editable-cell-line' ? 'editable-cell-line'
: !params.node?.group && !params.node?.rowPinned : '',
? 'editable-cell-line'
: '',
cellClassRules: { cellClassRules: {
'editable-cell-empty': params => 'editable-cell-empty': params =>
isOnlyCostScaleService.value && params.node?.rowPinned !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
? params.value == null || params.value === ''
: !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
}, },
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }), valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: formatMajorFactor valueFormatter: formatMajorFactor
@ -741,21 +835,14 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
colId: 'workStageFactor', colId: 'workStageFactor',
minWidth: 80, minWidth: 80,
flex: 1, flex: 1,
editable: params => editable: params => !params.node?.group && !params.node?.rowPinned,
isOnlyCostScaleService.value
? Boolean(params.node?.rowPinned)
: !params.node?.group && !params.node?.rowPinned,
cellClass: params => cellClass: params =>
isOnlyCostScaleService.value && params.node?.rowPinned !params.node?.group && !params.node?.rowPinned
? 'editable-cell-line' ? 'editable-cell-line'
: !params.node?.group && !params.node?.rowPinned : '',
? 'editable-cell-line'
: '',
cellClassRules: { cellClassRules: {
'editable-cell-empty': params => 'editable-cell-empty': params =>
isOnlyCostScaleService.value && params.node?.rowPinned !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
? params.value == null || params.value === ''
: !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
}, },
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }), valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: formatEditableNumber valueFormatter: formatEditableNumber
@ -766,21 +853,14 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
colId: 'workRatio', colId: 'workRatio',
minWidth: 80, minWidth: 80,
flex: 1, flex: 1,
editable: params => editable: params => !params.node?.group && !params.node?.rowPinned,
isOnlyCostScaleService.value
? Boolean(params.node?.rowPinned)
: !params.node?.group && !params.node?.rowPinned,
cellClass: params => cellClass: params =>
isOnlyCostScaleService.value && params.node?.rowPinned !params.node?.group && !params.node?.rowPinned
? 'editable-cell-line' ? 'editable-cell-line'
: !params.node?.group && !params.node?.rowPinned : '',
? 'editable-cell-line'
: '',
cellClassRules: { cellClassRules: {
'editable-cell-empty': params => 'editable-cell-empty': params =>
isOnlyCostScaleService.value && params.node?.rowPinned !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
? params.value == null || params.value === ''
: !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
}, },
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }), valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: formatEditableNumber valueFormatter: formatEditableNumber
@ -839,54 +919,64 @@ const autoGroupColumnDef: ColDef = {
if (params.node?.rowPinned) { if (params.node?.rowPinned) {
return totalLabel.value return totalLabel.value
} }
const rowData = params.data as DetailRow | undefined
if (!params.node?.group && rowData?.majorCode && rowData?.majorName) {
return `${rowData.majorCode} ${rowData.majorName}`
}
const nodeId = String(params.value || '') const nodeId = String(params.value || '')
const projectIndex = parseProjectIndexFromPathKey(nodeId)
if (projectIndex != null) return `项目${projectIndex}`
return idLabelMap.get(nodeId) || nodeId return idLabelMap.get(nodeId) || nodeId
}, },
tooltipValueGetter: params => { tooltipValueGetter: params => {
if (params.node?.rowPinned) return totalLabel.value if (params.node?.rowPinned) return totalLabel.value
const rowData = params.data as DetailRow | undefined
if (!params.node?.group && rowData?.majorCode && rowData?.majorName) {
return `${rowData.majorCode} ${rowData.majorName}`
}
const nodeId = String(params.value || '') const nodeId = String(params.value || '')
const projectIndex = parseProjectIndexFromPathKey(nodeId)
if (projectIndex != null) return `项目${projectIndex}`
return idLabelMap.get(nodeId) || nodeId return idLabelMap.get(nodeId) || nodeId
} }
} }
const totalAmount = computed(() => sumByNumber(detailRows.value, row => row.amount)) const totalAmount = computed(() => sumByNumber(detailRows.value, row => row.amount))
const visibleDetailRows = computed(() => (isOnlyCostScaleService.value ? [] : detailRows.value))
const onlyCostScaleSourceRow = computed(() => detailRows.value[0] ?? buildOnlyCostScaleRow(null))
const totalBudgetFeeBasic = computed(() => sumByNumber(detailRows.value, row => getBudgetFeeSplit(row)?.basic)) const totalBudgetFeeBasic = computed(() => sumByNumber(detailRows.value, row => getBudgetFeeSplit(row)?.basic))
const totalBudgetFeeOptional = computed(() => sumByNumber(detailRows.value, row => getBudgetFeeSplit(row)?.optional)) const totalBudgetFeeOptional = computed(() => sumByNumber(detailRows.value, row => getBudgetFeeSplit(row)?.optional))
const totalBudgetFee = computed(() => sumByNumber(detailRows.value, row => getBudgetFee(row))) const totalBudgetFee = computed(() => sumByNumber(detailRows.value, row => getBudgetFee(row)))
const pinnedTopRowData = computed(() => [ const pinnedTopRowData = computed(() => {
{ return [
id: 'pinned-total-row', {
groupCode: '', id: 'pinned-total-row',
groupName: '', groupCode: '',
majorCode: '', groupName: '',
majorName: '', majorCode: '',
hasCost: false, majorName: '',
hasArea: false, hasCost: false,
amount: isOnlyCostScaleService.value ? onlyCostScaleSourceRow.value.amount : null, hasArea: false,
benchmarkBudget: null, amount: null,
benchmarkBudgetBasic: null, benchmarkBudget: null,
benchmarkBudgetOptional: null, benchmarkBudgetBasic: null,
benchmarkBudgetBasicChecked: isOnlyCostScaleService.value ? onlyCostScaleSourceRow.value.benchmarkBudgetBasicChecked !== false : true, benchmarkBudgetOptional: null,
benchmarkBudgetOptionalChecked: isOnlyCostScaleService.value ? onlyCostScaleSourceRow.value.benchmarkBudgetOptionalChecked !== false : true, benchmarkBudgetBasicChecked: true,
basicFormula: '', benchmarkBudgetOptionalChecked: true,
optionalFormula: '', basicFormula: '',
consultCategoryFactor: isOnlyCostScaleService.value ? onlyCostScaleSourceRow.value.consultCategoryFactor : null, optionalFormula: '',
majorFactor: isOnlyCostScaleService.value ? onlyCostScaleSourceRow.value.majorFactor : null, consultCategoryFactor: null,
workStageFactor: isOnlyCostScaleService.value ? onlyCostScaleSourceRow.value.workStageFactor : null, majorFactor: null,
workRatio: isOnlyCostScaleService.value ? onlyCostScaleSourceRow.value.workRatio : null, workStageFactor: null,
budgetFee: totalBudgetFee.value, workRatio: null,
budgetFeeBasic: totalBudgetFeeBasic.value, budgetFee: totalBudgetFee.value,
budgetFeeOptional: totalBudgetFeeOptional.value, budgetFeeBasic: totalBudgetFeeBasic.value,
remark: '', budgetFeeOptional: totalBudgetFeeOptional.value,
path: ['TOTAL'] remark: '',
} path: ['TOTAL']
]) }
]
})
const syncComputedValuesToDetailRows = () => { const syncComputedValuesToDetailRows = () => {
for (const row of detailRows.value) { for (const row of detailRows.value) {
@ -943,26 +1033,114 @@ const saveToIndexedDB = async () => {
} }
} }
const getProjectMajorKeyFromRow = (row: Partial<DetailRow> | undefined) => {
if (!row) return ''
const majorDictId = resolveRowMajorDictId(row)
if (!majorDictId) return ''
return makeProjectMajorKey(resolveRowProjectIndex(row), majorDictId)
}
const buildRowsFromImportDefaultSource = async (
targetProjectCount: number
): Promise<DetailRow[]> => {
// 使
await loadFactorDefaults()
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
const hasContractRows = Array.isArray(htData?.detailRows) && htData.detailRows.length > 0
if (isOnlyCostScaleService.value) {
return hasContractRows
? buildOnlyCostScaleRows(htData!.detailRows as any, { projectCount: targetProjectCount, cloneFromProjectOne: true })
: buildOnlyCostScaleRows(undefined, { projectCount: targetProjectCount })
}
return hasContractRows
? mergeWithDictRows(htData!.detailRows, {
includeFactorValues: true,
projectCount: targetProjectCount,
cloneFromProjectOne: true
})
: buildDefaultRows(targetProjectCount).map(row => ({
...row,
consultCategoryFactor: getDefaultConsultCategoryFactor(),
majorFactor: getDefaultMajorFactorById(row.majorDictId || row.id)
}))
}
const applyProjectCountChange = async (nextValue: unknown) => {
const normalized = normalizeProjectCount(nextValue)
projectCount.value = normalized
if (!isMutipleService.value) return
const previousRows = detailRows.value.map(row => ({ ...row }))
const previousProjectCount = inferProjectCountFromRows(previousRows)
if (normalized === previousProjectCount) return
if (normalized < previousProjectCount) {
detailRows.value = isOnlyCostScaleService.value
? buildOnlyCostScaleRows(previousRows as any, { projectCount: normalized })
: mergeWithDictRows(previousRows as any, { projectCount: normalized })
syncComputedValuesToDetailRows()
await saveToIndexedDB()
return
}
const defaultRows = await buildRowsFromImportDefaultSource(normalized)
const existingMap = new Map<string, DetailRow>()
for (const row of previousRows) {
const key = getProjectMajorKeyFromRow(row)
if (!key) continue
existingMap.set(key, row)
}
detailRows.value = defaultRows.map(defaultRow => {
const key = getProjectMajorKeyFromRow(defaultRow)
const existingRow = key ? existingMap.get(key) : undefined
if (!existingRow) return defaultRow
if (resolveRowProjectIndex(existingRow) > previousProjectCount) return defaultRow
return {
...defaultRow,
...existingRow,
id: defaultRow.id,
projectIndex: defaultRow.projectIndex,
majorDictId: defaultRow.majorDictId,
groupCode: defaultRow.groupCode,
groupName: defaultRow.groupName,
majorCode: defaultRow.majorCode,
majorName: defaultRow.majorName,
hasCost: defaultRow.hasCost,
hasArea: defaultRow.hasArea,
path: defaultRow.path
}
})
syncComputedValuesToDetailRows()
await saveToIndexedDB()
}
const loadFromIndexedDB = async () => { const loadFromIndexedDB = async () => {
try { try {
const baseInfo = await localforage.getItem<XmBaseInfoState>(BASE_INFO_KEY) const baseInfo = await localforage.getItem<XmBaseInfoState>(BASE_INFO_KEY)
activeIndustryCode.value = activeIndustryCode.value =
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : '' typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
projectCount.value = 1
await ensureFactorDefaultsLoaded() await ensureFactorDefaultsLoaded()
const applyContractDefaultRows = async () => { const applyContractDefaultRows = async () => {
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value) const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
const hasContractRows = Array.isArray(htData?.detailRows) && htData.detailRows.length > 0 const hasContractRows = Array.isArray(htData?.detailRows) && htData.detailRows.length > 0
const targetProjectCount = getTargetProjectCount()
if (isOnlyCostScaleService.value) { if (isOnlyCostScaleService.value) {
detailRows.value = hasContractRows ? buildOnlyCostScaleRows(htData!.detailRows as any) : buildOnlyCostScaleRows() detailRows.value = hasContractRows
? buildOnlyCostScaleRows(htData!.detailRows as any, { projectCount: targetProjectCount, cloneFromProjectOne: true })
: buildOnlyCostScaleRows(undefined, { projectCount: targetProjectCount })
} else { } else {
detailRows.value = hasContractRows detailRows.value = hasContractRows
? mergeWithDictRows(htData!.detailRows, { includeFactorValues: true }) ? mergeWithDictRows(htData!.detailRows, {
: buildDefaultRows().map(row => ({ includeFactorValues: true,
projectCount: targetProjectCount,
cloneFromProjectOne: true
})
: buildDefaultRows(targetProjectCount).map(row => ({
...row, ...row,
consultCategoryFactor: getDefaultConsultCategoryFactor(), consultCategoryFactor: getDefaultConsultCategoryFactor(),
majorFactor: getDefaultMajorFactorById(row.id) majorFactor: getDefaultMajorFactorById(row.majorDictId || row.id)
})) }))
} }
syncComputedValuesToDetailRows() syncComputedValuesToDetailRows()
@ -974,9 +1152,18 @@ const loadFromIndexedDB = async () => {
const data = await localforage.getItem<XmInfoState>(DB_KEY.value) const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
if (data) { if (data) {
if (isMutipleService.value) {
projectCount.value = inferProjectCountFromRows(data.detailRows as any)
}
detailRows.value = isOnlyCostScaleService.value detailRows.value = isOnlyCostScaleService.value
? buildOnlyCostScaleRows(data.detailRows as any) ? buildOnlyCostScaleRows(data.detailRows as any, {
: mergeWithDictRows(data.detailRows) projectCount: getTargetProjectCount(),
cloneFromProjectOne: true
})
: mergeWithDictRows(data.detailRows as any, {
projectCount: getTargetProjectCount(),
cloneFromProjectOne: true
})
syncComputedValuesToDetailRows() syncComputedValuesToDetailRows()
return return
} }
@ -984,7 +1171,9 @@ const loadFromIndexedDB = async () => {
await applyContractDefaultRows() await applyContractDefaultRows()
} catch (error) { } catch (error) {
console.error('loadFromIndexedDB failed:', error) console.error('loadFromIndexedDB failed:', error)
detailRows.value = isOnlyCostScaleService.value ? buildOnlyCostScaleRows() : buildDefaultRows() detailRows.value = isOnlyCostScaleService.value
? buildOnlyCostScaleRows(undefined, { projectCount: getTargetProjectCount() })
: buildDefaultRows(getTargetProjectCount())
syncComputedValuesToDetailRows() syncComputedValuesToDetailRows()
} }
} }
@ -999,15 +1188,22 @@ const importContractData = async () => {
await loadFactorDefaults() await loadFactorDefaults()
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value) const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
const hasContractRows = Array.isArray(htData?.detailRows) && htData.detailRows.length > 0 const hasContractRows = Array.isArray(htData?.detailRows) && htData.detailRows.length > 0
const targetProjectCount = getTargetProjectCount()
if (isOnlyCostScaleService.value) { if (isOnlyCostScaleService.value) {
detailRows.value = hasContractRows ? buildOnlyCostScaleRows(htData!.detailRows as any) : buildOnlyCostScaleRows() detailRows.value = hasContractRows
? buildOnlyCostScaleRows(htData!.detailRows as any, { projectCount: targetProjectCount, cloneFromProjectOne: true })
: buildOnlyCostScaleRows(undefined, { projectCount: targetProjectCount })
} else { } else {
detailRows.value = hasContractRows detailRows.value = hasContractRows
? mergeWithDictRows(htData!.detailRows, { includeFactorValues: true }) ? mergeWithDictRows(htData!.detailRows, {
: buildDefaultRows().map(row => ({ includeFactorValues: true,
projectCount: targetProjectCount,
cloneFromProjectOne: true
})
: buildDefaultRows(targetProjectCount).map(row => ({
...row, ...row,
consultCategoryFactor: getDefaultConsultCategoryFactor(), consultCategoryFactor: getDefaultConsultCategoryFactor(),
majorFactor: getDefaultMajorFactorById(row.id) majorFactor: getDefaultMajorFactorById(row.majorDictId || row.id)
})) }))
} }
await saveToIndexedDB() await saveToIndexedDB()
@ -1018,7 +1214,9 @@ const importContractData = async () => {
const clearAllData = async () => { const clearAllData = async () => {
try { try {
detailRows.value = isOnlyCostScaleService.value ? buildOnlyCostScaleRows() : buildDefaultRows() detailRows.value = isOnlyCostScaleService.value
? buildOnlyCostScaleRows(undefined, { projectCount: getTargetProjectCount() })
: buildDefaultRows(getTargetProjectCount())
await saveToIndexedDB() await saveToIndexedDB()
} catch (error) { } catch (error) {
console.error('clearAllData failed:', error) console.error('clearAllData failed:', error)
@ -1039,29 +1237,7 @@ let persistTimer: ReturnType<typeof setTimeout> | null = null
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
const applyOnlyCostScalePinnedValue = (field: string, rawValue: unknown) => { const handleCellValueChanged = () => {
const parsedValue = parseNumberOrNull(rawValue, { precision: 3 })
const current = detailRows.value[0]
if (!current) {
detailRows.value = [buildOnlyCostScaleRow(field === 'amount' ? parsedValue : null)]
return
}
if (
field !== 'amount' &&
field !== 'consultCategoryFactor' &&
field !== 'majorFactor' &&
field !== 'workStageFactor' &&
field !== 'workRatio'
) {
return
}
detailRows.value = [{ ...current, [field]: parsedValue }]
}
const handleCellValueChanged = (event?: any) => {
if (isOnlyCostScaleService.value && event?.node?.rowPinned && typeof event.colDef?.field === 'string') {
applyOnlyCostScalePinnedValue(event.colDef.field, event.newValue)
}
syncComputedValuesToDetailRows() syncComputedValuesToDetailRows()
if (gridPersistTimer) clearTimeout(gridPersistTimer) if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridPersistTimer = setTimeout(() => { gridPersistTimer = setTimeout(() => {
@ -1112,10 +1288,16 @@ const processCellFromClipboard = (params: any) => {
<h3 class="text-sm font-semibold text-foreground">投资规模明细</h3> <h3 class="text-sm font-semibold text-foreground">投资规模明细</h3>
<div v-if="isMutipleService" class="flex items-center gap-2"> <div v-if="isMutipleService" class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">项目数量</span> <span class="text-xs text-muted-foreground">项目数量</span>
<NumberFieldRoot v-model="projectCount" :min="1" :step="1" class="inline-flex items-center rounded-md border bg-background"> <NumberFieldRoot
<NumberFieldDecrement class="px-2 py-1 text-xs text-muted-foreground hover:bg-muted">-</NumberFieldDecrement> v-model="projectCount"
:min="1"
:step="1"
class="inline-flex items-center rounded-md border bg-background"
@update:model-value="value => void applyProjectCountChange(value)"
>
<NumberFieldDecrement class="cursor-pointer px-2 py-1 text-xs text-muted-foreground hover:bg-muted">-</NumberFieldDecrement>
<NumberFieldInput class="h-7 w-14 border-x bg-transparent px-2 text-center text-xs outline-none" /> <NumberFieldInput class="h-7 w-14 border-x bg-transparent px-2 text-center text-xs outline-none" />
<NumberFieldIncrement class="px-2 py-1 text-xs text-muted-foreground hover:bg-muted">+</NumberFieldIncrement> <NumberFieldIncrement class="cursor-pointer px-2 py-1 text-xs text-muted-foreground hover:bg-muted">+</NumberFieldIncrement>
</NumberFieldRoot> </NumberFieldRoot>
</div> </div>
</div> </div>
@ -1168,7 +1350,7 @@ const processCellFromClipboard = (params: any) => {
</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">
<AgGridVue :style="{ height: '100%' }" :rowData="visibleDetailRows" :pinnedTopRowData="pinnedTopRowData" <AgGridVue :style="{ height: '100%' }" :rowData="detailRows" :pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs" :autoGroupColumnDef="autoGroupColumnDef" :gridOptions="gridOptions" :theme="myTheme" :columnDefs="columnDefs" :autoGroupColumnDef="autoGroupColumnDef" :gridOptions="gridOptions" :theme="myTheme"
@cell-value-changed="handleCellValueChanged" :suppressColumnVirtualisation="true" @cell-value-changed="handleCellValueChanged" :suppressColumnVirtualisation="true"
:suppressRowVirtualisation="true" :cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true" :suppressRowVirtualisation="true" :cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true"

View File

@ -49,6 +49,8 @@ interface DictGroup {
interface DetailRow { interface DetailRow {
id: string id: string
projectIndex?: number
majorDictId?: string
groupCode: string groupCode: string
groupName: string groupName: string
majorCode: string majorCode: string
@ -119,6 +121,64 @@ const isMutipleService = computed(() => {
return service?.mutiple === true return service?.mutiple === true
}) })
const projectCount = ref<number>(1) const projectCount = ref<number>(1)
const PROJECT_PATH_PREFIX = 'project-'
const PROJECT_ROW_ID_SEPARATOR = '::'
const normalizeProjectCount = (value: unknown) => {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return 1
return Math.max(1, Math.floor(parsed))
}
const getTargetProjectCount = () => (isMutipleService.value ? normalizeProjectCount(projectCount.value) : 1)
const buildProjectGroupPathKey = (projectIndex: number) => `${PROJECT_PATH_PREFIX}${projectIndex}`
const parseProjectIndexFromPathKey = (value: string) => {
const match = /^project-(\d+)$/.exec(value)
if (!match) return null
return normalizeProjectCount(Number(match[1]))
}
const buildScopedRowId = (projectIndex: number, majorId: string) =>
isMutipleService.value ? `${projectIndex}${PROJECT_ROW_ID_SEPARATOR}${majorId}` : majorId
const parseScopedRowId = (id: unknown) => {
const rawId = String(id || '')
const match = /^(\d+)::(.+)$/.exec(rawId)
if (!match) {
return {
projectIndex: 1,
majorDictId: rawId
}
}
return {
projectIndex: normalizeProjectCount(Number(match[1])),
majorDictId: String(match[2] || '').trim()
}
}
const resolveRowProjectIndex = (row: Partial<DetailRow> | undefined) => {
if (!row) return 1
if (typeof row.projectIndex === 'number' && Number.isFinite(row.projectIndex)) {
return normalizeProjectCount(row.projectIndex)
}
if (Array.isArray(row.path) && row.path.length > 0) {
const projectIndexFromPath = parseProjectIndexFromPathKey(String(row.path[0] || ''))
if (projectIndexFromPath != null) return projectIndexFromPath
}
return parseScopedRowId(row.id).projectIndex
}
const resolveRowMajorDictId = (row: Partial<DetailRow> | undefined) => {
if (!row) return ''
const direct = String(row.majorDictId || '').trim()
if (direct) return majorIdAliasMap.get(direct) || direct
const parsed = parseScopedRowId(row.id).majorDictId
return majorIdAliasMap.get(parsed) || parsed
}
const makeProjectMajorKey = (projectIndex: number, majorDictId: string) =>
`${normalizeProjectCount(projectIndex)}:${String(majorDictId || '').trim()}`
const inferProjectCountFromRows = (rows?: Array<Partial<DetailRow>>) => {
if (!isMutipleService.value) return 1
let maxProjectIndex = 1
for (const row of rows || []) {
maxProjectIndex = Math.max(maxProjectIndex, resolveRowProjectIndex(row))
}
return maxProjectIndex
}
const detailRows = ref<DetailRow[]>([]) const detailRows = ref<DetailRow[]>([])
const getDefaultConsultCategoryFactor = () => const getDefaultConsultCategoryFactor = () =>
@ -231,39 +291,46 @@ for (const group of detailDict) {
} }
} }
const buildDefaultRows = (): DetailRow[] => { const buildDefaultRows = (projectCountValue = getTargetProjectCount()): DetailRow[] => {
if (!activeIndustryCode.value) return [] if (!activeIndustryCode.value) return []
const rows: DetailRow[] = [] const rows: DetailRow[] = []
for (const group of detailDict) { for (let projectIndex = 1; projectIndex <= projectCountValue; projectIndex++) {
if (activeIndustryCode.value && !isMajorIdInIndustryScope(group.id, activeIndustryCode.value)) continue for (const group of detailDict) {
for (const child of group.children) { if (activeIndustryCode.value && !isMajorIdInIndustryScope(group.id, activeIndustryCode.value)) continue
rows.push({ for (const child of group.children) {
id: child.id, const rowId = buildScopedRowId(projectIndex, child.id)
groupCode: group.code, rows.push({
groupName: group.name, id: rowId,
majorCode: child.code, projectIndex,
majorName: child.name, majorDictId: child.id,
hasCost: child.hasCost, groupCode: group.code,
hasArea: child.hasArea, groupName: group.name,
amount: null, majorCode: child.code,
landArea: null, majorName: child.name,
benchmarkBudget: null, hasCost: child.hasCost,
benchmarkBudgetBasic: null, hasArea: child.hasArea,
benchmarkBudgetOptional: null, amount: null,
benchmarkBudgetBasicChecked: true, landArea: null,
benchmarkBudgetOptionalChecked: true, benchmarkBudget: null,
basicFormula: '', benchmarkBudgetBasic: null,
optionalFormula: '', benchmarkBudgetOptional: null,
consultCategoryFactor: null, benchmarkBudgetBasicChecked: true,
majorFactor: null, benchmarkBudgetOptionalChecked: true,
workStageFactor: 1, basicFormula: '',
workRatio: 100, optionalFormula: '',
budgetFee: null, consultCategoryFactor: null,
budgetFeeBasic: null, majorFactor: null,
budgetFeeOptional: null, workStageFactor: 1,
remark: '', workRatio: 100,
path: [group.id, child.id] budgetFee: null,
}) budgetFeeBasic: null,
budgetFeeOptional: null,
remark: '',
path: isMutipleService.value
? [buildProjectGroupPathKey(projectIndex), group.id, rowId]
: [group.id, rowId]
})
}
} }
} }
return rows return rows
@ -273,6 +340,8 @@ type SourceRow = Pick<DetailRow, 'id'> &
Partial< Partial<
Pick< Pick<
DetailRow, DetailRow,
| 'projectIndex'
| 'majorDictId'
| 'amount' | 'amount'
| 'landArea' | 'landArea'
| 'benchmarkBudget' | 'benchmarkBudget'
@ -294,22 +363,32 @@ type SourceRow = Pick<DetailRow, 'id'> &
> >
const mergeWithDictRows = ( const mergeWithDictRows = (
rowsFromDb: SourceRow[] | undefined, rowsFromDb: SourceRow[] | undefined,
options?: { includeScaleValues?: boolean; includeFactorValues?: boolean } options?: {
includeScaleValues?: boolean
includeFactorValues?: boolean
projectCount?: number
cloneFromProjectOne?: boolean
}
): DetailRow[] => { ): DetailRow[] => {
const includeScaleValues = options?.includeScaleValues ?? true const includeScaleValues = options?.includeScaleValues ?? true
const includeFactorValues = options?.includeFactorValues ?? true const includeFactorValues = options?.includeFactorValues ?? true
const targetProjectCount = normalizeProjectCount(options?.projectCount ?? getTargetProjectCount())
const dbValueMap = new Map<string, SourceRow>() const dbValueMap = new Map<string, SourceRow>()
for (const row of rowsFromDb || []) { for (const row of rowsFromDb || []) {
const rowId = String(row.id) const projectIndex = resolveRowProjectIndex(row)
dbValueMap.set(rowId, row) const majorDictId = resolveRowMajorDictId(row)
const aliasId = majorIdAliasMap.get(rowId) if (!majorDictId) continue
if (aliasId && !dbValueMap.has(aliasId)) { dbValueMap.set(makeProjectMajorKey(projectIndex, majorDictId), row)
dbValueMap.set(aliasId, row)
}
} }
return buildDefaultRows().map(row => { return buildDefaultRows(targetProjectCount).map(row => {
const fromDb = dbValueMap.get(row.id) const rowProjectIndex = resolveRowProjectIndex(row)
const rowMajorDictId = resolveRowMajorDictId(row)
const fromDb =
dbValueMap.get(makeProjectMajorKey(rowProjectIndex, rowMajorDictId)) ||
(options?.cloneFromProjectOne && rowProjectIndex > 1
? dbValueMap.get(makeProjectMajorKey(1, rowMajorDictId))
: undefined)
if (!fromDb) return row if (!fromDb) return row
const hasConsultCategoryFactor = Object.prototype.hasOwnProperty.call(fromDb, 'consultCategoryFactor') const hasConsultCategoryFactor = Object.prototype.hasOwnProperty.call(fromDb, 'consultCategoryFactor')
const hasMajorFactor = Object.prototype.hasOwnProperty.call(fromDb, 'majorFactor') const hasMajorFactor = Object.prototype.hasOwnProperty.call(fromDb, 'majorFactor')
@ -342,7 +421,7 @@ const mergeWithDictRows = (
? fromDb.majorFactor ? fromDb.majorFactor
: hasMajorFactor : hasMajorFactor
? null ? null
: getDefaultMajorFactorById(row.id), : getDefaultMajorFactorById(rowMajorDictId),
workStageFactor: typeof fromDb.workStageFactor === 'number' ? fromDb.workStageFactor : row.workStageFactor, workStageFactor: typeof fromDb.workStageFactor === 'number' ? fromDb.workStageFactor : row.workStageFactor,
workRatio: typeof fromDb.workRatio === 'number' ? fromDb.workRatio : row.workRatio, workRatio: typeof fromDb.workRatio === 'number' ? fromDb.workRatio : row.workRatio,
budgetFee: typeof fromDb.budgetFee === 'number' ? fromDb.budgetFee : null, budgetFee: typeof fromDb.budgetFee === 'number' ? fromDb.budgetFee : null,
@ -688,12 +767,24 @@ const autoGroupColumnDef: ColDef = {
if (params.node?.rowPinned) { if (params.node?.rowPinned) {
return totalLabel.value return totalLabel.value
} }
const rowData = params.data as DetailRow | undefined
if (!params.node?.group && rowData?.majorCode && rowData?.majorName) {
return `${rowData.majorCode} ${rowData.majorName}`
}
const nodeId = String(params.value || '') const nodeId = String(params.value || '')
const projectIndex = parseProjectIndexFromPathKey(nodeId)
if (projectIndex != null) return `项目${projectIndex}`
return idLabelMap.get(nodeId) || nodeId return idLabelMap.get(nodeId) || nodeId
}, },
tooltipValueGetter: params => { tooltipValueGetter: params => {
if (params.node?.rowPinned) return totalLabel.value if (params.node?.rowPinned) return totalLabel.value
const rowData = params.data as DetailRow | undefined
if (!params.node?.group && rowData?.majorCode && rowData?.majorName) {
return `${rowData.majorCode} ${rowData.majorName}`
}
const nodeId = String(params.value || '') const nodeId = String(params.value || '')
const projectIndex = parseProjectIndexFromPathKey(nodeId)
if (projectIndex != null) return `项目${projectIndex}`
return idLabelMap.get(nodeId) || nodeId return idLabelMap.get(nodeId) || nodeId
} }
} }
@ -794,22 +885,102 @@ const saveToIndexedDB = async () => {
} }
} }
const getProjectMajorKeyFromRow = (row: Partial<DetailRow> | undefined) => {
if (!row) return ''
const majorDictId = resolveRowMajorDictId(row)
if (!majorDictId) return ''
return makeProjectMajorKey(resolveRowProjectIndex(row), majorDictId)
}
const buildRowsFromImportDefaultSource = async (
targetProjectCount: number
): Promise<DetailRow[]> => {
// 使
await loadFactorDefaults()
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
const hasContractRows = Array.isArray(htData?.detailRows) && htData.detailRows.length > 0
return hasContractRows
? mergeWithDictRows(htData!.detailRows, {
includeFactorValues: true,
projectCount: targetProjectCount,
cloneFromProjectOne: true
})
: buildDefaultRows(targetProjectCount).map(row => ({
...row,
consultCategoryFactor: getDefaultConsultCategoryFactor(),
majorFactor: getDefaultMajorFactorById(row.majorDictId || row.id)
}))
}
const applyProjectCountChange = async (nextValue: unknown) => {
const normalized = normalizeProjectCount(nextValue)
projectCount.value = normalized
if (!isMutipleService.value) return
const previousRows = detailRows.value.map(row => ({ ...row }))
const previousProjectCount = inferProjectCountFromRows(previousRows)
if (normalized === previousProjectCount) return
if (normalized < previousProjectCount) {
detailRows.value = mergeWithDictRows(previousRows as any, { projectCount: normalized })
syncComputedValuesToDetailRows()
await saveToIndexedDB()
return
}
const defaultRows = await buildRowsFromImportDefaultSource(normalized)
const existingMap = new Map<string, DetailRow>()
for (const row of previousRows) {
const key = getProjectMajorKeyFromRow(row)
if (!key) continue
existingMap.set(key, row)
}
detailRows.value = defaultRows.map(defaultRow => {
const key = getProjectMajorKeyFromRow(defaultRow)
const existingRow = key ? existingMap.get(key) : undefined
if (!existingRow) return defaultRow
if (resolveRowProjectIndex(existingRow) > previousProjectCount) return defaultRow
return {
...defaultRow,
...existingRow,
id: defaultRow.id,
projectIndex: defaultRow.projectIndex,
majorDictId: defaultRow.majorDictId,
groupCode: defaultRow.groupCode,
groupName: defaultRow.groupName,
majorCode: defaultRow.majorCode,
majorName: defaultRow.majorName,
hasCost: defaultRow.hasCost,
hasArea: defaultRow.hasArea,
path: defaultRow.path
}
})
syncComputedValuesToDetailRows()
await saveToIndexedDB()
}
const loadFromIndexedDB = async () => { const loadFromIndexedDB = async () => {
try { try {
const baseInfo = await localforage.getItem<XmBaseInfoState>(BASE_INFO_KEY) const baseInfo = await localforage.getItem<XmBaseInfoState>(BASE_INFO_KEY)
activeIndustryCode.value = activeIndustryCode.value =
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : '' typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
projectCount.value = 1
await ensureFactorDefaultsLoaded() await ensureFactorDefaultsLoaded()
const applyContractDefaultRows = async () => { const applyContractDefaultRows = async () => {
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value) const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
const hasContractRows = Array.isArray(htData?.detailRows) && htData.detailRows.length > 0 const hasContractRows = Array.isArray(htData?.detailRows) && htData.detailRows.length > 0
const targetProjectCount = getTargetProjectCount()
detailRows.value = hasContractRows detailRows.value = hasContractRows
? mergeWithDictRows(htData!.detailRows, { includeFactorValues: true }) ? mergeWithDictRows(htData!.detailRows, {
: buildDefaultRows().map(row => ({ includeFactorValues: true,
projectCount: targetProjectCount,
cloneFromProjectOne: true
})
: buildDefaultRows(targetProjectCount).map(row => ({
...row, ...row,
consultCategoryFactor: getDefaultConsultCategoryFactor(), consultCategoryFactor: getDefaultConsultCategoryFactor(),
majorFactor: getDefaultMajorFactorById(row.id) majorFactor: getDefaultMajorFactorById(row.majorDictId || row.id)
})) }))
syncComputedValuesToDetailRows() syncComputedValuesToDetailRows()
} }
@ -820,7 +991,13 @@ const loadFromIndexedDB = async () => {
const data = await localforage.getItem<XmInfoState>(DB_KEY.value) const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
if (data) { if (data) {
detailRows.value = mergeWithDictRows(data.detailRows) if (isMutipleService.value) {
projectCount.value = inferProjectCountFromRows(data.detailRows as any)
}
detailRows.value = mergeWithDictRows(data.detailRows as any, {
projectCount: getTargetProjectCount(),
cloneFromProjectOne: true
})
syncComputedValuesToDetailRows() syncComputedValuesToDetailRows()
return return
} }
@ -828,7 +1005,7 @@ const loadFromIndexedDB = async () => {
await applyContractDefaultRows() await applyContractDefaultRows()
} catch (error) { } catch (error) {
console.error('loadFromIndexedDB failed:', error) console.error('loadFromIndexedDB failed:', error)
detailRows.value = buildDefaultRows() detailRows.value = buildDefaultRows(getTargetProjectCount())
syncComputedValuesToDetailRows() syncComputedValuesToDetailRows()
} }
} }
@ -843,12 +1020,17 @@ const importContractData = async () => {
await loadFactorDefaults() await loadFactorDefaults()
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value) const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
const hasContractRows = Array.isArray(htData?.detailRows) && htData.detailRows.length > 0 const hasContractRows = Array.isArray(htData?.detailRows) && htData.detailRows.length > 0
const targetProjectCount = getTargetProjectCount()
detailRows.value = hasContractRows detailRows.value = hasContractRows
? mergeWithDictRows(htData!.detailRows, { includeFactorValues: true }) ? mergeWithDictRows(htData!.detailRows, {
: buildDefaultRows().map(row => ({ includeFactorValues: true,
projectCount: targetProjectCount,
cloneFromProjectOne: true
})
: buildDefaultRows(targetProjectCount).map(row => ({
...row, ...row,
consultCategoryFactor: getDefaultConsultCategoryFactor(), consultCategoryFactor: getDefaultConsultCategoryFactor(),
majorFactor: getDefaultMajorFactorById(row.id) majorFactor: getDefaultMajorFactorById(row.majorDictId || row.id)
})) }))
await saveToIndexedDB() await saveToIndexedDB()
} catch (error) { } catch (error) {
@ -858,7 +1040,7 @@ const importContractData = async () => {
const clearAllData = async () => { const clearAllData = async () => {
try { try {
detailRows.value = buildDefaultRows() detailRows.value = buildDefaultRows(getTargetProjectCount())
await saveToIndexedDB() await saveToIndexedDB()
} catch (error) { } catch (error) {
console.error('clearAllData failed:', error) console.error('clearAllData failed:', error)
@ -930,10 +1112,16 @@ const processCellFromClipboard = (params: any) => {
<h3 class="text-sm font-semibold text-foreground">用地规模明细</h3> <h3 class="text-sm font-semibold text-foreground">用地规模明细</h3>
<div v-if="isMutipleService" class="flex items-center gap-2"> <div v-if="isMutipleService" class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">项目数量</span> <span class="text-xs text-muted-foreground">项目数量</span>
<NumberFieldRoot v-model="projectCount" :min="1" :step="1" class="inline-flex items-center rounded-md border bg-background"> <NumberFieldRoot
<NumberFieldDecrement class="px-2 py-1 text-xs text-muted-foreground hover:bg-muted">-</NumberFieldDecrement> v-model="projectCount"
:min="1"
:step="1"
class="inline-flex items-center rounded-md border bg-background"
@update:model-value="value => void applyProjectCountChange(value)"
>
<NumberFieldDecrement class="cursor-pointer px-2 py-1 text-xs text-muted-foreground hover:bg-muted">-</NumberFieldDecrement>
<NumberFieldInput class="h-7 w-14 border-x bg-transparent px-2 text-center text-xs outline-none" /> <NumberFieldInput class="h-7 w-14 border-x bg-transparent px-2 text-center text-xs outline-none" />
<NumberFieldIncrement class="px-2 py-1 text-xs text-muted-foreground hover:bg-muted">+</NumberFieldIncrement> <NumberFieldIncrement class="cursor-pointer px-2 py-1 text-xs text-muted-foreground hover:bg-muted">+</NumberFieldIncrement>
</NumberFieldRoot> </NumberFieldRoot>
</div> </div>
</div> </div>

View File

@ -371,6 +371,7 @@ const tabScrollAreaRef = ref<HTMLElement | null>(null)
const showTabScrollLeft = ref(false) const showTabScrollLeft = ref(false)
const showTabScrollRight = ref(false) const showTabScrollRight = ref(false)
const isTabStripHover = ref(false) const isTabStripHover = ref(false)
const isTabDragging = ref(false)
const tabTitleOverflowMap = ref<Record<string, boolean>>({}) const tabTitleOverflowMap = ref<Record<string, boolean>>({})
let tabStripViewportEl: HTMLElement | null = null let tabStripViewportEl: HTMLElement | null = null
let tabTitleOverflowRafId: number | null = null let tabTitleOverflowRafId: number | null = null
@ -544,6 +545,14 @@ const canMoveTab = (event: any) => {
return true return true
} }
const handleTabDragStart = () => {
isTabDragging.value = true
}
const handleTabDragEnd = () => {
isTabDragging.value = false
}
const setTabItemRef = (id: string, el: Element | ComponentPublicInstance | null) => { const setTabItemRef = (id: string, el: Element | ComponentPublicInstance | null) => {
if (el instanceof HTMLElement) { if (el instanceof HTMLElement) {
tabItemElMap.set(id, el) tabItemElMap.set(id, el)
@ -1341,9 +1350,9 @@ watch(
<template> <template>
<TooltipProvider> <TooltipProvider>
<div class="flex flex-col w-full h-screen bg-background overflow-hidden"> <div class="flex flex-col w-full h-screen bg-background overflow-hidden">
<div class="flex items-start gap-2 border-b bg-muted/30 px-2 pt-2 flex-none"> <div class="flex items-start gap-2 border-b bg-muted/30 px-2 pt-1 min-h-14 flex-none">
<div <div
class="mb-2 flex min-w-0 flex-1 items-center gap-1" class="flex min-w-0 flex-1 items-start gap-1 h-full self-start"
@mouseenter="isTabStripHover = true" @mouseenter="isTabStripHover = true"
@mouseleave="isTabStripHover = false" @mouseleave="isTabStripHover = false"
> >
@ -1362,20 +1371,26 @@ watch(
v-model="tabsModel" v-model="tabsModel"
item-key="id" item-key="id"
tag="div" tag="div"
class="flex w-max gap-1" :class="['tab-strip-sortable flex w-max gap-0', isTabDragging ? 'is-dragging' : '']"
:animation="180" :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" :move="canMoveTab"
@start="handleTabDragStart"
@end="handleTabDragEnd"
> >
<template #item="{ element: tab }"> <template #item="{ element: tab }">
<div <div
:ref="el => setTabItemRef(tab.id, el)" :ref="el => setTabItemRef(tab.id, el)"
@click="tabStore.activeTabId = tab.id" @mousedown.left="tabStore.activeTabId = tab.id"
@contextmenu.prevent="openTabContextMenu($event, tab.id)" @contextmenu.prevent="openTabContextMenu($event, tab.id)"
:class="[ :class="[
'group relative flex items-center h-9 px-4 min-w-[120px] max-w-[220px] cursor-pointer rounded-t-md border-x border-t transition-all text-sm', 'tab-item group relative -mb-px -ml-px first:ml-0 flex items-center h-10 px-4 min-w-[120px] max-w-[220px] cursor-pointer rounded-t-md border border-transparent transition-[background-color,border-color,color,box-shadow,transform] duration-200 text-sm',
tabStore.activeTabId === tab.id tabStore.activeTabId === tab.id && !isTabDragging
? 'bg-background border-border font-medium' ? 'z-10 bg-background text-foreground !border-border !border-b-0 font-medium'
: 'border-transparent hover:bg-muted text-muted-foreground', : 'bg-muted/25 text-muted-foreground hover:bg-muted/40 hover:text-foreground hover:border-border/70',
tab.id !== 'XmView' ? 'cursor-move' : '' tab.id !== 'XmView' ? 'cursor-move' : ''
]" ]"
> >
@ -1415,12 +1430,12 @@ watch(
&gt; &gt;
</button> </button>
</div> </div>
<div class="flex shrink-0 self-start items-start gap-1 mb-1">
<div ref="dataMenuRef" class="relative mb-2 shrink-0"> <div ref="dataMenuRef" class="relative shrink-0">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
class="h-9 min-h-9 px-3 py-0 text-sm leading-none cursor-pointer" class="h-9 min-h-9 shrink-0 px-3 py-0 text-sm leading-none cursor-pointer"
@click="dataMenuOpen = !dataMenuOpen" @click="dataMenuOpen = !dataMenuOpen"
> >
<ChevronDown class="h-4 w-4 mr-1" /> <ChevronDown class="h-4 w-4 mr-1" />
@ -1458,61 +1473,63 @@ watch(
/> />
</div> </div>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
class="mb-2 h-9 min-h-9 shrink-0 px-3 py-0 text-sm leading-none cursor-pointer" class="h-9 min-h-9 shrink-0 px-3 py-0 text-sm leading-none cursor-pointer"
@click="openUserGuide(0)" @click="openUserGuide(0)"
> >
<CircleHelp class="h-4 w-4 mr-1" /> <CircleHelp class="h-4 w-4 mr-1" />
使用引导 使用引导
</Button> </Button>
<AlertDialogRoot> <AlertDialogRoot>
<AlertDialogTrigger as-child> <AlertDialogTrigger as-child>
<Button variant="destructive" size="sm" class="mb-2 h-9 min-h-9 shrink-0 px-3 py-0 text-sm leading-none"> <Button variant="destructive" size="sm" class="h-9 min-h-9 shrink-0 px-3 py-0 text-sm leading-none cursor-pointer">
<RotateCcw class="h-4 w-4 mr-1" /> <RotateCcw class="h-4 w-4 mr-1" />
重置 重置
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogPortal> <AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" /> <AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl"> <AlertDialogContent class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">确认重置</AlertDialogTitle> <AlertDialogTitle class="text-base font-semibold">确认重置</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground"> <AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
将清空所有项目数据并恢复默认页面确认继续吗 将清空所有项目数据并恢复默认页面确认继续吗
</AlertDialogDescription> </AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2"> <div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child> <AlertDialogCancel as-child>
<Button variant="outline">取消</Button> <Button variant="outline">取消</Button>
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction as-child> <AlertDialogAction as-child>
<Button variant="destructive" @click="handleReset">确认重置</Button> <Button variant="destructive" @click="handleReset">确认重置</Button>
</AlertDialogAction> </AlertDialogAction>
</div> </div>
</AlertDialogContent> </AlertDialogContent>
</AlertDialogPortal> </AlertDialogPortal>
</AlertDialogRoot> </AlertDialogRoot>
<AlertDialogRoot :open="importConfirmOpen" @update:open="importConfirmOpen = $event"> <AlertDialogRoot :open="importConfirmOpen" @update:open="importConfirmOpen = $event">
<AlertDialogPortal> <AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" /> <AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl"> <AlertDialogContent class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">确认导入覆盖</AlertDialogTitle> <AlertDialogTitle class="text-base font-semibold">确认导入覆盖</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground"> <AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
将使用{{ pendingImportFileName || '所选文件' }}覆盖当前本地全部数据是否继续 将使用{{ pendingImportFileName || '所选文件' }}覆盖当前本地全部数据是否继续
</AlertDialogDescription> </AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2"> <div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child> <AlertDialogCancel as-child>
<Button variant="outline" @click="cancelImportConfirm">取消</Button> <Button variant="outline" @click="cancelImportConfirm">取消</Button>
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction as-child> <AlertDialogAction as-child>
<Button variant="destructive" @click="confirmImportOverride">确认覆盖</Button> <Button variant="destructive" @click="confirmImportOverride">确认覆盖</Button>
</AlertDialogAction> </AlertDialogAction>
</div> </div>
</AlertDialogContent> </AlertDialogContent>
</AlertDialogPortal> </AlertDialogPortal>
</AlertDialogRoot> </AlertDialogRoot>
</div>
</div> </div>
<div class="flex-1 overflow-auto relative"> <div class="flex-1 overflow-auto relative">
@ -1612,6 +1629,27 @@ watch(
</template> </template>
<style scoped> <style scoped>
.tab-strip-sortable > .tab-item {
transition: transform 0.26s cubic-bezier(0.22, 1, 0.36, 1);
}
.tab-strip-sortable.is-dragging > .tab-item {
will-change: transform;
}
.tab-drag-ghost {
opacity: 0.32;
}
.tab-drag-chosen {
transform: scale(1.015);
box-shadow: 0 10px 24px rgb(0 0 0 / 18%);
}
.tab-drag-active {
cursor: grabbing;
}
.tab-strip-scroll-area :deep([data-slot="scroll-area-viewport"]) { .tab-strip-scroll-area :deep([data-slot="scroll-area-viewport"]) {
scrollbar-width: none; scrollbar-width: none;
} }

View File

@ -310,20 +310,46 @@ const getOnlyCostScaleBudgetFee = (
majorFactorMap?: Map<string, number | null>, majorFactorMap?: Map<string, number | null>,
industryId?: string | null industryId?: string | null
) => { ) => {
const totalAmount = sumByNumber(rowsFromDb || [], row =>
typeof row?.amount === 'number' && Number.isFinite(row.amount) ? row.amount : null
)
const onlyRow = (rowsFromDb || []).find(row => String(row?.id || '') === ONLY_COST_SCALE_ROW_ID)
const consultCategoryFactor =
toFiniteNumberOrNull(onlyRow?.consultCategoryFactor) ??
consultCategoryFactorMap?.get(String(serviceId)) ??
getDefaultConsultCategoryFactor(serviceId)
const industryMajorEntry = getIndustryMajorEntryByIndustryId(industryId) const industryMajorEntry = getIndustryMajorEntryByIndustryId(industryId)
const majorFactor = const sourceRows = rowsFromDb || []
toFiniteNumberOrNull(onlyRow?.majorFactor) ?? const defaultConsultCategoryFactor =
consultCategoryFactorMap?.get(String(serviceId)) ?? getDefaultConsultCategoryFactor(serviceId)
const defaultMajorFactor =
(industryMajorEntry ? majorFactorMap?.get(industryMajorEntry.id) ?? null : null) ?? (industryMajorEntry ? majorFactorMap?.get(industryMajorEntry.id) ?? null : null) ??
toFiniteNumberOrNull(industryMajorEntry?.item?.defCoe) ?? toFiniteNumberOrNull(industryMajorEntry?.item?.defCoe) ??
1 1
// 新版 onlyCostScale 支持“按项目行”存储(如 1::majorId、2::majorId每行需独立计费后求和。
const usePerRowCalculation = sourceRows.some(row => {
if (typeof row?.projectIndex === 'number' && Number.isFinite(row.projectIndex)) return true
const id = String(row?.id || '')
return /^\d+::/.test(id)
})
if (usePerRowCalculation) {
return sumByNumber(sourceRows, row => {
const amount = toFiniteNumberOrNull(row?.amount)
if (amount == null) return null
return getScaleBudgetFee({
benchmarkBudget: getBenchmarkBudgetByAmount(amount),
majorFactor: toFiniteNumberOrNull(row?.majorFactor) ?? defaultMajorFactor,
consultCategoryFactor: toFiniteNumberOrNull(row?.consultCategoryFactor) ?? defaultConsultCategoryFactor,
workStageFactor: toFiniteNumberOrNull(row?.workStageFactor) ?? 1,
workRatio: toFiniteNumberOrNull(row?.workRatio) ?? 100
})
})
}
const totalAmount = sumByNumber(sourceRows, row =>
typeof row?.amount === 'number' && Number.isFinite(row.amount) ? row.amount : null
)
const onlyRow =
sourceRows.find(row => String(row?.id || '') === ONLY_COST_SCALE_ROW_ID) ||
sourceRows.find(row => hasOwn(row, 'consultCategoryFactor') || hasOwn(row, 'majorFactor')) ||
sourceRows[0]
const consultCategoryFactor =
toFiniteNumberOrNull(onlyRow?.consultCategoryFactor) ?? defaultConsultCategoryFactor
const majorFactor =
toFiniteNumberOrNull(onlyRow?.majorFactor) ?? defaultMajorFactor
const workStageFactor = toFiniteNumberOrNull(onlyRow?.workStageFactor) ?? 1 const workStageFactor = toFiniteNumberOrNull(onlyRow?.workStageFactor) ?? 1
const workRatio = toFiniteNumberOrNull(onlyRow?.workRatio) ?? 100 const workRatio = toFiniteNumberOrNull(onlyRow?.workRatio) ?? 100
return getScaleBudgetFee({ return getScaleBudgetFee({
@ -345,12 +371,15 @@ const buildOnlyCostScaleDetailRows = (
const totalAmount = sumByNumber(rowsFromDb || [], row => const totalAmount = sumByNumber(rowsFromDb || [], row =>
typeof row?.amount === 'number' && Number.isFinite(row.amount) ? row.amount : null typeof row?.amount === 'number' && Number.isFinite(row.amount) ? row.amount : null
) )
const onlyRow = (rowsFromDb || []).find(row => String(row?.id || '') === ONLY_COST_SCALE_ROW_ID) const industryMajorEntry = getIndustryMajorEntryByIndustryId(industryId)
const onlyCostRowId = industryMajorEntry?.id || ONLY_COST_SCALE_ROW_ID
const onlyRow =
(rowsFromDb || []).find(row => String(row?.id || '') === ONLY_COST_SCALE_ROW_ID) ||
(rowsFromDb || []).find(row => String(row?.id || '') === onlyCostRowId)
const consultCategoryFactor = const consultCategoryFactor =
toFiniteNumberOrNull(onlyRow?.consultCategoryFactor) ?? toFiniteNumberOrNull(onlyRow?.consultCategoryFactor) ??
consultCategoryFactorMap?.get(String(serviceId)) ?? consultCategoryFactorMap?.get(String(serviceId)) ??
getDefaultConsultCategoryFactor(serviceId) getDefaultConsultCategoryFactor(serviceId)
const industryMajorEntry = getIndustryMajorEntryByIndustryId(industryId)
const majorFactor = const majorFactor =
toFiniteNumberOrNull(onlyRow?.majorFactor) ?? toFiniteNumberOrNull(onlyRow?.majorFactor) ??
(industryMajorEntry ? majorFactorMap?.get(industryMajorEntry.id) ?? null : null) ?? (industryMajorEntry ? majorFactorMap?.get(industryMajorEntry.id) ?? null : null) ??
@ -361,7 +390,7 @@ const buildOnlyCostScaleDetailRows = (
return [ return [
{ {
id: ONLY_COST_SCALE_ROW_ID, id: onlyCostRowId,
amount: totalAmount, amount: totalAmount,
consultCategoryFactor, consultCategoryFactor,
majorFactor, majorFactor,

View File

@ -583,7 +583,7 @@ async function generateTemplate(data) {
}, },
], ],
}, },
m5: { m5: {//数量单价
fee: 10000, fee: 10000,
det: [ det: [
{ {
@ -610,11 +610,12 @@ async function generateTemplate(data) {
code: { 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' }] }, code: { 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: '咨询服务协调工作', name: '咨询服务协调工作',
fee: 10000, fee: 10000,
m0: { m0: { //费率计取
coe: 0.03,
coe: 0.03,//费率
fee: 10000, fee: 10000,
}, },
m4: { m4: { //工时法
person_num: 10, person_num: 10,
work_day: 3, work_day: 3,
fee: 10000, fee: 10000,
@ -1325,7 +1326,7 @@ async function generateTemplate(data) {
if (addobj.m4) { if (addobj.m4) {
cusInsertRowFunc(4 + num_4, [sheet_4.getRow(4)], sheet_4, (targetRow) => { cusInsertRowFunc(4 + num_4, [sheet_4.getRow(4)], sheet_4, (targetRow) => {
targetRow.getCell(1).value = num_4++; targetRow.getCell(1).value = num_4++;
targetRow.getCell(2).value = addobj.ref; targetRow.getCell(2).value = addobj.code;
targetRow.getCell(3).value = addobj.name; targetRow.getCell(3).value = addobj.name;
targetRow.getCell(4).value = numberFormatter(addobj.m4.person_num, 0); targetRow.getCell(4).value = numberFormatter(addobj.m4.person_num, 0);
targetRow.getCell(5).value = numberFormatter(addobj.m4.work_day, 2); targetRow.getCell(5).value = numberFormatter(addobj.m4.work_day, 2);
@ -1333,7 +1334,7 @@ async function generateTemplate(data) {
}); });
cusInsertRowFunc(4 + num_4_1, [sheet_4_1.getRow(4)], sheet_4_1, (targetRow) => { cusInsertRowFunc(4 + num_4_1, [sheet_4_1.getRow(4)], sheet_4_1, (targetRow) => {
targetRow.getCell(1).value = num_4_1++; targetRow.getCell(1).value = num_4_1++;
targetRow.getCell(2).value = addobj.ref; targetRow.getCell(2).value = addobj.code;
targetRow.getCell(3).value = addobj.name; targetRow.getCell(3).value = addobj.name;
targetRow.getCell(4).value = '/'; targetRow.getCell(4).value = '/';
targetRow.getCell(5).value = '/'; targetRow.getCell(5).value = '/';
@ -1361,20 +1362,20 @@ async function generateTemplate(data) {
if (addobj.m5) { if (addobj.m5) {
cusInsertRowFunc(4 + num_5, [sheet_5.getRow(4)], sheet_5, (targetRow) => { cusInsertRowFunc(4 + num_5, [sheet_5.getRow(4)], sheet_5, (targetRow) => {
num_5++; num_5++;
targetRow.getCell(1).value = addobj.ref; targetRow.getCell(1).value = addobj.code;
targetRow.getCell(2).value = addobj.name; targetRow.getCell(2).value = addobj.name;
targetRow.getCell(3).value = '/'; targetRow.getCell(3).value = '/';
targetRow.getCell(4).value = '/'; targetRow.getCell(4).value = '/';
targetRow.getCell(5).value = '/'; targetRow.getCell(5).value = '/';
targetRow.getCell(6).value = numberFormatter(addobj.m5.fee, 2); targetRow.getCell(6).value = numberFormatter(addobj.m5.fee, 2);
}); });
const tmpJSS = JSON.stringify(addobj.ref); const tmpJSS = JSON.stringify(addobj.code);
addobj.m5.det.forEach((eobj, eindex) => { addobj.m5.det.forEach((eobj, eindex) => {
let ref = JSON.parse(tmpJSS); let code = JSON.parse(tmpJSS);
ref.richText.push({ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: '-' + (eindex + 1) }); code.richText.push({ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: '-' + (eindex + 1) });
cusInsertRowFunc(4 + num_5, [sheet_5.getRow(4)], sheet_5, (targetRow) => { cusInsertRowFunc(4 + num_5, [sheet_5.getRow(4)], sheet_5, (targetRow) => {
num_5++; num_5++;
targetRow.getCell(1).value = ref; targetRow.getCell(1).value = code;
targetRow.getCell(2).value = eobj.name; targetRow.getCell(2).value = eobj.name;
targetRow.getCell(3).value = eobj.unit; targetRow.getCell(3).value = eobj.unit;
targetRow.getCell(4).value = numberFormatter(eobj.amount, 3); targetRow.getCell(4).value = numberFormatter(eobj.amount, 3);
@ -1391,7 +1392,7 @@ async function generateTemplate(data) {
endRows++; endRows++;
cusInsertRowFunc(ci.services.length + 3 + endRows, [sheet_1.getRow(3)], sheet_1, (targetRow) => { cusInsertRowFunc(ci.services.length + 3 + endRows, [sheet_1.getRow(3)], sheet_1, (targetRow) => {
targetRow.getCell(1).value = ci.services.length + endRows; targetRow.getCell(1).value = ci.services.length + endRows;
targetRow.getCell(2).value = ci.reserve.ref; targetRow.getCell(2).value = ci.reserve.code;
targetRow.getCell(3).value = ci.reserve.name; targetRow.getCell(3).value = ci.reserve.name;
let tmpArr = []; let tmpArr = [];
if (ci.reserve.m0) tmpArr.push(`按上述小计及附加工作费之和的${ci.reserve.m0.coe}计得${ci.reserve.m0.fee}`); if (ci.reserve.m0) tmpArr.push(`按上述小计及附加工作费之和的${ci.reserve.m0.coe}计得${ci.reserve.m0.fee}`);
@ -1403,7 +1404,7 @@ async function generateTemplate(data) {
if (ci.reserve.m4) { if (ci.reserve.m4) {
cusInsertRowFunc(4 + num_4, [sheet_4.getRow(4)], sheet_4, (targetRow) => { cusInsertRowFunc(4 + num_4, [sheet_4.getRow(4)], sheet_4, (targetRow) => {
targetRow.getCell(1).value = num_4++; targetRow.getCell(1).value = num_4++;
targetRow.getCell(2).value = ci.reserve.ref; targetRow.getCell(2).value = ci.reserve.code;
targetRow.getCell(3).value = ci.reserve.name; targetRow.getCell(3).value = ci.reserve.name;
targetRow.getCell(4).value = numberFormatter(ci.reserve.m4.person_num, 0); targetRow.getCell(4).value = numberFormatter(ci.reserve.m4.person_num, 0);
targetRow.getCell(5).value = numberFormatter(ci.reserve.m4.work_day, 2); targetRow.getCell(5).value = numberFormatter(ci.reserve.m4.work_day, 2);
@ -1411,7 +1412,7 @@ async function generateTemplate(data) {
}); });
cusInsertRowFunc(4 + num_4_1, [sheet_4_1.getRow(4)], sheet_4_1, (targetRow) => { cusInsertRowFunc(4 + num_4_1, [sheet_4_1.getRow(4)], sheet_4_1, (targetRow) => {
targetRow.getCell(1).value = num_4_1++; targetRow.getCell(1).value = num_4_1++;
targetRow.getCell(2).value = ci.reserve.ref; targetRow.getCell(2).value = ci.reserve.code;
targetRow.getCell(3).value = ci.reserve.name; targetRow.getCell(3).value = ci.reserve.name;
targetRow.getCell(4).value = '/'; targetRow.getCell(4).value = '/';
targetRow.getCell(5).value = '/'; targetRow.getCell(5).value = '/';
@ -1439,20 +1440,20 @@ async function generateTemplate(data) {
if (ci.reserve.m5) { if (ci.reserve.m5) {
cusInsertRowFunc(4 + num_5, [sheet_5.getRow(4)], sheet_5, (targetRow) => { cusInsertRowFunc(4 + num_5, [sheet_5.getRow(4)], sheet_5, (targetRow) => {
num_5++; num_5++;
targetRow.getCell(1).value = ci.reserve.ref; targetRow.getCell(1).value = ci.reserve.code;
targetRow.getCell(2).value = ci.reserve.name; targetRow.getCell(2).value = ci.reserve.name;
targetRow.getCell(3).value = '/'; targetRow.getCell(3).value = '/';
targetRow.getCell(4).value = '/'; targetRow.getCell(4).value = '/';
targetRow.getCell(5).value = '/'; targetRow.getCell(5).value = '/';
targetRow.getCell(6).value = numberFormatter(ci.reserve.m5.fee, 2); targetRow.getCell(6).value = numberFormatter(ci.reserve.m5.fee, 2);
}); });
const tmpJSS = JSON.stringify(ci.reserve.ref); const tmpJSS = JSON.stringify(ci.reserve.code);
ci.reserve.m5.det.forEach((eobj, eindex) => { ci.reserve.m5.det.forEach((eobj, eindex) => {
let ref = JSON.parse(tmpJSS); let code = JSON.parse(tmpJSS);
ref.richText.push({ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: '-' + (eindex + 1) }); code.richText.push({ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: '-' + (eindex + 1) });
cusInsertRowFunc(4 + num_5, [sheet_5.getRow(4)], sheet_5, (targetRow) => { cusInsertRowFunc(4 + num_5, [sheet_5.getRow(4)], sheet_5, (targetRow) => {
num_5++; num_5++;
targetRow.getCell(1).value = ref; targetRow.getCell(1).value = code;
targetRow.getCell(2).value = eobj.name; targetRow.getCell(2).value = eobj.name;
targetRow.getCell(3).value = eobj.unit; targetRow.getCell(3).value = eobj.unit;
targetRow.getCell(4).value = numberFormatter(eobj.amount, 3); targetRow.getCell(4).value = numberFormatter(eobj.amount, 3);