This commit is contained in:
zwgczx committed 2026-04-07 16:27:49 +08:00
commit 6fdf336b9e
128 files changed
+36789

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import HtFeeMethodGrid from '@/features/shared/components/HtFeeMethodGrid.vue'
import { getAdditionalWorkListEntries } from '@/sql'
const props = defineProps<{
contractId: string
contractName?: string
}>()
const { t, locale } = useI18n()
const STORAGE_KEY = computed(() => `htExtraFee-${props.contractId}-additional-work`)
const additionalWorkNames = computed(() =>
getAdditionalWorkListEntries(locale.value).map(item => ({ id: item?.id, name: item?.name }))
)
</script>
<template>
<HtFeeMethodGrid
:title="t('htFee.additionalTitle')"
:storageKey="STORAGE_KEY"
:contract-id="props.contractId"
:contract-name="props.contractName"
:fixed-names="additionalWorkNames"
/>
</template>
+88
View File
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
interface HtBaseInfoState {
quality: string
duration: string
}
const { t } = useI18n()
const DEFAULT_QUALITY = t('htBaseInfo.defaultQuality')
const DEFAULT_DURATION = ''
const props = defineProps<{
contractId: string
}>()
const zxFwPricingStore = useZxFwPricingStore()
const storageKey = () => `ht-base-info-${props.contractId}`
const quality = ref(DEFAULT_QUALITY)
const duration = ref('')
const lastSavedSnapshot = ref('')
const saveForm = (force = false) => {
const payload: HtBaseInfoState = {
quality: quality.value,
duration: duration.value
}
const snapshot = JSON.stringify(payload)
if (!force && snapshot === lastSavedSnapshot.value) return
zxFwPricingStore.setKeyState(storageKey(), payload)
lastSavedSnapshot.value = snapshot
}
const loadForm = async () => {
const data = await zxFwPricingStore.loadKeyState<HtBaseInfoState>(storageKey())
const hasStoredValue = Boolean(
data && (Object.prototype.hasOwnProperty.call(data, 'quality') || Object.prototype.hasOwnProperty.call(data, 'duration'))
)
quality.value = typeof data?.quality === 'string' && data.quality ? data.quality : DEFAULT_QUALITY
duration.value = typeof data?.duration === 'string' ? data.duration : (hasStoredValue ? '' : DEFAULT_DURATION)
lastSavedSnapshot.value = JSON.stringify({ quality: quality.value, duration: duration.value })
if (!hasStoredValue) saveForm(true)
}
watch([quality, duration], () => {
saveForm()
})
onMounted(() => {
void loadForm()
})
onBeforeUnmount(() => {
saveForm(true)
})
</script>
<template>
<div class="h-full min-h-0 flex flex-col">
<div class="rounded-lg border bg-card p-5">
<div class="mb-4 border-b pb-3">
<h3 class="text-sm font-semibold text-foreground">{{ t('htBaseInfo.title') }}</h3>
</div>
<div class="grid grid-cols-1 gap-5">
<label class="space-y-1.5">
<div class="text-xs font-medium text-muted-foreground">{{ t('htBaseInfo.qualityLabel') }}</div>
<textarea
v-model="quality"
rows="3"
:placeholder="t('htBaseInfo.qualityPlaceholder')"
class="w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/30 resize-none"
/>
</label>
<label class="space-y-1.5">
<div class="text-xs font-medium text-muted-foreground">{{ t('htBaseInfo.durationLabel') }}</div>
<textarea
v-model="duration"
rows="3"
:placeholder="t('htBaseInfo.durationPlaceholder')"
class="w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/30 resize-none"
/>
</label>
</div>
</div>
</div>
</template>
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { computed, onActivated, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { getServiceDictEntries, isIndustryEnabledByType, getIndustryTypeValue } from '@/sql'
import XmFactorGrid from '@/features/shared/components/XmFactorGrid.vue'
import { useKvStore } from '@/pinia/kv'
const props = defineProps<{
contractId: string
projectInfoKey?: string
parentStorageKey?: string
}>()
interface XmBaseInfoState {
projectIndustry?: string
}
type ServiceItem = {
code: string
name: string
defCoe: number | null
desc?: string | null
notshowByzxflxs?: boolean
}
const PROJECT_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1')
const projectIndustry = ref('')
const kvStore = useKvStore()
const { t } = useI18n()
const loadProjectIndustry = async () => {
try {
const data = await kvStore.getItem<XmBaseInfoState>(PROJECT_INFO_KEY.value)
projectIndustry.value =
typeof data?.projectIndustry === 'string' ? data.projectIndustry.trim() : ''
} catch (error) {
console.error('loadProjectIndustry failed:', error)
projectIndustry.value = ''
}
}
const filteredServiceDict = computed<Record<string, ServiceItem>>(() => {
const industry = projectIndustry.value
if (!industry) return {}
const entries = getServiceDictEntries()
.filter(({ item }) => isIndustryEnabledByType(item, getIndustryTypeValue(industry)))
.map(({ id, item }) => [id, item as ServiceItem] as const)
return Object.fromEntries(entries)
})
onMounted(() => {
void loadProjectIndustry()
})
onActivated(() => {
void loadProjectIndustry()
})
</script>
<template>
<XmFactorGrid
:title="t('htFactors.consultCategoryTitle')"
:storage-key="`ht-consult-category-factor-v1-${props.contractId}`"
:parent-storage-key="props.parentStorageKey || 'xm-consult-category-factor-v1'"
:dict="filteredServiceDict"
:disable-budget-edit-when-standard-null="true"
:exclude-notshow-by-zxflxs="true"
:init-budget-value-from-standard="true"
/>
</template>
@@ -0,0 +1,522 @@
<script setup lang="ts">
import { computed, defineComponent, h, nextTick, onActivated, onBeforeUnmount, onMounted, ref, shallowRef, watch, type PropType } from 'vue'
import { useI18n } from 'vue-i18n'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, FirstDataRenderedEvent, GridApi, GridOptions, GridReadyEvent, ICellRendererParams, RowDataUpdatedEvent } from 'ag-grid-community'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat'
import { roundTo, toFiniteNumberOrNull } 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 { t } = useI18n()
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 sum3 = (values: Array<number | null | undefined>) => {
const valid = values.filter((v): v is number => toFiniteNumberOrNull(v) != null)
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 = toFiniteNumberOrNull(row?.serviceBudget)
if (rowBudget != null) {
total += rowBudget
hasValid = true
continue
}
const adopted = toFiniteNumberOrNull(row?.adoptedBudgetUnitPrice)
const personnel = toFiniteNumberOrNull(row?.personnelCount)
const workday = toFiniteNumberOrNull(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 = toFiniteNumberOrNull(row?.budgetFee)
if (budget != null) {
total += budget
hasValid = true
continue
}
const quantity = toFiniteNumberOrNull(row?.quantity)
const unitPrice = toFiniteNumberOrNull(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 = toFiniteNumberOrNull(rateState?.budgetFee)
const rateValue = toFiniteNumberOrNull(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(t('htSummary.explainByRate', { rate: summary.m0.coe, fee: summary.m0.fee }))
}
if (summary.m4) {
lineParts.push(t('htSummary.explainByHourly', { fee: summary.m4.fee }))
}
if (summary.m5) {
lineParts.push(t('htSummary.explainByQuantity', { fee: summary.m5.fee }))
}
const linePrefix = rowType === 'additional' ? t('htSummary.additionalPrefix') : t('htSummary.reservePrefix')
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: toFiniteNumberOrNull(row.investScale),
landScale: toFiniteNumberOrNull(row.landScale),
workload: toFiniteNumberOrNull(row.workload),
hourly: toFiniteNumberOrNull(row.hourly),
subtotal: toFiniteNumberOrNull(row.subtotal),
finalFee: toFiniteNumberOrNull((row as { finalFee?: unknown }).finalFee) ?? toFiniteNumberOrNull(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>(() => {
const sumField = (pick: (row: SummaryRow) => number | null | undefined) =>
sum3(rowData.value.map(pick))
return {
id: 'summary-total-row',
rowType: 'total',
code: '',
name: t('htSummary.total'),
investScale: sumField(row => row.investScale),
landScale: sumField(row => row.landScale),
workload: sumField(row => row.workload),
hourly: sumField(row => row.hourly),
subtotal: null,
finalFee: sumField(row => row.finalFee)
}
})
const RichCodeRenderer = defineComponent({
name: 'RichCodeRenderer',
props: {
params: {
type: Object as PropType<ICellRendererParams<SummaryRow>>,
required: true
}
},
setup(props) {
return () => {
if (props.params.data?.rowType === 'total') {
return h('span', props.params.data.name || t('htSummary.total'))
}
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: t('htSummary.columns.code'),
field: 'code',
minWidth: 90,
maxWidth: 140,
colSpan: params => (params.data?.rowType === 'total' ? 2 : 1),
valueFormatter: params => {
if (params.data?.rowType === 'total') return params.data.name || t('htSummary.total')
return typeof params.value === 'string' ? params.value : ''
},
cellRenderer: RichCodeRenderer
},
{
headerName: t('htSummary.columns.name'),
field: 'name',
minWidth: 220,
flex: 2,
wrapText: true,
autoHeight: true,
cellStyle: { 'line-height': 1.6 }
},
{
headerName: t('htSummary.columns.investScale'),
field: 'investScale',
minWidth: 120,
flex: 1.2,
headerClass: 'ag-right-aligned-header',
cellClassRules: {
'ag-right-aligned-cell': () => true
},
colSpan: params => {
if (!params.data) return 1
if (params.data.rowType === 'total') return 5
if (params.data.rowType === 'additional' || params.data.rowType === 'reserve') return 5
return 1
},
valueFormatter: params => {
if (params.data?.rowType === 'total') return ''
return params.value == null ? '' : formatThousandsFlexible(params.value, 3)
}
},
{
headerName: t('htSummary.columns.landScale'),
field: 'landScale',
minWidth: 120,
flex: 1.2,
headerClass: 'ag-right-aligned-header',
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueFormatter: params => {
if (params.data?.rowType === 'total') return ''
return params.value == null ? '' : formatThousandsFlexible(params.value, 3)
}
},
{
headerName: t('htSummary.columns.workload'),
field: 'workload',
minWidth: 110,
flex: 1.2,
headerClass: 'ag-right-aligned-header',
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueFormatter: params => {
if (params.data?.rowType === 'total') return ''
return params.value == null ? '' : formatThousandsFlexible(params.value, 3)
}
},
{
headerName: t('htSummary.columns.hourly'),
field: 'hourly',
minWidth: 110,
flex: 1.2,
headerClass: 'ag-right-aligned-header',
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueFormatter: params => {
if (params.data?.rowType === 'total') return ''
return params.value == null ? '' : formatThousandsFlexible(params.value, 3)
}
},
{
headerName: t('htSummary.columns.subtotal'),
field: 'subtotal',
minWidth: 120,
flex: 1.2,
headerClass: 'ag-right-aligned-header',
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value, 2))
},
{
headerName: t('htSummary.columns.finalFee'),
field: 'finalFee',
minWidth: 120,
flex: 1.2,
headerClass: 'ag-right-aligned-header',
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value, 2))
}
]
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
const summaryGridOptions: GridOptions<SummaryRow> = {
...gridOptions,
treeData: false,
getDataPath: undefined,
domLayout: 'autoHeight',
suppressNoRowsOverlay: true,
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
void syncAutoRowHeights()
}
const isGridApiAlive = (api: GridApi<SummaryRow> | null | undefined): api is GridApi<SummaryRow> =>
Boolean(api && !api.isDestroyed?.())
const syncAutoRowHeights = async () => {
await nextTick()
const api = gridApi.value
if (!isGridApiAlive(api)) return
api.onRowHeightChanged()
api.refreshCells({ force: true })
}
const onFirstDataRendered = (_event: FirstDataRenderedEvent<SummaryRow>) => {
void syncAutoRowHeights()
}
const onRowDataUpdated = (_event: RowDataUpdatedEvent<SummaryRow>) => {
void syncAutoRowHeights()
}
watch(refreshSignature, (next, prev) => {
if (next === prev) return
scheduleReload()
})
watch(
() => rowData.value.length,
() => {
void syncAutoRowHeights()
}
)
onMounted(() => {
void reloadRows()
})
onActivated(() => {
void reloadRows()
})
onBeforeUnmount(() => {
if (isGridApiAlive(gridApi.value)) {
gridApi.value.stopEditing()
}
gridApi.value = null
})
</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">
{{ t('htSummary.title') }}
</h3>
</div>
<div class="ag-theme-quartz w-full">
<AgGridVue
:style="{ width: '100%' }"
:rowData="rowData"
:pinnedBottomRowData="[totalRow]"
:columnDefs="gridColumnDefs"
:gridOptions="summaryGridOptions"
:theme="myTheme"
:animateRows="true"
:localeText="AG_GRID_LOCALE_CN"
@grid-ready="onGridReady"
@first-data-rendered="onFirstDataRendered"
@row-data-updated="onRowDataUpdated"
/>
</div>
</div>
<form class="rounded-lg border bg-card p-3 space-y-2">
<div class="text-xs font-semibold text-foreground">{{ t('htSummary.remark') }}</div>
<textarea
:value="explanationText"
rows="3"
:placeholder="t('htSummary.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(.ag-layout-auto-height .ag-center-cols-viewport),
:deep(.ag-layout-auto-height .ag-center-cols-container) {
min-height: 0 !important;
}
:deep(.ht-summary-fee-row .ag-cell) {
background: color-mix(in oklab, var(--muted) 45%, transparent);
}
</style>
@@ -0,0 +1,242 @@
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { roundTo } from '@/lib/decimal'
import { parseNumberOrNull } from '@/lib/number'
import { formatThousandsFlexible } from '@/lib/numberFormat'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
interface RateMethodState {
rate: number | null
budgetFee?: number | null
remark: string
}
const props = defineProps<{
storageKey: string
contractId?: string
htMainStorageKey?: string
htRowId?: string
htMethodType?: 'rate-fee'
}>()
const zxFwPricingStore = useZxFwPricingStore()
const { t } = useI18n()
const rate = ref<number | null>(null)
const remark = ref('')
const rateInput = ref('')
const baseValue = ref<number | null>(null)
const lastSavedSnapshot = ref('')
const useHtMethodState = computed(
() => Boolean(props.htMainStorageKey && props.htRowId && props.htMethodType)
)
const contractIdText = computed(() => {
const contractId = String(props.contractId || '').trim()
return contractId
})
const isReserveFee = computed(() => {
const mainKey = String(props.htMainStorageKey || '').trim()
if (mainKey) return mainKey.endsWith('-reserve')
return String(props.storageKey || '').includes('-reserve')
})
const contractStateSignature = computed(() => {
const contractId = contractIdText.value
if (!contractId) return ''
return JSON.stringify(zxFwPricingStore.contracts[contractId] || null)
})
const additionalWorkStateSignature = computed(() => {
if (!isReserveFee.value) return ''
const contractId = contractIdText.value
if (!contractId) return ''
const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
return JSON.stringify(zxFwPricingStore.htFeeMainStates[additionalStorageKey] || null)
})
const baseLabel = computed(() =>
isReserveFee.value ? t('htFeeRate.reserveBaseLabel') : t('htFeeRate.baseLabel')
)
const budgetFee = computed<number | null>(() => {
if (baseValue.value == null || rate.value == null) return null
return roundTo(baseValue.value * rate.value / 100, 2)
})
const formatAmount = (value: number | null) =>
value == null ? '' : formatThousandsFlexible(value, 3)
const ensureContractLoaded = async () => {
const contractId = contractIdText.value
if (!contractId) return
try {
await zxFwPricingStore.loadContract(contractId)
const serviceBase = zxFwPricingStore.getBaseSubtotal(contractId)
if (!isReserveFee.value) {
baseValue.value = serviceBase == null ? null : roundTo(serviceBase, 3)
return
}
const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
const additionalState = await zxFwPricingStore.loadHtFeeMainState<{
rateFee?: unknown
hourlyFee?: unknown
quantityUnitPriceFee?: unknown
}>(additionalStorageKey)
const additionalTotal = (additionalState?.detailRows || []).reduce((total, row) => {
const rateFee = Number(row?.rateFee)
const hourlyFee = Number(row?.hourlyFee)
const quantityFee = Number(row?.quantityUnitPriceFee)
const safeRateFee = Number.isFinite(rateFee) ? rateFee : 0
const safeHourlyFee = Number.isFinite(hourlyFee) ? hourlyFee : 0
const safeQuantityFee = Number.isFinite(quantityFee) ? quantityFee : 0
return total + safeRateFee + safeHourlyFee + safeQuantityFee
}, 0)
const serviceBaseSafe = typeof serviceBase === 'number' && Number.isFinite(serviceBase) ? serviceBase : 0
const hasAny = (serviceBase != null) || additionalTotal !== 0
baseValue.value = hasAny ? roundTo(serviceBaseSafe + additionalTotal, 3) : null
} catch (error) {
console.error('load contract for rate base failed:', error)
baseValue.value = null
}
}
const loadForm = async () => {
try {
const data = useHtMethodState.value
? await zxFwPricingStore.loadHtFeeMethodState<RateMethodState>(
props.htMainStorageKey!,
props.htRowId!,
props.htMethodType!
)
: await zxFwPricingStore.loadKeyState<RateMethodState>(props.storageKey)
rate.value = typeof data?.rate === 'number' ? data.rate : null
remark.value = typeof data?.remark === 'string' ? data.remark : ''
rateInput.value = rate.value == null ? '' : String(rate.value)
const snapshot: RateMethodState = {
rate: rate.value,
budgetFee: budgetFee.value,
remark: remark.value
}
lastSavedSnapshot.value = JSON.stringify(snapshot)
} catch (error) {
console.error('load rate form failed:', error)
rate.value = null
remark.value = ''
rateInput.value = ''
lastSavedSnapshot.value = ''
}
}
const saveForm = async (force = false) => {
try {
const payload: RateMethodState = {
rate: rate.value,
budgetFee: budgetFee.value,
remark: remark.value
}
const snapshot = JSON.stringify(payload)
if (!force && snapshot === lastSavedSnapshot.value) return
if (useHtMethodState.value) {
zxFwPricingStore.setHtFeeMethodState(
props.htMainStorageKey!,
props.htRowId!,
props.htMethodType!,
payload,
{ force }
)
} else {
zxFwPricingStore.setKeyState(props.storageKey, payload, { force })
}
lastSavedSnapshot.value = snapshot
} catch (error) {
console.error('save rate form failed:', error)
}
}
const applyRateInput = () => {
const next = parseNumberOrNull(rateInput.value, { sanitize: true, precision: 2 })
rate.value = next
rateInput.value = next == null ? '' : String(next)
}
watch([rate, remark, budgetFee], () => {
void saveForm()
})
watch(
() => props.storageKey,
() => {
void loadForm()
}
)
watch(
() => contractIdText.value,
() => {
void ensureContractLoaded()
}
)
watch(
() => [props.storageKey, props.htMainStorageKey, props.htRowId, props.htMethodType],
() => {
void ensureContractLoaded()
}
)
watch([contractStateSignature, additionalWorkStateSignature], ([nextContractSig, nextAdditionalSig], [prevContractSig, prevAdditionalSig]) => {
if (nextContractSig === prevContractSig && nextAdditionalSig === prevAdditionalSig) return
void ensureContractLoaded()
})
onMounted(async () => {
await Promise.all([ensureContractLoaded(), loadForm()])
})
onActivated(async () => {
await Promise.all([ensureContractLoaded(), loadForm()])
})
onBeforeUnmount(() => {
void saveForm(true)
})
</script>
<template>
<div class="h-full min-h-0 flex flex-col">
<div class="rounded-lg border bg-card p-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<label class="space-y-1.5">
<div class="text-xs text-muted-foreground">{{ baseLabel }}</div>
<input type="text" :value="baseValue" readonly disabled tabindex="-1"
class="h-9 w-full cursor-not-allowed rounded-md border bg-muted/40 px-3 text-sm text-foreground select-none" />
</label>
<label class="space-y-1.5">
<div class="text-xs text-muted-foreground">{{ t('htFeeRate.rateLabel') }}</div>
<input v-model="rateInput" type="text" inputmode="decimal" :placeholder="t('htFeeRate.ratePlaceholder')"
class="rate-input h-9 w-full rounded-md border bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/30"
@blur="applyRateInput"
@keydown.enter.prevent="applyRateInput" />
</label>
<label class="space-y-1.5">
<div class="text-xs text-muted-foreground">{{ t('htFeeRate.budgetFeeLabel') }}</div>
<input type="text" :value="formatAmount(budgetFee)" readonly disabled tabindex="-1"
class="h-9 w-full cursor-not-allowed rounded-md border bg-muted/40 px-3 text-sm text-foreground select-none" />
</label>
<label class="space-y-1.5 md:col-span-2">
<div class="text-xs text-muted-foreground">{{ t('htFeeRate.remarkLabel') }}</div>
<textarea v-model="remark" rows="4" :placeholder="t('htFeeRate.remarkPlaceholder')"
class="w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/30" />
</label>
</div>
</div>
</div>
</template>
<style scoped>
:deep(.rate-input) {
text-align: left !important;
}
</style>
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { computed, onActivated, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
import XmFactorGrid from '@/features/shared/components/XmFactorGrid.vue'
import { useKvStore } from '@/pinia/kv'
interface XmBaseInfoState {
projectIndustry?: string
}
type MajorItem = {
code: string
name: string
defCoe: number | null
desc?: string | null
notshowByzxflxs?: boolean
}
const props = defineProps<{
contractId: string
projectInfoKey?: string
parentStorageKey?: string
}>()
const PROJECT_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1')
const projectIndustry = ref('')
const kvStore = useKvStore()
const { t } = useI18n()
const loadProjectIndustry = async () => {
try {
const data = await kvStore.getItem<XmBaseInfoState>(PROJECT_INFO_KEY.value)
projectIndustry.value =
typeof data?.projectIndustry === 'string' ? data.projectIndustry.trim() : ''
} catch (error) {
console.error('loadProjectIndustry failed:', error)
projectIndustry.value = ''
}
}
const filteredMajorDict = computed<Record<string, MajorItem>>(() => {
const industry = projectIndustry.value
if (!industry) return {}
const entries = getMajorDictEntries()
.filter(({ id }) => isMajorIdInIndustryScope(id, industry))
.map(({ id, item }) => [id, item as MajorItem] as const)
return Object.fromEntries(entries)
})
onMounted(() => {
void loadProjectIndustry()
})
onActivated(() => {
void loadProjectIndustry()
})
</script>
<template>
<XmFactorGrid
:title="t('htFactors.majorTitle')"
:storage-key="`ht-major-factor-v1-${props.contractId}`"
:parent-storage-key="props.parentStorageKey || 'xm-major-factor-v1'"
:dict="filteredMajorDict"
:disable-budget-edit-when-standard-null="true"
:exclude-notshow-by-zxflxs="true"
:init-budget-value-from-standard="true"
/>
</template>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import HtFeeMethodGrid from '@/features/shared/components/HtFeeMethodGrid.vue'
import { getReserveListEntries } from '@/sql'
const props = defineProps<{
contractId: string
contractName?: string
}>()
const { t, locale } = useI18n()
const STORAGE_KEY = computed(() => `htExtraFee-${props.contractId}-reserve`)
const reserveFeeNames = computed(() =>
getReserveListEntries(locale.value).map(item => ({ name: item.name, id: item.id }))
)
</script>
<template>
<HtFeeMethodGrid
:title="t('htFee.reserveTitle')"
:storageKey="STORAGE_KEY"
:contract-id="props.contractId"
:contract-name="props.contractName"
:fixed-names="reserveFeeNames"
/>
</template>
+361
View File
@@ -0,0 +1,361 @@
<template>
<!-- 修复模板字符串语法(反引号需用 v-bind 或模板插值)+ 补充属性格式 -->
<TypeLine
scene="ht-tab"
:title="t('htCard.title', { name: contractName })"
:subtitle="t('htCard.subtitle', { id: contractId })"
:meta-text="t('htCard.metaBudget', { amount: formatBudgetAmount(contractBudget) })"
:copy-text="contractId"
:storage-key="typeLineStorageKey"
default-category="base-info"
:categories="xmCategories"
/>
</template>
<script setup lang="ts">
import { computed, markRaw, defineAsyncComponent, defineComponent, h, onActivated, onBeforeUnmount, onMounted, ref, watch, type Component } from 'vue';
import { useI18n } from 'vue-i18n'
import TypeLine from '@/layout/typeLine.vue';
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { roundTo, sumNullableNumbers, toFiniteNumber } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat'
// 1. 完善 Props 类型 + 添加校验(可选但推荐)
const props = defineProps<{
contractId: string; // 合同ID(必传)
contractName: string; // 合同名称(必传)
projectInfoKey?: string; // 工作区基础信息键
projectScaleKey?: string | null; // 工作区规模信息键
projectConsultCategoryFactorKey?: string; // 工作区咨询分类系数键
projectMajorFactorKey?: string; // 工作区工程专业系数键
}>();
const { t } = useI18n()
const zxFwPricingStore = useZxFwPricingStore()
interface HtFeeMainRowLike {
id?: unknown
}
interface RateMethodStateLike {
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 contractBudget = ref<number | null>(null)
const typeLineStorageKey = computed(() => `project-active-cat-${props.contractId}`)
let budgetRefreshTimer: ReturnType<typeof setTimeout> | null = null
const formatBudgetAmount = (value: number | null | undefined) =>
typeof value === 'number' && Number.isFinite(value)
? `${formatThousands(value, 2)} ${t('htCard.currencySuffix')}`
: '--'
const sumHourlyMethodFee = (state: HourlyMethodStateLike | null): number | null => {
const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
if (rows.length === 0) return null
let hasValid = false
let total = 0
for (const row of rows) {
const serviceBudget = toFiniteNumber(row?.serviceBudget)
if (serviceBudget != null) {
total += serviceBudget
hasValid = true
continue
}
const adopted = toFiniteNumber(row?.adoptedBudgetUnitPrice)
const personnel = toFiniteNumber(row?.personnelCount)
const workday = toFiniteNumber(row?.workdayCount)
if (adopted == null || personnel == null || workday == null) continue
total += adopted * personnel * workday
hasValid = true
}
return hasValid ? roundTo(total, 2) : null
}
const sumQuantityMethodFee = (state: QuantityMethodStateLike | null): number | null => {
const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
if (rows.length === 0) return null
const subtotalRow = rows.find(row => String(row?.id || '') === 'fee-subtotal-fixed')
const subtotal = toFiniteNumber(subtotalRow?.budgetFee)
if (subtotal != null) return roundTo(subtotal, 2)
let hasValid = false
let total = 0
for (const row of rows) {
if (String(row?.id || '') === 'fee-subtotal-fixed') continue
const budget = toFiniteNumber(row?.budgetFee)
if (budget != null) {
total += budget
hasValid = true
continue
}
const quantity = toFiniteNumber(row?.quantity)
const unitPrice = toFiniteNumber(row?.unitPrice)
if (quantity == null || unitPrice == null) continue
total += quantity * unitPrice
hasValid = true
}
return hasValid ? roundTo(total, 2) : null
}
const loadHtMethodTotalByRow = async (mainStorageKey: string, rowId: string) => {
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 parts = [
toFiniteNumber(rateState?.budgetFee),
sumHourlyMethodFee(hourlyState),
sumQuantityMethodFee(quantityState)
]
const total = sumNullableNumbers(parts)
return total == null ? null : roundTo(total, 2)
}
const loadHtMainTotalFee = async (mainStorageKey: string) => {
const mainState = await zxFwPricingStore.loadHtFeeMainState<HtFeeMainRowLike>(mainStorageKey)
const rows = Array.isArray(mainState?.detailRows) ? mainState.detailRows : []
const rowIds = rows.map(row => String(row?.id || '').trim()).filter(Boolean)
if (rowIds.length === 0) return null
const rowTotals = await Promise.all(rowIds.map(rowId => loadHtMethodTotalByRow(mainStorageKey, rowId)))
const total = sumNullableNumbers(rowTotals)
return total == null ? null : roundTo(total, 2)
}
const refreshContractBudget = async () => {
await zxFwPricingStore.loadContract(props.contractId)
const serviceFee = zxFwPricingStore.getBaseSubtotal(props.contractId)
const [additionalFee, reserveFee] = await Promise.all([
loadHtMainTotalFee(`htExtraFee-${props.contractId}-additional-work`),
loadHtMainTotalFee(`htExtraFee-${props.contractId}-reserve`)
])
const parts = [serviceFee, additionalFee, reserveFee]
const total = sumNullableNumbers(parts)
contractBudget.value = total == null ? null : roundTo(total, 2)
}
const budgetRefreshSignature = computed(() => {
const additionalMainKey = `htExtraFee-${props.contractId}-additional-work`
const reserveMainKey = `htExtraFee-${props.contractId}-reserve`
return JSON.stringify({
contractState: zxFwPricingStore.contracts[props.contractId] || null,
addMain: zxFwPricingStore.htFeeMainStates[additionalMainKey] || null,
reserveMain: zxFwPricingStore.htFeeMainStates[reserveMainKey] || null,
addMethods: zxFwPricingStore.htFeeMethodStates[additionalMainKey] || null,
reserveMethods: zxFwPricingStore.htFeeMethodStates[reserveMainKey] || null
})
})
const scheduleRefreshContractBudget = () => {
if (budgetRefreshTimer) clearTimeout(budgetRefreshTimer)
budgetRefreshTimer = setTimeout(() => {
void refreshContractBudget()
}, 80)
}
// 2. 定义分类项的 TS 类型(核心:明确 categories 结构)
interface XmCategoryItem {
key:
| 'info'
| 'base-info'
| 'consult-category-factor'
| 'major-factor'
| 'work-grid'
| 'contract'
| 'additional-work-fee'
| 'reserve-fee'
| 'all';
label: string;
component: Component; // 标记为 Vue 组件类型
}
// 3. 优化异步组件导入(添加加载失败兜底 + 类型标注)
const htView = markRaw(
defineComponent({
name: 'HtInfoWithProps',
setup() {
const AsyncHtInfo = defineAsyncComponent({
loader: () => import('@/features/ht/components/htInfo.vue'),
onError: (err) => {
console.error('加载 htInfo 组件失败:', err);
}
});
return () => h(AsyncHtInfo, {
contractId: props.contractId,
projectScaleKey: props.projectScaleKey,
projectInfoKey: props.projectInfoKey
});
}
})
);
const zxfwView = markRaw(
defineComponent({
name: 'ZxFwWithProps',
setup() {
const AsyncZxFw = defineAsyncComponent({
loader: () => import('@/features/ht/components/zxFw.vue'),
onError: (err) => {
console.error('加载 zxFw 组件失败:', err);
}
});
return () => h(AsyncZxFw, {
contractId: props.contractId,
contractName: props.contractName,
projectInfoKey: props.projectInfoKey
});
}
})
);
const consultCategoryFactorView = markRaw(
defineComponent({
name: 'HtConsultCategoryFactorWithProps',
setup() {
const AsyncHtConsultCategoryFactor = defineAsyncComponent({
loader: () => import('@/features/ht/components/HtConsultCategoryFactor.vue'),
onError: (err) => {
console.error('加载 HtConsultCategoryFactor 组件失败:', err);
}
});
return () => h(AsyncHtConsultCategoryFactor, {
contractId: props.contractId,
projectInfoKey: props.projectInfoKey,
parentStorageKey: props.projectConsultCategoryFactorKey
});
}
})
);
const majorFactorView = markRaw(
defineComponent({
name: 'HtMajorFactorWithProps',
setup() {
const AsyncHtMajorFactor = defineAsyncComponent({
loader: () => import('@/features/ht/components/HtMajorFactor.vue'),
onError: (err) => {
console.error('加载 HtMajorFactor 组件失败:', err);
}
});
return () => h(AsyncHtMajorFactor, {
contractId: props.contractId,
projectInfoKey: props.projectInfoKey,
parentStorageKey: props.projectMajorFactorKey
});
}
})
);
const htBaseInfoView = markRaw(
defineComponent({
name: 'HtBaseInfoWithProps',
setup() {
const AsyncHtBaseInfo = defineAsyncComponent({
loader: () => import('@/features/ht/components/HtBaseInfo.vue'),
onError: (err) => {
console.error('加载 HtBaseInfo 组件失败:', err)
}
})
return () => h(AsyncHtBaseInfo, { contractId: props.contractId })
}
})
)
const additionalWorkFeeView = markRaw(
defineComponent({
name: 'HtAdditionalWorkFeeWithProps',
setup() {
const AsyncHtAdditionalWorkFee = defineAsyncComponent({
loader: () => import('@/features/ht/components/HtAdditionalWorkFee.vue'),
onError: (err) => {
console.error('加载 HtAdditionalWorkFee 组件失败:', err);
}
});
return () => h(AsyncHtAdditionalWorkFee, { contractId: props.contractId, contractName: props.contractName });
}
})
);
const reserveFeeView = markRaw(
defineComponent({
name: 'HtReserveFeeWithProps',
setup() {
const AsyncHtReserveFee = defineAsyncComponent({
loader: () => import('@/features/ht/components/HtReserveFee.vue'),
onError: (err) => {
console.error('加载 HtReserveFee 组件失败:', err);
}
});
return () => h(AsyncHtReserveFee, { contractId: props.contractId, contractName: props.contractName });
}
})
);
const summaryView = markRaw(
defineComponent({
name: 'HtContractSummaryWithProps',
setup() {
const AsyncSummary = defineAsyncComponent({
loader: () => import('@/features/ht/components/HtContractSummary.vue'),
onError: (err) => {
console.error('加载 HtContractSummary 组件失败:', err)
}
})
return () => h(AsyncSummary, { contractId: props.contractId })
}
})
)
// 4. 给分类数组添加严格类型标注
const xmCategories = computed<XmCategoryItem[]>(() => [
{ key: 'base-info', label: t('htCard.categories.baseInfo'), component: htBaseInfoView },
{ key: 'info', label: t('htCard.categories.scaleInfo'), component: htView },
{ key: 'contract', label: t('htCard.categories.services'), component: zxfwView },
{ key: 'consult-category-factor', label: t('htCard.categories.consultFactor'), component: consultCategoryFactorView },
{ key: 'major-factor', label: t('htCard.categories.majorFactor'), component: majorFactorView },
{ key: 'additional-work-fee', label: t('htCard.categories.additionalFee'), component: additionalWorkFeeView },
{ key: 'reserve-fee', label: t('htCard.categories.reserveFee'), component: reserveFeeView },
{ key: 'all', label: t('htCard.categories.summary'), component: summaryView },
]);
watch(budgetRefreshSignature, (next, prev) => {
if (next === prev) return
scheduleRefreshContractBudget()
})
onMounted(() => {
void refreshContractBudget()
})
onActivated(() => {
void refreshContractBudget()
})
onBeforeUnmount(() => {
if (budgetRefreshTimer) clearTimeout(budgetRefreshTimer)
})
</script>
+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import CommonAgGrid from '@/features/shared/components/xmCommonAgGrid.vue'
const props = defineProps<{
contractId: string
projectScaleKey?: string | null
projectInfoKey?: string
}>()
const DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
const XM_DB_KEY = computed(() => {
if (props.projectScaleKey === null) return undefined
return props.projectScaleKey || 'xm-info-v3'
})
const BASE_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1')
const { t } = useI18n()
</script>
<template>
<CommonAgGrid :title="t('htInfo.scaleDetailTitle')" :dbKey="DB_KEY" :xmInfoKey="XM_DB_KEY" :base-info-key="BASE_INFO_KEY"/>
</template>
File diff suppressed because it is too large. Load diff
+50
View File
@@ -0,0 +1,50 @@
export interface ContractItem {
id: string
name: string
order: number
createdAt: string
}
export const normalizeOrder = (list: ContractItem[]): ContractItem[] =>
list.map((item, index) => ({
...item,
order: index,
createdAt: item.createdAt || new Date().toISOString()
}))
export const formatDateTime = (value: string) => {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
export const normalizeContractsFromPayload = (value: unknown): ContractItem[] => {
if (!Array.isArray(value)) return []
return value
.filter(item => item && typeof item === 'object')
.map((item, index) => {
const row = item as Partial<ContractItem>
const name = typeof row.name === 'string' ? row.name.trim() : ''
const createdAt = typeof row.createdAt === 'string' ? row.createdAt : new Date().toISOString()
const id = typeof row.id === 'string' ? row.id : `import-contract-${index}`
return {
id,
name: name || `导入合同段-${index + 1}`,
order: index,
createdAt
}
})
}
export const isEntryRelatedToAnyContract = (
key: string,
contractIds: Set<string>,
matcher: (entryKey: string, contractId: string) => boolean
) => {
for (const contractId of contractIds) {
if (matcher(key, contractId)) return true
}
return false
}
+210
View File
@@ -0,0 +1,210 @@
.ht-contract-scroll-area :deep([data-slot='scroll-area-viewport']) {
overscroll-behavior: contain;
scroll-snap-type: y mandatory;
padding-top: 6px;
}
.ht-contract-scroll-area.is-dragging :deep([data-slot='scroll-area-viewport']) {
scroll-snap-type: none;
}
.ht-contract-scroll-area :deep(.ht-sortable-ghost) {
opacity: 0.35;
}
.ht-contract-scroll-area :deep(.ht-sortable-chosen),
.ht-contract-scroll-area :deep(.ht-sortable-drag) {
will-change: transform, opacity;
transform: translateZ(0);
backface-visibility: hidden;
}
.ht-contract-card {
will-change: transform, opacity;
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
isolation: isolate;
overflow: visible;
z-index: 0;
transition:
transform 220ms cubic-bezier(0.22, 0.61, 0.36, 1),
box-shadow 220ms cubic-bezier(0.22, 0.61, 0.36, 1),
border-color 180ms ease;
box-shadow:
0 1px 2px hsl(var(--foreground) / 0.04),
0 6px 16px hsl(var(--foreground) / 0.06);
}
.ht-contract-card::before {
content: '';
position: absolute;
inset: -4px;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(
130deg,
hsl(var(--primary) / 0.42) 0%,
hsl(var(--primary) / 0.22) 36%,
hsl(var(--foreground) / 0.09) 70%,
transparent 100%
);
opacity: 0;
transition: opacity 220ms cubic-bezier(0.22, 0.61, 0.36, 1);
}
.ht-contract-card::after {
content: '';
position: absolute;
left: 6px;
right: 6px;
bottom: -30px;
height: 52px;
border-radius: 999px;
pointer-events: none;
background:
radial-gradient(
ellipse at center,
hsl(var(--primary) / 0.42) 0%,
hsl(var(--primary) / 0.24) 34%,
hsl(var(--foreground) / 0.20) 58%,
transparent 86%
);
filter: blur(18px);
opacity: 0;
transform: translateY(4px);
transition:
opacity 220ms cubic-bezier(0.22, 0.61, 0.36, 1),
transform 220ms cubic-bezier(0.22, 0.61, 0.36, 1);
}
.ht-contract-card:hover {
transform: translate3d(0, -5px, 0);
z-index: 14;
box-shadow:
0 0 0 1.5px hsl(var(--primary) / 0.62),
0 0 28px hsl(var(--primary) / 0.34),
0 0 56px hsl(var(--primary) / 0.22),
0 16px 34px hsl(var(--foreground) / 0.22),
0 32px 60px hsl(var(--foreground) / 0.18);
border-color: hsl(var(--primary) / 0.72);
}
.ht-contract-card:hover::before {
opacity: 1;
}
.ht-contract-card:hover::after {
opacity: 0.95;
transform: translateY(0);
}
.ht-contract-card:active {
transform: translate3d(0, -2px, 0);
box-shadow:
0 5px 12px hsl(var(--foreground) / 0.10),
0 10px 20px hsl(var(--foreground) / 0.10);
}
.ht-contract-card--ready {
opacity: 1;
transform: translate3d(0, 0, 0);
}
.ht-contract-card--enter {
animation: ht-card-slide-in 560ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
animation-delay: var(--ht-card-enter-delay, 0ms);
}
.ht-contract-card--selecting {
transform-origin: 50% 100%;
animation: ht-card-select-wave 2200ms linear infinite both;
animation-delay: var(--ht-card-select-delay, 0ms);
}
.ht-contract-card--selecting:hover {
animation-play-state: paused;
}
.ht-contract-card--selecting.ht-contract-card--selected {
animation: none;
transform: translate3d(0, 0, 0) rotate(0deg);
}
.ht-contract-card--selected {
border-color: hsl(var(--primary));
transform: translate3d(0, -4px, 0);
box-shadow:
0 0 0 1px hsl(var(--primary) / 0.34),
0 12px 24px hsl(var(--primary) / 0.18),
0 22px 36px hsl(var(--foreground) / 0.10);
}
.ht-contract-card--selected::before {
opacity: 1;
}
.ht-contract-card--selected::after {
opacity: 1;
transform: translateY(0);
}
@keyframes ht-card-slide-in {
from {
opacity: 0;
transform: translate3d(44px, 0, 0);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
@keyframes ht-card-select-wave {
0%,
100% {
transform: translate3d(0, 0, 0) rotate(0deg);
}
11% {
transform: translate3d(-0.4px, 0, 0) rotate(-0.7deg);
}
22% {
transform: translate3d(-0.9px, 0, 0) rotate(-1.6deg);
}
34% {
transform: translate3d(-1.2px, 0, 0) rotate(-2.3deg);
}
48% {
transform: translate3d(-0.2px, 0, 0) rotate(-0.4deg);
}
62% {
transform: translate3d(0.8px, 0, 0) rotate(1.5deg);
}
76% {
transform: translate3d(1.25px, 0, 0) rotate(2.35deg);
}
88% {
transform: translate3d(0.35px, 0, 0) rotate(0.65deg);
}
}
@media (prefers-reduced-motion: reduce) {
.ht-contract-card--enter,
.ht-contract-card--selecting {
animation: none;
opacity: 1;
transform: none;
}
.ht-contract-card,
.ht-contract-card:hover,
.ht-contract-card:active,
.ht-contract-card--selected {
transition: none;
transform: none;
}
.ht-contract-card::before,
.ht-contract-card::after {
transition: none;
}
}
+172
View File
@@ -0,0 +1,172 @@
import {
cloneJson,
isContractRelatedForageKey,
isContractRelatedKeyedStateKey,
isRecord,
SERVICE_PRICING_METHODS
} from '@/lib/contractSegment'
type AnyRecord = Record<string, unknown>
export interface KvStoreLike {
keys: () => Promise<string[]>
getItem: (key: string) => Promise<unknown>
}
export interface ZxFwPricingStoreLike {
contracts: Record<string, unknown>
servicePricingStates: Record<string, unknown>
htFeeMainStates: Record<string, unknown>
htFeeMethodStates: Record<string, unknown>
keyedStates: Record<string, unknown>
loadContract: (...args: any[]) => Promise<unknown>
getContractState: (...args: any[]) => unknown
setContractState: (...args: any[]) => Promise<unknown>
setServicePricingMethodState: (...args: any[]) => unknown
setHtFeeMainState: (...args: any[]) => unknown
setHtFeeMethodState: (...args: any[]) => unknown
setKeyState: (...args: any[]) => unknown
}
export const buildContractPiniaPayload = async (
store: ZxFwPricingStoreLike,
contractIds: string[]
) => {
const idSet = new Set(contractIds.map(id => String(id || '').trim()).filter(Boolean))
const payload = {
contracts: {} as AnyRecord,
servicePricingStates: {} as AnyRecord,
htFeeMainStates: {} as AnyRecord,
htFeeMethodStates: {} as AnyRecord
}
if (idSet.size === 0) return payload
await Promise.all(Array.from(idSet).map(id => store.loadContract(id)))
for (const contractId of idSet) {
const contractState = store.getContractState(contractId)
if (contractState) {
payload.contracts[contractId] = cloneJson(contractState)
}
const servicePricingState = store.servicePricingStates[contractId]
if (isRecord(servicePricingState)) {
payload.servicePricingStates[contractId] = cloneJson(servicePricingState)
}
const mainPrefix = `htExtraFee-${contractId}-`
for (const [mainKey, mainState] of Object.entries(store.htFeeMainStates)) {
if (!mainKey.startsWith(mainPrefix)) continue
payload.htFeeMainStates[mainKey] = cloneJson(mainState)
}
for (const [mainKey, methodState] of Object.entries(store.htFeeMethodStates)) {
if (!mainKey.startsWith(mainPrefix)) continue
payload.htFeeMethodStates[mainKey] = cloneJson(methodState)
}
}
return payload
}
export const applyImportedContractPiniaPayload = async (
store: ZxFwPricingStoreLike,
piniaPayload: unknown,
oldToNewIdMap: Map<string, string>
) => {
if (!isRecord(piniaPayload)) return
const zxFwPayload = isRecord(piniaPayload.zxFwPricing) ? piniaPayload.zxFwPricing : null
if (!zxFwPayload) return
const contractsMap = isRecord(zxFwPayload.contracts) ? zxFwPayload.contracts : {}
const servicePricingStatesMap = isRecord(zxFwPayload.servicePricingStates) ? zxFwPayload.servicePricingStates : {}
const htFeeMainStatesMap = isRecord(zxFwPayload.htFeeMainStates) ? zxFwPayload.htFeeMainStates : {}
const htFeeMethodStatesMap = isRecord(zxFwPayload.htFeeMethodStates) ? zxFwPayload.htFeeMethodStates : {}
for (const [oldId, newId] of oldToNewIdMap.entries()) {
const rawContractState = contractsMap[oldId]
if (isRecord(rawContractState) && Array.isArray(rawContractState.detailRows)) {
await store.setContractState(newId, rawContractState as any)
}
const rawServicePricingByService = servicePricingStatesMap[oldId]
if (isRecord(rawServicePricingByService)) {
for (const [serviceId, rawServiceMethods] of Object.entries(rawServicePricingByService)) {
if (!isRecord(rawServiceMethods)) continue
for (const method of SERVICE_PRICING_METHODS) {
const methodState = rawServiceMethods[method]
if (!isRecord(methodState) || !Array.isArray(methodState.detailRows)) continue
store.setServicePricingMethodState(newId, serviceId, method, methodState as any, { force: true })
}
}
}
const oldMainPrefix = `htExtraFee-${oldId}-`
const newMainPrefix = `htExtraFee-${newId}-`
for (const [oldMainKey, rawMainState] of Object.entries(htFeeMainStatesMap)) {
if (!oldMainKey.startsWith(oldMainPrefix)) continue
if (!isRecord(rawMainState) || !Array.isArray(rawMainState.detailRows)) continue
const newMainKey = oldMainKey.replace(oldMainPrefix, newMainPrefix)
store.setHtFeeMainState(newMainKey, rawMainState as any, { force: true })
}
for (const [oldMainKey, rawByRow] of Object.entries(htFeeMethodStatesMap)) {
if (!oldMainKey.startsWith(oldMainPrefix)) continue
if (!isRecord(rawByRow)) continue
const newMainKey = oldMainKey.replace(oldMainPrefix, newMainPrefix)
for (const [rowId, rawByMethod] of Object.entries(rawByRow)) {
if (!isRecord(rawByMethod)) continue
const ratePayload = rawByMethod['rate-fee']
const hourlyPayload = rawByMethod['hourly-fee']
const quantityPayload = rawByMethod['quantity-unit-price-fee']
if (ratePayload != null) {
store.setHtFeeMethodState(newMainKey, rowId, 'rate-fee', cloneJson(ratePayload), { force: true })
}
if (hourlyPayload != null) {
store.setHtFeeMethodState(newMainKey, rowId, 'hourly-fee', cloneJson(hourlyPayload), { force: true })
}
if (quantityPayload != null) {
store.setHtFeeMethodState(newMainKey, rowId, 'quantity-unit-price-fee', cloneJson(quantityPayload), { force: true })
}
}
}
}
}
export const readContractRelatedForageEntries = async (
kvStore: KvStoreLike,
contractIds: string[]
) => {
const keys = await kvStore.keys()
const idSet = new Set(contractIds)
const targetKeys = keys.filter(key => {
for (const id of idSet) {
if (isContractRelatedForageKey(key, id)) return true
}
return false
})
return Promise.all(
targetKeys.map(async key => ({
key,
value: await kvStore.getItem(key)
}))
)
}
export const readContractRelatedKeyedEntries = (
store: Pick<ZxFwPricingStoreLike, 'keyedStates'>,
contractIds: string[]
) => {
const idSet = new Set(contractIds.map(id => String(id || '').trim()).filter(Boolean))
return Object.entries(store.keyedStates)
.filter(([key]) => {
for (const id of idSet) {
if (isContractRelatedKeyedStateKey(key, id)) return true
}
return false
})
.map(([key, value]) => ({
key,
value: cloneJson(value)
}))
}
+39
View File
@@ -0,0 +1,39 @@
export interface XmBaseInfoState {
projectIndustry?: string
}
export interface XmScaleState {
detailRows?: unknown[]
roughCalcEnabled?: boolean
totalAmount?: number | null
}
export interface HtFeeMainRowLike {
id?: unknown
}
export interface RateMethodStateLike {
budgetFee?: unknown
}
export interface HourlyMethodRowLike {
serviceBudget?: unknown
adoptedBudgetUnitPrice?: unknown
personnelCount?: unknown
workdayCount?: unknown
}
export interface HourlyMethodStateLike {
detailRows?: HourlyMethodRowLike[]
}
export interface QuantityMethodRowLike {
id?: unknown
budgetFee?: unknown
quantity?: unknown
unitPrice?: unknown
}
export interface QuantityMethodStateLike {
detailRows?: QuantityMethodRowLike[]
}