修复重置功能

This commit is contained in:
wintsa 2026-03-19 10:56:48 +08:00
parent 303d6d6185
commit 1a0e97011f
7 changed files with 493 additions and 9 deletions

View File

@ -0,0 +1,437 @@
<script setup lang="ts">
import { computed, defineComponent, h, onActivated, onMounted, ref, shallowRef, watch, type PropType } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat'
import { toFiniteNumberOrNull } from '@/lib/number'
import { roundTo } from '@/lib/decimal'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { additionalWorkList, reserveList } from '@/sql'
type SummaryRowType = 'service' | 'additional' | 'reserve' | 'total'
interface SummaryRow {
id: string
rowType: SummaryRowType
code: string | { richText?: Array<{ text?: string; font?: { italic?: boolean; vertAlign?: string } }> }
name: string
investScale: number | null
landScale: number | null
workload: number | null
hourly: number | null
subtotal: number | null
finalFee: number | null
}
interface RateMethodStateLike {
rate?: unknown
budgetFee?: unknown
}
interface HourlyMethodRowLike {
serviceBudget?: unknown
adoptedBudgetUnitPrice?: unknown
personnelCount?: unknown
workdayCount?: unknown
}
interface HourlyMethodStateLike {
detailRows?: HourlyMethodRowLike[]
}
interface QuantityMethodRowLike {
id?: unknown
budgetFee?: unknown
quantity?: unknown
unitPrice?: unknown
}
interface QuantityMethodStateLike {
detailRows?: QuantityMethodRowLike[]
}
const props = defineProps<{
contractId: string
}>()
const zxFwPricingStore = useZxFwPricingStore()
const gridApi = shallowRef<GridApi<SummaryRow> | null>(null)
const rowData = ref<SummaryRow[]>([])
const explanationText = ref('')
let reloadTimer: ReturnType<typeof setTimeout> | null = null
const toFinite = (value: unknown): number | null => toFiniteNumberOrNull(value)
const sum3 = (values: Array<number | null | undefined>) => {
const valid = values.filter((v): v is number => typeof v === 'number' && Number.isFinite(v))
if (valid.length === 0) return null
return roundTo(valid.reduce((a, b) => a + b, 0), 3)
}
const sumHourlyMethodFee = (state: HourlyMethodStateLike | null): number | null => {
const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
if (rows.length === 0) return null
let total = 0
let hasValid = false
for (const row of rows) {
const rowBudget = toFinite(row?.serviceBudget)
if (rowBudget != null) {
total += rowBudget
hasValid = true
continue
}
const adopted = toFinite(row?.adoptedBudgetUnitPrice)
const personnel = toFinite(row?.personnelCount)
const workday = toFinite(row?.workdayCount)
if (adopted == null || personnel == null || workday == null) continue
total += adopted * personnel * workday
hasValid = true
}
return hasValid ? roundTo(total, 3) : null
}
const sumQuantityMethodFee = (state: QuantityMethodStateLike | null): number | null => {
const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
if (rows.length === 0) return null
let total = 0
let hasValid = false
for (const row of rows) {
if (String(row?.id || '') === 'fee-subtotal-fixed') continue
const budget = toFinite(row?.budgetFee)
if (budget != null) {
total += budget
hasValid = true
continue
}
const quantity = toFinite(row?.quantity)
const unitPrice = toFinite(row?.unitPrice)
if (quantity == null || unitPrice == null) continue
total += quantity * unitPrice
hasValid = true
}
return hasValid ? roundTo(total, 3) : null
}
const loadHtMethodSummaryByRow = async (mainStorageKey: string, rowId: string): Promise<{
subtotal: number | null
m0: { coe: string; fee: number } | null
m4: { fee: number } | null
m5: { fee: number } | null
}> => {
const [rateState, hourlyState, quantityState] = await Promise.all([
zxFwPricingStore.loadHtFeeMethodState<RateMethodStateLike>(mainStorageKey, rowId, 'rate-fee'),
zxFwPricingStore.loadHtFeeMethodState<HourlyMethodStateLike>(mainStorageKey, rowId, 'hourly-fee'),
zxFwPricingStore.loadHtFeeMethodState<QuantityMethodStateLike>(mainStorageKey, rowId, 'quantity-unit-price-fee')
])
const rateFee = toFinite(rateState?.budgetFee)
const rateValue = toFinite(rateState?.rate)
const hourlyFee = sumHourlyMethodFee(hourlyState)
const quantityFee = sumQuantityMethodFee(quantityState)
const subtotal = sum3([rateFee, hourlyFee, quantityFee])
return {
subtotal,
m0: rateFee != null
? {
coe: rateValue == null ? '--' : String(rateValue),
fee: roundTo(rateFee, 2)
}
: null,
m4: hourlyFee != null ? { fee: roundTo(hourlyFee, 2) } : null,
m5: quantityFee != null ? { fee: roundTo(quantityFee, 2) } : null
}
}
const buildFeeRows = async (
rowType: 'additional' | 'reserve',
list: Array<{ id: string | number; name: string; code: unknown }>
): Promise<{ rows: SummaryRow[]; explainLines: string[] }> => {
const mainStorageKey = `htExtraFee-${props.contractId}-${rowType === 'additional' ? 'additional-work' : 'reserve'}`
await zxFwPricingStore.loadHtFeeMainState(mainStorageKey)
const tuples = await Promise.all(
list.map(async item => {
const summary = await loadHtMethodSummaryByRow(mainStorageKey, String(item.id))
const lineParts: string[] = []
if (summary.m0) {
lineParts.push(`按费率${summary.m0.coe}%计得${summary.m0.fee}`)
}
if (summary.m4) {
lineParts.push(`按工时法计得${summary.m4.fee}`)
}
if (summary.m5) {
lineParts.push(`按数量单价计得${summary.m5.fee}`)
}
const linePrefix = rowType === 'additional' ? '附加工作费' : '预备费'
const explainLine = lineParts.length > 0 ? `${linePrefix}-${item.name}${lineParts.join(';')}` : ''
const row: SummaryRow = {
id: `${rowType}-${item.id}`,
rowType,
code: item.code as SummaryRow['code'],
name: item.name,
investScale: null,
landScale: null,
workload: null,
hourly: null,
subtotal: summary.subtotal,
finalFee: summary.subtotal
}
return { row, explainLine }
})
)
const rows = tuples.map(item => item.row).filter(row => row.subtotal != null)
const explainLines = tuples
.filter(item => item.row.subtotal != null && item.explainLine)
.map(item => item.explainLine)
return { rows, explainLines }
}
const buildServiceRows = (): SummaryRow[] => {
const contractState = zxFwPricingStore.getContractState(props.contractId)
const selectedSet = new Set((contractState?.selectedIds || []).map(id => String(id)))
const rows = Array.isArray(contractState?.detailRows) ? contractState!.detailRows : []
return rows
.filter(row => String(row.id) !== 'fixed-budget-c' && selectedSet.has(String(row.id)))
.map(row => ({
id: `service-${row.id}`,
rowType: 'service' as const,
code: row.code || '',
name: row.name || '',
investScale: toFinite(row.investScale),
landScale: toFinite(row.landScale),
workload: toFinite(row.workload),
hourly: toFinite(row.hourly),
subtotal: toFinite(row.subtotal),
finalFee: toFinite((row as { finalFee?: unknown }).finalFee) ?? toFinite(row.subtotal)
}))
}
const reloadRows = async () => {
await zxFwPricingStore.loadContract(props.contractId)
const [additionalResult, reserveResult] = await Promise.all([
buildFeeRows(
'additional',
additionalWorkList.map(item => ({ id: item.id, name: item.name, code: item.code }))
),
buildFeeRows(
'reserve',
reserveList.map(item => ({ id: item.id, name: item.name, code: item.code }))
)
])
rowData.value = [...buildServiceRows(), ...additionalResult.rows, ...reserveResult.rows]
const lines = [...additionalResult.explainLines, ...reserveResult.explainLines]
explanationText.value = lines.join('\n')
}
const scheduleReload = () => {
if (reloadTimer) clearTimeout(reloadTimer)
reloadTimer = setTimeout(() => {
void reloadRows()
}, 80)
}
const refreshSignature = computed(() => {
const additionalKey = `htExtraFee-${props.contractId}-additional-work`
const reserveKey = `htExtraFee-${props.contractId}-reserve`
return JSON.stringify({
contract: zxFwPricingStore.contracts[props.contractId] || null,
addMain: zxFwPricingStore.htFeeMainStates[additionalKey] || null,
reserveMain: zxFwPricingStore.htFeeMainStates[reserveKey] || null,
addMethods: zxFwPricingStore.htFeeMethodStates[additionalKey] || null,
reserveMethods: zxFwPricingStore.htFeeMethodStates[reserveKey] || null
})
})
const totalRow = computed<SummaryRow | null>(() => {
if (rowData.value.length === 0) return null
const sumField = (pick: (row: SummaryRow) => number | null | undefined) =>
sum3(rowData.value.map(pick))
return {
id: 'summary-total-row',
rowType: 'total',
code: '',
name: '合计',
investScale: sumField(row => row.investScale),
landScale: sumField(row => row.landScale),
workload: sumField(row => row.workload),
hourly: sumField(row => row.hourly),
subtotal: sumField(row => row.subtotal),
finalFee: sumField(row => row.finalFee)
}
})
const RichCodeRenderer = defineComponent({
name: 'RichCodeRenderer',
props: {
params: {
type: Object as PropType<ICellRendererParams<SummaryRow>>,
required: true
}
},
setup(props) {
return () => {
const value = props.params.value as SummaryRow['code']
if (!value || typeof value === 'string') {
return h('span', value || '')
}
const runs = Array.isArray(value.richText) ? value.richText : []
return h(
'span',
{ class: 'inline-flex items-baseline gap-[1px]' },
runs.map((run, idx) =>
h(
'span',
{
key: `${idx}-${run.text || ''}`,
style: {
fontStyle: run?.font?.italic ? 'italic' : 'normal',
verticalAlign: run?.font?.vertAlign === 'subscript' ? 'sub' : run?.font?.vertAlign === 'superscript' ? 'super' : 'baseline',
fontSize: run?.font?.vertAlign ? '0.85em' : '1em'
}
},
run?.text || ''
)
)
)
}
}
})
const columnDefs: ColDef<SummaryRow>[] = [
{
headerName: '编码',
field: 'code',
minWidth: 90,
maxWidth: 140,
cellRenderer: RichCodeRenderer
},
{
headerName: '名称',
field: 'name',
minWidth: 220,
flex: 2
},
{
headerName: '投资规模法',
field: 'investScale',
minWidth: 120,
flex: 1.2,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
colSpan: params => (params.data && params.data.rowType !== 'service' ? 5 : 1),
valueFormatter: params => (params.value == null ? '' : formatThousandsFlexible(params.value, 3))
},
{
headerName: '用地规模法',
field: 'landScale',
minWidth: 120,
flex: 1.2,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
valueFormatter: params => (params.value == null ? '' : formatThousandsFlexible(params.value, 3))
},
{
headerName: '工作量法',
field: 'workload',
minWidth: 110,
flex: 1.2,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
valueFormatter: params => (params.value == null ? '' : formatThousandsFlexible(params.value, 3))
},
{
headerName: '工时法',
field: 'hourly',
minWidth: 110,
flex: 1.2,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
valueFormatter: params => (params.value == null ? '' : formatThousandsFlexible(params.value, 3))
},
{
headerName: '小计',
field: 'subtotal',
minWidth: 120,
flex: 1.2,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value, 2))
},
{
headerName: '确认金额',
field: 'finalFee',
minWidth: 120,
flex: 1.2,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value, 2))
}
]
const summaryGridOptions: GridOptions<SummaryRow> = {
...gridOptions,
treeData: false,
getDataPath: undefined,
domLayout: 'autoHeight',
rowSelection: {
mode: 'singleRow',
checkboxes: false,
enableClickSelection: false
},
getRowId: params => params.data.id,
getRowClass: params => (params.data?.rowType === 'additional' || params.data?.rowType === 'reserve' ? 'ht-summary-fee-row' : '')
}
const onGridReady = (event: GridReadyEvent<SummaryRow>) => {
gridApi.value = event.api
}
watch(refreshSignature, (next, prev) => {
if (next === prev) return
scheduleReload()
})
onMounted(() => {
void reloadRows()
})
onActivated(() => {
void reloadRows()
})
</script>
<template>
<div class="flex flex-col gap-3">
<div class="rounded-lg border bg-card xmMx flex flex-col overflow-hidden">
<div class="flex items-center justify-between border-b px-3 py-2">
<h3 class="text-xs font-semibold text-foreground leading-none">
合同段汇总
</h3>
</div>
<div class="ag-theme-quartz w-full" :style="{ minHeight: '120px' }">
<AgGridVue
:style="{ width: '100%' }"
:rowData="rowData"
:pinnedBottomRowData="totalRow ? [totalRow] : []"
:columnDefs="columnDefs"
:gridOptions="summaryGridOptions"
:theme="myTheme"
:localeText="AG_GRID_LOCALE_CN"
@grid-ready="onGridReady"
/>
</div>
</div>
<form class="rounded-lg border bg-card p-3 space-y-2">
<div class="text-xs font-semibold text-foreground">说明</div>
<textarea
:value="explanationText"
rows="3"
placeholder="自动生成说明"
readonly
class="w-full rounded-md border bg-muted/40 px-3 py-2 text-sm text-foreground outline-none"
/>
</form>
</div>
</template>
<style scoped>
:deep(.ht-summary-fee-row .ag-cell) {
background: color-mix(in oklab, var(--muted) 45%, transparent);
}
</style>

View File

@ -54,7 +54,7 @@ const baseLabel = computed(() =>
const budgetFee = computed<number | null>(() => {
if (baseValue.value == null || rate.value == null) return null
return Number((baseValue.value * rate.value).toFixed(3))
return Number((baseValue.value * rate.value/100).toFixed(3))
})
const formatAmount = (value: number | null) =>

View File

@ -317,6 +317,21 @@ const reserveFeeView = markRaw(
})
);
const summaryView = markRaw(
defineComponent({
name: 'HtContractSummaryWithProps',
setup() {
const AsyncSummary = defineAsyncComponent({
loader: () => import('@/components/ht/HtContractSummary.vue'),
onError: (err) => {
console.error('加载 HtContractSummary 组件失败:', err)
}
})
return () => h(AsyncSummary, { contractId: props.contractId })
}
})
)
// 4.
const xmCategories: XmCategoryItem[] = [
{ key: 'base-info', label: '基础信息', component: htBaseInfoView },
@ -326,7 +341,7 @@ const xmCategories: XmCategoryItem[] = [
{ key: 'contract', label: '咨询服务', component: zxfwView },
{ key: 'additional-work-fee', label: '附加工作费', component: additionalWorkFeeView },
{ key: 'reserve-fee', label: '预备费', component: reserveFeeView },
{ key: 'all', label: '汇总', component: reserveFeeView },
{ key: 'all', label: '汇总', component: summaryView },
];

View File

@ -632,7 +632,6 @@ const columnDefs: ColDef<DetailRow>[] = [
editable: params => !isFixedRow(params.data),
valueGetter: params => {
if (!params.data) return null
console.log(detailRows.value)
return params.data.finalFee
},
// valueSetter: params => {

View File

@ -1732,20 +1732,51 @@ const confirmImportOverride = async () => {
const handleReset = async () => {
try {
dataMenuOpen.value = false
// 1)
tabStore.resetTabs()
zxFwPricingStore.$patch({
contracts: {},
contractLoaded: {},
servicePricingStates: {},
htFeeMainStates: {},
htFeeMethodStates: {},
keyedStates: {}
} as any)
await kvStore.clear()
// 2) localforage
localStorage.clear()
sessionStorage.clear()
await localforage.clear()
// 3) pinia
await Promise.all(
getPiniaPersistStores().map(async ({ store }) => {
await store.clear()
})
)
// 4) store key
const clearTasks: Promise<void>[] = []
if (tabStore.$clearPersisted) clearTasks.push(tabStore.$clearPersisted())
if (zxFwPricingStore.$clearPersisted) clearTasks.push(zxFwPricingStore.$clearPersisted())
if (kvStore.$clearPersisted) clearTasks.push(kvStore.$clearPersisted())
await Promise.all(clearTasks)
// 5)
localStorage.setItem(USER_GUIDE_COMPLETED_KEY, '1')
// 6)
const persistTasks: Promise<void>[] = []
if (tabStore.$persistNow) persistTasks.push(tabStore.$persistNow())
if (zxFwPricingStore.$persistNow) persistTasks.push(zxFwPricingStore.$persistNow())
if (kvStore.$persistNow) persistTasks.push(kvStore.$persistNow())
await Promise.all(persistTasks)
window.location.reload()
} catch (error) {
console.error('reset failed:', error)
} finally {
tabStore.resetTabs()
await tabStore.$persistNow?.()
}
}

View File

@ -10,7 +10,7 @@ import {
RowAutoHeightModule,
TextEditorModule,
TooltipModule,
UndoRedoEditModule,RenderApiModule ,ColumnApiModule ,CellSpanModule
UndoRedoEditModule,RenderApiModule ,ColumnApiModule ,CellSpanModule ,RowStyleModule ,RowSelectionModule
} from 'ag-grid-community'
import {
@ -47,7 +47,7 @@ const AG_GRID_MODULES = [
RowGroupingModule,
CellSelectionModule,
ClipboardModule,
LocaleModule,ValidationModule ,CellSpanModule
LocaleModule,ValidationModule ,CellSpanModule ,RowStyleModule ,RowSelectionModule
]
const pinia = createPinia()

View File

@ -635,7 +635,9 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
if (!changed) return false
const rowsWithSyncedFinalFee = updatedRows.map(row => {
if (String(row.id || '') === FIXED_ROW_ID) return row
const rowId = String(row.id || '')
if (rowId === FIXED_ROW_ID) return row
if (rowId !== targetServiceId) return row
const rowSubtotal = sumNullableNumbers([
toFiniteNumberOrNull(row.investScale),
toFiniteNumberOrNull(row.landScale),